@rackbops/plugin-tracker 0.13.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 +23 -4
  2. package/dist/plugin.js +136 -6
  3. package/package.json +9 -2
package/README.md CHANGED
@@ -13,7 +13,8 @@ slice 3) -- the JSON task API with personal API tokens (#80, slice 4), for a pro
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
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). The model runs never happen here: they go
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
17
18
  through city-hall to Rackbops/docket-runner on roshne's own host (plan 5.12), and the whole execute
18
19
  lane stays off until the city-hall Executor is configured (Configuration below).
19
20
 
@@ -127,7 +128,11 @@ says research is not available; it does not list the waiting ones (`/tasks` does
127
128
  calls in all. At a ceiling the person's research waits until midnight Eastern; the person gets one
128
129
  DM and the admins one each. Reminders, renewals and prices never count. An admin can raise one
129
130
  person's ceiling from their admin page (0.13.0, below); the global ceiling still applies to everyone
130
- together.
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.
131
136
 
132
137
  **Findings.** A passed research answer's claims are stored with their first source (docket's
133
138
  `findings`), shown on the task's page to its owner, accepted recipients and admins (docket's
@@ -284,6 +289,16 @@ the function the command path runs:
284
289
  when) and a log line (`u1 set u2's daily ceiling to 4 USD / 40 calls (c1)`).
285
290
  - **Remove from the tracker**: forget-me for that person (below), with the same confirmation.
286
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
+
287
302
  **Deliveries** (0.10.0). `/admin/deliveries`, linked from every admin page, lists the DMs that
288
303
  settled `failed`, `unconfirmed` or `deferred` in the last 30 days, newest first, at most 200 rows
289
304
  (the page says how many matched when the bound cuts it). Each row: the task (its title, linking to
@@ -294,8 +309,10 @@ say). Before 0.10.0 these showed only in the bot log, where the warnings still g
294
309
  read-only, and like every admin page answers anyone who is not an admin with the unknown page's 404.
295
310
 
296
311
  Not here yet, though plan 5.10 lists them: grants and retry, and an admin pause of someone else's
297
- task. Of budgets, raising one person's ceiling is here; the global ceiling and the defaults are
298
- docket's constants.
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.
299
316
 
300
317
  **Forget me** (plan 5.8). A signed-in person erases themselves from `/forget` (web only: the plan
301
318
  asks for no Discord command, so without `TRACKER_WEB_URL` the only path is an admin with the web
@@ -499,6 +516,7 @@ $ curl -s https://clerk.example.com/tracker/api/v1/tasks/t9 -H "Authorization: B
499
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. |
500
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`. |
501
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. |
502
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. |
503
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. |
504
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. |
@@ -519,6 +537,7 @@ $ curl -s https://clerk.example.com/tracker/api/v1/tasks/t9 -H "Authorization: B
519
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. |
520
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. |
521
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. |
522
541
  | `TRACKER_CITY_HALL_URL` | no | The https origin of the city-hall that queues the tracker's model Jobs (no path). |
523
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. |
524
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. |
package/dist/plugin.js CHANGED
@@ -3331,6 +3331,7 @@ function isRaised(latest) {
3331
3331
  function budgetNoticeKey(userId, at) {
3332
3332
  return `budget:person:${userId}:${budgetDay(at).day}`;
3333
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.";
3334
3335
  var HISTORY_SHOWN = 20;
3335
3336
  function toChange(r) {
3336
3337
  return { id: `c${r.seq}`, userId: r.user_id, usd: r.usd, calls: r.calls, setBy: r.set_by, at: r.at };
@@ -3385,8 +3386,9 @@ async function setCeiling(d, admin, targetId, input) {
3385
3386
  return { ok: true, text: `${who2(target)} is already on the default ceiling.` };
3386
3387
  const change = d.ceilings.record(target.id, decided.limits, admin.id, d.clock.now().toISOString());
3387
3388
  const now = limitsOf(change);
3389
+ const note = d.budgetUnlimited ? ` ${NOT_ENFORCED}` : "";
3388
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})`);
3389
- return decided.limits === null ? { ok: true, text: `${who2(target)} is back on the default ceiling: ${describeLimits(now)}.` } : { ok: true, text: `${who2(target)}'s ceiling is now ${describeLimits(now)}, until an admin changes it.` };
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}` };
3390
3392
  }
3391
3393
 
3392
3394
  // plugins/tracker/src/delivery-health.ts
@@ -8869,7 +8871,8 @@ function ceilingSection(v, personId, view, now) {
8869
8871
  const { min, max } = view.bounds;
8870
8872
  return html`<section>
8871
8873
  <h2>Daily model budget</h2>
8872
- <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)"}.</p>
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>
8873
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>
8874
8877
  <form method="post" action="${action}" class="tr-stack">
8875
8878
  ${csrf}
@@ -8887,6 +8890,74 @@ ${view.history.length === 0 ? html`<p class="rb-muted">No admin has changed thei
8887
8890
  </section>`;
8888
8891
  }
8889
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
+
8890
8961
  // plugins/tracker/src/web/admin-pages.ts
8891
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.";
8892
8963
  var CONFIRM_WORD = "forget";
@@ -8908,7 +8979,10 @@ function hidden(v) {
8908
8979
  return html`<input type="hidden" name="csrf" value="${v.csrf}">`;
8909
8980
  }
8910
8981
  function adminNav(v) {
8911
- 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;
8912
8986
  }
8913
8987
  function personHref(v, id, action = "") {
8914
8988
  return `${v.base}/admin/people/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;
@@ -8943,6 +9017,7 @@ function adminPage(v, data, o = {}) {
8943
9017
  return framed(v, "Admin", html`<section>
8944
9018
  <h1>People</h1>
8945
9019
  ${adminNav(v)}
9020
+ ${budgetsOffBanner(o.unlimited === true)}
8946
9021
  ${flash(o.result ?? null)}
8947
9022
  <div class="rb-table-scroll"><table class="rb-table">
8948
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>
@@ -9078,6 +9153,48 @@ ${hidden(v)}
9078
9153
  </section>`);
9079
9154
  }
9080
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
+
9081
9198
  // plugins/tracker/src/web/admin.ts
9082
9199
  function unknownPage(base) {
9083
9200
  return htmlResponse(notFoundPage(base), 404);
@@ -9101,7 +9218,7 @@ function confirmed(form) {
9101
9218
  var WRONG_WORD = `Type ${CONFIRM_WORD} to confirm. Nothing was deleted.`;
9102
9219
  async function peoplePage(a, result, status, allow) {
9103
9220
  const data = { people: a.d.roster.people(), blocks: a.d.roster.activeBlocks(a.d.clock.now()), now: a.d.clock.now() };
9104
- 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);
9105
9222
  }
9106
9223
  async function onePerson(a, id, result = null) {
9107
9224
  const everyone = a.d.roster.people();
@@ -9117,7 +9234,8 @@ async function onePerson(a, id, result = null) {
9117
9234
  today: await spentToday(a.d.store, p.id, now),
9118
9235
  defaults: DEFAULT_BUDGET.person,
9119
9236
  bounds: boundsOf(),
9120
- names: new Map(everyone.map((x) => [x.id, nameOf(x)]))
9237
+ names: new Map(everyone.map((x) => [x.id, nameOf(x)])),
9238
+ unlimited: a.d.budgetUnlimited === true
9121
9239
  };
9122
9240
  return htmlResponse(personPage(a.v, p, tasks, now, result, isConfiguredAdmin(a.d, p), tokens, budget), result && !result.ok ? 400 : 200);
9123
9241
  }
@@ -9126,6 +9244,11 @@ async function adminGet(a, r) {
9126
9244
  return peoplePage(a, null, 200);
9127
9245
  if (r.kind === "admin-person")
9128
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
+ }
9129
9252
  const people = new Map(a.d.roster.people().map((p) => [p.id, nameOf(p)]));
9130
9253
  if (r.kind === "admin-deliveries") {
9131
9254
  const now = a.d.clock.now();
@@ -10131,6 +10254,8 @@ function route2(path) {
10131
10254
  return { kind: "admin-tasks" };
10132
10255
  if (path === "/admin/deliveries")
10133
10256
  return { kind: "admin-deliveries" };
10257
+ if (path === "/admin/usage")
10258
+ return { kind: "admin-usage" };
10134
10259
  if (path === "/admin/allow")
10135
10260
  return { kind: "admin-allow" };
10136
10261
  const ceiling = PERSON_CEILING.exec(path);
@@ -10484,6 +10609,7 @@ function createWebHandler(w) {
10484
10609
  case "admin":
10485
10610
  case "admin-tasks":
10486
10611
  case "admin-deliveries":
10612
+ case "admin-usage":
10487
10613
  case "admin-person":
10488
10614
  return adminGet(a, r);
10489
10615
  default:
@@ -10534,6 +10660,7 @@ function createPlugin(host, options = {}) {
10534
10660
  const guildIds = parseGuildIds(host.env.TRACKER_GUILD_ID);
10535
10661
  const webOrigin = parseWebUrl(host.env.TRACKER_WEB_URL);
10536
10662
  const cityHall = parseCityHallConfig(host.env);
10663
+ const budgetUnlimited = parseBudgetUnlimited(host.env[BUDGET_UNLIMITED_KEY]);
10537
10664
  const cityHallConfig = cityHall.config;
10538
10665
  const executeOn = cityHallConfig !== null || options.executor !== undefined;
10539
10666
  const clock = options.clock ?? { now: () => new Date };
@@ -10740,7 +10867,9 @@ function createPlugin(host, options = {}) {
10740
10867
  });
10741
10868
  }
10742
10869
  const ceilings = new Ceilings(opened);
10743
- executeLane = executor ? new ExecuteLane({ store: openedStore, clock, types, notifier, executor, locks, budget: budgetPolicy(ceilings) }) : null;
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);
10744
10873
  if (cityHallConfig)
10745
10874
  host.log.info(`execute lane on: city-hall ${cityHallConfig.url}, capability ${cityHallConfig.capability}`);
10746
10875
  else if ("missing" in cityHall && cityHall.missing.length > 0)
@@ -10761,6 +10890,7 @@ function createPlugin(host, options = {}) {
10761
10890
  apiTokens: new ApiTokens(opened),
10762
10891
  roster: new Roster(opened),
10763
10892
  ceilings,
10893
+ budgetUnlimited,
10764
10894
  configuredAdmins: new Set(adminIds),
10765
10895
  lanes,
10766
10896
  webEditor: webOrigin !== null,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rackbops/plugin-tracker",
3
- "version": "0.13.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, and raising a person's daily model ceiling), 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})?/?$",