@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/src/client/index.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/client/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
286
|
-
*
|
|
287
|
-
*
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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(`
|
|
328
|
+
TEXT.encode(`delivery${routineId}${crypto.randomUUID()}`),
|
|
309
329
|
),
|
|
310
330
|
);
|
|
311
331
|
}
|
package/src/inbox-store.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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 {
|
|
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",
|