@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.
- package/README.md +31 -9
- package/dist/plugin.js +286 -99
- 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
|
|
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.
|
|
128
|
-
|
|
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
|
|
277
|
-
|
|
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,
|
|
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
|
|
521
|
-
|
|
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
|
|
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
|
|
8951
|
-
|
|
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
|
-
|
|
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.
|
|
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"],
|