Public: anyone can read this; the crew can change it.

A maintenance duty stops at its budget, runs per day and dollars per day, and its last run row says it stopped there #11309

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11206-a-maintenance-duty-stops-at-its-budget-r → main 10 commits fork 2 threads · 2 unresolved

  • Merged. scout/11206-a-maintenance-duty-stops-at-its-budget-r was merged into main as 079f465.

Review

Sign in to review this change.
20 files changed +463 −23
docs/features/maintenance-budgets.md added +12
@@ -0,0 +1,12 @@
1 # Maintenance duty budgets (#11206)
2
3 Each maintenance duty (`src/maintain/registry.ts`) declares a budget: runs per day and dollars per day, with the reason for those numbers. A duty at either budget is skipped for the rest of the UTC day; the runner records one row that day saying which budget it stopped at ("budget: runs per day, 2 of 2 used"). That row is not a run and does not count against the budget or move the interval. The next UTC day the duty runs again.
4
5 A budget is a setting, not a constant: an admin's stored value (`forge_setting`, `maintain.<duty>.runs|dollars`, every change in `forge_setting_change`) wins over the duty's declared number. A value equal to the declared one, or a blank, deletes the stored row, so a later change to a duty's declared default still takes effect.
6
7 Both doors are for a forge admin who is a person, and call `setBudget` in `src/maintain/budget.ts`.
8
9 - **On the web.** `/maintenance`: each duty's row carries what it used today, the reason, and the two inputs (`POST /maintenance/budget`, admin only, same-origin only; a number outside what a budget takes is refused and nothing changes).
10 - **In the terminal.** Ctrl-K, **Set a maintenance duty's budget**: `j`/`k` choose, `+`/`-` runs per day, `]`/`[` dollars per day, `0` back to the duty's own, Esc closes.
11
12 Tests: `test/maintain.test.ts` (the stops, the stored values, the POST gates, the terminal screen).
src/core/migration-pins/20261111130000-maintenance-run-dollars.pin added +1
@@ -0,0 +1,1 @@
1 654b9344c0eb4018ec47e7e49ae33ca0ffa121f7953b1bf91951296ba0087e1b
src/core/migrations/20261111130000-maintenance-run-dollars.ts added +5
@@ -0,0 +1,5 @@
1 // A maintenance duty's daily budget (#11206): what a run cost, in dollars, so the runner can
2 // stop a duty that has spent its day's allowance. Additive: one column.
3 export default `
4 ALTER TABLE maintenance_run ADD COLUMN dollars REAL NOT NULL DEFAULT 0;
5 `;
src/http/server.ts +2
@@ -32,6 +32,7 @@ import { pipelinePage } from "../web/pages/pipeline.ts";
3232 import { adminDataPage, appDataPage, dataPage } from "../web/pages/data.ts";
3333 import { fleetAct, fleetReceipt, fleetUndo } from "../web/pages/fleet-ops.ts";
3434 import { maintenanceDuty, maintenancePage, maintenancePrepare, maintenanceResume } from "../web/pages/maintenance.ts";
35 import { maintenanceBudget } from "../web/pages/maintenance_budgets.ts";
3536 import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts";
3637 import { agentStartSet, billingLinksSet, limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts";
3738 import { plansSet } from "../web/settings/plans.ts";
@@ -995,6 +996,7 @@ export function buildRouter(): Router<Handler> {
995996 router.add("GET", "/maintenance", maintenancePage);
996997 router.add("POST", "/maintenance/prepare", maintenancePrepare);
997998 router.add("POST", "/maintenance/resume", maintenanceResume);
999 router.add("POST", "/maintenance/budget", maintenanceBudget);
9981000 router.add("POST", "/maintenance/duties/:name", maintenanceDuty);
9991001 router.add("GET", "/resilience/nightly/:job", nightlyOutputPage);
10001002 // A selection's spend and runs over time; literal, above /:owner.
src/maintain/budget.ts added +99
@@ -0,0 +1,99 @@
1 /**
2 * A duty's budget (#11206): runs per day and dollars per day, each a setting an admin moves on
3 * /maintenance with the duty's reason beside it, never a constant. The duty declares the
4 * default and why; a stored value (`forge_setting`, key `maintain.<duty>.runs|dollars`) wins.
5 * The day is the UTC day. Plain data in, plain data out.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import type { Duty } from "./duty.ts";
10
11 export const RUNS_MAX = 100_000;
12 export const DOLLARS_MIN = 0.01;
13 export const DOLLARS_MAX = 10_000;
14
15 export type BudgetKind = "runs" | "dollars";
16
17 export interface Budget {
18 readonly runsPerDay: number;
19 readonly dollarsPerDay: number;
20 }
21
22 export const settingKey = (duty: string, kind: BudgetKind): string => `maintain.${duty}.${kind}`;
23
24 /** The number `text` is, when it is a value the budget `kind` takes. */
25 export function parseBudget(kind: BudgetKind, text: string): number | undefined {
26 const t = text.trim();
27 if (kind === "runs") {
28 if (!/^\d{1,6}$/.test(t)) return undefined;
29 const n = Number(t);
30 return n >= 1 && n <= RUNS_MAX ? n : undefined;
31 }
32 if (!/^\d{1,5}(\.\d{1,4})?$/.test(t)) return undefined;
33 const n = Number(t);
34 return n >= DOLLARS_MIN && n <= DOLLARS_MAX ? n : undefined;
35 }
36
37 function stored(db: Database, duty: string, kind: BudgetKind): number | undefined {
38 const row = db.query("SELECT value FROM forge_setting WHERE key = ?1").get(settingKey(duty, kind)) as { value: string } | null;
39 return row === null ? undefined : parseBudget(kind, row.value);
40 }
41
42 const declared = (duty: Duty, kind: BudgetKind): number => (kind === "runs" ? duty.budget.runsPerDay : duty.budget.dollarsPerDay);
43
44 /** The budget in force for a duty: stored values over the duty's declared ones. Read on every tick. */
45 export function budgetOf(db: Database, duty: Duty): Budget {
46 return {
47 runsPerDay: stored(db, duty.name, "runs") ?? duty.budget.runsPerDay,
48 dollarsPerDay: stored(db, duty.name, "dollars") ?? duty.budget.dollarsPerDay,
49 };
50 }
51
52 /** Keep a new value for a budget (`null`, or the duty's own number, goes back to the duty's own), and who moved it. */
53 export function setBudget(db: Database, duty: Duty, kind: BudgetKind, given: number | null, actorId: string): void {
54 // Only a value that differs from the declared one is stored, so a later change of the default still takes.
55 const value = given === declared(duty, kind) ? null : given;
56 const key = settingKey(duty.name, kind);
57 const from = String(stored(db, duty.name, kind) ?? declared(duty, kind));
58 const at = new Date().toISOString();
59 db.transaction(() => {
60 if (value === null) db.query("DELETE FROM forge_setting WHERE key = ?1").run(key);
61 else
62 db.query(
63 `INSERT INTO forge_setting (key, value, updated_by, updated_at) VALUES (?1, ?2, ?3, ?4)
64 ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
65 ).run(key, String(value), actorId, at);
66 const to = String(stored(db, duty.name, kind) ?? declared(duty, kind));
67 db.query("INSERT INTO forge_setting_change (key, from_value, to_value, actor_id, at, reason) VALUES (?1, ?2, ?3, ?4, ?5, '')").run(key, from, to, actorId, at);
68 })();
69 }
70
71 /** The UTC day `now` falls in, as ISO bounds. */
72 export function dayBounds(now: Date): { from: string; to: string } {
73 const start = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
74 return { from: new Date(start).toISOString(), to: new Date(start + 86_400_000).toISOString() };
75 }
76
77 /** What a duty has used today: real runs (a budget stop is not one) and dollars. */
78 export function usedToday(db: Database, duty: string, now: Date): { runs: number; dollars: number } {
79 const { from, to } = dayBounds(now);
80 return db
81 .query("SELECT COUNT(*) AS runs, COALESCE(SUM(dollars), 0) AS dollars FROM maintenance_run WHERE duty = ?1 AND started >= ?2 AND started < ?3 AND outcome NOT LIKE 'budget:%'")
82 .get(duty, from, to) as { runs: number; dollars: number };
83 }
84
85 /** The outcome that names the budget a duty stopped at; undefined while it is within both. */
86 export function overBudget(db: Database, duty: Duty, now: Date): string | undefined {
87 const b = budgetOf(db, duty);
88 const used = usedToday(db, duty.name, now);
89 if (used.runs >= b.runsPerDay) return `budget: runs per day, ${used.runs} of ${b.runsPerDay} used`;
90 if (used.dollars >= b.dollarsPerDay) return `budget: dollars per day, $${used.dollars.toFixed(2)} of $${b.dollarsPerDay.toFixed(2)} spent`;
91 return undefined;
92 }
93
94 /** Whether today already has a stop row of this kind for the duty, so a stop is said once a day. */
95 export function stoppedToday(db: Database, duty: string, outcome: string, now: Date): boolean {
96 const { from, to } = dayBounds(now);
97 const prefix = `${outcome.slice(0, outcome.indexOf(","))}%`;
98 return db.query("SELECT 1 FROM maintenance_run WHERE duty = ?1 AND started >= ?2 AND started < ?3 AND outcome LIKE ?4 LIMIT 1").get(duty, from, to, prefix) !== null;
99 }
src/maintain/duties/efficiency_watcher.ts +1
@@ -6,6 +6,7 @@ import { watch } from "../../efficiency/watch.ts";
66 export const duty: Duty = {
77 name: "efficiency-watcher",
88 intervalMs: 3_600_000,
9 budget: { runsPerDay: 30, dollarsPerDay: 1, reason: "hourly is 24 a day; the rest is room for a catch-up, and it spends no money" },
910 run({ app, now }) {
1011 return app === undefined ? { findings: [], actions: [] } : watch(app, now.getTime());
1112 },
src/maintain/duties/flow_keeper.ts +1
@@ -34,6 +34,7 @@ export function flowKeeper(deps: Deps = {}): Duty {
3434 return {
3535 name: "flow-keeper",
3636 intervalMs: 60_000,
37 budget: { runsPerDay: 1500, dollarsPerDay: 1, reason: "every minute is 1440 a day; it acts on pulls, so a loop is cut at a day" },
3738 async run({ db, now, app }) {
3839 const findings: Finding[] = [];
3940 const actions: string[] = [];
src/maintain/duties/rule_watcher.ts +1
@@ -19,6 +19,7 @@ function reposOf(db: Database): Map<string, Pick<Repo, "id" | "ownerId">> {
1919 export const duty: Duty = {
2020 name: "rule-watcher",
2121 intervalMs: 15 * 60_000,
22 budget: { runsPerDay: 120, dollarsPerDay: 1, reason: "every 15 minutes is 96 a day; the rest is room for a catch-up, and it spends no money" },
2223 run({ db, bus, now }) {
2324 const w: Watch = { db, bus, repos: reposOf(db), now };
2425 // The runner records this run after it returns, so the last start on record is the previous run's.
src/maintain/duties/stale_pulls.ts +1
@@ -8,6 +8,7 @@ const SQL = "SELECT COUNT(*) AS n FROM issue WHERE kind = 'pull' AND state = 'op
88 export const duty: Duty = {
99 name: "pulls-waiting",
1010 intervalMs: 3_600_000,
11 budget: { runsPerDay: 30, dollarsPerDay: 1, reason: "hourly is 24 a day; the rest is room for a catch-up, and it spends no money" },
1112 run({ db, now }) {
1213 const cutoff = new Date(now.getTime() - DAY_MS).toISOString();
1314 const n = (db.query(SQL).get(cutoff) as { n: number }).n;
src/maintain/duties/visitor_retention.ts +1
@@ -6,6 +6,7 @@ import { purgeVisitors } from "../../perf/visitor_store.ts";
66 export const duty: Duty = {
77 name: "visitor-retention",
88 intervalMs: 86_400_000,
9 budget: { runsPerDay: 4, dollarsPerDay: 1, reason: "daily is one a day; the rest is room for a catch-up, and it spends no money" },
910 run({ db, now }) {
1011 const removed = purgeVisitors(db, now.getTime());
1112 return { findings: [], actions: removed === 0 ? [] : [`deleted ${removed} visitor rows older than 13 months`] };
src/maintain/duty.ts +10
@@ -23,6 +23,15 @@ export interface DutyResult {
2323 readonly findings: readonly Finding[];
2424 /** What the duty did about it, one line each. */
2525 readonly actions: readonly string[];
26 /** What the run spent, in dollars; counted against the duty's dollars per day. Default 0. */
27 readonly dollars?: number | undefined;
28 }
29
30 /** What a duty may use in a UTC day, and why that much; an admin's stored value replaces the numbers. */
31 export interface DutyBudget {
32 readonly runsPerDay: number;
33 readonly dollarsPerDay: number;
34 readonly reason: string;
2635 }
2736
2837 export interface DutyContext {
@@ -38,6 +47,7 @@ export interface Duty {
3847 /** Lowercase words and dashes; the row's `duty`. */
3948 readonly name: string;
4049 readonly intervalMs: number;
50 readonly budget: DutyBudget;
4151 readonly run: (ctx: DutyContext) => DutyResult | Promise<DutyResult>;
4252 }
4353
src/maintain/runner.ts +10 −1
@@ -6,6 +6,7 @@
66
77 import type { App } from "../app.ts";
88 import { during } from "../perf/jobs.ts";
9 import { overBudget, stoppedToday } from "./budget.ts";
910 import { clip, refusal, validName, type Duty } from "./duty.ts";
1011 import { duties as registered } from "./registry.ts";
1112 import { isPaused, pauseOnErrors } from "./pause.ts";
@@ -42,6 +43,7 @@ export function startMaintain(app: App, options: { duties?: readonly Duty[]; eve
4243 let findings: Awaited<ReturnType<Duty["run"]>>["findings"] = [];
4344 let actions: readonly string[] = [];
4445 let outcome = "ok";
46 let dollars = 0;
4547 try {
4648 // The duty keeps its `running` slot until it really settles, so a timed-out duty never overlaps itself.
4749 const work = Promise.resolve().then(() => duty.run({ db: app.db, bus: app.bus, now, app }));
@@ -56,12 +58,13 @@ export function startMaintain(app: App, options: { duties?: readonly Duty[]; eve
5658 else {
5759 findings = result.findings;
5860 actions = result.actions.map(clip);
61 dollars = typeof result.dollars === "number" && Number.isFinite(result.dollars) && result.dollars > 0 ? result.dollars : 0;
5962 }
6063 } catch (error) {
6164 outcome = clip(`failed: ${error instanceof Error ? error.message : String(error)}`);
6265 }
6366 try {
64 record(app.db, { duty: duty.name, started, finished: new Date(), findings, actions, outcome });
67 record(app.db, { duty: duty.name, started, finished: new Date(), findings, actions, outcome, dollars });
6568 prune(app.db, duty.name, options.keep ?? KEEP_PER_DUTY);
6669 if (pauseOnErrors(app.db, duty.name)) app.log.error("maintenance duty paused after errors", { duty: duty.name });
6770 } catch (error) {
@@ -74,6 +77,12 @@ export function startMaintain(app: App, options: { duties?: readonly Duty[]; eve
7477 const last = lastStarted(app.db, duty.name);
7578 if (last !== undefined && now.getTime() - last < duty.intervalMs) continue;
7679 if (running.has(duty.name) || isPaused(app.db, duty.name)) continue;
80 const stop = overBudget(app.db, duty, now);
81 if (stop !== undefined) {
82 // Said once a day per budget: the row names which one the duty stopped at.
83 if (!stoppedToday(app.db, duty.name, stop, now)) record(app.db, { duty: duty.name, started: now, finished: now, findings: [], actions: [], outcome: stop });
84 continue;
85 }
7786 await runOne(duty, now);
7887 // Duties take turns: the loop turns between one and the next.
7988 await new Promise((resolve) => setImmediate(resolve));
src/maintain/runs.ts +5 −3
@@ -16,10 +16,10 @@ export interface RunRow {
1616
1717 export function record(
1818 db: Database,
19 r: { duty: string; started: Date; finished: Date; findings: readonly Finding[]; actions: readonly string[]; outcome: string },
19 r: { duty: string; started: Date; finished: Date; findings: readonly Finding[]; actions: readonly string[]; outcome: string; dollars?: number },
2020 ): void {
2121 db.query(
22 "INSERT INTO maintenance_run (duty, started, finished, findings, actions, probes, outcome) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
22 "INSERT INTO maintenance_run (duty, started, finished, findings, actions, probes, outcome, dollars) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
2323 ).run(
2424 r.duty,
2525 r.started.toISOString(),
@@ -28,6 +28,7 @@ export function record(
2828 JSON.stringify(r.actions),
2929 JSON.stringify(r.findings.flatMap((f) => (f.probe ? [f.probe] : []))),
3030 r.outcome,
31 r.dollars ?? 0,
3132 );
3233 }
3334
@@ -36,8 +37,9 @@ export function prune(db: Database, duty: string, keep: number): void {
3637 db.query("DELETE FROM maintenance_run WHERE duty = ?1 AND id NOT IN (SELECT id FROM maintenance_run WHERE duty = ?1 ORDER BY id DESC LIMIT ?2)").run(duty, keep);
3738 }
3839
40 /** When a duty last really started; a budget stop is not a start. */
3941 export function lastStarted(db: Database, duty: string): number | undefined {
40 const row = db.query("SELECT started FROM maintenance_run WHERE duty = ?1 ORDER BY id DESC LIMIT 1").get(duty) as
42 const row = db.query("SELECT started FROM maintenance_run WHERE duty = ?1 AND outcome NOT LIKE 'budget:%' ORDER BY id DESC LIMIT 1").get(duty) as
4143 | { started: string }
4244 | null;
4345 return row ? Date.parse(row.started) : undefined;
src/tui/screens/dutybudgets.ts added +82
@@ -0,0 +1,82 @@
1 /**
2 * Maintenance duty budgets in the terminal (#11206): the budgets block of /maintenance, through
3 * the same `setBudget`, behind the same gate (a forge admin who is a person), for reads and
4 * writes alike. j/k choose a duty; +/- move its runs per day by one, ]/[ its dollars per day by
5 * one; 0 takes both back to the duty's own. Esc or q closes.
6 */
7
8 import { budgetOf, DOLLARS_MAX, DOLLARS_MIN, RUNS_MAX, setBudget, usedToday, type BudgetKind } from "../../maintain/budget.ts";
9 import { duties } from "../../maintain/registry.ts";
10 import type { Ctx, Screen } from "../app/app.ts";
11 import { binding } from "../app/keys.ts";
12 import type { Session } from "../session.ts";
13 import { style } from "../term/style.ts";
14 import { drawLines, Scroll } from "./common.ts";
15 import type { PaletteEntry } from "./entries.ts";
16
17 const mayAct = (session: Session): boolean => session.user?.isAdmin === true && session.user.kind !== "agent";
18
19 /** One duty a line, the chosen one marked; empty for anyone the gate refuses. */
20 export function budgetLines(session: Session, chosen: number): string[] {
21 if (!mayAct(session)) return [];
22 return duties.map((d, i) => {
23 const b = budgetOf(session.db, d);
24 const u = usedToday(session.db, d.name, new Date());
25 return `${i === chosen ? ">" : " "} ${d.name}: ${u.runs} of ${b.runsPerDay} runs, $${u.dollars.toFixed(2)} of $${b.dollarsPerDay.toFixed(2)} today (${d.budget.reason})`;
26 });
27 }
28
29 /** Move the chosen duty's budget by `delta` (or back to its own with `reset`); what it says, or nothing for anyone the gate refuses. */
30 export function moveBudget(session: Session, chosen: number, kind: BudgetKind, delta: number, reset = false): string {
31 if (!mayAct(session)) return "";
32 const d = duties[chosen];
33 if (d === undefined) return "";
34 const actor = session.user!.id;
35 if (reset) {
36 setBudget(session.db, d, "runs", null, actor);
37 setBudget(session.db, d, "dollars", null, actor);
38 return `${d.name} is back to its own budget.`;
39 }
40 const b = budgetOf(session.db, d);
41 const [now, lo, hi] = kind === "runs" ? [b.runsPerDay, 1, RUNS_MAX] : [b.dollarsPerDay, DOLLARS_MIN, DOLLARS_MAX];
42 const next = Math.min(hi, Math.max(lo, now + delta));
43 setBudget(session.db, d, kind, next, actor);
44 return `${d.name}: ${kind === "runs" ? "runs" : "dollars"} per day is ${next}.`;
45 }
46
47 export function dutyBudgetScreen(session: Session, ctx: Ctx): Screen {
48 let chosen = 0;
49 let note = "";
50 return {
51 title: "Maintenance duty budgets",
52 keys: [binding(["j", "k"], "choose"), binding(["+", "-"], "runs"), binding(["]", "["], "dollars"), binding(["0"], "reset"), binding(["esc", "q"], "back")],
53 render(area, s) {
54 const c = ctx.theme.c;
55 const lines = [...budgetLines(session, chosen), ...(note === "" ? [] : ["", note])];
56 drawLines(s, area, c, new Scroll(), lines.map((text) => [{ text, style: style(c.fg, c.bg) }]));
57 },
58 key(e) {
59 if (e.ctrl || e.alt) return true;
60 if (e.key === "esc" || e.key === "q") ctx.pop();
61 else if (e.key === "j" || e.key === "down") chosen = Math.min(chosen + 1, duties.length - 1);
62 else if (e.key === "k" || e.key === "up") chosen = Math.max(chosen - 1, 0);
63 else if (e.key === "+") note = moveBudget(session, chosen, "runs", 1);
64 else if (e.key === "-") note = moveBudget(session, chosen, "runs", -1);
65 else if (e.key === "]") note = moveBudget(session, chosen, "dollars", 1);
66 else if (e.key === "[") note = moveBudget(session, chosen, "dollars", -1);
67 else if (e.key === "0") note = moveBudget(session, chosen, "runs", 0, true);
68 else return false;
69 ctx.invalidate();
70 return true;
71 },
72 };
73 }
74
75 export const entry: PaletteEntry = {
76 id: "maintenance-budgets",
77 title: "Set a maintenance duty's budget",
78 hint: "runs per day and dollars per day a duty may use",
79 after: ["maintenance-duties", "maintenance-runs"],
80 offered: mayAct,
81 run: (session, ctx) => ctx.push(dutyBudgetScreen(session, ctx)),
82 };
src/tui/screens/entries.ts +2
@@ -58,6 +58,7 @@ import { entry as loopStalls } from "./loopstalls.ts";
5858 import { entry as slowPagesEntry } from "./slowpages.ts";
5959 import { entry as maintenance } from "./maintenance.ts";
6060 import { entry as maintainRuns } from "./maintainruns.ts";
61 import { entry as dutyBudgets } from "./dutybudgets.ts";
6162 import { entry as dutySwitch } from "./dutyswitch.ts";
6263 import { entry as mirrorPacksEntry } from "./mirrorpacks.ts";
6364 import { entry as packReposEntry } from "./packrepos.ts";
@@ -122,6 +123,7 @@ export const ENTRIES: readonly PaletteEntry[] = [
122123 maintenance,
123124 maintainRuns,
124125 dutySwitch,
126 dutyBudgets,
125127 mirrorPacksEntry,
126128 packReposEntry,
127129 valves,
src/web/pages/maintenance.ts +3 −2
@@ -15,6 +15,7 @@ import { field as formField, readForm } from "../form.ts";
1515 import { html, type Html } from "../html.ts";
1616 import { flashTo, page } from "../layout.ts";
1717 import { badge, btn, field } from "../ui.ts";
18 import { budgetOfRow } from "./maintenance_budgets.ts";
1819
1920 function admin(ctx: Ctx): Response | undefined {
2021 if (!ctx.auth.user) return signInFirst(ctx);
@@ -36,10 +37,10 @@ export async function maintenancePage(ctx: Ctx): Promise<Response> {
3637 <dl id="maintenance-duties" class="status-rows">
3738 ${duties(ctx.app.config.dataDir).map((d) => fact(d.name, html`${d.detail}${d.overdue ? html` ${badge("overdue", { tone: "danger" })}` : null}`))}
3839 ${dutyStates(ctx.app.db).map((d) =>
39 fact(d.name, html`${d.detail}
40 html`${fact(d.name, html`${d.detail}
4041 <form method="post" action="/maintenance/duties/${d.name}" class="inline">
4142 <input type="hidden" name="paused" value="${d.paused ? "no" : "yes"}">${btn(d.paused ? "Resume" : "Pause", { variant: "outline" })}
42 </form>`),
43 </form>`)}${budgetOfRow(ctx.app, d.name)}`,
4344 )}
4445 </dl>
4546 ${s.phase === "idle" ? html`<p id="maintenance-standby" class="muted">${s.standby.says}</p>` : null}
src/web/pages/maintenance_budgets.ts added +50
@@ -0,0 +1,50 @@
1 /**
2 * The budgets of the maintenance duties (#11206), in each duty's row on /maintenance: per duty, what it has used
3 * today against runs per day and dollars per day, the reason its default is what it is, and a
4 * row to move either. A blank field, or the duty's own number, takes the duty's own back.
5 */
6
7 import { budgetOf, parseBudget, setBudget, usedToday, DOLLARS_MAX, DOLLARS_MIN, RUNS_MAX } from "../../maintain/budget.ts";
8 import { duties } from "../../maintain/registry.ts";
9 import type { App } from "../../app.ts";
10 import type { Ctx } from "../../http/context.ts";
11 import { field as formField, readForm } from "../form.ts";
12 import { html, type Html } from "../html.ts";
13 import { flashTo } from "../layout.ts";
14 import { btn, field as formControl } from "../ui.ts";
15
16 /** What a duty's row says of its budget: use today, the reason, and the two inputs. */
17 export function budgetOfRow(app: App, name: string, now = new Date()): Html {
18 const d = duties.find((x) => x.name === name);
19 if (d === undefined) return html``;
20 const b = budgetOf(app.db, d);
21 const u = usedToday(app.db, d.name, now);
22 const row = (label: string, value: Html): Html => html`<div class="status-row"><dt>${label}</dt><dd>${value}</dd></div>`;
outdated

Principles 1 and 7: usage, reason and two inputs run together in one div inside a dd. Split into dl rows (use today / why / change) and use the named field components rather than bare labels and inputs.

23 return html`${row(`${d.name} budget`, html`<span id="budget-${d.name}">${String(u.runs)} of ${String(b.runsPerDay)} runs, $${u.dollars.toFixed(2)} of $${b.dollarsPerDay.toFixed(2)} today</span>`)}
24 ${row("Why", html`<span class="muted">${d.budget.reason}</span>`)}
25 ${row("Change", html`<form method="post" action="/maintenance/budget" class="inline">
26 <input type="hidden" name="duty" value="${d.name}" />
27 ${formControl({ name: "runs", id: `runs-${d.name}`, label: "Runs per day", type: "number", min: 1, max: RUNS_MAX, value: String(b.runsPerDay), mono: true })}
28 ${formControl({ name: "dollars", id: `dollars-${d.name}`, label: "Dollars per day", type: "number", min: DOLLARS_MIN, max: DOLLARS_MAX, step: "0.01", value: String(b.dollarsPerDay), mono: true })}
29 ${btn("Save budget", { variant: "outline" })}
30 </form>`)}`;
31 }
32
33 /** POST /maintenance/budget: an admin moves one duty's budgets; a blank value goes back to the duty's own. */
34 export async function maintenanceBudget(ctx: Ctx): Promise<Response> {
outdated

Gate mismatch: only isAdmin is checked, but the docs and the terminal screen require an admin who is a person. Add || user.kind === "agent" and a test.

35 const user = ctx.auth.user;
36 if (user === undefined || !user.isAdmin || user.kind === "agent") return flashTo(ctx, "/maintenance", { tone: "danger", message: "Only a forge admin who is a person sets a budget." });
37 const form = await readForm(ctx, "admin");
38 if (form instanceof Response) return form;
39 const duty = duties.find((d) => d.name === formField(form, "duty"));
40 if (duty === undefined) return flashTo(ctx, "/maintenance", { tone: "danger", message: "No such duty." });
41 const picked: [("runs" | "dollars"), number | null][] = [];
42 for (const kind of ["runs", "dollars"] as const) {
43 const text = formField(form, kind).trim();
44 const n = text === "" ? null : parseBudget(kind, text);
45 if (n === undefined) return flashTo(ctx, "/maintenance", { tone: "danger", message: `${kind === "runs" ? "Runs per day" : "Dollars per day"} is outside what a budget takes.` });
46 picked.push([kind, n]);
47 }
48 for (const [kind, n] of picked) setBudget(ctx.app.db, duty, kind, n, user.id);
49 return flashTo(ctx, "/maintenance", { message: `Budget of ${duty.name} saved.` });
50 }
test/maintain-pause.test.ts +3 −2
@@ -13,6 +13,7 @@ import { duties } from "../src/maintain/registry.ts";
1313 import { startMaintain } from "../src/maintain/runner.ts";
1414 import { dutySwitchScreen, entry as switchEntry, flipDuty } from "../src/tui/screens/dutyswitch.ts";
1515 import { quietLogs } from "./helpers/globals.ts";
16 const ROOMY = { runsPerDay: 100000, dollarsPerDay: 10000, reason: "test" };
1617
1718 quietLogs();
1819 let root = "";
@@ -134,7 +135,7 @@ test("the terminal switch is offered to an admin person only and flips the duty
134135 test("a paused duty does not run; three errors in a row pause it saying the last error; resume starts the count again", async () => {
135136 app.db.exec("DELETE FROM maintenance_run; DELETE FROM maintenance_duty_state");
136137 let calls = 0;
137 const bad: Duty = { name: "t-bad", intervalMs: 1, run: () => { calls++; throw new Error(`boom ${calls}`); } };
138 const bad: Duty = { name: "t-bad", intervalMs: 1, budget: ROOMY, run: () => { calls++; throw new Error(`boom ${calls}`); } };
138139 const m = startMaintain(app, { duties: [bad], everyMs: 1e9 });
139140 m.stop();
140141 const t = (i: number) => new Date(Date.UTC(2026, 10, 11, 0, 0, i));
@@ -161,7 +162,7 @@ test("a paused duty does not run; three errors in a row pause it saying the last
161162 test("an ok run in between keeps the count from reaching three", async () => {
162163 app.db.exec("DELETE FROM maintenance_run; DELETE FROM maintenance_duty_state");
163164 let n = 0;
164 const flaky: Duty = { name: "t-flaky", intervalMs: 1, run: () => { if (++n === 3) return { findings: [], actions: [] }; throw new Error("x"); } };
165 const flaky: Duty = { name: "t-flaky", intervalMs: 1, budget: ROOMY, run: () => { if (++n === 3) return { findings: [], actions: [] }; throw new Error("x"); } };
165166 const m = startMaintain(app, { duties: [flaky], everyMs: 1e9 });
166167 m.stop();
167168 for (let i = 1; i <= 5; i++) await m.tick(new Date(Date.UTC(2026, 10, 11, 0, 0, i)));
test/maintain.test.ts +173 −15
@@ -13,6 +13,7 @@ import { latest } from "../src/maintain/runs.ts";
1313 import type { Duty } from "../src/maintain/duty.ts";
1414 import { quietLogs } from "./helpers/globals.ts";
1515
16 const ROOMY = { runsPerDay: 100000, dollarsPerDay: 10000, reason: "test" };
1617 quietLogs();
1718
1819 let root = "";
@@ -42,7 +43,7 @@ const clear = () => app.db.exec("DELETE FROM maintenance_run");
4243 test("a duty runs when due, not before its interval, and leaves a row with findings and probes", async () => {
4344 clear();
4445 let calls = 0;
45 const duty: Duty = { name: "t-sched", intervalMs: 1000, run: () => (calls++, { findings: [{ summary: "deep queue", probe }], actions: ["noted"] }) };
46 const duty: Duty = { name: "t-sched", intervalMs: 1000, budget: ROOMY, run: () => (calls++, { findings: [{ summary: "deep queue", probe }], actions: ["noted"] }) };
4647 const m = startMaintain(app, { duties: [duty], everyMs: 1e9 });
4748 m.stop();
4849 const t0 = new Date("2026-11-11T00:00:00Z");
@@ -62,7 +63,7 @@ test("a duty never runs twice at once", async () => {
6263 let live = 0;
6364 let worst = 0;
6465 const duty: Duty = {
65 name: "t-once", intervalMs: 0,
66 name: "t-once", intervalMs: 0, budget: ROOMY,
6667 run: async () => { live++; worst = Math.max(worst, live); await new Promise((r) => setTimeout(r, 20)); live--; return { findings: [], actions: [] }; },
6768 };
6869 const m = startMaintain(app, { duties: [duty], everyMs: 1e9 });
@@ -73,8 +74,8 @@ test("a duty never runs twice at once", async () => {
7374
7475 test("a finding without a probe is refused and the outcome says so", async () => {
7576 clear();
76 const bad: Duty = { name: "t-bad", intervalMs: 1, run: () => ({ findings: [{ summary: "it is slow" }], actions: ["did a thing"] }) };
77 const nan: Duty = { name: "t-nan", intervalMs: 1, run: () => ({ findings: [{ summary: "x", probe: { ...probe, number: NaN } }], actions: [] }) };
77 const bad: Duty = { name: "t-bad", intervalMs: 1, budget: ROOMY, run: () => ({ findings: [{ summary: "it is slow" }], actions: ["did a thing"] }) };
78 const nan: Duty = { name: "t-nan", intervalMs: 1, budget: ROOMY, run: () => ({ findings: [{ summary: "x", probe: { ...probe, number: NaN } }], actions: [] }) };
7879 const m = startMaintain(app, { duties: [bad, nan], everyMs: 1e9 });
7980 m.stop();
8081 await m.tick();
@@ -89,8 +90,8 @@ test("a finding without a probe is refused and the outcome says so", async () =>
8990
9091 test("a duty that throws is recorded as failed and the next still runs", async () => {
9192 clear();
92 const boom: Duty = { name: "t-boom", intervalMs: 1, run: () => { throw new Error("no"); } };
93 const fine: Duty = { name: "t-fine", intervalMs: 1, run: () => ({ findings: [], actions: [] }) };
93 const boom: Duty = { name: "t-boom", intervalMs: 1, budget: ROOMY, run: () => { throw new Error("no"); } };
94 const fine: Duty = { name: "t-fine", intervalMs: 1, budget: ROOMY, run: () => ({ findings: [], actions: [] }) };
9495 const m = startMaintain(app, { duties: [boom, fine], everyMs: 1e9 });
9596 m.stop();
9697 await m.tick();
@@ -126,7 +127,7 @@ test("the example duty's count searches an existing index, not the table", () =>
126127
127128 test("GET /api/v1/maintenance/runs lists the latest rows for an admin only", async () => {
128129 clear();
129 const m = startMaintain(app, { duties: [{ name: "t-list", intervalMs: 1, run: () => ({ findings: [], actions: [] }) }], everyMs: 1e9 });
130 const m = startMaintain(app, { duties: [{ name: "t-list", intervalMs: 1, budget: ROOMY, run: () => ({ findings: [], actions: [] }) }], everyMs: 1e9 });
130131 m.stop();
131132 await m.tick();
132133 const get = (token?: string, q = "") => fetch(`${servers.url}/api/v1/maintenance/runs${q}`, { headers: token ? { authorization: `Bearer ${token}` } : {} });
@@ -143,8 +144,8 @@ test("a hung duty is recorded as timed out and the next duty still runs, without
143144 clear();
144145 let release = () => {};
145146 let starts = 0;
146 const hung: Duty = { name: "t-hung", intervalMs: 1, run: () => { starts++; return new Promise((r) => { release = () => r({ findings: [], actions: [] }); }); } };
147 const fine: Duty = { name: "t-after", intervalMs: 1, run: () => ({ findings: [], actions: [] }) };
147 const hung: Duty = { name: "t-hung", intervalMs: 1, budget: ROOMY, run: () => { starts++; return new Promise((r) => { release = () => r({ findings: [], actions: [] }); }); } };
148 const fine: Duty = { name: "t-after", intervalMs: 1, budget: ROOMY, run: () => ({ findings: [], actions: [] }) };
148149 const m = startMaintain(app, { duties: [hung, fine], everyMs: 1e9, timeoutMs: 20 });
149150 m.stop();
150151 await m.tick();
@@ -158,7 +159,7 @@ test("a hung duty is recorded as timed out and the next duty still runs, without
158159
159160 test("an empty finding reads refused:", async () => {
160161 clear();
161 const d: Duty = { name: "t-empty", intervalMs: 1, run: () => ({ findings: [{ summary: " ", probe }], actions: [] }) };
162 const d: Duty = { name: "t-empty", intervalMs: 1, budget: ROOMY, run: () => ({ findings: [{ summary: " ", probe }], actions: [] }) };
162163 const m = startMaintain(app, { duties: [d], everyMs: 1e9 });
163164 m.stop();
164165 await m.tick();
@@ -167,8 +168,8 @@ test("an empty finding reads refused:", async () => {
167168
168169 test("old rows are pruned to a cap per duty, and other duties keep theirs", async () => {
169170 clear();
170 const a: Duty = { name: "t-a", intervalMs: 1, run: () => ({ findings: [], actions: [] }) };
171 const b: Duty = { name: "t-b", intervalMs: 1, run: () => ({ findings: [], actions: [] }) };
171 const a: Duty = { name: "t-a", intervalMs: 1, budget: ROOMY, run: () => ({ findings: [], actions: [] }) };
172 const b: Duty = { name: "t-b", intervalMs: 1, budget: ROOMY, run: () => ({ findings: [], actions: [] }) };
172173 const m = startMaintain(app, { duties: [a, b], everyMs: 1e9, keep: 3 });
173174 m.stop();
174175 for (let i = 0; i < 6; i++) await m.tick(new Date(Date.now() + i * 10));
@@ -179,13 +180,13 @@ test("old rows are pruned to a cap per duty, and other duties keep theirs", asyn
179180
180181 test("a duty name that is not lowercase words and dashes, or is registered twice, is refused at start", () => {
181182 const run = () => ({ findings: [], actions: [] });
182 expect(() => startMaintain(app, { duties: [{ name: "Bad Name", intervalMs: 1, run }], everyMs: 1e9 })).toThrow("not lowercase");
183 expect(() => startMaintain(app, { duties: [{ name: "dup", intervalMs: 1, run }, { name: "dup", intervalMs: 1, run }], everyMs: 1e9 })).toThrow("twice");
183 expect(() => startMaintain(app, { duties: [{ name: "Bad Name", intervalMs: 1, budget: ROOMY, run }], everyMs: 1e9 })).toThrow("not lowercase");
184 expect(() => startMaintain(app, { duties: [{ name: "dup", intervalMs: 1, budget: ROOMY, run }, { name: "dup", intervalMs: 1, budget: ROOMY, run }], everyMs: 1e9 })).toThrow("twice");
184185 });
185186
186187 test("the terminal screen lists runs for an admin and nothing for anyone else", async () => {
187188 clear();
188 const m = startMaintain(app, { duties: [{ name: "t-tui", intervalMs: 1, run: () => ({ findings: [], actions: [] }) }], everyMs: 1e9 });
189 const m = startMaintain(app, { duties: [{ name: "t-tui", intervalMs: 1, budget: ROOMY, run: () => ({ findings: [], actions: [] }) }], everyMs: 1e9 });
189190 m.stop();
190191 await m.tick();
191192 const { runLines } = await import("../src/tui/screens/maintainruns.ts");
@@ -194,3 +195,160 @@ test("the terminal screen lists runs for an admin and nothing for anyone else",
194195 expect(runLines(as(false))).toEqual([]);
195196 expect(runLines({ user: undefined, db: app.db } as never)).toEqual([]);
196197 });
198
199 const tight = (runsPerDay: number, dollarsPerDay = 10000) => ({ runsPerDay, dollarsPerDay, reason: "test" });
200
201 test("a duty past its runs budget is skipped with a budget row, and runs again the next day", async () => {
202 clear();
203 let calls = 0;
204 const duty: Duty = { name: "t-runs", intervalMs: 1, budget: tight(2), run: () => (calls++, { findings: [], actions: [] }) };
205 const m = startMaintain(app, { duties: [duty], everyMs: 1e9 });
206 m.stop();
207 const t0 = Date.parse("2026-11-11T01:00:00Z");
208 for (let i = 0; i < 5; i++) await m.tick(new Date(t0 + i * 60_000));
209 expect(calls).toBe(2);
210 const rows = latest(app.db, 10).filter((r) => r.duty === "t-runs");
211 expect(rows.filter((r) => r.outcome === "ok")).toHaveLength(2);
212 const stops = rows.filter((r) => r.outcome.startsWith("budget:"));
213 expect(stops).toHaveLength(1);
214 expect(stops[0]!.outcome).toContain("runs per day");
215 await m.tick(new Date("2026-11-12T00:00:01Z"));
216 expect(calls).toBe(3);
217 });
218
219 test("a duty past its dollars budget is skipped and the row names dollars", async () => {
220 clear();
221 let calls = 0;
222 const duty: Duty = { name: "t-dollars", intervalMs: 1, budget: tight(1000, 1), run: () => (calls++, { findings: [], actions: [], dollars: 0.6 }) };
223 const m = startMaintain(app, { duties: [duty], everyMs: 1e9 });
224 m.stop();
225 const t0 = Date.parse("2026-11-11T01:00:00Z");
226 for (let i = 0; i < 4; i++) await m.tick(new Date(t0 + i * 60_000));
227 expect(calls).toBe(2);
228 const [row] = latest(app.db, 1);
229 expect(row!.outcome).toContain("budget: dollars per day");
230 });
231
232 test("a stored budget replaces the declared one, and a blank takes it back; the registered duties all declare a reason", async () => {
233 clear();
234 const { setBudget, budgetOf, parseBudget } = await import("../src/maintain/budget.ts");
235 const duty: Duty = { name: "t-set", intervalMs: 1, budget: tight(5), run: () => ({ findings: [], actions: [] }) };
236 setBudget(app.db, duty, "runs", 1, "u");
237 expect(budgetOf(app.db, duty).runsPerDay).toBe(1);
238 setBudget(app.db, duty, "runs", null, "u");
239 expect(budgetOf(app.db, duty).runsPerDay).toBe(5);
240 expect(parseBudget("runs", "0")).toBeUndefined();
241 expect(parseBudget("runs", "1e3")).toBeUndefined();
242 expect(parseBudget("dollars", "0.5")).toBe(0.5);
243 for (const d of duties) expect(d.budget.reason.length).toBeGreaterThan(10);
244 });
245
246 test("POST /maintenance/budget moves a budget for an admin, refuses a bad number, and the page shows the reason", async () => {
247 clear();
248 const { createSession } = await import("../src/core/sessions.ts");
249 const adminId = (app.db.query("SELECT id FROM user WHERE username = 'alice'").get() as { id: string }).id;
250 const cookie = `forge_session=${createSession(app.db, adminId).token}`;
251 const post = (body: Record<string, string>) =>
252 fetch(`${servers.url}/maintenance/budget`, { method: "POST", redirect: "manual", headers: { cookie, origin: servers.url, "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(body) });
253 const name = duties[0]!.name;
254 expect((await post({ duty: name, runs: "7", dollars: "2.5" })).status).toBe(303);
255 const { budgetOf } = await import("../src/maintain/budget.ts");
256 expect(budgetOf(app.db, duties[0]!)).toEqual({ runsPerDay: 7, dollarsPerDay: 2.5 });
257 await post({ duty: name, runs: "0", dollars: "2.5" });
258 expect(budgetOf(app.db, duties[0]!).runsPerDay).toBe(7);
259 await post({ duty: name, runs: "", dollars: "" });
260 expect(budgetOf(app.db, duties[0]!).runsPerDay).toBe(duties[0]!.budget.runsPerDay);
261 const page = await (await fetch(`${servers.url}/maintenance`, { headers: { cookie } })).text();
262 expect(page).toContain(duties[0]!.budget.reason);
263 });
264
265 test("only an admin moves a budget: a non-admin, a guest and a cross-origin post are refused and nothing changes", async () => {
266 const { createSession } = await import("../src/core/sessions.ts");
267 const { budgetOf } = await import("../src/maintain/budget.ts");
268 const id = (n: string) => (app.db.query("SELECT id FROM user WHERE username = ?1").get(n) as { id: string }).id;
269 const own = { ...budgetOf(app.db, duties[0]!) };
270 const post = (cookie: string, origin: string) =>
271 fetch(`${servers.url}/maintenance/budget`, {
272 method: "POST", redirect: "manual",
273 headers: { ...(cookie ? { cookie } : {}), origin, "content-type": "application/x-www-form-urlencoded" },
274 body: new URLSearchParams({ duty: duties[0]!.name, runs: "3", dollars: "3" }),
275 });
276 const bobCookie = `forge_session=${createSession(app.db, id("bob")).token}`;
277 const aliceCookie = `forge_session=${createSession(app.db, id("alice")).token}`;
278 await post(bobCookie, servers.url);
279 expect(budgetOf(app.db, duties[0]!)).toEqual(own);
280 await post("", servers.url);
281 expect(budgetOf(app.db, duties[0]!)).toEqual(own);
282 app.db.query("UPDATE user SET is_agent = 1, is_admin = 1 WHERE username = 'bob'").run();
283 await post(bobCookie, servers.url);
284 expect(budgetOf(app.db, duties[0]!)).toEqual(own);
285 app.db.query("UPDATE user SET is_agent = 0, is_admin = 0 WHERE username = 'bob'").run();
286 const cross = await post(aliceCookie, "https://evil.example");
287 expect(cross.status).toBeGreaterThanOrEqual(400);
288 expect(budgetOf(app.db, duties[0]!)).toEqual(own);
289 });
290
291 test("a stored dollars budget is enforced, and a value equal to the declared one is not stored", async () => {
292 clear();
293 const { setBudget, budgetOf } = await import("../src/maintain/budget.ts");
294 let calls = 0;
295 const duty: Duty = { name: "t-stored", intervalMs: 1, budget: tight(1000, 100), run: () => (calls++, { findings: [], actions: [], dollars: 0.6 }) };
296 setBudget(app.db, duty, "dollars", 1, "u");
297 expect(budgetOf(app.db, duty).dollarsPerDay).toBe(1);
298 const m = startMaintain(app, { duties: [duty], everyMs: 1e9 });
299 m.stop();
300 const t0 = Date.parse("2026-11-11T01:00:00Z");
301 for (let i = 0; i < 4; i++) await m.tick(new Date(t0 + i * 60_000));
302 expect(calls).toBe(2);
303 expect(latest(app.db, 1)[0]!.outcome).toContain("of $1.00 spent");
304 setBudget(app.db, duty, "dollars", 100, "u");
305 setBudget(app.db, duty, "runs", 1000, "u");
306 expect(app.db.query("SELECT COUNT(*) AS n FROM forge_setting WHERE key LIKE 'maintain.t-stored.%'").get()).toEqual({ n: 0 });
307 });
308
309 test("the budget screen moves a budget for an admin who is a person and shows nothing to anyone else", async () => {
310 const { budgetLines, moveBudget } = await import("../src/tui/screens/dutybudgets.ts");
311 const { budgetOf } = await import("../src/maintain/budget.ts");
312 const id = (app.db.query("SELECT id FROM user WHERE username = 'alice'").get() as { id: string }).id;
313 const as = (user: unknown) => ({ user, db: app.db }) as never;
314 const adminS = as({ isAdmin: true, kind: "person", id, username: "alice" });
315 const before = budgetOf(app.db, duties[0]!).runsPerDay;
316 expect(budgetLines(adminS, 0).some((l) => l.startsWith("> " + duties[0]!.name))).toBe(true);
317 moveBudget(adminS, 0, "runs", 1);
318 expect(budgetOf(app.db, duties[0]!).runsPerDay).toBe(before + 1);
319 moveBudget(adminS, 0, "runs", 0, true);
320 expect(budgetOf(app.db, duties[0]!).runsPerDay).toBe(duties[0]!.budget.runsPerDay);
321 for (const who of [as({ isAdmin: false, kind: "person", id }), as({ isAdmin: true, kind: "agent", id }), as(undefined)]) {
322 expect(budgetLines(who, 0)).toEqual([]);
323 expect(moveBudget(who, 0, "runs", 1)).toBe("");
324 }
325 expect(budgetOf(app.db, duties[0]!).runsPerDay).toBe(duties[0]!.budget.runsPerDay);
326 });
327
328 test("the budget screen's keys move the chosen duty's budget, and a refused session's keys do nothing", async () => {
329 const { dutyBudgetScreen } = await import("../src/tui/screens/dutybudgets.ts");
330 const { budgetOf } = await import("../src/maintain/budget.ts");
331 const id = (app.db.query("SELECT id FROM user WHERE username = 'alice'").get() as { id: string }).id;
332 let popped = 0;
333 const ctx = { invalidate: () => {}, pop: () => popped++, theme: {} } as never;
334 const press = (screen: { key?: (e: never) => boolean }, key: string) => screen.key!({ key, ctrl: false, alt: false } as never);
335 const d = duties[0]!;
336 const own = { ...budgetOf(app.db, d) };
337 const screen = dutyBudgetScreen({ user: { isAdmin: true, kind: "person", id }, db: app.db } as never, ctx);
338 press(screen, "+");
339 expect(budgetOf(app.db, d).runsPerDay).toBe(own.runsPerDay + 1);
340 press(screen, "-"); press(screen, "-");
341 expect(budgetOf(app.db, d).runsPerDay).toBe(own.runsPerDay - 1);
342 press(screen, "]");
343 expect(budgetOf(app.db, d).dollarsPerDay).toBe(own.dollarsPerDay + 1);
344 press(screen, "[");
345 expect(budgetOf(app.db, d).dollarsPerDay).toBe(own.dollarsPerDay);
346 press(screen, "+"); press(screen, "0");
347 expect(budgetOf(app.db, d)).toEqual(own);
348 expect(press(screen, "z")).toBe(false);
349 press(screen, "q");
350 expect(popped).toBe(1);
351 const agent = dutyBudgetScreen({ user: { isAdmin: true, kind: "agent", id }, db: app.db } as never, ctx);
352 press(agent, "+"); press(agent, "]");
353 expect(budgetOf(app.db, d)).toEqual(own);
354 });
test/palette-roster.test.ts +1
@@ -119,6 +119,7 @@ test("a site-admin person is offered the whole roster", () => {
119119 ["maintenance", "Maintenance", "prepare the forge for a power-off, check the drain, or end maintenance"],
120120 ["maintenance-runs", "Maintenance duty runs", "what each duty found, did and probed"],
121121 ["maintenance-duties", "Pause or resume a maintenance duty", "switch a duty off, or on again after it paused itself"],
122 ["maintenance-budgets", "Set a maintenance duty's budget", "runs per day and dollars per day a duty may use"],
122123 ["crawlers", "Crawlers", "their share of answering time, and who was refused"],
123124 ["scrapes", "Scrapes", "what was read by repository, and groups in a running capture"],
124125 ["traffic", "Traffic", "who is using the forge's answering time, and the top readers"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs