@frockbot/plugin-shell 0.0.0 → 0.1.0

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.
Files changed (73) hide show
  1. package/frockbot.json +68 -0
  2. package/package.json +87 -6
  3. package/src/agent.test.ts +372 -0
  4. package/src/agent.ts +335 -0
  5. package/src/approvals.test.ts +224 -0
  6. package/src/approvals.ts +530 -0
  7. package/src/backend-assignment.test.ts +161 -0
  8. package/src/backend-assignment.ts +274 -0
  9. package/src/backend-authoring.test.ts +518 -0
  10. package/src/backend-authoring.ts +531 -0
  11. package/src/backend-bot-identity.test.ts +215 -0
  12. package/src/backend-completion.test.ts +289 -0
  13. package/src/backend-completion.ts +95 -0
  14. package/src/backend-composition.ts +242 -0
  15. package/src/backend-computer.ts +76 -0
  16. package/src/backend-configuration.test.ts +1757 -0
  17. package/src/backend-contracts.test.ts +189 -0
  18. package/src/backend-contracts.ts +44 -0
  19. package/src/backend-debug.test.ts +202 -0
  20. package/src/backend-execution.ts +55 -0
  21. package/src/backend-flock.ts +96 -0
  22. package/src/backend-image.test.ts +115 -0
  23. package/src/backend-image.ts +180 -0
  24. package/src/backend-isolate.test.ts +238 -0
  25. package/src/backend-isolate.ts +409 -0
  26. package/src/backend-machine.ts +144 -0
  27. package/src/backend-memory.ts +89 -0
  28. package/src/backend-recovery-integration.test.ts +1575 -0
  29. package/src/backend-recovery.ts +106 -0
  30. package/src/backend-routines.ts +375 -0
  31. package/src/backend-runner.ts +251 -0
  32. package/src/backend-skills.test.ts +126 -0
  33. package/src/backend-skills.ts +198 -0
  34. package/src/backend-stop.test.ts +356 -0
  35. package/src/backend-subagents.ts +459 -0
  36. package/src/backend.ts +6035 -0
  37. package/src/client/FrockBotApp.vue +1026 -0
  38. package/src/client/SendPayloadView.vue +337 -0
  39. package/src/client/composer-draft.test.ts +31 -0
  40. package/src/client/composer-draft.ts +35 -0
  41. package/src/client/cordis-client-shim.d.ts +15 -0
  42. package/src/client/index.test.ts +2548 -0
  43. package/src/client/index.ts +2346 -0
  44. package/src/client/model-presentation.test.ts +35 -0
  45. package/src/client/model-presentation.ts +19 -0
  46. package/src/client/notify.test.ts +89 -0
  47. package/src/client/notify.ts +101 -0
  48. package/src/client/skill-invocation.test.ts +143 -0
  49. package/src/client/skill-invocation.ts +175 -0
  50. package/src/client/styles.css +1043 -0
  51. package/src/composition-views.ts +118 -0
  52. package/src/debug-protocol.test.ts +80 -0
  53. package/src/debug-protocol.ts +165 -0
  54. package/src/env.d.ts +10 -0
  55. package/src/history.test.ts +163 -0
  56. package/src/history.ts +108 -0
  57. package/src/host.ts +20 -0
  58. package/src/index.ts +2 -0
  59. package/src/manifest.ts +3 -0
  60. package/src/run-cursor.ts +28 -0
  61. package/src/run-protocol.test.ts +1281 -0
  62. package/src/run-protocol.ts +1417 -0
  63. package/src/settings-links.test.ts +106 -0
  64. package/src/settings-links.ts +289 -0
  65. package/src/shared.ts +338 -0
  66. package/src/skill-protocol.ts +117 -0
  67. package/src/terminal-records.test.ts +217 -0
  68. package/src/terminal-records.ts +150 -0
  69. package/src/unread.test.ts +362 -0
  70. package/src/unread.ts +675 -0
  71. package/tsconfig.json +18 -0
  72. package/vite.config.ts +32 -0
  73. package/README.md +0 -3
@@ -0,0 +1,530 @@
1
+ /**
2
+ * Approval cards (parity register row 53).
3
+ *
4
+ * The constitution's *Self-modification* rule is the whole reason this module
5
+ * exists: "a request for more becomes a durable pending decision for the User,
6
+ * never a grant". An `approval` send is that request, and `ApprovalRecordV1` is
7
+ * that durable pending decision. Nothing here grants anything; the record only
8
+ * ever says what the User answered, or that nobody did.
9
+ *
10
+ * Three rules live here and nowhere else.
11
+ *
12
+ * * **Written where the Turn settles.** `approvalTerminalRecordsV1` is handed
13
+ * the settled run and a reader bound to the transaction settling it, and
14
+ * returns the records that transaction writes. So there is no window in
15
+ * which a card has been shown to a person and no decision could be recorded
16
+ * against it — the card and its record become durable at the same instant.
17
+ *
18
+ * * **First write wins.** A decision is recorded once. A replayed `POST`
19
+ * answers with the decision already stored rather than overwriting it, which
20
+ * is "Recovery never silently duplicates" applied to a human answer: two
21
+ * clicks on Approve and Deny cannot both be true, and the first one is.
22
+ *
23
+ * * **Never an unbounded wait.** Every record carries an `expiresAt`, clamped
24
+ * between five minutes and seven days with a day as the default. The Bot
25
+ * Durable Object's own alarm expires it, and expiry queues the same pending
26
+ * input a human decision does, so the Bot always learns the outcome.
27
+ */
28
+ import type { SendToUserApprovalRiskV1 } from "@frockbot/kernel-contracts";
29
+
30
+ /** One `ApprovalRecordV1`, keyed by the Bot's own approval id. */
31
+ export const APPROVAL_PREFIX = "shell:approval:";
32
+
33
+ /** How long a card waits when the Bot names no window. */
34
+ export const APPROVAL_DEFAULT_EXPIRY_SECONDS = 24 * 60 * 60;
35
+ /**
36
+ * The shortest window. Below it a card would expire before a person who is not
37
+ * already looking at the screen could answer it, which is a refusal dressed as
38
+ * a question.
39
+ */
40
+ export const APPROVAL_MIN_EXPIRY_SECONDS = 5 * 60;
41
+ /** The longest. Past a week a pending decision is not pending, it is forgotten. */
42
+ export const APPROVAL_MAX_EXPIRY_SECONDS = 7 * 24 * 60 * 60;
43
+
44
+ /**
45
+ * Most records retained per Bot. Trimming loses a row, never a fact: the send
46
+ * itself stays on the durable log of the Turn that made it.
47
+ */
48
+ export const APPROVAL_RETENTION_LIMIT = 200;
49
+
50
+ const MAX_ID_LENGTH = 256;
51
+ const MAX_ACTION_LENGTH = 2_000;
52
+ const MAX_TIMESTAMP_LENGTH = 64;
53
+ const SEND_RATIONALE_MAX = 8_000;
54
+
55
+ export class ApprovalDecodeError extends Error {
56
+ constructor(message: string) {
57
+ super(message);
58
+ this.name = "ApprovalDecodeError";
59
+ }
60
+ }
61
+
62
+ /** What a pending decision resolved to, or that it has not. */
63
+ export type ApprovalDecisionV1 = "pending" | "approved" | "denied" | "expired";
64
+
65
+ /** The two answers a person may give. Expiry is not one of them. */
66
+ export type ApprovalUserDecisionV1 = "approved" | "denied";
67
+
68
+ /** The durable pending decision. One key, one schema version. */
69
+ export interface ApprovalRecordV1 {
70
+ schemaVersion: 1;
71
+ approvalId: string;
72
+ /** The Turn that asked. */
73
+ runId: string;
74
+ sessionId: string;
75
+ action: string;
76
+ risk: SendToUserApprovalRiskV1;
77
+ createdAt: string;
78
+ expiresAt: string;
79
+ decision: ApprovalDecisionV1;
80
+ decidedAt?: string;
81
+ /**
82
+ * Who answered. `"user"` for a person, `"expiry"` for the alarm — recorded
83
+ * rather than inferred, so a record read years later still says whether
84
+ * anyone actually looked at it.
85
+ */
86
+ decidedBy: "user" | "expiry" | "pending";
87
+ rationale?: string;
88
+ }
89
+
90
+ export function approvalKeyV1(approvalId: string): string {
91
+ return `${APPROVAL_PREFIX}${approvalId}`;
92
+ }
93
+
94
+ function record(input: unknown, label: string): Record<string, unknown> {
95
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
96
+ throw new ApprovalDecodeError(`${label} must be an object`);
97
+ }
98
+ return input as Record<string, unknown>;
99
+ }
100
+
101
+ function exactKeys(
102
+ value: Record<string, unknown>,
103
+ required: readonly string[],
104
+ optional: readonly string[],
105
+ label: string,
106
+ ): void {
107
+ const allowed = new Set([...required, ...optional]);
108
+ for (const key of Object.keys(value)) {
109
+ if (!allowed.has(key)) {
110
+ throw new ApprovalDecodeError(`${label} has an unexpected key "${key}"`);
111
+ }
112
+ }
113
+ for (const key of required) {
114
+ if (!Object.hasOwn(value, key)) {
115
+ throw new ApprovalDecodeError(`${label} is missing "${key}"`);
116
+ }
117
+ }
118
+ }
119
+
120
+ function text(value: unknown, maximum: number, label: string): string {
121
+ if (typeof value !== "string" || value.length === 0) {
122
+ throw new ApprovalDecodeError(`${label} must be a non-empty string`);
123
+ }
124
+ if (value.length > maximum) {
125
+ throw new ApprovalDecodeError(`${label} exceeds ${maximum} characters`);
126
+ }
127
+ return value;
128
+ }
129
+
130
+ function timestamp(value: unknown, label: string): string {
131
+ const stamp = text(value, MAX_TIMESTAMP_LENGTH, label);
132
+ if (Number.isNaN(Date.parse(stamp))) {
133
+ throw new ApprovalDecodeError(`${label} is not a timestamp`);
134
+ }
135
+ return stamp;
136
+ }
137
+
138
+ function risk(value: unknown, label: string): SendToUserApprovalRiskV1 {
139
+ if (value !== "low" && value !== "medium" && value !== "high") {
140
+ throw new ApprovalDecodeError(`${label} must be low, medium or high`);
141
+ }
142
+ return value;
143
+ }
144
+
145
+ function decision(value: unknown, label: string): ApprovalDecisionV1 {
146
+ if (
147
+ value !== "pending" &&
148
+ value !== "approved" &&
149
+ value !== "denied" &&
150
+ value !== "expired"
151
+ ) {
152
+ throw new ApprovalDecodeError(`${label} is invalid`);
153
+ }
154
+ return value;
155
+ }
156
+
157
+ export function decodeApprovalRecordV1(
158
+ value: unknown,
159
+ label = "approval record",
160
+ ): ApprovalRecordV1 {
161
+ const candidate = record(value, label);
162
+ exactKeys(
163
+ candidate,
164
+ [
165
+ "schemaVersion",
166
+ "approvalId",
167
+ "runId",
168
+ "sessionId",
169
+ "action",
170
+ "risk",
171
+ "createdAt",
172
+ "expiresAt",
173
+ "decision",
174
+ "decidedBy",
175
+ ],
176
+ ["decidedAt", "rationale"],
177
+ label,
178
+ );
179
+ if (candidate.schemaVersion !== 1) {
180
+ throw new ApprovalDecodeError(`${label} schemaVersion is unsupported`);
181
+ }
182
+ if (
183
+ candidate.decidedBy !== "user" &&
184
+ candidate.decidedBy !== "expiry" &&
185
+ candidate.decidedBy !== "pending"
186
+ ) {
187
+ throw new ApprovalDecodeError(`${label} decidedBy is invalid`);
188
+ }
189
+ return {
190
+ schemaVersion: 1,
191
+ approvalId: text(
192
+ candidate.approvalId,
193
+ MAX_ID_LENGTH,
194
+ `${label} approvalId`,
195
+ ),
196
+ runId: text(candidate.runId, MAX_ID_LENGTH, `${label} runId`),
197
+ sessionId: text(candidate.sessionId, MAX_ID_LENGTH, `${label} sessionId`),
198
+ action: text(candidate.action, MAX_ACTION_LENGTH, `${label} action`),
199
+ risk: risk(candidate.risk, `${label} risk`),
200
+ createdAt: timestamp(candidate.createdAt, `${label} createdAt`),
201
+ expiresAt: timestamp(candidate.expiresAt, `${label} expiresAt`),
202
+ decision: decision(candidate.decision, `${label} decision`),
203
+ decidedBy: candidate.decidedBy,
204
+ ...(candidate.decidedAt === undefined
205
+ ? {}
206
+ : { decidedAt: timestamp(candidate.decidedAt, `${label} decidedAt`) }),
207
+ ...(candidate.rationale === undefined
208
+ ? {}
209
+ : {
210
+ rationale: text(
211
+ candidate.rationale,
212
+ SEND_RATIONALE_MAX,
213
+ `${label} rationale`,
214
+ ),
215
+ }),
216
+ };
217
+ }
218
+
219
+ /**
220
+ * When a card expires, given when it was asked and what the Bot requested.
221
+ *
222
+ * The clamp is the contract, not a suggestion: a Bot that asks for one second
223
+ * gets five minutes, one that asks for a year gets a week, and one that asks
224
+ * for nothing gets a day. Nothing downstream re-checks it, so nothing
225
+ * downstream can disagree about it.
226
+ */
227
+ export function approvalExpiresAtV1(
228
+ createdAt: string,
229
+ expiresInSeconds?: number,
230
+ ): string {
231
+ const requested =
232
+ expiresInSeconds === undefined || !Number.isFinite(expiresInSeconds)
233
+ ? APPROVAL_DEFAULT_EXPIRY_SECONDS
234
+ : Math.floor(expiresInSeconds);
235
+ const seconds = Math.min(
236
+ APPROVAL_MAX_EXPIRY_SECONDS,
237
+ Math.max(APPROVAL_MIN_EXPIRY_SECONDS, requested),
238
+ );
239
+ const asked = Date.parse(createdAt);
240
+ if (Number.isNaN(asked)) {
241
+ throw new ApprovalDecodeError("approval createdAt is not a timestamp");
242
+ }
243
+ return new Date(asked + seconds * 1_000).toISOString();
244
+ }
245
+
246
+ /** One approval send that a settled Turn made, in the order it made them. */
247
+ export interface ApprovalSendV1 {
248
+ approvalId: string;
249
+ action: string;
250
+ rationale?: string;
251
+ risk: SendToUserApprovalRiskV1;
252
+ expiresInSeconds?: number;
253
+ }
254
+
255
+ /**
256
+ * The approval sends on a settled Turn's durable log. Read off `send/to-user`
257
+ * events rather than off anything the Agent returned, because the log is the
258
+ * reconstruction surface and a recovered Turn has only the log.
259
+ */
260
+ export function approvalSendsV1(
261
+ events: readonly { type: string }[],
262
+ ): ApprovalSendV1[] {
263
+ const sends: ApprovalSendV1[] = [];
264
+ const seen = new Set<string>();
265
+ for (const event of events) {
266
+ if (event.type !== "send/to-user") continue;
267
+ const payload = (event as { payload?: { type?: string } }).payload;
268
+ if (!payload || payload.type !== "approval") continue;
269
+ const approval = payload as unknown as ApprovalSendV1;
270
+ // The same card sent twice in one Turn is one decision, and the first
271
+ // wording of it is the one the person was shown first.
272
+ if (seen.has(approval.approvalId)) continue;
273
+ seen.add(approval.approvalId);
274
+ sends.push(approval);
275
+ }
276
+ return sends;
277
+ }
278
+
279
+ /** The settled Turn a terminal record set is computed from. */
280
+ export interface ApprovalTerminalInputV1 {
281
+ run: {
282
+ runId: string;
283
+ sessionId: string;
284
+ events: readonly { type: string }[];
285
+ };
286
+ now: string;
287
+ read<T>(key: string): Promise<T | undefined>;
288
+ }
289
+
290
+ /**
291
+ * The approval records one settled Turn contributes to the transaction that
292
+ * settles it.
293
+ *
294
+ * A record that already exists is left exactly as it is. That is what makes a
295
+ * recovered or replayed Turn safe: the Turn is re-settled, the same send is
296
+ * read off the same log, and a decision a person made in between is not
297
+ * overwritten by a second `pending`.
298
+ */
299
+ export async function approvalTerminalRecordsV1(
300
+ input: ApprovalTerminalInputV1,
301
+ ): Promise<Record<string, unknown>> {
302
+ const records: Record<string, unknown> = {};
303
+ for (const send of approvalSendsV1(input.run.events)) {
304
+ const key = approvalKeyV1(send.approvalId);
305
+ if ((await input.read<unknown>(key)) !== undefined) continue;
306
+ records[key] = {
307
+ schemaVersion: 1,
308
+ approvalId: send.approvalId,
309
+ runId: input.run.runId,
310
+ sessionId: input.run.sessionId,
311
+ action: send.action,
312
+ risk: send.risk,
313
+ createdAt: input.now,
314
+ expiresAt: approvalExpiresAtV1(input.now, send.expiresInSeconds),
315
+ decision: "pending",
316
+ decidedBy: "pending",
317
+ ...(send.rationale === undefined ? {} : { rationale: send.rationale }),
318
+ } satisfies ApprovalRecordV1;
319
+ }
320
+ return records;
321
+ }
322
+
323
+ /** One approval, as the hosted client is told it. */
324
+ export interface ApprovalCardViewV1 {
325
+ schemaVersion: 1;
326
+ approvalId: string;
327
+ runId: string;
328
+ action: string;
329
+ risk: SendToUserApprovalRiskV1;
330
+ createdAt: string;
331
+ expiresAt: string;
332
+ decision: ApprovalDecisionV1;
333
+ decidedAt?: string;
334
+ rationale?: string;
335
+ }
336
+
337
+ export function projectApprovalCardV1(
338
+ stored: ApprovalRecordV1,
339
+ ): ApprovalCardViewV1 {
340
+ return {
341
+ schemaVersion: 1,
342
+ approvalId: stored.approvalId,
343
+ runId: stored.runId,
344
+ action: stored.action,
345
+ risk: stored.risk,
346
+ createdAt: stored.createdAt,
347
+ expiresAt: stored.expiresAt,
348
+ decision: stored.decision,
349
+ ...(stored.decidedAt === undefined ? {} : { decidedAt: stored.decidedAt }),
350
+ ...(stored.rationale === undefined ? {} : { rationale: stored.rationale }),
351
+ };
352
+ }
353
+
354
+ /**
355
+ * The Bot's approvals, newest first, with the pending count beside them.
356
+ *
357
+ * Decided cards are carried too, because the card in the transcript has to be
358
+ * able to say what was decided rather than going quiet the moment it is
359
+ * answered; `pending` is the number the settings surface counts.
360
+ */
361
+ export interface ApprovalListViewV1 {
362
+ schemaVersion: 1;
363
+ botId: string;
364
+ approvals: ApprovalCardViewV1[];
365
+ pending: number;
366
+ }
367
+
368
+ /** One decision, as a person submits it. */
369
+ export interface ApprovalDecisionCommandV1 {
370
+ schemaVersion: 1;
371
+ decision: ApprovalUserDecisionV1;
372
+ }
373
+
374
+ /** What the decision route answers, on the first call and on every replay. */
375
+ export interface ApprovalDecisionReceiptV1 {
376
+ schemaVersion: 1;
377
+ approval: ApprovalCardViewV1;
378
+ /** `recorded` on the write that decided it; `replayed` on every one after. */
379
+ status: "recorded" | "replayed";
380
+ }
381
+
382
+ export function decodeApprovalDecisionCommandV1(
383
+ value: unknown,
384
+ label = "approval decision",
385
+ ): ApprovalDecisionCommandV1 {
386
+ const candidate = record(value, label);
387
+ exactKeys(candidate, ["schemaVersion", "decision"], [], label);
388
+ if (candidate.schemaVersion !== 1) {
389
+ throw new ApprovalDecodeError(`${label} schemaVersion is unsupported`);
390
+ }
391
+ if (candidate.decision !== "approved" && candidate.decision !== "denied") {
392
+ throw new ApprovalDecodeError(
393
+ `${label} decision must be approved or denied`,
394
+ );
395
+ }
396
+ return { schemaVersion: 1, decision: candidate.decision };
397
+ }
398
+
399
+ function decodeApprovalCardV1(
400
+ value: unknown,
401
+ label = "approval card",
402
+ ): ApprovalCardViewV1 {
403
+ const candidate = record(value, label);
404
+ exactKeys(
405
+ candidate,
406
+ [
407
+ "schemaVersion",
408
+ "approvalId",
409
+ "runId",
410
+ "action",
411
+ "risk",
412
+ "createdAt",
413
+ "expiresAt",
414
+ "decision",
415
+ ],
416
+ ["decidedAt", "rationale"],
417
+ label,
418
+ );
419
+ if (candidate.schemaVersion !== 1) {
420
+ throw new ApprovalDecodeError(`${label} schemaVersion is unsupported`);
421
+ }
422
+ return {
423
+ schemaVersion: 1,
424
+ approvalId: text(
425
+ candidate.approvalId,
426
+ MAX_ID_LENGTH,
427
+ `${label} approvalId`,
428
+ ),
429
+ runId: text(candidate.runId, MAX_ID_LENGTH, `${label} runId`),
430
+ action: text(candidate.action, MAX_ACTION_LENGTH, `${label} action`),
431
+ risk: risk(candidate.risk, `${label} risk`),
432
+ createdAt: timestamp(candidate.createdAt, `${label} createdAt`),
433
+ expiresAt: timestamp(candidate.expiresAt, `${label} expiresAt`),
434
+ decision: decision(candidate.decision, `${label} decision`),
435
+ ...(candidate.decidedAt === undefined
436
+ ? {}
437
+ : { decidedAt: timestamp(candidate.decidedAt, `${label} decidedAt`) }),
438
+ ...(candidate.rationale === undefined
439
+ ? {}
440
+ : {
441
+ rationale: text(
442
+ candidate.rationale,
443
+ SEND_RATIONALE_MAX,
444
+ `${label} rationale`,
445
+ ),
446
+ }),
447
+ };
448
+ }
449
+
450
+ export function decodeApprovalListViewV1(
451
+ value: unknown,
452
+ label = "approval list",
453
+ ): ApprovalListViewV1 {
454
+ const candidate = record(value, label);
455
+ exactKeys(
456
+ candidate,
457
+ ["schemaVersion", "botId", "approvals", "pending"],
458
+ [],
459
+ label,
460
+ );
461
+ if (candidate.schemaVersion !== 1) {
462
+ throw new ApprovalDecodeError(`${label} schemaVersion is unsupported`);
463
+ }
464
+ if (!Array.isArray(candidate.approvals)) {
465
+ throw new ApprovalDecodeError(`${label} approvals must be an array`);
466
+ }
467
+ if (
468
+ typeof candidate.pending !== "number" ||
469
+ !Number.isSafeInteger(candidate.pending) ||
470
+ candidate.pending < 0
471
+ ) {
472
+ throw new ApprovalDecodeError(`${label} pending is invalid`);
473
+ }
474
+ return {
475
+ schemaVersion: 1,
476
+ botId: text(candidate.botId, MAX_ID_LENGTH, `${label} botId`),
477
+ approvals: candidate.approvals.map((entry) =>
478
+ decodeApprovalCardV1(entry, `${label} entry`),
479
+ ),
480
+ pending: candidate.pending,
481
+ };
482
+ }
483
+
484
+ export function decodeApprovalDecisionReceiptV1(
485
+ value: unknown,
486
+ label = "approval receipt",
487
+ ): ApprovalDecisionReceiptV1 {
488
+ const candidate = record(value, label);
489
+ exactKeys(candidate, ["schemaVersion", "approval", "status"], [], label);
490
+ if (candidate.schemaVersion !== 1) {
491
+ throw new ApprovalDecodeError(`${label} schemaVersion is unsupported`);
492
+ }
493
+ if (candidate.status !== "recorded" && candidate.status !== "replayed") {
494
+ throw new ApprovalDecodeError(`${label} status is invalid`);
495
+ }
496
+ return {
497
+ schemaVersion: 1,
498
+ approval: decodeApprovalCardV1(candidate.approval, `${label} approval`),
499
+ status: candidate.status,
500
+ };
501
+ }
502
+
503
+ /**
504
+ * Who is told about a pending decision, and how loudly.
505
+ *
506
+ * An approval ignores `notifications.enabled` on purpose: muting a Bot silences
507
+ * its chatter, not a question that has stopped it. The urgency says so —
508
+ * `critical` is the one value the desktop and mobile notification Packages
509
+ * treat as interrupting.
510
+ */
511
+ export function approvalNotificationIdV1(approvalId: string): string {
512
+ return `approval:${approvalId}`;
513
+ }
514
+
515
+ /** What the User is told a card says, bounded for a notification body. */
516
+ export function approvalNotificationBodyV1(send: ApprovalSendV1): string {
517
+ return `${send.risk === "high" ? "High risk. " : ""}${send.action}`.slice(
518
+ 0,
519
+ 240,
520
+ );
521
+ }
522
+
523
+ /** The decided approvals a listing may drop, oldest first. */
524
+ export function trimmableApprovalKeysV1(
525
+ keys: readonly string[],
526
+ limit = APPROVAL_RETENTION_LIMIT,
527
+ ): string[] {
528
+ const sorted = [...keys].sort();
529
+ return sorted.length <= limit ? [] : sorted.slice(0, sorted.length - limit);
530
+ }
@@ -0,0 +1,161 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ nextAssignmentPhase,
4
+ requireStoredAssignmentSaga,
5
+ settleAssignmentSaga,
6
+ type AssignmentSagaEffects,
7
+ type StoredAssignmentSaga,
8
+ } from "./backend-assignment.js";
9
+
10
+ function saga(
11
+ phase: StoredAssignmentSaga["phase"],
12
+ input: Partial<StoredAssignmentSaga> = {},
13
+ ): StoredAssignmentSaga {
14
+ return {
15
+ schemaVersion: 1,
16
+ commandId: "command-1",
17
+ commandFingerprint: "configuration-command-v1:test",
18
+ userId: "user-1",
19
+ botId: "bot-1",
20
+ operation: "replacing",
21
+ assignmentId: "mail",
22
+ generation: "generation-1",
23
+ phase,
24
+ target: {
25
+ assignmentId: "mail",
26
+ packageId: "mail",
27
+ capabilityId: "send",
28
+ connectionId: "new-connection",
29
+ },
30
+ previous: {
31
+ assignmentId: "mail",
32
+ packageId: "mail",
33
+ capabilityId: "send",
34
+ connectionId: "old-connection",
35
+ state: "enabled",
36
+ },
37
+ previousGeneration: "old-generation",
38
+ deadlineAt: Date.now() + 60_000,
39
+ ...input,
40
+ acceptedReceipt: input.acceptedReceipt ?? {
41
+ schemaVersion: 1,
42
+ commandId: "command-1",
43
+ revision: 0,
44
+ status: "pending",
45
+ },
46
+ };
47
+ }
48
+
49
+ function effects(log: string[], acknowledge = true): AssignmentSagaEffects {
50
+ return {
51
+ acknowledge: () => {
52
+ log.push("acknowledge");
53
+ return Promise.resolve(acknowledge);
54
+ },
55
+ compensate: () => {
56
+ log.push("compensate");
57
+ return Promise.resolve();
58
+ },
59
+ release: () => {
60
+ log.push("release");
61
+ return Promise.resolve(true);
62
+ },
63
+ rejectCommitted: () => {
64
+ log.push("reject-committed");
65
+ return Promise.resolve();
66
+ },
67
+ };
68
+ }
69
+
70
+ describe("Assignment saga transitions", () => {
71
+ test("orders Replace as claim, commit, acknowledge, release", () => {
72
+ const committed = nextAssignmentPhase(saga("claiming"), "claimed")!;
73
+ const acknowledged = nextAssignmentPhase(committed, "committed")!;
74
+ const releasing = nextAssignmentPhase(acknowledged, "acknowledged")!;
75
+ expect([committed.phase, acknowledged.phase, releasing.phase]).toEqual([
76
+ "committing",
77
+ "acknowledging",
78
+ "releasing",
79
+ ]);
80
+ expect(nextAssignmentPhase(releasing, "released")).toBeUndefined();
81
+ });
82
+
83
+ test("finishes a connection-free Assign after commit", () => {
84
+ expect(
85
+ nextAssignmentPhase(
86
+ saga("committing", {
87
+ operation: "assigning",
88
+ target: {
89
+ assignmentId: "clock",
90
+ packageId: "clock",
91
+ capabilityId: "time",
92
+ },
93
+ previous: undefined,
94
+ previousGeneration: undefined,
95
+ }),
96
+ "committed",
97
+ ),
98
+ ).toBeUndefined();
99
+ });
100
+
101
+ test("strictly decodes durable saga state", () => {
102
+ expect(requireStoredAssignmentSaga(saga("claiming"))).toMatchObject({
103
+ operation: "replacing",
104
+ phase: "claiming",
105
+ });
106
+ expect(() =>
107
+ requireStoredAssignmentSaga({ ...saga("claiming"), extra: true }),
108
+ ).toThrow("invalid fields");
109
+ const hidden = saga("claiming") as StoredAssignmentSaga & { hidden?: true };
110
+ Object.defineProperty(hidden, "hidden", { value: true });
111
+ expect(() => requireStoredAssignmentSaga(hidden)).toThrow("invalid fields");
112
+ expect(() =>
113
+ requireStoredAssignmentSaga({
114
+ ...saga("claiming"),
115
+ [Symbol("extra")]: true,
116
+ }),
117
+ ).toThrow("invalid fields");
118
+ expect(() =>
119
+ requireStoredAssignmentSaga({
120
+ ...saga("claiming"),
121
+ acceptedReceipt: {
122
+ schemaVersion: 1,
123
+ commandId: "command-1",
124
+ revision: 0,
125
+ status: "applied",
126
+ },
127
+ }),
128
+ ).toThrow("accepted receipt is invalid");
129
+ });
130
+
131
+ test("rejects out-of-order advancement", () => {
132
+ expect(() => nextAssignmentPhase(saga("claiming"), "released")).toThrow(
133
+ "cannot apply released while claiming",
134
+ );
135
+ });
136
+
137
+ test("compensates a claiming saga and rejects an unacknowledged commit", async () => {
138
+ const compensated: string[] = [];
139
+ await expect(
140
+ settleAssignmentSaga(saga("claiming"), effects(compensated)),
141
+ ).resolves.toBe("compensated");
142
+ expect(compensated).toEqual(["compensate"]);
143
+
144
+ const acknowledged: string[] = [];
145
+ await expect(
146
+ settleAssignmentSaga(saga("acknowledging"), effects(acknowledged)),
147
+ ).resolves.toBe("acknowledged");
148
+ expect(acknowledged).toEqual(["acknowledge"]);
149
+
150
+ const log: string[] = [];
151
+ await expect(
152
+ settleAssignmentSaga(saga("acknowledging"), effects(log, false)),
153
+ ).resolves.toBe("rejected");
154
+ expect(log).toEqual([
155
+ "acknowledge",
156
+ "compensate",
157
+ "release",
158
+ "reject-committed",
159
+ ]);
160
+ });
161
+ });