@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.
- package/README.md +50 -9
- package/dist/plugin.js +418 -101
- 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
|
|
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.
|
|
128
|
-
|
|
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
|
|
277
|
-
|
|
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,
|
|
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
|
|
521
|
-
|
|
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
|
|
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
|
|
8951
|
-
|
|
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
|
-
|
|
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.
|
|
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})?/?$",
|