@rackbops/plugin-tracker 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +38 -25
  2. package/dist/plugin.js +90 -22
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -69,7 +69,7 @@ At start the plugin runs docket's `recover()`: a run left running is requeued, a
69
69
  by a stop mid-send is settled unconfirmed and logged.
70
70
 
71
71
  **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
72
+ 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
73
  carries `TRACKER_CITY_HALL_CAPABILITY` -- only docket-runner, on roshne's subscription (plan items
74
74
  70, 71). The tracker submits each Job with `POST /api/execute/jobs` under a prefixed key,
75
75
  `rackbops-tracker:<database id>:<docket's Job key>` -- the database id is random, made once and kept
@@ -103,15 +103,18 @@ the first sight (a judgement, not a measured figure): past it the Job answers "p
103
103
  docket gives the run up, and the admins are told once. It logs "the model runner is paused" naming
104
104
  the outcome once an hour per Job, and tells the admins once per Job and outcome. docket's own
105
105
  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 proposed sends one back only when it stores that result,
107
- as when a job that was requeued later fails at its maximum of expired leases.
106
+ that reaches the tracker: under city-hall#18 as merged, that happens only when the job ends failed
107
+ with that result still stored, as when a job that was requeued later fails at its maximum of
108
+ expired leases.
108
109
  The plugin never calls a model, holds no Claude credential and no `ANTHROPIC_*` variable; model
109
110
  output is data, cleaned by docket before it reaches a DM or a finding, and every research DM goes
110
111
  out with no allowed mentions: the host sends every `dm` with `allowedMentions: { parse: [] }`
111
- (CONTEXT.md). **The wire is city-hall#18's
112
- proposal, not yet agreed by Nazu**: the source pair is a stand-in for the source and responder
113
- contracts (its decision 0002), so it may change, and this adapter with it -- the requeue reading
114
- above and `lastOutcome` (city-hall#18 at 2ba40d3) included.
112
+ (CONTEXT.md). **The wire is city-hall#18's**
113
+ ([Lepid-Labs/city-hall#18](https://github.com/Lepid-Labs/city-hall/pull/18), merged as 90a06ec),
114
+ but its decision record 0002 is still "proposed" pending Nazu's review
115
+ ([Lepid-Labs/city-hall#17](https://github.com/Lepid-Labs/city-hall/issues/17)): the source pair is
116
+ a stand-in for the source and responder contracts, so it may change, and this adapter with it --
117
+ the requeue reading above and `lastOutcome` (present in the merged 90a06ec) included.
115
118
 
116
119
  **Research while the lane is off.** If an instance that had city-hall configured loses that
117
120
  configuration, its research requests stay queued: nothing runs them until research is available
@@ -148,8 +151,8 @@ served at a hashed path, cached for a year).
148
151
  | Path | What |
149
152
  |---|---|
150
153
  | `/` | 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). |
154
+ | `/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. |
155
+ | `/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
156
  | `/tasks/<id>/edit` | The owner's edit form (GET) and saving it (POST). |
154
157
  | `/tasks/<id>/pause`, `/resume`, `/delete` | POST only. Delete answers a confirmation first; only a second post carrying `confirm=yes` deletes. |
155
158
  | `/settings` | Preferred hour and time zone, checked as `/register` checks them. Links to API tokens and Forget me. |
@@ -184,8 +187,8 @@ than `TRACKER_WEB_URL`'s is refused. The sign-in post has no session yet, so it
184
187
  double-submit token from a `SameSite=Strict` cookie the link's page sets: another site cannot sign
185
188
  you in as someone else. Sign out is a POST too.
186
189
 
187
- **The task editor** (slice 2). The forms take the options of `/remind`, `/renewal` and `/price`,
188
- and the same functions check and make them: the same defaults, limits and messages, the page read
190
+ **The task editor** (slice 2). The forms take the options of `/remind`, `/renewal`, `/price` and
191
+ `/research` (#82), and the same functions check and make them: the same defaults, limits and messages, the page read
189
192
  once before a price tracker is made, at most 20 price trackers per person wherever they were made,
190
193
  and `near` turned into the same bounded pattern; a person has one new price tracker's page read in
191
194
  flight at a time. Every person may have at most 200 active or paused tasks of all types together,
@@ -208,7 +211,11 @@ are dropped and nothing more is sent, but its history is kept (an admin can see
208
211
  its page stays for the owner. Every editor post passes the session, CSRF and `Origin` checks; the
209
212
  task is loaded by the id in the path and must be the viewer's own, checked again in the write
210
213
  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. Form bodies are capped at 32 KiB, read no
214
+ refused form comes back with what was typed and why. A research request made on the web is
215
+ `/research`'s exactly -- the question, the optional context, deadline and start in `/research`'s
216
+ words, the cap of 5 waiting, the daily budget -- and, as in Discord, it is never edited: its page
217
+ has Pause, Resume and Delete but no Edit. Making one only queues its run; nothing reaches city-hall
218
+ until the execute tick. Form bodies are capped at 32 KiB, read no
212
219
  further than that.
213
220
 
214
221
  **Headers.** `Content-Security-Policy: default-src 'none'; style-src 'self'; img-src 'none';
@@ -393,15 +400,18 @@ a price back the page's address -- exactly as the web editor's empty fields do.
393
400
  value: it is refused as the wrong JSON type (send `""` to clear a note).
394
401
 
395
402
  Which types: `GET /tasks`, `GET /tasks/<id>`, pause, resume and `DELETE` work on any task you own,
396
- whatever its type; `POST /tasks` and `PATCH` take the three editor types (reminder, renewal,
397
- price), and a `PATCH` of a task of another type answers `404`, as the web editor does.
403
+ whatever its type. `POST /tasks` takes the three editor types (reminder, renewal, price) and,
404
+ while research is available on this bot, `research` (#82), by `/research`'s rules; while it is not,
405
+ a `research` create answers `503 unavailable`. `PATCH` takes the three editor types only: a
406
+ research request cannot be edited, here or in Discord, and a `PATCH` of one answers
407
+ `409 conflict`.
398
408
 
399
409
  | Method and path | What |
400
410
  |---|---|
401
411
  | `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's 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. |
412
+ | `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
413
  | `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 `price`, 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. |
414
+ | `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
415
  | `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
416
  | `PATCH /api/v1/tasks/<id>` | Edits it: the fields to change. A price's page is not editable. |
407
417
  | `POST /api/v1/tasks/<id>/pause`, `/resume` | Body `{}`. As the web's Pause and Resume, and `/task resume`. |
@@ -411,7 +421,8 @@ A task is `{"id", "type", "title", "status", "cadence", "nextAt", "createdAt", "
411
421
  "settings"}`, and a price also has a top-level `url`: `status` is `active`, `paused`, `done` or
412
422
  `deleted`; `cadence` is the schedule in words, in your zone; `nextAt` the next run's instant (UTC),
413
423
  null when paused or nothing is due; `settings` exactly the fields a `PATCH` takes, as the task has
414
- them. A price's `url` is read-only -- another page is another tracker -- so it is not in `settings`
424
+ them. A research request's `settings` are what was asked -- `question`, and `context` and
425
+ `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
426
  and a `PATCH` naming it is refused (`unknown_field`). A write answers
416
427
  `{"task", "message"}`, `message` being the words the web and the command say.
417
428
 
@@ -428,9 +439,9 @@ and a `PATCH` naming it is refused (`unknown_field`). A write answers
428
439
  | 403 | `not_member` | The owner has left every `TRACKER_GUILD_ID` server; their tokens are revoked. |
429
440
  | 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
441
  | 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, or 20 price trackers, as the commands count them. |
433
- | 409 | `busy` | Your last new price tracker's page is still being read. |
442
+ | 409 | `conflict` | A finished task edited, a research request edited, or a pause of a task not active (a resume of one not paused). |
443
+ | 409 | `limit_reached` | 200 active or paused tasks, 20 price trackers, or 5 research requests waiting, as the commands count them. |
444
+ | 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
445
  | 413 | `too_large` | Body over 16 KiB. |
435
446
  | 415 | `unsupported_media_type` | Not `application/json`. |
436
447
  | 429 | `rate_limited` | With `Retry-After`: this token's bucket, or the shared one for bad tokens. |
@@ -507,14 +518,16 @@ key>`, so two databases never collide.
507
518
  ## Not yet
508
519
 
509
520
  - The rest of the web area (E5): sharing a task from the web, and the admin view's grants, budgets
510
- (raising a person's ceiling) and retry. Research is made in Discord only (the web editor and
511
- the task API cover reminders, renewals and prices). The task API covers reminders, renewals and prices only --
512
- not `/task done`, `snooze`, `decide`, `share` or the settings -- and no intake agent uses it yet
521
+ (raising a person's ceiling) and retry. The task API makes reminders, renewals, prices and
522
+ research requests -- not `/task done`, `snooze`, `decide`, `share` or the settings -- and no intake agent uses it yet
513
523
  (E10, deferred). Discord OAuth2 as a second sign-in
514
524
  method, if chosen (plan item 41).
515
525
  - 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 (city-hall#18 is not
517
- merged). The scout and the want-list (E9).
526
+ policy, and live verification against a real city-hall and docket-runner
527
+ ([Lepid-Labs/city-hall#18](https://github.com/Lepid-Labs/city-hall/pull/18) is merged as
528
+ 90a06ec, its decision record still "proposed" pending
529
+ [Lepid-Labs/city-hall#17](https://github.com/Lepid-Labs/city-hall/issues/17); the research lane
530
+ still needs a deployed city-hall with a runner). The scout and the want-list (E9).
518
531
  - 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
532
  - 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
533
 
package/dist/plugin.js CHANGED
@@ -8289,14 +8289,15 @@ function cadence(entry, viewer, now) {
8289
8289
  return "once";
8290
8290
  return describeSchedule(s, entry.from ?? viewer, viewer.timeZone, now);
8291
8291
  }
8292
- function newLinks(v) {
8292
+ function newLinks(v, research) {
8293
8293
  return html`<p class="tr-row">
8294
8294
  <a class="rb-btn rb-btn--primary rb-btn--sm" href="${v.base}/new/reminder">New reminder</a>
8295
8295
  <a class="rb-btn rb-btn--ghost rb-btn--sm" href="${v.base}/new/renewal">New renewal</a>
8296
8296
  <a class="rb-btn rb-btn--ghost rb-btn--sm" href="${v.base}/new/price">New price tracker</a>
8297
+ ${research ? html`<a class="rb-btn rb-btn--ghost rb-btn--sm" href="${v.base}/new/research">New research request</a>` : null}
8297
8298
  </p>`;
8298
8299
  }
8299
- function tasksPage(v, data, now, flash = null) {
8300
+ function tasksPage(v, data, now, flash = null, research = false) {
8300
8301
  const rows = data.entries.map((e) => html`<tr>
8301
8302
  <td><a class="rb-link" href="${v.base}/tasks/${e.task.id}">${e.task.title}</a></td>
8302
8303
  <td>${e.from ? name3(e.from) : "you"}</td>
@@ -8315,7 +8316,7 @@ function tasksPage(v, data, now, flash = null) {
8315
8316
  <h1>My tasks</h1>
8316
8317
  ${flash}
8317
8318
  <p class="rb-muted">Times are in ${v.user.timeZone}.</p>
8318
- ${newLinks(v)}
8319
+ ${newLinks(v, research)}
8319
8320
  ${active}
8320
8321
  </section>
8321
8322
  ${paused}
@@ -8405,6 +8406,7 @@ function errorPage(base, title, message) {
8405
8406
 
8406
8407
  // plugins/tracker/src/web/editor-pages.ts
8407
8408
  var EDITOR_TYPES = ["reminder", "renewal", "price"];
8409
+ var NEW_TYPES = [...EDITOR_TYPES, "research"];
8408
8410
  var REPEAT_OPTIONS = [["none", "once"], ["day", "daily"], ["week", "weekly"], ["month", "monthly"]];
8409
8411
  var UNIT_OPTIONS = [["year", "yearly"], ["month", "monthly"], ["week", "weekly"], ["day", "daily"]];
8410
8412
  var BASELINE_OPTIONS = [["last", "the last price seen"], ["first", "the first price seen"], ["peak", "the highest price seen"]];
@@ -8414,8 +8416,16 @@ var PRICE_SETTINGS = [
8414
8416
  { name: "drop", label: "Alert on a drop of at least (percent)", kind: "text", decimal: true, help: "1 to 90; 10 when empty." },
8415
8417
  { name: "baseline", label: "Measure the drop from", kind: "select", options: BASELINE_OPTIONS }
8416
8418
  ];
8419
+ var RESEARCH_FIELDS = [
8420
+ { name: "question", label: "What to look into", kind: "textarea", required: true, maxlength: MAX_QUESTION_CHARS },
8421
+ { 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." },
8422
+ { name: "deadline", label: "Deadline", kind: "text", maxlength: MAX_WHEN, help: 'For example "fri 17:00". Past it nothing more is run.' },
8423
+ { name: "at", label: "Start", kind: "text", maxlength: MAX_WHEN, help: "Leave it empty to start now." }
8424
+ ];
8417
8425
  function fieldsFor(type, mode) {
8418
8426
  switch (type) {
8427
+ case "research":
8428
+ return mode === "new" ? RESEARCH_FIELDS : [];
8419
8429
  case "reminder":
8420
8430
  return [
8421
8431
  { name: "text", label: "Remind me to", kind: "text", required: true, maxlength: MAX_REMINDER_TEXT },
@@ -8458,6 +8468,13 @@ function field(f, values) {
8458
8468
  <label class="rb-label" for="${f.name}">${f.label}</label>
8459
8469
  <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
8470
  ${help}
8471
+ </div>`;
8472
+ }
8473
+ if (f.kind === "textarea") {
8474
+ return html`<div class="rb-field">
8475
+ <label class="rb-label" for="${f.name}">${f.label}</label>
8476
+ <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>
8477
+ ${help}
8461
8478
  </div>`;
8462
8479
  }
8463
8480
  const type = f.kind === "number" ? "number" : f.kind === "date" ? "date" : f.kind === "url" ? "url" : "text";
@@ -8473,19 +8490,25 @@ function alert(error) {
8473
8490
  function taskHref(v, id, action = "") {
8474
8491
  return `${v.base}/tasks/${encodeURIComponent(id)}${action ? `/${action}` : ""}`;
8475
8492
  }
8476
- var NOUN = { reminder: "reminder", renewal: "renewal", price: "price tracker" };
8477
- function newTaskPage(v, type, values, error) {
8478
- const intro = type === "price" ? html`<p class="rb-muted">I read the page once now; nothing is made unless I find a price in it.</p>` : null;
8479
- return framed(v, `New ${NOUN[type]}`, html`<section>
8480
- <p><a class="rb-link" href="${v.base}/">My tasks</a></p>
8481
- <h1>New ${NOUN[type]}</h1>
8482
- ${intro}
8483
- ${alert(error)}
8493
+ var NOUN = { reminder: "reminder", renewal: "renewal", price: "price tracker", research: "research request" };
8494
+ var INTRO = {
8495
+ price: "I read the page once now; nothing is made unless I find a price in it.",
8496
+ 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."
8497
+ };
8498
+ function newTaskPage(v, type, values, error, off) {
8499
+ const text = INTRO[type];
8500
+ const intro = text ? html`<p class="rb-muted">${text}</p>` : null;
8501
+ const form = off ? html`<div class="rb-alert rb-alert--warning" role="status"><p>${prose(off)}</p></div>` : html`${alert(error)}
8484
8502
  <form method="post" action="${v.base}/new/${type}" class="tr-stack">
8485
8503
  <input type="hidden" name="csrf" value="${v.csrf}">
8486
8504
  ${fieldsFor(type, "new").map((f) => field(f, values))}
8487
8505
  <div><button class="rb-btn rb-btn--primary" type="submit">Create</button></div>
8488
- </form>
8506
+ </form>`;
8507
+ return framed(v, `New ${NOUN[type]}`, html`<section>
8508
+ <p><a class="rb-link" href="${v.base}/">My tasks</a></p>
8509
+ <h1>New ${NOUN[type]}</h1>
8510
+ ${intro}
8511
+ ${form}
8489
8512
  </section>`);
8490
8513
  }
8491
8514
  function editTaskPage(v, task, values, o) {
@@ -9233,6 +9256,21 @@ function reminderInput(form) {
9233
9256
  const when = opt(form, "when");
9234
9257
  return { text: str2(form, "text"), ...when !== undefined ? { when } : {}, repeat: str2(form, "repeat") || "none" };
9235
9258
  }
9259
+ function lines(v) {
9260
+ return v?.replace(/\r\n?/g, `
9261
+ `);
9262
+ }
9263
+ function researchInput(form) {
9264
+ const context = lines(opt(form, "context"));
9265
+ const deadline = opt(form, "deadline");
9266
+ const at = opt(form, "at");
9267
+ return {
9268
+ question: lines(str2(form, "question")) ?? "",
9269
+ ...context !== undefined ? { context } : {},
9270
+ ...deadline !== undefined ? { deadline } : {},
9271
+ ...at !== undefined ? { at } : {}
9272
+ };
9273
+ }
9236
9274
  function renewalInput(form) {
9237
9275
  const every = int(form, "every");
9238
9276
  const lead = int(form, "lead");
@@ -9336,6 +9374,8 @@ async function makeTask(w, type, form) {
9336
9374
  return asUser(w, (u) => createReminder(w.d, u, reminderInput(form)));
9337
9375
  if (type === "renewal")
9338
9376
  return asUser(w, (u) => createRenewal(w.d, u, renewalInput(form)));
9377
+ if (type === "research")
9378
+ return asUser(w, (u) => createResearch(w.d, u, researchInput(form)));
9339
9379
  const input = priceInput(form);
9340
9380
  if (w.reading.has(w.userId))
9341
9381
  return { ok: false, error: READING };
@@ -9369,9 +9409,12 @@ function actOn(w, taskId, action) {
9369
9409
  var DEFAULTS = {
9370
9410
  reminder: { repeat: "none" },
9371
9411
  renewal: { unit: "year", every: "1", lead: "7" },
9372
- price: { hours: "12", drop: "10", baseline: "last" }
9412
+ price: { hours: "12", drop: "10", baseline: "last" },
9413
+ research: {}
9373
9414
  };
9374
9415
  function newGet(e, type) {
9416
+ if (type === "research" && !e.d.research)
9417
+ return htmlResponse(newTaskPage(e.v, type, {}, undefined, RESEARCH_OFF));
9375
9418
  return htmlResponse(newTaskPage(e.v, type, type === "renewal" ? { ...DEFAULTS.renewal, currency: "USD" } : DEFAULTS[type]));
9376
9419
  }
9377
9420
  async function newPost(e, type, form) {
@@ -9438,7 +9481,11 @@ function jsonKind(f) {
9438
9481
  return "integer";
9439
9482
  return f.decimal ? "number" : "string";
9440
9483
  }
9441
- function typesAnswer() {
9484
+ function offeredTypes(d) {
9485
+ return d.research ? NEW_TYPES : EDITOR_TYPES;
9486
+ }
9487
+ var NOT_EDITABLE = "A research request cannot be edited. Delete it and ask again.";
9488
+ function typesAnswer(d) {
9442
9489
  const describe = (fields, mode) => fields.map((f) => ({
9443
9490
  name: f.name,
9444
9491
  type: jsonKind(f),
@@ -9451,7 +9498,14 @@ function typesAnswer() {
9451
9498
  }));
9452
9499
  return {
9453
9500
  status: 200,
9454
- body: { types: EDITOR_TYPES.map((type) => ({ type, create: describe(fieldsFor(type, "new"), "new"), edit: describe(fieldsFor(type, "edit"), "edit") })) }
9501
+ body: {
9502
+ types: offeredTypes(d).map((type) => ({
9503
+ type,
9504
+ editable: type !== "research",
9505
+ create: describe(fieldsFor(type, "new"), "new"),
9506
+ edit: describe(fieldsFor(type, "edit"), "edit")
9507
+ }))
9508
+ }
9455
9509
  };
9456
9510
  }
9457
9511
  function asFields(body, type, mode) {
@@ -9481,8 +9535,12 @@ function refusal(error) {
9481
9535
  return notFound();
9482
9536
  if (error === NO_LONGER_LISTED)
9483
9537
  return problem(401, "invalid_token", "The token's owner is no longer on this tracker's list.");
9484
- if (error === READING)
9538
+ if (error === READING || error === TASK_BUSY)
9485
9539
  return problem(409, "busy", error);
9540
+ if (error === NOT_EDITABLE)
9541
+ return problem(409, "conflict", error);
9542
+ if (error === RESEARCH_OFF)
9543
+ return problem(503, "unavailable", error);
9486
9544
  if (error === FINISHED || /^That task is [a-z]+, not [a-z]+\.$/.test(error))
9487
9545
  return problem(409, "conflict", error);
9488
9546
  if (/^You already (have|track) [0-9]+ /.test(error))
@@ -9501,6 +9559,14 @@ async function taskJson(d, user, task) {
9501
9559
  const type = EDITOR_TYPES.includes(task.type) ? task.type : null;
9502
9560
  const values = editValues(d, user, task);
9503
9561
  const settings = {};
9562
+ if (task.type === "research") {
9563
+ const c = task.config;
9564
+ settings.question = c.question;
9565
+ if (c.context !== undefined)
9566
+ settings.context = c.context;
9567
+ if (c.deadline !== undefined)
9568
+ settings.deadline = c.deadline;
9569
+ }
9504
9570
  if (type) {
9505
9571
  for (const f of fieldsFor(type, "edit")) {
9506
9572
  if (f.name === "when")
@@ -9559,9 +9625,11 @@ async function written(w, user, result, status, base) {
9559
9625
  }
9560
9626
  async function createAnswer(w, user, body, base) {
9561
9627
  const type = body.type;
9562
- if (typeof type !== "string" || !EDITOR_TYPES.includes(type)) {
9563
- return problem(400, "invalid", `\`type\` is one of ${EDITOR_TYPES.join(", ")}.`);
9628
+ if (typeof type !== "string" || !NEW_TYPES.includes(type)) {
9629
+ return problem(400, "invalid", `\`type\` is one of ${offeredTypes(w.d).join(", ")}.`);
9564
9630
  }
9631
+ if (type === "research" && !w.d.research)
9632
+ return refusal(RESEARCH_OFF);
9565
9633
  const form = asFields(body, type, "new");
9566
9634
  if (!(form instanceof URLSearchParams))
9567
9635
  return form;
@@ -9570,7 +9638,7 @@ async function createAnswer(w, user, body, base) {
9570
9638
  async function editAnswer(w, user, id, body, base) {
9571
9639
  const task = await editableTask(w.d, user, id);
9572
9640
  if (!task)
9573
- return notFound();
9641
+ return (await ownLiveTask(w.d, user, id))?.type === "research" ? refusal(NOT_EDITABLE) : notFound();
9574
9642
  const form = asFields(body, task.type, "edit");
9575
9643
  if (!(form instanceof URLSearchParams))
9576
9644
  return form;
@@ -9756,7 +9824,7 @@ async function handleApi(w, request, path) {
9756
9824
  if (method === "DELETE" && r.kind === "task")
9757
9825
  return json2(await actAnswer(writer, user, r.id, "delete"));
9758
9826
  if (r.kind === "types")
9759
- return json2(typesAnswer());
9827
+ return json2(typesAnswer(w.d));
9760
9828
  if (r.kind === "tasks")
9761
9829
  return json2(await listAnswer(w.d, user));
9762
9830
  if (r.kind === "task")
@@ -9896,7 +9964,7 @@ function route2(path) {
9896
9964
  const created = NEW_PATH.exec(path);
9897
9965
  if (created) {
9898
9966
  const type = created[1];
9899
- return EDITOR_TYPES.includes(type) ? { kind: "new", type } : null;
9967
+ return NEW_TYPES.includes(type) ? { kind: "new", type } : null;
9900
9968
  }
9901
9969
  const one = TASK_PATH.exec(path) ?? TASK_ACTION.exec(path);
9902
9970
  if (!one)
@@ -10217,7 +10285,7 @@ function createWebHandler(w) {
10217
10285
  const done = url.searchParams.get("done");
10218
10286
  switch (r.kind) {
10219
10287
  case "tasks":
10220
- return htmlResponse(tasksPage(v, await loadTaskList(d, auth.user), now, notice(done)));
10288
+ return htmlResponse(tasksPage(v, await loadTaskList(d, auth.user), now, notice(done), d.research === true));
10221
10289
  case "task":
10222
10290
  return taskPage(e, r.id, done);
10223
10291
  case "edit":
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rackbops/plugin-tracker",
3
- "version": "0.11.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.12.0",
4
+ "description": "The task tracker for Rackbops Clerk: reminders, renewals, price drops and one-off research (through city-hall, with its findings) by slash command, delivered by DM with buttons, on docket's SQLite store (/allow, /register, /remind, /renewal, /price, /research, /tasks, /task, /settings, /web), with a web area for your tasks, history and settings, a web editor to make, change, pause and delete your own tasks (research requests too, made but not changed), an admin view (with the DMs that did not arrive), forget-me, and a JSON task API with personal API tokens",
5
5
  "type": "module",
6
6
  "main": "dist/plugin.js",
7
7
  "files": ["dist"],