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.
93 lines
4.8 KiB
JavaScript
93 lines
4.8 KiB
JavaScript
// REQ-LIMIT-1: human settings; decision 145 migration is a separate print-only read.
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { loadSystem } from "./config.mjs";
|
|
import { systemVars } from "../../business/src/index.mjs";
|
|
import { CliError } from "./errors.mjs";
|
|
import { migrateLaunch } from "../../business/src/migrate-launch.mjs";
|
|
|
|
// Row 155 (#1649, Q169): a PATCH may carry `assistants`, the assistant's vars, tracker and definition.
|
|
export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] | set --file PATCH [--business ID] | " +
|
|
"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) {
|
|
if (!Array.isArray(tokens) || tokens.length === 0) return "token limits: none\n";
|
|
const lines = tokens.map(t => {
|
|
const head = `${t.path.join(" ")}: limit ${t.limit} since ${t.since}`;
|
|
if (t.status === "counted") {
|
|
const kinds = Object.entries(t.kinds).map(([k, n]) => `${k} ${n}`).join(", ");
|
|
return `${head}, count ${t.count} (${kinds}), unknown turns ${t.unknownTurns}`;
|
|
}
|
|
if (t.status === "not-enforced") return `${head}, not enforced${t.reason ? `: ${t.reason}` : ""}`;
|
|
return `${head}, check failed: ${t.code}${t.check ? ` (${t.check})` : ""}`;
|
|
});
|
|
// 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) {
|
|
const out = { positional: [], flags: new Set(), values: {} };
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i];
|
|
if (arg === "--json") out.flags.add(arg);
|
|
else if (["--file", "--models", "--roles-dir", "--business"].includes(arg)) {
|
|
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(LIMITS_USAGE, 4);
|
|
out.values[arg] = args[++i];
|
|
} else if (arg.startsWith("--")) throw new CliError(LIMITS_USAGE, 4);
|
|
else out.positional.push(arg);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function limitsCommand(args, io, callFor) {
|
|
const parsed = parseLimits(args);
|
|
const verb = parsed.positional[0] ?? "view";
|
|
const values = parsed.values;
|
|
if (parsed.positional.length > 1 && verb !== "reset") throw new CliError(LIMITS_USAGE, 4);
|
|
try {
|
|
if (verb === "migrate") {
|
|
if (!values["--file"] || !values["--models"] || values["--business"]) {
|
|
throw new CliError(LIMITS_USAGE, 4);
|
|
}
|
|
const file = values["--file"];
|
|
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
const mapping = JSON.parse(readFileSync(values["--models"], "utf8"));
|
|
const rolesDir = values["--roles-dir"] ?? fileURLToPath(new URL("../../../roles", import.meta.url));
|
|
const inherits = raw.launch?.max && Object.values(raw.roles ?? {}).some(role => role.vars?.model === undefined);
|
|
const system = inherits ? systemVars(loadSystem({ env: io.env })) : null;
|
|
const converted = migrateLaunch(raw, mapping, file, { rolesDir, system });
|
|
io.stdout.write(JSON.stringify(converted, null, 2) + "\n");
|
|
return;
|
|
}
|
|
if (!["view", "set", "reset"].includes(verb) || values["--models"] || values["--roles-dir"] ||
|
|
(verb !== "set" && values["--file"]) || (verb === "set" && !values["--file"]) ||
|
|
(verb === "reset" && parsed.positional.length < 2)) {
|
|
throw new CliError(LIMITS_USAGE, 4);
|
|
}
|
|
const call = callFor(parsed);
|
|
// Row 125 (#1619, REQ-LIMIT-3): `reset tokens providers anthropic` restarts that limit's count
|
|
// from now. The path is one word per level, so a model name may hold dots or slashes. The
|
|
// result's `tokens` lists each token limit: counted, not-enforced or check-failed.
|
|
let result;
|
|
if (verb === "set") result = await call("limits.set", JSON.parse(readFileSync(values["--file"], "utf8")));
|
|
else if (verb === "reset") {
|
|
const { revision } = await call("limits", {});
|
|
result = await call("limits.set", { revision, reset: [parsed.positional.slice(1)] });
|
|
} else result = await call("limits", {});
|
|
if (parsed.flags.has("--json")) io.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
else {
|
|
const { tokens, ...rest } = result ?? {};
|
|
io.stdout.write(JSON.stringify(rest, null, 2) + "\n" + formatTokens(tokens));
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof CliError) throw error;
|
|
throw new CliError(error.message, 2);
|
|
}
|
|
}
|