@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.
- package/package.json +32 -29
- package/src/backend-applets.test.ts +581 -0
- package/src/backend-applets.ts +959 -0
- package/src/backend-authoring.test.ts +61 -19
- package/src/backend-authoring.ts +52 -26
- package/src/backend-composition.ts +64 -0
- package/src/backend-computer.test.ts +128 -0
- package/src/backend-computer.ts +81 -0
- package/src/backend-configuration.test.ts +8 -1
- package/src/backend-iframe-ui.test.ts +29 -12
- package/src/backend-isolate.ts +31 -5
- package/src/backend-package-catalog.test.ts +13 -8
- package/src/backend-package-catalog.ts +8 -6
- package/src/backend-recovery-integration.test.ts +23 -0
- package/src/backend.ts +508 -5
- package/src/client/AppletCanvas.vue +679 -0
- package/src/client/FrockBotApp.vue +169 -15
- package/src/client/PackageEntryTrigger.vue +77 -0
- package/src/client/PackageIframeHost.vue +148 -47
- package/src/client/PackageIframeSettings.vue +8 -6
- package/src/client/PackageSurfacePage.vue +39 -0
- package/src/client/applets-client.test.ts +204 -0
- package/src/client/applets-client.ts +139 -0
- package/src/client/applets-state.ts +64 -0
- package/src/client/index.test.ts +13 -7
- package/src/client/index.ts +308 -1
- package/src/client/package-iframe-entries.test.ts +122 -0
- package/src/client/package-iframe-entries.ts +112 -0
- package/src/client/package-iframe-host-message.test.ts +3 -3
- package/src/client/package-iframe-host-message.ts +3 -3
- package/src/client/styles.css +107 -1
- package/src/composition-views.ts +31 -6
- 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
|
+
}
|