@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 +11 -9
- package/src/agent.ts +65 -11
- package/src/backend.ts +26 -2
- package/src/client/RoutinesSection.vue +241 -13
- package/src/client/RoutinesSummary.vue +3 -1
- package/src/client/index.ts +18 -0
- package/src/client/state.ts +3 -0
- package/src/records.ts +29 -4
- package/src/scheduler.ts +6 -0
- package/src/shared.ts +35 -1
- package/src/store.ts +75 -26
- package/src/subscriptions.test.ts +159 -0
- package/src/subscriptions.ts +185 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-routines",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
36
|
-
"@frockbot/client-ui": "0.3.
|
|
37
|
-
"@frockbot/configuration-core": "0.3.
|
|
38
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
39
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
40
|
-
"@frockbot/plugin-shell": "0.3.
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
...(
|
|
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
|
-
: {
|
|
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
|
-
: {
|
|
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
|
-
:
|
|
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(
|
|
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
|
-
:
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
|
236
|
-
|
|
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-
|
|
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
|
-
|
|
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>{{
|
|
66
|
+
<small>{{
|
|
67
|
+
routine.schedule ?? routine.eventName ?? "Webhook"
|
|
68
|
+
}}</small>
|
|
67
69
|
</span>
|
|
68
70
|
<small>{{ routine.enabled ? "On" : "Paused" }}</small>
|
|
69
71
|
</li>
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
}
|
package/src/client/state.ts
CHANGED
|
@@ -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
|
|
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"
|
|
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
|
-
[
|
|
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
|
|
379
|
-
digest
|
|
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
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
761
|
+
if (record.trigger?.kind === "webhook" && held === undefined) {
|
|
713
762
|
minted = await this.#mint(transaction, record.routineId, at);
|
|
714
|
-
} else if (record.trigger
|
|
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
|
|
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
|
+
}
|