@frockbot/plugin-routines 0.0.0 → 0.1.1

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.
@@ -0,0 +1,150 @@
1
+ <script setup lang="ts">
2
+ // The Routines glance in the default Bot panel. Every row comes from the same
3
+ // durable listing the full editor reads.
4
+ import { UiAnchor, UiIcon } from "@frockbot/client-ui";
5
+ import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
6
+ import { settingsLinkV1 } from "@frockbot/plugin-shell/settings-links";
7
+ import { computed, inject, watch } from "vue";
8
+ import { routinesStateKey } from "./state.js";
9
+
10
+ const providedWeb = inject(frockBotWebDataKey);
11
+ const providedState = inject(routinesStateKey);
12
+ if (!providedWeb || !providedState) {
13
+ throw new Error("Routines client services were not provided");
14
+ }
15
+ const web = providedWeb;
16
+ const routines = providedState;
17
+ const botId = computed(() => web.value.activeBotId);
18
+ const rows = computed(() =>
19
+ routines.value.botId === botId.value ? routines.value.routines : [],
20
+ );
21
+ const link = computed(() =>
22
+ settingsLinkV1({ anchor: "bot-info-routines", botId: botId.value }),
23
+ );
24
+ const sectionLink = computed(() =>
25
+ settingsLinkV1({ anchor: "bot-routines", botId: botId.value }),
26
+ );
27
+
28
+ watch(
29
+ botId,
30
+ (id) => {
31
+ if (!id) return;
32
+ if (routines.value.botId !== id || !routines.value.loaded) {
33
+ void routines.value.load(id);
34
+ }
35
+ },
36
+ { immediate: true },
37
+ );
38
+ </script>
39
+
40
+ <template>
41
+ <UiAnchor
42
+ as="section"
43
+ anchor="bot-info-routines"
44
+ label="Routines"
45
+ :href="link"
46
+ class="routines-summary"
47
+ >
48
+ <header class="routines-summary__head">
49
+ <h3>Routines</h3>
50
+ <a
51
+ class="routines-summary__add"
52
+ :href="sectionLink"
53
+ aria-label="Open Routines editor"
54
+ title="Open Routines editor"
55
+ >
56
+ <UiIcon name="plus" size="sm" />
57
+ </a>
58
+ </header>
59
+ <p v-if="rows.length === 0" class="routines-summary__empty">
60
+ No Routines yet.
61
+ </p>
62
+ <ul v-else class="routines-summary__rows">
63
+ <li v-for="routine in rows" :key="routine.routineId">
64
+ <span>
65
+ <strong>{{ routine.name }}</strong>
66
+ <small>{{ routine.schedule ?? "Webhook" }}</small>
67
+ </span>
68
+ <small>{{ routine.enabled ? "On" : "Paused" }}</small>
69
+ </li>
70
+ </ul>
71
+ </UiAnchor>
72
+ </template>
73
+
74
+ <style scoped>
75
+ .routines-summary {
76
+ display: flex;
77
+ flex-direction: column;
78
+ gap: 8px;
79
+ padding-right: var(--frock-control-sm);
80
+ }
81
+
82
+ .routines-summary__head {
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 8px;
86
+ }
87
+
88
+ .routines-summary__add {
89
+ display: grid;
90
+ width: var(--frock-control-sm);
91
+ height: var(--frock-control-sm);
92
+ place-items: center;
93
+ border-radius: var(--frock-radius-control);
94
+ color: var(--frock-text);
95
+ }
96
+
97
+ .routines-summary__add:hover {
98
+ background: var(--frock-fill-hover);
99
+ }
100
+
101
+ .routines-summary__head h3 {
102
+ flex: 1 1 auto;
103
+ margin: 0;
104
+ font-size: var(--frock-text-md);
105
+ font-weight: 600;
106
+ }
107
+
108
+ .routines-summary__empty {
109
+ margin: 0;
110
+ color: var(--frock-text-muted);
111
+ font-size: var(--frock-text-sm);
112
+ }
113
+
114
+ .routines-summary__rows {
115
+ display: flex;
116
+ flex-direction: column;
117
+ gap: 2px;
118
+ margin: 0;
119
+ padding: 0;
120
+ list-style: none;
121
+ }
122
+
123
+ .routines-summary__rows li {
124
+ display: flex;
125
+ align-items: center;
126
+ justify-content: space-between;
127
+ gap: 10px;
128
+ padding: 9px 0;
129
+ border-bottom: 1px solid var(--frock-border);
130
+ }
131
+
132
+ .routines-summary__rows li > span {
133
+ display: flex;
134
+ min-width: 0;
135
+ flex-direction: column;
136
+ }
137
+
138
+ .routines-summary__rows strong {
139
+ overflow: hidden;
140
+ color: var(--frock-text);
141
+ font-size: var(--frock-text-sm);
142
+ text-overflow: ellipsis;
143
+ white-space: nowrap;
144
+ }
145
+
146
+ .routines-summary__rows small {
147
+ color: var(--frock-text-muted);
148
+ font-size: var(--frock-text-sm);
149
+ }
150
+ </style>
@@ -0,0 +1,209 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type {
3
+ ClientPluginContext,
4
+ ClientSlotRegistration,
5
+ } from "@frockbot/client-core";
6
+ import { routinesClientPlugin } from "./index.js";
7
+ import { routinesStateKey, type RoutinesClientState } from "./state.js";
8
+
9
+ const ROUTINE = {
10
+ schemaVersion: 1,
11
+ routineId: "brief",
12
+ name: "Morning brief",
13
+ prompt: "Summarize overnight email.",
14
+ schedule: "0 7 * * *",
15
+ timezone: "Australia/Sydney",
16
+ enabled: true,
17
+ createdBy: { kind: "user" },
18
+ updatedBy: { kind: "user" },
19
+ createdAt: "2026-08-31T00:00:00.000Z",
20
+ updatedAt: "2026-08-31T00:00:00.000Z",
21
+ };
22
+
23
+ function mount(
24
+ overrides: {
25
+ hostedRequest?: ClientPluginContext["transport"]["hostedRequest"];
26
+ } = {},
27
+ ): {
28
+ state: { value: RoutinesClientState };
29
+ slots: ClientSlotRegistration[];
30
+ calls: Array<[string, string | undefined, string | undefined]>;
31
+ dispose(): void;
32
+ } {
33
+ const slots: ClientSlotRegistration[] = [];
34
+ const calls: Array<[string, string | undefined, string | undefined]> = [];
35
+ let state: unknown;
36
+ const context: ClientPluginContext = {
37
+ transport: {
38
+ turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
39
+ hostedRequest:
40
+ overrides.hostedRequest ??
41
+ ((path, method, body) => {
42
+ calls.push([path, method, body]);
43
+ if (method === "POST") {
44
+ return Promise.resolve({
45
+ schemaVersion: 1,
46
+ commandId: (JSON.parse(body ?? "{}") as { commandId: string })
47
+ .commandId,
48
+ status: "applied",
49
+ routine: ROUTINE,
50
+ });
51
+ }
52
+ if (path.endsWith("/runs")) {
53
+ return Promise.resolve({
54
+ schemaVersion: 1,
55
+ botId: "scout",
56
+ routineId: "brief",
57
+ entries: [],
58
+ });
59
+ }
60
+ return Promise.resolve({
61
+ schemaVersion: 1,
62
+ botId: "scout",
63
+ routines: [ROUTINE],
64
+ });
65
+ }),
66
+ },
67
+ inject: () => {
68
+ throw new Error("unexpected client provider");
69
+ },
70
+ provide: (key, value) => {
71
+ if (key === routinesStateKey) state = value;
72
+ return () => {};
73
+ },
74
+ slot: (registration) => {
75
+ slots.push(registration);
76
+ return () => slots.splice(slots.indexOf(registration), 1);
77
+ },
78
+ };
79
+ const disposers = routinesClientPlugin(context);
80
+ if (!Array.isArray(disposers)) throw new Error("expected registrations");
81
+ return {
82
+ state: state as { value: RoutinesClientState },
83
+ slots,
84
+ calls,
85
+ dispose: () => {
86
+ for (const dispose of disposers.toReversed()) dispose();
87
+ },
88
+ };
89
+ }
90
+
91
+ describe("Routines client contribution", () => {
92
+ test("mounts into the settings outlet, the header and the Bot panel", () => {
93
+ const mounted = mount();
94
+ expect(mounted.slots.map((slot) => slot.slot)).toEqual([
95
+ "frockbot.bot-settings-sections",
96
+ "frockbot.header-actions",
97
+ "frockbot.bot-panel-sections",
98
+ ]);
99
+ mounted.dispose();
100
+ });
101
+
102
+ test("loads a Bot's Routines and decodes them at the seam", async () => {
103
+ const mounted = mount();
104
+ await mounted.state.value.load("scout");
105
+ expect(mounted.state.value.routines).toHaveLength(1);
106
+ expect(mounted.state.value.botId).toBe("scout");
107
+ expect(mounted.calls[0]).toEqual([
108
+ "/api/bots/scout/routines",
109
+ undefined,
110
+ undefined,
111
+ ]);
112
+ mounted.dispose();
113
+ });
114
+
115
+ test("submits one versioned command per action, each with its own key", async () => {
116
+ const mounted = mount();
117
+ await mounted.state.value.save("scout", {
118
+ name: "Morning brief",
119
+ prompt: "Summarize overnight email.",
120
+ schedule: "0 7 * * *",
121
+ timezone: "Australia/Sydney",
122
+ });
123
+ await mounted.state.value.setEnabled("scout", "brief", false);
124
+ await mounted.state.value.remove("scout", "brief");
125
+ const posts = mounted.calls
126
+ .filter(([, method]) => method === "POST")
127
+ .map(
128
+ ([, , body]) =>
129
+ JSON.parse(body ?? "{}") as { type: string; commandId: string },
130
+ );
131
+ expect(posts.map((post) => post.type)).toEqual([
132
+ "routine/create",
133
+ "routine/pause",
134
+ "routine/delete",
135
+ ]);
136
+ expect(new Set(posts.map((post) => post.commandId)).size).toBe(3);
137
+ mounted.dispose();
138
+ });
139
+
140
+ test("surfaces a refusal instead of throwing away the reason", async () => {
141
+ const mounted = mount({
142
+ hostedRequest: () =>
143
+ Promise.reject(new Error("cron expression must have five fields")),
144
+ });
145
+ await mounted.state.value.load("scout");
146
+ expect(mounted.state.value.error).toBe(
147
+ "cron expression must have five fields",
148
+ );
149
+ mounted.dispose();
150
+ });
151
+
152
+ test("loads a run log on demand", async () => {
153
+ const mounted = mount();
154
+ await mounted.state.value.loadRuns("scout", "brief");
155
+ expect(mounted.state.value.runs.brief).toEqual([]);
156
+ mounted.dispose();
157
+ });
158
+
159
+ test("run now posts routine/run and reloads the Routine and its log", async () => {
160
+ const mounted = mount({
161
+ hostedRequest: (path, method, body) => {
162
+ if (method === "POST") {
163
+ return Promise.resolve({
164
+ schemaVersion: 1,
165
+ commandId: (JSON.parse(body ?? "{}") as { commandId: string })
166
+ .commandId,
167
+ status: "fired",
168
+ routineId: "brief",
169
+ fireId: "rf-brief-manual-cmd",
170
+ });
171
+ }
172
+ if (path.endsWith("/runs")) {
173
+ return Promise.resolve({
174
+ schemaVersion: 1,
175
+ botId: "scout",
176
+ routineId: "brief",
177
+ entries: [
178
+ {
179
+ schemaVersion: 1,
180
+ entryId: "rf-brief-manual-cmd-entry",
181
+ runId: "rf-brief-manual-cmd",
182
+ trigger: "manual",
183
+ status: "running",
184
+ startedAt: "2026-08-31T00:00:00.000Z",
185
+ },
186
+ ],
187
+ });
188
+ }
189
+ return Promise.resolve({
190
+ schemaVersion: 1,
191
+ botId: "scout",
192
+ routines: [{ ...ROUTINE, nextRunAt: "2026-09-01T21:00:00.000Z" }],
193
+ });
194
+ },
195
+ });
196
+
197
+ await mounted.state.value.runNow("scout", "brief");
198
+ expect(mounted.state.value.error).toBeUndefined();
199
+ // The Routine came back with the moment the scheduler armed on.
200
+ expect(mounted.state.value.routines[0]?.nextRunAt).toBe(
201
+ "2026-09-01T21:00:00.000Z",
202
+ );
203
+ expect(mounted.state.value.runs.brief?.[0]).toMatchObject({
204
+ trigger: "manual",
205
+ status: "running",
206
+ });
207
+ mounted.dispose();
208
+ });
209
+ });
@@ -0,0 +1,289 @@
1
+ /// <reference path="../env.d.ts" />
2
+
3
+ // The Routines hosted client Contribution.
4
+ //
5
+ // "The hosted client renders backend state and submits commands. It does not
6
+ // become an alternate authority." Every write here is one `POST` of one
7
+ // versioned command with its own idempotency key, and every read is decoded at
8
+ // the seam before a component sees it.
9
+ import type { ClientPlugin } from "@frockbot/client-core";
10
+ import { ref } from "vue";
11
+ import {
12
+ decodeRoutineCommandReceiptV1,
13
+ decodeRoutineInboxReceiptV1,
14
+ decodeRoutineInboxViewV1,
15
+ decodeRoutineListViewV1,
16
+ decodeRoutineRunDetailViewV1,
17
+ decodeRoutineRunListViewV1,
18
+ } from "../shared.js";
19
+ import RoutineInboxBadge from "./RoutineInboxBadge.vue";
20
+ import RoutinesSection from "./RoutinesSection.vue";
21
+ import RoutinesSummary from "./RoutinesSummary.vue";
22
+ import {
23
+ routinesStateKey,
24
+ type RoutineFormSubmissionV1,
25
+ type RoutinesClientState,
26
+ } from "./state.js";
27
+
28
+ function message(error: unknown, fallback: string): string {
29
+ return error instanceof Error ? error.message : fallback;
30
+ }
31
+
32
+ export const routinesClientPlugin: ClientPlugin = (ctx) => {
33
+ const request = (
34
+ path: string,
35
+ method?: "GET" | "POST",
36
+ body?: string,
37
+ ): Promise<unknown> => {
38
+ if (!ctx.transport.hostedRequest) {
39
+ throw new Error("Routines are unavailable on this client");
40
+ }
41
+ return ctx.transport.hostedRequest(path, method, body);
42
+ };
43
+ const post = async (botId: string, command: unknown) => {
44
+ const receipt = decodeRoutineCommandReceiptV1(
45
+ await request(
46
+ `/api/bots/${encodeURIComponent(botId)}/routines`,
47
+ "POST",
48
+ JSON.stringify(command),
49
+ ),
50
+ );
51
+ // A minted key is on this receipt and on nothing else — no listing returns
52
+ // it and no second request can. It is held only for as long as the section
53
+ // is open.
54
+ if (receipt.status === "applied" && receipt.hook) {
55
+ state.value.mintedHook = receipt.hook;
56
+ }
57
+ return receipt;
58
+ };
59
+ const state = ref<RoutinesClientState>({
60
+ routines: [],
61
+ runs: {},
62
+ runDetails: {},
63
+ inbox: [],
64
+ unacknowledged: 0,
65
+ loaded: false,
66
+ busy: false,
67
+ async load(botId: string) {
68
+ try {
69
+ const view = decodeRoutineListViewV1(
70
+ await request(`/api/bots/${encodeURIComponent(botId)}/routines`),
71
+ );
72
+ state.value.botId = botId;
73
+ state.value.routines = view.routines;
74
+ state.value.loaded = true;
75
+ state.value.error = undefined;
76
+ } catch (error) {
77
+ state.value.error = message(error, "Could not load Routines");
78
+ }
79
+ },
80
+ async loadRuns(botId: string, routineId: string) {
81
+ try {
82
+ const view = decodeRoutineRunListViewV1(
83
+ await request(
84
+ `/api/bots/${encodeURIComponent(botId)}/routines/${encodeURIComponent(routineId)}/runs`,
85
+ ),
86
+ );
87
+ state.value.runs = { ...state.value.runs, [routineId]: view.entries };
88
+ state.value.error = undefined;
89
+ } catch (error) {
90
+ state.value.error = message(error, "Could not load the run log");
91
+ }
92
+ },
93
+ async loadRun(botId: string, routineId: string, runId: string) {
94
+ try {
95
+ const view = decodeRoutineRunDetailViewV1(
96
+ await request(
97
+ `/api/bots/${encodeURIComponent(botId)}/routines/${encodeURIComponent(routineId)}/runs/${encodeURIComponent(runId)}`,
98
+ ),
99
+ );
100
+ state.value.runDetails = { ...state.value.runDetails, [runId]: view };
101
+ state.value.error = undefined;
102
+ } catch (error) {
103
+ state.value.error = message(error, "Could not load the run");
104
+ }
105
+ },
106
+ async loadInbox(botId: string) {
107
+ try {
108
+ const view = decodeRoutineInboxViewV1(
109
+ await request(
110
+ `/api/bots/${encodeURIComponent(botId)}/routines/inbox`,
111
+ ),
112
+ );
113
+ state.value.inbox = view.entries;
114
+ state.value.unacknowledged = view.unacknowledged;
115
+ state.value.error = undefined;
116
+ } catch (error) {
117
+ state.value.error = message(error, "Could not load the inbox");
118
+ }
119
+ },
120
+ async acknowledgeInbox(botId: string, entryIds: string[]) {
121
+ state.value.busy = true;
122
+ try {
123
+ const receipt = decodeRoutineInboxReceiptV1(
124
+ await request(
125
+ `/api/bots/${encodeURIComponent(botId)}/routines/inbox`,
126
+ "POST",
127
+ JSON.stringify({
128
+ schemaVersion: 1,
129
+ commandId: crypto.randomUUID(),
130
+ botId,
131
+ type: "routine/acknowledge-inbox",
132
+ entryIds,
133
+ }),
134
+ ),
135
+ );
136
+ state.value.inbox = receipt.inbox.entries;
137
+ state.value.unacknowledged = receipt.inbox.unacknowledged;
138
+ state.value.error = undefined;
139
+ } catch (error) {
140
+ state.value.error = message(error, "Could not acknowledge the inbox");
141
+ } finally {
142
+ state.value.busy = false;
143
+ }
144
+ },
145
+ async save(botId: string, submission: RoutineFormSubmissionV1) {
146
+ state.value.busy = true;
147
+ try {
148
+ await post(botId, {
149
+ schemaVersion: 1,
150
+ type: submission.routineId ? "routine/update" : "routine/create",
151
+ commandId: crypto.randomUUID(),
152
+ botId,
153
+ ...(submission.routineId ? { routineId: submission.routineId } : {}),
154
+ name: submission.name,
155
+ prompt: submission.prompt,
156
+ ...(submission.schedule === undefined
157
+ ? {}
158
+ : { schedule: submission.schedule }),
159
+ ...(submission.trigger === undefined
160
+ ? {}
161
+ : { trigger: submission.trigger }),
162
+ timezone: submission.timezone,
163
+ });
164
+ await state.value.load(botId);
165
+ } catch (error) {
166
+ state.value.error = message(error, "Could not save the Routine");
167
+ throw error;
168
+ } finally {
169
+ state.value.busy = false;
170
+ }
171
+ },
172
+ async setEnabled(botId: string, routineId: string, enabled: boolean) {
173
+ state.value.busy = true;
174
+ try {
175
+ await post(botId, {
176
+ schemaVersion: 1,
177
+ type: enabled ? "routine/resume" : "routine/pause",
178
+ commandId: crypto.randomUUID(),
179
+ botId,
180
+ routineId,
181
+ });
182
+ await state.value.load(botId);
183
+ } catch (error) {
184
+ state.value.error = message(error, "Could not change the Routine");
185
+ } finally {
186
+ state.value.busy = false;
187
+ }
188
+ },
189
+ dismissHook() {
190
+ state.value.mintedHook = undefined;
191
+ },
192
+ async rotateKey(botId: string, routineId: string) {
193
+ state.value.busy = true;
194
+ try {
195
+ await post(botId, {
196
+ schemaVersion: 1,
197
+ type: "routine/rotate-key",
198
+ commandId: crypto.randomUUID(),
199
+ botId,
200
+ routineId,
201
+ });
202
+ await state.value.load(botId);
203
+ } catch (error) {
204
+ state.value.error = message(error, "Could not mint a webhook key");
205
+ } finally {
206
+ state.value.busy = false;
207
+ }
208
+ },
209
+ async revokeKey(botId: string, routineId: string) {
210
+ state.value.busy = true;
211
+ try {
212
+ state.value.mintedHook = undefined;
213
+ await post(botId, {
214
+ schemaVersion: 1,
215
+ type: "routine/revoke-key",
216
+ commandId: crypto.randomUUID(),
217
+ botId,
218
+ routineId,
219
+ });
220
+ await state.value.load(botId);
221
+ } catch (error) {
222
+ state.value.error = message(error, "Could not revoke the webhook key");
223
+ } finally {
224
+ state.value.busy = false;
225
+ }
226
+ },
227
+ async runNow(botId: string, routineId: string) {
228
+ state.value.busy = true;
229
+ try {
230
+ await post(botId, {
231
+ schemaVersion: 1,
232
+ type: "routine/run",
233
+ commandId: crypto.randomUUID(),
234
+ botId,
235
+ routineId,
236
+ });
237
+ await state.value.load(botId);
238
+ await state.value.loadRuns(botId, routineId);
239
+ } catch (error) {
240
+ state.value.error = message(error, "Could not run the Routine");
241
+ } finally {
242
+ state.value.busy = false;
243
+ }
244
+ },
245
+ async remove(botId: string, routineId: string) {
246
+ state.value.busy = true;
247
+ try {
248
+ await post(botId, {
249
+ schemaVersion: 1,
250
+ type: "routine/delete",
251
+ commandId: crypto.randomUUID(),
252
+ botId,
253
+ routineId,
254
+ });
255
+ await state.value.load(botId);
256
+ } catch (error) {
257
+ state.value.error = message(error, "Could not delete the Routine");
258
+ } finally {
259
+ state.value.busy = false;
260
+ }
261
+ },
262
+ });
263
+
264
+ return [
265
+ ctx.provide(routinesStateKey, state),
266
+ ctx.slot({
267
+ slot: "frockbot.bot-settings-sections",
268
+ order: 10,
269
+ component: RoutinesSection,
270
+ }),
271
+ // The header is where a completion that never spoke becomes visible: an
272
+ // automation Turn cannot reach the transcript, so the badge is the only
273
+ // thing that says a firing finished.
274
+ ctx.slot({
275
+ slot: "frockbot.header-actions",
276
+ order: 20,
277
+ component: RoutineInboxBadge,
278
+ }),
279
+ // The default Bot panel gets the glance; the section above stays the place a
280
+ // Routine is written.
281
+ ctx.slot({
282
+ slot: "frockbot.bot-panel-sections",
283
+ order: 10,
284
+ component: RoutinesSummary,
285
+ }),
286
+ ];
287
+ };
288
+
289
+ export default routinesClientPlugin;
@@ -0,0 +1,65 @@
1
+ import type { InjectionKey, Ref } from "vue";
2
+ import type {
3
+ RoutineHookMintV1,
4
+ RoutineInboxEntryViewV1,
5
+ RoutineRunDetailViewV1,
6
+ RoutineRunEntryViewV1,
7
+ RoutineTriggerV1,
8
+ RoutineViewV1,
9
+ } from "../shared.js";
10
+
11
+ /** What the create/edit form submits. One of `schedule` or `trigger`, never both. */
12
+ export interface RoutineFormSubmissionV1 {
13
+ routineId?: string;
14
+ name: string;
15
+ prompt: string;
16
+ schedule?: string;
17
+ trigger?: RoutineTriggerV1;
18
+ timezone: string;
19
+ }
20
+
21
+ export interface RoutinesClientState {
22
+ /** The Bot the loaded Routines belong to; nothing is shown for another. */
23
+ botId?: string;
24
+ routines: RoutineViewV1[];
25
+ /** Run logs by Routine, loaded on demand when a log is opened. */
26
+ runs: Record<string, RoutineRunEntryViewV1[]>;
27
+ /**
28
+ * One automation run's events, by run id, loaded when the reader opens it.
29
+ * An automation Turn is not in the transcript, so this is the only read of
30
+ * one and it is read-only.
31
+ */
32
+ runDetails: Record<string, RoutineRunDetailViewV1>;
33
+ /** The completion inbox, newest first, and what the header badge shows. */
34
+ inbox: RoutineInboxEntryViewV1[];
35
+ unacknowledged: number;
36
+ loaded: boolean;
37
+ busy: boolean;
38
+ error?: string;
39
+ load(botId: string): Promise<void>;
40
+ loadRuns(botId: string, routineId: string): Promise<void>;
41
+ loadRun(botId: string, routineId: string, runId: string): Promise<void>;
42
+ loadInbox(botId: string): Promise<void>;
43
+ /** Acknowledge entries; an empty list acknowledges all of them. */
44
+ acknowledgeInbox(botId: string, entryIds: string[]): Promise<void>;
45
+ save(botId: string, submission: RoutineFormSubmissionV1): Promise<void>;
46
+ setEnabled(botId: string, routineId: string, enabled: boolean): Promise<void>;
47
+ remove(botId: string, routineId: string): Promise<void>;
48
+ /** Ask for one firing now. It is queued; the alarm runs it. */
49
+ runNow(botId: string, routineId: string): Promise<void>;
50
+ /**
51
+ * The webhook key most recently minted in this session, and the only place it
52
+ * is ever readable. It is not persisted anywhere on the client and cannot be
53
+ * fetched again: rotating is the only way to see a key twice.
54
+ */
55
+ mintedHook?: RoutineHookMintV1;
56
+ /** Mint a fresh key, retiring the one before it. */
57
+ rotateKey(botId: string, routineId: string): Promise<void>;
58
+ /** Retire the key without minting another. Deliveries then answer 401. */
59
+ revokeKey(botId: string, routineId: string): Promise<void>;
60
+ /** Forget the key on screen. */
61
+ dismissHook(): void;
62
+ }
63
+
64
+ export const routinesStateKey: InjectionKey<Ref<RoutinesClientState>> =
65
+ Symbol("routines-state");