@frockbot/plugin-shell 0.3.1 → 0.3.3
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/agent.test.ts +15 -3
- 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 +66 -27
- package/src/backend-completion.ts +4 -2
- 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-recovery.ts +20 -12
- package/src/backend-runner-iframe.test.ts +10 -1
- package/src/backend-runner.ts +9 -2
- package/src/backend-stop.test.ts +6 -6
- package/src/backend-supersede.test.ts +377 -0
- package/src/backend.ts +567 -13
- package/src/client/AppletCanvas.vue +679 -0
- package/src/client/FrockBotApp.vue +195 -21
- 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 +221 -7
- package/src/client/index.ts +398 -6
- 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 +118 -1
- package/src/composition-views.ts +31 -6
- package/src/run-protocol.test.ts +92 -0
- package/src/run-protocol.ts +193 -17
- package/src/shared.ts +70 -0
- package/src/terminal-records.test.ts +52 -1
- package/src/terminal-records.ts +48 -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,
|
|
@@ -114,14 +119,36 @@ import {
|
|
|
114
119
|
eventsForFailedRun,
|
|
115
120
|
latestModelRequestJournalState,
|
|
116
121
|
planBotRunRecovery,
|
|
117
|
-
|
|
122
|
+
planInterruptedRunRecoveryV1,
|
|
118
123
|
} from "./backend-recovery.js";
|
|
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,
|
|
@@ -349,7 +379,10 @@ import {
|
|
|
349
379
|
projectPackageIframeCompositionV1,
|
|
350
380
|
} from "./composition-views.js";
|
|
351
381
|
import { executeBotTurn, executeDirectToolTurn } from "./backend-runner.js";
|
|
352
|
-
import {
|
|
382
|
+
import {
|
|
383
|
+
shellTerminalRecordsV1,
|
|
384
|
+
supersededTurnRecordsV1,
|
|
385
|
+
} from "./terminal-records.js";
|
|
353
386
|
import {
|
|
354
387
|
CLIENT_RUN_LIST_MAX_BYTES,
|
|
355
388
|
CLIENT_RUN_PAGE_LIMIT,
|
|
@@ -406,6 +439,7 @@ import {
|
|
|
406
439
|
type BotUnreadReceiptV1,
|
|
407
440
|
type BotUnreadViewV1,
|
|
408
441
|
} from "./unread.js";
|
|
442
|
+
import { defineBotBackendContribution } from "@frockbot/kernel-contracts/contributions";
|
|
409
443
|
|
|
410
444
|
export const BOT_CONFIGURATION_KEY = "bot-configuration";
|
|
411
445
|
const CONFIGURATION_RECEIPT_PREFIX = "configuration-receipt:";
|
|
@@ -441,7 +475,10 @@ interface StoredStopReceipt {
|
|
|
441
475
|
|
|
442
476
|
function isTerminalStoredRunStatus(status: StoredRunStatus): boolean {
|
|
443
477
|
return (
|
|
444
|
-
status === "completed" ||
|
|
478
|
+
status === "completed" ||
|
|
479
|
+
status === "failed" ||
|
|
480
|
+
status === "cancelled" ||
|
|
481
|
+
status === "superseded"
|
|
445
482
|
);
|
|
446
483
|
}
|
|
447
484
|
|
|
@@ -489,6 +526,13 @@ export interface BotStateEnv {
|
|
|
489
526
|
BOT_PACKAGES?: BotIsolateLoader;
|
|
490
527
|
/** Immutable, content-addressed Package artifacts, read hash-verified. */
|
|
491
528
|
APPLICATION_ARTIFACTS?: R2Bucket;
|
|
529
|
+
/**
|
|
530
|
+
* One Applet Durable Object per Applet instance (ADR 0022). Optional so a
|
|
531
|
+
* host without Applets still compiles; a Composition generation carrying an
|
|
532
|
+
* Applet member then fails verification, exactly as an isolate member does
|
|
533
|
+
* without a loader.
|
|
534
|
+
*/
|
|
535
|
+
APPLET_STATES?: AppletInstanceNamespaceV1;
|
|
492
536
|
/**
|
|
493
537
|
* The remote Package Catalog bucket. Read here only to index the Skills that
|
|
494
538
|
* arrived with the User's installed entries, at the generation each install
|
|
@@ -560,6 +604,13 @@ export interface ShellBotBackendHost {
|
|
|
560
604
|
* Durable Object has no honest way to dispatch one.
|
|
561
605
|
*/
|
|
562
606
|
subagents?: SubagentDurableBindingV1;
|
|
607
|
+
/**
|
|
608
|
+
* Immutable Package artifacts this bundle already carries, by object key.
|
|
609
|
+
*
|
|
610
|
+
* The application supplies these; the shell only hands them to the artifact
|
|
611
|
+
* store as a second place to look. See `createR2PackageArtifactStore`.
|
|
612
|
+
*/
|
|
613
|
+
bundledPackageArtifacts?: ReadonlyMap<string, string>;
|
|
563
614
|
invalidateComputerProjectionFile?(
|
|
564
615
|
userId: string,
|
|
565
616
|
botId: string,
|
|
@@ -611,6 +662,7 @@ export class ShellBotBackendContribution {
|
|
|
611
662
|
readonly ctx: DurableObjectState;
|
|
612
663
|
readonly env: BotStateEnv;
|
|
613
664
|
private readonly compileApplication: typeof compileFoundationApplication;
|
|
665
|
+
private readonly bundledPackageArtifacts?: ReadonlyMap<string, string>;
|
|
614
666
|
private readonly lifecycleAdmission?: ShellBotBackendHost["assertLifecycleActive"];
|
|
615
667
|
private readonly reconciliationActivities = new Map<
|
|
616
668
|
string,
|
|
@@ -632,7 +684,8 @@ export class ShellBotBackendContribution {
|
|
|
632
684
|
subagentRole?: string;
|
|
633
685
|
mounted: ShellMountedComposition;
|
|
634
686
|
signal: AbortSignal;
|
|
635
|
-
|
|
687
|
+
/** `detail` is recorded on the Turn's `turn/end`, never interpreted. */
|
|
688
|
+
cancel(detail?: string): void;
|
|
636
689
|
}
|
|
637
690
|
| undefined;
|
|
638
691
|
/**
|
|
@@ -676,6 +729,7 @@ export class ShellBotBackendContribution {
|
|
|
676
729
|
this.env = host.env;
|
|
677
730
|
this.compileApplication =
|
|
678
731
|
host.compileApplication ?? compileFoundationApplication;
|
|
732
|
+
this.bundledPackageArtifacts = host.bundledPackageArtifacts;
|
|
679
733
|
this.lifecycleAdmission = host.assertLifecycleActive;
|
|
680
734
|
this.outboundFetch = host.outboundFetch;
|
|
681
735
|
this.invalidateComputerProjectionFile =
|
|
@@ -715,6 +769,9 @@ export class ShellBotBackendContribution {
|
|
|
715
769
|
notification: (snapshot, result) =>
|
|
716
770
|
this.createNotification(snapshot, result),
|
|
717
771
|
terminalRecords: (input) => this.terminalPackageRecords(input),
|
|
772
|
+
supersededRecords: (input) => this.supersededPackageRecords(input),
|
|
773
|
+
interruptTurn: (runId, reason) =>
|
|
774
|
+
this.interruptActiveTurn(runId, reason),
|
|
718
775
|
scheduledDeadlines: (transaction) =>
|
|
719
776
|
this.scheduledDeadlines(transaction),
|
|
720
777
|
scheduledWorkInFlight: () =>
|
|
@@ -1059,6 +1116,12 @@ export class ShellBotBackendContribution {
|
|
|
1059
1116
|
}
|
|
1060
1117
|
|
|
1061
1118
|
async run(command: OwnedBotTurnCommand): Promise<ClientTurnV1> {
|
|
1119
|
+
// Before admission, so the pin this Turn takes already carries whatever the
|
|
1120
|
+
// User's Applet directory says now.
|
|
1121
|
+
await this.resolveAppletComposition(
|
|
1122
|
+
{ userId: command.userId, botId: command.botId },
|
|
1123
|
+
command,
|
|
1124
|
+
);
|
|
1062
1125
|
return projectClientTurnV1(await this.authority.run(command));
|
|
1063
1126
|
}
|
|
1064
1127
|
|
|
@@ -1141,7 +1204,34 @@ export class ShellBotBackendContribution {
|
|
|
1141
1204
|
const stored = await this.ctx.storage.get<AuthoredManifestRecordV1>(
|
|
1142
1205
|
authorshipManifestKey(member.manifestHash),
|
|
1143
1206
|
);
|
|
1144
|
-
|
|
1207
|
+
if (stored) return decodeFrockBotManifest(stored.manifest);
|
|
1208
|
+
return await this.readApplicationMemberManifest(member);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* The manifest of a member the *application* declared, not the Bot.
|
|
1213
|
+
*
|
|
1214
|
+
* `authorship:manifest:<hash>` is written by the authoring path and by a
|
|
1215
|
+
* Catalog install, so it exists for every member a Bot or its User put into
|
|
1216
|
+
* the Composition. A first-party artifact-backed member (ADR 0022 decision
|
|
1217
|
+
* 8) came from neither: it is in the compiled application, whose manifests
|
|
1218
|
+
* are already in this bundle. The `manifestHash` is still what decides —
|
|
1219
|
+
* the plan's manifest is accepted only when it hashes to exactly what the
|
|
1220
|
+
* generation recorded — so this is a second *place* to look, never a second
|
|
1221
|
+
* answer.
|
|
1222
|
+
*/
|
|
1223
|
+
private async readApplicationMemberManifest(
|
|
1224
|
+
member: CompositionMemberV1,
|
|
1225
|
+
): Promise<FrockBotManifest | undefined> {
|
|
1226
|
+
if (!member.artifact) return undefined;
|
|
1227
|
+
const application = await this.compileApplication();
|
|
1228
|
+
const declared = application.packages.find(
|
|
1229
|
+
(candidate) => candidate.id === member.packageId,
|
|
1230
|
+
);
|
|
1231
|
+
if (!declared) return undefined;
|
|
1232
|
+
const hash = await sha256(canonicalJson(declared.manifest));
|
|
1233
|
+
if (hash !== member.manifestHash) return undefined;
|
|
1234
|
+
return declared.manifest;
|
|
1145
1235
|
}
|
|
1146
1236
|
|
|
1147
1237
|
private async requireCompositionMemberManifest(
|
|
@@ -1184,6 +1274,59 @@ export class ShellBotBackendContribution {
|
|
|
1184
1274
|
* and its provenance says who put it there. The write goes through the same
|
|
1185
1275
|
* `writeSkillDocumentV1` the Bot's own `skill_write` uses, quota included.
|
|
1186
1276
|
*/
|
|
1277
|
+
/**
|
|
1278
|
+
* One file written into one of the User's durable roots, as the User.
|
|
1279
|
+
*
|
|
1280
|
+
* This is the stand-in for the Computer's sync in an environment that has
|
|
1281
|
+
* no Computer: an end-to-end run lands the bytes `applet build` would have
|
|
1282
|
+
* written, at the path the sync would have mirrored them to, through the
|
|
1283
|
+
* same store and with the same generation record. The writer is the User —
|
|
1284
|
+
* the authority the sync's `unattributed` mirror is *narrower* than — so
|
|
1285
|
+
* nothing here is a write the User could not have made from their own
|
|
1286
|
+
* Computer. A root belonging to another User is refused by the store.
|
|
1287
|
+
*/
|
|
1288
|
+
async writeUserWorkspaceFile(
|
|
1289
|
+
identity: BotIdentity,
|
|
1290
|
+
request: {
|
|
1291
|
+
root: WorkspaceRootV1;
|
|
1292
|
+
path: string;
|
|
1293
|
+
bytes: Uint8Array;
|
|
1294
|
+
mediaType?: string;
|
|
1295
|
+
},
|
|
1296
|
+
): Promise<
|
|
1297
|
+
| { status: "written"; generationId: string }
|
|
1298
|
+
| { status: "refused"; reason: string }
|
|
1299
|
+
> {
|
|
1300
|
+
await this.validateIdentity(identity);
|
|
1301
|
+
const files = (this.env as { WORKSPACE_FILES?: WorkspaceFilesV1 })
|
|
1302
|
+
.WORKSPACE_FILES;
|
|
1303
|
+
if (!files) {
|
|
1304
|
+
return { status: "refused", reason: "this Bot has no Workspace store" };
|
|
1305
|
+
}
|
|
1306
|
+
if (request.root.userId !== identity.userId) {
|
|
1307
|
+
return { status: "refused", reason: "the root belongs to another User" };
|
|
1308
|
+
}
|
|
1309
|
+
const path = { root: request.root, path: request.path };
|
|
1310
|
+
const existing = await files.stat(path);
|
|
1311
|
+
const outcome = await files.write({
|
|
1312
|
+
path,
|
|
1313
|
+
bytes: request.bytes,
|
|
1314
|
+
writer: { kind: "user", userId: identity.userId },
|
|
1315
|
+
expectedGenerationId:
|
|
1316
|
+
existing.status === "ok"
|
|
1317
|
+
? existing.entry.generation.generationId
|
|
1318
|
+
: null,
|
|
1319
|
+
...(request.mediaType ? { mediaType: request.mediaType } : {}),
|
|
1320
|
+
});
|
|
1321
|
+
if (outcome.status === "ok") {
|
|
1322
|
+
return {
|
|
1323
|
+
status: "written",
|
|
1324
|
+
generationId: outcome.generation.generationId,
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
return { status: "refused", reason: outcome.reason };
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1187
1330
|
async writeUserSkill(
|
|
1188
1331
|
identity: BotIdentity,
|
|
1189
1332
|
draft: { slug: string; name: string; description: string; body: string },
|
|
@@ -1406,6 +1549,21 @@ export class ShellBotBackendContribution {
|
|
|
1406
1549
|
// The isolate bindings follow the generation actually being mounted, so a
|
|
1407
1550
|
// fail-closed fallback loads the last known good's members, not the
|
|
1408
1551
|
// pinned generation's.
|
|
1552
|
+
// Applet tools route to the Applet Durable Object, which forwards to the
|
|
1553
|
+
// facet. The instance binding is minted once per Turn; the facet stub
|
|
1554
|
+
// itself never leaves that object.
|
|
1555
|
+
const appletInstances = this.env.APPLET_STATES
|
|
1556
|
+
? createAppletInstanceBindingV1(
|
|
1557
|
+
this.env.APPLET_STATES,
|
|
1558
|
+
input.identity.userId,
|
|
1559
|
+
)
|
|
1560
|
+
: undefined;
|
|
1561
|
+
const appletRouting: ShellAppletMountOptions | undefined = appletInstances
|
|
1562
|
+
? {
|
|
1563
|
+
invokeTool: (request) =>
|
|
1564
|
+
appletInstances(request.appletId).invokeTool(request),
|
|
1565
|
+
}
|
|
1566
|
+
: undefined;
|
|
1409
1567
|
const host: CompositionMountHost<ShellMountedComposition> = {
|
|
1410
1568
|
mount: async (mounting, signal) => {
|
|
1411
1569
|
const isolate = await this.isolateMountOptions(input.identity, {
|
|
@@ -1439,6 +1597,7 @@ export class ShellBotBackendContribution {
|
|
|
1439
1597
|
effect,
|
|
1440
1598
|
),
|
|
1441
1599
|
...(isolate ? { isolate } : {}),
|
|
1600
|
+
...(appletRouting ? { applets: appletRouting } : {}),
|
|
1442
1601
|
}).mount(mounting, signal);
|
|
1443
1602
|
mountedRoot = mounted.root;
|
|
1444
1603
|
mountedGeneration = mounted.generation;
|
|
@@ -1490,9 +1649,9 @@ export class ShellBotBackendContribution {
|
|
|
1490
1649
|
: {}),
|
|
1491
1650
|
mounted: activation.mounted,
|
|
1492
1651
|
signal: controller.signal,
|
|
1493
|
-
cancel: () => {
|
|
1652
|
+
cancel: (detail?: string) => {
|
|
1494
1653
|
controller.abort("user");
|
|
1495
|
-
activation.mounted.runtime.agent.agent.cancel("user");
|
|
1654
|
+
activation.mounted.runtime.agent.agent.cancel("user", detail);
|
|
1496
1655
|
},
|
|
1497
1656
|
};
|
|
1498
1657
|
this.activeTurn = active;
|
|
@@ -1506,10 +1665,14 @@ export class ShellBotBackendContribution {
|
|
|
1506
1665
|
"Package UI command does not match the mounted Composition generation",
|
|
1507
1666
|
);
|
|
1508
1667
|
}
|
|
1668
|
+
// Artifact-backed, not "not first-party": what makes a Package's page
|
|
1669
|
+
// able to name one of its tools is that the Package is loaded from an
|
|
1670
|
+
// immutable artifact with a manifest, which is exactly what ADR 0022
|
|
1671
|
+
// decision 8 gives a first-party Package too.
|
|
1509
1672
|
const member = activation.mounted.generation.members.find(
|
|
1510
1673
|
(candidate) =>
|
|
1511
1674
|
candidate.packageId === directTool.packageId &&
|
|
1512
|
-
candidate.
|
|
1675
|
+
candidate.artifact !== undefined,
|
|
1513
1676
|
);
|
|
1514
1677
|
if (!member)
|
|
1515
1678
|
throw new Error("Package UI command names an unavailable Package");
|
|
@@ -1597,6 +1760,7 @@ export class ShellBotBackendContribution {
|
|
|
1597
1760
|
private cancelActiveTurn(cancellation: {
|
|
1598
1761
|
sessionId: string;
|
|
1599
1762
|
runId: string;
|
|
1763
|
+
detail?: string;
|
|
1600
1764
|
}): boolean {
|
|
1601
1765
|
const active = this.activeTurn;
|
|
1602
1766
|
if (
|
|
@@ -1606,10 +1770,24 @@ export class ShellBotBackendContribution {
|
|
|
1606
1770
|
) {
|
|
1607
1771
|
return false;
|
|
1608
1772
|
}
|
|
1609
|
-
active.cancel();
|
|
1773
|
+
active.cancel(cancellation.detail);
|
|
1610
1774
|
return true;
|
|
1611
1775
|
}
|
|
1612
1776
|
|
|
1777
|
+
/**
|
|
1778
|
+
* The kernel's advisory interrupt, bound to this object's resident Agent.
|
|
1779
|
+
*
|
|
1780
|
+
* It runs only after the durable intent that justifies it is written, and it
|
|
1781
|
+
* changes nothing durable itself: a Turn whose Agent is no longer resident
|
|
1782
|
+
* is stopped by the effect fence on its next external effect instead, which
|
|
1783
|
+
* is the same outcome by a slower road.
|
|
1784
|
+
*/
|
|
1785
|
+
private interruptActiveTurn(runId: string, reason: string): void {
|
|
1786
|
+
const active = this.activeTurn;
|
|
1787
|
+
if (!active || active.runId !== runId) return;
|
|
1788
|
+
active.cancel(reason);
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1613
1791
|
/** The visible half of failing closed, through the Bot's notifications. */
|
|
1614
1792
|
private async recordCompositionFailureNotification(
|
|
1615
1793
|
settings: BotSettingsViewV1,
|
|
@@ -1665,7 +1843,10 @@ export class ShellBotBackendContribution {
|
|
|
1665
1843
|
runId: turn.runId,
|
|
1666
1844
|
turnId: turn.runId,
|
|
1667
1845
|
loader,
|
|
1668
|
-
artifacts: createR2PackageArtifactStore(
|
|
1846
|
+
artifacts: createR2PackageArtifactStore(
|
|
1847
|
+
artifacts,
|
|
1848
|
+
this.bundledPackageArtifacts,
|
|
1849
|
+
),
|
|
1669
1850
|
manifestFor: (member) => this.requireCompositionMemberManifest(member),
|
|
1670
1851
|
capabilitiesFor: (member) =>
|
|
1671
1852
|
mintCapabilities({
|
|
@@ -1688,6 +1869,7 @@ export class ShellBotBackendContribution {
|
|
|
1688
1869
|
bindingDigest: await isolateBindingDigestV1({
|
|
1689
1870
|
userId: identity.userId,
|
|
1690
1871
|
botId: identity.botId,
|
|
1872
|
+
runId: turn.runId,
|
|
1691
1873
|
connections: authority.connections,
|
|
1692
1874
|
...(authority.model ? { model: authority.model } : {}),
|
|
1693
1875
|
compositionGenerationId: turn.generationId,
|
|
@@ -2048,6 +2230,322 @@ export class ShellBotBackendContribution {
|
|
|
2048
2230
|
};
|
|
2049
2231
|
}
|
|
2050
2232
|
|
|
2233
|
+
// --- Applets (ADR 0022) --------------------------------------------------
|
|
2234
|
+
|
|
2235
|
+
/**
|
|
2236
|
+
* `ctx.applets` for one Bot, or `undefined` when this host cannot reach
|
|
2237
|
+
* Applets at all — no instance namespace, no artifact bucket, or no
|
|
2238
|
+
* Workspace. An absent capability is an `unavailable` outcome at the isolate
|
|
2239
|
+
* boundary, never a thrown error inside Bot code.
|
|
2240
|
+
*/
|
|
2241
|
+
private appletCapabilityHost(
|
|
2242
|
+
identity: BotIdentity,
|
|
2243
|
+
active?: NonNullable<ShellBotBackendContribution["activeTurn"]>,
|
|
2244
|
+
): AppletCapabilityHostV1 | undefined {
|
|
2245
|
+
const namespace = this.env.APPLET_STATES;
|
|
2246
|
+
const artifacts = this.env.APPLICATION_ARTIFACTS;
|
|
2247
|
+
const workspace = this.env.WORKSPACE_FILES;
|
|
2248
|
+
if (!namespace || !artifacts || !workspace) return undefined;
|
|
2249
|
+
const bucket = artifacts;
|
|
2250
|
+
return createAppletCapabilityHostV1({
|
|
2251
|
+
userId: identity.userId,
|
|
2252
|
+
botId: identity.botId,
|
|
2253
|
+
storage: {
|
|
2254
|
+
get: (key) => this.ctx.storage.get(key),
|
|
2255
|
+
put: (entries) => this.ctx.storage.put(entries),
|
|
2256
|
+
},
|
|
2257
|
+
directory: this.appletUserDirectory(identity),
|
|
2258
|
+
instanceFor: createAppletInstanceBindingV1(namespace, identity.userId),
|
|
2259
|
+
artifacts: {
|
|
2260
|
+
putPackageArtifact: async (contentHash, module) => {
|
|
2261
|
+
await bucket.put(`packages/${contentHash}.mjs`, module, {
|
|
2262
|
+
httpMetadata: { contentType: "application/javascript" },
|
|
2263
|
+
});
|
|
2264
|
+
},
|
|
2265
|
+
putPackageUiArtifact: async (contentHash, html) => {
|
|
2266
|
+
await bucket.put(`packages/${contentHash}.html`, html, {
|
|
2267
|
+
httpMetadata: { contentType: "text/html; charset=utf-8" },
|
|
2268
|
+
});
|
|
2269
|
+
},
|
|
2270
|
+
},
|
|
2271
|
+
workspace,
|
|
2272
|
+
// A publish reads `dist/` from the store, and `applet build` wrote it on
|
|
2273
|
+
// the Computer moments earlier in this very Turn — before the Turn's own
|
|
2274
|
+
// `turn-end` push. So the one root is reconciled first, through the one
|
|
2275
|
+
// sanctioned extra caller of the Computer's sync. It wakes nothing new: a
|
|
2276
|
+
// User with no Computer assignment has no root to pull, and the Bot that
|
|
2277
|
+
// just built on its Computer has it open already.
|
|
2278
|
+
syncSourceRootNow: active
|
|
2279
|
+
? async () => {
|
|
2280
|
+
const root = active.mounted.runtime.root as unknown as {
|
|
2281
|
+
computers?: ComputerRegistry;
|
|
2282
|
+
sessions: typeof active.mounted.runtime.root.sessions;
|
|
2283
|
+
};
|
|
2284
|
+
const computerIdentity = { userId: identity.userId };
|
|
2285
|
+
if (!root.computers?.assignment(computerIdentity)) return;
|
|
2286
|
+
const session = root.sessions.get(active.sessionId);
|
|
2287
|
+
const started = session?.events.findLast(
|
|
2288
|
+
(event) => event.type === "step/start",
|
|
2289
|
+
);
|
|
2290
|
+
const turn = started?.type === "step/start" ? started.turn : 0;
|
|
2291
|
+
const computer = await root.computers.open(
|
|
2292
|
+
computerIdentity,
|
|
2293
|
+
{ botId: identity.botId },
|
|
2294
|
+
{ signal: active.signal },
|
|
2295
|
+
);
|
|
2296
|
+
await syncWorkspaceRootNowV1({
|
|
2297
|
+
computer,
|
|
2298
|
+
sessions: root.sessions,
|
|
2299
|
+
sessionId: active.sessionId,
|
|
2300
|
+
turn,
|
|
2301
|
+
root: appletsSourceRootV1(identity.userId),
|
|
2302
|
+
signal: active.signal,
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
: undefined,
|
|
2306
|
+
composition: {
|
|
2307
|
+
current: () => this.authority.composition.current(),
|
|
2308
|
+
lastKnownGood: () => this.authority.composition.lastKnownGood(),
|
|
2309
|
+
propose: (generation, options) =>
|
|
2310
|
+
this.authority.composition.propose(generation, options),
|
|
2311
|
+
},
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
/** The User Durable Object's Applet directory, decoded on arrival. */
|
|
2316
|
+
private appletUserDirectory(identity: BotIdentity): AppletUserDirectoryV1 {
|
|
2317
|
+
const id = this.env.USER_CONFIGURATIONS.idFromName(identity.userId);
|
|
2318
|
+
// SAFETY: this namespace is bound to UserConfiguration; generated Worker
|
|
2319
|
+
// types do not expose its Applet directory RPC surface.
|
|
2320
|
+
const rpc = this.env.USER_CONFIGURATIONS.get(id) as unknown as {
|
|
2321
|
+
listApplets(input: unknown): Promise<unknown>;
|
|
2322
|
+
readAppletCompositionInput(input: unknown): Promise<unknown>;
|
|
2323
|
+
createApplet(input: unknown): Promise<unknown>;
|
|
2324
|
+
recordAppletGeneration(input: unknown): Promise<unknown>;
|
|
2325
|
+
deleteApplet(input: unknown): Promise<unknown>;
|
|
2326
|
+
};
|
|
2327
|
+
const userId = identity.userId;
|
|
2328
|
+
return {
|
|
2329
|
+
async list() {
|
|
2330
|
+
const answer = rpcJsonSnapshotV1(
|
|
2331
|
+
await rpc.listApplets({ schemaVersion: 1, userId }),
|
|
2332
|
+
) as { revision?: unknown; applets?: unknown };
|
|
2333
|
+
return {
|
|
2334
|
+
revision: Number(answer.revision ?? 0),
|
|
2335
|
+
applets: Array.isArray(answer.applets)
|
|
2336
|
+
? answer.applets.map((applet) => decodeAppletSummaryV1(applet))
|
|
2337
|
+
: [],
|
|
2338
|
+
};
|
|
2339
|
+
},
|
|
2340
|
+
async compositionInput() {
|
|
2341
|
+
const answer = rpcJsonSnapshotV1(
|
|
2342
|
+
await rpc.readAppletCompositionInput({ schemaVersion: 1, userId }),
|
|
2343
|
+
) as { revision?: unknown; applets?: unknown };
|
|
2344
|
+
return {
|
|
2345
|
+
revision: Number(answer.revision ?? 0),
|
|
2346
|
+
applets: (Array.isArray(answer.applets) ? answer.applets : []).map(
|
|
2347
|
+
(applet) => {
|
|
2348
|
+
const entry = applet as Record<string, unknown>;
|
|
2349
|
+
return {
|
|
2350
|
+
appletId: String(entry.appletId),
|
|
2351
|
+
generationId: String(entry.generationId),
|
|
2352
|
+
tools: (Array.isArray(entry.tools) ? entry.tools : []).map(
|
|
2353
|
+
(tool, index) =>
|
|
2354
|
+
decodeAppletToolDeclarationV1(
|
|
2355
|
+
tool,
|
|
2356
|
+
`Applet tool declaration[${index}]`,
|
|
2357
|
+
),
|
|
2358
|
+
),
|
|
2359
|
+
provenance: decodeAppletProvenanceV1(entry.provenance),
|
|
2360
|
+
};
|
|
2361
|
+
},
|
|
2362
|
+
),
|
|
2363
|
+
};
|
|
2364
|
+
},
|
|
2365
|
+
async create(input) {
|
|
2366
|
+
return decodeAppletSummaryV1(
|
|
2367
|
+
rpcJsonSnapshotV1(
|
|
2368
|
+
await rpc.createApplet({
|
|
2369
|
+
schemaVersion: 1,
|
|
2370
|
+
userId,
|
|
2371
|
+
displayName: input.displayName,
|
|
2372
|
+
provenance: input.provenance,
|
|
2373
|
+
}),
|
|
2374
|
+
),
|
|
2375
|
+
);
|
|
2376
|
+
},
|
|
2377
|
+
async recordGeneration(input) {
|
|
2378
|
+
return decodeAppletSummaryV1(
|
|
2379
|
+
rpcJsonSnapshotV1(
|
|
2380
|
+
await rpc.recordAppletGeneration({
|
|
2381
|
+
schemaVersion: 1,
|
|
2382
|
+
userId,
|
|
2383
|
+
appletId: input.appletId,
|
|
2384
|
+
generationId: input.generationId,
|
|
2385
|
+
tools: input.tools,
|
|
2386
|
+
}),
|
|
2387
|
+
),
|
|
2388
|
+
);
|
|
2389
|
+
},
|
|
2390
|
+
async delete(appletId) {
|
|
2391
|
+
return decodeAppletSummaryV1(
|
|
2392
|
+
rpcJsonSnapshotV1(
|
|
2393
|
+
await rpc.deleteApplet({ schemaVersion: 1, userId, appletId }),
|
|
2394
|
+
),
|
|
2395
|
+
);
|
|
2396
|
+
},
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
/**
|
|
2401
|
+
* The Applet capability at the isolate boundary. One RPC with an operation,
|
|
2402
|
+
* because seven near-identical forwarders would say nothing seven times; the
|
|
2403
|
+
* shapes are decoded here and the outcomes are declared, never thrown.
|
|
2404
|
+
*/
|
|
2405
|
+
async isolateApplets(
|
|
2406
|
+
input: IsolateCallScopeV1,
|
|
2407
|
+
): Promise<IsolateAppletsOutcomeV1> {
|
|
2408
|
+
const active = this.activeIsolateTurn(input);
|
|
2409
|
+
if (!active) {
|
|
2410
|
+
return {
|
|
2411
|
+
status: "unavailable",
|
|
2412
|
+
reason: "the Package is not running in this Bot's active Composition",
|
|
2413
|
+
};
|
|
2414
|
+
}
|
|
2415
|
+
const identity = { userId: input.userId, botId: input.botId };
|
|
2416
|
+
const host = this.appletCapabilityHost(identity, active);
|
|
2417
|
+
if (!host) {
|
|
2418
|
+
return { status: "unavailable", reason: "Applets are unavailable" };
|
|
2419
|
+
}
|
|
2420
|
+
const request = decodeIsolateAppletsRequestV1(input.request);
|
|
2421
|
+
const scope = {
|
|
2422
|
+
sessionId: input.sessionId,
|
|
2423
|
+
runId: input.runId,
|
|
2424
|
+
turnId: input.turnId,
|
|
2425
|
+
effectId: `applet:${input.turnId}:${request.op}:${
|
|
2426
|
+
"appletId" in request ? request.appletId : "new"
|
|
2427
|
+
}`,
|
|
2428
|
+
};
|
|
2429
|
+
try {
|
|
2430
|
+
switch (request.op) {
|
|
2431
|
+
case "list":
|
|
2432
|
+
return { status: "available", value: await host.list() };
|
|
2433
|
+
case "create":
|
|
2434
|
+
return {
|
|
2435
|
+
status: "available",
|
|
2436
|
+
value: await host.create(
|
|
2437
|
+
{ displayName: request.displayName },
|
|
2438
|
+
scope,
|
|
2439
|
+
),
|
|
2440
|
+
};
|
|
2441
|
+
case "publish":
|
|
2442
|
+
return {
|
|
2443
|
+
status: "available",
|
|
2444
|
+
value: await host.publish({ appletId: request.appletId }, scope),
|
|
2445
|
+
};
|
|
2446
|
+
case "revert":
|
|
2447
|
+
return {
|
|
2448
|
+
status: "available",
|
|
2449
|
+
value: await host.revert(
|
|
2450
|
+
{
|
|
2451
|
+
appletId: request.appletId,
|
|
2452
|
+
generationId: request.generationId,
|
|
2453
|
+
},
|
|
2454
|
+
scope,
|
|
2455
|
+
),
|
|
2456
|
+
};
|
|
2457
|
+
case "delete":
|
|
2458
|
+
return {
|
|
2459
|
+
status: "available",
|
|
2460
|
+
value: await host.delete({ appletId: request.appletId }),
|
|
2461
|
+
};
|
|
2462
|
+
case "focus":
|
|
2463
|
+
return {
|
|
2464
|
+
status: "available",
|
|
2465
|
+
value: await host.focus({ appletId: request.appletId }),
|
|
2466
|
+
};
|
|
2467
|
+
case "generations":
|
|
2468
|
+
return {
|
|
2469
|
+
status: "available",
|
|
2470
|
+
value: await host.generations({ appletId: request.appletId }),
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2473
|
+
} catch (error) {
|
|
2474
|
+
return {
|
|
2475
|
+
status: "unavailable",
|
|
2476
|
+
reason:
|
|
2477
|
+
error instanceof Error ? error.message : "the Applet call failed",
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
/** The Session's focused Applet, as the shell and its route read it. */
|
|
2483
|
+
async readFocusedApplet(identity: BotIdentity): Promise<FocusedAppletV1> {
|
|
2484
|
+
await this.validateIdentity(identity);
|
|
2485
|
+
const stored = await this.ctx.storage.get<unknown>(APPLET_FOCUSED_KEY);
|
|
2486
|
+
return stored === undefined
|
|
2487
|
+
? {
|
|
2488
|
+
schemaVersion: 1,
|
|
2489
|
+
appletId: null,
|
|
2490
|
+
changedAt: new Date(0).toISOString(),
|
|
2491
|
+
}
|
|
2492
|
+
: decodeFocusedAppletV1(stored);
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
async setFocusedApplet(
|
|
2496
|
+
identity: BotIdentity,
|
|
2497
|
+
appletId: string | null,
|
|
2498
|
+
): Promise<FocusedAppletV1> {
|
|
2499
|
+
await this.validateIdentity(identity);
|
|
2500
|
+
const focused = decodeFocusedAppletV1({
|
|
2501
|
+
schemaVersion: 1,
|
|
2502
|
+
appletId,
|
|
2503
|
+
changedAt: new Date().toISOString(),
|
|
2504
|
+
});
|
|
2505
|
+
await this.ctx.storage.put({ [APPLET_FOCUSED_KEY]: focused });
|
|
2506
|
+
return focused;
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
/**
|
|
2510
|
+
* Resolve the User's Applet directory into this Bot's next Composition
|
|
2511
|
+
* generation, before a Turn is admitted.
|
|
2512
|
+
*
|
|
2513
|
+
* Outside the admission transaction on purpose: the pin is taken in one
|
|
2514
|
+
* storage transaction, which cannot make a cross-object call. A publish or a
|
|
2515
|
+
* delete therefore activates at the *next* admitted Turn, and an in-flight
|
|
2516
|
+
* Turn keeps the set it pinned — which is exactly what ADR 0022 promises.
|
|
2517
|
+
* A directory that cannot be read leaves the Bot on the generation it has;
|
|
2518
|
+
* an Applet change is never a reason a Turn cannot start.
|
|
2519
|
+
*/
|
|
2520
|
+
private async resolveAppletComposition(
|
|
2521
|
+
identity: BotIdentity,
|
|
2522
|
+
command: OwnedBotTurnCommand,
|
|
2523
|
+
): Promise<void> {
|
|
2524
|
+
if (!this.env.APPLET_STATES) return;
|
|
2525
|
+
try {
|
|
2526
|
+
await resolveAppletCompositionV1({
|
|
2527
|
+
directory: this.appletUserDirectory(identity),
|
|
2528
|
+
composition: {
|
|
2529
|
+
current: () => this.authority.composition.current(),
|
|
2530
|
+
propose: (generation, options) =>
|
|
2531
|
+
this.authority.composition.propose(generation, options),
|
|
2532
|
+
},
|
|
2533
|
+
storage: {
|
|
2534
|
+
get: (key) => this.ctx.storage.get(key),
|
|
2535
|
+
put: (entries) => this.ctx.storage.put(entries),
|
|
2536
|
+
},
|
|
2537
|
+
origin: {
|
|
2538
|
+
kind: "bot-authored",
|
|
2539
|
+
runId: command.runId,
|
|
2540
|
+
sessionId: command.sessionId,
|
|
2541
|
+
turnId: command.runId,
|
|
2542
|
+
},
|
|
2543
|
+
});
|
|
2544
|
+
} catch {
|
|
2545
|
+
// Visible through the Applet's own failure records; never a wedged Turn.
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2051
2549
|
async isolateWorkspaceRead(
|
|
2052
2550
|
input: IsolateCallScopeV1,
|
|
2053
2551
|
): Promise<IsolateWorkspaceOutcomeV1> {
|
|
@@ -3634,6 +4132,13 @@ export class ShellBotBackendContribution {
|
|
|
3634
4132
|
user,
|
|
3635
4133
|
packages: packageDefinitions,
|
|
3636
4134
|
});
|
|
4135
|
+
// The durable roots this User's enabled Packages declare, read from the
|
|
4136
|
+
// same installations and the same compiled manifests the Composition is
|
|
4137
|
+
// resolved from. Handed to the Computer sync below; nothing else reads it.
|
|
4138
|
+
const packageRoots = declaredPackageRootsV1({
|
|
4139
|
+
installations: user.packages,
|
|
4140
|
+
packages: application.packages,
|
|
4141
|
+
});
|
|
3637
4142
|
const readSecret = (name: string) => {
|
|
3638
4143
|
// SAFETY: Worker secrets are dynamic string bindings not enumerable in Env.
|
|
3639
4144
|
const value = (this.env as unknown as Record<string, unknown>)[name];
|
|
@@ -3879,7 +4384,7 @@ export class ShellBotBackendContribution {
|
|
|
3879
4384
|
// object storage with an unattributed writer.
|
|
3880
4385
|
...(turn
|
|
3881
4386
|
? {
|
|
3882
|
-
computerSync: createBotComputerSyncHost(this.env),
|
|
4387
|
+
computerSync: createBotComputerSyncHost(this.env, packageRoots),
|
|
3883
4388
|
// The same Turn, as the writer a durable Computer write records.
|
|
3884
4389
|
computerWriter: {
|
|
3885
4390
|
sessionId: turn.sessionId,
|
|
@@ -4816,6 +5321,27 @@ export class ShellBotBackendContribution {
|
|
|
4816
5321
|
});
|
|
4817
5322
|
}
|
|
4818
5323
|
|
|
5324
|
+
/**
|
|
5325
|
+
* What a superseded Turn leaves for the Turn that replaced it.
|
|
5326
|
+
*
|
|
5327
|
+
* One durable input, drained once by the next conversational Turn. The
|
|
5328
|
+
* session log already carries what the Turn sent and what its tools
|
|
5329
|
+
* returned; this is the part that is not in the log — that it was cut off,
|
|
5330
|
+
* that nothing in flight completed, and that a subagent it dispatched is
|
|
5331
|
+
* still working. "A firing's outcome is delivered to the Bot's next
|
|
5332
|
+
* conversational Turn as durable input" and a superseded Turn's is too.
|
|
5333
|
+
*/
|
|
5334
|
+
private supersededPackageRecords(input: {
|
|
5335
|
+
run: StoredRun;
|
|
5336
|
+
read<T>(key: string): Promise<T | undefined>;
|
|
5337
|
+
}): Promise<Record<string, unknown>> {
|
|
5338
|
+
return supersededTurnRecordsV1({
|
|
5339
|
+
run: input.run,
|
|
5340
|
+
now: new Date().toISOString(),
|
|
5341
|
+
read: input.read,
|
|
5342
|
+
});
|
|
5343
|
+
}
|
|
5344
|
+
|
|
4819
5345
|
async alarm(): Promise<void> {
|
|
4820
5346
|
// One alarm: the kernel defers while work is in flight, settles Package
|
|
4821
5347
|
// scheduled work, and recovers the active run. A run left
|
|
@@ -5473,7 +5999,12 @@ export class ShellBotBackendContribution {
|
|
|
5473
5999
|
`effect admission "${effect.effectId}" does not match durable intent`,
|
|
5474
6000
|
);
|
|
5475
6001
|
}
|
|
5476
|
-
|
|
6002
|
+
// Supersede fences exactly as Stop does. It is what makes an interrupt
|
|
6003
|
+
// durable rather than advisory: a Turn whose Agent never got the signal
|
|
6004
|
+
// — because the object was evicted and resumed — still starts no new
|
|
6005
|
+
// provider call or tool effect once the intent is recorded.
|
|
6006
|
+
const outcome =
|
|
6007
|
+
run.stopRequestedAt || run.supersededAt ? "fenced" : "admitted";
|
|
5477
6008
|
const next = requireStoredRunV1({
|
|
5478
6009
|
...run,
|
|
5479
6010
|
effectAdmissions: [
|
|
@@ -5499,3 +6030,26 @@ export function createShellBotBackendPlugin(
|
|
|
5499
6030
|
): Plugin {
|
|
5500
6031
|
return () => lifecycle.mount(createShellBotBackendContribution(host));
|
|
5501
6032
|
}
|
|
6033
|
+
|
|
6034
|
+
/**
|
|
6035
|
+
* What an application hands this Contribution: the conversation surface and the Bot's Composition, under the
|
|
6036
|
+
* Package's own key so one wide host object can satisfy every Package's slice
|
|
6037
|
+
* without their fields colliding.
|
|
6038
|
+
*/
|
|
6039
|
+
export interface ShellBotApplicationHostV1 {
|
|
6040
|
+
shell: ShellBotBackendHost;
|
|
6041
|
+
}
|
|
6042
|
+
|
|
6043
|
+
/**
|
|
6044
|
+
* The manifest's `backend` entry, resolved by specifier. The
|
|
6045
|
+
* application looks this descriptor up in its Contribution table; it never
|
|
6046
|
+
* branches on which Package it belongs to.
|
|
6047
|
+
*/
|
|
6048
|
+
export const backendContribution = defineBotBackendContribution<
|
|
6049
|
+
ShellBotApplicationHostV1,
|
|
6050
|
+
ShellBotBackendContribution
|
|
6051
|
+
>({
|
|
6052
|
+
specifier: "@frockbot/plugin-shell/backend",
|
|
6053
|
+
create: (host, lifecycle) =>
|
|
6054
|
+
createShellBotBackendPlugin(host.shell, lifecycle),
|
|
6055
|
+
});
|