@frockbot/kernel-contracts 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/applets.test.ts +299 -0
- package/src/applets.ts +902 -0
- package/src/authoring.test.ts +47 -0
- package/src/authoring.ts +57 -21
- package/src/contributions.ts +102 -0
- package/src/iframe-ui.test.ts +238 -37
- package/src/iframe-ui.ts +430 -89
- package/src/index.ts +2 -0
- package/src/isolate-context-catalog.generated.ts +3 -2
- package/src/isolate.ts +98 -0
- package/src/types.ts +10 -3
package/src/applets.ts
ADDED
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
// The Applet DTOs.
|
|
2
|
+
//
|
|
3
|
+
// An Applet is one durable instance per User of a Package's Instance
|
|
4
|
+
// Contribution (ADR 0022). The kernel is the authority for its directory
|
|
5
|
+
// entry, its generations, its viewer sessions, and its deletion — never for
|
|
6
|
+
// its contents. These are the narrow, versioned records and views that cross
|
|
7
|
+
// between the User Durable Object, the Applet Durable Object, the Bot isolate
|
|
8
|
+
// capability, and the hosted client, so they are declared once here and every
|
|
9
|
+
// inbound value is decoded at its seam.
|
|
10
|
+
//
|
|
11
|
+
// The manifest side of the same feature lives in `@frockbot/kernel-composition`
|
|
12
|
+
// (`InstanceContributionV1`); the durable authority that writes these records
|
|
13
|
+
// is lane K3.
|
|
14
|
+
|
|
15
|
+
type AppletJsonScalarV1 = null | boolean | number | string;
|
|
16
|
+
type AppletJsonDepth1V1 =
|
|
17
|
+
| AppletJsonScalarV1
|
|
18
|
+
| AppletJsonScalarV1[]
|
|
19
|
+
| { [key: string]: AppletJsonScalarV1 };
|
|
20
|
+
type AppletJsonDepth2V1 =
|
|
21
|
+
| AppletJsonScalarV1
|
|
22
|
+
| AppletJsonDepth1V1[]
|
|
23
|
+
| { [key: string]: AppletJsonDepth1V1 };
|
|
24
|
+
type AppletJsonDepth3V1 =
|
|
25
|
+
| AppletJsonScalarV1
|
|
26
|
+
| AppletJsonDepth2V1[]
|
|
27
|
+
| { [key: string]: AppletJsonDepth2V1 };
|
|
28
|
+
/**
|
|
29
|
+
* Plain JSON, spelled out and bounded rather than recursive.
|
|
30
|
+
*
|
|
31
|
+
* These records cross a Durable Object RPC boundary, where `unknown` is not
|
|
32
|
+
* transferable — a record typed with it collapses the whole answer to `never`
|
|
33
|
+
* at the call site — and a self-referential type makes the serializability
|
|
34
|
+
* mapper give up instead. A tool's input schema is four levels at the outside.
|
|
35
|
+
*/
|
|
36
|
+
export type AppletJsonValueV1 =
|
|
37
|
+
| AppletJsonScalarV1
|
|
38
|
+
| AppletJsonDepth3V1[]
|
|
39
|
+
| { [key: string]: AppletJsonDepth3V1 };
|
|
40
|
+
|
|
41
|
+
/** The tool declaration an Applet generation copies from its manifest. */
|
|
42
|
+
export interface AppletToolDeclarationV1 {
|
|
43
|
+
name: string;
|
|
44
|
+
description: string;
|
|
45
|
+
inputSchema: { [key: string]: AppletJsonValueV1 };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AppletArtifactRefV1 {
|
|
49
|
+
contentHash: string;
|
|
50
|
+
size: number;
|
|
51
|
+
mediaType: "application/javascript";
|
|
52
|
+
bundlerVersion: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AppletUiArtifactRefV1 {
|
|
56
|
+
contentHash: string;
|
|
57
|
+
size: number;
|
|
58
|
+
mediaType: "text/html";
|
|
59
|
+
bundlerVersion: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type AppletStatusV1 = "draft" | "published" | "deleted";
|
|
63
|
+
|
|
64
|
+
export type AppletGenerationStatusV1 =
|
|
65
|
+
"pending" | "active" | "superseded" | "failed";
|
|
66
|
+
|
|
67
|
+
export type AppletProvenanceV1 =
|
|
68
|
+
| { kind: "bot"; botId: string; sessionId: string; turnId: string }
|
|
69
|
+
| { kind: "user" };
|
|
70
|
+
|
|
71
|
+
/** User Durable Object, key `applets:entry:<appletId>`. */
|
|
72
|
+
export interface AppletDirectoryEntryV1 {
|
|
73
|
+
schemaVersion: 1;
|
|
74
|
+
/** `<publicUserId>.<random>` — the ADR 0015 shape. */
|
|
75
|
+
appletId: string;
|
|
76
|
+
displayName: string;
|
|
77
|
+
/** Absent until the first successful publish. */
|
|
78
|
+
currentGenerationId?: string;
|
|
79
|
+
tools: AppletToolDeclarationV1[];
|
|
80
|
+
provenance: AppletProvenanceV1;
|
|
81
|
+
createdAt: string;
|
|
82
|
+
status: AppletStatusV1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Applet Durable Object, key `applet:generation:<generationId>`. */
|
|
86
|
+
export interface AppletGenerationV1 {
|
|
87
|
+
schemaVersion: 1;
|
|
88
|
+
/** Sortable and monotonic within one Applet. */
|
|
89
|
+
generationId: string;
|
|
90
|
+
parentGenerationId?: string;
|
|
91
|
+
server: AppletArtifactRefV1;
|
|
92
|
+
ui: AppletUiArtifactRefV1;
|
|
93
|
+
tools: AppletToolDeclarationV1[];
|
|
94
|
+
contract: 1;
|
|
95
|
+
origin: "publish" | "revert";
|
|
96
|
+
provenance: {
|
|
97
|
+
botId: string;
|
|
98
|
+
sessionId: string;
|
|
99
|
+
turnId: string;
|
|
100
|
+
runId: string;
|
|
101
|
+
};
|
|
102
|
+
createdAt: string;
|
|
103
|
+
status: AppletGenerationStatusV1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** What `ctx.applets.list` and `create` return to a Bot isolate. */
|
|
107
|
+
export interface AppletSummaryV1 {
|
|
108
|
+
appletId: string;
|
|
109
|
+
displayName: string;
|
|
110
|
+
status: AppletStatusV1;
|
|
111
|
+
currentGenerationId?: string;
|
|
112
|
+
tools: string[];
|
|
113
|
+
createdAt: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** One row of an Applet's version history. */
|
|
117
|
+
export interface AppletGenerationSummaryV1 {
|
|
118
|
+
generationId: string;
|
|
119
|
+
parentGenerationId?: string;
|
|
120
|
+
origin: "publish" | "revert";
|
|
121
|
+
status: AppletGenerationStatusV1;
|
|
122
|
+
tools: string[];
|
|
123
|
+
createdAt: string;
|
|
124
|
+
isCurrent: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The outcome of `publish` or `revert`. A failure is visible, never silent. */
|
|
128
|
+
export type AppletPublishResultV1 =
|
|
129
|
+
| {
|
|
130
|
+
status: "published";
|
|
131
|
+
appletId: string;
|
|
132
|
+
generationId: string;
|
|
133
|
+
tools: string[];
|
|
134
|
+
/** The Composition generation proposed for the calling Bot. */
|
|
135
|
+
compositionGenerationId?: string;
|
|
136
|
+
}
|
|
137
|
+
| {
|
|
138
|
+
status: "failed";
|
|
139
|
+
appletId: string;
|
|
140
|
+
generationId: string;
|
|
141
|
+
reason: string;
|
|
142
|
+
diagnostics: string[];
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** A short-lived credential for one viewer of one Applet generation. */
|
|
146
|
+
export interface AppletViewerTokenV1 {
|
|
147
|
+
token: string;
|
|
148
|
+
expiresAt: string;
|
|
149
|
+
socketUrl: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Where the canvas reads an Applet's live UI from. */
|
|
153
|
+
export interface AppletUiViewV1 {
|
|
154
|
+
uiUrl: string;
|
|
155
|
+
/** Absent while the Applet has no active generation. */
|
|
156
|
+
generationId?: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The Session's focused Applet. One per Session, and `null` is a value: it is
|
|
161
|
+
* the Session having deliberately no Applet in the canvas, not a missing read.
|
|
162
|
+
*/
|
|
163
|
+
export interface AppletFocusViewV1 {
|
|
164
|
+
appletId: string | null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The Applets the User owns, as a client reads them. */
|
|
168
|
+
export interface AppletListViewV1 {
|
|
169
|
+
schemaVersion: 1;
|
|
170
|
+
applets: AppletSummaryV1[];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** One text file of an Applet's source, as the canvas draws it. */
|
|
174
|
+
export interface AppletSourceFileV1 {
|
|
175
|
+
/** Relative to `applets/<appletId>/` in the Applets Package's durable root. */
|
|
176
|
+
path: string;
|
|
177
|
+
text: string;
|
|
178
|
+
/** The Workspace generation this text was read at. */
|
|
179
|
+
generationId: string;
|
|
180
|
+
/** When the Workspace last recorded a write, when the store knows it. */
|
|
181
|
+
changedAt?: string;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* An Applet's source as the canvas shows it while the Bot is still writing it.
|
|
186
|
+
* `truncated` says the store held more than the read limit, so the canvas can
|
|
187
|
+
* say so rather than implying the Applet is smaller than it is.
|
|
188
|
+
*/
|
|
189
|
+
export interface AppletSourceViewV1 {
|
|
190
|
+
appletId: string;
|
|
191
|
+
files: AppletSourceFileV1[];
|
|
192
|
+
truncated: boolean;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The outcome the Bot last recorded for `applet check` or `applet build`. */
|
|
196
|
+
export interface AppletBuildViewV1 {
|
|
197
|
+
status: "unknown" | "passed" | "failed";
|
|
198
|
+
command?: "check" | "build";
|
|
199
|
+
at?: string;
|
|
200
|
+
summary?: string;
|
|
201
|
+
diagnostics?: string[];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Bounds on the source read, so a canvas load can never be unbounded. */
|
|
205
|
+
export const APPLET_SOURCE_MAX_BYTES_V1 = 512 * 1024;
|
|
206
|
+
export const APPLET_SOURCE_MAX_FILES_V1 = 256;
|
|
207
|
+
export const APPLET_SOURCE_MAX_DIAGNOSTICS_V1 = 64;
|
|
208
|
+
|
|
209
|
+
export const APPLET_ID_V1 = /^[a-z0-9][a-z0-9-]{0,63}\.[a-z0-9-]{1,64}$/;
|
|
210
|
+
export const APPLET_TOOL_NAME_V1 = /^[a-z][a-z0-9_]{0,63}$/;
|
|
211
|
+
export const APPLET_MAX_TOOLS_V1 = 64;
|
|
212
|
+
export const APPLET_MAX_GENERATIONS_PAGE_V1 = 64;
|
|
213
|
+
|
|
214
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
215
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
216
|
+
throw new Error(`${label} must be an object`);
|
|
217
|
+
}
|
|
218
|
+
return value as Record<string, unknown>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function exactKeys(
|
|
222
|
+
value: Record<string, unknown>,
|
|
223
|
+
required: readonly string[],
|
|
224
|
+
optional: readonly string[],
|
|
225
|
+
label: string,
|
|
226
|
+
): void {
|
|
227
|
+
const allowed = new Set<string>([...required, ...optional]);
|
|
228
|
+
if (
|
|
229
|
+
!required.every((key) => Object.hasOwn(value, key)) ||
|
|
230
|
+
!Object.keys(value).every((key) => allowed.has(key))
|
|
231
|
+
) {
|
|
232
|
+
throw new Error(`${label} has invalid fields`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function boundedString(value: unknown, label: string, maximum: number): string {
|
|
237
|
+
if (typeof value !== "string" || value.length === 0 || value.length > maximum)
|
|
238
|
+
throw new Error(`${label} must be a bounded non-empty string`);
|
|
239
|
+
return value;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function timestamp(value: unknown, label: string): string {
|
|
243
|
+
const text = boundedString(value, label, 64);
|
|
244
|
+
if (Number.isNaN(Date.parse(text)))
|
|
245
|
+
throw new Error(`${label} must be an ISO timestamp`);
|
|
246
|
+
return text;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function appletId(value: unknown, label: string): string {
|
|
250
|
+
const id = boundedString(value, label, 129);
|
|
251
|
+
if (!APPLET_ID_V1.test(id)) throw new Error(`${label} is invalid`);
|
|
252
|
+
return id;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function json(value: unknown, label: string, depth = 0): void {
|
|
256
|
+
if (depth > 16) throw new Error(`${label} is too deeply nested`);
|
|
257
|
+
if (
|
|
258
|
+
value === null ||
|
|
259
|
+
typeof value === "string" ||
|
|
260
|
+
typeof value === "boolean" ||
|
|
261
|
+
(typeof value === "number" && Number.isFinite(value))
|
|
262
|
+
) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (Array.isArray(value)) {
|
|
266
|
+
if (value.length > 256) throw new Error(`${label} has too many entries`);
|
|
267
|
+
for (const entry of value) json(entry, label, depth + 1);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const object = record(value, label);
|
|
271
|
+
if (Object.keys(object).length > 256)
|
|
272
|
+
throw new Error(`${label} has too many fields`);
|
|
273
|
+
for (const entry of Object.values(object)) json(entry, label, depth + 1);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function toolNames(value: unknown, label: string): string[] {
|
|
277
|
+
if (!Array.isArray(value) || value.length > APPLET_MAX_TOOLS_V1)
|
|
278
|
+
throw new Error(`${label} must be a bounded array`);
|
|
279
|
+
const names = value.map((name, index) => {
|
|
280
|
+
const decoded = boundedString(name, `${label}[${index}]`, 64);
|
|
281
|
+
if (!APPLET_TOOL_NAME_V1.test(decoded))
|
|
282
|
+
throw new Error(`${label}[${index}] is invalid`);
|
|
283
|
+
return decoded;
|
|
284
|
+
});
|
|
285
|
+
if (new Set(names).size !== names.length)
|
|
286
|
+
throw new Error(`${label} contains duplicate names`);
|
|
287
|
+
return names;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function decodeAppletToolDeclarationV1(
|
|
291
|
+
input: unknown,
|
|
292
|
+
label = "Applet tool declaration",
|
|
293
|
+
): AppletToolDeclarationV1 {
|
|
294
|
+
const value = record(input, label);
|
|
295
|
+
exactKeys(value, ["name", "description", "inputSchema"], [], label);
|
|
296
|
+
const name = boundedString(value.name, `${label}.name`, 64);
|
|
297
|
+
if (!APPLET_TOOL_NAME_V1.test(name))
|
|
298
|
+
throw new Error(`${label}.name is invalid`);
|
|
299
|
+
const inputSchema = record(value.inputSchema, `${label}.inputSchema`);
|
|
300
|
+
json(inputSchema, `${label}.inputSchema`);
|
|
301
|
+
return {
|
|
302
|
+
name,
|
|
303
|
+
description: boundedString(
|
|
304
|
+
value.description,
|
|
305
|
+
`${label}.description`,
|
|
306
|
+
1_024,
|
|
307
|
+
),
|
|
308
|
+
// The round trip is the narrowing: `json` above proved every leaf is a
|
|
309
|
+
// JSON scalar, array, or object, which is exactly `AppletJsonValueV1`.
|
|
310
|
+
inputSchema: JSON.parse(JSON.stringify(inputSchema)) as {
|
|
311
|
+
[key: string]: AppletJsonValueV1;
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function toolDeclarations(
|
|
317
|
+
value: unknown,
|
|
318
|
+
label: string,
|
|
319
|
+
): AppletToolDeclarationV1[] {
|
|
320
|
+
if (!Array.isArray(value) || value.length > APPLET_MAX_TOOLS_V1)
|
|
321
|
+
throw new Error(`${label} must be a bounded array`);
|
|
322
|
+
const tools = value.map((tool, index) =>
|
|
323
|
+
decodeAppletToolDeclarationV1(tool, `${label}[${index}]`),
|
|
324
|
+
);
|
|
325
|
+
if (new Set(tools.map((tool) => tool.name)).size !== tools.length)
|
|
326
|
+
throw new Error(`${label} contains duplicate names`);
|
|
327
|
+
return tools;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function serverRef(input: unknown, label: string): AppletArtifactRefV1 {
|
|
331
|
+
const value = record(input, label);
|
|
332
|
+
exactKeys(
|
|
333
|
+
value,
|
|
334
|
+
["contentHash", "size", "mediaType", "bundlerVersion"],
|
|
335
|
+
[],
|
|
336
|
+
label,
|
|
337
|
+
);
|
|
338
|
+
if (
|
|
339
|
+
typeof value.contentHash !== "string" ||
|
|
340
|
+
!/^[0-9a-f]{64}$/.test(value.contentHash)
|
|
341
|
+
)
|
|
342
|
+
throw new Error(`${label}.contentHash must be a sha-256 hex digest`);
|
|
343
|
+
if (!Number.isSafeInteger(value.size) || (value.size as number) < 0)
|
|
344
|
+
throw new Error(`${label}.size must be a non-negative integer`);
|
|
345
|
+
if (value.mediaType !== "application/javascript")
|
|
346
|
+
throw new Error(`${label}.mediaType is invalid`);
|
|
347
|
+
return {
|
|
348
|
+
contentHash: value.contentHash,
|
|
349
|
+
size: value.size as number,
|
|
350
|
+
mediaType: "application/javascript",
|
|
351
|
+
bundlerVersion: boundedString(
|
|
352
|
+
value.bundlerVersion,
|
|
353
|
+
`${label}.bundlerVersion`,
|
|
354
|
+
128,
|
|
355
|
+
),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The most an Applet's UI page may be. An Applet page is `applet build`'s one
|
|
361
|
+
* self-contained file — React, TanStack DB, the kit, and the Applet's own code
|
|
362
|
+
* inlined — so it is bounded well above a hand-written Package page's 256 KB.
|
|
363
|
+
*/
|
|
364
|
+
export const APPLET_UI_MAX_BYTES_V1 = 4 * 1024 * 1024;
|
|
365
|
+
|
|
366
|
+
function uiRef(input: unknown, label: string): AppletUiArtifactRefV1 {
|
|
367
|
+
const value = record(input, label);
|
|
368
|
+
exactKeys(
|
|
369
|
+
value,
|
|
370
|
+
["contentHash", "size", "mediaType", "bundlerVersion"],
|
|
371
|
+
[],
|
|
372
|
+
label,
|
|
373
|
+
);
|
|
374
|
+
if (
|
|
375
|
+
typeof value.contentHash !== "string" ||
|
|
376
|
+
!/^[0-9a-f]{64}$/.test(value.contentHash)
|
|
377
|
+
)
|
|
378
|
+
throw new Error(`${label}.contentHash must be a sha-256 hex digest`);
|
|
379
|
+
if (
|
|
380
|
+
!Number.isSafeInteger(value.size) ||
|
|
381
|
+
(value.size as number) < 0 ||
|
|
382
|
+
(value.size as number) > APPLET_UI_MAX_BYTES_V1
|
|
383
|
+
)
|
|
384
|
+
throw new Error(`${label}.size must be within the 4 MB quota`);
|
|
385
|
+
if (value.mediaType !== "text/html")
|
|
386
|
+
throw new Error(`${label}.mediaType is invalid`);
|
|
387
|
+
return {
|
|
388
|
+
contentHash: value.contentHash,
|
|
389
|
+
size: value.size as number,
|
|
390
|
+
mediaType: "text/html",
|
|
391
|
+
bundlerVersion: boundedString(
|
|
392
|
+
value.bundlerVersion,
|
|
393
|
+
`${label}.bundlerVersion`,
|
|
394
|
+
128,
|
|
395
|
+
),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function status(value: unknown, label: string): AppletStatusV1 {
|
|
400
|
+
if (value !== "draft" && value !== "published" && value !== "deleted")
|
|
401
|
+
throw new Error(`${label} is invalid`);
|
|
402
|
+
return value;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function generationStatus(
|
|
406
|
+
value: unknown,
|
|
407
|
+
label: string,
|
|
408
|
+
): AppletGenerationStatusV1 {
|
|
409
|
+
if (
|
|
410
|
+
value !== "pending" &&
|
|
411
|
+
value !== "active" &&
|
|
412
|
+
value !== "superseded" &&
|
|
413
|
+
value !== "failed"
|
|
414
|
+
)
|
|
415
|
+
throw new Error(`${label} is invalid`);
|
|
416
|
+
return value;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function origin(value: unknown, label: string): "publish" | "revert" {
|
|
420
|
+
if (value !== "publish" && value !== "revert")
|
|
421
|
+
throw new Error(`${label} is invalid`);
|
|
422
|
+
return value;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Who created an Applet. A Bot-created one names the Bot, Session, and Turn,
|
|
427
|
+
* because a Bot-authored change is a durable effect whose provenance names all
|
|
428
|
+
* three; a User-created one names nothing more, because the User Durable Object
|
|
429
|
+
* already is the User.
|
|
430
|
+
*/
|
|
431
|
+
export function decodeAppletProvenanceV1(
|
|
432
|
+
input: unknown,
|
|
433
|
+
label = "Applet provenance",
|
|
434
|
+
): AppletProvenanceV1 {
|
|
435
|
+
const provenance = record(input, label);
|
|
436
|
+
if (provenance.kind === "user") {
|
|
437
|
+
exactKeys(provenance, ["kind"], [], label);
|
|
438
|
+
return { kind: "user" };
|
|
439
|
+
}
|
|
440
|
+
if (provenance.kind === "bot") {
|
|
441
|
+
exactKeys(provenance, ["kind", "botId", "sessionId", "turnId"], [], label);
|
|
442
|
+
return {
|
|
443
|
+
kind: "bot",
|
|
444
|
+
botId: boundedString(provenance.botId, `${label}.botId`, 256),
|
|
445
|
+
sessionId: boundedString(provenance.sessionId, `${label}.sessionId`, 257),
|
|
446
|
+
turnId: boundedString(provenance.turnId, `${label}.turnId`, 256),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
throw new Error(`${label}.kind is invalid`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function decodeAppletDirectoryEntryV1(
|
|
453
|
+
input: unknown,
|
|
454
|
+
label = "Applet directory entry",
|
|
455
|
+
): AppletDirectoryEntryV1 {
|
|
456
|
+
const value = record(input, label);
|
|
457
|
+
exactKeys(
|
|
458
|
+
value,
|
|
459
|
+
[
|
|
460
|
+
"schemaVersion",
|
|
461
|
+
"appletId",
|
|
462
|
+
"displayName",
|
|
463
|
+
"tools",
|
|
464
|
+
"provenance",
|
|
465
|
+
"createdAt",
|
|
466
|
+
"status",
|
|
467
|
+
],
|
|
468
|
+
["currentGenerationId"],
|
|
469
|
+
label,
|
|
470
|
+
);
|
|
471
|
+
if (value.schemaVersion !== 1)
|
|
472
|
+
throw new Error(`${label}.schemaVersion is unsupported`);
|
|
473
|
+
const provenance = record(value.provenance, `${label}.provenance`);
|
|
474
|
+
let decodedProvenance: AppletProvenanceV1;
|
|
475
|
+
if (provenance.kind === "user") {
|
|
476
|
+
exactKeys(provenance, ["kind"], [], `${label}.provenance`);
|
|
477
|
+
decodedProvenance = { kind: "user" };
|
|
478
|
+
} else if (provenance.kind === "bot") {
|
|
479
|
+
exactKeys(
|
|
480
|
+
provenance,
|
|
481
|
+
["kind", "botId", "sessionId", "turnId"],
|
|
482
|
+
[],
|
|
483
|
+
`${label}.provenance`,
|
|
484
|
+
);
|
|
485
|
+
decodedProvenance = {
|
|
486
|
+
kind: "bot",
|
|
487
|
+
botId: boundedString(provenance.botId, `${label}.provenance.botId`, 256),
|
|
488
|
+
sessionId: boundedString(
|
|
489
|
+
provenance.sessionId,
|
|
490
|
+
`${label}.provenance.sessionId`,
|
|
491
|
+
256,
|
|
492
|
+
),
|
|
493
|
+
turnId: boundedString(
|
|
494
|
+
provenance.turnId,
|
|
495
|
+
`${label}.provenance.turnId`,
|
|
496
|
+
256,
|
|
497
|
+
),
|
|
498
|
+
};
|
|
499
|
+
} else {
|
|
500
|
+
throw new Error(`${label}.provenance.kind is invalid`);
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
schemaVersion: 1,
|
|
504
|
+
appletId: appletId(value.appletId, `${label}.appletId`),
|
|
505
|
+
displayName: boundedString(value.displayName, `${label}.displayName`, 128),
|
|
506
|
+
...(value.currentGenerationId === undefined
|
|
507
|
+
? {}
|
|
508
|
+
: {
|
|
509
|
+
currentGenerationId: boundedString(
|
|
510
|
+
value.currentGenerationId,
|
|
511
|
+
`${label}.currentGenerationId`,
|
|
512
|
+
128,
|
|
513
|
+
),
|
|
514
|
+
}),
|
|
515
|
+
tools: toolDeclarations(value.tools, `${label}.tools`),
|
|
516
|
+
provenance: decodedProvenance,
|
|
517
|
+
createdAt: timestamp(value.createdAt, `${label}.createdAt`),
|
|
518
|
+
status: status(value.status, `${label}.status`),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export function decodeAppletGenerationV1(
|
|
523
|
+
input: unknown,
|
|
524
|
+
label = "Applet generation",
|
|
525
|
+
): AppletGenerationV1 {
|
|
526
|
+
const value = record(input, label);
|
|
527
|
+
exactKeys(
|
|
528
|
+
value,
|
|
529
|
+
[
|
|
530
|
+
"schemaVersion",
|
|
531
|
+
"generationId",
|
|
532
|
+
"server",
|
|
533
|
+
"ui",
|
|
534
|
+
"tools",
|
|
535
|
+
"contract",
|
|
536
|
+
"origin",
|
|
537
|
+
"provenance",
|
|
538
|
+
"createdAt",
|
|
539
|
+
"status",
|
|
540
|
+
],
|
|
541
|
+
["parentGenerationId"],
|
|
542
|
+
label,
|
|
543
|
+
);
|
|
544
|
+
if (value.schemaVersion !== 1)
|
|
545
|
+
throw new Error(`${label}.schemaVersion is unsupported`);
|
|
546
|
+
if (value.contract !== 1) throw new Error(`${label}.contract is unsupported`);
|
|
547
|
+
const provenance = record(value.provenance, `${label}.provenance`);
|
|
548
|
+
exactKeys(
|
|
549
|
+
provenance,
|
|
550
|
+
["botId", "sessionId", "turnId", "runId"],
|
|
551
|
+
[],
|
|
552
|
+
`${label}.provenance`,
|
|
553
|
+
);
|
|
554
|
+
return {
|
|
555
|
+
schemaVersion: 1,
|
|
556
|
+
generationId: boundedString(
|
|
557
|
+
value.generationId,
|
|
558
|
+
`${label}.generationId`,
|
|
559
|
+
128,
|
|
560
|
+
),
|
|
561
|
+
...(value.parentGenerationId === undefined
|
|
562
|
+
? {}
|
|
563
|
+
: {
|
|
564
|
+
parentGenerationId: boundedString(
|
|
565
|
+
value.parentGenerationId,
|
|
566
|
+
`${label}.parentGenerationId`,
|
|
567
|
+
128,
|
|
568
|
+
),
|
|
569
|
+
}),
|
|
570
|
+
server: serverRef(value.server, `${label}.server`),
|
|
571
|
+
ui: uiRef(value.ui, `${label}.ui`),
|
|
572
|
+
tools: toolDeclarations(value.tools, `${label}.tools`),
|
|
573
|
+
contract: 1,
|
|
574
|
+
origin: origin(value.origin, `${label}.origin`),
|
|
575
|
+
provenance: {
|
|
576
|
+
botId: boundedString(provenance.botId, `${label}.provenance.botId`, 256),
|
|
577
|
+
sessionId: boundedString(
|
|
578
|
+
provenance.sessionId,
|
|
579
|
+
`${label}.provenance.sessionId`,
|
|
580
|
+
256,
|
|
581
|
+
),
|
|
582
|
+
turnId: boundedString(
|
|
583
|
+
provenance.turnId,
|
|
584
|
+
`${label}.provenance.turnId`,
|
|
585
|
+
256,
|
|
586
|
+
),
|
|
587
|
+
runId: boundedString(provenance.runId, `${label}.provenance.runId`, 256),
|
|
588
|
+
},
|
|
589
|
+
createdAt: timestamp(value.createdAt, `${label}.createdAt`),
|
|
590
|
+
status: generationStatus(value.status, `${label}.status`),
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export function decodeAppletSummaryV1(
|
|
595
|
+
input: unknown,
|
|
596
|
+
label = "Applet summary",
|
|
597
|
+
): AppletSummaryV1 {
|
|
598
|
+
const value = record(input, label);
|
|
599
|
+
exactKeys(
|
|
600
|
+
value,
|
|
601
|
+
["appletId", "displayName", "status", "tools", "createdAt"],
|
|
602
|
+
["currentGenerationId"],
|
|
603
|
+
label,
|
|
604
|
+
);
|
|
605
|
+
return {
|
|
606
|
+
appletId: appletId(value.appletId, `${label}.appletId`),
|
|
607
|
+
displayName: boundedString(value.displayName, `${label}.displayName`, 128),
|
|
608
|
+
status: status(value.status, `${label}.status`),
|
|
609
|
+
...(value.currentGenerationId === undefined
|
|
610
|
+
? {}
|
|
611
|
+
: {
|
|
612
|
+
currentGenerationId: boundedString(
|
|
613
|
+
value.currentGenerationId,
|
|
614
|
+
`${label}.currentGenerationId`,
|
|
615
|
+
128,
|
|
616
|
+
),
|
|
617
|
+
}),
|
|
618
|
+
tools: toolNames(value.tools, `${label}.tools`),
|
|
619
|
+
createdAt: timestamp(value.createdAt, `${label}.createdAt`),
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function decodeAppletGenerationSummaryV1(
|
|
624
|
+
input: unknown,
|
|
625
|
+
label = "Applet generation summary",
|
|
626
|
+
): AppletGenerationSummaryV1 {
|
|
627
|
+
const value = record(input, label);
|
|
628
|
+
exactKeys(
|
|
629
|
+
value,
|
|
630
|
+
["generationId", "origin", "status", "tools", "createdAt", "isCurrent"],
|
|
631
|
+
["parentGenerationId"],
|
|
632
|
+
label,
|
|
633
|
+
);
|
|
634
|
+
if (typeof value.isCurrent !== "boolean")
|
|
635
|
+
throw new Error(`${label}.isCurrent must be a boolean`);
|
|
636
|
+
return {
|
|
637
|
+
generationId: boundedString(
|
|
638
|
+
value.generationId,
|
|
639
|
+
`${label}.generationId`,
|
|
640
|
+
128,
|
|
641
|
+
),
|
|
642
|
+
...(value.parentGenerationId === undefined
|
|
643
|
+
? {}
|
|
644
|
+
: {
|
|
645
|
+
parentGenerationId: boundedString(
|
|
646
|
+
value.parentGenerationId,
|
|
647
|
+
`${label}.parentGenerationId`,
|
|
648
|
+
128,
|
|
649
|
+
),
|
|
650
|
+
}),
|
|
651
|
+
origin: origin(value.origin, `${label}.origin`),
|
|
652
|
+
status: generationStatus(value.status, `${label}.status`),
|
|
653
|
+
tools: toolNames(value.tools, `${label}.tools`),
|
|
654
|
+
createdAt: timestamp(value.createdAt, `${label}.createdAt`),
|
|
655
|
+
isCurrent: value.isCurrent,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export function decodeAppletPublishResultV1(
|
|
660
|
+
input: unknown,
|
|
661
|
+
label = "Applet publish result",
|
|
662
|
+
): AppletPublishResultV1 {
|
|
663
|
+
const value = record(input, label);
|
|
664
|
+
if (value.status === "published") {
|
|
665
|
+
exactKeys(
|
|
666
|
+
value,
|
|
667
|
+
["status", "appletId", "generationId", "tools"],
|
|
668
|
+
["compositionGenerationId"],
|
|
669
|
+
label,
|
|
670
|
+
);
|
|
671
|
+
return {
|
|
672
|
+
status: "published",
|
|
673
|
+
appletId: appletId(value.appletId, `${label}.appletId`),
|
|
674
|
+
generationId: boundedString(
|
|
675
|
+
value.generationId,
|
|
676
|
+
`${label}.generationId`,
|
|
677
|
+
128,
|
|
678
|
+
),
|
|
679
|
+
tools: toolNames(value.tools, `${label}.tools`),
|
|
680
|
+
...(value.compositionGenerationId === undefined
|
|
681
|
+
? {}
|
|
682
|
+
: {
|
|
683
|
+
compositionGenerationId: boundedString(
|
|
684
|
+
value.compositionGenerationId,
|
|
685
|
+
`${label}.compositionGenerationId`,
|
|
686
|
+
128,
|
|
687
|
+
),
|
|
688
|
+
}),
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
if (value.status === "failed") {
|
|
692
|
+
exactKeys(
|
|
693
|
+
value,
|
|
694
|
+
["status", "appletId", "generationId", "reason", "diagnostics"],
|
|
695
|
+
[],
|
|
696
|
+
label,
|
|
697
|
+
);
|
|
698
|
+
if (!Array.isArray(value.diagnostics) || value.diagnostics.length > 64)
|
|
699
|
+
throw new Error(`${label}.diagnostics must be a bounded array`);
|
|
700
|
+
return {
|
|
701
|
+
status: "failed",
|
|
702
|
+
appletId: appletId(value.appletId, `${label}.appletId`),
|
|
703
|
+
generationId: boundedString(
|
|
704
|
+
value.generationId,
|
|
705
|
+
`${label}.generationId`,
|
|
706
|
+
128,
|
|
707
|
+
),
|
|
708
|
+
reason: boundedString(value.reason, `${label}.reason`, 512),
|
|
709
|
+
diagnostics: value.diagnostics.map((entry, index) =>
|
|
710
|
+
boundedString(entry, `${label}.diagnostics[${index}]`, 8_192),
|
|
711
|
+
),
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
throw new Error(`${label}.status is invalid`);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export function decodeAppletListViewV1(
|
|
718
|
+
input: unknown,
|
|
719
|
+
label = "Applet list",
|
|
720
|
+
): AppletListViewV1 {
|
|
721
|
+
const value = record(input, label);
|
|
722
|
+
exactKeys(value, ["schemaVersion", "applets"], [], label);
|
|
723
|
+
if (value.schemaVersion !== 1)
|
|
724
|
+
throw new Error(`${label} version is unsupported`);
|
|
725
|
+
if (!Array.isArray(value.applets) || value.applets.length > 256)
|
|
726
|
+
throw new Error(`${label}.applets must be a bounded array`);
|
|
727
|
+
const applets = value.applets.map((candidate, index) =>
|
|
728
|
+
decodeAppletSummaryV1(candidate, `${label}.applets[${index}]`),
|
|
729
|
+
);
|
|
730
|
+
if (new Set(applets.map((applet) => applet.appletId)).size !== applets.length)
|
|
731
|
+
throw new Error(`${label}.applets contains duplicate Applets`);
|
|
732
|
+
return { schemaVersion: 1, applets };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
export function decodeAppletUiViewV1(
|
|
736
|
+
input: unknown,
|
|
737
|
+
label = "Applet UI",
|
|
738
|
+
): AppletUiViewV1 {
|
|
739
|
+
const value = record(input, label);
|
|
740
|
+
exactKeys(value, ["uiUrl"], ["generationId"], label);
|
|
741
|
+
const uiUrl = boundedString(value.uiUrl, `${label}.uiUrl`, 2_048);
|
|
742
|
+
let parsed: URL;
|
|
743
|
+
try {
|
|
744
|
+
parsed = new URL(uiUrl);
|
|
745
|
+
} catch {
|
|
746
|
+
throw new Error(`${label}.uiUrl is invalid`);
|
|
747
|
+
}
|
|
748
|
+
if (!["http:", "https:"].includes(parsed.protocol))
|
|
749
|
+
throw new Error(`${label}.uiUrl is invalid`);
|
|
750
|
+
return {
|
|
751
|
+
uiUrl,
|
|
752
|
+
...(value.generationId === undefined
|
|
753
|
+
? {}
|
|
754
|
+
: {
|
|
755
|
+
generationId: boundedString(
|
|
756
|
+
value.generationId,
|
|
757
|
+
`${label}.generationId`,
|
|
758
|
+
128,
|
|
759
|
+
),
|
|
760
|
+
}),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** The focus read and write share one shape, so a write reads back exactly. */
|
|
765
|
+
export function decodeAppletFocusViewV1(
|
|
766
|
+
input: unknown,
|
|
767
|
+
label = "Applet focus",
|
|
768
|
+
): AppletFocusViewV1 {
|
|
769
|
+
const value = record(input, label);
|
|
770
|
+
exactKeys(value, ["appletId"], ["schemaVersion"], label);
|
|
771
|
+
if (value.schemaVersion !== undefined && value.schemaVersion !== 1)
|
|
772
|
+
throw new Error(`${label} version is unsupported`);
|
|
773
|
+
return {
|
|
774
|
+
appletId:
|
|
775
|
+
value.appletId === null
|
|
776
|
+
? null
|
|
777
|
+
: appletId(value.appletId, `${label}.appletId`),
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* The Applet's source as the canvas reads it. Text only and bounded: this is a
|
|
783
|
+
* projection for a person watching a Bot write code, never a file transfer.
|
|
784
|
+
*/
|
|
785
|
+
export function decodeAppletSourceViewV1(
|
|
786
|
+
input: unknown,
|
|
787
|
+
label = "Applet source",
|
|
788
|
+
): AppletSourceViewV1 {
|
|
789
|
+
const value = record(input, label);
|
|
790
|
+
exactKeys(value, ["appletId", "files", "truncated"], [], label);
|
|
791
|
+
if (typeof value.truncated !== "boolean")
|
|
792
|
+
throw new Error(`${label}.truncated must be a boolean`);
|
|
793
|
+
if (
|
|
794
|
+
!Array.isArray(value.files) ||
|
|
795
|
+
value.files.length > APPLET_SOURCE_MAX_FILES_V1
|
|
796
|
+
) {
|
|
797
|
+
throw new Error(`${label}.files must be a bounded array`);
|
|
798
|
+
}
|
|
799
|
+
let bytes = 0;
|
|
800
|
+
const files = value.files.map((candidate, index) => {
|
|
801
|
+
const fileLabel = `${label}.files[${index}]`;
|
|
802
|
+
const file = record(candidate, fileLabel);
|
|
803
|
+
exactKeys(file, ["path", "text", "generationId"], ["changedAt"], fileLabel);
|
|
804
|
+
const path = boundedString(file.path, `${fileLabel}.path`, 512);
|
|
805
|
+
if (path.startsWith("/") || path.includes("..") || path.includes("\\"))
|
|
806
|
+
throw new Error(`${fileLabel}.path is invalid`);
|
|
807
|
+
if (typeof file.text !== "string")
|
|
808
|
+
throw new Error(`${fileLabel}.text must be a string`);
|
|
809
|
+
bytes += file.text.length;
|
|
810
|
+
if (bytes > APPLET_SOURCE_MAX_BYTES_V1)
|
|
811
|
+
throw new Error(`${label} exceeds the source read limit`);
|
|
812
|
+
return {
|
|
813
|
+
path,
|
|
814
|
+
text: file.text,
|
|
815
|
+
generationId: boundedString(
|
|
816
|
+
file.generationId,
|
|
817
|
+
`${fileLabel}.generationId`,
|
|
818
|
+
128,
|
|
819
|
+
),
|
|
820
|
+
...(file.changedAt === undefined
|
|
821
|
+
? {}
|
|
822
|
+
: { changedAt: timestamp(file.changedAt, `${fileLabel}.changedAt`) }),
|
|
823
|
+
};
|
|
824
|
+
});
|
|
825
|
+
if (new Set(files.map((file) => file.path)).size !== files.length)
|
|
826
|
+
throw new Error(`${label}.files contains duplicate paths`);
|
|
827
|
+
return {
|
|
828
|
+
appletId: appletId(value.appletId, `${label}.appletId`),
|
|
829
|
+
files,
|
|
830
|
+
truncated: value.truncated,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
export function decodeAppletBuildViewV1(
|
|
835
|
+
input: unknown,
|
|
836
|
+
label = "Applet build",
|
|
837
|
+
): AppletBuildViewV1 {
|
|
838
|
+
const value = record(input, label);
|
|
839
|
+
exactKeys(
|
|
840
|
+
value,
|
|
841
|
+
["status"],
|
|
842
|
+
["command", "at", "summary", "diagnostics"],
|
|
843
|
+
label,
|
|
844
|
+
);
|
|
845
|
+
if (
|
|
846
|
+
value.status !== "unknown" &&
|
|
847
|
+
value.status !== "passed" &&
|
|
848
|
+
value.status !== "failed"
|
|
849
|
+
) {
|
|
850
|
+
throw new Error(`${label}.status is invalid`);
|
|
851
|
+
}
|
|
852
|
+
if (
|
|
853
|
+
value.command !== undefined &&
|
|
854
|
+
value.command !== "check" &&
|
|
855
|
+
value.command !== "build"
|
|
856
|
+
) {
|
|
857
|
+
throw new Error(`${label}.command is invalid`);
|
|
858
|
+
}
|
|
859
|
+
if (
|
|
860
|
+
value.diagnostics !== undefined &&
|
|
861
|
+
(!Array.isArray(value.diagnostics) ||
|
|
862
|
+
value.diagnostics.length > APPLET_SOURCE_MAX_DIAGNOSTICS_V1)
|
|
863
|
+
) {
|
|
864
|
+
throw new Error(`${label}.diagnostics must be a bounded array`);
|
|
865
|
+
}
|
|
866
|
+
return {
|
|
867
|
+
status: value.status,
|
|
868
|
+
...(value.command === undefined
|
|
869
|
+
? {}
|
|
870
|
+
: { command: value.command as "check" | "build" }),
|
|
871
|
+
...(value.at === undefined
|
|
872
|
+
? {}
|
|
873
|
+
: { at: timestamp(value.at, `${label}.at`) }),
|
|
874
|
+
...(value.summary === undefined
|
|
875
|
+
? {}
|
|
876
|
+
: { summary: boundedString(value.summary, `${label}.summary`, 512) }),
|
|
877
|
+
...(value.diagnostics === undefined
|
|
878
|
+
? {}
|
|
879
|
+
: {
|
|
880
|
+
diagnostics: (value.diagnostics as unknown[]).map((line, index) =>
|
|
881
|
+
boundedString(line, `${label}.diagnostics[${index}]`, 1_024),
|
|
882
|
+
),
|
|
883
|
+
}),
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
export function decodeAppletViewerTokenV1(
|
|
888
|
+
input: unknown,
|
|
889
|
+
label = "Applet viewer token",
|
|
890
|
+
): AppletViewerTokenV1 {
|
|
891
|
+
const value = record(input, label);
|
|
892
|
+
exactKeys(value, ["token", "expiresAt", "socketUrl"], [], label);
|
|
893
|
+
const socketUrl = boundedString(value.socketUrl, `${label}.socketUrl`, 2_048);
|
|
894
|
+
const url = new URL(socketUrl);
|
|
895
|
+
if (!["ws:", "wss:", "http:", "https:"].includes(url.protocol))
|
|
896
|
+
throw new Error(`${label}.socketUrl is invalid`);
|
|
897
|
+
return {
|
|
898
|
+
token: boundedString(value.token, `${label}.token`, 1_024),
|
|
899
|
+
expiresAt: timestamp(value.expiresAt, `${label}.expiresAt`),
|
|
900
|
+
socketUrl,
|
|
901
|
+
};
|
|
902
|
+
}
|