@rackbops/plugin-tracker 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +50 -9
  2. package/dist/plugin.js +418 -101
  3. package/package.json +9 -2
package/README.md CHANGED
@@ -12,7 +12,9 @@ your own tasks from the web, under the commands' own rules -- the admin view and
12
12
  slice 3) -- the JSON task API with personal API tokens (#80, slice 4), for a program acting as
13
13
  you, such as a later intake agent (plan E10) -- and the one-off research request (#82, plan E8,
14
14
  category 5): a question looked into on the web by a model run, checked by a second run against its
15
- sources, and DMed once, with its claims kept as findings. The model runs never happen here: they go
15
+ sources, and DMed once, with its claims kept as findings -- and an admin raising one person's
16
+ daily model ceiling from the web (#82, plan 5.7), with an operator switch that turns the daily
17
+ ceilings off for the alpha and an admin page of daily model usage to evaluate it by. The model runs never happen here: they go
16
18
  through city-hall to Rackbops/docket-runner on roshne's own host (plan 5.12), and the whole execute
17
19
  lane stays off until the city-hall Executor is configured (Configuration below).
18
20
 
@@ -124,8 +126,13 @@ says research is not available; it does not list the waiting ones (`/tasks` does
124
126
 
125
127
  **Budgets** (plan 5.7, docket's defaults): 2 USD and 20 model calls a person a day, 10 USD and 100
126
128
  calls in all. At a ceiling the person's research waits until midnight Eastern; the person gets one
127
- DM and the admins one each. Reminders, renewals and prices never count. There is no web control to
128
- raise a person's ceiling yet.
129
+ DM and the admins one each. Reminders, renewals and prices never count. An admin can raise one
130
+ person's ceiling from their admin page (0.13.0, below); the global ceiling still applies to everyone
131
+ together. **For the alpha the ceilings can be switched off** (0.14.0; roshne, 2026-10-02, choosing option
132
+ A: "A. but lets build in an unlimited budget "flag" during alpha, evaluate usage during alpha, then
133
+ test budgets during beta"): with `TRACKER_BUDGET_UNLIMITED` on (Configuration, below) no daily ceiling holds a run,
134
+ while each Job's own caps (15 turns and 1 USD for a research run) still bound it and every run is
135
+ still charged, so `/admin/usage` (below) shows what the defaults would have held.
129
136
 
130
137
  **Findings.** A passed research answer's claims are stored with their first source (docket's
131
138
  `findings`), shown on the task's page to its owner, accepted recipients and admins (docket's
@@ -262,8 +269,36 @@ the function the command path runs:
262
269
  - **Lift** a decline block in force: docket's `liftBlock`, recorded as that admin's lift.
263
270
  - **Revoke an API token** of anyone's, from their page, which lists their tokens (never the
264
271
  secret). It stops working at once; the owner is not told. Logged by token id.
272
+ - **Raise a person's daily ceiling** (0.13.0, plan 5.7: "The admin raises a person's ceiling from
273
+ the web area"). Their page shows what they spent today (the budget day ends at midnight Eastern),
274
+ their ceiling now, a form with both numbers -- dollars to the cent and whole model calls -- and
275
+ the latest 20 changes an admin made, newest first (older ones stay in the table). Each number
276
+ must be at least the default (2 USD, 20 calls) and at most the global ceiling (10 USD, 100
277
+ calls), which docket checks first anyway; those bounds are proposed on #82, roshne's to confirm.
278
+ Setting exactly the default is a reset to it, not a raise.
279
+ **A raise stands until an admin changes it** -- raises it again or presses **Back to the
280
+ default**. That default is proposed on #82 and is roshne's to confirm; a today-only raise
281
+ would be a small change (`limitsOf` and `isRaised` plus a clock in `Ceilings`, and the page's
282
+ badge). Enforcement stays docket's: the
283
+ execute lane hands docket's `budgetHold` a policy whose `personFor` reads the person's newest
284
+ change, before every run, so a raise lets a held request run on the next tick. docket
285
+ sends its ceiling notices once a day per person (`noticeOnce`, key `budget:person:<id>:<day>`),
286
+ so every change -- raise or reset -- deletes that person's key for the current budget day in the
287
+ same transaction: meeting the new ceiling the same day DMs the person and the admins again (plan
288
+ 5.7: "nothing fails silently"). Every change is an append-only `ceiling_changes` row (who, for whom, the values,
289
+ when) and a log line (`u1 set u2's daily ceiling to 4 USD / 40 calls (c1)`).
265
290
  - **Remove from the tracker**: forget-me for that person (below), with the same confirmation.
266
291
 
292
+ **Usage** (0.14.0). `/admin/usage`, linked from every admin page, shows the model spend per budget
293
+ day (midnight Eastern) for the last 14 days, newest first: dollars and calls for everyone together,
294
+ then per person. Each row is measured against the default ceilings, not a person's raise -- a
295
+ "would have hit the 20 call limit" (or "2.00 USD") marker on a day that reached one, and how many
296
+ calls were charged after it had, roughly what that ceiling would have held (docket checks before a
297
+ run and charges at its end). It reads docket's `usage` table, which every run is charged to whether
298
+ budgets are on or off -- the way to evaluate the alpha with `TRACKER_BUDGET_UNLIMITED` on
299
+ (Configuration, below). Read-only, and like every admin page answers anyone who is not an admin
300
+ with the unknown page's 404.
301
+
267
302
  **Deliveries** (0.10.0). `/admin/deliveries`, linked from every admin page, lists the DMs that
268
303
  settled `failed`, `unconfirmed` or `deferred` in the last 30 days, newest first, at most 200 rows
269
304
  (the page says how many matched when the bound cuts it). Each row: the task (its title, linking to
@@ -273,8 +308,11 @@ have gone out and is never resent; `deferred` is still owed (a recipient whose d
273
308
  say). Before 0.10.0 these showed only in the bot log, where the warnings still go. The page is
274
309
  read-only, and like every admin page answers anyone who is not an admin with the unknown page's 404.
275
310
 
276
- Not here yet, though plan 5.10 lists them: grants, budgets and retry (they belong to the execute
277
- lane, which is not built) and an admin pause of someone else's task.
311
+ Not here yet, though plan 5.10 lists them: grants and retry, and an admin pause of someone else's
312
+ task. Of budgets, raising one person's ceiling and the usage page are here; the global ceiling and
313
+ the defaults are docket's constants, and turning budgets off is the operator's
314
+ `TRACKER_BUDGET_UNLIMITED`, not a web control. While it is on, the ceiling section says the ceiling
315
+ is not enforced; a change is still recorded and applies once it is off.
278
316
 
279
317
  **Forget me** (plan 5.8). A signed-in person erases themselves from `/forget` (web only: the plan
280
318
  asks for no Discord command, so without `TRACKER_WEB_URL` the only path is an admin with the web
@@ -286,14 +324,15 @@ in one SQLite transaction in the write queue, **deleted, not archived**:
286
324
  - on everyone else's tasks: their recipient rows, their replies and answers, the history rows they
287
325
  made or that name them (an invitation of them, a pause for them), the run events and deliveries
288
326
  of DMs to them (docket's `deleteDeliveries`), charges made for them, and their pause rows;
289
- - the budget notices kept for them (`budget:person:<id>:...`);
327
+ - the budget notices kept for them (`budget:person:<id>:...`) and the changes to their ceiling;
290
328
  - every decline block they are either side of;
291
329
  - their admission, delivery health, web sessions, unused sign-in links and API tokens, and their
292
330
  person row.
293
331
 
294
332
  What stays, and why: another person's task that they received stays that owner's; one paused only
295
333
  because DMs to them failed goes back on. Another person's row keeps its own audit fact but not their
296
- id -- `admitted_by` of someone they admitted, and `lifted_by` of a block they lifted, become
334
+ id -- `admitted_by` of someone they admitted, `lifted_by` of a block they lifted, and `set_by` of a
335
+ change they made to someone's ceiling become
297
336
  `forgotten` ("an admin since forgotten"; null already means "the configuration"). Text is matched
298
337
  only in the forms the code writes a person's id into -- docket's consent rows (`u5`, `u5 24h`), the
299
338
  tracker's `u5: ...` removals and `delivery to {u5} paused` pauses, docket's `u5 <message>` delivery
@@ -477,6 +516,7 @@ $ curl -s https://clerk.example.com/tracker/api/v1/tasks/t9 -H "Authorization: B
477
516
  | Store | `src/store.ts`, `src/schema.ts` | docket's `Store` port on `bun:sqlite`, in `<dataDir>/tracker/tracker.sqlite` (WAL). A Discord id belongs to at most one user. Schema versioned by `PRAGMA user_version`; a shipped migration is never edited. Schema 5 (0.9.0) is docket 0.4.0's: a run's `record`, a series point's `key`, `deliveries` (from `delivery_claims`), `usage` and `notices`. Schema 6 (0.11.0) is docket 0.5.0's, all additive: `findings`, a charge's `usage.key`, and the Executor's `executor_jobs`. Forget-me erases a person's findings and their runs' Job records too. `store.test.ts` runs docket's `STORE_CONTRACT` against it. |
478
517
  | Notify lane | `src/notify-lane.ts` | docket's `Lanes.tickNotify` on two host ticks, every 60 s, one pass per task with work (a due run, a run in flight, a DM owed or claimed), each under that task's lock through a view of the store limited to that task: `notify` runs every type but the page readers, `poll` runs only them (`price`) with the Fetch port, so a slow page never holds up a reminder. A task the execute tick holds is left for the next notify tick, never waited on. Registers `reminder`, `renewal`, `price` and `research`. |
479
518
  | Execute lane | `src/execute-lane.ts`, `src/executor.ts`, `src/research.ts` | Only when the city-hall Executor is configured: a third host tick, `execute`, that starts docket's `Lanes.tickExecute` in the background (one at a time, at most once a minute) over every task with a due execute-lane run, reserving those tasks and then holding their locks in id order, through one docket `Lanes` kept while the plugin is active (docket keeps its usage-limit pause on it). The Executor is city-hall#18's source pair (above), its I/O injected; `executor_jobs` keeps each Job key's city-hall id. `/research`'s rules. |
519
+ | Budgets, usage | `src/ceilings.ts`, `src/usage.ts`; `src/web/ceiling-pages.ts`, `src/web/usage-pages.ts` | The execute lane's `BudgetPolicy` (docket's defaults plus each person's raise, or no ceiling at all while `TRACKER_BUDGET_UNLIMITED` is on), the ceiling changes, and the pure usage report `/admin/usage` renders from docket's `usage` table. |
480
520
  | Page reads | `src/fetch.ts` | docket's `Fetch` port for `price`: http or https on the default port, no credentials, every resolved address public (no loopback, private, link-local, CGNAT, multicast or reserved range, IPv4 or IPv6), redirects followed by hand and re-checked (at most 5), 15 s including the name lookup, at most 3 MB kept. The body is then rebuilt in linear time (`src/page.ts`) to just what extraction reads -- JSON-LD, meta tags, and the page with every `<` blanked, so `near` still reads text, attributes and script data -- because docket's extraction patterns take quadratic time on a page of unclosed tags. A read the tick's abort cuts short requeues its run instead of counting a miss. A DNS answer that changes between the check and the read is not caught here. |
481
521
  | Renewals, prices | `src/tracked.ts`, `src/price.ts`, `src/series.ts`, `src/page.ts` | `/renewal`, `/price` and `/task decide`; the series lines of `/task history`. The series (docket's `series` table, schema 1) holds a renewal's paid amounts and a price's readings. |
482
522
  | Delivery | `src/notifier.ts`, `src/buttons.ts` | docket's `Notifier` over `host.dm`, with the buttons. docket claims each (run, person) in the store's `deliveries` before the DM and settles it after. The notifier maps the host's answers to docket's errors: cannot be messaged (50007) or an unknown user is `DeliveryFailedError(msg, true)` (failed for good; 50007 and 10013 count toward the pause); a message refused for its content is a plain `DeliveryFailedError` (retried); a paused person is `ExecutorUnavailableError` (deferred); anything else is rethrown, so docket settles it unconfirmed and never resends it. |
@@ -497,6 +537,7 @@ $ curl -s https://clerk.example.com/tracker/api/v1/tasks/t9 -H "Authorization: B
497
537
  | `TRACKER_ADMIN_DISCORD_IDS` | no | Comma-separated Discord user ids (spaces around commas allowed; an empty entry, as from a trailing comma, is refused and the plugin does not load) made admin at start. Unset = none. Removing an id does not revoke it. |
498
538
  | `TRACKER_GUILD_ID` | no | The Discord server whose members may use the tracker, or a comma-separated list of them (spaces around commas allowed; a repeated id counts once): a member of any listed server passes. Checked through the interaction's client with a single-member lookup per server (no privileged intent), all asked at once; a yes from any server is a member, a no from every server is not, and otherwise the answer is unknown (refused, never revoked). Inside a listed server, the person running a command needs no lookup. Unset = no membership gate, and a warning is logged each time the plugin activates; a malformed or empty entry anywhere in the list refuses to load, naming it. One store and one admission list serve every listed server; there are no per-server admins. |
499
539
  | `TRACKER_WEB_URL` | no | The https origin the bot's HTTP is reached at through its tunnel, e.g. `https://clerk.example.com` (no path). `/web` links and the allowed `Origin` come from it, never from a request's `Host`. Unset = no web area (`/web` says so, the pages answer 404); anything but a bare https origin refuses to load. |
540
+ | `TRACKER_BUDGET_UNLIMITED` | no | Budgets off for the alpha (0.14.0): `true` or `1` means no daily ceiling -- a person's or the global one -- holds a model run, bot-wide. Each Job's own caps still apply, usage is still recorded (see `/admin/usage`), the bot logs one line at start saying so (or, with the execute lane off, that no model work runs), the People and Usage admin pages show a "Budgets off (alpha): unlimited" banner, and the person page's ceiling section shows a "not enforced" note. Unset, empty, `false` or `0` = the ceilings apply (the default); anything else refuses to load. |
500
541
  | `TRACKER_CITY_HALL_URL` | no | The https origin of the city-hall that queues the tracker's model Jobs (no path). |
501
542
  | `TRACKER_CITY_HALL_KEY` | yes | The source bearer key city-hall checks on `/api/execute/jobs` (its `CITY_HALL_API_KEY`, city-hall#18). Never logged. Kept in the instance's own env, never in a repo. |
502
543
  | `TRACKER_CITY_HALL_CAPABILITY` | no | The capability tag every Job names, one only docket-runner carries (plan item 71), e.g. `claude-cli:subscription`. No default: a guessed tag could send the tracker's Jobs to another agent. |
@@ -517,8 +558,8 @@ key>`, so two databases never collide.
517
558
 
518
559
  ## Not yet
519
560
 
520
- - The rest of the web area (E5): sharing a task from the web, and the admin view's grants, budgets
521
- (raising a person's ceiling) and retry. The task API makes reminders, renewals, prices and
561
+ - The rest of the web area (E5): sharing a task from the web, and the admin view's grants and
562
+ retry. The task API makes reminders, renewals, prices and
522
563
  research requests -- not `/task done`, `snooze`, `decide`, `share` or the settings -- and no intake agent uses it yet
523
564
  (E10, deferred). Discord OAuth2 as a second sign-in
524
565
  method, if chosen (plan item 41).
package/dist/plugin.js CHANGED
@@ -3189,6 +3189,208 @@ class Admissions {
3189
3189
  }
3190
3190
  }
3191
3191
 
3192
+ // plugins/tracker/src/admin.ts
3193
+ var NO_SUCH_PERSON = "There is no such person on this tracker's list.";
3194
+ var NO_SUCH_BLOCK = "There is no such block in force.";
3195
+ var LAST_ADMIN = "That is the tracker's only admin: make someone else an admin first.";
3196
+ var BUSY = "The tracker is busy sending reminders; nothing was deleted. Try again in a minute.";
3197
+ var TICK_WAIT_MS = 20000;
3198
+ var CONFIGURED_ADMIN = "That admin is named in TRACKER_ADMIN_DISCORD_IDS, which makes them an admin again at every start: remove them from the configuration first.";
3199
+ var CONFIGURED_SELF = "You are named in TRACKER_ADMIN_DISCORD_IDS: this erases you now, but the next start makes you an admin again, as a new person, unless you are removed from the configuration first.";
3200
+ function isConfiguredAdmin(d, u) {
3201
+ return u.discordId !== null && d.configuredAdmins.has(u.discordId);
3202
+ }
3203
+ function ticksSettled(d) {
3204
+ return d.lanes.idle(TICK_WAIT_MS);
3205
+ }
3206
+ function isPersonId(id) {
3207
+ return /^u[1-9][0-9]{0,15}$/.test(id);
3208
+ }
3209
+ async function person(d, id) {
3210
+ return isPersonId(id) ? d.store.getUser(id) : null;
3211
+ }
3212
+ function who(u) {
3213
+ return u.displayName ?? u.discordId ?? u.id;
3214
+ }
3215
+ async function setAdminFlag(d, admin, targetId, flag) {
3216
+ if (!admin.admin)
3217
+ return { ok: false, error: NOT_ADMIN };
3218
+ const target = await person(d, targetId);
3219
+ if (!target)
3220
+ return { ok: false, error: NO_SUCH_PERSON };
3221
+ if (target.admin === flag)
3222
+ return { ok: true, text: `${who(target)} ${flag ? "is already" : "is not"} an admin.` };
3223
+ if (!flag && d.roster.admins() <= 1)
3224
+ return { ok: false, error: LAST_ADMIN };
3225
+ if (!flag && isConfiguredAdmin(d, target))
3226
+ return { ok: false, error: CONFIGURED_ADMIN };
3227
+ await d.store.updateUser(target.id, { admin: flag });
3228
+ d.log.info(`${admin.id} ${flag ? "made" : "revoked"} ${target.id} ${flag ? "an admin" : "as admin"}`);
3229
+ return { ok: true, text: flag ? `${who(target)} is now an admin.` : `${who(target)} is no longer an admin.` };
3230
+ }
3231
+ async function resumeDelivery(d, admin, targetId) {
3232
+ if (!admin.admin)
3233
+ return { ok: false, error: NOT_ADMIN };
3234
+ const target = await person(d, targetId);
3235
+ if (!target)
3236
+ return { ok: false, error: NO_SUCH_PERSON };
3237
+ const resumed = await d.health.resume(target.id, d.clock.now());
3238
+ if (!resumed)
3239
+ return { ok: false, error: `Delivery to ${who(target)} is not paused.` };
3240
+ return { ok: true, text: `Delivery to ${who(target)} resumed; ${resumed.tasks.length} task(s) back on.` };
3241
+ }
3242
+ async function liftDeclineBlock(d, admin, blockId) {
3243
+ if (!admin.admin)
3244
+ return { ok: false, error: NOT_ADMIN };
3245
+ const block = /^b[1-9][0-9]{0,15}$/.test(blockId) ? await d.store.getBlock(blockId) : null;
3246
+ const now = d.clock.now();
3247
+ if (!block || (await activeBlock(d.store, block.ownerId, block.recipientId, now))?.id !== block.id)
3248
+ return { ok: false, error: NO_SUCH_BLOCK };
3249
+ await liftBlock(d.store, { userId: admin.id, admin: true }, block.id, now);
3250
+ const owner = await d.store.getUser(block.ownerId);
3251
+ const recipient = await d.store.getUser(block.recipientId);
3252
+ return {
3253
+ ok: true,
3254
+ text: `Lifted: ${owner ? who(owner) : block.ownerId} may invite ${recipient ? who(recipient) : block.recipientId} again.`
3255
+ };
3256
+ }
3257
+ async function forgetPerson(d, actor, targetId) {
3258
+ if (actor.id !== targetId && !actor.admin)
3259
+ return { ok: false, error: NOT_ADMIN };
3260
+ const target = await person(d, targetId);
3261
+ if (!target)
3262
+ return { ok: false, error: NO_SUCH_PERSON };
3263
+ if (target.admin && d.roster.admins() <= 1)
3264
+ return { ok: false, error: LAST_ADMIN };
3265
+ if (actor.id !== target.id && isConfiguredAdmin(d, target))
3266
+ return { ok: false, error: CONFIGURED_ADMIN };
3267
+ const now = d.clock.now();
3268
+ const erased = await d.health.exclusive(async () => {
3269
+ if (d.lanes.busy())
3270
+ return null;
3271
+ const e = d.roster.erase(target.id);
3272
+ for (const taskId of e.freed)
3273
+ await d.health.release(taskId, null, "resumed: a recipient left the tracker", now);
3274
+ return e;
3275
+ });
3276
+ if (erased === null)
3277
+ return { ok: false, error: BUSY };
3278
+ if (!d.roster.checkpoint())
3279
+ d.log.warn(`after forgetting ${target.id}: the write-ahead log is busy, so SQLite's next checkpoint copies it back`);
3280
+ const total = Object.values(erased.rows).reduce((a, b) => a + b, 0);
3281
+ d.log.info(`forgot ${target.id} (${actor.id === target.id ? "at their own request" : `removed by ${actor.id}`}): ${total} row(s) deleted`);
3282
+ return { ok: true, text: actor.id === target.id ? "Everything the tracker held about you is deleted." : `${who(target)} is removed from the tracker.` };
3283
+ }
3284
+
3285
+ // plugins/tracker/src/ceilings.ts
3286
+ function boundsOf(policy = DEFAULT_BUDGET) {
3287
+ return { min: policy.person, max: policy.global };
3288
+ }
3289
+ function limitsOf(latest, defaults = DEFAULT_BUDGET.person) {
3290
+ if (!latest || latest.usd === null || latest.calls === null)
3291
+ return defaults;
3292
+ return { usd: latest.usd, calls: latest.calls };
3293
+ }
3294
+ var USD = /^[0-9]{1,4}(\.[0-9]{1,2})?$/;
3295
+ var CALLS = /^[0-9]{1,5}$/;
3296
+ function money2(n) {
3297
+ return `${n.toFixed(2)} USD`;
3298
+ }
3299
+ function decideCeiling(input, bounds = boundsOf()) {
3300
+ if (input.reset)
3301
+ return { ok: true, limits: null };
3302
+ const usdText = input.usd.trim();
3303
+ const callsText = input.calls.trim();
3304
+ if (!USD.test(usdText))
3305
+ return { ok: false, error: "Give the dollars as a number, like 4 or 4.50." };
3306
+ if (!CALLS.test(callsText))
3307
+ return { ok: false, error: "Give the model calls as a whole number, like 40." };
3308
+ const usd = Number(usdText);
3309
+ const calls = Number(callsText);
3310
+ const { min, max } = bounds;
3311
+ if (min.usd !== null && usd < min.usd)
3312
+ return { ok: false, error: `The dollars cannot go below the default, ${money2(min.usd)}.` };
3313
+ if (min.calls !== null && calls < min.calls)
3314
+ return { ok: false, error: `The model calls cannot go below the default, ${min.calls}.` };
3315
+ if (max.usd !== null && usd > max.usd)
3316
+ return { ok: false, error: `The dollars cannot go above the global ceiling, ${money2(max.usd)} a day.` };
3317
+ if (max.calls !== null && calls > max.calls)
3318
+ return { ok: false, error: `The model calls cannot go above the global ceiling, ${max.calls} a day.` };
3319
+ if (usd === min.usd && calls === min.calls)
3320
+ return { ok: true, limits: null };
3321
+ return { ok: true, limits: { usd, calls } };
3322
+ }
3323
+ function describeLimits(l) {
3324
+ const usd = l.usd === null ? "no dollar ceiling" : money2(l.usd);
3325
+ const calls = l.calls === null ? "no call ceiling" : `${l.calls} model calls`;
3326
+ return `${usd} and ${calls} a day`;
3327
+ }
3328
+ function isRaised(latest) {
3329
+ return latest !== null && latest.usd !== null && latest.calls !== null;
3330
+ }
3331
+ function budgetNoticeKey(userId, at) {
3332
+ return `budget:person:${userId}:${budgetDay(at).day}`;
3333
+ }
3334
+ var NOT_ENFORCED = "Budgets are off (alpha): TRACKER_BUDGET_UNLIMITED is on, so no ceiling is enforced; this one applies once it is turned off.";
3335
+ var HISTORY_SHOWN = 20;
3336
+ function toChange(r) {
3337
+ return { id: `c${r.seq}`, userId: r.user_id, usd: r.usd, calls: r.calls, setBy: r.set_by, at: r.at };
3338
+ }
3339
+
3340
+ class Ceilings {
3341
+ db;
3342
+ constructor(db) {
3343
+ this.db = db;
3344
+ }
3345
+ latest(userId) {
3346
+ const r = this.db.query("SELECT * FROM ceiling_changes WHERE user_id = ? ORDER BY seq DESC LIMIT 1").get(userId);
3347
+ return r ? toChange(r) : null;
3348
+ }
3349
+ history(userId, limit = HISTORY_SHOWN) {
3350
+ return this.db.query("SELECT * FROM ceiling_changes WHERE user_id = ? ORDER BY seq DESC LIMIT ?").all(userId, limit).map(toChange);
3351
+ }
3352
+ record(userId, limits, setBy, at) {
3353
+ return this.db.transaction(() => {
3354
+ const r = this.db.query("INSERT INTO ceiling_changes (user_id, usd, calls, set_by, at) VALUES (?, ?, ?, ?, ?) RETURNING *").get(userId, limits?.usd ?? null, limits?.calls ?? null, setBy, at);
3355
+ this.db.query("DELETE FROM notices WHERE key = ?").run(budgetNoticeKey(userId, new Date(at)));
3356
+ return toChange(r);
3357
+ })();
3358
+ }
3359
+ personFor(user) {
3360
+ const latest = this.latest(user.id);
3361
+ return isRaised(latest) ? limitsOf(latest) : null;
3362
+ }
3363
+ }
3364
+ function budgetPolicy(ceilings, base = DEFAULT_BUDGET) {
3365
+ return { person: base.person, global: base.global, personFor: (u) => ceilings.personFor(u) };
3366
+ }
3367
+ async function spentToday(store, userId, now) {
3368
+ const day = budgetDay(now);
3369
+ const rows = await store.listUsage({ userId, since: day.start, before: day.end });
3370
+ return spent(rows);
3371
+ }
3372
+ function who2(u) {
3373
+ return u.displayName ?? u.discordId ?? u.id;
3374
+ }
3375
+ async function setCeiling(d, admin, targetId, input) {
3376
+ if (!admin.admin)
3377
+ return { ok: false, error: NOT_ADMIN };
3378
+ const target = isPersonId(targetId) ? await d.store.getUser(targetId) : null;
3379
+ if (!target)
3380
+ return { ok: false, error: NO_SUCH_PERSON };
3381
+ const decided = decideCeiling(input);
3382
+ if (!decided.ok)
3383
+ return { ok: false, error: decided.error };
3384
+ const before = d.ceilings.latest(target.id);
3385
+ if (decided.limits === null && !isRaised(before))
3386
+ return { ok: true, text: `${who2(target)} is already on the default ceiling.` };
3387
+ const change = d.ceilings.record(target.id, decided.limits, admin.id, d.clock.now().toISOString());
3388
+ const now = limitsOf(change);
3389
+ const note = d.budgetUnlimited ? ` ${NOT_ENFORCED}` : "";
3390
+ d.log.info(`${admin.id} set ${target.id}'s daily ceiling to ${decided.limits === null ? "the default" : `${now.usd} USD / ${now.calls} calls`} (${change.id})`);
3391
+ return decided.limits === null ? { ok: true, text: `${who2(target)} is back on the default ceiling: ${describeLimits(now)}.${note}` } : { ok: true, text: `${who2(target)}'s ceiling is now ${describeLimits(now)}, until an admin changes it.${note}` };
3392
+ }
3393
+
3192
3394
  // plugins/tracker/src/delivery-health.ts
3193
3395
  var PAUSE_AFTER = 3;
3194
3396
  var PAUSE_DETAIL = `delivery paused: ${PAUSE_AFTER} DMs in a row could not be delivered (DMs closed, or the bot is blocked)`;
@@ -4201,6 +4403,18 @@ var MIGRATIONS = [
4201
4403
  paused_at TEXT
4202
4404
  );
4203
4405
  CREATE INDEX executor_jobs_occurrence ON executor_jobs (occurrence_id);
4406
+ `,
4407
+ `
4408
+ CREATE TABLE ceiling_changes (
4409
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
4410
+ user_id TEXT NOT NULL,
4411
+ usd REAL,
4412
+ calls INTEGER,
4413
+ set_by TEXT NOT NULL,
4414
+ at TEXT NOT NULL,
4415
+ CHECK ((usd IS NULL) = (calls IS NULL))
4416
+ );
4417
+ CREATE INDEX ceiling_changes_user ON ceiling_changes (user_id, seq);
4204
4418
  `
4205
4419
  ];
4206
4420
  var BUSY_TIMEOUT_MS = 5000;
@@ -6849,6 +7063,8 @@ class Roster {
6849
7063
  del("web_login_tokens", "user_id = ?", userId);
6850
7064
  del("api_tokens", "user_id = ?", userId);
6851
7065
  del("notices", "key LIKE 'budget:person:' || ? || ':%'", userId);
7066
+ del("ceiling_changes", "user_id = ?", userId);
7067
+ this.db.query("UPDATE ceiling_changes SET set_by = ? WHERE set_by = ?").run(FORGOTTEN, userId);
6852
7068
  del("users", "seq = ?", Number(seq));
6853
7069
  const held = this.db.query("SELECT 1 FROM delivery_pauses WHERE task_id = ? LIMIT 1");
6854
7070
  const freed = [...new Set(pausedFor)].filter((t) => held.get(t) === null);
@@ -6857,99 +7073,6 @@ class Roster {
6857
7073
  }
6858
7074
  }
6859
7075
 
6860
- // plugins/tracker/src/admin.ts
6861
- var NO_SUCH_PERSON = "There is no such person on this tracker's list.";
6862
- var NO_SUCH_BLOCK = "There is no such block in force.";
6863
- var LAST_ADMIN = "That is the tracker's only admin: make someone else an admin first.";
6864
- var BUSY = "The tracker is busy sending reminders; nothing was deleted. Try again in a minute.";
6865
- var TICK_WAIT_MS = 20000;
6866
- var CONFIGURED_ADMIN = "That admin is named in TRACKER_ADMIN_DISCORD_IDS, which makes them an admin again at every start: remove them from the configuration first.";
6867
- var CONFIGURED_SELF = "You are named in TRACKER_ADMIN_DISCORD_IDS: this erases you now, but the next start makes you an admin again, as a new person, unless you are removed from the configuration first.";
6868
- function isConfiguredAdmin(d, u) {
6869
- return u.discordId !== null && d.configuredAdmins.has(u.discordId);
6870
- }
6871
- function ticksSettled(d) {
6872
- return d.lanes.idle(TICK_WAIT_MS);
6873
- }
6874
- function isPersonId(id) {
6875
- return /^u[1-9][0-9]{0,15}$/.test(id);
6876
- }
6877
- async function person(d, id) {
6878
- return isPersonId(id) ? d.store.getUser(id) : null;
6879
- }
6880
- function who(u) {
6881
- return u.displayName ?? u.discordId ?? u.id;
6882
- }
6883
- async function setAdminFlag(d, admin, targetId, flag) {
6884
- if (!admin.admin)
6885
- return { ok: false, error: NOT_ADMIN };
6886
- const target = await person(d, targetId);
6887
- if (!target)
6888
- return { ok: false, error: NO_SUCH_PERSON };
6889
- if (target.admin === flag)
6890
- return { ok: true, text: `${who(target)} ${flag ? "is already" : "is not"} an admin.` };
6891
- if (!flag && d.roster.admins() <= 1)
6892
- return { ok: false, error: LAST_ADMIN };
6893
- if (!flag && isConfiguredAdmin(d, target))
6894
- return { ok: false, error: CONFIGURED_ADMIN };
6895
- await d.store.updateUser(target.id, { admin: flag });
6896
- d.log.info(`${admin.id} ${flag ? "made" : "revoked"} ${target.id} ${flag ? "an admin" : "as admin"}`);
6897
- return { ok: true, text: flag ? `${who(target)} is now an admin.` : `${who(target)} is no longer an admin.` };
6898
- }
6899
- async function resumeDelivery(d, admin, targetId) {
6900
- if (!admin.admin)
6901
- return { ok: false, error: NOT_ADMIN };
6902
- const target = await person(d, targetId);
6903
- if (!target)
6904
- return { ok: false, error: NO_SUCH_PERSON };
6905
- const resumed = await d.health.resume(target.id, d.clock.now());
6906
- if (!resumed)
6907
- return { ok: false, error: `Delivery to ${who(target)} is not paused.` };
6908
- return { ok: true, text: `Delivery to ${who(target)} resumed; ${resumed.tasks.length} task(s) back on.` };
6909
- }
6910
- async function liftDeclineBlock(d, admin, blockId) {
6911
- if (!admin.admin)
6912
- return { ok: false, error: NOT_ADMIN };
6913
- const block = /^b[1-9][0-9]{0,15}$/.test(blockId) ? await d.store.getBlock(blockId) : null;
6914
- const now = d.clock.now();
6915
- if (!block || (await activeBlock(d.store, block.ownerId, block.recipientId, now))?.id !== block.id)
6916
- return { ok: false, error: NO_SUCH_BLOCK };
6917
- await liftBlock(d.store, { userId: admin.id, admin: true }, block.id, now);
6918
- const owner = await d.store.getUser(block.ownerId);
6919
- const recipient = await d.store.getUser(block.recipientId);
6920
- return {
6921
- ok: true,
6922
- text: `Lifted: ${owner ? who(owner) : block.ownerId} may invite ${recipient ? who(recipient) : block.recipientId} again.`
6923
- };
6924
- }
6925
- async function forgetPerson(d, actor, targetId) {
6926
- if (actor.id !== targetId && !actor.admin)
6927
- return { ok: false, error: NOT_ADMIN };
6928
- const target = await person(d, targetId);
6929
- if (!target)
6930
- return { ok: false, error: NO_SUCH_PERSON };
6931
- if (target.admin && d.roster.admins() <= 1)
6932
- return { ok: false, error: LAST_ADMIN };
6933
- if (actor.id !== target.id && isConfiguredAdmin(d, target))
6934
- return { ok: false, error: CONFIGURED_ADMIN };
6935
- const now = d.clock.now();
6936
- const erased = await d.health.exclusive(async () => {
6937
- if (d.lanes.busy())
6938
- return null;
6939
- const e = d.roster.erase(target.id);
6940
- for (const taskId of e.freed)
6941
- await d.health.release(taskId, null, "resumed: a recipient left the tracker", now);
6942
- return e;
6943
- });
6944
- if (erased === null)
6945
- return { ok: false, error: BUSY };
6946
- if (!d.roster.checkpoint())
6947
- d.log.warn(`after forgetting ${target.id}: the write-ahead log is busy, so SQLite's next checkpoint copies it back`);
6948
- const total = Object.values(erased.rows).reduce((a, b) => a + b, 0);
6949
- d.log.info(`forgot ${target.id} (${actor.id === target.id ? "at their own request" : `removed by ${actor.id}`}): ${total} row(s) deleted`);
6950
- return { ok: true, text: actor.id === target.id ? "Everything the tracker held about you is deleted." : `${who(target)} is removed from the tracker.` };
6951
- }
6952
-
6953
7076
  // node_modules/@rackbops/styles/rackbops-noir/bundle.css
6954
7077
  var bundle_default = `:where([data-rb-style], [data-rb-style] *), :where([data-rb-style], [data-rb-style] *):before, :where([data-rb-style], [data-rb-style] *):after {
6955
7078
  box-sizing: border-box;
@@ -8727,6 +8850,114 @@ ${tokenTable(v, tokens, now, (id) => tokenHref(v, id))}
8727
8850
  </section>`);
8728
8851
  }
8729
8852
 
8853
+ // plugins/tracker/src/web/ceiling-pages.ts
8854
+ function setBy(id, names) {
8855
+ if (id === FORGOTTEN)
8856
+ return "an admin since forgotten";
8857
+ return names.get(id) ?? id;
8858
+ }
8859
+ function change(c, v, now, names) {
8860
+ const what = c.usd === null || c.calls === null ? "back to the default" : `raised to ${describeLimits({ usd: c.usd, calls: c.calls })}`;
8861
+ return html`<li>${formatInstant(c.at, v.user.timeZone, now)}: ${what}, by ${setBy(c.setBy, names)}</li>`;
8862
+ }
8863
+ function limitText(n, unit) {
8864
+ return n === null ? `no ${unit} ceiling` : String(n);
8865
+ }
8866
+ function ceilingSection(v, personId, view, now) {
8867
+ const limits = limitsOf(view.latest, view.defaults);
8868
+ const raised = isRaised(view.latest);
8869
+ const action = `${v.base}/admin/people/${encodeURIComponent(personId)}/ceiling`;
8870
+ const csrf = html`<input type="hidden" name="csrf" value="${v.csrf}">`;
8871
+ const { min, max } = view.bounds;
8872
+ return html`<section>
8873
+ <h2>Daily model budget</h2>
8874
+ ${view.unlimited ? html`<div class="rb-alert rb-alert--warning" role="status"><p>${NOT_ENFORCED}</p></div>` : null}
8875
+ <p>Today: ${view.today.usd.toFixed(2)} USD and ${view.today.calls} model call(s). Ceiling: ${describeLimits(limits)}${raised ? html` <span class="rb-badge">raised</span>` : " (the default)"}${view.unlimited ? " -- not enforced" : null}.</p>
8876
+ <p class="rb-muted">A raise stands until an admin changes it. Setting exactly the default puts them back on it. It may not go below the default (${describeLimits(view.defaults)}) or above the global ceiling (${describeLimits(max)}), which still applies to everyone together. A day ends at midnight Eastern.</p>
8877
+ <form method="post" action="${action}" class="tr-stack">
8878
+ ${csrf}
8879
+ <div class="tr-row">
8880
+ <div class="rb-field"><label class="rb-label" for="ceiling-usd">Dollars a day</label>
8881
+ <input class="rb-input" id="ceiling-usd" name="usd" inputmode="decimal" maxlength="7" value="${limits.usd === null ? "" : limits.usd.toFixed(2)}" required></div>
8882
+ <div class="rb-field"><label class="rb-label" for="ceiling-calls">Model calls a day</label>
8883
+ <input class="rb-input" id="ceiling-calls" name="calls" inputmode="numeric" maxlength="5" value="${limits.calls === null ? "" : String(limits.calls)}" required></div>
8884
+ </div>
8885
+ <p class="rb-muted">From ${limitText(min.usd, "dollar")} to ${limitText(max.usd, "dollar")} dollars; from ${limitText(min.calls, "call")} to ${limitText(max.calls, "call")} calls.</p>
8886
+ <div class="tr-row"><button class="rb-btn rb-btn--accent" type="submit">Set ceiling</button></div>
8887
+ </form>
8888
+ ${raised ? html`<form method="post" action="${action}">${csrf}<input type="hidden" name="reset" value="yes"><button class="rb-btn rb-btn--ghost" type="submit">Back to the default</button></form>` : null}
8889
+ ${view.history.length === 0 ? html`<p class="rb-muted">No admin has changed their ceiling.</p>` : html`<h3>Changes</h3><p class="rb-muted">The latest ${HISTORY_SHOWN} at most, newest first.</p><ul>${view.history.map((c) => change(c, v, now, view.names))}</ul>`}
8890
+ </section>`;
8891
+ }
8892
+
8893
+ // plugins/tracker/src/usage.ts
8894
+ var BUDGET_UNLIMITED_KEY = "TRACKER_BUDGET_UNLIMITED";
8895
+ function parseBudgetUnlimited(raw) {
8896
+ const value = (raw ?? "").trim().toLowerCase();
8897
+ if (value === "" || value === "false" || value === "0")
8898
+ return false;
8899
+ if (value === "true" || value === "1")
8900
+ return true;
8901
+ throw new Error(`${BUDGET_UNLIMITED_KEY} must be true, 1, false or 0 (or unset)`);
8902
+ }
8903
+ var NO_LIMITS = Object.freeze({ usd: null, calls: null });
8904
+ var UNLIMITED_BUDGET = Object.freeze({ person: NO_LIMITS, global: NO_LIMITS });
8905
+ function executeBudget(ceilings, unlimited) {
8906
+ return unlimited ? UNLIMITED_BUDGET : budgetPolicy(ceilings);
8907
+ }
8908
+ var UNLIMITED_LOG = `budgets off (alpha): ${BUDGET_UNLIMITED_KEY} is on, so no daily ceiling holds a model run; each Job's own caps still apply and usage is still recorded`;
8909
+ var UNLIMITED_IDLE_LOG = `${BUDGET_UNLIMITED_KEY} is on, but the execute lane is off, so no model work runs for it to leave unbudgeted`;
8910
+ var UNLIMITED_BANNER = "Budgets off (alpha): unlimited. No daily ceiling holds a model run while TRACKER_BUDGET_UNLIMITED is on; usage is still recorded.";
8911
+ var USAGE_DAYS = 14;
8912
+ function usageWindow(now, days = USAGE_DAYS) {
8913
+ const out = [budgetDay(now)];
8914
+ while (out.length < days)
8915
+ out.unshift(budgetDay(new Date(Date.parse(out[0].start) - 1)));
8916
+ return out;
8917
+ }
8918
+ function measure(rows, limits) {
8919
+ const total = { usd: 0, calls: 0 };
8920
+ let past = 0;
8921
+ for (const r of rows) {
8922
+ if (reached(total, limits) !== null)
8923
+ past += r.calls;
8924
+ total.usd += r.costUsd;
8925
+ total.calls += r.calls;
8926
+ }
8927
+ return { ...total, reached: reached(total, limits), past };
8928
+ }
8929
+ function byAt(a, b) {
8930
+ return a.at < b.at ? -1 : a.at > b.at ? 1 : 0;
8931
+ }
8932
+ function usageReport(rows, now, days = USAGE_DAYS, policy = DEFAULT_BUDGET) {
8933
+ const window = usageWindow(now, days);
8934
+ const index = new Map(window.map((w, i) => [w.day, i]));
8935
+ const buckets = window.map(() => []);
8936
+ for (const r of [...rows].sort(byAt)) {
8937
+ const i = index.get(budgetDay(new Date(r.at)).day);
8938
+ if (i !== undefined)
8939
+ buckets[i]?.push(r);
8940
+ }
8941
+ const out = window.map((w, i) => {
8942
+ const dayRows = buckets[i] ?? [];
8943
+ const users = [...new Set(dayRows.map((r) => r.userId))];
8944
+ const people = users.map((userId) => ({ userId, ...measure(dayRows.filter((r) => r.userId === userId), policy.person) })).sort((a, b) => b.usd - a.usd || b.calls - a.calls || (a.userId < b.userId ? -1 : 1));
8945
+ return { day: w.day, everyone: measure(dayRows, policy.global), people };
8946
+ });
8947
+ return {
8948
+ days: out.reverse(),
8949
+ person: policy.person,
8950
+ global: policy.global,
8951
+ since: window[0].start,
8952
+ before: window.at(-1).end
8953
+ };
8954
+ }
8955
+ async function loadUsage(store, now, days = USAGE_DAYS) {
8956
+ const window = usageWindow(now, days);
8957
+ const rows = await store.listUsage({ since: window[0].start, before: window.at(-1).end });
8958
+ return usageReport(rows, now, days);
8959
+ }
8960
+
8730
8961
  // plugins/tracker/src/web/admin-pages.ts
8731
8962
  var CONFIGURED_NOTE = "Named in TRACKER_ADMIN_DISCORD_IDS: made an admin again at every start, so they cannot be revoked or removed here. Remove them from the configuration first.";
8732
8963
  var CONFIRM_WORD = "forget";
@@ -8748,7 +8979,10 @@ function hidden(v) {
8748
8979
  return html`<input type="hidden" name="csrf" value="${v.csrf}">`;
8749
8980
  }
8750
8981
  function adminNav(v) {
8751
- return html`<p class="tr-row"><a class="rb-link" href="${v.base}/admin">People</a> <a class="rb-link" href="${v.base}/admin/tasks">All tasks</a> <a class="rb-link" href="${v.base}/admin/deliveries">Deliveries</a></p>`;
8982
+ return html`<p class="tr-row"><a class="rb-link" href="${v.base}/admin">People</a> <a class="rb-link" href="${v.base}/admin/tasks">All tasks</a> <a class="rb-link" href="${v.base}/admin/deliveries">Deliveries</a> <a class="rb-link" href="${v.base}/admin/usage">Usage</a></p>`;
8983
+ }
8984
+ function budgetsOffBanner(unlimited) {
8985
+ return unlimited ? html`<div class="rb-alert rb-alert--warning" role="status"><p><strong>${UNLIMITED_BANNER}</strong></p></div>` : null;
8752
8986
  }
8753
8987
  function personHref(v, id, action = "") {
8754
8988
  return `${v.base}/admin/people/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;
@@ -8783,6 +9017,7 @@ function adminPage(v, data, o = {}) {
8783
9017
  return framed(v, "Admin", html`<section>
8784
9018
  <h1>People</h1>
8785
9019
  ${adminNav(v)}
9020
+ ${budgetsOffBanner(o.unlimited === true)}
8786
9021
  ${flash(o.result ?? null)}
8787
9022
  <div class="rb-table-scroll"><table class="rb-table">
8788
9023
  <thead><tr><th scope="col">Person</th><th scope="col">Status</th><th scope="col">Zone, hour</th><th scope="col">Delivery</th><th scope="col">Tasks</th></tr></thead>
@@ -8857,7 +9092,7 @@ ${data.rows.length === 0 ? html`<p class="rb-muted">None in that time.</p>` : ht
8857
9092
  function post2(v, p, action, label, style) {
8858
9093
  return html`<form method="post" action="${personHref(v, p.id, action)}">${hidden(v)}<button class="rb-btn ${style} rb-btn--sm" type="submit">${label}</button></form>`;
8859
9094
  }
8860
- function personPage(v, p, tasks, now, result = null, configured = false, tokens = []) {
9095
+ function personPage(v, p, tasks, now, result = null, configured = false, tokens = [], budget) {
8861
9096
  return framed(v, nameOf(p), html`<section>
8862
9097
  ${adminNav(v)}
8863
9098
  <h1>${nameOf(p)}${p.admin ? html` <span class="rb-badge">admin</span>` : null}</h1>
@@ -8876,6 +9111,7 @@ ${p.deliveryPausedAt ? post2(v, p, "resume-delivery", "Resume delivery", "rb-btn
8876
9111
  ${configured && p.id !== v.user.id ? null : post2(v, p, "forget", "Remove from the tracker", "rb-btn--danger")}
8877
9112
  </div>
8878
9113
  </section>
9114
+ ${budget ? ceilingSection(v, p.id, budget, now) : null}
8879
9115
  <section>
8880
9116
  <h2>Their tasks</h2>
8881
9117
  ${tasks.length === 0 ? html`<p class="rb-muted">None.</p>` : html`<ul>${tasks.map((t) => html`<li><a class="rb-link" href="${taskHref(v, t.id)}">${t.title}</a> -- ${t.type}, ${t.status === "archived" ? "deleted" : t.status}</li>`)}</ul>`}
@@ -8917,6 +9153,48 @@ ${hidden(v)}
8917
9153
  </section>`);
8918
9154
  }
8919
9155
 
9156
+ // plugins/tracker/src/web/usage-pages.ts
9157
+ function marker(m, limits) {
9158
+ if (m.reached === null)
9159
+ return html`<span class="rb-muted">under</span>`;
9160
+ const which = m.reached === "calls" ? `${limits.calls} call` : `${(limits.usd ?? 0).toFixed(2)} USD`;
9161
+ const past = m.past > 0 ? `; ${m.past} call(s) after it` : "";
9162
+ return html`<span class="rb-badge">would have hit the ${which} limit</span>${past}`;
9163
+ }
9164
+ function row(day, who, m, limits) {
9165
+ return html`<tr>
9166
+ <td>${day ?? ""}</td>
9167
+ <td>${who}</td>
9168
+ <td>${m.usd.toFixed(2)}</td>
9169
+ <td>${m.calls}</td>
9170
+ <td>${marker(m, limits)}</td>
9171
+ </tr>`;
9172
+ }
9173
+ function adminUsagePage(v, data) {
9174
+ const { report, names } = data;
9175
+ const rows = report.days.map((d) => {
9176
+ if (d.everyone.calls === 0 && d.everyone.usd === 0) {
9177
+ return html`<tr><td>${d.day}</td><td colspan="4" class="rb-muted">No model use.</td></tr>`;
9178
+ }
9179
+ return [
9180
+ row(d.day, html`<strong>Everyone</strong>`, d.everyone, report.global),
9181
+ ...d.people.map((p) => row(null, html`<a class="rb-link" href="${personHref(v, p.userId)}">${names.get(p.userId) ?? p.userId}</a>`, p, report.person))
9182
+ ];
9183
+ });
9184
+ const total = report.days.reduce((s, d) => ({ usd: s.usd + d.everyone.usd, calls: s.calls + d.everyone.calls }), { usd: 0, calls: 0 });
9185
+ return framed(v, "Usage", html`<section>
9186
+ <h1>Model usage</h1>
9187
+ ${adminNav(v)}
9188
+ ${budgetsOffBanner(data.unlimited)}
9189
+ <p>The last ${report.days.length} days, newest first: ${total.usd.toFixed(2)} USD and ${total.calls} model call(s) in all. A day ends at midnight Eastern. Dollars are the CLI's list-price estimate, not a bill.</p>
9190
+ <p class="rb-muted">Each row is measured against the default ceilings -- ${describeLimits(report.person)} a person, ${describeLimits(report.global)} for everyone together -- not a person's raised one. "Would have hit" means that day's spend reached the ceiling; "calls after it" are the calls charged once it had, roughly what it would have held (the ceiling is checked before a run and the run charged at its end).${data.unlimited ? " While budgets are off, nothing is held." : ""}</p>
9191
+ <div class="rb-table-scroll"><table class="rb-table">
9192
+ <thead><tr><th scope="col">Day</th><th scope="col">Who</th><th scope="col">USD</th><th scope="col">Calls</th><th scope="col">Default ceiling</th></tr></thead>
9193
+ <tbody>${rows}</tbody>
9194
+ </table></div>
9195
+ </section>`);
9196
+ }
9197
+
8920
9198
  // plugins/tracker/src/web/admin.ts
8921
9199
  function unknownPage(base) {
8922
9200
  return htmlResponse(notFoundPage(base), 404);
@@ -8940,21 +9218,37 @@ function confirmed(form) {
8940
9218
  var WRONG_WORD = `Type ${CONFIRM_WORD} to confirm. Nothing was deleted.`;
8941
9219
  async function peoplePage(a, result, status, allow) {
8942
9220
  const data = { people: a.d.roster.people(), blocks: a.d.roster.activeBlocks(a.d.clock.now()), now: a.d.clock.now() };
8943
- return htmlResponse(adminPage(a.v, data, { result, ...allow !== undefined ? { allow } : {} }), status);
9221
+ return htmlResponse(adminPage(a.v, data, { result, unlimited: a.d.budgetUnlimited === true, ...allow !== undefined ? { allow } : {} }), status);
8944
9222
  }
8945
9223
  async function onePerson(a, id, result = null) {
8946
- const p = a.d.roster.people().find((x) => x.id === id);
9224
+ const everyone = a.d.roster.people();
9225
+ const p = everyone.find((x) => x.id === id);
8947
9226
  if (!p)
8948
9227
  return htmlResponse(notFoundPage(a.v.base, a.v), 404);
8949
9228
  const tasks = await a.d.store.listTasks({ ownerId: p.id });
8950
- const tokens = a.d.apiTokens.listFor(p.id, a.d.clock.now());
8951
- return htmlResponse(personPage(a.v, p, tasks, a.d.clock.now(), result, isConfiguredAdmin(a.d, p), tokens), result && !result.ok ? 400 : 200);
9229
+ const now = a.d.clock.now();
9230
+ const tokens = a.d.apiTokens.listFor(p.id, now);
9231
+ const budget = {
9232
+ latest: a.d.ceilings.latest(p.id),
9233
+ history: a.d.ceilings.history(p.id),
9234
+ today: await spentToday(a.d.store, p.id, now),
9235
+ defaults: DEFAULT_BUDGET.person,
9236
+ bounds: boundsOf(),
9237
+ names: new Map(everyone.map((x) => [x.id, nameOf(x)])),
9238
+ unlimited: a.d.budgetUnlimited === true
9239
+ };
9240
+ return htmlResponse(personPage(a.v, p, tasks, now, result, isConfiguredAdmin(a.d, p), tokens, budget), result && !result.ok ? 400 : 200);
8952
9241
  }
8953
9242
  async function adminGet(a, r) {
8954
9243
  if (r.kind === "admin")
8955
9244
  return peoplePage(a, null, 200);
8956
9245
  if (r.kind === "admin-person")
8957
9246
  return onePerson(a, r.id);
9247
+ if (r.kind === "admin-usage") {
9248
+ const now = a.d.clock.now();
9249
+ const people = new Map(a.d.roster.people().map((p) => [p.id, nameOf(p)]));
9250
+ return htmlResponse(adminUsagePage(a.v, { report: await loadUsage(a.d.store, now), names: people, unlimited: a.d.budgetUnlimited === true }));
9251
+ }
8958
9252
  const people = new Map(a.d.roster.people().map((p) => [p.id, nameOf(p)]));
8959
9253
  if (r.kind === "admin-deliveries") {
8960
9254
  const now = a.d.clock.now();
@@ -8999,6 +9293,15 @@ async function adminPost(a, r, form) {
8999
9293
  return htmlResponse(notFoundPage(base, a.v), 404);
9000
9294
  return onePerson(a, owner.whose, { ok: true, text: "Revoked: that token no longer works." });
9001
9295
  }
9296
+ if (r.kind === "admin-ceiling") {
9297
+ const input = form.get("reset") === "yes" ? { reset: true } : { reset: false, usd: form.get("usd") ?? "", calls: form.get("calls") ?? "" };
9298
+ const done = await fresh(a, true, (me) => setCeiling(a.d, me, r.id, input));
9299
+ if (done === null)
9300
+ return unknownPage(base);
9301
+ if (!done.ok && done.error === NO_SUCH_PERSON)
9302
+ return htmlResponse(notFoundPage(base, a.v), 404);
9303
+ return onePerson(a, r.id, said2(done));
9304
+ }
9002
9305
  if (r.kind !== "admin-act")
9003
9306
  return unknownPage(base);
9004
9307
  if (r.action === "forget") {
@@ -9914,6 +10217,7 @@ var TASK_ACTION = /^\/tasks\/([^/]+)\/(edit|pause|resume|delete)$/;
9914
10217
  var NEW_PATH = /^\/new\/([a-z]+)$/;
9915
10218
  var PERSON_PATH = /^\/admin\/people\/([^/]+)$/;
9916
10219
  var PERSON_ACTION = /^\/admin\/people\/([^/]+)\/(grant|revoke|resume-delivery|forget)$/;
10220
+ var PERSON_CEILING = /^\/admin\/people\/([^/]+)\/ceiling$/;
9917
10221
  var BLOCK_LIFT = /^\/admin\/blocks\/([^/]+)\/lift$/;
9918
10222
  var TOKEN_REVOKE = /^\/tokens\/([^/]+)\/revoke$/;
9919
10223
  var ADMIN_TOKEN_REVOKE = /^\/admin\/tokens\/([^/]+)\/revoke$/;
@@ -9950,8 +10254,13 @@ function route2(path) {
9950
10254
  return { kind: "admin-tasks" };
9951
10255
  if (path === "/admin/deliveries")
9952
10256
  return { kind: "admin-deliveries" };
10257
+ if (path === "/admin/usage")
10258
+ return { kind: "admin-usage" };
9953
10259
  if (path === "/admin/allow")
9954
10260
  return { kind: "admin-allow" };
10261
+ const ceiling = PERSON_CEILING.exec(path);
10262
+ if (ceiling)
10263
+ return { kind: "admin-ceiling", id: segment2(ceiling[1]) };
9955
10264
  const personAct = PERSON_ACTION.exec(path);
9956
10265
  if (personAct)
9957
10266
  return { kind: "admin-act", id: segment2(personAct[1]), action: personAct[2] };
@@ -9978,7 +10287,7 @@ function route2(path) {
9978
10287
  return { kind: "act", id, action };
9979
10288
  }
9980
10289
  function methodsOf(r) {
9981
- if (r.kind === "logout" || r.kind === "act" || r.kind === "admin-allow" || r.kind === "admin-act" || r.kind === "admin-lift" || r.kind === "token-revoke" || r.kind === "admin-token-revoke") {
10290
+ if (r.kind === "logout" || r.kind === "act" || r.kind === "admin-allow" || r.kind === "admin-act" || r.kind === "admin-ceiling" || r.kind === "admin-lift" || r.kind === "token-revoke" || r.kind === "admin-token-revoke") {
9982
10291
  return "POST";
9983
10292
  }
9984
10293
  if (r.kind === "settings" || r.kind === "edit" || r.kind === "new" || r.kind === "forget" || r.kind === "tokens")
@@ -10274,6 +10583,7 @@ function createWebHandler(w) {
10274
10583
  return tokenRevokePost(d, v, r.id);
10275
10584
  case "admin-allow":
10276
10585
  case "admin-act":
10586
+ case "admin-ceiling":
10277
10587
  case "admin-lift":
10278
10588
  case "admin-token-revoke":
10279
10589
  return adminPost(a, r, form);
@@ -10299,6 +10609,7 @@ function createWebHandler(w) {
10299
10609
  case "admin":
10300
10610
  case "admin-tasks":
10301
10611
  case "admin-deliveries":
10612
+ case "admin-usage":
10302
10613
  case "admin-person":
10303
10614
  return adminGet(a, r);
10304
10615
  default:
@@ -10349,6 +10660,7 @@ function createPlugin(host, options = {}) {
10349
10660
  const guildIds = parseGuildIds(host.env.TRACKER_GUILD_ID);
10350
10661
  const webOrigin = parseWebUrl(host.env.TRACKER_WEB_URL);
10351
10662
  const cityHall = parseCityHallConfig(host.env);
10663
+ const budgetUnlimited = parseBudgetUnlimited(host.env[BUDGET_UNLIMITED_KEY]);
10352
10664
  const cityHallConfig = cityHall.config;
10353
10665
  const executeOn = cityHallConfig !== null || options.executor !== undefined;
10354
10666
  const clock = options.clock ?? { now: () => new Date };
@@ -10554,7 +10866,10 @@ function createPlugin(host, options = {}) {
10554
10866
  ...options.cityHallFetch ? { fetchImpl: options.cityHallFetch } : {}
10555
10867
  });
10556
10868
  }
10557
- executeLane = executor ? new ExecuteLane({ store: openedStore, clock, types, notifier, executor, locks }) : null;
10869
+ const ceilings = new Ceilings(opened);
10870
+ executeLane = executor ? new ExecuteLane({ store: openedStore, clock, types, notifier, executor, locks, budget: executeBudget(ceilings, budgetUnlimited) }) : null;
10871
+ if (budgetUnlimited)
10872
+ host.log.info(executeLane ? UNLIMITED_LOG : UNLIMITED_IDLE_LOG);
10558
10873
  if (cityHallConfig)
10559
10874
  host.log.info(`execute lane on: city-hall ${cityHallConfig.url}, capability ${cityHallConfig.capability}`);
10560
10875
  else if ("missing" in cityHall && cityHall.missing.length > 0)
@@ -10574,6 +10889,8 @@ function createPlugin(host, options = {}) {
10574
10889
  sessions: new Sessions(opened),
10575
10890
  apiTokens: new ApiTokens(opened),
10576
10891
  roster: new Roster(opened),
10892
+ ceilings,
10893
+ budgetUnlimited,
10577
10894
  configuredAdmins: new Set(adminIds),
10578
10895
  lanes,
10579
10896
  webEditor: webOrigin !== null,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rackbops/plugin-tracker",
3
- "version": "0.12.0",
4
- "description": "The task tracker for Rackbops Clerk: reminders, renewals, price drops and one-off research (through city-hall, with its findings) by slash command, delivered by DM with buttons, on docket's SQLite store (/allow, /register, /remind, /renewal, /price, /research, /tasks, /task, /settings, /web), with a web area for your tasks, history and settings, a web editor to make, change, pause and delete your own tasks (research requests too, made but not changed), an admin view (with the DMs that did not arrive), forget-me, and a JSON task API with personal API tokens",
3
+ "version": "0.14.0",
4
+ "description": "The task tracker for Rackbops Clerk: reminders, renewals, price drops and one-off research (through city-hall, with its findings) by slash command, delivered by DM with buttons, on docket's SQLite store (/allow, /register, /remind, /renewal, /price, /research, /tasks, /task, /settings, /web), with a web area for your tasks, history and settings, a web editor to make, change, pause and delete your own tasks (research requests too, made but not changed), an admin view (with the DMs that did not arrive, raising a person's daily model ceiling, and daily model usage per person), an operator switch that turns the daily budgets off for the alpha, forget-me, and a JSON task API with personal API tokens",
5
5
  "type": "module",
6
6
  "main": "dist/plugin.js",
7
7
  "files": ["dist"],
@@ -44,6 +44,13 @@
44
44
  "secret": false,
45
45
  "description": "The https origin the bot's HTTP is reached at through its tunnel, e.g. https://clerk.example.com (no path). /web links to <this>/tracker/login, and a form post from any other origin is refused. Unset = no web area: /web says so and its pages answer 404."
46
46
  },
47
+ {
48
+ "key": "TRACKER_BUDGET_UNLIMITED",
49
+ "format": "^ *([Tt][Rr][Uu][Ee]|[Ff][Aa][Ll][Ss][Ee]|1|0)? *$",
50
+ "required": false,
51
+ "secret": false,
52
+ "description": "Budgets off for the alpha: true or 1 means no daily ceiling (per person or for everyone) holds a model run, bot-wide. Each Job's own caps still apply, usage is still recorded, the People and Usage admin pages show a budgets-off banner, and the person page shows a not-enforced note. Unset, empty, false or 0 = the daily ceilings apply (the default); anything else refuses to load."
53
+ },
47
54
  {
48
55
  "key": "TRACKER_CITY_HALL_URL",
49
56
  "format": "^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?/?$",