@rackbops/plugin-tracker 0.11.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 +68 -33
- package/dist/plugin.js +376 -121
- 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
|
|
|
@@ -69,7 +70,7 @@ At start the plugin runs docket's `recover()`: a run left running is requeued, a
|
|
|
69
70
|
by a stop mid-send is settled unconfirmed and logged.
|
|
70
71
|
|
|
71
72
|
**Research and the execute lane** (docket 0.5.0, #82). A research request is two model Jobs, run
|
|
72
|
-
one after the other through city-hall's execute lane (Lepid-Labs/city-hall#18) by the runner that
|
|
73
|
+
one after the other through city-hall's execute lane ([Lepid-Labs/city-hall#18](https://github.com/Lepid-Labs/city-hall/pull/18)) by the runner that
|
|
73
74
|
carries `TRACKER_CITY_HALL_CAPABILITY` -- only docket-runner, on roshne's subscription (plan items
|
|
74
75
|
70, 71). The tracker submits each Job with `POST /api/execute/jobs` under a prefixed key,
|
|
75
76
|
`rackbops-tracker:<database id>:<docket's Job key>` -- the database id is random, made once and kept
|
|
@@ -103,15 +104,18 @@ the first sight (a judgement, not a measured figure): past it the Job answers "p
|
|
|
103
104
|
docket gives the run up, and the admins are told once. It logs "the model runner is paused" naming
|
|
104
105
|
the outcome once an hour per Job, and tells the admins once per Job and outcome. docket's own
|
|
105
106
|
usage-limit pause (which waits for the reset the CLI named) applies only to a `usage_limit` result
|
|
106
|
-
that reaches the tracker: city-hall#18 as
|
|
107
|
-
as when a job that was requeued later fails at its maximum of
|
|
107
|
+
that reaches the tracker: under city-hall#18 as merged, that happens only when the job ends failed
|
|
108
|
+
with that result still stored, as when a job that was requeued later fails at its maximum of
|
|
109
|
+
expired leases.
|
|
108
110
|
The plugin never calls a model, holds no Claude credential and no `ANTHROPIC_*` variable; model
|
|
109
111
|
output is data, cleaned by docket before it reaches a DM or a finding, and every research DM goes
|
|
110
112
|
out with no allowed mentions: the host sends every `dm` with `allowedMentions: { parse: [] }`
|
|
111
|
-
(CONTEXT.md). **The wire is city-hall#18's
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
(CONTEXT.md). **The wire is city-hall#18's**
|
|
114
|
+
([Lepid-Labs/city-hall#18](https://github.com/Lepid-Labs/city-hall/pull/18), merged as 90a06ec),
|
|
115
|
+
but its decision record 0002 is still "proposed" pending Nazu's review
|
|
116
|
+
([Lepid-Labs/city-hall#17](https://github.com/Lepid-Labs/city-hall/issues/17)): the source pair is
|
|
117
|
+
a stand-in for the source and responder contracts, so it may change, and this adapter with it --
|
|
118
|
+
the requeue reading above and `lastOutcome` (present in the merged 90a06ec) included.
|
|
115
119
|
|
|
116
120
|
**Research while the lane is off.** If an instance that had city-hall configured loses that
|
|
117
121
|
configuration, its research requests stay queued: nothing runs them until research is available
|
|
@@ -121,8 +125,9 @@ says research is not available; it does not list the waiting ones (`/tasks` does
|
|
|
121
125
|
|
|
122
126
|
**Budgets** (plan 5.7, docket's defaults): 2 USD and 20 model calls a person a day, 10 USD and 100
|
|
123
127
|
calls in all. At a ceiling the person's research waits until midnight Eastern; the person gets one
|
|
124
|
-
DM and the admins one each. Reminders, renewals and prices never count.
|
|
125
|
-
|
|
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.
|
|
126
131
|
|
|
127
132
|
**Findings.** A passed research answer's claims are stored with their first source (docket's
|
|
128
133
|
`findings`), shown on the task's page to its owner, accepted recipients and admins (docket's
|
|
@@ -148,8 +153,8 @@ served at a hashed path, cached for a year).
|
|
|
148
153
|
| Path | What |
|
|
149
154
|
|---|---|
|
|
150
155
|
| `/` | My tasks: the same list as `/tasks` (active tasks owned and received, next run in your zone; paused ones and why), each linking to its history, and links to make a new one. |
|
|
151
|
-
| `/tasks/<id>` | A task's history: the same as `/task history`, for the owner, an accepted recipient or an admin. Anyone else gets the same 404 as an unknown id. The owner also sees Edit, Pause or Resume, and Delete. |
|
|
152
|
-
| `/new/reminder`, `/new/renewal`, `/new/price` | The editor's new-task forms (GET), and making one (POST). |
|
|
156
|
+
| `/tasks/<id>` | A task's history: the same as `/task history`, for the owner, an accepted recipient or an admin. Anyone else gets the same 404 as an unknown id. The owner also sees Edit (not for a research request), Pause or Resume, and Delete. |
|
|
157
|
+
| `/new/reminder`, `/new/renewal`, `/new/price`, `/new/research` | The editor's new-task forms (GET), and making one (POST). `/new/research` is linked from My tasks only while research is available; without it the page says research is not available instead of showing a form. |
|
|
153
158
|
| `/tasks/<id>/edit` | The owner's edit form (GET) and saving it (POST). |
|
|
154
159
|
| `/tasks/<id>/pause`, `/resume`, `/delete` | POST only. Delete answers a confirmation first; only a second post carrying `confirm=yes` deletes. |
|
|
155
160
|
| `/settings` | Preferred hour and time zone, checked as `/register` checks them. Links to API tokens and Forget me. |
|
|
@@ -184,8 +189,8 @@ than `TRACKER_WEB_URL`'s is refused. The sign-in post has no session yet, so it
|
|
|
184
189
|
double-submit token from a `SameSite=Strict` cookie the link's page sets: another site cannot sign
|
|
185
190
|
you in as someone else. Sign out is a POST too.
|
|
186
191
|
|
|
187
|
-
**The task editor** (slice 2). The forms take the options of `/remind`, `/renewal` and
|
|
188
|
-
and the same functions check and make them: the same defaults, limits and messages, the page read
|
|
192
|
+
**The task editor** (slice 2). The forms take the options of `/remind`, `/renewal`, `/price` and
|
|
193
|
+
`/research` (#82), and the same functions check and make them: the same defaults, limits and messages, the page read
|
|
189
194
|
once before a price tracker is made, at most 20 price trackers per person wherever they were made,
|
|
190
195
|
and `near` turned into the same bounded pattern; a person has one new price tracker's page read in
|
|
191
196
|
flight at a time. Every person may have at most 200 active or paused tasks of all types together,
|
|
@@ -208,7 +213,11 @@ are dropped and nothing more is sent, but its history is kept (an admin can see
|
|
|
208
213
|
its page stays for the owner. Every editor post passes the session, CSRF and `Origin` checks; the
|
|
209
214
|
task is loaded by the id in the path and must be the viewer's own, checked again in the write
|
|
210
215
|
queue, and anyone else's -- even one an admin can see -- answers the same 404 as an unknown id. A
|
|
211
|
-
refused form comes back with what was typed and why.
|
|
216
|
+
refused form comes back with what was typed and why. A research request made on the web is
|
|
217
|
+
`/research`'s exactly -- the question, the optional context, deadline and start in `/research`'s
|
|
218
|
+
words, the cap of 5 waiting, the daily budget -- and, as in Discord, it is never edited: its page
|
|
219
|
+
has Pause, Resume and Delete but no Edit. Making one only queues its run; nothing reaches city-hall
|
|
220
|
+
until the execute tick. Form bodies are capped at 32 KiB, read no
|
|
212
221
|
further than that.
|
|
213
222
|
|
|
214
223
|
**Headers.** `Content-Security-Policy: default-src 'none'; style-src 'self'; img-src 'none';
|
|
@@ -255,6 +264,24 @@ the function the command path runs:
|
|
|
255
264
|
- **Lift** a decline block in force: docket's `liftBlock`, recorded as that admin's lift.
|
|
256
265
|
- **Revoke an API token** of anyone's, from their page, which lists their tokens (never the
|
|
257
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)`).
|
|
258
285
|
- **Remove from the tracker**: forget-me for that person (below), with the same confirmation.
|
|
259
286
|
|
|
260
287
|
**Deliveries** (0.10.0). `/admin/deliveries`, linked from every admin page, lists the DMs that
|
|
@@ -266,8 +293,9 @@ have gone out and is never resent; `deferred` is still owed (a recipient whose d
|
|
|
266
293
|
say). Before 0.10.0 these showed only in the bot log, where the warnings still go. The page is
|
|
267
294
|
read-only, and like every admin page answers anyone who is not an admin with the unknown page's 404.
|
|
268
295
|
|
|
269
|
-
Not here yet, though plan 5.10 lists them: grants
|
|
270
|
-
|
|
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.
|
|
271
299
|
|
|
272
300
|
**Forget me** (plan 5.8). A signed-in person erases themselves from `/forget` (web only: the plan
|
|
273
301
|
asks for no Discord command, so without `TRACKER_WEB_URL` the only path is an admin with the web
|
|
@@ -279,14 +307,15 @@ in one SQLite transaction in the write queue, **deleted, not archived**:
|
|
|
279
307
|
- on everyone else's tasks: their recipient rows, their replies and answers, the history rows they
|
|
280
308
|
made or that name them (an invitation of them, a pause for them), the run events and deliveries
|
|
281
309
|
of DMs to them (docket's `deleteDeliveries`), charges made for them, and their pause rows;
|
|
282
|
-
- 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;
|
|
283
311
|
- every decline block they are either side of;
|
|
284
312
|
- their admission, delivery health, web sessions, unused sign-in links and API tokens, and their
|
|
285
313
|
person row.
|
|
286
314
|
|
|
287
315
|
What stays, and why: another person's task that they received stays that owner's; one paused only
|
|
288
316
|
because DMs to them failed goes back on. Another person's row keeps its own audit fact but not their
|
|
289
|
-
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
|
|
290
319
|
`forgotten` ("an admin since forgotten"; null already means "the configuration"). Text is matched
|
|
291
320
|
only in the forms the code writes a person's id into -- docket's consent rows (`u5`, `u5 24h`), the
|
|
292
321
|
tracker's `u5: ...` removals and `delivery to {u5} paused` pauses, docket's `u5 <message>` delivery
|
|
@@ -393,15 +422,18 @@ a price back the page's address -- exactly as the web editor's empty fields do.
|
|
|
393
422
|
value: it is refused as the wrong JSON type (send `""` to clear a note).
|
|
394
423
|
|
|
395
424
|
Which types: `GET /tasks`, `GET /tasks/<id>`, pause, resume and `DELETE` work on any task you own,
|
|
396
|
-
whatever its type
|
|
397
|
-
|
|
425
|
+
whatever its type. `POST /tasks` takes the three editor types (reminder, renewal, price) and,
|
|
426
|
+
while research is available on this bot, `research` (#82), by `/research`'s rules; while it is not,
|
|
427
|
+
a `research` create answers `503 unavailable`. `PATCH` takes the three editor types only: a
|
|
428
|
+
research request cannot be edited, here or in Discord, and a `PATCH` of one answers
|
|
429
|
+
`409 conflict`.
|
|
398
430
|
|
|
399
431
|
| Method and path | What |
|
|
400
432
|
|---|---|
|
|
401
433
|
| `GET /api/v1/me` | The token's owner (id, name, zone, preferred hour) and the token (id, name, made, expires). |
|
|
402
|
-
| `GET /api/v1/types` | Each type
|
|
434
|
+
| `GET /api/v1/types` | Each type this bot makes now (research only while it is available), whether a `PATCH` takes it (`editable`; false for research, whose `edit` is empty), and its create and edit fields: name, JSON type, required (never, for an edit), description, and limits (`maxLength`, `minimum`, `maximum`, `enum`). This describes the tracker's own editor fields -- what these endpoints take -- and is **not** docket-core's `TaskType.intake` (`IntakeSpec`), which describes a type's config. |
|
|
403
435
|
| `GET /api/v1/tasks` | Your tasks that are not deleted -- active, paused and done -- oldest first. Not the ones shared with you. |
|
|
404
|
-
| `POST /api/v1/tasks` | Makes one: `type` is `reminder`, `renewal` or `
|
|
436
|
+
| `POST /api/v1/tasks` | Makes one: `type` is `reminder`, `renewal`, `price` or `research`, and the rest are that type's create fields. `201`, with `Location`. A price's page is read first, and nothing is made unless a price is found in it. A research request's fields are `question` (required), `context`, `deadline` and `at`, all text. |
|
|
405
437
|
| `GET /api/v1/tasks/<id>` | One of your tasks (a deleted one too), with its history: the newest runs and changes, as `/task history` shows them. |
|
|
406
438
|
| `PATCH /api/v1/tasks/<id>` | Edits it: the fields to change. A price's page is not editable. |
|
|
407
439
|
| `POST /api/v1/tasks/<id>/pause`, `/resume` | Body `{}`. As the web's Pause and Resume, and `/task resume`. |
|
|
@@ -411,7 +443,8 @@ A task is `{"id", "type", "title", "status", "cadence", "nextAt", "createdAt", "
|
|
|
411
443
|
"settings"}`, and a price also has a top-level `url`: `status` is `active`, `paused`, `done` or
|
|
412
444
|
`deleted`; `cadence` is the schedule in words, in your zone; `nextAt` the next run's instant (UTC),
|
|
413
445
|
null when paused or nothing is due; `settings` exactly the fields a `PATCH` takes, as the task has
|
|
414
|
-
them. A
|
|
446
|
+
them. A research request's `settings` are what was asked -- `question`, and `context` and
|
|
447
|
+
`deadline` (an instant) when given -- shown to you alone and changed by no `PATCH`. A price's `url` is read-only -- another page is another tracker -- so it is not in `settings`
|
|
415
448
|
and a `PATCH` naming it is refused (`unknown_field`). A write answers
|
|
416
449
|
`{"task", "message"}`, `message` being the words the web and the command say.
|
|
417
450
|
|
|
@@ -428,9 +461,9 @@ and a `PATCH` naming it is refused (`unknown_field`). A write answers
|
|
|
428
461
|
| 403 | `not_member` | The owner has left every `TRACKER_GUILD_ID` server; their tokens are revoked. |
|
|
429
462
|
| 404 | `not_found` | No such endpoint, or no such task of yours. Anyone else's task -- one shared with you, or any task to an admin's token -- answers exactly as an unknown id. |
|
|
430
463
|
| 405 | `method_not_allowed` | With `Allow`. |
|
|
431
|
-
| 409 | `conflict` | A finished task edited, or a pause of a task not active (a resume of one not paused). |
|
|
432
|
-
| 409 | `limit_reached` | 200 active or paused tasks,
|
|
433
|
-
| 409 | `busy` | Your last new price tracker's page is still being read. |
|
|
464
|
+
| 409 | `conflict` | A finished task edited, a research request edited, or a pause of a task not active (a resume of one not paused). |
|
|
465
|
+
| 409 | `limit_reached` | 200 active or paused tasks, 20 price trackers, or 5 research requests waiting, as the commands count them. |
|
|
466
|
+
| 409 | `busy` | Your last new price tracker's page is still being read, or the task is with the model runner right now (try again in a minute). |
|
|
434
467
|
| 413 | `too_large` | Body over 16 KiB. |
|
|
435
468
|
| 415 | `unsupported_media_type` | Not `application/json`. |
|
|
436
469
|
| 429 | `rate_limited` | With `Retry-After`: this token's bucket, or the shared one for bad tokens. |
|
|
@@ -506,15 +539,17 @@ key>`, so two databases never collide.
|
|
|
506
539
|
|
|
507
540
|
## Not yet
|
|
508
541
|
|
|
509
|
-
- The rest of the web area (E5): sharing a task from the web, and the admin view's grants
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
not `/task done`, `snooze`, `decide`, `share` or the settings -- and no intake agent uses it yet
|
|
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
|
|
544
|
+
research requests -- not `/task done`, `snooze`, `decide`, `share` or the settings -- and no intake agent uses it yet
|
|
513
545
|
(E10, deferred). Discord OAuth2 as a second sign-in
|
|
514
546
|
method, if chosen (plan item 41).
|
|
515
547
|
- The rest of #82: per-type grants beyond tier 0 (research needs only `notify`), the transcripts
|
|
516
|
-
policy, and live verification against a real city-hall and docket-runner
|
|
517
|
-
|
|
548
|
+
policy, and live verification against a real city-hall and docket-runner
|
|
549
|
+
([Lepid-Labs/city-hall#18](https://github.com/Lepid-Labs/city-hall/pull/18) is merged as
|
|
550
|
+
90a06ec, its decision record still "proposed" pending
|
|
551
|
+
[Lepid-Labs/city-hall#17](https://github.com/Lepid-Labs/city-hall/issues/17); the research lane
|
|
552
|
+
still needs a deployed city-hall with a runner). The scout and the want-list (E9).
|
|
518
553
|
- Editing in Discord (the editor is on the web only), and changing a price tracker's page or `near` after it is made (make a new one); a free-form pattern for `price` (`near` is the safe subset: a user's regular expression run on a large page could hang the bot).
|
|
519
554
|
- An optional Discord-role gate (plan 5.5), and showing unconfirmed deliveries to admins anywhere but the log, or a run's deliveries in its history.
|
|
520
555
|
|
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;
|
|
@@ -8289,14 +8410,15 @@ function cadence(entry, viewer, now) {
|
|
|
8289
8410
|
return "once";
|
|
8290
8411
|
return describeSchedule(s, entry.from ?? viewer, viewer.timeZone, now);
|
|
8291
8412
|
}
|
|
8292
|
-
function newLinks(v) {
|
|
8413
|
+
function newLinks(v, research) {
|
|
8293
8414
|
return html`<p class="tr-row">
|
|
8294
8415
|
<a class="rb-btn rb-btn--primary rb-btn--sm" href="${v.base}/new/reminder">New reminder</a>
|
|
8295
8416
|
<a class="rb-btn rb-btn--ghost rb-btn--sm" href="${v.base}/new/renewal">New renewal</a>
|
|
8296
8417
|
<a class="rb-btn rb-btn--ghost rb-btn--sm" href="${v.base}/new/price">New price tracker</a>
|
|
8418
|
+
${research ? html`<a class="rb-btn rb-btn--ghost rb-btn--sm" href="${v.base}/new/research">New research request</a>` : null}
|
|
8297
8419
|
</p>`;
|
|
8298
8420
|
}
|
|
8299
|
-
function tasksPage(v, data, now, flash = null) {
|
|
8421
|
+
function tasksPage(v, data, now, flash = null, research = false) {
|
|
8300
8422
|
const rows = data.entries.map((e) => html`<tr>
|
|
8301
8423
|
<td><a class="rb-link" href="${v.base}/tasks/${e.task.id}">${e.task.title}</a></td>
|
|
8302
8424
|
<td>${e.from ? name3(e.from) : "you"}</td>
|
|
@@ -8315,7 +8437,7 @@ function tasksPage(v, data, now, flash = null) {
|
|
|
8315
8437
|
<h1>My tasks</h1>
|
|
8316
8438
|
${flash}
|
|
8317
8439
|
<p class="rb-muted">Times are in ${v.user.timeZone}.</p>
|
|
8318
|
-
${newLinks(v)}
|
|
8440
|
+
${newLinks(v, research)}
|
|
8319
8441
|
${active}
|
|
8320
8442
|
</section>
|
|
8321
8443
|
${paused}
|
|
@@ -8405,6 +8527,7 @@ function errorPage(base, title, message) {
|
|
|
8405
8527
|
|
|
8406
8528
|
// plugins/tracker/src/web/editor-pages.ts
|
|
8407
8529
|
var EDITOR_TYPES = ["reminder", "renewal", "price"];
|
|
8530
|
+
var NEW_TYPES = [...EDITOR_TYPES, "research"];
|
|
8408
8531
|
var REPEAT_OPTIONS = [["none", "once"], ["day", "daily"], ["week", "weekly"], ["month", "monthly"]];
|
|
8409
8532
|
var UNIT_OPTIONS = [["year", "yearly"], ["month", "monthly"], ["week", "weekly"], ["day", "daily"]];
|
|
8410
8533
|
var BASELINE_OPTIONS = [["last", "the last price seen"], ["first", "the first price seen"], ["peak", "the highest price seen"]];
|
|
@@ -8414,8 +8537,16 @@ var PRICE_SETTINGS = [
|
|
|
8414
8537
|
{ name: "drop", label: "Alert on a drop of at least (percent)", kind: "text", decimal: true, help: "1 to 90; 10 when empty." },
|
|
8415
8538
|
{ name: "baseline", label: "Measure the drop from", kind: "select", options: BASELINE_OPTIONS }
|
|
8416
8539
|
];
|
|
8540
|
+
var RESEARCH_FIELDS = [
|
|
8541
|
+
{ name: "question", label: "What to look into", kind: "textarea", required: true, maxlength: MAX_QUESTION_CHARS },
|
|
8542
|
+
{ name: "context", label: "Context", kind: "textarea", maxlength: MAX_CONTEXT_CHARS, help: "Anything that narrows it down. Only you see it here; it goes to the model with the question." },
|
|
8543
|
+
{ name: "deadline", label: "Deadline", kind: "text", maxlength: MAX_WHEN, help: 'For example "fri 17:00". Past it nothing more is run.' },
|
|
8544
|
+
{ name: "at", label: "Start", kind: "text", maxlength: MAX_WHEN, help: "Leave it empty to start now." }
|
|
8545
|
+
];
|
|
8417
8546
|
function fieldsFor(type, mode) {
|
|
8418
8547
|
switch (type) {
|
|
8548
|
+
case "research":
|
|
8549
|
+
return mode === "new" ? RESEARCH_FIELDS : [];
|
|
8419
8550
|
case "reminder":
|
|
8420
8551
|
return [
|
|
8421
8552
|
{ name: "text", label: "Remind me to", kind: "text", required: true, maxlength: MAX_REMINDER_TEXT },
|
|
@@ -8458,6 +8589,13 @@ function field(f, values) {
|
|
|
8458
8589
|
<label class="rb-label" for="${f.name}">${f.label}</label>
|
|
8459
8590
|
<select class="rb-select" id="${f.name}" name="${f.name}">${(f.options ?? []).map(([v, label]) => html`<option value="${v}"${v === value ? html` selected` : null}>${label}</option>`)}</select>
|
|
8460
8591
|
${help}
|
|
8592
|
+
</div>`;
|
|
8593
|
+
}
|
|
8594
|
+
if (f.kind === "textarea") {
|
|
8595
|
+
return html`<div class="rb-field">
|
|
8596
|
+
<label class="rb-label" for="${f.name}">${f.label}</label>
|
|
8597
|
+
<textarea class="rb-textarea" id="${f.name}" name="${f.name}" rows="4"${f.maxlength ? html` maxlength="${f.maxlength}"` : null}${f.required ? html` required` : null}>${value}</textarea>
|
|
8598
|
+
${help}
|
|
8461
8599
|
</div>`;
|
|
8462
8600
|
}
|
|
8463
8601
|
const type = f.kind === "number" ? "number" : f.kind === "date" ? "date" : f.kind === "url" ? "url" : "text";
|
|
@@ -8473,19 +8611,25 @@ function alert(error) {
|
|
|
8473
8611
|
function taskHref(v, id, action = "") {
|
|
8474
8612
|
return `${v.base}/tasks/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;
|
|
8475
8613
|
}
|
|
8476
|
-
var NOUN = { reminder: "reminder", renewal: "renewal", price: "price tracker" };
|
|
8477
|
-
|
|
8478
|
-
|
|
8479
|
-
|
|
8480
|
-
|
|
8481
|
-
|
|
8482
|
-
|
|
8483
|
-
|
|
8614
|
+
var NOUN = { reminder: "reminder", renewal: "renewal", price: "price tracker", research: "research request" };
|
|
8615
|
+
var INTRO = {
|
|
8616
|
+
price: "I read the page once now; nothing is made unless I find a price in it.",
|
|
8617
|
+
research: "A research run looks it up on the web, then a second run checks the answer against its sources; only an answer that passes is DMed to you. It can take a while, and requests share a daily budget."
|
|
8618
|
+
};
|
|
8619
|
+
function newTaskPage(v, type, values, error, off) {
|
|
8620
|
+
const text = INTRO[type];
|
|
8621
|
+
const intro = text ? html`<p class="rb-muted">${text}</p>` : null;
|
|
8622
|
+
const form = off ? html`<div class="rb-alert rb-alert--warning" role="status"><p>${prose(off)}</p></div>` : html`${alert(error)}
|
|
8484
8623
|
<form method="post" action="${v.base}/new/${type}" class="tr-stack">
|
|
8485
8624
|
<input type="hidden" name="csrf" value="${v.csrf}">
|
|
8486
8625
|
${fieldsFor(type, "new").map((f) => field(f, values))}
|
|
8487
8626
|
<div><button class="rb-btn rb-btn--primary" type="submit">Create</button></div>
|
|
8488
|
-
</form
|
|
8627
|
+
</form>`;
|
|
8628
|
+
return framed(v, `New ${NOUN[type]}`, html`<section>
|
|
8629
|
+
<p><a class="rb-link" href="${v.base}/">My tasks</a></p>
|
|
8630
|
+
<h1>New ${NOUN[type]}</h1>
|
|
8631
|
+
${intro}
|
|
8632
|
+
${form}
|
|
8489
8633
|
</section>`);
|
|
8490
8634
|
}
|
|
8491
8635
|
function editTaskPage(v, task, values, o) {
|
|
@@ -8704,6 +8848,45 @@ ${tokenTable(v, tokens, now, (id) => tokenHref(v, id))}
|
|
|
8704
8848
|
</section>`);
|
|
8705
8849
|
}
|
|
8706
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
|
+
|
|
8707
8890
|
// plugins/tracker/src/web/admin-pages.ts
|
|
8708
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.";
|
|
8709
8892
|
var CONFIRM_WORD = "forget";
|
|
@@ -8834,7 +9017,7 @@ ${data.rows.length === 0 ? html`<p class="rb-muted">None in that time.</p>` : ht
|
|
|
8834
9017
|
function post2(v, p, action, label, style) {
|
|
8835
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>`;
|
|
8836
9019
|
}
|
|
8837
|
-
function personPage(v, p, tasks, now, result = null, configured = false, tokens = []) {
|
|
9020
|
+
function personPage(v, p, tasks, now, result = null, configured = false, tokens = [], budget) {
|
|
8838
9021
|
return framed(v, nameOf(p), html`<section>
|
|
8839
9022
|
${adminNav(v)}
|
|
8840
9023
|
<h1>${nameOf(p)}${p.admin ? html` <span class="rb-badge">admin</span>` : null}</h1>
|
|
@@ -8853,6 +9036,7 @@ ${p.deliveryPausedAt ? post2(v, p, "resume-delivery", "Resume delivery", "rb-btn
|
|
|
8853
9036
|
${configured && p.id !== v.user.id ? null : post2(v, p, "forget", "Remove from the tracker", "rb-btn--danger")}
|
|
8854
9037
|
</div>
|
|
8855
9038
|
</section>
|
|
9039
|
+
${budget ? ceilingSection(v, p.id, budget, now) : null}
|
|
8856
9040
|
<section>
|
|
8857
9041
|
<h2>Their tasks</h2>
|
|
8858
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>`}
|
|
@@ -8920,12 +9104,22 @@ async function peoplePage(a, result, status, allow) {
|
|
|
8920
9104
|
return htmlResponse(adminPage(a.v, data, { result, ...allow !== undefined ? { allow } : {} }), status);
|
|
8921
9105
|
}
|
|
8922
9106
|
async function onePerson(a, id, result = null) {
|
|
8923
|
-
const
|
|
9107
|
+
const everyone = a.d.roster.people();
|
|
9108
|
+
const p = everyone.find((x) => x.id === id);
|
|
8924
9109
|
if (!p)
|
|
8925
9110
|
return htmlResponse(notFoundPage(a.v.base, a.v), 404);
|
|
8926
9111
|
const tasks = await a.d.store.listTasks({ ownerId: p.id });
|
|
8927
|
-
const
|
|
8928
|
-
|
|
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);
|
|
8929
9123
|
}
|
|
8930
9124
|
async function adminGet(a, r) {
|
|
8931
9125
|
if (r.kind === "admin")
|
|
@@ -8976,6 +9170,15 @@ async function adminPost(a, r, form) {
|
|
|
8976
9170
|
return htmlResponse(notFoundPage(base, a.v), 404);
|
|
8977
9171
|
return onePerson(a, owner.whose, { ok: true, text: "Revoked: that token no longer works." });
|
|
8978
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
|
+
}
|
|
8979
9182
|
if (r.kind !== "admin-act")
|
|
8980
9183
|
return unknownPage(base);
|
|
8981
9184
|
if (r.action === "forget") {
|
|
@@ -9233,6 +9436,21 @@ function reminderInput(form) {
|
|
|
9233
9436
|
const when = opt(form, "when");
|
|
9234
9437
|
return { text: str2(form, "text"), ...when !== undefined ? { when } : {}, repeat: str2(form, "repeat") || "none" };
|
|
9235
9438
|
}
|
|
9439
|
+
function lines(v) {
|
|
9440
|
+
return v?.replace(/\r\n?/g, `
|
|
9441
|
+
`);
|
|
9442
|
+
}
|
|
9443
|
+
function researchInput(form) {
|
|
9444
|
+
const context = lines(opt(form, "context"));
|
|
9445
|
+
const deadline = opt(form, "deadline");
|
|
9446
|
+
const at = opt(form, "at");
|
|
9447
|
+
return {
|
|
9448
|
+
question: lines(str2(form, "question")) ?? "",
|
|
9449
|
+
...context !== undefined ? { context } : {},
|
|
9450
|
+
...deadline !== undefined ? { deadline } : {},
|
|
9451
|
+
...at !== undefined ? { at } : {}
|
|
9452
|
+
};
|
|
9453
|
+
}
|
|
9236
9454
|
function renewalInput(form) {
|
|
9237
9455
|
const every = int(form, "every");
|
|
9238
9456
|
const lead = int(form, "lead");
|
|
@@ -9336,6 +9554,8 @@ async function makeTask(w, type, form) {
|
|
|
9336
9554
|
return asUser(w, (u) => createReminder(w.d, u, reminderInput(form)));
|
|
9337
9555
|
if (type === "renewal")
|
|
9338
9556
|
return asUser(w, (u) => createRenewal(w.d, u, renewalInput(form)));
|
|
9557
|
+
if (type === "research")
|
|
9558
|
+
return asUser(w, (u) => createResearch(w.d, u, researchInput(form)));
|
|
9339
9559
|
const input = priceInput(form);
|
|
9340
9560
|
if (w.reading.has(w.userId))
|
|
9341
9561
|
return { ok: false, error: READING };
|
|
@@ -9369,9 +9589,12 @@ function actOn(w, taskId, action) {
|
|
|
9369
9589
|
var DEFAULTS = {
|
|
9370
9590
|
reminder: { repeat: "none" },
|
|
9371
9591
|
renewal: { unit: "year", every: "1", lead: "7" },
|
|
9372
|
-
price: { hours: "12", drop: "10", baseline: "last" }
|
|
9592
|
+
price: { hours: "12", drop: "10", baseline: "last" },
|
|
9593
|
+
research: {}
|
|
9373
9594
|
};
|
|
9374
9595
|
function newGet(e, type) {
|
|
9596
|
+
if (type === "research" && !e.d.research)
|
|
9597
|
+
return htmlResponse(newTaskPage(e.v, type, {}, undefined, RESEARCH_OFF));
|
|
9375
9598
|
return htmlResponse(newTaskPage(e.v, type, type === "renewal" ? { ...DEFAULTS.renewal, currency: "USD" } : DEFAULTS[type]));
|
|
9376
9599
|
}
|
|
9377
9600
|
async function newPost(e, type, form) {
|
|
@@ -9438,7 +9661,11 @@ function jsonKind(f) {
|
|
|
9438
9661
|
return "integer";
|
|
9439
9662
|
return f.decimal ? "number" : "string";
|
|
9440
9663
|
}
|
|
9441
|
-
function
|
|
9664
|
+
function offeredTypes(d) {
|
|
9665
|
+
return d.research ? NEW_TYPES : EDITOR_TYPES;
|
|
9666
|
+
}
|
|
9667
|
+
var NOT_EDITABLE = "A research request cannot be edited. Delete it and ask again.";
|
|
9668
|
+
function typesAnswer(d) {
|
|
9442
9669
|
const describe = (fields, mode) => fields.map((f) => ({
|
|
9443
9670
|
name: f.name,
|
|
9444
9671
|
type: jsonKind(f),
|
|
@@ -9451,7 +9678,14 @@ function typesAnswer() {
|
|
|
9451
9678
|
}));
|
|
9452
9679
|
return {
|
|
9453
9680
|
status: 200,
|
|
9454
|
-
body: {
|
|
9681
|
+
body: {
|
|
9682
|
+
types: offeredTypes(d).map((type) => ({
|
|
9683
|
+
type,
|
|
9684
|
+
editable: type !== "research",
|
|
9685
|
+
create: describe(fieldsFor(type, "new"), "new"),
|
|
9686
|
+
edit: describe(fieldsFor(type, "edit"), "edit")
|
|
9687
|
+
}))
|
|
9688
|
+
}
|
|
9455
9689
|
};
|
|
9456
9690
|
}
|
|
9457
9691
|
function asFields(body, type, mode) {
|
|
@@ -9481,8 +9715,12 @@ function refusal(error) {
|
|
|
9481
9715
|
return notFound();
|
|
9482
9716
|
if (error === NO_LONGER_LISTED)
|
|
9483
9717
|
return problem(401, "invalid_token", "The token's owner is no longer on this tracker's list.");
|
|
9484
|
-
if (error === READING)
|
|
9718
|
+
if (error === READING || error === TASK_BUSY)
|
|
9485
9719
|
return problem(409, "busy", error);
|
|
9720
|
+
if (error === NOT_EDITABLE)
|
|
9721
|
+
return problem(409, "conflict", error);
|
|
9722
|
+
if (error === RESEARCH_OFF)
|
|
9723
|
+
return problem(503, "unavailable", error);
|
|
9486
9724
|
if (error === FINISHED || /^That task is [a-z]+, not [a-z]+\.$/.test(error))
|
|
9487
9725
|
return problem(409, "conflict", error);
|
|
9488
9726
|
if (/^You already (have|track) [0-9]+ /.test(error))
|
|
@@ -9501,6 +9739,14 @@ async function taskJson(d, user, task) {
|
|
|
9501
9739
|
const type = EDITOR_TYPES.includes(task.type) ? task.type : null;
|
|
9502
9740
|
const values = editValues(d, user, task);
|
|
9503
9741
|
const settings = {};
|
|
9742
|
+
if (task.type === "research") {
|
|
9743
|
+
const c = task.config;
|
|
9744
|
+
settings.question = c.question;
|
|
9745
|
+
if (c.context !== undefined)
|
|
9746
|
+
settings.context = c.context;
|
|
9747
|
+
if (c.deadline !== undefined)
|
|
9748
|
+
settings.deadline = c.deadline;
|
|
9749
|
+
}
|
|
9504
9750
|
if (type) {
|
|
9505
9751
|
for (const f of fieldsFor(type, "edit")) {
|
|
9506
9752
|
if (f.name === "when")
|
|
@@ -9559,9 +9805,11 @@ async function written(w, user, result, status, base) {
|
|
|
9559
9805
|
}
|
|
9560
9806
|
async function createAnswer(w, user, body, base) {
|
|
9561
9807
|
const type = body.type;
|
|
9562
|
-
if (typeof type !== "string" || !
|
|
9563
|
-
return problem(400, "invalid", `\`type\` is one of ${
|
|
9808
|
+
if (typeof type !== "string" || !NEW_TYPES.includes(type)) {
|
|
9809
|
+
return problem(400, "invalid", `\`type\` is one of ${offeredTypes(w.d).join(", ")}.`);
|
|
9564
9810
|
}
|
|
9811
|
+
if (type === "research" && !w.d.research)
|
|
9812
|
+
return refusal(RESEARCH_OFF);
|
|
9565
9813
|
const form = asFields(body, type, "new");
|
|
9566
9814
|
if (!(form instanceof URLSearchParams))
|
|
9567
9815
|
return form;
|
|
@@ -9570,7 +9818,7 @@ async function createAnswer(w, user, body, base) {
|
|
|
9570
9818
|
async function editAnswer(w, user, id, body, base) {
|
|
9571
9819
|
const task = await editableTask(w.d, user, id);
|
|
9572
9820
|
if (!task)
|
|
9573
|
-
return notFound();
|
|
9821
|
+
return (await ownLiveTask(w.d, user, id))?.type === "research" ? refusal(NOT_EDITABLE) : notFound();
|
|
9574
9822
|
const form = asFields(body, task.type, "edit");
|
|
9575
9823
|
if (!(form instanceof URLSearchParams))
|
|
9576
9824
|
return form;
|
|
@@ -9756,7 +10004,7 @@ async function handleApi(w, request, path) {
|
|
|
9756
10004
|
if (method === "DELETE" && r.kind === "task")
|
|
9757
10005
|
return json2(await actAnswer(writer, user, r.id, "delete"));
|
|
9758
10006
|
if (r.kind === "types")
|
|
9759
|
-
return json2(typesAnswer());
|
|
10007
|
+
return json2(typesAnswer(w.d));
|
|
9760
10008
|
if (r.kind === "tasks")
|
|
9761
10009
|
return json2(await listAnswer(w.d, user));
|
|
9762
10010
|
if (r.kind === "task")
|
|
@@ -9846,6 +10094,7 @@ var TASK_ACTION = /^\/tasks\/([^/]+)\/(edit|pause|resume|delete)$/;
|
|
|
9846
10094
|
var NEW_PATH = /^\/new\/([a-z]+)$/;
|
|
9847
10095
|
var PERSON_PATH = /^\/admin\/people\/([^/]+)$/;
|
|
9848
10096
|
var PERSON_ACTION = /^\/admin\/people\/([^/]+)\/(grant|revoke|resume-delivery|forget)$/;
|
|
10097
|
+
var PERSON_CEILING = /^\/admin\/people\/([^/]+)\/ceiling$/;
|
|
9849
10098
|
var BLOCK_LIFT = /^\/admin\/blocks\/([^/]+)\/lift$/;
|
|
9850
10099
|
var TOKEN_REVOKE = /^\/tokens\/([^/]+)\/revoke$/;
|
|
9851
10100
|
var ADMIN_TOKEN_REVOKE = /^\/admin\/tokens\/([^/]+)\/revoke$/;
|
|
@@ -9884,6 +10133,9 @@ function route2(path) {
|
|
|
9884
10133
|
return { kind: "admin-deliveries" };
|
|
9885
10134
|
if (path === "/admin/allow")
|
|
9886
10135
|
return { kind: "admin-allow" };
|
|
10136
|
+
const ceiling = PERSON_CEILING.exec(path);
|
|
10137
|
+
if (ceiling)
|
|
10138
|
+
return { kind: "admin-ceiling", id: segment2(ceiling[1]) };
|
|
9887
10139
|
const personAct = PERSON_ACTION.exec(path);
|
|
9888
10140
|
if (personAct)
|
|
9889
10141
|
return { kind: "admin-act", id: segment2(personAct[1]), action: personAct[2] };
|
|
@@ -9896,7 +10148,7 @@ function route2(path) {
|
|
|
9896
10148
|
const created = NEW_PATH.exec(path);
|
|
9897
10149
|
if (created) {
|
|
9898
10150
|
const type = created[1];
|
|
9899
|
-
return
|
|
10151
|
+
return NEW_TYPES.includes(type) ? { kind: "new", type } : null;
|
|
9900
10152
|
}
|
|
9901
10153
|
const one = TASK_PATH.exec(path) ?? TASK_ACTION.exec(path);
|
|
9902
10154
|
if (!one)
|
|
@@ -9910,7 +10162,7 @@ function route2(path) {
|
|
|
9910
10162
|
return { kind: "act", id, action };
|
|
9911
10163
|
}
|
|
9912
10164
|
function methodsOf(r) {
|
|
9913
|
-
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") {
|
|
9914
10166
|
return "POST";
|
|
9915
10167
|
}
|
|
9916
10168
|
if (r.kind === "settings" || r.kind === "edit" || r.kind === "new" || r.kind === "forget" || r.kind === "tokens")
|
|
@@ -10206,6 +10458,7 @@ function createWebHandler(w) {
|
|
|
10206
10458
|
return tokenRevokePost(d, v, r.id);
|
|
10207
10459
|
case "admin-allow":
|
|
10208
10460
|
case "admin-act":
|
|
10461
|
+
case "admin-ceiling":
|
|
10209
10462
|
case "admin-lift":
|
|
10210
10463
|
case "admin-token-revoke":
|
|
10211
10464
|
return adminPost(a, r, form);
|
|
@@ -10217,7 +10470,7 @@ function createWebHandler(w) {
|
|
|
10217
10470
|
const done = url.searchParams.get("done");
|
|
10218
10471
|
switch (r.kind) {
|
|
10219
10472
|
case "tasks":
|
|
10220
|
-
return htmlResponse(tasksPage(v, await loadTaskList(d, auth.user), now, notice(done)));
|
|
10473
|
+
return htmlResponse(tasksPage(v, await loadTaskList(d, auth.user), now, notice(done), d.research === true));
|
|
10221
10474
|
case "task":
|
|
10222
10475
|
return taskPage(e, r.id, done);
|
|
10223
10476
|
case "edit":
|
|
@@ -10486,7 +10739,8 @@ function createPlugin(host, options = {}) {
|
|
|
10486
10739
|
...options.cityHallFetch ? { fetchImpl: options.cityHallFetch } : {}
|
|
10487
10740
|
});
|
|
10488
10741
|
}
|
|
10489
|
-
|
|
10742
|
+
const ceilings = new Ceilings(opened);
|
|
10743
|
+
executeLane = executor ? new ExecuteLane({ store: openedStore, clock, types, notifier, executor, locks, budget: budgetPolicy(ceilings) }) : null;
|
|
10490
10744
|
if (cityHallConfig)
|
|
10491
10745
|
host.log.info(`execute lane on: city-hall ${cityHallConfig.url}, capability ${cityHallConfig.capability}`);
|
|
10492
10746
|
else if ("missing" in cityHall && cityHall.missing.length > 0)
|
|
@@ -10506,6 +10760,7 @@ function createPlugin(host, options = {}) {
|
|
|
10506
10760
|
sessions: new Sessions(opened),
|
|
10507
10761
|
apiTokens: new ApiTokens(opened),
|
|
10508
10762
|
roster: new Roster(opened),
|
|
10763
|
+
ceilings,
|
|
10509
10764
|
configuredAdmins: new Set(adminIds),
|
|
10510
10765
|
lanes,
|
|
10511
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, 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"],
|