limits: a daily token quota for the account the stack uses, and bus schema 3h to 3i (row 126, #1620)
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.
This commit is contained in:
+21
-6
@@ -671,10 +671,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
|
||||
|
||||
## Schema and verification
|
||||
|
||||
`schema.sql` is v3g, SHA-256
|
||||
`a79b3dbe6f4dfba10871c01ad8513ea290071eb8d8af2b87be6db52dbcf23428`.
|
||||
It has 15 tables, 68 triggers and five views. Source digest and a trusted full
|
||||
sqlite_master digest are checked at open, alongside schema version `3g`, WAL,
|
||||
`schema.sql` is v3i, SHA-256
|
||||
`6cde5232c57ac04dc6073112c0830c89cfc5e953d7a5727e7716263747fe8737`.
|
||||
It has 16 tables, 71 triggers and five views. Source digest and a trusted full
|
||||
sqlite_master digest are checked at open, alongside schema version `3i`, WAL,
|
||||
quick_check and foreign-key integrity. Missing metadata refuses; it never
|
||||
reinitializes an existing file. Full synchronous WAL transactions either commit
|
||||
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
|
||||
@@ -822,6 +822,18 @@ Schema 3h adds only the append-only usage_records table and its three protection
|
||||
triggers. The 3g→3h step preserves all prior evidence; migration is explicit and
|
||||
offline in Mos's re-pin window. The normal bus never auto-migrates an old store.
|
||||
|
||||
Schema 3i (row 126, #1620) drops the 3h reservation `account TEXT CHECK (account IS NULL)`
|
||||
so a record can carry the run's account. SQLite cannot drop a CHECK, so the 3h→3i step
|
||||
drops the three triggers, renames the table to usage_records_3h, creates usage_records
|
||||
with text identical to schema.sql's, copies every row with its seq, carries the table's
|
||||
sqlite_sequence value, drops the old table and recreates the three triggers. The rename
|
||||
comes first because a renamed table's stored SQL quotes its new name, and the digest
|
||||
compares that text. Every other column, CHECK and trigger is unchanged. The step runs in
|
||||
one transaction; a failure leaves the store at 3h. It runs only through
|
||||
`stack-release.sh activate … --migrate`, which backs up the store first; a 3h engine
|
||||
refuses a 3i store. The 3h fixture, `tests/fixtures/schema-3h.sql`, is pinned from
|
||||
81f26551b's tree; no live store is used.
|
||||
|
||||
The managed runner calls usage.begin before an actual harness turn and usage.end
|
||||
after it, including a failed/stopped turn. Each takes only {turn: deliveredMessageId}.
|
||||
The broker requires the bound role holder and its delivery, binds run identity,
|
||||
@@ -838,6 +850,9 @@ It returns total plus byRun, byInstance, byProvider, byModel and byAccount array
|
||||
Each total has input, output, cacheWrite, cacheRead and unknownTurns. Known
|
||||
readings sum; unknown turns count distinctly by run and turn next to every total.
|
||||
An exact aggregate beyond JavaScript's safe integer range is a decimal string,
|
||||
not a rounded count or a policy ceiling. Account is null until account tracking
|
||||
ships. A session can edit its own transcript: this is a best reading, not proof.
|
||||
not a rounded count or a policy ceiling. Row 126: a run's account is the one
|
||||
the launcher bound it with (`launch.account.name` for a Claude Code run), kept
|
||||
on the session and written on each usage record; a pi run's is null. The
|
||||
account stays out of `session.launched`'s body. `mayLaunch` and `mustStop`
|
||||
pass it to the limit checks in the subject. A session can edit its own transcript: this is a best reading, not proof.
|
||||
No enforcement or resets are added. Settings reads through the same bus view.
|
||||
|
||||
@@ -344,7 +344,7 @@ CREATE TABLE usage_records (
|
||||
instance TEXT NOT NULL,
|
||||
provider TEXT,
|
||||
model TEXT,
|
||||
account TEXT CHECK (account IS NULL),
|
||||
account TEXT,
|
||||
turn TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
|
||||
|
||||
@@ -412,7 +412,7 @@ export class Broker {
|
||||
bindLaunch(record) {
|
||||
keys(
|
||||
record,
|
||||
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'provider', 'model'],
|
||||
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'provider', 'model', 'account'],
|
||||
['business', 'role', 'run', 'harness'],
|
||||
);
|
||||
const b = this.#business(identifier(record.business));
|
||||
@@ -422,6 +422,9 @@ export class Broker {
|
||||
if (record.address !== undefined) string(record.address, 1024);
|
||||
if (record.provider !== undefined) string(record.provider, 1024);
|
||||
if (record.model !== undefined) string(record.model, 1024);
|
||||
// Row 126: the account a claude-code run spends (launch.account.name); null for a pi run. The
|
||||
// session keeps it for usage records and mustStop; session.launched's body doesn't carry it.
|
||||
if (record.account !== undefined && record.account !== null) identifier(record.account);
|
||||
// Row 121: a thread instance's run is for one thread, and no other run names one.
|
||||
if (this.#threaded(record.business, record.role)) {
|
||||
if (record.thread === undefined) fail('thread-required');
|
||||
@@ -1287,7 +1290,7 @@ export class Broker {
|
||||
if (!this.#limits) return;
|
||||
let answer;
|
||||
try {
|
||||
answer = await this.#limits.mustStop(s.business, { instance: s.role, provider: s.provider ?? null, model: s.model ?? null });
|
||||
answer = await this.#limits.mustStop(s.business, { instance: s.role, provider: s.provider ?? null, model: s.model ?? null, account: s.account ?? null });
|
||||
} catch {
|
||||
answer = { stop: true, check: null, code: 'check-threw' };
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@ const turns = value => Number.isSafeInteger(value) && value >= 0;
|
||||
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
// Every token limit in launch.limits, in file order, at its schema depth only: tokens.instances.X,
|
||||
// tokens.providers.X, tokens.keys.X and tokens.models.P.M. The agent limits (total, providers,
|
||||
// tokens.providers.X, tokens.keys.X, tokens.models.P.M and tokens.daily.A (row 126). The agent limits (total, providers,
|
||||
// models) are never checks' limits, whatever their names, so a provider called `limit` there isn't
|
||||
// taken for one. The settings writer (business launch-settings.mjs) walks the same depths.
|
||||
const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2 };
|
||||
const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2, daily: 1 };
|
||||
export function limitLeaves(limits) {
|
||||
const out = [];
|
||||
const walk = (value, depth, path) => {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
-- Row 126 (#1620): usage records carry the account a claude-code run spends. Only the account
|
||||
-- column's CHECK changes. SQLite can't drop a CHECK, so the table is rebuilt in this one step's
|
||||
-- transaction. The old table is renamed first so the new CREATE TABLE text stays unquoted and
|
||||
-- matches schema.sql; seq values are copied as they are, and the sequence carries over.
|
||||
DROP TRIGGER usage_records_no_update;
|
||||
DROP TRIGGER usage_records_no_delete;
|
||||
DROP TRIGGER usage_records_no_replace;
|
||||
ALTER TABLE usage_records RENAME TO usage_records_3h;
|
||||
CREATE TABLE usage_records (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
run TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
provider TEXT,
|
||||
model TEXT,
|
||||
account TEXT,
|
||||
turn TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
|
||||
status TEXT NOT NULL CHECK (status IN ('known','unknown')),
|
||||
code TEXT,
|
||||
cursor TEXT CHECK (cursor IS NULL OR json_valid(cursor)),
|
||||
input INTEGER,
|
||||
output INTEGER,
|
||||
cache_write INTEGER,
|
||||
cache_read INTEGER,
|
||||
UNIQUE (business,run,turn,source,phase),
|
||||
CHECK ((status='unknown' AND input IS NULL AND output IS NULL AND cache_write IS NULL AND cache_read IS NULL)
|
||||
OR (status='known' AND input >= 0 AND output >= 0 AND cache_write >= 0 AND cache_read >= 0
|
||||
AND input IS NOT NULL AND output IS NOT NULL AND cache_write IS NOT NULL AND cache_read IS NOT NULL))
|
||||
) STRICT;
|
||||
INSERT INTO usage_records(seq,at,business,run,instance,provider,model,account,turn,source,phase,status,code,cursor,input,output,cache_write,cache_read)
|
||||
SELECT seq,at,business,run,instance,provider,model,account,turn,source,phase,status,code,cursor,input,output,cache_write,cache_read FROM usage_records_3h ORDER BY seq;
|
||||
DELETE FROM sqlite_sequence WHERE name='usage_records';
|
||||
INSERT INTO sqlite_sequence(name,seq) SELECT 'usage_records', seq FROM sqlite_sequence WHERE name='usage_records_3h';
|
||||
DROP TABLE usage_records_3h;
|
||||
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
@@ -189,7 +189,7 @@ export async function startBroker({
|
||||
threadsState: (business) => broker.threadsState(business),
|
||||
threadIdle: (r) => broker.threadIdle(r),
|
||||
// Row 125 (#1619, REQ-LIMIT-4): the launcher asks the limit checks before every launch.
|
||||
mayLaunch: ({ business, instance, provider, model }) => limits.mayLaunch(business, { instance, provider, model }),
|
||||
mayLaunch: ({ business, instance, provider, model, account = null }) => limits.mayLaunch(business, { instance, provider, model, account }),
|
||||
identity: (cap) => broker.identity(cap),
|
||||
authorizeLaunch,
|
||||
checkPush,
|
||||
|
||||
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
||||
export const SCHEMA_SHA256 = 'bb2ba58175a111bb99681778911507846bef714a6d573f8a8af67580a3636950';
|
||||
export const SCHEMA_VERSION = '3h';
|
||||
export const SCHEMA_SHA256 = '6cde5232c57ac04dc6073112c0830c89cfc5e953d7a5727e7716263747fe8737';
|
||||
export const SCHEMA_VERSION = '3i';
|
||||
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
|
||||
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
|
||||
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
|
||||
@@ -51,6 +51,11 @@ const migrations = [{
|
||||
from: '3g',
|
||||
to: '3h',
|
||||
sql: readFileSync(new URL('./migrations/3g-3h.sql', import.meta.url), 'utf8'),
|
||||
}, {
|
||||
// Row 126 (#1620): usage records carry the run's account; the table is rebuilt without its CHECK.
|
||||
from: '3h',
|
||||
to: '3i',
|
||||
sql: readFileSync(new URL('./migrations/3h-3i.sql', import.meta.url), 'utf8'),
|
||||
}];
|
||||
const timedTables = [
|
||||
'events',
|
||||
|
||||
@@ -62,7 +62,7 @@ export async function createUsage({ store, dataRoot, businesses, log }) {
|
||||
// This map serializes retries per run, without keeping a SQL transaction across source awaits.
|
||||
const pending = new Map();
|
||||
async function perform(session, phase, turn) {
|
||||
const { business, run, role: instance, harness, provider = null, model = null } = session;
|
||||
const { business, run, role: instance, harness, provider = null, model = null, account = null } = session;
|
||||
const existing = store.all('SELECT * FROM usage_records WHERE business=? AND run=? AND turn=? AND phase=?', business, run, turn, phase);
|
||||
if (existing.length) return { recorded: true };
|
||||
const loader = loaders.get(business);
|
||||
@@ -91,8 +91,8 @@ export async function createUsage({ store, dataRoot, businesses, log }) {
|
||||
const known = result.status === 'known';
|
||||
const counts = phase === 'begin' ? zero() : result;
|
||||
store.run(`INSERT INTO usage_records(at,business,run,instance,provider,model,account,turn,source,phase,status,code,cursor,input,output,cache_write,cache_read)
|
||||
VALUES (?,?,?,?,?,?,NULL,?,?,?,?,?,?,?,?,?,?)`, new Date().toISOString(), business, run, instance,
|
||||
provider, model, turn, name, phase, result.status, result.code ?? null,
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, new Date().toISOString(), business, run, instance,
|
||||
provider, model, account, turn, name, phase, result.status, result.code ?? null,
|
||||
phase === 'begin' ? JSON.stringify(result) : null,
|
||||
known ? counts.input : null, known ? counts.output : null, known ? counts.cacheWrite : null, known ? counts.cacheRead : null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { Store, schemaDigest } from '../src/store.mjs';
|
||||
// Row 126 (#1620, REQ-LIMIT-4): the 3h→3i step rebuilds usage_records so its account column takes a
|
||||
// name. It runs on a 3h store built here from the pinned 3h schema (81f26551b's schema.sql) and
|
||||
// fixture rows. No live store is read or copied. Mos's go (mos-0835) asks for one transaction, a
|
||||
// schema that differs from 3h only in the account CHECK, and a 3h engine that refuses a 3i store.
|
||||
const AT = '2026-10-18T00:00:00.000Z';
|
||||
const SEQUENCE = 50;
|
||||
const ddl3h = readFileSync(new URL('./fixtures/schema-3h.sql', import.meta.url), 'utf8');
|
||||
const here = (p) => new URL(p, import.meta.url);
|
||||
function source3h(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'account-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(ddl3h).digest('hex'), 'bb2ba58175a111bb99681778911507846bef714a6d573f8a8af67580a3636950');
|
||||
db.exec(ddl3h);
|
||||
const run = (sql, ...args) => db.prepare(sql).run(...args);
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3h');
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
|
||||
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
|
||||
run('INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)', 'event', AT, 'demo', 'action.allowed', '{}');
|
||||
run('INSERT INTO instance_names(at,business,instance,name,by) VALUES(?,?,?,?,?)', AT, 'demo', 'coder', 'Builder', 'jason');
|
||||
const usage = 'INSERT INTO usage_records(at,business,run,instance,provider,model,account,turn,source,phase,status,code,cursor,input,output,cache_write,cache_read) VALUES(?,?,?,?,?,?,NULL,?,?,?,?,?,?,?,?,?,?)';
|
||||
run(usage, AT, 'demo', 'run1', 'coder', 'anthropic', 'opus', 't1', 'claude', 'begin', 'known', null, '{"input":1}', 0, 0, 0, 0);
|
||||
run(usage, AT, 'demo', 'run1', 'coder', 'anthropic', 'opus', 't1', 'claude', 'end', 'known', null, null, 4, 5, 6, 7);
|
||||
run(usage, AT, 'demo', 'run2', 'coder', null, null, 't1', 'claude', 'end', 'unknown', 'no-source', null, null, null, null, null);
|
||||
// The sequence runs past the last seq, so the step must carry it, not rebuild it from the rows.
|
||||
run("UPDATE sqlite_sequence SET seq=? WHERE name='usage_records'", SEQUENCE);
|
||||
db.close(); chmodSync(path, 0o600);
|
||||
return { root, path };
|
||||
}
|
||||
function snapshot(path, columns) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
columns ??= Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()
|
||||
.map(({ name }) => [name, db.prepare(`PRAGMA table_info(${name})`).all().map(c => c.name)]));
|
||||
const rows = Object.fromEntries(Object.entries(columns).map(([table, keys]) => [table,
|
||||
db.prepare(`SELECT ${keys.join(',')} FROM ${table} ORDER BY ${table === 'sqlite_sequence' ? 'name' : 'rowid'}`).all()
|
||||
.filter(r => table !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key))]));
|
||||
return { columns, rows };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
function 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 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(); }
|
||||
};
|
||||
// A copy of the bus module whose store.mjs, schema.sql and steps the caller edits.
|
||||
async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
const moduleRoot = join(root, name);
|
||||
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8'), edited = store ? store(source) : source;
|
||||
if (store) assert.notEqual(edited, source);
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), schema ?? readFileSync(here('../schema.sql'), 'utf8'));
|
||||
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql'])
|
||||
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
|
||||
writeFileSync(join(moduleRoot, 'src/migrations/3h-3i.sql'), step(readFileSync(here('../src/migrations/3h-3i.sql'), 'utf8')));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
|
||||
test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence carries over, and only the account CHECK changes', t => {
|
||||
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3i');
|
||||
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
|
||||
assert.deepEqual({ ...before.rows.sqlite_sequence.find(r => r.name === 'usage_records') }, { name: 'usage_records', seq: SEQUENCE });
|
||||
// sqlite_master as at 3h, the account line aside: same tables, indexes and triggers, same text.
|
||||
const old = ' account TEXT CHECK (account IS NULL),\n';
|
||||
assert.equal(was.filter(r => r.sql?.includes(old)).map(r => r.name).join(), 'usage_records');
|
||||
assert.deepEqual(master(path), was.map(r => r.name === 'usage_records' ? { ...r, sql: r.sql.replace(old, ' account TEXT,\n') } : r));
|
||||
assert.equal(was.filter(r => r.type === 'trigger' && r.tbl_name === 'usage_records').length, 3);
|
||||
const store = new Store(root);
|
||||
try {
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
// A new record takes an account, and its seq follows the carried sequence.
|
||||
store.run(`INSERT INTO usage_records(at,business,run,instance,provider,model,account,turn,source,phase,status,cursor,input,output,cache_write,cache_read)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, AT, 'demo', 'run3', 'coder', 'anthropic', 'opus', 'jason-max', 't1', 'claude', 'begin', 'known', '{}', 0, 0, 0, 0);
|
||||
assert.deepEqual({ ...store.get("SELECT seq,account FROM usage_records WHERE run='run3'") }, { seq: SEQUENCE + 1, account: 'jason-max' });
|
||||
// Still append-only on 3i.
|
||||
assert.throws(() => store.run("UPDATE usage_records SET account='other' WHERE seq=1"), /usage_records is append-only/);
|
||||
assert.throws(() => store.run('DELETE FROM usage_records WHERE seq=1'), /usage_records is append-only/);
|
||||
assert.throws(() => store.run(`INSERT OR REPLACE INTO usage_records(at,business,run,instance,turn,source,phase,status)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, AT, 'demo', 'run2', 'coder', 't1', 'claude', 'end', 'unknown'), /usage_records is append-only/);
|
||||
assert.throws(() => store.run(`INSERT INTO usage_records(seq,at,business,run,instance,turn,source,phase,status)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, 1, AT, 'demo', 'run9', 'coder', 't1', 'claude', 'end', 'unknown'), /usage_records is append-only/);
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 4);
|
||||
assert.equal(store.get("SELECT account FROM usage_records WHERE seq=1").account, null);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with every row, the sequence and the schema intact', async t => {
|
||||
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
|
||||
// The real step with one statement added after the copy: it copies a row again, which only
|
||||
// fails when there are rows, so the empty replay at import passes and the store's run fails
|
||||
// after the triggers are dropped, the table renamed, and the new one made and filled.
|
||||
const extra = 'INSERT INTO usage_records SELECT * FROM usage_records_3h LIMIT 1;\n';
|
||||
const Failing = await engine(root, 'engine-failing', { step: (sql) => {
|
||||
assert.equal(sql.split('DROP TABLE usage_records_3h;\n').length, 2);
|
||||
return sql.replace('DROP TABLE usage_records_3h;\n', extra + 'DROP TABLE usage_records_3h;\n');
|
||||
} });
|
||||
assert.throws(() => new Failing(root, { migrateSchema: true }), /UNIQUE constraint failed: usage_records\.seq/);
|
||||
assert.equal(version(path), '3h');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
// Still a whole 3h store: the real step migrates it.
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3i');
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-4 the 3h engine refuses a 3i store, migrating or not, and leaves it at 3i', async t => {
|
||||
const { root, path } = source3h(t);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
const after = snapshot(path), was = master(path);
|
||||
const Store3h = await engine(root, 'engine-3h', {
|
||||
schema: ddl3h,
|
||||
store: (source) => source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3i';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
|
||||
});
|
||||
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
|
||||
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
|
||||
assert.throws(() => new Store3h(root), /schema-mismatch/);
|
||||
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
|
||||
assert.equal(version(path), '3i');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
|
||||
});
|
||||
@@ -434,7 +434,7 @@ async function database3c(t) {
|
||||
let 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(ddl3c).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3h';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace("export const SCHEMA_VERSION = '3i';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.notEqual(pinned, source);
|
||||
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
|
||||
@@ -458,7 +458,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
|
||||
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, '3h');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
|
||||
const b = new Broker({ store, businesses: demo(), organization: null });
|
||||
|
||||
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
|
||||
const original = readFileSync(source);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3h');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
|
||||
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
|
||||
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
|
||||
const sha = createHash('sha256').update(futureDDL).digest('hex');
|
||||
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
|
||||
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3h';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3h', to: '3i', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
.replace("export const SCHEMA_VERSION = '3i';", "export const SCHEMA_VERSION = '3j';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3i', to: '3j', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql'])
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql'])
|
||||
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
|
||||
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
|
||||
const future = new FutureStore(root, { migrateSchema: true });
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3j');
|
||||
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
|
||||
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
|
||||
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
|
||||
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
|
||||
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.match(migrated.stdout, /bus schema 3h ready/);
|
||||
assert.match(migrated.stdout, /bus schema 3i ready/);
|
||||
new Store(root).close();
|
||||
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
||||
assert.equal(usage.status, 2);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Row 126 (#1620, REQ-LIMIT-2, REQ-LIMIT-4): the account a run spends. bindLaunch keeps it on the
|
||||
// session, out of session.launched's body; usage records carry it; mayLaunch and mustStop pass it
|
||||
// to the shipped daily-quota through the real loader. A run with no account is null throughout.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
|
||||
const SINCE = '2026-10-18T07:00:00.000Z';
|
||||
const DAILY = ['tokens', 'daily', 'jason-max'];
|
||||
const ROLES = { coder: { definition: 'coder', authority: { withinRole: [], crossRole: [] } } };
|
||||
|
||||
async function setup(t, limit = 0) {
|
||||
const { startBroker } = await import('../src/runtime.mjs');
|
||||
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-quota-')), rolesDir = rolesCopy(root), doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { 'limit-check': ['daily-quota'] };
|
||||
doc.launch.account = { name: 'jason-max', credential: 'CLAUDE_CODE_OAUTH_TOKEN' };
|
||||
doc.launch.limits = { ...doc.launch.limits,
|
||||
tokens: { daily: { 'jason-max': { limit, since: SINCE, reset: '05:00', timeZone: 'America/Chicago' } } } };
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'),
|
||||
businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } },
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
return { bus, human: bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
|
||||
}
|
||||
|
||||
async function turn(broker, human, cap) {
|
||||
const { id } = broker.request(human, { verb: 'message.send', args: { to: 'coder', body: 'work' } });
|
||||
broker.request(cap, { verb: 'message.receive' });
|
||||
await broker.request(cap, { verb: 'usage.begin', args: { turn: id } });
|
||||
await broker.request(cap, { verb: 'usage.end', args: { turn: id } });
|
||||
}
|
||||
|
||||
test('REQ-LIMIT-2 runtime: limits counts the daily quota, and mayLaunch refuses only the account at it', async t => {
|
||||
const { bus, human } = await setup(t);
|
||||
const [row] = bus.broker.request(human, { verb: 'limits' }).tokens;
|
||||
assert.deepEqual({ path: row.path, limit: row.limit, since: row.since, status: row.status, check: row.check, count: row.count },
|
||||
{ path: DAILY, limit: 0, since: SINCE, status: 'counted', check: 'daily-quota', count: 0 });
|
||||
assert.deepEqual(await bus.mayLaunch({ business: 'demo', instance: 'coder', provider: 'anthropic', model: 'opus', account: 'jason-max' }),
|
||||
{ allow: false, check: 'daily-quota', hit: { path: DAILY, limit: 0, count: 0, unknownTurns: 0 } });
|
||||
// A pi launch has no account, so no daily quota covers it; the op may also leave it out.
|
||||
assert.deepEqual(await bus.mayLaunch({ business: 'demo', instance: 'coder', provider: 'zai', model: 'glm', account: null }), { allow: true });
|
||||
assert.deepEqual(await bus.mayLaunch({ business: 'demo', instance: 'coder', provider: 'zai', model: 'glm' }), { allow: true });
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-4 runtime: a run bound with the account writes it on its usage records and stops at the quota', async t => {
|
||||
const { bus, human } = await setup(t);
|
||||
const { broker } = bus;
|
||||
const coder = broker.bindLaunch({ business: 'demo', role: 'coder', run: 'run1', harness: 'claude-code', provider: 'anthropic', model: 'opus', account: 'jason-max' });
|
||||
broker.request(coder, { verb: 'role.claim' });
|
||||
await turn(broker, human, coder);
|
||||
assert.deepEqual(broker.request(human, { verb: 'usage' }).byAccount.map(r => r.account), ['jason-max']);
|
||||
assert.throws(() => broker.request(coder, { verb: 'message.receive' }), { code: 'not-holder' });
|
||||
assert.deepEqual(bus.endLaunch({ business: 'demo', run: 'run1', reason: 'exited', exitCode: 22 }),
|
||||
{ released: false, limit: 'limit: daily-quota tokens.daily.jason-max limit 0 count 0' });
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-4 runtime: a run bound without an account records null and the daily quota never stops it', async t => {
|
||||
const { bus, human } = await setup(t);
|
||||
const { broker } = bus;
|
||||
const coder = broker.bindLaunch({ business: 'demo', role: 'coder', run: 'run1', harness: 'pi', provider: 'zai', model: 'glm' });
|
||||
broker.request(coder, { verb: 'role.claim' });
|
||||
await turn(broker, human, coder);
|
||||
assert.deepEqual(broker.request(human, { verb: 'usage' }).byAccount.map(r => r.account), [null]);
|
||||
assert.deepEqual(broker.request(coder, { verb: 'message.receive' }), []);
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-4 bindLaunch keeps the account off session.launched, refuses one that is not an identifier, and takes null as none', t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-daily-bind-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const broker = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' },
|
||||
roles: ROLES, definitions: { coder: {} }, projects: { stack: { root: '/nonexistent' } } } } });
|
||||
const base = { business: 'demo', role: 'coder', harness: 'claude-code', provider: 'anthropic', model: 'opus' };
|
||||
for (const [i, account] of ['Jason Max', '', 5, {}, '-x'].entries())
|
||||
assert.throws(() => broker.bindLaunch({ ...base, run: `bad${i}`, account }), { code: 'invalid-request' }, JSON.stringify(account));
|
||||
assert.ok(broker.bindLaunch({ ...base, run: 'none', account: null }));
|
||||
assert.ok(broker.bindLaunch({ ...base, run: 'run1', account: 'jason-max' }));
|
||||
// The body is what it was before row 126, so a rebind after a broker restart compares the same.
|
||||
const { body } = store.get("SELECT body FROM events WHERE kind='session.launched' AND subject='run1'");
|
||||
assert.deepEqual(JSON.parse(body), { harness: 'claude-code', address: null, pid: null, startTime: null });
|
||||
});
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
|
||||
CREATE TABLE events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN (
|
||||
'session.launched',
|
||||
'session.ended',
|
||||
'action.allowed',
|
||||
'action.refused',
|
||||
'task.created',
|
||||
'task.assigned',
|
||||
'task.state',
|
||||
'task.closed',
|
||||
'task.changed.external',
|
||||
'task.conflict',
|
||||
'task.missing',
|
||||
'review.requested',
|
||||
'review.verdict',
|
||||
'human.input',
|
||||
'config.refused',
|
||||
'credential.expiring',
|
||||
'credential.expired',
|
||||
'credential.changed',
|
||||
'launch.revoked',
|
||||
'launch.restored',
|
||||
'digest.sent')),
|
||||
actor_role TEXT, actor_run TEXT,
|
||||
subject TEXT,
|
||||
corrects TEXT REFERENCES events(id),
|
||||
body TEXT NOT NULL CHECK (json_valid(body))
|
||||
) STRICT;
|
||||
CREATE TABLE role_claims (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL, role TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
|
||||
holder_run TEXT NOT NULL,
|
||||
harness TEXT NOT NULL, address TEXT,
|
||||
by TEXT NOT NULL, reason TEXT,
|
||||
decision TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE decisions (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL, project TEXT,
|
||||
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
|
||||
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
|
||||
action TEXT NOT NULL,
|
||||
route_to TEXT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
|
||||
recommendation TEXT NOT NULL,
|
||||
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
|
||||
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
|
||||
supersedes TEXT REFERENCES decisions(id)
|
||||
, spec_path TEXT, spec_commit TEXT) STRICT;
|
||||
CREATE TABLE decision_events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
at TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
|
||||
by TEXT NOT NULL,
|
||||
choice TEXT, note TEXT, via TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE messages (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
|
||||
to_role TEXT NOT NULL,
|
||||
class TEXT NOT NULL,
|
||||
in_reply_to TEXT REFERENCES messages(id),
|
||||
decision TEXT REFERENCES decisions(id),
|
||||
corrects TEXT REFERENCES messages(id),
|
||||
body TEXT NOT NULL
|
||||
, to_name TEXT) STRICT;
|
||||
CREATE TABLE deliveries (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message TEXT NOT NULL REFERENCES messages(id),
|
||||
at TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
|
||||
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE task_snapshots (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
|
||||
updated TEXT NOT NULL,
|
||||
etag TEXT,
|
||||
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
|
||||
fields TEXT NOT NULL CHECK (json_valid(fields)),
|
||||
source TEXT NOT NULL CHECK (source IN ('self','poll')),
|
||||
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
|
||||
read_at TEXT,
|
||||
role TEXT, run TEXT,
|
||||
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
|
||||
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
|
||||
CHECK (json_type(fields, '$.bucket') IS 'integer'
|
||||
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
|
||||
) STRICT;
|
||||
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
|
||||
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
|
||||
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
|
||||
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
|
||||
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
|
||||
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
|
||||
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
|
||||
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
|
||||
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
|
||||
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
|
||||
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
||||
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
||||
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
|
||||
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
|
||||
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
|
||||
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
|
||||
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
|
||||
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind GLOB 'credential.*' AND (
|
||||
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
|
||||
OR json_extract(NEW.body, '$.instance') IS NULL)
|
||||
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
|
||||
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'task.missing' AND (
|
||||
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
|
||||
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
|
||||
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
|
||||
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
|
||||
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
|
||||
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
|
||||
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'task.created' AND (
|
||||
json_type(NEW.body, '$.request') IS NOT 'text'
|
||||
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
|
||||
AND kind = 'human.input' AND business = NEW.business)
|
||||
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
|
||||
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
|
||||
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
|
||||
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
|
||||
CREATE VIEW launch_state AS
|
||||
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
|
||||
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
|
||||
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
|
||||
CREATE VIEW task_external_changes AS
|
||||
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
|
||||
FROM task_snapshots p
|
||||
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
|
||||
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
|
||||
WHERE p.source = 'poll'
|
||||
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
|
||||
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
|
||||
CREATE VIEW task_current AS
|
||||
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
|
||||
FROM task_snapshots s
|
||||
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
|
||||
WHERE c.business = s.business AND c.task_ref = s.task_ref
|
||||
AND (c.source = 'self'
|
||||
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
|
||||
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
|
||||
ORDER BY ls.seq DESC LIMIT 1), '')));
|
||||
CREATE VIEW tasks_open AS
|
||||
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
|
||||
FROM task_current s
|
||||
WHERE json_type(s.fields, '$.gone') IS NULL
|
||||
AND json_extract(s.fields, '$.done') = 0;
|
||||
CREATE VIEW urgent_inbox AS
|
||||
SELECT d.id, d.business, d.task_ref, d.question, d.at
|
||||
FROM decisions d
|
||||
WHERE d.class = 'gated' AND d.blocking = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
|
||||
CREATE TABLE task_comment_cursors (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
business TEXT NOT NULL,
|
||||
task_id INTEGER NOT NULL CHECK (task_id > 0),
|
||||
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
|
||||
) STRICT;
|
||||
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
|
||||
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
|
||||
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
|
||||
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
|
||||
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
|
||||
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
|
||||
CREATE TABLE projects (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
repo TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
UNIQUE (business, name)
|
||||
) STRICT;
|
||||
CREATE TABLE threads (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
project TEXT NOT NULL REFERENCES projects(id),
|
||||
instance TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
UNIQUE (project, name)
|
||||
) STRICT;
|
||||
CREATE TABLE thread_claims (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
|
||||
holder_run TEXT NOT NULL,
|
||||
harness TEXT NOT NULL, address TEXT,
|
||||
by TEXT NOT NULL, reason TEXT,
|
||||
decision TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE message_threads (
|
||||
message TEXT PRIMARY KEY REFERENCES messages(id),
|
||||
thread TEXT NOT NULL REFERENCES threads(id)
|
||||
) STRICT;
|
||||
CREATE TABLE thread_opens (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
|
||||
CREATE INDEX message_threads_thread ON message_threads (thread);
|
||||
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
|
||||
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
|
||||
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread already held'); END;
|
||||
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
|
||||
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = c.thread) AND c.op = 'claim')
|
||||
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); END;
|
||||
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread is not held'); END;
|
||||
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
||||
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
||||
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
|
||||
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
|
||||
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
|
||||
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
|
||||
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
|
||||
CREATE TRIGGER delivery_same_thread BEFORE INSERT ON deliveries
|
||||
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
|
||||
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
|
||||
WHERE mt.message = NEW.message AND m.to_role = t.instance
|
||||
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = t.id)
|
||||
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its thread''s holder'); END;
|
||||
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TABLE instance_names (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TRIGGER instance_names_unique BEFORE INSERT ON instance_names
|
||||
WHEN EXISTS (SELECT 1 FROM instance_names n WHERE n.business = NEW.business AND n.name = NEW.name
|
||||
AND n.instance <> NEW.instance
|
||||
AND n.seq = (SELECT max(seq) FROM instance_names WHERE business = n.business AND instance = n.instance))
|
||||
BEGIN SELECT RAISE(ABORT, 'an instance name is unique in its business'); END;
|
||||
CREATE TRIGGER instance_names_no_update BEFORE UPDATE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
CREATE TRIGGER instance_names_no_delete BEFORE DELETE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
CREATE TRIGGER instance_names_no_replace BEFORE INSERT ON instance_names WHEN EXISTS (SELECT 1 FROM instance_names WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
-- Row 124 (#1618): immutable source snapshots and turn outcomes, never transcript text.
|
||||
CREATE TABLE usage_records (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
run TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
provider TEXT,
|
||||
model TEXT,
|
||||
account TEXT CHECK (account IS NULL),
|
||||
turn TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
|
||||
status TEXT NOT NULL CHECK (status IN ('known','unknown')),
|
||||
code TEXT,
|
||||
cursor TEXT CHECK (cursor IS NULL OR json_valid(cursor)),
|
||||
input INTEGER,
|
||||
output INTEGER,
|
||||
cache_write INTEGER,
|
||||
cache_read INTEGER,
|
||||
UNIQUE (business,run,turn,source,phase),
|
||||
CHECK ((status='unknown' AND input IS NULL AND output IS NULL AND cache_write IS NULL AND cache_read IS NULL)
|
||||
OR (status='known' AND input >= 0 AND output >= 0 AND cache_write >= 0 AND cache_read >= 0
|
||||
AND input IS NOT NULL AND output IS NOT NULL AND cache_write IS NOT NULL AND cache_read IS NOT NULL))
|
||||
) STRICT;
|
||||
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
|
||||
@@ -535,7 +535,7 @@ test('usage.begin with a decision id needs it held for the run\'s instance, usag
|
||||
['begin', 'end'].map((phase) => ({ run: 'merge-gate-run', instance: 'merge-gate', turn: open.id, phase })),
|
||||
);
|
||||
assert.equal(asked.length, 1);
|
||||
assert.deepEqual(asked[0].subject, { instance: 'merge-gate', provider: null, model: null });
|
||||
assert.deepEqual(asked[0].subject, { instance: 'merge-gate', provider: null, model: null, account: null });
|
||||
assert.deepEqual(asked[0].turns.map((r) => [r.run, r.turn, r.instance]), [['merge-gate-run', open.id, 'merge-gate']]);
|
||||
// Spent, a second landing turn's begin refuses.
|
||||
await assert.rejects(usage('merge-gate', 'begin', open.id), { code: 'not-recipient' }, 'spent');
|
||||
|
||||
@@ -58,7 +58,7 @@ test('REQ-LIMIT-4 a role run at its limit stops at the end of its turn: RESULT a
|
||||
assert.deepEqual(broker.request(coder, { verb: 'message.receive' }), []);
|
||||
// At the limit: the turn's reply and read receipt still land.
|
||||
const id = await turn(broker, human, coder, send);
|
||||
assert.deepEqual(asked, [1, 2].map(() => ({ business: 'demo', subject: { instance: 'coder', provider: 'anthropic', model: 'opus' } })));
|
||||
assert.deepEqual(asked, [1, 2].map(() => ({ business: 'demo', subject: { instance: 'coder', provider: 'anthropic', model: 'opus', account: null } })));
|
||||
const reply = broker.request(coder, { verb: 'message.send', args: { to: 'human', body: 'done', class: 'RESULT', in_reply_to: id } });
|
||||
assert.ok(reply.id);
|
||||
broker.request(coder, { verb: 'message.read', args: { id } });
|
||||
|
||||
@@ -55,7 +55,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3h');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
|
||||
|
||||
@@ -53,7 +53,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3h');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
|
||||
@@ -49,7 +49,7 @@ async function database3d(t) {
|
||||
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 = '3h';", "export const SCHEMA_VERSION = '3d';")
|
||||
.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);
|
||||
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
|
||||
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, '3h');
|
||||
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.
|
||||
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
|
||||
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 3h ready', ''].join('\n'));
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3i ready', ''].join('\n'));
|
||||
});
|
||||
|
||||
@@ -51,12 +51,12 @@ function snapshot(path, columns) {
|
||||
return { columns, rows };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
test('REQ-LIMIT-4 3g copy migrates to 3h preserving every old column, row and sequence; new usage table is empty', t => {
|
||||
test('REQ-LIMIT-4 3g copy migrates through 3h to 3i preserving every old column, row and sequence; new usage table is empty', t => {
|
||||
const { root, path, copy } = source3g(t), before = snapshot(path), untouched = readFileSync(copy);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3h');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3i');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
|
||||
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
|
||||
|
||||
@@ -107,6 +107,15 @@ instance whose definition holds `role.launch` within-role; otherwise the
|
||||
file refuses. Only that instance keeps `role.launch`. Every other instance
|
||||
loses it, so for them it's gated.
|
||||
|
||||
`launch.account` (optional, row 126) is `{name, credential}`: the account
|
||||
the business's Claude Code launches spend, an identifier, and the
|
||||
environment variables of one login set joined with `+` (for example
|
||||
`CLAUDE_CODE_OAUTH_TOKEN`), never a value. Which sets exist is the
|
||||
launcher's check. `launch.limits.tokens.daily` maps an account to
|
||||
`{limit, since, reset, timeZone}`: `reset` is `HH:MM`, `timeZone` an IANA
|
||||
name `Intl` accepts. A daily quota for any name but `launch.account.name`
|
||||
refuses the file, since it would count nothing.
|
||||
|
||||
`launch.threadIdleSeconds` (optional, row 121) is how long a thread's
|
||||
session runs with nothing pending and no new message or open before the
|
||||
launcher stops it. Absent means 900 (`THREAD_IDLE_SECONDS` in
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { dirname, isAbsolute, join, normalize } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { checkModels, checkLimits } from "./launch.mjs";
|
||||
import { checkModels, checkLimits, checkAccount } from "./launch.mjs";
|
||||
import { checkVars } from "./vars.mjs";
|
||||
import { parseCredentialRef } from "./credentials.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
@@ -118,7 +118,7 @@ function checkLaunch(launch, roles, definitions, file) {
|
||||
const where = `${file} launch`;
|
||||
requireObject(launch, where);
|
||||
if (Object.hasOwn(launch,"max")) refuse(`${where}.max needs migration: mosaic limits migrate --file FILE --models FILE`);
|
||||
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds"], where);
|
||||
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account"], where);
|
||||
requireId(launch.by, `${where}.by`);
|
||||
if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`);
|
||||
const launcher = definitions[roles[launch.by].definition];
|
||||
@@ -133,10 +133,16 @@ function checkLaunch(launch, roles, definitions, file) {
|
||||
const models=checkModels(launch.models,`${where}.models`);
|
||||
const limits=checkLimits(launch.limits===undefined?{}:launch.limits,`${where}.limits`);
|
||||
for(const instance of Object.keys(limits.tokens?.instances??{}))if(!Object.hasOwn(roles,instance))refuse(`${where}.limits.tokens.instances names ${instance}, which the business file doesn't declare`);
|
||||
// Row 126 (#1620): a daily quota for any account but launch.account would count nothing and still
|
||||
// show as counted, so it refuses the read.
|
||||
const account = launch.account === undefined ? undefined : checkAccount(launch.account, `${where}.account`);
|
||||
for (const name of Object.keys(limits.tokens?.daily ?? {})) {
|
||||
if (name !== account?.name) refuse(`${where}.limits.tokens.daily names ${name}, which isn't ${where}.account.name`);
|
||||
}
|
||||
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`);
|
||||
// No upper bound: limits have no ceiling in code (Q150).
|
||||
const threadIdleSeconds = launch.threadIdleSeconds === undefined ? THREAD_IDLE_SECONDS : requirePositiveInt(launch.threadIdleSeconds, `${where}.threadIdleSeconds`);
|
||||
return { by: launch.by, instances, models, limits, threadIdleSeconds };
|
||||
return { by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}) };
|
||||
}
|
||||
|
||||
// Validate a parsed business document read from `file`. `rolesDir` is where
|
||||
|
||||
@@ -18,10 +18,10 @@ const own = (value, key) => value !== null && typeof value === "object" && !Arra
|
||||
const plain = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
// Row 125 (#1619, REQ-LIMIT-3): token limits sit only at their schema depth, tokens.instances.X,
|
||||
// tokens.providers.X, tokens.keys.X and tokens.models.P.M. Nothing else in launch.limits is one,
|
||||
// tokens.providers.X, tokens.keys.X, tokens.models.P.M and (row 126) tokens.daily.X. Nothing else in launch.limits is one,
|
||||
// so a concurrency provider or model named `limit`, or a token limit's subject named `limit`, is
|
||||
// never mistaken for a leaf. The bus core's limitLeaves walks the same depths.
|
||||
const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2 };
|
||||
const DEPTH = { instances: 1, providers: 1, keys: 1, models: 2, daily: 1 };
|
||||
function tokenLeaves(limits) {
|
||||
const out = [];
|
||||
const walk = (value, depth, path) => {
|
||||
|
||||
@@ -34,15 +34,33 @@ const tokenLimit = (value, at) => {
|
||||
new Date(since).toISOString() !== since) refuse(`${at}.since must be a canonical timestamp`);
|
||||
return { limit: count(value.limit, `${at}.limit`), since };
|
||||
};
|
||||
// Row 126 (#1620, REQ-LIMIT-2): a daily quota is a token limit with the wall-clock time its day
|
||||
// starts and the IANA zone that time is read in. A zone must be a name (an offset such as +05:00
|
||||
// is refused) that Intl accepts.
|
||||
const RESET = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
const ZONE = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/;
|
||||
const dailyQuota = (value, at) => {
|
||||
requireObject(value, at);
|
||||
rejectUnknownKeys(value, ["limit", "since", "reset", "timeZone"], at);
|
||||
const { limit, since } = tokenLimit({ limit: value.limit, since: value.since }, at);
|
||||
if (typeof value.reset !== "string" || !RESET.test(value.reset)) refuse(`${at}.reset must be a time of day, HH:MM`);
|
||||
const timeZone = value.timeZone;
|
||||
let known = typeof timeZone === "string" && ZONE.test(timeZone);
|
||||
if (known) {
|
||||
try { new Intl.DateTimeFormat("en-US", { timeZone }); } catch { known = false; }
|
||||
}
|
||||
if (!known) refuse(`${at}.timeZone must be an IANA time zone name`);
|
||||
return { limit, since, reset: value.reset, timeZone };
|
||||
};
|
||||
const tokenMap = (value, at, key, each) => {
|
||||
requireObject(value, at);
|
||||
return Object.fromEntries(Object.entries(value).map(([k, v]) => [key(k, at), each(v, `${at}.${k}`)]));
|
||||
};
|
||||
|
||||
// launch.limits.tokens: instances, providers, models (per provider) and keys, each optional.
|
||||
// launch.limits.tokens: instances, providers, models (per provider), keys and daily, each optional.
|
||||
export function checkTokenLimits(value, where = "launch.limits.tokens") {
|
||||
requireObject(value, where);
|
||||
rejectUnknownKeys(value, ["instances", "providers", "models", "keys"], where);
|
||||
rejectUnknownKeys(value, ["instances", "providers", "models", "keys", "daily"], where);
|
||||
const out = {};
|
||||
if (Object.hasOwn(value, "instances")) out.instances = tokenMap(value.instances, `${where}.instances`, requireId, tokenLimit);
|
||||
if (Object.hasOwn(value, "providers")) out.providers = tokenMap(value.providers, `${where}.providers`, name, tokenLimit);
|
||||
@@ -51,9 +69,25 @@ export function checkTokenLimits(value, where = "launch.limits.tokens") {
|
||||
}
|
||||
// A key is a name until row 126 names the accounts.
|
||||
if (Object.hasOwn(value, "keys")) out.keys = tokenMap(value.keys, `${where}.keys`, requireId, tokenLimit);
|
||||
// One quota per account; the business read checks each key names launch.account.
|
||||
if (Object.hasOwn(value, "daily")) out.daily = tokenMap(value.daily, `${where}.daily`, requireId, dailyQuota);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Row 126 (#1620): the account a business's claude-code launches use. `credential` names the
|
||||
// environment variables of one credential set, joined with "+", so a secret can't pass the check.
|
||||
// Which sets exist is the launcher's to check (seat imports business, not the other way).
|
||||
const CREDENTIAL = /^[A-Z][A-Z0-9_]*(\+[A-Z][A-Z0-9_]*)*$/;
|
||||
export function checkAccount(value, where = "launch.account") {
|
||||
requireObject(value, where);
|
||||
rejectUnknownKeys(value, ["name", "credential"], where);
|
||||
const accountName = requireId(value.name, `${where}.name`);
|
||||
if (typeof value.credential !== "string" || !CREDENTIAL.test(value.credential)) {
|
||||
refuse(`${where}.credential must name environment variables, joined with "+"`);
|
||||
}
|
||||
return { name: accountName, credential: value.credential };
|
||||
}
|
||||
|
||||
export function checkLimits(value = {}, where = "launch.limits") {
|
||||
requireObject(value, where);
|
||||
rejectUnknownKeys(value, ["total", "providers", "models", "tokens"], where);
|
||||
|
||||
@@ -101,3 +101,52 @@ test('REQ-LIMIT-3 a reset moves since to now, is logged with its actor, and refu
|
||||
assert.throws(()=>f.settings.set('acme',{revision:view.revision,reset},'jason'),{code:'invalid-request'},JSON.stringify(reset));
|
||||
assert.equal(readFileSync(f.file,'utf8'),bytes);
|
||||
});
|
||||
|
||||
// Row 126 (#1620, REQ-LIMIT-2): launch.account and tokens.daily.<account>.
|
||||
const ACCOUNT={name:'max',credential:'CLAUDE_CODE_OAUTH_TOKEN'};
|
||||
const DAILY={limit:40,since:T0,reset:'05:00',timeZone:'America/Chicago'};
|
||||
test('REQ-LIMIT-2 schema: launch.account names a credential set; a daily quota needs a reset and an IANA zone and names the account',()=>{
|
||||
const f=fixture(),read=(launch)=>{const d=structuredClone(f.doc);Object.assign(d.launch,launch);return validateBusinessDocument(d,f.file,{rolesDir:f.rolesDir}).launch;};
|
||||
// No account: the launch reads as before, with no account key.
|
||||
assert.equal(Object.hasOwn(read({}),'account'),false);
|
||||
for(const credential of ['CLAUDE_CODE_OAUTH_TOKEN','ANTHROPIC_API_KEY','ANTHROPIC_AUTH_TOKEN+ANTHROPIC_BASE_URL'])
|
||||
assert.deepEqual(read({account:{name:'max',credential}}).account,{name:'max',credential});
|
||||
const ok=read({account:ACCOUNT,limits:{tokens:{daily:{max:DAILY}}}});
|
||||
assert.deepEqual(ok.limits.tokens.daily,{max:DAILY});
|
||||
for(const timeZone of ['UTC','Etc/GMT+5','America/Argentina/Buenos_Aires'])
|
||||
assert.equal(read({account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,timeZone}}}}}).limits.tokens.daily.max.timeZone,timeZone);
|
||||
const bad=[
|
||||
{account:{name:'max'}},{account:{credential:'ANTHROPIC_API_KEY'}},{account:{name:'Max Account',credential:'ANTHROPIC_API_KEY'}},
|
||||
{account:{name:'max',credential:'anthropic_api_key'}},{account:{name:'max',credential:'sk-ant-api03-abc'}},
|
||||
{account:{name:'max',credential:'ANTHROPIC_API_KEY+'}},{account:{name:'max',credential:'ANTHROPIC_API_KEY',key:'x'}},{account:'max'},
|
||||
];
|
||||
for(const reset of ['5:00','24:00','05:60','05:00:00','',undefined,500])bad.push({account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,reset}}}}});
|
||||
for(const timeZone of ['Chicago time','+05:00','-0600','Mars/Phobos','America/Chicago ','',undefined,5])bad.push({account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,timeZone}}}}});
|
||||
bad.push({account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,limit:-1}}}}},{account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,since:'2026-10-18'}}}}},
|
||||
{account:ACCOUNT,limits:{tokens:{daily:{max:{...DAILY,extra:1}}}}},{account:ACCOUNT,limits:{tokens:{daily:{max:{limit:40,since:T0}}}}});
|
||||
// A daily quota for another account, or with no account at all, would count nothing.
|
||||
bad.push({account:ACCOUNT,limits:{tokens:{daily:{other:DAILY}}}},{limits:{tokens:{daily:{max:DAILY}}}});
|
||||
for(const launch of bad)assert.throws(()=>read(launch),JSON.stringify(launch));
|
||||
// The credential check's refusal names the field, never the value given.
|
||||
assert.throws(()=>read({account:{name:'max',credential:'sk-ant-api03-abc'}}),e=>/launch\.account\.credential/.test(e.message)&&!e.message.includes('sk-ant'));
|
||||
assert.throws(()=>read({account:ACCOUNT,limits:{tokens:{daily:{other:DAILY}}}}),/daily names other/);
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-2 the writer stamps a daily quota\'s since, keeps its reset and zone, and a reset moves since',()=>{
|
||||
const f=fixture();
|
||||
f.doc.launch.account=ACCOUNT;writeJson(f.file,f.doc);
|
||||
let view=f.settings.read('acme');
|
||||
view=f.settings.set('acme',{revision:view.revision,limits:{tokens:{daily:{max:{limit:40,reset:'05:00',timeZone:'America/Chicago'}}}}},'jason');
|
||||
assert.deepEqual(stored(f),{daily:{max:{limit:40,reset:'05:00',timeZone:'America/Chicago',since:T0}}});
|
||||
f.tick(T1);
|
||||
view=f.settings.set('acme',{revision:view.revision,limits:{tokens:{daily:{max:{limit:30,reset:'06:00',timeZone:'UTC'}}}}},'jason');
|
||||
assert.deepEqual(stored(f),{daily:{max:{limit:30,reset:'06:00',timeZone:'UTC',since:T0}}});
|
||||
assert.throws(()=>f.settings.set('acme',{revision:view.revision,limits:{tokens:{daily:{max:{limit:30,reset:'06:00',timeZone:'UTC',since:T2}}}}},'jason'),{code:'invalid-request'});
|
||||
f.tick(T2);
|
||||
view=f.settings.set('acme',{revision:view.revision,reset:[['tokens','daily','max']]},'chief');
|
||||
assert.equal(stored(f).daily.max.since,T2);
|
||||
assert.equal(lastLog(f).actor,'chief');
|
||||
assert.throws(()=>f.settings.set('acme',{revision:view.revision,reset:[['tokens','daily']]},'jason'),{code:'invalid-request'});
|
||||
// The account itself stays a hand edit: the file's account is untouched by the writes.
|
||||
assert.deepEqual(JSON.parse(readFileSync(f.file,'utf8')).launch.account,ACCOUNT);
|
||||
});
|
||||
|
||||
@@ -341,6 +341,9 @@ is `{"ok": true, "result": {run, instance, harness, model}}` or
|
||||
| the instance resolves to `pi` or `claude-code` and a model | `unsupported-harness` |
|
||||
| its role has a contract file | `no-role-contract` |
|
||||
| a Claude Code session has `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, or `ANTHROPIC_AUTH_TOKEN` with `ANTHROPIC_BASE_URL` to log in with | `no-session-credential` |
|
||||
| with `launch.account` set, its name is not the value of any of those variables the bus host sets, since usage records keep the name | `account-is-credential` |
|
||||
| with `launch.account` set, a Claude Code session's account names one of those login sets and the bus host holds all of it | `account-credential-missing` |
|
||||
| with `launch.account` set, the bus host sets no other of those variables, `ANTHROPIC_BASE_URL` included | `account-credential-ambiguous` |
|
||||
| bwrap runs here | `sandbox-unavailable` |
|
||||
| its model selects an exact entry in `launch.models` | `unsupported-harness` |
|
||||
| total, provider and exact provider/model limits have room; the PM counts too | `capacity-full` |
|
||||
@@ -348,6 +351,13 @@ is `{"ok": true, "result": {run, instance, harness, model}}` or
|
||||
| the workspace has a clone of each project, or the host can make one | `checkout-failed`, with the project id |
|
||||
| the bundle builds and the runner starts | `launch-failed` |
|
||||
|
||||
Row 126 (#1620): only a Claude Code launch spends `launch.account.name`. It
|
||||
goes to the `mayLaunch` limit checks, to the bus with the run, onto the
|
||||
run's usage records, and onto the `launch` record as `account`. A pi launch
|
||||
has account null: its records say null, no daily quota covers it, and the
|
||||
two account checks above are skipped. Both account refusals name the
|
||||
variables, never a value.
|
||||
|
||||
Every refusal is an `action.refused` event (the broker's `refuse`, or its
|
||||
own refusal for the last broker check) and a line in the launch log. Then
|
||||
the launcher reads the instance's credential status from the broker
|
||||
|
||||
@@ -243,6 +243,20 @@ export function createLauncher({
|
||||
const names = SESSION_CREDENTIALS.map((set) => set.join(" with ")).join(", ");
|
||||
throw new Refusal("no-session-credential", `the bus host has none of ${names}; the bus unit's EnvironmentFile sets one when the bus starts`);
|
||||
}
|
||||
// Row 126 (#1620): only a claude-code launch spends the named account; a pi launch has none.
|
||||
// The bus environment must hold the named set and no other credential variable, so the session
|
||||
// can't log in some other way. Messages name variables, never values.
|
||||
const account = model.harness === "claude-code" ? b.launch.account?.name ?? null : null;
|
||||
if (account !== null) {
|
||||
// The usage records keep this name (bus schema 3i), so it must never be a credential's value.
|
||||
const holds = [...new Set(SESSION_CREDENTIALS.flat())].filter((k) => env[k] === account);
|
||||
if (holds.length) throw new Refusal("account-is-credential", `launch.account.name is the value of ${holds.join(", ")}; it names the account, never its credential`);
|
||||
const named = b.launch.account.credential.split("+");
|
||||
if (!SESSION_CREDENTIALS.some((set) => set.join("+") === named.join("+")) || !named.every((k) => Boolean(env[k])))
|
||||
throw new Refusal("account-credential-missing", `account ${account} names ${named.join(" with ")}, which the bus host doesn't hold as a session login`);
|
||||
const other = [...new Set(SESSION_CREDENTIALS.flat())].filter((k) => !named.includes(k) && Boolean(env[k]));
|
||||
if (other.length) throw new Refusal("account-credential-ambiguous", `account ${account} names ${named.join(" with ")}, but the bus host also sets ${other.join(", ")}`);
|
||||
}
|
||||
const unavailable = sandboxProblem({ bwrap });
|
||||
if (unavailable) throw new Refusal("sandbox-unavailable", unavailable);
|
||||
const hit=limitHit(registry.sessions,b.id,b.launch.limits,model);
|
||||
@@ -251,7 +265,7 @@ export function createLauncher({
|
||||
// and so does a bus that can't answer (fail closed, lead decision 150).
|
||||
let may;
|
||||
try {
|
||||
may = await host.op({ op: "mayLaunch", business: b.id, instance, provider: model.provider, model: model.model });
|
||||
may = await host.op({ op: "mayLaunch", business: b.id, instance, provider: model.provider, model: model.model, account });
|
||||
} catch (e) {
|
||||
throw new Refusal("limit-check-failed", `bus: ${e.code ?? "launch-op-refused"}`);
|
||||
}
|
||||
@@ -259,11 +273,11 @@ export function createLauncher({
|
||||
if (may?.hit) { const error = new Refusal("limit-reached", JSON.stringify(may.hit)); error.limit = { check: may.check, ...may.hit }; throw error; }
|
||||
throw new Refusal("limit-check-failed", `${may?.check ?? "bus"}: ${may?.code ?? "malformed-answer"}`);
|
||||
}
|
||||
return { resolved, model };
|
||||
return { resolved, model, account };
|
||||
}
|
||||
|
||||
// Bundle, spawn, bind, register, hand over the capability.
|
||||
async function start({ instance, launchedBy, resolved, model, thread = null }) {
|
||||
async function start({ instance, launchedBy, resolved, model, account = null, thread = null }) {
|
||||
// A thread's store is checked first, so a bad id or an unsafe directory
|
||||
// launches nothing.
|
||||
let store = null;
|
||||
@@ -293,7 +307,7 @@ export function createLauncher({
|
||||
if (c.outcome === "kept") record({ event: "checkout-kept", instance, project: c.project, reason: c.reason });
|
||||
else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason });
|
||||
}
|
||||
({resolved,model}=await check(instance,{pm:instance===b.launch.by,thread}));
|
||||
({resolved,model,account}=await check(instance,{pm:instance===b.launch.by,thread}));
|
||||
const { run, runDir } = newRun(dataRoot, b.id);
|
||||
const sessionDir = store ? store.session : join(runDir, "session");
|
||||
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
|
||||
@@ -369,7 +383,7 @@ export function createLauncher({
|
||||
const outerStart = startTimeOf(child.pid);
|
||||
const runner = await runnerOf(child);
|
||||
if (!outerStart) throw new Error("the session process exited at once");
|
||||
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, pid: child.pid, startTime: outerStart, ...(thread ? { thread } : {}) });
|
||||
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, pid: child.pid, startTime: outerStart, ...(thread ? { thread } : {}) });
|
||||
bound = true;
|
||||
entry = {
|
||||
business: b.id, instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey,
|
||||
@@ -404,7 +418,7 @@ export function createLauncher({
|
||||
refusedRelease.delete(run);
|
||||
}
|
||||
});
|
||||
record({ event: "launch", instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey, pid: child.pid, runnerPid: entry.runnerPid, manifest });
|
||||
record({ event: "launch", instance, ...(thread ? { thread } : {}), run, launchedBy, harness: model.harness, model: model.model, provider: model.provider, modelKey: model.modelKey, account, pid: child.pid, runnerPid: entry.runnerPid, manifest });
|
||||
return { run, instance, harness: model.harness, model: model.model };
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] | set --file
|
||||
"reset PATH... [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
|
||||
"PATCH keys: revision, limits, models, instances, reset, assistants";
|
||||
|
||||
export const DAILY_NOTE = "Daily quotas count only this stack's own use of the account, not its use anywhere else.";
|
||||
|
||||
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): one line per token limit, its path in the words
|
||||
// `mosaic limits reset` takes. A limit no switched-on check counts reads "not enforced".
|
||||
export function formatTokens(tokens) {
|
||||
@@ -24,7 +26,9 @@ export function formatTokens(tokens) {
|
||||
if (t.status === "not-enforced") return `${head}, not enforced${t.reason ? `: ${t.reason}` : ""}`;
|
||||
return `${head}, check failed: ${t.code}${t.check ? ` (${t.check})` : ""}`;
|
||||
});
|
||||
return `token limits:\n${lines.map(l => ` ${l}`).join("\n")}\n`;
|
||||
// Row 126 (#1620): a daily quota counts only the records this stack wrote. Said once, after the list.
|
||||
const daily = tokens.some(t => t.path?.[1] === "daily") ? `${DAILY_NOTE}\n` : "";
|
||||
return `token limits:\n${lines.map(l => ` ${l}`).join("\n")}\n${daily}`;
|
||||
}
|
||||
|
||||
export function parseLimits(args) {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Row 126 (#1620, REQ-LIMIT-2): the account a launch spends. Only a claude-code launch carries
|
||||
// launch.account.name; the bus environment must hold the named login set and no other credential
|
||||
// variable, and the refusals name variables, never values. A pi launch has account null and skips
|
||||
// both checks. A stub host answers the ops; no sandbox, Docker or session process.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {createLauncher} from '../src/launcher.mjs';
|
||||
import {fixture,tmp} from './helpers.mjs';
|
||||
import {launchLogFile,SESSION_CREDENTIALS} from '../../seat/src/session.mjs';
|
||||
|
||||
const VALUE='stand-in-value-7f3a';
|
||||
const ACCOUNT={name:'jason-max',credential:'CLAUDE_CODE_OAUTH_TOKEN'};
|
||||
|
||||
// set is the credential variables the bus environment holds; every other one is removed.
|
||||
function setup(t,{harness='claude-code',account=ACCOUNT,set=['CLAUDE_CODE_OAUTH_TOKEN']}={}){
|
||||
const root=tmp(t),f=fixture(root,'acme',doc=>{
|
||||
doc.roles.coder.vars.model='claude-sonnet-5-5';doc.roles.coder.vars.harness=harness;
|
||||
if(account)doc.launch.account=account;
|
||||
return doc;
|
||||
}),ops=[];
|
||||
for(const k of SESSION_CREDENTIALS.flat())delete f.env[k];
|
||||
for(const k of set)f.env[k]=VALUE;
|
||||
const host={business:'acme',beforeClose:()=>{},op:async x=>{
|
||||
ops.push(x);
|
||||
if(x.op==='identity')return {business:'acme',role:'pm'};
|
||||
if(x.op==='mayLaunch')return {allow:true};
|
||||
if(x.op==='authorizeLaunch')throw Object.assign(Error('fixture authorization stop'),{code:'fixture-policy'});
|
||||
return {};
|
||||
}};
|
||||
const launcher=createLauncher({host,system:JSON.parse(readFileSync(f.config,'utf8')),env:f.env,bwrap:'/usr/bin/true',log:()=>{}});
|
||||
const last=()=>JSON.parse(readFileSync(launchLogFile(f.dataRoot,'acme'),'utf8').trim().split('\n').at(-1));
|
||||
const may=()=>ops.find(x=>x.op==='mayLaunch');
|
||||
return {launcher,ops,last,may};
|
||||
}
|
||||
|
||||
test('REQ-LIMIT-2 a claude-code launch asks mayLaunch with launch.account.name',async t=>{
|
||||
const {launcher,may}=setup(t);
|
||||
assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy');
|
||||
assert.equal(may().account,'jason-max');
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-2 each login set the account can name is accepted when the bus host holds exactly it',async t=>{
|
||||
for(const set of SESSION_CREDENTIALS){
|
||||
const {launcher,may}=setup(t,{account:{name:'jason-max',credential:set.join('+')},set});
|
||||
assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy',set.join('+'));
|
||||
assert.equal(may().account,'jason-max');
|
||||
}
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-2 account-credential-missing: the named set is not a login set, or the bus host lacks part of it',async t=>{
|
||||
for(const [credential,set] of [
|
||||
['ANTHROPIC_API_KEY',['CLAUDE_CODE_OAUTH_TOKEN']],
|
||||
// The host holds a login (OAuth), so no-session-credential passes; the named set lacks its URL.
|
||||
['ANTHROPIC_AUTH_TOKEN+ANTHROPIC_BASE_URL',['CLAUDE_CODE_OAUTH_TOKEN','ANTHROPIC_AUTH_TOKEN']],
|
||||
['ANTHROPIC_AUTH_TOKEN',['ANTHROPIC_AUTH_TOKEN','ANTHROPIC_BASE_URL']],
|
||||
['ANTHROPIC_BASE_URL+ANTHROPIC_AUTH_TOKEN',['ANTHROPIC_AUTH_TOKEN','ANTHROPIC_BASE_URL']],
|
||||
]){
|
||||
const {launcher,ops,last}=setup(t,{account:{name:'jason-max',credential},set});
|
||||
assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'account-credential-missing'},credential);
|
||||
const event=last();
|
||||
assert.equal(event.code,'account-credential-missing');
|
||||
assert.match(event.why,new RegExp(`^account jason-max names ${credential.split('+').join(' with ')},`));
|
||||
assert.ok(!JSON.stringify(event).includes(VALUE),'no value in the log');
|
||||
assert.equal(ops.find(x=>x.op==='mayLaunch'),undefined,credential);
|
||||
}
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-2 account-credential-ambiguous: the bus host sets a credential variable outside the named set, ANTHROPIC_BASE_URL included',async t=>{
|
||||
for(const [extra,named] of [[['ANTHROPIC_API_KEY'],'ANTHROPIC_API_KEY'],[['ANTHROPIC_BASE_URL'],'ANTHROPIC_BASE_URL'],
|
||||
[['ANTHROPIC_AUTH_TOKEN','ANTHROPIC_BASE_URL'],'ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL']]){
|
||||
const {launcher,ops,last}=setup(t,{set:['CLAUDE_CODE_OAUTH_TOKEN',...extra]});
|
||||
assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'account-credential-ambiguous'},named);
|
||||
const event=last();
|
||||
assert.equal(event.why,`account jason-max names CLAUDE_CODE_OAUTH_TOKEN, but the bus host also sets ${named}`);
|
||||
assert.ok(!JSON.stringify(event).includes(VALUE),'no value in the log');
|
||||
assert.equal(ops.find(x=>x.op==='mayLaunch'),undefined,named);
|
||||
}
|
||||
});
|
||||
|
||||
// Usage records keep the account's name, so a name that is a credential variable's value refuses,
|
||||
// whichever variable holds it, before any message that names the account.
|
||||
test('REQ-LIMIT-2 account-is-credential: launch.account.name is the value of a credential variable the bus host sets',async t=>{
|
||||
for(const set of [['CLAUDE_CODE_OAUTH_TOKEN'],['CLAUDE_CODE_OAUTH_TOKEN','ANTHROPIC_API_KEY']]){
|
||||
const {launcher,ops,last}=setup(t,{account:{name:VALUE,credential:'CLAUDE_CODE_OAUTH_TOKEN'},set});
|
||||
assert.deepEqual(await launcher.fromSession('fixture-cap','coder'),{ok:false,error:'account-is-credential'},set.join());
|
||||
const event=last();
|
||||
assert.equal(event.code,'account-is-credential');
|
||||
assert.match(event.why,/^launch\.account\.name is the value of [A-Z_, ]+; it names the account, never its credential$/);
|
||||
for(const k of set)assert.ok(event.why.includes(k),k);
|
||||
assert.ok(!JSON.stringify(event).includes(VALUE),'no value in the log');
|
||||
assert.equal(ops.find(x=>x.op==='mayLaunch'),undefined,set.join());
|
||||
}
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-2 a pi launch carries account null and skips both account checks',async t=>{
|
||||
// No credential variable at all, and two at once: neither refuses a pi launch.
|
||||
for(const set of [[],['CLAUDE_CODE_OAUTH_TOKEN','ANTHROPIC_API_KEY']]){
|
||||
const {launcher,may}=setup(t,{harness:'pi',set});
|
||||
assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy',set.join());
|
||||
assert.equal(may().account,null);
|
||||
}
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-2 with no launch.account a claude-code launch carries null and the environment is not checked for ambiguity',async t=>{
|
||||
const {launcher,may}=setup(t,{account:null,set:['CLAUDE_CODE_OAUTH_TOKEN','ANTHROPIC_API_KEY']});
|
||||
assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy');
|
||||
assert.equal(may().account,null);
|
||||
});
|
||||
@@ -26,10 +26,10 @@ function setup(t,answer){
|
||||
return {launcher,ops,last};
|
||||
}
|
||||
|
||||
test('REQ-LIMIT-4 the launcher asks mayLaunch with the instance, provider and model, and goes on to authorization on an allow',async t=>{
|
||||
test('REQ-LIMIT-4 the launcher asks mayLaunch with the instance, provider, model and account (row 126), and goes on to authorization on an allow',async t=>{
|
||||
const {launcher,ops}=setup(t,{allow:true});
|
||||
assert.equal((await launcher.fromSession('fixture-cap','coder')).error,'fixture-policy');
|
||||
assert.deepEqual(ops.find(x=>x.op==='mayLaunch'),{op:'mayLaunch',business:'acme',instance:'coder',provider:'zai',model:'claude-sonnet-5-5'});
|
||||
assert.deepEqual(ops.find(x=>x.op==='mayLaunch'),{op:'mayLaunch',business:'acme',instance:'coder',provider:'zai',model:'claude-sonnet-5-5',account:null});
|
||||
assert.ok(ops.findIndex(x=>x.op==='mayLaunch')<ops.findIndex(x=>x.op==='authorizeLaunch'));
|
||||
});
|
||||
|
||||
|
||||
@@ -171,6 +171,8 @@ test("bus start --pm: the launcher instance (the chief of staff) runs under its
|
||||
const [launched] = log(f).filter((e) => e.event === "launch");
|
||||
assert.equal(launched.run, chief.run);
|
||||
assert.equal(launched.manifest, join(runDir, "bundle", "manifest.json"));
|
||||
// Row 126: a pi launch spends no account, so the record says null.
|
||||
assert.equal(launched.account, null);
|
||||
assert.equal(manifest.model, "claude-opus-5-5");
|
||||
|
||||
// A second PM is refused before anything spawns.
|
||||
@@ -708,6 +710,8 @@ test("a real Claude Code coder's shell can't read a stand-in secret or anything
|
||||
const given = prepare(t, (doc) => {
|
||||
doc.roles.coder.vars.harness = "claude-code";
|
||||
doc.roles.coder.vars["limits.tools"] = ["bash"];
|
||||
// Row 126: the claude-code launch spends this account, which names the proxy login set.
|
||||
doc.launch.account = { name: "proxy-login", credential: "ANTHROPIC_AUTH_TOKEN+ANTHROPIC_BASE_URL" };
|
||||
return doc;
|
||||
});
|
||||
delete given.f.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
@@ -743,6 +747,7 @@ test("a real Claude Code coder's shell can't read a stand-in secret or anything
|
||||
assert.equal(JSON.parse(launched.body).instance, "coder", launched.body);
|
||||
const coder = readSessions(f.dataRoot).find((s) => s.instance === "coder");
|
||||
assert.equal(coder.harness, "claude-code");
|
||||
assert.deepEqual(log(f).filter((e) => e.event === "launch").map((e) => [e.instance, e.account]), [["chief", null], ["coder", "proxy-login"]]);
|
||||
|
||||
const hello = await client.call("message.send", { to: "coder", class: "REQUEST", body: "probe" });
|
||||
const reply = await until(async () => (await client.call("message.receive")).find((m) => m.in_reply_to === hello.id), 150000);
|
||||
@@ -758,6 +763,13 @@ test("a real Claude Code coder's shell can't read a stand-in secret or anything
|
||||
// The session logged in with the stand-in proxy key, against the mock.
|
||||
assert.ok(api.requests.length > 0);
|
||||
assert.ok(api.requests.some((r) => r.tools.includes("Bash")), api.requests.map((r) => r.tools.join(",")).join(" | "));
|
||||
// Row 126: the bus got the account with the run, so the coder's turn records it (no usage
|
||||
// source here, so the turn is unknown); the pi chief's turns record null.
|
||||
const accounts = await until(async () => {
|
||||
const seen = (await client.call("usage")).byAccount.map((r) => r.account);
|
||||
return seen.includes("proxy-login") && seen;
|
||||
});
|
||||
assert.deepEqual([...accounts].sort(), [null, "proxy-login"]);
|
||||
});
|
||||
|
||||
for (const exited of [false, true]) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {main} from '../src/cli.mjs';
|
||||
import {formatTokens} from '../src/limits.mjs';
|
||||
import {formatTokens,DAILY_NOTE} from '../src/limits.mjs';
|
||||
import {io,tmp} from './helpers.mjs';
|
||||
|
||||
const SINCE='2026-10-18T07:00:00.000Z';
|
||||
@@ -26,6 +26,15 @@ test('REQ-LIMIT-3 REQ-LIMIT-4 formatTokens shows each limit counted, not enforce
|
||||
for(const none of [[],undefined])assert.equal(formatTokens(none),'token limits: none\n');
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-2 row 126: any daily quota row adds one line after the list saying the count is this stack\'s own use',()=>{
|
||||
const daily={path:['tokens','daily','jason-max'],limit:40,since:SINCE,status:'counted',check:'daily-quota',count:3,kinds:{input:1,output:1,cacheWrite:0,cacheRead:1},unknownTurns:0};
|
||||
const out=formatTokens([TOKENS[0],daily,{...daily,path:['tokens','daily','other'],status:'not-enforced',reason:null}]);
|
||||
assert.ok(out.endsWith(` tokens daily other: limit 40 since ${SINCE}, not enforced\n${DAILY_NOTE}\n`),out);
|
||||
assert.equal(out.split(DAILY_NOTE).length,2,'said once');
|
||||
assert.equal(DAILY_NOTE,"Daily quotas count only this stack's own use of the account, not its use anywhere else.");
|
||||
assert.ok(!formatTokens(TOKENS).includes(DAILY_NOTE));
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-3 the view prints the limits then the token lines; --json keeps tokens in the result',async t=>{
|
||||
const root=tmp(t),deps={system:{dataRoot:root},transport:()=>()=>({revision:'b'.repeat(64),limits:{total:3},tokens:TOKENS})};
|
||||
const x=io();await main(['limits','--business','acme'],x,deps);
|
||||
|
||||
@@ -631,7 +631,7 @@ test("REQ-LIMIT-4 a runner at its limit stops at the end of its turn: RESULT and
|
||||
assert.equal(reply.class, "RESULT");
|
||||
assert.equal(reply.in_reply_to, sent.id);
|
||||
assert.ok(ctx.store.get("SELECT 1 FROM deliveries WHERE message=? AND op='read'", sent.id));
|
||||
assert.deepEqual(asked, [{ business: "demo", subject: { instance: "coder", provider: null, model: null } }]);
|
||||
assert.deepEqual(asked, [{ business: "demo", subject: { instance: "coder", provider: null, model: null, account: null } }]);
|
||||
const row = ctx.store.get("SELECT op, by, reason, holder_run FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1");
|
||||
assert.deepEqual({ ...row }, { op: "revoke", by: "limit-check", holder_run: "coder-run",
|
||||
reason: "limit: token-limit tokens.instances.coder limit 100 count 120" });
|
||||
|
||||
@@ -193,6 +193,22 @@ sets `since` when a limit is added; a changed limit keeps it, a caller's
|
||||
record's account; while no record names one, a key limit reports as not
|
||||
enforced. See `limit-check/token-limit/README.md`.
|
||||
|
||||
### daily-quota
|
||||
|
||||
`limit-check/daily-quota` (row 126) enforces `launch.limits.tokens.daily`,
|
||||
one quota per account, each with the `reset` time of day and the IANA
|
||||
`timeZone` its day starts in:
|
||||
|
||||
```json
|
||||
"daily": {"jason-max": {"limit": 40000000, "since": "…", "reset": "05:00", "timeZone": "America/Chicago"}}
|
||||
```
|
||||
|
||||
A count is the sum of all four kinds over the account's known records since
|
||||
the later of the day's start and `since`. Only a Claude Code launch carries
|
||||
an account; a pi launch's is null, so no quota covers it. The count is this
|
||||
stack's own use of the account, not its use anywhere else, and `mosaic
|
||||
limits` and Settings say so. See `limit-check/daily-quota/README.md`.
|
||||
|
||||
## Human project/thread channels
|
||||
|
||||
`channel` modules carry project/thread operations between a human surface and
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Daily quota check
|
||||
|
||||
Row 126 (#1620), REQ-LIMIT-2 and REQ-LIMIT-4, limit-check interface 2. Enable
|
||||
with `"plugins": { "limit-check": ["daily-quota"] }` in the organization file,
|
||||
keeping its other plug-in kinds. Human changes use `mosaic plugins set` and the
|
||||
current revision.
|
||||
|
||||
It enforces `launch.limits.tokens.daily`, one quota per account:
|
||||
|
||||
```json
|
||||
"daily": {"jason-max": {"limit": 40000000, "since": "…", "reset": "05:00", "timeZone": "America/Chicago"}}
|
||||
```
|
||||
|
||||
- The account is `launch.account.name`. Only a claude-code launch carries it;
|
||||
a pi launch has account null, so no daily quota covers it.
|
||||
- A day starts at `reset`, wall-clock time in `timeZone`. A date's reset is
|
||||
the earliest instant on that local date at or after `reset`: when the
|
||||
clocks skip it, the day starts at the first instant after the gap (02:30
|
||||
on 2026-03-08 in America/Chicago is 03:00 CDT); when they repeat it, the
|
||||
first one starts the day and the second starts nothing. A day with a DST
|
||||
change is 23 or 25 hours long.
|
||||
- A count is `input + output + cacheWrite + cacheRead` summed over the known
|
||||
per-turn records whose `account` is the quota's and whose `at` is at or
|
||||
after the later of the day's start and `since`. An unknown turn adds
|
||||
nothing; `unknownTurns` counts the distinct unknown turns apart, so a count
|
||||
is a floor, not proof.
|
||||
- `mayLaunch` and `mustStop` answer the first quota for the subject's
|
||||
account whose count is at or over it. A limit of 0 refuses every launch on
|
||||
the account.
|
||||
- `report` gives one counted entry per quota for the day that holds `now`.
|
||||
Its `since` is the quota's own `since`, not the day's start.
|
||||
- The count is this stack's own use of the account. Use of the same account
|
||||
anywhere else isn't in the records, and the CLI and Settings say so.
|
||||
- Counts are summed as BigInt; one past `Number.MAX_SAFE_INTEGER` is reported
|
||||
as a digit string.
|
||||
|
||||
The module is pure: it reads no file and no store. A bad `since`, record `at`
|
||||
or `now` throws `bad-time`, a bad amount `bad-record`, records that aren't a
|
||||
list `bad-records`, and a quota without a whole `limit`, an `HH:MM` `reset`
|
||||
or a zone `Intl` accepts `bad-leaf`; core turns any throw into a refusal, a
|
||||
stop, or `check failed` (fail closed).
|
||||
@@ -0,0 +1,115 @@
|
||||
// Row 126 (#1620, REQ-LIMIT-2, REQ-LIMIT-4): a daily token quota per account. Pure: core passes
|
||||
// the business's launch limits, its per-turn usage records and the time, and nothing here reads a
|
||||
// file or a store. Each `launch.limits.tokens.daily.<account>` leaf is {limit, since, reset,
|
||||
// timeZone}. A count is every kind summed over the account's known records since the later of the
|
||||
// day's reset and the leaf's `since`; an unknown turn adds nothing and is counted on its own.
|
||||
// It copies token-limit's tally helpers rather than importing a sibling check, so each loads alone.
|
||||
export const interfaceVersion = 2;
|
||||
|
||||
const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
|
||||
const SAFE = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
const DAY = 86400000;
|
||||
const RESET = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
const refuse = code => { throw Object.assign(new Error(code), { code }); };
|
||||
const plain = n => n <= SAFE ? Number(n) : String(n);
|
||||
const time = value => {
|
||||
const t = typeof value === 'string' ? Date.parse(value) : NaN;
|
||||
if (Number.isNaN(t)) refuse('bad-time');
|
||||
return t;
|
||||
};
|
||||
const amount = value => Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : refuse('bad-record');
|
||||
|
||||
// The wall-clock reading of instant t in a zone, as the UTC milliseconds of the same reading.
|
||||
function reader(timeZone) {
|
||||
let format;
|
||||
try {
|
||||
format = new Intl.DateTimeFormat('en-US', { timeZone, hourCycle: 'h23', year: 'numeric', month: 'numeric',
|
||||
day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric' });
|
||||
} catch { refuse('bad-leaf'); }
|
||||
return t => {
|
||||
const p = Object.fromEntries(format.formatToParts(new Date(t)).map(x => [x.type, Number(x.value)]));
|
||||
return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) + (t % 1000 + 1000) % 1000;
|
||||
};
|
||||
}
|
||||
|
||||
// Date D's reset: the earliest instant whose local date is D and whose local time is at or after
|
||||
// `reset`. D is given as the UTC midnight of its date; wall is D's reset as a local reading.
|
||||
// Each offset in force a day either side gives a candidate. A candidate that reads back as the
|
||||
// reset is one; the earliest wins (a repeated hour). If neither does, the reset is in a skipped
|
||||
// hour, and the first instant reading at or after it lies between the two candidates.
|
||||
function resetOf(local, date, minutes) {
|
||||
const wall = date + minutes * 60000;
|
||||
const candidates = [wall - (local(wall - DAY) - (wall - DAY)), wall - (local(wall + DAY) - (wall + DAY))].sort((a, b) => a - b);
|
||||
const exact = candidates.find(c => local(c) === wall);
|
||||
if (exact !== undefined) return exact;
|
||||
let [lo, hi] = candidates;
|
||||
while (hi - lo > 1) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
if (local(mid) >= wall) hi = mid; else lo = mid;
|
||||
}
|
||||
return hi;
|
||||
}
|
||||
|
||||
// The start of the day window that holds now.
|
||||
function windowStart(entry, now) {
|
||||
const local = reader(entry.timeZone);
|
||||
const [, hh, mm] = RESET.exec(entry.reset);
|
||||
const minutes = Number(hh) * 60 + Number(mm);
|
||||
const today = Math.floor(local(now) / DAY) * DAY;
|
||||
const start = resetOf(local, today, minutes);
|
||||
return start <= now ? start : resetOf(local, today - DAY, minutes);
|
||||
}
|
||||
|
||||
function leaves(limits) {
|
||||
const daily = limits?.tokens?.daily ?? {};
|
||||
return Object.entries(daily).map(([account, entry]) => {
|
||||
if (!Number.isSafeInteger(entry?.limit) || entry.limit < 0 || typeof entry.reset !== 'string' || !RESET.test(entry.reset) ||
|
||||
typeof entry.timeZone !== 'string') refuse('bad-leaf');
|
||||
return { path: ['tokens', 'daily', account], entry, account };
|
||||
});
|
||||
}
|
||||
|
||||
function tally(leaf, records, now) {
|
||||
const from = Math.max(windowStart(leaf.entry, now), time(leaf.entry.since));
|
||||
const kinds = Object.fromEntries(KINDS.map(k => [k, 0n]));
|
||||
const unknown = new Set();
|
||||
for (const r of records) {
|
||||
if (r.account !== leaf.account || time(r.at) < from) continue;
|
||||
if (r.status === 'known') for (const k of KINDS) kinds[k] += amount(r[k]);
|
||||
else unknown.add(JSON.stringify([r.run, r.turn]));
|
||||
}
|
||||
return { count: KINDS.reduce((sum, k) => sum + kinds[k], 0n), kinds, unknownTurns: unknown.size };
|
||||
}
|
||||
|
||||
// The first quota for the subject's account whose count is at or over it.
|
||||
function hitFor({ limits, subject, records, now }) {
|
||||
if (!Array.isArray(records)) refuse('bad-records');
|
||||
const at = time(now);
|
||||
for (const leaf of leaves(limits)) {
|
||||
if (subject?.account !== leaf.account) continue;
|
||||
const { count, unknownTurns } = tally(leaf, records, at);
|
||||
if (count >= BigInt(leaf.entry.limit)) return { path: leaf.path, limit: leaf.entry.limit, count: plain(count), unknownTurns };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function mayLaunch(args) {
|
||||
const hit = hitFor(args);
|
||||
return hit ? { allow: false, hit } : { allow: true };
|
||||
}
|
||||
|
||||
export function mustStop(args) {
|
||||
const hit = hitFor(args);
|
||||
return hit ? { stop: true, hit } : { stop: false };
|
||||
}
|
||||
|
||||
// Every daily quota with today's count. `since` is the leaf's own, as core requires.
|
||||
export function report({ limits, records, now }) {
|
||||
if (!Array.isArray(records)) refuse('bad-records');
|
||||
const at = time(now);
|
||||
return leaves(limits).map(leaf => {
|
||||
const { path, entry: { limit, since } } = leaf;
|
||||
const { count, kinds, unknownTurns } = tally(leaf, records, at);
|
||||
return { path, limit, since, count: plain(count), kinds: Object.fromEntries(KINDS.map(k => [k, plain(kinds[k])])), unknownTurns };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// Row 126 (#1620, REQ-LIMIT-2, REQ-LIMIT-4): the daily-quota check on its own. It is pure, so
|
||||
// these tests pass it limits, records and a time and read its answers; no loader, store or file.
|
||||
// America/Chicago in 2026: CDT (UTC-5) until 2026-11-01 07:00Z, CST (UTC-6) from then, and CDT
|
||||
// again from 2026-03-08 08:00Z.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as check from "../limit-check/daily-quota/index.mjs";
|
||||
|
||||
const SINCE = "2026-01-01T00:00:00.000Z";
|
||||
const NOW = "2026-10-18T12:00:00.000Z";
|
||||
const ZONE = "America/Chicago";
|
||||
const PATH = ["tokens", "daily", "max"];
|
||||
const subject = (over = {}) => ({ instance: "coder", provider: "anthropic", model: "opus", account: "max", ...over });
|
||||
let seq = 0;
|
||||
const known = (at, over = {}, counts = [10, 20, 30, 40]) => {
|
||||
const [input, output, cacheWrite, cacheRead] = counts;
|
||||
return { at, run: "r1", turn: `t${++seq}`, source: "pi-session", instance: "coder", provider: "anthropic", model: "opus",
|
||||
account: "max", status: "known", input, output, cacheWrite, cacheRead, ...over };
|
||||
};
|
||||
const unknown = (at, over = {}) => ({ ...known(at, over), status: "unknown", input: null, output: null, cacheWrite: null, cacheRead: null });
|
||||
const quota = (over = {}) => ({ limit: 1000, since: SINCE, reset: "05:00", timeZone: ZONE, ...over });
|
||||
const args = (daily, records, over = {}) => ({ business: "demo", limits: { tokens: { daily } }, subject: subject(), records, now: NOW, ...over });
|
||||
const count = (daily, records, now) => check.report(args(daily, records, { now }))[0].count;
|
||||
|
||||
test("REQ-LIMIT-2 daily-quota exports interface version 2 with mayLaunch, mustStop and report", () => {
|
||||
assert.equal(check.interfaceVersion, 2);
|
||||
for (const name of ["mayLaunch", "mustStop", "report"]) assert.equal(typeof check[name], "function");
|
||||
});
|
||||
|
||||
test("REQ-LIMIT-2 the count sums every kind of the account's known records since the later of the day start and since", () => {
|
||||
const records = [known("2026-10-18T11:00:00.000Z", {}, [1, 2, 3, 4]), known("2026-10-18T11:30:00.000Z", { account: "other" }),
|
||||
known("2026-10-18T11:40:00.000Z", { account: null }), known("2026-10-18T11:50:00.000Z", {}, [5, 0, 0, 5])];
|
||||
assert.deepEqual(check.report(args({ max: quota() }, records)), [{ path: PATH, limit: 1000, since: SINCE, count: 20,
|
||||
kinds: { input: 6, output: 2, cacheWrite: 3, cacheRead: 9 }, unknownTurns: 0 }]);
|
||||
// A since after the day's start moves the count's start to it; report keeps the quota's own since.
|
||||
const late = "2026-10-18T11:45:00.000Z";
|
||||
assert.deepEqual(check.report(args({ max: quota({ since: late }) }, records))[0],
|
||||
{ path: PATH, limit: 1000, since: late, count: 10, kinds: { input: 5, output: 0, cacheWrite: 0, cacheRead: 5 }, unknownTurns: 0 });
|
||||
// Another account's quota covers neither these records nor this subject's launches.
|
||||
const full = [known("2026-10-18T11:00:00.000Z"), known("2026-10-18T11:10:00.000Z")];
|
||||
assert.deepEqual(check.mayLaunch(args({ other: quota({ limit: 150 }) }, full)), { allow: true });
|
||||
assert.equal(check.report(args({ other: quota({ limit: 150 }) }, full))[0].count, 0);
|
||||
assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 150 }) }, full, { subject: subject({ account: "other" }) })), { allow: true });
|
||||
});
|
||||
|
||||
test("REQ-LIMIT-2 a launch with no account (pi) is covered by no daily quota", () => {
|
||||
const records = [known("2026-10-18T11:00:00.000Z", { account: null })];
|
||||
const a = args({ max: quota({ limit: 0 }) }, records, { subject: subject({ account: null }) });
|
||||
assert.deepEqual(check.mayLaunch(a), { allow: true });
|
||||
assert.deepEqual(check.mustStop(a), { stop: false });
|
||||
assert.equal(check.report(a)[0].count, 0);
|
||||
});
|
||||
|
||||
test("REQ-LIMIT-4 an unknown turn adds nothing and is counted apart", () => {
|
||||
const u = unknown("2026-10-18T11:00:00.000Z", { run: "r2", turn: "u1" });
|
||||
const records = [known("2026-10-18T11:00:00.000Z", {}, [1, 1, 1, 1]), u, { ...u, source: "other-source" },
|
||||
unknown("2026-10-18T11:10:00.000Z", { run: "r2", turn: "u2" }), unknown("2026-10-18T09:00:00.000Z", { run: "r2", turn: "u0" })];
|
||||
const [entry] = check.report(args({ max: quota({ limit: 5 }) }, records));
|
||||
assert.equal(entry.count, 4);
|
||||
assert.equal(entry.unknownTurns, 2);
|
||||
assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 5 }) }, records)), { allow: true });
|
||||
});
|
||||
|
||||
test("REQ-LIMIT-2 the day turns at the reset in its zone, not in UTC, and before it the day is yesterday's", () => {
|
||||
const daily = { max: quota() }; // 05:00 America/Chicago is 10:00Z in October
|
||||
const records = [known("2026-10-17T11:00:00.000Z", {}, [1, 0, 0, 0]), known("2026-10-18T09:59:59.999Z", {}, [10, 0, 0, 0]),
|
||||
known("2026-10-18T10:00:00.000Z", {}, [100, 0, 0, 0])];
|
||||
assert.equal(count(daily, records, "2026-10-18T12:00:00.000Z"), 100);
|
||||
assert.equal(count(daily, records, "2026-10-18T10:00:00.000Z"), 100);
|
||||
// At 04:59 CDT the day that started at 05:00 yesterday still runs.
|
||||
assert.equal(count(daily, records.slice(0, 2), "2026-10-18T09:59:59.999Z"), 11);
|
||||
assert.equal(count(daily, records.slice(0, 2), "2026-10-18T09:00:00.000Z"), 11);
|
||||
});
|
||||
|
||||
test("REQ-LIMIT-2 the spring DST change makes the day after a 00:00 reset 23 hours long", () => {
|
||||
const daily = { max: quota({ reset: "00:00" }) };
|
||||
// 2026-03-08 00:00 CST is 06:00Z; 2026-03-09 00:00 CDT is 05:00Z.
|
||||
const records = [known("2026-03-08T05:30:00.000Z", {}, [1, 0, 0, 0]), known("2026-03-08T06:00:00.000Z", {}, [10, 0, 0, 0]),
|
||||
known("2026-03-09T04:59:59.999Z", {}, [100, 0, 0, 0]), known("2026-03-09T05:00:00.000Z", {}, [1000, 0, 0, 0])];
|
||||
assert.equal(count(daily, records.slice(0, 3), "2026-03-09T04:59:59.999Z"), 110);
|
||||
assert.equal(count(daily, records, "2026-03-09T05:00:00.000Z"), 1000);
|
||||
});
|
||||
|
||||
test("REQ-LIMIT-2 the fall DST change makes the day after a 00:00 reset 25 hours long", () => {
|
||||
const daily = { max: quota({ reset: "00:00" }) };
|
||||
// 2026-11-01 00:00 CDT is 05:00Z; 2026-11-02 00:00 CST is 06:00Z.
|
||||
const records = [known("2026-11-01T04:59:59.999Z", {}, [1, 0, 0, 0]), known("2026-11-01T05:30:00.000Z", {}, [10, 0, 0, 0]),
|
||||
known("2026-11-02T05:30:00.000Z", {}, [100, 0, 0, 0]), known("2026-11-02T06:00:00.000Z", {}, [1000, 0, 0, 0])];
|
||||
assert.equal(count(daily, records.slice(0, 3), "2026-11-02T05:59:59.999Z"), 110);
|
||||
assert.equal(count(daily, records, "2026-11-02T06:00:00.000Z"), 1000);
|
||||
});
|
||||
|
||||
test("REQ-LIMIT-2 a reset the clocks skip starts the day at the first instant after the gap", () => {
|
||||
const daily = { max: quota({ reset: "02:30" }) };
|
||||
// 02:30 doesn't happen on 2026-03-08: 01:59:59 CST (07:59:59Z) is followed by 03:00 CDT (08:00Z).
|
||||
const records = [known("2026-03-07T09:00:00.000Z", {}, [1, 0, 0, 0]), known("2026-03-08T07:59:59.999Z", {}, [10, 0, 0, 0]),
|
||||
known("2026-03-08T08:00:00.000Z", {}, [100, 0, 0, 0])];
|
||||
assert.equal(count(daily, records, "2026-03-08T08:15:00.000Z"), 100);
|
||||
assert.equal(count(daily, records, "2026-03-08T08:00:00.000Z"), 100);
|
||||
// Before the gap ends, the day that started at 02:30 CST on 03-07 (08:30Z) still runs.
|
||||
assert.equal(count(daily, records.slice(0, 2), "2026-03-08T07:59:59.999Z"), 11);
|
||||
});
|
||||
|
||||
test("REQ-LIMIT-2 a reset the clocks repeat starts one day, at the first one", () => {
|
||||
const daily = { max: quota({ reset: "01:30" }) };
|
||||
// 01:30 happens twice on 2026-11-01: CDT at 06:30Z and CST at 07:30Z.
|
||||
const records = [known("2026-11-01T06:29:59.999Z", {}, [1, 0, 0, 0]), known("2026-11-01T06:45:00.000Z", {}, [10, 0, 0, 0]),
|
||||
known("2026-11-01T07:45:00.000Z", {}, [100, 0, 0, 0])];
|
||||
assert.equal(count(daily, records, "2026-11-01T07:50:00.000Z"), 110);
|
||||
assert.equal(count(daily, records.slice(0, 2), "2026-11-01T06:50:00.000Z"), 10);
|
||||
// Before the first 01:30, the day that started at 01:30 CDT on 10-31 (06:30Z) still runs.
|
||||
assert.equal(count(daily, records.slice(0, 1), "2026-11-01T06:29:59.999Z"), 1);
|
||||
});
|
||||
|
||||
test("REQ-LIMIT-2 a launch is refused at the quota, a turn that reaches it stops, and the next day allows again", () => {
|
||||
const records = [known("2026-10-18T11:00:00.000Z"), known("2026-10-18T11:10:00.000Z")]; // 200 tokens
|
||||
const hit = { path: PATH, limit: 200, count: 200, unknownTurns: 0 };
|
||||
assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 200 }) }, records)), { allow: false, hit });
|
||||
assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 201 }) }, records)), { allow: true });
|
||||
// mustStop answers on the records the turn's usage end added.
|
||||
assert.deepEqual(check.mustStop(args({ max: quota({ limit: 200 }) }, records.slice(0, 1))), { stop: false });
|
||||
assert.deepEqual(check.mustStop(args({ max: quota({ limit: 200 }) }, records)), { stop: true, hit });
|
||||
// The next reset, 05:00 CDT on 10-19 (10:00Z), starts a day with nothing counted.
|
||||
const next = { now: "2026-10-19T10:00:00.000Z" };
|
||||
assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 200 }) }, records, { now: "2026-10-19T09:59:59.999Z" })), { allow: false, hit });
|
||||
assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 200 }) }, records, next)), { allow: true });
|
||||
assert.deepEqual(check.mustStop(args({ max: quota({ limit: 200 }) }, records, next)), { stop: false });
|
||||
// A limit of 0 refuses with no records.
|
||||
assert.deepEqual(check.mayLaunch(args({ max: quota({ limit: 0 }) }, [])),
|
||||
{ allow: false, hit: { path: PATH, limit: 0, count: 0, unknownTurns: 0 } });
|
||||
});
|
||||
|
||||
test("REQ-LIMIT-2 counts past the safe integer range come back as digit strings", () => {
|
||||
const big = Number.MAX_SAFE_INTEGER;
|
||||
const records = [known("2026-10-18T11:00:00.000Z", {}, [big, big, 0, 0])];
|
||||
const [entry] = check.report(args({ max: quota({ limit: 1 }) }, records));
|
||||
assert.equal(entry.count, (2n * BigInt(big)).toString());
|
||||
assert.equal(entry.kinds.input, big);
|
||||
assert.equal(check.mustStop(args({ max: quota({ limit: 1 }) }, records)).hit.count, (2n * BigInt(big)).toString());
|
||||
});
|
||||
|
||||
test("REQ-LIMIT-4 bad leaves, records or times throw a code, and the check reads nothing but its arguments", () => {
|
||||
const ok = [known("2026-10-18T11:00:00.000Z")];
|
||||
const leaves = [{ limit: -1 }, { limit: 1.5 }, { limit: "1" }, { reset: "5:00" }, { reset: "24:00" }, { reset: "05:60" },
|
||||
{ reset: undefined }, { timeZone: "Mars/Phobos" }, { timeZone: undefined }, { timeZone: 5 }];
|
||||
for (const fn of ["mayLaunch", "mustStop", "report"]) {
|
||||
for (const over of leaves) {
|
||||
assert.throws(() => check[fn](args({ max: quota(over) }, ok)), { code: "bad-leaf" }, `${fn} ${JSON.stringify(over)}`);
|
||||
}
|
||||
assert.throws(() => check[fn](args({ max: null }, ok)), { code: "bad-leaf" }, fn);
|
||||
assert.throws(() => check[fn](args({ max: quota({ since: "never" }) }, ok)), { code: "bad-time" }, fn);
|
||||
assert.throws(() => check[fn](args({ max: quota() }, ok, { now: "soon" })), { code: "bad-time" }, fn);
|
||||
assert.throws(() => check[fn](args({ max: quota() }, null)), { code: "bad-records" }, fn);
|
||||
assert.throws(() => check[fn](args({ max: quota() }, [known("yesterday")])), { code: "bad-time" }, fn);
|
||||
assert.throws(() => check[fn](args({ max: quota() }, [known("2026-10-18T11:00:00.000Z", {}, [-1, 0, 0, 0])])), { code: "bad-record" }, fn);
|
||||
}
|
||||
const input = args({ max: quota() }, ok);
|
||||
const copy = structuredClone(input);
|
||||
check.mayLaunch(input); check.mustStop(input); check.report(input);
|
||||
assert.deepEqual(input, copy);
|
||||
});
|
||||
|
||||
test("REQ-LIMIT-2 no daily quotas: every launch is allowed and the report is empty", () => {
|
||||
const records = [known("2026-10-18T11:00:00.000Z")];
|
||||
for (const l of [{}, { tokens: {} }, { tokens: { daily: {} } }, { tokens: { instances: { coder: { limit: 0, since: SINCE } } } }]) {
|
||||
const a = { business: "demo", limits: l, subject: subject(), records, now: NOW };
|
||||
assert.deepEqual(check.mayLaunch(a), { allow: true });
|
||||
assert.deepEqual(check.mustStop(a), { stop: false });
|
||||
assert.deepEqual(check.report(a), []);
|
||||
}
|
||||
});
|
||||
@@ -141,3 +141,11 @@ test("REQ-LIMIT-3 no token limits: every launch is allowed and the report is emp
|
||||
assert.deepEqual(check.report(a), []);
|
||||
}
|
||||
});
|
||||
|
||||
test("REQ-LIMIT-3 token-limit ignores tokens.daily, which the daily-quota check enforces (row 126)", () => {
|
||||
const daily = { k1: { limit: 0, since: SINCE, reset: "05:00", timeZone: "America/Chicago" } };
|
||||
const a = { business: "demo", limits: { tokens: { daily } }, subject: subject({ account: "k1" }), records: [known({ account: "k1" })], now: NOW };
|
||||
assert.deepEqual(check.mayLaunch(a), { allow: true });
|
||||
assert.deepEqual(check.mustStop(a), { stop: false });
|
||||
assert.deepEqual(check.report(a), []);
|
||||
});
|
||||
|
||||
@@ -592,7 +592,8 @@
|
||||
catch{settingsReceipt='Outcome unknown. Read current settings before trying again. Draft kept.';}finally{settingsSaving=false;if(!out())render(false);}
|
||||
});
|
||||
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): each token limit as the bus's checks report it. A
|
||||
// limit no switched-on check counts reads "not enforced". Reset counts it again from now.
|
||||
// limit no switched-on check counts reads "not enforced". Reset counts it again from now. Row 126
|
||||
// (#1620): any daily quota row adds one line saying the count is this stack's own use only.
|
||||
let resetReceipt='';
|
||||
function tokenLimits(rows, refused) {
|
||||
const title = '<h2 id="token-limits-title">Token limits</h2>';
|
||||
@@ -604,7 +605,7 @@
|
||||
: t.status === 'not-enforced' ? `not enforced${t.reason ? `: ${txt(t.reason)}` : ''}`
|
||||
: `check failed: ${txt(t.code)}${t.check ? ` (${txt(t.check)})` : ''}`;
|
||||
const body = rows.map(t => `<tr><th scope="row">${txt((t.path || []).join(' / '))}</th><td>${txt(t.limit)}</td><td>${txt(t.since)}</td><td>${state(t)}</td><td><button type="button" data-limit-reset="${esc(JSON.stringify(t.path || []))}"${can ? '' : ' disabled'}>Reset</button></td></tr>`).join('');
|
||||
return `<section class="panel" aria-labelledby="token-limits-title">${title}<p>A count is every token kind since the limit was set or reset. Unknown turns add nothing to it.</p><div class="table-wrap"><table id="token-limits-table"><thead><tr><th scope="col">Subject</th><th scope="col">Limit</th><th scope="col">Since</th><th scope="col">Count</th><th scope="col"><span class="sr-only">Reset</span></th></tr></thead><tbody>${body}</tbody></table></div><p class="small muted">${canSend ? 'A reset is logged with your identity.' : 'Resetting requires signed-in send mode.'}</p><p role="status" id="token-limits-receipt">${txt(resetReceipt)}</p></section>`;
|
||||
return `<section class="panel" aria-labelledby="token-limits-title">${title}<p>A count is every token kind since the limit was set or reset. Unknown turns add nothing to it.</p><div class="table-wrap"><table id="token-limits-table"><thead><tr><th scope="col">Subject</th><th scope="col">Limit</th><th scope="col">Since</th><th scope="col">Count</th><th scope="col"><span class="sr-only">Reset</span></th></tr></thead><tbody>${body}</tbody></table></div>${rows.some(t => t.path?.[1] === 'daily') ? '<p class="small muted" id="token-limits-daily">Daily quotas count only this stack\'s own use of the account, not its use anywhere else.</p>' : ''}<p class="small muted">${canSend ? 'A reset is logged with your identity.' : 'Resetting requires signed-in send mode.'}</p><p role="status" id="token-limits-receipt">${txt(resetReceipt)}</p></section>`;
|
||||
}
|
||||
document.addEventListener('click',async e=>{
|
||||
const path=e.target.dataset?.limitReset;if(path===undefined||!canSend||out()||settingsSaving)return;
|
||||
|
||||
@@ -37,6 +37,7 @@ test('REQ-LIMIT-3 REQ-LIMIT-4 Settings shows token limits counted, not enforced
|
||||
['tokens / models / anthropic / claude-opus-5-5','7',SINCE,`check failed: check-threw (${hostile})`,'Reset'],
|
||||
]);
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("[data-limit-reset]")].every(e=>e.disabled)'),true);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#token-limits-daily").length'),0,'no daily row, no daily line');
|
||||
assert.equal(await b.evaluate('window.injected===true'),false);
|
||||
// Signed-in send mode: Reset posts the revision and that one path, and nothing else. A draft
|
||||
// typed before the reset stays, and its save carries the revision the reset returned (row 167).
|
||||
@@ -54,6 +55,13 @@ test('REQ-LIMIT-3 REQ-LIMIT-4 Settings shows token limits counted, not enforced
|
||||
assert.equal(save.revision,'b'.repeat(64));
|
||||
assert.equal(save.limits.total,3);
|
||||
assert.deepEqual(save.limits.tokens,tokens);
|
||||
// Row 126 (#1620): a daily quota row adds one line under the table. Its own query string makes
|
||||
// this a full load, and the next block's /#/settings one too.
|
||||
answer={revision:launch.revision,limits:launch.limits,tokens:[...TOKENS,{path:['tokens','daily','jason-max'],limit:40,since:SINCE,status:'counted',check:'daily-quota',count:3,kinds:{input:1,output:1,cacheWrite:0,cacheRead:1},unknownTurns:0}]};
|
||||
await b.navigate(`http://127.0.0.1:${port}/?daily=1#/settings`);
|
||||
await wait('!!document.querySelector("#token-limits-daily")');
|
||||
assert.equal(await b.evaluate('document.querySelector("#token-limits-daily").textContent'),"Daily quotas count only this stack's own use of the account, not its use anywhere else.");
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#token-limits-daily").length'),1);
|
||||
// A limits answer the server refuses shows no rows and no count.
|
||||
answer={revision:launch.revision,limits:launch.limits};
|
||||
await b.navigate(`http://127.0.0.1:${port}/#/settings`);
|
||||
|
||||
Reference in New Issue
Block a user