@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 +8 -8
- package/src/client/RoutineInboxBadge.vue +1 -0
- package/src/client/RoutinesSection.vue +25 -4
- package/src/client/index.ts +14 -1
- package/src/inbox-store.ts +44 -14
- package/src/inbox.test.ts +58 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-routines",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
36
|
-
"@frockbot/client-ui": "0.3.
|
|
37
|
-
"@frockbot/configuration-core": "0.3.
|
|
38
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
39
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
40
|
-
"@frockbot/plugin-shell": "0.3.
|
|
35
|
+
"@frockbot/client-core": "0.3.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": "
|
|
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
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
568
|
+
flex: 1 1 14rem;
|
|
548
569
|
flex-direction: column;
|
|
549
570
|
}
|
|
550
571
|
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/inbox-store.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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", () => {
|