@frockbot/plugin-routines 0.3.45 → 0.3.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -9
- package/src/agent.ts +65 -11
- package/src/backend.ts +26 -2
- package/src/client/RoutinesSection.vue +266 -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/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
|
+
});
|