@frockbot/plugin-shell 0.0.0 → 0.1.1
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/frockbot.json +68 -0
- package/package.json +87 -6
- package/src/agent.test.ts +372 -0
- package/src/agent.ts +335 -0
- package/src/approvals.test.ts +224 -0
- package/src/approvals.ts +530 -0
- package/src/backend-assignment.test.ts +161 -0
- package/src/backend-assignment.ts +274 -0
- package/src/backend-authoring.test.ts +518 -0
- package/src/backend-authoring.ts +531 -0
- package/src/backend-bot-identity.test.ts +215 -0
- package/src/backend-completion.test.ts +289 -0
- package/src/backend-completion.ts +95 -0
- package/src/backend-composition.ts +242 -0
- package/src/backend-computer.ts +76 -0
- package/src/backend-configuration.test.ts +1757 -0
- package/src/backend-contracts.test.ts +189 -0
- package/src/backend-contracts.ts +44 -0
- package/src/backend-debug.test.ts +202 -0
- package/src/backend-execution.ts +55 -0
- package/src/backend-flock.ts +96 -0
- package/src/backend-image.test.ts +115 -0
- package/src/backend-image.ts +180 -0
- package/src/backend-isolate.test.ts +238 -0
- package/src/backend-isolate.ts +409 -0
- package/src/backend-machine.ts +144 -0
- package/src/backend-memory.ts +89 -0
- package/src/backend-recovery-integration.test.ts +1575 -0
- package/src/backend-recovery.ts +106 -0
- package/src/backend-routines.ts +375 -0
- package/src/backend-runner.ts +251 -0
- package/src/backend-skills.test.ts +126 -0
- package/src/backend-skills.ts +198 -0
- package/src/backend-stop.test.ts +356 -0
- package/src/backend-subagents.ts +459 -0
- package/src/backend.ts +6035 -0
- package/src/client/FrockBotApp.vue +1026 -0
- package/src/client/SendPayloadView.vue +337 -0
- package/src/client/composer-draft.test.ts +31 -0
- package/src/client/composer-draft.ts +35 -0
- package/src/client/cordis-client-shim.d.ts +15 -0
- package/src/client/index.test.ts +2548 -0
- package/src/client/index.ts +2346 -0
- package/src/client/model-presentation.test.ts +35 -0
- package/src/client/model-presentation.ts +19 -0
- package/src/client/notify.test.ts +89 -0
- package/src/client/notify.ts +101 -0
- package/src/client/skill-invocation.test.ts +143 -0
- package/src/client/skill-invocation.ts +175 -0
- package/src/client/styles.css +1043 -0
- package/src/composition-views.ts +118 -0
- package/src/debug-protocol.test.ts +80 -0
- package/src/debug-protocol.ts +165 -0
- package/src/env.d.ts +10 -0
- package/src/history.test.ts +163 -0
- package/src/history.ts +108 -0
- package/src/host.ts +20 -0
- package/src/index.ts +2 -0
- package/src/manifest.ts +3 -0
- package/src/run-cursor.ts +28 -0
- package/src/run-protocol.test.ts +1281 -0
- package/src/run-protocol.ts +1417 -0
- package/src/settings-links.test.ts +106 -0
- package/src/settings-links.ts +289 -0
- package/src/shared.ts +338 -0
- package/src/skill-protocol.ts +117 -0
- package/src/terminal-records.test.ts +217 -0
- package/src/terminal-records.ts +150 -0
- package/src/unread.test.ts +362 -0
- package/src/unread.ts +675 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +32 -0
- package/README.md +0 -3
package/src/approvals.ts
ADDED
|
@@ -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
|
+
});
|