@rackbops/plugin-tracker 0.12.0 → 0.13.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 +31 -9
  2. package/dist/plugin.js +286 -99
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -12,7 +12,8 @@ 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). The model runs never happen here: they go
16
17
  through city-hall to Rackbops/docket-runner on roshne's own host (plan 5.12), and the whole execute
17
18
  lane stays off until the city-hall Executor is configured (Configuration below).
18
19
 
@@ -124,8 +125,9 @@ says research is not available; it does not list the waiting ones (`/tasks` does
124
125
 
125
126
  **Budgets** (plan 5.7, docket's defaults): 2 USD and 20 model calls a person a day, 10 USD and 100
126
127
  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.
128
+ DM and the admins one each. Reminders, renewals and prices never count. An admin can raise one
129
+ person's ceiling from their admin page (0.13.0, below); the global ceiling still applies to everyone
130
+ together.
129
131
 
130
132
  **Findings.** A passed research answer's claims are stored with their first source (docket's
131
133
  `findings`), shown on the task's page to its owner, accepted recipients and admins (docket's
@@ -262,6 +264,24 @@ the function the command path runs:
262
264
  - **Lift** a decline block in force: docket's `liftBlock`, recorded as that admin's lift.
263
265
  - **Revoke an API token** of anyone's, from their page, which lists their tokens (never the
264
266
  secret). It stops working at once; the owner is not told. Logged by token id.
267
+ - **Raise a person's daily ceiling** (0.13.0, plan 5.7: "The admin raises a person's ceiling from
268
+ the web area"). Their page shows what they spent today (the budget day ends at midnight Eastern),
269
+ their ceiling now, a form with both numbers -- dollars to the cent and whole model calls -- and
270
+ the latest 20 changes an admin made, newest first (older ones stay in the table). Each number
271
+ must be at least the default (2 USD, 20 calls) and at most the global ceiling (10 USD, 100
272
+ calls), which docket checks first anyway; those bounds are proposed on #82, roshne's to confirm.
273
+ Setting exactly the default is a reset to it, not a raise.
274
+ **A raise stands until an admin changes it** -- raises it again or presses **Back to the
275
+ default**. That default is proposed on #82 and is roshne's to confirm; a today-only raise
276
+ would be a small change (`limitsOf` and `isRaised` plus a clock in `Ceilings`, and the page's
277
+ badge). Enforcement stays docket's: the
278
+ execute lane hands docket's `budgetHold` a policy whose `personFor` reads the person's newest
279
+ change, before every run, so a raise lets a held request run on the next tick. docket
280
+ sends its ceiling notices once a day per person (`noticeOnce`, key `budget:person:<id>:<day>`),
281
+ so every change -- raise or reset -- deletes that person's key for the current budget day in the
282
+ same transaction: meeting the new ceiling the same day DMs the person and the admins again (plan
283
+ 5.7: "nothing fails silently"). Every change is an append-only `ceiling_changes` row (who, for whom, the values,
284
+ when) and a log line (`u1 set u2's daily ceiling to 4 USD / 40 calls (c1)`).
265
285
  - **Remove from the tracker**: forget-me for that person (below), with the same confirmation.
266
286
 
267
287
  **Deliveries** (0.10.0). `/admin/deliveries`, linked from every admin page, lists the DMs that
@@ -273,8 +293,9 @@ have gone out and is never resent; `deferred` is still owed (a recipient whose d
273
293
  say). Before 0.10.0 these showed only in the bot log, where the warnings still go. The page is
274
294
  read-only, and like every admin page answers anyone who is not an admin with the unknown page's 404.
275
295
 
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.
296
+ 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.
278
299
 
279
300
  **Forget me** (plan 5.8). A signed-in person erases themselves from `/forget` (web only: the plan
280
301
  asks for no Discord command, so without `TRACKER_WEB_URL` the only path is an admin with the web
@@ -286,14 +307,15 @@ in one SQLite transaction in the write queue, **deleted, not archived**:
286
307
  - on everyone else's tasks: their recipient rows, their replies and answers, the history rows they
287
308
  made or that name them (an invitation of them, a pause for them), the run events and deliveries
288
309
  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>:...`);
310
+ - the budget notices kept for them (`budget:person:<id>:...`) and the changes to their ceiling;
290
311
  - every decline block they are either side of;
291
312
  - their admission, delivery health, web sessions, unused sign-in links and API tokens, and their
292
313
  person row.
293
314
 
294
315
  What stays, and why: another person's task that they received stays that owner's; one paused only
295
316
  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
317
+ id -- `admitted_by` of someone they admitted, `lifted_by` of a block they lifted, and `set_by` of a
318
+ change they made to someone's ceiling become
297
319
  `forgotten` ("an admin since forgotten"; null already means "the configuration"). Text is matched
298
320
  only in the forms the code writes a person's id into -- docket's consent rows (`u5`, `u5 24h`), the
299
321
  tracker's `u5: ...` removals and `delivery to {u5} paused` pauses, docket's `u5 <message>` delivery
@@ -517,8 +539,8 @@ key>`, so two databases never collide.
517
539
 
518
540
  ## Not yet
519
541
 
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
542
+ - The rest of the web area (E5): sharing a task from the web, and the admin view's grants and
543
+ retry. The task API makes reminders, renewals, prices and
522
544
  research requests -- not `/task done`, `snooze`, `decide`, `share` or the settings -- and no intake agent uses it yet
523
545
  (E10, deferred). Discord OAuth2 as a second sign-in
524
546
  method, if chosen (plan item 41).
package/dist/plugin.js CHANGED
@@ -3189,6 +3189,206 @@ 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 HISTORY_SHOWN = 20;
3335
+ function toChange(r) {
3336
+ return { id: `c${r.seq}`, userId: r.user_id, usd: r.usd, calls: r.calls, setBy: r.set_by, at: r.at };
3337
+ }
3338
+
3339
+ class Ceilings {
3340
+ db;
3341
+ constructor(db) {
3342
+ this.db = db;
3343
+ }
3344
+ latest(userId) {
3345
+ const r = this.db.query("SELECT * FROM ceiling_changes WHERE user_id = ? ORDER BY seq DESC LIMIT 1").get(userId);
3346
+ return r ? toChange(r) : null;
3347
+ }
3348
+ history(userId, limit = HISTORY_SHOWN) {
3349
+ return this.db.query("SELECT * FROM ceiling_changes WHERE user_id = ? ORDER BY seq DESC LIMIT ?").all(userId, limit).map(toChange);
3350
+ }
3351
+ record(userId, limits, setBy, at) {
3352
+ return this.db.transaction(() => {
3353
+ 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);
3354
+ this.db.query("DELETE FROM notices WHERE key = ?").run(budgetNoticeKey(userId, new Date(at)));
3355
+ return toChange(r);
3356
+ })();
3357
+ }
3358
+ personFor(user) {
3359
+ const latest = this.latest(user.id);
3360
+ return isRaised(latest) ? limitsOf(latest) : null;
3361
+ }
3362
+ }
3363
+ function budgetPolicy(ceilings, base = DEFAULT_BUDGET) {
3364
+ return { person: base.person, global: base.global, personFor: (u) => ceilings.personFor(u) };
3365
+ }
3366
+ async function spentToday(store, userId, now) {
3367
+ const day = budgetDay(now);
3368
+ const rows = await store.listUsage({ userId, since: day.start, before: day.end });
3369
+ return spent(rows);
3370
+ }
3371
+ function who2(u) {
3372
+ return u.displayName ?? u.discordId ?? u.id;
3373
+ }
3374
+ async function setCeiling(d, admin, targetId, input) {
3375
+ if (!admin.admin)
3376
+ return { ok: false, error: NOT_ADMIN };
3377
+ const target = isPersonId(targetId) ? await d.store.getUser(targetId) : null;
3378
+ if (!target)
3379
+ return { ok: false, error: NO_SUCH_PERSON };
3380
+ const decided = decideCeiling(input);
3381
+ if (!decided.ok)
3382
+ return { ok: false, error: decided.error };
3383
+ const before = d.ceilings.latest(target.id);
3384
+ if (decided.limits === null && !isRaised(before))
3385
+ return { ok: true, text: `${who2(target)} is already on the default ceiling.` };
3386
+ const change = d.ceilings.record(target.id, decided.limits, admin.id, d.clock.now().toISOString());
3387
+ const now = limitsOf(change);
3388
+ 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.` };
3390
+ }
3391
+
3192
3392
  // plugins/tracker/src/delivery-health.ts
3193
3393
  var PAUSE_AFTER = 3;
3194
3394
  var PAUSE_DETAIL = `delivery paused: ${PAUSE_AFTER} DMs in a row could not be delivered (DMs closed, or the bot is blocked)`;
@@ -4201,6 +4401,18 @@ var MIGRATIONS = [
4201
4401
  paused_at TEXT
4202
4402
  );
4203
4403
  CREATE INDEX executor_jobs_occurrence ON executor_jobs (occurrence_id);
4404
+ `,
4405
+ `
4406
+ CREATE TABLE ceiling_changes (
4407
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
4408
+ user_id TEXT NOT NULL,
4409
+ usd REAL,
4410
+ calls INTEGER,
4411
+ set_by TEXT NOT NULL,
4412
+ at TEXT NOT NULL,
4413
+ CHECK ((usd IS NULL) = (calls IS NULL))
4414
+ );
4415
+ CREATE INDEX ceiling_changes_user ON ceiling_changes (user_id, seq);
4204
4416
  `
4205
4417
  ];
4206
4418
  var BUSY_TIMEOUT_MS = 5000;
@@ -6849,6 +7061,8 @@ class Roster {
6849
7061
  del("web_login_tokens", "user_id = ?", userId);
6850
7062
  del("api_tokens", "user_id = ?", userId);
6851
7063
  del("notices", "key LIKE 'budget:person:' || ? || ':%'", userId);
7064
+ del("ceiling_changes", "user_id = ?", userId);
7065
+ this.db.query("UPDATE ceiling_changes SET set_by = ? WHERE set_by = ?").run(FORGOTTEN, userId);
6852
7066
  del("users", "seq = ?", Number(seq));
6853
7067
  const held = this.db.query("SELECT 1 FROM delivery_pauses WHERE task_id = ? LIMIT 1");
6854
7068
  const freed = [...new Set(pausedFor)].filter((t) => held.get(t) === null);
@@ -6857,99 +7071,6 @@ class Roster {
6857
7071
  }
6858
7072
  }
6859
7073
 
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
7074
  // node_modules/@rackbops/styles/rackbops-noir/bundle.css
6954
7075
  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
7076
  box-sizing: border-box;
@@ -8727,6 +8848,45 @@ ${tokenTable(v, tokens, now, (id) => tokenHref(v, id))}
8727
8848
  </section>`);
8728
8849
  }
8729
8850
 
8851
+ // plugins/tracker/src/web/ceiling-pages.ts
8852
+ function setBy(id, names) {
8853
+ if (id === FORGOTTEN)
8854
+ return "an admin since forgotten";
8855
+ return names.get(id) ?? id;
8856
+ }
8857
+ function change(c, v, now, names) {
8858
+ const what = c.usd === null || c.calls === null ? "back to the default" : `raised to ${describeLimits({ usd: c.usd, calls: c.calls })}`;
8859
+ return html`<li>${formatInstant(c.at, v.user.timeZone, now)}: ${what}, by ${setBy(c.setBy, names)}</li>`;
8860
+ }
8861
+ function limitText(n, unit) {
8862
+ return n === null ? `no ${unit} ceiling` : String(n);
8863
+ }
8864
+ function ceilingSection(v, personId, view, now) {
8865
+ const limits = limitsOf(view.latest, view.defaults);
8866
+ const raised = isRaised(view.latest);
8867
+ const action = `${v.base}/admin/people/${encodeURIComponent(personId)}/ceiling`;
8868
+ const csrf = html`<input type="hidden" name="csrf" value="${v.csrf}">`;
8869
+ const { min, max } = view.bounds;
8870
+ return html`<section>
8871
+ <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>
8873
+ <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
+ <form method="post" action="${action}" class="tr-stack">
8875
+ ${csrf}
8876
+ <div class="tr-row">
8877
+ <div class="rb-field"><label class="rb-label" for="ceiling-usd">Dollars a day</label>
8878
+ <input class="rb-input" id="ceiling-usd" name="usd" inputmode="decimal" maxlength="7" value="${limits.usd === null ? "" : limits.usd.toFixed(2)}" required></div>
8879
+ <div class="rb-field"><label class="rb-label" for="ceiling-calls">Model calls a day</label>
8880
+ <input class="rb-input" id="ceiling-calls" name="calls" inputmode="numeric" maxlength="5" value="${limits.calls === null ? "" : String(limits.calls)}" required></div>
8881
+ </div>
8882
+ <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>
8883
+ <div class="tr-row"><button class="rb-btn rb-btn--accent" type="submit">Set ceiling</button></div>
8884
+ </form>
8885
+ ${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}
8886
+ ${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>`}
8887
+ </section>`;
8888
+ }
8889
+
8730
8890
  // plugins/tracker/src/web/admin-pages.ts
8731
8891
  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
8892
  var CONFIRM_WORD = "forget";
@@ -8857,7 +9017,7 @@ ${data.rows.length === 0 ? html`<p class="rb-muted">None in that time.</p>` : ht
8857
9017
  function post2(v, p, action, label, style) {
8858
9018
  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
9019
  }
8860
- function personPage(v, p, tasks, now, result = null, configured = false, tokens = []) {
9020
+ function personPage(v, p, tasks, now, result = null, configured = false, tokens = [], budget) {
8861
9021
  return framed(v, nameOf(p), html`<section>
8862
9022
  ${adminNav(v)}
8863
9023
  <h1>${nameOf(p)}${p.admin ? html` <span class="rb-badge">admin</span>` : null}</h1>
@@ -8876,6 +9036,7 @@ ${p.deliveryPausedAt ? post2(v, p, "resume-delivery", "Resume delivery", "rb-btn
8876
9036
  ${configured && p.id !== v.user.id ? null : post2(v, p, "forget", "Remove from the tracker", "rb-btn--danger")}
8877
9037
  </div>
8878
9038
  </section>
9039
+ ${budget ? ceilingSection(v, p.id, budget, now) : null}
8879
9040
  <section>
8880
9041
  <h2>Their tasks</h2>
8881
9042
  ${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>`}
@@ -8943,12 +9104,22 @@ async function peoplePage(a, result, status, allow) {
8943
9104
  return htmlResponse(adminPage(a.v, data, { result, ...allow !== undefined ? { allow } : {} }), status);
8944
9105
  }
8945
9106
  async function onePerson(a, id, result = null) {
8946
- const p = a.d.roster.people().find((x) => x.id === id);
9107
+ const everyone = a.d.roster.people();
9108
+ const p = everyone.find((x) => x.id === id);
8947
9109
  if (!p)
8948
9110
  return htmlResponse(notFoundPage(a.v.base, a.v), 404);
8949
9111
  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);
9112
+ const now = a.d.clock.now();
9113
+ const tokens = a.d.apiTokens.listFor(p.id, now);
9114
+ const budget = {
9115
+ latest: a.d.ceilings.latest(p.id),
9116
+ history: a.d.ceilings.history(p.id),
9117
+ today: await spentToday(a.d.store, p.id, now),
9118
+ defaults: DEFAULT_BUDGET.person,
9119
+ bounds: boundsOf(),
9120
+ names: new Map(everyone.map((x) => [x.id, nameOf(x)]))
9121
+ };
9122
+ return htmlResponse(personPage(a.v, p, tasks, now, result, isConfiguredAdmin(a.d, p), tokens, budget), result && !result.ok ? 400 : 200);
8952
9123
  }
8953
9124
  async function adminGet(a, r) {
8954
9125
  if (r.kind === "admin")
@@ -8999,6 +9170,15 @@ async function adminPost(a, r, form) {
8999
9170
  return htmlResponse(notFoundPage(base, a.v), 404);
9000
9171
  return onePerson(a, owner.whose, { ok: true, text: "Revoked: that token no longer works." });
9001
9172
  }
9173
+ if (r.kind === "admin-ceiling") {
9174
+ const input = form.get("reset") === "yes" ? { reset: true } : { reset: false, usd: form.get("usd") ?? "", calls: form.get("calls") ?? "" };
9175
+ const done = await fresh(a, true, (me) => setCeiling(a.d, me, r.id, input));
9176
+ if (done === null)
9177
+ return unknownPage(base);
9178
+ if (!done.ok && done.error === NO_SUCH_PERSON)
9179
+ return htmlResponse(notFoundPage(base, a.v), 404);
9180
+ return onePerson(a, r.id, said2(done));
9181
+ }
9002
9182
  if (r.kind !== "admin-act")
9003
9183
  return unknownPage(base);
9004
9184
  if (r.action === "forget") {
@@ -9914,6 +10094,7 @@ var TASK_ACTION = /^\/tasks\/([^/]+)\/(edit|pause|resume|delete)$/;
9914
10094
  var NEW_PATH = /^\/new\/([a-z]+)$/;
9915
10095
  var PERSON_PATH = /^\/admin\/people\/([^/]+)$/;
9916
10096
  var PERSON_ACTION = /^\/admin\/people\/([^/]+)\/(grant|revoke|resume-delivery|forget)$/;
10097
+ var PERSON_CEILING = /^\/admin\/people\/([^/]+)\/ceiling$/;
9917
10098
  var BLOCK_LIFT = /^\/admin\/blocks\/([^/]+)\/lift$/;
9918
10099
  var TOKEN_REVOKE = /^\/tokens\/([^/]+)\/revoke$/;
9919
10100
  var ADMIN_TOKEN_REVOKE = /^\/admin\/tokens\/([^/]+)\/revoke$/;
@@ -9952,6 +10133,9 @@ function route2(path) {
9952
10133
  return { kind: "admin-deliveries" };
9953
10134
  if (path === "/admin/allow")
9954
10135
  return { kind: "admin-allow" };
10136
+ const ceiling = PERSON_CEILING.exec(path);
10137
+ if (ceiling)
10138
+ return { kind: "admin-ceiling", id: segment2(ceiling[1]) };
9955
10139
  const personAct = PERSON_ACTION.exec(path);
9956
10140
  if (personAct)
9957
10141
  return { kind: "admin-act", id: segment2(personAct[1]), action: personAct[2] };
@@ -9978,7 +10162,7 @@ function route2(path) {
9978
10162
  return { kind: "act", id, action };
9979
10163
  }
9980
10164
  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") {
10165
+ 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
10166
  return "POST";
9983
10167
  }
9984
10168
  if (r.kind === "settings" || r.kind === "edit" || r.kind === "new" || r.kind === "forget" || r.kind === "tokens")
@@ -10274,6 +10458,7 @@ function createWebHandler(w) {
10274
10458
  return tokenRevokePost(d, v, r.id);
10275
10459
  case "admin-allow":
10276
10460
  case "admin-act":
10461
+ case "admin-ceiling":
10277
10462
  case "admin-lift":
10278
10463
  case "admin-token-revoke":
10279
10464
  return adminPost(a, r, form);
@@ -10554,7 +10739,8 @@ function createPlugin(host, options = {}) {
10554
10739
  ...options.cityHallFetch ? { fetchImpl: options.cityHallFetch } : {}
10555
10740
  });
10556
10741
  }
10557
- executeLane = executor ? new ExecuteLane({ store: openedStore, clock, types, notifier, executor, locks }) : null;
10742
+ const ceilings = new Ceilings(opened);
10743
+ executeLane = executor ? new ExecuteLane({ store: openedStore, clock, types, notifier, executor, locks, budget: budgetPolicy(ceilings) }) : null;
10558
10744
  if (cityHallConfig)
10559
10745
  host.log.info(`execute lane on: city-hall ${cityHallConfig.url}, capability ${cityHallConfig.capability}`);
10560
10746
  else if ("missing" in cityHall && cityHall.missing.length > 0)
@@ -10574,6 +10760,7 @@ function createPlugin(host, options = {}) {
10574
10760
  sessions: new Sessions(opened),
10575
10761
  apiTokens: new ApiTokens(opened),
10576
10762
  roster: new Roster(opened),
10763
+ ceilings,
10577
10764
  configuredAdmins: new Set(adminIds),
10578
10765
  lanes,
10579
10766
  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.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",
5
5
  "type": "module",
6
6
  "main": "dist/plugin.js",
7
7
  "files": ["dist"],