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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11206-a-maintenance-duty-stops-at-its-budget-r → main 10 commits fork 2 threads · 2 unresolved
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"; | ||
| 32 | 32 | import { adminDataPage, appDataPage, dataPage } from "../web/pages/data.ts"; |
| 33 | 33 | import { fleetAct, fleetReceipt, fleetUndo } from "../web/pages/fleet-ops.ts"; |
| 34 | 34 | import { maintenanceDuty, maintenancePage, maintenancePrepare, maintenanceResume } from "../web/pages/maintenance.ts"; |
| 35 | import { maintenanceBudget } from "../web/pages/maintenance_budgets.ts"; | |
| 35 | 36 | import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts"; |
| 36 | 37 | import { agentStartSet, billingLinksSet, limitsPage, limitsSet, quotasSet, signupSet, inviteMint, inviteRevoke } from "../web/pages/admin-limits.ts"; |
| 37 | 38 | import { plansSet } from "../web/settings/plans.ts"; |
| @@ -995,6 +996,7 @@ export function buildRouter(): Router<Handler> { | ||
| 995 | 996 | router.add("GET", "/maintenance", maintenancePage); |
| 996 | 997 | router.add("POST", "/maintenance/prepare", maintenancePrepare); |
| 997 | 998 | router.add("POST", "/maintenance/resume", maintenanceResume); |
| 999 | router.add("POST", "/maintenance/budget", maintenanceBudget); | |
| 998 | 1000 | router.add("POST", "/maintenance/duties/:name", maintenanceDuty); |
| 999 | 1001 | router.add("GET", "/resilience/nightly/:job", nightlyOutputPage); |
| 1000 | 1002 | // 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"; | ||
| 6 | 6 | export const duty: Duty = { |
| 7 | 7 | name: "efficiency-watcher", |
| 8 | 8 | 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" }, | |
| 9 | 10 | run({ app, now }) { |
| 10 | 11 | return app === undefined ? { findings: [], actions: [] } : watch(app, now.getTime()); |
| 11 | 12 | }, |
src/maintain/duties/flow_keeper.ts +1
| @@ -34,6 +34,7 @@ export function flowKeeper(deps: Deps = {}): Duty { | ||
| 34 | 34 | return { |
| 35 | 35 | name: "flow-keeper", |
| 36 | 36 | 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" }, | |
| 37 | 38 | async run({ db, now, app }) { |
| 38 | 39 | const findings: Finding[] = []; |
| 39 | 40 | const actions: string[] = []; |
src/maintain/duties/rule_watcher.ts +1
| @@ -19,6 +19,7 @@ function reposOf(db: Database): Map<string, Pick<Repo, "id" | "ownerId">> { | ||
| 19 | 19 | export const duty: Duty = { |
| 20 | 20 | name: "rule-watcher", |
| 21 | 21 | 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" }, | |
| 22 | 23 | run({ db, bus, now }) { |
| 23 | 24 | const w: Watch = { db, bus, repos: reposOf(db), now }; |
| 24 | 25 | // 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 | ||
| 8 | 8 | export const duty: Duty = { |
| 9 | 9 | name: "pulls-waiting", |
| 10 | 10 | 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" }, | |
| 11 | 12 | run({ db, now }) { |
| 12 | 13 | const cutoff = new Date(now.getTime() - DAY_MS).toISOString(); |
| 13 | 14 | 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"; | ||
| 6 | 6 | export const duty: Duty = { |
| 7 | 7 | name: "visitor-retention", |
| 8 | 8 | 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" }, | |
| 9 | 10 | run({ db, now }) { |
| 10 | 11 | const removed = purgeVisitors(db, now.getTime()); |
| 11 | 12 | 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 { | ||
| 23 | 23 | readonly findings: readonly Finding[]; |
| 24 | 24 | /** What the duty did about it, one line each. */ |
| 25 | 25 | 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; | |
| 26 | 35 | } |
| 27 | 36 | |
| 28 | 37 | export interface DutyContext { |
| @@ -38,6 +47,7 @@ export interface Duty { | ||
| 38 | 47 | /** Lowercase words and dashes; the row's `duty`. */ |
| 39 | 48 | readonly name: string; |
| 40 | 49 | readonly intervalMs: number; |
| 50 | readonly budget: DutyBudget; | |
| 41 | 51 | readonly run: (ctx: DutyContext) => DutyResult | Promise<DutyResult>; |
| 42 | 52 | } |
| 43 | 53 |
src/maintain/runner.ts +10 −1
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | |
| 7 | 7 | import type { App } from "../app.ts"; |
| 8 | 8 | import { during } from "../perf/jobs.ts"; |
| 9 | import { overBudget, stoppedToday } from "./budget.ts"; | |
| 9 | 10 | import { clip, refusal, validName, type Duty } from "./duty.ts"; |
| 10 | 11 | import { duties as registered } from "./registry.ts"; |
| 11 | 12 | import { isPaused, pauseOnErrors } from "./pause.ts"; |
| @@ -42,6 +43,7 @@ export function startMaintain(app: App, options: { duties?: readonly Duty[]; eve | ||
| 42 | 43 | let findings: Awaited<ReturnType<Duty["run"]>>["findings"] = []; |
| 43 | 44 | let actions: readonly string[] = []; |
| 44 | 45 | let outcome = "ok"; |
| 46 | let dollars = 0; | |
| 45 | 47 | try { |
| 46 | 48 | // The duty keeps its `running` slot until it really settles, so a timed-out duty never overlaps itself. |
| 47 | 49 | 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 | ||
| 56 | 58 | else { |
| 57 | 59 | findings = result.findings; |
| 58 | 60 | actions = result.actions.map(clip); |
| 61 | dollars = typeof result.dollars === "number" && Number.isFinite(result.dollars) && result.dollars > 0 ? result.dollars : 0; | |
| 59 | 62 | } |
| 60 | 63 | } catch (error) { |
| 61 | 64 | outcome = clip(`failed: ${error instanceof Error ? error.message : String(error)}`); |
| 62 | 65 | } |
| 63 | 66 | 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 }); | |
| 65 | 68 | prune(app.db, duty.name, options.keep ?? KEEP_PER_DUTY); |
| 66 | 69 | if (pauseOnErrors(app.db, duty.name)) app.log.error("maintenance duty paused after errors", { duty: duty.name }); |
| 67 | 70 | } catch (error) { |
| @@ -74,6 +77,12 @@ export function startMaintain(app: App, options: { duties?: readonly Duty[]; eve | ||
| 74 | 77 | const last = lastStarted(app.db, duty.name); |
| 75 | 78 | if (last !== undefined && now.getTime() - last < duty.intervalMs) continue; |
| 76 | 79 | 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 | } | |
| 77 | 86 | await runOne(duty, now); |
| 78 | 87 | // Duties take turns: the loop turns between one and the next. |
| 79 | 88 | await new Promise((resolve) => setImmediate(resolve)); |
src/maintain/runs.ts +5 −3
| @@ -16,10 +16,10 @@ export interface RunRow { | ||
| 16 | 16 | |
| 17 | 17 | export function record( |
| 18 | 18 | 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 }, | |
| 20 | 20 | ): void { |
| 21 | 21 | 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)", | |
| 23 | 23 | ).run( |
| 24 | 24 | r.duty, |
| 25 | 25 | r.started.toISOString(), |
| @@ -28,6 +28,7 @@ export function record( | ||
| 28 | 28 | JSON.stringify(r.actions), |
| 29 | 29 | JSON.stringify(r.findings.flatMap((f) => (f.probe ? [f.probe] : []))), |
| 30 | 30 | r.outcome, |
| 31 | r.dollars ?? 0, | |
| 31 | 32 | ); |
| 32 | 33 | } |
| 33 | 34 | |
| @@ -36,8 +37,9 @@ export function prune(db: Database, duty: string, keep: number): void { | ||
| 36 | 37 | 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); |
| 37 | 38 | } |
| 38 | 39 | |
| 40 | /** When a duty last really started; a budget stop is not a start. */ | |
| 39 | 41 | 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 | |
| 41 | 43 | | { started: string } |
| 42 | 44 | | null; |
| 43 | 45 | 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"; | ||
| 58 | 58 | import { entry as slowPagesEntry } from "./slowpages.ts"; |
| 59 | 59 | import { entry as maintenance } from "./maintenance.ts"; |
| 60 | 60 | import { entry as maintainRuns } from "./maintainruns.ts"; |
| 61 | import { entry as dutyBudgets } from "./dutybudgets.ts"; | |
| 61 | 62 | import { entry as dutySwitch } from "./dutyswitch.ts"; |
| 62 | 63 | import { entry as mirrorPacksEntry } from "./mirrorpacks.ts"; |
| 63 | 64 | import { entry as packReposEntry } from "./packrepos.ts"; |
| @@ -122,6 +123,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 122 | 123 | maintenance, |
| 123 | 124 | maintainRuns, |
| 124 | 125 | dutySwitch, |
| 126 | dutyBudgets, | |
| 125 | 127 | mirrorPacksEntry, |
| 126 | 128 | packReposEntry, |
| 127 | 129 | valves, |
src/web/pages/maintenance.ts +3 −2
| @@ -15,6 +15,7 @@ import { field as formField, readForm } from "../form.ts"; | ||
| 15 | 15 | import { html, type Html } from "../html.ts"; |
| 16 | 16 | import { flashTo, page } from "../layout.ts"; |
| 17 | 17 | import { badge, btn, field } from "../ui.ts"; |
| 18 | import { budgetOfRow } from "./maintenance_budgets.ts"; | |
| 18 | 19 | |
| 19 | 20 | function admin(ctx: Ctx): Response | undefined { |
| 20 | 21 | if (!ctx.auth.user) return signInFirst(ctx); |
| @@ -36,10 +37,10 @@ export async function maintenancePage(ctx: Ctx): Promise<Response> { | ||
| 36 | 37 | <dl id="maintenance-duties" class="status-rows"> |
| 37 | 38 | ${duties(ctx.app.config.dataDir).map((d) => fact(d.name, html`${d.detail}${d.overdue ? html` ${badge("overdue", { tone: "danger" })}` : null}`))} |
| 38 | 39 | ${dutyStates(ctx.app.db).map((d) => |
| 39 | fact(d.name, html`${d.detail} | |
| 40 | html`${fact(d.name, html`${d.detail} | |
| 40 | 41 | <form method="post" action="/maintenance/duties/${d.name}" class="inline"> |
| 41 | 42 | <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)}`, | |
| 43 | 44 | )} |
| 44 | 45 | </dl> |
| 45 | 46 | ${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>`; | |
| 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 |
||
| 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"; | ||
| 13 | 13 | import { startMaintain } from "../src/maintain/runner.ts"; |
| 14 | 14 | import { dutySwitchScreen, entry as switchEntry, flipDuty } from "../src/tui/screens/dutyswitch.ts"; |
| 15 | 15 | import { quietLogs } from "./helpers/globals.ts"; |
| 16 | const ROOMY = { runsPerDay: 100000, dollarsPerDay: 10000, reason: "test" }; | |
| 16 | 17 | |
| 17 | 18 | quietLogs(); |
| 18 | 19 | let root = ""; |
| @@ -134,7 +135,7 @@ test("the terminal switch is offered to an admin person only and flips the duty | ||
| 134 | 135 | test("a paused duty does not run; three errors in a row pause it saying the last error; resume starts the count again", async () => { |
| 135 | 136 | app.db.exec("DELETE FROM maintenance_run; DELETE FROM maintenance_duty_state"); |
| 136 | 137 | 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}`); } }; | |
| 138 | 139 | const m = startMaintain(app, { duties: [bad], everyMs: 1e9 }); |
| 139 | 140 | m.stop(); |
| 140 | 141 | 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 | ||
| 161 | 162 | test("an ok run in between keeps the count from reaching three", async () => { |
| 162 | 163 | app.db.exec("DELETE FROM maintenance_run; DELETE FROM maintenance_duty_state"); |
| 163 | 164 | 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"); } }; | |
| 165 | 166 | const m = startMaintain(app, { duties: [flaky], everyMs: 1e9 }); |
| 166 | 167 | m.stop(); |
| 167 | 168 | 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"; | ||
| 13 | 13 | import type { Duty } from "../src/maintain/duty.ts"; |
| 14 | 14 | import { quietLogs } from "./helpers/globals.ts"; |
| 15 | 15 | |
| 16 | const ROOMY = { runsPerDay: 100000, dollarsPerDay: 10000, reason: "test" }; | |
| 16 | 17 | quietLogs(); |
| 17 | 18 | |
| 18 | 19 | let root = ""; |
| @@ -42,7 +43,7 @@ const clear = () => app.db.exec("DELETE FROM maintenance_run"); | ||
| 42 | 43 | test("a duty runs when due, not before its interval, and leaves a row with findings and probes", async () => { |
| 43 | 44 | clear(); |
| 44 | 45 | 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"] }) }; | |
| 46 | 47 | const m = startMaintain(app, { duties: [duty], everyMs: 1e9 }); |
| 47 | 48 | m.stop(); |
| 48 | 49 | const t0 = new Date("2026-11-11T00:00:00Z"); |
| @@ -62,7 +63,7 @@ test("a duty never runs twice at once", async () => { | ||
| 62 | 63 | let live = 0; |
| 63 | 64 | let worst = 0; |
| 64 | 65 | const duty: Duty = { |
| 65 | name: "t-once", intervalMs: 0, | |
| 66 | name: "t-once", intervalMs: 0, budget: ROOMY, | |
| 66 | 67 | run: async () => { live++; worst = Math.max(worst, live); await new Promise((r) => setTimeout(r, 20)); live--; return { findings: [], actions: [] }; }, |
| 67 | 68 | }; |
| 68 | 69 | const m = startMaintain(app, { duties: [duty], everyMs: 1e9 }); |
| @@ -73,8 +74,8 @@ test("a duty never runs twice at once", async () => { | ||
| 73 | 74 | |
| 74 | 75 | test("a finding without a probe is refused and the outcome says so", async () => { |
| 75 | 76 | 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: [] }) }; | |
| 78 | 79 | const m = startMaintain(app, { duties: [bad, nan], everyMs: 1e9 }); |
| 79 | 80 | m.stop(); |
| 80 | 81 | await m.tick(); |
| @@ -89,8 +90,8 @@ test("a finding without a probe is refused and the outcome says so", async () => | ||
| 89 | 90 | |
| 90 | 91 | test("a duty that throws is recorded as failed and the next still runs", async () => { |
| 91 | 92 | 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: [] }) }; | |
| 94 | 95 | const m = startMaintain(app, { duties: [boom, fine], everyMs: 1e9 }); |
| 95 | 96 | m.stop(); |
| 96 | 97 | await m.tick(); |
| @@ -126,7 +127,7 @@ test("the example duty's count searches an existing index, not the table", () => | ||
| 126 | 127 | |
| 127 | 128 | test("GET /api/v1/maintenance/runs lists the latest rows for an admin only", async () => { |
| 128 | 129 | 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 }); | |
| 130 | 131 | m.stop(); |
| 131 | 132 | await m.tick(); |
| 132 | 133 | 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 | ||
| 143 | 144 | clear(); |
| 144 | 145 | let release = () => {}; |
| 145 | 146 | 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: [] }) }; | |
| 148 | 149 | const m = startMaintain(app, { duties: [hung, fine], everyMs: 1e9, timeoutMs: 20 }); |
| 149 | 150 | m.stop(); |
| 150 | 151 | await m.tick(); |
| @@ -158,7 +159,7 @@ test("a hung duty is recorded as timed out and the next duty still runs, without | ||
| 158 | 159 | |
| 159 | 160 | test("an empty finding reads refused:", async () => { |
| 160 | 161 | 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: [] }) }; | |
| 162 | 163 | const m = startMaintain(app, { duties: [d], everyMs: 1e9 }); |
| 163 | 164 | m.stop(); |
| 164 | 165 | await m.tick(); |
| @@ -167,8 +168,8 @@ test("an empty finding reads refused:", async () => { | ||
| 167 | 168 | |
| 168 | 169 | test("old rows are pruned to a cap per duty, and other duties keep theirs", async () => { |
| 169 | 170 | 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: [] }) }; | |
| 172 | 173 | const m = startMaintain(app, { duties: [a, b], everyMs: 1e9, keep: 3 }); |
| 173 | 174 | m.stop(); |
| 174 | 175 | 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 | ||
| 179 | 180 | |
| 180 | 181 | test("a duty name that is not lowercase words and dashes, or is registered twice, is refused at start", () => { |
| 181 | 182 | 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"); | |
| 184 | 185 | }); |
| 185 | 186 | |
| 186 | 187 | test("the terminal screen lists runs for an admin and nothing for anyone else", async () => { |
| 187 | 188 | 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 }); | |
| 189 | 190 | m.stop(); |
| 190 | 191 | await m.tick(); |
| 191 | 192 | 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", | ||
| 194 | 195 | expect(runLines(as(false))).toEqual([]); |
| 195 | 196 | expect(runLines({ user: undefined, db: app.db } as never)).toEqual([]); |
| 196 | 197 | }); |
| 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", () => { | ||
| 119 | 119 | ["maintenance", "Maintenance", "prepare the forge for a power-off, check the drain, or end maintenance"], |
| 120 | 120 | ["maintenance-runs", "Maintenance duty runs", "what each duty found, did and probed"], |
| 121 | 121 | ["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"], | |
| 122 | 123 | ["crawlers", "Crawlers", "their share of answering time, and who was refused"], |
| 123 | 124 | ["scrapes", "Scrapes", "what was read by repository, and groups in a running capture"], |
| 124 | 125 | ["traffic", "Traffic", "who is using the forge's answering time, and the top readers"], |
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.