@frockbot/plugin-routines 0.3.9 → 0.3.11
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/backend.ts +14 -9
- package/src/client/RoutineInboxBadge.vue +24 -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 +57 -5
- package/src/inbox.test.ts +102 -1
- package/src/scheduler.test.ts +99 -1
- package/src/scheduler.ts +49 -5
- package/src/shared.test.ts +8 -4
- package/src/shared.ts +16 -5
- package/src/store.test.ts +50 -2
- package/src/store.ts +26 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-routines",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -32,12 +32,12 @@
|
|
|
32
32
|
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
|
33
33
|
},
|
|
34
34
|
"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.
|
|
35
|
+
"@frockbot/client-core": "0.3.11",
|
|
36
|
+
"@frockbot/client-ui": "0.3.11",
|
|
37
|
+
"@frockbot/configuration-core": "0.3.11",
|
|
38
|
+
"@frockbot/kernel-agent-loop": "0.3.11",
|
|
39
|
+
"@frockbot/kernel-contracts": "0.3.11",
|
|
40
|
+
"@frockbot/plugin-shell": "0.3.11",
|
|
41
41
|
"cordis": "4.0.0-rc.8",
|
|
42
42
|
"croner": "10.0.1",
|
|
43
43
|
"vue": "3.5.41"
|
package/src/backend.ts
CHANGED
|
@@ -164,10 +164,11 @@ function errorResponse(error: unknown): Response {
|
|
|
164
164
|
error instanceof Error ? error.message : "Routine request is invalid",
|
|
165
165
|
);
|
|
166
166
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
);
|
|
167
|
+
// A decode refusal above is the User's own request coming back at them and
|
|
168
|
+
// says what to fix. Anything reaching here is ours: it names internals the
|
|
169
|
+
// caller cannot act on, so it goes to the log and the caller gets the fact.
|
|
170
|
+
console.error("Routine request failed", error);
|
|
171
|
+
return jsonError(500, "Routine request failed");
|
|
171
172
|
}
|
|
172
173
|
|
|
173
174
|
/**
|
|
@@ -242,13 +243,17 @@ async function deliverHook(
|
|
|
242
243
|
error.name === "RoutineHookError"
|
|
243
244
|
) {
|
|
244
245
|
const hookError = error as RoutineHookError;
|
|
245
|
-
|
|
246
|
+
// The wire body, not the reason. A refusal on this route answers the
|
|
247
|
+
// open internet, and the raw message names deployment internals — which
|
|
248
|
+
// environment variable is unset, which decoder rejected what.
|
|
249
|
+
return jsonError(
|
|
250
|
+
hookError.status,
|
|
251
|
+
hookError.publicMessage ?? "webhook delivery failed",
|
|
252
|
+
);
|
|
246
253
|
}
|
|
247
254
|
if (isMissingBot(error)) return jsonError(404, "Routine not found");
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
error instanceof Error ? error.message : "webhook delivery failed",
|
|
251
|
-
);
|
|
255
|
+
console.error("routine webhook delivery failed", error);
|
|
256
|
+
return jsonError(500, "webhook delivery failed");
|
|
252
257
|
}
|
|
253
258
|
}
|
|
254
259
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// completion becomes visible is here — a count of what has not been read, and a
|
|
7
7
|
// drawer that reads it. Acknowledging is a command, never a side effect of
|
|
8
8
|
// opening the drawer, so a glance does not clear the badge.
|
|
9
|
-
import { UiButton, UiIcon } from "@frockbot/client-ui";
|
|
9
|
+
import { formatRelativeMomentV1, UiButton, UiIcon } from "@frockbot/client-ui";
|
|
10
10
|
import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
|
|
11
11
|
import { computed, inject, ref, watch } from "vue";
|
|
12
12
|
import { routinesStateKey } from "./state.js";
|
|
@@ -38,10 +38,27 @@ function toggle(): void {
|
|
|
38
38
|
if (open.value && botId.value) void routines.value.loadInbox(botId.value);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** What is unread and on screen right now — never "everything unread". */
|
|
42
|
+
const unreadOnScreen = computed(() =>
|
|
43
|
+
routines.value.inbox
|
|
44
|
+
.filter((entry) => !entry.acknowledged)
|
|
45
|
+
.map((entry) => entry.entryId),
|
|
46
|
+
);
|
|
47
|
+
|
|
41
48
|
function acknowledge(entryIds: string[]): void {
|
|
42
|
-
if (!botId.value) return;
|
|
49
|
+
if (!botId.value || entryIds.length === 0) return;
|
|
43
50
|
void routines.value.acknowledgeInbox(botId.value, entryIds);
|
|
44
51
|
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* "Mark all read" means the entries the reader can see, not every unread entry
|
|
55
|
+
* the object holds. An empty list acknowledges everything, including a firing
|
|
56
|
+
* that landed a second ago and has never been rendered — with a `@every 1m`
|
|
57
|
+
* Routine that is a completion silently marked read and never read.
|
|
58
|
+
*/
|
|
59
|
+
function markAllRead(): void {
|
|
60
|
+
acknowledge(unreadOnScreen.value);
|
|
61
|
+
}
|
|
45
62
|
</script>
|
|
46
63
|
|
|
47
64
|
<template>
|
|
@@ -61,10 +78,10 @@ function acknowledge(entryIds: string[]): void {
|
|
|
61
78
|
<header class="routine-inbox__header">
|
|
62
79
|
<h2>Routine completions</h2>
|
|
63
80
|
<UiButton
|
|
64
|
-
v-if="
|
|
81
|
+
v-if="unreadOnScreen.length > 0"
|
|
65
82
|
variant="ghost"
|
|
66
83
|
:disabled="routines.busy"
|
|
67
|
-
@click="
|
|
84
|
+
@click="markAllRead()"
|
|
68
85
|
>Mark all read</UiButton
|
|
69
86
|
>
|
|
70
87
|
</header>
|
|
@@ -81,7 +98,9 @@ function acknowledge(entryIds: string[]): void {
|
|
|
81
98
|
<p class="routine-inbox__attribution">{{ entry.attribution }}</p>
|
|
82
99
|
<p class="routine-inbox__text">{{ entry.text }}</p>
|
|
83
100
|
<footer class="routine-inbox__meta">
|
|
84
|
-
<
|
|
101
|
+
<time :datetime="entry.createdAt">{{
|
|
102
|
+
formatRelativeMomentV1(entry.createdAt)
|
|
103
|
+
}}</time>
|
|
85
104
|
<!-- One thing going wrong repeatedly is one entry and a count. -->
|
|
86
105
|
<span v-if="(entry.repeatCount ?? 1) > 1"
|
|
87
106
|
>Happened {{ entry.repeatCount }} times</span
|
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
// Routine. It renders durable state and submits versioned commands; it decides
|
|
4
4
|
// nothing — "Next run" is the moment the scheduler has actually armed an alarm
|
|
5
5
|
// on, sent down with the Routine, and blank when there is none to promise.
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
browserTimeZoneV1,
|
|
8
|
+
formatMomentV1,
|
|
9
|
+
formatRelativeMomentV1,
|
|
10
|
+
UiAnchor,
|
|
11
|
+
UiButton,
|
|
12
|
+
UiField,
|
|
13
|
+
UiIcon,
|
|
14
|
+
} from "@frockbot/client-ui";
|
|
7
15
|
import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
|
|
8
16
|
import { settingsLinkV1 } from "@frockbot/plugin-shell/settings-links";
|
|
9
17
|
import { computed, inject, reactive, ref, watch } from "vue";
|
|
@@ -27,7 +35,6 @@ const formOpen = ref(false);
|
|
|
27
35
|
const openLog = ref<string>();
|
|
28
36
|
const copied = ref(false);
|
|
29
37
|
const openRun = ref<string>();
|
|
30
|
-
|
|
31
38
|
// An automation Turn never appears in the transcript, so opening a run here is
|
|
32
39
|
// the only way to read one, and it is read-only in both directions: the view
|
|
33
40
|
// carries what happened and no way to act on it.
|
|
@@ -50,6 +57,31 @@ const form = reactive({
|
|
|
50
57
|
timezone: "UTC",
|
|
51
58
|
});
|
|
52
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The reason the last save was refused, held beside the form rather than in
|
|
62
|
+
* the section header. The header sits above every Routine card, so on a Bot
|
|
63
|
+
* with a few Routines the refusal rendered hundreds of pixels off-screen and
|
|
64
|
+
* the form simply appeared to do nothing.
|
|
65
|
+
*/
|
|
66
|
+
const saveError = ref<string>();
|
|
67
|
+
/** The Routine a delete has been asked for and not yet confirmed. */
|
|
68
|
+
const pendingDelete = ref<RoutineViewV1>();
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether a refusal is about the schedule, so it can be rendered under the
|
|
72
|
+
* Schedule field. Every schedule refusal comes from one validator, and it
|
|
73
|
+
* names cron, the expression, or the time zone.
|
|
74
|
+
*/
|
|
75
|
+
const scheduleError = computed(() =>
|
|
76
|
+
saveError.value !== undefined &&
|
|
77
|
+
form.timing === "schedule" &&
|
|
78
|
+
/cron|schedule|expression|time zone|timezone|occurrence/iu.test(
|
|
79
|
+
saveError.value,
|
|
80
|
+
)
|
|
81
|
+
? saveError.value
|
|
82
|
+
: undefined,
|
|
83
|
+
);
|
|
84
|
+
|
|
53
85
|
watch(
|
|
54
86
|
botId,
|
|
55
87
|
(id) => {
|
|
@@ -92,13 +124,22 @@ function summary(routine: RoutineViewV1): string {
|
|
|
92
124
|
: "Webhook trigger";
|
|
93
125
|
}
|
|
94
126
|
|
|
127
|
+
/** A durable moment, read in the Routine's own zone — the one it fires on. */
|
|
128
|
+
function moment(routine: RoutineViewV1, iso: string): string {
|
|
129
|
+
return formatMomentV1(iso, { timeZone: routine.timezone });
|
|
130
|
+
}
|
|
131
|
+
|
|
95
132
|
function startCreate(): void {
|
|
96
133
|
form.routineId = undefined;
|
|
97
134
|
form.name = "";
|
|
98
135
|
form.prompt = "";
|
|
99
136
|
form.timing = "schedule";
|
|
100
137
|
form.schedule = "0 9 * * *";
|
|
101
|
-
|
|
138
|
+
// The reader's own zone, not UTC: a schedule is almost always meant in the
|
|
139
|
+
// day the person writing it is living in, and the Bot picks the same when it
|
|
140
|
+
// writes one itself.
|
|
141
|
+
form.timezone = browserTimeZoneV1();
|
|
142
|
+
saveError.value = undefined;
|
|
102
143
|
formOpen.value = true;
|
|
103
144
|
}
|
|
104
145
|
|
|
@@ -109,12 +150,14 @@ function startEdit(routine: RoutineViewV1): void {
|
|
|
109
150
|
form.timing = routine.schedule ? "schedule" : "webhook";
|
|
110
151
|
form.schedule = routine.schedule ?? "";
|
|
111
152
|
form.timezone = routine.timezone;
|
|
153
|
+
saveError.value = undefined;
|
|
112
154
|
formOpen.value = true;
|
|
113
155
|
}
|
|
114
156
|
|
|
115
157
|
async function submit(): Promise<void> {
|
|
116
158
|
const id = botId.value;
|
|
117
159
|
if (!id) return;
|
|
160
|
+
saveError.value = undefined;
|
|
118
161
|
try {
|
|
119
162
|
await routines.value.save(id, {
|
|
120
163
|
...(form.routineId ? { routineId: form.routineId } : {}),
|
|
@@ -126,11 +169,33 @@ async function submit(): Promise<void> {
|
|
|
126
169
|
timezone: form.timezone.trim(),
|
|
127
170
|
});
|
|
128
171
|
formOpen.value = false;
|
|
129
|
-
} catch {
|
|
130
|
-
// The
|
|
172
|
+
} catch (error) {
|
|
173
|
+
// The form stays open and holds the reason itself, beside the field that
|
|
174
|
+
// caused it. The section header keeps its copy for the reader who scrolls
|
|
175
|
+
// back up, but the form no longer refuses in silence.
|
|
176
|
+
saveError.value =
|
|
177
|
+
error instanceof Error
|
|
178
|
+
? error.message
|
|
179
|
+
: (routines.value.error ?? "Could not save the Routine");
|
|
131
180
|
}
|
|
132
181
|
}
|
|
133
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Deleting takes a Routine, its schedule, its prompt and its whole run log
|
|
185
|
+
* with it, and the button sits in a row of six others. It asks first.
|
|
186
|
+
*/
|
|
187
|
+
function askDelete(routine: RoutineViewV1): void {
|
|
188
|
+
pendingDelete.value = routine;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function confirmDelete(): Promise<void> {
|
|
192
|
+
const routine = pendingDelete.value;
|
|
193
|
+
const id = botId.value;
|
|
194
|
+
pendingDelete.value = undefined;
|
|
195
|
+
if (!routine || !id) return;
|
|
196
|
+
await routines.value.remove(id, routine.routineId);
|
|
197
|
+
}
|
|
198
|
+
|
|
134
199
|
async function toggleLog(routineId: string): Promise<void> {
|
|
135
200
|
const id = botId.value;
|
|
136
201
|
if (!id) return;
|
|
@@ -220,7 +285,12 @@ async function toggleLog(routineId: string): Promise<void> {
|
|
|
220
285
|
<dl class="routine-card__facts">
|
|
221
286
|
<div>
|
|
222
287
|
<dt>Next run</dt>
|
|
223
|
-
<dd>
|
|
288
|
+
<dd>
|
|
289
|
+
<time v-if="routine.nextRunAt" :datetime="routine.nextRunAt">{{
|
|
290
|
+
moment(routine, routine.nextRunAt)
|
|
291
|
+
}}</time>
|
|
292
|
+
<template v-else>—</template>
|
|
293
|
+
</dd>
|
|
224
294
|
</div>
|
|
225
295
|
<div v-if="routine.trigger">
|
|
226
296
|
<dt>Webhook key</dt>
|
|
@@ -234,7 +304,15 @@ async function toggleLog(routineId: string): Promise<void> {
|
|
|
234
304
|
</div>
|
|
235
305
|
<div>
|
|
236
306
|
<dt>Last run</dt>
|
|
237
|
-
<dd>
|
|
307
|
+
<dd>
|
|
308
|
+
<time
|
|
309
|
+
v-if="routine.lastRunAt"
|
|
310
|
+
:datetime="routine.lastRunAt"
|
|
311
|
+
:title="moment(routine, routine.lastRunAt)"
|
|
312
|
+
>{{ formatRelativeMomentV1(routine.lastRunAt) }}</time
|
|
313
|
+
>
|
|
314
|
+
<template v-else>Never</template>
|
|
315
|
+
</dd>
|
|
238
316
|
</div>
|
|
239
317
|
<div>
|
|
240
318
|
<dt>Written by</dt>
|
|
@@ -289,11 +367,36 @@ async function toggleLog(routineId: string): Promise<void> {
|
|
|
289
367
|
type="button"
|
|
290
368
|
variant="danger"
|
|
291
369
|
:disabled="routines.busy"
|
|
292
|
-
@click="
|
|
370
|
+
@click="askDelete(routine)"
|
|
293
371
|
>
|
|
294
372
|
Delete
|
|
295
373
|
</UiButton>
|
|
296
374
|
</div>
|
|
375
|
+
<div
|
|
376
|
+
v-if="pendingDelete?.routineId === routine.routineId"
|
|
377
|
+
class="routine-confirm"
|
|
378
|
+
role="alertdialog"
|
|
379
|
+
:aria-label="`Delete ${routine.name}?`"
|
|
380
|
+
>
|
|
381
|
+
<strong>Delete {{ routine.name }}?</strong>
|
|
382
|
+
<small>
|
|
383
|
+
Its schedule, its prompt and its whole run log go with it. This can't
|
|
384
|
+
be undone.
|
|
385
|
+
</small>
|
|
386
|
+
<div class="routine-card__actions">
|
|
387
|
+
<UiButton type="button" @click="pendingDelete = undefined">
|
|
388
|
+
Cancel
|
|
389
|
+
</UiButton>
|
|
390
|
+
<UiButton
|
|
391
|
+
type="button"
|
|
392
|
+
variant="danger"
|
|
393
|
+
:disabled="routines.busy"
|
|
394
|
+
@click="confirmDelete"
|
|
395
|
+
>
|
|
396
|
+
Delete Routine
|
|
397
|
+
</UiButton>
|
|
398
|
+
</div>
|
|
399
|
+
</div>
|
|
297
400
|
<div v-if="openLog === routine.routineId" class="routine-card__log">
|
|
298
401
|
<p
|
|
299
402
|
v-if="(routines.runs[routine.routineId] ?? []).length === 0"
|
|
@@ -312,7 +415,13 @@ async function toggleLog(routineId: string): Promise<void> {
|
|
|
312
415
|
:aria-expanded="openRun === entry.runId"
|
|
313
416
|
@click="toggleRun(routine.routineId, entry.runId)"
|
|
314
417
|
>
|
|
315
|
-
<span
|
|
418
|
+
<span
|
|
419
|
+
><time
|
|
420
|
+
:datetime="entry.startedAt"
|
|
421
|
+
:title="moment(routine, entry.startedAt)"
|
|
422
|
+
>{{ formatRelativeMomentV1(entry.startedAt) }}</time
|
|
423
|
+
></span
|
|
424
|
+
>
|
|
316
425
|
<span>{{ entry.trigger }}</span>
|
|
317
426
|
<span>{{ entry.status }}</span>
|
|
318
427
|
</button>
|
|
@@ -361,8 +470,20 @@ async function toggleLog(routineId: string): Promise<void> {
|
|
|
361
470
|
v-model="form.schedule"
|
|
362
471
|
maxlength="256"
|
|
363
472
|
placeholder="0 9 * * *"
|
|
473
|
+
:aria-invalid="scheduleError ? 'true' : undefined"
|
|
474
|
+
:aria-describedby="
|
|
475
|
+
scheduleError ? 'routine-schedule-error' : undefined
|
|
476
|
+
"
|
|
364
477
|
/>
|
|
365
478
|
</UiField>
|
|
479
|
+
<p
|
|
480
|
+
v-if="scheduleError"
|
|
481
|
+
id="routine-schedule-error"
|
|
482
|
+
class="routine-form__error"
|
|
483
|
+
role="alert"
|
|
484
|
+
>
|
|
485
|
+
{{ scheduleError }}
|
|
486
|
+
</p>
|
|
366
487
|
<p v-else class="routines__note">
|
|
367
488
|
A delivery key is minted when the Routine is saved, and shown once.
|
|
368
489
|
</p>
|
|
@@ -373,6 +494,13 @@ async function toggleLog(routineId: string): Promise<void> {
|
|
|
373
494
|
placeholder="Australia/Sydney"
|
|
374
495
|
/>
|
|
375
496
|
</UiField>
|
|
497
|
+
<p
|
|
498
|
+
v-if="saveError && !scheduleError"
|
|
499
|
+
class="routine-form__error"
|
|
500
|
+
role="alert"
|
|
501
|
+
>
|
|
502
|
+
{{ saveError }}
|
|
503
|
+
</p>
|
|
376
504
|
<div class="routine-card__actions">
|
|
377
505
|
<UiButton
|
|
378
506
|
type="button"
|
|
@@ -576,6 +704,33 @@ async function toggleLog(routineId: string): Promise<void> {
|
|
|
576
704
|
font-size: var(--frock-text-xs);
|
|
577
705
|
}
|
|
578
706
|
|
|
707
|
+
.routine-form__error {
|
|
708
|
+
margin: 0;
|
|
709
|
+
color: var(--frock-danger-text);
|
|
710
|
+
font-size: var(--frock-text-sm);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
.routine-confirm {
|
|
714
|
+
display: flex;
|
|
715
|
+
flex-direction: column;
|
|
716
|
+
gap: 8px;
|
|
717
|
+
border: 1px solid var(--frock-danger-text);
|
|
718
|
+
border-radius: var(--frock-radius-card);
|
|
719
|
+
padding: 10px;
|
|
720
|
+
background: var(--frock-surface);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
.routine-confirm strong {
|
|
724
|
+
color: var(--frock-text);
|
|
725
|
+
font-size: var(--frock-text-sm);
|
|
726
|
+
font-weight: 600;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
.routine-confirm small {
|
|
730
|
+
color: var(--frock-text-muted);
|
|
731
|
+
font-size: var(--frock-text-sm);
|
|
732
|
+
}
|
|
733
|
+
|
|
579
734
|
.routine-form__timing {
|
|
580
735
|
display: flex;
|
|
581
736
|
gap: 12px;
|
package/src/client/index.test.ts
CHANGED
|
@@ -3,6 +3,8 @@ import type {
|
|
|
3
3
|
ClientPluginContext,
|
|
4
4
|
ClientSlotRegistration,
|
|
5
5
|
} from "@frockbot/client-core";
|
|
6
|
+
import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
|
|
7
|
+
import { ref } from "vue";
|
|
6
8
|
import { routinesClientPlugin } from "./index.js";
|
|
7
9
|
import { routinesStateKey, type RoutinesClientState } from "./state.js";
|
|
8
10
|
|
|
@@ -207,3 +209,87 @@ describe("Routines client contribution", () => {
|
|
|
207
209
|
mounted.dispose();
|
|
208
210
|
});
|
|
209
211
|
});
|
|
212
|
+
|
|
213
|
+
describe("the completion badge and the state channel", () => {
|
|
214
|
+
/**
|
|
215
|
+
* A Routine that finishes cannot speak in the transcript, so the badge is
|
|
216
|
+
* the only place a completion becomes visible — and it used to read the
|
|
217
|
+
* inbox only on a Bot switch and on opening the drawer. A firing that
|
|
218
|
+
* completed while the app sat open left the count stale until something else
|
|
219
|
+
* happened to reload it, which for a `@every 1m` Routine is most of the day.
|
|
220
|
+
*/
|
|
221
|
+
test("reads the inbox again when the Bot's runs change", async () => {
|
|
222
|
+
const inboxReads: string[] = [];
|
|
223
|
+
let invalidate:
|
|
224
|
+
((topic: "computer" | "runs" | undefined) => Promise<void>) | undefined;
|
|
225
|
+
let stopped = 0;
|
|
226
|
+
const shell = ref({ activeBotId: "scout" });
|
|
227
|
+
const context: ClientPluginContext = {
|
|
228
|
+
transport: {
|
|
229
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
230
|
+
hostedRequest: (path) => {
|
|
231
|
+
if (path.endsWith("/inbox")) {
|
|
232
|
+
inboxReads.push(path);
|
|
233
|
+
return Promise.resolve({
|
|
234
|
+
schemaVersion: 1,
|
|
235
|
+
botId: "scout",
|
|
236
|
+
entries: [],
|
|
237
|
+
unacknowledged: inboxReads.length,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return Promise.resolve({
|
|
241
|
+
schemaVersion: 1,
|
|
242
|
+
botId: "scout",
|
|
243
|
+
routines: [],
|
|
244
|
+
});
|
|
245
|
+
},
|
|
246
|
+
watchBotState: (_botId, listener) => {
|
|
247
|
+
invalidate = listener.invalidate;
|
|
248
|
+
return () => {
|
|
249
|
+
stopped += 1;
|
|
250
|
+
};
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
inject: (key) => {
|
|
254
|
+
if (key === frockBotWebDataKey) return shell as never;
|
|
255
|
+
throw new Error("unexpected client provider");
|
|
256
|
+
},
|
|
257
|
+
provide: () => () => {},
|
|
258
|
+
slot: () => () => {},
|
|
259
|
+
};
|
|
260
|
+
const disposers = routinesClientPlugin(context);
|
|
261
|
+
if (!Array.isArray(disposers)) throw new Error("expected registrations");
|
|
262
|
+
|
|
263
|
+
expect(invalidate).toBeDefined();
|
|
264
|
+
// A Turn settling on this Bot — an automation Turn is one — refreshes it.
|
|
265
|
+
await invalidate!("runs");
|
|
266
|
+
// A resynchronise carries no topic and must not be filtered out.
|
|
267
|
+
await invalidate!(undefined);
|
|
268
|
+
// Another subsystem's news is not the badge's business.
|
|
269
|
+
await invalidate!("computer");
|
|
270
|
+
expect(inboxReads).toHaveLength(2);
|
|
271
|
+
|
|
272
|
+
shell.value = { activeBotId: "other" };
|
|
273
|
+
await Promise.resolve();
|
|
274
|
+
expect(stopped).toBe(1);
|
|
275
|
+
for (const dispose of disposers.toReversed()) dispose();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("does not reach for the shell when the client has no state channel", () => {
|
|
279
|
+
// The Cordis local host has no channel. Injecting the shell there would
|
|
280
|
+
// throw on mount and take the whole Contribution with it.
|
|
281
|
+
const context: ClientPluginContext = {
|
|
282
|
+
transport: {
|
|
283
|
+
turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
|
|
284
|
+
hostedRequest: () =>
|
|
285
|
+
Promise.resolve({ schemaVersion: 1, botId: "scout", routines: [] }),
|
|
286
|
+
},
|
|
287
|
+
inject: () => {
|
|
288
|
+
throw new Error("unexpected client provider");
|
|
289
|
+
},
|
|
290
|
+
provide: () => () => {},
|
|
291
|
+
slot: () => () => {},
|
|
292
|
+
};
|
|
293
|
+
expect(() => routinesClientPlugin(context)).not.toThrow();
|
|
294
|
+
});
|
|
295
|
+
});
|
package/src/client/index.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// versioned command with its own idempotency key, and every read is decoded at
|
|
8
8
|
// the seam before a component sees it.
|
|
9
9
|
import type { ClientPlugin } from "@frockbot/client-core";
|
|
10
|
-
import {
|
|
10
|
+
import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
|
|
11
|
+
import { ref, watch } from "vue";
|
|
11
12
|
import {
|
|
12
13
|
decodeRoutineCommandReceiptV1,
|
|
13
14
|
decodeRoutineInboxReceiptV1,
|
|
@@ -262,6 +263,40 @@ export const routinesClientPlugin: ClientPlugin = (ctx) => {
|
|
|
262
263
|
},
|
|
263
264
|
});
|
|
264
265
|
|
|
266
|
+
// The badge is the only place a firing that finished becomes visible, and it
|
|
267
|
+
// used to load only on a Bot switch and on opening the drawer: a Routine that
|
|
268
|
+
// completed while the app was open left the count stale until something else
|
|
269
|
+
// happened to reload it. The state channel already says when this Bot's runs
|
|
270
|
+
// changed — an automation Turn settling is one of those — so the badge reads
|
|
271
|
+
// the inbox again on that signal rather than polling or waiting for a click.
|
|
272
|
+
const watchBotState = ctx.transport.watchBotState;
|
|
273
|
+
if (watchBotState) {
|
|
274
|
+
const shell = ctx.inject(frockBotWebDataKey);
|
|
275
|
+
let stopWatching: (() => void) | undefined;
|
|
276
|
+
watch(
|
|
277
|
+
() => shell.value.activeBotId,
|
|
278
|
+
(activeBotId) => {
|
|
279
|
+
stopWatching?.();
|
|
280
|
+
stopWatching = undefined;
|
|
281
|
+
if (!activeBotId) return;
|
|
282
|
+
stopWatching = watchBotState(activeBotId, {
|
|
283
|
+
async invalidate(topic) {
|
|
284
|
+
// `undefined` is a resynchronise, so it is not filtered out.
|
|
285
|
+
if (topic !== undefined && topic !== "runs") return;
|
|
286
|
+
if (shell.value.activeBotId !== activeBotId) return;
|
|
287
|
+
await state.value.loadInbox(activeBotId);
|
|
288
|
+
},
|
|
289
|
+
status() {
|
|
290
|
+
// The badge has nothing to say about the socket itself; a closed
|
|
291
|
+
// channel simply stops refreshing it, and opening the drawer still
|
|
292
|
+
// reads the inbox.
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
},
|
|
296
|
+
{ immediate: true },
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
265
300
|
return [
|
|
266
301
|
ctx.provide(routinesStateKey, state),
|
|
267
302
|
ctx.slot({
|
package/src/hook.test.ts
CHANGED
|
@@ -66,7 +66,19 @@ describe("the hook token", () => {
|
|
|
66
66
|
(error: unknown) => error,
|
|
67
67
|
);
|
|
68
68
|
expect(refusal).toBeInstanceOf(RoutineHookError);
|
|
69
|
-
|
|
69
|
+
// A door that was never given a key is unavailable, not broken, and the
|
|
70
|
+
// caller is told that and nothing more: the reason names the deployment's
|
|
71
|
+
// secret variable and stays in the log.
|
|
72
|
+
expect((refusal as RoutineHookError).status).toBe(503);
|
|
73
|
+
expect((refusal as RoutineHookError).message).toContain(
|
|
74
|
+
"ROUTINE_HOOK_SECRET",
|
|
75
|
+
);
|
|
76
|
+
expect((refusal as RoutineHookError).publicMessage).toBe(
|
|
77
|
+
"webhook delivery is not configured",
|
|
78
|
+
);
|
|
79
|
+
expect((refusal as RoutineHookError).publicMessage).not.toContain(
|
|
80
|
+
"ROUTINE_HOOK_SECRET",
|
|
81
|
+
);
|
|
70
82
|
});
|
|
71
83
|
|
|
72
84
|
test("a key version is part of the token, so a rotation is a new token", async () => {
|
|
@@ -92,14 +104,13 @@ describe("the hook token", () => {
|
|
|
92
104
|
});
|
|
93
105
|
|
|
94
106
|
describe("delivery identity", () => {
|
|
95
|
-
test("is
|
|
96
|
-
|
|
97
|
-
|
|
107
|
+
test("is unique per request when the caller sent no idempotency key", async () => {
|
|
108
|
+
// Two real events with the same payload are two deliveries. Hashing the
|
|
109
|
+
// body used to turn the second one into a `duplicate` receipt and no
|
|
110
|
+
// firing, with nothing anywhere saying an event had been swallowed.
|
|
111
|
+
expect(await routineDeliveryIdV1("brief", '{"ping":true}')).not.toBe(
|
|
112
|
+
await routineDeliveryIdV1("brief", '{"ping":true}'),
|
|
98
113
|
);
|
|
99
|
-
expect(await routineDeliveryIdV1("brief", '{"a":1}')).not.toBe(
|
|
100
|
-
await routineDeliveryIdV1("brief", '{"a":2}'),
|
|
101
|
-
);
|
|
102
|
-
// Two Routines receiving the same body are two deliveries.
|
|
103
114
|
expect(await routineDeliveryIdV1("brief", "{}")).not.toBe(
|
|
104
115
|
await routineDeliveryIdV1("other", "{}"),
|
|
105
116
|
);
|
|
@@ -235,9 +246,11 @@ describe("the durable half of the check", () => {
|
|
|
235
246
|
const receipt = await store.execute(create, USER);
|
|
236
247
|
const token = (receipt as { hook: { token: string } }).hook.token;
|
|
237
248
|
|
|
238
|
-
|
|
249
|
+
// A replay is a delivery the caller itself said was the same one, by
|
|
250
|
+
// sending the key twice.
|
|
251
|
+
const first = await deliver(store, token, '{"event":"push"}', "evt-1");
|
|
239
252
|
expect(first.status).toBe("accepted");
|
|
240
|
-
const second = await deliver(store, token, '{"event":"push"}');
|
|
253
|
+
const second = await deliver(store, token, '{"event":"push"}', "evt-1");
|
|
241
254
|
expect(second).toEqual({ status: "duplicate", fireId: first.fireId });
|
|
242
255
|
|
|
243
256
|
const fired: string[] = [];
|
|
@@ -250,6 +263,29 @@ describe("the durable half of the check", () => {
|
|
|
250
263
|
expect(fired).toEqual([first.fireId]);
|
|
251
264
|
});
|
|
252
265
|
|
|
266
|
+
test("two distinct deliveries with identical bodies are two firings", async () => {
|
|
267
|
+
const { scheduler, store, create } = harness();
|
|
268
|
+
const receipt = await store.execute(create, USER);
|
|
269
|
+
const token = (receipt as { hook: { token: string } }).hook.token;
|
|
270
|
+
|
|
271
|
+
// A provider that sends `{"event":"push"}` twice sent two events. Without
|
|
272
|
+
// an `Idempotency-Key` nothing has claimed they are the same delivery, and
|
|
273
|
+
// the second used to be answered `duplicate` and never fired — a swallowed
|
|
274
|
+
// event with no trace anywhere.
|
|
275
|
+
const first = await deliver(store, token, '{"event":"push"}');
|
|
276
|
+
const second = await deliver(store, token, '{"event":"push"}');
|
|
277
|
+
expect(first.status).toBe("accepted");
|
|
278
|
+
expect(second.status).toBe("accepted");
|
|
279
|
+
expect(second.fireId).not.toBe(first.fireId);
|
|
280
|
+
|
|
281
|
+
const fired: string[] = [];
|
|
282
|
+
await scheduler.settle(async (fire) => {
|
|
283
|
+
fired.push(fire.fireId);
|
|
284
|
+
return { status: "ok" };
|
|
285
|
+
});
|
|
286
|
+
expect(fired.sort()).toEqual([first.fireId, second.fireId].sort());
|
|
287
|
+
});
|
|
288
|
+
|
|
253
289
|
test("a rotated key retires the one before it", async () => {
|
|
254
290
|
const { store, create } = harness();
|
|
255
291
|
const created = await store.execute(create, USER);
|
package/src/hook.ts
CHANGED
|
@@ -70,9 +70,18 @@ export interface RoutineDeliveryReceiptV1 {
|
|
|
70
70
|
export class RoutineHookError extends Error {
|
|
71
71
|
override readonly name = "RoutineHookError";
|
|
72
72
|
readonly status: number;
|
|
73
|
-
|
|
73
|
+
/**
|
|
74
|
+
* What an anonymous caller is told. It is deliberately separate from
|
|
75
|
+
* `message`: the delivery route answers the open internet, and a refusal
|
|
76
|
+
* used to hand back the raw reason — including the name of the deployment's
|
|
77
|
+
* signing-secret variable and whether it was missing or merely short. The
|
|
78
|
+
* detail stays in `message` for the log; this is the wire body.
|
|
79
|
+
*/
|
|
80
|
+
readonly publicMessage: string;
|
|
81
|
+
constructor(status: number, message: string, publicMessage?: string) {
|
|
74
82
|
super(message);
|
|
75
83
|
this.status = status;
|
|
84
|
+
this.publicMessage = publicMessage ?? message;
|
|
76
85
|
}
|
|
77
86
|
}
|
|
78
87
|
|
|
@@ -124,8 +133,10 @@ export function constantTimeEqualsV1(left: string, right: string): boolean {
|
|
|
124
133
|
async function signingKey(secret: string): Promise<CryptoKey> {
|
|
125
134
|
if (typeof secret !== "string" || secret.length < 16) {
|
|
126
135
|
throw new RoutineHookError(
|
|
127
|
-
|
|
136
|
+
503,
|
|
128
137
|
"ROUTINE_HOOK_SECRET is missing or too short for webhook delivery",
|
|
138
|
+
// The caller learns the door is shut, not how it is built.
|
|
139
|
+
"webhook delivery is not configured",
|
|
129
140
|
);
|
|
130
141
|
}
|
|
131
142
|
return crypto.subtle.importKey(
|
|
@@ -282,30 +293,39 @@ export function decodeRoutineHookKeyV1(value: unknown): RoutineHookKeyV1 {
|
|
|
282
293
|
}
|
|
283
294
|
|
|
284
295
|
/**
|
|
285
|
-
* The delivery id one request is remembered by
|
|
286
|
-
*
|
|
287
|
-
*
|
|
296
|
+
* The delivery id one request is remembered by.
|
|
297
|
+
*
|
|
298
|
+
* Idempotency is the caller's claim to make, and only the caller can make it:
|
|
299
|
+
* an `Idempotency-Key` says "this is the same delivery I already sent", and
|
|
300
|
+
* two deliveries under one key are one firing. Without a key the id is unique
|
|
301
|
+
* per request, so two deliveries are two firings.
|
|
302
|
+
*
|
|
303
|
+
* It used to hash the body when no key was sent. That reads as a safety net
|
|
304
|
+
* and is really a silent decision about someone else's data: a provider
|
|
305
|
+
* POSTing `{"ping":true}` twice — two real events, identical payloads — got
|
|
306
|
+
* one firing and a `duplicate` receipt for the second, with nothing anywhere
|
|
307
|
+
* saying an event had been dropped. Coalescing without being asked to is worse
|
|
308
|
+
* than firing twice, because the caller can see a double firing and cannot see
|
|
309
|
+
* a swallowed one.
|
|
288
310
|
*/
|
|
289
311
|
export async function routineDeliveryIdV1(
|
|
290
312
|
routineId: string,
|
|
291
313
|
body: string,
|
|
292
314
|
idempotencyKey?: string | null,
|
|
293
315
|
): Promise<string> {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
);
|
|
303
|
-
}
|
|
316
|
+
const trimmed = idempotencyKey?.trim().slice(0, 256) ?? "";
|
|
317
|
+
if (trimmed.length > 0) {
|
|
318
|
+
return hex(
|
|
319
|
+
await crypto.subtle.digest(
|
|
320
|
+
"SHA-256",
|
|
321
|
+
TEXT.encode(`key${routineId}${trimmed}`),
|
|
322
|
+
),
|
|
323
|
+
);
|
|
304
324
|
}
|
|
305
325
|
return hex(
|
|
306
326
|
await crypto.subtle.digest(
|
|
307
327
|
"SHA-256",
|
|
308
|
-
TEXT.encode(`
|
|
328
|
+
TEXT.encode(`delivery${routineId}${crypto.randomUUID()}`),
|
|
309
329
|
),
|
|
310
330
|
);
|
|
311
331
|
}
|
package/src/inbox-store.ts
CHANGED
|
@@ -208,6 +208,33 @@ export interface StoredPendingInputV1 {
|
|
|
208
208
|
input: PendingBotInputV1;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The inputs one drain carries, under the pending-input bound.
|
|
213
|
+
*
|
|
214
|
+
* The bound exists so a burst cannot hand a single Turn an unbounded prompt,
|
|
215
|
+
* and it used to be a flat `slice(-16)` over everything queued. But the four
|
|
216
|
+
* input kinds are not interchangeable. A dropped `wake` still has an inbox
|
|
217
|
+
* entry, so the user can read it and nothing is lost; an `approval`, a
|
|
218
|
+
* `machine-result` or a `superseded-turn` writes no entry anywhere, so
|
|
219
|
+
* dropping one silently loses a decision the user made or a result a machine
|
|
220
|
+
* produced. Those are kept whole and the cap falls on the wakes alone — the
|
|
221
|
+
* only kind that can be dropped and still be read.
|
|
222
|
+
*/
|
|
223
|
+
export function retainedPendingInputsV1(
|
|
224
|
+
inputs: readonly PendingBotInputV1[],
|
|
225
|
+
): PendingBotInputV1[] {
|
|
226
|
+
if (inputs.length <= ROUTINE_PENDING_INPUT_LIMIT) return [...inputs];
|
|
227
|
+
const durable = inputs.filter((input) => input.kind !== "wake");
|
|
228
|
+
const budget = ROUTINE_PENDING_INPUT_LIMIT - durable.length;
|
|
229
|
+
if (budget <= 0) return durable;
|
|
230
|
+
const keptWakes = new Set(
|
|
231
|
+
inputs.filter((input) => input.kind === "wake").slice(-budget),
|
|
232
|
+
);
|
|
233
|
+
return inputs.filter(
|
|
234
|
+
(input) => input.kind !== "wake" || keptWakes.has(input),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
211
238
|
export class RoutineInboxStore {
|
|
212
239
|
readonly #storage: RoutineStorageV1;
|
|
213
240
|
readonly #now: () => Date;
|
|
@@ -365,7 +392,7 @@ export class RoutineInboxStore {
|
|
|
365
392
|
// active run, so no firing can settle while it runs and no wake can
|
|
366
393
|
// arrive behind this read; an empty receipt would be a record of nothing.
|
|
367
394
|
if (inputs.length === 0) return [];
|
|
368
|
-
const retained = inputs
|
|
395
|
+
const retained = retainedPendingInputsV1(inputs);
|
|
369
396
|
await transaction.put(receiptKey, {
|
|
370
397
|
schemaVersion: 1,
|
|
371
398
|
runId,
|
|
@@ -377,15 +404,40 @@ export class RoutineInboxStore {
|
|
|
377
404
|
});
|
|
378
405
|
}
|
|
379
406
|
|
|
380
|
-
/** Trim the inbox to its retention bound,
|
|
407
|
+
/** Trim the inbox to its retention bound, acknowledged entries first. */
|
|
381
408
|
async #trimInbox(): Promise<void> {
|
|
382
409
|
const stored = await this.#storage.list<unknown>({
|
|
383
410
|
prefix: ROUTINE_INBOX_PREFIX,
|
|
384
411
|
});
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
412
|
+
// Inbox keys are descending, so ascending key order is newest first and
|
|
413
|
+
// the oldest entry is the last of them.
|
|
414
|
+
const entries = [...stored.entries()]
|
|
415
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
416
|
+
.reverse();
|
|
417
|
+
let excess = entries.length - ROUTINE_INBOX_LIMIT;
|
|
418
|
+
if (excess <= 0) return;
|
|
419
|
+
// Read entries go first, oldest read before newest, and only then unread
|
|
420
|
+
// ones. Trimming purely by age used to drop a completion the reader had
|
|
421
|
+
// never seen while a dozen they had already acknowledged sat beside it —
|
|
422
|
+
// the inbox is the only place a firing ever speaks, so what has not been
|
|
423
|
+
// read is the last thing to lose.
|
|
424
|
+
const acknowledged: string[] = [];
|
|
425
|
+
const unread: string[] = [];
|
|
426
|
+
for (const [key, value] of entries) {
|
|
427
|
+
let read = false;
|
|
428
|
+
try {
|
|
429
|
+
read = decodeRoutineInboxEntryV1(value).acknowledged;
|
|
430
|
+
} catch {
|
|
431
|
+
// An entry nothing can decode says nothing to anyone; it is the first
|
|
432
|
+
// thing worth reclaiming space from.
|
|
433
|
+
read = true;
|
|
434
|
+
}
|
|
435
|
+
(read ? acknowledged : unread).push(key);
|
|
436
|
+
}
|
|
437
|
+
for (const key of [...acknowledged, ...unread]) {
|
|
438
|
+
if (excess <= 0) return;
|
|
388
439
|
await this.#storage.delete(key);
|
|
440
|
+
excess -= 1;
|
|
389
441
|
}
|
|
390
442
|
}
|
|
391
443
|
|
package/src/inbox.test.ts
CHANGED
|
@@ -7,11 +7,16 @@ import {
|
|
|
7
7
|
routineHandoffTextV1,
|
|
8
8
|
subagentAttributionV1,
|
|
9
9
|
} from "./inbox.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
retainedPendingInputsV1,
|
|
12
|
+
RoutineInboxStore,
|
|
13
|
+
routineTerminalRecordsV1,
|
|
14
|
+
} from "./inbox-store.js";
|
|
11
15
|
import { createMemoryRoutineStorageV1 } from "./testing.js";
|
|
12
16
|
import {
|
|
13
17
|
ROUTINE_INBOX_LIMIT,
|
|
14
18
|
ROUTINE_INBOX_PREFIX,
|
|
19
|
+
ROUTINE_PENDING_INPUT_LIMIT,
|
|
15
20
|
ROUTINE_WAKE_PREFIX,
|
|
16
21
|
} from "./storage-keys.js";
|
|
17
22
|
|
|
@@ -435,3 +440,99 @@ describe("a Turn the User's next message replaced", () => {
|
|
|
435
440
|
).not.toContain("Subagents");
|
|
436
441
|
});
|
|
437
442
|
});
|
|
443
|
+
|
|
444
|
+
describe("the pending-input cap", () => {
|
|
445
|
+
/**
|
|
446
|
+
* The four input kinds are not interchangeable. A dropped `wake` still has
|
|
447
|
+
* an inbox entry the user can read; an `approval`, a `machine-result` or a
|
|
448
|
+
* `superseded-turn` writes no entry anywhere, so dropping one loses a
|
|
449
|
+
* decision the user made or a result a machine produced, silently.
|
|
450
|
+
*/
|
|
451
|
+
test("keeps every non-wake input and spends the budget on the wakes", async () => {
|
|
452
|
+
const store = storage();
|
|
453
|
+
const inbox = new RoutineInboxStore(store);
|
|
454
|
+
for (let index = 0; index < ROUTINE_PENDING_INPUT_LIMIT + 4; index += 1) {
|
|
455
|
+
await settle(store, { runId: `rf-${index}`, handoff: `wake ${index}` });
|
|
456
|
+
}
|
|
457
|
+
await inbox.enqueue({
|
|
458
|
+
schemaVersion: 1,
|
|
459
|
+
kind: "approval",
|
|
460
|
+
approvalId: "ap-1",
|
|
461
|
+
decision: "approved",
|
|
462
|
+
createdAt: NOW,
|
|
463
|
+
});
|
|
464
|
+
await inbox.enqueue({
|
|
465
|
+
schemaVersion: 1,
|
|
466
|
+
kind: "superseded-turn",
|
|
467
|
+
runId: "run-9",
|
|
468
|
+
unfinishedWork: true,
|
|
469
|
+
createdAt: NOW,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const drained = await inbox.drainInto("chat-run-1");
|
|
473
|
+
expect(drained).toHaveLength(ROUTINE_PENDING_INPUT_LIMIT);
|
|
474
|
+
// The approval decision and the superseded Turn survive; a flat
|
|
475
|
+
// `slice(-16)` used to drop whichever of them sat behind enough wakes.
|
|
476
|
+
expect(drained.filter((input) => input.kind === "approval")).toHaveLength(
|
|
477
|
+
1,
|
|
478
|
+
);
|
|
479
|
+
expect(
|
|
480
|
+
drained.filter((input) => input.kind === "superseded-turn"),
|
|
481
|
+
).toHaveLength(1);
|
|
482
|
+
expect(drained.filter((input) => input.kind === "wake")).toHaveLength(
|
|
483
|
+
ROUTINE_PENDING_INPUT_LIMIT - 2,
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test("keeps every durable input even when they alone exceed the bound", () => {
|
|
488
|
+
const approvals = Array.from(
|
|
489
|
+
{ length: ROUTINE_PENDING_INPUT_LIMIT + 3 },
|
|
490
|
+
(_unused, index) =>
|
|
491
|
+
({
|
|
492
|
+
schemaVersion: 1,
|
|
493
|
+
kind: "approval",
|
|
494
|
+
approvalId: `ap-${index}`,
|
|
495
|
+
decision: "approved",
|
|
496
|
+
createdAt: NOW,
|
|
497
|
+
}) as const,
|
|
498
|
+
);
|
|
499
|
+
// Over the bound is a problem worth having; losing an approval decision
|
|
500
|
+
// with nothing anywhere recording it is not.
|
|
501
|
+
expect(retainedPendingInputsV1(approvals)).toHaveLength(approvals.length);
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
describe("inbox retention", () => {
|
|
506
|
+
test("gives up read entries before unread ones", async () => {
|
|
507
|
+
const store = storage();
|
|
508
|
+
const inbox = new RoutineInboxStore(store);
|
|
509
|
+
for (let index = 0; index < ROUTINE_INBOX_LIMIT; index += 1) {
|
|
510
|
+
await settle(store, {
|
|
511
|
+
runId: `rf-${String(index).padStart(4, "0")}`,
|
|
512
|
+
responseText: `run ${index}`,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
// The reader has caught up on everything so far.
|
|
516
|
+
await inbox.acknowledge([]);
|
|
517
|
+
// Five more land while they are away.
|
|
518
|
+
for (let index = 0; index < 5; index += 1) {
|
|
519
|
+
await settle(store, {
|
|
520
|
+
runId: `rf-new-${index}`,
|
|
521
|
+
responseText: `fresh ${index}`,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const entries = await inbox.list();
|
|
526
|
+
expect(entries).toHaveLength(ROUTINE_INBOX_LIMIT);
|
|
527
|
+
// Trimming purely by age used to drop whatever was oldest regardless of
|
|
528
|
+
// whether it had ever been read. Nothing unread is gone here.
|
|
529
|
+
const unread = entries.filter((entry) => !entry.acknowledged);
|
|
530
|
+
expect(unread.map((entry) => entry.text)).toEqual([
|
|
531
|
+
"fresh 4",
|
|
532
|
+
"fresh 3",
|
|
533
|
+
"fresh 2",
|
|
534
|
+
"fresh 1",
|
|
535
|
+
"fresh 0",
|
|
536
|
+
]);
|
|
537
|
+
});
|
|
538
|
+
});
|
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
|
});
|
|
@@ -901,3 +902,100 @@ describe("an undecodable Routine record", () => {
|
|
|
901
902
|
expect(await drain(scheduler)).toHaveLength(1);
|
|
902
903
|
});
|
|
903
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
|
@@ -63,7 +63,6 @@ import {
|
|
|
63
63
|
ROUTINE_FAILURE_PAUSE_AFTER,
|
|
64
64
|
ROUTINE_FIRE_TIMEOUT_MS,
|
|
65
65
|
ROUTINE_LIMIT_PER_BOT,
|
|
66
|
-
ROUTINE_MISSED_GRACE_MS,
|
|
67
66
|
ROUTINE_PREFIX,
|
|
68
67
|
ROUTINE_QUEUE_LIMIT,
|
|
69
68
|
ROUTINE_QUEUE_PREFIX,
|
|
@@ -71,6 +70,7 @@ import {
|
|
|
71
70
|
routineKeyV1,
|
|
72
71
|
routineQueueKeyV1,
|
|
73
72
|
routineQueuePrefixV1,
|
|
73
|
+
routineRunPrefixV1,
|
|
74
74
|
routineScheduleKeyV1,
|
|
75
75
|
} from "./storage-keys.js";
|
|
76
76
|
|
|
@@ -603,7 +603,9 @@ export class RoutineScheduler {
|
|
|
603
603
|
record: RoutineRecordV1,
|
|
604
604
|
state: RoutineScheduleStateV1,
|
|
605
605
|
now: Date,
|
|
606
|
-
|
|
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> {
|
|
607
609
|
const normalized = normalizeRoutineScheduleV1(
|
|
608
610
|
record.schedule!,
|
|
609
611
|
record.timezone,
|
|
@@ -642,13 +644,38 @@ export class RoutineScheduler {
|
|
|
642
644
|
// re-owe this occurrence.
|
|
643
645
|
const from = missedCount > 1 ? now : new Date(state.dueAt);
|
|
644
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
|
+
}
|
|
645
674
|
await transaction.put(routineScheduleKeyV1(record.routineId), {
|
|
646
675
|
schemaVersion: 1,
|
|
647
676
|
routineId: record.routineId,
|
|
648
677
|
anchor: record.updatedAt,
|
|
649
|
-
dueAt: (
|
|
650
|
-
next ?? new Date(now.getTime() + ROUTINE_MISSED_GRACE_MS)
|
|
651
|
-
).getTime(),
|
|
678
|
+
dueAt: next.getTime(),
|
|
652
679
|
...(state.consecutiveFailures === undefined
|
|
653
680
|
? {}
|
|
654
681
|
: { consecutiveFailures: state.consecutiveFailures }),
|
|
@@ -889,6 +916,23 @@ export class RoutineScheduler {
|
|
|
889
916
|
const finishedAt = at.toISOString();
|
|
890
917
|
await this.#storage.transaction(async (transaction) => {
|
|
891
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
|
+
}
|
|
892
936
|
await this.#recordOutcomeOnClock(transaction, fire, outcome, at);
|
|
893
937
|
if (outcome.status !== "ok") {
|
|
894
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
|
|
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
|
}
|