@frockbot/cloudflare 0.0.0 → 0.7.292

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/build-artifact.ts +130 -0
  2. package/build-flutter-web.ts +508 -0
  3. package/deployment-config/README.md +414 -0
  4. package/deployment-config/cli.ts +228 -0
  5. package/deployment-config/generate.ts +689 -0
  6. package/deployment-config/jsonc.ts +18 -0
  7. package/deployment-config/profile-schema.generated.ts +391 -0
  8. package/deployment-config/profile.schema.json +358 -0
  9. package/deployment-config/profile.ts +124 -0
  10. package/migrations/0001_better_auth.sql +15 -0
  11. package/migrations/0002_drop_account_issuer.sql +8 -0
  12. package/package.json +61 -5
  13. package/release-version.ts +20 -0
  14. package/src/account-admission.ts +179 -0
  15. package/src/account-deletion.ts +274 -0
  16. package/src/admin-entrypoint.ts +194 -0
  17. package/src/admin-identities.ts +26 -0
  18. package/src/audit.ts +103 -0
  19. package/src/auth-package.access.ts +24 -0
  20. package/src/auth-package.ts +36 -0
  21. package/src/avatar-state-cleanup.ts +107 -0
  22. package/src/billing-computer.ts +267 -0
  23. package/src/billing-readiness.ts +21 -0
  24. package/src/billing.ts +461 -0
  25. package/src/bot-capabilities.ts +480 -0
  26. package/src/bot-recovery.ts +1 -0
  27. package/src/bot-state-channel.ts +702 -0
  28. package/src/bot-state.ts +3995 -0
  29. package/src/bot-template-cleanup.ts +98 -0
  30. package/src/bot-title-cleanup.ts +104 -0
  31. package/src/brand-icon.png +0 -0
  32. package/src/brand-logo.ts +2 -0
  33. package/src/brand.ts +36 -0
  34. package/src/client-compatibility.ts +51 -0
  35. package/src/compaction-announcement-cleanup.ts +113 -0
  36. package/src/computer-egress.ts +110 -0
  37. package/src/computer-host.ts +92 -0
  38. package/src/computer-screenshot-cleanup.ts +78 -0
  39. package/src/contracts.ts +832 -0
  40. package/src/debug.ts +272 -0
  41. package/src/default-packages-marker-cleanup.ts +39 -0
  42. package/src/deployment-policy-admin-host.ts +121 -0
  43. package/src/deployment-policy.ts +426 -0
  44. package/src/directory-profile-cleanup.ts +101 -0
  45. package/src/durable-rpc.ts +753 -0
  46. package/src/durable-session.ts +21 -0
  47. package/src/entry-boundary.ts +103 -0
  48. package/src/frock-ai.ts +338 -0
  49. package/src/gateway.ts +1392 -0
  50. package/src/group-chat.ts +736 -0
  51. package/src/hidden-bot-notifications-cleanup.ts +32 -0
  52. package/src/index.ts +2798 -0
  53. package/src/insights.ts +15 -0
  54. package/src/machine-messages-cleanup.ts +121 -0
  55. package/src/machine-socket.ts +164 -0
  56. package/src/memory-records.ts +181 -0
  57. package/src/memory.ts +145 -0
  58. package/src/model-rates.ts +252 -0
  59. package/src/native-auth.ts +1006 -0
  60. package/src/native-sessions.ts +121 -0
  61. package/src/notification-state-cleanup.ts +18 -0
  62. package/src/ollama-web-search-cleanup.ts +69 -0
  63. package/src/package-page-shapes-cleanup.ts +190 -0
  64. package/src/plugin-egress.ts +31 -0
  65. package/src/plugin-page-route.ts +72 -0
  66. package/src/plugin-panels-cleanup.ts +101 -0
  67. package/src/prepared-input-cleanup.ts +105 -0
  68. package/src/production-secrets.ts +484 -0
  69. package/src/project-cleanup.ts +123 -0
  70. package/src/project-events-cleanup.ts +152 -0
  71. package/src/publication-state-cleanup.ts +92 -0
  72. package/src/push.ts +498 -0
  73. package/src/request-body.ts +165 -0
  74. package/src/routine-state-cleanup.ts +57 -0
  75. package/src/search.ts +88 -0
  76. package/src/sidebar-label-cleanup.ts +165 -0
  77. package/src/skill-index-cleanup.ts +53 -0
  78. package/src/supersede-cleanup.ts +162 -0
  79. package/src/test-chat-cleanup.ts +93 -0
  80. package/src/uploads.ts +486 -0
  81. package/src/user-application.ts +1068 -0
  82. package/src/user-configuration.ts +4491 -0
  83. package/src/voice-assistant.ts +4999 -0
  84. package/src/voice-dictation.ts +743 -0
  85. package/src/working-context-cleanup.ts +96 -0
  86. package/src/workspace.ts +201 -0
  87. package/wrangler.jsonc +385 -0
  88. package/README.md +0 -3
@@ -0,0 +1,3995 @@
1
+ import { prepaidComputerHost } from "./billing-computer.js";
2
+ import type { UsageAttributionV1 } from "@frockbot/app/billing/ledger";
3
+ import { runCauseV1 } from "@frockbot/app/billing/run-cause";
4
+ import { runCauseReadersV1 } from "@frockbot/app/shell/run-cause";
5
+ import {
6
+ createHostedModelRatesReaderV1,
7
+ type HostedModelRatesAuthorityV1,
8
+ } from "./model-rates.js";
9
+ import { DEPLOYMENT_POLICY_SINGLETON_NAME } from "./deployment-policy.js";
10
+ import type { BillingAccountRpc } from "./billing.js";
11
+ import {
12
+ hostedBillingEnabledV1,
13
+ type BillingSwitchEnv,
14
+ } from "./billing-readiness.js";
15
+ import { cleanNotificationTestState } from "./notification-state-cleanup.js";
16
+ import { cleanHiddenBotNotifications } from "./hidden-bot-notifications-cleanup.js";
17
+ import { cleanRetiredRoutineStateV1 } from "./routine-state-cleanup.js";
18
+ import { cleanUnpreparedRunsV1 } from "./prepared-input-cleanup.js";
19
+ import { cleanSupersedeStateV1 } from "./supersede-cleanup.js";
20
+ import { cleanBotLabelV1 } from "./sidebar-label-cleanup.js";
21
+ import { cleanBotTitleV1 } from "./bot-title-cleanup.js";
22
+ import { cleanProjectEventsV1 } from "./project-events-cleanup.js";
23
+ import { cleanUndecodableSkillIndexesV1 } from "./skill-index-cleanup.js";
24
+ import {
25
+ messageIdV1,
26
+ visibleMessageRecordsV1,
27
+ type MessageNotice,
28
+ } from "@frockbot/app/notifications/messages";
29
+ import {
30
+ PUSH_OUTBOX_DRAIN_LIMIT,
31
+ PUSH_OUTBOX_PREFIX,
32
+ PUSH_READ_KEY,
33
+ } from "@frockbot/app/notifications/storage-keys";
34
+ import {
35
+ UNREAD_STATE_KEY,
36
+ optionalUnreadStateV1,
37
+ } from "@frockbot/app/shell/unread";
38
+ import type { PushUpdate } from "./push.js";
39
+ import { cleanIncidentTestChatsV1 } from "./test-chat-cleanup.js";
40
+ import { cleanBotAvatarTestState } from "./avatar-state-cleanup.js";
41
+ import { cleanBotProfileMirrorTestState } from "./directory-profile-cleanup.js";
42
+ import { cleanRetiredPublicationStateV1 } from "./publication-state-cleanup.js";
43
+ import { cleanCompactionAnnouncementsV1 } from "./compaction-announcement-cleanup.js";
44
+ import { projectUnprojectedSessionsV1 } from "./working-context-cleanup.js";
45
+ import { cleanRetiredComputerScreenshotsV1 } from "./computer-screenshot-cleanup.js";
46
+ import { createComputerLoginVaultV1 } from "@frockbot/app/shell/computer-logins";
47
+ import { computerBotPathKeyV1 } from "@frockbot/computer/core/bot-path";
48
+ import {
49
+ decodeStoredComputerFrameV1,
50
+ type StoredComputerFrameV1,
51
+ } from "@frockbot/computer/frame";
52
+ import {
53
+ deliverProfileMirrorV1,
54
+ PROFILE_MIRROR_KEY_V1,
55
+ } from "@frockbot/app/flock/profile-mirror";
56
+ import { DurableObject } from "cloudflare:workers";
57
+ import {
58
+ BotStateChannel,
59
+ BOT_STATE_CHANNEL_INTERNAL_PATH,
60
+ } from "./bot-state-channel.js";
61
+ import { foundationShellApplicationV1 } from "@frockbot/app/runtime";
62
+ import { BRAND_V1 } from "#brand";
63
+ import {
64
+ computerBotContribution,
65
+ createFoundationBackendContributions,
66
+ createFoundationMountedContributionsV1,
67
+ flockBotContribution,
68
+ backendDescriptorsV1,
69
+ shellBotContribution,
70
+ } from "@frockbot/app/contributions";
71
+ import { ComputerRegistry } from "@frockbot/computer/core/host";
72
+ import {
73
+ mountRuntimeFeaturesV1,
74
+ type TurnSupervisor,
75
+ } from "@frockbot/core/contracts";
76
+ import {
77
+ computerHostBindingV1,
78
+ createComputerHostV1,
79
+ } from "./computer-host.js";
80
+ import type { ShellComputerHostOptionsV1 } from "@frockbot/app/shell/backend-runtime";
81
+ import {
82
+ decodeBotConfigurationExecuteRpcV1,
83
+ decodeBotConfigurationReadRpcV1,
84
+ decodeUserSettingsViewV1,
85
+ decodeCompositionGenerationIdV1,
86
+ decodeRevertCompositionCommandV1,
87
+ MAX_COMPOSITION_GENERATION_PAGE_V1,
88
+ userTimezoneV1,
89
+ type BotSettingsViewV1,
90
+ type RevertCompositionCommandV1,
91
+ } from "@frockbot/core/configuration";
92
+ import {
93
+ BotDurableAuthority,
94
+ IDENTITY_KEY,
95
+ type BotIdentity,
96
+ type BotTurnCommand,
97
+ } from "@frockbot/core/durable";
98
+ import {
99
+ keepBotFilesV1,
100
+ recordUploadV1,
101
+ removeBotFilesV1,
102
+ resolveUploadRefsV1,
103
+ } from "@frockbot/app/uploads/bot";
104
+ import {
105
+ decodeStoredUploadV1,
106
+ type StoredUploadV1,
107
+ } from "@frockbot/app/uploads/shared";
108
+ import {
109
+ botFileStoreV1,
110
+ deleteBotUploadsV1,
111
+ releaseBotUploadQuotaRpcV1,
112
+ } from "./uploads.js";
113
+ import type {
114
+ OwnedBotTurnCommand,
115
+ ShellBotBackendContribution,
116
+ } from "@frockbot/app/shell/backend";
117
+ import type { BotStateEnv } from "@frockbot/app/shell/backend-state";
118
+ import type { BotStateRpcTargetV1 } from "@frockbot/app/shell/durable-rpc-targets";
119
+ import { createBindingEmailSenderV1 } from "@frockbot/app/email/sender";
120
+ import {
121
+ acceptSubagentTask,
122
+ claimTaskMessages,
123
+ listTasks,
124
+ readSubagentTaskContext,
125
+ readTask,
126
+ settleTask,
127
+ stopSubagentTask,
128
+ stopTaskForUser,
129
+ } from "@frockbot/app/subagents/bot";
130
+ import {
131
+ isolateConnection,
132
+ isolateModelTransport,
133
+ isolateAuthority,
134
+ isolateInvokeModel,
135
+ isolateSettings,
136
+ isolateStorageDelete,
137
+ isolateStorageGet,
138
+ isolateStorageList,
139
+ isolateStoragePut,
140
+ type IsolateCallScopeV1,
141
+ isolateMemoryForget,
142
+ isolateJevDecide,
143
+ isolateMemoryRead,
144
+ isolateMemoryWrite,
145
+ isolateEmail,
146
+ isolateSchedule,
147
+ isolateWorkspaceDelete,
148
+ isolateWorkspaceList,
149
+ isolateWorkspaceRead,
150
+ isolateWorkspaceStat,
151
+ isolateWorkspaceWrite,
152
+ } from "@frockbot/app/isolates/bot";
153
+ import { isolateDeviceCall } from "@frockbot/app/isolates/device";
154
+ import { deliverMachineResult } from "@frockbot/app/machine/bot";
155
+ import { answerComputerEgressV1 } from "@frockbot/computer/egress";
156
+ import { decodeComputerEgressAnswerInputV1 } from "./computer-egress.js";
157
+ import { listSkills } from "@frockbot/app/skills/bot";
158
+ import {
159
+ archiveEligible,
160
+ refreshScheduledWork,
161
+ } from "@frockbot/app/shell/identity";
162
+ import { stopRun } from "@frockbot/app/shell/turn";
163
+ import {
164
+ isVoiceReplyOutboxEntryV1,
165
+ VOICE_REPLY_OUTBOX_PREFIX_V1,
166
+ } from "@frockbot/app/shell/voice-reply";
167
+ import {
168
+ acknowledgeNotification,
169
+ listNotifications,
170
+ } from "@frockbot/app/notifications/bot";
171
+ import {
172
+ connectionTriggersFromUserV1,
173
+ pluginTriggerIndexFromUserV1,
174
+ deliverConnectEvent,
175
+ deliverRoutineModuleEvent,
176
+ listPluginTriggerRoutines,
177
+ deliverRoutineHook,
178
+ executeRoutineCommand,
179
+ executeRoutineInboxCommand,
180
+ listRoutineInbox,
181
+ listRoutineRuns,
182
+ listRoutines,
183
+ projectRoutineAccountTimezoneV1,
184
+ readRoutinesFrame,
185
+ readRoutineRun,
186
+ } from "@frockbot/app/routines/bot";
187
+ import {
188
+ BOT_CONFIGURATION_KEY,
189
+ executeConfiguration,
190
+ readBotSettingsV1,
191
+ readConfiguration,
192
+ resolveConfiguration,
193
+ userConfigurationV1,
194
+ } from "@frockbot/app/settings/bot";
195
+ import { decideApproval, listApprovals } from "@frockbot/app/approvals/bot";
196
+ import { cardAction, listCards, readCardView } from "@frockbot/app/cards/bot";
197
+ import { submitSecretV1 } from "@frockbot/app/secrets/bot";
198
+ import {
199
+ decodeSecretSubmitCommandV1,
200
+ type SecretSubmitCommandV1,
201
+ } from "@frockbot/app/secrets/shared";
202
+ import {
203
+ getCompositionGeneration,
204
+ listCompositionGenerations,
205
+ revertComposition,
206
+ } from "@frockbot/app/shell/composition-views";
207
+ import { executeUnreadCommand, readUnread } from "@frockbot/app/shell/unread";
208
+ import { appendAnnouncement } from "@frockbot/app/shell/reads";
209
+ import {
210
+ isBotTombstoneV1,
211
+ type FlockBotBackendContribution,
212
+ } from "@frockbot/app/flock/bot";
213
+ import type { ComputerBotBackendContribution } from "@frockbot/computer/bot";
214
+ import { decodeComputerCommandV1 } from "@frockbot/computer/protocol";
215
+ import {
216
+ decodeBotLifecycleCommandV1,
217
+ decodeBotRegistrationV1,
218
+ decodeUpdateAvatarCommandV1,
219
+ decodeUpdateVoiceCommandV1,
220
+ decodeUpdateLookCommandV1,
221
+ decodeLookIdentityViewV1,
222
+ type BotLifecycleCommandV1,
223
+ type BotRegistrationV1,
224
+ } from "@frockbot/app/flock/shared";
225
+ import { decodeBotDebugQueryV1 } from "@frockbot/app/shell/debug-protocol";
226
+ import {
227
+ decodeClientRunListQueryV1,
228
+ decodeClientRunLookupQueryV1,
229
+ decodeClientRunStopCommandV1,
230
+ type ClientRunListQueryV1,
231
+ type ClientRunLookupQueryV1,
232
+ type ClientRunStopCommandV1,
233
+ } from "@frockbot/app/shell/run-protocol";
234
+ import {
235
+ decodeBotUnreadCommandV1,
236
+ type BotUnreadCommandV1,
237
+ } from "@frockbot/app/shell/unread";
238
+ import {
239
+ decodeApprovalDecisionCommandV1,
240
+ type ApprovalDecisionCommandV1,
241
+ } from "@frockbot/app/shell/approvals";
242
+ import {
243
+ decodeCardActionCommandV1,
244
+ decodeCardSurfaceIdV1,
245
+ type CardActionCommandV1,
246
+ } from "@frockbot/app/shell/cards";
247
+ import {
248
+ decodeIsolateJevRequestV1,
249
+ decodeIsolateMemoryReadRequestV1,
250
+ decodeIsolateStorageDeleteRequestV1,
251
+ decodeIsolateStorageGetRequestV1,
252
+ decodeIsolateStorageListRequestV1,
253
+ decodeIsolateStoragePutRequestV1,
254
+ decodeIsolateMemoryWriteRequestV1,
255
+ decodeIsolateDeviceCallRequestV1,
256
+ decodeIsolateEmailRequestV1,
257
+ decodeIsolateScheduleRequestV1,
258
+ decodeIsolateWorkspaceDeleteRequestV1,
259
+ decodeIsolateWorkspaceListRequestV1,
260
+ decodeIsolateWorkspacePathV1,
261
+ decodeIsolateWorkspaceWriteRequestV1,
262
+ decodeNormalizedModelRequestV1,
263
+ } from "@frockbot/core/contracts";
264
+ import type {
265
+ NormalizedModelRequest,
266
+ WorkspaceFilesV1,
267
+ WorkspaceGenerationsV1,
268
+ WorkspacePathV1,
269
+ WorkspaceRootV1,
270
+ WorkspaceSyncEffectsV1,
271
+ } from "@frockbot/core/contracts";
272
+ import { decodeWorkspacePathV1 } from "@frockbot/core/contracts";
273
+
274
+ /** Base64 without a Node Buffer: this object runs in workerd. */
275
+ function bytesToBase64(bytes: Uint8Array): string {
276
+ let binary = "";
277
+ for (const byte of bytes) binary += String.fromCharCode(byte);
278
+ return btoa(binary);
279
+ }
280
+ import {
281
+ decodeRoutineCommandV1,
282
+ decodeRoutineInboxCommandV1,
283
+ type RoutineCommandV1,
284
+ type RoutineInboxCommandV1,
285
+ } from "@frockbot/app/routines/shared";
286
+ import {
287
+ decodeRoutineHookDeliveryV1,
288
+ ROUTINE_HOOK_BODY_MAX_BYTES,
289
+ type RoutineHookDeliveryV1,
290
+ } from "@frockbot/app/routines/hook";
291
+ import type { RoutineModuleEventDeliveryV1 } from "@frockbot/app/routines/store";
292
+ import {
293
+ decodeSubagentRunTaskRequestV1,
294
+ type SubagentRunTaskRequestV1,
295
+ } from "@frockbot/app/subagents/durable-binding";
296
+ import {
297
+ decodeTaskOutcomeV1,
298
+ type TaskOutcomeV1,
299
+ } from "@frockbot/app/subagents/records";
300
+ import {
301
+ decodeMachineResultDeliveryV1,
302
+ type MachineResultDeliveryV1,
303
+ } from "@frockbot/app/machine/delivery";
304
+ import {
305
+ createDurableWorkspaceFilesV1,
306
+ createR2ObjectBucketV1,
307
+ deleteBotWorkspaceRootsV1,
308
+ } from "./workspace.js";
309
+ import {
310
+ publishWorkspaceSkillGenerationV1,
311
+ readDurableSkillIndexV1,
312
+ } from "@frockbot/app/skills/index-store";
313
+ import { decodeSkillMetadataIndexV1 } from "@frockbot/app/skills/metadata-index";
314
+ import { reseedInstructionRootV1 } from "@frockbot/app/skills/reseed";
315
+ import {
316
+ workspaceObjectPrefixV1,
317
+ type WorkspaceGenerationPublicationV1,
318
+ } from "@frockbot/core/workspace-store";
319
+ import { cleanRetiredMemoryFactObjectsV1 } from "@frockbot/app/memory/cleanup";
320
+ import type { ClientWorkspaceFileV1 } from "./contracts.js";
321
+ import {
322
+ DurableWorkspaceGenerations,
323
+ DurableWorkspaceSyncEffects,
324
+ } from "@frockbot/core/durable";
325
+ import type { MemoryGroupsV1 } from "@frockbot/app/memory/groups";
326
+ import { memoryMembershipRevisionV1 } from "@frockbot/app/memory/engine-tools";
327
+ import {
328
+ decodeMemoryChunkIndexEntryV1,
329
+ memoryChunkIndexEntriesV1,
330
+ MEMORY_CHUNK_INDEX_PREFIX_V1,
331
+ type MemoryChunkIndexWriterV1,
332
+ } from "@frockbot/app/memory/chunk-index";
333
+ import {
334
+ searchRowsFromClientRunV1,
335
+ type SearchSinkV1,
336
+ } from "@frockbot/app/search";
337
+ import {
338
+ createBotSearchRowPageV1,
339
+ createUserSearchSinkV1,
340
+ type UserSearchRpc,
341
+ } from "./search.js";
342
+ import {
343
+ AuditOutboxV1,
344
+ auditEntriesFromStoredRunV1,
345
+ auditEntryForDeviceUseV1,
346
+ decodeDeviceUseV1,
347
+ DEVICE_USE_PAGE_DONE_V1,
348
+ DeviceUseLogV1,
349
+ type AuditSinkV1,
350
+ type DeviceUseV1,
351
+ } from "@frockbot/app/audit";
352
+ import {
353
+ createBotAuditEntryPageV1,
354
+ createUserAuditSinkV1,
355
+ type UserAuditRpc,
356
+ } from "./audit.js";
357
+ import {
358
+ createRoutedWorkspaceGenerationsV1,
359
+ createUserMemoryGroupsV1,
360
+ createUserWorkspaceGenerationsV1,
361
+ type UserMemoryRpc,
362
+ } from "./memory.js";
363
+ import { createMemoryEmbedder } from "@frockbot/app/memory/embeddings";
364
+ import { MemoryRecordsV1 } from "@frockbot/app/memory/owner";
365
+ import { createVectorMemorySearchV1 } from "@frockbot/app/memory/semantic";
366
+ import { createHostedMemoryExtractorV1 } from "@frockbot/app/memory/extraction-host";
367
+ import {
368
+ createBotMemoryEngineV1,
369
+ createUserMemoryRecordsRemoteV1,
370
+ drainDurableMemoryV1,
371
+ durableObjectHasSqlV1,
372
+ } from "./memory-records.js";
373
+ import type { MemoryEngineV1 } from "@frockbot/app/memory/engine";
374
+ import type {
375
+ MemoryAiBinding,
376
+ MemoryVectorIndex,
377
+ } from "@frockbot/app/memory/types";
378
+ import {
379
+ createFrockAiGatewayHostV1,
380
+ type FrockAiGatewayHostV1,
381
+ } from "./frock-ai.js";
382
+ import {
383
+ decodeBotAgentRunRpcV1,
384
+ decodeBotRunRpcV1,
385
+ type DecodedBotRunRpcV1,
386
+ decodeBotVoiceRunRpcV1,
387
+ decodeVoiceChatResultRpcV1,
388
+ decodeVoiceCallTranscriptRpcV1,
389
+ decodeBotGroupTurnRpcV1,
390
+ decodeBotEmailTurnRpcV1,
391
+ decodeRpcEnvelopeV1,
392
+ rpcBoolean,
393
+ rpcBotId,
394
+ rpcDecoded,
395
+ rpcEnum,
396
+ rpcIdentifier,
397
+ rpcInteger,
398
+ rpcJsonSnapshotV1,
399
+ rpcObject,
400
+ rpcOrigin,
401
+ rpcPattern,
402
+ rpcPluginIdOrNull,
403
+ rpcString,
404
+ } from "./durable-rpc.js";
405
+ import { answeredEntryV1, loggedEntryV1 } from "./entry-boundary.js";
406
+ import {
407
+ PLUGIN_ID_MAX_LENGTH_V1,
408
+ PLUGIN_ID_V1,
409
+ PluginEnablementConflictError,
410
+ readPluginEnablementV1,
411
+ setPluginEnabledV1,
412
+ } from "@frockbot/app/plugins/enablement";
413
+ import {
414
+ readBotPluginsFrameV1,
415
+ setBotPluginEnabledV1,
416
+ } from "@frockbot/app/plugins/bot";
417
+ import {
418
+ decodePluginToolCommandV1,
419
+ decodeSetBotPluginEnabledCommandV1,
420
+ } from "@frockbot/app/plugins/page";
421
+ import { executeBotPluginToolV1 } from "@frockbot/app/plugins/views-bot";
422
+ import {
423
+ applyPanelFocusV1,
424
+ openFocusedPanelV1,
425
+ panelDeviceUserV1,
426
+ readFocusedPanelV1,
427
+ } from "@frockbot/app/plugins/panels-bot";
428
+ import {
429
+ decodePluginPageReportV1,
430
+ recordPluginPageReportV1,
431
+ type PluginPageReportCommandV1,
432
+ } from "@frockbot/app/plugins/page-reports";
433
+ import { readBotPluginRosterV1 } from "@frockbot/app/plugins/worker-bot";
434
+ import { cleanBotAppletsV1 } from "./plugin-panels-cleanup.js";
435
+ import { cleanPackagePageShapesV1 } from "./package-page-shapes-cleanup.js";
436
+ import { cleanBotMachineMessagesV1 } from "./machine-messages-cleanup.js";
437
+ import {
438
+ groupOriginOfRunV1,
439
+ groupTurnStateOfRunV1,
440
+ groupWaitingKeyV1,
441
+ } from "@frockbot/app/groups/bot";
442
+ import { groupChatObjectNameV1 } from "@frockbot/app/groups/shared";
443
+ import { assembleBotThemeV1 } from "@frockbot/app/theme/assemble";
444
+ import {
445
+ deferThemeAssembleV1,
446
+ holdThemePickV1,
447
+ themeAssembleDeadlineV1,
448
+ } from "@frockbot/app/theme/owed";
449
+ import { decodeThemeDocumentV1, decodeBotLookV1 } from "@frockbot/core/theme";
450
+
451
+ function isFrockAiGatewayBindingV1(
452
+ value: BotStateEnv["AI"],
453
+ ): value is NonNullable<BotStateEnv["AI"]> & Pick<Ai, "gateway"> {
454
+ return (
455
+ value !== undefined && typeof Reflect.get(value, "gateway") === "function"
456
+ );
457
+ }
458
+
459
+ function optionalWorkerVarV1(env: object, name: string): string | undefined {
460
+ const value = Reflect.get(env, name);
461
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
462
+ }
463
+
464
+ /**
465
+ * A Frock AI setting, read under its current name and then under the
466
+ * pre-rename `FLOCK_AI_*` one. The vars are deployment configuration and the
467
+ * secrets are set outside this repo, so the fallback is what lets the rename
468
+ * land before they are re-added under the new names. Remove it once every
469
+ * environment names them `FROCK_AI_*`.
470
+ */
471
+ export function frockAiWorkerVarV1(
472
+ env: object,
473
+ name: `FROCK_AI_${string}`,
474
+ ): string | undefined {
475
+ return (
476
+ optionalWorkerVarV1(env, name) ??
477
+ optionalWorkerVarV1(env, `FLOCK_AI_${name.slice("FROCK_AI_".length)}`)
478
+ );
479
+ }
480
+
481
+ /** One Vectorize mutation per alarm firing, at the Workers binding limit. */
482
+ export const MEMORY_VECTOR_DELETE_BATCH_SIZE_V1 = 1_000;
483
+
484
+ /**
485
+ * Voice answers handed over per drain pass. A call is one person speaking, so
486
+ * a queue this long already means something upstream is stuck; the bound is
487
+ * there to keep one pass from being unbounded, not because it is ever reached.
488
+ */
489
+ export const VOICE_REPLY_DRAIN_LIMIT_V1 = 32;
490
+ const MEMORY_VECTOR_PURGE_JOURNAL_KEY_V1 = "memory:vector-purge:v1";
491
+ const MEMORY_VECTOR_PURGE_RETRY_DELAY_MS_V1 = 1_000;
492
+
493
+ interface MemoryVectorPurgeJournalV1 {
494
+ schemaVersion: 1;
495
+ userId: string;
496
+ botId: string;
497
+ status: "pending" | "complete" | "skipped";
498
+ deleted: number;
499
+ note?: string;
500
+ }
501
+
502
+ function decodeMemoryVectorPurgeJournalV1(
503
+ input: unknown,
504
+ ): MemoryVectorPurgeJournalV1 {
505
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
506
+ throw new Error("Stored Memory vector purge journal is invalid");
507
+ }
508
+ const allowed = new Set([
509
+ "schemaVersion",
510
+ "userId",
511
+ "botId",
512
+ "status",
513
+ "deleted",
514
+ "note",
515
+ ]);
516
+ const status = Reflect.get(input, "status");
517
+ const note = Reflect.get(input, "note");
518
+ if (
519
+ Object.keys(input).some((field) => !allowed.has(field)) ||
520
+ Reflect.get(input, "schemaVersion") !== 1 ||
521
+ typeof Reflect.get(input, "userId") !== "string" ||
522
+ typeof Reflect.get(input, "botId") !== "string" ||
523
+ (status !== "pending" && status !== "complete" && status !== "skipped") ||
524
+ !Number.isSafeInteger(Reflect.get(input, "deleted")) ||
525
+ (Reflect.get(input, "deleted") as number) < 0 ||
526
+ (note !== undefined && typeof note !== "string")
527
+ ) {
528
+ throw new Error("Stored Memory vector purge journal is invalid");
529
+ }
530
+ return {
531
+ schemaVersion: 1,
532
+ userId: Reflect.get(input, "userId") as string,
533
+ botId: Reflect.get(input, "botId") as string,
534
+ status,
535
+ deleted: Reflect.get(input, "deleted") as number,
536
+ ...(typeof note === "string" ? { note } : {}),
537
+ };
538
+ }
539
+
540
+ export type { BotStateEnv, OwnedBotTurnCommand };
541
+
542
+ export interface BotStateDependencies {
543
+ outboundFetch?: typeof fetch;
544
+ /** A test's stand-in for Turn supervision; production builds it from env. */
545
+ turnSupervisor?: TurnSupervisor;
546
+ }
547
+
548
+ function decodeBotIdentityRpcV1(input: unknown): {
549
+ userId: string;
550
+ botId: string;
551
+ } {
552
+ const request = decodeRpcEnvelopeV1(input, {
553
+ userId: rpcIdentifier,
554
+ botId: rpcBotId,
555
+ });
556
+ if (typeof request.userId !== "string" || typeof request.botId !== "string") {
557
+ throw new Error("Computer identity RPC was not decoded");
558
+ }
559
+ return {
560
+ userId: request.userId,
561
+ botId: request.botId,
562
+ };
563
+ }
564
+
565
+ function decodeIsolateCallRpcV1(
566
+ input: unknown,
567
+ decodeRequest: (value: unknown) => unknown,
568
+ ) {
569
+ return decodeRpcEnvelopeV1(input, {
570
+ userId: rpcIdentifier,
571
+ botId: rpcBotId,
572
+ runId: rpcIdentifier,
573
+ sessionId: rpcString(257),
574
+ turnId: rpcIdentifier,
575
+ packageId: rpcIdentifier,
576
+ generationId: rpcIdentifier,
577
+ request: rpcDecoded(decodeRequest),
578
+ });
579
+ }
580
+
581
+ export class BotState
582
+ extends DurableObject<BotStateEnv>
583
+ implements BotStateRpcTargetV1
584
+ {
585
+ private readonly outboundFetch?: typeof fetch;
586
+ private readonly turnSupervisor?: TurnSupervisor;
587
+ /**
588
+ * The environment the Shell Package runs under: the Durable Object's
589
+ * bindings plus the Workspace file surface built over them. `WORKSPACE_FILES`
590
+ * is not a Worker binding — it is `WorkspaceFilesV1` over the durable-root
591
+ * object store, with its generations recorded in this object — so it is
592
+ * constructed here and never reaches the deployed bindings map.
593
+ */
594
+ protected readonly backendEnv: BotStateEnv & {
595
+ FROCK_AI?: FrockAiGatewayHostV1;
596
+ WORKSPACE_FILES?: WorkspaceFilesV1;
597
+ MEMORY_WORKSPACE_FILES?: WorkspaceFilesV1;
598
+ MEMORY_GROUPS?: MemoryGroupsV1;
599
+ MEMORY_RECORDS?: MemoryRecordsV1;
600
+ MEMORY_CHUNK_INDEX?: MemoryChunkIndexWriterV1;
601
+ WORKSPACE_SYNC_FILES?: WorkspaceFilesV1;
602
+ WORKSPACE_SYNC_EFFECTS?: WorkspaceSyncEffectsV1;
603
+ WORKSPACE_SYNC_GENERATIONS?: WorkspaceGenerationsV1;
604
+ /** The User-scoped transcript index a settled Turn projects into. */
605
+ SEARCH_SINK?: SearchSinkV1;
606
+ /** The User-scoped audit table this object's outbox drains into. */
607
+ AUDIT_SINK?: AuditSinkV1;
608
+ };
609
+ /** The identity the Workspace and Memory surfaces above were built for. */
610
+ private surfacesFor: string | undefined;
611
+ /**
612
+ * This object's generation ledger — one instance, for every root it owns.
613
+ *
614
+ * "The Bot's Durable Object is the authority for everything Bot-scoped", and
615
+ * an authority that exists twice is not one: each instance caches the
616
+ * minting cursor while resident, so two of them can mint the same id for two
617
+ * different files.
618
+ */
619
+ protected readonly workspaceGenerations: DurableWorkspaceGenerations =
620
+ new DurableWorkspaceGenerations({ state: this.ctx });
621
+ /** Durable invalidation log plus hibernatable observer transport. */
622
+ private readonly stateChannel = new BotStateChannel(this.ctx);
623
+ private mountedShell: ShellBotBackendContribution["state"] | undefined;
624
+ private mounted:
625
+ | Promise<{
626
+ shell: ShellBotBackendContribution;
627
+ flock: FlockBotBackendContribution;
628
+ computer: ComputerBotBackendContribution;
629
+ dispose(): Promise<void>;
630
+ }>
631
+ | undefined;
632
+
633
+ /**
634
+ * Adopt the User's Profile timezone before a Routine is listed or written.
635
+ *
636
+ * Best effort: the projection already holds a zone, and a User object that
637
+ * cannot be read is a "next run" column computed under the zone last
638
+ * projected — never a Routines list that refuses to open.
639
+ */
640
+ private async syncRoutineTimezone(
641
+ identity: BotIdentity,
642
+ shell: ShellBotBackendContribution,
643
+ ): Promise<void> {
644
+ try {
645
+ const rpc = this.env.USER_CONFIGURATIONS.get(
646
+ this.env.USER_CONFIGURATIONS.idFromName(identity.userId),
647
+ ) as unknown as { readConfiguration(input: unknown): Promise<unknown> };
648
+ const user = decodeUserSettingsViewV1(
649
+ rpcJsonSnapshotV1(
650
+ await rpc.readConfiguration({
651
+ schemaVersion: 1,
652
+ userId: identity.userId,
653
+ view: 2,
654
+ }),
655
+ ),
656
+ );
657
+ await projectRoutineAccountTimezoneV1(
658
+ shell.state,
659
+ userTimezoneV1(user.profile),
660
+ user.revision,
661
+ );
662
+ } catch {
663
+ // Left at the zone last projected, which the next Turn refreshes.
664
+ }
665
+ }
666
+
667
+ constructor(
668
+ ctx: DurableObjectState,
669
+ env: BotStateEnv,
670
+ dependencies: BotStateDependencies = {},
671
+ ) {
672
+ super(ctx, env);
673
+ // Runs before any request or alarm can mount the old conversation.
674
+ this.ctx.blockConcurrencyWhile(async () => {
675
+ // A deleted Bot has no conversation left to clean, and a receipt
676
+ // written here would be the only thing in it besides its tombstone.
677
+ if (await isBotTombstoneV1(this.ctx.storage)) return;
678
+ // Before anything decodes the settings or a run: a profile `label` or
679
+ // `title` no longer parses.
680
+ await cleanBotLabelV1(this.ctx.storage);
681
+ await cleanBotTitleV1(this.ctx.storage);
682
+ await cleanIncidentTestChatsV1(this.ctx.storage);
683
+ await cleanNotificationTestState(this.ctx.storage);
684
+ await cleanHiddenBotNotifications(this.ctx.storage);
685
+ await cleanBotAvatarTestState(this.ctx.storage);
686
+ await cleanBotProfileMirrorTestState(this.ctx.storage);
687
+ await cleanRetiredRoutineStateV1(this.ctx.storage);
688
+ await cleanBotAppletsV1(this.ctx.storage);
689
+ // Before anything decodes a run: a `superseded` record no longer parses.
690
+ await cleanSupersedeStateV1(this.ctx.storage);
691
+ // Before anything decodes a run or an event: a `directTool` run and a
692
+ // `publish` sync no longer parse.
693
+ await cleanPackagePageShapesV1(this.ctx.storage);
694
+ // Before anything decodes a machine intent: a `messages` op no longer parses.
695
+ await cleanBotMachineMessagesV1(this.ctx.storage);
696
+ await cleanProjectEventsV1(this.ctx.storage);
697
+ await cleanUnpreparedRunsV1(this.ctx.storage);
698
+ await cleanUndecodableSkillIndexesV1(this.ctx.storage);
699
+ await cleanRetiredPublicationStateV1(this.ctx.storage);
700
+ await cleanCompactionAnnouncementsV1(this.ctx.storage);
701
+ await projectUnprojectedSessionsV1(this.ctx.storage);
702
+ const identity = await this.ctx.storage.get<{
703
+ userId: string;
704
+ botId: string;
705
+ }>(IDENTITY_KEY);
706
+ if (identity && this.env.MEMORY_FILES) {
707
+ await reseedInstructionRootV1({
708
+ storage: this.ctx.storage,
709
+ bucket: createR2ObjectBucketV1(this.env.MEMORY_FILES),
710
+ root: {
711
+ kind: "bot-instructions",
712
+ userId: identity.userId,
713
+ botId: identity.botId,
714
+ },
715
+ receiptKey: "maintenance:skill-index:bot:2026-09-22",
716
+ });
717
+ const bucket = createR2ObjectBucketV1(this.env.MEMORY_FILES);
718
+ const listing = {
719
+ list: async (options: {
720
+ prefix: string;
721
+ limit: number;
722
+ cursor?: string;
723
+ }) => {
724
+ const page = await bucket.list(options);
725
+ return {
726
+ keys: page.objects.map((object) => object.key),
727
+ ...(page.cursor ? { cursor: page.cursor } : {}),
728
+ truncated: page.truncated,
729
+ };
730
+ },
731
+ delete: (key: string) => bucket.delete(key),
732
+ };
733
+ await cleanRetiredMemoryFactObjectsV1(
734
+ this.ctx.storage,
735
+ listing,
736
+ workspaceObjectPrefixV1({
737
+ kind: "bot-memory",
738
+ userId: identity.userId,
739
+ botId: identity.botId,
740
+ }),
741
+ );
742
+ await cleanRetiredComputerScreenshotsV1(
743
+ this.ctx.storage,
744
+ listing,
745
+ `${workspaceObjectPrefixV1({
746
+ kind: "package-declared",
747
+ userId: identity.userId,
748
+ packageId: "computer",
749
+ rootId: "screenshots",
750
+ })}${computerBotPathKeyV1(identity.botId)}/`,
751
+ );
752
+ }
753
+ if (durableObjectHasSqlV1(this.ctx.storage)) {
754
+ const memoryDue = createBotMemoryEngineV1(
755
+ this.ctx.storage,
756
+ ).nextWakeupAt();
757
+ if (
758
+ memoryDue !== undefined &&
759
+ (await this.ctx.storage.getAlarm()) === null
760
+ ) {
761
+ await this.ctx.storage.setAlarm(memoryDue);
762
+ }
763
+ }
764
+ });
765
+ this.outboundFetch = dependencies.outboundFetch;
766
+ this.turnSupervisor = dependencies.turnSupervisor;
767
+ const emailSender = createBindingEmailSenderV1(
768
+ env as Parameters<typeof createBindingEmailSenderV1>[0],
769
+ BRAND_V1.emailSenderName,
770
+ );
771
+ // One copy of the deployment's rate table per Bot object: billing prices
772
+ // from it and the Gateway bounds each request by it.
773
+ const hostedRates = hostedBillingEnabledV1(env as BillingSwitchEnv)
774
+ ? createHostedModelRatesReaderV1(
775
+ () =>
776
+ (
777
+ env as BotStateEnv & { DEPLOYMENT_POLICY: DurableObjectNamespace }
778
+ ).DEPLOYMENT_POLICY.getByName(
779
+ DEPLOYMENT_POLICY_SINGLETON_NAME,
780
+ ) as unknown as HostedModelRatesAuthorityV1,
781
+ )
782
+ : undefined;
783
+ // The surfaces are built per identity in `bindSurfaces`, not here: they
784
+ // carry the `owner` guard, and a Durable Object learns which User it
785
+ // serves from the RPC that addresses it, never from its constructor.
786
+ this.backendEnv = {
787
+ ...env,
788
+ ...(hostedBillingEnabledV1(env as BillingSwitchEnv) && env.COMPUTER_HOST
789
+ ? {
790
+ COMPUTER_HOST: prepaidComputerHost(
791
+ env.COMPUTER_HOST,
792
+ (userId) =>
793
+ env.USER_CONFIGURATIONS.get(
794
+ env.USER_CONFIGURATIONS.idFromName(userId),
795
+ ) as unknown as BillingAccountRpc,
796
+ undefined,
797
+ (botId, personal) => this.computerSpend(botId, personal),
798
+ ),
799
+ }
800
+ : {}),
801
+ ...(hostedRates
802
+ ? {
803
+ BILLING: (
804
+ userId: string,
805
+ botId: string,
806
+ sessionId: string,
807
+ spend?: import("@frockbot/app/billing/ledger").UsageAttributionV1,
808
+ ) => {
809
+ const account = env.USER_CONFIGURATIONS.get(
810
+ env.USER_CONFIGURATIONS.idFromName(userId),
811
+ ) as unknown as BillingAccountRpc;
812
+ return {
813
+ botId,
814
+ sessionId,
815
+ ...(spend ? { spend } : {}),
816
+ limits: {
817
+ paused: (scopes: string[]) =>
818
+ account.readSpendingPaused({ userId, scopes }),
819
+ claimSpike: (scope: string) =>
820
+ account.claimSpendingSpike({ userId, scope }),
821
+ },
822
+ rates: hostedRates.rates,
823
+ reportUnpriced: (report: {
824
+ servedModel: string | null;
825
+ route: string;
826
+ version: number;
827
+ }) => this.ctx.waitUntil(hostedRates.reportUnpriced(report)),
828
+ account: {
829
+ // A charge that names no cause of its own — a search —
830
+ // is recorded against the Turn this billing was made for.
831
+ reserve: (
832
+ reservation: import("@frockbot/app/billing/ledger").UsageReservation,
833
+ ) =>
834
+ account.reserveUsage({
835
+ userId,
836
+ reservation:
837
+ spend && !reservation.attribution
838
+ ? { ...reservation, attribution: spend }
839
+ : reservation,
840
+ }),
841
+ settle: (
842
+ settlement: import("@frockbot/app/billing/ledger").UsageSettlement,
843
+ ) => account.settleUsage({ userId, settlement }),
844
+ },
845
+ };
846
+ },
847
+ }
848
+ : {}),
849
+ // The deployment's own sender, when this one bound both halves of it.
850
+ // Nothing is constructed without them, so a Worker with no email
851
+ // binding carries no sender rather than one that fails on use.
852
+ ...(emailSender === undefined ? {} : { EMAIL_SENDER: emailSender }),
853
+ MEMORY_CHUNK_INDEX: {
854
+ record: async (vectorIds) => {
855
+ const entries = memoryChunkIndexEntriesV1(vectorIds);
856
+ if (Object.keys(entries).length > 0) {
857
+ await this.ctx.storage.put(entries);
858
+ }
859
+ },
860
+ },
861
+ ...(isFrockAiGatewayBindingV1(env.AI)
862
+ ? {
863
+ FROCK_AI: createFrockAiGatewayHostV1(env.AI, {
864
+ gatewayId: frockAiWorkerVarV1(env, "FROCK_AI_GATEWAY_ID"),
865
+ autoRoute: frockAiWorkerVarV1(env, "FROCK_AI_AUTO_ROUTE"),
866
+ accountId: frockAiWorkerVarV1(env, "FROCK_AI_ACCOUNT_ID"),
867
+ token: frockAiWorkerVarV1(env, "FROCK_AI_GATEWAY_TOKEN"),
868
+ ...(hostedRates ? { billingLimits: hostedRates.limits } : {}),
869
+ }),
870
+ }
871
+ : {}),
872
+ };
873
+ }
874
+
875
+ private contributions(): Promise<{
876
+ shell: ShellBotBackendContribution;
877
+ flock: FlockBotBackendContribution;
878
+ computer: ComputerBotBackendContribution;
879
+ dispose(): Promise<void>;
880
+ }> {
881
+ if (!this.mounted) {
882
+ const pending = (async () => {
883
+ const computers = new ComputerRegistry();
884
+ // The Contribution's own registry, over the same host the Turn's
885
+ // runtime mounts. `./computer-host.ts` is where this deployment
886
+ // chooses which host that is.
887
+ const binding = computerHostBindingV1(this.backendEnv);
888
+ const computerConfigured = Boolean(binding);
889
+ const disposeComputers = await mountRuntimeFeaturesV1({ computers }, [
890
+ ({ computers: registry }) =>
891
+ binding
892
+ ? registry.register(createComputerHostV1(binding))
893
+ : undefined,
894
+ ]);
895
+ // Where each descriptor's mounted value lands as the mount runs. The
896
+ // Shell and Flock Contributions need each other, and each reaches the
897
+ // other by naming the table entry it imported.
898
+ const mountedContributions = createFoundationMountedContributionsV1();
899
+ const requireShell = (): ShellBotBackendContribution => {
900
+ const shell = mountedContributions.get(shellBotContribution);
901
+ if (!shell) throw new Error("Shell Bot Contribution is unavailable");
902
+ return shell;
903
+ };
904
+ const mounted = await createFoundationBackendContributions<
905
+ | ShellBotBackendContribution
906
+ | FlockBotBackendContribution
907
+ | ComputerBotBackendContribution
908
+ >({
909
+ backendHost: "bot",
910
+ mountedContributions,
911
+ shell: {
912
+ ...foundationShellApplicationV1(BRAND_V1),
913
+ state: this.ctx,
914
+ env: this.backendEnv,
915
+ outboundFetch: this.outboundFetch,
916
+ ...(this.turnSupervisor
917
+ ? { turnSupervisor: this.turnSupervisor }
918
+ : {}),
919
+ messagesCommitted: () => {
920
+ this.ctx.waitUntil(this.drainPush());
921
+ this.ctx.waitUntil(
922
+ (async () => {
923
+ const shell = this.mounted
924
+ ? (await this.mounted).shell
925
+ : undefined;
926
+ this.nudgeGroup(
927
+ await shell?.state.authority.readActiveRunId(),
928
+ );
929
+ })().catch(() => undefined),
930
+ );
931
+ },
932
+ deliverPublication: (updates) => {
933
+ this.stateChannel.broadcastCommitted(updates);
934
+ return Promise.resolve();
935
+ },
936
+ deliverReplyDraft: (draft) => {
937
+ this.stateChannel.broadcastDraft(draft);
938
+ },
939
+ runSettled: (runId) => {
940
+ this.nudgeGroup(runId);
941
+ return this.projectSettled(requireShell(), runId);
942
+ },
943
+ // The Durable Object owns the kernel authority; the Shell
944
+ // Package supplies only its configuration and Composition
945
+ // hooks. Chat delivery is a commit contribution, not a
946
+ // storage interceptor: an observed write is not proof it
947
+ // committed.
948
+ createAuthority: (options) => new BotDurableAuthority(options),
949
+ // The Computer Contribution's projection cache and its share of
950
+ // the authority's one durable alarm, reached through the table
951
+ // once it has mounted.
952
+ // The same host, for the Turn's runtime. The app is handed a
953
+ // factory because two of its seams belong to one Turn.
954
+ ...(binding
955
+ ? {
956
+ computerHost: (options: ShellComputerHostOptionsV1) =>
957
+ createComputerHostV1(binding, options),
958
+ }
959
+ : {}),
960
+ invalidateComputerProjectionFile: (userId, botId, kind) => {
961
+ mountedContributions
962
+ .get(computerBotContribution)
963
+ ?.invalidateProjectionFile(userId, botId, kind);
964
+ // Dropping the resident cache only makes the next read
965
+ // honest. The notice is what makes an attached client take
966
+ // that read, so a new frame reaches the card in about a
967
+ // second instead of at the next projection poll.
968
+ this.stateChannel.noticeComputer();
969
+ },
970
+ deleteComputerDemonstration: (demonstrationId) =>
971
+ mountedContributions
972
+ .get(computerBotContribution)
973
+ ?.deleteDemonstration(demonstrationId) ??
974
+ Promise.resolve("missing" as const),
975
+ scheduledDeadlines: async (transaction) => [
976
+ ...((await mountedContributions
977
+ .get(computerBotContribution)
978
+ ?.scheduledDeadlines(transaction)) ?? []),
979
+ ...((
980
+ await transaction.list({ prefix: PUSH_OUTBOX_PREFIX, limit: 1 })
981
+ ).size || (await transaction.get(PUSH_READ_KEY))
982
+ ? [Date.now() + 30_000]
983
+ : []),
984
+ ...(await themeAssembleDeadlineV1(transaction)),
985
+ ...(durableObjectHasSqlV1(this.ctx.storage)
986
+ ? (() => {
987
+ const due = this.memoryEngine().nextWakeupAt();
988
+ return due === undefined ? [] : [due];
989
+ })()
990
+ : []),
991
+ ],
992
+ scheduledWorkInFlight: () =>
993
+ mountedContributions
994
+ .get(computerBotContribution)
995
+ ?.scheduledWorkInFlight() ?? false,
996
+ deferScheduledWork: async (transaction) => {
997
+ await (mountedContributions
998
+ .get(computerBotContribution)
999
+ ?.deferScheduledWork(transaction) ?? Promise.resolve());
1000
+ await deferThemeAssembleV1(transaction, Date.now());
1001
+ },
1002
+ settleScheduledWork: async () => {
1003
+ await (mountedContributions
1004
+ .get(computerBotContribution)
1005
+ ?.settleScheduledWork() ?? Promise.resolve());
1006
+ await this.assembleThemeIfDue();
1007
+ await this.deliverProfileMirror();
1008
+ await this.drainMemoryProcessing();
1009
+ },
1010
+ // An archived Bot admits no configuration command; the Flock
1011
+ // Contribution owns that durable lifecycle state.
1012
+ assertLifecycleActive: (storage, botId) => {
1013
+ const flock = mountedContributions.get(flockBotContribution);
1014
+ if (!flock) {
1015
+ throw new Error("Flock Bot Contribution is unavailable");
1016
+ }
1017
+ return flock.assertActive(storage, botId);
1018
+ },
1019
+ },
1020
+ flock: {
1021
+ storage: this.ctx.storage,
1022
+ materializeSettings: async (registration, userId) => {
1023
+ await requireShell().materializeSettings(
1024
+ { userId, botId: registration.botId },
1025
+ {
1026
+ name: registration.initialName,
1027
+ ...(registration.initialDescription === undefined
1028
+ ? {}
1029
+ : { description: registration.initialDescription }),
1030
+ },
1031
+ );
1032
+ },
1033
+ archiveEligible: (storage) => archiveEligible(storage),
1034
+ tearDown: (identity) => this.tearDown(identity),
1035
+ },
1036
+ computer: {
1037
+ storage: this.stateChannel.computerStorage,
1038
+ workspace: this.backendEnv.WORKSPACE_FILES,
1039
+ providerLabel: "Computer",
1040
+ configured: computerConfigured,
1041
+ // A stopped recording becomes the Bot's uploads, kept and
1042
+ // counted exactly as a file the person attaches.
1043
+ demonstrations: {
1044
+ keep: ({ userId, botId, files }) =>
1045
+ keepBotFilesV1(
1046
+ botFileStoreV1(this.env, { userId, botId }, this.ctx.storage),
1047
+ { userId, botId },
1048
+ files,
1049
+ ),
1050
+ remove: ({ userId, botId, uploadIds }) =>
1051
+ removeBotFilesV1(
1052
+ botFileStoreV1(this.env, { userId, botId }, this.ctx.storage),
1053
+ { userId, botId },
1054
+ uploadIds,
1055
+ ),
1056
+ },
1057
+ openComputer: (userId, botId, effectId) => {
1058
+ const identity = { userId };
1059
+ if (!computers.assignment(identity)) {
1060
+ computers.assign(identity, "computer-host");
1061
+ }
1062
+ return computers.open(identity, { botId }, { effectId });
1063
+ },
1064
+ loginVault: (userId) =>
1065
+ createComputerLoginVaultV1({
1066
+ userId,
1067
+ keyring: this.backendEnv.CREDENTIAL_KEYRING,
1068
+ user: this.backendEnv.USER_CONFIGURATIONS.get(
1069
+ this.backendEnv.USER_CONFIGURATIONS.idFromName(userId),
1070
+ ),
1071
+ }),
1072
+ },
1073
+ });
1074
+ // Every Bot-host Contribution the application lists must have
1075
+ // mounted, and each of the three the Bot Durable Object depends on
1076
+ // must be one of them.
1077
+ const shell = mounted.get(shellBotContribution);
1078
+ const flock = mounted.get(flockBotContribution);
1079
+ const computer = mounted.get(computerBotContribution);
1080
+ if (
1081
+ !shell ||
1082
+ !flock ||
1083
+ !computer ||
1084
+ mounted.contributions.length !==
1085
+ backendDescriptorsV1.filter(
1086
+ (descriptor) => descriptor.host === "bot",
1087
+ ).length
1088
+ ) {
1089
+ await mounted.dispose();
1090
+ await disposeComputers();
1091
+ throw new Error(
1092
+ "Foundation requires Shell, Flock and Computer Bot backend Contributions",
1093
+ );
1094
+ }
1095
+ const alarmOwner = shell;
1096
+ this.stateChannel.setAlarmRefresher((transaction) =>
1097
+ refreshScheduledWork(alarmOwner.state, transaction),
1098
+ );
1099
+ return {
1100
+ shell,
1101
+ flock,
1102
+ computer,
1103
+ async dispose() {
1104
+ await mounted.dispose();
1105
+ await disposeComputers();
1106
+ },
1107
+ };
1108
+ })();
1109
+ this.mounted = pending;
1110
+ // Read by Computer billing, which must never wait on a mount: the
1111
+ // mount itself may be what is calling the Computer.
1112
+ void pending.then(
1113
+ (mounted) => {
1114
+ if (this.mounted === pending) this.mountedShell = mounted.shell.state;
1115
+ },
1116
+ () => undefined,
1117
+ );
1118
+ // A mount that failed is not a durable verdict. Memoizing the rejection
1119
+ // made one transient failure — an artifact read, a User RPC, a member
1120
+ // that would not resolve — final for the life of the object: every
1121
+ // later call awaited the same rejected promise, the recovery alarm
1122
+ // included, so nothing could heal it short of eviction. The next call
1123
+ // retries instead, exactly as `immutable-application.ts` already does.
1124
+ void pending.catch(() => {
1125
+ if (this.mounted === pending) {
1126
+ this.mounted = undefined;
1127
+ this.mountedShell = undefined;
1128
+ }
1129
+ });
1130
+ }
1131
+ return this.mounted;
1132
+ }
1133
+
1134
+ private async registration(identity: {
1135
+ userId: string;
1136
+ botId: string;
1137
+ }): Promise<BotRegistrationV1> {
1138
+ const id = this.env.USER_CONFIGURATIONS.idFromName(identity.userId);
1139
+ // SAFETY: USER_CONFIGURATIONS binds UserConfiguration; workers-types cannot infer its generated Flock RPC surface.
1140
+ const rpc = this.env.USER_CONFIGURATIONS.get(id) as unknown as {
1141
+ getBotRegistration(input: unknown): Promise<BotRegistrationV1>;
1142
+ };
1143
+ return decodeBotRegistrationV1(
1144
+ structuredClone(
1145
+ await rpc.getBotRegistration({ schemaVersion: 1, ...identity }),
1146
+ ),
1147
+ );
1148
+ }
1149
+
1150
+ /** The User Durable Object, as this object's Memory authority. */
1151
+ private userMemoryRpc(userId: string): UserMemoryRpc {
1152
+ const id = this.env.USER_CONFIGURATIONS.idFromName(userId);
1153
+ // SAFETY: USER_CONFIGURATIONS binds UserConfiguration; workers-types cannot
1154
+ // infer its generated Memory RPC surface.
1155
+ return this.env.USER_CONFIGURATIONS.get(id) as unknown as UserMemoryRpc;
1156
+ }
1157
+
1158
+ #memoryEngine: MemoryEngineV1 | undefined;
1159
+
1160
+ private memoryEngine(): MemoryEngineV1 {
1161
+ this.#memoryEngine ??= createBotMemoryEngineV1(this.ctx.storage);
1162
+ return this.#memoryEngine;
1163
+ }
1164
+
1165
+ private async drainMemoryProcessing(): Promise<void> {
1166
+ if (!durableObjectHasSqlV1(this.ctx.storage)) return;
1167
+ const extract = createHostedMemoryExtractorV1({
1168
+ displayName: BRAND_V1.builtInModelName,
1169
+ ...(this.backendEnv.FROCK_AI
1170
+ ? { gateway: this.backendEnv.FROCK_AI }
1171
+ : {}),
1172
+ ...(this.backendEnv.BILLING ? { billing: this.backendEnv.BILLING } : {}),
1173
+ });
1174
+ await drainDurableMemoryV1(this.memoryEngine(), {
1175
+ ...(this.env.MEMORY_INDEX
1176
+ ? { vectors: this.env.MEMORY_INDEX as MemoryVectorIndex }
1177
+ : {}),
1178
+ ...(this.env.AI ? { ai: this.env.AI as MemoryAiBinding } : {}),
1179
+ ...(extract ? { extract } : {}),
1180
+ });
1181
+ }
1182
+
1183
+ /**
1184
+ * Builds the Workspace and Memory file surfaces for one identity.
1185
+ *
1186
+ * Three surfaces, deliberately, because the store refuses to be two things
1187
+ * at once: `WORKSPACE_FILES` is the kernel surface and refuses every Memory
1188
+ * root; `MEMORY_WORKSPACE_FILES` is the Memory Package's and serves Memory
1189
+ * roots and nothing else; `WORKSPACE_SYNC_FILES` is the durable-root sync's,
1190
+ * the only surface that reads every root and the only one that accepts an
1191
+ * `unattributed` writer — a shell wrote the file and nothing recorded who.
1192
+ * The Memory surface routes a shared root's generations to the User Durable
1193
+ * Object, which is the authority for the generation records of the User
1194
+ * Memory root, while the Bot's own Memory root stays in this object.
1195
+ *
1196
+ * The sync's effect records stay here too: a push records its intent in the
1197
+ * Bot's Durable Object before it runs (§ Computer and Workspace), so an
1198
+ * interrupted push is read back rather than repeated.
1199
+ */
1200
+ protected async skillIndexLoad(identity: { userId: string; botId: string }) {
1201
+ const storage = this.ctx.storage;
1202
+ const bot = await readDurableSkillIndexV1(storage, {
1203
+ kind: "bot-instructions",
1204
+ userId: identity.userId,
1205
+ botId: identity.botId,
1206
+ });
1207
+ const userStub = this.env.USER_CONFIGURATIONS.get(
1208
+ this.env.USER_CONFIGURATIONS.idFromName(identity.userId),
1209
+ );
1210
+ const user = decodeSkillMetadataIndexV1(
1211
+ await userStub.readSkillIndex({
1212
+ schemaVersion: 1,
1213
+ userId: identity.userId,
1214
+ root: { kind: "user-instructions", userId: identity.userId },
1215
+ }),
1216
+ );
1217
+ return { bot, user, liveBot: bot, liveUser: user };
1218
+ }
1219
+
1220
+ protected instructionPublication(
1221
+ userId: string,
1222
+ ): (event: WorkspaceGenerationPublicationV1) => Promise<void> {
1223
+ const storage = this.ctx.storage;
1224
+ const bucket = this.env.MEMORY_FILES;
1225
+ const objects = createR2ObjectBucketV1(bucket);
1226
+ const user = this.env.USER_CONFIGURATIONS.get(
1227
+ this.env.USER_CONFIGURATIONS.idFromName(userId),
1228
+ );
1229
+ return (event) =>
1230
+ publishWorkspaceSkillGenerationV1({
1231
+ event,
1232
+ userId,
1233
+ storage,
1234
+ bodies: {
1235
+ put: async (key, bytes) => {
1236
+ await objects.put(key, bytes);
1237
+ },
1238
+ get: async (key) => {
1239
+ const object = await objects.get(key);
1240
+ return object ? object.bytes() : undefined;
1241
+ },
1242
+ delete: (key) => objects.delete(key),
1243
+ },
1244
+ user,
1245
+ });
1246
+ }
1247
+
1248
+ protected bindSurfaces(identity: { userId: string; botId: string }): void {
1249
+ const key = `${identity.userId}\u0000${identity.botId}`;
1250
+ if (this.surfacesFor === key) return;
1251
+ const owner = { userId: identity.userId };
1252
+ // One ledger per Durable Object, shared by every surface it builds. A
1253
+ // ledger caches its minting cursor while resident, so two instances on one
1254
+ // object can read one cursor and mint one generation id twice — two files
1255
+ // claiming one generation, which is the single thing the id exists to
1256
+ // prevent. The routed ledger is one instance too: the same Bot half serves
1257
+ // the Memory and sync surfaces, and only a shared Memory root is routed to
1258
+ // the User object.
1259
+ const bot = this.workspaceGenerations;
1260
+ const onInstructionPublication = this.instructionPublication(
1261
+ identity.userId,
1262
+ );
1263
+ const workspace = createDurableWorkspaceFilesV1(this.env, {
1264
+ owner,
1265
+ generations: bot,
1266
+ onInstructionPublication,
1267
+ });
1268
+ const rpc = this.userMemoryRpc(identity.userId);
1269
+ const routed = createRoutedWorkspaceGenerationsV1({
1270
+ bot,
1271
+ user: createUserWorkspaceGenerationsV1(rpc, identity.userId),
1272
+ });
1273
+ const memory = createDurableWorkspaceFilesV1(this.env, {
1274
+ owner,
1275
+ surface: "memory",
1276
+ generations: routed,
1277
+ });
1278
+ const sync = createDurableWorkspaceFilesV1(this.env, {
1279
+ owner,
1280
+ surface: "sync",
1281
+ generations: routed,
1282
+ onInstructionPublication,
1283
+ });
1284
+ if (workspace) this.backendEnv.WORKSPACE_FILES = workspace;
1285
+ if (sync) {
1286
+ this.backendEnv.WORKSPACE_SYNC_FILES = sync;
1287
+ this.backendEnv.WORKSPACE_SYNC_EFFECTS = new DurableWorkspaceSyncEffects({
1288
+ state: this.ctx,
1289
+ });
1290
+ this.backendEnv.WORKSPACE_SYNC_GENERATIONS = routed;
1291
+ }
1292
+ if (memory) {
1293
+ this.backendEnv.MEMORY_WORKSPACE_FILES = memory;
1294
+ this.backendEnv.MEMORY_GROUPS = createUserMemoryGroupsV1(rpc, identity);
1295
+ }
1296
+ if (durableObjectHasSqlV1(this.ctx.storage)) {
1297
+ const vectors = this.env.MEMORY_INDEX as MemoryVectorIndex | undefined;
1298
+ const ai = this.env.AI as MemoryAiBinding | undefined;
1299
+ this.backendEnv.MEMORY_RECORDS = new MemoryRecordsV1({
1300
+ owner: "bot",
1301
+ engine: this.memoryEngine(),
1302
+ remote: createUserMemoryRecordsRemoteV1(rpc, identity),
1303
+ ...(vectors && ai
1304
+ ? {
1305
+ semantic: createVectorMemorySearchV1(
1306
+ vectors,
1307
+ createMemoryEmbedder(ai),
1308
+ ),
1309
+ }
1310
+ : {}),
1311
+ });
1312
+ }
1313
+ // The transcript index is User-scoped state, so its authority is the User
1314
+ // Durable Object and this object reaches it through a narrow binding —
1315
+ // the same shape as `MEMORY_GROUPS` above. It is a projection, never an
1316
+ // authority, so nothing here waits on it and nothing here reads from it.
1317
+ this.backendEnv.SEARCH_SINK = createUserSearchSinkV1(
1318
+ rpc as unknown as UserSearchRpc,
1319
+ identity,
1320
+ );
1321
+ // The audit table is User-scoped too, and reached the same way — but
1322
+ // through a durable outbox rather than fire-and-forget, because
1323
+ // completeness is the parity item (register row 30b).
1324
+ this.backendEnv.AUDIT_SINK = createUserAuditSinkV1(
1325
+ rpc as unknown as UserAuditRpc,
1326
+ identity,
1327
+ );
1328
+ this.surfacesFor = key;
1329
+ }
1330
+
1331
+ /**
1332
+ * Everything this Bot owns, destroyed. The Flock Contribution calls this on
1333
+ * `bot/delete` and writes its tombstone afterwards.
1334
+ *
1335
+ * Order matters. The object's existing alarm is cancelled first, then its
1336
+ * Bot-Memory vector ids are deleted from Vectorize in bounded alarm pages.
1337
+ * Only once the durable chunk index is empty do the durable keys go in one
1338
+ * `deleteAll()`:
1339
+ * the session event log and its runs, the transcript and conversations, the
1340
+ * Bot's Memory and Skills generation ledger, Routines and their schedules,
1341
+ * Subagent tasks, approvals, notifications, unread and sidebar preview, the
1342
+ * Package composition generations, and the state-channel log. Then the two
1343
+ * object-store roots the Bot owns, which are the only Bot-scoped state that
1344
+ * does not live in this object.
1345
+ *
1346
+ * If Vectorize is not bound (local development and workerd by default), the
1347
+ * journal records that the derived cleanup was skipped before teardown.
1348
+ *
1349
+ * The mount memo is dropped last so the next call to this object rebuilds
1350
+ * from empty storage, reads the tombstone the caller is about to write, and
1351
+ * refuses rather than materializing the Bot again. Dropping it also
1352
+ * guarantees no surviving Contribution re-arms the alarm from a stale
1353
+ * transaction.
1354
+ *
1355
+ * Idempotent, because the delete saga replays: an empty object has nothing
1356
+ * to delete and an empty prefix has nothing to list.
1357
+ */
1358
+ private async finishTearDown(identity: {
1359
+ userId: string;
1360
+ botId: string;
1361
+ }): Promise<void> {
1362
+ await this.ctx.storage.deleteAlarm();
1363
+ await this.ctx.storage.deleteAll();
1364
+ await deleteBotWorkspaceRootsV1(this.env, identity);
1365
+ // The Bot's uploads go with it, and the account gets their space back.
1366
+ await deleteBotUploadsV1(this.env, identity);
1367
+ await releaseBotUploadQuotaRpcV1(this.env, identity);
1368
+ this.mounted = undefined;
1369
+ this.mountedShell = undefined;
1370
+ this.surfacesFor = undefined;
1371
+ }
1372
+
1373
+ /**
1374
+ * Delete one bounded page of this Bot's own Memory vectors.
1375
+ *
1376
+ * The index keys are the cursor. They leave storage only after the external
1377
+ * delete succeeds, so an eviction in either side of that boundary repeats a
1378
+ * harmless delete or resumes at the first id not yet acknowledged. The next
1379
+ * alarm is armed before the remote call: even a long Vectorize outage cannot
1380
+ * exhaust the platform's finite automatic alarm retries and strand data.
1381
+ */
1382
+ private async purgeMemoryVectorBatch(
1383
+ journal: MemoryVectorPurgeJournalV1,
1384
+ ): Promise<"complete" | "pending"> {
1385
+ const vectorize = this.env.MEMORY_INDEX;
1386
+ if (!vectorize) {
1387
+ await this.ctx.storage.put(MEMORY_VECTOR_PURGE_JOURNAL_KEY_V1, {
1388
+ ...journal,
1389
+ status: "skipped",
1390
+ note: "MEMORY_INDEX binding is absent; no Vectorize purge was available",
1391
+ } satisfies MemoryVectorPurgeJournalV1);
1392
+ return "complete";
1393
+ }
1394
+
1395
+ const page = await this.ctx.storage.list<unknown>({
1396
+ prefix: MEMORY_CHUNK_INDEX_PREFIX_V1,
1397
+ limit: MEMORY_VECTOR_DELETE_BATCH_SIZE_V1,
1398
+ });
1399
+ if (page.size === 0) {
1400
+ await this.ctx.storage.put(MEMORY_VECTOR_PURGE_JOURNAL_KEY_V1, {
1401
+ ...journal,
1402
+ status: "complete",
1403
+ } satisfies MemoryVectorPurgeJournalV1);
1404
+ return "complete";
1405
+ }
1406
+
1407
+ const entries = [...page].map(([key, value]) =>
1408
+ decodeMemoryChunkIndexEntryV1(key, value),
1409
+ );
1410
+ await this.ctx.storage.setAlarm(
1411
+ Date.now() + MEMORY_VECTOR_PURGE_RETRY_DELAY_MS_V1,
1412
+ );
1413
+ await vectorize.deleteByIds(entries.map((entry) => entry.vectorId));
1414
+ const keys = [...page.keys()];
1415
+ const deleted = journal.deleted + entries.length;
1416
+ const hasAnotherPage = page.size === MEMORY_VECTOR_DELETE_BATCH_SIZE_V1;
1417
+ await this.ctx.storage.transaction(async (storage) => {
1418
+ await storage.delete(keys);
1419
+ await storage.put(MEMORY_VECTOR_PURGE_JOURNAL_KEY_V1, {
1420
+ ...journal,
1421
+ status: hasAnotherPage ? "pending" : "complete",
1422
+ deleted,
1423
+ } satisfies MemoryVectorPurgeJournalV1);
1424
+ if (hasAnotherPage) {
1425
+ await storage.setAlarm(
1426
+ Date.now() + MEMORY_VECTOR_PURGE_RETRY_DELAY_MS_V1,
1427
+ );
1428
+ } else {
1429
+ await storage.deleteAlarm();
1430
+ }
1431
+ });
1432
+ return hasAnotherPage ? "pending" : "complete";
1433
+ }
1434
+
1435
+ private async tearDown(identity: {
1436
+ userId: string;
1437
+ botId: string;
1438
+ }): Promise<"complete" | "pending"> {
1439
+ // A throttled channel notice and every pre-delete scheduled deadline must
1440
+ // stop before the alarm is repurposed for the purge journal.
1441
+ this.stateChannel.silence();
1442
+ const stored = await this.ctx.storage.get<unknown>(
1443
+ MEMORY_VECTOR_PURGE_JOURNAL_KEY_V1,
1444
+ );
1445
+ let journal: MemoryVectorPurgeJournalV1;
1446
+ if (stored === undefined) {
1447
+ await this.ctx.storage.deleteAlarm();
1448
+ journal = {
1449
+ schemaVersion: 1,
1450
+ ...identity,
1451
+ status: "pending",
1452
+ deleted: 0,
1453
+ };
1454
+ await this.ctx.storage.put(MEMORY_VECTOR_PURGE_JOURNAL_KEY_V1, journal);
1455
+ } else {
1456
+ journal = decodeMemoryVectorPurgeJournalV1(stored);
1457
+ if (
1458
+ journal.userId !== identity.userId ||
1459
+ journal.botId !== identity.botId
1460
+ ) {
1461
+ throw new Error("Memory vector purge journal identity does not match");
1462
+ }
1463
+ }
1464
+ const outcome =
1465
+ journal.status === "pending"
1466
+ ? await this.purgeMemoryVectorBatch(journal)
1467
+ : "complete";
1468
+ if (outcome === "pending") return outcome;
1469
+ await this.finishTearDown(identity);
1470
+ return "complete";
1471
+ }
1472
+
1473
+ /** What a Computer charge made from this object is recorded against. */
1474
+ private async computerSpend(
1475
+ botId: string,
1476
+ personal: boolean,
1477
+ ): Promise<UsageAttributionV1> {
1478
+ const desktop: UsageAttributionV1 = { cause: { kind: "desktop", botId } };
1479
+ const state = this.mountedShell;
1480
+ // No Turn runs in an object that has not mounted.
1481
+ if (personal || !state) return desktop;
1482
+ const runId = await state.authority.readActiveRunId();
1483
+ if (!runId) return desktop;
1484
+ const run = await state.authority
1485
+ .readRunHeader(runId)
1486
+ .catch(() => undefined);
1487
+ return {
1488
+ runId,
1489
+ cause: await runCauseV1(
1490
+ botId,
1491
+ run?.admission?.origin,
1492
+ runCauseReadersV1(state),
1493
+ ),
1494
+ };
1495
+ }
1496
+
1497
+ private async materialized(identity: { userId: string; botId: string }) {
1498
+ const mounted = await this.unmaterialized(identity);
1499
+ await mounted.flock.materialize(mounted.registration, identity.userId);
1500
+ return mounted;
1501
+ }
1502
+
1503
+ /** Mounted and registered, without the Flock's materialization. */
1504
+ private async unmaterialized(identity: { userId: string; botId: string }) {
1505
+ this.bindSurfaces(identity);
1506
+ const contributions = await this.contributions();
1507
+ const registration = await this.registration(identity);
1508
+ return { ...contributions, registration };
1509
+ }
1510
+
1511
+ private async contribution(): Promise<ShellBotBackendContribution> {
1512
+ return (await this.contributions()).shell;
1513
+ }
1514
+
1515
+ async readConfiguration(input: unknown) {
1516
+ const request = decodeBotConfigurationReadRpcV1(input);
1517
+ const { shell } = await this.materialized({
1518
+ userId: request.userId,
1519
+ botId: request.botId,
1520
+ });
1521
+ return readConfiguration(shell.state, request);
1522
+ }
1523
+
1524
+ async executeConfiguration(input: unknown) {
1525
+ const request = decodeBotConfigurationExecuteRpcV1(input);
1526
+ const { shell } = await this.materialized({
1527
+ userId: request.userId,
1528
+ botId: request.botId,
1529
+ });
1530
+ const receipt = await executeConfiguration(shell.state, request);
1531
+ if (
1532
+ receipt.status === "applied" &&
1533
+ (request.command.type === "bot/update-profile" ||
1534
+ request.command.type === "bot/set-profile")
1535
+ ) {
1536
+ this.ctx.waitUntil(this.deliverProfileMirror().catch(() => undefined));
1537
+ }
1538
+ if (request.command.type === "bot/set-package-settings") {
1539
+ this.ctx.waitUntil(
1540
+ this.assembleTheme({
1541
+ schemaVersion: 1,
1542
+ userId: request.userId,
1543
+ botId: request.botId,
1544
+ }).catch(() => undefined),
1545
+ );
1546
+ }
1547
+ return receipt;
1548
+ }
1549
+
1550
+ /** Which of the User's installed Plugins this Bot runs (ADR 0026). */
1551
+ async readPluginEnablement(input: unknown) {
1552
+ const request = decodeRpcEnvelopeV1(input, {
1553
+ userId: rpcIdentifier,
1554
+ botId: rpcBotId,
1555
+ });
1556
+ const identity = {
1557
+ userId: request.userId as string,
1558
+ botId: request.botId as string,
1559
+ };
1560
+ const { shell } = await this.materialized(identity);
1561
+ await shell.validateIdentity(identity);
1562
+ return readPluginEnablementV1(shell.state.ctx.storage);
1563
+ }
1564
+
1565
+ /**
1566
+ * Switches one Plugin for this Bot, fenced on the revision the caller read.
1567
+ * A stale revision is a receipt, not a thrown error: the caller re-reads and
1568
+ * decides again, the way every other revision-fenced command answers.
1569
+ */
1570
+ async setPluginEnabled(input: unknown) {
1571
+ const request = decodeRpcEnvelopeV1(input, {
1572
+ userId: rpcIdentifier,
1573
+ botId: rpcBotId,
1574
+ pluginId: rpcPattern(PLUGIN_ID_V1, PLUGIN_ID_MAX_LENGTH_V1),
1575
+ enabled: rpcBoolean,
1576
+ expectedRevision: rpcInteger({ minimum: 0, maximum: 1_000_000 }),
1577
+ });
1578
+ const identity = {
1579
+ userId: request.userId as string,
1580
+ botId: request.botId as string,
1581
+ };
1582
+ const { shell } = await this.materialized(identity);
1583
+ await shell.validateIdentity(identity);
1584
+ try {
1585
+ const enablement = await setPluginEnabledV1(shell.state.ctx.storage, {
1586
+ pluginId: request.pluginId as string,
1587
+ enabled: request.enabled as boolean,
1588
+ expectedRevision: request.expectedRevision as number,
1589
+ });
1590
+ return { status: "applied" as const, enablement };
1591
+ } catch (error) {
1592
+ if (error instanceof PluginEnablementConflictError) {
1593
+ return {
1594
+ status: "conflict" as const,
1595
+ currentRevision: error.currentRevision,
1596
+ };
1597
+ }
1598
+ throw error;
1599
+ }
1600
+ }
1601
+
1602
+ /** This Bot's Plugins page: one row per Plugin it could run, and whether it does. */
1603
+ async readBotPluginsFrame(input: unknown) {
1604
+ const request = decodeRpcEnvelopeV1(input, {
1605
+ userId: rpcIdentifier,
1606
+ botId: rpcBotId,
1607
+ });
1608
+ const identity = {
1609
+ userId: request.userId as string,
1610
+ botId: request.botId as string,
1611
+ };
1612
+ const { shell } = await this.materialized(identity);
1613
+ await shell.validateIdentity(identity);
1614
+ // The page's read draws every section; a switch re-reads without them.
1615
+ return readBotPluginsFrameV1(shell.state, identity, undefined, {
1616
+ sections: true,
1617
+ });
1618
+ }
1619
+
1620
+ /**
1621
+ * Runs the tool a section's control names, outside any Turn. A Plugin
1622
+ * that is off, has no section or no such tool is a rejection with the
1623
+ * reason, never a thrown error.
1624
+ */
1625
+ async executeBotPluginTool(input: unknown) {
1626
+ const request = decodeRpcEnvelopeV1(input, {
1627
+ userId: rpcIdentifier,
1628
+ botId: rpcBotId,
1629
+ command: rpcDecoded(decodePluginToolCommandV1),
1630
+ });
1631
+ const identity = {
1632
+ userId: request.userId as string,
1633
+ botId: request.botId as string,
1634
+ };
1635
+ const { shell } = await this.materialized(identity);
1636
+ await shell.validateIdentity(identity);
1637
+ return executeBotPluginToolV1(
1638
+ shell.state,
1639
+ identity,
1640
+ request.command as ReturnType<typeof decodePluginToolCommandV1>,
1641
+ );
1642
+ }
1643
+
1644
+ /** The canvas's one read: this Bot's panel bag, doors, and focused page. */
1645
+ async openFocusedPanel(input: unknown) {
1646
+ const request = decodeRpcEnvelopeV1(input, {
1647
+ userId: rpcIdentifier,
1648
+ botId: rpcBotId,
1649
+ appOrigin: rpcOrigin,
1650
+ });
1651
+ const identity = {
1652
+ userId: request.userId as string,
1653
+ botId: request.botId as string,
1654
+ };
1655
+ const { shell } = await this.materialized(identity);
1656
+ await shell.validateIdentity(identity);
1657
+ return openFocusedPanelV1(
1658
+ shell.state,
1659
+ identity,
1660
+ request.appOrigin as string,
1661
+ );
1662
+ }
1663
+
1664
+ async readFocusedPanel(input: unknown) {
1665
+ const identity = decodeBotIdentityRpcV1(input);
1666
+ const { shell } = await this.materialized(identity);
1667
+ await shell.validateIdentity(identity);
1668
+ const focus = await readFocusedPanelV1(shell.state.ctx.storage);
1669
+ return (
1670
+ focus ?? {
1671
+ schemaVersion: 1,
1672
+ pluginId: null,
1673
+ changedAt: new Date(0).toISOString(),
1674
+ }
1675
+ );
1676
+ }
1677
+
1678
+ /**
1679
+ * Records a Plugin page's use of a device ability once the client that
1680
+ * opened it says it has ended. Kept here first, so a rebuild reproduces the
1681
+ * row, then queued like any other; a retry of the same use is one row.
1682
+ */
1683
+ async recordPanelDeviceUse(input: unknown) {
1684
+ const request = decodeRpcEnvelopeV1(input, {
1685
+ userId: rpcIdentifier,
1686
+ botId: rpcBotId,
1687
+ use: rpcDecoded(decodeDeviceUseV1),
1688
+ });
1689
+ const identity = {
1690
+ userId: request.userId as string,
1691
+ botId: request.botId as string,
1692
+ };
1693
+ const { shell } = await this.materialized(identity);
1694
+ await shell.validateIdentity(identity);
1695
+ const use = request.use as DeviceUseV1;
1696
+ const user = await panelDeviceUserV1(shell.state, identity, use);
1697
+ if ("refused" in user) {
1698
+ return { status: "refused" as const, reason: user.refused };
1699
+ }
1700
+ const entry = await auditEntryForDeviceUseV1(
1701
+ identity.botId,
1702
+ use,
1703
+ user.displayName,
1704
+ );
1705
+ await new DeviceUseLogV1(this.ctx.storage).record(entry);
1706
+ if (this.backendEnv.AUDIT_SINK) {
1707
+ // Appended even for a retry: the outbox and the table are both keyed
1708
+ // by the use, so a second append is one row, and a first append lost
1709
+ // between the two writes is not.
1710
+ await this.auditOutbox().append([entry]);
1711
+ await this.drainAuditOutbox();
1712
+ }
1713
+ return { status: "recorded" as const };
1714
+ }
1715
+
1716
+ /** What a Plugin's page reported, kept for its Bot to read back. */
1717
+ async recordPanelPageReport(input: unknown) {
1718
+ const request = decodeRpcEnvelopeV1(input, {
1719
+ userId: rpcIdentifier,
1720
+ botId: rpcBotId,
1721
+ report: rpcDecoded(decodePluginPageReportV1),
1722
+ });
1723
+ const identity = {
1724
+ userId: request.userId as string,
1725
+ botId: request.botId as string,
1726
+ };
1727
+ const { shell } = await this.materialized(identity);
1728
+ await shell.validateIdentity(identity);
1729
+ return recordPluginPageReportV1(
1730
+ this.ctx.storage,
1731
+ await readBotPluginRosterV1(shell.state, identity),
1732
+ request.report as PluginPageReportCommandV1,
1733
+ new Date(),
1734
+ );
1735
+ }
1736
+
1737
+ async setFocusedPanel(input: unknown) {
1738
+ const request = decodeRpcEnvelopeV1(
1739
+ input,
1740
+ {
1741
+ userId: rpcIdentifier,
1742
+ botId: rpcBotId,
1743
+ pluginId: rpcPluginIdOrNull,
1744
+ },
1745
+ {
1746
+ surfaceId: rpcPattern(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/, 128),
1747
+ },
1748
+ );
1749
+ const identity = {
1750
+ userId: request.userId as string,
1751
+ botId: request.botId as string,
1752
+ };
1753
+ const { shell } = await this.materialized(identity);
1754
+ await shell.validateIdentity(identity);
1755
+ return applyPanelFocusV1(shell.state, identity, {
1756
+ pluginId: request.pluginId as string | null,
1757
+ ...(typeof request.surfaceId === "string"
1758
+ ? { surfaceId: request.surfaceId }
1759
+ : {}),
1760
+ });
1761
+ }
1762
+
1763
+ /**
1764
+ * Flips one switch for this Bot, fenced on the revision the page read. A
1765
+ * stale revision, a locked Plugin or an unavailable feature is a receipt,
1766
+ * not a thrown error: the page re-reads and says why.
1767
+ */
1768
+ async setBotPluginEnabled(input: unknown) {
1769
+ const request = decodeRpcEnvelopeV1(input, {
1770
+ userId: rpcIdentifier,
1771
+ botId: rpcBotId,
1772
+ command: rpcDecoded(decodeSetBotPluginEnabledCommandV1),
1773
+ });
1774
+ const identity = {
1775
+ userId: request.userId as string,
1776
+ botId: request.botId as string,
1777
+ };
1778
+ const { shell } = await this.materialized(identity);
1779
+ await shell.validateIdentity(identity);
1780
+ const receipt = await setBotPluginEnabledV1(
1781
+ shell.state,
1782
+ identity,
1783
+ request.command as ReturnType<typeof decodeSetBotPluginEnabledCommandV1>,
1784
+ );
1785
+ if (receipt.status === "applied") {
1786
+ this.ctx.waitUntil(
1787
+ this.assembleTheme({
1788
+ schemaVersion: 1,
1789
+ userId: identity.userId,
1790
+ botId: identity.botId,
1791
+ }).catch(() => undefined),
1792
+ );
1793
+ }
1794
+ return receipt;
1795
+ }
1796
+
1797
+ /** A non-waking projection of this Bot's durable Computer presence. */
1798
+ async readComputerPresence(input: unknown) {
1799
+ const identity = decodeBotIdentityRpcV1(input);
1800
+ const { shell, computer } = await this.materialized(identity);
1801
+ await shell.validateIdentity(identity);
1802
+ return computer.read(identity.userId, identity.botId);
1803
+ }
1804
+
1805
+ /**
1806
+ * Keeps the frame a subagent's Turn left on the Bot's desktop as the card's.
1807
+ * The subagent runs in its task's own object; the card reads this one.
1808
+ */
1809
+ async putComputerFrame(input: unknown): Promise<void> {
1810
+ const request = decodeRpcEnvelopeV1(input, {
1811
+ userId: rpcIdentifier,
1812
+ botId: rpcBotId,
1813
+ frame: rpcDecoded(decodeStoredComputerFrameV1),
1814
+ });
1815
+ const identity = {
1816
+ userId: request.userId as string,
1817
+ botId: request.botId as string,
1818
+ };
1819
+ const { shell, computer } = await this.materialized(identity);
1820
+ await shell.validateIdentity(identity);
1821
+ await computer.putFrame(request.frame as StoredComputerFrameV1);
1822
+ }
1823
+
1824
+ /**
1825
+ * The Bot's Computer frame, when `contentHash` still names it. Read from
1826
+ * this object's own storage; no Computer wakes to serve it.
1827
+ */
1828
+ async readComputerFrame(
1829
+ input: unknown,
1830
+ ): Promise<{ bytesBase64: string } | null> {
1831
+ const request = decodeRpcEnvelopeV1(input, {
1832
+ userId: rpcIdentifier,
1833
+ botId: rpcBotId,
1834
+ contentHash: rpcIdentifier,
1835
+ });
1836
+ const identity = {
1837
+ userId: request.userId as string,
1838
+ botId: request.botId as string,
1839
+ };
1840
+ const { shell, computer } = await this.materialized(identity);
1841
+ await shell.validateIdentity(identity);
1842
+ const frame = await computer.readFrame(request.contentHash as string);
1843
+ return frame ? { bytesBase64: bytesToBase64(frame.bytes) } : null;
1844
+ }
1845
+
1846
+ /** One durably admitted User command against this Bot's Computer. */
1847
+ async executeComputerPresenceCommand(input: unknown) {
1848
+ const request = decodeRpcEnvelopeV1(input, {
1849
+ userId: rpcIdentifier,
1850
+ botId: rpcBotId,
1851
+ command: rpcDecoded(decodeComputerCommandV1),
1852
+ });
1853
+ if (
1854
+ typeof request.userId !== "string" ||
1855
+ typeof request.botId !== "string"
1856
+ ) {
1857
+ throw new Error("Computer command RPC identity was not decoded");
1858
+ }
1859
+ const command = decodeComputerCommandV1(request.command);
1860
+ const identity = {
1861
+ userId: request.userId,
1862
+ botId: request.botId,
1863
+ };
1864
+ const { shell, computer } = await this.materialized(identity);
1865
+ await shell.validateIdentity(identity);
1866
+ return computer.execute(identity.userId, identity.botId, command);
1867
+ }
1868
+
1869
+ async readAvatar(input: unknown) {
1870
+ const identity = decodeBotIdentityRpcV1(input);
1871
+ const { flock, registration } = await this.materialized(identity);
1872
+ return flock.read(registration, identity.userId);
1873
+ }
1874
+
1875
+ async updateAvatar(input: unknown) {
1876
+ const request = decodeRpcEnvelopeV1(input, {
1877
+ userId: rpcIdentifier,
1878
+ botId: rpcBotId,
1879
+ command: rpcDecoded(decodeUpdateAvatarCommandV1),
1880
+ });
1881
+ const identity = {
1882
+ userId: request.userId as string,
1883
+ botId: request.botId as string,
1884
+ };
1885
+ const { flock, registration } = await this.materialized(identity);
1886
+ return flock.update(
1887
+ registration,
1888
+ identity.userId,
1889
+ request.command as ReturnType<typeof decodeUpdateAvatarCommandV1>,
1890
+ );
1891
+ }
1892
+
1893
+ async readVoice(input: unknown) {
1894
+ const identity = decodeBotIdentityRpcV1(input);
1895
+ const { flock, registration } = await this.materialized(identity);
1896
+ return flock.readVoice(registration, identity.userId);
1897
+ }
1898
+
1899
+ async updateVoice(input: unknown) {
1900
+ const request = decodeRpcEnvelopeV1(input, {
1901
+ userId: rpcIdentifier,
1902
+ botId: rpcBotId,
1903
+ command: rpcDecoded(decodeUpdateVoiceCommandV1),
1904
+ });
1905
+ const identity = {
1906
+ userId: request.userId as string,
1907
+ botId: request.botId as string,
1908
+ };
1909
+ const { flock, registration } = await this.materialized(identity);
1910
+ return flock.updateVoice(
1911
+ registration,
1912
+ identity.userId,
1913
+ request.command as ReturnType<typeof decodeUpdateVoiceCommandV1>,
1914
+ );
1915
+ }
1916
+
1917
+ async readLook(input: unknown) {
1918
+ const identity = decodeBotIdentityRpcV1(input);
1919
+ const { flock, registration } = await this.materialized(identity);
1920
+ return flock.readLook(registration, identity.userId);
1921
+ }
1922
+
1923
+ async updateLook(input: unknown) {
1924
+ const request = decodeRpcEnvelopeV1(input, {
1925
+ userId: rpcIdentifier,
1926
+ botId: rpcBotId,
1927
+ command: rpcDecoded(decodeUpdateLookCommandV1),
1928
+ });
1929
+ const identity = {
1930
+ userId: request.userId as string,
1931
+ botId: request.botId as string,
1932
+ };
1933
+ const { flock, registration } = await this.materialized(identity);
1934
+ const receipt = await flock.updateLook(
1935
+ registration,
1936
+ identity.userId,
1937
+ request.command as ReturnType<typeof decodeUpdateLookCommandV1>,
1938
+ );
1939
+ if (receipt.status === "applied") {
1940
+ await holdThemePickV1(this.ctx.storage);
1941
+ // Another of the person's devices may have this Bot open.
1942
+ this.ctx.waitUntil(this.stateChannel.noticeLook().catch(() => undefined));
1943
+ this.ctx.waitUntil(
1944
+ this.assembleTheme({
1945
+ schemaVersion: 1,
1946
+ userId: identity.userId,
1947
+ botId: identity.botId,
1948
+ }).catch(() => undefined),
1949
+ );
1950
+ }
1951
+ return receipt;
1952
+ }
1953
+
1954
+ async persistAssembledDocument(input: unknown) {
1955
+ const request = decodeRpcEnvelopeV1(
1956
+ input,
1957
+ {
1958
+ userId: rpcIdentifier,
1959
+ botId: rpcBotId,
1960
+ },
1961
+ { document: rpcDecoded(decodeThemeDocumentV1) },
1962
+ );
1963
+ const identity = {
1964
+ userId: request.userId as string,
1965
+ botId: request.botId as string,
1966
+ };
1967
+ const { flock, registration } = await this.materialized(identity);
1968
+ return flock.persistAssembledDocument(
1969
+ registration,
1970
+ identity.userId,
1971
+ request.document === undefined
1972
+ ? undefined
1973
+ : (request.document as ReturnType<typeof decodeThemeDocumentV1>),
1974
+ );
1975
+ }
1976
+
1977
+ async assembleTheme(input: unknown) {
1978
+ const identity = decodeBotIdentityRpcV1(input);
1979
+ const { flock, registration, shell } = await this.materialized(identity);
1980
+ const user = decodeUserSettingsViewV1(
1981
+ rpcJsonSnapshotV1(
1982
+ await userConfigurationV1(shell.state, identity).readConfiguration({
1983
+ schemaVersion: 1,
1984
+ userId: identity.userId,
1985
+ }),
1986
+ ),
1987
+ );
1988
+ return assembleBotThemeV1(shell.state, identity, {
1989
+ flock,
1990
+ registration,
1991
+ appearance: user.appearance?.look ?? "ink",
1992
+ looks: BRAND_V1.looks,
1993
+ timezone: userTimezoneV1(user.profile),
1994
+ mirror: async (look, document) => {
1995
+ await userConfigurationV1(shell.state, identity).mirrorBotLook(
1996
+ identity.userId,
1997
+ identity.botId,
1998
+ look,
1999
+ document,
2000
+ );
2001
+ },
2002
+ changed: () => {
2003
+ this.ctx.waitUntil(
2004
+ this.stateChannel.noticeLook().catch(() => undefined),
2005
+ );
2006
+ },
2007
+ });
2008
+ }
2009
+
2010
+ /**
2011
+ * Sends the queued profile mirror, if one is due. The alarm path and the
2012
+ * post-commit attempt share this. A miss keeps the record for the alarm.
2013
+ */
2014
+ private async deliverProfileMirror(): Promise<void> {
2015
+ const pending = await this.ctx.storage.get(PROFILE_MIRROR_KEY_V1);
2016
+ if (pending === undefined) return;
2017
+ const identity = await this.ctx.storage.get<BotIdentity>(IDENTITY_KEY);
2018
+ if (!identity) return;
2019
+ const shell = await this.contribution();
2020
+ await deliverProfileMirrorV1({
2021
+ storage: this.ctx.storage,
2022
+ now: Date.now(),
2023
+ botId: identity.botId,
2024
+ mirror: (profile) =>
2025
+ userConfigurationV1(shell.state, identity).mirrorBotProfile(
2026
+ identity.userId,
2027
+ identity.botId,
2028
+ profile,
2029
+ ),
2030
+ refreshAlarm: (transaction) =>
2031
+ shell.state.authority.refreshRecoveryAlarm(
2032
+ transaction as unknown as Parameters<
2033
+ typeof shell.state.authority.refreshRecoveryAlarm
2034
+ >[0],
2035
+ ),
2036
+ });
2037
+ }
2038
+
2039
+ private async assembleThemeIfDue(): Promise<void> {
2040
+ const due = await themeAssembleDeadlineV1(this.ctx.storage);
2041
+ if (due.length === 0 || due[0]! > Date.now()) return;
2042
+ const identity = await this.ctx.storage.get<BotIdentity>(IDENTITY_KEY);
2043
+ if (!identity) return;
2044
+ await this.assembleTheme({
2045
+ schemaVersion: 1,
2046
+ userId: identity.userId,
2047
+ botId: identity.botId,
2048
+ });
2049
+ }
2050
+
2051
+ // Neither lifecycle door materializes the Bot first: the Flock Contribution
2052
+ // reads the tombstone before it would, and materializing a deleted Bot
2053
+ // throws, which would leave a delete whose settlement was lost unable ever
2054
+ // to learn that it had happened.
2055
+ async readLifecycle(input: unknown) {
2056
+ const identity = decodeBotIdentityRpcV1(input);
2057
+ const { flock, registration } = await this.unmaterialized(identity);
2058
+ return flock.readLifecycle(registration, identity.userId);
2059
+ }
2060
+
2061
+ async executeLifecycle(input: unknown) {
2062
+ const request = decodeRpcEnvelopeV1(input, {
2063
+ userId: rpcIdentifier,
2064
+ botId: rpcBotId,
2065
+ command: rpcDecoded(decodeBotLifecycleCommandV1),
2066
+ });
2067
+ const identity = {
2068
+ userId: request.userId as string,
2069
+ botId: request.botId as string,
2070
+ };
2071
+ const command = request.command as BotLifecycleCommandV1;
2072
+ if (command.botId !== identity.botId)
2073
+ throw new Error("lifecycle command does not match Bot authority");
2074
+ const { flock, registration } = await this.unmaterialized(identity);
2075
+ return flock.executeLifecycle(registration, identity.userId, command);
2076
+ }
2077
+
2078
+ /**
2079
+ * D6: model invocation as a User-enabled binding. Without the resolved
2080
+ * model binding the answer is unavailable; with one, the
2081
+ * request is recorded and the credential lease taken through the existing
2082
+ * provider path before any event is streamed back.
2083
+ */
2084
+ async isolateInvokeModel(input: unknown) {
2085
+ const request = decodeIsolateCallRpcV1(
2086
+ input,
2087
+ decodeNormalizedModelRequestV1,
2088
+ );
2089
+ const identity = {
2090
+ userId: request.userId as string,
2091
+ botId: request.botId as string,
2092
+ };
2093
+ const shell = await this.contribution();
2094
+ return isolateInvokeModel(shell.state, identity, {
2095
+ runId: request.runId as string,
2096
+ sessionId: request.sessionId as string,
2097
+ turnId: request.turnId as string,
2098
+ packageId: request.packageId as string,
2099
+ generationId: request.generationId as string,
2100
+ request: request.request as NormalizedModelRequest,
2101
+ });
2102
+ }
2103
+
2104
+ async isolateAuthority(input: unknown) {
2105
+ const request = decodeIsolateCallRpcV1(input, (value) => value ?? null);
2106
+ return isolateAuthority(
2107
+ (await this.contribution()).state,
2108
+ { userId: request.userId as string, botId: request.botId as string },
2109
+ request as unknown as IsolateCallScopeV1,
2110
+ );
2111
+ }
2112
+
2113
+ async isolateStorageGet(input: unknown) {
2114
+ return isolateStorageGet(
2115
+ (await this.contribution()).state,
2116
+ decodeIsolateCallRpcV1(
2117
+ input,
2118
+ decodeIsolateStorageGetRequestV1,
2119
+ ) as unknown as IsolateCallScopeV1,
2120
+ );
2121
+ }
2122
+
2123
+ async isolateStoragePut(input: unknown) {
2124
+ return isolateStoragePut(
2125
+ (await this.contribution()).state,
2126
+ decodeIsolateCallRpcV1(
2127
+ input,
2128
+ decodeIsolateStoragePutRequestV1,
2129
+ ) as unknown as IsolateCallScopeV1,
2130
+ );
2131
+ }
2132
+
2133
+ async isolateStorageDelete(input: unknown) {
2134
+ return isolateStorageDelete(
2135
+ (await this.contribution()).state,
2136
+ decodeIsolateCallRpcV1(
2137
+ input,
2138
+ decodeIsolateStorageDeleteRequestV1,
2139
+ ) as unknown as IsolateCallScopeV1,
2140
+ );
2141
+ }
2142
+
2143
+ async isolateStorageList(input: unknown) {
2144
+ return isolateStorageList(
2145
+ (await this.contribution()).state,
2146
+ decodeIsolateCallRpcV1(input, (value) =>
2147
+ decodeIsolateStorageListRequestV1(value ?? {}),
2148
+ ) as unknown as IsolateCallScopeV1,
2149
+ );
2150
+ }
2151
+
2152
+ async isolateSettings(input: unknown) {
2153
+ return isolateSettings(
2154
+ (await this.contribution()).state,
2155
+ decodeIsolateCallRpcV1(
2156
+ input,
2157
+ (value) => value ?? null,
2158
+ ) as unknown as IsolateCallScopeV1,
2159
+ );
2160
+ }
2161
+
2162
+ async isolateJevDecide(input: unknown) {
2163
+ return isolateJevDecide(
2164
+ (await this.contribution()).state,
2165
+ decodeIsolateCallRpcV1(input, decodeIsolateJevRequestV1) as never,
2166
+ );
2167
+ }
2168
+
2169
+ async isolateMemoryRead(input: unknown) {
2170
+ return isolateMemoryRead(
2171
+ (await this.contribution()).state,
2172
+ decodeIsolateCallRpcV1(input, decodeIsolateMemoryReadRequestV1) as never,
2173
+ );
2174
+ }
2175
+
2176
+ async isolateMemoryWrite(input: unknown) {
2177
+ return isolateMemoryWrite(
2178
+ (await this.contribution()).state,
2179
+ decodeIsolateCallRpcV1(input, decodeIsolateMemoryWriteRequestV1) as never,
2180
+ );
2181
+ }
2182
+
2183
+ async isolateMemoryForget(input: unknown) {
2184
+ return isolateMemoryForget(
2185
+ (await this.contribution()).state,
2186
+ decodeIsolateCallRpcV1(input, decodeIsolateMemoryWriteRequestV1) as never,
2187
+ );
2188
+ }
2189
+
2190
+ async isolateWorkspaceRead(input: unknown) {
2191
+ return isolateWorkspaceRead(
2192
+ (await this.contribution()).state,
2193
+ decodeIsolateCallRpcV1(input, decodeIsolateWorkspacePathV1) as never,
2194
+ );
2195
+ }
2196
+
2197
+ async isolateWorkspaceList(input: unknown) {
2198
+ return isolateWorkspaceList(
2199
+ (await this.contribution()).state,
2200
+ decodeIsolateCallRpcV1(
2201
+ input,
2202
+ decodeIsolateWorkspaceListRequestV1,
2203
+ ) as never,
2204
+ );
2205
+ }
2206
+
2207
+ async isolateWorkspaceStat(input: unknown) {
2208
+ return isolateWorkspaceStat(
2209
+ (await this.contribution()).state,
2210
+ decodeIsolateCallRpcV1(input, decodeIsolateWorkspacePathV1) as never,
2211
+ );
2212
+ }
2213
+
2214
+ async isolateWorkspaceWrite(input: unknown) {
2215
+ return isolateWorkspaceWrite(
2216
+ (await this.contribution()).state,
2217
+ decodeIsolateCallRpcV1(
2218
+ input,
2219
+ decodeIsolateWorkspaceWriteRequestV1,
2220
+ ) as never,
2221
+ );
2222
+ }
2223
+
2224
+ async isolateWorkspaceDelete(input: unknown) {
2225
+ return isolateWorkspaceDelete(
2226
+ (await this.contribution()).state,
2227
+ decodeIsolateCallRpcV1(
2228
+ input,
2229
+ decodeIsolateWorkspaceDeleteRequestV1,
2230
+ ) as never,
2231
+ );
2232
+ }
2233
+
2234
+ async isolateConnection(input: unknown) {
2235
+ return isolateConnection(
2236
+ (await this.contribution()).state,
2237
+ decodeIsolateCallRpcV1(input, (value) => {
2238
+ if (
2239
+ typeof value !== "string" ||
2240
+ value.length === 0 ||
2241
+ value.length > 256
2242
+ ) {
2243
+ throw new Error("Connection id is invalid");
2244
+ }
2245
+ return value;
2246
+ }) as never,
2247
+ );
2248
+ }
2249
+
2250
+ async isolateModelTransport(input: unknown) {
2251
+ return isolateModelTransport(
2252
+ (await this.contribution()).state,
2253
+ // The transport request is decoded where the ticket is spent, so a
2254
+ // malformed call is refused without costing the dispatch its attempt.
2255
+ decodeIsolateCallRpcV1(input, (value) => value) as never,
2256
+ );
2257
+ }
2258
+
2259
+ async isolateEmail(input: unknown) {
2260
+ return isolateEmail(
2261
+ (await this.contribution()).state,
2262
+ decodeIsolateCallRpcV1(input, decodeIsolateEmailRequestV1) as never,
2263
+ );
2264
+ }
2265
+
2266
+ async isolateDeviceCall(input: unknown) {
2267
+ return isolateDeviceCall(
2268
+ (await this.contribution()).state,
2269
+ decodeIsolateCallRpcV1(input, decodeIsolateDeviceCallRequestV1) as never,
2270
+ );
2271
+ }
2272
+
2273
+ async isolateSchedule(input: unknown) {
2274
+ return isolateSchedule(
2275
+ (await this.contribution()).state,
2276
+ decodeIsolateCallRpcV1(input, decodeIsolateScheduleRequestV1) as never,
2277
+ );
2278
+ }
2279
+
2280
+ async resolveConfiguration(input: unknown) {
2281
+ const identity = decodeBotIdentityRpcV1(input);
2282
+ const { shell } = await this.materialized(identity);
2283
+ return resolveConfiguration(shell.state, identity);
2284
+ }
2285
+
2286
+ async run(input: unknown) {
2287
+ const request = decodeBotRunRpcV1(input);
2288
+ const identity = { userId: request.userId, botId: request.botId };
2289
+ const { shell } = await this.materialized(identity);
2290
+ return shell.run({
2291
+ ...identity,
2292
+ ...(await this.withAttachments(request.command)),
2293
+ });
2294
+ }
2295
+
2296
+ /**
2297
+ * A command's file refs, resolved against the uploads this Bot holds. A
2298
+ * ref to anything else is refused here, before the Turn is admitted, so a
2299
+ * message can only carry a file this Bot was given.
2300
+ */
2301
+ private async withAttachments(
2302
+ command: DecodedBotRunRpcV1["command"],
2303
+ ): Promise<BotTurnCommand> {
2304
+ const { attachments: refs, ...rest } = command;
2305
+ const attachments = await resolveUploadRefsV1(this.ctx.storage, refs);
2306
+ if (attachments) {
2307
+ // A recording the person just sent is theirs no longer to decide on:
2308
+ // the Computer stops offering it. Never a reason to refuse the message.
2309
+ const mounted = await this.mounted?.catch(() => undefined);
2310
+ await mounted?.computer
2311
+ .noteDemonstrationSent(attachments.map(({ uploadId }) => uploadId))
2312
+ .catch(() => undefined);
2313
+ }
2314
+ return { ...rest, ...(attachments ? { attachments } : {}) };
2315
+ }
2316
+
2317
+ /**
2318
+ * Records one upload the gateway has just written to object storage. The
2319
+ * upload route answers only after this, so the file a later message names
2320
+ * is one this Bot durably holds.
2321
+ */
2322
+ async recordUploadV1(input: unknown): Promise<StoredUploadV1> {
2323
+ const request = decodeRpcEnvelopeV1(input, {
2324
+ userId: rpcIdentifier,
2325
+ botId: rpcBotId,
2326
+ upload: rpcDecoded(decodeStoredUploadV1),
2327
+ });
2328
+ const identity = {
2329
+ userId: request.userId as string,
2330
+ botId: request.botId as string,
2331
+ };
2332
+ const { shell } = await this.materialized(identity);
2333
+ await shell.validateIdentity(identity);
2334
+ return recordUploadV1(this.ctx.storage, request.upload as StoredUploadV1);
2335
+ }
2336
+
2337
+ /**
2338
+ * Durably accepts a composer command and returns its receipt.
2339
+ *
2340
+ * Execution continues on this object's drive. `waitUntil` keeps the
2341
+ * isolate awake for that drive; the recovery alarm is what resumes it
2342
+ * after eviction.
2343
+ */
2344
+ async admitRun(input: unknown) {
2345
+ const request = decodeBotRunRpcV1(input);
2346
+ const identity = { userId: request.userId, botId: request.botId };
2347
+ const { shell } = await this.materialized(identity);
2348
+ const receipt = await shell.admit({
2349
+ ...identity,
2350
+ ...(await this.withAttachments(request.command)),
2351
+ });
2352
+ const work = shell.pendingWork();
2353
+ if (work) this.ctx.waitUntil(work);
2354
+ return receipt;
2355
+ }
2356
+
2357
+ /**
2358
+ * Finishes the resident drive, including the settled-run projections.
2359
+ *
2360
+ * The composer returns at admission. A caller that needs search and audit
2361
+ * rows waits here. Eviction drops this promise; the recovery alarm resumes
2362
+ * the run.
2363
+ */
2364
+ async joinAdmittedDrive(): Promise<void> {
2365
+ const mounted = this.mounted;
2366
+ if (!mounted) return;
2367
+ const { shell } = await mounted;
2368
+ await shell.pendingWork();
2369
+ }
2370
+
2371
+ async runAgent(input: unknown) {
2372
+ const request = decodeBotAgentRunRpcV1(input);
2373
+ const identity = { userId: request.userId, botId: request.botId };
2374
+ const { shell } = await this.materialized(identity);
2375
+ const turn = await shell.run({
2376
+ ...identity,
2377
+ runId: request.command.runId,
2378
+ sessionId: request.command.sessionId,
2379
+ acceptedAt: request.command.acceptedAt,
2380
+ text: request.command.text,
2381
+ turnType: "agent",
2382
+ lane: "agent",
2383
+ origin: request.command.source,
2384
+ });
2385
+ return turn;
2386
+ }
2387
+
2388
+ /**
2389
+ * The account's voice session asking this Bot for something.
2390
+ *
2391
+ * The same agent lane a Bot-to-Bot question uses, for the same reason: it
2392
+ * queues behind whatever the person or a Routine already has running and
2393
+ * never takes its place. What differs is the return address — a call and a
2394
+ * spoken Turn rather than a Bot — and that the answer goes back through the
2395
+ * voice reply outbox instead of this call's return value.
2396
+ */
2397
+ async runVoice(input: unknown) {
2398
+ const request = decodeBotVoiceRunRpcV1(input);
2399
+ const identity = { userId: request.userId, botId: request.botId };
2400
+ const { shell } = await this.materialized(identity);
2401
+ const turn = await shell.run({
2402
+ ...identity,
2403
+ runId: request.command.runId,
2404
+ sessionId: request.command.sessionId,
2405
+ acceptedAt: request.command.acceptedAt,
2406
+ text: request.command.text,
2407
+ turnType: "agent",
2408
+ lane: "agent",
2409
+ origin: request.command.source,
2410
+ });
2411
+ await this.drainVoiceReplyOutbox(identity.userId);
2412
+ return turn;
2413
+ }
2414
+
2415
+ /**
2416
+ * A Group Chat asking this member for a Turn.
2417
+ *
2418
+ * The agent lane, like a Bot's question: it waits behind the person's own
2419
+ * chat and never makes it yield. The admission returns at once; the group
2420
+ * reads the Turn back by id as it runs.
2421
+ */
2422
+ async admitGroupTurn(input: unknown) {
2423
+ const request = decodeBotGroupTurnRpcV1(input);
2424
+ const identity = { userId: request.userId, botId: request.botId };
2425
+ const { shell } = await this.materialized(identity);
2426
+ const receipt = await shell.admit({
2427
+ ...identity,
2428
+ runId: request.command.runId,
2429
+ sessionId: request.command.sessionId,
2430
+ acceptedAt: request.command.acceptedAt,
2431
+ text: request.command.text,
2432
+ turnType: "agent",
2433
+ lane: "agent",
2434
+ origin: request.command.origin,
2435
+ });
2436
+ const work = shell.pendingWork();
2437
+ if (work) this.ctx.waitUntil(work);
2438
+ return receipt;
2439
+ }
2440
+
2441
+ /**
2442
+ * The person, writing to this Bot's inbound address from one of their
2443
+ * confirmed mailboxes.
2444
+ *
2445
+ * The user lane, like a typed message. The `email()` handler returns only
2446
+ * once this does, so the message is durable before Email Routing is told it
2447
+ * arrived, and a redelivery — the same run id — replays this admission. Its
2448
+ * files are refs to uploads the handler recorded here first, resolved like a
2449
+ * composer's.
2450
+ */
2451
+ async admitEmailTurn(input: unknown) {
2452
+ const request = decodeBotEmailTurnRpcV1(input);
2453
+ const identity = { userId: request.userId, botId: request.botId };
2454
+ const { shell } = await this.materialized(identity);
2455
+ await shell.validateIdentity(identity);
2456
+ const { origin, ...command } = request.command;
2457
+ // A message that only passes something on is answered without waking a
2458
+ // device. Unjudged, the Turn is as loud as ever.
2459
+ const triage = await shell.state.emailTriageJudge?.({ text: command.text });
2460
+ const receipt = await shell.admit({
2461
+ ...identity,
2462
+ ...(await this.withAttachments(command)),
2463
+ turnType: "chat",
2464
+ lane: "user",
2465
+ origin: triage?.quiet ? { ...origin, quiet: true } : origin,
2466
+ });
2467
+ const work = shell.pendingWork();
2468
+ if (work) this.ctx.waitUntil(work);
2469
+ return receipt;
2470
+ }
2471
+
2472
+ /** One of this member's group Turns, as its group reads it back. */
2473
+ async readGroupTurn(input: unknown) {
2474
+ const request = decodeRpcEnvelopeV1(input, {
2475
+ userId: rpcIdentifier,
2476
+ botId: rpcBotId,
2477
+ runId: rpcString(128),
2478
+ });
2479
+ const identity = {
2480
+ userId: request.userId as string,
2481
+ botId: request.botId as string,
2482
+ };
2483
+ const { shell } = await this.materialized(identity);
2484
+ const run = await shell.state.authority.readRun(request.runId as string);
2485
+ if (!run || !groupOriginOfRunV1(run)) {
2486
+ return { schemaVersion: 1, found: false } as const;
2487
+ }
2488
+ return {
2489
+ schemaVersion: 1,
2490
+ found: true,
2491
+ state: groupTurnStateOfRunV1(run),
2492
+ } as const;
2493
+ }
2494
+
2495
+ /**
2496
+ * A newer message in a group this member has a Turn open in. Kept as the
2497
+ * highest position heard of, so the Turn yields at its next step boundary.
2498
+ */
2499
+ async signalGroupMessage(input: unknown) {
2500
+ const request = decodeRpcEnvelopeV1(input, {
2501
+ userId: rpcIdentifier,
2502
+ botId: rpcBotId,
2503
+ groupId: rpcPattern(/^g-[0-9a-f]{20}$/, 22),
2504
+ seq: rpcInteger({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
2505
+ });
2506
+ await this.materialized({
2507
+ userId: request.userId as string,
2508
+ botId: request.botId as string,
2509
+ });
2510
+ const key = groupWaitingKeyV1(request.groupId as string);
2511
+ await this.ctx.storage.transaction(async (transaction) => {
2512
+ const current = await transaction.get<number>(key);
2513
+ if (typeof current !== "number" || current < (request.seq as number)) {
2514
+ await transaction.put(key, request.seq as number);
2515
+ }
2516
+ });
2517
+ return { schemaVersion: 1 } as const;
2518
+ }
2519
+
2520
+ /**
2521
+ * Tells a group one of its member Turns here changed: it started, sent
2522
+ * something, or settled. Best effort: the group reads the Turn itself, and
2523
+ * its alarm reads it again if this is lost.
2524
+ */
2525
+ private nudgeGroup(runId: string | undefined): void {
2526
+ const namespace = this.backendEnv.GROUP_CHATS;
2527
+ if (!namespace || !runId) return;
2528
+ this.ctx.waitUntil(
2529
+ (async () => {
2530
+ const stored = await this.ctx.storage.get<{
2531
+ admission?: { origin?: { kind: string; groupId?: string } };
2532
+ }>(`run:${runId}`);
2533
+ const groupId =
2534
+ stored?.admission?.origin?.kind === "group"
2535
+ ? stored.admission.origin.groupId
2536
+ : undefined;
2537
+ if (!groupId) return;
2538
+ const identity = await this.ctx.storage.get<BotIdentity>(IDENTITY_KEY);
2539
+ if (!identity) return;
2540
+ // SAFETY: the binding names GroupChat; this is its reviewed RPC door.
2541
+ const group = namespace.get(
2542
+ namespace.idFromName(groupChatObjectNameV1(identity.userId, groupId)),
2543
+ ) as unknown as { turnChanged(input: unknown): Promise<unknown> };
2544
+ await group.turnChanged({
2545
+ schemaVersion: 1,
2546
+ userId: identity.userId,
2547
+ groupId,
2548
+ botId: identity.botId,
2549
+ runId,
2550
+ });
2551
+ })().catch(() => undefined),
2552
+ );
2553
+ }
2554
+
2555
+ /**
2556
+ * A settled voice request written into this Bot's thread after hang-up.
2557
+ *
2558
+ * The voice object is the only caller. The receipt is the run id, so a
2559
+ * retried announce is one message. The send is projected onto the voice
2560
+ * request's own run, the way a Routine that broke after it had already
2561
+ * ended still lands a readable line.
2562
+ */
2563
+ async deliverVoiceChatResult(input: unknown) {
2564
+ const request = decodeVoiceChatResultRpcV1(input);
2565
+ const identity = { userId: request.userId, botId: request.botId };
2566
+ const { shell } = await this.materialized(identity);
2567
+ await shell.validateIdentity(identity);
2568
+ const settings = await readBotSettingsV1(shell.state, identity);
2569
+ const receiptKey = `voice:chat-result:${request.command.runId}`;
2570
+ const createdAt = new Date().toISOString();
2571
+ let committed = false;
2572
+ await this.ctx.storage.transaction(async (transaction) => {
2573
+ if (await transaction.get(receiptKey)) return;
2574
+ const records = await visibleMessageRecordsV1({
2575
+ settings,
2576
+ read: (key) => transaction.get(key),
2577
+ messages: [
2578
+ {
2579
+ messageId: messageIdV1(
2580
+ request.command.runId,
2581
+ request.command.ordinal,
2582
+ ),
2583
+ runId: request.command.runId,
2584
+ createdAt,
2585
+ body: request.command.body.slice(0, 2_000),
2586
+ automation: true,
2587
+ projectedSendOrdinal: request.command.ordinal,
2588
+ },
2589
+ ],
2590
+ });
2591
+ for (const [key, value] of Object.entries(records)) {
2592
+ await transaction.put(key, value);
2593
+ }
2594
+ await transaction.put(receiptKey, { schemaVersion: 1, at: createdAt });
2595
+ committed = true;
2596
+ });
2597
+ if (committed) this.ctx.waitUntil(this.drainPush());
2598
+ return { status: "accepted" as const };
2599
+ }
2600
+
2601
+ /**
2602
+ * Spoken turns of one ended call, written as a collapsible thread section.
2603
+ *
2604
+ * The voice object is the only caller. The receipt is the call id, so a
2605
+ * hang-up and the abandoned-call alarm that follows a dropped socket write
2606
+ * one section. The observer is told so the thread redraws without a poll.
2607
+ */
2608
+ async deliverVoiceCallTranscript(input: unknown) {
2609
+ const request = decodeVoiceCallTranscriptRpcV1(input);
2610
+ const identity = { userId: request.userId, botId: request.botId };
2611
+ const { shell } = await this.materialized(identity);
2612
+ await shell.validateIdentity(identity);
2613
+ const receiptKey = `voice:call-transcript:${request.command.callId}`;
2614
+ let committed = false;
2615
+ await this.ctx.storage.transaction(async (transaction) => {
2616
+ if (await transaction.get(receiptKey)) return;
2617
+ await appendAnnouncement(transaction, (seq) => ({
2618
+ type: "voice/call",
2619
+ seq,
2620
+ timestamp: request.command.endedAt,
2621
+ callId: request.command.callId,
2622
+ startedAt: request.command.startedAt,
2623
+ endedAt: request.command.endedAt,
2624
+ turns: request.command.turns,
2625
+ }));
2626
+ await transaction.put({
2627
+ [receiptKey]: {
2628
+ schemaVersion: 1,
2629
+ at: request.command.endedAt,
2630
+ },
2631
+ });
2632
+ await shell.state.authority.refreshRecoveryAlarm(transaction);
2633
+ committed = true;
2634
+ });
2635
+ if (committed) await shell.state.authority.drainCommittedPublication();
2636
+ return { status: "accepted" as const };
2637
+ }
2638
+
2639
+ /** This object's bounded, durable audit outbox. */
2640
+ private auditOutbox(): AuditOutboxV1 {
2641
+ return new AuditOutboxV1(this.ctx.storage);
2642
+ }
2643
+
2644
+ /**
2645
+ * Queues one settled run's audit entries and drains what is pending.
2646
+ *
2647
+ * Queue first, deliver second, and never the other way round: the entries
2648
+ * are durable in this object before the User object is asked for anything,
2649
+ * so a User object that is away, slow, or mid-eviction costs a retry rather
2650
+ * than a gap. Whatever a drain leaves behind is picked up by the next
2651
+ * settlement or by the alarm this object already has.
2652
+ */
2653
+ private async projectSettledAudit(
2654
+ shell: ShellBotBackendContribution,
2655
+ identity: { userId: string; botId: string },
2656
+ runId: string,
2657
+ ): Promise<void> {
2658
+ const sink = this.backendEnv.AUDIT_SINK;
2659
+ if (!sink) return;
2660
+ const outbox = this.auditOutbox();
2661
+ try {
2662
+ const lookup = await shell.lookupRun({ schemaVersion: 1, runId });
2663
+ if (lookup.state === "terminal") {
2664
+ const stored = await shell.listRunEventPage();
2665
+ const run = stored.runs.find((candidate) => candidate.runId === runId);
2666
+ if (run) {
2667
+ await outbox.append(
2668
+ await auditEntriesFromStoredRunV1(identity.botId, run),
2669
+ );
2670
+ }
2671
+ }
2672
+ } catch {
2673
+ // A projection this object could not build is a gap a rebuild closes;
2674
+ // it is never a reason for an admitted Turn to look as if it failed.
2675
+ }
2676
+ await this.drainAuditOutbox();
2677
+ }
2678
+
2679
+ /**
2680
+ * Hands whatever is pending to the User Durable Object.
2681
+ *
2682
+ * The failure is swallowed *here* and not inside the outbox: the outbox
2683
+ * throws so that nothing is cleared that was not delivered, and this call
2684
+ * site swallows so that a derived projection never decides whether a Turn
2685
+ * settled.
2686
+ */
2687
+ private async drainAuditOutbox(): Promise<void> {
2688
+ const sink = this.backendEnv.AUDIT_SINK;
2689
+ if (!sink) return;
2690
+ try {
2691
+ await this.auditOutbox().drain(sink);
2692
+ } catch {
2693
+ // Still pending, still durable, still visible as `pending` in the
2694
+ // outbox state the Activity surface reads.
2695
+ }
2696
+ }
2697
+
2698
+ /**
2699
+ * One page of this Bot's audit entries, projected from its own stored runs.
2700
+ *
2701
+ * The durable session events rather than the client projection: the client
2702
+ * projection drops `call.input`, and the argument digest needs the exact
2703
+ * arguments.
2704
+ */
2705
+ async projectAuditEntries(input: unknown) {
2706
+ const request = decodeRpcEnvelopeV1(
2707
+ input,
2708
+ { userId: rpcIdentifier, botId: rpcBotId },
2709
+ { cursor: rpcString(512) },
2710
+ );
2711
+ const identity = {
2712
+ userId: request.userId as string,
2713
+ botId: request.botId as string,
2714
+ };
2715
+ const { shell } = await this.materialized(identity);
2716
+ await shell.validateIdentity(identity);
2717
+ const cursor = request.cursor as string | undefined;
2718
+ // Device uses come from no run, so this object's own record of them is
2719
+ // the first page; the runs follow from their start.
2720
+ if (cursor === undefined) {
2721
+ const devices = await new DeviceUseLogV1(this.ctx.storage).entries();
2722
+ if (devices.length > 0) {
2723
+ return {
2724
+ schemaVersion: 1 as const,
2725
+ botId: identity.botId,
2726
+ entries: devices,
2727
+ nextCursor: DEVICE_USE_PAGE_DONE_V1,
2728
+ };
2729
+ }
2730
+ }
2731
+ return createBotAuditEntryPageV1(
2732
+ identity.botId,
2733
+ await shell.listRunEventPage(
2734
+ cursor === DEVICE_USE_PAGE_DONE_V1 ? undefined : cursor,
2735
+ ),
2736
+ );
2737
+ }
2738
+
2739
+ /**
2740
+ * Search and audit for one settled run. Called from the authority once the
2741
+ * run is terminal, including when the composer did not wait for it.
2742
+ */
2743
+ private async projectSettled(
2744
+ shell: ShellBotBackendContribution,
2745
+ runId: string,
2746
+ ): Promise<void> {
2747
+ const identity = await this.ctx.storage.get<BotIdentity>(IDENTITY_KEY);
2748
+ if (!identity) return;
2749
+ await this.projectSettledRun(shell, identity, runId);
2750
+ await this.projectSettledAudit(shell, identity, runId);
2751
+ }
2752
+
2753
+ /**
2754
+ * Projects one settled run into the User's transcript index.
2755
+ *
2756
+ * After settlement, and never before it: the run is already durable in this
2757
+ * object, so an index write that fails costs a rebuild rather than a Turn.
2758
+ * The failure is swallowed for the same reason — a derived projection must
2759
+ * not decide whether the thing it derives from succeeded.
2760
+ */
2761
+ private async projectSettledRun(
2762
+ shell: ShellBotBackendContribution,
2763
+ identity: { userId: string; botId: string },
2764
+ runId: string,
2765
+ ): Promise<void> {
2766
+ const sink = this.backendEnv.SEARCH_SINK;
2767
+ if (!sink) return;
2768
+ try {
2769
+ const lookup = await shell.lookupRun({ schemaVersion: 1, runId });
2770
+ if (lookup.state !== "terminal") return;
2771
+ await sink.indexRows(
2772
+ searchRowsFromClientRunV1(identity.botId, lookup.run),
2773
+ );
2774
+ } catch {
2775
+ // Visible as a gap the index state and a rebuild both report; never a
2776
+ // reason for an admitted Turn to look as if it failed.
2777
+ }
2778
+ }
2779
+
2780
+ /**
2781
+ * One page of this Bot's rows, projected from its own stored runs.
2782
+ *
2783
+ * This is what makes the User's index disposable: every row it holds can be
2784
+ * read back out of the authority that owns the conversation.
2785
+ */
2786
+ async projectSearchRows(input: unknown) {
2787
+ const request = decodeRpcEnvelopeV1(
2788
+ input,
2789
+ { userId: rpcIdentifier, botId: rpcBotId },
2790
+ { cursor: rpcString(512) },
2791
+ );
2792
+ const identity = {
2793
+ userId: request.userId as string,
2794
+ botId: request.botId as string,
2795
+ };
2796
+ const { shell } = await this.materialized(identity);
2797
+ await shell.validateIdentity(identity);
2798
+ const cursor = request.cursor as string | undefined;
2799
+ return createBotSearchRowPageV1(
2800
+ identity.botId,
2801
+ await shell.listRuns({
2802
+ schemaVersion: 1,
2803
+ ...(cursor === undefined ? {} : { before: cursor }),
2804
+ }),
2805
+ );
2806
+ }
2807
+
2808
+ /**
2809
+ * The Bot's invocable Skills, for the composer's `/` and `@` popover. A
2810
+ * read: it binds the Workspace surfaces the Turn path binds, and writes
2811
+ * nothing.
2812
+ */
2813
+ /**
2814
+ * One durable-root file, read from object storage.
2815
+ *
2816
+ * The Workspace read path the hosted client needs: a screenshot the Bot
2817
+ * filed under a Package-declared root is durable content, and a card that
2818
+ * renders it has to be able to fetch it without reaching a Computer. It
2819
+ * wakes none — this is R2 and this object's own generation ledger — which is
2820
+ * exactly why the durable-root sync exists.
2821
+ */
2822
+ async readWorkspaceFileV1(input: unknown): Promise<ClientWorkspaceFileV1> {
2823
+ const request = decodeRpcEnvelopeV1(input, {
2824
+ userId: rpcIdentifier,
2825
+ botId: rpcBotId,
2826
+ path: rpcDecoded(decodeWorkspacePathV1),
2827
+ });
2828
+ const identity = {
2829
+ userId: request.userId as string,
2830
+ botId: request.botId as string,
2831
+ };
2832
+ const { shell } = await this.materialized(identity);
2833
+ await shell.validateIdentity(identity);
2834
+ const files = this.backendEnv.WORKSPACE_FILES;
2835
+ if (!files) {
2836
+ return {
2837
+ schemaVersion: 1 as const,
2838
+ status: "unavailable" as const,
2839
+ reason: "This deployment binds no Workspace object store",
2840
+ };
2841
+ }
2842
+ const outcome = await files.read(request.path as WorkspacePathV1);
2843
+ if (outcome.status !== "ok") {
2844
+ return {
2845
+ schemaVersion: 1 as const,
2846
+ status: outcome.status,
2847
+ reason: outcome.reason,
2848
+ };
2849
+ }
2850
+ return {
2851
+ schemaVersion: 1 as const,
2852
+ status: "ok" as const,
2853
+ contentHash: outcome.file.generation.contentHash,
2854
+ size: outcome.file.generation.size,
2855
+ bytesBase64: bytesToBase64(outcome.file.bytes),
2856
+ };
2857
+ }
2858
+
2859
+ async listSkills(input: unknown) {
2860
+ const identity = decodeBotIdentityRpcV1(input);
2861
+ const { shell } = await this.materialized(identity);
2862
+ return listSkills(shell.state, identity);
2863
+ }
2864
+
2865
+ async stopRun(input: unknown) {
2866
+ const request = decodeRpcEnvelopeV1(input, {
2867
+ userId: rpcIdentifier,
2868
+ botId: rpcBotId,
2869
+ command: rpcDecoded(decodeClientRunStopCommandV1),
2870
+ });
2871
+ const identity = {
2872
+ userId: request.userId as string,
2873
+ botId: request.botId as string,
2874
+ };
2875
+ const { shell } = await this.materialized(identity);
2876
+ return stopRun(
2877
+ shell.state,
2878
+ identity,
2879
+ request.command as ClientRunStopCommandV1,
2880
+ );
2881
+ }
2882
+
2883
+ /**
2884
+ * Hands every answer a voice call is owed to that account's voice object.
2885
+ *
2886
+ * The entry is written in the transaction that recorded the answer, so this
2887
+ * can lose a wake-up without losing the answer: an entry that was not
2888
+ * delivered is still there for the next settlement or this object's own
2889
+ * alarm, and the voice object's scheduled look-up settles it anyway. Delivery
2890
+ * is by request id, and the voice ledger refuses a request that is no longer
2891
+ * `admitted`, so a redelivery is a no-op rather than a second answer.
2892
+ *
2893
+ * An entry is deleted only after the voice object has acknowledged it. One
2894
+ * that names a call the account no longer has is acknowledged all the same:
2895
+ * the answer is in the Bot's thread, and the outbox is not where it lives.
2896
+ */
2897
+ private async drainVoiceReplyOutbox(userId: string): Promise<void> {
2898
+ const namespace = (
2899
+ this.env as BotStateEnv & { VOICE_ASSISTANTS?: DurableObjectNamespace }
2900
+ ).VOICE_ASSISTANTS;
2901
+ if (!namespace) return;
2902
+ const entries = await this.ctx.storage.list<unknown>({
2903
+ prefix: VOICE_REPLY_OUTBOX_PREFIX_V1,
2904
+ limit: VOICE_REPLY_DRAIN_LIMIT_V1,
2905
+ });
2906
+ if (entries.size === 0) return;
2907
+ // SAFETY: the binding names VoiceAssistant; this is its reviewed RPC door.
2908
+ const voice = namespace.get(namespace.idFromName(userId)) as unknown as {
2909
+ deliverVoiceReply(input: unknown): Promise<unknown>;
2910
+ };
2911
+ for (const [key, value] of entries) {
2912
+ const entry = isVoiceReplyOutboxEntryV1(value) ? value : undefined;
2913
+ if (!entry) {
2914
+ // Not a record this build wrote. It cannot be delivered and will never
2915
+ // become deliverable, so it goes rather than blocking the queue.
2916
+ await this.ctx.storage.delete(key);
2917
+ continue;
2918
+ }
2919
+ try {
2920
+ await voice.deliverVoiceReply({
2921
+ schemaVersion: 1,
2922
+ userId,
2923
+ requestId: entry.runId,
2924
+ });
2925
+ } catch {
2926
+ // Still durable, still owed, retried by the next settlement or alarm.
2927
+ return;
2928
+ }
2929
+ await this.ctx.storage.delete(key);
2930
+ }
2931
+ }
2932
+
2933
+ private pushDrain?: Promise<void>;
2934
+ /**
2935
+ * A commit landed while a drain was already in flight.
2936
+ *
2937
+ * The drain lists the outbox once and then awaits a delivery per entry, so an
2938
+ * entry written after that listing — the second `send_to_user` of one Turn is
2939
+ * the ordinary case — is invisible to it. Without this the second
2940
+ * notification waits for the next alarm. A pass that delivered anything is
2941
+ * followed by another, and this flag closes the last gap: a commit that
2942
+ * lands after the final, empty listing. Neither can spin, because a pass
2943
+ * that delivers nothing ends the drain and every delivered entry is deleted.
2944
+ */
2945
+ private pushDrainAgain = false;
2946
+ private drainPush(): Promise<void> {
2947
+ if (this.pushDrain) {
2948
+ this.pushDrainAgain = true;
2949
+ return this.pushDrain;
2950
+ }
2951
+ this.pushDrain = (async () => {
2952
+ let delivered = 0;
2953
+ do {
2954
+ this.pushDrainAgain = false;
2955
+ delivered = await this.flushPush();
2956
+ } while (this.pushDrainAgain || delivered > 0);
2957
+ })()
2958
+ .catch(() => {
2959
+ console.error(JSON.stringify({ event: "push-outbox-pending" }));
2960
+ })
2961
+ .finally(() => {
2962
+ this.pushDrain = undefined;
2963
+ this.pushDrainAgain = false;
2964
+ });
2965
+ return this.pushDrain;
2966
+ }
2967
+
2968
+ /** How many outbox entries this pass delivered. Zero ends the drain. */
2969
+ private async flushPush(): Promise<number> {
2970
+ const identity = await this.ctx.storage.get<BotIdentity>(IDENTITY_KEY);
2971
+ if (!identity) return 0;
2972
+ const rpc = this.env.USER_CONFIGURATIONS.get(
2973
+ this.env.USER_CONFIGURATIONS.idFromName(identity.userId),
2974
+ ) as unknown as {
2975
+ deliverPush(input: { userId: string; update: PushUpdate }): Promise<void>;
2976
+ };
2977
+ const entries = await this.ctx.storage.list<MessageNotice>({
2978
+ prefix: PUSH_OUTBOX_PREFIX,
2979
+ limit: PUSH_OUTBOX_DRAIN_LIMIT,
2980
+ });
2981
+ // Both are read per entry rather than once for the pass. Every delivery is
2982
+ // an await on another object, and a Bot muted or a conversation read during
2983
+ // one of them must decide the next entry: a hoisted snapshot would alert
2984
+ // for a Bot that is already silenced.
2985
+ for (const [key, notice] of entries) {
2986
+ const settings = await this.ctx.storage.get<BotSettingsViewV1>(
2987
+ BOT_CONFIGURATION_KEY,
2988
+ );
2989
+ const unread = optionalUnreadStateV1(
2990
+ await this.ctx.storage.get(UNREAD_STATE_KEY),
2991
+ );
2992
+ await rpc.deliverPush({
2993
+ userId: identity.userId,
2994
+ update: {
2995
+ botId: identity.botId,
2996
+ kind: "message",
2997
+ cursor: notice.notificationId,
2998
+ title: notice.title,
2999
+ body: notice.body,
3000
+ notify:
3001
+ notice.notify &&
3002
+ settings?.notifications.enabled !== false &&
3003
+ (unread.lastSeenCursor === undefined ||
3004
+ notice.notificationId > unread.lastSeenCursor),
3005
+ },
3006
+ });
3007
+ await this.ctx.storage.delete(key);
3008
+ }
3009
+ const read = await this.ctx.storage.get<{ cursor: string }>(PUSH_READ_KEY);
3010
+ if (read) {
3011
+ await rpc.deliverPush({
3012
+ userId: identity.userId,
3013
+ update: { botId: identity.botId, kind: "read", cursor: read.cursor },
3014
+ });
3015
+ await this.ctx.storage.transaction(async (tx) => {
3016
+ if (
3017
+ (await tx.get<{ cursor: string }>(PUSH_READ_KEY))?.cursor ===
3018
+ read.cursor
3019
+ )
3020
+ await tx.delete(PUSH_READ_KEY);
3021
+ });
3022
+ }
3023
+ return entries.size;
3024
+ }
3025
+
3026
+ /** The Bot's unread projection; the Bot Durable Object derives the count. */
3027
+ async readUnread(input: unknown) {
3028
+ const identity = decodeBotIdentityRpcV1(input);
3029
+ const { shell } = await this.materialized(identity);
3030
+ return readUnread(shell.state, identity);
3031
+ }
3032
+
3033
+ /** `bot/mark-read` / `bot/mark-unread`, idempotent on the command id. */
3034
+ async executeUnreadCommand(input: unknown) {
3035
+ const request = decodeRpcEnvelopeV1(input, {
3036
+ userId: rpcIdentifier,
3037
+ botId: rpcBotId,
3038
+ command: rpcDecoded(decodeBotUnreadCommandV1),
3039
+ });
3040
+ const identity = {
3041
+ userId: request.userId as string,
3042
+ botId: request.botId as string,
3043
+ };
3044
+ const { shell } = await this.materialized(identity);
3045
+ const receipt = await executeUnreadCommand(
3046
+ shell.state,
3047
+ identity,
3048
+ request.command as BotUnreadCommandV1,
3049
+ );
3050
+ this.ctx.waitUntil(this.drainPush());
3051
+ return receipt;
3052
+ }
3053
+
3054
+ /** The Bot's approvals, newest first, pending and decided alike. */
3055
+ async listApprovals(input: unknown) {
3056
+ const identity = decodeBotIdentityRpcV1(input);
3057
+ const { shell } = await this.materialized(identity);
3058
+ return listApprovals(shell.state, identity);
3059
+ }
3060
+
3061
+ /**
3062
+ * One decision on one approval. First write wins: a replay answers with the
3063
+ * decision already recorded rather than overwriting it.
3064
+ */
3065
+ async decideApproval(input: unknown) {
3066
+ const request = decodeRpcEnvelopeV1(input, {
3067
+ userId: rpcIdentifier,
3068
+ botId: rpcBotId,
3069
+ approvalId: rpcIdentifier,
3070
+ command: rpcDecoded(decodeApprovalDecisionCommandV1),
3071
+ });
3072
+ const identity = {
3073
+ userId: request.userId as string,
3074
+ botId: request.botId as string,
3075
+ };
3076
+ const { shell } = await this.materialized(identity);
3077
+ return decideApproval(
3078
+ shell.state,
3079
+ identity,
3080
+ request.approvalId as string,
3081
+ request.command as ApprovalDecisionCommandV1,
3082
+ );
3083
+ }
3084
+
3085
+ /** The Bot's Cards, newest first, tombstoned ones included (ADR 0030). */
3086
+ async listCards(input: unknown) {
3087
+ const identity = decodeBotIdentityRpcV1(input);
3088
+ const { shell } = await this.materialized(identity);
3089
+ return listCards(shell.state, identity);
3090
+ }
3091
+
3092
+ /**
3093
+ * One Card by its surface id. The listing is bounded by bytes, so this is
3094
+ * how a client reads a surface that fell past the budget.
3095
+ */
3096
+ async readCard(input: unknown) {
3097
+ const request = decodeRpcEnvelopeV1(input, {
3098
+ userId: rpcIdentifier,
3099
+ botId: rpcBotId,
3100
+ surfaceId: rpcDecoded(decodeCardSurfaceIdV1),
3101
+ });
3102
+ const identity = {
3103
+ userId: request.userId as string,
3104
+ botId: request.botId as string,
3105
+ };
3106
+ const { shell } = await this.materialized(identity);
3107
+ return readCardView(shell.state, identity, request.surfaceId as string);
3108
+ }
3109
+
3110
+ /**
3111
+ * One action on one Card. The kernel decides what the action's name means;
3112
+ * a stale revision is refused rather than applied to a surface the person
3113
+ * was not looking at.
3114
+ */
3115
+ async cardAction(input: unknown) {
3116
+ const request = decodeRpcEnvelopeV1(input, {
3117
+ userId: rpcIdentifier,
3118
+ botId: rpcBotId,
3119
+ command: rpcDecoded(decodeCardActionCommandV1),
3120
+ });
3121
+ const identity = {
3122
+ userId: request.userId as string,
3123
+ botId: request.botId as string,
3124
+ };
3125
+ const { shell } = await this.materialized(identity);
3126
+ const receipt = await cardAction(
3127
+ shell.state,
3128
+ identity,
3129
+ request.command as CardActionCommandV1,
3130
+ );
3131
+ return receipt;
3132
+ }
3133
+
3134
+ /**
3135
+ * A value a person typed on one of this Bot's secret-request cards. It is
3136
+ * handed straight to the User Durable Object to seal and is never written
3137
+ * here; what this object keeps is that the request was answered.
3138
+ */
3139
+ async submitSecret(input: unknown) {
3140
+ const request = decodeRpcEnvelopeV1(input, {
3141
+ userId: rpcIdentifier,
3142
+ botId: rpcBotId,
3143
+ requestId: rpcPattern(/^secret-request-[0-9a-f]{32}$/, 128),
3144
+ command: rpcDecoded(decodeSecretSubmitCommandV1),
3145
+ });
3146
+ const identity = {
3147
+ userId: request.userId as string,
3148
+ botId: request.botId as string,
3149
+ };
3150
+ const { shell } = await this.materialized(identity);
3151
+ return submitSecretV1(
3152
+ shell.state,
3153
+ identity,
3154
+ request.requestId as string,
3155
+ request.command as SecretSubmitCommandV1,
3156
+ );
3157
+ }
3158
+
3159
+ async listNotifications(input: unknown) {
3160
+ const identity = decodeBotIdentityRpcV1(input);
3161
+ const { shell } = await this.materialized(identity);
3162
+ await shell.validateIdentity(identity);
3163
+ return listNotifications(shell.state);
3164
+ }
3165
+
3166
+ async acknowledgeNotification(input: unknown) {
3167
+ const request = decodeRpcEnvelopeV1(input, {
3168
+ userId: rpcIdentifier,
3169
+ botId: rpcBotId,
3170
+ notificationId: rpcIdentifier,
3171
+ });
3172
+ const identity = {
3173
+ userId: request.userId as string,
3174
+ botId: request.botId as string,
3175
+ };
3176
+ const { shell } = await this.materialized(identity);
3177
+ await shell.validateIdentity(identity);
3178
+ return acknowledgeNotification(
3179
+ shell.state,
3180
+ request.notificationId as string,
3181
+ );
3182
+ }
3183
+
3184
+ /**
3185
+ * The Bot's subagent tasks. Bot-scoped, so it proves directory membership the
3186
+ * same way the other Bot RPCs do: a Bot that is not this User's is not found.
3187
+ *
3188
+ * This is the *parent* object's answer. A Subagent Durable Object has no
3189
+ * route of its own and holds no task list: it holds one Session, and every
3190
+ * authority stays here.
3191
+ */
3192
+ async listTasks(input: unknown) {
3193
+ const identity = decodeBotIdentityRpcV1(input);
3194
+ const { shell } = await this.materialized(identity);
3195
+ await shell.validateIdentity(identity);
3196
+ return listTasks(shell.state, identity);
3197
+ }
3198
+
3199
+ /** One task, by id. The parent object's answer; a child holds no list. */
3200
+ async readTask(input: unknown) {
3201
+ const request = decodeRpcEnvelopeV1(input, {
3202
+ userId: rpcIdentifier,
3203
+ botId: rpcBotId,
3204
+ taskId: rpcString(128),
3205
+ });
3206
+ const identity = {
3207
+ userId: request.userId as string,
3208
+ botId: request.botId as string,
3209
+ };
3210
+ const { shell } = await this.materialized(identity);
3211
+ return readTask(shell.state, identity, request.taskId as string);
3212
+ }
3213
+
3214
+ /**
3215
+ * The User's own cancellation of one task.
3216
+ *
3217
+ * The same durable act `task_stop` performs, through a second authenticated
3218
+ * door: explicit, authenticated, and terminal — never a second mechanism.
3219
+ */
3220
+ async stopTask(input: unknown) {
3221
+ const request = decodeRpcEnvelopeV1(input, {
3222
+ userId: rpcIdentifier,
3223
+ botId: rpcBotId,
3224
+ taskId: rpcString(128),
3225
+ });
3226
+ const identity = {
3227
+ userId: request.userId as string,
3228
+ botId: request.botId as string,
3229
+ };
3230
+ const { shell } = await this.materialized(identity);
3231
+ return stopTaskForUser(shell.state, identity, request.taskId as string);
3232
+ }
3233
+
3234
+ /** The Subagent Durable Object's cancellation door. */
3235
+ async stopSubagentTask(input: unknown) {
3236
+ const request = decodeRpcEnvelopeV1(input, {
3237
+ userId: rpcIdentifier,
3238
+ botId: rpcBotId,
3239
+ taskId: rpcString(128),
3240
+ });
3241
+ const identity = {
3242
+ userId: request.userId as string,
3243
+ botId: request.botId as string,
3244
+ };
3245
+ const { shell } = await this.materialized(identity);
3246
+ return stopSubagentTask(shell.state, identity, request.taskId as string);
3247
+ }
3248
+
3249
+ /**
3250
+ * The Subagent Durable Object's door.
3251
+ *
3252
+ * It records the task and arms its own alarm; the Turn runs on that alarm.
3253
+ * The parent is still inside the Turn that dispatched when this returns, so
3254
+ * anything longer than a write here would block it.
3255
+ */
3256
+ async runTask(input: unknown) {
3257
+ const request = decodeRpcEnvelopeV1(input, {
3258
+ userId: rpcIdentifier,
3259
+ botId: rpcBotId,
3260
+ request: rpcDecoded(decodeSubagentRunTaskRequestV1),
3261
+ });
3262
+ const identity = {
3263
+ userId: request.userId as string,
3264
+ botId: request.botId as string,
3265
+ };
3266
+ const { shell } = await this.materialized(identity);
3267
+ return acceptSubagentTask(
3268
+ shell.state,
3269
+ identity,
3270
+ request.request as SubagentRunTaskRequestV1,
3271
+ );
3272
+ }
3273
+
3274
+ /** What a Subagent Durable Object holds for one task, for reconciliation. */
3275
+ async readSubagentTask(input: unknown) {
3276
+ const request = decodeRpcEnvelopeV1(input, {
3277
+ userId: rpcIdentifier,
3278
+ botId: rpcBotId,
3279
+ taskId: rpcString(128),
3280
+ });
3281
+ const identity = {
3282
+ userId: request.userId as string,
3283
+ botId: request.botId as string,
3284
+ };
3285
+ const { shell } = await this.materialized(identity);
3286
+ return readSubagentTaskContext(shell.state, request.taskId as string);
3287
+ }
3288
+
3289
+ /**
3290
+ * The messages a parent has queued for one of its tasks, claimed by the
3291
+ * child that is running it.
3292
+ *
3293
+ * The claim marks what it hands over in the parent's own transaction, so a
3294
+ * child that retries a step reads the marks back rather than the message.
3295
+ */
3296
+ async claimTaskMessages(input: unknown) {
3297
+ const request = decodeRpcEnvelopeV1(input, {
3298
+ userId: rpcIdentifier,
3299
+ botId: rpcBotId,
3300
+ taskId: rpcString(128),
3301
+ });
3302
+ const identity = {
3303
+ userId: request.userId as string,
3304
+ botId: request.botId as string,
3305
+ };
3306
+ const { shell } = await this.materialized(identity);
3307
+ return claimTaskMessages(shell.state, identity, request.taskId as string);
3308
+ }
3309
+
3310
+ /** One terminal task outcome, recorded on the parent. Idempotent per task. */
3311
+ async settleTask(input: unknown) {
3312
+ const request = decodeRpcEnvelopeV1(input, {
3313
+ userId: rpcIdentifier,
3314
+ botId: rpcBotId,
3315
+ taskId: rpcString(128),
3316
+ outcome: rpcDecoded(decodeTaskOutcomeV1),
3317
+ });
3318
+ const identity = {
3319
+ userId: request.userId as string,
3320
+ botId: request.botId as string,
3321
+ };
3322
+ const { shell } = await this.materialized(identity);
3323
+ return settleTask(
3324
+ shell.state,
3325
+ identity,
3326
+ request.taskId as string,
3327
+ request.outcome as TaskOutcomeV1,
3328
+ );
3329
+ }
3330
+
3331
+ /**
3332
+ * The Bot's Routines. Bot-scoped, so it proves directory membership the same
3333
+ * way the other Bot RPCs do: a Bot that is not this User's is not found.
3334
+ */
3335
+ async listRoutines(input: unknown) {
3336
+ const request = decodeRpcEnvelopeV1(input, {
3337
+ userId: rpcIdentifier,
3338
+ botId: rpcBotId,
3339
+ });
3340
+ const identity = {
3341
+ userId: request.userId as string,
3342
+ botId: request.botId as string,
3343
+ };
3344
+ const { shell } = await this.materialized(identity);
3345
+ await shell.validateIdentity(identity);
3346
+ await this.syncRoutineTimezone(identity, shell);
3347
+ return listRoutines(shell.state, identity);
3348
+ }
3349
+
3350
+ /**
3351
+ * The Routines list and the inbox as one RPC. The document read is this,
3352
+ * so the Worker is not addressing the object twice for one panel.
3353
+ */
3354
+ async readRoutinesFrame(input: unknown) {
3355
+ const request = decodeRpcEnvelopeV1(input, {
3356
+ userId: rpcIdentifier,
3357
+ botId: rpcBotId,
3358
+ });
3359
+ const identity = {
3360
+ userId: request.userId as string,
3361
+ botId: request.botId as string,
3362
+ };
3363
+ const { shell } = await this.materialized(identity);
3364
+ await shell.validateIdentity(identity);
3365
+ await this.syncRoutineTimezone(identity, shell);
3366
+ return readRoutinesFrame(shell.state, identity);
3367
+ }
3368
+
3369
+ /** One Routine command, applied durably with the User recorded as writer. */
3370
+ async executeRoutineCommand(input: unknown) {
3371
+ const request = decodeRpcEnvelopeV1(input, {
3372
+ userId: rpcIdentifier,
3373
+ botId: rpcBotId,
3374
+ command: rpcDecoded(decodeRoutineCommandV1),
3375
+ });
3376
+ const identity = {
3377
+ userId: request.userId as string,
3378
+ botId: request.botId as string,
3379
+ };
3380
+ const { shell } = await this.materialized(identity);
3381
+ await shell.validateIdentity(identity);
3382
+ await this.syncRoutineTimezone(identity, shell);
3383
+ return executeRoutineCommand(
3384
+ shell.state,
3385
+ identity,
3386
+ request.command as RoutineCommandV1,
3387
+ { kind: "user" },
3388
+ connectionTriggersFromUserV1(userConfigurationV1(shell.state, identity)),
3389
+ pluginTriggerIndexFromUserV1(userConfigurationV1(shell.state, identity)),
3390
+ );
3391
+ }
3392
+
3393
+ /**
3394
+ * One connected-app event, after the User object resolved the instance to
3395
+ * this Bot and this Routine. Directory membership is not proved here: the
3396
+ * caller is the provider door, and the instance mapping is the credential.
3397
+ */
3398
+ async deliverConnectEvent(input: unknown) {
3399
+ const request = decodeRpcEnvelopeV1(input, {
3400
+ userId: rpcIdentifier,
3401
+ botId: rpcBotId,
3402
+ routineId: rpcIdentifier,
3403
+ eventId: rpcIdentifier,
3404
+ payload: (value) => value,
3405
+ });
3406
+ const identity = {
3407
+ userId: request.userId as string,
3408
+ botId: request.botId as string,
3409
+ };
3410
+ const { shell } = await this.materialized(identity);
3411
+ return deliverConnectEvent(shell.state, {
3412
+ routineId: request.routineId as string,
3413
+ eventId: request.eventId as string,
3414
+ payload: request.payload,
3415
+ });
3416
+ }
3417
+
3418
+ /**
3419
+ * One device module's event (ADR 0037), after the User object admitted it
3420
+ * against the active generation and found this Routine in its index. Like
3421
+ * a connected-app event, the User object's routing is the credential.
3422
+ */
3423
+ async deliverPluginModuleEvent(input: unknown) {
3424
+ const request = decodeRpcEnvelopeV1(input, {
3425
+ userId: rpcIdentifier,
3426
+ botId: rpcBotId,
3427
+ routineId: rpcIdentifier,
3428
+ deliveryId: rpcPattern(/^[0-9a-f]{64}$/, 64),
3429
+ pluginId: rpcPattern(/^[a-z][a-z0-9-]{0,63}$/, 64),
3430
+ trigger: rpcPattern(/^[a-z][a-z0-9_-]{0,63}$/, 64),
3431
+ body: rpcString(ROUTINE_HOOK_BODY_MAX_BYTES),
3432
+ source: rpcObject({
3433
+ kind: rpcEnum(["device-module"] as const),
3434
+ moduleId: rpcString(64),
3435
+ machineId: rpcString(200),
3436
+ key: rpcString(256),
3437
+ }),
3438
+ });
3439
+ const identity = {
3440
+ userId: request.userId as string,
3441
+ botId: request.botId as string,
3442
+ };
3443
+ const { shell } = await this.materialized(identity);
3444
+ return deliverRoutineModuleEvent(shell.state, {
3445
+ routineId: request.routineId as string,
3446
+ deliveryId: request.deliveryId as string,
3447
+ pluginId: request.pluginId as string,
3448
+ trigger: request.trigger as string,
3449
+ body: request.body as string,
3450
+ source: request.source as RoutineModuleEventDeliveryV1["source"],
3451
+ });
3452
+ }
3453
+
3454
+ /** What the User object rebuilds its Plugin-trigger index from. */
3455
+ async listPluginTriggerRoutines(input: unknown) {
3456
+ const request = decodeRpcEnvelopeV1(input, {
3457
+ userId: rpcIdentifier,
3458
+ botId: rpcBotId,
3459
+ });
3460
+ const identity = {
3461
+ userId: request.userId as string,
3462
+ botId: request.botId as string,
3463
+ };
3464
+ const { shell } = await this.materialized(identity);
3465
+ return listPluginTriggerRoutines(shell.state);
3466
+ }
3467
+
3468
+ /** Adopt a Profile timezone pushed by this User's authoritative object. */
3469
+ async refreshRoutineTimezone(input: unknown) {
3470
+ const request = decodeRpcEnvelopeV1(input, {
3471
+ userId: rpcIdentifier,
3472
+ botId: rpcBotId,
3473
+ timezone: rpcString(64),
3474
+ revision: rpcInteger({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
3475
+ });
3476
+ const identity = {
3477
+ userId: request.userId as string,
3478
+ botId: request.botId as string,
3479
+ };
3480
+ const { shell } = await this.materialized(identity);
3481
+ await shell.validateIdentity(identity);
3482
+ await projectRoutineAccountTimezoneV1(
3483
+ shell.state,
3484
+ request.timezone as string,
3485
+ request.revision as number,
3486
+ );
3487
+ return { schemaVersion: 1 as const, status: "applied" as const };
3488
+ }
3489
+
3490
+ /**
3491
+ * One webhook delivery, forwarded by the gateway after it verified the key's
3492
+ * signature. The Bot re-checks the key against its own durable record: the
3493
+ * edge proved the token was minted by this deployment, not that it is still
3494
+ * this Routine's key.
3495
+ *
3496
+ * Directory membership is deliberately *not* proved here. The caller is an
3497
+ * external system with no session; the key is the whole credential, and it
3498
+ * names the User the token was minted for.
3499
+ */
3500
+ async deliverRoutineHook(input: unknown) {
3501
+ const request = decodeRpcEnvelopeV1(input, {
3502
+ userId: rpcIdentifier,
3503
+ botId: rpcBotId,
3504
+ delivery: rpcDecoded(decodeRoutineHookDeliveryV1),
3505
+ });
3506
+ const identity = {
3507
+ userId: request.userId as string,
3508
+ botId: request.botId as string,
3509
+ };
3510
+ const { shell } = await this.materialized(identity);
3511
+ return deliverRoutineHook(
3512
+ shell.state,
3513
+ request.delivery as RoutineHookDeliveryV1,
3514
+ );
3515
+ }
3516
+
3517
+ /**
3518
+ * One finished machine command, handed over by the Worker that answered the
3519
+ * machine.
3520
+ *
3521
+ * It names the Bot the command record named — a Bot cannot be told about a
3522
+ * command it never asked for, because it is the command that carries the
3523
+ * `botId`.
3524
+ */
3525
+ async deliverMachineResult(input: unknown) {
3526
+ const request = decodeRpcEnvelopeV1(input, {
3527
+ userId: rpcIdentifier,
3528
+ botId: rpcBotId,
3529
+ delivery: rpcDecoded(decodeMachineResultDeliveryV1),
3530
+ });
3531
+ const identity = {
3532
+ userId: request.userId as string,
3533
+ botId: request.botId as string,
3534
+ };
3535
+ const { shell } = await this.materialized(identity);
3536
+ return deliverMachineResult(
3537
+ shell.state,
3538
+ request.delivery as MachineResultDeliveryV1,
3539
+ );
3540
+ }
3541
+
3542
+ /**
3543
+ * One request a `computer_exec` call's CLI made to a connected app, which
3544
+ * the Worker has already verified this object's token for. Answered by the
3545
+ * call's own handler while it runs, and refused once it has finished.
3546
+ */
3547
+ async answerComputerEgress(input: unknown) {
3548
+ const request = decodeComputerEgressAnswerInputV1(input);
3549
+ return answerComputerEgressV1({
3550
+ object: request.object,
3551
+ nonce: request.nonce,
3552
+ request: request.request,
3553
+ });
3554
+ }
3555
+
3556
+ /** One Routine's bounded run log, newest first. */
3557
+ async listRoutineRuns(input: unknown) {
3558
+ const request = decodeRpcEnvelopeV1(input, {
3559
+ userId: rpcIdentifier,
3560
+ botId: rpcBotId,
3561
+ routineId: rpcIdentifier,
3562
+ });
3563
+ const identity = {
3564
+ userId: request.userId as string,
3565
+ botId: request.botId as string,
3566
+ };
3567
+ const { shell } = await this.materialized(identity);
3568
+ await shell.validateIdentity(identity);
3569
+ return listRoutineRuns(shell.state, identity, request.routineId as string);
3570
+ }
3571
+
3572
+ /**
3573
+ * One automation run, read-only. It is not in the visible transcript and
3574
+ * never will be; the Routine's run log is the only door to it.
3575
+ */
3576
+ async readRoutineRun(input: unknown) {
3577
+ const request = decodeRpcEnvelopeV1(input, {
3578
+ userId: rpcIdentifier,
3579
+ botId: rpcBotId,
3580
+ routineId: rpcIdentifier,
3581
+ runId: rpcIdentifier,
3582
+ });
3583
+ const identity = {
3584
+ userId: request.userId as string,
3585
+ botId: request.botId as string,
3586
+ };
3587
+ const { shell } = await this.materialized(identity);
3588
+ await shell.validateIdentity(identity);
3589
+ return readRoutineRun(
3590
+ shell.state,
3591
+ identity,
3592
+ request.routineId as string,
3593
+ request.runId as string,
3594
+ );
3595
+ }
3596
+
3597
+ /** The completion inbox: what the Bot's firings left for its User. */
3598
+ async listRoutineInbox(input: unknown) {
3599
+ const request = decodeRpcEnvelopeV1(input, {
3600
+ userId: rpcIdentifier,
3601
+ botId: rpcBotId,
3602
+ });
3603
+ const identity = {
3604
+ userId: request.userId as string,
3605
+ botId: request.botId as string,
3606
+ };
3607
+ const { shell } = await this.materialized(identity);
3608
+ await shell.validateIdentity(identity);
3609
+ return listRoutineInbox(shell.state, identity);
3610
+ }
3611
+
3612
+ /** Acknowledging inbox entries; an explicit command, never a read. */
3613
+ async executeRoutineInboxCommand(input: unknown) {
3614
+ const request = decodeRpcEnvelopeV1(input, {
3615
+ userId: rpcIdentifier,
3616
+ botId: rpcBotId,
3617
+ command: rpcDecoded(decodeRoutineInboxCommandV1),
3618
+ });
3619
+ const identity = {
3620
+ userId: request.userId as string,
3621
+ botId: request.botId as string,
3622
+ };
3623
+ const { shell } = await this.materialized(identity);
3624
+ await shell.validateIdentity(identity);
3625
+ return executeRoutineInboxCommand(
3626
+ shell.state,
3627
+ identity,
3628
+ request.command as RoutineInboxCommandV1,
3629
+ );
3630
+ }
3631
+
3632
+ /**
3633
+ * The Bot's durable Composition generations, newest first. Bot-scoped, so it
3634
+ * proves directory membership the same way the other Bot RPCs do.
3635
+ */
3636
+ async listCompositionGenerations(input: unknown) {
3637
+ const request = decodeRpcEnvelopeV1(input, {
3638
+ userId: rpcIdentifier,
3639
+ botId: rpcBotId,
3640
+ query: rpcObject(
3641
+ {
3642
+ limit: rpcInteger({
3643
+ minimum: 1,
3644
+ maximum: MAX_COMPOSITION_GENERATION_PAGE_V1,
3645
+ }),
3646
+ },
3647
+ { cursor: rpcString(512) },
3648
+ ),
3649
+ });
3650
+ const identity = {
3651
+ userId: request.userId as string,
3652
+ botId: request.botId as string,
3653
+ };
3654
+ const { shell } = await this.materialized(identity);
3655
+ await shell.validateIdentity(identity);
3656
+ return listCompositionGenerations(
3657
+ shell.state,
3658
+ identity,
3659
+ request.query as { limit: number; cursor?: string },
3660
+ );
3661
+ }
3662
+
3663
+ /**
3664
+ * One generation, including the recorded source of each isolate member once
3665
+ * authoring records exist (plan Step 5); the member list until then.
3666
+ */
3667
+ async getCompositionGeneration(input: unknown) {
3668
+ const request = decodeRpcEnvelopeV1(input, {
3669
+ userId: rpcIdentifier,
3670
+ botId: rpcBotId,
3671
+ generationId: rpcDecoded(decodeCompositionGenerationIdV1),
3672
+ });
3673
+ const identity = {
3674
+ userId: request.userId as string,
3675
+ botId: request.botId as string,
3676
+ };
3677
+ const { shell } = await this.materialized(identity);
3678
+ await shell.validateIdentity(identity);
3679
+ return getCompositionGeneration(
3680
+ shell.state,
3681
+ identity,
3682
+ request.generationId as string,
3683
+ );
3684
+ }
3685
+
3686
+ /** Reverting records a new pending generation; it never mutates a record. */
3687
+ async revertComposition(input: unknown) {
3688
+ const request = decodeRpcEnvelopeV1(input, {
3689
+ userId: rpcIdentifier,
3690
+ botId: rpcBotId,
3691
+ command: rpcDecoded(decodeRevertCompositionCommandV1),
3692
+ });
3693
+ const identity = {
3694
+ userId: request.userId as string,
3695
+ botId: request.botId as string,
3696
+ };
3697
+ const command = request.command as RevertCompositionCommandV1;
3698
+ if (command.botId !== identity.botId) {
3699
+ throw new Error("Composition revert command does not match its Bot");
3700
+ }
3701
+ const { shell } = await this.materialized(identity);
3702
+ await shell.validateIdentity(identity);
3703
+ return revertComposition(shell.state, identity, command);
3704
+ }
3705
+
3706
+ /**
3707
+ * Canonical Memory for the selected Bot. Voice uses this for prepared core,
3708
+ * blocking tools and standing preferences. Membership is filled here.
3709
+ */
3710
+ async operateMemory(input: unknown): Promise<unknown> {
3711
+ if (!input || typeof input !== "object") {
3712
+ throw new Error("memory request is invalid");
3713
+ }
3714
+ const request = input as {
3715
+ schemaVersion?: number;
3716
+ userId?: string;
3717
+ botId?: string;
3718
+ action?: string;
3719
+ request?: unknown;
3720
+ };
3721
+ if (
3722
+ request.schemaVersion !== 1 ||
3723
+ typeof request.userId !== "string" ||
3724
+ typeof request.botId !== "string" ||
3725
+ typeof request.action !== "string"
3726
+ ) {
3727
+ throw new Error("memory request is invalid");
3728
+ }
3729
+ const identity = { userId: request.userId, botId: request.botId };
3730
+ const { shell } = await this.materialized(identity);
3731
+ await shell.validateIdentity(identity);
3732
+ const records = this.backendEnv.MEMORY_RECORDS;
3733
+ if (!records) throw new Error("Memory is unavailable");
3734
+ const body = { ...((request.request ?? {}) as Record<string, unknown>) };
3735
+ const groups = this.backendEnv.MEMORY_GROUPS;
3736
+ const joined = groups ? await groups.memberOf() : [];
3737
+ const claimed = body.authority;
3738
+ if (claimed && typeof claimed === "object" && !Array.isArray(claimed)) {
3739
+ body.authority = {
3740
+ ...(claimed as Record<string, unknown>),
3741
+ userId: identity.userId,
3742
+ botId: identity.botId,
3743
+ actor:
3744
+ (claimed as { actor?: unknown }).actor === "user" ? "user" : "bot",
3745
+ joinedGroupChatIds: joined,
3746
+ membershipRevision: memoryMembershipRevisionV1(joined),
3747
+ };
3748
+ }
3749
+ switch (request.action) {
3750
+ case "write":
3751
+ return records.write(body as never);
3752
+ case "forget":
3753
+ return records.forget(body as never);
3754
+ case "recall":
3755
+ return records.recall(body as never);
3756
+ case "expand":
3757
+ return records.expand(body as never);
3758
+ case "browse":
3759
+ return records.browse(body as never);
3760
+ case "preparedCore":
3761
+ return records.preparedCore(body as never);
3762
+ case "capture":
3763
+ return records.captureExtraction(body as never);
3764
+ default:
3765
+ throw new Error(`unknown Memory action ${request.action}`);
3766
+ }
3767
+ }
3768
+
3769
+ async readVoiceContext(input: unknown) {
3770
+ if (!input || typeof input !== "object") {
3771
+ throw new Error("voice context request is invalid");
3772
+ }
3773
+ const request = input as {
3774
+ schemaVersion?: number;
3775
+ userId?: string;
3776
+ botId?: string;
3777
+ limit?: number;
3778
+ };
3779
+ if (
3780
+ request.schemaVersion !== 1 ||
3781
+ typeof request.userId !== "string" ||
3782
+ typeof request.botId !== "string"
3783
+ ) {
3784
+ throw new Error("voice context request is invalid");
3785
+ }
3786
+ const identity = { userId: request.userId, botId: request.botId };
3787
+ const { shell } = await this.materialized(identity);
3788
+ await shell.validateIdentity(identity);
3789
+ return shell.readVoiceContext(
3790
+ typeof request.limit === "number" ? request.limit : 6,
3791
+ );
3792
+ }
3793
+
3794
+ async listRuns(input: unknown) {
3795
+ const request = decodeRpcEnvelopeV1(input, {
3796
+ userId: rpcIdentifier,
3797
+ botId: rpcBotId,
3798
+ query: rpcDecoded(decodeClientRunListQueryV1),
3799
+ });
3800
+ const identity = {
3801
+ userId: request.userId as string,
3802
+ botId: request.botId as string,
3803
+ };
3804
+ const { shell } = await this.materialized(identity);
3805
+ await shell.validateIdentity(identity);
3806
+ return shell.listRuns(request.query as ClientRunListQueryV1);
3807
+ }
3808
+
3809
+ /**
3810
+ * The operator snapshot behind `/api/debug`. Bot-scoped like every other
3811
+ * Bot RPC — the debug token authorizes the *caller*, it does not widen what
3812
+ * a Bot will answer about itself.
3813
+ */
3814
+ async debugSnapshot(input: unknown) {
3815
+ const request = decodeRpcEnvelopeV1(input, {
3816
+ userId: rpcIdentifier,
3817
+ botId: rpcBotId,
3818
+ query: rpcDecoded(decodeBotDebugQueryV1),
3819
+ });
3820
+ const identity = {
3821
+ userId: request.userId as string,
3822
+ botId: request.botId as string,
3823
+ };
3824
+ const { shell } = await this.materialized(identity);
3825
+ await shell.validateIdentity(identity);
3826
+ return shell.debugSnapshot(identity, request.query);
3827
+ }
3828
+
3829
+ async runQuestions(input: unknown) {
3830
+ const request = decodeRpcEnvelopeV1(input, {
3831
+ userId: rpcIdentifier,
3832
+ botId: rpcBotId,
3833
+ query: rpcDecoded(decodeClientRunLookupQueryV1),
3834
+ });
3835
+ const identity = {
3836
+ userId: request.userId as string,
3837
+ botId: request.botId as string,
3838
+ };
3839
+ const { shell } = await this.materialized(identity);
3840
+ await shell.validateIdentity(identity);
3841
+ return shell.runQuestions(
3842
+ identity,
3843
+ request.query as ClientRunLookupQueryV1,
3844
+ );
3845
+ }
3846
+
3847
+ async lookupRun(input: unknown) {
3848
+ const request = decodeRpcEnvelopeV1(input, {
3849
+ userId: rpcIdentifier,
3850
+ botId: rpcBotId,
3851
+ query: rpcDecoded(decodeClientRunLookupQueryV1),
3852
+ });
3853
+ const identity = {
3854
+ userId: request.userId as string,
3855
+ botId: request.botId as string,
3856
+ };
3857
+ const { shell } = await this.materialized(identity);
3858
+ await shell.validateIdentity(identity);
3859
+ return shell.lookupRun(request.query as ClientRunLookupQueryV1);
3860
+ }
3861
+
3862
+ async fenceRunAdmission(input: unknown) {
3863
+ const request = decodeRpcEnvelopeV1(input, {
3864
+ userId: rpcIdentifier,
3865
+ botId: rpcBotId,
3866
+ query: rpcDecoded(decodeClientRunLookupQueryV1),
3867
+ });
3868
+ const identity = {
3869
+ userId: request.userId as string,
3870
+ botId: request.botId as string,
3871
+ };
3872
+ const { shell } = await this.materialized(identity);
3873
+ return shell.fenceRunAdmission(
3874
+ identity,
3875
+ request.query as ClientRunLookupQueryV1,
3876
+ );
3877
+ }
3878
+
3879
+ async alarm(): Promise<void> {
3880
+ await this.drainPush();
3881
+ const purgeValue = await this.ctx.storage.get<unknown>(
3882
+ MEMORY_VECTOR_PURGE_JOURNAL_KEY_V1,
3883
+ );
3884
+ if (purgeValue !== undefined) {
3885
+ await loggedEntryV1("Bot Memory vector purge", async () => {
3886
+ const journal = decodeMemoryVectorPurgeJournalV1(purgeValue);
3887
+ const outcome =
3888
+ journal.status === "pending"
3889
+ ? await this.purgeMemoryVectorBatch(journal)
3890
+ : "complete";
3891
+ if (outcome === "complete") {
3892
+ await this.finishTearDown({
3893
+ userId: journal.userId,
3894
+ botId: journal.botId,
3895
+ });
3896
+ }
3897
+ });
3898
+ return;
3899
+ }
3900
+ // The outbox drain is in a `finally` for the same reason the kernel's
3901
+ // re-arm is: it is the audit trail's second chance, and a throw anywhere in
3902
+ // the Bot's own settlement must not be what stops entries leaving.
3903
+ //
3904
+ // Both halves are then wrapped: an alarm has no caller, so anything that
3905
+ // escapes here is an uncaught exception in the object — which is one of the
3906
+ // ways the dev Worker died. The alarm is rescheduled by whatever owns it,
3907
+ // so recording the failure and letting the next firing try again is the
3908
+ // whole of the recovery.
3909
+ await loggedEntryV1("Bot alarm", async () => {
3910
+ try {
3911
+ await (await this.contribution()).alarm();
3912
+ } finally {
3913
+ // The alarm the Bot already has is also the audit outbox's second
3914
+ // chance: entries a settlement could not deliver leave on the next
3915
+ // firing rather than waiting for the Bot to be spoken to again.
3916
+ const identity = await this.ctx.storage.get<BotIdentity>(IDENTITY_KEY);
3917
+ await Promise.all([
3918
+ loggedEntryV1("Bot audit outbox drain", () =>
3919
+ this.drainAuditOutbox(),
3920
+ ),
3921
+ // And the voice reply outbox's: an answer whose hand-off could not
3922
+ // reach the voice object leaves here rather than waiting for the
3923
+ // next thing anyone asks this Bot.
3924
+ loggedEntryV1("Bot voice reply outbox drain", () =>
3925
+ identity
3926
+ ? this.drainVoiceReplyOutbox(identity.userId)
3927
+ : Promise.resolve(),
3928
+ ),
3929
+ ]);
3930
+ }
3931
+ });
3932
+ }
3933
+
3934
+ /**
3935
+ * Internal fetch surface reached only after the gateway authenticates
3936
+ * ownership.
3937
+ *
3938
+ * This is the state-channel upgrade, and it is where a `BotNotFoundError`
3939
+ * escaped and took the dev Worker down twice: a Durable Object's own `fetch`
3940
+ * is an entry point, so a throw here has no caller inside the object to catch
3941
+ * it. A Bot that is not there is a 404, and every other failure is a 500 that
3942
+ * still carries a reason.
3943
+ */
3944
+ fetch(request: Request): Promise<Response> {
3945
+ return answeredEntryV1("Bot-state channel failed", () =>
3946
+ this.#openChannel(request),
3947
+ );
3948
+ }
3949
+
3950
+ async #openChannel(request: Request): Promise<Response> {
3951
+ const url = new URL(request.url);
3952
+ if (url.pathname !== BOT_STATE_CHANNEL_INTERNAL_PATH) {
3953
+ return new Response("Not found", { status: 404 });
3954
+ }
3955
+ const userId = request.headers.get("x-frockbot-user-id");
3956
+ const botId = request.headers.get("x-frockbot-bot-id");
3957
+ if (!userId || !botId) {
3958
+ return Response.json(
3959
+ { error: "authenticated Bot identity required" },
3960
+ { status: 401 },
3961
+ );
3962
+ }
3963
+ const identity = { userId, botId };
3964
+ const { shell } = await this.materialized(identity);
3965
+ await shell.validateIdentity(identity);
3966
+ return this.stateChannel.upgrade(request, identity);
3967
+ }
3968
+
3969
+ // Three more entry points with no caller. A throw in any of them is an
3970
+ // uncaught exception in the object, and none of them has anybody to answer.
3971
+ webSocketMessage(
3972
+ socket: WebSocket,
3973
+ _message: string | ArrayBuffer,
3974
+ ): Promise<void> {
3975
+ return loggedEntryV1("Bot-state channel message", () =>
3976
+ this.stateChannel.message(socket),
3977
+ );
3978
+ }
3979
+
3980
+ webSocketClose(
3981
+ socket: WebSocket,
3982
+ code: number,
3983
+ reason: string,
3984
+ ): Promise<void> {
3985
+ return loggedEntryV1("Bot-state channel close", () =>
3986
+ this.stateChannel.close(socket, code, reason),
3987
+ );
3988
+ }
3989
+
3990
+ webSocketError(socket: WebSocket, _error: unknown): Promise<void> {
3991
+ return loggedEntryV1("Bot-state channel error", () =>
3992
+ this.stateChannel.error(socket),
3993
+ );
3994
+ }
3995
+ }