@frockbot/plugin-shell 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/package.json +32 -29
  2. package/src/backend-applets.test.ts +581 -0
  3. package/src/backend-applets.ts +959 -0
  4. package/src/backend-authoring.test.ts +61 -19
  5. package/src/backend-authoring.ts +52 -26
  6. package/src/backend-composition.ts +64 -0
  7. package/src/backend-computer.test.ts +128 -0
  8. package/src/backend-computer.ts +81 -0
  9. package/src/backend-configuration.test.ts +8 -1
  10. package/src/backend-iframe-ui.test.ts +29 -12
  11. package/src/backend-isolate.ts +31 -5
  12. package/src/backend-package-catalog.test.ts +13 -8
  13. package/src/backend-package-catalog.ts +8 -6
  14. package/src/backend-recovery-integration.test.ts +23 -0
  15. package/src/backend.ts +508 -5
  16. package/src/client/AppletCanvas.vue +679 -0
  17. package/src/client/FrockBotApp.vue +169 -15
  18. package/src/client/PackageEntryTrigger.vue +77 -0
  19. package/src/client/PackageIframeHost.vue +148 -47
  20. package/src/client/PackageIframeSettings.vue +8 -6
  21. package/src/client/PackageSurfacePage.vue +39 -0
  22. package/src/client/applets-client.test.ts +204 -0
  23. package/src/client/applets-client.ts +139 -0
  24. package/src/client/applets-state.ts +64 -0
  25. package/src/client/index.test.ts +13 -7
  26. package/src/client/index.ts +308 -1
  27. package/src/client/package-iframe-entries.test.ts +122 -0
  28. package/src/client/package-iframe-entries.ts +112 -0
  29. package/src/client/package-iframe-host-message.test.ts +3 -3
  30. package/src/client/package-iframe-host-message.ts +3 -3
  31. package/src/client/styles.css +107 -1
  32. package/src/composition-views.ts +31 -6
  33. package/src/shared.ts +52 -0
@@ -0,0 +1,959 @@
1
+ // The Bot Durable Object's half of Applets: `ctx.applets`, and the resolution
2
+ // of Applet members into a Bot's Composition.
3
+ //
4
+ // Two things live here and nothing else does. The **capability** a Bot isolate
5
+ // calls — list, create, publish, revert, delete, focus, generations — and the
6
+ // **resolution** that turns the User's Applet directory into the `applet`
7
+ // members of the Bot's next Composition generation. Both are Bot-scoped
8
+ // because they run as one Bot, with exactly that Bot's authority; the directory
9
+ // they read and write is the User's, and the instance they mount is the
10
+ // kernel's Applet Durable Object.
11
+ //
12
+ // `publish` is a durable effect, and it is written in the order the
13
+ // constitution's rule requires: record intent, then read, then verify, then the
14
+ // immutable artifact, then the durable records, then the mount, then the
15
+ // Composition proposal. A crash anywhere resumes from the recorded intent
16
+ // rather than repeating a side effect.
17
+ import {
18
+ appletGenerationIdV1,
19
+ APPLETS_PACKAGE_ID_V1,
20
+ APPLETS_SOURCE_ROOT_ID_V1,
21
+ APPLET_CONTRACT_V1,
22
+ APPLET_FOCUSED_KEY,
23
+ decodeFocusedAppletV1,
24
+ type FocusedAppletV1,
25
+ } from "@frockbot/kernel-do";
26
+ import {
27
+ decodeAppletGenerationV1,
28
+ decodeAppletSummaryV1,
29
+ decodeAppletToolDeclarationV1,
30
+ type AppletGenerationSummaryV1,
31
+ type AppletGenerationV1,
32
+ type AppletProvenanceV1,
33
+ type AppletPublishResultV1,
34
+ type AppletSummaryV1,
35
+ type AppletToolDeclarationV1,
36
+ } from "@frockbot/kernel-contracts";
37
+ import type {
38
+ WorkspacePathV1,
39
+ WorkspaceReadsV1,
40
+ } from "@frockbot/kernel-contracts";
41
+ import {
42
+ compositionArtifactSetHashV1,
43
+ compositionGenerationIdV1,
44
+ decodeCompositionGenerationV1,
45
+ type CompositionAppletMemberV1,
46
+ type CompositionJsonValueV1,
47
+ type CompositionGenerationV1,
48
+ type CompositionStore,
49
+ type PackageProvenanceV1,
50
+ } from "@frockbot/kernel-composition/generation";
51
+
52
+ /** The durable key one publish intent is recorded under, by effect id. */
53
+ export const APPLET_PUBLISH_EFFECT_PREFIX = "applets:publish-effect:";
54
+ /**
55
+ * The directory revision the Bot's current Composition generation resolved
56
+ * Applet members at. A revision that no longer matches the User's is the whole
57
+ * of the fan-out signal.
58
+ */
59
+ export const APPLET_DIRECTORY_REVISION_SEEN_KEY = "applets:directory-revision";
60
+
61
+ /** The three files `applet build` writes, read from the durable root. */
62
+ export const APPLET_DIST_FILES_V1 = [
63
+ "dist/server.js",
64
+ "dist/ui.html",
65
+ "dist/manifest.json",
66
+ ] as const;
67
+
68
+ /**
69
+ * Ceilings on what a publish will read and store.
70
+ *
71
+ * The UI bound is the Applet one, not the Package-page one: a Package page is
72
+ * hand-written inline HTML and 256 KB is generous, while an Applet's page is
73
+ * `applet build`'s single self-contained file carrying React, TanStack DB, and
74
+ * the kit — roughly half a megabyte before the Applet's own code.
75
+ */
76
+ export const APPLET_MAX_SERVER_BYTES_V1 = 2 * 1024 * 1024;
77
+ export const APPLET_MAX_UI_BYTES_V1 = 4 * 1024 * 1024;
78
+ export const APPLET_MAX_MANIFEST_BYTES_V1 = 64 * 1024;
79
+
80
+ export interface AppletPublishIntentV1 {
81
+ schemaVersion: 1;
82
+ effectId: string;
83
+ appletId: string;
84
+ botId: string;
85
+ sessionId: string;
86
+ turnId: string;
87
+ runId: string;
88
+ recordedAt: string;
89
+ /** Set once the effect settled, so a retry answers rather than repeats. */
90
+ outcome?: AppletPublishResultV1;
91
+ }
92
+
93
+ /** The User Durable Object's Applet directory, as this Bot reads and writes it. */
94
+ export interface AppletUserDirectoryV1 {
95
+ list(): Promise<{ revision: number; applets: AppletSummaryV1[] }>;
96
+ compositionInput(): Promise<{
97
+ revision: number;
98
+ applets: {
99
+ appletId: string;
100
+ generationId: string;
101
+ tools: AppletToolDeclarationV1[];
102
+ provenance: AppletProvenanceV1;
103
+ }[];
104
+ }>;
105
+ create(input: {
106
+ displayName: string;
107
+ provenance: AppletProvenanceV1;
108
+ }): Promise<AppletSummaryV1>;
109
+ recordGeneration(input: {
110
+ appletId: string;
111
+ generationId: string;
112
+ tools: AppletToolDeclarationV1[];
113
+ }): Promise<AppletSummaryV1>;
114
+ delete(appletId: string): Promise<AppletSummaryV1>;
115
+ }
116
+
117
+ /** One Applet instance's Durable Object, as this Bot calls it. */
118
+ export interface AppletInstanceBindingV1 {
119
+ publish(input: { appletId: string; generation: AppletGenerationV1 }): Promise<
120
+ | { status: "active"; generationId: string; tools: string[] }
121
+ | {
122
+ status: "failed";
123
+ generationId: string;
124
+ reason: string;
125
+ diagnostics: string[];
126
+ }
127
+ >;
128
+ revert(input: { appletId: string; generation: AppletGenerationV1 }): Promise<
129
+ | { status: "active"; generationId: string; tools: string[] }
130
+ | {
131
+ status: "failed";
132
+ generationId: string;
133
+ reason: string;
134
+ diagnostics: string[];
135
+ }
136
+ >;
137
+ invokeTool(input: {
138
+ appletId: string;
139
+ tool: string;
140
+ input: unknown;
141
+ }): Promise<{ status: "ok" | "error"; content: string }>;
142
+ read(input: { appletId: string }): Promise<{
143
+ current?: { generationId: string };
144
+ generations: AppletGenerationV1[];
145
+ }>;
146
+ }
147
+
148
+ /** The `APPLET_STATES` binding, as this Package needs to see it. */
149
+ export type AppletInstanceNamespaceV1 = DurableObjectNamespace;
150
+
151
+ /** The Applet Durable Object's RPC surface, addressed by name. */
152
+ interface AppletInstanceRpcV1 {
153
+ publish(input: unknown): Promise<unknown>;
154
+ revert(input: unknown): Promise<unknown>;
155
+ invokeTool(input: unknown): Promise<unknown>;
156
+ read(input: unknown): Promise<unknown>;
157
+ }
158
+
159
+ function appletInstanceRpc(
160
+ namespace: AppletInstanceNamespaceV1,
161
+ userId: string,
162
+ appletId: string,
163
+ ): AppletInstanceRpcV1 {
164
+ const name = `${userId}:${appletId}`;
165
+ // SAFETY: this namespace is bound to the kernel's AppletState class;
166
+ // generated Worker types do not expose its RPC surface.
167
+ return namespace.get(
168
+ namespace.idFromName(name),
169
+ ) as unknown as AppletInstanceRpcV1;
170
+ }
171
+
172
+ function decodeActivation(
173
+ value: unknown,
174
+ label: string,
175
+ ):
176
+ | { status: "active"; generationId: string; tools: string[] }
177
+ | {
178
+ status: "failed";
179
+ generationId: string;
180
+ reason: string;
181
+ diagnostics: string[];
182
+ } {
183
+ const snapshot = JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
184
+ if (snapshot?.status === "active") {
185
+ return {
186
+ status: "active",
187
+ generationId: String(snapshot.generationId),
188
+ tools: Array.isArray(snapshot.tools) ? snapshot.tools.map(String) : [],
189
+ };
190
+ }
191
+ if (snapshot?.status === "failed") {
192
+ return {
193
+ status: "failed",
194
+ generationId: String(snapshot.generationId),
195
+ reason: String(snapshot.reason),
196
+ diagnostics: Array.isArray(snapshot.diagnostics)
197
+ ? snapshot.diagnostics.map(String)
198
+ : [],
199
+ };
200
+ }
201
+ throw new Error(`${label} is invalid`);
202
+ }
203
+
204
+ /**
205
+ * One Applet instance over the `APPLET_STATES` namespace. Every answer is
206
+ * snapshotted and decoded on arrival: a Durable Object answer is a live stub
207
+ * until it is, and the exact-keys decoders are right to refuse one.
208
+ */
209
+ export function createAppletInstanceBindingV1(
210
+ namespace: AppletInstanceNamespaceV1,
211
+ userId: string,
212
+ ): (appletId: string) => AppletInstanceBindingV1 {
213
+ return (appletId) => {
214
+ const rpc = appletInstanceRpc(namespace, userId, appletId);
215
+ const envelope = (extra: Record<string, unknown> = {}) => ({
216
+ schemaVersion: 1 as const,
217
+ userId,
218
+ appletId,
219
+ ...extra,
220
+ });
221
+ return {
222
+ async publish(input) {
223
+ return decodeActivation(
224
+ await rpc.publish(envelope({ generation: input.generation })),
225
+ "Applet publish outcome",
226
+ );
227
+ },
228
+ async revert(input) {
229
+ return decodeActivation(
230
+ await rpc.revert(envelope({ generation: input.generation })),
231
+ "Applet revert outcome",
232
+ );
233
+ },
234
+ async invokeTool(input) {
235
+ const answer = JSON.parse(
236
+ JSON.stringify(
237
+ await rpc.invokeTool(
238
+ envelope({ tool: input.tool, toolInput: input.input ?? null }),
239
+ ),
240
+ ),
241
+ ) as { status?: unknown; content?: unknown };
242
+ return {
243
+ status: answer.status === "ok" ? "ok" : "error",
244
+ content: typeof answer.content === "string" ? answer.content : "",
245
+ };
246
+ },
247
+ async read() {
248
+ const answer = JSON.parse(
249
+ JSON.stringify(await rpc.read(envelope())),
250
+ ) as { current?: { generationId?: unknown }; generations?: unknown };
251
+ return {
252
+ ...(answer.current?.generationId
253
+ ? { current: { generationId: String(answer.current.generationId) } }
254
+ : {}),
255
+ generations: Array.isArray(answer.generations)
256
+ ? answer.generations.map((generation) =>
257
+ decodeAppletGenerationV1(generation),
258
+ )
259
+ : [],
260
+ };
261
+ },
262
+ };
263
+ };
264
+ }
265
+
266
+ /** The immutable artifact store, as a publish writes it. */
267
+ export interface AppletArtifactSinkV1 {
268
+ putPackageArtifact(contentHash: string, module: string): Promise<void>;
269
+ putPackageUiArtifact(contentHash: string, html: string): Promise<void>;
270
+ }
271
+
272
+ export interface AppletCapabilityStorageV1 {
273
+ get<T>(key: string): Promise<T | undefined>;
274
+ put(entries: Record<string, unknown>): Promise<void>;
275
+ }
276
+
277
+ export interface AppletCapabilityHostOptionsV1 {
278
+ userId: string;
279
+ botId: string;
280
+ storage: AppletCapabilityStorageV1;
281
+ directory: AppletUserDirectoryV1;
282
+ instanceFor(appletId: string): AppletInstanceBindingV1;
283
+ artifacts: AppletArtifactSinkV1;
284
+ /** Reads the built Applet under the Applets Package's declared root. */
285
+ workspace: WorkspaceReadsV1;
286
+ /**
287
+ * Forces a pull of the `applets/source` root before it is read, so a publish
288
+ * sees what the Bot just wrote on the Computer rather than the last synced
289
+ * copy.
290
+ *
291
+ * The Bot Durable Object supplies it over the Computer Package's
292
+ * `syncWorkspaceRootNowV1` when the User has a Computer. Absent means the
293
+ * store is read as it stands — correct but possibly stale — and the Bot is
294
+ * told so in the failure when the files are missing, rather than the publish
295
+ * silently using old bytes.
296
+ */
297
+ syncSourceRootNow?(): Promise<void>;
298
+ composition: Pick<CompositionStore, "current" | "lastKnownGood" | "propose">;
299
+ now?(): Date;
300
+ }
301
+
302
+ export interface AppletCapabilityCallScopeV1 {
303
+ sessionId: string;
304
+ runId: string;
305
+ turnId: string;
306
+ effectId: string;
307
+ }
308
+
309
+ /** `ctx.applets`, as the Bot Durable Object implements it. */
310
+ export interface AppletCapabilityHostV1 {
311
+ list(): Promise<AppletSummaryV1[]>;
312
+ create(
313
+ input: { displayName: string },
314
+ scope: AppletCapabilityCallScopeV1,
315
+ ): Promise<AppletSummaryV1>;
316
+ publish(
317
+ input: { appletId: string },
318
+ scope: AppletCapabilityCallScopeV1,
319
+ ): Promise<AppletPublishResultV1>;
320
+ revert(
321
+ input: { appletId: string; generationId: string },
322
+ scope: AppletCapabilityCallScopeV1,
323
+ ): Promise<AppletPublishResultV1>;
324
+ delete(input: { appletId: string }): Promise<{ status: "deleted" }>;
325
+ focus(input: { appletId: string | null }): Promise<FocusedAppletV1>;
326
+ generations(input: {
327
+ appletId: string;
328
+ }): Promise<AppletGenerationSummaryV1[]>;
329
+ /** What the shell reads for the canvas, and what a route projects. */
330
+ readFocused(): Promise<FocusedAppletV1>;
331
+ }
332
+
333
+ const TEXT = new TextDecoder();
334
+
335
+ /**
336
+ * The plain JSON a cross-object RPC answer really is.
337
+ *
338
+ * A Durable Object answer arrives as a live stub carrying `Symbol.dispose` and
339
+ * whatever else the runtime attached, and an exact-keys decoder is right to
340
+ * refuse that. Snapshotting first is what turns the answer into the DTO it
341
+ * claims to be.
342
+ */
343
+ export function appletRpcSnapshotV1<T>(value: T): T {
344
+ const serialized = JSON.stringify(value);
345
+ if (serialized === undefined) {
346
+ throw new Error("Applet RPC response is not a JSON value");
347
+ }
348
+ return JSON.parse(serialized) as T;
349
+ }
350
+
351
+ async function sha256Hex(value: string): Promise<string> {
352
+ const digest = await crypto.subtle.digest(
353
+ "SHA-256",
354
+ new TextEncoder().encode(value),
355
+ );
356
+ return [...new Uint8Array(digest)]
357
+ .map((byte) => byte.toString(16).padStart(2, "0"))
358
+ .join("");
359
+ }
360
+
361
+ /** The declared-root path one of an Applet's built files lives at. */
362
+ export function appletDistPathV1(
363
+ userId: string,
364
+ appletId: string,
365
+ file: string,
366
+ ): WorkspacePathV1 {
367
+ return {
368
+ root: {
369
+ kind: "package-declared",
370
+ userId,
371
+ packageId: APPLETS_PACKAGE_ID_V1,
372
+ rootId: APPLETS_SOURCE_ROOT_ID_V1,
373
+ },
374
+ path: `${appletId}/${file}`,
375
+ };
376
+ }
377
+
378
+ /**
379
+ * `dist/manifest.json` as `applet build` writes it. Verified against the bytes
380
+ * actually read, so a manifest cannot name code it does not describe.
381
+ */
382
+ export interface AppletBuildManifestV1 {
383
+ contract: 1;
384
+ tools: AppletToolDeclarationV1[];
385
+ hashes: { server: string; ui: string };
386
+ }
387
+
388
+ export function decodeAppletBuildManifestV1(
389
+ input: unknown,
390
+ label = "Applet build manifest",
391
+ ): AppletBuildManifestV1 {
392
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
393
+ throw new Error(`${label} must be an object`);
394
+ }
395
+ const value = input as Record<string, unknown>;
396
+ const keys = ["contract", "tools", "hashes"] as const;
397
+ const allowed = new Set<string>(keys);
398
+ if (
399
+ !Object.keys(value).every((key) => allowed.has(key)) ||
400
+ !keys.every((key) => Object.hasOwn(value, key))
401
+ ) {
402
+ throw new Error(`${label} has invalid fields`);
403
+ }
404
+ if (value.contract !== 1) throw new Error(`${label}.contract is unsupported`);
405
+ if (!Array.isArray(value.tools) || value.tools.length > 64) {
406
+ throw new Error(`${label}.tools must be a bounded array`);
407
+ }
408
+ const tools = value.tools.map((tool, index) =>
409
+ decodeAppletToolDeclarationV1(tool, `${label}.tools[${index}]`),
410
+ );
411
+ if (new Set(tools.map((tool) => tool.name)).size !== tools.length) {
412
+ throw new Error(`${label}.tools contains duplicate names`);
413
+ }
414
+ const hashes = value.hashes;
415
+ if (!hashes || typeof hashes !== "object" || Array.isArray(hashes)) {
416
+ throw new Error(`${label}.hashes must be an object`);
417
+ }
418
+ const { server, ui } = hashes as Record<string, unknown>;
419
+ if (typeof server !== "string" || !/^[0-9a-f]{64}$/.test(server)) {
420
+ throw new Error(`${label}.hashes.server must be a sha-256 hex digest`);
421
+ }
422
+ if (typeof ui !== "string" || !/^[0-9a-f]{64}$/.test(ui)) {
423
+ throw new Error(`${label}.hashes.ui must be a sha-256 hex digest`);
424
+ }
425
+ return { contract: 1, tools, hashes: { server, ui } };
426
+ }
427
+
428
+ /**
429
+ * The Applet member set one Composition generation records, from the User's
430
+ * directory. Ordered by Applet id, so the artifact set hash is stable.
431
+ */
432
+ export function appletCompositionMembersV1(
433
+ applets: readonly {
434
+ appletId: string;
435
+ generationId: string;
436
+ tools: AppletToolDeclarationV1[];
437
+ provenance: AppletProvenanceV1;
438
+ }[],
439
+ ): CompositionAppletMemberV1[] {
440
+ return [...applets]
441
+ .sort((left, right) => left.appletId.localeCompare(right.appletId))
442
+ .map((applet) => ({
443
+ kind: "applet" as const,
444
+ appletId: applet.appletId,
445
+ generationId: applet.generationId,
446
+ tools: applet.tools.map((tool) => ({
447
+ name: tool.name,
448
+ description: tool.description,
449
+ // The declaration's schema really is JSON — the tool decoder proved it
450
+ // — but its declared type is `Record<string, unknown>`, which a
451
+ // Durable Object RPC boundary cannot carry. The round trip is the
452
+ // narrowing.
453
+ inputSchema: JSON.parse(JSON.stringify(tool.inputSchema)) as {
454
+ [key: string]: CompositionJsonValueV1;
455
+ },
456
+ })),
457
+ provenance: appletMemberProvenanceV1(applet),
458
+ }));
459
+ }
460
+
461
+ /**
462
+ * An Applet's provenance in the shape the Composition records provenance in.
463
+ * The "package" is the Applet and its "version" is its generation, because a
464
+ * Composition member is identified by what it is and which version of it ran.
465
+ */
466
+ export function appletMemberProvenanceV1(applet: {
467
+ appletId: string;
468
+ generationId: string;
469
+ provenance: AppletProvenanceV1;
470
+ }): PackageProvenanceV1 {
471
+ if (applet.provenance.kind === "bot") {
472
+ return {
473
+ kind: "bot",
474
+ packageId: applet.appletId,
475
+ version: applet.generationId,
476
+ botId: applet.provenance.botId,
477
+ sessionId: applet.provenance.sessionId,
478
+ turnId: applet.provenance.turnId,
479
+ runId: applet.provenance.turnId,
480
+ authoredAt: new Date(0).toISOString(),
481
+ };
482
+ }
483
+ return {
484
+ kind: "user",
485
+ packageId: applet.appletId,
486
+ version: applet.generationId,
487
+ userId: applet.appletId.slice(0, applet.appletId.lastIndexOf(".")),
488
+ authoredAt: new Date(0).toISOString(),
489
+ };
490
+ }
491
+
492
+ /** True when two Applet member sets differ in identity, generation, or tools. */
493
+ export function appletMembersDifferV1(
494
+ left: readonly CompositionAppletMemberV1[],
495
+ right: readonly CompositionAppletMemberV1[],
496
+ ): boolean {
497
+ if (left.length !== right.length) return true;
498
+ return left.some((member, index) => {
499
+ const other = right[index];
500
+ return (
501
+ !other ||
502
+ other.appletId !== member.appletId ||
503
+ other.generationId !== member.generationId ||
504
+ other.tools.length !== member.tools.length ||
505
+ other.tools.some((tool, at) => tool.name !== member.tools[at]?.name)
506
+ );
507
+ });
508
+ }
509
+
510
+ /**
511
+ * Resolve the Applet members of the Bot's next Composition generation.
512
+ *
513
+ * Called before a Turn is admitted, never inside the admission transaction: it
514
+ * reads the User Durable Object, and an admitted Turn's pin is taken in one
515
+ * storage transaction that cannot make a cross-object call. The result is a
516
+ * proposal the next admission pins — which is exactly ADR 0022's "a published
517
+ * generation activates at the next admitted Turn", and why an in-flight Turn
518
+ * keeps the set it pinned.
519
+ */
520
+ export async function resolveAppletCompositionV1(options: {
521
+ directory: Pick<AppletUserDirectoryV1, "compositionInput">;
522
+ composition: Pick<CompositionStore, "current" | "propose">;
523
+ storage: AppletCapabilityStorageV1;
524
+ origin: CompositionGenerationV1["origin"];
525
+ now?: Date;
526
+ }): Promise<CompositionGenerationV1 | undefined> {
527
+ const current = await options.composition.current();
528
+ const seen = await options.storage.get<number>(
529
+ APPLET_DIRECTORY_REVISION_SEEN_KEY,
530
+ );
531
+ const input = await options.directory.compositionInput();
532
+ const members = appletCompositionMembersV1(input.applets);
533
+ if (
534
+ seen === input.revision &&
535
+ !appletMembersDifferV1(members, current.applets ?? [])
536
+ ) {
537
+ return undefined;
538
+ }
539
+ if (!appletMembersDifferV1(members, current.applets ?? [])) {
540
+ await options.storage.put({
541
+ [APPLET_DIRECTORY_REVISION_SEEN_KEY]: input.revision,
542
+ });
543
+ return undefined;
544
+ }
545
+ const createdAt = (options.now ?? new Date()).toISOString();
546
+ const artifactSetHash = await compositionArtifactSetHashV1(
547
+ current.members,
548
+ members,
549
+ );
550
+ const generation = decodeCompositionGenerationV1({
551
+ schemaVersion: 1,
552
+ generationId: compositionGenerationIdV1(createdAt, artifactSetHash),
553
+ artifactSetHash,
554
+ parentGenerationId: current.generationId,
555
+ createdAt,
556
+ origin: options.origin,
557
+ members: current.members,
558
+ ...(members.length === 0 ? {} : { applets: members }),
559
+ status: "pending",
560
+ });
561
+ await options.composition.propose(generation, { pin: true });
562
+ await options.storage.put({
563
+ [APPLET_DIRECTORY_REVISION_SEEN_KEY]: input.revision,
564
+ });
565
+ return generation;
566
+ }
567
+
568
+ function publishEffectKey(effectId: string): string {
569
+ return `${APPLET_PUBLISH_EFFECT_PREFIX}${effectId}`;
570
+ }
571
+
572
+ function failed(
573
+ appletId: string,
574
+ generationId: string,
575
+ reason: string,
576
+ diagnostics: string[] = [],
577
+ ): AppletPublishResultV1 {
578
+ return {
579
+ status: "failed",
580
+ appletId,
581
+ generationId,
582
+ reason: reason.slice(0, 512),
583
+ diagnostics,
584
+ };
585
+ }
586
+
587
+ /** `ctx.applets` over the Bot Durable Object's authority. */
588
+ export function createAppletCapabilityHostV1(
589
+ options: AppletCapabilityHostOptionsV1,
590
+ ): AppletCapabilityHostV1 {
591
+ const now = options.now ?? (() => new Date());
592
+
593
+ async function readFile(
594
+ appletId: string,
595
+ file: string,
596
+ maximum: number,
597
+ ): Promise<{ text: string } | { failure: string }> {
598
+ const outcome = await options.workspace.read(
599
+ appletDistPathV1(options.userId, appletId, file),
600
+ );
601
+ if (outcome.status !== "ok") {
602
+ return {
603
+ failure: `"${file}" is ${outcome.status}: run \`applet build\` in applets/${appletId} on the Computer first`,
604
+ };
605
+ }
606
+ if (outcome.file.bytes.byteLength > maximum) {
607
+ return { failure: `"${file}" exceeds its ${maximum}-byte bound` };
608
+ }
609
+ return { text: TEXT.decode(outcome.file.bytes) };
610
+ }
611
+
612
+ async function setFocus(appletId: string | null): Promise<FocusedAppletV1> {
613
+ const focused = decodeFocusedAppletV1({
614
+ schemaVersion: 1,
615
+ appletId,
616
+ changedAt: now().toISOString(),
617
+ });
618
+ await options.storage.put({ [APPLET_FOCUSED_KEY]: focused });
619
+ return focused;
620
+ }
621
+
622
+ async function proposeAfterDirectoryChange(
623
+ scope: AppletCapabilityCallScopeV1,
624
+ ): Promise<string | undefined> {
625
+ const generation = await resolveAppletCompositionV1({
626
+ directory: options.directory,
627
+ composition: options.composition,
628
+ storage: options.storage,
629
+ origin: {
630
+ kind: "bot-authored",
631
+ runId: scope.runId,
632
+ sessionId: scope.sessionId,
633
+ turnId: scope.turnId,
634
+ },
635
+ now: now(),
636
+ });
637
+ return generation?.generationId;
638
+ }
639
+
640
+ async function activate(
641
+ input: {
642
+ appletId: string;
643
+ generation: AppletGenerationV1;
644
+ tools: AppletToolDeclarationV1[];
645
+ },
646
+ scope: AppletCapabilityCallScopeV1,
647
+ origin: "publish" | "revert",
648
+ ): Promise<AppletPublishResultV1> {
649
+ const instance = options.instanceFor(input.appletId);
650
+ const mounted =
651
+ origin === "publish"
652
+ ? await instance.publish({
653
+ appletId: input.appletId,
654
+ generation: input.generation,
655
+ })
656
+ : await instance.revert({
657
+ appletId: input.appletId,
658
+ generation: input.generation,
659
+ });
660
+ if (mounted.status === "failed") {
661
+ return failed(
662
+ input.appletId,
663
+ mounted.generationId,
664
+ mounted.reason,
665
+ mounted.diagnostics,
666
+ );
667
+ }
668
+ // The directory follows the mount, never precedes it: the tools a Bot is
669
+ // offered are the tools the resident generation actually reported.
670
+ await options.directory.recordGeneration({
671
+ appletId: input.appletId,
672
+ generationId: mounted.generationId,
673
+ tools: input.tools,
674
+ });
675
+ const compositionGenerationId = await proposeAfterDirectoryChange(scope);
676
+ return {
677
+ status: "published",
678
+ appletId: input.appletId,
679
+ generationId: mounted.generationId,
680
+ tools: mounted.tools,
681
+ ...(compositionGenerationId ? { compositionGenerationId } : {}),
682
+ };
683
+ }
684
+
685
+ return {
686
+ async list() {
687
+ return (await options.directory.list()).applets.map((applet) =>
688
+ decodeAppletSummaryV1(applet),
689
+ );
690
+ },
691
+
692
+ async create(input, scope) {
693
+ const created = await options.directory.create({
694
+ displayName: input.displayName,
695
+ provenance: {
696
+ kind: "bot",
697
+ botId: options.botId,
698
+ sessionId: scope.sessionId,
699
+ turnId: scope.turnId,
700
+ },
701
+ });
702
+ // "`applet_create` and `applet_publish` set focus by default" (plan §6).
703
+ await setFocus(created.appletId);
704
+ return created;
705
+ },
706
+
707
+ async publish(input, scope) {
708
+ const key = publishEffectKey(scope.effectId);
709
+ const recorded = await options.storage.get<AppletPublishIntentV1>(key);
710
+ if (recorded?.outcome) return recorded.outcome;
711
+ // Intent first, before a byte is read or written. A recovery reads this
712
+ // back and settles the effect rather than repeating it.
713
+ const intent: AppletPublishIntentV1 = recorded ?? {
714
+ schemaVersion: 1,
715
+ effectId: scope.effectId,
716
+ appletId: input.appletId,
717
+ botId: options.botId,
718
+ sessionId: scope.sessionId,
719
+ turnId: scope.turnId,
720
+ runId: scope.runId,
721
+ recordedAt: now().toISOString(),
722
+ };
723
+ if (!recorded) await options.storage.put({ [key]: intent });
724
+
725
+ const settle = async (
726
+ outcome: AppletPublishResultV1,
727
+ ): Promise<AppletPublishResultV1> => {
728
+ await options.storage.put({ [key]: { ...intent, outcome } });
729
+ return outcome;
730
+ };
731
+
732
+ // Force a pull of the source root so the publish sees what the Bot just
733
+ // built, not the last synced copy. See the seam note on the option.
734
+ await options.syncSourceRootNow?.();
735
+
736
+ const server = await readFile(
737
+ input.appletId,
738
+ "dist/server.js",
739
+ APPLET_MAX_SERVER_BYTES_V1,
740
+ );
741
+ if ("failure" in server) {
742
+ return settle(failed(input.appletId, "unbuilt", server.failure));
743
+ }
744
+ const ui = await readFile(
745
+ input.appletId,
746
+ "dist/ui.html",
747
+ APPLET_MAX_UI_BYTES_V1,
748
+ );
749
+ if ("failure" in ui) {
750
+ return settle(failed(input.appletId, "unbuilt", ui.failure));
751
+ }
752
+ const manifestFile = await readFile(
753
+ input.appletId,
754
+ "dist/manifest.json",
755
+ APPLET_MAX_MANIFEST_BYTES_V1,
756
+ );
757
+ if ("failure" in manifestFile) {
758
+ return settle(failed(input.appletId, "unbuilt", manifestFile.failure));
759
+ }
760
+ let manifest: AppletBuildManifestV1;
761
+ try {
762
+ manifest = decodeAppletBuildManifestV1(JSON.parse(manifestFile.text));
763
+ } catch (error) {
764
+ return settle(
765
+ failed(
766
+ input.appletId,
767
+ "unbuilt",
768
+ `dist/manifest.json is invalid: ${
769
+ error instanceof Error ? error.message : String(error)
770
+ }`,
771
+ ),
772
+ );
773
+ }
774
+ // Every Applet's tools share one Bot tool catalog, and the registry
775
+ // refuses a duplicate name at mount — which would fail the whole
776
+ // Composition closed for a name clash. So the clash is refused here, at
777
+ // publish, where the Bot can rename the tool and try again.
778
+ const others = (await options.directory.list()).applets.filter(
779
+ (applet) =>
780
+ applet.appletId !== input.appletId && applet.status !== "deleted",
781
+ );
782
+ const taken = new Map<string, string>();
783
+ for (const other of others) {
784
+ for (const name of other.tools) taken.set(name, other.displayName);
785
+ }
786
+ const clashes = manifest.tools
787
+ .filter((tool) => taken.has(tool.name))
788
+ .map(
789
+ (tool) =>
790
+ `"${tool.name}" is already a tool of "${taken.get(tool.name)}"`,
791
+ );
792
+ if (clashes.length > 0) {
793
+ return settle(
794
+ failed(
795
+ input.appletId,
796
+ "unbuilt",
797
+ "an Applet tool name is already taken by another Applet; rename it and run `applet build` again",
798
+ clashes,
799
+ ),
800
+ );
801
+ }
802
+
803
+ const serverHash = await sha256Hex(server.text);
804
+ const uiHash = await sha256Hex(ui.text);
805
+ if (
806
+ manifest.hashes.server !== serverHash ||
807
+ manifest.hashes.ui !== uiHash
808
+ ) {
809
+ return settle(
810
+ failed(
811
+ input.appletId,
812
+ "unbuilt",
813
+ "dist/manifest.json does not match the built files; run `applet build` again",
814
+ [
815
+ `server declared:${manifest.hashes.server} actual:${serverHash}`,
816
+ `ui declared:${manifest.hashes.ui} actual:${uiHash}`,
817
+ ],
818
+ ),
819
+ );
820
+ }
821
+
822
+ // Immutable, content-addressed, and written before anything points at it.
823
+ await options.artifacts.putPackageArtifact(serverHash, server.text);
824
+ await options.artifacts.putPackageUiArtifact(uiHash, ui.text);
825
+
826
+ const createdAt = now().toISOString();
827
+ const existing = await options
828
+ .instanceFor(input.appletId)
829
+ .read({ appletId: input.appletId });
830
+ const generation = decodeAppletGenerationV1({
831
+ schemaVersion: 1,
832
+ generationId: appletGenerationIdV1(createdAt, serverHash),
833
+ ...(existing.current
834
+ ? { parentGenerationId: existing.current.generationId }
835
+ : {}),
836
+ server: {
837
+ contentHash: serverHash,
838
+ size: server.text.length,
839
+ mediaType: "application/javascript",
840
+ bundlerVersion: `applet-cli-contract-${APPLET_CONTRACT_V1}`,
841
+ },
842
+ ui: {
843
+ contentHash: uiHash,
844
+ size: ui.text.length,
845
+ mediaType: "text/html",
846
+ bundlerVersion: `applet-cli-contract-${APPLET_CONTRACT_V1}`,
847
+ },
848
+ tools: manifest.tools,
849
+ contract: 1,
850
+ origin: "publish",
851
+ provenance: {
852
+ botId: options.botId,
853
+ sessionId: scope.sessionId,
854
+ turnId: scope.turnId,
855
+ runId: scope.runId,
856
+ },
857
+ createdAt,
858
+ status: "pending",
859
+ });
860
+ const outcome = await activate(
861
+ { appletId: input.appletId, generation, tools: manifest.tools },
862
+ scope,
863
+ "publish",
864
+ );
865
+ if (outcome.status === "published") await setFocus(input.appletId);
866
+ return settle(outcome);
867
+ },
868
+
869
+ async revert(input, scope) {
870
+ const instance = options.instanceFor(input.appletId);
871
+ const state = await instance.read({ appletId: input.appletId });
872
+ const target = state.generations.find(
873
+ (generation) => generation.generationId === input.generationId,
874
+ );
875
+ if (!target) {
876
+ return failed(
877
+ input.appletId,
878
+ input.generationId,
879
+ `Applet "${input.appletId}" has no generation "${input.generationId}"`,
880
+ );
881
+ }
882
+ const createdAt = now().toISOString();
883
+ // A revert is itself a recorded generation, never a mutation of the one
884
+ // it points back to (plan D5).
885
+ const generation = decodeAppletGenerationV1({
886
+ ...target,
887
+ generationId: appletGenerationIdV1(
888
+ createdAt,
889
+ target.server.contentHash,
890
+ ),
891
+ ...(state.current
892
+ ? { parentGenerationId: state.current.generationId }
893
+ : {}),
894
+ origin: "revert",
895
+ provenance: {
896
+ botId: options.botId,
897
+ sessionId: scope.sessionId,
898
+ turnId: scope.turnId,
899
+ runId: scope.runId,
900
+ },
901
+ createdAt,
902
+ status: "pending",
903
+ });
904
+ return activate(
905
+ { appletId: input.appletId, generation, tools: target.tools },
906
+ scope,
907
+ "revert",
908
+ );
909
+ },
910
+
911
+ async delete(input) {
912
+ await options.directory.delete(input.appletId);
913
+ const focused = await options.storage.get<unknown>(APPLET_FOCUSED_KEY);
914
+ if (
915
+ focused !== undefined &&
916
+ decodeFocusedAppletV1(focused).appletId === input.appletId
917
+ ) {
918
+ await setFocus(null);
919
+ }
920
+ return { status: "deleted" };
921
+ },
922
+
923
+ focus(input) {
924
+ return setFocus(input.appletId);
925
+ },
926
+
927
+ async generations(input) {
928
+ const state = await options
929
+ .instanceFor(input.appletId)
930
+ .read({ appletId: input.appletId });
931
+ return state.generations
932
+ .sort((left, right) =>
933
+ right.generationId.localeCompare(left.generationId),
934
+ )
935
+ .map((generation) => ({
936
+ generationId: generation.generationId,
937
+ ...(generation.parentGenerationId
938
+ ? { parentGenerationId: generation.parentGenerationId }
939
+ : {}),
940
+ origin: generation.origin,
941
+ status: generation.status,
942
+ tools: generation.tools.map((tool) => tool.name),
943
+ createdAt: generation.createdAt,
944
+ isCurrent: state.current?.generationId === generation.generationId,
945
+ }));
946
+ },
947
+
948
+ async readFocused() {
949
+ const stored = await options.storage.get<unknown>(APPLET_FOCUSED_KEY);
950
+ return stored === undefined
951
+ ? {
952
+ schemaVersion: 1,
953
+ appletId: null,
954
+ changedAt: new Date(0).toISOString(),
955
+ }
956
+ : decodeFocusedAppletV1(stored);
957
+ },
958
+ };
959
+ }