@rackbops/plugin-tracker 0.8.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 +30 -11
  2. package/dist/plugin.js +1411 -574
  3. package/package.json +3 -3
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
  }
@@ -1879,53 +2497,6 @@ class Admissions {
1879
2497
  }
1880
2498
  }
1881
2499
 
1882
- // plugins/tracker/src/claims.ts
1883
- function toClaim(r) {
1884
- const s = (v) => v === null || v === undefined ? null : String(v);
1885
- return {
1886
- occurrenceId: String(r.occurrence_id),
1887
- userId: String(r.user_id),
1888
- discordId: String(r.discord_id),
1889
- status: String(r.status),
1890
- messageId: s(r.message_id),
1891
- channelId: s(r.channel_id),
1892
- error: s(r.error),
1893
- claimedAt: String(r.claimed_at),
1894
- settledAt: s(r.settled_at),
1895
- reportedAt: s(r.reported_at)
1896
- };
1897
- }
1898
-
1899
- class ClaimStore {
1900
- db;
1901
- constructor(db) {
1902
- this.db = db;
1903
- }
1904
- get(occurrenceId, userId) {
1905
- const r = this.db.query("SELECT * FROM delivery_claims WHERE occurrence_id = ? AND user_id = ?").get(occurrenceId, userId);
1906
- return r ? toClaim(r) : null;
1907
- }
1908
- claim(occurrenceId, userId, discordId, at) {
1909
- const { changes } = this.db.query(`INSERT INTO delivery_claims (occurrence_id, user_id, discord_id, status, claimed_at)
1910
- VALUES (?, ?, ?, 'claimed', ?)
1911
- ON CONFLICT (occurrence_id, user_id) DO UPDATE SET
1912
- discord_id = excluded.discord_id, status = 'claimed', claimed_at = excluded.claimed_at,
1913
- message_id = NULL, channel_id = NULL, error = NULL, settled_at = NULL, reported_at = NULL
1914
- WHERE delivery_claims.status = 'failed'`).run(occurrenceId, userId, discordId, at);
1915
- return changes === 1;
1916
- }
1917
- settle(occurrenceId, userId, status, at, detail = {}) {
1918
- this.db.query(`UPDATE delivery_claims SET status = ?, message_id = ?, channel_id = ?, error = ?, settled_at = ?
1919
- WHERE occurrence_id = ? AND user_id = ?`).run(status, detail.messageId ?? null, detail.channelId ?? null, detail.error ?? null, at, occurrenceId, userId);
1920
- }
1921
- markReported(occurrenceId, userId, at) {
1922
- this.db.query("UPDATE delivery_claims SET reported_at = ? WHERE occurrence_id = ? AND user_id = ?").run(at, occurrenceId, userId);
1923
- }
1924
- listUnsettled() {
1925
- return this.db.query("SELECT * FROM delivery_claims WHERE status IN ('claimed', 'unconfirmed') ORDER BY claimed_at").all().map(toClaim);
1926
- }
1927
- }
1928
-
1929
2500
  // plugins/tracker/src/delivery-health.ts
1930
2501
  var PAUSE_AFTER = 3;
1931
2502
  var PAUSE_DETAIL = `delivery paused: ${PAUSE_AFTER} DMs in a row could not be delivered (DMs closed, or the bot is blocked)`;
@@ -2518,7 +3089,7 @@ function retimed(s, day, owner) {
2518
3089
  return null;
2519
3090
  }
2520
3091
  async function heldRuns(store, task) {
2521
- 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));
2522
3093
  }
2523
3094
  async function restoreHeldRun(store, task, held, before, after, now) {
2524
3095
  const s = task.schedule;
@@ -2563,13 +3134,10 @@ async function allowPerson(d, admin, target) {
2563
3134
  return `Allowed <@${target.discordId}>: they can now use \`/register\`.`;
2564
3135
  }
2565
3136
  function registeredText(user, first) {
2566
- return [
2567
- first ? "You are registered." : "Your settings are updated.",
2568
- `Reminders reach you by DM. One without a time of day arrives at ${String(user.preferredHour).padStart(2, "0")}:00, ${user.timeZone} time.`,
2569
- `Your tasks are private to you and anyone you choose to share them with. ${ADMIN_DISCLOSURE}`,
2570
- `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.`
2571
- ].join(`
2572
- `);
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
+ });
2573
3141
  }
2574
3142
  async function registerPerson(d, user, input) {
2575
3143
  let updated;
@@ -2629,21 +3197,21 @@ async function rescheduleOwned(d, owner, zoneChanged, before) {
2629
3197
  continue;
2630
3198
  if (!zoneChanged && s.hour !== undefined)
2631
3199
  continue;
2632
- const held = task.status === "paused" ? await heldRuns(d.store, task) : [];
2633
- const updated = await rescheduleKeepingSnoozes(d, task, owner, s, owner.id);
2634
- if (held.length > 0)
2635
- 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
+ });
2636
3209
  moved++;
2637
3210
  }
2638
3211
  return moved;
2639
3212
  }
2640
3213
  async function rescheduleKeepingSnoozes(d, task, owner, schedule, actorId) {
2641
- const snoozes = (await d.store.listOccurrences({ taskId: task.id, status: "queued" })).filter((o) => o.dedupeKey.startsWith(SNOOZE_PREFIX));
2642
- const now = d.clock.now();
2643
- const { task: updated } = await reschedule(d.store, task, owner, schedule, actorId, now);
2644
- for (const o of snoozes) {
2645
- await d.store.createOccurrence({ taskId: o.taskId, lane: o.lane, dueAt: o.dueAt, dedupeKey: o.dedupeKey, at: now.toISOString() });
2646
- }
3214
+ const { task: updated } = await reschedule(d.store, task, owner, schedule, actorId, d.clock.now());
2647
3215
  return updated;
2648
3216
  }
2649
3217
  async function liveTaskCap(d, owner) {
@@ -2687,14 +3255,24 @@ async function ownTask(d, user, taskId) {
2687
3255
  const task = await visibleTask(d.store, { userId: user.id, admin: false }, taskId.trim());
2688
3256
  return task && task.ownerId === user.id ? task : null;
2689
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
+ }
2690
3266
  async function answerLatest(d, user, input) {
2691
3267
  if (input.until !== undefined && input.until.trim().length > MAX_WHEN)
2692
3268
  return `\`until\` is longer than ${MAX_WHEN} characters.`;
2693
3269
  const task = await ownTask(d, user, input.taskId);
2694
3270
  if (!task)
2695
3271
  return NO_SUCH_TASK;
2696
- const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status === "running" || o.status === "done" || o.status === "failed");
2697
- 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);
2698
3276
  if (!latest)
2699
3277
  return "That task has not reminded you yet, so there is nothing to answer.";
2700
3278
  let payload = null;
@@ -2730,7 +3308,13 @@ function editable(task) {
2730
3308
  return task.status === "active" || task.status === "paused";
2731
3309
  }
2732
3310
  var FINISHED = "That task has finished, so it cannot be changed. Set a new one instead.";
2733
- 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) {
2734
3318
  const task = await ownLiveTask(d, user, taskId);
2735
3319
  if (!task)
2736
3320
  return { ok: false, error: NO_SUCH_TASK };
@@ -2741,7 +3325,10 @@ async function pauseTask(d, user, taskId) {
2741
3325
  await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "paused", detail: "paused by the owner", at });
2742
3326
  return { ok: true, text: `Paused \`${task.id}\`: nothing is sent until you resume it.` };
2743
3327
  }
2744
- 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) {
2745
3332
  const task = await ownLiveTask(d, user, taskId);
2746
3333
  if (!task)
2747
3334
  return { ok: false, error: NO_SUCH_TASK };
@@ -2767,12 +3354,18 @@ async function resumeTask(d, user, taskId) {
2767
3354
  const without = names.length > 0 ? `, without ${names.join(", ")}` : "";
2768
3355
  return { ok: true, text: `Resumed \`${task.id}\`${without}.` };
2769
3356
  }
2770
- 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) {
2771
3361
  const task = await ownLiveTask(d, user, taskId);
2772
3362
  if (!task)
2773
3363
  return { ok: false, error: NO_SUCH_TASK };
2774
3364
  const at = d.clock.now().toISOString();
2775
- 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
+ }
2776
3369
  await d.store.updateTask(task.id, { status: "archived", at });
2777
3370
  await d.store.addTaskEvent({ taskId: task.id, actorId: user.id, kind: "archived", detail: "deleted by the owner", at });
2778
3371
  return { ok: true, text: `Deleted \`${task.id}\` ${task.title}.` };
@@ -2907,30 +3500,31 @@ function hostButtons(message) {
2907
3500
  // plugins/tracker/src/notifier.ts
2908
3501
  var HOST_CANNOT_MESSAGE = "recipient cannot be messaged";
2909
3502
  var MAX_CONTENT = 2000;
2910
- var UNCONFIRMED_MESSAGE_ID = "unconfirmed";
2911
3503
  var HOST_SNOWFLAKE = /^[0-9]{5,25}$/;
2912
3504
  var URL_OR_WRAPPED = /<(https?:\/\/[^\s<>]+)>|(https?:\/\/[^\s<>]+)/g;
2913
3505
  function wrapBareUrls(content) {
2914
3506
  return content.replace(URL_OR_WRAPPED, (whole, wrapped, bare) => wrapped !== undefined ? whole : `<${bare}>`);
2915
3507
  }
2916
- function isPreSendError(err) {
2917
- 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)
2918
3511
  return true;
3512
+ return (err instanceof Error ? err.message : String(err)) === "Unknown User";
3513
+ }
3514
+ function isHostRefusal(err) {
2919
3515
  const message = err instanceof Error ? err.message : String(err);
2920
- 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);
2921
3517
  }
2922
-
2923
- class RecipientUnreachableError extends Error {
2924
- userId;
2925
- name = "RecipientUnreachableError";
2926
- constructor(userId) {
2927
- super(`recipient ${userId} cannot be messaged (DMs closed, or the bot is blocked)`);
2928
- this.userId = userId;
2929
- }
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;
2930
3525
  }
2931
-
2932
- class UndeliverableError extends Error {
2933
- name = "UndeliverableError";
3526
+ function isUnreachable(err) {
3527
+ return err instanceof DeliveryFailedError && err.unreachable;
2934
3528
  }
2935
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))/;
2936
3530
  function isButtonRefusal(err) {
@@ -2954,39 +3548,18 @@ function createDmNotifier(d) {
2954
3548
  async sendDm(userId, message) {
2955
3549
  const user = await d.store.getUser(userId);
2956
3550
  if (!user)
2957
- throw new UndeliverableError(`no user ${userId}`);
3551
+ throw new DeliveryFailedError(`no user ${userId}`, true);
2958
3552
  const discordId = user.discordId;
2959
3553
  if (!discordId || !HOST_SNOWFLAKE.test(discordId))
2960
- throw new UndeliverableError(`user ${userId} has no valid Discord id`);
3554
+ throw new DeliveryFailedError(`user ${userId} has no valid Discord id`, true);
2961
3555
  const hostMessage = toHostMessage(message);
2962
3556
  if (!hostMessage)
2963
- throw new UndeliverableError("the message has no text");
2964
- const occurrenceId = message.ref?.occurrenceId ?? null;
2965
- if (occurrenceId !== null) {
2966
- const existing = d.claims.get(occurrenceId, userId);
2967
- if (existing?.status === "sent")
2968
- return { messageId: existing.messageId || UNCONFIRMED_MESSAGE_ID };
2969
- if (existing && existing.status !== "failed") {
2970
- if (existing.status === "claimed") {
2971
- d.claims.settle(occurrenceId, userId, "unconfirmed", d.clock.now().toISOString(), { error: "claimed, never settled" });
2972
- }
2973
- reportUnconfirmed(d.claims, d.log, existing, d.clock.now().toISOString());
2974
- return { messageId: UNCONFIRMED_MESSAGE_ID };
2975
- }
2976
- }
3557
+ throw new DeliveryFailedError("the message has no text");
2977
3558
  if (d.health?.isPaused(userId))
2978
3559
  throw new ExecutorUnavailableError(`delivery to ${userId} is paused`);
2979
- if (d.signal?.aborted)
2980
- throw new ExecutorUnavailableError("the tick was aborted before this send");
2981
- if (occurrenceId !== null) {
2982
- const at = d.clock.now().toISOString();
2983
- if (!d.claims.claim(occurrenceId, userId, discordId, at)) {
2984
- return { messageId: UNCONFIRMED_MESSAGE_ID };
2985
- }
2986
- }
3560
+ const buttons = hostButtons(message);
3561
+ let delivery;
2987
3562
  try {
2988
- const buttons = hostButtons(message);
2989
- let delivery;
2990
3563
  try {
2991
3564
  delivery = await d.dm(discordId, buttons.length > 0 ? { ...hostMessage, buttons } : hostMessage);
2992
3565
  } catch (err) {
@@ -2995,39 +3568,28 @@ function createDmNotifier(d) {
2995
3568
  d.log.warn(`DM sent without buttons: the host refused them (${err instanceof Error ? err.message : String(err)})`);
2996
3569
  delivery = await d.dm(discordId, hostMessage);
2997
3570
  }
2998
- d.health?.recordSuccess(userId);
2999
- if (occurrenceId !== null) {
3000
- d.claims.settle(occurrenceId, userId, "sent", d.clock.now().toISOString(), {
3001
- messageId: delivery.messageId,
3002
- channelId: delivery.channelId
3003
- });
3004
- }
3005
- return { messageId: delivery.messageId };
3006
3571
  } catch (err) {
3572
+ const mapped = deliveryError(err);
3007
3573
  const reason = err instanceof Error ? err.message : String(err);
3008
- const unreachable = reason === HOST_CANNOT_MESSAGE;
3009
- const nothingSent = unreachable || isPreSendError(err);
3010
- if (occurrenceId !== null) {
3011
- d.claims.settle(occurrenceId, userId, nothingSent ? "failed" : "unconfirmed", d.clock.now().toISOString(), { error: reason });
3012
- }
3013
- if (unreachable) {
3014
- const counted = await d.health?.recordFailure(userId, reason, d.clock.now().toISOString());
3015
- if (counted?.paused)
3016
- d.log.warn(`paused delivery to ${userId}: ${counted.failures} DMs in a row could not be delivered`);
3017
- 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`);
3018
3585
  }
3019
- throw err;
3586
+ throw mapped;
3020
3587
  }
3588
+ d.health?.recordSuccess(userId);
3589
+ return { messageId: delivery.messageId };
3021
3590
  }
3022
3591
  };
3023
3592
  }
3024
- function reportUnconfirmed(claims, log, claim, at) {
3025
- if (claim.reportedAt !== null)
3026
- return;
3027
- const what = claim.status === "claimed" ? `claimed ${claim.claimedAt}, never settled` : `unconfirmed since ${claim.claimedAt} (${claim.error ?? "no detail"})`;
3028
- log.warn(`delivery of ${claim.occurrenceId} to ${claim.userId} was ${what}; it will not be resent`);
3029
- claims.markReported(claim.occurrenceId, claim.userId, at);
3030
- }
3031
3593
 
3032
3594
  // plugins/tracker/src/press.ts
3033
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.";
@@ -3073,8 +3635,9 @@ async function finishShare(d, p, err) {
3073
3635
  detail: `${p.targetId}: the invitation could not be delivered`,
3074
3636
  at: d.clock.now().toISOString()
3075
3637
  });
3076
- if (err instanceof RecipientUnreachableError) {
3077
- 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.`;
3078
3641
  }
3079
3642
  d.log.error(`invitation DM for ${p.taskId} to ${p.targetId} failed`, err);
3080
3643
  return "The invitation could not be sent; try again in a minute.";
@@ -3084,7 +3647,19 @@ var CONSENT_TEXT = {
3084
3647
  decline: "Declined. You will not get this task's results.",
3085
3648
  opt_out: "You will not get this task's messages any more."
3086
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
+ }
3087
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) {
3088
3663
  const verdict = await replyForRef(d.store, ref, user.id);
3089
3664
  if (!verdict.ok)
3090
3665
  return { ok: false, error: verdict.error };
@@ -3115,6 +3690,11 @@ async function replyRefusal(d, user, occurrenceId) {
3115
3690
  return mine?.state === "accepted" ? null : "That is not yours to reply to.";
3116
3691
  }
3117
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) {
3118
3698
  const refusal = await replyRefusal(d, user, occurrenceId);
3119
3699
  if (refusal)
3120
3700
  return refusal;
@@ -3612,18 +4192,22 @@ async function decideRenewal(d, user, input) {
3612
4192
  return `\`/task decide\` answers a renewal; \`${task.id}\` is a ${task.type}.`;
3613
4193
  if (input.amount !== undefined && (!Number.isFinite(input.amount) || input.amount < 0))
3614
4194
  return "The amount has to be zero or more.";
3615
- const fired = (await d.store.listOccurrences({ taskId: task.id })).filter((o) => o.status === "running" || o.status === "done" || o.status === "failed");
3616
- const latest = fired.at(-1);
3617
- if (!latest)
3618
- return "That renewal has no ask waiting for an answer yet.";
3619
4195
  const payload = input.amount === undefined ? input.choice : { choice: input.choice, amount: input.amount };
3620
- try {
3621
- await replyLanes(d).reply({ taskId: task.id, occurrenceId: latest.id, userId: user.id, kind: "decision", payload });
3622
- } catch (err) {
3623
- if (err instanceof ReplyRefusedError)
3624
- return err.message;
3625
- throw err;
3626
- }
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;
3627
4211
  if (input.choice === "cancel")
3628
4212
  return `Cancelled: \`${task.id}\` ${task.title} will not ask again.`;
3629
4213
  const currency = task.config.currency ?? "";
@@ -3893,6 +4477,29 @@ function healthResponse(health) {
3893
4477
  });
3894
4478
  }
3895
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
+
3896
4503
  // plugins/tracker/src/notify-lane.ts
3897
4504
  var FETCH_TYPES = new Set(["price"]);
3898
4505
  function takesType(kind, type) {
@@ -3915,17 +4522,41 @@ function untilAborted(type, signal) {
3915
4522
  }
3916
4523
  };
3917
4524
  }
3918
- 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) {
3919
4547
  const view = Object.create(store);
3920
- view.listOccurrences = async (filter = {}) => {
3921
- const found = await store.listOccurrences(filter);
3922
- if (filter.status !== "queued" || filter.dueBefore === undefined)
3923
- 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;
3924
4554
  const kept = [];
3925
- for (const o of found) {
3926
- const task = await store.getTask(o.taskId);
3927
- if (task?.status === "active" && keep(task))
3928
- 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);
3929
4560
  }
3930
4561
  return kept;
3931
4562
  };
@@ -3937,22 +4568,31 @@ async function runNotifyTick(d, signal) {
3937
4568
  if (signal?.aborted)
3938
4569
  return { kind: "aborted" };
3939
4570
  const kind = d.kind ?? "notify";
3940
- const store = laneStore(d.store, (task) => takesType(kind, task.type));
3941
- const notifier = createDmNotifier({
3942
- store,
3943
- claims: d.claims,
3944
- dm: d.dm,
3945
- clock: d.clock,
3946
- log: d.log,
3947
- ...signal ? { signal } : {},
3948
- ...d.health ? { health: d.health } : {}
3949
- });
4571
+ const notifier = createDmNotifier({ store: d.store, dm: d.dm, clock: d.clock, log: d.log, ...d.health ? { health: d.health } : {} });
3950
4572
  let types = d.types;
3951
4573
  if (kind === "poll" && signal) {
3952
4574
  types = Object.fromEntries(Object.entries(d.types).map(([id, t]) => [id, FETCH_TYPES.has(id) ? untilAborted(t, signal) : t]));
3953
4575
  }
3954
- const lanes = new Lanes({ store, clock: d.clock, types, notifier, ...kind === "poll" && d.fetch ? { fetch: d.fetch } : {} });
3955
- 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
+ }
3956
4596
  return signal?.aborted ? { kind: "aborted" } : { kind: "ran", result };
3957
4597
  }
3958
4598
 
@@ -4142,6 +4782,68 @@ var MIGRATIONS = [
4142
4782
  member_checked_at TEXT
4143
4783
  );
4144
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;
4145
4847
  `
4146
4848
  ];
4147
4849
  var BUSY_TIMEOUT_MS = 5000;
@@ -4167,212 +4869,33 @@ function openDatabase(path) {
4167
4869
  db.exec("PRAGMA secure_delete = ON");
4168
4870
  migrate(db);
4169
4871
  return db;
4170
- } catch (err) {
4171
- db.close();
4172
- throw err;
4173
- }
4174
- }
4175
- var META_TABLE = "tracker_meta";
4176
- var VACUUMED = "vacuumed_for_secure_delete";
4177
- function vacuumOnce(db, path) {
4178
- if (path === ":memory:")
4179
- return { ran: false };
4180
- try {
4181
- db.exec(`CREATE TABLE IF NOT EXISTS ${META_TABLE} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
4182
- if (db.query(`SELECT 1 FROM ${META_TABLE} WHERE key = ?`).get(VACUUMED) !== null)
4183
- return { ran: false };
4184
- db.exec("PRAGMA busy_timeout = 0");
4185
- try {
4186
- db.exec("VACUUM");
4187
- const r = db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
4188
- if (r && Number(r.busy) !== 0)
4189
- throw new Error("the write-ahead log is busy");
4190
- } finally {
4191
- db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
4192
- }
4193
- db.query(`INSERT INTO ${META_TABLE} (key, value) VALUES (?, ?)`).run(VACUUMED, new Date().toISOString());
4194
- return { ran: true };
4195
- } catch (err) {
4196
- return { ran: false, error: err instanceof Error ? err.message : String(err) };
4197
- }
4198
- }
4199
-
4200
- // plugins/tracker/src/roster.ts
4201
- var FORGOTTEN = "forgotten";
4202
- function historyNames(kind, detail, id) {
4203
- switch (kind) {
4204
- case "recipient_invited":
4205
- case "recipient_accepted":
4206
- case "recipient_declined":
4207
- case "recipient_opted_out":
4208
- return detail === id;
4209
- case "blocked":
4210
- return detail === `${id} 24h` || detail === `${id} permanent`;
4211
- case "recipient_removed":
4212
- return detail.startsWith(`${id}: `);
4213
- case "paused":
4214
- return detail.startsWith(`delivery to {${id}} paused:`);
4215
- default:
4216
- return false;
4217
- }
4218
- }
4219
- function eventNames(type, text, id) {
4220
- return type === "delivered" && text.startsWith(`${id} `);
4221
- }
4222
- function errorForms(id) {
4223
- return new RegExp(`(recipient |user |delivery to )${id}(?![0-9A-Za-z_])`, "g");
4224
- }
4225
- function errorNames(text, id) {
4226
- return text !== null && errorForms(id).test(text);
4227
- }
4228
- function redactError(text, id) {
4229
- return text.replace(errorForms(id), `$1(${FORGOTTEN})`);
4230
- }
4231
-
4232
- class Roster {
4233
- db;
4234
- constructor(db) {
4235
- this.db = db;
4236
- }
4237
- people() {
4238
- 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,
4239
- a.admitted_by, a.registered_at, h.paused_at, h.failures
4240
- FROM users u
4241
- LEFT JOIN admissions a ON a.user_id = 'u' || u.seq
4242
- LEFT JOIN delivery_health h ON h.user_id = 'u' || u.seq
4243
- ORDER BY u.seq`).all();
4244
- const owned = this.db.query("SELECT owner_id, status, COUNT(*) AS n FROM tasks GROUP BY owner_id, status").all();
4245
- 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
4246
- WHERE r.state = 'accepted' AND t.status IN ('active', 'paused') GROUP BY r.user_id`).all();
4247
- return rows.map((r) => {
4248
- const id = `u${Number(r.seq)}`;
4249
- const counts = { active: 0, paused: 0, done: 0, archived: 0 };
4250
- for (const o of owned)
4251
- if (o.owner_id === id && o.status in counts)
4252
- counts[o.status] = Number(o.n);
4253
- const str = (v) => v === null || v === undefined ? null : String(v);
4254
- return {
4255
- id,
4256
- discordId: str(r.discord_id),
4257
- displayName: str(r.display_name),
4258
- timeZone: String(r.time_zone),
4259
- preferredHour: Number(r.preferred_hour),
4260
- admin: Number(r.admin) === 1,
4261
- createdAt: String(r.created_at),
4262
- admittedBy: str(r.admitted_by),
4263
- registeredAt: str(r.registered_at),
4264
- deliveryPausedAt: str(r.paused_at),
4265
- failures: Number(r.failures ?? 0),
4266
- owned: counts,
4267
- receiving: Number(receiving.find((x) => x.user_id === id)?.n ?? 0)
4268
- };
4269
- });
4270
- }
4271
- checkpoint() {
4272
- this.db.exec("PRAGMA busy_timeout = 0");
4273
- try {
4274
- const r = this.db.query("PRAGMA wal_checkpoint(TRUNCATE)").get();
4275
- return !r || Number(r.busy) === 0;
4276
- } finally {
4277
- this.db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
4278
- }
4279
- }
4280
- othersNamed(taskId, userId, name) {
4281
- const ids = new Set;
4282
- for (const r of this.db.query("SELECT user_id FROM task_recipients WHERE task_id = ?").all(taskId))
4283
- ids.add(r.user_id);
4284
- for (const r of this.db.query("SELECT detail FROM task_events WHERE task_id = ? AND kind LIKE 'recipient_%'").all(taskId)) {
4285
- const id = /^(u[1-9][0-9]*)(:|$)/.exec(r.detail)?.[1];
4286
- if (id)
4287
- ids.add(id);
4288
- }
4289
- ids.delete(userId);
4290
- const named = this.db.query("SELECT 1 FROM users WHERE seq = ? AND display_name = ?");
4291
- return [...ids].some((id) => named.get(Number(id.slice(1)), name) !== null);
4292
- }
4293
- admins() {
4294
- return this.db.query("SELECT COUNT(*) AS n FROM users WHERE admin = 1").get().n;
4295
- }
4296
- activeBlocks(now) {
4297
- const rows = this.db.query(`SELECT seq, owner_id, recipient_id, expires_at, created_at FROM invite_blocks
4298
- WHERE lifted_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY seq DESC`).all(now.toISOString());
4299
- return rows.map((r) => ({
4300
- id: `b${Number(r.seq)}`,
4301
- ownerId: String(r.owner_id),
4302
- recipientId: String(r.recipient_id),
4303
- expiresAt: r.expires_at === null ? null : String(r.expires_at),
4304
- createdAt: String(r.created_at)
4305
- }));
4306
- }
4307
- erase(userId) {
4308
- const seq = /^u([1-9][0-9]*)$/.exec(userId)?.[1];
4309
- if (seq === undefined)
4310
- throw new Error(`no user ${userId}`);
4311
- return this.db.transaction(() => {
4312
- const person = this.db.query("SELECT display_name FROM users WHERE seq = ?").get(Number(seq));
4313
- if (!person)
4314
- throw new Error(`no user ${userId}`);
4315
- const rows = {};
4316
- const del = (table, where, ...params) => {
4317
- rows[table] = (rows[table] ?? 0) + this.db.query(`DELETE FROM ${table} WHERE ${where}`).run(...params).changes;
4318
- };
4319
- const THEIR_TASKS = "SELECT 't' || seq FROM tasks WHERE owner_id = ?";
4320
- const THEIR_RUNS = `SELECT 'o' || seq FROM occurrences WHERE task_id IN (${THEIR_TASKS})`;
4321
- 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);
4322
- const taskIds = (sql) => this.db.query(sql).all(userId).map((r) => r.t);
4323
- const recipientOf = new Set([
4324
- ...pausedFor,
4325
- ...taskIds("SELECT task_id AS t FROM task_recipients WHERE user_id = ?"),
4326
- ...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)
4327
- ]);
4328
- del("events", `occurrence_id IN (${THEIR_RUNS})`, userId);
4329
- del("delivery_claims", `occurrence_id IN (${THEIR_RUNS}) OR user_id = ?`, userId, userId);
4330
- del("replies", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
4331
- del("task_events", `task_id IN (${THEIR_TASKS}) OR actor_id = ?`, userId, userId);
4332
- del("series", `task_id IN (${THEIR_TASKS})`, userId);
4333
- del("task_recipients", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
4334
- del("delivery_pauses", `task_id IN (${THEIR_TASKS}) OR user_id = ?`, userId, userId);
4335
- del("occurrences", `task_id IN (${THEIR_TASKS})`, userId);
4336
- del("tasks", "owner_id = ?", userId);
4337
- const containing = (table, columns, column) => this.db.query(`SELECT seq, ${columns} FROM ${table} WHERE instr(${column}, ?) > 0`).all(userId);
4338
- for (const r of containing("task_events", "kind, detail", "detail")) {
4339
- if (historyNames(String(r.kind), String(r.detail), userId))
4340
- del("task_events", "seq = ?", Number(r.seq));
4341
- }
4342
- for (const r of containing("events", "type, text", "text")) {
4343
- if (eventNames(String(r.type), String(r.text), userId))
4344
- del("events", "seq = ?", Number(r.seq));
4345
- else if (errorNames(String(r.text), userId))
4346
- this.db.query("UPDATE events SET text = ? WHERE seq = ?").run(redactError(String(r.text), userId), r.seq);
4347
- }
4348
- for (const r of containing("occurrences", "error", "error")) {
4349
- if (errorNames(String(r.error), userId))
4350
- this.db.query("UPDATE occurrences SET error = ? WHERE seq = ?").run(redactError(String(r.error), userId), r.seq);
4351
- }
4352
- if (person.display_name !== null) {
4353
- const legacy = `delivery to ${person.display_name} paused:`;
4354
- const rowsNaming = this.db.query("SELECT seq, task_id, detail FROM task_events WHERE kind = 'paused' AND actor_id IS NULL").all();
4355
- for (const r of rowsNaming) {
4356
- if (!recipientOf.has(r.task_id) || !r.detail.startsWith(legacy))
4357
- continue;
4358
- if (this.othersNamed(r.task_id, userId, person.display_name))
4359
- continue;
4360
- del("task_events", "seq = ?", r.seq);
4361
- }
4362
- }
4363
- del("invite_blocks", "owner_id = ? OR recipient_id = ?", userId, userId);
4364
- this.db.query("UPDATE invite_blocks SET lifted_by = ? WHERE lifted_by = ?").run(FORGOTTEN, userId);
4365
- del("admissions", "user_id = ?", userId);
4366
- this.db.query("UPDATE admissions SET admitted_by = ? WHERE admitted_by = ?").run(FORGOTTEN, userId);
4367
- del("delivery_health", "user_id = ?", userId);
4368
- del("web_sessions", "user_id = ?", userId);
4369
- del("web_login_tokens", "user_id = ?", userId);
4370
- del("api_tokens", "user_id = ?", userId);
4371
- del("users", "seq = ?", Number(seq));
4372
- const held = this.db.query("SELECT 1 FROM delivery_pauses WHERE task_id = ? LIMIT 1");
4373
- const freed = [...new Set(pausedFor)].filter((t) => held.get(t) === null);
4374
- return { rows, freed };
4375
- })();
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) };
4376
4899
  }
4377
4900
  }
4378
4901
 
@@ -4401,7 +4924,6 @@ function toUser(r) {
4401
4924
  return {
4402
4925
  id: idOf("u", r.seq),
4403
4926
  discordId: str(r.discord_id),
4404
- usrSubject: str(r.usr_subject),
4405
4927
  displayName: str(r.display_name),
4406
4928
  timeZone: String(r.time_zone),
4407
4929
  preferredHour: Number(r.preferred_hour),
@@ -4454,6 +4976,7 @@ function toOccurrence(r) {
4454
4976
  summary: str(r.summary),
4455
4977
  costUsd: r.cost_usd === null || r.cost_usd === undefined ? null : Number(r.cost_usd),
4456
4978
  error: str(r.error),
4979
+ record: r.record === null || r.record === undefined ? null : parse(r.record),
4457
4980
  createdAt: String(r.created_at)
4458
4981
  };
4459
4982
  }
@@ -4489,7 +5012,37 @@ function toReply(r) {
4489
5012
  };
4490
5013
  }
4491
5014
  function toPoint(r) {
4492
- 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;
4493
5046
  }
4494
5047
 
4495
5048
  class SqliteStore {
@@ -4524,13 +5077,9 @@ class SqliteStore {
4524
5077
  const r = this.one("SELECT * FROM users WHERE discord_id = ? ORDER BY seq LIMIT 1", discordId);
4525
5078
  return r ? toUser(r) : null;
4526
5079
  }
4527
- async findUserBySubject(usrSubject) {
4528
- const r = this.one("SELECT * FROM users WHERE usr_subject = ? ORDER BY seq LIMIT 1", usrSubject);
4529
- return r ? toUser(r) : null;
4530
- }
4531
5080
  async createUser(u) {
4532
- const { lastInsertRowid } = this.run(`INSERT INTO users (discord_id, usr_subject, display_name, time_zone, preferred_hour, admin, created_at)
4533
- 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);
4534
5083
  return toUser(this.mustGet("users", "u", idOf("u", lastInsertRowid), "user"));
4535
5084
  }
4536
5085
  async updateUser(id, patch) {
@@ -4538,16 +5087,19 @@ class SqliteStore {
4538
5087
  const next = {
4539
5088
  ...cur,
4540
5089
  ...patch.discordId !== undefined ? { discordId: patch.discordId } : {},
4541
- ...patch.usrSubject !== undefined ? { usrSubject: patch.usrSubject } : {},
4542
5090
  ...patch.displayName !== undefined ? { displayName: patch.displayName } : {},
4543
5091
  ...patch.timeZone !== undefined ? { timeZone: patch.timeZone } : {},
4544
5092
  ...patch.preferredHour !== undefined ? { preferredHour: patch.preferredHour } : {},
4545
5093
  ...patch.admin !== undefined ? { admin: patch.admin } : {}
4546
5094
  };
4547
- this.run(`UPDATE users SET discord_id = ?, usr_subject = ?, display_name = ?, time_zone = ?, preferred_hour = ?, admin = ?
4548
- 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));
4549
5097
  return toUser(this.mustGet("users", "u", id, "user"));
4550
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
+ }
4551
5103
  async createTask(t) {
4552
5104
  const { lastInsertRowid } = this.run(`INSERT INTO tasks (owner_id, type, title, config, state, schedule, lane, capabilities, status, created_at, updated_at)
4553
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);
@@ -4639,28 +5191,56 @@ class SqliteStore {
4639
5191
  return this.all(`SELECT * FROM occurrences ${clause} ORDER BY due_at, created_at, seq`, ...params).map(toOccurrence);
4640
5192
  }
4641
5193
  async updateOccurrence(id, patch) {
4642
- const cur = toOccurrence(this.mustGet("occurrences", "o", id, "occurrence"));
4643
- const next = {
4644
- ...cur,
4645
- ...patch.status !== undefined ? { status: patch.status } : {},
4646
- ...patch.startedAt !== undefined ? { startedAt: patch.startedAt } : {},
4647
- ...patch.finishedAt !== undefined ? { finishedAt: patch.finishedAt } : {},
4648
- ...patch.late !== undefined ? { late: patch.late } : {},
4649
- ...patch.summary !== undefined ? { summary: patch.summary } : {},
4650
- ...patch.costUsd !== undefined ? { costUsd: patch.costUsd } : {},
4651
- ...patch.error !== undefined ? { error: patch.error } : {}
4652
- };
4653
- this.run(`UPDATE occurrences SET status = ?, started_at = ?, finished_at = ?, late = ?, summary = ?, cost_usd = ?, error = ?
4654
- 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);
4655
5196
  return toOccurrence(this.mustGet("occurrences", "o", id, "occurrence"));
4656
5197
  }
4657
- async deleteQueuedOccurrences(taskId) {
4658
- 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;
4659
5239
  }
4660
5240
  async requeueRunning(lane) {
4661
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);
4662
5242
  for (const r of rows)
4663
- 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);
4664
5244
  return rows.map((r) => idOf("o", r.seq));
4665
5245
  }
4666
5246
  async addEvent(e) {
@@ -4686,7 +5266,14 @@ class SqliteStore {
4686
5266
  return this.all("SELECT * FROM replies WHERE task_id = ? ORDER BY seq", taskId).map(toReply);
4687
5267
  }
4688
5268
  async addSeriesPoint(p) {
4689
- 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
+ }
4690
5277
  return toPoint(this.mustGet("series", "s", idOf("s", lastInsertRowid), "series point"));
4691
5278
  }
4692
5279
  async listSeries(taskId, filter = {}) {
@@ -4694,6 +5281,246 @@ class SqliteStore {
4694
5281
  const kept = filter.limit === undefined ? points : points.slice(-filter.limit);
4695
5282
  return kept.map(toPoint);
4696
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
+ }
4697
5524
  }
4698
5525
 
4699
5526
  // plugins/tracker/src/admin.ts
@@ -6845,7 +7672,7 @@ async function applyEdit(d, task, user, patch) {
6845
7672
  }
6846
7673
  async function nextRun(d, task) {
6847
7674
  const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
6848
- return queued[0] ?? null;
7675
+ return queued.find((o) => !hasFired(o)) ?? null;
6849
7676
  }
6850
7677
  function savedText(task, user, next, now, what) {
6851
7678
  const cadence = task.schedule ? describeSchedule(task.schedule, user, user.timeZone, now) : "no schedule";
@@ -6853,7 +7680,10 @@ function savedText(task, user, next, now, what) {
6853
7680
  return clip(`Saved \`${task.id}\` ${what}: ${cadence}. Next: ${when}.`);
6854
7681
  }
6855
7682
  var blank = (v) => v === undefined || v.trim() === "";
6856
- 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) {
6857
7687
  const task = await owned(d, user, taskId, "reminder");
6858
7688
  if (typeof task === "string")
6859
7689
  return { ok: false, error: task };
@@ -6897,10 +7727,13 @@ async function askedAbout(d, task, owner, date) {
6897
7727
  const old = task.schedule;
6898
7728
  if (old?.kind !== "period")
6899
7729
  return false;
6900
- 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));
6901
7731
  return fired.some((o) => periodDate(old, new Date(o.dueAt), owner.timeZone) === date);
6902
7732
  }
6903
- 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) {
6904
7737
  const task = await owned(d, user, taskId, "renewal");
6905
7738
  if (typeof task === "string")
6906
7739
  return { ok: false, error: task };
@@ -6949,7 +7782,10 @@ async function editRenewal(d, user, taskId, input) {
6949
7782
  const cadence = describeSchedule(schedule, user, user.timeZone, now);
6950
7783
  return { ok: true, task: updated, text: clip(`Saved \`${updated.id}\` (renewal): ${cadence}. Next ask: ${ask}.`) };
6951
7784
  }
6952
- 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) {
6953
7789
  const task = await owned(d, user, taskId, "price");
6954
7790
  if (typeof task === "string")
6955
7791
  return { ok: false, error: task };
@@ -7262,7 +8098,7 @@ async function nextAt(d, task) {
7262
8098
  if (task.status !== "active")
7263
8099
  return null;
7264
8100
  const queued = await d.store.listOccurrences({ taskId: task.id, status: "queued" });
7265
- return queued[0]?.dueAt ?? null;
8101
+ return queued.find((o) => !hasFired(o))?.dueAt ?? null;
7266
8102
  }
7267
8103
  async function taskJson(d, user, task) {
7268
8104
  const type = EDITOR_TYPES.includes(task.type) ? task.type : null;
@@ -8033,7 +8869,7 @@ var TRACKER_TYPES = Object.freeze({
8033
8869
  });
8034
8870
  var NO_DM = {
8035
8871
  async sendDm() {
8036
- 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)");
8037
8873
  }
8038
8874
  };
8039
8875
  function createPlugin(host, options = {}) {
@@ -8046,12 +8882,12 @@ function createPlugin(host, options = {}) {
8046
8882
  const health = { activatedAt: null, lastTickAt: null, blocked: null };
8047
8883
  let db = null;
8048
8884
  let store = null;
8049
- let claims = null;
8050
8885
  let delivery = null;
8051
8886
  let deps = null;
8052
8887
  let warnedNoDm = false;
8053
8888
  const dm = host.dm?.bind(host);
8054
8889
  const queue = serial();
8890
+ const locks = new TaskLocks;
8055
8891
  const surface = createSurface({
8056
8892
  deps: () => deps,
8057
8893
  guildIds,
@@ -8061,11 +8897,11 @@ function createPlugin(host, options = {}) {
8061
8897
  ...options.membership ? { membership: options.membership } : {}
8062
8898
  });
8063
8899
  async function tick(kind, signal) {
8064
- if (!store || !claims)
8900
+ if (!store)
8065
8901
  return;
8066
8902
  const outcome = await runNotifyTick({
8067
8903
  store,
8068
- claims,
8904
+ locks,
8069
8905
  clock,
8070
8906
  types,
8071
8907
  dm,
@@ -8151,7 +8987,6 @@ function createPlugin(host, options = {}) {
8151
8987
  }
8152
8988
  const opened = openDatabase(path);
8153
8989
  const openedStore = new SqliteStore(opened);
8154
- const openedClaims = new ClaimStore(opened);
8155
8990
  try {
8156
8991
  const vacuum = vacuumOnce(opened, path);
8157
8992
  if ("error" in vacuum)
@@ -8165,18 +9000,20 @@ function createPlugin(host, options = {}) {
8165
9000
  }
8166
9001
  if (granted.length > 0)
8167
9002
  host.log.info(`made ${granted.length} admin(s) from TRACKER_ADMIN_DISCORD_IDS`);
8168
- 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();
8169
9005
  if (requeued.length > 0)
8170
9006
  host.log.warn(`requeued ${requeued.length} occurrence(s) left running: ${requeued.join(", ")}`);
8171
- for (const c of openedClaims.listUnsettled())
8172
- 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
+ }
8173
9011
  } catch (err) {
8174
9012
  opened.close();
8175
9013
  throw err;
8176
9014
  }
8177
9015
  db = opened;
8178
9016
  store = openedStore;
8179
- claims = openedClaims;
8180
9017
  delivery = new DeliveryHealth(opened, openedStore, {
8181
9018
  log: host.log,
8182
9019
  ...dm ? {
@@ -8195,7 +9032,8 @@ function createPlugin(host, options = {}) {
8195
9032
  fetch: pageFetch(),
8196
9033
  dm,
8197
9034
  log: host.log,
8198
- 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,
8199
9037
  logins: new LoginLinks(opened),
8200
9038
  sessions: new Sessions(opened),
8201
9039
  apiTokens: new ApiTokens(opened),
@@ -8214,7 +9052,6 @@ function createPlugin(host, options = {}) {
8214
9052
  deps = null;
8215
9053
  delivery = null;
8216
9054
  store = null;
8217
- claims = null;
8218
9055
  const closing = db;
8219
9056
  db = null;
8220
9057
  closing?.close();