@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-routines",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10",
|
|
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.10",
|
|
36
|
+
"@frockbot/client-ui": "0.3.10",
|
|
37
|
+
"@frockbot/configuration-core": "0.3.10",
|
|
38
|
+
"@frockbot/kernel-agent-loop": "0.3.10",
|
|
39
|
+
"@frockbot/kernel-contracts": "0.3.10",
|
|
40
|
+
"@frockbot/plugin-shell": "0.3.10",
|
|
41
41
|
"cordis": "4.0.0-rc.8",
|
|
42
42
|
"croner": "10.0.1",
|
|
43
43
|
"vue": "3.5.41"
|
package/src/agent.test.ts
CHANGED
|
@@ -180,6 +180,97 @@ describe("routine_manage", () => {
|
|
|
180
180
|
});
|
|
181
181
|
});
|
|
182
182
|
|
|
183
|
+
// A Bot paused a User's Routine in a Turn about sheep farming: no approval, no
|
|
184
|
+
// confirmation, nothing in the transcript. A Routine the User made is theirs.
|
|
185
|
+
describe("a Routine the User created", () => {
|
|
186
|
+
async function seeded() {
|
|
187
|
+
const seam = host();
|
|
188
|
+
await seam.store.execute(
|
|
189
|
+
{
|
|
190
|
+
schemaVersion: 1,
|
|
191
|
+
type: "routine/create",
|
|
192
|
+
commandId: "cmd-user",
|
|
193
|
+
botId: "scout",
|
|
194
|
+
routineId: "theirs",
|
|
195
|
+
name: "Minute ping",
|
|
196
|
+
prompt: "Say ping.",
|
|
197
|
+
schedule: "@every 1m",
|
|
198
|
+
timezone: "UTC",
|
|
199
|
+
},
|
|
200
|
+
{ kind: "user" },
|
|
201
|
+
);
|
|
202
|
+
return seam;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const action of ["pause", "delete", "update"] as const) {
|
|
206
|
+
test(`refuses ${action} when the User did not ask`, async () => {
|
|
207
|
+
const seam = await seeded();
|
|
208
|
+
const tool = createRoutineManageTool({ ...seam, writer: WRITER });
|
|
209
|
+
|
|
210
|
+
const result = await tool.execute(
|
|
211
|
+
{
|
|
212
|
+
action,
|
|
213
|
+
routineId: "theirs",
|
|
214
|
+
...(action === "update" ? { prompt: "Say pong." } : {}),
|
|
215
|
+
},
|
|
216
|
+
CONTEXT,
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
expect(result.isError).toBe(true);
|
|
220
|
+
expect(result.content).toContain("created by the User");
|
|
221
|
+
expect(result.content).toContain("userAsked: true");
|
|
222
|
+
const listed = await seam.list();
|
|
223
|
+
expect(listed.routines[0]).toMatchObject({
|
|
224
|
+
enabled: true,
|
|
225
|
+
prompt: "Say ping.",
|
|
226
|
+
createdBy: { kind: "user" },
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
test("pauses it once the User has asked", async () => {
|
|
232
|
+
const seam = await seeded();
|
|
233
|
+
const tool = createRoutineManageTool({ ...seam, writer: WRITER });
|
|
234
|
+
|
|
235
|
+
const result = await tool.execute(
|
|
236
|
+
{ action: "pause", routineId: "theirs", userAsked: true },
|
|
237
|
+
CONTEXT,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
expect(result.isError).toBe(false);
|
|
241
|
+
expect((await seam.list()).routines[0]).toMatchObject({ enabled: false });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("leaves the Bot free to manage its own Routines", async () => {
|
|
245
|
+
const seam = host();
|
|
246
|
+
const tool = createRoutineManageTool({ ...seam, writer: WRITER });
|
|
247
|
+
await tool.execute(
|
|
248
|
+
{
|
|
249
|
+
action: "create",
|
|
250
|
+
routineId: "mine",
|
|
251
|
+
name: "Housekeeping",
|
|
252
|
+
prompt: "Tidy up.",
|
|
253
|
+
schedule: "@daily",
|
|
254
|
+
},
|
|
255
|
+
CONTEXT,
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
const result = await tool.execute(
|
|
259
|
+
{ action: "pause", routineId: "mine" },
|
|
260
|
+
{ ...CONTEXT, effectId: "tool:1:2:0" },
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
expect(result.isError).toBe(false);
|
|
264
|
+
expect((await seam.list()).routines[0]).toMatchObject({ enabled: false });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("says destructive actions need the User's word", () => {
|
|
268
|
+
const tool = createRoutineManageTool({ ...host(), writer: WRITER });
|
|
269
|
+
expect(tool.description).toContain("only when the User asked you");
|
|
270
|
+
expect(tool.description).toContain("do not switch it off yourself");
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
183
274
|
describe("routineManageCommandV1", () => {
|
|
184
275
|
test("maps a webhook trigger to the record's trigger shape", () => {
|
|
185
276
|
expect(
|
package/src/agent.ts
CHANGED
|
@@ -102,11 +102,23 @@ const ROUTINE_MANAGE_INPUT_SCHEMA = {
|
|
|
102
102
|
description:
|
|
103
103
|
"IANA time zone the schedule is read in, such as Australia/Sydney.",
|
|
104
104
|
},
|
|
105
|
+
userAsked: {
|
|
106
|
+
type: "boolean",
|
|
107
|
+
description:
|
|
108
|
+
"Set true only when the User asked you, in this conversation, to pause, edit, or delete this Routine. Required for those three actions on a Routine the User created. Never set it because a Routine looks wrong to you, is failing, or is no longer useful: say so and let the User decide.",
|
|
109
|
+
},
|
|
105
110
|
},
|
|
106
111
|
required: ["action"],
|
|
107
112
|
additionalProperties: false,
|
|
108
113
|
} as const;
|
|
109
114
|
|
|
115
|
+
/** The actions that switch off or overwrite something already running. */
|
|
116
|
+
const DESTRUCTIVE_ROUTINE_ACTIONS = new Set<RoutineManageActionV1>([
|
|
117
|
+
"pause",
|
|
118
|
+
"update",
|
|
119
|
+
"delete",
|
|
120
|
+
]);
|
|
121
|
+
|
|
110
122
|
interface RoutineManageInputV1 {
|
|
111
123
|
action: RoutineManageActionV1;
|
|
112
124
|
routineId?: string;
|
|
@@ -115,6 +127,7 @@ interface RoutineManageInputV1 {
|
|
|
115
127
|
schedule?: string;
|
|
116
128
|
trigger?: "webhook";
|
|
117
129
|
timezone?: string;
|
|
130
|
+
userAsked?: boolean;
|
|
118
131
|
}
|
|
119
132
|
|
|
120
133
|
function decodeRoutineManageInputV1(input: unknown): RoutineManageInputV1 {
|
|
@@ -130,6 +143,7 @@ function decodeRoutineManageInputV1(input: unknown): RoutineManageInputV1 {
|
|
|
130
143
|
"schedule",
|
|
131
144
|
"trigger",
|
|
132
145
|
"timezone",
|
|
146
|
+
"userAsked",
|
|
133
147
|
]);
|
|
134
148
|
for (const key of Object.keys(value)) {
|
|
135
149
|
if (!allowed.has(key)) {
|
|
@@ -153,6 +167,9 @@ function decodeRoutineManageInputV1(input: unknown): RoutineManageInputV1 {
|
|
|
153
167
|
if (value.trigger !== undefined && value.trigger !== "webhook") {
|
|
154
168
|
throw new RoutineDecodeError('routine_manage trigger must be "webhook"');
|
|
155
169
|
}
|
|
170
|
+
if (value.userAsked !== undefined && typeof value.userAsked !== "boolean") {
|
|
171
|
+
throw new RoutineDecodeError("routine_manage userAsked must be a boolean");
|
|
172
|
+
}
|
|
156
173
|
return {
|
|
157
174
|
action,
|
|
158
175
|
...(optional("routineId") === undefined
|
|
@@ -169,6 +186,9 @@ function decodeRoutineManageInputV1(input: unknown): RoutineManageInputV1 {
|
|
|
169
186
|
...(optional("timezone") === undefined
|
|
170
187
|
? {}
|
|
171
188
|
: { timezone: optional("timezone")! }),
|
|
189
|
+
...(value.userAsked === undefined
|
|
190
|
+
? {}
|
|
191
|
+
: { userAsked: value.userAsked as boolean }),
|
|
172
192
|
};
|
|
173
193
|
}
|
|
174
194
|
|
|
@@ -239,6 +259,28 @@ export function routineManageCommandV1(
|
|
|
239
259
|
});
|
|
240
260
|
}
|
|
241
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Whether the User, rather than this Bot, created the Routine.
|
|
264
|
+
*
|
|
265
|
+
* A listing that cannot be read answers `true`: not knowing who owns a Routine
|
|
266
|
+
* is a reason to ask, not a reason to switch it off. A Routine that is not in
|
|
267
|
+
* the listing at all is gone, and the command below will say so properly.
|
|
268
|
+
*/
|
|
269
|
+
async function userAuthoredRoutineV1(
|
|
270
|
+
host: RoutinesRuntimeHostV1,
|
|
271
|
+
routineId: string,
|
|
272
|
+
): Promise<boolean> {
|
|
273
|
+
try {
|
|
274
|
+
const listing = await host.list();
|
|
275
|
+
const routine = listing.routines.find(
|
|
276
|
+
(candidate) => candidate.routineId === routineId,
|
|
277
|
+
);
|
|
278
|
+
return routine === undefined ? false : routine.createdBy.kind === "user";
|
|
279
|
+
} catch {
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
242
284
|
function refusal(reason: string): { content: string; isError: boolean } {
|
|
243
285
|
return { content: `routine_manage was refused: ${reason}`, isError: true };
|
|
244
286
|
}
|
|
@@ -257,6 +299,10 @@ export function createRoutineManageTool(
|
|
|
257
299
|
"A Routine is a standing instruction that fires on a schedule or on a delivered webhook,",
|
|
258
300
|
`as its own Turn rather than inside this conversation. Names are at most ${ROUTINE_NAME_MAX_LENGTH}`,
|
|
259
301
|
`characters and prompts at most ${ROUTINE_PROMPT_MAX_LENGTH}.`,
|
|
302
|
+
"Pausing, editing, or deleting a Routine the User created switches off something they set up,",
|
|
303
|
+
"so do it only when the User asked you to in this conversation, and pass userAsked: true when they did.",
|
|
304
|
+
"If a Routine of theirs is failing or looks wrong, tell them and let them decide — do not switch it off yourself.",
|
|
305
|
+
"Say in your reply whatever you changed.",
|
|
260
306
|
].join(" "),
|
|
261
307
|
inputSchema: ROUTINE_MANAGE_INPUT_SCHEMA as unknown as Record<
|
|
262
308
|
string,
|
|
@@ -272,15 +318,32 @@ export function createRoutineManageTool(
|
|
|
272
318
|
}
|
|
273
319
|
},
|
|
274
320
|
execute: async (input: unknown, context: ToolExecutionContext) => {
|
|
321
|
+
let decoded: RoutineManageInputV1;
|
|
275
322
|
let command: RoutineCommandV1;
|
|
276
323
|
try {
|
|
277
|
-
|
|
324
|
+
decoded = decodeRoutineManageInputV1(input);
|
|
325
|
+
command = routineManageCommandV1(decoded, {
|
|
278
326
|
botId: host.botId,
|
|
279
327
|
commandId: routineToolCommandIdV1(context.effectId),
|
|
280
328
|
});
|
|
281
329
|
} catch (error) {
|
|
282
330
|
return refusal(error instanceof Error ? error.message : String(error));
|
|
283
331
|
}
|
|
332
|
+
// A Bot paused a User's Routine in a Turn about sheep farming, with no
|
|
333
|
+
// approval, no confirmation, and nothing in the transcript saying so.
|
|
334
|
+
// The User's own Routines are theirs: switching one off, or rewriting
|
|
335
|
+
// it, needs the User to have asked for it in this conversation. The
|
|
336
|
+
// Bot's own Routines it may manage freely — those are its housekeeping.
|
|
337
|
+
if (
|
|
338
|
+
DESTRUCTIVE_ROUTINE_ACTIONS.has(decoded.action) &&
|
|
339
|
+
decoded.userAsked !== true &&
|
|
340
|
+
decoded.routineId !== undefined &&
|
|
341
|
+
(await userAuthoredRoutineV1(host, decoded.routineId))
|
|
342
|
+
) {
|
|
343
|
+
return refusal(
|
|
344
|
+
`Routine ${decoded.routineId} was created by the User. Ask them before you ${decoded.action === "update" ? "change" : decoded.action} it, and call this again with userAsked: true once they say so.`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
284
347
|
const writer: RoutineWriterV1 = {
|
|
285
348
|
kind: "bot",
|
|
286
349
|
botId: host.botId,
|
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,13 @@ 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>
|
|
104
|
+
<!-- One thing going wrong repeatedly is one entry and a count. -->
|
|
105
|
+
<span v-if="(entry.repeatCount ?? 1) > 1"
|
|
106
|
+
>Happened {{ entry.repeatCount }} times</span
|
|
107
|
+
>
|
|
85
108
|
<UiButton
|
|
86
109
|
v-if="!entry.acknowledged"
|
|
87
110
|
variant="ghost"
|
|
@@ -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;
|