@rackbops/plugin-tracker 0.7.0 → 0.9.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 +43 -24
  2. package/dist/plugin.js +1443 -592
  3. package/package.json +5 -5
package/dist/plugin.js CHANGED
@@ -3,10 +3,59 @@
3
3
  import { mkdirSync } from "fs";
4
4
  import { join } from "path";
5
5
 
6
+ // node_modules/@rackbops/docket-core/dist/record.js
7
+ function isObj(v) {
8
+ return typeof v === "object" && v !== null && !Array.isArray(v);
9
+ }
10
+ var optional = (o, k, check) => o[k] === undefined || check(o[k]);
11
+ var isString = (v) => typeof v === "string";
12
+ var isNumber = (v) => typeof v === "number" && Number.isFinite(v);
13
+ var isStringArray = (v) => Array.isArray(v) && v.every(isString);
14
+ var REPLY_KINDS = new Set([
15
+ "accept",
16
+ "decline",
17
+ "opt_out",
18
+ "done",
19
+ "snooze",
20
+ "decision",
21
+ "text"
22
+ ]);
23
+ function isMessage(v) {
24
+ if (!isObj(v) || !isString(v.text))
25
+ return false;
26
+ const actions = v.actions;
27
+ if (actions !== undefined && !(Array.isArray(actions) && actions.every((a) => REPLY_KINDS.has(a)))) {
28
+ return false;
29
+ }
30
+ return optional(v, "decisions", isStringArray) && v.ref === undefined;
31
+ }
32
+ function isSeries(v) {
33
+ return Array.isArray(v) && v.every((p) => isObj(p) && isNumber(p.value) && optional(p, "unit", isString) && optional(p, "note", isString) && optional(p, "at", isString));
34
+ }
35
+ function isFindings(v) {
36
+ return Array.isArray(v) && v.every((f) => isObj(f) && isString(f.text) && optional(f, "tags", isStringArray) && optional(f, "source", isString));
37
+ }
38
+ function isOutcome(v) {
39
+ return isObj(v) && v.snoozeUntil === undefined && optional(v, "notify", isMessage) && optional(v, "summary", isString) && optional(v, "series", isSeries) && optional(v, "complete", (c) => typeof c === "boolean") && optional(v, "findings", isFindings);
40
+ }
41
+ function parseRunRecord(value) {
42
+ if (!isObj(value))
43
+ return null;
44
+ const ok = isOutcome(value.outcome) && (value.costUsd === null || isNumber(value.costUsd)) && isString(value.firedAt) && (value.appliedAt === null || isString(value.appliedAt)) && Number.isInteger(value.resumes);
45
+ return ok ? value : null;
46
+ }
47
+ function hasFired(occurrence) {
48
+ return occurrence.record !== null && occurrence.record !== undefined;
49
+ }
50
+ function isFinishing(occurrence) {
51
+ return hasFired(occurrence) && occurrence.finishedAt === null;
52
+ }
53
+
6
54
  // node_modules/@rackbops/docket-core/dist/answer.js
7
55
  var RUN_KINDS = new Set(["done", "snooze", "decision"]);
8
56
  var NOT_YOURS = "That is not yours to answer.";
9
57
  var OVER = "That run is over; answer the latest message instead.";
58
+ var STILL_FINISHING = "That run is still finishing; try again in a minute.";
10
59
 
11
60
  class ReplyRefusedError extends Error {
12
61
  name = "ReplyRefusedError";
@@ -21,8 +70,11 @@ async function runRefusal(store, task, occurrenceId, userId) {
21
70
  return "That run no longer exists.";
22
71
  if (task.status !== "active")
23
72
  return `That task is ${task.status}.`;
24
- if (occurrence.status === "queued")
73
+ if ((occurrence.status === "queued" || occurrence.status === "running") && !hasFired(occurrence)) {
25
74
  return "That run has not fired yet.";
75
+ }
76
+ if (isFinishing(occurrence))
77
+ return STILL_FINISHING;
26
78
  if (occurrence.status === "snoozed" || occurrence.status === "skipped")
27
79
  return OVER;
28
80
  const answered = (await store.listReplies(task.id)).some((r) => r.occurrenceId === occurrence.id && RUN_KINDS.has(r.kind));
@@ -52,20 +104,40 @@ async function visibleTasks(store, actor, filter = {}) {
52
104
  }
53
105
  return out;
54
106
  }
107
+ function seesAll(actor, task) {
108
+ return actor.admin || task.ownerId === actor.userId;
109
+ }
55
110
  async function visibleOccurrences(store, actor, taskId) {
56
- if (!await visibleTask(store, actor, taskId))
111
+ const task = await visibleTask(store, actor, taskId);
112
+ if (!task)
57
113
  return null;
58
- return store.listOccurrences({ taskId });
114
+ const runs = await store.listOccurrences({ taskId });
115
+ return seesAll(actor, task) ? runs : runs.map((o) => ({ ...o, record: null }));
59
116
  }
60
117
  async function visibleReplies(store, actor, taskId) {
61
- if (!await visibleTask(store, actor, taskId))
118
+ const task = await visibleTask(store, actor, taskId);
119
+ if (!task)
62
120
  return null;
63
- return store.listReplies(taskId);
64
- }
121
+ const replies = await store.listReplies(taskId);
122
+ return seesAll(actor, task) ? replies : replies.filter((r) => r.userId === actor.userId);
123
+ }
124
+ var ABOUT_A_RECIPIENT = new Set([
125
+ "recipient_invited",
126
+ "recipient_accepted",
127
+ "recipient_declined",
128
+ "recipient_opted_out",
129
+ "recipient_removed",
130
+ "blocked",
131
+ "block_lifted"
132
+ ]);
65
133
  async function visibleHistory(store, actor, taskId) {
66
- if (!await visibleTask(store, actor, taskId))
134
+ const task = await visibleTask(store, actor, taskId);
135
+ if (!task)
67
136
  return null;
68
- return store.listTaskEvents(taskId);
137
+ const events = await store.listTaskEvents(taskId);
138
+ if (seesAll(actor, task))
139
+ return events;
140
+ return events.filter((e) => !ABOUT_A_RECIPIENT.has(e.kind) || e.detail.split(" ")[0] === actor.userId);
69
141
  }
70
142
  async function visibleSeries(store, actor, taskId, filter) {
71
143
  if (!await visibleTask(store, actor, taskId))
@@ -73,6 +145,191 @@ async function visibleSeries(store, actor, taskId, filter) {
73
145
  return store.listSeries(taskId, filter);
74
146
  }
75
147
 
148
+ // node_modules/@rackbops/docket-core/dist/zoned.js
149
+ var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
150
+ var formatters = new Map;
151
+ function formatter(zone) {
152
+ let f = formatters.get(zone);
153
+ if (!f) {
154
+ f = new Intl.DateTimeFormat("en-US", {
155
+ timeZone: zone,
156
+ hourCycle: "h23",
157
+ year: "numeric",
158
+ month: "2-digit",
159
+ day: "2-digit",
160
+ hour: "2-digit",
161
+ minute: "2-digit",
162
+ weekday: "short"
163
+ });
164
+ formatters.set(zone, f);
165
+ }
166
+ return f;
167
+ }
168
+ function isTimeZone(zone) {
169
+ try {
170
+ formatter(zone);
171
+ return true;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+ function wallClock(instant, zone) {
177
+ const parts = formatter(zone).formatToParts(instant);
178
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
179
+ return {
180
+ year: Number(get("year")),
181
+ month: Number(get("month")),
182
+ day: Number(get("day")),
183
+ hour: Number(get("hour")),
184
+ minute: Number(get("minute")),
185
+ weekday: WEEKDAYS.indexOf(get("weekday"))
186
+ };
187
+ }
188
+ function zoneOffsetMinutes(instant, zone) {
189
+ const w = wallClock(instant, zone);
190
+ const asUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute);
191
+ const truncated = Math.floor(instant.getTime() / 60000) * 60000;
192
+ return Math.round((asUtc - truncated) / 60000);
193
+ }
194
+ function reads(instant, zone, d, h, min) {
195
+ const w = wallClock(new Date(instant), zone);
196
+ return w.day === d && w.hour === h && w.minute === min;
197
+ }
198
+ function zonedInstant(year, month, day, hour, minute, zone) {
199
+ const wanted = Date.UTC(year, month - 1, day, hour, minute);
200
+ const offset1 = zoneOffsetMinutes(new Date(wanted), zone);
201
+ const candidate1 = wanted - offset1 * 60000;
202
+ if (reads(candidate1, zone, day, hour, minute))
203
+ return new Date(candidate1);
204
+ const offset2 = zoneOffsetMinutes(new Date(candidate1), zone);
205
+ const candidate2 = wanted - offset2 * 60000;
206
+ if (reads(candidate2, zone, day, hour, minute))
207
+ return new Date(candidate2);
208
+ return new Date(Math.max(candidate1, candidate2));
209
+ }
210
+
211
+ // node_modules/@rackbops/docket-core/dist/budget.js
212
+ var BUDGET_ZONE = "America/New_York";
213
+ var DEFAULT_BUDGET = {
214
+ person: { usd: 2, calls: 20 },
215
+ global: { usd: 10, calls: 100 }
216
+ };
217
+ function pad(n) {
218
+ return String(n).padStart(2, "0");
219
+ }
220
+ function budgetDay(now) {
221
+ const w = wallClock(now, BUDGET_ZONE);
222
+ const next = new Date(Date.UTC(w.year, w.month - 1, w.day + 1));
223
+ const start = zonedInstant(w.year, w.month, w.day, 0, 0, BUDGET_ZONE);
224
+ const end = zonedInstant(next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate(), 0, 0, BUDGET_ZONE);
225
+ return {
226
+ day: `${w.year}-${pad(w.month)}-${pad(w.day)}`,
227
+ start: start.toISOString(),
228
+ end: end.toISOString()
229
+ };
230
+ }
231
+ function spent(rows) {
232
+ return rows.reduce((s, r) => ({ usd: s.usd + r.costUsd, calls: s.calls + r.calls }), {
233
+ usd: 0,
234
+ calls: 0
235
+ });
236
+ }
237
+ function reached(used, limits) {
238
+ if (limits.calls !== null && used.calls >= limits.calls)
239
+ return "calls";
240
+ if (limits.usd !== null && used.usd >= limits.usd)
241
+ return "usd";
242
+ return null;
243
+ }
244
+ async function budgetHold(store, policy, owner, now) {
245
+ const day = budgetDay(now);
246
+ const window = { since: day.start, before: day.end };
247
+ const all = await store.listUsage(window);
248
+ const everyone = spent(all);
249
+ const globalLimit = reached(everyone, policy.global);
250
+ if (globalLimit) {
251
+ return hold("global", globalLimit, everyone, policy.global, day);
252
+ }
253
+ const limits = await policy.personFor?.(owner) ?? policy.person;
254
+ const mine = spent(all.filter((u) => u.userId === owner.id));
255
+ const personLimit = reached(mine, limits);
256
+ return personLimit ? hold("person", personLimit, mine, limits, day) : null;
257
+ }
258
+ function hold(scope, limit, used, limits, day) {
259
+ return { scope, limit, used, limits, until: day.end, day: day.day };
260
+ }
261
+ async function charge(store, c) {
262
+ return store.addUsage({
263
+ userId: c.userId,
264
+ taskId: c.taskId,
265
+ occurrenceId: c.occurrenceId ?? null,
266
+ source: c.source,
267
+ calls: c.calls,
268
+ costUsd: c.costUsd ?? 0,
269
+ at: c.at.toISOString()
270
+ });
271
+ }
272
+ function describeLimit(h) {
273
+ return h.limit === "calls" ? `${h.limits.calls} model calls` : `${h.limits.usd} USD of model use (an estimate)`;
274
+ }
275
+ function budgetPersonMessage(h) {
276
+ return {
277
+ text: [
278
+ `You have reached today's limit of ${describeLimit(h)} for tasks that use the model.`,
279
+ "They will run again after midnight Eastern time. Reminders are not affected."
280
+ ].join(`
281
+ `)
282
+ };
283
+ }
284
+ function budgetAdminMessage(h, who) {
285
+ const used = `(${h.used.calls} calls, about ${h.used.usd.toFixed(2)} USD)`;
286
+ if (h.scope === "global") {
287
+ return {
288
+ text: [
289
+ `Everyone together has reached today's limit of ${describeLimit(h)} ${used}.`,
290
+ "Every task that uses the model waits until midnight Eastern time."
291
+ ].join(`
292
+ `)
293
+ };
294
+ }
295
+ const name = who?.displayName ?? who?.id ?? "Someone";
296
+ return {
297
+ text: [
298
+ `${name} has reached today's limit of ${describeLimit(h)} ${used}.`,
299
+ `${name}'s tasks that use the model wait until midnight Eastern time; an admin can raise ${name}'s limit.`
300
+ ].join(`
301
+ `)
302
+ };
303
+ }
304
+ function usageLimitAdminMessage(until, parsed, retryAt) {
305
+ const retry = retryAt && retryAt < until ? `, trying again at ${retryAt.toISOString()} in case it was misread` : "";
306
+ const when = parsed ? `until ${until.toISOString()}${retry}` : "for an hour, then try again (no usable reset time came with it)";
307
+ return {
308
+ text: [
309
+ "The Claude subscription's usage limit was reached.",
310
+ `Tasks that use the model wait ${when}; nobody's budget is charged for it.`
311
+ ].join(`
312
+ `)
313
+ };
314
+ }
315
+ async function noticeOnce(store, notifier, key, now, admins, person) {
316
+ if (!await store.claimNotice(key, now.toISOString()))
317
+ return false;
318
+ const sends = [];
319
+ for (const admin of await store.listUsers({ admin: true })) {
320
+ if (admin.id !== person?.user.id)
321
+ sends.push([admin.id, admins]);
322
+ }
323
+ if (person)
324
+ sends.push([person.user.id, person.message]);
325
+ for (const [userId, message] of sends) {
326
+ try {
327
+ await notifier.sendDm(userId, message);
328
+ } catch {}
329
+ }
330
+ return true;
331
+ }
332
+
76
333
  // node_modules/@rackbops/docket-core/dist/capabilities.js
77
334
  var CAPABILITIES = ["notify", "graph:write", "discord:post", "github:issue"];
78
335
  var CAPABILITY_TIER = {
@@ -206,69 +463,6 @@ function isLane(value) {
206
463
  return LANES.includes(value);
207
464
  }
208
465
 
209
- // node_modules/@rackbops/docket-core/dist/zoned.js
210
- var WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
211
- var formatters = new Map;
212
- function formatter(zone) {
213
- let f = formatters.get(zone);
214
- if (!f) {
215
- f = new Intl.DateTimeFormat("en-US", {
216
- timeZone: zone,
217
- hourCycle: "h23",
218
- year: "numeric",
219
- month: "2-digit",
220
- day: "2-digit",
221
- hour: "2-digit",
222
- minute: "2-digit",
223
- weekday: "short"
224
- });
225
- formatters.set(zone, f);
226
- }
227
- return f;
228
- }
229
- function isTimeZone(zone) {
230
- try {
231
- formatter(zone);
232
- return true;
233
- } catch {
234
- return false;
235
- }
236
- }
237
- function wallClock(instant, zone) {
238
- const parts = formatter(zone).formatToParts(instant);
239
- const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
240
- return {
241
- year: Number(get("year")),
242
- month: Number(get("month")),
243
- day: Number(get("day")),
244
- hour: Number(get("hour")),
245
- minute: Number(get("minute")),
246
- weekday: WEEKDAYS.indexOf(get("weekday"))
247
- };
248
- }
249
- function zoneOffsetMinutes(instant, zone) {
250
- const w = wallClock(instant, zone);
251
- const asUtc = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute);
252
- const truncated = Math.floor(instant.getTime() / 60000) * 60000;
253
- return Math.round((asUtc - truncated) / 60000);
254
- }
255
- function reads(instant, zone, d, h, min) {
256
- const w = wallClock(new Date(instant), zone);
257
- return w.day === d && w.hour === h && w.minute === min;
258
- }
259
- function zonedInstant(year, month, day, hour, minute, zone) {
260
- const wanted = Date.UTC(year, month - 1, day, hour, minute);
261
- const offset1 = zoneOffsetMinutes(new Date(wanted), zone);
262
- const candidate1 = wanted - offset1 * 60000;
263
- if (reads(candidate1, zone, day, hour, minute))
264
- return new Date(candidate1);
265
- const offset2 = zoneOffsetMinutes(new Date(candidate1), zone);
266
- const candidate2 = wanted - offset2 * 60000;
267
- if (reads(candidate2, zone, day, hour, minute))
268
- return new Date(candidate2);
269
- return new Date(Math.max(candidate1, candidate2));
270
- }
271
-
272
466
  // node_modules/@rackbops/docket-core/dist/schedule.js
273
467
  var SCHEDULE_KINDS = ["once", "calendar", "poll", "period"];
274
468
  function isScheduleKind(value) {
@@ -548,11 +742,30 @@ function snoozeKey(occurrenceId) {
548
742
  }
549
743
  var SNOOZE_PREFIX = "snooze:";
550
744
 
745
+ // node_modules/@rackbops/docket-core/dist/ports.js
746
+ class DeliveryFailedError extends Error {
747
+ unreachable;
748
+ name = "DeliveryFailedError";
749
+ constructor(message, unreachable = false) {
750
+ super(message);
751
+ this.unreachable = unreachable;
752
+ }
753
+ }
754
+
755
+ class ExecutorUnavailableError extends Error {
756
+ name = "ExecutorUnavailableError";
757
+ }
758
+
551
759
  // node_modules/@rackbops/docket-core/dist/delivery.js
552
- var NOTIFIER = "notifier";
553
- async function deliveredTo(store, occurrenceId) {
554
- const events = await store.listEvents(occurrenceId);
555
- return new Set(events.filter((e) => e.type === "delivered").map((e) => e.text.split(" ")[0] ?? ""));
760
+ var DEFAULT_DELIVERY_POLICY = {
761
+ attempts: 3,
762
+ deferrals: 8,
763
+ backoffMs: 60000,
764
+ maxBackoffMs: 3600000
765
+ };
766
+ var STALE_CLAIM_MS = 10 * 60000;
767
+ function backoff(policy, n) {
768
+ return Math.min(policy.backoffMs * 2 ** Math.max(0, n - 1), policy.maxBackoffMs);
556
769
  }
557
770
  function messageFor(message, occurrence, target, ownerId) {
558
771
  const ref = { taskId: occurrence.taskId, occurrenceId: occurrence.id };
@@ -564,30 +777,129 @@ function messageFor(message, occurrence, target, ownerId) {
564
777
  const kept = actions.filter((a) => !RUN_KINDS.has(a) && a !== "opt_out");
565
778
  return { ...rest, actions: [...kept, "opt_out"], ref };
566
779
  }
567
- async function deliver(store, notifier, occurrence, targets, message, now) {
568
- const task = await store.getTask(occurrence.taskId);
569
- if (!task)
570
- throw new Error(`no task ${occurrence.taskId}`);
571
- const done = await deliveredTo(store, occurrence.id);
572
- const report = { sent: [], skipped: [] };
573
- for (const target of targets) {
574
- if (done.has(target.id)) {
575
- report.skipped.push(target.id);
780
+ function reason(err) {
781
+ return err instanceof Error ? err.message : String(err);
782
+ }
783
+ function emptyReport() {
784
+ return { sent: [], retrying: [], failed: [], unconfirmed: [], untried: [] };
785
+ }
786
+ async function deliver(store, notifier, occurrence, ownerId, targets, message, options) {
787
+ const policy = options.policy ?? DEFAULT_DELIVERY_POLICY;
788
+ const now = () => options.now();
789
+ const report = emptyReport();
790
+ const owed = await store.listDeliveries({
791
+ occurrenceId: occurrence.id,
792
+ dueBefore: now().toISOString()
793
+ });
794
+ for (const row of owed) {
795
+ if (options.signal?.aborted) {
796
+ report.untried.push(row.userId);
576
797
  continue;
577
798
  }
578
- const { messageId } = await notifier.sendDm(target.id, messageFor(message, occurrence, target, task.ownerId));
579
- await store.addEvent({
580
- occurrenceId: occurrence.id,
581
- agent: NOTIFIER,
582
- type: "delivered",
583
- text: `${target.id} ${messageId}`,
584
- at: now().toISOString()
585
- });
586
- done.add(target.id);
587
- report.sent.push(target.id);
799
+ const target = targets.get(row.userId);
800
+ if (!target) {
801
+ await finalFail(store, row, "no longer a recipient", now());
802
+ report.failed.push(row.userId);
803
+ continue;
804
+ }
805
+ const claim = await store.claimDelivery(occurrence.id, row.userId, now().toISOString());
806
+ if (!claim) {
807
+ report.untried.push(row.userId);
808
+ continue;
809
+ }
810
+ await sendOne(store, notifier, occurrence, claim, target, ownerId, message, policy, now, report);
588
811
  }
589
812
  return report;
590
813
  }
814
+ async function sendOne(store, notifier, occurrence, claim, target, ownerId, message, policy, now, report) {
815
+ const settle = (s) => store.settleDelivery(occurrence.id, claim.userId, s);
816
+ const counts = { attempts: claim.attempts, deferrals: claim.deferrals };
817
+ let messageId;
818
+ try {
819
+ ({ messageId } = await notifier.sendDm(target.id, messageFor(message, occurrence, target, ownerId)));
820
+ } catch (err) {
821
+ const error = reason(err);
822
+ const at = now();
823
+ if (err instanceof ExecutorUnavailableError) {
824
+ const deferrals = claim.deferrals + 1;
825
+ const last = deferrals >= policy.deferrals;
826
+ await settle({
827
+ status: last ? "failed" : "deferred",
828
+ error,
829
+ ...counts,
830
+ deferrals,
831
+ retryAt: last ? null : new Date(at.getTime() + backoff(policy, deferrals)).toISOString(),
832
+ at: at.toISOString()
833
+ });
834
+ (last ? report.failed : report.retrying).push(claim.userId);
835
+ } else if (err instanceof DeliveryFailedError) {
836
+ const attempts = claim.attempts + 1;
837
+ const last = err.unreachable || attempts >= policy.attempts;
838
+ await settle({
839
+ status: "failed",
840
+ error,
841
+ ...counts,
842
+ attempts,
843
+ retryAt: last ? null : new Date(at.getTime() + backoff(policy, attempts)).toISOString(),
844
+ at: at.toISOString()
845
+ });
846
+ (last ? report.failed : report.retrying).push(claim.userId);
847
+ } else {
848
+ await settle({ status: "unconfirmed", error, ...counts, retryAt: null, at: at.toISOString() });
849
+ report.unconfirmed.push(claim.userId);
850
+ }
851
+ return;
852
+ }
853
+ await settle({ status: "sent", messageId, ...counts, retryAt: null, at: now().toISOString() });
854
+ report.sent.push(claim.userId);
855
+ }
856
+ async function finalFail(store, row, error, at) {
857
+ await store.settleDelivery(row.occurrenceId, row.userId, {
858
+ status: "failed",
859
+ error,
860
+ attempts: row.attempts,
861
+ deferrals: row.deferrals,
862
+ retryAt: null,
863
+ at: at.toISOString()
864
+ });
865
+ }
866
+ async function dropOwed(store, rows, error, at) {
867
+ for (const row of rows)
868
+ await finalFail(store, row, error, at);
869
+ }
870
+ async function settleStaleClaims(store, now, all = false) {
871
+ const cutoff = new Date(now.getTime() - STALE_CLAIM_MS).toISOString();
872
+ let settled = 0;
873
+ for (const row of await store.listDeliveries({ status: "claimed" })) {
874
+ if (!all && (row.claimedAt ?? "") > cutoff)
875
+ continue;
876
+ await store.settleDelivery(row.occurrenceId, row.userId, {
877
+ status: "unconfirmed",
878
+ error: "claimed, never settled",
879
+ attempts: row.attempts,
880
+ deferrals: row.deferrals,
881
+ retryAt: null,
882
+ at: now.toISOString()
883
+ });
884
+ settled += 1;
885
+ }
886
+ return settled;
887
+ }
888
+ async function freshRoundForPause(store, rows) {
889
+ for (const row of rows) {
890
+ if (row.attempts === 0 && row.deferrals === 0)
891
+ continue;
892
+ await store.settleDelivery(row.occurrenceId, row.userId, {
893
+ status: row.status === "claimed" ? "pending" : row.status,
894
+ messageId: row.messageId,
895
+ error: row.error,
896
+ attempts: 0,
897
+ deferrals: 0,
898
+ retryAt: row.retryAt,
899
+ at: row.settledAt ?? row.createdAt
900
+ });
901
+ }
902
+ }
591
903
 
592
904
  // node_modules/@rackbops/docket-core/dist/describe.js
593
905
  var WD = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
@@ -648,11 +960,6 @@ function describeSchedule(schedule, owner, viewerZone = owner.timeZone, now) {
648
960
  }
649
961
  }
650
962
 
651
- // node_modules/@rackbops/docket-core/dist/ports.js
652
- class ExecutorUnavailableError extends Error {
653
- name = "ExecutorUnavailableError";
654
- }
655
-
656
963
  // node_modules/@rackbops/docket-core/dist/scheduler.js
657
964
  var LATE_AFTER_MS = 5 * 60000;
658
965
 
@@ -666,7 +973,7 @@ async function materialize(store, task, owner, now) {
666
973
  if (task.status !== "active" || !task.schedule)
667
974
  return null;
668
975
  const pending = await store.listOccurrences({ taskId: task.id });
669
- const scheduled = pending.filter((o) => !o.dedupeKey.startsWith(SNOOZE_PREFIX));
976
+ const scheduled = pending.filter((o) => !o.dedupeKey.startsWith(SNOOZE_PREFIX) && !hasFired(o));
670
977
  if (scheduled.some((o) => o.status === "queued" || o.status === "running"))
671
978
  return null;
672
979
  const after = firstDueAfter(task.schedule, pending.length === 0, now);
@@ -691,6 +998,16 @@ function firstDueAfter(schedule, fresh, now) {
691
998
  return new Date(Date.parse(schedule.start) - 1);
692
999
  return now;
693
1000
  }
1001
+ async function cancelScheduledRuns(store, taskId) {
1002
+ let removed = 0;
1003
+ for (const o of await store.listOccurrences({ taskId, status: "queued" })) {
1004
+ if (o.dedupeKey.startsWith(SNOOZE_PREFIX) || hasFired(o))
1005
+ continue;
1006
+ if (await store.deleteOccurrence(o.id))
1007
+ removed += 1;
1008
+ }
1009
+ return removed;
1010
+ }
694
1011
  async function reschedule(store, task, owner, schedule, actorId, now) {
695
1012
  if (schedule) {
696
1013
  const problems = scheduleProblems(schedule);
@@ -698,7 +1015,7 @@ async function reschedule(store, task, owner, schedule, actorId, now) {
698
1015
  throw new ScheduleError(problems.join("; "));
699
1016
  }
700
1017
  const at = now.toISOString();
701
- const removed = await store.deleteQueuedOccurrences(task.id);
1018
+ const removed = await cancelScheduledRuns(store, task.id);
702
1019
  const updated = await store.updateTask(task.id, { schedule, at });
703
1020
  await store.addTaskEvent({
704
1021
  taskId: task.id,
@@ -713,34 +1030,95 @@ async function reschedule(store, task, owner, schedule, actorId, now) {
713
1030
  // node_modules/@rackbops/docket-core/dist/dispatch.js
714
1031
  var ME = "docket";
715
1032
  var SERIES_IN_CONTEXT = 100;
1033
+ var USAGE_LIMIT_BACKOFF_MS = 3600000;
1034
+ var MAX_USAGE_LIMIT_WAIT_MS = 86400000;
1035
+ var USAGE_WINDOW_MS = 5 * 3600000;
1036
+ var MAX_RESUMES = 3;
1037
+ var STALE_RUN_MS = 10 * 60000;
1038
+
1039
+ class UsageLimitReached extends Error {
1040
+ until;
1041
+ parsed;
1042
+ window;
1043
+ constructor(until, parsed, window) {
1044
+ super(`usage limit until ${until.toISOString()}`);
1045
+ this.until = until;
1046
+ this.parsed = parsed;
1047
+ this.window = window;
1048
+ }
1049
+ }
716
1050
 
717
1051
  class Lanes {
718
1052
  d;
1053
+ executeAfter = null;
719
1054
  constructor(d) {
720
1055
  this.d = d;
721
1056
  }
722
1057
  async recover() {
723
- return this.d.store.requeueRunning();
1058
+ const ids = await this.d.store.requeueRunning();
1059
+ await settleStaleClaims(this.d.store, this.d.clock.now(), true);
1060
+ return ids;
724
1061
  }
725
- async tickNotify() {
1062
+ async tickNotify(signal) {
726
1063
  const result = { ran: 0, failed: 0, skipped: 0 };
727
- for (const occurrence of await this.due("notify")) {
728
- await this.runOne(occurrence) ? result.ran++ : result.failed++;
1064
+ const count = (verdict) => {
1065
+ if (verdict === "stop" || verdict === "held")
1066
+ result.skipped++;
1067
+ else
1068
+ verdict === "ran" ? result.ran++ : result.failed++;
1069
+ };
1070
+ await this.guarded(() => this.sweepStaleRuns());
1071
+ for (const occurrence of await this.unfinished()) {
1072
+ if (signal?.aborted)
1073
+ return result;
1074
+ count(await this.guarded(() => this.resume(occurrence, signal)));
1075
+ }
1076
+ const due = await this.due("notify");
1077
+ for (const [i, occurrence] of due.entries()) {
1078
+ if (signal?.aborted) {
1079
+ result.skipped += due.length - i;
1080
+ return result;
1081
+ }
1082
+ count(await this.guarded(() => this.runOne(occurrence, signal)));
729
1083
  }
1084
+ if (!signal?.aborted)
1085
+ await this.guarded(() => this.deliverOwed(signal));
730
1086
  return result;
731
1087
  }
1088
+ async guarded(step) {
1089
+ try {
1090
+ return await step();
1091
+ } catch {
1092
+ return "failed";
1093
+ }
1094
+ }
1095
+ async sweepStaleRuns() {
1096
+ const { store, clock } = this.d;
1097
+ const cutoff = new Date(clock.now().getTime() - STALE_RUN_MS).toISOString();
1098
+ for (const o of await store.listOccurrences({ status: "running" })) {
1099
+ if (!hasFired(o) || (o.startedAt ?? "") > cutoff)
1100
+ continue;
1101
+ await store.updateOccurrenceIf(o.id, "running", { status: "queued", startedAt: null });
1102
+ }
1103
+ }
732
1104
  async tickExecute() {
733
1105
  const due = await this.due("execute");
734
1106
  if (!this.d.executor)
735
1107
  return { ran: 0, failed: 0, skipped: due.length };
1108
+ if (this.executeAfter && this.d.clock.now() < this.executeAfter) {
1109
+ return { ran: 0, failed: 0, skipped: due.length };
1110
+ }
736
1111
  const result = { ran: 0, failed: 0, skipped: 0 };
737
- for (const occurrence of due) {
738
- const outcome = await this.runOne(occurrence);
739
- if (outcome === null) {
740
- result.skipped += due.length - result.ran - result.failed;
1112
+ for (const [i, occurrence] of due.entries()) {
1113
+ const verdict = await this.guarded(() => this.runOne(occurrence));
1114
+ if (verdict === "stop") {
1115
+ result.skipped += due.length - i;
741
1116
  break;
742
1117
  }
743
- outcome ? result.ran++ : result.failed++;
1118
+ if (verdict === "held")
1119
+ result.skipped++;
1120
+ else
1121
+ verdict === "ran" ? result.ran++ : result.failed++;
744
1122
  }
745
1123
  return result;
746
1124
  }
@@ -774,7 +1152,7 @@ class Lanes {
774
1152
  return null;
775
1153
  const ctx = await this.context(loaded, occurrence, now);
776
1154
  const outcome = await loaded.type.onReply({ ...ctx, reply });
777
- await this.apply(task, outcome, now);
1155
+ await this.apply(task, outcome, now, null);
778
1156
  if (outcome.complete)
779
1157
  await this.complete(task, input.userId, now);
780
1158
  else if (outcome.snoozeUntil) {
@@ -783,19 +1161,36 @@ class Lanes {
783
1161
  return outcome;
784
1162
  }
785
1163
  async due(lane) {
786
- return this.d.store.listOccurrences({
1164
+ const queued = await this.d.store.listOccurrences({
787
1165
  lane,
788
1166
  status: "queued",
789
1167
  dueBefore: this.d.clock.now().toISOString()
790
1168
  });
1169
+ const status = new Map;
1170
+ const kept = [];
1171
+ for (const o of queued) {
1172
+ if (hasFired(o))
1173
+ continue;
1174
+ if (!status.has(o.taskId)) {
1175
+ status.set(o.taskId, (await this.d.store.getTask(o.taskId))?.status ?? null);
1176
+ }
1177
+ const s = status.get(o.taskId);
1178
+ if (s === null || s === "active")
1179
+ kept.push(o);
1180
+ }
1181
+ return kept;
791
1182
  }
792
- async load(task) {
793
- const type = this.d.types[task.type];
794
- if (!type)
795
- return { error: `no task type ${task.type}` };
1183
+ async unfinished() {
1184
+ const queued = await this.d.store.listOccurrences({
1185
+ status: "queued",
1186
+ dueBefore: this.d.clock.now().toISOString()
1187
+ });
1188
+ return queued.filter(isFinishing);
1189
+ }
1190
+ async audience(task) {
796
1191
  const owner = await this.d.store.getUser(task.ownerId);
797
1192
  if (!owner)
798
- return { error: `no owner ${task.ownerId}` };
1193
+ return null;
799
1194
  const recipients = [];
800
1195
  for (const r of await this.d.store.listRecipients(task.id)) {
801
1196
  if (r.state !== "accepted")
@@ -804,7 +1199,16 @@ class Lanes {
804
1199
  if (user)
805
1200
  recipients.push(user);
806
1201
  }
807
- return { task, type, owner, recipients };
1202
+ return { owner, recipients };
1203
+ }
1204
+ async load(task) {
1205
+ const type = this.d.types[task.type];
1206
+ if (!type)
1207
+ return { error: `no task type ${task.type}` };
1208
+ const people = await this.audience(task);
1209
+ if (!people)
1210
+ return { error: `no owner ${task.ownerId}` };
1211
+ return { task, type, ...people };
808
1212
  }
809
1213
  async context(loaded, occurrence, now) {
810
1214
  const originalDueAt = await this.originalDueAt(occurrence);
@@ -837,75 +1241,275 @@ class Lanes {
837
1241
  }
838
1242
  return found;
839
1243
  }
840
- async apply(task, outcome, now) {
1244
+ async apply(task, outcome, now, occurrenceId) {
841
1245
  const { store } = this.d;
842
1246
  if (outcome.state !== undefined) {
843
1247
  await store.updateTask(task.id, { state: outcome.state, at: now.toISOString() });
844
1248
  }
845
- for (const point of outcome.series ?? []) {
1249
+ for (const [i, point] of (outcome.series ?? []).entries()) {
846
1250
  await store.addSeriesPoint({
847
1251
  taskId: task.id,
848
1252
  at: point.at ?? now.toISOString(),
849
1253
  value: point.value,
850
1254
  unit: point.unit ?? null,
851
- note: point.note ?? null
1255
+ note: point.note ?? null,
1256
+ key: occurrenceId === null ? null : `${occurrenceId}:${i}`
852
1257
  });
853
1258
  }
854
1259
  }
855
1260
  async complete(task, actorId, now) {
856
1261
  const { store } = this.d;
857
1262
  const at = now.toISOString();
858
- await store.deleteQueuedOccurrences(task.id);
1263
+ for (const o of await store.listOccurrences({ taskId: task.id, status: "queued" })) {
1264
+ if (!hasFired(o))
1265
+ await store.deleteOccurrence(o.id);
1266
+ }
859
1267
  await store.updateTask(task.id, { status: "done", at });
860
1268
  await store.addTaskEvent({ taskId: task.id, actorId, kind: "completed", detail: "", at });
861
1269
  }
862
- async runOne(occurrence) {
1270
+ async runOne(occurrence, signal) {
863
1271
  const { store, clock } = this.d;
864
1272
  const task = await store.getTask(occurrence.taskId);
865
1273
  if (!task)
866
1274
  return this.fail(occurrence, `no task ${occurrence.taskId}`);
1275
+ if (task.status !== "active")
1276
+ return "held";
1277
+ if (await this.finishing(task.id, occurrence.id))
1278
+ return "held";
867
1279
  const loaded = await this.load(task);
868
1280
  if ("error" in loaded)
869
1281
  return this.fail(occurrence, loaded.error);
1282
+ if (loaded.type.lane === "execute") {
1283
+ const held = await this.budgetCheck(loaded.owner);
1284
+ if (held)
1285
+ return held;
1286
+ }
870
1287
  const now = clock.now();
871
1288
  const late = isLate(occurrence, now);
872
- await store.updateOccurrence(occurrence.id, {
1289
+ const started = await store.updateOccurrenceIf(occurrence.id, "queued", {
873
1290
  status: "running",
874
1291
  startedAt: now.toISOString(),
875
1292
  late,
876
1293
  error: null
877
1294
  });
878
- await this.event(occurrence, "status", late ? "started late" : "started");
1295
+ if (!started || hasFired(started))
1296
+ return "held";
1297
+ let record;
1298
+ try {
1299
+ await this.event(occurrence, "status", late ? "started late" : "started");
1300
+ const ctx = await this.context(loaded, started, now);
1301
+ const { outcome, costUsd } = await this.execute(loaded.type, ctx, started);
1302
+ if (signal?.aborted) {
1303
+ await this.requeue(started, "the tick was aborted before the outcome was stored");
1304
+ return "stop";
1305
+ }
1306
+ const { snoozeUntil: _replyOnly, ...kept } = outcome;
1307
+ record = {
1308
+ outcome: kept,
1309
+ costUsd,
1310
+ firedAt: clock.now().toISOString(),
1311
+ appliedAt: null,
1312
+ resumes: 0
1313
+ };
1314
+ await store.updateOccurrence(started.id, { record });
1315
+ } catch (err) {
1316
+ if (err instanceof ExecutorUnavailableError || err instanceof UsageLimitReached) {
1317
+ await this.requeue(started, err.message);
1318
+ if (err instanceof UsageLimitReached)
1319
+ await this.usageLimit(err);
1320
+ return "stop";
1321
+ }
1322
+ await this.fail(started, err instanceof Error ? err.message : String(err));
1323
+ await this.next(task, loaded.owner);
1324
+ return "failed";
1325
+ }
1326
+ return this.finishFired({ ...started, record }, task, record, signal);
1327
+ }
1328
+ async requeue(occurrence, why) {
1329
+ await this.event(occurrence, "status", `waiting: ${why}`);
1330
+ await this.d.store.updateOccurrence(occurrence.id, { status: "queued", startedAt: null });
1331
+ }
1332
+ async resume(occurrence, signal) {
1333
+ const { store, clock } = this.d;
1334
+ const started = await store.updateOccurrenceIf(occurrence.id, "queued", {
1335
+ status: "running",
1336
+ startedAt: clock.now().toISOString()
1337
+ });
1338
+ if (!started)
1339
+ return "held";
1340
+ const record = parseRunRecord(started.record);
1341
+ const task = await store.getTask(started.taskId);
1342
+ if (!record || !task) {
1343
+ const error = record ? `no task ${started.taskId}` : "the run's record could not be read";
1344
+ await this.event(started, "error", error);
1345
+ await store.updateOccurrence(started.id, {
1346
+ status: "done",
1347
+ finishedAt: clock.now().toISOString(),
1348
+ error
1349
+ });
1350
+ await dropOwed(store, await store.listDeliveries({ occurrenceId: started.id }), error, clock.now());
1351
+ return "failed";
1352
+ }
1353
+ await this.event(started, "status", "resumed");
1354
+ return this.finishFired(started, task, record, signal);
1355
+ }
1356
+ async finishFired(occurrence, task, record, signal) {
1357
+ const { store, clock } = this.d;
1358
+ const { outcome } = record;
1359
+ let people = null;
1360
+ let current = null;
879
1361
  try {
880
- const ctx = await this.context(loaded, occurrence, now);
881
- const { outcome, costUsd } = await this.execute(loaded.type, ctx, occurrence);
882
- await this.apply(task, outcome, clock.now());
883
- if (outcome.notify) {
884
- await deliver(store, this.d.notifier, occurrence, [loaded.owner, ...loaded.recipients], outcome.notify, () => clock.now());
1362
+ people = await this.audience(task);
1363
+ if (people && !outcome.complete)
1364
+ await this.next(task, people.owner);
1365
+ if (record.appliedAt === null) {
1366
+ await this.apply(task, outcome, new Date(record.firedAt), occurrence.id);
1367
+ const applied = { ...record, appliedAt: clock.now().toISOString() };
1368
+ await store.updateOccurrence(occurrence.id, { record: applied });
1369
+ record = applied;
1370
+ }
1371
+ if (outcome.notify && people) {
1372
+ const at = clock.now().toISOString();
1373
+ const snoozeRun = occurrence.dedupeKey.startsWith(SNOOZE_PREFIX);
1374
+ const to = snoozeRun ? [people.owner] : [people.owner, ...people.recipients];
1375
+ for (const u of to)
1376
+ await store.planDelivery(occurrence.id, u.id, at);
885
1377
  }
886
- const snoozed = (await store.getOccurrence(occurrence.id))?.status === "snoozed";
887
- await store.updateOccurrence(occurrence.id, {
888
- status: snoozed ? "snoozed" : "done",
1378
+ const done = await store.updateOccurrenceIf(occurrence.id, "running", {
1379
+ status: "done",
889
1380
  finishedAt: clock.now().toISOString(),
890
1381
  summary: outcome.summary ?? null,
891
- costUsd
1382
+ costUsd: record.costUsd,
1383
+ error: people ? null : `no owner ${task.ownerId}`
892
1384
  });
893
- if (outcome.complete)
894
- await this.complete(task, null, clock.now());
895
- else
896
- await this.next(task, loaded.owner);
897
- return true;
1385
+ if (!done)
1386
+ return "held";
1387
+ current = await store.getTask(task.id);
1388
+ if (outcome.complete && current && current.status !== "done") {
1389
+ await this.complete(current, null, clock.now());
1390
+ current = await store.getTask(task.id);
1391
+ }
898
1392
  } catch (err) {
899
- if (err instanceof ExecutorUnavailableError) {
900
- await this.event(occurrence, "status", `waiting: ${err.message}`);
901
- await store.updateOccurrence(occurrence.id, { status: "queued" });
902
- return null;
1393
+ return this.putBack(occurrence, record, err);
1394
+ }
1395
+ const sending = current?.status === "active" || current?.status === "done";
1396
+ if (outcome.notify && people && sending) {
1397
+ try {
1398
+ await this.deliverRun(occurrence, task, people, outcome.notify, signal);
1399
+ } catch {}
1400
+ }
1401
+ return "ran";
1402
+ }
1403
+ async putBack(occurrence, record, err) {
1404
+ const { store, clock } = this.d;
1405
+ const message = err instanceof Error ? err.message : String(err);
1406
+ const resumes = record.resumes + 1;
1407
+ try {
1408
+ await this.event(occurrence, "error", message);
1409
+ if (resumes > MAX_RESUMES) {
1410
+ await store.updateOccurrenceIf(occurrence.id, "running", {
1411
+ status: "done",
1412
+ finishedAt: clock.now().toISOString(),
1413
+ error: `could not finish after ${MAX_RESUMES} tries: ${message}`
1414
+ });
1415
+ return "failed";
903
1416
  }
904
- await this.fail(occurrence, err instanceof Error ? err.message : String(err));
905
- await this.next(task, loaded.owner);
906
- return false;
1417
+ await store.updateOccurrenceIf(occurrence.id, "running", {
1418
+ status: "queued",
1419
+ startedAt: null,
1420
+ record: { ...record, resumes }
1421
+ });
1422
+ } catch {}
1423
+ return "held";
1424
+ }
1425
+ async finishing(taskId, except) {
1426
+ for (const status of ["queued", "running"]) {
1427
+ const runs = await this.d.store.listOccurrences({ taskId, status });
1428
+ if (runs.some((o) => o.id !== except && isFinishing(o)))
1429
+ return true;
1430
+ }
1431
+ return false;
1432
+ }
1433
+ async deliverRun(occurrence, task, people, message, signal) {
1434
+ const targets = new Map;
1435
+ for (const u of [people.owner, ...people.recipients])
1436
+ targets.set(u.id, u);
1437
+ await deliver(this.d.store, this.d.notifier, occurrence, task.ownerId, targets, message, {
1438
+ now: () => this.d.clock.now(),
1439
+ policy: this.d.delivery ?? DEFAULT_DELIVERY_POLICY,
1440
+ ...signal ? { signal } : {}
1441
+ });
1442
+ }
1443
+ async deliverOwed(signal) {
1444
+ const { store, clock } = this.d;
1445
+ await settleStaleClaims(store, clock.now());
1446
+ const byRun = new Map;
1447
+ for (const row of await store.listDeliveries({ dueBefore: clock.now().toISOString() })) {
1448
+ byRun.set(row.occurrenceId, [...byRun.get(row.occurrenceId) ?? [], row]);
1449
+ }
1450
+ for (const [occurrenceId, rows] of byRun) {
1451
+ if (signal?.aborted)
1452
+ return;
1453
+ try {
1454
+ const occurrence = await store.getOccurrence(occurrenceId);
1455
+ const record = occurrence ? parseRunRecord(occurrence.record) : null;
1456
+ const task = occurrence ? await store.getTask(occurrence.taskId) : null;
1457
+ const message = record?.outcome.notify;
1458
+ const people = task ? await this.audience(task) : null;
1459
+ if (!occurrence || !message || !task || !people || task.status === "archived") {
1460
+ await dropOwed(store, rows, "the run or its task is gone", clock.now());
1461
+ continue;
1462
+ }
1463
+ if (task.status === "paused") {
1464
+ await freshRoundForPause(store, rows);
1465
+ continue;
1466
+ }
1467
+ await this.deliverRun(occurrence, task, people, message, signal);
1468
+ } catch {}
907
1469
  }
908
1470
  }
1471
+ async budgetCheck(owner) {
1472
+ const { store, notifier, clock } = this.d;
1473
+ const now = clock.now();
1474
+ const hold = await budgetHold(store, this.d.budget ?? DEFAULT_BUDGET, owner, now);
1475
+ if (!hold)
1476
+ return null;
1477
+ if (hold.scope === "global") {
1478
+ await noticeOnce(store, notifier, `budget:global:${hold.day}`, now, budgetAdminMessage(hold, null));
1479
+ return "stop";
1480
+ }
1481
+ await noticeOnce(store, notifier, `budget:person:${owner.id}:${hold.day}`, now, budgetAdminMessage(hold, owner), { user: owner, message: budgetPersonMessage(hold) });
1482
+ return "held";
1483
+ }
1484
+ async usageLimit(err) {
1485
+ const { store, notifier, clock } = this.d;
1486
+ this.executeAfter = err.until;
1487
+ const key = `usage-limit:${err.window}`;
1488
+ await noticeOnce(store, notifier, key, clock.now(), usageLimitAdminMessage(err.parsed ? new Date(err.window) : err.until, err.parsed, err.until));
1489
+ }
1490
+ async chargeRun(occurrence, task, result) {
1491
+ if (result.kind === "usage_limit") {
1492
+ const now = this.d.clock.now();
1493
+ const reset = result.resetsAt ? new Date(result.resetsAt) : null;
1494
+ if (reset && !Number.isNaN(reset.getTime()) && reset > now) {
1495
+ const until = new Date(Math.min(reset.getTime(), now.getTime() + MAX_USAGE_LIMIT_WAIT_MS));
1496
+ throw new UsageLimitReached(until, true, reset.toISOString());
1497
+ }
1498
+ const window = Math.floor(now.getTime() / USAGE_WINDOW_MS);
1499
+ throw new UsageLimitReached(new Date(now.getTime() + USAGE_LIMIT_BACKOFF_MS), false, `unknown:${window}`);
1500
+ }
1501
+ if (result.kind === "auth_failed")
1502
+ return;
1503
+ await charge(this.d.store, {
1504
+ userId: task.ownerId,
1505
+ taskId: task.id,
1506
+ occurrenceId: occurrence.id,
1507
+ source: "run",
1508
+ calls: 1,
1509
+ costUsd: result.totalCostUsd ?? null,
1510
+ at: this.d.clock.now()
1511
+ });
1512
+ }
909
1513
  async next(task, owner) {
910
1514
  const current = await this.d.store.getTask(task.id);
911
1515
  if (current)
@@ -923,12 +1527,17 @@ class Lanes {
923
1527
  throw new ExecutorUnavailableError("no executor configured");
924
1528
  const spec = await type.prepare(ctx);
925
1529
  const result = await this.d.executor.run(spec, occurrence.id);
1530
+ await this.chargeRun(occurrence, ctx.task, result);
926
1531
  const outcome = await type.finish(ctx, result);
927
1532
  return { outcome, costUsd: result.totalCostUsd ?? null };
928
1533
  }
929
1534
  async snooze(task, occurrence, until, reply, now) {
930
1535
  const { store } = this.d;
931
- await store.updateOccurrence(occurrence.id, { status: "snoozed" });
1536
+ const current = await store.getOccurrence(occurrence.id);
1537
+ if (!current || current.status !== "done" && current.status !== "failed")
1538
+ return;
1539
+ if (!await store.updateOccurrenceIf(current.id, current.status, { status: "snoozed" }))
1540
+ return;
932
1541
  await this.event(occurrence, "status", `snoozed until ${until.toISOString()} by ${reply.userId}`);
933
1542
  await store.createOccurrence({
934
1543
  taskId: task.id,
@@ -940,13 +1549,12 @@ class Lanes {
940
1549
  }
941
1550
  async fail(occurrence, message) {
942
1551
  await this.event(occurrence, "error", message);
943
- const snoozed = (await this.d.store.getOccurrence(occurrence.id))?.status === "snoozed";
944
1552
  await this.d.store.updateOccurrence(occurrence.id, {
945
- status: snoozed ? "snoozed" : "failed",
1553
+ status: "failed",
946
1554
  finishedAt: this.d.clock.now().toISOString(),
947
1555
  error: message
948
1556
  });
949
- return false;
1557
+ return "failed";
950
1558
  }
951
1559
  async event(occurrence, type, text) {
952
1560
  await this.d.store.addEvent({
@@ -961,6 +1569,16 @@ class Lanes {
961
1569
 
962
1570
  // node_modules/@rackbops/docket-core/dist/messages.js
963
1571
  var ADMIN_DISCLOSURE = "An admin of this tracker can see every task and its history, including yours.";
1572
+ function registrationText(user, options) {
1573
+ const hour = String(user.preferredHour).padStart(2, "0");
1574
+ return [
1575
+ options.first ? "You are registered." : "Your settings are updated.",
1576
+ `Reminders reach you by DM. One without a time of day arrives at ${hour}:00, ${user.timeZone} time.`,
1577
+ `Your tasks are private to you and anyone you choose to share them with. ${ADMIN_DISCLOSURE}`,
1578
+ ...options.notes ?? []
1579
+ ].join(`
1580
+ `);
1581
+ }
964
1582
  function name(user) {
965
1583
  return user.displayName ?? "Someone";
966
1584
  }
@@ -982,7 +1600,7 @@ async function taskList(store, actor) {
982
1600
  const tasks = await visibleTasks(store, { ...actor, admin: false }, { status: "active" });
983
1601
  const entries = [];
984
1602
  for (const task of tasks) {
985
- const queued = await store.listOccurrences({ taskId: task.id, status: "queued" });
1603
+ const queued = (await store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => !hasFired(o));
986
1604
  const from = task.ownerId === actor.userId ? null : await store.getUser(task.ownerId);
987
1605
  entries.push({ task, next: queued[0] ?? null, from });
988
1606
  }
@@ -1841,13 +2459,18 @@ function decideAccess(input) {
1841
2459
  return NOT_REGISTERED;
1842
2460
  return null;
1843
2461
  }
1844
- function parseGuildId(raw) {
2462
+ function parseGuildIds(raw) {
1845
2463
  if (raw === undefined || raw.trim() === "")
1846
2464
  return null;
1847
- const id = raw.trim();
1848
- if (!/^[0-9]{17,20}$/.test(id))
1849
- throw new Error(`TRACKER_GUILD_ID: "${id}" is not a Discord server id`);
1850
- return id;
2465
+ const ids = [];
2466
+ for (const entry of raw.split(",")) {
2467
+ const id = entry.trim();
2468
+ if (!/^[0-9]{17,20}$/.test(id))
2469
+ throw new Error(`TRACKER_GUILD_ID: "${id}" is not a Discord server id`);
2470
+ if (!ids.includes(id))
2471
+ ids.push(id);
2472
+ }
2473
+ return ids;
1851
2474
  }
1852
2475
 
1853
2476
  // plugins/tracker/src/admissions.ts
@@ -1874,53 +2497,6 @@ class Admissions {
1874
2497
  }
1875
2498
  }
1876
2499
 
1877
- // plugins/tracker/src/claims.ts
1878
- function toClaim(r) {
1879
- const s = (v) => v === null || v === undefined ? null : String(v);
1880
- return {
1881
- occurrenceId: String(r.occurrence_id),
1882
- userId: String(r.user_id),
1883
- discordId: String(r.discord_id),
1884
- status: String(r.status),
1885
- messageId: s(r.message_id),
1886
- channelId: s(r.channel_id),
1887
- error: s(r.error),
1888
- claimedAt: String(r.claimed_at),
1889
- settledAt: s(r.settled_at),
1890
- reportedAt: s(r.reported_at)
1891
- };
1892
- }
1893
-
1894
- class ClaimStore {
1895
- db;
1896
- constructor(db) {
1897
- this.db = db;
1898
- }
1899
- get(occurrenceId, userId) {
1900
- const r = this.db.query("SELECT * FROM delivery_claims WHERE occurrence_id = ? AND user_id = ?").get(occurrenceId, userId);
1901
- return r ? toClaim(r) : null;
1902
- }
1903
- claim(occurrenceId, userId, discordId, at) {
1904
- const { changes } = this.db.query(`INSERT INTO delivery_claims (occurrence_id, user_id, discord_id, status, claimed_at)
1905
- VALUES (?, ?, ?, 'claimed', ?)
1906
- ON CONFLICT (occurrence_id, user_id) DO UPDATE SET
1907
- discord_id = excluded.discord_id, status = 'claimed', claimed_at = excluded.claimed_at,
1908
- message_id = NULL, channel_id = NULL, error = NULL, settled_at = NULL, reported_at = NULL
1909
- WHERE delivery_claims.status = 'failed'`).run(occurrenceId, userId, discordId, at);
1910
- return changes === 1;
1911
- }
1912
- settle(occurrenceId, userId, status, at, detail = {}) {
1913
- this.db.query(`UPDATE delivery_claims SET status = ?, message_id = ?, channel_id = ?, error = ?, settled_at = ?
1914
- WHERE occurrence_id = ? AND user_id = ?`).run(status, detail.messageId ?? null, detail.channelId ?? null, detail.error ?? null, at, occurrenceId, userId);
1915
- }
1916
- markReported(occurrenceId, userId, at) {
1917
- this.db.query("UPDATE delivery_claims SET reported_at = ? WHERE occurrence_id = ? AND user_id = ?").run(at, occurrenceId, userId);
1918
- }
1919
- listUnsettled() {
1920
- return this.db.query("SELECT * FROM delivery_claims WHERE status IN ('claimed', 'unconfirmed') ORDER BY claimed_at").all().map(toClaim);
1921
- }
1922
- }
1923
-
1924
2500
  // plugins/tracker/src/delivery-health.ts
1925
2501
  var PAUSE_AFTER = 3;
1926
2502
  var PAUSE_DETAIL = `delivery paused: ${PAUSE_AFTER} DMs in a row could not be delivered (DMs closed, or the bot is blocked)`;
@@ -2372,13 +2948,22 @@ var EPHEMERAL = { flags: MessageFlags.Ephemeral };
2372
2948
  var NO_MENTIONS = { allowedMentions: { parse: [] } };
2373
2949
  var UNKNOWN_MEMBER = 10007;
2374
2950
  var UNKNOWN_USER = 10013;
2375
- async function lookupMembership(interaction, guildId, discordId, log) {
2376
- if (guildId === null)
2951
+ async function lookupMembership(interaction, guildIds, discordId, log) {
2952
+ if (guildIds === null)
2377
2953
  return "not-checked";
2378
- if (interaction.guildId === guildId && interaction.user.id === discordId)
2954
+ if (interaction.guildId !== null && guildIds.includes(interaction.guildId) && interaction.user.id === discordId)
2379
2955
  return "member";
2956
+ const lookups = guildIds.map((guildId) => memberOf(interaction.client, guildId, discordId, log));
2957
+ const firstYes = new Promise((resolve) => {
2958
+ for (const l of lookups)
2959
+ l.then((a) => a === "member" && resolve("member"));
2960
+ });
2961
+ const all = Promise.all(lookups).then((answers) => answers.includes("unknown") ? "unknown" : answers.includes("member") ? "member" : "not-member");
2962
+ return Promise.race([firstYes, all]);
2963
+ }
2964
+ async function memberOf(client, guildId, discordId, log) {
2380
2965
  try {
2381
- const guild = await interaction.client.guilds.fetch(guildId);
2966
+ const guild = await client.guilds.fetch(guildId);
2382
2967
  await guild.members.fetch({ user: discordId, force: true });
2383
2968
  return "member";
2384
2969
  } catch (err) {
@@ -2504,7 +3089,7 @@ function retimed(s, day, owner) {
2504
3089
  return null;
2505
3090
  }
2506
3091
  async function heldRuns(store, task) {
2507
- return (await store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => !o.dedupeKey.startsWith(SNOOZE_PREFIX));
3092
+ return (await store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => !o.dedupeKey.startsWith(SNOOZE_PREFIX) && !hasFired(o));
2508
3093
  }
2509
3094
  async function restoreHeldRun(store, task, held, before, after, now) {
2510
3095
  const s = task.schedule;
@@ -2549,13 +3134,10 @@ async function allowPerson(d, admin, target) {
2549
3134
  return `Allowed <@${target.discordId}>: they can now use \`/register\`.`;
2550
3135
  }
2551
3136
  function registeredText(user, first) {
2552
- return [
2553
- first ? "You are registered." : "Your settings are updated.",
2554
- `Reminders reach you by DM. One without a time of day arrives at ${String(user.preferredHour).padStart(2, "0")}:00, ${user.timeZone} time.`,
2555
- `Your tasks are private to you and anyone you choose to share them with. ${ADMIN_DISCLOSURE}`,
2556
- `Keep DMs from this server open: after ${PAUSE_AFTER} DMs in a row that cannot be delivered, your reminders pause until you next use a command.`
2557
- ].join(`
2558
- `);
3137
+ return registrationText(user, {
3138
+ first,
3139
+ notes: [`Keep DMs from this server open: after ${PAUSE_AFTER} DMs in a row that cannot be delivered, your reminders pause until you next use a command.`]
3140
+ });
2559
3141
  }
2560
3142
  async function registerPerson(d, user, input) {
2561
3143
  let updated;
@@ -2615,21 +3197,21 @@ async function rescheduleOwned(d, owner, zoneChanged, before) {
2615
3197
  continue;
2616
3198
  if (!zoneChanged && s.hour !== undefined)
2617
3199
  continue;
2618
- const held = task.status === "paused" ? await heldRuns(d.store, task) : [];
2619
- const updated = await rescheduleKeepingSnoozes(d, task, owner, s, owner.id);
2620
- if (held.length > 0)
2621
- await restoreHeldRun(d.store, updated, held, before, owner, d.clock.now());
3200
+ await d.locks.run(task.id, async () => {
3201
+ const current = await d.store.getTask(task.id);
3202
+ if (!current || current.status !== "active" && current.status !== "paused")
3203
+ return;
3204
+ const held = current.status === "paused" ? await heldRuns(d.store, current) : [];
3205
+ const updated = await rescheduleKeepingSnoozes(d, current, owner, s, owner.id);
3206
+ if (held.length > 0)
3207
+ await restoreHeldRun(d.store, updated, held, before, owner, d.clock.now());
3208
+ });
2622
3209
  moved++;
2623
3210
  }
2624
3211
  return moved;
2625
3212
  }
2626
3213
  async function rescheduleKeepingSnoozes(d, task, owner, schedule, actorId) {
2627
- const snoozes = (await d.store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => o.dedupeKey.startsWith(SNOOZE_PREFIX));
2628
- const now = d.clock.now();
2629
- const { task: updated } = await reschedule(d.store, task, owner, schedule, actorId, now);
2630
- for (const o of snoozes) {
2631
- await d.store.createOccurrence({ taskId: o.taskId, lane: o.lane, dueAt: o.dueAt, dedupeKey: o.dedupeKey, at: now.toISOString() });
2632
- }
3214
+ const { task: updated } = await reschedule(d.store, task, owner, schedule, actorId, d.clock.now());
2633
3215
  return updated;
2634
3216
  }
2635
3217
  async function liveTaskCap(d, owner) {
@@ -2673,14 +3255,24 @@ async function ownTask(d, user, taskId) {
2673
3255
  const task = await visibleTask(d.store, { userId: user.id, admin: false }, taskId.trim());
2674
3256
  return task && task.ownerId === user.id ? task : null;
2675
3257
  }
3258
+ function answerable(o) {
3259
+ if (o.status === "done" || o.status === "failed")
3260
+ return true;
3261
+ return (o.status === "running" || o.status === "queued") && hasFired(o);
3262
+ }
3263
+ async function latestRun(d, taskId) {
3264
+ return (await d.store.listOccurrences({ taskId })).filter(answerable).at(-1) ?? null;
3265
+ }
2676
3266
  async function answerLatest(d, user, input) {
2677
3267
  if (input.until !== undefined && input.until.trim().length > MAX_WHEN)
2678
3268
  return `\`until\` is longer than ${MAX_WHEN} characters.`;
2679
3269
  const task = await ownTask(d, user, input.taskId);
2680
3270
  if (!task)
2681
3271
  return NO_SUCH_TASK;
2682
- const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status === "running" || o.status === "done" || o.status === "failed");
2683
- const latest = fired.at(-1);
3272
+ return d.locks.run(task.id, () => answerLatestLocked(d, user, task, input));
3273
+ }
3274
+ async function answerLatestLocked(d, user, task, input) {
3275
+ const latest = await latestRun(d, task.id);
2684
3276
  if (!latest)
2685
3277
  return "That task has not reminded you yet, so there is nothing to answer.";
2686
3278
  let payload = null;
@@ -2716,7 +3308,13 @@ function editable(task) {
2716
3308
  return task.status === "active" || task.status === "paused";
2717
3309
  }
2718
3310
  var FINISHED = "That task has finished, so it cannot be changed. Set a new one instead.";
2719
- async function pauseTask(d, user, taskId) {
3311
+ function locked(d, taskId, act) {
3312
+ return d.locks.run(taskId.trim(), act);
3313
+ }
3314
+ function pauseTask(d, user, taskId) {
3315
+ return locked(d, taskId, () => pauseLocked(d, user, taskId));
3316
+ }
3317
+ async function pauseLocked(d, user, taskId) {
2720
3318
  const task = await ownLiveTask(d, user, taskId);
2721
3319
  if (!task)
2722
3320
  return { ok: false, error: NO_SUCH_TASK };
@@ -2727,7 +3325,10 @@ async function pauseTask(d, user, taskId) {
2727
3325
  await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "paused", detail: "paused by the owner", at });
2728
3326
  return { ok: true, text: `Paused \`${task.id}\`: nothing is sent until you resume it.` };
2729
3327
  }
2730
- async function resumeTask(d, user, taskId) {
3328
+ function resumeTask(d, user, taskId) {
3329
+ return locked(d, taskId, () => resumeLocked(d, user, taskId));
3330
+ }
3331
+ async function resumeLocked(d, user, taskId) {
2731
3332
  const task = await ownLiveTask(d, user, taskId);
2732
3333
  if (!task)
2733
3334
  return { ok: false, error: NO_SUCH_TASK };
@@ -2753,12 +3354,18 @@ async function resumeTask(d, user, taskId) {
2753
3354
  const without = names.length > 0 ? `, without ${names.join(", ")}` : "";
2754
3355
  return { ok: true, text: `Resumed \`${task.id}\`${without}.` };
2755
3356
  }
2756
- async function deleteTask(d, user, taskId) {
3357
+ function deleteTask(d, user, taskId) {
3358
+ return locked(d, taskId, () => deleteLocked(d, user, taskId));
3359
+ }
3360
+ async function deleteLocked(d, user, taskId) {
2757
3361
  const task = await ownLiveTask(d, user, taskId);
2758
3362
  if (!task)
2759
3363
  return { ok: false, error: NO_SUCH_TASK };
2760
3364
  const at = d.clock.now().toISOString();
2761
- await d.store.deleteQueuedOccurrences(task.id);
3365
+ for (const o of await d.store.listOccurrences({ taskId: task.id, status: "queued" })) {
3366
+ if (!hasFired(o))
3367
+ await d.store.deleteOccurrence(o.id);
3368
+ }
2762
3369
  await d.store.updateTask(task.id, { status: "archived", at });
2763
3370
  await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "archived", detail: "deleted by the owner", at });
2764
3371
  return { ok: true, text: `Deleted \`${task.id}\` ${task.title}.` };
@@ -2893,30 +3500,31 @@ function hostButtons(message) {
2893
3500
  // plugins/tracker/src/notifier.ts
2894
3501
  var HOST_CANNOT_MESSAGE = "recipient cannot be messaged";
2895
3502
  var MAX_CONTENT = 2000;
2896
- var UNCONFIRMED_MESSAGE_ID = "unconfirmed";
2897
3503
  var HOST_SNOWFLAKE = /^[0-9]{5,25}$/;
2898
3504
  var URL_OR_WRAPPED = /<(https?:\/\/[^\s<>]+)>|(https?:\/\/[^\s<>]+)/g;
2899
3505
  function wrapBareUrls(content) {
2900
3506
  return content.replace(URL_OR_WRAPPED, (whole, wrapped, bare) => wrapped !== undefined ? whole : `<${bare}>`);
2901
3507
  }
2902
- function isPreSendError(err) {
2903
- if (typeof err === "object" && err !== null && err.code === 10013)
3508
+ var UNKNOWN_USER2 = 10013;
3509
+ function isUnknownUser(err) {
3510
+ if (typeof err === "object" && err !== null && err.code === UNKNOWN_USER2)
2904
3511
  return true;
3512
+ return (err instanceof Error ? err.message : String(err)) === "Unknown User";
3513
+ }
3514
+ function isHostRefusal(err) {
2905
3515
  const message = err instanceof Error ? err.message : String(err);
2906
- return message === "userId is not a valid id" || message === "message must be an object" || message === "content must be a string" || message === "content is empty" || message === "interactive buttons are not supported yet" || message === "Unknown User" || message.startsWith("content is longer than");
3516
+ return message === "userId is not a valid id" || message === "message must be an object" || message === "content must be a string" || message === "content is empty" || message === "interactive buttons are not supported yet" || message.startsWith("content is longer than") || isButtonRefusal(err);
2907
3517
  }
2908
-
2909
- class RecipientUnreachableError extends Error {
2910
- userId;
2911
- name = "RecipientUnreachableError";
2912
- constructor(userId) {
2913
- super(`recipient ${userId} cannot be messaged (DMs closed, or the bot is blocked)`);
2914
- this.userId = userId;
2915
- }
3518
+ function deliveryError(err) {
3519
+ const reason = err instanceof Error ? err.message : String(err);
3520
+ if (reason === HOST_CANNOT_MESSAGE || isUnknownUser(err))
3521
+ return new DeliveryFailedError(reason, true);
3522
+ if (isHostRefusal(err))
3523
+ return new DeliveryFailedError(reason);
3524
+ return err;
2916
3525
  }
2917
-
2918
- class UndeliverableError extends Error {
2919
- name = "UndeliverableError";
3526
+ function isUnreachable(err) {
3527
+ return err instanceof DeliveryFailedError && err.unreachable;
2920
3528
  }
2921
3529
  var HOST_BUTTON_REFUSAL = /^(interactive buttons are not supported|buttons? (label|customId|customIds|style|must|is not valid|and links need|need this plugin))/;
2922
3530
  function isButtonRefusal(err) {
@@ -2940,39 +3548,18 @@ function createDmNotifier(d) {
2940
3548
  async sendDm(userId, message) {
2941
3549
  const user = await d.store.getUser(userId);
2942
3550
  if (!user)
2943
- throw new UndeliverableError(`no user ${userId}`);
3551
+ throw new DeliveryFailedError(`no user ${userId}`, true);
2944
3552
  const discordId = user.discordId;
2945
3553
  if (!discordId || !HOST_SNOWFLAKE.test(discordId))
2946
- throw new UndeliverableError(`user ${userId} has no valid Discord id`);
3554
+ throw new DeliveryFailedError(`user ${userId} has no valid Discord id`, true);
2947
3555
  const hostMessage = toHostMessage(message);
2948
3556
  if (!hostMessage)
2949
- throw new UndeliverableError("the message has no text");
2950
- const occurrenceId = message.ref?.occurrenceId ?? null;
2951
- if (occurrenceId !== null) {
2952
- const existing = d.claims.get(occurrenceId, userId);
2953
- if (existing?.status === "sent")
2954
- return { messageId: existing.messageId || UNCONFIRMED_MESSAGE_ID };
2955
- if (existing && existing.status !== "failed") {
2956
- if (existing.status === "claimed") {
2957
- d.claims.settle(occurrenceId, userId, "unconfirmed", d.clock.now().toISOString(), { error: "claimed, never settled" });
2958
- }
2959
- reportUnconfirmed(d.claims, d.log, existing, d.clock.now().toISOString());
2960
- return { messageId: UNCONFIRMED_MESSAGE_ID };
2961
- }
2962
- }
3557
+ throw new DeliveryFailedError("the message has no text");
2963
3558
  if (d.health?.isPaused(userId))
2964
3559
  throw new ExecutorUnavailableError(`delivery to ${userId} is paused`);
2965
- if (d.signal?.aborted)
2966
- throw new ExecutorUnavailableError("the tick was aborted before this send");
2967
- if (occurrenceId !== null) {
2968
- const at = d.clock.now().toISOString();
2969
- if (!d.claims.claim(occurrenceId, userId, discordId, at)) {
2970
- return { messageId: UNCONFIRMED_MESSAGE_ID };
2971
- }
2972
- }
3560
+ const buttons = hostButtons(message);
3561
+ let delivery;
2973
3562
  try {
2974
- const buttons = hostButtons(message);
2975
- let delivery;
2976
3563
  try {
2977
3564
  delivery = await d.dm(discordId, buttons.length > 0 ? { ...hostMessage, buttons } : hostMessage);
2978
3565
  } catch (err) {
@@ -2981,39 +3568,28 @@ function createDmNotifier(d) {
2981
3568
  d.log.warn(`DM sent without buttons: the host refused them (${err instanceof Error ? err.message : String(err)})`);
2982
3569
  delivery = await d.dm(discordId, hostMessage);
2983
3570
  }
2984
- d.health?.recordSuccess(userId);
2985
- if (occurrenceId !== null) {
2986
- d.claims.settle(occurrenceId, userId, "sent", d.clock.now().toISOString(), {
2987
- messageId: delivery.messageId,
2988
- channelId: delivery.channelId
2989
- });
2990
- }
2991
- return { messageId: delivery.messageId };
2992
3571
  } catch (err) {
3572
+ const mapped = deliveryError(err);
2993
3573
  const reason = err instanceof Error ? err.message : String(err);
2994
- const unreachable = reason === HOST_CANNOT_MESSAGE;
2995
- const nothingSent = unreachable || isPreSendError(err);
2996
- if (occurrenceId !== null) {
2997
- d.claims.settle(occurrenceId, userId, nothingSent ? "failed" : "unconfirmed", d.clock.now().toISOString(), { error: reason });
2998
- }
2999
- if (unreachable) {
3000
- const counted = await d.health?.recordFailure(userId, reason, d.clock.now().toISOString());
3001
- if (counted?.paused)
3002
- d.log.warn(`paused delivery to ${userId}: ${counted.failures} DMs in a row could not be delivered`);
3003
- throw new RecipientUnreachableError(userId);
3574
+ if (reason === HOST_CANNOT_MESSAGE || isUnknownUser(err)) {
3575
+ try {
3576
+ const counted = await d.health?.recordFailure(userId, reason, d.clock.now().toISOString());
3577
+ if (counted?.paused)
3578
+ d.log.warn(`paused delivery to ${userId}: ${counted.failures} DMs in a row could not be delivered`);
3579
+ } catch (countErr) {
3580
+ d.log.error(`could not count a failed DM to ${userId}`, countErr);
3581
+ }
3582
+ } else if (!(mapped instanceof DeliveryFailedError)) {
3583
+ const what = message.ref?.occurrenceId ? `delivery of ${message.ref.occurrenceId}` : "a DM";
3584
+ d.log.warn(`${what} to ${userId} is unconfirmed (${reason}); it will not be resent`);
3004
3585
  }
3005
- throw err;
3586
+ throw mapped;
3006
3587
  }
3588
+ d.health?.recordSuccess(userId);
3589
+ return { messageId: delivery.messageId };
3007
3590
  }
3008
3591
  };
3009
3592
  }
3010
- function reportUnconfirmed(claims, log, claim, at) {
3011
- if (claim.reportedAt !== null)
3012
- return;
3013
- const what = claim.status === "claimed" ? `claimed ${claim.claimedAt}, never settled` : `unconfirmed since ${claim.claimedAt} (${claim.error ?? "no detail"})`;
3014
- log.warn(`delivery of ${claim.occurrenceId} to ${claim.userId} was ${what}; it will not be resent`);
3015
- claims.markReported(claim.occurrenceId, claim.userId, at);
3016
- }
3017
3593
 
3018
3594
  // plugins/tracker/src/press.ts
3019
3595
  var CANNOT_SHARE = "can't be shared with: they have to be on this tracker's list, registered, a member of its server, and reachable by DM.";
@@ -3059,8 +3635,9 @@ async function finishShare(d, p, err) {
3059
3635
  detail: `${p.targetId}: the invitation could not be delivered`,
3060
3636
  at: d.clock.now().toISOString()
3061
3637
  });
3062
- if (err instanceof RecipientUnreachableError) {
3063
- return `I could not DM <@${p.targetDiscordId}> (their DMs are closed, or they blocked the bot), so the invitation is withdrawn.`;
3638
+ if (isUnreachable(err)) {
3639
+ const why = err instanceof Error && err.message === HOST_CANNOT_MESSAGE ? "their DMs are closed, or they blocked the bot" : "I can't reach them on Discord";
3640
+ return `I could not DM <@${p.targetDiscordId}> (${why}), so the invitation is withdrawn.`;
3064
3641
  }
3065
3642
  d.log.error(`invitation DM for ${p.taskId} to ${p.targetId} failed`, err);
3066
3643
  return "The invitation could not be sent; try again in a minute.";
@@ -3070,7 +3647,19 @@ var CONSENT_TEXT = {
3070
3647
  decline: "Declined. You will not get this task's results.",
3071
3648
  opt_out: "You will not get this task's messages any more."
3072
3649
  };
3650
+ async function taskOfRef(d, ref) {
3651
+ const decoded = decodeReplyRef(ref);
3652
+ if (!decoded)
3653
+ return null;
3654
+ if (decoded.scope === "t")
3655
+ return decoded.id;
3656
+ return (await d.store.getOccurrence(decoded.id))?.taskId ?? null;
3657
+ }
3073
3658
  async function press(d, user, ref) {
3659
+ const taskId = await taskOfRef(d, ref);
3660
+ return taskId === null ? pressLocked(d, user, ref) : d.locks.run(taskId, () => pressLocked(d, user, ref));
3661
+ }
3662
+ async function pressLocked(d, user, ref) {
3074
3663
  const verdict = await replyForRef(d.store, ref, user.id);
3075
3664
  if (!verdict.ok)
3076
3665
  return { ok: false, error: verdict.error };
@@ -3101,6 +3690,11 @@ async function replyRefusal(d, user, occurrenceId) {
3101
3690
  return mine?.state === "accepted" ? null : "That is not yours to reply to.";
3102
3691
  }
3103
3692
  async function submitReply(d, user, occurrenceId, text) {
3693
+ const taskId = (await d.store.getOccurrence(occurrenceId))?.taskId;
3694
+ const act = () => submitReplyLocked(d, user, occurrenceId, text);
3695
+ return taskId === undefined ? act() : d.locks.run(taskId, act);
3696
+ }
3697
+ async function submitReplyLocked(d, user, occurrenceId, text) {
3104
3698
  const refusal = await replyRefusal(d, user, occurrenceId);
3105
3699
  if (refusal)
3106
3700
  return refusal;
@@ -3598,18 +4192,22 @@ async function decideRenewal(d, user, input) {
3598
4192
  return `\`/task decide\` answers a renewal; \`${task.id}\` is a ${task.type}.`;
3599
4193
  if (input.amount !== undefined && (!Number.isFinite(input.amount) || input.amount < 0))
3600
4194
  return "The amount has to be zero or more.";
3601
- const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status === "running" || o.status === "done" || o.status === "failed");
3602
- const latest = fired.at(-1);
3603
- if (!latest)
3604
- return "That renewal has no ask waiting for an answer yet.";
3605
4195
  const payload = input.amount === undefined ? input.choice : { choice: input.choice, amount: input.amount };
3606
- try {
3607
- await replyLanes(d).reply({ taskId: task.id, occurrenceId: latest.id, userId: user.id, kind: "decision", payload });
3608
- } catch (err) {
3609
- if (err instanceof ReplyRefusedError)
3610
- return err.message;
3611
- throw err;
3612
- }
4196
+ const refused = await d.locks.run(task.id, async () => {
4197
+ const latest = await latestRun(d, task.id);
4198
+ if (!latest)
4199
+ return "That renewal has no ask waiting for an answer yet.";
4200
+ try {
4201
+ await replyLanes(d).reply({ taskId: task.id, occurrenceId: latest.id, userId: user.id, kind: "decision", payload });
4202
+ return null;
4203
+ } catch (err) {
4204
+ if (err instanceof ReplyRefusedError)
4205
+ return err.message;
4206
+ throw err;
4207
+ }
4208
+ });
4209
+ if (refused !== null)
4210
+ return refused;
3613
4211
  if (input.choice === "cancel")
3614
4212
  return `Cancelled: \`${task.id}\` ${task.title} will not ask again.`;
3615
4213
  const currency = task.config.currency ?? "";
@@ -3687,7 +4285,7 @@ function createSurface(w) {
3687
4285
  const ctx = {
3688
4286
  deps: w.deps,
3689
4287
  queue: w.queue ?? serial(),
3690
- membershipOf: (interaction, discordId) => w.membership ? w.membership(interaction, discordId) : lookupMembership(interaction, w.guildId, discordId, w.log),
4288
+ membershipOf: (interaction, discordId) => w.membership ? w.membership(interaction, discordId) : lookupMembership(interaction, w.guildIds, discordId, w.log),
3691
4289
  log: w.log
3692
4290
  };
3693
4291
  const membershipOf = (interaction, discordId) => ctx.membershipOf(interaction, discordId);
@@ -3879,6 +4477,29 @@ function healthResponse(health) {
3879
4477
  });
3880
4478
  }
3881
4479
 
4480
+ // plugins/tracker/src/locks.ts
4481
+ class TaskLocks {
4482
+ tails = new Map;
4483
+ async run(taskId, fn) {
4484
+ const before = this.tails.get(taskId) ?? Promise.resolve();
4485
+ let release = () => {};
4486
+ const mine = new Promise((resolve) => release = resolve);
4487
+ const tail = before.then(() => mine);
4488
+ this.tails.set(taskId, tail);
4489
+ try {
4490
+ await before;
4491
+ return await fn();
4492
+ } finally {
4493
+ release();
4494
+ if (this.tails.get(taskId) === tail)
4495
+ this.tails.delete(taskId);
4496
+ }
4497
+ }
4498
+ held(taskId) {
4499
+ return this.tails.has(taskId);
4500
+ }
4501
+ }
4502
+
3882
4503
  // plugins/tracker/src/notify-lane.ts
3883
4504
  var FETCH_TYPES = new Set(["price"]);
3884
4505
  function takesType(kind, type) {
@@ -3901,17 +4522,41 @@ function untilAborted(type, signal) {
3901
4522
  }
3902
4523
  };
3903
4524
  }
3904
- function laneStore(store, keep = () => true) {
4525
+ async function tasksWithWork(store, now, keep) {
4526
+ const at = now.toISOString();
4527
+ const ids = [];
4528
+ const add = (id) => void (ids.includes(id) || ids.push(id));
4529
+ for (const o of await store.listOccurrences({ status: "queued", dueBefore: at }))
4530
+ add(o.taskId);
4531
+ for (const o of await store.listOccurrences({ status: "running" }))
4532
+ add(o.taskId);
4533
+ const rows = [...await store.listDeliveries({ dueBefore: at }), ...await store.listDeliveries({ status: "claimed" })];
4534
+ for (const row of rows) {
4535
+ const o = await store.getOccurrence(row.occurrenceId);
4536
+ if (o)
4537
+ add(o.taskId);
4538
+ }
4539
+ const kept = [];
4540
+ for (const id of ids) {
4541
+ if (keep(await store.getTask(id)))
4542
+ kept.push(id);
4543
+ }
4544
+ return kept;
4545
+ }
4546
+ function taskStore(store, taskId) {
3905
4547
  const view = Object.create(store);
3906
- view.listOccurrences = async (filter = {}) => {
3907
- const found = await store.listOccurrences(filter);
3908
- if (filter.status !== "queued" || filter.dueBefore === undefined)
3909
- return found;
4548
+ view.listOccurrences = (filter = {}) => store.listOccurrences(filter.taskId === undefined ? { ...filter, taskId } : filter);
4549
+ view.listDeliveries = async (filter = {}) => {
4550
+ const rows = await store.listDeliveries(filter);
4551
+ if (filter.occurrenceId !== undefined)
4552
+ return rows;
4553
+ const ours = new Map;
3910
4554
  const kept = [];
3911
- for (const o of found) {
3912
- const task = await store.getTask(o.taskId);
3913
- if (task?.status === "active" && keep(task))
3914
- kept.push(o);
4555
+ for (const row of rows) {
4556
+ if (!ours.has(row.occurrenceId))
4557
+ ours.set(row.occurrenceId, (await store.getOccurrence(row.occurrenceId))?.taskId === taskId);
4558
+ if (ours.get(row.occurrenceId))
4559
+ kept.push(row);
3915
4560
  }
3916
4561
  return kept;
3917
4562
  };
@@ -3923,22 +4568,31 @@ async function runNotifyTick(d, signal) {
3923
4568
  if (signal?.aborted)
3924
4569
  return { kind: "aborted" };
3925
4570
  const kind = d.kind ?? "notify";
3926
- const store = laneStore(d.store, (task) => takesType(kind, task.type));
3927
- const notifier = createDmNotifier({
3928
- store,
3929
- claims: d.claims,
3930
- dm: d.dm,
3931
- clock: d.clock,
3932
- log: d.log,
3933
- ...signal ? { signal } : {},
3934
- ...d.health ? { health: d.health } : {}
3935
- });
4571
+ const notifier = createDmNotifier({ store: d.store, dm: d.dm, clock: d.clock, log: d.log, ...d.health ? { health: d.health } : {} });
3936
4572
  let types = d.types;
3937
4573
  if (kind === "poll" && signal) {
3938
4574
  types = Object.fromEntries(Object.entries(d.types).map(([id, t]) => [id, FETCH_TYPES.has(id) ? untilAborted(t, signal) : t]));
3939
4575
  }
3940
- const lanes = new Lanes({ store, clock: d.clock, types, notifier, ...kind === "poll" && d.fetch ? { fetch: d.fetch } : {} });
3941
- const result = await lanes.tickNotify();
4576
+ const locks = d.locks;
4577
+ const result = { ran: 0, failed: 0, skipped: 0 };
4578
+ for (const taskId of await tasksWithWork(d.store, d.clock.now(), (task) => task ? takesType(kind, task.type) : kind === "notify")) {
4579
+ if (signal?.aborted)
4580
+ break;
4581
+ const pass = async () => {
4582
+ const lanes = new Lanes({
4583
+ store: taskStore(d.store, taskId),
4584
+ clock: d.clock,
4585
+ types,
4586
+ notifier,
4587
+ ...kind === "poll" && d.fetch ? { fetch: d.fetch } : {}
4588
+ });
4589
+ const r = await lanes.tickNotify(signal);
4590
+ result.ran += r.ran;
4591
+ result.failed += r.failed;
4592
+ result.skipped += r.skipped;
4593
+ };
4594
+ await (locks ? locks.run(taskId, pass) : pass());
4595
+ }
3942
4596
  return signal?.aborted ? { kind: "aborted" } : { kind: "ran", result };
3943
4597
  }
3944
4598
 
@@ -4128,6 +4782,68 @@ var MIGRATIONS = [
4128
4782
  member_checked_at TEXT
4129
4783
  );
4130
4784
  CREATE INDEX api_tokens_user ON api_tokens (user_id);
4785
+ `,
4786
+ `
4787
+ ALTER TABLE occurrences ADD COLUMN record TEXT;
4788
+
4789
+ ALTER TABLE series ADD COLUMN key TEXT;
4790
+ CREATE UNIQUE INDEX series_key ON series (key) WHERE key IS NOT NULL;
4791
+
4792
+ CREATE TABLE deliveries (
4793
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
4794
+ occurrence_id TEXT NOT NULL,
4795
+ user_id TEXT NOT NULL,
4796
+ status TEXT NOT NULL,
4797
+ message_id TEXT,
4798
+ error TEXT,
4799
+ attempts INTEGER NOT NULL,
4800
+ deferrals INTEGER NOT NULL,
4801
+ retry_at TEXT,
4802
+ created_at TEXT NOT NULL,
4803
+ claimed_at TEXT,
4804
+ settled_at TEXT,
4805
+ UNIQUE (occurrence_id, user_id)
4806
+ );
4807
+ CREATE INDEX deliveries_user ON deliveries (user_id);
4808
+ CREATE INDEX deliveries_owed ON deliveries (retry_at) WHERE retry_at IS NOT NULL;
4809
+ CREATE INDEX deliveries_status ON deliveries (status);
4810
+ INSERT INTO deliveries (occurrence_id, user_id, status, message_id, error, attempts, deferrals, retry_at, created_at, claimed_at, settled_at)
4811
+ SELECT occurrence_id, user_id,
4812
+ CASE
4813
+ WHEN status IN ('sent', 'failed') THEN status
4814
+ WHEN status = 'unconfirmed' AND reported_at IS NOT NULL THEN 'unconfirmed'
4815
+ ELSE 'claimed'
4816
+ END,
4817
+ message_id, error,
4818
+ CASE WHEN status = 'failed' THEN 1 ELSE 0 END,
4819
+ 0, NULL, claimed_at, claimed_at,
4820
+ CASE
4821
+ WHEN status IN ('sent', 'failed') OR (status = 'unconfirmed' AND reported_at IS NOT NULL)
4822
+ THEN COALESCE(settled_at, claimed_at)
4823
+ ELSE NULL
4824
+ END
4825
+ FROM delivery_claims ORDER BY claimed_at, occurrence_id, user_id;
4826
+ DROP TABLE delivery_claims;
4827
+
4828
+ CREATE TABLE usage (
4829
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
4830
+ user_id TEXT NOT NULL,
4831
+ task_id TEXT,
4832
+ occurrence_id TEXT,
4833
+ source TEXT NOT NULL,
4834
+ calls INTEGER NOT NULL,
4835
+ cost_usd REAL NOT NULL,
4836
+ at TEXT NOT NULL
4837
+ );
4838
+ CREATE INDEX usage_user ON usage (user_id, at);
4839
+
4840
+ CREATE TABLE notices (
4841
+ key TEXT PRIMARY KEY,
4842
+ at TEXT NOT NULL
4843
+ );
4844
+
4845
+ DROP INDEX users_usr_subject;
4846
+ ALTER TABLE users DROP COLUMN usr_subject;
4131
4847
  `
4132
4848
  ];
4133
4849
  var BUSY_TIMEOUT_MS = 5000;
@@ -4153,212 +4869,33 @@ function openDatabase(path) {
4153
4869
  db.exec("PRAGMA secure_delete = ON");
4154
4870
  migrate(db);
4155
4871
  return db;
4156
- } catch (err) {
4157
- db.close();
4158
- throw err;
4159
- }
4160
- }
4161
- var META_TABLE = "tracker_meta";
4162
- var VACUUMED = "vacuumed_for_secure_delete";
4163
- function vacuumOnce(db, path) {
4164
- if (path === ":memory:")
4165
- return { ran: false };
4166
- try {
4167
- db.exec(`CREATE TABLE IF NOT EXISTS ${META_TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
4168
- if (db.query(`SELECT 1 FROM ${META_TABLE} WHERE key = ?`).get(VACUUMED) !== null)
4169
- return { ran: false };
4170
- db.exec("PRAGMA busy_timeout = 0");
4171
- try {
4172
- db.exec("VACUUM");
4173
- const r = db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
4174
- if (r && Number(r.busy) !== 0)
4175
- throw new Error("the write-ahead log is busy");
4176
- } finally {
4177
- db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
4178
- }
4179
- db.query(`INSERT INTO ${META_TABLE} (key, value) VALUES (?, ?)`).run(VACUUMED, new Date().toISOString());
4180
- return { ran: true };
4181
- } catch (err) {
4182
- return { ran: false, error: err instanceof Error ? err.message : String(err) };
4183
- }
4184
- }
4185
-
4186
- // plugins/tracker/src/roster.ts
4187
- var FORGOTTEN = "forgotten";
4188
- function historyNames(kind, detail, id) {
4189
- switch (kind) {
4190
- case "recipient_invited":
4191
- case "recipient_accepted":
4192
- case "recipient_declined":
4193
- case "recipient_opted_out":
4194
- return detail === id;
4195
- case "blocked":
4196
- return detail === `${id} 24h` || detail === `${id} permanent`;
4197
- case "recipient_removed":
4198
- return detail.startsWith(`${id}: `);
4199
- case "paused":
4200
- return detail.startsWith(`delivery to {${id}} paused:`);
4201
- default:
4202
- return false;
4203
- }
4204
- }
4205
- function eventNames(type, text, id) {
4206
- return type === "delivered" && text.startsWith(`${id} `);
4207
- }
4208
- function errorForms(id) {
4209
- return new RegExp(`(recipient |user |delivery to )${id}(?![0-9A-Za-z_])`, "g");
4210
- }
4211
- function errorNames(text, id) {
4212
- return text !== null && errorForms(id).test(text);
4213
- }
4214
- function redactError(text, id) {
4215
- return text.replace(errorForms(id), `$1(${FORGOTTEN})`);
4216
- }
4217
-
4218
- class Roster {
4219
- db;
4220
- constructor(db) {
4221
- this.db = db;
4222
- }
4223
- people() {
4224
- const rows = this.db.query(`SELECT u.seq, u.discord_id, u.display_name, u.time_zone, u.preferred_hour, u.admin, u.created_at,
4225
- a.admitted_by, a.registered_at, h.paused_at, h.failures
4226
- FROM users u
4227
- LEFT JOIN admissions a ON a.user_id = 'u' || u.seq
4228
- LEFT JOIN delivery_health h ON h.user_id = 'u' || u.seq
4229
- ORDER BY u.seq`).all();
4230
- const owned = this.db.query("SELECT owner_id, status, COUNT(*) AS n FROM tasks GROUP BY owner_id, status").all();
4231
- const receiving = this.db.query(`SELECT r.user_id, COUNT(*) AS n FROM task_recipients r JOIN tasks t ON r.task_id = 't' || t.seq
4232
- WHERE r.state = 'accepted' AND t.status IN ('active', 'paused') GROUP BY r.user_id`).all();
4233
- return rows.map((r) => {
4234
- const id = `u${Number(r.seq)}`;
4235
- const counts = { active: 0, paused: 0, done: 0, archived: 0 };
4236
- for (const o of owned)
4237
- if (o.owner_id === id && o.status in counts)
4238
- counts[o.status] = Number(o.n);
4239
- const str = (v) => v === null || v === undefined ? null : String(v);
4240
- return {
4241
- id,
4242
- discordId: str(r.discord_id),
4243
- displayName: str(r.display_name),
4244
- timeZone: String(r.time_zone),
4245
- preferredHour: Number(r.preferred_hour),
4246
- admin: Number(r.admin) === 1,
4247
- createdAt: String(r.created_at),
4248
- admittedBy: str(r.admitted_by),
4249
- registeredAt: str(r.registered_at),
4250
- deliveryPausedAt: str(r.paused_at),
4251
- failures: Number(r.failures ?? 0),
4252
- owned: counts,
4253
- receiving: Number(receiving.find((x) => x.user_id === id)?.n ?? 0)
4254
- };
4255
- });
4256
- }
4257
- checkpoint() {
4258
- this.db.exec("PRAGMA busy_timeout = 0");
4259
- try {
4260
- const r = this.db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
4261
- return !r || Number(r.busy) === 0;
4262
- } finally {
4263
- this.db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
4264
- }
4265
- }
4266
- othersNamed(taskId, userId, name) {
4267
- const ids = new Set;
4268
- for (const r of this.db.query("SELECT user_id FROM task_recipients WHERE task_id = ?").all(taskId))
4269
- ids.add(r.user_id);
4270
- for (const r of this.db.query("SELECT detail FROM task_events WHERE task_id = ? AND kind LIKE 'recipient_%'").all(taskId)) {
4271
- const id = /^(u[1-9][0-9]*)(:|$)/.exec(r.detail)?.[1];
4272
- if (id)
4273
- ids.add(id);
4274
- }
4275
- ids.delete(userId);
4276
- const named = this.db.query("SELECT 1 FROM users WHERE seq = ? AND display_name = ?");
4277
- return [...ids].some((id) => named.get(Number(id.slice(1)), name) !== null);
4278
- }
4279
- admins() {
4280
- return this.db.query("SELECT COUNT(*) AS n FROM users WHERE admin = 1").get().n;
4281
- }
4282
- activeBlocks(now) {
4283
- const rows = this.db.query(`SELECT seq, owner_id, recipient_id, expires_at, created_at FROM invite_blocks
4284
- WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY seq DESC`).all(now.toISOString());
4285
- return rows.map((r) => ({
4286
- id: `b${Number(r.seq)}`,
4287
- ownerId: String(r.owner_id),
4288
- recipientId: String(r.recipient_id),
4289
- expiresAt: r.expires_at === null ? null : String(r.expires_at),
4290
- createdAt: String(r.created_at)
4291
- }));
4292
- }
4293
- erase(userId) {
4294
- const seq = /^u([1-9][0-9]*)$/.exec(userId)?.[1];
4295
- if (seq === undefined)
4296
- throw new Error(`no user ${userId}`);
4297
- return this.db.transaction(() => {
4298
- const person = this.db.query("SELECT display_name FROM users WHERE seq = ?").get(Number(seq));
4299
- if (!person)
4300
- throw new Error(`no user ${userId}`);
4301
- const rows = {};
4302
- const del = (table, where, ...params) => {
4303
- rows[table] = (rows[table] ?? 0) + this.db.query(`DELETE FROM ${table} WHERE ${where}`).run(...params).changes;
4304
- };
4305
- const THEIR_TASKS = "SELECT 't' || seq FROM tasks WHERE owner_id = ?";
4306
- const THEIR_RUNS = `SELECT 'o' || seq FROM occurrences WHERE task_id IN (${THEIR_TASKS})`;
4307
- const pausedFor = this.db.query(`SELECT task_id FROM delivery_pauses WHERE user_id = ? AND task_id NOT IN (${THEIR_TASKS})`).all(userId, userId).map((r) => r.task_id);
4308
- const taskIds = (sql) => this.db.query(sql).all(userId).map((r) => r.t);
4309
- const recipientOf = new Set([
4310
- ...pausedFor,
4311
- ...taskIds("SELECT task_id AS t FROM task_recipients WHERE user_id = ?"),
4312
- ...this.db.query("SELECT task_id AS t, kind, detail FROM task_events WHERE instr(detail, ?) > 0").all(userId).filter((r) => historyNames(r.kind, r.detail, userId)).map((r) => r.t)
4313
- ]);
4314
- del("events", `occurrence_id IN (${THEIR_RUNS})`, userId);
4315
- del("delivery_claims", `occurrence_id IN (${THEIR_RUNS}) OR user_id = ?`, userId, userId);
4316
- del("replies", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
4317
- del("task_events", `task_id IN (${THEIR_TASKS}) OR actor_id = ?`, userId, userId);
4318
- del("series", `task_id IN (${THEIR_TASKS})`, userId);
4319
- del("task_recipients", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
4320
- del("delivery_pauses", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
4321
- del("occurrences", `task_id IN (${THEIR_TASKS})`, userId);
4322
- del("tasks", "owner_id = ?", userId);
4323
- const containing = (table, columns, column) => this.db.query(`SELECT seq, ${columns} FROM ${table} WHERE instr(${column}, ?) > 0`).all(userId);
4324
- for (const r of containing("task_events", "kind, detail", "detail")) {
4325
- if (historyNames(String(r.kind), String(r.detail), userId))
4326
- del("task_events", "seq = ?", Number(r.seq));
4327
- }
4328
- for (const r of containing("events", "type, text", "text")) {
4329
- if (eventNames(String(r.type), String(r.text), userId))
4330
- del("events", "seq = ?", Number(r.seq));
4331
- else if (errorNames(String(r.text), userId))
4332
- this.db.query("UPDATE events SET text = ? WHERE seq = ?").run(redactError(String(r.text), userId), r.seq);
4333
- }
4334
- for (const r of containing("occurrences", "error", "error")) {
4335
- if (errorNames(String(r.error), userId))
4336
- this.db.query("UPDATE occurrences SET error = ? WHERE seq = ?").run(redactError(String(r.error), userId), r.seq);
4337
- }
4338
- if (person.display_name !== null) {
4339
- const legacy = `delivery to ${person.display_name} paused:`;
4340
- const rowsNaming = this.db.query("SELECT seq, task_id, detail FROM task_events WHERE kind = 'paused' AND actor_id IS NULL").all();
4341
- for (const r of rowsNaming) {
4342
- if (!recipientOf.has(r.task_id) || !r.detail.startsWith(legacy))
4343
- continue;
4344
- if (this.othersNamed(r.task_id, userId, person.display_name))
4345
- continue;
4346
- del("task_events", "seq = ?", r.seq);
4347
- }
4348
- }
4349
- del("invite_blocks", "owner_id = ? OR recipient_id = ?", userId, userId);
4350
- this.db.query("UPDATE invite_blocks SET lifted_by = ? WHERE lifted_by = ?").run(FORGOTTEN, userId);
4351
- del("admissions", "user_id = ?", userId);
4352
- this.db.query("UPDATE admissions SET admitted_by = ? WHERE admitted_by = ?").run(FORGOTTEN, userId);
4353
- del("delivery_health", "user_id = ?", userId);
4354
- del("web_sessions", "user_id = ?", userId);
4355
- del("web_login_tokens", "user_id = ?", userId);
4356
- del("api_tokens", "user_id = ?", userId);
4357
- del("users", "seq = ?", Number(seq));
4358
- const held = this.db.query("SELECT 1 FROM delivery_pauses WHERE task_id = ? LIMIT 1");
4359
- const freed = [...new Set(pausedFor)].filter((t) => held.get(t) === null);
4360
- return { rows, freed };
4361
- })();
4872
+ } catch (err) {
4873
+ db.close();
4874
+ throw err;
4875
+ }
4876
+ }
4877
+ var META_TABLE = "tracker_meta";
4878
+ var VACUUMED = "vacuumed_for_secure_delete";
4879
+ function vacuumOnce(db, path) {
4880
+ if (path === ":memory:")
4881
+ return { ran: false };
4882
+ try {
4883
+ db.exec(`CREATE TABLE IF NOT EXISTS ${META_TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
4884
+ if (db.query(`SELECT 1 FROM ${META_TABLE} WHERE key = ?`).get(VACUUMED) !== null)
4885
+ return { ran: false };
4886
+ db.exec("PRAGMA busy_timeout = 0");
4887
+ try {
4888
+ db.exec("VACUUM");
4889
+ const r = db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
4890
+ if (r && Number(r.busy) !== 0)
4891
+ throw new Error("the write-ahead log is busy");
4892
+ } finally {
4893
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
4894
+ }
4895
+ db.query(`INSERT INTO ${META_TABLE} (key, value) VALUES (?, ?)`).run(VACUUMED, new Date().toISOString());
4896
+ return { ran: true };
4897
+ } catch (err) {
4898
+ return { ran: false, error: err instanceof Error ? err.message : String(err) };
4362
4899
  }
4363
4900
  }
4364
4901
 
@@ -4387,7 +4924,6 @@ function toUser(r) {
4387
4924
  return {
4388
4925
  id: idOf("u", r.seq),
4389
4926
  discordId: str(r.discord_id),
4390
- usrSubject: str(r.usr_subject),
4391
4927
  displayName: str(r.display_name),
4392
4928
  timeZone: String(r.time_zone),
4393
4929
  preferredHour: Number(r.preferred_hour),
@@ -4440,6 +4976,7 @@ function toOccurrence(r) {
4440
4976
  summary: str(r.summary),
4441
4977
  costUsd: r.cost_usd === null || r.cost_usd === undefined ? null : Number(r.cost_usd),
4442
4978
  error: str(r.error),
4979
+ record: r.record === null || r.record === undefined ? null : parse(r.record),
4443
4980
  createdAt: String(r.created_at)
4444
4981
  };
4445
4982
  }
@@ -4475,7 +5012,37 @@ function toReply(r) {
4475
5012
  };
4476
5013
  }
4477
5014
  function toPoint(r) {
4478
- return { id: idOf("s", r.seq), taskId: String(r.task_id), at: String(r.at), value: Number(r.value), unit: str(r.unit), note: str(r.note) };
5015
+ return { id: idOf("s", r.seq), taskId: String(r.task_id), key: str(r.key), at: String(r.at), value: Number(r.value), unit: str(r.unit), note: str(r.note) };
5016
+ }
5017
+ function toUsage(r) {
5018
+ return {
5019
+ id: idOf("c", r.seq),
5020
+ userId: String(r.user_id),
5021
+ taskId: str(r.task_id),
5022
+ occurrenceId: str(r.occurrence_id),
5023
+ source: String(r.source),
5024
+ calls: Number(r.calls),
5025
+ costUsd: Number(r.cost_usd),
5026
+ at: String(r.at)
5027
+ };
5028
+ }
5029
+ function toDelivery(r) {
5030
+ return {
5031
+ occurrenceId: String(r.occurrence_id),
5032
+ userId: String(r.user_id),
5033
+ status: String(r.status),
5034
+ messageId: str(r.message_id),
5035
+ error: str(r.error),
5036
+ attempts: Number(r.attempts),
5037
+ deferrals: Number(r.deferrals),
5038
+ retryAt: str(r.retry_at),
5039
+ createdAt: String(r.created_at),
5040
+ claimedAt: str(r.claimed_at),
5041
+ settledAt: str(r.settled_at)
5042
+ };
5043
+ }
5044
+ function deleteDeliveriesOf(db, userId) {
5045
+ return db.query("DELETE FROM deliveries WHERE user_id = ?").run(userId).changes;
4479
5046
  }
4480
5047
 
4481
5048
  class SqliteStore {
@@ -4510,13 +5077,9 @@ class SqliteStore {
4510
5077
  const r = this.one("SELECT * FROM users WHERE discord_id = ? ORDER BY seq LIMIT 1", discordId);
4511
5078
  return r ? toUser(r) : null;
4512
5079
  }
4513
- async findUserBySubject(usrSubject) {
4514
- const r = this.one("SELECT * FROM users WHERE usr_subject = ? ORDER BY seq LIMIT 1", usrSubject);
4515
- return r ? toUser(r) : null;
4516
- }
4517
5080
  async createUser(u) {
4518
- const { lastInsertRowid } = this.run(`INSERT INTO users (discord_id, usr_subject, display_name, time_zone, preferred_hour, admin, created_at)
4519
- VALUES (?, ?, ?, ?, ?, ?, ?)`, u.discordId ?? null, u.usrSubject ?? null, u.displayName ?? null, u.timeZone ?? DEFAULT_TIME_ZONE2, u.preferredHour ?? DEFAULT_PREFERRED_HOUR2, u.admin ? 1 : 0, u.at);
5081
+ const { lastInsertRowid } = this.run(`INSERT INTO users (discord_id, display_name, time_zone, preferred_hour, admin, created_at)
5082
+ VALUES (?, ?, ?, ?, ?, ?)`, u.discordId ?? null, u.displayName ?? null, u.timeZone ?? DEFAULT_TIME_ZONE2, u.preferredHour ?? DEFAULT_PREFERRED_HOUR2, u.admin ? 1 : 0, u.at);
4520
5083
  return toUser(this.mustGet("users", "u", idOf("u", lastInsertRowid), "user"));
4521
5084
  }
4522
5085
  async updateUser(id, patch) {
@@ -4524,16 +5087,19 @@ class SqliteStore {
4524
5087
  const next = {
4525
5088
  ...cur,
4526
5089
  ...patch.discordId !== undefined ? { discordId: patch.discordId } : {},
4527
- ...patch.usrSubject !== undefined ? { usrSubject: patch.usrSubject } : {},
4528
5090
  ...patch.displayName !== undefined ? { displayName: patch.displayName } : {},
4529
5091
  ...patch.timeZone !== undefined ? { timeZone: patch.timeZone } : {},
4530
5092
  ...patch.preferredHour !== undefined ? { preferredHour: patch.preferredHour } : {},
4531
5093
  ...patch.admin !== undefined ? { admin: patch.admin } : {}
4532
5094
  };
4533
- this.run(`UPDATE users SET discord_id = ?, usr_subject = ?, display_name = ?, time_zone = ?, preferred_hour = ?, admin = ?
4534
- WHERE seq = ?`, next.discordId, next.usrSubject, next.displayName, next.timeZone, next.preferredHour, next.admin ? 1 : 0, seqOf("u", id));
5095
+ this.run(`UPDATE users SET discord_id = ?, display_name = ?, time_zone = ?, preferred_hour = ?, admin = ?
5096
+ WHERE seq = ?`, next.discordId, next.displayName, next.timeZone, next.preferredHour, next.admin ? 1 : 0, seqOf("u", id));
4535
5097
  return toUser(this.mustGet("users", "u", id, "user"));
4536
5098
  }
5099
+ async listUsers(filter = {}) {
5100
+ const rows = filter.admin === undefined ? this.all("SELECT * FROM users ORDER BY seq") : this.all("SELECT * FROM users WHERE admin = ? ORDER BY seq", filter.admin ? 1 : 0);
5101
+ return rows.map(toUser);
5102
+ }
4537
5103
  async createTask(t) {
4538
5104
  const { lastInsertRowid } = this.run(`INSERT INTO tasks (owner_id, type, title, config, state, schedule, lane, capabilities, status, created_at, updated_at)
4539
5105
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)`, t.ownerId, t.type, t.title, json(t.config), json(t.state), json(t.schedule), t.lane, json(t.capabilities), t.at, t.at);
@@ -4625,28 +5191,56 @@ class SqliteStore {
4625
5191
  return this.all(`SELECT * FROM occurrences ${clause} ORDER BY due_at, created_at, seq`, ...params).map(toOccurrence);
4626
5192
  }
4627
5193
  async updateOccurrence(id, patch) {
4628
- const cur = toOccurrence(this.mustGet("occurrences", "o", id, "occurrence"));
4629
- const next = {
4630
- ...cur,
4631
- ...patch.status !== undefined ? { status: patch.status } : {},
4632
- ...patch.startedAt !== undefined ? { startedAt: patch.startedAt } : {},
4633
- ...patch.finishedAt !== undefined ? { finishedAt: patch.finishedAt } : {},
4634
- ...patch.late !== undefined ? { late: patch.late } : {},
4635
- ...patch.summary !== undefined ? { summary: patch.summary } : {},
4636
- ...patch.costUsd !== undefined ? { costUsd: patch.costUsd } : {},
4637
- ...patch.error !== undefined ? { error: patch.error } : {}
4638
- };
4639
- this.run(`UPDATE occurrences SET status = ?, started_at = ?, finished_at = ?, late = ?, summary = ?, cost_usd = ?, error = ?
4640
- WHERE seq = ?`, next.status, next.startedAt, next.finishedAt, next.late ? 1 : 0, next.summary, next.costUsd, next.error, seqOf("o", id));
5194
+ this.mustGet("occurrences", "o", id, "occurrence");
5195
+ this.patchOccurrence(id, patch, null);
4641
5196
  return toOccurrence(this.mustGet("occurrences", "o", id, "occurrence"));
4642
5197
  }
4643
- async deleteQueuedOccurrences(taskId) {
4644
- return this.run("DELETE FROM occurrences WHERE task_id = ? AND status = 'queued'", taskId).changes;
5198
+ async updateOccurrenceIf(id, expected, patch) {
5199
+ if (!this.patchOccurrence(id, patch, expected))
5200
+ return null;
5201
+ return toOccurrence(this.mustGet("occurrences", "o", id, "occurrence"));
5202
+ }
5203
+ patchOccurrence(id, patch, expected) {
5204
+ const seq = seqOf("o", id);
5205
+ if (seq === null)
5206
+ return false;
5207
+ const sets = [];
5208
+ const params = [];
5209
+ const set = (column, value) => (sets.push(`${column} = ?`), params.push(value));
5210
+ if (patch.status !== undefined)
5211
+ set("status", patch.status);
5212
+ if (patch.startedAt !== undefined)
5213
+ set("started_at", patch.startedAt);
5214
+ if (patch.finishedAt !== undefined)
5215
+ set("finished_at", patch.finishedAt);
5216
+ if (patch.late !== undefined)
5217
+ set("late", patch.late ? 1 : 0);
5218
+ if (patch.summary !== undefined)
5219
+ set("summary", patch.summary);
5220
+ if (patch.costUsd !== undefined)
5221
+ set("cost_usd", patch.costUsd);
5222
+ if (patch.error !== undefined)
5223
+ set("error", patch.error);
5224
+ if (patch.record !== undefined)
5225
+ set("record", patch.record === null ? null : json(patch.record));
5226
+ if (sets.length === 0)
5227
+ sets.push("seq = seq");
5228
+ params.push(seq);
5229
+ const guard = expected === null ? "" : " AND status = ?";
5230
+ if (expected !== null)
5231
+ params.push(expected);
5232
+ return this.run(`UPDATE occurrences SET ${sets.join(", ")} WHERE seq = ?${guard}`, ...params).changes > 0;
5233
+ }
5234
+ async deleteOccurrence(id) {
5235
+ const seq = seqOf("o", id);
5236
+ if (seq === null)
5237
+ return false;
5238
+ return this.run("DELETE FROM occurrences WHERE seq = ? AND status = 'queued'", seq).changes > 0;
4645
5239
  }
4646
5240
  async requeueRunning(lane) {
4647
5241
  const rows = lane === undefined ? this.all("SELECT seq FROM occurrences WHERE status = 'running' ORDER BY seq") : this.all("SELECT seq FROM occurrences WHERE status = 'running' AND lane = ? ORDER BY seq", lane);
4648
5242
  for (const r of rows)
4649
- this.run("UPDATE occurrences SET status = 'queued' WHERE seq = ?", r.seq);
5243
+ this.run("UPDATE occurrences SET status = 'queued', started_at = NULL WHERE seq = ? AND status = 'running'", r.seq);
4650
5244
  return rows.map((r) => idOf("o", r.seq));
4651
5245
  }
4652
5246
  async addEvent(e) {
@@ -4672,7 +5266,14 @@ class SqliteStore {
4672
5266
  return this.all("SELECT * FROM replies WHERE task_id = ? ORDER BY seq", taskId).map(toReply);
4673
5267
  }
4674
5268
  async addSeriesPoint(p) {
4675
- const { lastInsertRowid } = this.run("INSERT INTO series (task_id, at, value, unit, note) VALUES (?, ?, ?, ?, ?)", p.taskId, p.at, p.value, p.unit ?? null, p.note ?? null);
5269
+ const key = p.key ?? null;
5270
+ const { changes, lastInsertRowid } = this.run("INSERT INTO series (task_id, key, at, value, unit, note) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (key) WHERE key IS NOT NULL DO NOTHING", p.taskId, key, p.at, p.value, p.unit ?? null, p.note ?? null);
5271
+ if (changes === 0) {
5272
+ const known = this.one("SELECT * FROM series WHERE key = ?", key);
5273
+ if (!known)
5274
+ throw new Error(`series point ${key} was neither added nor found`);
5275
+ return toPoint(known);
5276
+ }
4676
5277
  return toPoint(this.mustGet("series", "s", idOf("s", lastInsertRowid), "series point"));
4677
5278
  }
4678
5279
  async listSeries(taskId, filter = {}) {
@@ -4680,6 +5281,246 @@ class SqliteStore {
4680
5281
  const kept = filter.limit === undefined ? points : points.slice(-filter.limit);
4681
5282
  return kept.map(toPoint);
4682
5283
  }
5284
+ async addUsage(u) {
5285
+ const { lastInsertRowid } = this.run("INSERT INTO usage (user_id, task_id, occurrence_id, source, calls, cost_usd, at) VALUES (?, ?, ?, ?, ?, ?, ?)", u.userId, u.taskId, u.occurrenceId, u.source, u.calls, u.costUsd, u.at);
5286
+ return toUsage(this.mustGet("usage", "c", idOf("c", lastInsertRowid), "usage"));
5287
+ }
5288
+ async listUsage(filter = {}) {
5289
+ const where = [];
5290
+ const params = [];
5291
+ if (filter.userId !== undefined)
5292
+ where.push("user_id = ?"), params.push(filter.userId);
5293
+ if (filter.since !== undefined)
5294
+ where.push("at >= ?"), params.push(filter.since);
5295
+ if (filter.before !== undefined)
5296
+ where.push("at < ?"), params.push(filter.before);
5297
+ const clause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
5298
+ return this.all(`SELECT * FROM usage ${clause} ORDER BY at, seq`, ...params).map(toUsage);
5299
+ }
5300
+ async claimNotice(key, at) {
5301
+ return this.run("INSERT INTO notices (key, at) VALUES (?, ?) ON CONFLICT (key) DO NOTHING", key, at).changes === 1;
5302
+ }
5303
+ delivery(occurrenceId, userId) {
5304
+ const r = this.one("SELECT * FROM deliveries WHERE occurrence_id = ? AND user_id = ?", occurrenceId, userId);
5305
+ return r ? toDelivery(r) : null;
5306
+ }
5307
+ async planDelivery(occurrenceId, userId, at) {
5308
+ const { changes } = this.run(`INSERT INTO deliveries (occurrence_id, user_id, status, attempts, deferrals, retry_at, created_at)
5309
+ VALUES (?, ?, 'pending', 0, 0, ?, ?)
5310
+ ON CONFLICT (occurrence_id, user_id) DO NOTHING`, occurrenceId, userId, at, at);
5311
+ return changes === 1 ? this.delivery(occurrenceId, userId) : null;
5312
+ }
5313
+ async claimDelivery(occurrenceId, userId, at) {
5314
+ const { changes } = this.run(`UPDATE deliveries SET status = 'claimed', claimed_at = ?, retry_at = NULL
5315
+ WHERE occurrence_id = ? AND user_id = ? AND retry_at IS NOT NULL`, at, occurrenceId, userId);
5316
+ return changes === 1 ? this.delivery(occurrenceId, userId) : null;
5317
+ }
5318
+ async settleDelivery(occurrenceId, userId, settle) {
5319
+ const { changes } = this.run(`UPDATE deliveries SET status = ?, message_id = ?, error = ?, attempts = ?, deferrals = ?, retry_at = ?, settled_at = ?
5320
+ WHERE occurrence_id = ? AND user_id = ?`, settle.status, settle.messageId ?? null, settle.error ?? null, settle.attempts, settle.deferrals, settle.retryAt, settle.at, occurrenceId, userId);
5321
+ if (changes === 0)
5322
+ throw new Error(`no delivery ${occurrenceId}:${userId}`);
5323
+ return this.delivery(occurrenceId, userId);
5324
+ }
5325
+ async listDeliveries(filter = {}) {
5326
+ const where = [];
5327
+ const params = [];
5328
+ if (filter.occurrenceId !== undefined)
5329
+ where.push("occurrence_id = ?"), params.push(filter.occurrenceId);
5330
+ if (filter.userId !== undefined)
5331
+ where.push("user_id = ?"), params.push(filter.userId);
5332
+ if (filter.status !== undefined)
5333
+ where.push("status = ?"), params.push(filter.status);
5334
+ if (filter.dueBefore !== undefined)
5335
+ where.push("retry_at IS NOT NULL AND retry_at <= ?"), params.push(filter.dueBefore);
5336
+ const clause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
5337
+ return this.all(`SELECT * FROM deliveries ${clause} ORDER BY created_at, seq`, ...params).map(toDelivery);
5338
+ }
5339
+ async deleteDeliveries(userId) {
5340
+ return deleteDeliveriesOf(this.db, userId);
5341
+ }
5342
+ }
5343
+
5344
+ // plugins/tracker/src/roster.ts
5345
+ var FORGOTTEN = "forgotten";
5346
+ function historyNames(kind, detail, id) {
5347
+ switch (kind) {
5348
+ case "recipient_invited":
5349
+ case "recipient_accepted":
5350
+ case "recipient_declined":
5351
+ case "recipient_opted_out":
5352
+ return detail === id;
5353
+ case "blocked":
5354
+ return detail === `${id} 24h` || detail === `${id} permanent`;
5355
+ case "recipient_removed":
5356
+ return detail.startsWith(`${id}: `);
5357
+ case "paused":
5358
+ return detail.startsWith(`delivery to {${id}} paused:`);
5359
+ default:
5360
+ return false;
5361
+ }
5362
+ }
5363
+ function eventNames(type, text, id) {
5364
+ return type === "delivered" && text.startsWith(`${id} `);
5365
+ }
5366
+ function errorForms(id) {
5367
+ return new RegExp(`(recipient |user |delivery to )${id}(?![0-9A-Za-z_])`, "g");
5368
+ }
5369
+ function errorNames(text, id) {
5370
+ return text !== null && errorForms(id).test(text);
5371
+ }
5372
+ function redactError(text, id) {
5373
+ return text.replace(errorForms(id), `$1(${FORGOTTEN})`);
5374
+ }
5375
+
5376
+ class Roster {
5377
+ db;
5378
+ constructor(db) {
5379
+ this.db = db;
5380
+ }
5381
+ people() {
5382
+ const rows = this.db.query(`SELECT u.seq, u.discord_id, u.display_name, u.time_zone, u.preferred_hour, u.admin, u.created_at,
5383
+ a.admitted_by, a.registered_at, h.paused_at, h.failures
5384
+ FROM users u
5385
+ LEFT JOIN admissions a ON a.user_id = 'u' || u.seq
5386
+ LEFT JOIN delivery_health h ON h.user_id = 'u' || u.seq
5387
+ ORDER BY u.seq`).all();
5388
+ const owned = this.db.query("SELECT owner_id, status, COUNT(*) AS n FROM tasks GROUP BY owner_id, status").all();
5389
+ const receiving = this.db.query(`SELECT r.user_id, COUNT(*) AS n FROM task_recipients r JOIN tasks t ON r.task_id = 't' || t.seq
5390
+ WHERE r.state = 'accepted' AND t.status IN ('active', 'paused') GROUP BY r.user_id`).all();
5391
+ return rows.map((r) => {
5392
+ const id = `u${Number(r.seq)}`;
5393
+ const counts = { active: 0, paused: 0, done: 0, archived: 0 };
5394
+ for (const o of owned)
5395
+ if (o.owner_id === id && o.status in counts)
5396
+ counts[o.status] = Number(o.n);
5397
+ const str = (v) => v === null || v === undefined ? null : String(v);
5398
+ return {
5399
+ id,
5400
+ discordId: str(r.discord_id),
5401
+ displayName: str(r.display_name),
5402
+ timeZone: String(r.time_zone),
5403
+ preferredHour: Number(r.preferred_hour),
5404
+ admin: Number(r.admin) === 1,
5405
+ createdAt: String(r.created_at),
5406
+ admittedBy: str(r.admitted_by),
5407
+ registeredAt: str(r.registered_at),
5408
+ deliveryPausedAt: str(r.paused_at),
5409
+ failures: Number(r.failures ?? 0),
5410
+ owned: counts,
5411
+ receiving: Number(receiving.find((x) => x.user_id === id)?.n ?? 0)
5412
+ };
5413
+ });
5414
+ }
5415
+ checkpoint() {
5416
+ this.db.exec("PRAGMA busy_timeout = 0");
5417
+ try {
5418
+ const r = this.db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
5419
+ return !r || Number(r.busy) === 0;
5420
+ } finally {
5421
+ this.db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
5422
+ }
5423
+ }
5424
+ othersNamed(taskId, userId, name) {
5425
+ const ids = new Set;
5426
+ for (const r of this.db.query("SELECT user_id FROM task_recipients WHERE task_id = ?").all(taskId))
5427
+ ids.add(r.user_id);
5428
+ for (const r of this.db.query("SELECT detail FROM task_events WHERE task_id = ? AND kind LIKE 'recipient_%'").all(taskId)) {
5429
+ const id = /^(u[1-9][0-9]*)(:|$)/.exec(r.detail)?.[1];
5430
+ if (id)
5431
+ ids.add(id);
5432
+ }
5433
+ ids.delete(userId);
5434
+ const named = this.db.query("SELECT 1 FROM users WHERE seq = ? AND display_name = ?");
5435
+ return [...ids].some((id) => named.get(Number(id.slice(1)), name) !== null);
5436
+ }
5437
+ admins() {
5438
+ return this.db.query("SELECT COUNT(*) AS n FROM users WHERE admin = 1").get().n;
5439
+ }
5440
+ activeBlocks(now) {
5441
+ const rows = this.db.query(`SELECT seq, owner_id, recipient_id, expires_at, created_at FROM invite_blocks
5442
+ WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY seq DESC`).all(now.toISOString());
5443
+ return rows.map((r) => ({
5444
+ id: `b${Number(r.seq)}`,
5445
+ ownerId: String(r.owner_id),
5446
+ recipientId: String(r.recipient_id),
5447
+ expiresAt: r.expires_at === null ? null : String(r.expires_at),
5448
+ createdAt: String(r.created_at)
5449
+ }));
5450
+ }
5451
+ erase(userId) {
5452
+ const seq = /^u([1-9][0-9]*)$/.exec(userId)?.[1];
5453
+ if (seq === undefined)
5454
+ throw new Error(`no user ${userId}`);
5455
+ return this.db.transaction(() => {
5456
+ const person = this.db.query("SELECT display_name FROM users WHERE seq = ?").get(Number(seq));
5457
+ if (!person)
5458
+ throw new Error(`no user ${userId}`);
5459
+ const rows = {};
5460
+ const del = (table, where, ...params) => {
5461
+ rows[table] = (rows[table] ?? 0) + this.db.query(`DELETE FROM ${table} WHERE ${where}`).run(...params).changes;
5462
+ };
5463
+ const THEIR_TASKS = "SELECT 't' || seq FROM tasks WHERE owner_id = ?";
5464
+ const THEIR_RUNS = `SELECT 'o' || seq FROM occurrences WHERE task_id IN (${THEIR_TASKS})`;
5465
+ const pausedFor = this.db.query(`SELECT task_id FROM delivery_pauses WHERE user_id = ? AND task_id NOT IN (${THEIR_TASKS})`).all(userId, userId).map((r) => r.task_id);
5466
+ const taskIds = (sql) => this.db.query(sql).all(userId).map((r) => r.t);
5467
+ const recipientOf = new Set([
5468
+ ...pausedFor,
5469
+ ...taskIds("SELECT task_id AS t FROM task_recipients WHERE user_id = ?"),
5470
+ ...this.db.query("SELECT task_id AS t, kind, detail FROM task_events WHERE instr(detail, ?) > 0").all(userId).filter((r) => historyNames(r.kind, r.detail, userId)).map((r) => r.t)
5471
+ ]);
5472
+ del("events", `occurrence_id IN (${THEIR_RUNS})`, userId);
5473
+ del("deliveries", `occurrence_id IN (${THEIR_RUNS})`, userId);
5474
+ rows.deliveries = (rows.deliveries ?? 0) + deleteDeliveriesOf(this.db, userId);
5475
+ del("usage", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
5476
+ del("replies", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
5477
+ del("task_events", `task_id IN (${THEIR_TASKS}) OR actor_id = ?`, userId, userId);
5478
+ del("series", `task_id IN (${THEIR_TASKS})`, userId);
5479
+ del("task_recipients", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
5480
+ del("delivery_pauses", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
5481
+ del("occurrences", `task_id IN (${THEIR_TASKS})`, userId);
5482
+ del("tasks", "owner_id = ?", userId);
5483
+ const containing = (table, columns, column) => this.db.query(`SELECT seq, ${columns} FROM ${table} WHERE instr(${column}, ?) > 0`).all(userId);
5484
+ for (const r of containing("task_events", "kind, detail", "detail")) {
5485
+ if (historyNames(String(r.kind), String(r.detail), userId))
5486
+ del("task_events", "seq = ?", Number(r.seq));
5487
+ }
5488
+ for (const r of containing("events", "type, text", "text")) {
5489
+ if (eventNames(String(r.type), String(r.text), userId))
5490
+ del("events", "seq = ?", Number(r.seq));
5491
+ else if (errorNames(String(r.text), userId))
5492
+ this.db.query("UPDATE events SET text = ? WHERE seq = ?").run(redactError(String(r.text), userId), r.seq);
5493
+ }
5494
+ for (const r of containing("occurrences", "error", "error")) {
5495
+ if (errorNames(String(r.error), userId))
5496
+ this.db.query("UPDATE occurrences SET error = ? WHERE seq = ?").run(redactError(String(r.error), userId), r.seq);
5497
+ }
5498
+ if (person.display_name !== null) {
5499
+ const legacy = `delivery to ${person.display_name} paused:`;
5500
+ const rowsNaming = this.db.query("SELECT seq, task_id, detail FROM task_events WHERE kind = 'paused' AND actor_id IS NULL").all();
5501
+ for (const r of rowsNaming) {
5502
+ if (!recipientOf.has(r.task_id) || !r.detail.startsWith(legacy))
5503
+ continue;
5504
+ if (this.othersNamed(r.task_id, userId, person.display_name))
5505
+ continue;
5506
+ del("task_events", "seq = ?", r.seq);
5507
+ }
5508
+ }
5509
+ del("invite_blocks", "owner_id = ? OR recipient_id = ?", userId, userId);
5510
+ this.db.query("UPDATE invite_blocks SET lifted_by = ? WHERE lifted_by = ?").run(FORGOTTEN, userId);
5511
+ del("admissions", "user_id = ?", userId);
5512
+ this.db.query("UPDATE admissions SET admitted_by = ? WHERE admitted_by = ?").run(FORGOTTEN, userId);
5513
+ del("delivery_health", "user_id = ?", userId);
5514
+ del("web_sessions", "user_id = ?", userId);
5515
+ del("web_login_tokens", "user_id = ?", userId);
5516
+ del("api_tokens", "user_id = ?", userId);
5517
+ del("notices", "key LIKE 'budget:person:' || ? || ':%'", userId);
5518
+ del("users", "seq = ?", Number(seq));
5519
+ const held = this.db.query("SELECT 1 FROM delivery_pauses WHERE task_id = ? LIMIT 1");
5520
+ const freed = [...new Set(pausedFor)].filter((t) => held.get(t) === null);
5521
+ return { rows, freed };
5522
+ })();
5523
+ }
4683
5524
  }
4684
5525
 
4685
5526
  // plugins/tracker/src/admin.ts
@@ -6831,7 +7672,7 @@ async function applyEdit(d, task, user, patch) {
6831
7672
  }
6832
7673
  async function nextRun(d, task) {
6833
7674
  const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
6834
- return queued[0] ?? null;
7675
+ return queued.find((o) => !hasFired(o)) ?? null;
6835
7676
  }
6836
7677
  function savedText(task, user, next, now, what) {
6837
7678
  const cadence = task.schedule ? describeSchedule(task.schedule, user, user.timeZone, now) : "no schedule";
@@ -6839,7 +7680,10 @@ function savedText(task, user, next, now, what) {
6839
7680
  return clip(`Saved \`${task.id}\` ${what}: ${cadence}. Next: ${when}.`);
6840
7681
  }
6841
7682
  var blank = (v) => v === undefined || v.trim() === "";
6842
- async function editReminder(d, user, taskId, input) {
7683
+ function editReminder(d, user, taskId, input) {
7684
+ return d.locks.run(taskId.trim(), () => editReminderLocked(d, user, taskId, input));
7685
+ }
7686
+ async function editReminderLocked(d, user, taskId, input) {
6843
7687
  const task = await owned(d, user, taskId, "reminder");
6844
7688
  if (typeof task === "string")
6845
7689
  return { ok: false, error: task };
@@ -6883,10 +7727,13 @@ async function askedAbout(d, task, owner, date) {
6883
7727
  const old = task.schedule;
6884
7728
  if (old?.kind !== "period")
6885
7729
  return false;
6886
- const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status !== "queued" && !o.dedupeKey.startsWith(SNOOZE_PREFIX));
7730
+ const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => (o.status !== "queued" || hasFired(o)) && !o.dedupeKey.startsWith(SNOOZE_PREFIX));
6887
7731
  return fired.some((o) => periodDate(old, new Date(o.dueAt), owner.timeZone) === date);
6888
7732
  }
6889
- async function editRenewal(d, user, taskId, input) {
7733
+ function editRenewal(d, user, taskId, input) {
7734
+ return d.locks.run(taskId.trim(), () => editRenewalLocked(d, user, taskId, input));
7735
+ }
7736
+ async function editRenewalLocked(d, user, taskId, input) {
6890
7737
  const task = await owned(d, user, taskId, "renewal");
6891
7738
  if (typeof task === "string")
6892
7739
  return { ok: false, error: task };
@@ -6935,7 +7782,10 @@ async function editRenewal(d, user, taskId, input) {
6935
7782
  const cadence = describeSchedule(schedule, user, user.timeZone, now);
6936
7783
  return { ok: true, task: updated, text: clip(`Saved \`${updated.id}\` (renewal): ${cadence}. Next ask: ${ask}.`) };
6937
7784
  }
6938
- async function editPrice(d, user, taskId, input) {
7785
+ function editPrice(d, user, taskId, input) {
7786
+ return d.locks.run(taskId.trim(), () => editPriceLocked(d, user, taskId, input));
7787
+ }
7788
+ async function editPriceLocked(d, user, taskId, input) {
6939
7789
  const task = await owned(d, user, taskId, "price");
6940
7790
  if (typeof task === "string")
6941
7791
  return { ok: false, error: task };
@@ -7248,7 +8098,7 @@ async function nextAt(d, task) {
7248
8098
  if (task.status !== "active")
7249
8099
  return null;
7250
8100
  const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
7251
- return queued[0]?.dueAt ?? null;
8101
+ return queued.find((o) => !hasFired(o))?.dueAt ?? null;
7252
8102
  }
7253
8103
  async function taskJson(d, user, task) {
7254
8104
  const type = EDITOR_TYPES.includes(task.type) ? task.type : null;
@@ -7778,7 +8628,7 @@ function createWebHandler(w) {
7778
8628
  return lookupWithin(() => shared, MEMBER_CHECK_TIMEOUT_MS);
7779
8629
  }
7780
8630
  async function recheck(d, user, checkedAt) {
7781
- if (w.guildId === null)
8631
+ if (w.guildIds === null)
7782
8632
  return "ok";
7783
8633
  const now = d.clock.now().getTime();
7784
8634
  const age = checkedAt ? now - Date.parse(checkedAt) : Number.POSITIVE_INFINITY;
@@ -7818,7 +8668,7 @@ function createWebHandler(w) {
7818
8668
  return { kind: "signed-out", note: RECHECK_FAILED };
7819
8669
  }
7820
8670
  async function memberOf(discordId) {
7821
- if (w.guildId === null)
8671
+ if (w.guildIds === null)
7822
8672
  return "not-checked";
7823
8673
  return await lookupWithin(() => w.membership(discordId), MEMBER_CHECK_TIMEOUT_MS) ?? "unknown";
7824
8674
  }
@@ -8019,12 +8869,12 @@ var TRACKER_TYPES = Object.freeze({
8019
8869
  });
8020
8870
  var NO_DM = {
8021
8871
  async sendDm() {
8022
- throw new Error("this bot has no host.dm (rackbops-discord-bot#736)");
8872
+ throw new DeliveryFailedError("this bot has no host.dm (rackbops-discord-bot#736)");
8023
8873
  }
8024
8874
  };
8025
8875
  function createPlugin(host, options = {}) {
8026
8876
  const adminIds = parseAdminIds(host.env.TRACKER_ADMIN_DISCORD_IDS);
8027
- const guildId = parseGuildId(host.env.TRACKER_GUILD_ID);
8877
+ const guildIds = parseGuildIds(host.env.TRACKER_GUILD_ID);
8028
8878
  const webOrigin = parseWebUrl(host.env.TRACKER_WEB_URL);
8029
8879
  const clock = options.clock ?? { now: () => new Date };
8030
8880
  const types = options.types ?? TRACKER_TYPES;
@@ -8032,26 +8882,26 @@ function createPlugin(host, options = {}) {
8032
8882
  const health = { activatedAt: null, lastTickAt: null, blocked: null };
8033
8883
  let db = null;
8034
8884
  let store = null;
8035
- let claims = null;
8036
8885
  let delivery = null;
8037
8886
  let deps = null;
8038
8887
  let warnedNoDm = false;
8039
8888
  const dm = host.dm?.bind(host);
8040
8889
  const queue = serial();
8890
+ const locks = new TaskLocks;
8041
8891
  const surface = createSurface({
8042
8892
  deps: () => deps,
8043
- guildId,
8893
+ guildIds,
8044
8894
  log: host.log,
8045
8895
  queue,
8046
- web: webOrigin ? { origin: webOrigin, name: host.name, gated: guildId !== null } : null,
8896
+ web: webOrigin ? { origin: webOrigin, name: host.name, gated: guildIds !== null } : null,
8047
8897
  ...options.membership ? { membership: options.membership } : {}
8048
8898
  });
8049
8899
  async function tick(kind, signal) {
8050
- if (!store || !claims)
8900
+ if (!store)
8051
8901
  return;
8052
8902
  const outcome = await runNotifyTick({
8053
8903
  store,
8054
- claims,
8904
+ locks,
8055
8905
  clock,
8056
8906
  types,
8057
8907
  dm,
@@ -8105,14 +8955,14 @@ function createPlugin(host, options = {}) {
8105
8955
  const clientMembership = async (discordId) => {
8106
8956
  if (!discordClient)
8107
8957
  return null;
8108
- return lookupMembership({ guildId: null, user: { id: discordId }, client: discordClient }, guildId, discordId, host.log);
8958
+ return lookupMembership({ guildId: null, user: { id: discordId }, client: discordClient }, guildIds, discordId, host.log);
8109
8959
  };
8110
8960
  const web = createWebHandler({
8111
8961
  name: host.name,
8112
8962
  origin: webOrigin,
8113
8963
  deps: () => deps,
8114
8964
  queue,
8115
- guildId,
8965
+ guildIds,
8116
8966
  membership: options.webMembership ?? clientMembership
8117
8967
  });
8118
8968
  const interactions = surface.interactions;
@@ -8137,7 +8987,6 @@ function createPlugin(host, options = {}) {
8137
8987
  }
8138
8988
  const opened = openDatabase(path);
8139
8989
  const openedStore = new SqliteStore(opened);
8140
- const openedClaims = new ClaimStore(opened);
8141
8990
  try {
8142
8991
  const vacuum = vacuumOnce(opened, path);
8143
8992
  if ("error" in vacuum)
@@ -8151,18 +9000,20 @@ function createPlugin(host, options = {}) {
8151
9000
  }
8152
9001
  if (granted.length > 0)
8153
9002
  host.log.info(`made ${granted.length} admin(s) from TRACKER_ADMIN_DISCORD_IDS`);
8154
- const requeued = await openedStore.requeueRunning();
9003
+ const open = await openedStore.listDeliveries({ status: "claimed" });
9004
+ const requeued = await new Lanes({ store: openedStore, clock, types, notifier: NO_DM }).recover();
8155
9005
  if (requeued.length > 0)
8156
9006
  host.log.warn(`requeued ${requeued.length} occurrence(s) left running: ${requeued.join(", ")}`);
8157
- for (const c of openedClaims.listUnsettled())
8158
- reportUnconfirmed(openedClaims, host.log, c, clock.now().toISOString());
9007
+ for (const c of open) {
9008
+ const why = c.error ? ` (${c.error})` : "";
9009
+ host.log.warn(`delivery of ${c.occurrenceId} to ${c.userId} claimed ${c.claimedAt ?? "?"} is unconfirmed${why}; it will not be resent`);
9010
+ }
8159
9011
  } catch (err) {
8160
9012
  opened.close();
8161
9013
  throw err;
8162
9014
  }
8163
9015
  db = opened;
8164
9016
  store = openedStore;
8165
- claims = openedClaims;
8166
9017
  delivery = new DeliveryHealth(opened, openedStore, {
8167
9018
  log: host.log,
8168
9019
  ...dm ? {
@@ -8181,7 +9032,8 @@ function createPlugin(host, options = {}) {
8181
9032
  fetch: pageFetch(),
8182
9033
  dm,
8183
9034
  log: host.log,
8184
- notifier: dm ? createDmNotifier({ store: openedStore, claims: openedClaims, dm, clock, log: host.log, health: delivery }) : NO_DM,
9035
+ notifier: dm ? createDmNotifier({ store: openedStore, dm, clock, log: host.log, health: delivery }) : NO_DM,
9036
+ locks,
8185
9037
  logins: new LoginLinks(opened),
8186
9038
  sessions: new Sessions(opened),
8187
9039
  apiTokens: new ApiTokens(opened),
@@ -8190,7 +9042,7 @@ function createPlugin(host, options = {}) {
8190
9042
  lanes,
8191
9043
  webEditor: webOrigin !== null
8192
9044
  };
8193
- if (guildId === null)
9045
+ if (guildIds === null)
8194
9046
  host.log.warn("TRACKER_GUILD_ID is unset: no membership gate, only the admission list");
8195
9047
  health.blocked = typeof host.dm === "function" ? null : "this bot has no host.dm (it predates rackbops-discord-bot#736)";
8196
9048
  health.activatedAt = clock.now();
@@ -8200,7 +9052,6 @@ function createPlugin(host, options = {}) {
8200
9052
  deps = null;
8201
9053
  delivery = null;
8202
9054
  store = null;
8203
- claims = null;
8204
9055
  const closing = db;
8205
9056
  db = null;
8206
9057
  closing?.close();