@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.
- 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
|
@@ -0,0 +1,1417 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeSendToUserPayloadV1,
|
|
3
|
+
decodeSkillRefsV1,
|
|
4
|
+
type SendToUserPayloadV1,
|
|
5
|
+
type SessionEvent,
|
|
6
|
+
type SkillRefV1,
|
|
7
|
+
} from "@frockbot/kernel-contracts";
|
|
8
|
+
import { isPublicIdentifier } from "@frockbot/configuration-core";
|
|
9
|
+
import { decodeRunCursorV1, RUN_CURSOR_PATTERN } from "./run-cursor.js";
|
|
10
|
+
export { decodeRunCursorV1, RUN_CURSOR_PATTERN };
|
|
11
|
+
import type {
|
|
12
|
+
ClientNotificationIntent,
|
|
13
|
+
ClientRun,
|
|
14
|
+
ClientTurnEvent,
|
|
15
|
+
ClientTurnResponse,
|
|
16
|
+
} from "@frockbot/client-core";
|
|
17
|
+
import {
|
|
18
|
+
decodeRunIdV1,
|
|
19
|
+
requireStoredRunV1,
|
|
20
|
+
type BotNotificationIntent,
|
|
21
|
+
type BotTurnCompletion,
|
|
22
|
+
type StoredRun,
|
|
23
|
+
} from "./backend-contracts.js";
|
|
24
|
+
|
|
25
|
+
const MAX_RUN_ID_LENGTH = 128;
|
|
26
|
+
const MAX_TIMESTAMP_LENGTH = 64;
|
|
27
|
+
const MAX_INPUT_BYTES = 32_000;
|
|
28
|
+
const MAX_OUTCOME_BYTES = 64_000;
|
|
29
|
+
const MAX_FAILURE_BYTES = 8_000;
|
|
30
|
+
const MAX_VISIBLE_EVENTS = 512;
|
|
31
|
+
const MAX_EVENT_ID_LENGTH = 256;
|
|
32
|
+
const MAX_EVENT_NAME_BYTES = 256;
|
|
33
|
+
const MAX_EVENT_CONTENT_BYTES = 32_000;
|
|
34
|
+
const MAX_VISIBLE_EVENT_BYTES = 128_000;
|
|
35
|
+
const MAX_NOTIFICATION_TITLE_BYTES = 512;
|
|
36
|
+
const MAX_NOTIFICATION_BODY_BYTES = 2_000;
|
|
37
|
+
const MAX_CLIENT_TURN_BYTES = 256_000;
|
|
38
|
+
const MAX_CURSOR_LENGTH = 320;
|
|
39
|
+
const MAX_TASK_DESCRIPTION_BYTES = 800;
|
|
40
|
+
const MAX_TASK_MODEL_BYTES = 512;
|
|
41
|
+
export const CLIENT_RUN_PAGE_LIMIT = 32;
|
|
42
|
+
export const CLIENT_RUN_LIST_MAX_BYTES = 512_000;
|
|
43
|
+
|
|
44
|
+
export type ClientRunStatusV1 =
|
|
45
|
+
"running" | "completed" | "failed" | "cancelled" | "reconciliation-required";
|
|
46
|
+
|
|
47
|
+
const CANCELLED_RUN_MESSAGE = "Stopped by an authenticated Stop command.";
|
|
48
|
+
|
|
49
|
+
export type ClientRunEventV1 =
|
|
50
|
+
| {
|
|
51
|
+
type: "run/events-truncated";
|
|
52
|
+
omittedInteractions: number;
|
|
53
|
+
}
|
|
54
|
+
| {
|
|
55
|
+
type: "tool/call";
|
|
56
|
+
call: { id: string; name: string };
|
|
57
|
+
}
|
|
58
|
+
| {
|
|
59
|
+
type: "tool/result";
|
|
60
|
+
callId: string;
|
|
61
|
+
content: string;
|
|
62
|
+
isError: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Binaries the tool filed in a durable root. References, never bytes:
|
|
65
|
+
* the client fetches them from the Workspace read route, so a thread
|
|
66
|
+
* that scrolls past a hundred screenshots carries a hundred paths.
|
|
67
|
+
*/
|
|
68
|
+
attachments?: ClientToolAttachmentV1[];
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A user-facing send, projected so the client can draw the payload. The
|
|
72
|
+
* Turn's derived text carries only what the model wrote as an assistant
|
|
73
|
+
* message, and a widget-ended Turn writes none, so the payload has to reach
|
|
74
|
+
* the client here or not at all.
|
|
75
|
+
*/
|
|
76
|
+
| {
|
|
77
|
+
type: "send/to-user";
|
|
78
|
+
payload: SendToUserPayloadV1;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* A child Turn's hand-off to its parent. Projected because it is durable
|
|
82
|
+
* history of that Turn; delivering it into the parent is a later slice.
|
|
83
|
+
*/
|
|
84
|
+
| {
|
|
85
|
+
type: "wake/parent";
|
|
86
|
+
message: string;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A subagent this Turn dispatched (ADR 0017). The child's Session never
|
|
90
|
+
* enters the visible transcript, so this chip is the whole of what the
|
|
91
|
+
* conversation says about it: what it is, what it was asked to do, and which
|
|
92
|
+
* model it runs on. Its *live* status and its summary come from the Bot's
|
|
93
|
+
* task list, not from here — this event is the durable fact that the dispatch
|
|
94
|
+
* happened, and it never changes after it is written.
|
|
95
|
+
*/
|
|
96
|
+
| {
|
|
97
|
+
type: "task/dispatched";
|
|
98
|
+
taskId: string;
|
|
99
|
+
taskType: string;
|
|
100
|
+
description: string;
|
|
101
|
+
model: string;
|
|
102
|
+
background: boolean;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type ClientRunOutcomeV1 =
|
|
106
|
+
| { type: "completed"; text: string }
|
|
107
|
+
| { type: "failed"; message: string }
|
|
108
|
+
| { type: "cancelled"; message: string };
|
|
109
|
+
|
|
110
|
+
export interface ClientRunRecoveryV1 {
|
|
111
|
+
action: "resume";
|
|
112
|
+
message: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The run projection. Version 2 adds `send/to-user` and `wake/parent` to
|
|
117
|
+
* `events`; a version 1 body is a version 2 body that carries neither, so the
|
|
118
|
+
* decoder accepts both and the projection emits 2.
|
|
119
|
+
*/
|
|
120
|
+
export interface ClientRunV1 {
|
|
121
|
+
schemaVersion: 1 | 2;
|
|
122
|
+
runId: string;
|
|
123
|
+
admittedAt: string;
|
|
124
|
+
input: string;
|
|
125
|
+
status: ClientRunStatusV1;
|
|
126
|
+
events: ClientRunEventV1[];
|
|
127
|
+
/** Durable Stop intent, projected independently of the run status. */
|
|
128
|
+
stopRequestedAt?: string;
|
|
129
|
+
outcome?: ClientRunOutcomeV1;
|
|
130
|
+
recovery?: ClientRunRecoveryV1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface ClientRunPageV1 {
|
|
134
|
+
truncated: boolean;
|
|
135
|
+
nextCursor?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A durable Session event that belongs to no Turn. The WebUI renders it as a
|
|
140
|
+
* system line in the conversation.
|
|
141
|
+
*/
|
|
142
|
+
export interface ClientAnnouncementV1 {
|
|
143
|
+
type: "bot/renamed";
|
|
144
|
+
announcementId: string;
|
|
145
|
+
at: string;
|
|
146
|
+
from: string;
|
|
147
|
+
to: string;
|
|
148
|
+
namedBy: "user" | "bot";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface ClientRunListV1 {
|
|
152
|
+
schemaVersion: 1;
|
|
153
|
+
runs: ClientRunV1[];
|
|
154
|
+
page: ClientRunPageV1;
|
|
155
|
+
/**
|
|
156
|
+
* Optional so a stored projection written before announcements existed still
|
|
157
|
+
* decodes. Absent means the same as an empty list.
|
|
158
|
+
*/
|
|
159
|
+
announcements?: ClientAnnouncementV1[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ClientRunListQueryV1 {
|
|
163
|
+
schemaVersion: 1;
|
|
164
|
+
before?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface ClientTurnCommandV1 {
|
|
168
|
+
schemaVersion: 1;
|
|
169
|
+
commandId: string;
|
|
170
|
+
text: string;
|
|
171
|
+
/**
|
|
172
|
+
* The Skills this message invokes, attached in the composer with `/` or `@`.
|
|
173
|
+
* Absent means none. Bounded by the kernel's `MAX_INVOKED_SKILLS_V1`; a ref names a
|
|
174
|
+
* Skill, it never carries its text, so a client cannot inject instructions
|
|
175
|
+
* by pretending to invoke one.
|
|
176
|
+
*/
|
|
177
|
+
skills?: SkillRefV1[];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface ClientNotificationAcknowledgementCommandV1 {
|
|
181
|
+
schemaVersion: 1;
|
|
182
|
+
action: "acknowledge";
|
|
183
|
+
notificationId: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface ClientRunReconciliationCommandV1 {
|
|
187
|
+
schemaVersion: 1;
|
|
188
|
+
action: "resume";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Exact authenticated Stop command; one command targets exactly one run. */
|
|
192
|
+
export interface ClientRunStopCommandV1 {
|
|
193
|
+
schemaVersion: 1;
|
|
194
|
+
action: "stop";
|
|
195
|
+
commandId: string;
|
|
196
|
+
runId: string;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Stop acknowledgement. `accepted` reports a durable receipt, never a claim of
|
|
201
|
+
* terminal cancellation; `run` carries the authoritative projection.
|
|
202
|
+
*/
|
|
203
|
+
export interface ClientRunStopReceiptV1 {
|
|
204
|
+
schemaVersion: 1;
|
|
205
|
+
status: "accepted";
|
|
206
|
+
commandId: string;
|
|
207
|
+
runId: string;
|
|
208
|
+
run: ClientRunV1;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface ClientRunLookupQueryV1 {
|
|
212
|
+
schemaVersion: 1;
|
|
213
|
+
runId: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface ClientRunAdmissionFenceCommandV1 {
|
|
217
|
+
schemaVersion: 1;
|
|
218
|
+
action: "fence-admission";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export type ClientRunLookupStateV1 =
|
|
222
|
+
"not-admitted" | "running" | "reconciliation-required" | "terminal";
|
|
223
|
+
|
|
224
|
+
export type ClientRunLookupV1 =
|
|
225
|
+
| { schemaVersion: 1; state: "not-admitted" }
|
|
226
|
+
| {
|
|
227
|
+
schemaVersion: 1;
|
|
228
|
+
state: Exclude<ClientRunLookupStateV1, "not-admitted">;
|
|
229
|
+
run: ClientRunV1;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export type ClientRunLookup =
|
|
233
|
+
| { state: "not-admitted" }
|
|
234
|
+
| {
|
|
235
|
+
state: Exclude<ClientRunLookupStateV1, "not-admitted">;
|
|
236
|
+
run: ClientRun;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
export interface ClientNotificationIntentV1 {
|
|
240
|
+
notificationId: string;
|
|
241
|
+
runId: string;
|
|
242
|
+
createdAt: string;
|
|
243
|
+
title: string;
|
|
244
|
+
body: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface ClientTurnV1 {
|
|
248
|
+
schemaVersion: 1;
|
|
249
|
+
runId: string;
|
|
250
|
+
text: string;
|
|
251
|
+
events: ClientRunEventV1[];
|
|
252
|
+
notification?: ClientNotificationIntentV1;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function truncate(value: string, maximum: number): string {
|
|
256
|
+
return value.length <= maximum ? value : value.slice(0, maximum);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function wireBytes(value: unknown): number {
|
|
260
|
+
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function truncateWireString(value: string, maximumBytes: number): string {
|
|
264
|
+
if (wireBytes(value) <= maximumBytes) return value;
|
|
265
|
+
let low = 0;
|
|
266
|
+
let high = value.length;
|
|
267
|
+
while (low < high) {
|
|
268
|
+
const middle = Math.ceil((low + high) / 2);
|
|
269
|
+
if (wireBytes(value.slice(0, middle)) <= maximumBytes) low = middle;
|
|
270
|
+
else high = middle - 1;
|
|
271
|
+
}
|
|
272
|
+
const bounded = value.slice(0, low);
|
|
273
|
+
return /[\uD800-\uDBFF]$/.test(bounded) ? bounded.slice(0, -1) : bounded;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function publicEventId(value: string, label: string): string {
|
|
277
|
+
if (value.length === 0 || value.length > MAX_EVENT_ID_LENGTH) {
|
|
278
|
+
throw new Error(`${label} must be a bounded non-empty string`);
|
|
279
|
+
}
|
|
280
|
+
return value;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isTerminalRunStatus(status: ClientRunStatusV1): boolean {
|
|
284
|
+
return (
|
|
285
|
+
status === "completed" || status === "failed" || status === "cancelled"
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** One attachment of a projected tool result. */
|
|
290
|
+
export interface ClientToolAttachmentV1 {
|
|
291
|
+
kind: "image";
|
|
292
|
+
mediaType: string;
|
|
293
|
+
contentHash: string;
|
|
294
|
+
bytes: number;
|
|
295
|
+
/** The encoded `WorkspacePathV1` the Workspace read route takes. */
|
|
296
|
+
path: string;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
type ClientToolCallV1 = Extract<ClientRunEventV1, { type: "tool/call" }>;
|
|
300
|
+
type ClientToolResultV1 = Extract<ClientRunEventV1, { type: "tool/result" }>;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* One indivisible run of projected events, in the order the durable log wrote
|
|
304
|
+
* them. A tool interaction is its call and its result together — truncation
|
|
305
|
+
* never splits one — and a send or a hand-off is a unit of its own.
|
|
306
|
+
*
|
|
307
|
+
* `droppable` is false only for a tool call still waiting on its result: the
|
|
308
|
+
* client draws it as running work, so dropping it would show a Turn as idle
|
|
309
|
+
* while it is not.
|
|
310
|
+
*/
|
|
311
|
+
interface ProjectionUnitV1 {
|
|
312
|
+
events: ClientRunEventV1[];
|
|
313
|
+
droppable: boolean;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function projectionUnits(
|
|
317
|
+
events: readonly SessionEvent[],
|
|
318
|
+
status: ClientRunStatusV1,
|
|
319
|
+
): ProjectionUnitV1[] {
|
|
320
|
+
const units: ProjectionUnitV1[] = [];
|
|
321
|
+
const byOccurrence = new Map<string, ProjectionUnitV1>();
|
|
322
|
+
let callCount = 0;
|
|
323
|
+
for (const event of events) {
|
|
324
|
+
if (event.type === "tool/call") {
|
|
325
|
+
if (byOccurrence.has(event.occurrenceId)) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`tool occurrence "${event.occurrenceId}" has duplicate intent`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
callCount += 1;
|
|
331
|
+
const call: ClientToolCallV1 = {
|
|
332
|
+
type: "tool/call",
|
|
333
|
+
call: {
|
|
334
|
+
id: `tool-${callCount}`,
|
|
335
|
+
name: truncateWireString(event.name, MAX_EVENT_NAME_BYTES),
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
const unit: ProjectionUnitV1 = { events: [call], droppable: false };
|
|
339
|
+
units.push(unit);
|
|
340
|
+
byOccurrence.set(event.occurrenceId, unit);
|
|
341
|
+
} else if (event.type === "tool/result") {
|
|
342
|
+
const unit = byOccurrence.get(event.occurrenceId);
|
|
343
|
+
if (!unit) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`tool result has no matching occurrence "${event.occurrenceId}"`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
if (unit.droppable) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
`tool occurrence "${event.occurrenceId}" has duplicate results`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
const call = unit.events[0] as ClientToolCallV1;
|
|
354
|
+
const result: ClientToolResultV1 = {
|
|
355
|
+
type: "tool/result",
|
|
356
|
+
callId: call.call.id,
|
|
357
|
+
content: truncateWireString(event.content, MAX_EVENT_CONTENT_BYTES),
|
|
358
|
+
isError: event.isError,
|
|
359
|
+
...(event.attachments && event.attachments.length > 0
|
|
360
|
+
? {
|
|
361
|
+
attachments: event.attachments.map((attachment) => ({
|
|
362
|
+
kind: attachment.kind,
|
|
363
|
+
mediaType: attachment.mediaType,
|
|
364
|
+
contentHash: attachment.contentHash,
|
|
365
|
+
bytes: attachment.bytes,
|
|
366
|
+
path: JSON.stringify(attachment.workspacePath),
|
|
367
|
+
})),
|
|
368
|
+
}
|
|
369
|
+
: {}),
|
|
370
|
+
};
|
|
371
|
+
unit.events.push(result);
|
|
372
|
+
unit.droppable = true;
|
|
373
|
+
} else if (event.type === "send/to-user") {
|
|
374
|
+
units.push({
|
|
375
|
+
events: [{ type: "send/to-user", payload: event.payload }],
|
|
376
|
+
droppable: true,
|
|
377
|
+
});
|
|
378
|
+
} else if (event.type === "wake/parent") {
|
|
379
|
+
units.push({
|
|
380
|
+
events: [
|
|
381
|
+
{
|
|
382
|
+
type: "wake/parent",
|
|
383
|
+
message: truncateWireString(event.message, MAX_EVENT_CONTENT_BYTES),
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
droppable: true,
|
|
387
|
+
});
|
|
388
|
+
} else if (event.type === "task/dispatched") {
|
|
389
|
+
units.push({
|
|
390
|
+
events: [
|
|
391
|
+
{
|
|
392
|
+
type: "task/dispatched",
|
|
393
|
+
taskId: truncate(event.taskId, MAX_EVENT_ID_LENGTH),
|
|
394
|
+
taskType: truncateWireString(event.taskType, MAX_EVENT_NAME_BYTES),
|
|
395
|
+
description: truncateWireString(
|
|
396
|
+
event.description,
|
|
397
|
+
MAX_TASK_DESCRIPTION_BYTES,
|
|
398
|
+
),
|
|
399
|
+
model: truncateWireString(event.model, MAX_TASK_MODEL_BYTES),
|
|
400
|
+
background: event.background,
|
|
401
|
+
},
|
|
402
|
+
],
|
|
403
|
+
droppable: true,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (isTerminalRunStatus(status)) {
|
|
408
|
+
const orphaned = units.find((unit) => !unit.droppable);
|
|
409
|
+
if (orphaned) {
|
|
410
|
+
const call = orphaned.events[0] as ClientToolCallV1;
|
|
411
|
+
throw new Error(
|
|
412
|
+
`terminal run has no result for tool call "${call.call.id}"`,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return units;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function visibleEvents(
|
|
420
|
+
events: readonly SessionEvent[],
|
|
421
|
+
status: ClientRunStatusV1,
|
|
422
|
+
): ClientRunEventV1[] {
|
|
423
|
+
const units = projectionUnits(events, status);
|
|
424
|
+
const complete = units.flatMap((unit) => unit.events);
|
|
425
|
+
if (
|
|
426
|
+
complete.length <= MAX_VISIBLE_EVENTS &&
|
|
427
|
+
wireBytes(complete) <= MAX_VISIBLE_EVENT_BYTES
|
|
428
|
+
) {
|
|
429
|
+
return complete;
|
|
430
|
+
}
|
|
431
|
+
// Oldest first: truncation drops history, never the newest thing the User is
|
|
432
|
+
// waiting on. `omittedInteractions` counts dropped units, so a dropped send
|
|
433
|
+
// is as visible in the marker as a dropped tool interaction.
|
|
434
|
+
let retained = units;
|
|
435
|
+
let omittedInteractions = 0;
|
|
436
|
+
const buildProjection = (): ClientRunEventV1[] => [
|
|
437
|
+
{ type: "run/events-truncated", omittedInteractions },
|
|
438
|
+
...retained.flatMap((unit) => unit.events),
|
|
439
|
+
];
|
|
440
|
+
let projection = buildProjection();
|
|
441
|
+
while (
|
|
442
|
+
projection.length > MAX_VISIBLE_EVENTS ||
|
|
443
|
+
wireBytes(projection) > MAX_VISIBLE_EVENT_BYTES
|
|
444
|
+
) {
|
|
445
|
+
const index = retained.findIndex((unit) => unit.droppable);
|
|
446
|
+
if (index < 0) break;
|
|
447
|
+
retained = [...retained.slice(0, index), ...retained.slice(index + 1)];
|
|
448
|
+
omittedInteractions += 1;
|
|
449
|
+
projection = buildProjection();
|
|
450
|
+
}
|
|
451
|
+
if (projection.length > MAX_VISIBLE_EVENTS) {
|
|
452
|
+
throw new Error("run has too many pending tool interactions to project");
|
|
453
|
+
}
|
|
454
|
+
if (wireBytes(projection) > MAX_VISIBLE_EVENT_BYTES) {
|
|
455
|
+
throw new Error("pending tool interactions exceed the wire byte limit");
|
|
456
|
+
}
|
|
457
|
+
return projection;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function runStatus(run: StoredRun): ClientRunStatusV1 {
|
|
461
|
+
return requireStoredRunV1(run).status;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function projectClientRunV1(run: StoredRun): ClientRunV1 {
|
|
465
|
+
const status = runStatus(run);
|
|
466
|
+
const outcome =
|
|
467
|
+
status === "completed"
|
|
468
|
+
? ({
|
|
469
|
+
type: "completed",
|
|
470
|
+
text: truncateWireString(run.responseText ?? "", MAX_OUTCOME_BYTES),
|
|
471
|
+
} satisfies ClientRunOutcomeV1)
|
|
472
|
+
: status === "failed"
|
|
473
|
+
? ({
|
|
474
|
+
type: "failed",
|
|
475
|
+
message: truncateWireString(
|
|
476
|
+
run.failure ?? "Agent request failed.",
|
|
477
|
+
MAX_FAILURE_BYTES,
|
|
478
|
+
),
|
|
479
|
+
} satisfies ClientRunOutcomeV1)
|
|
480
|
+
: status === "cancelled"
|
|
481
|
+
? ({
|
|
482
|
+
type: "cancelled",
|
|
483
|
+
message: CANCELLED_RUN_MESSAGE,
|
|
484
|
+
} satisfies ClientRunOutcomeV1)
|
|
485
|
+
: undefined;
|
|
486
|
+
const recovery =
|
|
487
|
+
status === "reconciliation-required"
|
|
488
|
+
? ({
|
|
489
|
+
action: "resume",
|
|
490
|
+
message: truncateWireString(
|
|
491
|
+
run.failure ??
|
|
492
|
+
"Provider reconciliation is required before this Turn can continue.",
|
|
493
|
+
MAX_FAILURE_BYTES,
|
|
494
|
+
),
|
|
495
|
+
} satisfies ClientRunRecoveryV1)
|
|
496
|
+
: undefined;
|
|
497
|
+
return {
|
|
498
|
+
// Version 2: the projection may now carry `send/to-user` and
|
|
499
|
+
// `wake/parent`. A client pinned to 1 keeps decoding a stored list.
|
|
500
|
+
schemaVersion: 2,
|
|
501
|
+
runId: truncate(run.runId, MAX_RUN_ID_LENGTH),
|
|
502
|
+
admittedAt: truncate(run.acceptedAt, MAX_TIMESTAMP_LENGTH),
|
|
503
|
+
input: truncateWireString(run.input, MAX_INPUT_BYTES),
|
|
504
|
+
status,
|
|
505
|
+
events: visibleEvents(run.events, status),
|
|
506
|
+
...(run.stopRequestedAt
|
|
507
|
+
? {
|
|
508
|
+
stopRequestedAt: truncate(run.stopRequestedAt, MAX_TIMESTAMP_LENGTH),
|
|
509
|
+
}
|
|
510
|
+
: {}),
|
|
511
|
+
...(outcome ? { outcome } : {}),
|
|
512
|
+
...(recovery ? { recovery } : {}),
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function lookupState(
|
|
517
|
+
status: ClientRunStatusV1,
|
|
518
|
+
): Exclude<ClientRunLookupStateV1, "not-admitted"> {
|
|
519
|
+
if (status === "completed" || status === "failed" || status === "cancelled") {
|
|
520
|
+
return "terminal";
|
|
521
|
+
}
|
|
522
|
+
if (status === "reconciliation-required") {
|
|
523
|
+
return "reconciliation-required";
|
|
524
|
+
}
|
|
525
|
+
return "running";
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Whether one stored run belongs to the visible conversation.
|
|
530
|
+
*
|
|
531
|
+
* This is the client half of the transcript seam: the Bot Durable Object keeps
|
|
532
|
+
* one ordered log, and the projection — not the kernel — decides that only a
|
|
533
|
+
* Turn admitted as `chat` is a Turn a person sees. A run recorded before turn
|
|
534
|
+
* admission existed carries no marker and was a chat Turn, so it stays visible.
|
|
535
|
+
* An automation run is reachable only through its Routine's run log.
|
|
536
|
+
*/
|
|
537
|
+
export function isVisibleRunV1(run: {
|
|
538
|
+
admission?: { turnType?: string };
|
|
539
|
+
}): boolean {
|
|
540
|
+
return (run.admission?.turnType ?? "chat") === "chat";
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export function projectClientRunLookupV1(
|
|
544
|
+
run: StoredRun | undefined,
|
|
545
|
+
): ClientRunLookupV1 {
|
|
546
|
+
if (!run || !isVisibleRunV1(run)) {
|
|
547
|
+
return { schemaVersion: 1, state: "not-admitted" };
|
|
548
|
+
}
|
|
549
|
+
const projected = projectClientRunV1(run);
|
|
550
|
+
return {
|
|
551
|
+
schemaVersion: 1,
|
|
552
|
+
state: lookupState(projected.status),
|
|
553
|
+
run: projected,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function projectNotificationV1(
|
|
558
|
+
notification: BotNotificationIntent,
|
|
559
|
+
): ClientNotificationIntentV1 {
|
|
560
|
+
return {
|
|
561
|
+
notificationId: truncate(notification.notificationId, MAX_EVENT_ID_LENGTH),
|
|
562
|
+
runId: truncate(notification.runId, MAX_RUN_ID_LENGTH),
|
|
563
|
+
createdAt: truncate(notification.createdAt, MAX_TIMESTAMP_LENGTH),
|
|
564
|
+
title: truncateWireString(notification.title, MAX_NOTIFICATION_TITLE_BYTES),
|
|
565
|
+
body: truncateWireString(notification.body, MAX_NOTIFICATION_BODY_BYTES),
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export function projectClientTurnV1(result: BotTurnCompletion): ClientTurnV1 {
|
|
570
|
+
if (result.notification && result.notification.runId !== result.runId) {
|
|
571
|
+
throw new Error("turn notification does not match its run");
|
|
572
|
+
}
|
|
573
|
+
return {
|
|
574
|
+
schemaVersion: 1,
|
|
575
|
+
runId: truncate(result.runId, MAX_RUN_ID_LENGTH),
|
|
576
|
+
text: truncateWireString(result.text, MAX_OUTCOME_BYTES),
|
|
577
|
+
events: visibleEvents(result.events, "completed"),
|
|
578
|
+
...(result.notification
|
|
579
|
+
? { notification: projectNotificationV1(result.notification) }
|
|
580
|
+
: {}),
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
export function projectClientRunListV1(
|
|
585
|
+
runs: readonly StoredRun[],
|
|
586
|
+
): ClientRunListV1 {
|
|
587
|
+
return createClientRunListV1(runs.map(projectClientRunV1), {
|
|
588
|
+
truncated: false,
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function createClientRunListV1(
|
|
593
|
+
runs: ClientRunV1[],
|
|
594
|
+
page: ClientRunPageV1,
|
|
595
|
+
announcements: readonly ClientAnnouncementV1[] = [],
|
|
596
|
+
): ClientRunListV1 {
|
|
597
|
+
return {
|
|
598
|
+
schemaVersion: 1,
|
|
599
|
+
runs,
|
|
600
|
+
page,
|
|
601
|
+
...(announcements.length > 0 ? { announcements: [...announcements] } : {}),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const MAX_ANNOUNCEMENTS = 64;
|
|
606
|
+
const MAX_ANNOUNCEMENT_NAME_BYTES = 400;
|
|
607
|
+
|
|
608
|
+
/** Projects the Bot's durable announcement events onto the wire. */
|
|
609
|
+
export function projectClientAnnouncementsV1(
|
|
610
|
+
events: readonly SessionEvent[],
|
|
611
|
+
): ClientAnnouncementV1[] {
|
|
612
|
+
return events.flatMap((event) =>
|
|
613
|
+
event.type === "bot/renamed"
|
|
614
|
+
? [
|
|
615
|
+
{
|
|
616
|
+
type: "bot/renamed" as const,
|
|
617
|
+
announcementId: `announcement-${event.seq}`,
|
|
618
|
+
at: truncate(event.timestamp, MAX_TIMESTAMP_LENGTH),
|
|
619
|
+
from: truncateWireString(event.from, MAX_ANNOUNCEMENT_NAME_BYTES),
|
|
620
|
+
to: truncateWireString(event.to, MAX_ANNOUNCEMENT_NAME_BYTES),
|
|
621
|
+
namedBy: event.namedBy,
|
|
622
|
+
},
|
|
623
|
+
]
|
|
624
|
+
: [],
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export function clientRunListWireBytes(value: ClientRunListV1): number {
|
|
629
|
+
return wireBytes(value);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
633
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
634
|
+
throw new Error(`${label} must be an object`);
|
|
635
|
+
}
|
|
636
|
+
return value as Record<string, unknown>;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function exactKeys(
|
|
640
|
+
value: Record<string, unknown>,
|
|
641
|
+
allowed: readonly string[],
|
|
642
|
+
label: string,
|
|
643
|
+
): void {
|
|
644
|
+
const allowedKeys = new Set(allowed);
|
|
645
|
+
const unexpected = Reflect.ownKeys(value).find(
|
|
646
|
+
(key) =>
|
|
647
|
+
typeof key !== "string" ||
|
|
648
|
+
!allowedKeys.has(key) ||
|
|
649
|
+
!Object.prototype.propertyIsEnumerable.call(value, key),
|
|
650
|
+
);
|
|
651
|
+
if (unexpected !== undefined) {
|
|
652
|
+
const field =
|
|
653
|
+
typeof unexpected === "symbol" ? unexpected.toString() : unexpected;
|
|
654
|
+
throw new Error(`${label}.${field} is not allowed`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function string(
|
|
659
|
+
value: Record<string, unknown>,
|
|
660
|
+
key: string,
|
|
661
|
+
maximum: number,
|
|
662
|
+
label: string,
|
|
663
|
+
): string {
|
|
664
|
+
const field = value[key];
|
|
665
|
+
if (typeof field !== "string" || field.length > maximum) {
|
|
666
|
+
throw new Error(`${label}.${key} must be a bounded string`);
|
|
667
|
+
}
|
|
668
|
+
return field;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function wireString(
|
|
672
|
+
value: Record<string, unknown>,
|
|
673
|
+
key: string,
|
|
674
|
+
maximumBytes: number,
|
|
675
|
+
label: string,
|
|
676
|
+
): string {
|
|
677
|
+
const field = value[key];
|
|
678
|
+
if (typeof field !== "string" || wireBytes(field) > maximumBytes) {
|
|
679
|
+
throw new Error(`${label}.${key} must be a wire-bounded string`);
|
|
680
|
+
}
|
|
681
|
+
return field;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function status(value: unknown): ClientRunStatusV1 {
|
|
685
|
+
if (
|
|
686
|
+
value !== "running" &&
|
|
687
|
+
value !== "completed" &&
|
|
688
|
+
value !== "failed" &&
|
|
689
|
+
value !== "cancelled" &&
|
|
690
|
+
value !== "reconciliation-required"
|
|
691
|
+
) {
|
|
692
|
+
throw new Error("run.status is invalid");
|
|
693
|
+
}
|
|
694
|
+
return value;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function decodeEvent(value: unknown): ClientRunEventV1 {
|
|
698
|
+
const event = record(value, "run event");
|
|
699
|
+
if (event.type === "run/events-truncated") {
|
|
700
|
+
exactKeys(event, ["type", "omittedInteractions"], "run event");
|
|
701
|
+
if (
|
|
702
|
+
!Number.isSafeInteger(event.omittedInteractions) ||
|
|
703
|
+
(event.omittedInteractions as number) <= 0
|
|
704
|
+
) {
|
|
705
|
+
throw new Error(
|
|
706
|
+
"run event.omittedInteractions must be a positive safe integer",
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
return {
|
|
710
|
+
type: "run/events-truncated",
|
|
711
|
+
omittedInteractions: event.omittedInteractions as number,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
if (event.type === "tool/call") {
|
|
715
|
+
exactKeys(event, ["type", "call"], "run event");
|
|
716
|
+
const call = record(event.call, "run event.call");
|
|
717
|
+
exactKeys(call, ["id", "name"], "run event.call");
|
|
718
|
+
return {
|
|
719
|
+
type: "tool/call",
|
|
720
|
+
call: {
|
|
721
|
+
id: publicEventId(
|
|
722
|
+
string(call, "id", MAX_EVENT_ID_LENGTH, "run event.call"),
|
|
723
|
+
"run event.call.id",
|
|
724
|
+
),
|
|
725
|
+
name: wireString(call, "name", MAX_EVENT_NAME_BYTES, "run event.call"),
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
if (event.type === "tool/result") {
|
|
730
|
+
exactKeys(event, ["type", "callId", "content", "isError"], "run event");
|
|
731
|
+
if (typeof event.isError !== "boolean") {
|
|
732
|
+
throw new Error("run event.isError must be a boolean");
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
type: "tool/result",
|
|
736
|
+
callId: publicEventId(
|
|
737
|
+
string(event, "callId", MAX_EVENT_ID_LENGTH, "run event"),
|
|
738
|
+
"run event.callId",
|
|
739
|
+
),
|
|
740
|
+
content: wireString(
|
|
741
|
+
event,
|
|
742
|
+
"content",
|
|
743
|
+
MAX_EVENT_CONTENT_BYTES,
|
|
744
|
+
"run event",
|
|
745
|
+
),
|
|
746
|
+
isError: event.isError,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
if (event.type === "send/to-user") {
|
|
750
|
+
exactKeys(event, ["type", "payload"], "run event");
|
|
751
|
+
return {
|
|
752
|
+
type: "send/to-user",
|
|
753
|
+
payload: decodeSendToUserPayloadV1(event.payload, "run event.payload"),
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
if (event.type === "wake/parent") {
|
|
757
|
+
exactKeys(event, ["type", "message"], "run event");
|
|
758
|
+
return {
|
|
759
|
+
type: "wake/parent",
|
|
760
|
+
message: wireString(
|
|
761
|
+
event,
|
|
762
|
+
"message",
|
|
763
|
+
MAX_EVENT_CONTENT_BYTES,
|
|
764
|
+
"run event",
|
|
765
|
+
),
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
if (event.type === "task/dispatched") {
|
|
769
|
+
exactKeys(
|
|
770
|
+
event,
|
|
771
|
+
["type", "taskId", "taskType", "description", "model", "background"],
|
|
772
|
+
"run event",
|
|
773
|
+
);
|
|
774
|
+
if (typeof event.background !== "boolean") {
|
|
775
|
+
throw new Error("run event.background must be a boolean");
|
|
776
|
+
}
|
|
777
|
+
return {
|
|
778
|
+
type: "task/dispatched",
|
|
779
|
+
taskId: publicEventId(
|
|
780
|
+
string(event, "taskId", MAX_EVENT_ID_LENGTH, "run event"),
|
|
781
|
+
"run event.taskId",
|
|
782
|
+
),
|
|
783
|
+
taskType: wireString(
|
|
784
|
+
event,
|
|
785
|
+
"taskType",
|
|
786
|
+
MAX_EVENT_NAME_BYTES,
|
|
787
|
+
"run event",
|
|
788
|
+
),
|
|
789
|
+
description: wireString(
|
|
790
|
+
event,
|
|
791
|
+
"description",
|
|
792
|
+
MAX_TASK_DESCRIPTION_BYTES,
|
|
793
|
+
"run event",
|
|
794
|
+
),
|
|
795
|
+
model: wireString(event, "model", MAX_TASK_MODEL_BYTES, "run event"),
|
|
796
|
+
background: event.background,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
throw new Error("run event.type is invalid");
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function decodeEvents(
|
|
803
|
+
values: unknown[],
|
|
804
|
+
runStatus: ClientRunStatusV1,
|
|
805
|
+
): ClientTurnEvent[] {
|
|
806
|
+
const events = values.map(decodeEvent);
|
|
807
|
+
let index = 0;
|
|
808
|
+
if (events[0]?.type === "run/events-truncated") index = 1;
|
|
809
|
+
if (
|
|
810
|
+
events.slice(index).some((event) => event.type === "run/events-truncated")
|
|
811
|
+
) {
|
|
812
|
+
throw new Error("run truncation marker must be the first event");
|
|
813
|
+
}
|
|
814
|
+
const callIds = new Set<string>();
|
|
815
|
+
while (index < events.length) {
|
|
816
|
+
const call = events[index];
|
|
817
|
+
// A send and a hand-off stand alone: they pair with nothing, so the
|
|
818
|
+
// call/result walk steps straight over them.
|
|
819
|
+
if (
|
|
820
|
+
call?.type === "send/to-user" ||
|
|
821
|
+
call?.type === "wake/parent" ||
|
|
822
|
+
call?.type === "task/dispatched"
|
|
823
|
+
) {
|
|
824
|
+
index += 1;
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
if (call?.type !== "tool/call") {
|
|
828
|
+
throw new Error("run tool result has no matching call");
|
|
829
|
+
}
|
|
830
|
+
const id = call.call.id;
|
|
831
|
+
if (callIds.has(id)) {
|
|
832
|
+
throw new Error(`run tool call "${id}" is duplicated`);
|
|
833
|
+
}
|
|
834
|
+
callIds.add(id);
|
|
835
|
+
const result = events[index + 1];
|
|
836
|
+
if (result?.type === "tool/result") {
|
|
837
|
+
if (result.callId !== id) {
|
|
838
|
+
throw new Error(`run tool result does not match call "${id}"`);
|
|
839
|
+
}
|
|
840
|
+
index += 2;
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
if (isTerminalRunStatus(runStatus)) {
|
|
844
|
+
throw new Error(`terminal run has no result for tool call "${id}"`);
|
|
845
|
+
}
|
|
846
|
+
index += 1;
|
|
847
|
+
}
|
|
848
|
+
return events;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function decodeOutcome(
|
|
852
|
+
value: unknown,
|
|
853
|
+
runStatus: ClientRunStatusV1,
|
|
854
|
+
): ClientRunOutcomeV1 | undefined {
|
|
855
|
+
if (value === undefined) {
|
|
856
|
+
if (isTerminalRunStatus(runStatus)) {
|
|
857
|
+
throw new Error("terminal run.outcome is required");
|
|
858
|
+
}
|
|
859
|
+
return undefined;
|
|
860
|
+
}
|
|
861
|
+
const outcome = record(value, "run.outcome");
|
|
862
|
+
if (outcome.type === "completed" && runStatus === "completed") {
|
|
863
|
+
exactKeys(outcome, ["type", "text"], "run.outcome");
|
|
864
|
+
return {
|
|
865
|
+
type: "completed",
|
|
866
|
+
text: wireString(outcome, "text", MAX_OUTCOME_BYTES, "run.outcome"),
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
if (outcome.type === "failed" && runStatus === "failed") {
|
|
870
|
+
exactKeys(outcome, ["type", "message"], "run.outcome");
|
|
871
|
+
return {
|
|
872
|
+
type: "failed",
|
|
873
|
+
message: wireString(outcome, "message", MAX_FAILURE_BYTES, "run.outcome"),
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
if (outcome.type === "cancelled" && runStatus === "cancelled") {
|
|
877
|
+
exactKeys(outcome, ["type", "message"], "run.outcome");
|
|
878
|
+
return {
|
|
879
|
+
type: "cancelled",
|
|
880
|
+
message: wireString(outcome, "message", MAX_FAILURE_BYTES, "run.outcome"),
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
throw new Error("run.outcome does not match run.status");
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function decodeRecovery(
|
|
887
|
+
value: unknown,
|
|
888
|
+
runStatus: ClientRunStatusV1,
|
|
889
|
+
): ClientRunRecoveryV1 | undefined {
|
|
890
|
+
if (value === undefined) {
|
|
891
|
+
if (runStatus === "reconciliation-required") {
|
|
892
|
+
throw new Error("reconciliation-required run.recovery is required");
|
|
893
|
+
}
|
|
894
|
+
return undefined;
|
|
895
|
+
}
|
|
896
|
+
if (runStatus !== "reconciliation-required") {
|
|
897
|
+
throw new Error("run.recovery does not match run.status");
|
|
898
|
+
}
|
|
899
|
+
const recovery = record(value, "run.recovery");
|
|
900
|
+
exactKeys(recovery, ["action", "message"], "run.recovery");
|
|
901
|
+
if (recovery.action !== "resume") {
|
|
902
|
+
throw new Error("run.recovery.action is invalid");
|
|
903
|
+
}
|
|
904
|
+
return {
|
|
905
|
+
action: "resume",
|
|
906
|
+
message: wireString(recovery, "message", MAX_FAILURE_BYTES, "run.recovery"),
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function decodeRun(value: unknown): ClientRun {
|
|
911
|
+
const run = record(value, "run");
|
|
912
|
+
exactKeys(
|
|
913
|
+
run,
|
|
914
|
+
[
|
|
915
|
+
"schemaVersion",
|
|
916
|
+
"runId",
|
|
917
|
+
"admittedAt",
|
|
918
|
+
"input",
|
|
919
|
+
"status",
|
|
920
|
+
"events",
|
|
921
|
+
"stopRequestedAt",
|
|
922
|
+
"outcome",
|
|
923
|
+
"recovery",
|
|
924
|
+
],
|
|
925
|
+
"run",
|
|
926
|
+
);
|
|
927
|
+
// 1 and 2 differ only by the event types a version 2 body may carry, and a
|
|
928
|
+
// version 1 body carries a subset of them, so one walk decodes both.
|
|
929
|
+
if (run.schemaVersion !== 1 && run.schemaVersion !== 2) {
|
|
930
|
+
throw new Error("run.schemaVersion is invalid");
|
|
931
|
+
}
|
|
932
|
+
let runId: string;
|
|
933
|
+
try {
|
|
934
|
+
runId = decodeRunIdV1(string(run, "runId", MAX_RUN_ID_LENGTH, "run"));
|
|
935
|
+
} catch {
|
|
936
|
+
throw new Error("run.runId is invalid");
|
|
937
|
+
}
|
|
938
|
+
const admittedAt = string(run, "admittedAt", MAX_TIMESTAMP_LENGTH, "run");
|
|
939
|
+
if (!Number.isFinite(Date.parse(admittedAt))) {
|
|
940
|
+
throw new Error("run.admittedAt is invalid");
|
|
941
|
+
}
|
|
942
|
+
if (!Array.isArray(run.events) || run.events.length > MAX_VISIBLE_EVENTS) {
|
|
943
|
+
throw new Error("run.events must be a bounded array");
|
|
944
|
+
}
|
|
945
|
+
const runStatus = status(run.status);
|
|
946
|
+
const outcome = decodeOutcome(run.outcome, runStatus);
|
|
947
|
+
const recovery = decodeRecovery(run.recovery, runStatus);
|
|
948
|
+
let stopRequestedAt: string | undefined;
|
|
949
|
+
if (run.stopRequestedAt !== undefined) {
|
|
950
|
+
stopRequestedAt = string(
|
|
951
|
+
run,
|
|
952
|
+
"stopRequestedAt",
|
|
953
|
+
MAX_TIMESTAMP_LENGTH,
|
|
954
|
+
"run",
|
|
955
|
+
);
|
|
956
|
+
if (!Number.isFinite(Date.parse(stopRequestedAt))) {
|
|
957
|
+
throw new Error("run.stopRequestedAt is invalid");
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
if (runStatus === "cancelled" && stopRequestedAt === undefined) {
|
|
961
|
+
throw new Error("cancelled run.stopRequestedAt is required");
|
|
962
|
+
}
|
|
963
|
+
return {
|
|
964
|
+
runId,
|
|
965
|
+
admittedAt,
|
|
966
|
+
input: wireString(run, "input", MAX_INPUT_BYTES, "run"),
|
|
967
|
+
status: runStatus,
|
|
968
|
+
events: decodeEvents(run.events, runStatus),
|
|
969
|
+
...(stopRequestedAt ? { stopRequestedAt } : {}),
|
|
970
|
+
...(outcome?.type === "completed" ? { responseText: outcome.text } : {}),
|
|
971
|
+
...(outcome?.type === "failed" ? { failure: outcome.message } : {}),
|
|
972
|
+
...(outcome?.type === "cancelled" ? { failure: outcome.message } : {}),
|
|
973
|
+
...(recovery ? { failure: recovery.message, recovery } : {}),
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function decodePage(value: unknown): ClientRunPageV1 {
|
|
978
|
+
const page = record(value, "run list.page");
|
|
979
|
+
exactKeys(page, ["truncated", "nextCursor"], "run list.page");
|
|
980
|
+
if (typeof page.truncated !== "boolean") {
|
|
981
|
+
throw new Error("run list.page.truncated must be a boolean");
|
|
982
|
+
}
|
|
983
|
+
const nextCursor =
|
|
984
|
+
page.nextCursor === undefined
|
|
985
|
+
? undefined
|
|
986
|
+
: decodeRunCursorV1(
|
|
987
|
+
string(page, "nextCursor", MAX_CURSOR_LENGTH, "run list.page"),
|
|
988
|
+
);
|
|
989
|
+
if (page.truncated && !nextCursor) {
|
|
990
|
+
throw new Error("truncated run list requires a next cursor");
|
|
991
|
+
}
|
|
992
|
+
if (!page.truncated && nextCursor !== undefined) {
|
|
993
|
+
throw new Error("complete run list must not include a next cursor");
|
|
994
|
+
}
|
|
995
|
+
return {
|
|
996
|
+
truncated: page.truncated,
|
|
997
|
+
...(nextCursor ? { nextCursor } : {}),
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function decodeNotificationV1(value: unknown): ClientNotificationIntent {
|
|
1002
|
+
const notification = record(value, "turn.notification");
|
|
1003
|
+
exactKeys(
|
|
1004
|
+
notification,
|
|
1005
|
+
["notificationId", "runId", "createdAt", "title", "body"],
|
|
1006
|
+
"turn.notification",
|
|
1007
|
+
);
|
|
1008
|
+
const createdAt = string(
|
|
1009
|
+
notification,
|
|
1010
|
+
"createdAt",
|
|
1011
|
+
MAX_TIMESTAMP_LENGTH,
|
|
1012
|
+
"turn.notification",
|
|
1013
|
+
);
|
|
1014
|
+
if (!Number.isFinite(Date.parse(createdAt))) {
|
|
1015
|
+
throw new Error("turn.notification.createdAt is invalid");
|
|
1016
|
+
}
|
|
1017
|
+
return {
|
|
1018
|
+
notificationId: publicEventId(
|
|
1019
|
+
string(
|
|
1020
|
+
notification,
|
|
1021
|
+
"notificationId",
|
|
1022
|
+
MAX_EVENT_ID_LENGTH,
|
|
1023
|
+
"turn.notification",
|
|
1024
|
+
),
|
|
1025
|
+
"turn.notification.notificationId",
|
|
1026
|
+
),
|
|
1027
|
+
runId: string(
|
|
1028
|
+
notification,
|
|
1029
|
+
"runId",
|
|
1030
|
+
MAX_RUN_ID_LENGTH,
|
|
1031
|
+
"turn.notification",
|
|
1032
|
+
),
|
|
1033
|
+
createdAt,
|
|
1034
|
+
title: wireString(
|
|
1035
|
+
notification,
|
|
1036
|
+
"title",
|
|
1037
|
+
MAX_NOTIFICATION_TITLE_BYTES,
|
|
1038
|
+
"turn.notification",
|
|
1039
|
+
),
|
|
1040
|
+
body: wireString(
|
|
1041
|
+
notification,
|
|
1042
|
+
"body",
|
|
1043
|
+
MAX_NOTIFICATION_BODY_BYTES,
|
|
1044
|
+
"turn.notification",
|
|
1045
|
+
),
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
export function decodeClientTurnV1(input: unknown): ClientTurnResponse {
|
|
1050
|
+
const turn = record(input, "turn");
|
|
1051
|
+
exactKeys(
|
|
1052
|
+
turn,
|
|
1053
|
+
["schemaVersion", "runId", "text", "events", "notification"],
|
|
1054
|
+
"turn",
|
|
1055
|
+
);
|
|
1056
|
+
if (turn.schemaVersion !== 1) {
|
|
1057
|
+
throw new Error("turn.schemaVersion is invalid");
|
|
1058
|
+
}
|
|
1059
|
+
if (!Array.isArray(turn.events) || turn.events.length > MAX_VISIBLE_EVENTS) {
|
|
1060
|
+
throw new Error("turn.events must be a bounded array");
|
|
1061
|
+
}
|
|
1062
|
+
if (wireBytes(input) > MAX_CLIENT_TURN_BYTES) {
|
|
1063
|
+
throw new Error("turn exceeds the wire byte limit");
|
|
1064
|
+
}
|
|
1065
|
+
let runId: string;
|
|
1066
|
+
try {
|
|
1067
|
+
runId = decodeRunIdV1(string(turn, "runId", MAX_RUN_ID_LENGTH, "turn"));
|
|
1068
|
+
} catch {
|
|
1069
|
+
throw new Error("turn.runId is invalid");
|
|
1070
|
+
}
|
|
1071
|
+
const notification =
|
|
1072
|
+
turn.notification === undefined
|
|
1073
|
+
? undefined
|
|
1074
|
+
: decodeNotificationV1(turn.notification);
|
|
1075
|
+
if (notification && notification.runId !== runId) {
|
|
1076
|
+
throw new Error("turn.notification.runId does not match turn.runId");
|
|
1077
|
+
}
|
|
1078
|
+
return {
|
|
1079
|
+
runId,
|
|
1080
|
+
text: wireString(turn, "text", MAX_OUTCOME_BYTES, "turn"),
|
|
1081
|
+
events: decodeEvents(turn.events, "completed"),
|
|
1082
|
+
...(notification ? { notification } : {}),
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
export function decodeClientRunListQueryV1(
|
|
1087
|
+
input: unknown,
|
|
1088
|
+
): ClientRunListQueryV1 {
|
|
1089
|
+
const query = record(input, "run list query");
|
|
1090
|
+
exactKeys(query, ["schemaVersion", "before"], "run list query");
|
|
1091
|
+
if (query.schemaVersion !== 1) {
|
|
1092
|
+
throw new Error("run list query.schemaVersion is invalid");
|
|
1093
|
+
}
|
|
1094
|
+
const before =
|
|
1095
|
+
query.before === undefined
|
|
1096
|
+
? undefined
|
|
1097
|
+
: string(query, "before", MAX_CURSOR_LENGTH, "run list query");
|
|
1098
|
+
if (before !== undefined) {
|
|
1099
|
+
try {
|
|
1100
|
+
decodeRunCursorV1(before);
|
|
1101
|
+
} catch {
|
|
1102
|
+
throw new Error("run list query.before is invalid");
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
return { schemaVersion: 1, ...(before ? { before } : {}) };
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
export function decodeClientTurnCommandV1(input: unknown): ClientTurnCommandV1 {
|
|
1109
|
+
const command = record(input, "turn command");
|
|
1110
|
+
exactKeys(
|
|
1111
|
+
command,
|
|
1112
|
+
["schemaVersion", "commandId", "text", "skills"],
|
|
1113
|
+
"turn command",
|
|
1114
|
+
);
|
|
1115
|
+
if (command.schemaVersion !== 1) {
|
|
1116
|
+
throw new Error("turn command.schemaVersion is invalid");
|
|
1117
|
+
}
|
|
1118
|
+
const commandId = string(
|
|
1119
|
+
command,
|
|
1120
|
+
"commandId",
|
|
1121
|
+
MAX_RUN_ID_LENGTH,
|
|
1122
|
+
"turn command",
|
|
1123
|
+
);
|
|
1124
|
+
if (!isPublicIdentifier(commandId)) {
|
|
1125
|
+
throw new Error("turn command.commandId is invalid");
|
|
1126
|
+
}
|
|
1127
|
+
const text = wireString(
|
|
1128
|
+
command,
|
|
1129
|
+
"text",
|
|
1130
|
+
MAX_INPUT_BYTES,
|
|
1131
|
+
"turn command",
|
|
1132
|
+
).trim();
|
|
1133
|
+
if (!text) throw new Error("turn command.text is required");
|
|
1134
|
+
if (command.skills === undefined)
|
|
1135
|
+
return { schemaVersion: 1, commandId, text };
|
|
1136
|
+
const skills = decodeSkillRefsV1(command.skills, "turn command.skills");
|
|
1137
|
+
return skills.length > 0
|
|
1138
|
+
? { schemaVersion: 1, commandId, text, skills }
|
|
1139
|
+
: { schemaVersion: 1, commandId, text };
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
export function decodeClientNotificationAcknowledgementCommandV1(
|
|
1143
|
+
input: unknown,
|
|
1144
|
+
): ClientNotificationAcknowledgementCommandV1 {
|
|
1145
|
+
const command = record(input, "notification acknowledgement command");
|
|
1146
|
+
exactKeys(
|
|
1147
|
+
command,
|
|
1148
|
+
["schemaVersion", "action", "notificationId"],
|
|
1149
|
+
"notification acknowledgement command",
|
|
1150
|
+
);
|
|
1151
|
+
if (command.schemaVersion !== 1 || command.action !== "acknowledge") {
|
|
1152
|
+
throw new Error("notification acknowledgement command is invalid");
|
|
1153
|
+
}
|
|
1154
|
+
const notificationId = string(
|
|
1155
|
+
command,
|
|
1156
|
+
"notificationId",
|
|
1157
|
+
MAX_RUN_ID_LENGTH,
|
|
1158
|
+
"notification acknowledgement command",
|
|
1159
|
+
);
|
|
1160
|
+
if (!isPublicIdentifier(notificationId)) {
|
|
1161
|
+
throw new Error(
|
|
1162
|
+
"notification acknowledgement command.notificationId is invalid",
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
return { schemaVersion: 1, action: "acknowledge", notificationId };
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
export function decodeClientRunReconciliationCommandV1(
|
|
1169
|
+
input: unknown,
|
|
1170
|
+
): ClientRunReconciliationCommandV1 {
|
|
1171
|
+
const command = record(input, "run reconciliation command");
|
|
1172
|
+
exactKeys(command, ["schemaVersion", "action"], "run reconciliation command");
|
|
1173
|
+
if (command.schemaVersion !== 1 || command.action !== "resume") {
|
|
1174
|
+
throw new Error("run reconciliation command is invalid");
|
|
1175
|
+
}
|
|
1176
|
+
return { schemaVersion: 1, action: "resume" };
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
export function decodeClientRunStopCommandV1(
|
|
1180
|
+
input: unknown,
|
|
1181
|
+
): ClientRunStopCommandV1 {
|
|
1182
|
+
const command = record(input, "run stop command");
|
|
1183
|
+
exactKeys(
|
|
1184
|
+
command,
|
|
1185
|
+
["schemaVersion", "action", "commandId", "runId"],
|
|
1186
|
+
"run stop command",
|
|
1187
|
+
);
|
|
1188
|
+
if (command.schemaVersion !== 1 || command.action !== "stop") {
|
|
1189
|
+
throw new Error("run stop command is invalid");
|
|
1190
|
+
}
|
|
1191
|
+
const commandId = string(
|
|
1192
|
+
command,
|
|
1193
|
+
"commandId",
|
|
1194
|
+
MAX_RUN_ID_LENGTH,
|
|
1195
|
+
"run stop command",
|
|
1196
|
+
);
|
|
1197
|
+
if (!isPublicIdentifier(commandId)) {
|
|
1198
|
+
throw new Error("run stop command.commandId is invalid");
|
|
1199
|
+
}
|
|
1200
|
+
let runId: string;
|
|
1201
|
+
try {
|
|
1202
|
+
runId = decodeRunIdV1(
|
|
1203
|
+
string(command, "runId", MAX_RUN_ID_LENGTH, "run stop command"),
|
|
1204
|
+
);
|
|
1205
|
+
} catch {
|
|
1206
|
+
throw new Error("run stop command.runId is invalid");
|
|
1207
|
+
}
|
|
1208
|
+
return { schemaVersion: 1, action: "stop", commandId, runId };
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
export function createClientRunStopReceiptV1(
|
|
1212
|
+
command: ClientRunStopCommandV1,
|
|
1213
|
+
run: ClientRunV1,
|
|
1214
|
+
): ClientRunStopReceiptV1 {
|
|
1215
|
+
if (run.runId !== command.runId) {
|
|
1216
|
+
throw new Error("run stop receipt does not match its command");
|
|
1217
|
+
}
|
|
1218
|
+
return {
|
|
1219
|
+
schemaVersion: 1,
|
|
1220
|
+
status: "accepted",
|
|
1221
|
+
commandId: command.commandId,
|
|
1222
|
+
runId: command.runId,
|
|
1223
|
+
run,
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
export function decodeClientRunStopReceiptV1(input: unknown): {
|
|
1228
|
+
commandId: string;
|
|
1229
|
+
runId: string;
|
|
1230
|
+
run: ClientRun;
|
|
1231
|
+
} {
|
|
1232
|
+
const receipt = record(input, "run stop receipt");
|
|
1233
|
+
exactKeys(
|
|
1234
|
+
receipt,
|
|
1235
|
+
["schemaVersion", "status", "commandId", "runId", "run"],
|
|
1236
|
+
"run stop receipt",
|
|
1237
|
+
);
|
|
1238
|
+
if (receipt.schemaVersion !== 1 || receipt.status !== "accepted") {
|
|
1239
|
+
throw new Error("run stop receipt is invalid");
|
|
1240
|
+
}
|
|
1241
|
+
if (wireBytes(input) > CLIENT_RUN_LIST_MAX_BYTES) {
|
|
1242
|
+
throw new Error("run stop receipt exceeds the wire byte limit");
|
|
1243
|
+
}
|
|
1244
|
+
const commandId = string(
|
|
1245
|
+
receipt,
|
|
1246
|
+
"commandId",
|
|
1247
|
+
MAX_RUN_ID_LENGTH,
|
|
1248
|
+
"run stop receipt",
|
|
1249
|
+
);
|
|
1250
|
+
if (!isPublicIdentifier(commandId)) {
|
|
1251
|
+
throw new Error("run stop receipt.commandId is invalid");
|
|
1252
|
+
}
|
|
1253
|
+
const runId = string(receipt, "runId", MAX_RUN_ID_LENGTH, "run stop receipt");
|
|
1254
|
+
const run = decodeRun(receipt.run);
|
|
1255
|
+
if (run.runId !== runId) {
|
|
1256
|
+
throw new Error("run stop receipt.run does not match run stop receipt");
|
|
1257
|
+
}
|
|
1258
|
+
return { commandId, runId, run };
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
export function decodeClientRunLookupQueryV1(
|
|
1262
|
+
input: unknown,
|
|
1263
|
+
): ClientRunLookupQueryV1 {
|
|
1264
|
+
const query = record(input, "run lookup query");
|
|
1265
|
+
exactKeys(query, ["schemaVersion", "runId"], "run lookup query");
|
|
1266
|
+
if (query.schemaVersion !== 1) {
|
|
1267
|
+
throw new Error("run lookup query.schemaVersion is invalid");
|
|
1268
|
+
}
|
|
1269
|
+
let runId: string;
|
|
1270
|
+
try {
|
|
1271
|
+
runId = decodeRunIdV1(
|
|
1272
|
+
string(query, "runId", MAX_RUN_ID_LENGTH, "run lookup query"),
|
|
1273
|
+
);
|
|
1274
|
+
} catch {
|
|
1275
|
+
throw new Error("run lookup query.runId is invalid");
|
|
1276
|
+
}
|
|
1277
|
+
return { schemaVersion: 1, runId };
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
export function decodeClientRunAdmissionFenceCommandV1(
|
|
1281
|
+
input: unknown,
|
|
1282
|
+
): ClientRunAdmissionFenceCommandV1 {
|
|
1283
|
+
const command = record(input, "run admission fence command");
|
|
1284
|
+
exactKeys(
|
|
1285
|
+
command,
|
|
1286
|
+
["schemaVersion", "action"],
|
|
1287
|
+
"run admission fence command",
|
|
1288
|
+
);
|
|
1289
|
+
if (command.schemaVersion !== 1 || command.action !== "fence-admission") {
|
|
1290
|
+
throw new Error("run admission fence command is invalid");
|
|
1291
|
+
}
|
|
1292
|
+
return { schemaVersion: 1, action: "fence-admission" };
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
export function decodeClientRunLookupV1(input: unknown): ClientRunLookup {
|
|
1296
|
+
const lookup = record(input, "run lookup");
|
|
1297
|
+
exactKeys(lookup, ["schemaVersion", "state", "run"], "run lookup");
|
|
1298
|
+
if (lookup.schemaVersion !== 1) {
|
|
1299
|
+
throw new Error("run lookup.schemaVersion is invalid");
|
|
1300
|
+
}
|
|
1301
|
+
if (wireBytes(input) > CLIENT_RUN_LIST_MAX_BYTES) {
|
|
1302
|
+
throw new Error("run lookup exceeds the wire byte limit");
|
|
1303
|
+
}
|
|
1304
|
+
if (lookup.state === "not-admitted") {
|
|
1305
|
+
if (lookup.run !== undefined) {
|
|
1306
|
+
throw new Error("not-admitted run lookup must not include a run");
|
|
1307
|
+
}
|
|
1308
|
+
return { state: "not-admitted" };
|
|
1309
|
+
}
|
|
1310
|
+
if (
|
|
1311
|
+
lookup.state !== "running" &&
|
|
1312
|
+
lookup.state !== "reconciliation-required" &&
|
|
1313
|
+
lookup.state !== "terminal"
|
|
1314
|
+
) {
|
|
1315
|
+
throw new Error("run lookup.state is invalid");
|
|
1316
|
+
}
|
|
1317
|
+
if (lookup.run === undefined) {
|
|
1318
|
+
throw new Error("admitted run lookup requires a run");
|
|
1319
|
+
}
|
|
1320
|
+
const run = decodeRun(lookup.run);
|
|
1321
|
+
if (lookupState(run.status) !== lookup.state) {
|
|
1322
|
+
throw new Error("run lookup.state does not match run.status");
|
|
1323
|
+
}
|
|
1324
|
+
return { state: lookup.state, run };
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function decodeAnnouncement(value: unknown): ClientAnnouncementV1 {
|
|
1328
|
+
const announcement = record(value, "run list.announcement");
|
|
1329
|
+
exactKeys(
|
|
1330
|
+
announcement,
|
|
1331
|
+
["type", "announcementId", "at", "from", "to", "namedBy"],
|
|
1332
|
+
"run list.announcement",
|
|
1333
|
+
);
|
|
1334
|
+
if (announcement.type !== "bot/renamed") {
|
|
1335
|
+
throw new Error("run list.announcement.type is invalid");
|
|
1336
|
+
}
|
|
1337
|
+
const at = string(
|
|
1338
|
+
announcement,
|
|
1339
|
+
"at",
|
|
1340
|
+
MAX_TIMESTAMP_LENGTH,
|
|
1341
|
+
"run list.announcement",
|
|
1342
|
+
);
|
|
1343
|
+
if (!Number.isFinite(Date.parse(at))) {
|
|
1344
|
+
throw new Error("run list.announcement.at is invalid");
|
|
1345
|
+
}
|
|
1346
|
+
if (announcement.namedBy !== "user" && announcement.namedBy !== "bot") {
|
|
1347
|
+
throw new Error("run list.announcement.namedBy is invalid");
|
|
1348
|
+
}
|
|
1349
|
+
return {
|
|
1350
|
+
type: "bot/renamed",
|
|
1351
|
+
announcementId: publicEventId(
|
|
1352
|
+
string(
|
|
1353
|
+
announcement,
|
|
1354
|
+
"announcementId",
|
|
1355
|
+
MAX_EVENT_ID_LENGTH,
|
|
1356
|
+
"run list.announcement",
|
|
1357
|
+
),
|
|
1358
|
+
"run list.announcement.announcementId",
|
|
1359
|
+
),
|
|
1360
|
+
at,
|
|
1361
|
+
from: wireString(
|
|
1362
|
+
announcement,
|
|
1363
|
+
"from",
|
|
1364
|
+
MAX_ANNOUNCEMENT_NAME_BYTES,
|
|
1365
|
+
"run list.announcement",
|
|
1366
|
+
),
|
|
1367
|
+
to: wireString(
|
|
1368
|
+
announcement,
|
|
1369
|
+
"to",
|
|
1370
|
+
MAX_ANNOUNCEMENT_NAME_BYTES,
|
|
1371
|
+
"run list.announcement",
|
|
1372
|
+
),
|
|
1373
|
+
namedBy: announcement.namedBy,
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
export function decodeClientRunPageV1(input: unknown): {
|
|
1378
|
+
runs: ClientRun[];
|
|
1379
|
+
page: ClientRunPageV1;
|
|
1380
|
+
announcements: ClientAnnouncementV1[];
|
|
1381
|
+
} {
|
|
1382
|
+
const list = record(input, "run list");
|
|
1383
|
+
exactKeys(
|
|
1384
|
+
list,
|
|
1385
|
+
["schemaVersion", "runs", "page", "announcements"],
|
|
1386
|
+
"run list",
|
|
1387
|
+
);
|
|
1388
|
+
if (list.schemaVersion !== 1) {
|
|
1389
|
+
throw new Error("run list.schemaVersion is invalid");
|
|
1390
|
+
}
|
|
1391
|
+
if (!Array.isArray(list.runs)) {
|
|
1392
|
+
throw new Error("run list.runs must be an array");
|
|
1393
|
+
}
|
|
1394
|
+
if (list.runs.length > CLIENT_RUN_PAGE_LIMIT) {
|
|
1395
|
+
throw new Error("run list.runs exceeds the page limit");
|
|
1396
|
+
}
|
|
1397
|
+
if (wireBytes(input) > CLIENT_RUN_LIST_MAX_BYTES) {
|
|
1398
|
+
throw new Error("run list exceeds the wire byte limit");
|
|
1399
|
+
}
|
|
1400
|
+
if (
|
|
1401
|
+
list.announcements !== undefined &&
|
|
1402
|
+
(!Array.isArray(list.announcements) ||
|
|
1403
|
+
list.announcements.length > MAX_ANNOUNCEMENTS)
|
|
1404
|
+
) {
|
|
1405
|
+
throw new Error("run list.announcements must be a bounded array");
|
|
1406
|
+
}
|
|
1407
|
+
const decoded = {
|
|
1408
|
+
runs: list.runs.map(decodeRun),
|
|
1409
|
+
page: decodePage(list.page),
|
|
1410
|
+
announcements: (list.announcements ?? []).map(decodeAnnouncement),
|
|
1411
|
+
};
|
|
1412
|
+
return decoded;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
export function decodeClientRunListV1(input: unknown): ClientRun[] {
|
|
1416
|
+
return decodeClientRunPageV1(input).runs;
|
|
1417
|
+
}
|