@frockbot/plugin-routines 0.3.8 → 0.3.10
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 +7 -7
- package/src/agent.test.ts +91 -0
- package/src/agent.ts +64 -1
- package/src/backend.ts +14 -9
- package/src/client/RoutineInboxBadge.vue +28 -5
- package/src/client/RoutinesSection.vue +164 -9
- package/src/client/index.test.ts +86 -0
- package/src/client/index.ts +36 -1
- package/src/hook.test.ts +46 -10
- package/src/hook.ts +36 -16
- package/src/inbox-store.ts +60 -5
- package/src/inbox.test.ts +102 -1
- package/src/inbox.ts +70 -1
- package/src/scheduler.test.ts +212 -1
- package/src/scheduler.ts +120 -10
- package/src/shared.test.ts +8 -4
- package/src/shared.ts +20 -5
- package/src/store.test.ts +50 -2
- package/src/store.ts +26 -2
package/src/scheduler.test.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { RoutineInboxStore } from "./inbox-store.js";
|
|
|
10
10
|
import { createMemoryRoutineStorageV1 } from "./testing.js";
|
|
11
11
|
import {
|
|
12
12
|
routineFireKeyV1,
|
|
13
|
+
routineKeyV1,
|
|
13
14
|
routineScheduleKeyV1,
|
|
14
15
|
ROUTINE_FAILURE_PAUSE_AFTER,
|
|
15
16
|
ROUTINE_FIRE_LEASE_MS,
|
|
@@ -575,7 +576,7 @@ describe("an abandoned firing", () => {
|
|
|
575
576
|
});
|
|
576
577
|
|
|
577
578
|
test("releases a lock nothing can decode", async () => {
|
|
578
|
-
const { storage, scheduler, store, create } = harness({
|
|
579
|
+
const { storage, time, scheduler, store, create } = harness({
|
|
579
580
|
start: "2026-01-01T08:59:00.000Z",
|
|
580
581
|
schedule: "0 9 * * *",
|
|
581
582
|
});
|
|
@@ -759,6 +760,119 @@ describe("a failed firing", () => {
|
|
|
759
760
|
});
|
|
760
761
|
});
|
|
761
762
|
|
|
763
|
+
describe("what a failing Routine tells the person", () => {
|
|
764
|
+
// The inbox entry, the run-log summary and the notification all carried
|
|
765
|
+
// `tool occurrence "tool:1:1:1" was not settled before step end`.
|
|
766
|
+
test("writes a sentence, and keeps the kernel string on the run log", async () => {
|
|
767
|
+
const { storage, time, scheduler, store, create } = harness({
|
|
768
|
+
start: "2026-01-01T00:00:00.000Z",
|
|
769
|
+
schedule: "@every 1m",
|
|
770
|
+
});
|
|
771
|
+
await store.execute(create, USER);
|
|
772
|
+
time.set("2026-01-01T00:01:00.000Z");
|
|
773
|
+
const raw =
|
|
774
|
+
'Bot turn ended with outcome model-error: tool occurrence "tool:1:1:1" was not settled before step end';
|
|
775
|
+
await drain(scheduler, { status: "failed", summary: raw });
|
|
776
|
+
|
|
777
|
+
const entries = await new RoutineInboxStore(storage, {
|
|
778
|
+
now: time.now,
|
|
779
|
+
}).list();
|
|
780
|
+
expect(entries).toHaveLength(1);
|
|
781
|
+
expect(entries[0]?.text).toBe(
|
|
782
|
+
'"Morning brief" did not run: Something inside FrockBot went wrong; the run log has the details.',
|
|
783
|
+
);
|
|
784
|
+
expect(entries[0]?.text).not.toContain("tool occurrence");
|
|
785
|
+
// The operator's copy is untouched.
|
|
786
|
+
const runs = await storage.list<unknown>({ prefix: ROUTINE_RUN_PREFIX });
|
|
787
|
+
expect(
|
|
788
|
+
[...runs.values()].map((row) => decodeRoutineRunEntryV1(row).summary),
|
|
789
|
+
).toEqual([raw]);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test("a provider's own words reach the person unchanged", async () => {
|
|
793
|
+
const { storage, time, scheduler, store, create } = harness({
|
|
794
|
+
start: "2026-01-01T00:00:00.000Z",
|
|
795
|
+
schedule: "@every 1m",
|
|
796
|
+
});
|
|
797
|
+
await store.execute(create, USER);
|
|
798
|
+
time.set("2026-01-01T00:01:00.000Z");
|
|
799
|
+
await drain(scheduler, {
|
|
800
|
+
status: "failed",
|
|
801
|
+
summary: "the model provider is rate limiting this account",
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
const entries = await new RoutineInboxStore(storage, {
|
|
805
|
+
now: time.now,
|
|
806
|
+
}).list();
|
|
807
|
+
expect(entries[0]?.text).toBe(
|
|
808
|
+
'"Morning brief" did not run: the model provider is rate limiting this account',
|
|
809
|
+
);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
// Sixty rows of the same sentence is not sixty things being wrong.
|
|
813
|
+
test("folds repeats of one failure into a single entry with a count", async () => {
|
|
814
|
+
const { storage, time, scheduler, store, create } = harness({
|
|
815
|
+
start: "2026-01-01T00:00:00.000Z",
|
|
816
|
+
schedule: "@every 1m",
|
|
817
|
+
});
|
|
818
|
+
await store.execute(create, USER);
|
|
819
|
+
const failure = { status: "failed" as const, summary: "flaked" };
|
|
820
|
+
for (const minute of ["00:01", "02:00", "04:00"]) {
|
|
821
|
+
time.set(`2026-01-01T${minute}:00.000Z`);
|
|
822
|
+
await drain(scheduler, failure);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const entries = await new RoutineInboxStore(storage, {
|
|
826
|
+
now: time.now,
|
|
827
|
+
}).list();
|
|
828
|
+
expect(entries).toHaveLength(1);
|
|
829
|
+
expect(entries[0]?.repeatCount).toBe(3);
|
|
830
|
+
expect(entries[0]?.createdAt).toBe("2026-01-01T04:00:00.000Z");
|
|
831
|
+
// Every firing still has its own run-log row; only the inbox collapses.
|
|
832
|
+
expect(
|
|
833
|
+
(await storage.list<unknown>({ prefix: ROUTINE_RUN_PREFIX })).size,
|
|
834
|
+
).toBeGreaterThanOrEqual(3);
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
test("a different failure gets its own entry", async () => {
|
|
838
|
+
const { storage, time, scheduler, store, create } = harness({
|
|
839
|
+
start: "2026-01-01T00:00:00.000Z",
|
|
840
|
+
schedule: "@every 1m",
|
|
841
|
+
});
|
|
842
|
+
await store.execute(create, USER);
|
|
843
|
+
time.set("2026-01-01T00:01:00.000Z");
|
|
844
|
+
await drain(scheduler, { status: "failed", summary: "flaked" });
|
|
845
|
+
time.set("2026-01-01T02:00:00.000Z");
|
|
846
|
+
await drain(scheduler, { status: "failed", summary: "the disk is full" });
|
|
847
|
+
|
|
848
|
+
const entries = await new RoutineInboxStore(storage, {
|
|
849
|
+
now: time.now,
|
|
850
|
+
}).list();
|
|
851
|
+
expect(entries).toHaveLength(2);
|
|
852
|
+
expect(entries.every((entry) => entry.repeatCount === undefined)).toBe(
|
|
853
|
+
true,
|
|
854
|
+
);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// The panel showed a "Next run" five minutes in the past that never moved.
|
|
858
|
+
test("reports a backed-off Routine's next run in the future", async () => {
|
|
859
|
+
const { storage, time, scheduler, store, create } = harness({
|
|
860
|
+
start: "2026-01-01T00:00:00.000Z",
|
|
861
|
+
schedule: "@every 1m",
|
|
862
|
+
});
|
|
863
|
+
await store.execute(create, USER);
|
|
864
|
+
time.set("2026-01-01T00:01:00.000Z");
|
|
865
|
+
await drain(scheduler, { status: "failed", summary: "flaked" });
|
|
866
|
+
|
|
867
|
+
const next = (await scheduler.nextRuns()).get("brief");
|
|
868
|
+
expect(next).toBeDefined();
|
|
869
|
+
expect(Date.parse(next!)).toBeGreaterThan(time.now().getTime());
|
|
870
|
+
expect(next).toBe(
|
|
871
|
+
new Date(routineDeadlineV1(await state(storage))).toISOString(),
|
|
872
|
+
);
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
|
|
762
876
|
describe("an undecodable Routine record", () => {
|
|
763
877
|
test("degrades that one Routine, never the whole object", async () => {
|
|
764
878
|
const { storage, time, scheduler, store, create } = harness({
|
|
@@ -788,3 +902,100 @@ describe("an undecodable Routine record", () => {
|
|
|
788
902
|
expect(await drain(scheduler)).toHaveLength(1);
|
|
789
903
|
});
|
|
790
904
|
});
|
|
905
|
+
|
|
906
|
+
describe("a Routine deleted while it was firing", () => {
|
|
907
|
+
/**
|
|
908
|
+
* Delete sweeps the record, the clock, the lock, anything queued and the
|
|
909
|
+
* whole run log. A firing already in flight settled afterwards and wrote a
|
|
910
|
+
* run entry back under the swept key and an inbox entry naming a Routine
|
|
911
|
+
* that no longer exists — an orphan the user cannot open, delete, or
|
|
912
|
+
* explain.
|
|
913
|
+
*/
|
|
914
|
+
test("leaves no run entry and no inbox entry behind", async () => {
|
|
915
|
+
const { storage, time, scheduler, store, create } = harness({
|
|
916
|
+
start: "2026-01-01T08:59:00.000Z",
|
|
917
|
+
schedule: "0 9 * * *",
|
|
918
|
+
});
|
|
919
|
+
await store.execute(create, USER);
|
|
920
|
+
time.set("2026-01-01T09:00:00.000Z");
|
|
921
|
+
|
|
922
|
+
const inbox = new RoutineInboxStore(storage);
|
|
923
|
+
await scheduler.settle(async () => {
|
|
924
|
+
// The user deletes it from the panel while the Turn is still running.
|
|
925
|
+
await store.execute(
|
|
926
|
+
{
|
|
927
|
+
schemaVersion: 1,
|
|
928
|
+
type: "routine/delete",
|
|
929
|
+
commandId: "cmd-delete",
|
|
930
|
+
botId: "scout",
|
|
931
|
+
routineId: "brief",
|
|
932
|
+
},
|
|
933
|
+
USER,
|
|
934
|
+
);
|
|
935
|
+
return { status: "failed", summary: "the model refused" };
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
expect(
|
|
939
|
+
storage.keys().filter((key) => key.startsWith(ROUTINE_RUN_PREFIX)),
|
|
940
|
+
).toEqual([]);
|
|
941
|
+
expect(await inbox.list()).toEqual([]);
|
|
942
|
+
expect(await storage.get(routineFireKeyV1("brief"))).toBeUndefined();
|
|
943
|
+
expect(await storage.get(routineScheduleKeyV1("brief"))).toBeUndefined();
|
|
944
|
+
expect(await scheduler.deadlines(storage)).toEqual([]);
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
test("a firing whose Routine still exists settles as it always did", async () => {
|
|
948
|
+
const { storage, time, scheduler, store, create } = harness({
|
|
949
|
+
start: "2026-01-01T08:59:00.000Z",
|
|
950
|
+
schedule: "0 9 * * *",
|
|
951
|
+
});
|
|
952
|
+
await store.execute(create, USER);
|
|
953
|
+
time.set("2026-01-01T09:00:00.000Z");
|
|
954
|
+
const inbox = new RoutineInboxStore(storage);
|
|
955
|
+
await drain(scheduler, { status: "failed", summary: "the model refused" });
|
|
956
|
+
expect(
|
|
957
|
+
storage.keys().filter((key) => key.startsWith(ROUTINE_RUN_PREFIX)),
|
|
958
|
+
).not.toEqual([]);
|
|
959
|
+
expect(await inbox.list()).toHaveLength(1);
|
|
960
|
+
});
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
describe("a stored schedule with no occurrence left", () => {
|
|
964
|
+
/**
|
|
965
|
+
* The write path refuses these now, but a record written before it did — or
|
|
966
|
+
* by an older deploy — is still in storage. The claim used to fall back to
|
|
967
|
+
* `now + ROUTINE_MISSED_GRACE_MS` when the schedule named no next run, so
|
|
968
|
+
* the Routine fired a real model Turn every five minutes for ever, with
|
|
969
|
+
* nothing on screen and no bound on the spend.
|
|
970
|
+
*/
|
|
971
|
+
test("turns itself off and says why, instead of firing every five minutes", async () => {
|
|
972
|
+
const { storage, scheduler, store, create } = harness({
|
|
973
|
+
start: "2026-01-01T00:00:00.000Z",
|
|
974
|
+
schedule: "0 9 * * *",
|
|
975
|
+
});
|
|
976
|
+
await store.execute(create, USER);
|
|
977
|
+
// February the 30th, straight into storage, past the write-time guard.
|
|
978
|
+
const stored = (await storage.get(routineKeyV1("brief"))) as Record<
|
|
979
|
+
string,
|
|
980
|
+
unknown
|
|
981
|
+
>;
|
|
982
|
+
await storage.put(routineKeyV1("brief"), {
|
|
983
|
+
...stored,
|
|
984
|
+
schedule: "0 0 30 2 *",
|
|
985
|
+
});
|
|
986
|
+
await storage.delete(routineScheduleKeyV1("brief"));
|
|
987
|
+
|
|
988
|
+
expect(await drain(scheduler)).toEqual([]);
|
|
989
|
+
|
|
990
|
+
const listed = await store.list("scout");
|
|
991
|
+
expect(listed.routines[0]!.enabled).toBe(false);
|
|
992
|
+
const runs = await store.listRuns("scout", "brief");
|
|
993
|
+
expect(runs.entries[0]!.status).toBe("skipped");
|
|
994
|
+
expect(runs.entries[0]!.summary).toContain("never comes around again");
|
|
995
|
+
// And the alarm no longer has anything to arm on for it.
|
|
996
|
+
expect(await scheduler.deadlines(storage)).toEqual([]);
|
|
997
|
+
|
|
998
|
+
// Draining again does not fire it either: it is off and its clock is gone.
|
|
999
|
+
expect(await drain(scheduler)).toEqual([]);
|
|
1000
|
+
});
|
|
1001
|
+
});
|
package/src/scheduler.ts
CHANGED
|
@@ -35,6 +35,11 @@ import {
|
|
|
35
35
|
type RoutineFireV1,
|
|
36
36
|
type RoutineScheduleStateV1,
|
|
37
37
|
} from "./firing.js";
|
|
38
|
+
import {
|
|
39
|
+
decodeRoutineInboxEntryV1,
|
|
40
|
+
routineFailureSentenceV1,
|
|
41
|
+
type RoutineInboxEntryV1,
|
|
42
|
+
} from "./inbox.js";
|
|
38
43
|
import { routineTerminalRecordsV1 } from "./inbox-store.js";
|
|
39
44
|
import {
|
|
40
45
|
decodeRoutineRecordV1,
|
|
@@ -53,11 +58,11 @@ import {
|
|
|
53
58
|
ROUTINE_DEFERRAL_MS,
|
|
54
59
|
ROUTINE_FIRE_LEASE_MS,
|
|
55
60
|
ROUTINE_FIRE_PREFIX,
|
|
61
|
+
ROUTINE_INBOX_PREFIX,
|
|
56
62
|
ROUTINE_FAILURE_BACKOFF_MS,
|
|
57
63
|
ROUTINE_FAILURE_PAUSE_AFTER,
|
|
58
64
|
ROUTINE_FIRE_TIMEOUT_MS,
|
|
59
65
|
ROUTINE_LIMIT_PER_BOT,
|
|
60
|
-
ROUTINE_MISSED_GRACE_MS,
|
|
61
66
|
ROUTINE_PREFIX,
|
|
62
67
|
ROUTINE_QUEUE_LIMIT,
|
|
63
68
|
ROUTINE_QUEUE_PREFIX,
|
|
@@ -65,6 +70,7 @@ import {
|
|
|
65
70
|
routineKeyV1,
|
|
66
71
|
routineQueueKeyV1,
|
|
67
72
|
routineQueuePrefixV1,
|
|
73
|
+
routineRunPrefixV1,
|
|
68
74
|
routineScheduleKeyV1,
|
|
69
75
|
} from "./storage-keys.js";
|
|
70
76
|
|
|
@@ -437,7 +443,14 @@ export class RoutineScheduler {
|
|
|
437
443
|
async nextRuns(): Promise<Map<string, string>> {
|
|
438
444
|
const next = new Map<string, string>();
|
|
439
445
|
for (const { record, state } of await this.#clocks(this.#storage)) {
|
|
440
|
-
|
|
446
|
+
// The deadline, not the raw due time: a Routine held back by a deferral
|
|
447
|
+
// or a failure backoff is next owed a firing when the hold ends. Reading
|
|
448
|
+
// `dueAt` alone showed a "Next run" that had already gone past and never
|
|
449
|
+
// moved, for as long as the backoff lasted.
|
|
450
|
+
next.set(
|
|
451
|
+
record.routineId,
|
|
452
|
+
new Date(routineDeadlineV1(state)).toISOString(),
|
|
453
|
+
);
|
|
441
454
|
}
|
|
442
455
|
return next;
|
|
443
456
|
}
|
|
@@ -590,7 +603,9 @@ export class RoutineScheduler {
|
|
|
590
603
|
record: RoutineRecordV1,
|
|
591
604
|
state: RoutineScheduleStateV1,
|
|
592
605
|
now: Date,
|
|
593
|
-
|
|
606
|
+
// `undefined` when the schedule has no occurrence left: the Routine turns
|
|
607
|
+
// itself off and there is nothing to fire.
|
|
608
|
+
): Promise<ClaimedFiringV1 | undefined> {
|
|
594
609
|
const normalized = normalizeRoutineScheduleV1(
|
|
595
610
|
record.schedule!,
|
|
596
611
|
record.timezone,
|
|
@@ -629,13 +644,38 @@ export class RoutineScheduler {
|
|
|
629
644
|
// re-owe this occurrence.
|
|
630
645
|
const from = missedCount > 1 ? now : new Date(state.dueAt);
|
|
631
646
|
const next = nextRoutineRunV1(normalized, from, anchor);
|
|
647
|
+
if (next === undefined) {
|
|
648
|
+
// The schedule has no occurrence left — `0 0 30 2 *` is February the
|
|
649
|
+
// 30th, and a Routine written before this was refused at write time
|
|
650
|
+
// still holds one. Falling back to "five minutes from now" turned that
|
|
651
|
+
// into a real model Turn every five minutes for ever. It is turned off
|
|
652
|
+
// instead, with a run-log entry that says why, and the firing this claim
|
|
653
|
+
// was about is abandoned rather than run.
|
|
654
|
+
await transaction.put(routineKeyV1(record.routineId), {
|
|
655
|
+
...record,
|
|
656
|
+
enabled: false,
|
|
657
|
+
updatedAt: now.toISOString(),
|
|
658
|
+
} satisfies RoutineRecordV1);
|
|
659
|
+
await transaction.delete(routineScheduleKeyV1(record.routineId));
|
|
660
|
+
await appendRoutineRunEntryV1(transaction, {
|
|
661
|
+
schemaVersion: 1,
|
|
662
|
+
entryId: `${routineFireIdV1(record.routineId, String(state.dueAt))}-unreachable`,
|
|
663
|
+
routineId: record.routineId,
|
|
664
|
+
runId: routineFireIdV1(record.routineId, String(state.dueAt)),
|
|
665
|
+
fireId: routineFireIdV1(record.routineId, String(state.dueAt)),
|
|
666
|
+
trigger: "cron",
|
|
667
|
+
status: "skipped",
|
|
668
|
+
startedAt: now.toISOString(),
|
|
669
|
+
finishedAt: now.toISOString(),
|
|
670
|
+
summary: `The schedule "${record.schedule}" never comes around again in ${record.timezone}, so this Routine has been turned off. Give it a schedule that does and turn it back on.`,
|
|
671
|
+
} satisfies RoutineRunEntryV1);
|
|
672
|
+
return undefined;
|
|
673
|
+
}
|
|
632
674
|
await transaction.put(routineScheduleKeyV1(record.routineId), {
|
|
633
675
|
schemaVersion: 1,
|
|
634
676
|
routineId: record.routineId,
|
|
635
677
|
anchor: record.updatedAt,
|
|
636
|
-
dueAt: (
|
|
637
|
-
next ?? new Date(now.getTime() + ROUTINE_MISSED_GRACE_MS)
|
|
638
|
-
).getTime(),
|
|
678
|
+
dueAt: next.getTime(),
|
|
639
679
|
...(state.consecutiveFailures === undefined
|
|
640
680
|
? {}
|
|
641
681
|
: { consecutiveFailures: state.consecutiveFailures }),
|
|
@@ -798,14 +838,32 @@ export class RoutineScheduler {
|
|
|
798
838
|
): Promise<void> {
|
|
799
839
|
const name = await this.#routineName(transaction, fire.routineId);
|
|
800
840
|
const verb = outcome.status === "cancelled" ? "was stopped" : "did not run";
|
|
841
|
+
// The sentence, not the kernel string: the raw summary is on the run-log
|
|
842
|
+
// row this same transaction writes, which is where an operator looks.
|
|
843
|
+
const text = `"${name}" ${verb}: ${routineFailureSentenceV1(outcome.summary)}`;
|
|
844
|
+
// The same Routine failing the same way every minute is one thing that is
|
|
845
|
+
// wrong, not sixty. It folds into the entry already at the head of the
|
|
846
|
+
// inbox, which keeps its place in the order and gains a count.
|
|
847
|
+
const repeated = await this.#collapsibleFailure(
|
|
848
|
+
transaction,
|
|
849
|
+
fire.routineId,
|
|
850
|
+
text,
|
|
851
|
+
);
|
|
852
|
+
if (repeated) {
|
|
853
|
+
await transaction.put(repeated.key, {
|
|
854
|
+
...repeated.entry,
|
|
855
|
+
runId: fire.fireId,
|
|
856
|
+
createdAt: now,
|
|
857
|
+
repeatCount: (repeated.entry.repeatCount ?? 1) + 1,
|
|
858
|
+
} satisfies RoutineInboxEntryV1);
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
801
861
|
const records = await routineTerminalRecordsV1({
|
|
802
862
|
runId: fire.fireId,
|
|
803
863
|
routineId: fire.routineId,
|
|
804
864
|
routineName: name,
|
|
805
|
-
responseText:
|
|
806
|
-
|
|
807
|
-
? `"${name}" ${verb}.`
|
|
808
|
-
: `"${name}" ${verb}: ${outcome.summary}`,
|
|
865
|
+
responseText: text,
|
|
866
|
+
failure: true,
|
|
809
867
|
now,
|
|
810
868
|
read: (key) => transaction.get(key),
|
|
811
869
|
});
|
|
@@ -815,6 +873,41 @@ export class RoutineScheduler {
|
|
|
815
873
|
}
|
|
816
874
|
}
|
|
817
875
|
|
|
876
|
+
/**
|
|
877
|
+
* The newest inbox entry, when it is an unread repeat of this same failure.
|
|
878
|
+
*
|
|
879
|
+
* Only the newest: an entry the User has already read, or one with anything
|
|
880
|
+
* in front of it, is part of a history rather than the live complaint, and
|
|
881
|
+
* rewriting it would move a row the User has already looked past.
|
|
882
|
+
*/
|
|
883
|
+
async #collapsibleFailure(
|
|
884
|
+
transaction: RoutineStorageWritesV1,
|
|
885
|
+
routineId: string,
|
|
886
|
+
text: string,
|
|
887
|
+
): Promise<{ key: string; entry: RoutineInboxEntryV1 } | undefined> {
|
|
888
|
+
const newest = await transaction.list<unknown>({
|
|
889
|
+
prefix: ROUTINE_INBOX_PREFIX,
|
|
890
|
+
limit: 1,
|
|
891
|
+
});
|
|
892
|
+
for (const [key, stored] of newest) {
|
|
893
|
+
try {
|
|
894
|
+
const entry = decodeRoutineInboxEntryV1(stored);
|
|
895
|
+
if (
|
|
896
|
+
!entry.acknowledged &&
|
|
897
|
+
entry.routineId === routineId &&
|
|
898
|
+
entry.wakeId === undefined &&
|
|
899
|
+
entry.text === text
|
|
900
|
+
) {
|
|
901
|
+
return { key, entry };
|
|
902
|
+
}
|
|
903
|
+
} catch {
|
|
904
|
+
// An entry that cannot be read is not one to fold into.
|
|
905
|
+
}
|
|
906
|
+
return undefined;
|
|
907
|
+
}
|
|
908
|
+
return undefined;
|
|
909
|
+
}
|
|
910
|
+
|
|
818
911
|
async #settleFiring(
|
|
819
912
|
fire: RoutineFireV1,
|
|
820
913
|
outcome: RoutineFireOutcomeV1,
|
|
@@ -823,6 +916,23 @@ export class RoutineScheduler {
|
|
|
823
916
|
const finishedAt = at.toISOString();
|
|
824
917
|
await this.#storage.transaction(async (transaction) => {
|
|
825
918
|
await transaction.delete(routineFireKeyV1(fire.routineId));
|
|
919
|
+
// The Routine may have been deleted while this firing was in flight.
|
|
920
|
+
// Delete sweeps the record, the clock, the lock and the whole run log,
|
|
921
|
+
// and the settlement then used to re-append a run entry under the swept
|
|
922
|
+
// key and write an inbox entry naming a Routine that no longer exists —
|
|
923
|
+
// an orphan the user cannot open, delete, or explain. Nothing outlives
|
|
924
|
+
// the record it belongs to.
|
|
925
|
+
if (
|
|
926
|
+
(await transaction.get<unknown>(routineKeyV1(fire.routineId))) ===
|
|
927
|
+
undefined
|
|
928
|
+
) {
|
|
929
|
+
const orphans = await transaction.list<unknown>({
|
|
930
|
+
prefix: routineRunPrefixV1(fire.routineId),
|
|
931
|
+
});
|
|
932
|
+
for (const key of orphans.keys()) await transaction.delete(key);
|
|
933
|
+
await transaction.delete(routineScheduleKeyV1(fire.routineId));
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
826
936
|
await this.#recordOutcomeOnClock(transaction, fire, outcome, at);
|
|
827
937
|
if (outcome.status !== "ok") {
|
|
828
938
|
await this.#recordFailureInbox(transaction, fire, outcome, finishedAt);
|
package/src/shared.test.ts
CHANGED
|
@@ -97,10 +97,14 @@ describe("RoutineViewV1", () => {
|
|
|
97
97
|
});
|
|
98
98
|
if (receipt.status !== "applied") throw new Error("unreachable");
|
|
99
99
|
const view = receipt.routine;
|
|
100
|
-
// The Bot writer's Session and Turn
|
|
101
|
-
|
|
102
|
-
expect(
|
|
103
|
-
|
|
100
|
+
// The Bot writer's Session and Turn travel with it: they are provenance,
|
|
101
|
+
// not key material, and the journey asks the record to name the Turn.
|
|
102
|
+
expect(view.createdBy).toEqual({
|
|
103
|
+
kind: "bot",
|
|
104
|
+
botId: "scout",
|
|
105
|
+
sessionId: "tim:scout",
|
|
106
|
+
turnId: "turn-1",
|
|
107
|
+
});
|
|
104
108
|
expect(decodeRoutineViewV1(JSON.parse(JSON.stringify(view)))).toEqual(view);
|
|
105
109
|
expect(
|
|
106
110
|
decodeRoutineCommandReceiptV1(JSON.parse(JSON.stringify(receipt))),
|
package/src/shared.ts
CHANGED
|
@@ -55,12 +55,16 @@ export const ROUTINE_LIST_MAX = 100;
|
|
|
55
55
|
export const ROUTINE_RUN_LIST_MAX = 50;
|
|
56
56
|
|
|
57
57
|
/**
|
|
58
|
-
* Who wrote a Routine, as the client is told it.
|
|
59
|
-
*
|
|
60
|
-
*
|
|
58
|
+
* Who wrote a Routine, as the client is told it.
|
|
59
|
+
*
|
|
60
|
+
* A Bot writer names the Session and the Turn it wrote from. The panel only
|
|
61
|
+
* says "the Bot wrote this", but provenance that cannot answer "which Turn?"
|
|
62
|
+
* is not provenance: the audit trail and the run log both address a Turn, and
|
|
63
|
+
* a Routine that a Bot wrote must be traceable to the one that wrote it.
|
|
61
64
|
*/
|
|
62
65
|
export type RoutineWriterViewV1 =
|
|
63
|
-
|
|
66
|
+
| { kind: "user" }
|
|
67
|
+
| { kind: "bot"; botId: string; sessionId: string; turnId: string };
|
|
64
68
|
|
|
65
69
|
/** One Routine as the hosted client sees it. Never any key material. */
|
|
66
70
|
export interface RoutineViewV1 {
|
|
@@ -420,10 +424,17 @@ function decodeRoutineWriterViewV1(
|
|
|
420
424
|
if (candidate.kind !== "bot") {
|
|
421
425
|
throw new RoutineDecodeError(`${label} kind is invalid`);
|
|
422
426
|
}
|
|
423
|
-
routineExactKeys(
|
|
427
|
+
routineExactKeys(
|
|
428
|
+
candidate,
|
|
429
|
+
["kind", "botId", "sessionId", "turnId"],
|
|
430
|
+
[],
|
|
431
|
+
label,
|
|
432
|
+
);
|
|
424
433
|
return {
|
|
425
434
|
kind: "bot",
|
|
426
435
|
botId: routineText(candidate.botId, 128, `${label} botId`),
|
|
436
|
+
sessionId: routineText(candidate.sessionId, 128, `${label} sessionId`),
|
|
437
|
+
turnId: routineText(candidate.turnId, 128, `${label} turnId`),
|
|
427
438
|
};
|
|
428
439
|
}
|
|
429
440
|
|
|
@@ -709,6 +720,10 @@ export interface RoutineInboxEntryViewV1 {
|
|
|
709
720
|
createdAt: string;
|
|
710
721
|
acknowledged: boolean;
|
|
711
722
|
acknowledgedAt?: string;
|
|
723
|
+
/** How many firings this entry stands for; absent means one. */
|
|
724
|
+
repeatCount?: number;
|
|
725
|
+
/** The firing did not work. */
|
|
726
|
+
failure?: true;
|
|
712
727
|
}
|
|
713
728
|
|
|
714
729
|
export interface RoutineInboxViewV1 {
|
package/src/store.test.ts
CHANGED
|
@@ -54,11 +54,19 @@ describe("RoutineStore.execute", () => {
|
|
|
54
54
|
expect(listed.routines).toHaveLength(1);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
test("records a Bot writer as the Bot,
|
|
57
|
+
test("records a Bot writer as the Bot, naming the Turn that wrote it", async () => {
|
|
58
58
|
const routines = store();
|
|
59
59
|
const receipt = await routines.execute(create(), BOT);
|
|
60
60
|
if (receipt.status !== "applied") throw new Error("unreachable");
|
|
61
|
-
|
|
61
|
+
// Provenance that cannot answer "which Turn?" is not provenance: the view
|
|
62
|
+
// used to carry the Bot id alone, so a Routine a Bot wrote could not be
|
|
63
|
+
// traced back to the Turn that wrote it.
|
|
64
|
+
expect(receipt.routine.createdBy).toEqual({
|
|
65
|
+
kind: "bot",
|
|
66
|
+
botId: "scout",
|
|
67
|
+
sessionId: "tim:scout",
|
|
68
|
+
turnId: "turn-7",
|
|
69
|
+
});
|
|
62
70
|
const stored = await routines.read("brief");
|
|
63
71
|
expect(stored?.createdBy).toEqual(BOT);
|
|
64
72
|
});
|
|
@@ -259,3 +267,43 @@ describe("RoutineStore run log", () => {
|
|
|
259
267
|
expect(log.entries[0]).toMatchObject({ status: "failed" });
|
|
260
268
|
});
|
|
261
269
|
});
|
|
270
|
+
|
|
271
|
+
describe("a schedule that never comes around", () => {
|
|
272
|
+
/**
|
|
273
|
+
* `0 0 30 2 *` is February the 30th: croner parses it happily and then never
|
|
274
|
+
* names a next run. It used to be stored, and the scheduler's clock then fell
|
|
275
|
+
* back to "five minutes from now" on every claim, so the Routine burned a
|
|
276
|
+
* whole model Turn every five minutes for ever. A schedule with no future
|
|
277
|
+
* occurrence is not a schedule.
|
|
278
|
+
*/
|
|
279
|
+
test("is refused at write time, and nothing is stored", async () => {
|
|
280
|
+
const routines = store();
|
|
281
|
+
await expect(
|
|
282
|
+
routines.execute(create({ schedule: "0 0 30 2 *" }), USER),
|
|
283
|
+
).rejects.toThrow(/never comes around again/u);
|
|
284
|
+
await expect(
|
|
285
|
+
routines.execute(
|
|
286
|
+
create({ commandId: "cmd-2", schedule: "0 0 31 4 *" }),
|
|
287
|
+
USER,
|
|
288
|
+
),
|
|
289
|
+
).rejects.toThrow(/never comes around again/u);
|
|
290
|
+
expect((await routines.list("scout")).routines).toHaveLength(0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("names the expression and the zone, so the field can be corrected", async () => {
|
|
294
|
+
const routines = store();
|
|
295
|
+
const refusal = await routines
|
|
296
|
+
.execute(create({ schedule: "0 0 30 2 *" }), USER)
|
|
297
|
+
.catch((error: unknown) => error);
|
|
298
|
+
expect((refusal as Error).message).toContain("0 0 30 2 *");
|
|
299
|
+
expect((refusal as Error).message).toContain("Australia/Sydney");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("an ordinary rare schedule is still accepted", async () => {
|
|
303
|
+
const routines = store();
|
|
304
|
+
// February the 29th happens; it is simply not every year.
|
|
305
|
+
await expect(
|
|
306
|
+
routines.execute(create({ schedule: "0 0 29 2 *" }), USER),
|
|
307
|
+
).resolves.toMatchObject({ status: "applied" });
|
|
308
|
+
});
|
|
309
|
+
});
|
package/src/store.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// trigger kind, and minting is D3's.
|
|
22
22
|
import {
|
|
23
23
|
isRoutineTimezoneV1,
|
|
24
|
+
nextRoutineRunV1,
|
|
24
25
|
normalizeRoutineScheduleV1,
|
|
25
26
|
RoutineScheduleError,
|
|
26
27
|
} from "./cron.js";
|
|
@@ -161,9 +162,17 @@ export interface RoutineStoreOptionsV1 {
|
|
|
161
162
|
}
|
|
162
163
|
|
|
163
164
|
function writerView(writer: RoutineWriterV1): RoutineWriterViewV1 {
|
|
165
|
+
// The Session and Turn travel with the Bot writer. A Routine a Bot wrote is
|
|
166
|
+
// provenance, and provenance that cannot name the Turn it came from is only
|
|
167
|
+
// half a record: "the Bot wrote this" is not answerable to "which Turn?".
|
|
164
168
|
return writer.kind === "user"
|
|
165
169
|
? { kind: "user" }
|
|
166
|
-
: {
|
|
170
|
+
: {
|
|
171
|
+
kind: "bot",
|
|
172
|
+
botId: writer.botId,
|
|
173
|
+
sessionId: writer.sessionId,
|
|
174
|
+
turnId: writer.turnId,
|
|
175
|
+
};
|
|
167
176
|
}
|
|
168
177
|
|
|
169
178
|
/**
|
|
@@ -774,8 +783,12 @@ export class RoutineStore {
|
|
|
774
783
|
);
|
|
775
784
|
}
|
|
776
785
|
if (decoded.schedule !== undefined) {
|
|
786
|
+
let normalized;
|
|
777
787
|
try {
|
|
778
|
-
normalizeRoutineScheduleV1(
|
|
788
|
+
normalized = normalizeRoutineScheduleV1(
|
|
789
|
+
decoded.schedule,
|
|
790
|
+
decoded.timezone,
|
|
791
|
+
);
|
|
779
792
|
} catch (error) {
|
|
780
793
|
throw new RoutineDecodeError(
|
|
781
794
|
error instanceof RoutineScheduleError
|
|
@@ -783,6 +796,17 @@ export class RoutineStore {
|
|
|
783
796
|
: `Routine schedule is invalid: ${String(error)}`,
|
|
784
797
|
);
|
|
785
798
|
}
|
|
799
|
+
// A syntactically valid expression can still name a moment that never
|
|
800
|
+
// arrives — `0 0 30 2 *` is February the 30th. It used to be accepted,
|
|
801
|
+
// and then the clock fell back to "five minutes from now" on every claim,
|
|
802
|
+
// so the Routine burned a whole model Turn every five minutes for ever.
|
|
803
|
+
// A schedule that never comes around is not a schedule.
|
|
804
|
+
const now = this.#now();
|
|
805
|
+
if (nextRoutineRunV1(normalized, now, now) === undefined) {
|
|
806
|
+
throw new RoutineDecodeError(
|
|
807
|
+
`schedule "${decoded.schedule}" never comes around again in ${decoded.timezone}`,
|
|
808
|
+
);
|
|
809
|
+
}
|
|
786
810
|
}
|
|
787
811
|
return decoded;
|
|
788
812
|
}
|