@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
package/src/backend.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type PersistSessionEvents,
|
|
22
22
|
type SessionEvent,
|
|
23
23
|
type WorkspacePathV1,
|
|
24
|
+
type WorkspaceRootV1,
|
|
24
25
|
validateToolOccurrenceJournal,
|
|
25
26
|
type BotCapabilitiesStub,
|
|
26
27
|
type IsolateModelInvocationV1,
|
|
@@ -36,7 +37,11 @@ import {
|
|
|
36
37
|
isClientIframeContribution,
|
|
37
38
|
type FrockBotManifest,
|
|
38
39
|
} from "@frockbot/kernel-composition";
|
|
40
|
+
import { canonicalJson, sha256 } from "@frockbot/kernel-composition/compiler";
|
|
39
41
|
import type { Plugin } from "cordis";
|
|
42
|
+
import type { ComputerRegistry } from "@frockbot/computer-core";
|
|
43
|
+
import { appletsSourceRootV1 } from "@frockbot/plugin-applets/root";
|
|
44
|
+
import { syncWorkspaceRootNowV1 } from "@frockbot/plugin-computer/agent";
|
|
40
45
|
import {
|
|
41
46
|
ACTIVE_RUN_KEY,
|
|
42
47
|
BotDurableAuthority,
|
|
@@ -119,9 +124,31 @@ import {
|
|
|
119
124
|
import {
|
|
120
125
|
bootstrapCompositionGeneration,
|
|
121
126
|
createShellCompositionHost,
|
|
127
|
+
type ShellAppletMountOptions,
|
|
122
128
|
type ShellIsolateMountOptions,
|
|
123
129
|
type ShellMountedComposition,
|
|
124
130
|
} from "./backend-composition.js";
|
|
131
|
+
import {
|
|
132
|
+
createAppletCapabilityHostV1,
|
|
133
|
+
createAppletInstanceBindingV1,
|
|
134
|
+
appletRpcSnapshotV1 as rpcJsonSnapshotV1,
|
|
135
|
+
resolveAppletCompositionV1,
|
|
136
|
+
type AppletCapabilityHostV1,
|
|
137
|
+
type AppletInstanceNamespaceV1,
|
|
138
|
+
type AppletUserDirectoryV1,
|
|
139
|
+
} from "./backend-applets.js";
|
|
140
|
+
import {
|
|
141
|
+
APPLET_FOCUSED_KEY,
|
|
142
|
+
decodeFocusedAppletV1,
|
|
143
|
+
type FocusedAppletV1,
|
|
144
|
+
} from "@frockbot/kernel-do";
|
|
145
|
+
import {
|
|
146
|
+
decodeAppletProvenanceV1,
|
|
147
|
+
decodeAppletSummaryV1,
|
|
148
|
+
decodeAppletToolDeclarationV1,
|
|
149
|
+
decodeIsolateAppletsRequestV1,
|
|
150
|
+
type IsolateAppletsOutcomeV1,
|
|
151
|
+
} from "@frockbot/kernel-contracts";
|
|
125
152
|
import { compositionFailureTurnTextV1 } from "./backend-composition-input.js";
|
|
126
153
|
import {
|
|
127
154
|
activateCompositionV1,
|
|
@@ -139,7 +166,10 @@ import {
|
|
|
139
166
|
createPackageCatalogHost,
|
|
140
167
|
createR2BotPackageCatalogReader,
|
|
141
168
|
} from "./backend-package-catalog.js";
|
|
142
|
-
import {
|
|
169
|
+
import {
|
|
170
|
+
createBotComputerSyncHost,
|
|
171
|
+
declaredPackageRootsV1,
|
|
172
|
+
} from "./backend-computer.js";
|
|
143
173
|
import {
|
|
144
174
|
decodeDirectoryViewV1,
|
|
145
175
|
decodeFlockReceiptV1,
|
|
@@ -406,6 +436,7 @@ import {
|
|
|
406
436
|
type BotUnreadReceiptV1,
|
|
407
437
|
type BotUnreadViewV1,
|
|
408
438
|
} from "./unread.js";
|
|
439
|
+
import { defineBotBackendContribution } from "@frockbot/kernel-contracts/contributions";
|
|
409
440
|
|
|
410
441
|
export const BOT_CONFIGURATION_KEY = "bot-configuration";
|
|
411
442
|
const CONFIGURATION_RECEIPT_PREFIX = "configuration-receipt:";
|
|
@@ -489,6 +520,13 @@ export interface BotStateEnv {
|
|
|
489
520
|
BOT_PACKAGES?: BotIsolateLoader;
|
|
490
521
|
/** Immutable, content-addressed Package artifacts, read hash-verified. */
|
|
491
522
|
APPLICATION_ARTIFACTS?: R2Bucket;
|
|
523
|
+
/**
|
|
524
|
+
* One Applet Durable Object per Applet instance (ADR 0022). Optional so a
|
|
525
|
+
* host without Applets still compiles; a Composition generation carrying an
|
|
526
|
+
* Applet member then fails verification, exactly as an isolate member does
|
|
527
|
+
* without a loader.
|
|
528
|
+
*/
|
|
529
|
+
APPLET_STATES?: AppletInstanceNamespaceV1;
|
|
492
530
|
/**
|
|
493
531
|
* The remote Package Catalog bucket. Read here only to index the Skills that
|
|
494
532
|
* arrived with the User's installed entries, at the generation each install
|
|
@@ -560,6 +598,13 @@ export interface ShellBotBackendHost {
|
|
|
560
598
|
* Durable Object has no honest way to dispatch one.
|
|
561
599
|
*/
|
|
562
600
|
subagents?: SubagentDurableBindingV1;
|
|
601
|
+
/**
|
|
602
|
+
* Immutable Package artifacts this bundle already carries, by object key.
|
|
603
|
+
*
|
|
604
|
+
* The application supplies these; the shell only hands them to the artifact
|
|
605
|
+
* store as a second place to look. See `createR2PackageArtifactStore`.
|
|
606
|
+
*/
|
|
607
|
+
bundledPackageArtifacts?: ReadonlyMap<string, string>;
|
|
563
608
|
invalidateComputerProjectionFile?(
|
|
564
609
|
userId: string,
|
|
565
610
|
botId: string,
|
|
@@ -611,6 +656,7 @@ export class ShellBotBackendContribution {
|
|
|
611
656
|
readonly ctx: DurableObjectState;
|
|
612
657
|
readonly env: BotStateEnv;
|
|
613
658
|
private readonly compileApplication: typeof compileFoundationApplication;
|
|
659
|
+
private readonly bundledPackageArtifacts?: ReadonlyMap<string, string>;
|
|
614
660
|
private readonly lifecycleAdmission?: ShellBotBackendHost["assertLifecycleActive"];
|
|
615
661
|
private readonly reconciliationActivities = new Map<
|
|
616
662
|
string,
|
|
@@ -676,6 +722,7 @@ export class ShellBotBackendContribution {
|
|
|
676
722
|
this.env = host.env;
|
|
677
723
|
this.compileApplication =
|
|
678
724
|
host.compileApplication ?? compileFoundationApplication;
|
|
725
|
+
this.bundledPackageArtifacts = host.bundledPackageArtifacts;
|
|
679
726
|
this.lifecycleAdmission = host.assertLifecycleActive;
|
|
680
727
|
this.outboundFetch = host.outboundFetch;
|
|
681
728
|
this.invalidateComputerProjectionFile =
|
|
@@ -1059,6 +1106,12 @@ export class ShellBotBackendContribution {
|
|
|
1059
1106
|
}
|
|
1060
1107
|
|
|
1061
1108
|
async run(command: OwnedBotTurnCommand): Promise<ClientTurnV1> {
|
|
1109
|
+
// Before admission, so the pin this Turn takes already carries whatever the
|
|
1110
|
+
// User's Applet directory says now.
|
|
1111
|
+
await this.resolveAppletComposition(
|
|
1112
|
+
{ userId: command.userId, botId: command.botId },
|
|
1113
|
+
command,
|
|
1114
|
+
);
|
|
1062
1115
|
return projectClientTurnV1(await this.authority.run(command));
|
|
1063
1116
|
}
|
|
1064
1117
|
|
|
@@ -1141,7 +1194,34 @@ export class ShellBotBackendContribution {
|
|
|
1141
1194
|
const stored = await this.ctx.storage.get<AuthoredManifestRecordV1>(
|
|
1142
1195
|
authorshipManifestKey(member.manifestHash),
|
|
1143
1196
|
);
|
|
1144
|
-
|
|
1197
|
+
if (stored) return decodeFrockBotManifest(stored.manifest);
|
|
1198
|
+
return await this.readApplicationMemberManifest(member);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* The manifest of a member the *application* declared, not the Bot.
|
|
1203
|
+
*
|
|
1204
|
+
* `authorship:manifest:<hash>` is written by the authoring path and by a
|
|
1205
|
+
* Catalog install, so it exists for every member a Bot or its User put into
|
|
1206
|
+
* the Composition. A first-party artifact-backed member (ADR 0022 decision
|
|
1207
|
+
* 8) came from neither: it is in the compiled application, whose manifests
|
|
1208
|
+
* are already in this bundle. The `manifestHash` is still what decides —
|
|
1209
|
+
* the plan's manifest is accepted only when it hashes to exactly what the
|
|
1210
|
+
* generation recorded — so this is a second *place* to look, never a second
|
|
1211
|
+
* answer.
|
|
1212
|
+
*/
|
|
1213
|
+
private async readApplicationMemberManifest(
|
|
1214
|
+
member: CompositionMemberV1,
|
|
1215
|
+
): Promise<FrockBotManifest | undefined> {
|
|
1216
|
+
if (!member.artifact) return undefined;
|
|
1217
|
+
const application = await this.compileApplication();
|
|
1218
|
+
const declared = application.packages.find(
|
|
1219
|
+
(candidate) => candidate.id === member.packageId,
|
|
1220
|
+
);
|
|
1221
|
+
if (!declared) return undefined;
|
|
1222
|
+
const hash = await sha256(canonicalJson(declared.manifest));
|
|
1223
|
+
if (hash !== member.manifestHash) return undefined;
|
|
1224
|
+
return declared.manifest;
|
|
1145
1225
|
}
|
|
1146
1226
|
|
|
1147
1227
|
private async requireCompositionMemberManifest(
|
|
@@ -1184,6 +1264,59 @@ export class ShellBotBackendContribution {
|
|
|
1184
1264
|
* and its provenance says who put it there. The write goes through the same
|
|
1185
1265
|
* `writeSkillDocumentV1` the Bot's own `skill_write` uses, quota included.
|
|
1186
1266
|
*/
|
|
1267
|
+
/**
|
|
1268
|
+
* One file written into one of the User's durable roots, as the User.
|
|
1269
|
+
*
|
|
1270
|
+
* This is the stand-in for the Computer's sync in an environment that has
|
|
1271
|
+
* no Computer: an end-to-end run lands the bytes `applet build` would have
|
|
1272
|
+
* written, at the path the sync would have mirrored them to, through the
|
|
1273
|
+
* same store and with the same generation record. The writer is the User —
|
|
1274
|
+
* the authority the sync's `unattributed` mirror is *narrower* than — so
|
|
1275
|
+
* nothing here is a write the User could not have made from their own
|
|
1276
|
+
* Computer. A root belonging to another User is refused by the store.
|
|
1277
|
+
*/
|
|
1278
|
+
async writeUserWorkspaceFile(
|
|
1279
|
+
identity: BotIdentity,
|
|
1280
|
+
request: {
|
|
1281
|
+
root: WorkspaceRootV1;
|
|
1282
|
+
path: string;
|
|
1283
|
+
bytes: Uint8Array;
|
|
1284
|
+
mediaType?: string;
|
|
1285
|
+
},
|
|
1286
|
+
): Promise<
|
|
1287
|
+
| { status: "written"; generationId: string }
|
|
1288
|
+
| { status: "refused"; reason: string }
|
|
1289
|
+
> {
|
|
1290
|
+
await this.validateIdentity(identity);
|
|
1291
|
+
const files = (this.env as { WORKSPACE_FILES?: WorkspaceFilesV1 })
|
|
1292
|
+
.WORKSPACE_FILES;
|
|
1293
|
+
if (!files) {
|
|
1294
|
+
return { status: "refused", reason: "this Bot has no Workspace store" };
|
|
1295
|
+
}
|
|
1296
|
+
if (request.root.userId !== identity.userId) {
|
|
1297
|
+
return { status: "refused", reason: "the root belongs to another User" };
|
|
1298
|
+
}
|
|
1299
|
+
const path = { root: request.root, path: request.path };
|
|
1300
|
+
const existing = await files.stat(path);
|
|
1301
|
+
const outcome = await files.write({
|
|
1302
|
+
path,
|
|
1303
|
+
bytes: request.bytes,
|
|
1304
|
+
writer: { kind: "user", userId: identity.userId },
|
|
1305
|
+
expectedGenerationId:
|
|
1306
|
+
existing.status === "ok"
|
|
1307
|
+
? existing.entry.generation.generationId
|
|
1308
|
+
: null,
|
|
1309
|
+
...(request.mediaType ? { mediaType: request.mediaType } : {}),
|
|
1310
|
+
});
|
|
1311
|
+
if (outcome.status === "ok") {
|
|
1312
|
+
return {
|
|
1313
|
+
status: "written",
|
|
1314
|
+
generationId: outcome.generation.generationId,
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
return { status: "refused", reason: outcome.reason };
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1187
1320
|
async writeUserSkill(
|
|
1188
1321
|
identity: BotIdentity,
|
|
1189
1322
|
draft: { slug: string; name: string; description: string; body: string },
|
|
@@ -1406,6 +1539,21 @@ export class ShellBotBackendContribution {
|
|
|
1406
1539
|
// The isolate bindings follow the generation actually being mounted, so a
|
|
1407
1540
|
// fail-closed fallback loads the last known good's members, not the
|
|
1408
1541
|
// pinned generation's.
|
|
1542
|
+
// Applet tools route to the Applet Durable Object, which forwards to the
|
|
1543
|
+
// facet. The instance binding is minted once per Turn; the facet stub
|
|
1544
|
+
// itself never leaves that object.
|
|
1545
|
+
const appletInstances = this.env.APPLET_STATES
|
|
1546
|
+
? createAppletInstanceBindingV1(
|
|
1547
|
+
this.env.APPLET_STATES,
|
|
1548
|
+
input.identity.userId,
|
|
1549
|
+
)
|
|
1550
|
+
: undefined;
|
|
1551
|
+
const appletRouting: ShellAppletMountOptions | undefined = appletInstances
|
|
1552
|
+
? {
|
|
1553
|
+
invokeTool: (request) =>
|
|
1554
|
+
appletInstances(request.appletId).invokeTool(request),
|
|
1555
|
+
}
|
|
1556
|
+
: undefined;
|
|
1409
1557
|
const host: CompositionMountHost<ShellMountedComposition> = {
|
|
1410
1558
|
mount: async (mounting, signal) => {
|
|
1411
1559
|
const isolate = await this.isolateMountOptions(input.identity, {
|
|
@@ -1439,6 +1587,7 @@ export class ShellBotBackendContribution {
|
|
|
1439
1587
|
effect,
|
|
1440
1588
|
),
|
|
1441
1589
|
...(isolate ? { isolate } : {}),
|
|
1590
|
+
...(appletRouting ? { applets: appletRouting } : {}),
|
|
1442
1591
|
}).mount(mounting, signal);
|
|
1443
1592
|
mountedRoot = mounted.root;
|
|
1444
1593
|
mountedGeneration = mounted.generation;
|
|
@@ -1506,10 +1655,14 @@ export class ShellBotBackendContribution {
|
|
|
1506
1655
|
"Package UI command does not match the mounted Composition generation",
|
|
1507
1656
|
);
|
|
1508
1657
|
}
|
|
1658
|
+
// Artifact-backed, not "not first-party": what makes a Package's page
|
|
1659
|
+
// able to name one of its tools is that the Package is loaded from an
|
|
1660
|
+
// immutable artifact with a manifest, which is exactly what ADR 0022
|
|
1661
|
+
// decision 8 gives a first-party Package too.
|
|
1509
1662
|
const member = activation.mounted.generation.members.find(
|
|
1510
1663
|
(candidate) =>
|
|
1511
1664
|
candidate.packageId === directTool.packageId &&
|
|
1512
|
-
candidate.
|
|
1665
|
+
candidate.artifact !== undefined,
|
|
1513
1666
|
);
|
|
1514
1667
|
if (!member)
|
|
1515
1668
|
throw new Error("Package UI command names an unavailable Package");
|
|
@@ -1665,7 +1818,10 @@ export class ShellBotBackendContribution {
|
|
|
1665
1818
|
runId: turn.runId,
|
|
1666
1819
|
turnId: turn.runId,
|
|
1667
1820
|
loader,
|
|
1668
|
-
artifacts: createR2PackageArtifactStore(
|
|
1821
|
+
artifacts: createR2PackageArtifactStore(
|
|
1822
|
+
artifacts,
|
|
1823
|
+
this.bundledPackageArtifacts,
|
|
1824
|
+
),
|
|
1669
1825
|
manifestFor: (member) => this.requireCompositionMemberManifest(member),
|
|
1670
1826
|
capabilitiesFor: (member) =>
|
|
1671
1827
|
mintCapabilities({
|
|
@@ -1688,6 +1844,7 @@ export class ShellBotBackendContribution {
|
|
|
1688
1844
|
bindingDigest: await isolateBindingDigestV1({
|
|
1689
1845
|
userId: identity.userId,
|
|
1690
1846
|
botId: identity.botId,
|
|
1847
|
+
runId: turn.runId,
|
|
1691
1848
|
connections: authority.connections,
|
|
1692
1849
|
...(authority.model ? { model: authority.model } : {}),
|
|
1693
1850
|
compositionGenerationId: turn.generationId,
|
|
@@ -2048,6 +2205,322 @@ export class ShellBotBackendContribution {
|
|
|
2048
2205
|
};
|
|
2049
2206
|
}
|
|
2050
2207
|
|
|
2208
|
+
// --- Applets (ADR 0022) --------------------------------------------------
|
|
2209
|
+
|
|
2210
|
+
/**
|
|
2211
|
+
* `ctx.applets` for one Bot, or `undefined` when this host cannot reach
|
|
2212
|
+
* Applets at all — no instance namespace, no artifact bucket, or no
|
|
2213
|
+
* Workspace. An absent capability is an `unavailable` outcome at the isolate
|
|
2214
|
+
* boundary, never a thrown error inside Bot code.
|
|
2215
|
+
*/
|
|
2216
|
+
private appletCapabilityHost(
|
|
2217
|
+
identity: BotIdentity,
|
|
2218
|
+
active?: NonNullable<ShellBotBackendContribution["activeTurn"]>,
|
|
2219
|
+
): AppletCapabilityHostV1 | undefined {
|
|
2220
|
+
const namespace = this.env.APPLET_STATES;
|
|
2221
|
+
const artifacts = this.env.APPLICATION_ARTIFACTS;
|
|
2222
|
+
const workspace = this.env.WORKSPACE_FILES;
|
|
2223
|
+
if (!namespace || !artifacts || !workspace) return undefined;
|
|
2224
|
+
const bucket = artifacts;
|
|
2225
|
+
return createAppletCapabilityHostV1({
|
|
2226
|
+
userId: identity.userId,
|
|
2227
|
+
botId: identity.botId,
|
|
2228
|
+
storage: {
|
|
2229
|
+
get: (key) => this.ctx.storage.get(key),
|
|
2230
|
+
put: (entries) => this.ctx.storage.put(entries),
|
|
2231
|
+
},
|
|
2232
|
+
directory: this.appletUserDirectory(identity),
|
|
2233
|
+
instanceFor: createAppletInstanceBindingV1(namespace, identity.userId),
|
|
2234
|
+
artifacts: {
|
|
2235
|
+
putPackageArtifact: async (contentHash, module) => {
|
|
2236
|
+
await bucket.put(`packages/${contentHash}.mjs`, module, {
|
|
2237
|
+
httpMetadata: { contentType: "application/javascript" },
|
|
2238
|
+
});
|
|
2239
|
+
},
|
|
2240
|
+
putPackageUiArtifact: async (contentHash, html) => {
|
|
2241
|
+
await bucket.put(`packages/${contentHash}.html`, html, {
|
|
2242
|
+
httpMetadata: { contentType: "text/html; charset=utf-8" },
|
|
2243
|
+
});
|
|
2244
|
+
},
|
|
2245
|
+
},
|
|
2246
|
+
workspace,
|
|
2247
|
+
// A publish reads `dist/` from the store, and `applet build` wrote it on
|
|
2248
|
+
// the Computer moments earlier in this very Turn — before the Turn's own
|
|
2249
|
+
// `turn-end` push. So the one root is reconciled first, through the one
|
|
2250
|
+
// sanctioned extra caller of the Computer's sync. It wakes nothing new: a
|
|
2251
|
+
// User with no Computer assignment has no root to pull, and the Bot that
|
|
2252
|
+
// just built on its Computer has it open already.
|
|
2253
|
+
syncSourceRootNow: active
|
|
2254
|
+
? async () => {
|
|
2255
|
+
const root = active.mounted.runtime.root as unknown as {
|
|
2256
|
+
computers?: ComputerRegistry;
|
|
2257
|
+
sessions: typeof active.mounted.runtime.root.sessions;
|
|
2258
|
+
};
|
|
2259
|
+
const computerIdentity = { userId: identity.userId };
|
|
2260
|
+
if (!root.computers?.assignment(computerIdentity)) return;
|
|
2261
|
+
const session = root.sessions.get(active.sessionId);
|
|
2262
|
+
const started = session?.events.findLast(
|
|
2263
|
+
(event) => event.type === "step/start",
|
|
2264
|
+
);
|
|
2265
|
+
const turn = started?.type === "step/start" ? started.turn : 0;
|
|
2266
|
+
const computer = await root.computers.open(
|
|
2267
|
+
computerIdentity,
|
|
2268
|
+
{ botId: identity.botId },
|
|
2269
|
+
{ signal: active.signal },
|
|
2270
|
+
);
|
|
2271
|
+
await syncWorkspaceRootNowV1({
|
|
2272
|
+
computer,
|
|
2273
|
+
sessions: root.sessions,
|
|
2274
|
+
sessionId: active.sessionId,
|
|
2275
|
+
turn,
|
|
2276
|
+
root: appletsSourceRootV1(identity.userId),
|
|
2277
|
+
signal: active.signal,
|
|
2278
|
+
});
|
|
2279
|
+
}
|
|
2280
|
+
: undefined,
|
|
2281
|
+
composition: {
|
|
2282
|
+
current: () => this.authority.composition.current(),
|
|
2283
|
+
lastKnownGood: () => this.authority.composition.lastKnownGood(),
|
|
2284
|
+
propose: (generation, options) =>
|
|
2285
|
+
this.authority.composition.propose(generation, options),
|
|
2286
|
+
},
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
/** The User Durable Object's Applet directory, decoded on arrival. */
|
|
2291
|
+
private appletUserDirectory(identity: BotIdentity): AppletUserDirectoryV1 {
|
|
2292
|
+
const id = this.env.USER_CONFIGURATIONS.idFromName(identity.userId);
|
|
2293
|
+
// SAFETY: this namespace is bound to UserConfiguration; generated Worker
|
|
2294
|
+
// types do not expose its Applet directory RPC surface.
|
|
2295
|
+
const rpc = this.env.USER_CONFIGURATIONS.get(id) as unknown as {
|
|
2296
|
+
listApplets(input: unknown): Promise<unknown>;
|
|
2297
|
+
readAppletCompositionInput(input: unknown): Promise<unknown>;
|
|
2298
|
+
createApplet(input: unknown): Promise<unknown>;
|
|
2299
|
+
recordAppletGeneration(input: unknown): Promise<unknown>;
|
|
2300
|
+
deleteApplet(input: unknown): Promise<unknown>;
|
|
2301
|
+
};
|
|
2302
|
+
const userId = identity.userId;
|
|
2303
|
+
return {
|
|
2304
|
+
async list() {
|
|
2305
|
+
const answer = rpcJsonSnapshotV1(
|
|
2306
|
+
await rpc.listApplets({ schemaVersion: 1, userId }),
|
|
2307
|
+
) as { revision?: unknown; applets?: unknown };
|
|
2308
|
+
return {
|
|
2309
|
+
revision: Number(answer.revision ?? 0),
|
|
2310
|
+
applets: Array.isArray(answer.applets)
|
|
2311
|
+
? answer.applets.map((applet) => decodeAppletSummaryV1(applet))
|
|
2312
|
+
: [],
|
|
2313
|
+
};
|
|
2314
|
+
},
|
|
2315
|
+
async compositionInput() {
|
|
2316
|
+
const answer = rpcJsonSnapshotV1(
|
|
2317
|
+
await rpc.readAppletCompositionInput({ schemaVersion: 1, userId }),
|
|
2318
|
+
) as { revision?: unknown; applets?: unknown };
|
|
2319
|
+
return {
|
|
2320
|
+
revision: Number(answer.revision ?? 0),
|
|
2321
|
+
applets: (Array.isArray(answer.applets) ? answer.applets : []).map(
|
|
2322
|
+
(applet) => {
|
|
2323
|
+
const entry = applet as Record<string, unknown>;
|
|
2324
|
+
return {
|
|
2325
|
+
appletId: String(entry.appletId),
|
|
2326
|
+
generationId: String(entry.generationId),
|
|
2327
|
+
tools: (Array.isArray(entry.tools) ? entry.tools : []).map(
|
|
2328
|
+
(tool, index) =>
|
|
2329
|
+
decodeAppletToolDeclarationV1(
|
|
2330
|
+
tool,
|
|
2331
|
+
`Applet tool declaration[${index}]`,
|
|
2332
|
+
),
|
|
2333
|
+
),
|
|
2334
|
+
provenance: decodeAppletProvenanceV1(entry.provenance),
|
|
2335
|
+
};
|
|
2336
|
+
},
|
|
2337
|
+
),
|
|
2338
|
+
};
|
|
2339
|
+
},
|
|
2340
|
+
async create(input) {
|
|
2341
|
+
return decodeAppletSummaryV1(
|
|
2342
|
+
rpcJsonSnapshotV1(
|
|
2343
|
+
await rpc.createApplet({
|
|
2344
|
+
schemaVersion: 1,
|
|
2345
|
+
userId,
|
|
2346
|
+
displayName: input.displayName,
|
|
2347
|
+
provenance: input.provenance,
|
|
2348
|
+
}),
|
|
2349
|
+
),
|
|
2350
|
+
);
|
|
2351
|
+
},
|
|
2352
|
+
async recordGeneration(input) {
|
|
2353
|
+
return decodeAppletSummaryV1(
|
|
2354
|
+
rpcJsonSnapshotV1(
|
|
2355
|
+
await rpc.recordAppletGeneration({
|
|
2356
|
+
schemaVersion: 1,
|
|
2357
|
+
userId,
|
|
2358
|
+
appletId: input.appletId,
|
|
2359
|
+
generationId: input.generationId,
|
|
2360
|
+
tools: input.tools,
|
|
2361
|
+
}),
|
|
2362
|
+
),
|
|
2363
|
+
);
|
|
2364
|
+
},
|
|
2365
|
+
async delete(appletId) {
|
|
2366
|
+
return decodeAppletSummaryV1(
|
|
2367
|
+
rpcJsonSnapshotV1(
|
|
2368
|
+
await rpc.deleteApplet({ schemaVersion: 1, userId, appletId }),
|
|
2369
|
+
),
|
|
2370
|
+
);
|
|
2371
|
+
},
|
|
2372
|
+
};
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
/**
|
|
2376
|
+
* The Applet capability at the isolate boundary. One RPC with an operation,
|
|
2377
|
+
* because seven near-identical forwarders would say nothing seven times; the
|
|
2378
|
+
* shapes are decoded here and the outcomes are declared, never thrown.
|
|
2379
|
+
*/
|
|
2380
|
+
async isolateApplets(
|
|
2381
|
+
input: IsolateCallScopeV1,
|
|
2382
|
+
): Promise<IsolateAppletsOutcomeV1> {
|
|
2383
|
+
const active = this.activeIsolateTurn(input);
|
|
2384
|
+
if (!active) {
|
|
2385
|
+
return {
|
|
2386
|
+
status: "unavailable",
|
|
2387
|
+
reason: "the Package is not running in this Bot's active Composition",
|
|
2388
|
+
};
|
|
2389
|
+
}
|
|
2390
|
+
const identity = { userId: input.userId, botId: input.botId };
|
|
2391
|
+
const host = this.appletCapabilityHost(identity, active);
|
|
2392
|
+
if (!host) {
|
|
2393
|
+
return { status: "unavailable", reason: "Applets are unavailable" };
|
|
2394
|
+
}
|
|
2395
|
+
const request = decodeIsolateAppletsRequestV1(input.request);
|
|
2396
|
+
const scope = {
|
|
2397
|
+
sessionId: input.sessionId,
|
|
2398
|
+
runId: input.runId,
|
|
2399
|
+
turnId: input.turnId,
|
|
2400
|
+
effectId: `applet:${input.turnId}:${request.op}:${
|
|
2401
|
+
"appletId" in request ? request.appletId : "new"
|
|
2402
|
+
}`,
|
|
2403
|
+
};
|
|
2404
|
+
try {
|
|
2405
|
+
switch (request.op) {
|
|
2406
|
+
case "list":
|
|
2407
|
+
return { status: "available", value: await host.list() };
|
|
2408
|
+
case "create":
|
|
2409
|
+
return {
|
|
2410
|
+
status: "available",
|
|
2411
|
+
value: await host.create(
|
|
2412
|
+
{ displayName: request.displayName },
|
|
2413
|
+
scope,
|
|
2414
|
+
),
|
|
2415
|
+
};
|
|
2416
|
+
case "publish":
|
|
2417
|
+
return {
|
|
2418
|
+
status: "available",
|
|
2419
|
+
value: await host.publish({ appletId: request.appletId }, scope),
|
|
2420
|
+
};
|
|
2421
|
+
case "revert":
|
|
2422
|
+
return {
|
|
2423
|
+
status: "available",
|
|
2424
|
+
value: await host.revert(
|
|
2425
|
+
{
|
|
2426
|
+
appletId: request.appletId,
|
|
2427
|
+
generationId: request.generationId,
|
|
2428
|
+
},
|
|
2429
|
+
scope,
|
|
2430
|
+
),
|
|
2431
|
+
};
|
|
2432
|
+
case "delete":
|
|
2433
|
+
return {
|
|
2434
|
+
status: "available",
|
|
2435
|
+
value: await host.delete({ appletId: request.appletId }),
|
|
2436
|
+
};
|
|
2437
|
+
case "focus":
|
|
2438
|
+
return {
|
|
2439
|
+
status: "available",
|
|
2440
|
+
value: await host.focus({ appletId: request.appletId }),
|
|
2441
|
+
};
|
|
2442
|
+
case "generations":
|
|
2443
|
+
return {
|
|
2444
|
+
status: "available",
|
|
2445
|
+
value: await host.generations({ appletId: request.appletId }),
|
|
2446
|
+
};
|
|
2447
|
+
}
|
|
2448
|
+
} catch (error) {
|
|
2449
|
+
return {
|
|
2450
|
+
status: "unavailable",
|
|
2451
|
+
reason:
|
|
2452
|
+
error instanceof Error ? error.message : "the Applet call failed",
|
|
2453
|
+
};
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
/** The Session's focused Applet, as the shell and its route read it. */
|
|
2458
|
+
async readFocusedApplet(identity: BotIdentity): Promise<FocusedAppletV1> {
|
|
2459
|
+
await this.validateIdentity(identity);
|
|
2460
|
+
const stored = await this.ctx.storage.get<unknown>(APPLET_FOCUSED_KEY);
|
|
2461
|
+
return stored === undefined
|
|
2462
|
+
? {
|
|
2463
|
+
schemaVersion: 1,
|
|
2464
|
+
appletId: null,
|
|
2465
|
+
changedAt: new Date(0).toISOString(),
|
|
2466
|
+
}
|
|
2467
|
+
: decodeFocusedAppletV1(stored);
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
async setFocusedApplet(
|
|
2471
|
+
identity: BotIdentity,
|
|
2472
|
+
appletId: string | null,
|
|
2473
|
+
): Promise<FocusedAppletV1> {
|
|
2474
|
+
await this.validateIdentity(identity);
|
|
2475
|
+
const focused = decodeFocusedAppletV1({
|
|
2476
|
+
schemaVersion: 1,
|
|
2477
|
+
appletId,
|
|
2478
|
+
changedAt: new Date().toISOString(),
|
|
2479
|
+
});
|
|
2480
|
+
await this.ctx.storage.put({ [APPLET_FOCUSED_KEY]: focused });
|
|
2481
|
+
return focused;
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
/**
|
|
2485
|
+
* Resolve the User's Applet directory into this Bot's next Composition
|
|
2486
|
+
* generation, before a Turn is admitted.
|
|
2487
|
+
*
|
|
2488
|
+
* Outside the admission transaction on purpose: the pin is taken in one
|
|
2489
|
+
* storage transaction, which cannot make a cross-object call. A publish or a
|
|
2490
|
+
* delete therefore activates at the *next* admitted Turn, and an in-flight
|
|
2491
|
+
* Turn keeps the set it pinned — which is exactly what ADR 0022 promises.
|
|
2492
|
+
* A directory that cannot be read leaves the Bot on the generation it has;
|
|
2493
|
+
* an Applet change is never a reason a Turn cannot start.
|
|
2494
|
+
*/
|
|
2495
|
+
private async resolveAppletComposition(
|
|
2496
|
+
identity: BotIdentity,
|
|
2497
|
+
command: OwnedBotTurnCommand,
|
|
2498
|
+
): Promise<void> {
|
|
2499
|
+
if (!this.env.APPLET_STATES) return;
|
|
2500
|
+
try {
|
|
2501
|
+
await resolveAppletCompositionV1({
|
|
2502
|
+
directory: this.appletUserDirectory(identity),
|
|
2503
|
+
composition: {
|
|
2504
|
+
current: () => this.authority.composition.current(),
|
|
2505
|
+
propose: (generation, options) =>
|
|
2506
|
+
this.authority.composition.propose(generation, options),
|
|
2507
|
+
},
|
|
2508
|
+
storage: {
|
|
2509
|
+
get: (key) => this.ctx.storage.get(key),
|
|
2510
|
+
put: (entries) => this.ctx.storage.put(entries),
|
|
2511
|
+
},
|
|
2512
|
+
origin: {
|
|
2513
|
+
kind: "bot-authored",
|
|
2514
|
+
runId: command.runId,
|
|
2515
|
+
sessionId: command.sessionId,
|
|
2516
|
+
turnId: command.runId,
|
|
2517
|
+
},
|
|
2518
|
+
});
|
|
2519
|
+
} catch {
|
|
2520
|
+
// Visible through the Applet's own failure records; never a wedged Turn.
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2051
2524
|
async isolateWorkspaceRead(
|
|
2052
2525
|
input: IsolateCallScopeV1,
|
|
2053
2526
|
): Promise<IsolateWorkspaceOutcomeV1> {
|
|
@@ -3634,6 +4107,13 @@ export class ShellBotBackendContribution {
|
|
|
3634
4107
|
user,
|
|
3635
4108
|
packages: packageDefinitions,
|
|
3636
4109
|
});
|
|
4110
|
+
// The durable roots this User's enabled Packages declare, read from the
|
|
4111
|
+
// same installations and the same compiled manifests the Composition is
|
|
4112
|
+
// resolved from. Handed to the Computer sync below; nothing else reads it.
|
|
4113
|
+
const packageRoots = declaredPackageRootsV1({
|
|
4114
|
+
installations: user.packages,
|
|
4115
|
+
packages: application.packages,
|
|
4116
|
+
});
|
|
3637
4117
|
const readSecret = (name: string) => {
|
|
3638
4118
|
// SAFETY: Worker secrets are dynamic string bindings not enumerable in Env.
|
|
3639
4119
|
const value = (this.env as unknown as Record<string, unknown>)[name];
|
|
@@ -3879,7 +4359,7 @@ export class ShellBotBackendContribution {
|
|
|
3879
4359
|
// object storage with an unattributed writer.
|
|
3880
4360
|
...(turn
|
|
3881
4361
|
? {
|
|
3882
|
-
computerSync: createBotComputerSyncHost(this.env),
|
|
4362
|
+
computerSync: createBotComputerSyncHost(this.env, packageRoots),
|
|
3883
4363
|
// The same Turn, as the writer a durable Computer write records.
|
|
3884
4364
|
computerWriter: {
|
|
3885
4365
|
sessionId: turn.sessionId,
|
|
@@ -5499,3 +5979,26 @@ export function createShellBotBackendPlugin(
|
|
|
5499
5979
|
): Plugin {
|
|
5500
5980
|
return () => lifecycle.mount(createShellBotBackendContribution(host));
|
|
5501
5981
|
}
|
|
5982
|
+
|
|
5983
|
+
/**
|
|
5984
|
+
* What an application hands this Contribution: the conversation surface and the Bot's Composition, under the
|
|
5985
|
+
* Package's own key so one wide host object can satisfy every Package's slice
|
|
5986
|
+
* without their fields colliding.
|
|
5987
|
+
*/
|
|
5988
|
+
export interface ShellBotApplicationHostV1 {
|
|
5989
|
+
shell: ShellBotBackendHost;
|
|
5990
|
+
}
|
|
5991
|
+
|
|
5992
|
+
/**
|
|
5993
|
+
* The manifest's `backend` entry, resolved by specifier. The
|
|
5994
|
+
* application looks this descriptor up in its Contribution table; it never
|
|
5995
|
+
* branches on which Package it belongs to.
|
|
5996
|
+
*/
|
|
5997
|
+
export const backendContribution = defineBotBackendContribution<
|
|
5998
|
+
ShellBotApplicationHostV1,
|
|
5999
|
+
ShellBotBackendContribution
|
|
6000
|
+
>({
|
|
6001
|
+
specifier: "@frockbot/plugin-shell/backend",
|
|
6002
|
+
create: (host, lifecycle) =>
|
|
6003
|
+
createShellBotBackendPlugin(host.shell, lifecycle),
|
|
6004
|
+
});
|