@frockbot/plugin-routines 0.3.45 → 0.3.47

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.
@@ -1,3 +1,4 @@
1
+ import { decodeConnectionTriggerCatalogV1 } from "@frockbot/connection-core";
1
2
  /// <reference path="../env.d.ts" />
2
3
 
3
4
  // The Routines hosted client Contribution.
@@ -88,6 +89,23 @@ export const routinesClientPlugin: ClientPlugin = (ctx) => {
88
89
  state.value.routines = view.routines;
89
90
  state.value.loaded = true;
90
91
  state.value.error = undefined;
92
+ try {
93
+ const catalog = decodeConnectionTriggerCatalogV1(
94
+ await request(
95
+ `/api/bots/${encodeURIComponent(botId)}/routines/triggers`,
96
+ ),
97
+ );
98
+ if (state.value.botId === botId) {
99
+ state.value.triggers = catalog.items;
100
+ state.value.triggerError = undefined;
101
+ }
102
+ } catch {
103
+ if (state.value.botId === botId) {
104
+ state.value.triggers = [];
105
+ state.value.triggerError =
106
+ "Events are temporarily unavailable. Try again shortly.";
107
+ }
108
+ }
91
109
  } catch (error) {
92
110
  state.value.error = message(error, "Could not load Routines");
93
111
  }
@@ -1,3 +1,4 @@
1
+ import type { ConnectionTriggerTypeV1 } from "@frockbot/connection-core";
1
2
  import type { InjectionKey, Ref } from "vue";
2
3
  import type {
3
4
  RoutineHookMintV1,
@@ -22,6 +23,8 @@ export interface RoutinesClientState {
22
23
  /** The Bot the loaded Routines belong to; nothing is shown for another. */
23
24
  botId?: string;
24
25
  routines: RoutineViewV1[];
26
+ triggers?: ConnectionTriggerTypeV1[];
27
+ triggerError?: string;
25
28
  /** Run logs by Routine, loaded on demand when a log is opened. */
26
29
  runs: Record<string, RoutineRunEntryViewV1[]>;
27
30
  /**
package/src/records.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import {
2
+ decodeConnectionTriggerV1,
3
+ type ConnectionTriggerV1,
4
+ } from "@frockbot/connection-core";
1
5
  // The durable Routine records, and their strict codecs.
2
6
  //
3
7
  // Every record is versioned, exact-field, and decoded at the seam it crosses.
@@ -46,9 +50,8 @@ export type RoutineWriterV1 =
46
50
  | { kind: "bot"; botId: string; sessionId: string; turnId: string };
47
51
 
48
52
  /** A Routine that fires on a delivered event rather than on a clock. */
49
- export interface RoutineTriggerV1 {
50
- kind: "webhook";
51
- }
53
+ export type RoutineTriggerV1 =
54
+ { kind: "webhook" } | ({ kind: "connection" } & ConnectionTriggerV1);
52
55
 
53
56
  /**
54
57
  * One Routine. `schedule` and `trigger` are exclusive: "never both `schedule`
@@ -178,10 +181,32 @@ export function decodeRoutineTriggerV1(
178
181
  label = "Routine trigger",
179
182
  ): RoutineTriggerV1 {
180
183
  const candidate = record(value, label);
184
+ if (candidate.kind === "connection") {
185
+ routineExactKeys(
186
+ candidate,
187
+ ["kind", "connectionId", "triggerType", "config"],
188
+ [],
189
+ label,
190
+ );
191
+ try {
192
+ return {
193
+ kind: "connection",
194
+ ...decodeConnectionTriggerV1({
195
+ connectionId: candidate.connectionId,
196
+ triggerType: candidate.triggerType,
197
+ config: candidate.config,
198
+ }),
199
+ };
200
+ } catch {
201
+ throw new RoutineDecodeError(
202
+ "Choose an available event from an existing connection",
203
+ );
204
+ }
205
+ }
181
206
  routineExactKeys(candidate, ["kind"], [], label);
182
207
  if (candidate.kind !== "webhook") {
183
208
  throw new RoutineDecodeError(
184
- `${label} kind must be "webhook"; no other delivery ships yet`,
209
+ `${label} kind must be "webhook" or "connection"`,
185
210
  );
186
211
  }
187
212
  return { kind: "webhook" };
package/src/scheduler.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { stageRoutineSubscriptionV1 } from "./subscriptions.js";
1
2
  // The scheduler: the half of a Routine that makes it fire.
2
3
  //
3
4
  // It owns no alarm of its own. "The Bot's Durable Object is the authority for
@@ -772,6 +773,11 @@ export class RoutineScheduler {
772
773
  enabled: false,
773
774
  updatedAt: now.toISOString(),
774
775
  } satisfies RoutineRecordV1);
776
+ await stageRoutineSubscriptionV1(transaction, fire.routineId, {
777
+ ...decoded,
778
+ enabled: false,
779
+ updatedAt: now.toISOString(),
780
+ });
775
781
  await appendRoutineRunEntryV1(transaction, {
776
782
  schemaVersion: 1,
777
783
  entryId: `${fire.entryId}-paused`,
package/src/shared.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import {
2
+ decodeConnectionTriggerStatusV1,
3
+ type ConnectionTriggerStatusV1,
4
+ } from "@frockbot/connection-core";
1
5
  // The Routines DTOs and commands: everything that crosses a runtime seam.
2
6
  //
3
7
  // "Cross-runtime communication uses narrow, versioned DTOs, and every inbound
@@ -92,6 +96,8 @@ export interface RoutineViewV1 {
92
96
  * its digest: the UI needs to say "a key exists", not to re-read one.
93
97
  */
94
98
  hookKeyVersion?: number;
99
+ eventStatus?: ConnectionTriggerStatusV1;
100
+ eventName?: string;
95
101
  }
96
102
 
97
103
  export interface RoutineListViewV1 {
@@ -454,7 +460,15 @@ export function decodeRoutineViewV1(value: unknown): RoutineViewV1 {
454
460
  "createdAt",
455
461
  "updatedAt",
456
462
  ],
457
- ["schedule", "trigger", "lastRunAt", "nextRunAt", "hookKeyVersion"],
463
+ [
464
+ "schedule",
465
+ "trigger",
466
+ "lastRunAt",
467
+ "nextRunAt",
468
+ "hookKeyVersion",
469
+ "eventStatus",
470
+ "eventName",
471
+ ],
458
472
  "Routine view",
459
473
  );
460
474
  if (candidate.schemaVersion !== 1) {
@@ -514,6 +528,26 @@ export function decodeRoutineViewV1(value: unknown): RoutineViewV1 {
514
528
  ? {}
515
529
  : { hookKeyVersion: hookKeyVersion(candidate.hookKeyVersion) }),
516
530
  };
531
+ if (candidate.eventStatus !== undefined) {
532
+ if (
533
+ ![
534
+ "starting",
535
+ "active",
536
+ "paused",
537
+ "missing",
538
+ "failed",
539
+ "unavailable",
540
+ ].includes(String(candidate.eventStatus))
541
+ )
542
+ throw new RoutineDecodeError("Routine event status is invalid");
543
+ view.eventStatus = decodeConnectionTriggerStatusV1(candidate.eventStatus);
544
+ }
545
+ if (candidate.eventName !== undefined)
546
+ view.eventName = routineText(
547
+ candidate.eventName,
548
+ 500,
549
+ "Routine event name",
550
+ );
517
551
  requireScheduleXorTriggerV1(view);
518
552
  return view;
519
553
  }
package/src/store.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import {
2
+ stageRoutineSubscriptionV1,
3
+ routineSubscriptionMatchesV1,
4
+ } from "./subscriptions.js";
1
5
  // The Routines authority: the Bot Durable Object's durable Routine records.
2
6
  //
3
7
  // "The Bot's Durable Object is the authority for everything Bot-scoped: …
@@ -141,7 +145,7 @@ export interface RoutineFiringSeamV1 {
141
145
  transaction: RoutineStorageWritesV1,
142
146
  input: {
143
147
  routineId: string;
144
- trigger: "manual" | "webhook";
148
+ trigger: "manual" | "webhook" | "integration";
145
149
  discriminator: string;
146
150
  delivery?: string;
147
151
  },
@@ -375,8 +379,9 @@ export class RoutineStore {
375
379
  */
376
380
  async deliverHook(input: {
377
381
  routineId: string;
378
- keyVersion: number;
379
- digest: string;
382
+ keyVersion?: number;
383
+ digest?: string;
384
+ subscriptionId?: string;
380
385
  deliveryId: string;
381
386
  body: string;
382
387
  contentType?: string | null;
@@ -394,37 +399,62 @@ export class RoutineStore {
394
399
  throw new RoutineHookError(404, "Routine not found");
395
400
  }
396
401
  const record = decodeRoutineRecordV1(stored);
397
- const held = await transaction.get<unknown>(
398
- routineHookKeyRecordV1(input.routineId),
399
- );
400
- if (record.trigger === undefined || held === undefined) {
401
- // No live key: the same answer a forged one gets, because saying
402
- // "revoked" would tell a caller its guess named a real Routine.
403
- throw new RoutineHookError(401, "webhook key is invalid");
404
- }
405
- const key = decodeRoutineHookKeyV1(held);
406
- if (
407
- key.keyVersion !== input.keyVersion ||
408
- !constantTimeEqualsV1(key.digest, input.digest)
409
- ) {
410
- throw new RoutineHookError(401, "webhook key is invalid");
402
+ const integration = input.subscriptionId !== undefined;
403
+ if (integration) {
404
+ if (
405
+ record.trigger?.kind !== "connection" ||
406
+ input.digest !== undefined ||
407
+ input.keyVersion !== undefined ||
408
+ !(await routineSubscriptionMatchesV1(
409
+ transaction,
410
+ input.routineId,
411
+ input.subscriptionId!,
412
+ ))
413
+ )
414
+ throw new RoutineHookError(401, "Event subscription is invalid");
415
+ } else {
416
+ const held = await transaction.get<unknown>(
417
+ routineHookKeyRecordV1(input.routineId),
418
+ );
419
+ if (record.trigger?.kind !== "webhook" || held === undefined)
420
+ throw new RoutineHookError(401, "webhook key is invalid");
421
+ const key = decodeRoutineHookKeyV1(held);
422
+ if (
423
+ key.keyVersion !== input.keyVersion ||
424
+ !input.digest ||
425
+ !constantTimeEqualsV1(key.digest, input.digest)
426
+ )
427
+ throw new RoutineHookError(401, "webhook key is invalid");
411
428
  }
412
429
  if (!record.enabled) {
413
430
  // The key is good and the Routine is real; it is simply paused. That
414
431
  // is worth telling the caller, so a delivery can be retried later.
415
432
  throw new RoutineHookError(409, "Routine is paused");
416
433
  }
417
- const receiptKey = routineDeliveryKeyV1(input.deliveryId);
434
+ const receiptKey = integration
435
+ ? `routine-event-delivery:${input.deliveryId}`
436
+ : routineDeliveryKeyV1(input.deliveryId);
418
437
  const seen = await transaction.get<RoutineDeliveryReceiptV1>(receiptKey);
419
438
  if (
420
439
  seen &&
421
- Date.parse(seen.acceptedAt) > now.getTime() - ROUTINE_DELIVERY_TTL_MS
440
+ (integration ||
441
+ Date.parse(seen.acceptedAt) > now.getTime() - ROUTINE_DELIVERY_TTL_MS)
422
442
  ) {
423
443
  return { status: "duplicate" as const, fireId: seen.fireId };
424
444
  }
445
+ if (integration) {
446
+ const count =
447
+ (await transaction.get<number>("routine-event-delivery-count")) ?? 0;
448
+ if (count >= 100_000)
449
+ throw new RoutineHookError(
450
+ 429,
451
+ "This Bot has reached its event history limit",
452
+ );
453
+ await transaction.put("routine-event-delivery-count", count + 1);
454
+ }
425
455
  const { fireId } = await firings.enqueueWithin(transaction, {
426
456
  routineId: input.routineId,
427
- trigger: "webhook",
457
+ trigger: integration ? "integration" : "webhook",
428
458
  discriminator: `hook-${input.deliveryId.slice(0, 40)}`,
429
459
  delivery: renderRoutineDeliveryV1(input.body, input.contentType),
430
460
  });
@@ -434,7 +464,7 @@ export class RoutineStore {
434
464
  fireId,
435
465
  acceptedAt: now.toISOString(),
436
466
  } satisfies RoutineDeliveryReceiptV1);
437
- await this.#trimDeliveries(transaction, now);
467
+ if (!integration) await this.#trimDeliveries(transaction, now);
438
468
  return { status: "accepted" as const, fireId };
439
469
  });
440
470
  }
@@ -477,7 +507,13 @@ export class RoutineStore {
477
507
  async execute(
478
508
  command: RoutineCommandV1,
479
509
  writer: RoutineWriterV1,
510
+ validate?: () => Promise<void>,
480
511
  ): Promise<RoutineCommandReceiptV1> {
512
+ if (
513
+ validate &&
514
+ !(await this.#storage.get(routineReceiptKeyV1(command.commandId)))
515
+ )
516
+ await validate();
481
517
  const fingerprint = routineCommandFingerprintV1(command);
482
518
  // A refused command is returned out of the transaction rather than thrown
483
519
  // through it: every refusal happens before the first write, so rolling back
@@ -507,6 +543,19 @@ export class RoutineStore {
507
543
  } catch (error) {
508
544
  return { ok: false, error };
509
545
  }
546
+ if (receipt.status !== "fired") {
547
+ const id =
548
+ receipt.status === "deleted"
549
+ ? receipt.routineId
550
+ : receipt.routine.routineId;
551
+ const next = await transaction.get<unknown>(routineKeyV1(id));
552
+ await stageRoutineSubscriptionV1(
553
+ transaction,
554
+ id,
555
+ next === undefined ? undefined : decodeRoutineRecordV1(next),
556
+ true,
557
+ );
558
+ }
510
559
  await transaction.put(receiptKey, {
511
560
  commandFingerprint: fingerprint,
512
561
  // The minted key is stripped before the receipt is stored. A key a
@@ -565,7 +614,7 @@ export class RoutineStore {
565
614
  // A webhook Routine is useless without a door key, so creating one mints
566
615
  // it in the same transaction. It is handed back once and never stored.
567
616
  const minted =
568
- record.trigger === undefined
617
+ record.trigger?.kind !== "webhook"
569
618
  ? undefined
570
619
  : await this.#mint(transaction, record.routineId, at);
571
620
  return {
@@ -607,7 +656,7 @@ export class RoutineStore {
607
656
  command.type === "routine/rotate-key" ||
608
657
  command.type === "routine/revoke-key"
609
658
  ) {
610
- if (current.trigger === undefined) {
659
+ if (current.trigger?.kind !== "webhook") {
611
660
  throw new RoutineDecodeError(
612
661
  `Routine "${command.routineId}" has no webhook trigger to key`,
613
662
  );
@@ -709,9 +758,9 @@ export class RoutineStore {
709
758
  const held = await transaction.get<unknown>(
710
759
  routineHookKeyRecordV1(record.routineId),
711
760
  );
712
- if (record.trigger !== undefined && held === undefined) {
761
+ if (record.trigger?.kind === "webhook" && held === undefined) {
713
762
  minted = await this.#mint(transaction, record.routineId, at);
714
- } else if (record.trigger === undefined && held !== undefined) {
763
+ } else if (record.trigger?.kind !== "webhook" && held !== undefined) {
715
764
  await transaction.delete(routineHookKeyRecordV1(record.routineId));
716
765
  }
717
766
  return {
@@ -722,7 +771,7 @@ export class RoutineStore {
722
771
  record,
723
772
  undefined,
724
773
  minted?.keyVersion ??
725
- (record.trigger !== undefined && held !== undefined
774
+ (record.trigger?.kind === "webhook" && held !== undefined
726
775
  ? decodeRoutineHookKeyV1(held).keyVersion
727
776
  : undefined),
728
777
  ),
@@ -0,0 +1,159 @@
1
+ import { expect, test } from "bun:test";
2
+ import type { RoutineSubscriptionIntentV1 } from "@frockbot/connection-core";
3
+ import { RoutineStore } from "./store.js";
4
+ import { createMemoryRoutineStorageV1 } from "./testing.js";
5
+ import {
6
+ flushRoutineSubscriptionsV1,
7
+ routineSubscriptionDeadlinesV1,
8
+ } from "./subscriptions.js";
9
+
10
+ const trigger = {
11
+ kind: "connection" as const,
12
+ connectionId: "gmail-work",
13
+ triggerType: "GMAIL_NEW_GMAIL_MESSAGE",
14
+ config: {},
15
+ };
16
+ test("the Routine and its subscription outbox survive reconstruction; a lost acknowledgement resends the same intent", async () => {
17
+ const storage = createMemoryRoutineStorageV1();
18
+ const make = () => new RoutineStore(storage);
19
+ await make().execute(
20
+ {
21
+ schemaVersion: 1,
22
+ type: "routine/create",
23
+ commandId: "create",
24
+ botId: "bot",
25
+ routineId: "email",
26
+ name: "Email",
27
+ prompt: "Read the new message",
28
+ trigger,
29
+ },
30
+ { kind: "user" },
31
+ );
32
+ const sent: RoutineSubscriptionIntentV1[] = [];
33
+ await flushRoutineSubscriptionsV1(
34
+ storage,
35
+ async (intent) => {
36
+ sent.push(intent);
37
+ throw new Error("ack lost");
38
+ },
39
+ true,
40
+ );
41
+ expect(await routineSubscriptionDeadlinesV1(storage)).toHaveLength(1);
42
+ await flushRoutineSubscriptionsV1(
43
+ storage,
44
+ async (intent) => {
45
+ sent.push(intent);
46
+ return { schemaVersion: 1, status: "recorded" };
47
+ },
48
+ true,
49
+ );
50
+ expect(sent[0]).toEqual(sent[1]);
51
+ expect(await routineSubscriptionDeadlinesV1(storage)).toHaveLength(0);
52
+ await make().execute(
53
+ {
54
+ schemaVersion: 1,
55
+ type: "routine/pause",
56
+ commandId: "pause",
57
+ botId: "bot",
58
+ routineId: "email",
59
+ },
60
+ { kind: "user" },
61
+ );
62
+ await flushRoutineSubscriptionsV1(
63
+ storage,
64
+ async (intent) => {
65
+ sent.push(intent);
66
+ return { schemaVersion: 1, status: "recorded" };
67
+ },
68
+ true,
69
+ );
70
+ expect(sent[2]).toMatchObject({
71
+ subscriptionId: sent[0]!.subscriptionId,
72
+ enabled: false,
73
+ revision: 2,
74
+ });
75
+ await make().execute(
76
+ {
77
+ schemaVersion: 1,
78
+ type: "routine/delete",
79
+ commandId: "delete",
80
+ botId: "bot",
81
+ routineId: "email",
82
+ },
83
+ { kind: "user" },
84
+ );
85
+ await flushRoutineSubscriptionsV1(
86
+ storage,
87
+ async (intent) => {
88
+ sent.push(intent);
89
+ return { schemaVersion: 1, status: "recorded" };
90
+ },
91
+ true,
92
+ );
93
+ expect(sent[3]).toMatchObject({
94
+ subscriptionId: sent[0]!.subscriptionId,
95
+ enabled: false,
96
+ deleted: true,
97
+ revision: 3,
98
+ });
99
+ });
100
+
101
+ test("event admission uses the hook queue, keeps replay receipts beyond a day, and fences changed subscriptions", async () => {
102
+ const storage = createMemoryRoutineStorageV1();
103
+ let now = new Date("2026-09-05T00:00:00Z"),
104
+ firings = 0;
105
+ const make = () =>
106
+ new RoutineStore(storage, {
107
+ now: () => now,
108
+ firings: {
109
+ enqueueWithin: async () => ({
110
+ fireId: `fire-${++firings}`,
111
+ queued: false,
112
+ }),
113
+ },
114
+ });
115
+ await make().execute(
116
+ {
117
+ schemaVersion: 1,
118
+ type: "routine/create",
119
+ commandId: "create",
120
+ botId: "bot",
121
+ routineId: "email",
122
+ name: "Email",
123
+ prompt: "Read the new message",
124
+ trigger,
125
+ },
126
+ { kind: "user" },
127
+ );
128
+ const subscriptionId = await storage.get<string>(
129
+ "routine-subscription-current:email",
130
+ );
131
+ const event = {
132
+ routineId: "email",
133
+ subscriptionId: subscriptionId!,
134
+ deliveryId: "a".repeat(64),
135
+ body: '{"subject":"Hello"}',
136
+ };
137
+ expect((await make().deliverHook(event)).status).toBe("accepted");
138
+ now = new Date("2026-09-09T00:00:00Z");
139
+ expect((await make().deliverHook(event)).status).toBe("duplicate");
140
+ expect(firings).toBe(1);
141
+ await make().execute(
142
+ {
143
+ schemaVersion: 1,
144
+ type: "routine/update",
145
+ commandId: "change",
146
+ botId: "bot",
147
+ routineId: "email",
148
+ trigger: { ...trigger, config: { label: "Important" } },
149
+ },
150
+ { kind: "user" },
151
+ );
152
+ expect(await storage.get("routine-subscription-current:email")).not.toBe(
153
+ subscriptionId,
154
+ );
155
+ await expect(
156
+ make().deliverHook({ ...event, deliveryId: "b".repeat(64) }),
157
+ ).rejects.toMatchObject({ status: 401 });
158
+ expect(firings).toBe(1);
159
+ });