@frockbot/configuration-core 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/package.json +22 -6
- package/src/bot-id.ts +5 -0
- package/src/bot-identity.test.ts +186 -0
- package/src/errors.ts +18 -0
- package/src/identifiers.ts +40 -0
- package/src/index.test.ts +1387 -0
- package/src/index.ts +2498 -0
- package/src/package-settings.test.ts +200 -0
- package/src/package-settings.ts +335 -0
- package/tsconfig.json +14 -0
- package/README.md +0 -3
package/src/index.ts
ADDED
|
@@ -0,0 +1,2498 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConnectionAuthorizationViewV1,
|
|
3
|
+
ConnectionModelCatalogV1,
|
|
4
|
+
} from "@frockbot/connection-core";
|
|
5
|
+
import {
|
|
6
|
+
decodeConnectionAuthorizationViewV1,
|
|
7
|
+
decodeConnectionModelCatalogV1,
|
|
8
|
+
} from "@frockbot/connection-core";
|
|
9
|
+
import { ConfigurationDecodeError } from "./errors.js";
|
|
10
|
+
import {
|
|
11
|
+
MAX_PACKAGE_SETTINGS_V1,
|
|
12
|
+
MAX_PACKAGE_SETTING_TEXT_V1,
|
|
13
|
+
type PackageSettingValueV1,
|
|
14
|
+
} from "./package-settings.js";
|
|
15
|
+
export { ConfigurationDecodeError } from "./errors.js";
|
|
16
|
+
import { isBotIdV1 } from "./bot-id.js";
|
|
17
|
+
import {
|
|
18
|
+
isConnectionIdentifier,
|
|
19
|
+
isPublicIdentifier,
|
|
20
|
+
isRpcIdentifier,
|
|
21
|
+
} from "./identifiers.js";
|
|
22
|
+
export { isBotIdV1 } from "./bot-id.js";
|
|
23
|
+
export {
|
|
24
|
+
decodePackageSettingsPatchV1,
|
|
25
|
+
decodePackageSettingValueV1,
|
|
26
|
+
decodePackageSettingValuesV1,
|
|
27
|
+
emptyPackageSettingValuesV1,
|
|
28
|
+
MAX_PACKAGE_SETTINGS_V1,
|
|
29
|
+
MAX_PACKAGE_SETTING_TEXT_V1,
|
|
30
|
+
resolvePackageSettingValuesV1,
|
|
31
|
+
type PackageSettingValueV1,
|
|
32
|
+
type PackageSettingValuesV1,
|
|
33
|
+
} from "./package-settings.js";
|
|
34
|
+
export {
|
|
35
|
+
isApplicationDeploymentHash,
|
|
36
|
+
isConnectionIdentifier,
|
|
37
|
+
isPublicIdentifier,
|
|
38
|
+
isRpcIdentifier,
|
|
39
|
+
} from "./identifiers.js";
|
|
40
|
+
|
|
41
|
+
export type JsonValue =
|
|
42
|
+
null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
|
43
|
+
|
|
44
|
+
export interface UserPrincipal {
|
|
45
|
+
userId: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface BotAuthority extends UserPrincipal {
|
|
49
|
+
botId: string;
|
|
50
|
+
invocationId: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Who wrote the Bot's current name. A User rename and a Bot self-rename are
|
|
55
|
+
* both durable writes of the same field, and "every durable write records its
|
|
56
|
+
* writer", so the provenance travels with the name rather than beside it.
|
|
57
|
+
*/
|
|
58
|
+
export type BotNameProvenanceV1 = "user" | "bot";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The Bot that made a durable Bot-scoped write, and the admitted Turn it made
|
|
62
|
+
* it in. `namedBy` says *which kind* of writer changed a name; this says
|
|
63
|
+
* *which* Bot, in which Session and Turn, so a self-management write is
|
|
64
|
+
* reconstructable from durable state alone rather than only from the fact that
|
|
65
|
+
* something Bot-shaped touched it.
|
|
66
|
+
*
|
|
67
|
+
* It is optional everywhere it appears: a record written by a User carries no
|
|
68
|
+
* Bot writer, and a record written before this existed still decodes.
|
|
69
|
+
*/
|
|
70
|
+
export interface BotSelfWriterV1 {
|
|
71
|
+
kind: "bot";
|
|
72
|
+
botId: string;
|
|
73
|
+
sessionId: string;
|
|
74
|
+
turnId: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface BotProfile {
|
|
78
|
+
name: string;
|
|
79
|
+
label?: string;
|
|
80
|
+
description?: string;
|
|
81
|
+
/** A short role line shown under the Bot's name. */
|
|
82
|
+
title?: string;
|
|
83
|
+
/** Provenance of the current `name`. Absent on records written before it. */
|
|
84
|
+
namedBy?: BotNameProvenanceV1;
|
|
85
|
+
/** Keeps the Bot out of the default sidebar list without archiving it. */
|
|
86
|
+
hiddenFromSidebar?: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A partial Bot profile. Only the keys that are present change; an absent key
|
|
91
|
+
* leaves the durable field exactly as it was. An empty string clears an
|
|
92
|
+
* optional text field.
|
|
93
|
+
*/
|
|
94
|
+
export interface BotProfilePatchV1 {
|
|
95
|
+
name?: string;
|
|
96
|
+
label?: string;
|
|
97
|
+
description?: string;
|
|
98
|
+
title?: string;
|
|
99
|
+
hiddenFromSidebar?: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface BotNotificationPolicy {
|
|
103
|
+
enabled: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface ModelAssignment {
|
|
107
|
+
connectionId: string;
|
|
108
|
+
providerModelId: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether the User chose the default model or a provider selected it safely. */
|
|
112
|
+
export type NewBotModelTemplateSourceV1 = "user" | "auto";
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Where an installed Package came from. `first-party` is a Package compiled
|
|
116
|
+
* into the running application; `catalog` is one admitted from a pinned remote
|
|
117
|
+
* Catalog generation, whose manifest is data and whose executing code is still
|
|
118
|
+
* a reviewed first-party Package (ADR 0014). Absent means `first-party`, so
|
|
119
|
+
* every installation recorded before the Catalog existed keeps its meaning.
|
|
120
|
+
*/
|
|
121
|
+
export type PackageProvenanceV1 = "first-party" | "catalog";
|
|
122
|
+
|
|
123
|
+
export interface PackageInstallationView {
|
|
124
|
+
packageId: string;
|
|
125
|
+
version: string;
|
|
126
|
+
state: "installed" | "disabled" | "failed";
|
|
127
|
+
failure?: string;
|
|
128
|
+
/** The Catalog identity this installation was admitted from, if any. */
|
|
129
|
+
catalogId?: string;
|
|
130
|
+
/** The immutable Catalog generation `catalogId` was read from. */
|
|
131
|
+
catalogGeneration?: string;
|
|
132
|
+
provenance?: PackageProvenanceV1;
|
|
133
|
+
/** The setup values the install carried, as GrokBot's `InstallPlugin{values}`. */
|
|
134
|
+
values?: Record<string, JsonValue>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A durable pending decision for the User: this Connection needs authorizing
|
|
139
|
+
* before it will do anything again.
|
|
140
|
+
*
|
|
141
|
+
* It carries **no URL**, and that is the whole design. A Bot may write one —
|
|
142
|
+
* `mcp_authenticate_server` does, and so does a mount that met a 401 — but a
|
|
143
|
+
* redirect is minted only by an authenticated User action. A single-use
|
|
144
|
+
* ten-minute link stored in a projection every client reads, and replayed into
|
|
145
|
+
* every transcript, would outlive the decision it belonged to and would let a
|
|
146
|
+
* Bot hand its User a link it authored. The card is drawn from this record and
|
|
147
|
+
* the link is authored when the User presses it.
|
|
148
|
+
*/
|
|
149
|
+
export interface PendingAuthorizationV1 {
|
|
150
|
+
/** Why it is pending, in the Package's own vocabulary (`needs-auth`). */
|
|
151
|
+
reason: string;
|
|
152
|
+
/** When it became pending, ISO-8601. */
|
|
153
|
+
since: string;
|
|
154
|
+
connectionId: string;
|
|
155
|
+
label: string;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface ConnectionView {
|
|
159
|
+
connectionId: string;
|
|
160
|
+
packageId: string;
|
|
161
|
+
connectionTypeId: string;
|
|
162
|
+
displayName: string;
|
|
163
|
+
state:
|
|
164
|
+
| "authorizing"
|
|
165
|
+
| "ready"
|
|
166
|
+
| "disabled"
|
|
167
|
+
| "revoking"
|
|
168
|
+
| "revoked"
|
|
169
|
+
| "reconciliation-required"
|
|
170
|
+
| "failed";
|
|
171
|
+
generation?: string;
|
|
172
|
+
providerType?: string;
|
|
173
|
+
authorization?: ConnectionAuthorizationViewV1;
|
|
174
|
+
modelCatalog?: ConnectionModelCatalogV1;
|
|
175
|
+
settings?: Record<string, JsonValue>;
|
|
176
|
+
safeMetadata: Record<string, JsonValue>;
|
|
177
|
+
failure?: string;
|
|
178
|
+
/** Set while this Connection is waiting on a User authorization. */
|
|
179
|
+
pendingAuthorization?: PendingAuthorizationV1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface StartConnectionCommandV1 {
|
|
183
|
+
schemaVersion: 1;
|
|
184
|
+
type: "connection/start";
|
|
185
|
+
commandId: string;
|
|
186
|
+
connectionTypeId: string;
|
|
187
|
+
alias?: string;
|
|
188
|
+
nativeReturnNonce?: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface RevokeConnectionCommandV1 {
|
|
192
|
+
schemaVersion: 1;
|
|
193
|
+
type: "connection/revoke";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export const MAX_USER_CONNECTIONS_V1 = 100;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The stored name of a User who has not chosen one. The contract requires a
|
|
200
|
+
* non-empty name, so "unset" is spelled with this sentinel.
|
|
201
|
+
*/
|
|
202
|
+
export const USER_PROFILE_PLACEHOLDER_NAME_V1 = "FrockBot user";
|
|
203
|
+
|
|
204
|
+
/** Whether a profile name is one the User actually chose (not blank, not the sentinel). */
|
|
205
|
+
export function isChosenUserName(name: string | undefined): name is string {
|
|
206
|
+
const candidate = name?.trim();
|
|
207
|
+
return Boolean(candidate && candidate !== USER_PROFILE_PLACEHOLDER_NAME_V1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface UserSettingsViewV1 {
|
|
211
|
+
schemaVersion: 1;
|
|
212
|
+
revision: number;
|
|
213
|
+
profile: { name: string; email?: string };
|
|
214
|
+
packages: PackageInstallationView[];
|
|
215
|
+
connections: ConnectionView[];
|
|
216
|
+
newBotModelTemplate?: ModelAssignment;
|
|
217
|
+
/**
|
|
218
|
+
* `user` is sticky, including when the User explicitly clears the default.
|
|
219
|
+
* Providers may replace only an `auto` default (or the untouched absence).
|
|
220
|
+
*/
|
|
221
|
+
newBotModelTemplateSource?: NewBotModelTemplateSourceV1;
|
|
222
|
+
/**
|
|
223
|
+
* The remote Catalog generation this User is pinned to, and the content hash
|
|
224
|
+
* of that generation's index. Pinned on the first read that finds a Catalog
|
|
225
|
+
* and never moved by an install, so a Catalog install is always validated
|
|
226
|
+
* against an immutable, content-addressed generation. Both are absent for a
|
|
227
|
+
* User whose deployment has no Catalog, so the decoder must accept absence.
|
|
228
|
+
*/
|
|
229
|
+
catalogGeneration?: string;
|
|
230
|
+
catalogIndexHash?: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface CapabilityAssignmentView {
|
|
234
|
+
assignmentId: string;
|
|
235
|
+
packageId: string;
|
|
236
|
+
capabilityId: string;
|
|
237
|
+
connectionId?: string;
|
|
238
|
+
state: "enabled" | "disabled" | "unavailable";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface ConnectionDependencyRequirementV1 {
|
|
242
|
+
schemaVersion: 1;
|
|
243
|
+
packageId: string;
|
|
244
|
+
packageVersion: string;
|
|
245
|
+
capabilityId: string;
|
|
246
|
+
connectionTypeIds: string[];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface CapabilityAssignmentOperationViewV1 {
|
|
250
|
+
commandId: string;
|
|
251
|
+
kind: "assigning" | "replacing" | "unassigning";
|
|
252
|
+
assignmentId: string;
|
|
253
|
+
state: "pending" | "retrying";
|
|
254
|
+
target?: Omit<CapabilityAssignmentView, "state">;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export interface BotSettingsViewV1 {
|
|
258
|
+
schemaVersion: 1;
|
|
259
|
+
botId: string;
|
|
260
|
+
revision: number;
|
|
261
|
+
profile: BotProfile;
|
|
262
|
+
notifications: BotNotificationPolicy;
|
|
263
|
+
assignments: CapabilityAssignmentView[];
|
|
264
|
+
assignmentOperations: CapabilityAssignmentOperationViewV1[];
|
|
265
|
+
model?: ModelAssignment;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function initializeBotSettingsV1(
|
|
269
|
+
botId: string,
|
|
270
|
+
model?: ModelAssignment,
|
|
271
|
+
): BotSettingsViewV1 {
|
|
272
|
+
return {
|
|
273
|
+
schemaVersion: 1,
|
|
274
|
+
botId,
|
|
275
|
+
revision: 0,
|
|
276
|
+
profile: { name: botId === "default" ? "Barebones" : botId },
|
|
277
|
+
notifications: { enabled: true },
|
|
278
|
+
assignments: [],
|
|
279
|
+
assignmentOperations: [],
|
|
280
|
+
model: model ? structuredClone(model) : undefined,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export type ConfigurationViewV1 = UserSettingsViewV1 | BotSettingsViewV1;
|
|
285
|
+
|
|
286
|
+
export type ConfigurationQueryV1 =
|
|
287
|
+
| { schemaVersion: 1; type: "user/get" }
|
|
288
|
+
| { schemaVersion: 1; type: "bot/get"; botId: string };
|
|
289
|
+
|
|
290
|
+
interface CommandMetaV1 {
|
|
291
|
+
schemaVersion: 1;
|
|
292
|
+
commandId: string;
|
|
293
|
+
expectedRevision: number;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export type ConfigurationCommandV1 =
|
|
297
|
+
| (CommandMetaV1 & {
|
|
298
|
+
type: "user/update-profile";
|
|
299
|
+
profile: UserSettingsViewV1["profile"];
|
|
300
|
+
})
|
|
301
|
+
| (CommandMetaV1 & {
|
|
302
|
+
type: "user/set-new-bot-model";
|
|
303
|
+
model?: ModelAssignment;
|
|
304
|
+
source: NewBotModelTemplateSourceV1;
|
|
305
|
+
})
|
|
306
|
+
| (CommandMetaV1 & {
|
|
307
|
+
type: "user/install-package";
|
|
308
|
+
packageId: string;
|
|
309
|
+
version: string;
|
|
310
|
+
/**
|
|
311
|
+
* A Catalog install names the entry and the generation it was read
|
|
312
|
+
* from. The User Durable Object refuses a generation other than the one
|
|
313
|
+
* it pinned, so a stale browser cannot install off a moved index. All
|
|
314
|
+
* three absent is the unchanged compiled-in install path.
|
|
315
|
+
*/
|
|
316
|
+
catalogId?: string;
|
|
317
|
+
catalogGeneration?: string;
|
|
318
|
+
values?: Record<string, JsonValue>;
|
|
319
|
+
})
|
|
320
|
+
| (CommandMetaV1 & {
|
|
321
|
+
/**
|
|
322
|
+
* Removes the installation. Dependent Assignments are never deleted:
|
|
323
|
+
* they resolve as unavailable tombstones the User can repair (ADR 0003).
|
|
324
|
+
* Connections are untouched.
|
|
325
|
+
*/
|
|
326
|
+
type: "user/uninstall-package";
|
|
327
|
+
packageId: string;
|
|
328
|
+
})
|
|
329
|
+
| (CommandMetaV1 & {
|
|
330
|
+
type: "user/set-package-enabled";
|
|
331
|
+
packageId: string;
|
|
332
|
+
enabled: boolean;
|
|
333
|
+
})
|
|
334
|
+
| (CommandMetaV1 & {
|
|
335
|
+
/**
|
|
336
|
+
* A partial update of one installed Package's setting values: only the
|
|
337
|
+
* ids it names change, and an id it omits keeps the value it had.
|
|
338
|
+
*
|
|
339
|
+
* The values are shape-checked here and schema-checked by the User
|
|
340
|
+
* Durable Object, which is the only place that knows which settings the
|
|
341
|
+
* installed version of that Package declares.
|
|
342
|
+
*/
|
|
343
|
+
type: "user/set-package-settings";
|
|
344
|
+
packageId: string;
|
|
345
|
+
values: Record<string, PackageSettingValueV1>;
|
|
346
|
+
})
|
|
347
|
+
| (CommandMetaV1 & {
|
|
348
|
+
type: "bot/update-profile";
|
|
349
|
+
botId: string;
|
|
350
|
+
profile: BotProfile;
|
|
351
|
+
})
|
|
352
|
+
| (CommandMetaV1 & {
|
|
353
|
+
/**
|
|
354
|
+
* Partial profile update: only the fields the command carries change.
|
|
355
|
+
* `namedBy` records the writer of a name change and defaults to the
|
|
356
|
+
* User, so a Bot renaming itself states so explicitly.
|
|
357
|
+
*/
|
|
358
|
+
type: "bot/set-profile";
|
|
359
|
+
botId: string;
|
|
360
|
+
namedBy?: BotNameProvenanceV1;
|
|
361
|
+
/**
|
|
362
|
+
* The Bot and Turn that wrote this patch, when a Bot wrote it. A User
|
|
363
|
+
* edit carries none: the authenticated principal is already the writer.
|
|
364
|
+
*/
|
|
365
|
+
writer?: BotSelfWriterV1;
|
|
366
|
+
profile: BotProfilePatchV1;
|
|
367
|
+
})
|
|
368
|
+
| (CommandMetaV1 & {
|
|
369
|
+
type: "bot/update-notifications";
|
|
370
|
+
botId: string;
|
|
371
|
+
notifications: BotNotificationPolicy;
|
|
372
|
+
})
|
|
373
|
+
| (CommandMetaV1 & {
|
|
374
|
+
type: "bot/select-model";
|
|
375
|
+
botId: string;
|
|
376
|
+
model: ModelAssignment;
|
|
377
|
+
})
|
|
378
|
+
| (CommandMetaV1 & {
|
|
379
|
+
type: "bot/assign-capability";
|
|
380
|
+
botId: string;
|
|
381
|
+
assignment: Omit<CapabilityAssignmentView, "state">;
|
|
382
|
+
model?: ModelAssignment;
|
|
383
|
+
})
|
|
384
|
+
| (CommandMetaV1 & {
|
|
385
|
+
type: "bot/replace-capability";
|
|
386
|
+
botId: string;
|
|
387
|
+
assignment: Omit<CapabilityAssignmentView, "state">;
|
|
388
|
+
model?: ModelAssignment;
|
|
389
|
+
})
|
|
390
|
+
| (CommandMetaV1 & {
|
|
391
|
+
type: "bot/unassign-capability";
|
|
392
|
+
botId: string;
|
|
393
|
+
assignmentId: string;
|
|
394
|
+
})
|
|
395
|
+
| (CommandMetaV1 & {
|
|
396
|
+
type: "bot/unbind-model";
|
|
397
|
+
botId: string;
|
|
398
|
+
assignmentId: string;
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
export type UserConfigurationCommandV1 = Exclude<
|
|
402
|
+
ConfigurationCommandV1,
|
|
403
|
+
{ botId: string }
|
|
404
|
+
>;
|
|
405
|
+
|
|
406
|
+
export type BotConfigurationCommandV1 = Extract<
|
|
407
|
+
ConfigurationCommandV1,
|
|
408
|
+
{ botId: string }
|
|
409
|
+
>;
|
|
410
|
+
|
|
411
|
+
function canonicalFingerprintValue(value: unknown): string {
|
|
412
|
+
if (
|
|
413
|
+
value === null ||
|
|
414
|
+
typeof value === "string" ||
|
|
415
|
+
typeof value === "boolean" ||
|
|
416
|
+
typeof value === "number"
|
|
417
|
+
) {
|
|
418
|
+
const encoded = JSON.stringify(value);
|
|
419
|
+
if (encoded !== undefined) return encoded;
|
|
420
|
+
}
|
|
421
|
+
if (Array.isArray(value)) {
|
|
422
|
+
return `[${value.map(canonicalFingerprintValue).join(",")}]`;
|
|
423
|
+
}
|
|
424
|
+
if (typeof value === "object") {
|
|
425
|
+
const record = value as Record<string, unknown>;
|
|
426
|
+
return `{${Object.keys(record)
|
|
427
|
+
.filter((key) => record[key] !== undefined)
|
|
428
|
+
.sort()
|
|
429
|
+
.map(
|
|
430
|
+
(key) =>
|
|
431
|
+
`${JSON.stringify(key)}:${canonicalFingerprintValue(record[key])}`,
|
|
432
|
+
)
|
|
433
|
+
.join(",")}}`;
|
|
434
|
+
}
|
|
435
|
+
throw new Error("Configuration command fingerprint value is not JSON");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The idempotency fingerprint of any command, under a caller-chosen namespace.
|
|
440
|
+
*
|
|
441
|
+
* One canonicalization serves every command family so a replayed idempotency
|
|
442
|
+
* key is compared the same way everywhere, and the namespace keeps two families
|
|
443
|
+
* from ever producing the same bytes for different meanings.
|
|
444
|
+
*/
|
|
445
|
+
export function canonicalCommandFingerprintV1(
|
|
446
|
+
namespace: string,
|
|
447
|
+
command: unknown,
|
|
448
|
+
): string {
|
|
449
|
+
return `${namespace}:${canonicalFingerprintValue(command)}`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function configurationCommandFingerprintV1(
|
|
453
|
+
command: ConfigurationCommandV1,
|
|
454
|
+
): string {
|
|
455
|
+
const { commandId: _commandId, ...semanticCommand } = command;
|
|
456
|
+
return canonicalCommandFingerprintV1(
|
|
457
|
+
"configuration-command-v1",
|
|
458
|
+
semanticCommand,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export interface UserConfigurationReadRpcV1 {
|
|
463
|
+
schemaVersion: 1;
|
|
464
|
+
userId: string;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export interface UserConfigurationExecuteRpcV1 {
|
|
468
|
+
schemaVersion: 1;
|
|
469
|
+
userId: string;
|
|
470
|
+
command: UserConfigurationCommandV1;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export interface BotConfigurationReadRpcV1 {
|
|
474
|
+
schemaVersion: 1;
|
|
475
|
+
userId: string;
|
|
476
|
+
botId: string;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export interface BotConfigurationExecuteRpcV1 {
|
|
480
|
+
schemaVersion: 1;
|
|
481
|
+
userId: string;
|
|
482
|
+
botId: string;
|
|
483
|
+
command: BotConfigurationCommandV1;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export type OperationReceiptV1 =
|
|
487
|
+
| {
|
|
488
|
+
schemaVersion: 1;
|
|
489
|
+
commandId: string;
|
|
490
|
+
revision: number;
|
|
491
|
+
status: "pending" | "applied";
|
|
492
|
+
}
|
|
493
|
+
| {
|
|
494
|
+
schemaVersion: 1;
|
|
495
|
+
commandId: string;
|
|
496
|
+
revision: number;
|
|
497
|
+
status: "rejected";
|
|
498
|
+
failure: string;
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
export interface BotExecutionPlanV1 {
|
|
502
|
+
schemaVersion: 1;
|
|
503
|
+
botId: string;
|
|
504
|
+
revision: number;
|
|
505
|
+
model?: ModelAssignment;
|
|
506
|
+
assignments: CapabilityAssignmentView[];
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export interface ExecutionPackageDefinition {
|
|
510
|
+
packageId: string;
|
|
511
|
+
version: string;
|
|
512
|
+
capabilities: Array<{
|
|
513
|
+
id: string;
|
|
514
|
+
kind?: "tool" | "model" | "memory" | "notification" | "computer";
|
|
515
|
+
connectionTypes: string[];
|
|
516
|
+
}>;
|
|
517
|
+
connectionTypes: Array<{
|
|
518
|
+
id: string;
|
|
519
|
+
capabilities: string[];
|
|
520
|
+
}>;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
export function capabilityAssignmentFailureV1(input: {
|
|
524
|
+
assignment: Omit<CapabilityAssignmentView, "state">;
|
|
525
|
+
user: UserSettingsViewV1;
|
|
526
|
+
packages: readonly ExecutionPackageDefinition[];
|
|
527
|
+
}): string | undefined {
|
|
528
|
+
const installation = input.user.packages.find(
|
|
529
|
+
(pkg) =>
|
|
530
|
+
pkg.packageId === input.assignment.packageId && pkg.state === "installed",
|
|
531
|
+
);
|
|
532
|
+
const pkg = input.packages.find(
|
|
533
|
+
(candidate) =>
|
|
534
|
+
candidate.packageId === input.assignment.packageId &&
|
|
535
|
+
candidate.version === installation?.version,
|
|
536
|
+
);
|
|
537
|
+
if (!installation || !pkg) {
|
|
538
|
+
return `Package "${input.assignment.packageId}" is not installed and enabled`;
|
|
539
|
+
}
|
|
540
|
+
const capability = pkg.capabilities.find(
|
|
541
|
+
(candidate) => candidate.id === input.assignment.capabilityId,
|
|
542
|
+
);
|
|
543
|
+
if (!capability) {
|
|
544
|
+
return `Capability "${input.assignment.capabilityId}" is not declared by Package "${input.assignment.packageId}"`;
|
|
545
|
+
}
|
|
546
|
+
if (capability.connectionTypes.length === 0) {
|
|
547
|
+
return input.assignment.connectionId
|
|
548
|
+
? `Capability "${input.assignment.capabilityId}" does not accept a Connection`
|
|
549
|
+
: undefined;
|
|
550
|
+
}
|
|
551
|
+
if (!input.assignment.connectionId) {
|
|
552
|
+
return `Capability "${input.assignment.capabilityId}" requires a Connection`;
|
|
553
|
+
}
|
|
554
|
+
const connection = input.user.connections.find(
|
|
555
|
+
(candidate) => candidate.connectionId === input.assignment.connectionId,
|
|
556
|
+
);
|
|
557
|
+
if (
|
|
558
|
+
!connection ||
|
|
559
|
+
connection.packageId !== input.assignment.packageId ||
|
|
560
|
+
connection.state !== "ready"
|
|
561
|
+
) {
|
|
562
|
+
return `Connection "${input.assignment.connectionId}" is not a ready Connection for Package "${input.assignment.packageId}"`;
|
|
563
|
+
}
|
|
564
|
+
const connectionType = pkg.connectionTypes.find(
|
|
565
|
+
(candidate) => candidate.id === connection.connectionTypeId,
|
|
566
|
+
);
|
|
567
|
+
if (
|
|
568
|
+
!connectionType ||
|
|
569
|
+
!capability.connectionTypes.includes(connectionType.id) ||
|
|
570
|
+
!connectionType.capabilities.includes(capability.id)
|
|
571
|
+
) {
|
|
572
|
+
return `Connection "${input.assignment.connectionId}" has an incompatible Connection Type`;
|
|
573
|
+
}
|
|
574
|
+
return undefined;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export interface ResolvedModelBindingV1 {
|
|
578
|
+
assignment: ModelAssignment;
|
|
579
|
+
state: "ready" | "requires-resolution" | "unavailable";
|
|
580
|
+
connection?: ConnectionView;
|
|
581
|
+
packageId?: string;
|
|
582
|
+
providerType?: string;
|
|
583
|
+
failure?: string;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export function resolveBotModelBindingV1(input: {
|
|
587
|
+
model: ModelAssignment;
|
|
588
|
+
assignments: readonly CapabilityAssignmentView[];
|
|
589
|
+
user: UserSettingsViewV1;
|
|
590
|
+
packages: readonly ExecutionPackageDefinition[];
|
|
591
|
+
}): ResolvedModelBindingV1 {
|
|
592
|
+
const unavailable = (failure: string): ResolvedModelBindingV1 => ({
|
|
593
|
+
assignment: structuredClone(input.model),
|
|
594
|
+
state: "unavailable",
|
|
595
|
+
failure,
|
|
596
|
+
});
|
|
597
|
+
const connection = input.user.connections.find(
|
|
598
|
+
(candidate) => candidate.connectionId === input.model.connectionId,
|
|
599
|
+
);
|
|
600
|
+
if (!connection) return unavailable("Connection is unavailable");
|
|
601
|
+
if (connection.state !== "ready") {
|
|
602
|
+
return unavailable(`Connection is ${connection.state}`);
|
|
603
|
+
}
|
|
604
|
+
const installation = input.user.packages.find(
|
|
605
|
+
(candidate) => candidate.packageId === connection.packageId,
|
|
606
|
+
);
|
|
607
|
+
if (!installation || installation.state !== "installed") {
|
|
608
|
+
return unavailable("Connection Package is not installed and enabled");
|
|
609
|
+
}
|
|
610
|
+
const pkg = input.packages.find(
|
|
611
|
+
(candidate) =>
|
|
612
|
+
candidate.packageId === connection.packageId &&
|
|
613
|
+
candidate.version === installation.version,
|
|
614
|
+
);
|
|
615
|
+
const connectionType = pkg?.connectionTypes.find(
|
|
616
|
+
(candidate) => candidate.id === connection.connectionTypeId,
|
|
617
|
+
);
|
|
618
|
+
const modelCapability = pkg?.capabilities.find(
|
|
619
|
+
(candidate) =>
|
|
620
|
+
candidate.kind === "model" &&
|
|
621
|
+
connectionType?.capabilities.includes(candidate.id) &&
|
|
622
|
+
candidate.connectionTypes.includes(connection.connectionTypeId),
|
|
623
|
+
);
|
|
624
|
+
if (!pkg || !connectionType || !modelCapability) {
|
|
625
|
+
return unavailable("Connection does not provide models");
|
|
626
|
+
}
|
|
627
|
+
const assignment = input.assignments.find(
|
|
628
|
+
(candidate) =>
|
|
629
|
+
candidate.packageId === pkg.packageId &&
|
|
630
|
+
candidate.capabilityId === modelCapability.id &&
|
|
631
|
+
candidate.connectionId === connection.connectionId &&
|
|
632
|
+
candidate.state === "enabled",
|
|
633
|
+
);
|
|
634
|
+
if (!assignment) {
|
|
635
|
+
return unavailable("Bot is not assigned the Connection model capability");
|
|
636
|
+
}
|
|
637
|
+
if (!connection.providerType) {
|
|
638
|
+
return unavailable("Connection provider type is unavailable");
|
|
639
|
+
}
|
|
640
|
+
const knownModel = connection.modelCatalog?.models.some(
|
|
641
|
+
(candidate: { providerModelId: string }) =>
|
|
642
|
+
candidate.providerModelId === input.model.providerModelId,
|
|
643
|
+
);
|
|
644
|
+
return {
|
|
645
|
+
assignment: structuredClone(input.model),
|
|
646
|
+
state: knownModel ? "ready" : "requires-resolution",
|
|
647
|
+
connection: structuredClone(connection),
|
|
648
|
+
packageId: pkg.packageId,
|
|
649
|
+
providerType: connection.providerType,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export interface EffectiveBotModelV1 {
|
|
654
|
+
/**
|
|
655
|
+
* "bot" when the Bot overrides the User default, "default" when the Bot
|
|
656
|
+
* follows `UserSettingsViewV1.newBotModelTemplate`, "none" when neither is
|
|
657
|
+
* set.
|
|
658
|
+
*/
|
|
659
|
+
source: "bot" | "default" | "none";
|
|
660
|
+
model?: ModelAssignment;
|
|
661
|
+
binding?: ResolvedModelBindingV1;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The model a Bot actually runs on. A Bot without its own `model` follows the
|
|
666
|
+
* User's default dynamically, so changing the default changes every Bot that
|
|
667
|
+
* has not overridden it. Authority is unchanged: the returned binding is still
|
|
668
|
+
* resolved against the Bot's own Assignments, so a Bot that has never claimed
|
|
669
|
+
* the Connection's model Capability resolves "unavailable" until it does.
|
|
670
|
+
*/
|
|
671
|
+
export function resolveEffectiveBotModelV1(input: {
|
|
672
|
+
bot: Pick<BotSettingsViewV1, "model" | "assignments">;
|
|
673
|
+
user: UserSettingsViewV1;
|
|
674
|
+
packages: readonly ExecutionPackageDefinition[];
|
|
675
|
+
}): EffectiveBotModelV1 {
|
|
676
|
+
const model = input.bot.model ?? input.user.newBotModelTemplate;
|
|
677
|
+
if (!model) return { source: "none" };
|
|
678
|
+
return {
|
|
679
|
+
source: input.bot.model ? "bot" : "default",
|
|
680
|
+
model: structuredClone(model),
|
|
681
|
+
binding: resolveBotModelBindingV1({
|
|
682
|
+
model,
|
|
683
|
+
assignments: input.bot.assignments,
|
|
684
|
+
user: input.user,
|
|
685
|
+
packages: input.packages,
|
|
686
|
+
}),
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export function resolveBotExecutionPlanV1(input: {
|
|
691
|
+
bot: BotSettingsViewV1;
|
|
692
|
+
user: UserSettingsViewV1;
|
|
693
|
+
packages: readonly ExecutionPackageDefinition[];
|
|
694
|
+
}): BotExecutionPlanV1 {
|
|
695
|
+
const assignments = input.bot.assignments.map((assignment) => {
|
|
696
|
+
if (assignment.state !== "enabled") return structuredClone(assignment);
|
|
697
|
+
if (
|
|
698
|
+
capabilityAssignmentFailureV1({
|
|
699
|
+
assignment,
|
|
700
|
+
user: input.user,
|
|
701
|
+
packages: input.packages,
|
|
702
|
+
})
|
|
703
|
+
) {
|
|
704
|
+
return { ...assignment, state: "unavailable" as const };
|
|
705
|
+
}
|
|
706
|
+
return structuredClone(assignment);
|
|
707
|
+
});
|
|
708
|
+
return {
|
|
709
|
+
schemaVersion: 1,
|
|
710
|
+
botId: input.bot.botId,
|
|
711
|
+
revision: input.bot.revision,
|
|
712
|
+
model: input.bot.model ? structuredClone(input.bot.model) : undefined,
|
|
713
|
+
assignments,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export interface ConfigurationApplication {
|
|
718
|
+
read(
|
|
719
|
+
principal: UserPrincipal,
|
|
720
|
+
query: ConfigurationQueryV1,
|
|
721
|
+
): Promise<ConfigurationViewV1>;
|
|
722
|
+
execute(
|
|
723
|
+
principal: UserPrincipal,
|
|
724
|
+
command: ConfigurationCommandV1,
|
|
725
|
+
): Promise<OperationReceiptV1>;
|
|
726
|
+
resolveBot(authority: BotAuthority): Promise<BotExecutionPlanV1>;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
export class ConfigurationConflictError extends Error {
|
|
730
|
+
constructor(readonly currentRevision: number) {
|
|
731
|
+
super(`configuration revision is ${currentRevision}`);
|
|
732
|
+
this.name = "ConfigurationConflictError";
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
737
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
738
|
+
throw new ConfigurationDecodeError(`${label} must be an object`);
|
|
739
|
+
}
|
|
740
|
+
return value as Record<string, unknown>;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
export function decodeBotIdV1(value: unknown, label = "botId"): string {
|
|
744
|
+
if (!isBotIdV1(value))
|
|
745
|
+
throw new ConfigurationDecodeError(`${label} is invalid`);
|
|
746
|
+
return value;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function identifier(value: unknown, label: string): string {
|
|
750
|
+
if (!isPublicIdentifier(value)) {
|
|
751
|
+
throw new ConfigurationDecodeError(`${label} is invalid`);
|
|
752
|
+
}
|
|
753
|
+
return value;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function connectionIdentifier(value: unknown, label: string): string {
|
|
757
|
+
if (!isConnectionIdentifier(value)) {
|
|
758
|
+
throw new ConfigurationDecodeError(`${label} is invalid`);
|
|
759
|
+
}
|
|
760
|
+
return value;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const rpcDisposalKeys: ReadonlySet<PropertyKey> = new Set<PropertyKey>([
|
|
764
|
+
Symbol.dispose,
|
|
765
|
+
Symbol.asyncDispose,
|
|
766
|
+
]);
|
|
767
|
+
|
|
768
|
+
function exactRecord(
|
|
769
|
+
value: unknown,
|
|
770
|
+
label: string,
|
|
771
|
+
required: readonly PropertyKey[],
|
|
772
|
+
optional: readonly PropertyKey[] = [],
|
|
773
|
+
): Record<string, unknown> {
|
|
774
|
+
const decoded = record(value, label);
|
|
775
|
+
const allowed = new Set<PropertyKey>([...required, ...optional]);
|
|
776
|
+
// Values returned over Durable Object RPC carry a disposal symbol as an own
|
|
777
|
+
// key; it is transport, not a field. Every other own key must be declared.
|
|
778
|
+
if (
|
|
779
|
+
!required.every((key) => Object.hasOwn(decoded, key)) ||
|
|
780
|
+
Reflect.ownKeys(decoded).some(
|
|
781
|
+
(key) => !allowed.has(key) && !rpcDisposalKeys.has(key),
|
|
782
|
+
)
|
|
783
|
+
) {
|
|
784
|
+
throw new ConfigurationDecodeError(`${label} has invalid fields`);
|
|
785
|
+
}
|
|
786
|
+
return decoded;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export function decodeStartConnectionCommandV1(
|
|
790
|
+
input: unknown,
|
|
791
|
+
): StartConnectionCommandV1 {
|
|
792
|
+
const value = exactRecord(
|
|
793
|
+
input,
|
|
794
|
+
"Connection start command",
|
|
795
|
+
["schemaVersion", "type", "commandId", "connectionTypeId"],
|
|
796
|
+
["alias", "nativeReturnNonce"],
|
|
797
|
+
);
|
|
798
|
+
if (value.schemaVersion !== 1 || value.type !== "connection/start") {
|
|
799
|
+
throw new ConfigurationDecodeError("unsupported Connection start command");
|
|
800
|
+
}
|
|
801
|
+
if (
|
|
802
|
+
value.alias !== undefined &&
|
|
803
|
+
(typeof value.alias !== "string" || value.alias.length > 100)
|
|
804
|
+
) {
|
|
805
|
+
throw new ConfigurationDecodeError("alias is invalid");
|
|
806
|
+
}
|
|
807
|
+
return {
|
|
808
|
+
schemaVersion: 1,
|
|
809
|
+
type: "connection/start",
|
|
810
|
+
commandId: connectionIdentifier(value.commandId, "commandId"),
|
|
811
|
+
connectionTypeId: connectionIdentifier(
|
|
812
|
+
value.connectionTypeId,
|
|
813
|
+
"connectionTypeId",
|
|
814
|
+
),
|
|
815
|
+
...(value.alias === undefined ? {} : { alias: value.alias }),
|
|
816
|
+
...(value.nativeReturnNonce === undefined
|
|
817
|
+
? {}
|
|
818
|
+
: {
|
|
819
|
+
nativeReturnNonce: connectionIdentifier(
|
|
820
|
+
value.nativeReturnNonce,
|
|
821
|
+
"nativeReturnNonce",
|
|
822
|
+
),
|
|
823
|
+
}),
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
export function decodeRevokeConnectionCommandV1(
|
|
828
|
+
input: unknown,
|
|
829
|
+
): RevokeConnectionCommandV1 {
|
|
830
|
+
const value = exactRecord(input, "Connection revoke command", [
|
|
831
|
+
"schemaVersion",
|
|
832
|
+
"type",
|
|
833
|
+
]);
|
|
834
|
+
if (value.schemaVersion !== 1 || value.type !== "connection/revoke") {
|
|
835
|
+
throw new ConfigurationDecodeError("unsupported Connection revoke command");
|
|
836
|
+
}
|
|
837
|
+
return { schemaVersion: 1, type: "connection/revoke" };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
export function decodeConnectionDependencyRequirementV1(
|
|
841
|
+
input: unknown,
|
|
842
|
+
): ConnectionDependencyRequirementV1 {
|
|
843
|
+
const value = exactRecord(input, "Connection dependency requirement", [
|
|
844
|
+
"schemaVersion",
|
|
845
|
+
"packageId",
|
|
846
|
+
"packageVersion",
|
|
847
|
+
"capabilityId",
|
|
848
|
+
"connectionTypeIds",
|
|
849
|
+
]);
|
|
850
|
+
schemaVersion(value);
|
|
851
|
+
if (
|
|
852
|
+
!Array.isArray(value.connectionTypeIds) ||
|
|
853
|
+
value.connectionTypeIds.length === 0
|
|
854
|
+
) {
|
|
855
|
+
throw new ConfigurationDecodeError(
|
|
856
|
+
"Connection dependency requirement connectionTypeIds are invalid",
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
return {
|
|
860
|
+
schemaVersion: 1,
|
|
861
|
+
packageId: identifier(value.packageId, "packageId"),
|
|
862
|
+
packageVersion: text(value.packageVersion, "packageVersion", 128),
|
|
863
|
+
capabilityId: identifier(value.capabilityId, "capabilityId"),
|
|
864
|
+
connectionTypeIds: value.connectionTypeIds.map((item) =>
|
|
865
|
+
identifier(item, "connectionTypeId"),
|
|
866
|
+
),
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function text(value: unknown, label: string, maximum: number): string {
|
|
871
|
+
if (typeof value !== "string") {
|
|
872
|
+
throw new ConfigurationDecodeError(`${label} must be a string`);
|
|
873
|
+
}
|
|
874
|
+
const normalized = value.trim();
|
|
875
|
+
if (!normalized || normalized.length > maximum) {
|
|
876
|
+
throw new ConfigurationDecodeError(`${label} is invalid`);
|
|
877
|
+
}
|
|
878
|
+
return normalized;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function optionalText(
|
|
882
|
+
value: unknown,
|
|
883
|
+
label: string,
|
|
884
|
+
maximum: number,
|
|
885
|
+
): string | undefined {
|
|
886
|
+
return value === undefined ? undefined : text(value, label, maximum);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function revision(value: unknown): number {
|
|
890
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
891
|
+
throw new ConfigurationDecodeError("expectedRevision is invalid");
|
|
892
|
+
}
|
|
893
|
+
return value as number;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const COMMAND_META_FIELDS = [
|
|
897
|
+
"schemaVersion",
|
|
898
|
+
"type",
|
|
899
|
+
"commandId",
|
|
900
|
+
"expectedRevision",
|
|
901
|
+
] as const;
|
|
902
|
+
|
|
903
|
+
function exactCommand(
|
|
904
|
+
input: unknown,
|
|
905
|
+
fields: readonly string[],
|
|
906
|
+
optional: readonly string[] = [],
|
|
907
|
+
): Record<string, unknown> {
|
|
908
|
+
return exactRecord(
|
|
909
|
+
input,
|
|
910
|
+
"command",
|
|
911
|
+
[...COMMAND_META_FIELDS, ...fields],
|
|
912
|
+
optional,
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function commandMeta(value: Record<string, unknown>): CommandMetaV1 {
|
|
917
|
+
if (value.schemaVersion !== 1) {
|
|
918
|
+
throw new ConfigurationDecodeError("unsupported configuration schema");
|
|
919
|
+
}
|
|
920
|
+
return {
|
|
921
|
+
schemaVersion: 1,
|
|
922
|
+
commandId: identifier(value.commandId, "commandId"),
|
|
923
|
+
expectedRevision: revision(value.expectedRevision),
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function nameProvenance(value: unknown, label: string): BotNameProvenanceV1 {
|
|
928
|
+
if (value !== "user" && value !== "bot") {
|
|
929
|
+
throw new ConfigurationDecodeError(`${label} is invalid`);
|
|
930
|
+
}
|
|
931
|
+
return value;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* The exact Bot writer DTO. It crosses the Bot Durable Object seam and the
|
|
936
|
+
* session event log, so — like every other cross-runtime value — it decodes
|
|
937
|
+
* exactly once, here, with no extra fields tolerated.
|
|
938
|
+
*/
|
|
939
|
+
export function decodeBotSelfWriterV1(
|
|
940
|
+
value: unknown,
|
|
941
|
+
label = "writer",
|
|
942
|
+
): BotSelfWriterV1 {
|
|
943
|
+
const writer = exactRecord(value, label, [
|
|
944
|
+
"kind",
|
|
945
|
+
"botId",
|
|
946
|
+
"sessionId",
|
|
947
|
+
"turnId",
|
|
948
|
+
]);
|
|
949
|
+
if (writer.kind !== "bot") {
|
|
950
|
+
throw new ConfigurationDecodeError(`${label}.kind is invalid`);
|
|
951
|
+
}
|
|
952
|
+
return {
|
|
953
|
+
kind: "bot",
|
|
954
|
+
botId: decodeBotIdV1(writer.botId, `${label}.botId`),
|
|
955
|
+
sessionId: text(writer.sessionId, `${label}.sessionId`, 256),
|
|
956
|
+
turnId: text(writer.turnId, `${label}.turnId`, 128),
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function flag(value: unknown, label: string): boolean {
|
|
961
|
+
if (typeof value !== "boolean") {
|
|
962
|
+
throw new ConfigurationDecodeError(`${label} must be a boolean`);
|
|
963
|
+
}
|
|
964
|
+
return value;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const BOT_PROFILE_OPTIONAL_FIELDS = [
|
|
968
|
+
"label",
|
|
969
|
+
"description",
|
|
970
|
+
"title",
|
|
971
|
+
"namedBy",
|
|
972
|
+
"hiddenFromSidebar",
|
|
973
|
+
] as const;
|
|
974
|
+
|
|
975
|
+
function botProfile(value: unknown): BotProfile {
|
|
976
|
+
const profile = exactRecord(
|
|
977
|
+
value,
|
|
978
|
+
"profile",
|
|
979
|
+
["name"],
|
|
980
|
+
BOT_PROFILE_OPTIONAL_FIELDS,
|
|
981
|
+
);
|
|
982
|
+
return {
|
|
983
|
+
name: text(profile.name, "profile.name", 100),
|
|
984
|
+
label: optionalText(profile.label, "profile.label", 120),
|
|
985
|
+
description: optionalText(
|
|
986
|
+
profile.description,
|
|
987
|
+
"profile.description",
|
|
988
|
+
10_000,
|
|
989
|
+
),
|
|
990
|
+
...(profile.title === undefined
|
|
991
|
+
? {}
|
|
992
|
+
: { title: text(profile.title, "profile.title", 120) }),
|
|
993
|
+
...(profile.namedBy === undefined
|
|
994
|
+
? {}
|
|
995
|
+
: { namedBy: nameProvenance(profile.namedBy, "profile.namedBy") }),
|
|
996
|
+
...(profile.hiddenFromSidebar === undefined
|
|
997
|
+
? {}
|
|
998
|
+
: {
|
|
999
|
+
hiddenFromSidebar: flag(
|
|
1000
|
+
profile.hiddenFromSidebar,
|
|
1001
|
+
"profile.hiddenFromSidebar",
|
|
1002
|
+
),
|
|
1003
|
+
}),
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* A patch field that carries text: a non-empty string sets it, and the empty
|
|
1009
|
+
* string clears it. A partial update has no other way to say "remove this".
|
|
1010
|
+
*/
|
|
1011
|
+
function patchText(
|
|
1012
|
+
value: unknown,
|
|
1013
|
+
label: string,
|
|
1014
|
+
maximum: number,
|
|
1015
|
+
): string | "" {
|
|
1016
|
+
if (typeof value !== "string") {
|
|
1017
|
+
throw new ConfigurationDecodeError(`${label} must be a string`);
|
|
1018
|
+
}
|
|
1019
|
+
const normalized = value.trim();
|
|
1020
|
+
if (normalized.length > maximum) {
|
|
1021
|
+
throw new ConfigurationDecodeError(`${label} is invalid`);
|
|
1022
|
+
}
|
|
1023
|
+
return normalized;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function botProfilePatch(value: unknown): BotProfilePatchV1 {
|
|
1027
|
+
const patch = exactRecord(
|
|
1028
|
+
value,
|
|
1029
|
+
"profile",
|
|
1030
|
+
[],
|
|
1031
|
+
["name", "label", "description", "title", "hiddenFromSidebar"],
|
|
1032
|
+
);
|
|
1033
|
+
if (Reflect.ownKeys(patch).length === 0) {
|
|
1034
|
+
throw new ConfigurationDecodeError("profile has invalid fields");
|
|
1035
|
+
}
|
|
1036
|
+
const optional = (key: "label" | "description" | "title", maximum: number) =>
|
|
1037
|
+
patch[key] === undefined
|
|
1038
|
+
? {}
|
|
1039
|
+
: { [key]: patchText(patch[key], `profile.${key}`, maximum) };
|
|
1040
|
+
return {
|
|
1041
|
+
// The name is the one field a partial update may not blank.
|
|
1042
|
+
...(patch.name === undefined
|
|
1043
|
+
? {}
|
|
1044
|
+
: { name: text(patch.name, "profile.name", 100) }),
|
|
1045
|
+
...optional("label", 120),
|
|
1046
|
+
...optional("description", 10_000),
|
|
1047
|
+
...optional("title", 120),
|
|
1048
|
+
...(patch.hiddenFromSidebar === undefined
|
|
1049
|
+
? {}
|
|
1050
|
+
: {
|
|
1051
|
+
hiddenFromSidebar: flag(
|
|
1052
|
+
patch.hiddenFromSidebar,
|
|
1053
|
+
"profile.hiddenFromSidebar",
|
|
1054
|
+
),
|
|
1055
|
+
}),
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* Apply a partial profile update. Only the keys the patch carries change; an
|
|
1061
|
+
* empty string clears an optional text field. `namedBy` is recorded only when
|
|
1062
|
+
* the name actually changes, so
|
|
1063
|
+
* an unrelated edit never rewrites the provenance of the current name.
|
|
1064
|
+
*/
|
|
1065
|
+
export function applyBotProfilePatchV1(
|
|
1066
|
+
current: BotProfile,
|
|
1067
|
+
patch: BotProfilePatchV1,
|
|
1068
|
+
namedBy: BotNameProvenanceV1,
|
|
1069
|
+
): BotProfile {
|
|
1070
|
+
const next: BotProfile = { ...structuredClone(current) };
|
|
1071
|
+
if (patch.name !== undefined && patch.name !== current.name) {
|
|
1072
|
+
next.name = patch.name;
|
|
1073
|
+
next.namedBy = namedBy;
|
|
1074
|
+
}
|
|
1075
|
+
for (const key of ["label", "description", "title"] as const) {
|
|
1076
|
+
const value = patch[key];
|
|
1077
|
+
if (value === undefined) continue;
|
|
1078
|
+
if (value === "") delete next[key];
|
|
1079
|
+
else next[key] = value;
|
|
1080
|
+
}
|
|
1081
|
+
if (patch.hiddenFromSidebar !== undefined) {
|
|
1082
|
+
if (patch.hiddenFromSidebar) next.hiddenFromSidebar = true;
|
|
1083
|
+
else delete next.hiddenFromSidebar;
|
|
1084
|
+
}
|
|
1085
|
+
return next;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function notifications(value: unknown): BotNotificationPolicy {
|
|
1089
|
+
const policy = exactRecord(value, "notifications", ["enabled"]);
|
|
1090
|
+
if (typeof policy.enabled !== "boolean") {
|
|
1091
|
+
throw new ConfigurationDecodeError("notifications.enabled is invalid");
|
|
1092
|
+
}
|
|
1093
|
+
return { enabled: policy.enabled };
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** The exact Bot model DTO, decoded wherever it crosses a durable seam. */
|
|
1097
|
+
export function decodeModelAssignmentV1(value: unknown): ModelAssignment {
|
|
1098
|
+
return model(value);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function model(value: unknown): ModelAssignment {
|
|
1102
|
+
const assignment = exactRecord(value, "model", [
|
|
1103
|
+
"connectionId",
|
|
1104
|
+
"providerModelId",
|
|
1105
|
+
]);
|
|
1106
|
+
return {
|
|
1107
|
+
connectionId: identifier(assignment.connectionId, "model.connectionId"),
|
|
1108
|
+
providerModelId: text(
|
|
1109
|
+
assignment.providerModelId,
|
|
1110
|
+
"model.providerModelId",
|
|
1111
|
+
256,
|
|
1112
|
+
),
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
export function decodeConfigurationQueryV1(
|
|
1117
|
+
input: unknown,
|
|
1118
|
+
): ConfigurationQueryV1 {
|
|
1119
|
+
const value = record(input, "query");
|
|
1120
|
+
if (value.schemaVersion !== 1) {
|
|
1121
|
+
throw new ConfigurationDecodeError("unsupported configuration schema");
|
|
1122
|
+
}
|
|
1123
|
+
if (value.type === "user/get") {
|
|
1124
|
+
exactRecord(input, "query", ["schemaVersion", "type"]);
|
|
1125
|
+
return { schemaVersion: 1, type: "user/get" };
|
|
1126
|
+
}
|
|
1127
|
+
if (value.type === "bot/get") {
|
|
1128
|
+
const query = exactRecord(input, "query", [
|
|
1129
|
+
"schemaVersion",
|
|
1130
|
+
"type",
|
|
1131
|
+
"botId",
|
|
1132
|
+
]);
|
|
1133
|
+
return {
|
|
1134
|
+
schemaVersion: 1,
|
|
1135
|
+
type: "bot/get",
|
|
1136
|
+
botId: identifier(query.botId, "botId"),
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
throw new ConfigurationDecodeError("unknown configuration query");
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
export function decodeConfigurationCommandV1(
|
|
1143
|
+
input: unknown,
|
|
1144
|
+
): ConfigurationCommandV1 {
|
|
1145
|
+
const value = record(input, "command");
|
|
1146
|
+
switch (value.type) {
|
|
1147
|
+
case "user/update-profile": {
|
|
1148
|
+
const command = exactCommand(input, ["profile"]);
|
|
1149
|
+
const profile = exactRecord(
|
|
1150
|
+
command.profile,
|
|
1151
|
+
"profile",
|
|
1152
|
+
["name"],
|
|
1153
|
+
["email"],
|
|
1154
|
+
);
|
|
1155
|
+
return {
|
|
1156
|
+
...commandMeta(command),
|
|
1157
|
+
type: value.type,
|
|
1158
|
+
profile: {
|
|
1159
|
+
name: text(profile.name, "profile.name", 100),
|
|
1160
|
+
email: optionalText(profile.email, "profile.email", 320),
|
|
1161
|
+
},
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
case "user/set-new-bot-model": {
|
|
1165
|
+
const command = exactCommand(input, ["source"], ["model"]);
|
|
1166
|
+
if (command.source !== "user" && command.source !== "auto") {
|
|
1167
|
+
throw new ConfigurationDecodeError(
|
|
1168
|
+
"new Bot model source must be user or auto",
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
if (command.source === "auto" && command.model === undefined) {
|
|
1172
|
+
throw new ConfigurationDecodeError(
|
|
1173
|
+
"an automatic new Bot model must name a model",
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
return {
|
|
1177
|
+
...commandMeta(command),
|
|
1178
|
+
type: value.type,
|
|
1179
|
+
model: command.model === undefined ? undefined : model(command.model),
|
|
1180
|
+
source: command.source,
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
case "user/install-package": {
|
|
1184
|
+
const command = exactCommand(
|
|
1185
|
+
input,
|
|
1186
|
+
["packageId", "version"],
|
|
1187
|
+
["catalogId", "catalogGeneration", "values"],
|
|
1188
|
+
);
|
|
1189
|
+
// A Catalog install is all three of identity, generation and (optional)
|
|
1190
|
+
// values or none of them: half a Catalog install would be an install
|
|
1191
|
+
// against no pinned generation at all.
|
|
1192
|
+
if (
|
|
1193
|
+
(command.catalogId === undefined) !==
|
|
1194
|
+
(command.catalogGeneration === undefined)
|
|
1195
|
+
) {
|
|
1196
|
+
throw new ConfigurationDecodeError(
|
|
1197
|
+
"a Catalog install requires both catalogId and catalogGeneration",
|
|
1198
|
+
);
|
|
1199
|
+
}
|
|
1200
|
+
if (command.catalogId === undefined && command.values !== undefined) {
|
|
1201
|
+
throw new ConfigurationDecodeError(
|
|
1202
|
+
"install values require a Catalog entry",
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
return {
|
|
1206
|
+
...commandMeta(command),
|
|
1207
|
+
type: value.type,
|
|
1208
|
+
packageId: identifier(command.packageId, "packageId"),
|
|
1209
|
+
version: text(command.version, "version", 100),
|
|
1210
|
+
...(command.catalogId === undefined
|
|
1211
|
+
? {}
|
|
1212
|
+
: {
|
|
1213
|
+
catalogId: identifier(command.catalogId, "catalogId"),
|
|
1214
|
+
catalogGeneration: identifier(
|
|
1215
|
+
command.catalogGeneration,
|
|
1216
|
+
"catalogGeneration",
|
|
1217
|
+
),
|
|
1218
|
+
}),
|
|
1219
|
+
...(command.values === undefined
|
|
1220
|
+
? {}
|
|
1221
|
+
: { values: installValues(command.values) }),
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
case "user/uninstall-package": {
|
|
1225
|
+
const command = exactCommand(input, ["packageId"]);
|
|
1226
|
+
return {
|
|
1227
|
+
...commandMeta(command),
|
|
1228
|
+
type: value.type,
|
|
1229
|
+
packageId: identifier(command.packageId, "packageId"),
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
case "user/set-package-enabled": {
|
|
1233
|
+
const command = exactCommand(input, ["packageId", "enabled"]);
|
|
1234
|
+
if (typeof command.enabled !== "boolean") {
|
|
1235
|
+
throw new ConfigurationDecodeError("enabled is invalid");
|
|
1236
|
+
}
|
|
1237
|
+
return {
|
|
1238
|
+
...commandMeta(command),
|
|
1239
|
+
type: value.type,
|
|
1240
|
+
packageId: identifier(command.packageId, "packageId"),
|
|
1241
|
+
enabled: command.enabled,
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
case "user/set-package-settings": {
|
|
1245
|
+
const command = exactCommand(input, ["packageId", "values"]);
|
|
1246
|
+
return {
|
|
1247
|
+
...commandMeta(command),
|
|
1248
|
+
type: value.type,
|
|
1249
|
+
packageId: identifier(command.packageId, "packageId"),
|
|
1250
|
+
values: packageSettingsPatch(command.values),
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
case "bot/update-profile": {
|
|
1254
|
+
const command = exactCommand(input, ["botId", "profile"]);
|
|
1255
|
+
return {
|
|
1256
|
+
...commandMeta(command),
|
|
1257
|
+
type: value.type,
|
|
1258
|
+
botId: identifier(command.botId, "botId"),
|
|
1259
|
+
profile: botProfile(command.profile),
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
case "bot/set-profile": {
|
|
1263
|
+
const command = exactCommand(
|
|
1264
|
+
input,
|
|
1265
|
+
["botId", "profile"],
|
|
1266
|
+
["namedBy", "writer"],
|
|
1267
|
+
);
|
|
1268
|
+
const botId = identifier(command.botId, "botId");
|
|
1269
|
+
const writer =
|
|
1270
|
+
command.writer === undefined
|
|
1271
|
+
? undefined
|
|
1272
|
+
: decodeBotSelfWriterV1(command.writer);
|
|
1273
|
+
// A Bot writes only its own profile. The command names the target twice,
|
|
1274
|
+
// so the seam refuses a writer aimed at anything but itself rather than
|
|
1275
|
+
// recording a provenance the authority never granted.
|
|
1276
|
+
if (writer && writer.botId !== botId) {
|
|
1277
|
+
throw new ConfigurationDecodeError("writer.botId is invalid");
|
|
1278
|
+
}
|
|
1279
|
+
return {
|
|
1280
|
+
...commandMeta(command),
|
|
1281
|
+
type: "bot/set-profile",
|
|
1282
|
+
botId,
|
|
1283
|
+
...(command.namedBy === undefined
|
|
1284
|
+
? {}
|
|
1285
|
+
: { namedBy: nameProvenance(command.namedBy, "namedBy") }),
|
|
1286
|
+
...(writer ? { writer } : {}),
|
|
1287
|
+
profile: botProfilePatch(command.profile),
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
case "bot/update-notifications": {
|
|
1291
|
+
const command = exactCommand(input, ["botId", "notifications"]);
|
|
1292
|
+
return {
|
|
1293
|
+
...commandMeta(command),
|
|
1294
|
+
type: value.type,
|
|
1295
|
+
botId: identifier(command.botId, "botId"),
|
|
1296
|
+
notifications: notifications(command.notifications),
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
case "bot/select-model": {
|
|
1300
|
+
const command = exactCommand(input, ["botId", "model"]);
|
|
1301
|
+
return {
|
|
1302
|
+
...commandMeta(command),
|
|
1303
|
+
type: value.type,
|
|
1304
|
+
botId: identifier(command.botId, "botId"),
|
|
1305
|
+
model: model(command.model),
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
case "bot/assign-capability":
|
|
1309
|
+
case "bot/replace-capability": {
|
|
1310
|
+
const command = exactCommand(input, ["botId", "assignment"], ["model"]);
|
|
1311
|
+
return {
|
|
1312
|
+
...commandMeta(command),
|
|
1313
|
+
type: value.type,
|
|
1314
|
+
botId: identifier(command.botId, "botId"),
|
|
1315
|
+
assignment: assignmentTarget(command.assignment, "assignment"),
|
|
1316
|
+
model: command.model === undefined ? undefined : model(command.model),
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
case "bot/unassign-capability":
|
|
1320
|
+
case "bot/unbind-model": {
|
|
1321
|
+
const command = exactCommand(input, ["botId", "assignmentId"]);
|
|
1322
|
+
return {
|
|
1323
|
+
...commandMeta(command),
|
|
1324
|
+
type: value.type,
|
|
1325
|
+
botId: identifier(command.botId, "botId"),
|
|
1326
|
+
assignmentId: identifier(command.assignmentId, "assignmentId"),
|
|
1327
|
+
};
|
|
1328
|
+
}
|
|
1329
|
+
default:
|
|
1330
|
+
throw new ConfigurationDecodeError("unknown configuration command");
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
export function decodeUserConfigurationReadRpcV1(
|
|
1335
|
+
input: unknown,
|
|
1336
|
+
): UserConfigurationReadRpcV1 {
|
|
1337
|
+
const value = exactRecord(input, "User configuration read RPC", [
|
|
1338
|
+
"schemaVersion",
|
|
1339
|
+
"userId",
|
|
1340
|
+
]);
|
|
1341
|
+
schemaVersion(value);
|
|
1342
|
+
return {
|
|
1343
|
+
schemaVersion: 1,
|
|
1344
|
+
userId: identifier(value.userId, "userId"),
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
export function decodeUserConfigurationExecuteRpcV1(
|
|
1349
|
+
input: unknown,
|
|
1350
|
+
): UserConfigurationExecuteRpcV1 {
|
|
1351
|
+
const value = exactRecord(input, "User configuration execute RPC", [
|
|
1352
|
+
"schemaVersion",
|
|
1353
|
+
"userId",
|
|
1354
|
+
"command",
|
|
1355
|
+
]);
|
|
1356
|
+
schemaVersion(value);
|
|
1357
|
+
const command = decodeConfigurationCommandV1(value.command);
|
|
1358
|
+
if ("botId" in command) {
|
|
1359
|
+
throw new ConfigurationDecodeError(
|
|
1360
|
+
"User configuration RPC requires a User command",
|
|
1361
|
+
);
|
|
1362
|
+
}
|
|
1363
|
+
return {
|
|
1364
|
+
schemaVersion: 1,
|
|
1365
|
+
userId: identifier(value.userId, "userId"),
|
|
1366
|
+
command,
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
export function decodeBotConfigurationReadRpcV1(
|
|
1371
|
+
input: unknown,
|
|
1372
|
+
): BotConfigurationReadRpcV1 {
|
|
1373
|
+
const value = exactRecord(input, "Bot configuration read RPC", [
|
|
1374
|
+
"schemaVersion",
|
|
1375
|
+
"userId",
|
|
1376
|
+
"botId",
|
|
1377
|
+
]);
|
|
1378
|
+
schemaVersion(value);
|
|
1379
|
+
return {
|
|
1380
|
+
schemaVersion: 1,
|
|
1381
|
+
userId: identifier(value.userId, "userId"),
|
|
1382
|
+
botId: identifier(value.botId, "botId"),
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
export function decodeBotConfigurationExecuteRpcV1(
|
|
1387
|
+
input: unknown,
|
|
1388
|
+
): BotConfigurationExecuteRpcV1 {
|
|
1389
|
+
const value = exactRecord(input, "Bot configuration execute RPC", [
|
|
1390
|
+
"schemaVersion",
|
|
1391
|
+
"userId",
|
|
1392
|
+
"botId",
|
|
1393
|
+
"command",
|
|
1394
|
+
]);
|
|
1395
|
+
schemaVersion(value);
|
|
1396
|
+
const botId = identifier(value.botId, "botId");
|
|
1397
|
+
const command = decodeConfigurationCommandV1(value.command);
|
|
1398
|
+
if (!("botId" in command)) {
|
|
1399
|
+
throw new ConfigurationDecodeError(
|
|
1400
|
+
"Bot configuration RPC requires a Bot command",
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
if (command.botId !== botId) {
|
|
1404
|
+
throw new ConfigurationDecodeError(
|
|
1405
|
+
"Bot configuration command does not match its authority",
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
return {
|
|
1409
|
+
schemaVersion: 1,
|
|
1410
|
+
userId: identifier(value.userId, "userId"),
|
|
1411
|
+
botId,
|
|
1412
|
+
command,
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function safeJsonValue(value: unknown, label: string): JsonValue {
|
|
1417
|
+
if (
|
|
1418
|
+
value === null ||
|
|
1419
|
+
typeof value === "string" ||
|
|
1420
|
+
typeof value === "boolean" ||
|
|
1421
|
+
(typeof value === "number" && Number.isFinite(value))
|
|
1422
|
+
) {
|
|
1423
|
+
return value as JsonValue;
|
|
1424
|
+
}
|
|
1425
|
+
if (Array.isArray(value)) {
|
|
1426
|
+
return value.map((item) => safeJsonValue(item, label));
|
|
1427
|
+
}
|
|
1428
|
+
if (typeof value === "object" && value !== null) {
|
|
1429
|
+
return Object.fromEntries(
|
|
1430
|
+
Object.entries(value).map(([key, item]) => [
|
|
1431
|
+
key,
|
|
1432
|
+
safeJsonValue(item, label),
|
|
1433
|
+
]),
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
throw new ConfigurationDecodeError(`${label} is not JSON`);
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/** Most setup values one Catalog install may carry. */
|
|
1440
|
+
const MAX_INSTALL_VALUES_V1 = 32;
|
|
1441
|
+
const MAX_INSTALL_VALUES_BYTES_V1 = 16_384;
|
|
1442
|
+
|
|
1443
|
+
/**
|
|
1444
|
+
* The `values` a Catalog install carries. Bounded and JSON-only, because they
|
|
1445
|
+
* become durable User state: the User Durable Object stores them on the
|
|
1446
|
+
* installation, and nothing here may become a prototype or a function.
|
|
1447
|
+
*/
|
|
1448
|
+
function installValues(value: unknown): Record<string, JsonValue> {
|
|
1449
|
+
const values = record(value, "values");
|
|
1450
|
+
const entries = Object.entries(values);
|
|
1451
|
+
if (entries.length > MAX_INSTALL_VALUES_V1) {
|
|
1452
|
+
throw new ConfigurationDecodeError("values is too large");
|
|
1453
|
+
}
|
|
1454
|
+
const decoded = Object.fromEntries(
|
|
1455
|
+
entries.map(([key, item]) => [
|
|
1456
|
+
identifier(key, "values key"),
|
|
1457
|
+
safeJsonValue(item, `values.${key}`),
|
|
1458
|
+
]),
|
|
1459
|
+
);
|
|
1460
|
+
const serialized = JSON.stringify(decoded);
|
|
1461
|
+
if (
|
|
1462
|
+
serialized === undefined ||
|
|
1463
|
+
serialized.length > MAX_INSTALL_VALUES_BYTES_V1
|
|
1464
|
+
) {
|
|
1465
|
+
throw new ConfigurationDecodeError("values is too large");
|
|
1466
|
+
}
|
|
1467
|
+
return decoded;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
/**
|
|
1471
|
+
* The shape of a `user/set-package-settings` payload, before anything knows
|
|
1472
|
+
* which Package it is for.
|
|
1473
|
+
*
|
|
1474
|
+
* Only the shape: ids are identifiers, values are scalars, and the bag is
|
|
1475
|
+
* bounded. Whether a named setting exists, and whether its value satisfies the
|
|
1476
|
+
* schema the Package declared, is the User Durable Object's answer — it is the
|
|
1477
|
+
* authority that holds the installed version.
|
|
1478
|
+
*/
|
|
1479
|
+
function packageSettingsPatch(
|
|
1480
|
+
value: unknown,
|
|
1481
|
+
): Record<string, PackageSettingValueV1> {
|
|
1482
|
+
const values = record(value, "values");
|
|
1483
|
+
const entries = Object.entries(values);
|
|
1484
|
+
if (entries.length === 0) {
|
|
1485
|
+
throw new ConfigurationDecodeError("values names no setting");
|
|
1486
|
+
}
|
|
1487
|
+
if (entries.length > MAX_PACKAGE_SETTINGS_V1) {
|
|
1488
|
+
throw new ConfigurationDecodeError("values is too large");
|
|
1489
|
+
}
|
|
1490
|
+
return Object.fromEntries(
|
|
1491
|
+
entries.map(([key, item]) => {
|
|
1492
|
+
if (
|
|
1493
|
+
typeof item !== "string" &&
|
|
1494
|
+
typeof item !== "number" &&
|
|
1495
|
+
typeof item !== "boolean"
|
|
1496
|
+
) {
|
|
1497
|
+
throw new ConfigurationDecodeError(`values.${key} is invalid`);
|
|
1498
|
+
}
|
|
1499
|
+
if (typeof item === "number" && !Number.isFinite(item)) {
|
|
1500
|
+
throw new ConfigurationDecodeError(`values.${key} is invalid`);
|
|
1501
|
+
}
|
|
1502
|
+
if (
|
|
1503
|
+
typeof item === "string" &&
|
|
1504
|
+
item.length > MAX_PACKAGE_SETTING_TEXT_V1
|
|
1505
|
+
) {
|
|
1506
|
+
throw new ConfigurationDecodeError(`values.${key} is too long`);
|
|
1507
|
+
}
|
|
1508
|
+
return [identifier(key, "values key"), item];
|
|
1509
|
+
}),
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function viewRevision(value: unknown): number {
|
|
1514
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
1515
|
+
throw new ConfigurationDecodeError("configuration revision is invalid");
|
|
1516
|
+
}
|
|
1517
|
+
return value as number;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
function packageInstallation(value: unknown): PackageInstallationView {
|
|
1521
|
+
const installation = exactRecord(
|
|
1522
|
+
value,
|
|
1523
|
+
"Package installation",
|
|
1524
|
+
["packageId", "version", "state"],
|
|
1525
|
+
["failure", "catalogId", "catalogGeneration", "provenance", "values"],
|
|
1526
|
+
);
|
|
1527
|
+
if (
|
|
1528
|
+
installation.state !== "installed" &&
|
|
1529
|
+
installation.state !== "disabled" &&
|
|
1530
|
+
installation.state !== "failed"
|
|
1531
|
+
) {
|
|
1532
|
+
throw new ConfigurationDecodeError("Package installation state is invalid");
|
|
1533
|
+
}
|
|
1534
|
+
if (
|
|
1535
|
+
installation.provenance !== undefined &&
|
|
1536
|
+
installation.provenance !== "first-party" &&
|
|
1537
|
+
installation.provenance !== "catalog"
|
|
1538
|
+
) {
|
|
1539
|
+
throw new ConfigurationDecodeError(
|
|
1540
|
+
"Package installation provenance is invalid",
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
return {
|
|
1544
|
+
packageId: identifier(installation.packageId, "packageId"),
|
|
1545
|
+
version: text(installation.version, "version", 100),
|
|
1546
|
+
state: installation.state,
|
|
1547
|
+
failure: optionalText(installation.failure, "failure", 2_000),
|
|
1548
|
+
...(installation.catalogId === undefined
|
|
1549
|
+
? {}
|
|
1550
|
+
: { catalogId: identifier(installation.catalogId, "catalogId") }),
|
|
1551
|
+
...(installation.catalogGeneration === undefined
|
|
1552
|
+
? {}
|
|
1553
|
+
: {
|
|
1554
|
+
catalogGeneration: identifier(
|
|
1555
|
+
installation.catalogGeneration,
|
|
1556
|
+
"catalogGeneration",
|
|
1557
|
+
),
|
|
1558
|
+
}),
|
|
1559
|
+
...(installation.provenance === undefined
|
|
1560
|
+
? {}
|
|
1561
|
+
: { provenance: installation.provenance }),
|
|
1562
|
+
...(installation.values === undefined
|
|
1563
|
+
? {}
|
|
1564
|
+
: { values: installValues(installation.values) }),
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function connectionView(value: unknown): ConnectionView {
|
|
1569
|
+
const connection = exactRecord(
|
|
1570
|
+
value,
|
|
1571
|
+
"Connection",
|
|
1572
|
+
[
|
|
1573
|
+
"connectionId",
|
|
1574
|
+
"packageId",
|
|
1575
|
+
"connectionTypeId",
|
|
1576
|
+
"displayName",
|
|
1577
|
+
"state",
|
|
1578
|
+
"safeMetadata",
|
|
1579
|
+
],
|
|
1580
|
+
[
|
|
1581
|
+
"failure",
|
|
1582
|
+
"generation",
|
|
1583
|
+
"providerType",
|
|
1584
|
+
"authorization",
|
|
1585
|
+
"modelCatalog",
|
|
1586
|
+
"settings",
|
|
1587
|
+
"pendingAuthorization",
|
|
1588
|
+
],
|
|
1589
|
+
);
|
|
1590
|
+
const states: ConnectionView["state"][] = [
|
|
1591
|
+
"authorizing",
|
|
1592
|
+
"ready",
|
|
1593
|
+
"disabled",
|
|
1594
|
+
"revoking",
|
|
1595
|
+
"revoked",
|
|
1596
|
+
"reconciliation-required",
|
|
1597
|
+
"failed",
|
|
1598
|
+
];
|
|
1599
|
+
if (!states.includes(connection.state as ConnectionView["state"])) {
|
|
1600
|
+
throw new ConfigurationDecodeError("Connection state is invalid");
|
|
1601
|
+
}
|
|
1602
|
+
const safeMetadata = record(connection.safeMetadata, "safeMetadata");
|
|
1603
|
+
const settings =
|
|
1604
|
+
connection.settings === undefined
|
|
1605
|
+
? undefined
|
|
1606
|
+
: record(connection.settings, "settings");
|
|
1607
|
+
return {
|
|
1608
|
+
connectionId: identifier(connection.connectionId, "connectionId"),
|
|
1609
|
+
packageId: identifier(connection.packageId, "packageId"),
|
|
1610
|
+
connectionTypeId: identifier(
|
|
1611
|
+
connection.connectionTypeId,
|
|
1612
|
+
"connectionTypeId",
|
|
1613
|
+
),
|
|
1614
|
+
displayName: text(connection.displayName, "displayName", 200),
|
|
1615
|
+
state: connection.state as ConnectionView["state"],
|
|
1616
|
+
generation: optionalText(connection.generation, "generation", 128),
|
|
1617
|
+
providerType: optionalText(connection.providerType, "providerType", 128),
|
|
1618
|
+
...(connection.authorization === undefined
|
|
1619
|
+
? {}
|
|
1620
|
+
: {
|
|
1621
|
+
authorization: decodeConnectionAuthorizationViewV1(
|
|
1622
|
+
connection.authorization,
|
|
1623
|
+
),
|
|
1624
|
+
}),
|
|
1625
|
+
...(connection.modelCatalog === undefined
|
|
1626
|
+
? {}
|
|
1627
|
+
: {
|
|
1628
|
+
modelCatalog: decodeConnectionModelCatalogV1(connection.modelCatalog),
|
|
1629
|
+
}),
|
|
1630
|
+
...(settings === undefined
|
|
1631
|
+
? {}
|
|
1632
|
+
: {
|
|
1633
|
+
settings: Object.fromEntries(
|
|
1634
|
+
Object.entries(settings).map(([key, item]) => [
|
|
1635
|
+
key,
|
|
1636
|
+
safeJsonValue(item, `settings.${key}`),
|
|
1637
|
+
]),
|
|
1638
|
+
),
|
|
1639
|
+
}),
|
|
1640
|
+
safeMetadata: Object.fromEntries(
|
|
1641
|
+
Object.entries(safeMetadata).map(([key, item]) => [
|
|
1642
|
+
key,
|
|
1643
|
+
safeJsonValue(item, `safeMetadata.${key}`),
|
|
1644
|
+
]),
|
|
1645
|
+
),
|
|
1646
|
+
failure: optionalText(connection.failure, "failure", 2_000),
|
|
1647
|
+
...(connection.pendingAuthorization === undefined
|
|
1648
|
+
? {}
|
|
1649
|
+
: {
|
|
1650
|
+
pendingAuthorization: decodePendingAuthorizationV1(
|
|
1651
|
+
connection.pendingAuthorization,
|
|
1652
|
+
),
|
|
1653
|
+
}),
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/**
|
|
1658
|
+
* The pending decision, decoded strictly — and refused outright if it carries
|
|
1659
|
+
* anything that looks like a redirect. The rule that a Bot never hands its
|
|
1660
|
+
* User a link it authored is worth an assertion rather than a convention.
|
|
1661
|
+
*/
|
|
1662
|
+
export function decodePendingAuthorizationV1(
|
|
1663
|
+
input: unknown,
|
|
1664
|
+
): PendingAuthorizationV1 {
|
|
1665
|
+
const value = exactRecord(input, "pendingAuthorization", [
|
|
1666
|
+
"reason",
|
|
1667
|
+
"since",
|
|
1668
|
+
"connectionId",
|
|
1669
|
+
"label",
|
|
1670
|
+
]);
|
|
1671
|
+
return {
|
|
1672
|
+
reason: text(value.reason, "pendingAuthorization.reason", 64),
|
|
1673
|
+
since: text(value.since, "pendingAuthorization.since", 64),
|
|
1674
|
+
connectionId: identifier(
|
|
1675
|
+
value.connectionId,
|
|
1676
|
+
"pendingAuthorization.connectionId",
|
|
1677
|
+
),
|
|
1678
|
+
label: text(value.label, "pendingAuthorization.label", 200),
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
export function decodeCapabilityAssignmentV1(
|
|
1683
|
+
value: unknown,
|
|
1684
|
+
): CapabilityAssignmentView {
|
|
1685
|
+
const assignment = exactRecord(
|
|
1686
|
+
value,
|
|
1687
|
+
"Capability Assignment",
|
|
1688
|
+
["assignmentId", "packageId", "capabilityId", "state"],
|
|
1689
|
+
["connectionId"],
|
|
1690
|
+
);
|
|
1691
|
+
if (
|
|
1692
|
+
assignment.state !== "enabled" &&
|
|
1693
|
+
assignment.state !== "disabled" &&
|
|
1694
|
+
assignment.state !== "unavailable"
|
|
1695
|
+
) {
|
|
1696
|
+
throw new ConfigurationDecodeError(
|
|
1697
|
+
"Capability Assignment state is invalid",
|
|
1698
|
+
);
|
|
1699
|
+
}
|
|
1700
|
+
return {
|
|
1701
|
+
assignmentId: identifier(assignment.assignmentId, "assignmentId"),
|
|
1702
|
+
packageId: identifier(assignment.packageId, "assignment.packageId"),
|
|
1703
|
+
capabilityId: identifier(
|
|
1704
|
+
assignment.capabilityId,
|
|
1705
|
+
"assignment.capabilityId",
|
|
1706
|
+
),
|
|
1707
|
+
connectionId:
|
|
1708
|
+
assignment.connectionId === undefined
|
|
1709
|
+
? undefined
|
|
1710
|
+
: identifier(assignment.connectionId, "assignment.connectionId"),
|
|
1711
|
+
state: assignment.state,
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
function assignmentTarget(
|
|
1716
|
+
value: unknown,
|
|
1717
|
+
label = "assignment target",
|
|
1718
|
+
): Omit<CapabilityAssignmentView, "state"> {
|
|
1719
|
+
const assignment = exactRecord(
|
|
1720
|
+
value,
|
|
1721
|
+
label,
|
|
1722
|
+
["assignmentId", "packageId", "capabilityId"],
|
|
1723
|
+
["connectionId"],
|
|
1724
|
+
);
|
|
1725
|
+
return {
|
|
1726
|
+
assignmentId: identifier(assignment.assignmentId, `${label}.assignmentId`),
|
|
1727
|
+
packageId: identifier(assignment.packageId, `${label}.packageId`),
|
|
1728
|
+
capabilityId: identifier(assignment.capabilityId, `${label}.capabilityId`),
|
|
1729
|
+
connectionId:
|
|
1730
|
+
assignment.connectionId === undefined
|
|
1731
|
+
? undefined
|
|
1732
|
+
: identifier(assignment.connectionId, `${label}.connectionId`),
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
function assignmentOperation(
|
|
1737
|
+
value: unknown,
|
|
1738
|
+
): CapabilityAssignmentOperationViewV1 {
|
|
1739
|
+
const operation = exactRecord(
|
|
1740
|
+
value,
|
|
1741
|
+
"Assignment operation",
|
|
1742
|
+
["commandId", "kind", "assignmentId", "state"],
|
|
1743
|
+
["target"],
|
|
1744
|
+
);
|
|
1745
|
+
if (
|
|
1746
|
+
operation.kind !== "assigning" &&
|
|
1747
|
+
operation.kind !== "replacing" &&
|
|
1748
|
+
operation.kind !== "unassigning"
|
|
1749
|
+
) {
|
|
1750
|
+
throw new ConfigurationDecodeError("Assignment operation kind is invalid");
|
|
1751
|
+
}
|
|
1752
|
+
if (operation.state !== "pending" && operation.state !== "retrying") {
|
|
1753
|
+
throw new ConfigurationDecodeError("Assignment operation state is invalid");
|
|
1754
|
+
}
|
|
1755
|
+
if (operation.kind !== "unassigning" && operation.target === undefined) {
|
|
1756
|
+
throw new ConfigurationDecodeError(
|
|
1757
|
+
"Assignment operation target is required",
|
|
1758
|
+
);
|
|
1759
|
+
}
|
|
1760
|
+
if (operation.kind === "unassigning" && operation.target !== undefined) {
|
|
1761
|
+
throw new ConfigurationDecodeError(
|
|
1762
|
+
"Unassign operation cannot have a target",
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
return {
|
|
1766
|
+
commandId: identifier(operation.commandId, "operation.commandId"),
|
|
1767
|
+
kind: operation.kind,
|
|
1768
|
+
assignmentId: identifier(operation.assignmentId, "operation.assignmentId"),
|
|
1769
|
+
state: operation.state,
|
|
1770
|
+
target:
|
|
1771
|
+
operation.target === undefined
|
|
1772
|
+
? undefined
|
|
1773
|
+
: assignmentTarget(operation.target),
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
function schemaVersion(value: Record<string, unknown>): void {
|
|
1778
|
+
if (value.schemaVersion !== 1) {
|
|
1779
|
+
throw new ConfigurationDecodeError("unsupported configuration schema");
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
export function decodeUserSettingsViewV1(input: unknown): UserSettingsViewV1 {
|
|
1784
|
+
const value = exactRecord(
|
|
1785
|
+
input,
|
|
1786
|
+
"User settings",
|
|
1787
|
+
["schemaVersion", "revision", "profile", "packages", "connections"],
|
|
1788
|
+
[
|
|
1789
|
+
"newBotModelTemplate",
|
|
1790
|
+
"newBotModelTemplateSource",
|
|
1791
|
+
"catalogGeneration",
|
|
1792
|
+
"catalogIndexHash",
|
|
1793
|
+
],
|
|
1794
|
+
);
|
|
1795
|
+
schemaVersion(value);
|
|
1796
|
+
const profile = exactRecord(value.profile, "profile", ["name"], ["email"]);
|
|
1797
|
+
if (
|
|
1798
|
+
!Array.isArray(value.packages) ||
|
|
1799
|
+
!Array.isArray(value.connections) ||
|
|
1800
|
+
value.connections.length > MAX_USER_CONNECTIONS_V1
|
|
1801
|
+
) {
|
|
1802
|
+
throw new ConfigurationDecodeError(
|
|
1803
|
+
"User settings Packages and Connections must be bounded arrays",
|
|
1804
|
+
);
|
|
1805
|
+
}
|
|
1806
|
+
if (
|
|
1807
|
+
value.newBotModelTemplateSource !== undefined &&
|
|
1808
|
+
value.newBotModelTemplateSource !== "user" &&
|
|
1809
|
+
value.newBotModelTemplateSource !== "auto"
|
|
1810
|
+
) {
|
|
1811
|
+
throw new ConfigurationDecodeError(
|
|
1812
|
+
"new Bot model source must be user or auto",
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
if (
|
|
1816
|
+
value.newBotModelTemplate !== undefined &&
|
|
1817
|
+
value.newBotModelTemplateSource === undefined
|
|
1818
|
+
) {
|
|
1819
|
+
throw new ConfigurationDecodeError(
|
|
1820
|
+
"a new Bot model must record its source",
|
|
1821
|
+
);
|
|
1822
|
+
}
|
|
1823
|
+
if (
|
|
1824
|
+
value.newBotModelTemplate === undefined &&
|
|
1825
|
+
value.newBotModelTemplateSource === "auto"
|
|
1826
|
+
) {
|
|
1827
|
+
throw new ConfigurationDecodeError(
|
|
1828
|
+
"an automatic new Bot model must name a model",
|
|
1829
|
+
);
|
|
1830
|
+
}
|
|
1831
|
+
return {
|
|
1832
|
+
schemaVersion: 1,
|
|
1833
|
+
revision: viewRevision(value.revision),
|
|
1834
|
+
profile: {
|
|
1835
|
+
name: text(profile.name, "profile.name", 100),
|
|
1836
|
+
email: optionalText(profile.email, "profile.email", 320),
|
|
1837
|
+
},
|
|
1838
|
+
packages: value.packages.map(packageInstallation),
|
|
1839
|
+
connections: value.connections.map(connectionView),
|
|
1840
|
+
newBotModelTemplate:
|
|
1841
|
+
value.newBotModelTemplate === undefined
|
|
1842
|
+
? undefined
|
|
1843
|
+
: model(value.newBotModelTemplate),
|
|
1844
|
+
newBotModelTemplateSource: value.newBotModelTemplateSource,
|
|
1845
|
+
// The pin is optional — a deployment with no Catalog has none — but never
|
|
1846
|
+
// half present: one field alone is a corrupt pin, not a pin.
|
|
1847
|
+
...(value.catalogGeneration === undefined &&
|
|
1848
|
+
value.catalogIndexHash === undefined
|
|
1849
|
+
? {}
|
|
1850
|
+
: {
|
|
1851
|
+
catalogGeneration: identifier(
|
|
1852
|
+
value.catalogGeneration,
|
|
1853
|
+
"catalogGeneration",
|
|
1854
|
+
),
|
|
1855
|
+
catalogIndexHash: text(
|
|
1856
|
+
value.catalogIndexHash,
|
|
1857
|
+
"catalogIndexHash",
|
|
1858
|
+
64,
|
|
1859
|
+
),
|
|
1860
|
+
}),
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
export function decodeBotSettingsViewV1(input: unknown): BotSettingsViewV1 {
|
|
1865
|
+
const value = exactRecord(
|
|
1866
|
+
input,
|
|
1867
|
+
"Bot settings",
|
|
1868
|
+
[
|
|
1869
|
+
"schemaVersion",
|
|
1870
|
+
"botId",
|
|
1871
|
+
"revision",
|
|
1872
|
+
"profile",
|
|
1873
|
+
"notifications",
|
|
1874
|
+
"assignments",
|
|
1875
|
+
"assignmentOperations",
|
|
1876
|
+
],
|
|
1877
|
+
["model"],
|
|
1878
|
+
);
|
|
1879
|
+
schemaVersion(value);
|
|
1880
|
+
if (
|
|
1881
|
+
!Array.isArray(value.assignments) ||
|
|
1882
|
+
!Array.isArray(value.assignmentOperations)
|
|
1883
|
+
) {
|
|
1884
|
+
throw new ConfigurationDecodeError(
|
|
1885
|
+
"Bot settings Assignments and operations must be arrays",
|
|
1886
|
+
);
|
|
1887
|
+
}
|
|
1888
|
+
return {
|
|
1889
|
+
schemaVersion: 1,
|
|
1890
|
+
botId: identifier(value.botId, "botId"),
|
|
1891
|
+
revision: viewRevision(value.revision),
|
|
1892
|
+
profile: botProfile(value.profile),
|
|
1893
|
+
notifications: notifications(value.notifications),
|
|
1894
|
+
assignments: value.assignments.map(decodeCapabilityAssignmentV1),
|
|
1895
|
+
assignmentOperations: value.assignmentOperations.map(assignmentOperation),
|
|
1896
|
+
model: value.model === undefined ? undefined : model(value.model),
|
|
1897
|
+
};
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
export function decodeConfigurationViewV1(input: unknown): ConfigurationViewV1 {
|
|
1901
|
+
const value = record(input, "configuration");
|
|
1902
|
+
return "botId" in value
|
|
1903
|
+
? decodeBotSettingsViewV1(value)
|
|
1904
|
+
: decodeUserSettingsViewV1(value);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
export function decodeOperationReceiptV1(input: unknown): OperationReceiptV1 {
|
|
1908
|
+
const candidate = record(input, "operation receipt");
|
|
1909
|
+
if (
|
|
1910
|
+
candidate.status !== "pending" &&
|
|
1911
|
+
candidate.status !== "applied" &&
|
|
1912
|
+
candidate.status !== "rejected"
|
|
1913
|
+
) {
|
|
1914
|
+
throw new ConfigurationDecodeError("operation receipt status is invalid");
|
|
1915
|
+
}
|
|
1916
|
+
const value = exactRecord(
|
|
1917
|
+
input,
|
|
1918
|
+
"operation receipt",
|
|
1919
|
+
candidate.status === "rejected"
|
|
1920
|
+
? ["schemaVersion", "commandId", "revision", "status", "failure"]
|
|
1921
|
+
: ["schemaVersion", "commandId", "revision", "status"],
|
|
1922
|
+
);
|
|
1923
|
+
schemaVersion(value);
|
|
1924
|
+
const receipt = {
|
|
1925
|
+
schemaVersion: 1,
|
|
1926
|
+
commandId: identifier(value.commandId, "commandId"),
|
|
1927
|
+
revision: viewRevision(value.revision),
|
|
1928
|
+
} as const;
|
|
1929
|
+
if (value.status === "rejected") {
|
|
1930
|
+
return {
|
|
1931
|
+
...receipt,
|
|
1932
|
+
status: "rejected",
|
|
1933
|
+
failure: text(value.failure, "operation receipt failure", 1_000),
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
return {
|
|
1937
|
+
...receipt,
|
|
1938
|
+
status: value.status as "pending" | "applied",
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
// ---------------------------------------------------------------------------
|
|
1943
|
+
// Composition generations: the redacted Bot-scoped projection of the durable
|
|
1944
|
+
// `CompositionGenerationV1` records the Bot Durable Object owns. Artifact bytes
|
|
1945
|
+
// never cross this seam; a member carries its content hash, and its recorded
|
|
1946
|
+
// source text only when the Bot object holds an authorship record for it.
|
|
1947
|
+
// ---------------------------------------------------------------------------
|
|
1948
|
+
|
|
1949
|
+
export const MAX_COMPOSITION_GENERATION_PAGE_V1 = 50;
|
|
1950
|
+
export const MAX_COMPOSITION_MEMBERS_V1 = 512;
|
|
1951
|
+
export const MAX_COMPOSITION_MEMBER_SOURCE_V1 = 262_144;
|
|
1952
|
+
|
|
1953
|
+
export type CompositionGenerationStatusViewV1 =
|
|
1954
|
+
"pending" | "active" | "superseded" | "failed" | "quarantined";
|
|
1955
|
+
|
|
1956
|
+
const COMPOSITION_GENERATION_STATUSES_V1: readonly CompositionGenerationStatusViewV1[] =
|
|
1957
|
+
["pending", "active", "superseded", "failed", "quarantined"];
|
|
1958
|
+
|
|
1959
|
+
export type CompositionProvenanceViewV1 =
|
|
1960
|
+
| { kind: "first-party" }
|
|
1961
|
+
| { kind: "user"; userId: string; authoredAt: string }
|
|
1962
|
+
| {
|
|
1963
|
+
kind: "bot";
|
|
1964
|
+
botId: string;
|
|
1965
|
+
sessionId: string;
|
|
1966
|
+
turnId: string;
|
|
1967
|
+
runId: string;
|
|
1968
|
+
authoredAt: string;
|
|
1969
|
+
};
|
|
1970
|
+
|
|
1971
|
+
export type CompositionOriginViewV1 =
|
|
1972
|
+
| { kind: "bootstrap" }
|
|
1973
|
+
| { kind: "bot-authored"; runId: string; sessionId: string; turnId: string }
|
|
1974
|
+
| { kind: "user-install"; userId: string }
|
|
1975
|
+
| { kind: "revert"; revertsTo: string; userId: string };
|
|
1976
|
+
|
|
1977
|
+
export interface CompositionMemberViewV1 {
|
|
1978
|
+
packageId: string;
|
|
1979
|
+
version: string;
|
|
1980
|
+
provenance: CompositionProvenanceViewV1;
|
|
1981
|
+
/** Artifact identity only; the bundled bytes never reach a client. */
|
|
1982
|
+
contentHash?: string;
|
|
1983
|
+
/** Recorded source text for an isolate member, when authorship holds it. */
|
|
1984
|
+
source?: string;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
export type CompositionFailurePhaseViewV1 =
|
|
1988
|
+
"resolve" | "bundle" | "mount" | "health";
|
|
1989
|
+
|
|
1990
|
+
const COMPOSITION_FAILURE_PHASES_V1: readonly CompositionFailurePhaseViewV1[] =
|
|
1991
|
+
["resolve", "bundle", "mount", "health"];
|
|
1992
|
+
|
|
1993
|
+
export const MAX_COMPOSITION_FAILURE_PAGE_V1 = 32;
|
|
1994
|
+
|
|
1995
|
+
/**
|
|
1996
|
+
* One recorded activation failure. Diagnostics stay durable-side: they name
|
|
1997
|
+
* artifact content hashes and loader identities, which never cross this seam.
|
|
1998
|
+
*/
|
|
1999
|
+
export interface CompositionFailureViewV1 {
|
|
2000
|
+
attempt: number;
|
|
2001
|
+
at: string;
|
|
2002
|
+
phase: CompositionFailurePhaseViewV1;
|
|
2003
|
+
message: string;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
export interface CompositionQuarantineViewV1 {
|
|
2007
|
+
quarantinedAt: string;
|
|
2008
|
+
reason: string;
|
|
2009
|
+
failures: number;
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
export interface CompositionGenerationViewV1 {
|
|
2013
|
+
schemaVersion: 1;
|
|
2014
|
+
botId: string;
|
|
2015
|
+
generationId: string;
|
|
2016
|
+
createdAt: string;
|
|
2017
|
+
status: CompositionGenerationStatusViewV1;
|
|
2018
|
+
origin: CompositionOriginViewV1;
|
|
2019
|
+
parentGenerationId?: string;
|
|
2020
|
+
isCurrent: boolean;
|
|
2021
|
+
members: CompositionMemberViewV1[];
|
|
2022
|
+
/** Oldest attempt first; empty for a generation that never failed. */
|
|
2023
|
+
failures: CompositionFailureViewV1[];
|
|
2024
|
+
/** Present once three consecutive failures quarantined this generation. */
|
|
2025
|
+
quarantine?: CompositionQuarantineViewV1;
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
export interface CompositionGenerationListViewV1 {
|
|
2029
|
+
schemaVersion: 1;
|
|
2030
|
+
botId: string;
|
|
2031
|
+
currentGenerationId: string;
|
|
2032
|
+
generations: CompositionGenerationViewV1[];
|
|
2033
|
+
cursor?: string;
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
export interface CompositionMemberVersionV1 {
|
|
2037
|
+
version: string;
|
|
2038
|
+
contentHash?: string;
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
export interface CompositionMemberDiffV1 {
|
|
2042
|
+
packageId: string;
|
|
2043
|
+
change: "added" | "removed" | "changed" | "unchanged";
|
|
2044
|
+
from?: CompositionMemberVersionV1;
|
|
2045
|
+
to?: CompositionMemberVersionV1;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
export interface CompositionDiffV1 {
|
|
2049
|
+
fromGenerationId: string;
|
|
2050
|
+
toGenerationId: string;
|
|
2051
|
+
members: CompositionMemberDiffV1[];
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
export interface RevertCompositionCommandV1 {
|
|
2055
|
+
schemaVersion: 1;
|
|
2056
|
+
type: "composition/revert";
|
|
2057
|
+
commandId: string;
|
|
2058
|
+
botId: string;
|
|
2059
|
+
toGenerationId: string;
|
|
2060
|
+
/** Optimistic check, mirroring `expectedRevision` on configuration commands. */
|
|
2061
|
+
expectedGenerationId: string;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
export type CompositionCommandReceiptV1 =
|
|
2065
|
+
| {
|
|
2066
|
+
schemaVersion: 1;
|
|
2067
|
+
commandId: string;
|
|
2068
|
+
status: "applied";
|
|
2069
|
+
generationId: string;
|
|
2070
|
+
currentGenerationId: string;
|
|
2071
|
+
}
|
|
2072
|
+
| {
|
|
2073
|
+
schemaVersion: 1;
|
|
2074
|
+
commandId: string;
|
|
2075
|
+
status: "rejected";
|
|
2076
|
+
failure: string;
|
|
2077
|
+
currentGenerationId: string;
|
|
2078
|
+
};
|
|
2079
|
+
|
|
2080
|
+
export function decodeCompositionGenerationIdV1(
|
|
2081
|
+
value: unknown,
|
|
2082
|
+
label = "generationId",
|
|
2083
|
+
): string {
|
|
2084
|
+
if (!isRpcIdentifier(value)) {
|
|
2085
|
+
throw new ConfigurationDecodeError(`${label} is invalid`);
|
|
2086
|
+
}
|
|
2087
|
+
return value;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
function compositionTimestamp(value: unknown, label: string): string {
|
|
2091
|
+
const candidate = text(value, label, 64);
|
|
2092
|
+
if (!Number.isFinite(Date.parse(candidate))) {
|
|
2093
|
+
throw new ConfigurationDecodeError(`${label} is invalid`);
|
|
2094
|
+
}
|
|
2095
|
+
return candidate;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
function compositionHash(value: unknown, label: string): string {
|
|
2099
|
+
if (typeof value !== "string" || !/^[0-9a-f]{64}$/.test(value)) {
|
|
2100
|
+
throw new ConfigurationDecodeError(`${label} is invalid`);
|
|
2101
|
+
}
|
|
2102
|
+
return value;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
function compositionProvenanceView(
|
|
2106
|
+
input: unknown,
|
|
2107
|
+
): CompositionProvenanceViewV1 {
|
|
2108
|
+
const kind = record(input, "Composition provenance").kind;
|
|
2109
|
+
if (kind === "first-party") {
|
|
2110
|
+
exactRecord(input, "Composition provenance", ["kind"]);
|
|
2111
|
+
return { kind: "first-party" };
|
|
2112
|
+
}
|
|
2113
|
+
if (kind === "user") {
|
|
2114
|
+
const value = exactRecord(input, "Composition provenance", [
|
|
2115
|
+
"kind",
|
|
2116
|
+
"userId",
|
|
2117
|
+
"authoredAt",
|
|
2118
|
+
]);
|
|
2119
|
+
return {
|
|
2120
|
+
kind: "user",
|
|
2121
|
+
userId: text(value.userId, "Composition provenance userId", 256),
|
|
2122
|
+
authoredAt: compositionTimestamp(
|
|
2123
|
+
value.authoredAt,
|
|
2124
|
+
"Composition provenance authoredAt",
|
|
2125
|
+
),
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
if (kind === "bot") {
|
|
2129
|
+
const value = exactRecord(input, "Composition provenance", [
|
|
2130
|
+
"kind",
|
|
2131
|
+
"botId",
|
|
2132
|
+
"sessionId",
|
|
2133
|
+
"turnId",
|
|
2134
|
+
"runId",
|
|
2135
|
+
"authoredAt",
|
|
2136
|
+
]);
|
|
2137
|
+
return {
|
|
2138
|
+
kind: "bot",
|
|
2139
|
+
botId: decodeBotIdV1(value.botId),
|
|
2140
|
+
sessionId: text(value.sessionId, "Composition provenance sessionId", 257),
|
|
2141
|
+
turnId: text(value.turnId, "Composition provenance turnId", 128),
|
|
2142
|
+
runId: text(value.runId, "Composition provenance runId", 128),
|
|
2143
|
+
authoredAt: compositionTimestamp(
|
|
2144
|
+
value.authoredAt,
|
|
2145
|
+
"Composition provenance authoredAt",
|
|
2146
|
+
),
|
|
2147
|
+
};
|
|
2148
|
+
}
|
|
2149
|
+
throw new ConfigurationDecodeError("Composition provenance kind is invalid");
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
function compositionOriginView(input: unknown): CompositionOriginViewV1 {
|
|
2153
|
+
const kind = record(input, "Composition origin").kind;
|
|
2154
|
+
if (kind === "bootstrap") {
|
|
2155
|
+
exactRecord(input, "Composition origin", ["kind"]);
|
|
2156
|
+
return { kind: "bootstrap" };
|
|
2157
|
+
}
|
|
2158
|
+
if (kind === "bot-authored") {
|
|
2159
|
+
const value = exactRecord(input, "Composition origin", [
|
|
2160
|
+
"kind",
|
|
2161
|
+
"runId",
|
|
2162
|
+
"sessionId",
|
|
2163
|
+
"turnId",
|
|
2164
|
+
]);
|
|
2165
|
+
return {
|
|
2166
|
+
kind: "bot-authored",
|
|
2167
|
+
runId: text(value.runId, "Composition origin runId", 128),
|
|
2168
|
+
sessionId: text(value.sessionId, "Composition origin sessionId", 257),
|
|
2169
|
+
turnId: text(value.turnId, "Composition origin turnId", 128),
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
if (kind === "user-install") {
|
|
2173
|
+
const value = exactRecord(input, "Composition origin", ["kind", "userId"]);
|
|
2174
|
+
return {
|
|
2175
|
+
kind: "user-install",
|
|
2176
|
+
userId: text(value.userId, "Composition origin userId", 256),
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
if (kind === "revert") {
|
|
2180
|
+
const value = exactRecord(input, "Composition origin", [
|
|
2181
|
+
"kind",
|
|
2182
|
+
"revertsTo",
|
|
2183
|
+
"userId",
|
|
2184
|
+
]);
|
|
2185
|
+
return {
|
|
2186
|
+
kind: "revert",
|
|
2187
|
+
revertsTo: decodeCompositionGenerationIdV1(
|
|
2188
|
+
value.revertsTo,
|
|
2189
|
+
"Composition origin revertsTo",
|
|
2190
|
+
),
|
|
2191
|
+
userId: text(value.userId, "Composition origin userId", 256),
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
throw new ConfigurationDecodeError("Composition origin kind is invalid");
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
function compositionMemberView(input: unknown): CompositionMemberViewV1 {
|
|
2198
|
+
const value = exactRecord(
|
|
2199
|
+
input,
|
|
2200
|
+
"Composition member",
|
|
2201
|
+
["packageId", "version", "provenance"],
|
|
2202
|
+
["contentHash", "source"],
|
|
2203
|
+
);
|
|
2204
|
+
if (
|
|
2205
|
+
value.source !== undefined &&
|
|
2206
|
+
(typeof value.source !== "string" ||
|
|
2207
|
+
value.source.length === 0 ||
|
|
2208
|
+
value.source.length > MAX_COMPOSITION_MEMBER_SOURCE_V1)
|
|
2209
|
+
) {
|
|
2210
|
+
throw new ConfigurationDecodeError("Composition member source is invalid");
|
|
2211
|
+
}
|
|
2212
|
+
return {
|
|
2213
|
+
packageId: identifier(value.packageId, "Composition member packageId"),
|
|
2214
|
+
version: text(value.version, "Composition member version", 64),
|
|
2215
|
+
provenance: compositionProvenanceView(value.provenance),
|
|
2216
|
+
...(value.contentHash === undefined
|
|
2217
|
+
? {}
|
|
2218
|
+
: {
|
|
2219
|
+
contentHash: compositionHash(
|
|
2220
|
+
value.contentHash,
|
|
2221
|
+
"Composition member contentHash",
|
|
2222
|
+
),
|
|
2223
|
+
}),
|
|
2224
|
+
...(value.source === undefined ? {} : { source: value.source as string }),
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
function compositionFailureView(input: unknown): CompositionFailureViewV1 {
|
|
2229
|
+
const value = exactRecord(input, "Composition failure", [
|
|
2230
|
+
"attempt",
|
|
2231
|
+
"at",
|
|
2232
|
+
"phase",
|
|
2233
|
+
"message",
|
|
2234
|
+
]);
|
|
2235
|
+
if (
|
|
2236
|
+
!Number.isSafeInteger(value.attempt) ||
|
|
2237
|
+
(value.attempt as number) < 1 ||
|
|
2238
|
+
(value.attempt as number) > 1_000
|
|
2239
|
+
) {
|
|
2240
|
+
throw new ConfigurationDecodeError(
|
|
2241
|
+
"Composition failure attempt is invalid",
|
|
2242
|
+
);
|
|
2243
|
+
}
|
|
2244
|
+
if (
|
|
2245
|
+
!COMPOSITION_FAILURE_PHASES_V1.includes(
|
|
2246
|
+
value.phase as CompositionFailurePhaseViewV1,
|
|
2247
|
+
)
|
|
2248
|
+
) {
|
|
2249
|
+
throw new ConfigurationDecodeError("Composition failure phase is invalid");
|
|
2250
|
+
}
|
|
2251
|
+
return {
|
|
2252
|
+
attempt: value.attempt as number,
|
|
2253
|
+
at: compositionTimestamp(value.at, "Composition failure at"),
|
|
2254
|
+
phase: value.phase as CompositionFailurePhaseViewV1,
|
|
2255
|
+
message: text(value.message, "Composition failure message", 2_000),
|
|
2256
|
+
};
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
function compositionQuarantineView(
|
|
2260
|
+
input: unknown,
|
|
2261
|
+
): CompositionQuarantineViewV1 {
|
|
2262
|
+
const value = exactRecord(input, "Composition quarantine", [
|
|
2263
|
+
"quarantinedAt",
|
|
2264
|
+
"reason",
|
|
2265
|
+
"failures",
|
|
2266
|
+
]);
|
|
2267
|
+
if (
|
|
2268
|
+
!Number.isSafeInteger(value.failures) ||
|
|
2269
|
+
(value.failures as number) < 1 ||
|
|
2270
|
+
(value.failures as number) > 1_000
|
|
2271
|
+
) {
|
|
2272
|
+
throw new ConfigurationDecodeError(
|
|
2273
|
+
"Composition quarantine failures is invalid",
|
|
2274
|
+
);
|
|
2275
|
+
}
|
|
2276
|
+
return {
|
|
2277
|
+
quarantinedAt: compositionTimestamp(
|
|
2278
|
+
value.quarantinedAt,
|
|
2279
|
+
"Composition quarantine quarantinedAt",
|
|
2280
|
+
),
|
|
2281
|
+
reason: text(value.reason, "Composition quarantine reason", 2_000),
|
|
2282
|
+
failures: value.failures as number,
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
export function decodeCompositionGenerationViewV1(
|
|
2287
|
+
input: unknown,
|
|
2288
|
+
): CompositionGenerationViewV1 {
|
|
2289
|
+
const value = exactRecord(
|
|
2290
|
+
input,
|
|
2291
|
+
"Composition generation",
|
|
2292
|
+
[
|
|
2293
|
+
"schemaVersion",
|
|
2294
|
+
"botId",
|
|
2295
|
+
"generationId",
|
|
2296
|
+
"createdAt",
|
|
2297
|
+
"status",
|
|
2298
|
+
"origin",
|
|
2299
|
+
"isCurrent",
|
|
2300
|
+
"members",
|
|
2301
|
+
"failures",
|
|
2302
|
+
],
|
|
2303
|
+
["parentGenerationId", "quarantine"],
|
|
2304
|
+
);
|
|
2305
|
+
if (
|
|
2306
|
+
!Array.isArray(value.failures) ||
|
|
2307
|
+
value.failures.length > MAX_COMPOSITION_FAILURE_PAGE_V1
|
|
2308
|
+
) {
|
|
2309
|
+
throw new ConfigurationDecodeError(
|
|
2310
|
+
"Composition generation failures are invalid",
|
|
2311
|
+
);
|
|
2312
|
+
}
|
|
2313
|
+
schemaVersion(value);
|
|
2314
|
+
if (
|
|
2315
|
+
!COMPOSITION_GENERATION_STATUSES_V1.includes(
|
|
2316
|
+
value.status as CompositionGenerationStatusViewV1,
|
|
2317
|
+
)
|
|
2318
|
+
) {
|
|
2319
|
+
throw new ConfigurationDecodeError(
|
|
2320
|
+
"Composition generation status is invalid",
|
|
2321
|
+
);
|
|
2322
|
+
}
|
|
2323
|
+
if (typeof value.isCurrent !== "boolean") {
|
|
2324
|
+
throw new ConfigurationDecodeError(
|
|
2325
|
+
"Composition generation isCurrent is invalid",
|
|
2326
|
+
);
|
|
2327
|
+
}
|
|
2328
|
+
if (
|
|
2329
|
+
!Array.isArray(value.members) ||
|
|
2330
|
+
value.members.length > MAX_COMPOSITION_MEMBERS_V1
|
|
2331
|
+
) {
|
|
2332
|
+
throw new ConfigurationDecodeError(
|
|
2333
|
+
"Composition generation members are invalid",
|
|
2334
|
+
);
|
|
2335
|
+
}
|
|
2336
|
+
const members = value.members.map(compositionMemberView);
|
|
2337
|
+
if (
|
|
2338
|
+
new Set(members.map((member) => member.packageId)).size !== members.length
|
|
2339
|
+
)
|
|
2340
|
+
throw new ConfigurationDecodeError(
|
|
2341
|
+
"Composition generation members are invalid",
|
|
2342
|
+
);
|
|
2343
|
+
return {
|
|
2344
|
+
schemaVersion: 1,
|
|
2345
|
+
botId: decodeBotIdV1(value.botId),
|
|
2346
|
+
generationId: decodeCompositionGenerationIdV1(value.generationId),
|
|
2347
|
+
createdAt: compositionTimestamp(
|
|
2348
|
+
value.createdAt,
|
|
2349
|
+
"Composition generation createdAt",
|
|
2350
|
+
),
|
|
2351
|
+
status: value.status as CompositionGenerationStatusViewV1,
|
|
2352
|
+
origin: compositionOriginView(value.origin),
|
|
2353
|
+
isCurrent: value.isCurrent,
|
|
2354
|
+
members,
|
|
2355
|
+
failures: value.failures.map(compositionFailureView),
|
|
2356
|
+
...(value.quarantine === undefined
|
|
2357
|
+
? {}
|
|
2358
|
+
: { quarantine: compositionQuarantineView(value.quarantine) }),
|
|
2359
|
+
...(value.parentGenerationId === undefined
|
|
2360
|
+
? {}
|
|
2361
|
+
: {
|
|
2362
|
+
parentGenerationId: decodeCompositionGenerationIdV1(
|
|
2363
|
+
value.parentGenerationId,
|
|
2364
|
+
"parentGenerationId",
|
|
2365
|
+
),
|
|
2366
|
+
}),
|
|
2367
|
+
};
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
export function decodeCompositionGenerationListViewV1(
|
|
2371
|
+
input: unknown,
|
|
2372
|
+
): CompositionGenerationListViewV1 {
|
|
2373
|
+
const value = exactRecord(
|
|
2374
|
+
input,
|
|
2375
|
+
"Composition generation list",
|
|
2376
|
+
["schemaVersion", "botId", "currentGenerationId", "generations"],
|
|
2377
|
+
["cursor"],
|
|
2378
|
+
);
|
|
2379
|
+
schemaVersion(value);
|
|
2380
|
+
if (
|
|
2381
|
+
!Array.isArray(value.generations) ||
|
|
2382
|
+
value.generations.length > MAX_COMPOSITION_GENERATION_PAGE_V1
|
|
2383
|
+
) {
|
|
2384
|
+
throw new ConfigurationDecodeError(
|
|
2385
|
+
"Composition generation list is invalid",
|
|
2386
|
+
);
|
|
2387
|
+
}
|
|
2388
|
+
const botId = decodeBotIdV1(value.botId);
|
|
2389
|
+
const generations = value.generations.map(decodeCompositionGenerationViewV1);
|
|
2390
|
+
if (generations.some((generation) => generation.botId !== botId)) {
|
|
2391
|
+
throw new ConfigurationDecodeError(
|
|
2392
|
+
"Composition generation list is invalid",
|
|
2393
|
+
);
|
|
2394
|
+
}
|
|
2395
|
+
return {
|
|
2396
|
+
schemaVersion: 1,
|
|
2397
|
+
botId,
|
|
2398
|
+
currentGenerationId: decodeCompositionGenerationIdV1(
|
|
2399
|
+
value.currentGenerationId,
|
|
2400
|
+
"currentGenerationId",
|
|
2401
|
+
),
|
|
2402
|
+
generations,
|
|
2403
|
+
...(value.cursor === undefined
|
|
2404
|
+
? {}
|
|
2405
|
+
: { cursor: text(value.cursor, "cursor", 512) }),
|
|
2406
|
+
};
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
export function decodeRevertCompositionCommandV1(
|
|
2410
|
+
input: unknown,
|
|
2411
|
+
): RevertCompositionCommandV1 {
|
|
2412
|
+
const value = exactRecord(input, "Composition revert command", [
|
|
2413
|
+
"schemaVersion",
|
|
2414
|
+
"type",
|
|
2415
|
+
"commandId",
|
|
2416
|
+
"botId",
|
|
2417
|
+
"toGenerationId",
|
|
2418
|
+
"expectedGenerationId",
|
|
2419
|
+
]);
|
|
2420
|
+
schemaVersion(value);
|
|
2421
|
+
if (value.type !== "composition/revert") {
|
|
2422
|
+
throw new ConfigurationDecodeError(
|
|
2423
|
+
"unsupported Composition revert command",
|
|
2424
|
+
);
|
|
2425
|
+
}
|
|
2426
|
+
const toGenerationId = decodeCompositionGenerationIdV1(
|
|
2427
|
+
value.toGenerationId,
|
|
2428
|
+
"toGenerationId",
|
|
2429
|
+
);
|
|
2430
|
+
const expectedGenerationId = decodeCompositionGenerationIdV1(
|
|
2431
|
+
value.expectedGenerationId,
|
|
2432
|
+
"expectedGenerationId",
|
|
2433
|
+
);
|
|
2434
|
+
if (toGenerationId === expectedGenerationId) {
|
|
2435
|
+
throw new ConfigurationDecodeError(
|
|
2436
|
+
"Composition revert command targets the current generation",
|
|
2437
|
+
);
|
|
2438
|
+
}
|
|
2439
|
+
return {
|
|
2440
|
+
schemaVersion: 1,
|
|
2441
|
+
type: "composition/revert",
|
|
2442
|
+
commandId: connectionIdentifier(value.commandId, "commandId"),
|
|
2443
|
+
botId: decodeBotIdV1(value.botId),
|
|
2444
|
+
toGenerationId,
|
|
2445
|
+
expectedGenerationId,
|
|
2446
|
+
};
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
export function decodeCompositionCommandReceiptV1(
|
|
2450
|
+
input: unknown,
|
|
2451
|
+
): CompositionCommandReceiptV1 {
|
|
2452
|
+
const candidate = record(input, "Composition command receipt");
|
|
2453
|
+
if (candidate.status !== "applied" && candidate.status !== "rejected") {
|
|
2454
|
+
throw new ConfigurationDecodeError(
|
|
2455
|
+
"Composition command receipt status is invalid",
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
const value = exactRecord(
|
|
2459
|
+
input,
|
|
2460
|
+
"Composition command receipt",
|
|
2461
|
+
candidate.status === "applied"
|
|
2462
|
+
? [
|
|
2463
|
+
"schemaVersion",
|
|
2464
|
+
"commandId",
|
|
2465
|
+
"status",
|
|
2466
|
+
"generationId",
|
|
2467
|
+
"currentGenerationId",
|
|
2468
|
+
]
|
|
2469
|
+
: [
|
|
2470
|
+
"schemaVersion",
|
|
2471
|
+
"commandId",
|
|
2472
|
+
"status",
|
|
2473
|
+
"failure",
|
|
2474
|
+
"currentGenerationId",
|
|
2475
|
+
],
|
|
2476
|
+
);
|
|
2477
|
+
schemaVersion(value);
|
|
2478
|
+
const shared = {
|
|
2479
|
+
schemaVersion: 1,
|
|
2480
|
+
commandId: connectionIdentifier(value.commandId, "commandId"),
|
|
2481
|
+
currentGenerationId: decodeCompositionGenerationIdV1(
|
|
2482
|
+
value.currentGenerationId,
|
|
2483
|
+
"currentGenerationId",
|
|
2484
|
+
),
|
|
2485
|
+
} as const;
|
|
2486
|
+
if (value.status === "rejected") {
|
|
2487
|
+
return {
|
|
2488
|
+
...shared,
|
|
2489
|
+
status: "rejected",
|
|
2490
|
+
failure: text(value.failure, "Composition command failure", 1_000),
|
|
2491
|
+
};
|
|
2492
|
+
}
|
|
2493
|
+
return {
|
|
2494
|
+
...shared,
|
|
2495
|
+
status: "applied",
|
|
2496
|
+
generationId: decodeCompositionGenerationIdV1(value.generationId),
|
|
2497
|
+
};
|
|
2498
|
+
}
|