@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.
@@ -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
+ });
@@ -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 { ref } from "vue";
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
- expect((refusal as RoutineHookError).status).toBe(500);
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 the content when the caller sent no idempotency key", async () => {
96
- expect(await routineDeliveryIdV1("brief", '{"a":1}')).toBe(
97
- await routineDeliveryIdV1("brief", '{"a":1}'),
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
- const first = await deliver(store, token, '{"event":"push"}');
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
- constructor(status: number, message: string) {
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
- 500,
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: the caller's `Idempotency-Key`
286
- * when it sent one, and otherwise the content itself. Either way the same
287
- * delivery twice is one firing.
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
- if (idempotencyKey) {
295
- const trimmed = idempotencyKey.trim().slice(0, 256);
296
- if (trimmed.length > 0) {
297
- return hex(
298
- await crypto.subtle.digest(
299
- "SHA-256",
300
- TEXT.encode(`key${routineId}${trimmed}`),
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(`body${routineId}${body}`),
328
+ TEXT.encode(`delivery${routineId}${crypto.randomUUID()}`),
309
329
  ),
310
330
  );
311
331
  }
@@ -82,6 +82,8 @@ export interface RoutineTerminalInputV1 {
82
82
  /** The Turn's own response text, used when it handed off nothing. */
83
83
  responseText?: string;
84
84
  now: string;
85
+ /** The firing did not work; the entry is a complaint, not a completion. */
86
+ failure?: true;
85
87
  read<T>(key: string): Promise<T | undefined>;
86
88
  }
87
89
 
@@ -129,6 +131,7 @@ export async function routineTerminalRecordsV1(
129
131
  createdAt: input.now,
130
132
  acknowledged: false,
131
133
  ...(input.handoff === undefined ? {} : { wakeId }),
134
+ ...(input.failure === undefined ? {} : { failure: input.failure }),
132
135
  };
133
136
  const records: Record<string, unknown> = {
134
137
  [routineInboxKeyV1(inbox.nextSeq)]: entry,
@@ -205,6 +208,33 @@ export interface StoredPendingInputV1 {
205
208
  input: PendingBotInputV1;
206
209
  }
207
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
+
208
238
  export class RoutineInboxStore {
209
239
  readonly #storage: RoutineStorageV1;
210
240
  readonly #now: () => Date;
@@ -362,7 +392,7 @@ export class RoutineInboxStore {
362
392
  // active run, so no firing can settle while it runs and no wake can
363
393
  // arrive behind this read; an empty receipt would be a record of nothing.
364
394
  if (inputs.length === 0) return [];
365
- const retained = inputs.slice(-ROUTINE_PENDING_INPUT_LIMIT);
395
+ const retained = retainedPendingInputsV1(inputs);
366
396
  await transaction.put(receiptKey, {
367
397
  schemaVersion: 1,
368
398
  runId,
@@ -374,15 +404,40 @@ export class RoutineInboxStore {
374
404
  });
375
405
  }
376
406
 
377
- /** Trim the inbox to its retention bound, oldest first. */
407
+ /** Trim the inbox to its retention bound, acknowledged entries first. */
378
408
  async #trimInbox(): Promise<void> {
379
409
  const stored = await this.#storage.list<unknown>({
380
410
  prefix: ROUTINE_INBOX_PREFIX,
381
411
  });
382
- const keys = [...stored.keys()].sort();
383
- if (keys.length <= ROUTINE_INBOX_LIMIT) return;
384
- for (const key of keys.slice(ROUTINE_INBOX_LIMIT)) {
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;
385
439
  await this.#storage.delete(key);
440
+ excess -= 1;
386
441
  }
387
442
  }
388
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 { RoutineInboxStore, routineTerminalRecordsV1 } from "./inbox-store.js";
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/inbox.ts CHANGED
@@ -58,6 +58,42 @@ export function subagentAttributionV1(description: string): string {
58
58
 
59
59
  /** Longest hand-off an inbox entry or a pending wake carries. */
60
60
  export const ROUTINE_INBOX_TEXT_MAX = 4_000;
61
+
62
+ /**
63
+ * What a person is told a firing failed for.
64
+ *
65
+ * A failure summary is whatever the kernel had to hand — the run's `failure`
66
+ * string, a thrown message, a lease timeout. Some of those are sentences a
67
+ * person can act on ("the model is unavailable"); others are invariants
68
+ * addressed to this codebase, like `tool occurrence "tool:1:1:1" was not
69
+ * settled before step end`, which told a User nothing except that something
70
+ * they cannot see is broken. The raw string stays on the run-log row, which is
71
+ * where an operator looks; what reaches the inbox and the notification is a
72
+ * sentence.
73
+ *
74
+ * The test is deliberately coarse: anything carrying the shape of an internal
75
+ * identifier — a quoted occurrence, a `foo:1:2:3` coordinate, a stack frame —
76
+ * is not for a person. Everything else is passed through, because a provider
77
+ * saying "rate limited" is exactly what the User wants to read.
78
+ */
79
+ const INTERNAL_FAILURE_MARKERS = [
80
+ /\btool occurrence\b/i,
81
+ /\b[a-z-]+:\d+:\d+:\d+\b/i,
82
+ /\bat [\w$.]+ \(/,
83
+ /\bschemaVersion\b/,
84
+ /\boutcome model-error\b/i,
85
+ /\binvariant\b/i,
86
+ ];
87
+
88
+ /** The sentence a person reads instead of a kernel string. Never empty. */
89
+ export function routineFailureSentenceV1(summary: string | undefined): string {
90
+ const text = (summary ?? "").trim();
91
+ if (text.length === 0) return "It stopped without saying why.";
92
+ if (INTERNAL_FAILURE_MARKERS.some((marker) => marker.test(text))) {
93
+ return "Something inside FrockBot went wrong; the run log has the details.";
94
+ }
95
+ return text;
96
+ }
61
97
  /** Longest title a pending wake carries. */
62
98
  export const ROUTINE_WAKE_TITLE_MAX = 200;
63
99
 
@@ -78,6 +114,23 @@ export interface RoutineInboxEntryV1 {
78
114
  /** Present when the Turn also handed off, naming the wake it queued. */
79
115
  wakeId?: string;
80
116
  acknowledgedAt?: string;
117
+ /**
118
+ * How many firings this entry stands for. Absent means one.
119
+ *
120
+ * A Routine that fails every minute wrote a fresh entry every minute, and a
121
+ * user who had not looked in an hour found sixty rows of the same sentence.
122
+ * Consecutive failures of the same Routine with the same failure fold into
123
+ * the entry already there and raise this count, so the inbox says what is
124
+ * wrong once and how often it has happened.
125
+ */
126
+ repeatCount?: number;
127
+ /**
128
+ * Set when the entry is a firing that did not work. A completion is routine
129
+ * and deliberately badges nothing; a failure is the Bot telling its User
130
+ * that an automation they set up has stopped, which is exactly what the
131
+ * sidebar badge is for.
132
+ */
133
+ failure?: true;
81
134
  /**
82
135
  * What produced this entry. Absent means `routine`; `routineId` then carries
83
136
  * the task id, because the field names the automation the entry came from
@@ -210,7 +263,7 @@ export function decodeRoutineInboxEntryV1(
210
263
  "createdAt",
211
264
  "acknowledged",
212
265
  ],
213
- ["wakeId", "acknowledgedAt", "source"],
266
+ ["wakeId", "acknowledgedAt", "source", "repeatCount", "failure"],
214
267
  label,
215
268
  );
216
269
  if (candidate.schemaVersion !== 1) {
@@ -249,9 +302,25 @@ export function decodeRoutineInboxEntryV1(
249
302
  ...(candidate.source === undefined
250
303
  ? {}
251
304
  : { source: completionSourceV1(candidate.source, `${label} source`) }),
305
+ ...(candidate.repeatCount === undefined
306
+ ? {}
307
+ : {
308
+ repeatCount: routineRepeatCountV1(
309
+ candidate.repeatCount,
310
+ `${label} repeatCount`,
311
+ ),
312
+ }),
313
+ ...(candidate.failure === undefined ? {} : { failure: true as const }),
252
314
  };
253
315
  }
254
316
 
317
+ function routineRepeatCountV1(value: unknown, label: string): number {
318
+ if (!Number.isSafeInteger(value) || (value as number) < 1) {
319
+ throw new RoutineDecodeError(`${label} is invalid`);
320
+ }
321
+ return value as number;
322
+ }
323
+
255
324
  export function decodePendingBotInputV1(
256
325
  value: unknown,
257
326
  label = "pending Bot input",