@frockbot/plugin-routines 0.3.11 → 0.3.12

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.11",
3
+ "version": "0.3.12",
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.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",
35
+ "@frockbot/client-core": "0.3.12",
36
+ "@frockbot/client-ui": "0.3.12",
37
+ "@frockbot/configuration-core": "0.3.12",
38
+ "@frockbot/kernel-agent-loop": "0.3.12",
39
+ "@frockbot/kernel-contracts": "0.3.12",
40
+ "@frockbot/plugin-shell": "0.3.12",
41
41
  "cordis": "4.0.0-rc.8",
42
42
  "croner": "10.0.1",
43
43
  "vue": "3.5.41"
@@ -46,7 +46,7 @@
46
46
  "@types/bun": "1.4.0",
47
47
  "@types/node": "26.2.0",
48
48
  "@vitejs/plugin-vue": "6.0.8",
49
- "typescript": "5.9.3",
49
+ "typescript": "npm:typescript-native-bridge@6.0.3-bridge.16.tsgo.7.0.2",
50
50
  "vite": "8.2.2",
51
51
  "vue-tsc": "3.3.10"
52
52
  },
@@ -67,6 +67,7 @@ function markAllRead(): void {
67
67
  type="button"
68
68
  class="routine-inbox__trigger"
69
69
  :aria-expanded="open"
70
+ :aria-label="`Routine completions${count > 0 ? ` (${badge} unread)` : ''}`"
70
71
  :title="`Routine completions${count > 0 ? ` (${badge} unread)` : ''}`"
71
72
  @click="toggle"
72
73
  >
@@ -12,6 +12,10 @@ import {
12
12
  UiField,
13
13
  UiIcon,
14
14
  } from "@frockbot/client-ui";
15
+ import {
16
+ presentClientFailureV1,
17
+ serverRefusalMessageV1,
18
+ } from "@frockbot/client-core";
15
19
  import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
16
20
  import { settingsLinkV1 } from "@frockbot/plugin-shell/settings-links";
17
21
  import { computed, inject, reactive, ref, watch } from "vue";
@@ -173,10 +177,15 @@ async function submit(): Promise<void> {
173
177
  // The form stays open and holds the reason itself, beside the field that
174
178
  // caused it. The section header keeps its copy for the reader who scrolls
175
179
  // back up, but the form no longer refuses in silence.
180
+ //
181
+ // A refused save is the deployment explaining a rule it holds — which
182
+ // field is wrong and why — so its own sentence is what belongs here. A
183
+ // fault carries no such sentence, and the classified failure's short one
184
+ // is what a person can act on instead of a stack's worth of plumbing.
176
185
  saveError.value =
177
- error instanceof Error
178
- ? error.message
179
- : (routines.value.error ?? "Could not save the Routine");
186
+ serverRefusalMessageV1(error) ??
187
+ routines.value.error ??
188
+ presentClientFailureV1(error, "save the Routine");
180
189
  }
181
190
  }
182
191
 
@@ -523,12 +532,24 @@ async function toggleLog(routineId: string): Promise<void> {
523
532
  gap: 12px;
524
533
  }
525
534
 
535
+ /*
536
+ * The section head wraps rather than squeezing its own description. With the
537
+ * action fixed at its intrinsic width, the sentence was folded into a ~180px
538
+ * ribbon while a quarter of the panel sat empty beside it; below that basis
539
+ * the action takes its own line instead.
540
+ */
526
541
  .routines__header {
527
542
  display: flex;
543
+ flex-wrap: wrap;
528
544
  align-items: center;
529
545
  gap: 10px;
530
546
  }
531
547
 
548
+ .routines__header > :deep(.ui-button),
549
+ .routines__header > button {
550
+ margin-left: auto;
551
+ }
552
+
532
553
  .routines__icon {
533
554
  display: grid;
534
555
  width: var(--frock-avatar-sm);
@@ -544,7 +565,7 @@ async function toggleLog(routineId: string): Promise<void> {
544
565
  .routines__intro {
545
566
  display: flex;
546
567
  min-width: 0;
547
- flex: 1 1 auto;
568
+ flex: 1 1 14rem;
548
569
  flex-direction: column;
549
570
  }
550
571
 
@@ -6,7 +6,10 @@
6
6
  // become an alternate authority." Every write here is one `POST` of one
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
- import type { ClientPlugin } from "@frockbot/client-core";
9
+ import {
10
+ serverRefusalMessageV1,
11
+ type ClientPlugin,
12
+ } from "@frockbot/client-core";
10
13
  import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
11
14
  import { ref, watch } from "vue";
12
15
  import {
@@ -27,7 +30,17 @@ import {
27
30
  } from "./state.js";
28
31
  import { defineClientContribution } from "@frockbot/kernel-contracts/contributions";
29
32
 
33
+ /*
34
+ * A refusal's own sentence first, then a presentable one.
35
+ *
36
+ * A validator that rejects a schedule says why in words meant for the person
37
+ * writing it — "cron expression ... must have five fields". That sentence is
38
+ * the answer, so it goes on screen unchanged. Anything else is plumbing, and
39
+ * `.message` on a classified failure is already the short sentence for it.
40
+ */
30
41
  function message(error: unknown, fallback: string): string {
42
+ const refusal = serverRefusalMessageV1(error);
43
+ if (refusal !== undefined) return refusal;
31
44
  return error instanceof Error ? error.message : fallback;
32
45
  }
33
46
 
@@ -235,6 +235,26 @@ export function retainedPendingInputsV1(
235
235
  );
236
236
  }
237
237
 
238
+ /**
239
+ * One stored inbox entry, or nothing when it cannot be read.
240
+ *
241
+ * The inbox is the only place a firing ever speaks, and it is read on a plain
242
+ * `GET`. Every other Routine record is decoded strictly, because a Routine that
243
+ * cannot be decoded must not be fired; an inbox entry is inert text already
244
+ * written, so the strictness buys nothing and costs the whole list. Exported so
245
+ * the tolerance is testable without a store.
246
+ */
247
+ export function readableRoutineInboxEntryV1(
248
+ value: unknown,
249
+ ): RoutineInboxEntryV1 | undefined {
250
+ try {
251
+ return decodeRoutineInboxEntryV1(value);
252
+ } catch (error) {
253
+ if (error instanceof RoutineDecodeError) return undefined;
254
+ throw error;
255
+ }
256
+ }
257
+
238
258
  export class RoutineInboxStore {
239
259
  readonly #storage: RoutineStorageV1;
240
260
  readonly #now: () => Date;
@@ -248,8 +268,15 @@ export class RoutineInboxStore {
248
268
  }
249
269
 
250
270
  /**
251
- * Every inbox entry, newest first, trimmed to the retention bound first so a
252
- * read is also the place the bound is enforced.
271
+ * Every inbox entry that still decodes, newest first, trimmed to the
272
+ * retention bound first so a read is also the place the bound is enforced.
273
+ *
274
+ * One unreadable row must not take the whole inbox with it. The decoder is
275
+ * exact — an entry a later version wrote with a field this one has never
276
+ * heard of is rejected as firmly as a corrupt one — so a single legacy row
277
+ * used to turn `GET …/routines/inbox` into a 500 and hide every completion
278
+ * beside it. A row nobody can read says nothing to anyone: it is skipped, and
279
+ * `#trimInbox` reclaims its space ahead of anything readable.
253
280
  */
254
281
  async list(): Promise<RoutineInboxEntryV1[]> {
255
282
  await this.#trimInbox();
@@ -257,9 +284,12 @@ export class RoutineInboxStore {
257
284
  prefix: ROUTINE_INBOX_PREFIX,
258
285
  limit: ROUTINE_INBOX_LIMIT,
259
286
  });
260
- return [...stored.values()].map((value) =>
261
- decodeRoutineInboxEntryV1(value),
262
- );
287
+ const entries: RoutineInboxEntryV1[] = [];
288
+ for (const value of stored.values()) {
289
+ const entry = readableRoutineInboxEntryV1(value);
290
+ if (entry) entries.push(entry);
291
+ }
292
+ return entries;
263
293
  }
264
294
 
265
295
  /**
@@ -277,7 +307,12 @@ export class RoutineInboxStore {
277
307
  });
278
308
  let changed = 0;
279
309
  for (const [key, value] of stored.entries()) {
280
- const entry = decodeRoutineInboxEntryV1(value);
310
+ // The same tolerance the read has: an entry that cannot be decoded
311
+ // cannot be shown either, so there is nothing here to mark read, and
312
+ // failing the whole command over it would leave the readable entries
313
+ // unacknowledged for good.
314
+ const entry = readableRoutineInboxEntryV1(value);
315
+ if (!entry) continue;
281
316
  if (entry.acknowledged) continue;
282
317
  if (wanted.size > 0 && !wanted.has(entry.entryId)) continue;
283
318
  await transaction.put(key, {
@@ -424,14 +459,9 @@ export class RoutineInboxStore {
424
459
  const acknowledged: string[] = [];
425
460
  const unread: string[] = [];
426
461
  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
- }
462
+ // An entry nothing can decode says nothing to anyone; it is the first
463
+ // thing worth reclaiming space from.
464
+ const read = readableRoutineInboxEntryV1(value)?.acknowledged ?? true;
435
465
  (read ? acknowledged : unread).push(key);
436
466
  }
437
467
  for (const key of [...acknowledged, ...unread]) {
package/src/inbox.test.ts CHANGED
@@ -8,12 +8,14 @@ import {
8
8
  subagentAttributionV1,
9
9
  } from "./inbox.js";
10
10
  import {
11
+ readableRoutineInboxEntryV1,
11
12
  retainedPendingInputsV1,
12
13
  RoutineInboxStore,
13
14
  routineTerminalRecordsV1,
14
15
  } from "./inbox-store.js";
15
16
  import { createMemoryRoutineStorageV1 } from "./testing.js";
16
17
  import {
18
+ routineInboxKeyV1,
17
19
  ROUTINE_INBOX_LIMIT,
18
20
  ROUTINE_INBOX_PREFIX,
19
21
  ROUTINE_PENDING_INPUT_LIMIT,
@@ -109,6 +111,62 @@ describe("the completion inbox", () => {
109
111
  true,
110
112
  );
111
113
  });
114
+
115
+ /*
116
+ * `GET /api/bots/:id/routines/inbox` answered 500 once in verification. The
117
+ * read decoded every stored row strictly, and the decoder rejects an unknown
118
+ * field as firmly as a corrupt one — so one row written by a later version, or
119
+ * one half-written row, hid every readable completion beside it behind an
120
+ * error. A row nobody can read is skipped instead.
121
+ */
122
+ test("a malformed entry is skipped rather than failing the whole read", async () => {
123
+ const store = storage();
124
+ await settle(store, { runId: "rf-good", responseText: "readable" });
125
+ // Three shapes of bad row: a field this version has never heard of, a
126
+ // required field missing, and a value that is not an object at all.
127
+ await store.put(routineInboxKeyV1(90), {
128
+ schemaVersion: 1,
129
+ entryId: "ri-rf-future",
130
+ runId: "rf-future",
131
+ routineId: "morning-brief",
132
+ text: "from a later version",
133
+ attribution: "Automation: Morning brief",
134
+ createdAt: NOW,
135
+ acknowledged: false,
136
+ severity: "urgent",
137
+ });
138
+ await store.put(routineInboxKeyV1(91), {
139
+ schemaVersion: 1,
140
+ entryId: "ri-rf-partial",
141
+ runId: "rf-partial",
142
+ });
143
+ await store.put(routineInboxKeyV1(92), "not an entry at all");
144
+
145
+ const inbox = new RoutineInboxStore(store);
146
+ const entries = await inbox.list();
147
+ expect(entries.map((entry) => entry.runId)).toEqual(["rf-good"]);
148
+ // Acknowledging is tolerant on the same terms: the readable entry is
149
+ // marked, and the unreadable rows neither break the command nor count.
150
+ expect(await inbox.acknowledge([])).toBe(1);
151
+ expect((await inbox.list())[0]!.acknowledged).toBe(true);
152
+ });
153
+
154
+ test("the tolerant decode keeps a readable entry and drops an unreadable one", () => {
155
+ expect(readableRoutineInboxEntryV1({ schemaVersion: 2 })).toBeUndefined();
156
+ expect(readableRoutineInboxEntryV1(undefined)).toBeUndefined();
157
+ expect(
158
+ readableRoutineInboxEntryV1({
159
+ schemaVersion: 1,
160
+ entryId: "ri-1",
161
+ runId: "rf-1",
162
+ routineId: "morning-brief",
163
+ text: "readable",
164
+ attribution: "Automation: Morning brief",
165
+ createdAt: NOW,
166
+ acknowledged: false,
167
+ })?.entryId,
168
+ ).toBe("ri-1");
169
+ });
112
170
  });
113
171
 
114
172
  describe("the pending-input queue", () => {