@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-routines",
3
- "version": "0.3.8",
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.8",
36
- "@frockbot/client-ui": "0.3.8",
37
- "@frockbot/configuration-core": "0.3.8",
38
- "@frockbot/kernel-agent-loop": "0.3.8",
39
- "@frockbot/kernel-contracts": "0.3.8",
40
- "@frockbot/plugin-shell": "0.3.8",
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
- command = routineManageCommandV1(decodeRoutineManageInputV1(input), {
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
- return jsonError(
168
- 500,
169
- error instanceof Error ? error.message : "Routine request failed",
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
- return jsonError(hookError.status, hookError.message);
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
- return jsonError(
249
- 500,
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="count > 0"
81
+ v-if="unreadOnScreen.length > 0"
65
82
  variant="ghost"
66
83
  :disabled="routines.busy"
67
- @click="acknowledge([])"
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
- <span>{{ entry.createdAt }}</span>
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 { UiAnchor, UiButton, UiField, UiIcon } from "@frockbot/client-ui";
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
- form.timezone = "UTC";
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 state holds the reason; the form stays open so it can be corrected.
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>{{ routine.nextRunAt ?? "—" }}</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>{{ routine.lastRunAt ?? "Never" }}</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="botId && routines.remove(botId, routine.routineId)"
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>{{ entry.startedAt }}</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;