@frockbot/plugin-routines 0.3.44 → 0.3.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-routines",
3
- "version": "0.3.44",
3
+ "version": "0.3.46",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,7 +21,8 @@
21
21
  "./shared": "./src/shared.ts",
22
22
  "./storage-keys": "./src/storage-keys.ts",
23
23
  "./store": "./src/store.ts",
24
- "./testing": "./src/testing.ts"
24
+ "./testing": "./src/testing.ts",
25
+ "./subscriptions": "./src/subscriptions.ts"
25
26
  },
26
27
  "frockbot": {
27
28
  "manifest": "./frockbot.json"
@@ -32,15 +33,16 @@
32
33
  "typecheck": "vue-tsc --noEmit -p tsconfig.json"
33
34
  },
34
35
  "dependencies": {
35
- "@frockbot/client-core": "0.3.44",
36
- "@frockbot/client-ui": "0.3.44",
37
- "@frockbot/configuration-core": "0.3.44",
38
- "@frockbot/kernel-agent-loop": "0.3.44",
39
- "@frockbot/kernel-contracts": "0.3.44",
40
- "@frockbot/plugin-shell": "0.3.44",
36
+ "@frockbot/client-core": "0.3.46",
37
+ "@frockbot/client-ui": "0.3.46",
38
+ "@frockbot/configuration-core": "0.3.46",
39
+ "@frockbot/kernel-agent-loop": "0.3.46",
40
+ "@frockbot/kernel-contracts": "0.3.46",
41
+ "@frockbot/plugin-shell": "0.3.46",
41
42
  "cordis": "4.0.0-rc.8",
42
43
  "croner": "10.0.1",
43
- "vue": "3.5.41"
44
+ "vue": "3.5.41",
45
+ "@frockbot/connection-core": "0.3.46"
44
46
  },
45
47
  "devDependencies": {
46
48
  "@types/bun": "1.4.0",
package/src/agent.ts CHANGED
@@ -1,3 +1,8 @@
1
+ import {
2
+ decodeConnectionTriggerV1,
3
+ type ConnectionTriggerV1,
4
+ type ConnectionTriggerCatalogV1,
5
+ } from "@frockbot/connection-core";
1
6
  // The Routines runtime Contribution: one tool, `routine_manage`.
2
7
  //
3
8
  // GrokBot's `update_state target=routine {create,update,pause,resume,delete}`
@@ -50,6 +55,7 @@ export interface RoutinesRuntimeHostV1 {
50
55
  botId: string;
51
56
  writer?: RoutineWriterIdentityV1;
52
57
  list(): Promise<RoutineListViewV1>;
58
+ listTriggers?(): Promise<ConnectionTriggerCatalogV1>;
53
59
  execute(
54
60
  command: RoutineCommandV1,
55
61
  writer: RoutineWriterV1,
@@ -63,6 +69,7 @@ export const ROUTINE_MANAGE_ACTIONS = [
63
69
  "resume",
64
70
  "delete",
65
71
  "run_now",
72
+ "list_triggers",
66
73
  ] as const;
67
74
 
68
75
  export type RoutineManageActionV1 = (typeof ROUTINE_MANAGE_ACTIONS)[number];
@@ -92,10 +99,28 @@ const ROUTINE_MANAGE_INPUT_SCHEMA = {
92
99
  "A five-field cron expression, or @hourly, @daily, @weekly, @monthly, or @every 15m. Optionally prefixed with CRON_TZ=<zone>. A Routine has a schedule or a webhook trigger, never both.",
93
100
  },
94
101
  trigger: {
95
- type: "string",
96
- enum: ["webhook"],
102
+ oneOf: [
103
+ { type: "string", enum: ["webhook"] },
104
+ {
105
+ type: "object",
106
+ properties: {
107
+ composio: {
108
+ type: "object",
109
+ properties: {
110
+ connectionId: { type: "string" },
111
+ triggerType: { type: "string" },
112
+ config: { type: "object" },
113
+ },
114
+ required: ["connectionId", "triggerType", "config"],
115
+ additionalProperties: false,
116
+ },
117
+ },
118
+ required: ["composio"],
119
+ additionalProperties: false,
120
+ },
121
+ ],
97
122
  description:
98
- "Fire on a delivered webhook instead of on a clock. A Routine has a schedule or a trigger, never both.",
123
+ "Fire on an event from an existing connected account. Use list_triggers first to find the account, event type and configuration schema. A Routine has a schedule or a trigger, never both.",
99
124
  },
100
125
  timezone: {
101
126
  type: "string",
@@ -125,7 +150,7 @@ interface RoutineManageInputV1 {
125
150
  name?: string;
126
151
  prompt?: string;
127
152
  schedule?: string;
128
- trigger?: "webhook";
153
+ trigger?: "webhook" | { composio: ConnectionTriggerV1 };
129
154
  timezone?: string;
130
155
  userAsked?: boolean;
131
156
  }
@@ -164,8 +189,18 @@ function decodeRoutineManageInputV1(input: unknown): RoutineManageInputV1 {
164
189
  }
165
190
  return candidate;
166
191
  };
167
- if (value.trigger !== undefined && value.trigger !== "webhook") {
168
- throw new RoutineDecodeError('routine_manage trigger must be "webhook"');
192
+ let trigger: RoutineManageInputV1["trigger"];
193
+ if (value.trigger === "webhook") trigger = "webhook";
194
+ else if (value.trigger !== undefined) {
195
+ if (
196
+ !value.trigger ||
197
+ typeof value.trigger !== "object" ||
198
+ Array.isArray(value.trigger) ||
199
+ Object.keys(value.trigger).some((key) => key !== "composio") ||
200
+ !("composio" in value.trigger)
201
+ )
202
+ throw new RoutineDecodeError("Choose a service event or webhook trigger");
203
+ trigger = { composio: decodeConnectionTriggerV1(value.trigger.composio) };
169
204
  }
170
205
  if (value.userAsked !== undefined && typeof value.userAsked !== "boolean") {
171
206
  throw new RoutineDecodeError("routine_manage userAsked must be a boolean");
@@ -182,7 +217,7 @@ function decodeRoutineManageInputV1(input: unknown): RoutineManageInputV1 {
182
217
  ...(optional("schedule") === undefined
183
218
  ? {}
184
219
  : { schedule: optional("schedule")! }),
185
- ...(value.trigger === undefined ? {} : { trigger: "webhook" as const }),
220
+ ...(trigger === undefined ? {} : { trigger }),
186
221
  ...(optional("timezone") === undefined
187
222
  ? {}
188
223
  : { timezone: optional("timezone")! }),
@@ -228,7 +263,12 @@ export function routineManageCommandV1(
228
263
  ...(input.schedule === undefined ? {} : { schedule: input.schedule }),
229
264
  ...(input.trigger === undefined
230
265
  ? {}
231
- : { trigger: { kind: input.trigger } }),
266
+ : {
267
+ trigger:
268
+ input.trigger === "webhook"
269
+ ? { kind: "webhook" }
270
+ : { kind: "connection", ...input.trigger.composio },
271
+ }),
232
272
  ...(input.timezone === undefined ? {} : { timezone: input.timezone }),
233
273
  });
234
274
  }
@@ -247,7 +287,12 @@ export function routineManageCommandV1(
247
287
  ...(input.schedule === undefined ? {} : { schedule: input.schedule }),
248
288
  ...(input.trigger === undefined
249
289
  ? {}
250
- : { trigger: { kind: input.trigger } }),
290
+ : {
291
+ trigger:
292
+ input.trigger === "webhook"
293
+ ? { kind: "webhook" }
294
+ : { kind: "connection", ...input.trigger.composio },
295
+ }),
251
296
  ...(input.timezone === undefined ? {} : { timezone: input.timezone }),
252
297
  });
253
298
  }
@@ -295,7 +340,7 @@ export function createRoutineManageTool(
295
340
  // video roles. See `@frockbot/plugin-subagents` `SUBAGENT_TOOL_REACH_V1`.
296
341
  admission: { subagentRoles: ["executor"] },
297
342
  description: [
298
- "Create, edit, pause, resume, delete, or immediately run one of your own Routines.",
343
+ "Create, edit, pause, resume, delete, or immediately run one of your own Routines. Use list_triggers to list events and configuration schemas on the User’s existing connected accounts.",
299
344
  "A Routine is a standing instruction that fires on a schedule or on a delivered webhook,",
300
345
  `as its own Turn rather than inside this conversation. Names are at most ${ROUTINE_NAME_MAX_LENGTH}`,
301
346
  `characters and prompts at most ${ROUTINE_PROMPT_MAX_LENGTH}.`,
@@ -322,6 +367,13 @@ export function createRoutineManageTool(
322
367
  let command: RoutineCommandV1;
323
368
  try {
324
369
  decoded = decodeRoutineManageInputV1(input);
370
+ if (decoded.action === "list_triggers")
371
+ return {
372
+ content: JSON.stringify(
373
+ (await host.listTriggers?.()) ?? { schemaVersion: 1, items: [] },
374
+ ),
375
+ isError: false,
376
+ };
325
377
  command = routineManageCommandV1(decoded, {
326
378
  botId: host.botId,
327
379
  commandId: routineToolCommandIdV1(context.effectId),
@@ -374,7 +426,9 @@ export function createRoutineManageTool(
374
426
  const routine = receipt.routine;
375
427
  const timing = routine.schedule
376
428
  ? `schedule ${routine.schedule} (${routine.timezone})`
377
- : "webhook trigger";
429
+ : routine.trigger?.kind === "connection"
430
+ ? (routine.eventName ?? "a service event")
431
+ : "webhook trigger";
378
432
  return {
379
433
  content: [
380
434
  `Routine "${routine.name}" (${routine.routineId}) is ${
package/src/backend.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import {
2
+ decodeConnectionTriggerCatalogV1,
3
+ type ConnectionTriggerCatalogV1,
4
+ } from "@frockbot/connection-core";
1
5
  // The Routines gateway Contribution: the authenticated HTTP surface.
2
6
  //
3
7
  // Three routes, all Bot-scoped and all beside `/api/bots/:id/settings`:
@@ -73,6 +77,10 @@ export interface RoutinesGatewayHost {
73
77
  },
74
78
  ): Promise<RoutineHookDeliveryReceiptV1>;
75
79
  listRoutines(userId: string, botId: string): Promise<RoutineListViewV1>;
80
+ listTriggers?(
81
+ userId: string,
82
+ botId: string,
83
+ ): Promise<ConnectionTriggerCatalogV1>;
76
84
  executeRoutineCommand(
77
85
  userId: string,
78
86
  botId: string,
@@ -117,6 +125,7 @@ export interface RoutinesBackendRouteContribution {
117
125
  }
118
126
 
119
127
  const ROUTINES = /^\/api\/bots\/([^/]+)\/routines$/;
128
+ const ROUTINE_TRIGGERS = /^\/api\/bots\/([^/]+)\/routines\/triggers$/;
120
129
  const ROUTINE_INBOX = /^\/api\/bots\/([^/]+)\/routines\/inbox$/;
121
130
  const ROUTINE_RUNS = /^\/api\/bots\/([^/]+)\/routines\/([^/]+)\/runs$/;
122
131
  const ROUTINE_HOOK = /^\/api\/bots\/([^/]+)\/routines\/([^/]+)\/hook$/;
@@ -270,16 +279,31 @@ export function createRoutinesBackendContribution(
270
279
  packageId: "routines",
271
280
  async route(request, url, context) {
272
281
  if (!context.userId) return undefined;
282
+ const triggers = ROUTINE_TRIGGERS.exec(url.pathname);
273
283
  const list = ROUTINES.exec(url.pathname);
274
284
  const inbox = ROUTINE_INBOX.exec(url.pathname);
275
285
  const runs = ROUTINE_RUNS.exec(url.pathname);
276
286
  const run = ROUTINE_RUN.exec(url.pathname);
277
- if (!list && !inbox && !runs && !run) return undefined;
287
+ if (!triggers && !list && !inbox && !runs && !run) return undefined;
278
288
  if ([...url.searchParams.keys()].length > 0) {
279
289
  return jsonError(400, "Routine routes take no query parameters");
280
290
  }
281
291
  try {
282
- const botId = pathSegment((list ?? inbox ?? runs ?? run)![1]!);
292
+ const botId = pathSegment(
293
+ (triggers ?? list ?? inbox ?? runs ?? run)![1]!,
294
+ );
295
+ if (triggers) {
296
+ if (request.method !== "GET")
297
+ return jsonError(405, "method not allowed");
298
+ return Response.json(
299
+ decodeConnectionTriggerCatalogV1(
300
+ (await host.listTriggers?.(context.userId, botId)) ?? {
301
+ schemaVersion: 1,
302
+ items: [],
303
+ },
304
+ ),
305
+ );
306
+ }
283
307
  if (run) {
284
308
  if (request.method !== "GET") {
285
309
  return jsonError(405, "method not allowed");
@@ -18,7 +18,8 @@ import {
18
18
  } from "@frockbot/client-core";
19
19
  import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
20
20
  import { settingsLinkV1 } from "@frockbot/plugin-shell/settings-links";
21
- import { computed, inject, reactive, ref, watch } from "vue";
21
+ import { computed, inject, reactive, ref, toRaw, watch } from "vue";
22
+ import { triggerObject } from "@frockbot/connection-core";
22
23
  import type { RoutineViewV1 } from "../shared.js";
23
24
  import { routinesStateKey } from "./state.js";
24
25
 
@@ -56,11 +57,85 @@ const form = reactive({
56
57
  routineId: undefined as string | undefined,
57
58
  name: "",
58
59
  prompt: "",
59
- timing: "schedule" as "schedule" | "webhook",
60
+ timing: "schedule" as "schedule" | "webhook" | "connection",
60
61
  schedule: "",
62
+ connectionId: "",
63
+ triggerType: "",
64
+ config: {} as Record<string, unknown>,
61
65
  timezone: "UTC",
62
66
  });
63
67
 
68
+ const accounts = computed(() => [
69
+ ...new Map(
70
+ (routines.value.triggers ?? []).map((item) => [item.connectionId, item]),
71
+ ).values(),
72
+ ]);
73
+ const events = computed(() =>
74
+ (routines.value.triggers ?? []).filter(
75
+ (item) => item.connectionId === form.connectionId,
76
+ ),
77
+ );
78
+ const selectedEvent = computed(() =>
79
+ events.value.find((item) => item.triggerType === form.triggerType),
80
+ );
81
+ const eventFields = computed(() => {
82
+ const schema = selectedEvent.value?.configSchema;
83
+ if (!schema || !triggerObject(schema.properties)) return [];
84
+ return Object.entries(schema.properties).flatMap(([key, value]) =>
85
+ triggerObject(value)
86
+ ? [
87
+ {
88
+ key,
89
+ label:
90
+ typeof value.title === "string"
91
+ ? value.title
92
+ : key
93
+ .replaceAll("_", " ")
94
+ .replace(/^./, (letter) => letter.toUpperCase()),
95
+ hint:
96
+ typeof value.description === "string"
97
+ ? value.description
98
+ : undefined,
99
+ type: value.type,
100
+ options: Array.isArray(value.enum)
101
+ ? value.enum.filter(
102
+ (item): item is string => typeof item === "string",
103
+ )
104
+ : [],
105
+ required:
106
+ Array.isArray(schema.required) && schema.required.includes(key),
107
+ },
108
+ ]
109
+ : [],
110
+ );
111
+ });
112
+ function selectEvent(): void {
113
+ form.config = {};
114
+ const properties = selectedEvent.value?.configSchema.properties;
115
+ if (triggerObject(properties))
116
+ for (const [key, value] of Object.entries(properties)) {
117
+ if (triggerObject(value) && value.default !== undefined)
118
+ form.config[key] = structuredClone(toRaw(value.default));
119
+ }
120
+ }
121
+ function selectAccount(): void {
122
+ form.triggerType = events.value[0]?.triggerType ?? "";
123
+ selectEvent();
124
+ }
125
+ function eventState(routine: RoutineViewV1): string {
126
+ return (
127
+ {
128
+ active: "Listening",
129
+ starting: "Starting…",
130
+ paused: "Paused",
131
+ missing: "Listening stopped — edit and save to restart",
132
+ failed: "Could not start listening — review the event settings",
133
+ unavailable: "Connection unavailable — check Connectors",
134
+ }[routine.eventStatus ?? "unavailable"] ??
135
+ "Connection unavailable — check Connectors"
136
+ );
137
+ }
138
+
64
139
  /**
65
140
  * The reason the last save was refused, held beside the form rather than in
66
141
  * the section header. The header sits above every Routine card, so on a Bot
@@ -125,7 +200,9 @@ async function copyHook(): Promise<void> {
125
200
  function summary(routine: RoutineViewV1): string {
126
201
  return routine.schedule
127
202
  ? `${routine.schedule} · ${routine.timezone}`
128
- : "Webhook trigger";
203
+ : routine.trigger?.kind === "connection"
204
+ ? (routine.eventName ?? "Connected account event")
205
+ : "Webhook trigger";
129
206
  }
130
207
 
131
208
  /** A durable moment, read in the Routine's own zone — the one it fires on. */
@@ -134,10 +211,13 @@ function moment(routine: RoutineViewV1, iso: string): string {
134
211
  }
135
212
 
136
213
  function startCreate(): void {
214
+ if (botId.value) void routines.value.load(botId.value);
137
215
  form.routineId = undefined;
138
216
  form.name = "";
139
217
  form.prompt = "";
140
218
  form.timing = "schedule";
219
+ form.connectionId = accounts.value[0]?.connectionId ?? "";
220
+ selectAccount();
141
221
  form.schedule = "0 9 * * *";
142
222
  // The reader's own zone, not UTC: a schedule is almost always meant in the
143
223
  // day the person writing it is living in, and the Bot picks the same when it
@@ -151,7 +231,16 @@ function startEdit(routine: RoutineViewV1): void {
151
231
  form.routineId = routine.routineId;
152
232
  form.name = routine.name;
153
233
  form.prompt = routine.prompt;
154
- form.timing = routine.schedule ? "schedule" : "webhook";
234
+ form.timing = routine.schedule
235
+ ? "schedule"
236
+ : routine.trigger?.kind === "connection"
237
+ ? "connection"
238
+ : "webhook";
239
+ if (routine.trigger?.kind === "connection") {
240
+ form.connectionId = routine.trigger.connectionId;
241
+ form.triggerType = routine.trigger.triggerType;
242
+ form.config = structuredClone(toRaw(routine.trigger.config));
243
+ }
155
244
  form.schedule = routine.schedule ?? "";
156
245
  form.timezone = routine.timezone;
157
246
  saveError.value = undefined;
@@ -163,13 +252,46 @@ async function submit(): Promise<void> {
163
252
  if (!id) return;
164
253
  saveError.value = undefined;
165
254
  try {
255
+ if (form.timing === "connection" && !selectedEvent.value)
256
+ throw new Error("Choose an available account and event");
257
+ const config = Object.fromEntries(
258
+ Object.entries(form.config).filter(
259
+ ([, value]) => value !== "" && value !== undefined,
260
+ ),
261
+ );
262
+ for (const field of eventFields.value) {
263
+ if (field.required && config[field.key] === undefined)
264
+ throw new Error(`Enter ${field.label.toLowerCase()}`);
265
+ if (
266
+ config[field.key] !== undefined &&
267
+ (field.type === "array" || field.type === "object") &&
268
+ typeof config[field.key] === "string"
269
+ ) {
270
+ try {
271
+ config[field.key] = JSON.parse(String(config[field.key]));
272
+ } catch {
273
+ throw new Error(
274
+ `Check ${field.label.toLowerCase()}: enter valid JSON`,
275
+ );
276
+ }
277
+ }
278
+ }
166
279
  await routines.value.save(id, {
167
280
  ...(form.routineId ? { routineId: form.routineId } : {}),
168
281
  name: form.name.trim(),
169
282
  prompt: form.prompt.trim(),
170
283
  ...(form.timing === "schedule"
171
284
  ? { schedule: form.schedule.trim() }
172
- : { trigger: { kind: "webhook" as const } }),
285
+ : form.timing === "connection"
286
+ ? {
287
+ trigger: {
288
+ kind: "connection" as const,
289
+ connectionId: form.connectionId,
290
+ triggerType: form.triggerType,
291
+ config,
292
+ },
293
+ }
294
+ : { trigger: { kind: "webhook" as const } }),
173
295
  timezone: form.timezone.trim(),
174
296
  });
175
297
  formOpen.value = false;
@@ -185,7 +307,9 @@ async function submit(): Promise<void> {
185
307
  saveError.value =
186
308
  serverRefusalMessageV1(error) ??
187
309
  routines.value.error ??
188
- presentClientFailureV1(error, "save the Routine");
310
+ (error instanceof Error
311
+ ? error.message
312
+ : presentClientFailureV1(error, "save the Routine"));
189
313
  }
190
314
  }
191
315
 
@@ -232,8 +356,8 @@ async function toggleLog(routineId: string): Promise<void> {
232
356
  <span class="routines__intro">
233
357
  <strong>Routines</strong>
234
358
  <small>
235
- Standing instructions this Bot runs on a schedule, or when a webhook
236
- fires.
359
+ Standing instructions this Bot runs on a schedule or when something
360
+ happens.
237
361
  </small>
238
362
  </span>
239
363
  <UiButton type="button" :disabled="!botId" @click="startCreate">
@@ -291,6 +415,13 @@ async function toggleLog(routineId: string): Promise<void> {
291
415
  </UiButton>
292
416
  </div>
293
417
  </div>
418
+ <p
419
+ v-if="routine.trigger?.kind === 'connection'"
420
+ class="routines__note"
421
+ role="status"
422
+ >
423
+ {{ eventState(routine) }}
424
+ </p>
294
425
  <dl class="routine-card__facts">
295
426
  <div>
296
427
  <dt>Next run</dt>
@@ -301,7 +432,7 @@ async function toggleLog(routineId: string): Promise<void> {
301
432
  <template v-else>—</template>
302
433
  </dd>
303
434
  </div>
304
- <div v-if="routine.trigger">
435
+ <div v-if="routine.trigger?.kind === 'webhook'">
305
436
  <dt>Webhook key</dt>
306
437
  <dd>
307
438
  {{
@@ -354,7 +485,7 @@ async function toggleLog(routineId: string): Promise<void> {
354
485
  Run now
355
486
  </UiButton>
356
487
  <UiButton
357
- v-if="routine.trigger"
488
+ v-if="routine.trigger?.kind === 'webhook'"
358
489
  type="button"
359
490
  :disabled="routines.busy"
360
491
  @click="botId && routines.rotateKey(botId, routine.routineId)"
@@ -362,7 +493,7 @@ async function toggleLog(routineId: string): Promise<void> {
362
493
  {{ routine.hookKeyVersion ? "Rotate key" : "Mint key" }}
363
494
  </UiButton>
364
495
  <UiButton
365
- v-if="routine.trigger && routine.hookKeyVersion"
496
+ v-if="routine.trigger?.kind === 'webhook' && routine.hookKeyVersion"
366
497
  type="button"
367
498
  :disabled="routines.busy"
368
499
  @click="botId && routines.revokeKey(botId, routine.routineId)"
@@ -469,7 +600,103 @@ async function toggleLog(routineId: string): Promise<void> {
469
600
  <input v-model="form.timing" type="radio" value="webhook" />
470
601
  <span>A webhook</span>
471
602
  </label>
603
+ <label>
604
+ <input v-model="form.timing" type="radio" value="connection" />
605
+ <span>An event in a connected account</span>
606
+ </label>
472
607
  </fieldset>
608
+ <template v-if="form.timing === 'connection'">
609
+ <p
610
+ v-if="routines.triggerError"
611
+ class="routine-form__error"
612
+ role="alert"
613
+ >
614
+ {{ routines.triggerError }}
615
+ </p>
616
+ <p v-else-if="!accounts.length" class="routines__note">
617
+ No events are available for your connected accounts yet.
618
+ </p>
619
+ <template v-else>
620
+ <UiField label="Account">
621
+ <select v-model="form.connectionId" @change="selectAccount">
622
+ <option disabled value="">Choose an account</option>
623
+ <option
624
+ v-for="account in accounts"
625
+ :key="account.connectionId"
626
+ :value="account.connectionId"
627
+ >
628
+ {{ account.connectorName }} · {{ account.accountName }}
629
+ </option>
630
+ </select>
631
+ </UiField>
632
+ <UiField label="When" :hint="selectedEvent?.description">
633
+ <select v-model="form.triggerType" @change="selectEvent">
634
+ <option disabled value="">Choose an event</option>
635
+ <option
636
+ v-for="event in events"
637
+ :key="event.triggerType"
638
+ :value="event.triggerType"
639
+ >
640
+ {{ event.name }}
641
+ </option>
642
+ </select>
643
+ </UiField>
644
+ <UiField
645
+ v-for="field in eventFields"
646
+ :key="field.key"
647
+ :label="field.label"
648
+ :hint="field.hint"
649
+ >
650
+ <select
651
+ v-if="field.options.length"
652
+ v-model="form.config[field.key]"
653
+ :required="field.required"
654
+ >
655
+ <option v-if="!field.required" value="">Any</option>
656
+ <option
657
+ v-for="option in field.options"
658
+ :key="option"
659
+ :value="option"
660
+ >
661
+ {{ option }}
662
+ </option>
663
+ </select>
664
+ <input
665
+ v-else-if="field.type === 'boolean'"
666
+ v-model="form.config[field.key]"
667
+ type="checkbox"
668
+ />
669
+ <input
670
+ v-else-if="field.type === 'number' || field.type === 'integer'"
671
+ v-model.number="form.config[field.key]"
672
+ type="number"
673
+ :step="field.type === 'integer' ? 1 : 'any'"
674
+ :required="field.required"
675
+ />
676
+ <textarea
677
+ v-else-if="field.type === 'array' || field.type === 'object'"
678
+ :value="
679
+ typeof form.config[field.key] === 'string'
680
+ ? String(form.config[field.key])
681
+ : JSON.stringify(form.config[field.key], null, 2)
682
+ "
683
+ @input="
684
+ form.config[field.key] = (
685
+ $event.target as HTMLTextAreaElement
686
+ ).value
687
+ "
688
+ :required="field.required"
689
+ rows="3"
690
+ />
691
+ <input
692
+ v-else
693
+ v-model="form.config[field.key]"
694
+ :required="field.required"
695
+ maxlength="8000"
696
+ />
697
+ </UiField>
698
+ </template>
699
+ </template>
473
700
  <UiField
474
701
  v-if="form.timing === 'schedule'"
475
702
  label="Schedule"
@@ -493,7 +720,7 @@ async function toggleLog(routineId: string): Promise<void> {
493
720
  >
494
721
  {{ scheduleError }}
495
722
  </p>
496
- <p v-else class="routines__note">
723
+ <p v-if="form.timing === 'webhook'" class="routines__note">
497
724
  A delivery key is minted when the Routine is saved, and shown once.
498
725
  </p>
499
726
  <UiField label="Time zone">
@@ -754,7 +981,8 @@ async function toggleLog(routineId: string): Promise<void> {
754
981
 
755
982
  .routine-form__timing {
756
983
  display: flex;
757
- gap: 12px;
984
+ flex-direction: column;
985
+ gap: 8px;
758
986
  border: 0;
759
987
  margin: 0;
760
988
  padding: 0;
@@ -63,7 +63,9 @@ watch(
63
63
  <li v-for="routine in rows" :key="routine.routineId">
64
64
  <span>
65
65
  <strong>{{ routine.name }}</strong>
66
- <small>{{ routine.schedule ?? "Webhook" }}</small>
66
+ <small>{{
67
+ routine.schedule ?? routine.eventName ?? "Webhook"
68
+ }}</small>
67
69
  </span>
68
70
  <small>{{ routine.enabled ? "On" : "Paused" }}</small>
69
71
  </li>
@@ -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
+ });
@@ -0,0 +1,185 @@
1
+ import { canonicalCommandFingerprintV1 } from "@frockbot/configuration-core";
2
+ import {
3
+ decodeRoutineSubscriptionIntentV1,
4
+ type RoutineSubscriptionIntentV1,
5
+ } from "@frockbot/connection-core";
6
+ import type { RoutineRecordV1 } from "./records.js";
7
+ import type {
8
+ RoutineStorageReadsV1,
9
+ RoutineStorageWritesV1,
10
+ RoutineStorageV1,
11
+ } from "./store.js";
12
+
13
+ const PREFIX = "routine-subscription:",
14
+ POINTER = "routine-subscription-current:";
15
+ interface Binding {
16
+ schemaVersion: 1;
17
+ intent: RoutineSubscriptionIntentV1;
18
+ pending: boolean;
19
+ dueAt: number;
20
+ }
21
+ function decode(value: unknown): Binding {
22
+ if (
23
+ !value ||
24
+ typeof value !== "object" ||
25
+ !("schemaVersion" in value) ||
26
+ value.schemaVersion !== 1 ||
27
+ !("intent" in value) ||
28
+ !("pending" in value) ||
29
+ typeof value.pending !== "boolean" ||
30
+ !("dueAt" in value) ||
31
+ typeof value.dueAt !== "number" ||
32
+ !Number.isFinite(value.dueAt)
33
+ )
34
+ throw new Error("Routine event binding is invalid");
35
+ return {
36
+ schemaVersion: 1,
37
+ intent: decodeRoutineSubscriptionIntentV1(value.intent),
38
+ pending: value.pending,
39
+ dueAt: value.dueAt,
40
+ };
41
+ }
42
+ /** Staged in the same Bot transaction as the Routine; no provider call occurs here. */
43
+ export async function stageRoutineSubscriptionV1(
44
+ tx: RoutineStorageWritesV1,
45
+ routineId: string,
46
+ next?: RoutineRecordV1,
47
+ force = false,
48
+ ) {
49
+ const current = await tx.get<string>(`${POINTER}${routineId}`);
50
+ const held = current
51
+ ? decode(await tx.get(`${PREFIX}${current}`))
52
+ : undefined;
53
+ const trigger =
54
+ next?.trigger?.kind === "connection"
55
+ ? {
56
+ connectionId: next.trigger.connectionId,
57
+ triggerType: next.trigger.triggerType,
58
+ config: next.trigger.config,
59
+ }
60
+ : undefined;
61
+ const same =
62
+ held &&
63
+ trigger &&
64
+ canonicalCommandFingerprintV1("trigger", held.intent.trigger) ===
65
+ canonicalCommandFingerprintV1("trigger", trigger);
66
+ if (same && held) {
67
+ if (held.intent.enabled === next!.enabled && !force) return;
68
+ await tx.put(`${PREFIX}${current}`, {
69
+ schemaVersion: 1,
70
+ intent: {
71
+ ...held.intent,
72
+ revision: held.intent.revision + 1,
73
+ enabled: next!.enabled,
74
+ },
75
+ pending: true,
76
+ dueAt: Date.now(),
77
+ } satisfies Binding);
78
+ return;
79
+ }
80
+ if (held && !held.intent.deleted) {
81
+ await tx.put(`${PREFIX}${current}`, {
82
+ schemaVersion: 1,
83
+ intent: {
84
+ ...held.intent,
85
+ revision: held.intent.revision + 1,
86
+ enabled: false,
87
+ deleted: true,
88
+ },
89
+ pending: true,
90
+ dueAt: Date.now(),
91
+ } satisfies Binding);
92
+ await tx.delete(`${POINTER}${routineId}`);
93
+ }
94
+ if (!trigger) return;
95
+ if ((await tx.list({ prefix: PREFIX, limit: 1000 })).size >= 1000)
96
+ throw new Error(
97
+ "This Bot has reached its event subscription history limit",
98
+ );
99
+ const subscriptionId = crypto.randomUUID();
100
+ await tx.put(`${PREFIX}${subscriptionId}`, {
101
+ schemaVersion: 1,
102
+ intent: {
103
+ schemaVersion: 1,
104
+ subscriptionId,
105
+ routineId,
106
+ revision: 1,
107
+ enabled: next!.enabled,
108
+ deleted: false,
109
+ trigger,
110
+ },
111
+ pending: true,
112
+ dueAt: Date.now(),
113
+ } satisfies Binding);
114
+ await tx.put(`${POINTER}${routineId}`, subscriptionId);
115
+ }
116
+ export async function routineSubscriptionMatchesV1(
117
+ tx: RoutineStorageReadsV1,
118
+ routineId: string,
119
+ subscriptionId: string,
120
+ ): Promise<boolean> {
121
+ if ((await tx.get(`${POINTER}${routineId}`)) !== subscriptionId) return false;
122
+ const binding = decode(await tx.get(`${PREFIX}${subscriptionId}`));
123
+ return binding.intent.enabled && !binding.intent.deleted;
124
+ }
125
+ export async function routineSubscriptionDeadlinesV1(
126
+ tx: RoutineStorageReadsV1,
127
+ ): Promise<number[]> {
128
+ return [...(await tx.list({ prefix: PREFIX })).values()]
129
+ .map(decode)
130
+ .filter((binding) => binding.pending)
131
+ .map((binding) => binding.dueAt);
132
+ }
133
+ export async function flushRoutineSubscriptionsV1(
134
+ storage: RoutineStorageV1,
135
+ send: (intent: RoutineSubscriptionIntentV1) => Promise<unknown>,
136
+ force = false,
137
+ ): Promise<void> {
138
+ for (const [key, value] of await storage.list({ prefix: PREFIX })) {
139
+ const binding = decode(value);
140
+ if (!binding.pending || (!force && binding.dueAt > Date.now())) continue;
141
+ await storage.transaction(async (tx) => {
142
+ const live = decode(await tx.get(key));
143
+ if (live.intent.revision === binding.intent.revision)
144
+ await tx.put(key, { ...live, dueAt: Date.now() + 60_000 });
145
+ });
146
+ try {
147
+ const receipt = await send(binding.intent);
148
+ if (
149
+ !receipt ||
150
+ typeof receipt !== "object" ||
151
+ !("schemaVersion" in receipt) ||
152
+ receipt.schemaVersion !== 1 ||
153
+ !("status" in receipt) ||
154
+ receipt.status !== "recorded"
155
+ )
156
+ throw new Error("Routine subscription was not acknowledged");
157
+ await storage.transaction(async (tx) => {
158
+ const live = decode(await tx.get(key));
159
+ if (live.intent.revision === binding.intent.revision)
160
+ await tx.put(key, { ...live, pending: false });
161
+ });
162
+ } catch {
163
+ /* The persisted deadline retries the same desired revision. */
164
+ }
165
+ }
166
+ }
167
+
168
+ export async function routineSubscriptionBindingsV1(
169
+ storage: RoutineStorageReadsV1,
170
+ ): Promise<Record<string, string>> {
171
+ const result: Record<string, string> = {};
172
+ for (const [key, id] of await storage.list<unknown>({ prefix: POINTER })) {
173
+ if (typeof id !== "string")
174
+ throw new Error("Routine event binding is invalid");
175
+ const held = decode(await storage.get(`${PREFIX}${id}`));
176
+ if (
177
+ held.intent.subscriptionId !== id ||
178
+ held.intent.routineId !== key.slice(POINTER.length) ||
179
+ held.intent.deleted
180
+ )
181
+ throw new Error("Routine event binding is invalid");
182
+ result[held.intent.routineId] = id;
183
+ }
184
+ return result;
185
+ }