From 78564c18ab164d1d1a95a1eeba2e9cea28a5b87f Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:31:51 +0800 Subject: [PATCH 01/55] feat(bots): establish unified bot runtime foundation Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- apps/desktop/drizzle/0091_amazing_blur.sql | 1 + .../drizzle/0092_motionless_hiroim.sql | 350 + apps/desktop/drizzle/meta/0091_snapshot.json | 4253 ++++++++++ apps/desktop/drizzle/meta/0092_snapshot.json | 6893 +++++++++++++++++ apps/desktop/drizzle/meta/_journal.json | 14 + .../drizzle/scripts/0091_amazing_blur.ts | 13 + .../drizzle/scripts/0092_motionless_hiroim.ts | 15 + .../deviceLinkAutoTitleWiring.test.ts | 2 +- .../messagePersistBroadcaster.test.ts | 152 +- .../worktreeSessionRemovalRecycle.test.ts | 45 +- .../main/agent-island/__tests__/state.test.ts | 21 +- apps/desktop/src/main/agent-island/state.ts | 55 +- apps/desktop/src/main/bootstrap-electron.ts | 13 + .../__tests__/cindyMediaCatalog.test.ts | 32 + .../src/main/cindy-brain/cindyMediaCatalog.ts | 4 +- .../main/cindy-brain/ghostInstallReceipt.ts | 6 +- apps/desktop/src/main/cindy-brain/index.ts | 3 +- .../__tests__/crossProcessLock.test.ts | 6 +- .../hook-control/__tests__/bindings.test.ts | 41 + .../__tests__/botChannelConnections.test.ts | 82 + .../__tests__/botRouteTarget.test.ts | 80 + .../hook-control/__tests__/dispatcher.test.ts | 210 + .../__tests__/transport-manager.test.ts | 80 + .../desktop/src/main/hook-control/bindings.ts | 87 +- .../hook-control/botChannelConnections.ts | 46 + .../src/main/hook-control/botRouteTarget.ts | 208 + .../src/main/hook-control/dispatcher.ts | 295 +- apps/desktop/src/main/hook-control/ipc.ts | 198 +- apps/desktop/src/main/hook-control/manager.ts | 48 +- .../main/im/__tests__/accountBoundary.test.ts | 73 + apps/desktop/src/main/im/accountBoundary.ts | 52 + apps/desktop/src/main/im/index.ts | 238 + .../shared/__tests__/botRouteTarget.test.ts | 39 + .../__tests__/earlyUserMessagePersist.test.ts | 15 + .../im/shared/__tests__/slashCommands.test.ts | 121 + .../__tests__/stopCommandRouting.test.ts | 258 +- .../__tests__/turnRunnerSendOutcome.test.ts | 101 + .../src/main/im/shared/botRouteSource.ts | 23 + .../src/main/im/shared/botRouteTarget.ts | 83 + .../src/main/im/shared/cardActionHandler.ts | 224 +- .../src/main/im/shared/messageHandler.ts | 188 +- .../src/main/im/shared/orchestrator.ts | 3 + .../src/main/im/shared/slashCommands.ts | 195 +- apps/desktop/src/main/im/shared/turnRunner.ts | 206 +- apps/desktop/src/main/im/shared/types.ts | 8 +- .../im/telegram/__tests__/adapter.test.ts | 34 +- apps/desktop/src/main/im/telegram/adapter.ts | 7 +- apps/desktop/src/main/im/telegram/index.ts | 16 +- apps/desktop/src/main/im/wechat/WechatIM.ts | 30 +- .../main/im/wechat/__tests__/WechatIM.test.ts | 12 + .../__tests__/botImMigrationService.test.ts | 514 ++ .../__tests__/botMigrationReplay.test.ts | 135 + .../__tests__/botPortabilityArchive.test.ts | 43 + .../__tests__/botPortabilityService.test.ts | 205 + .../__tests__/botProfileDeletionStore.test.ts | 80 + .../localDb/__tests__/botRouteService.test.ts | 135 + .../__tests__/conversationSearch.test.ts | 6 + .../sessionCreateToRow.providerId.test.ts | 18 + .../src/main/localDb/botHistoryScope.ts | 56 + .../src/main/localDb/botImMigrationService.ts | 661 ++ .../src/main/localDb/botPortabilityArchive.ts | 97 + .../src/main/localDb/botPortabilityService.ts | 344 + .../main/localDb/botProfileDeletionStore.ts | 14 + .../src/main/localDb/botRouteService.ts | 733 ++ .../src/main/localDb/chatHistoryReader.ts | 15 + .../src/main/localDb/chatHistorySearch.ts | 12 + .../localDb/client/WorkerThreadTransport.ts | 11 +- .../main/localDb/client/__tests__/tx.test.ts | 107 +- .../src/main/localDb/client/tx/types.ts | 328 + .../src/main/localDb/codexPlanState.ts | 194 + .../src/main/localDb/conversationSearch.ts | 29 +- .../ipc/__tests__/botCanonicalSession.test.ts | 4048 ++++++++++ .../__tests__/botProfileVersioning.test.ts | 45 + .../sessionsRestoreIfArchived.test.ts | 8 + .../ipc/__tests__/sessionsUpdate.test.ts | 17 + .../main/localDb/ipc/botProfileVersioning.ts | 32 + apps/desktop/src/main/localDb/ipc/bots.ts | 1603 ++++ .../src/main/localDb/ipc/registerAll.ts | 2 + .../src/main/localDb/ipc/rightSidebarTabs.ts | 2 +- apps/desktop/src/main/localDb/ipc/sessions.ts | 80 +- apps/desktop/src/main/localDb/mapper.ts | 5 +- apps/desktop/src/main/localDb/schema.ts | 632 ++ .../worker/opHandlers/__tests__/botTx.test.ts | 101 + .../src/main/localDb/worker/opHandlers/tx.ts | 1349 +++- .../activeCatalogXdAvailability.test.ts | 16 +- .../__tests__/autoPermissionReviewer.test.ts | 24 + .../__tests__/autoReviewBudget.test.ts | 124 - .../__tests__/autoReviewModelRouter.test.ts | 183 + .../__tests__/cc-remote-mcp.test.ts | 92 +- .../__tests__/custom-provider-store.test.ts | 70 + .../iosSimulatorCodexDynamicTools.test.ts | 26 + .../__tests__/piRemoteTransport.test.ts | 12 + .../__tests__/providerAccessPolicy.test.ts | 232 +- .../providerCatalogRealmReload.test.ts | 408 +- .../__tests__/providerDiagnostics.test.ts | 77 + .../__tests__/providerModelFetch.test.ts | 17 + .../__tests__/providerRoute.test.ts | 50 + .../__tests__/sessionSearch.test.ts | 79 + .../src/main/maker-host/active-catalog.ts | 116 +- .../maker-host/auto-permission-reviewer.ts | 32 +- .../src/main/maker-host/auto-review-budget.ts | 135 - .../maker-host/auto-review-model-router.ts | 247 + .../src/main/maker-host/cc-remote-mcp.ts | 15 +- .../createDesktopProviderService.ts | 66 +- .../src/main/maker-host/custom-mcp-store.ts | 23 + .../main/maker-host/custom-provider-store.ts | 152 +- .../src/main/maker-host/generic-oauth.ts | 10 +- apps/desktop/src/main/maker-host/index.ts | 277 +- .../ios-simulator-codex-dynamic-tools.ts | 21 +- apps/desktop/src/main/maker-host/pi-host.ts | 2 + .../main/maker-host/pi-remote-transport.ts | 31 + .../main/maker-host/provider-access-policy.ts | 121 +- .../main/maker-host/provider-diagnostics.ts | 20 +- .../src/main/maker-host/provider-route.ts | 19 +- .../src/main/maker-host/session-search.ts | 18 + .../botAutomationMutationLock.test.ts | 44 + .../botCanonicalReplacementGuard.test.ts | 27 + .../botCompactRuntimeRefresh.test.ts | 154 + .../botDelegationCapabilityTarget.test.ts | 33 + .../__tests__/botDurableNoteService.test.ts | 128 + .../__tests__/botLifecycleService.test.ts | 426 + .../__tests__/botProfileRuntime.test.ts | 100 + .../botRemoteWorkspaceService.test.ts | 62 + .../__tests__/botSessionInputGuard.test.ts | 29 + .../__tests__/makerSendTransaction.test.ts | 76 +- .../maker-ipc/__tests__/planReconcile.test.ts | 12 + .../__tests__/scheduleReadiness.test.ts | 33 +- .../src/main/maker-ipc/bot-automation.ts | 792 ++ .../maker-ipc/botAutomationMutationLock.ts | 33 + .../botCanonicalReplacementCoordinator.ts | 25 + .../maker-ipc/botCanonicalReplacementGuard.ts | 20 + .../maker-ipc/botCompactRuntimeRefresh.ts | 138 + .../main/maker-ipc/botDelegationLifecycle.ts | 25 + .../main/maker-ipc/botDelegationService.ts | 2412 ++++++ .../maker-ipc/botDeliveryOutboxService.ts | 727 ++ .../main/maker-ipc/botDurableNoteService.ts | 193 + .../src/main/maker-ipc/botLifecycleService.ts | 546 ++ .../src/main/maker-ipc/botProfileRuntime.ts | 760 ++ .../maker-ipc/botRemoteWorkspaceService.ts | 206 + .../main/maker-ipc/botSessionInputGuard.ts | 21 + .../maker-ipc/botWorkspaceLeaseLifecycle.ts | 264 + .../src/main/maker-ipc/botWorkspaceRuntime.ts | 1216 +++ apps/desktop/src/main/maker-ipc/channels.ts | 21 + .../main/maker-ipc/makerSendTransaction.ts | 29 +- .../src/main/maker-ipc/planReconcile.ts | 9 + apps/desktop/src/main/maker-ipc/register.ts | 791 +- apps/desktop/src/main/maker-ipc/schedule.ts | 181 +- .../__tests__/codexBuiltinToolPolicy.test.ts | 40 + .../__tests__/ghostWorkdirGate.test.ts | 39 +- .../__tests__/piEnvironment.test.ts | 102 +- .../codexBuiltinToolPolicy.ts | 32 +- .../main/mcp-integrations/codexEnvironment.ts | 16 +- .../main/mcp-integrations/codexHttpBridge.ts | 5 +- .../src/main/mcp-integrations/ghost.ts | 36 +- .../main/mcp-integrations/mcp-providers.ts | 188 +- .../main/mcp-integrations/piEnvironment.ts | 101 +- .../src/main/messagePersistBroadcaster.ts | 49 +- .../src/main/right-sidebar-window/ipc.ts | 27 + .../__tests__/botAutomationRecovery.test.ts | 355 + .../scheduler-host/bot-automation-runner.ts | 1322 ++++ apps/desktop/src/main/scheduler-host/index.ts | 56 +- .../src/main/scheduler-host/storage.ts | 51 +- .../src/main/sessionSpendBroadcaster.ts | 19 +- .../__tests__/oneShotCandidates.test.ts | 219 +- .../main/utility-model/oneShotCandidates.ts | 182 +- .../main/worktree/sessionRemovalRecycle.ts | 23 +- apps/desktop/src/preload/preload.ts | 147 +- .../addProviderWizardPresetEntry.test.tsx | 200 + .../automationGeneratedSessions.test.ts | 4 +- .../customProviderDialogPresetLocale.test.tsx | 42 +- .../__tests__/dialogueSidebarSection.test.ts | 8 + .../renderer/__tests__/mainListModel.test.ts | 180 +- .../__tests__/newMakerOrcaCreateOrder.test.ts | 2 +- .../__tests__/orcaWorkflowRoute.test.ts | 2 +- .../__tests__/projectsSidebarSection.test.ts | 3 + .../remoteSessionSyncInvariants.test.ts | 2 +- .../sidebarRemoteProjectIcon.test.ts | 2 +- .../__tests__/sidebarSessionTime.test.ts | 11 +- .../renderer/__tests__/toastDuration.test.ts | 68 + .../components/settings/AddProviderWizard.tsx | 174 +- .../settings/CustomProviderDialog.tsx | 7 + ...CustomProviderDialogAccessibility.test.tsx | 44 + .../components/sidebar/SidebarTopNav.tsx | 26 +- .../features/bots/BotAutomationSettings.tsx | 731 ++ .../features/bots/BotCapabilitySettings.tsx | 484 ++ .../features/bots/BotHistorySessionView.tsx | 54 + .../features/bots/BotLifecycleSettings.tsx | 622 ++ .../features/bots/BotProjectSettings.tsx | 385 + .../features/bots/BotRouteSettings.tsx | 327 + .../renderer/features/bots/BotSessionView.tsx | 67 + .../features/bots/BotsFeatureLayout.tsx | 36 + .../renderer/features/bots/BotsHomeView.tsx | 1317 ++++ .../renderer/features/bots/BotsSidebar.tsx | 260 + .../features/bots/__tests__/botStore.test.ts | 122 + .../src/renderer/features/bots/botStore.ts | 846 ++ .../features/cc-agent/CCAgentSessionView.tsx | 95 +- .../features/cc-agent/CCAgentSidebarUpper.tsx | 32 +- .../cc-agent/SessionContentHeader.tsx | 13 +- .../features/cc-agent/lib/mainListModel.ts | 123 +- .../sidebar/AutomationSessionGroupItem.tsx | 15 +- .../features/cc-agent/sidebar/SessionCard.tsx | 98 +- .../features/cc-agent/sidebar/SessionItem.tsx | 180 +- .../__tests__/SessionCard.visual.test.ts | 13 +- .../sessionCardReviewRegressions.test.ts | 54 + .../sidebar/sections/ProjectsSection.tsx | 42 +- .../features/right-sidebar/TabBar.tsx | 3 + .../lib/executeSidebarCommand.ts | 12 + .../lib/openBotDelegationsTab.ts | 75 + .../bot-delegations/BotDelegationsBody.tsx | 241 + .../plugins/bot-delegations/index.tsx | 58 + .../features/right-sidebar/plugins/index.ts | 1 + .../renderer/features/right-sidebar/types.ts | 1 + .../features/scheduler/SchedulerPage.tsx | 3 + .../src/renderer/hooks/useActiveMainView.ts | 3 +- .../src/renderer/hooks/useCCSessions.ts | 2 + .../src/renderer/i18n/locales/en/common.json | 438 +- .../src/renderer/i18n/locales/ja/common.json | 380 +- .../src/renderer/i18n/locales/ko/common.json | 380 +- .../renderer/i18n/locales/zh-CN/common.json | 431 +- .../renderer/i18n/locales/zh-TW/common.json | 380 +- .../customProviderRuntimeFill.test.ts | 39 +- .../lib/__tests__/customProviders.test.ts | 69 + .../renderer/lib/customProviderRuntimeFill.ts | 1 + .../src/renderer/lib/customProviders.ts | 2 + .../desktop/src/renderer/lib/sessionsStore.ts | 4 + apps/desktop/src/renderer/lib/toast.ts | 18 +- apps/desktop/src/renderer/router.tsx | 15 + apps/desktop/src/renderer/vite-env.d.ts | 174 +- .../__tests__/botChannelRegistry.test.ts | 19 + .../__tests__/botDeliveryDiagnostic.test.ts | 30 + .../__tests__/botOutputArtifact.test.ts | 30 + .../__tests__/botRemoteCapabilities.test.ts | 38 + .../shared/__tests__/liveTaskPriority.test.ts | 20 + apps/desktop/src/shared/botAutomation.ts | 239 + apps/desktop/src/shared/botChannelRegistry.ts | 123 + apps/desktop/src/shared/botDelegation.ts | 271 + apps/desktop/src/shared/botDelivery.ts | 38 + .../src/shared/botDeliveryDiagnostic.ts | 44 + apps/desktop/src/shared/botImMigration.ts | 85 + apps/desktop/src/shared/botLifecycle.ts | 107 + apps/desktop/src/shared/botOutputArtifact.ts | 55 + apps/desktop/src/shared/botPortability.ts | 154 + apps/desktop/src/shared/botProfileDefaults.ts | 7 + .../src/shared/botRemoteCapabilities.ts | 20 + apps/desktop/src/shared/botRoute.ts | 90 + apps/desktop/src/shared/botWorkspace.ts | 34 + apps/desktop/src/shared/liveTaskPriority.ts | 30 + .../src/shared/piRuntimeInitialization.ts | 1 + apps/desktop/src/shared/rightSidebarWindow.ts | 8 + apps/desktop/src/shared/sessionSource.ts | 5 +- apps/mobile/app/bots/[deviceId].tsx | 199 + apps/mobile/app/devices/[deviceId].tsx | 15 + apps/mobile/src/__tests__/remoteBots.test.ts | 76 + apps/mobile/src/bots/remoteBots.ts | 98 + apps/mobile/src/i18n/locales/en/devices.json | 15 + apps/mobile/src/i18n/locales/ja/devices.json | 15 + apps/mobile/src/i18n/locales/ko/devices.json | 15 + .../src/i18n/locales/zh-CN/devices.json | 15 + .../src/i18n/locales/zh-TW/devices.json | 15 + docs/product-rules/sidebar-redesign-plan.md | 23 +- .../src/__tests__/allowlist.test.ts | 17 + packages/device-link/src/allowlist.ts | 4 + packages/lizi-im/src/channelIM.ts | 53 +- .../__tests__/feishuOpenerConsume.test.ts | 653 ++ .../feishu/__tests__/ipcAccountScope.test.ts | 1 + .../src/feishu/__tests__/outboundLane.test.ts | 299 +- .../feishu/__tests__/wsClientGroup.test.ts | 969 ++- .../wsClientGroupThreadRouting.test.ts | 205 +- packages/lizi-im/src/feishu/index.ts | 582 +- packages/lizi-im/src/feishu/ipc.ts | 6 + packages/lizi-im/src/feishu/messages.ts | 6 + packages/lizi-im/src/feishu/outbound.ts | 474 +- packages/lizi-im/src/feishu/wsClient.ts | 793 +- .../src/__tests__/botDurableNotesTool.test.ts | 54 + .../src/__tests__/botHistoryScope.test.ts | 64 + .../src/__tests__/sessionContext.test.ts | 57 + .../lizi-mcps/src/lizi_xdtHelperMcpServer.ts | 38 +- .../src/lizi_xdtHelperToolRegistry.ts | 8 +- packages/lizi-mcps/src/memory/_shared.ts | 4 +- .../lizi-mcps/src/memory/sessionSearch.ts | 14 +- packages/lizi-mcps/src/types.ts | 13 + .../src/xdt-helper/_history_scope.ts | 29 + .../src/xdt-helper/_history_types.ts | 9 + .../src/xdt-helper/bot_delegation.ts | 155 + .../src/xdt-helper/bot_durable_notes.ts | 82 + .../src/xdt-helper/get_chat_history.ts | 10 +- packages/lizi-mcps/src/xdt-helper/index.ts | 5 + .../lizi-mcps/src/xdt-helper/list_sessions.ts | 5 + .../lizi-mcps/src/xdt-helper/list_workdirs.ts | 5 + .../src/xdt-helper/search_chat_history.ts | 10 +- .../__tests__/protocol.test.ts | 4 +- .../__tests__/session-registry.test.ts | 88 + packages/maker-cc-manager/src/protocol.ts | 12 +- packages/maker-cc-manager/src/sdk-handlers.ts | 18 + .../maker-cc-manager/src/session-registry.ts | 205 +- packages/maker-core/src/agents/base-agent.ts | 120 +- .../__tests__/bot-profile-prompt.test.ts | 31 + .../__tests__/flag-settings.test.ts | 20 + .../__tests__/mcp-approval-policy.test.ts | 30 + .../claude-code/__tests__/plan-mode.test.ts | 102 + .../__tests__/upstream-idle-watchdog.test.ts | 293 +- .../src/agents/claude-code/flag-settings.ts | 20 +- .../src/agents/claude-code/index.ts | 248 +- .../src/agents/claude-code/translator.ts | 47 +- .../agents/codex/capability-routing.test.ts | 46 + .../src/agents/codex/capability-routing.ts | 63 + .../maker-core/src/agents/codex/index.test.ts | 174 +- packages/maker-core/src/agents/codex/index.ts | 202 +- .../pi/__tests__/bot-skill-policy.test.ts | 68 + .../__tests__/pi-auto-review-dispatch.test.ts | 50 + .../src/agents/pi/bot-skill-policy.ts | 50 + .../src/agents/pi/cindy-bridge-source.ts | 98 +- packages/maker-core/src/agents/pi/index.ts | 86 +- .../agents/shared/bot-runtime-policy.test.ts | 24 + .../src/agents/shared/bot-runtime-policy.ts | 17 + .../shared/remote-skill-scanner.test.ts | 77 + .../src/agents/shared/remote-skill-scanner.ts | 185 + .../shared/workspace-write-policy.test.ts | 58 + .../agents/shared/workspace-write-policy.ts | 85 + packages/maker-core/src/index.ts | 1 + .../maker-core/src/interfaces/mcp-provider.ts | 2 + packages/maker-core/src/maker.test.ts | 36 +- packages/maker-core/src/maker.ts | 48 +- .../maker-core/src/memory/scope-key.test.ts | 23 +- packages/maker-core/src/memory/storage.ts | 19 + packages/maker-core/src/types/palette.ts | 5 + packages/maker-scheduler/src/types.ts | 2 +- .../model-providers/catalog/providers.json | 7 + .../src/__tests__/catalog.test.ts | 41 + .../src/__tests__/presets.test.ts | 80 + .../src/__tests__/source-registry.test.ts | 96 +- .../src/__tests__/user-provider.test.ts | 26 + packages/model-providers/src/catalog.ts | 106 +- packages/model-providers/src/index.ts | 4 + packages/model-providers/src/source.ts | 77 +- packages/model-providers/src/types.ts | 27 + packages/model-providers/src/user-provider.ts | 1 + 337 files changed, 59399 insertions(+), 1799 deletions(-) create mode 100644 apps/desktop/drizzle/0091_amazing_blur.sql create mode 100644 apps/desktop/drizzle/0092_motionless_hiroim.sql create mode 100644 apps/desktop/drizzle/meta/0091_snapshot.json create mode 100644 apps/desktop/drizzle/meta/0092_snapshot.json create mode 100644 apps/desktop/drizzle/scripts/0091_amazing_blur.ts create mode 100644 apps/desktop/drizzle/scripts/0092_motionless_hiroim.ts create mode 100644 apps/desktop/src/main/hook-control/__tests__/botChannelConnections.test.ts create mode 100644 apps/desktop/src/main/hook-control/__tests__/botRouteTarget.test.ts create mode 100644 apps/desktop/src/main/hook-control/botChannelConnections.ts create mode 100644 apps/desktop/src/main/hook-control/botRouteTarget.ts create mode 100644 apps/desktop/src/main/im/shared/__tests__/botRouteTarget.test.ts create mode 100644 apps/desktop/src/main/im/shared/botRouteSource.ts create mode 100644 apps/desktop/src/main/im/shared/botRouteTarget.ts create mode 100644 apps/desktop/src/main/localDb/__tests__/botImMigrationService.test.ts create mode 100644 apps/desktop/src/main/localDb/__tests__/botMigrationReplay.test.ts create mode 100644 apps/desktop/src/main/localDb/__tests__/botPortabilityArchive.test.ts create mode 100644 apps/desktop/src/main/localDb/__tests__/botPortabilityService.test.ts create mode 100644 apps/desktop/src/main/localDb/__tests__/botProfileDeletionStore.test.ts create mode 100644 apps/desktop/src/main/localDb/__tests__/botRouteService.test.ts create mode 100644 apps/desktop/src/main/localDb/botHistoryScope.ts create mode 100644 apps/desktop/src/main/localDb/botImMigrationService.ts create mode 100644 apps/desktop/src/main/localDb/botPortabilityArchive.ts create mode 100644 apps/desktop/src/main/localDb/botPortabilityService.ts create mode 100644 apps/desktop/src/main/localDb/botProfileDeletionStore.ts create mode 100644 apps/desktop/src/main/localDb/botRouteService.ts create mode 100644 apps/desktop/src/main/localDb/codexPlanState.ts create mode 100644 apps/desktop/src/main/localDb/ipc/__tests__/botCanonicalSession.test.ts create mode 100644 apps/desktop/src/main/localDb/ipc/__tests__/botProfileVersioning.test.ts create mode 100644 apps/desktop/src/main/localDb/ipc/botProfileVersioning.ts create mode 100644 apps/desktop/src/main/localDb/ipc/bots.ts create mode 100644 apps/desktop/src/main/localDb/worker/opHandlers/__tests__/botTx.test.ts delete mode 100644 apps/desktop/src/main/maker-host/__tests__/autoReviewBudget.test.ts create mode 100644 apps/desktop/src/main/maker-host/__tests__/autoReviewModelRouter.test.ts create mode 100644 apps/desktop/src/main/maker-host/__tests__/sessionSearch.test.ts delete mode 100644 apps/desktop/src/main/maker-host/auto-review-budget.ts create mode 100644 apps/desktop/src/main/maker-host/auto-review-model-router.ts create mode 100644 apps/desktop/src/main/maker-ipc/__tests__/botAutomationMutationLock.test.ts create mode 100644 apps/desktop/src/main/maker-ipc/__tests__/botCanonicalReplacementGuard.test.ts create mode 100644 apps/desktop/src/main/maker-ipc/__tests__/botCompactRuntimeRefresh.test.ts create mode 100644 apps/desktop/src/main/maker-ipc/__tests__/botDelegationCapabilityTarget.test.ts create mode 100644 apps/desktop/src/main/maker-ipc/__tests__/botDurableNoteService.test.ts create mode 100644 apps/desktop/src/main/maker-ipc/__tests__/botLifecycleService.test.ts create mode 100644 apps/desktop/src/main/maker-ipc/__tests__/botProfileRuntime.test.ts create mode 100644 apps/desktop/src/main/maker-ipc/__tests__/botRemoteWorkspaceService.test.ts create mode 100644 apps/desktop/src/main/maker-ipc/__tests__/botSessionInputGuard.test.ts create mode 100644 apps/desktop/src/main/maker-ipc/bot-automation.ts create mode 100644 apps/desktop/src/main/maker-ipc/botAutomationMutationLock.ts create mode 100644 apps/desktop/src/main/maker-ipc/botCanonicalReplacementCoordinator.ts create mode 100644 apps/desktop/src/main/maker-ipc/botCanonicalReplacementGuard.ts create mode 100644 apps/desktop/src/main/maker-ipc/botCompactRuntimeRefresh.ts create mode 100644 apps/desktop/src/main/maker-ipc/botDelegationLifecycle.ts create mode 100644 apps/desktop/src/main/maker-ipc/botDelegationService.ts create mode 100644 apps/desktop/src/main/maker-ipc/botDeliveryOutboxService.ts create mode 100644 apps/desktop/src/main/maker-ipc/botDurableNoteService.ts create mode 100644 apps/desktop/src/main/maker-ipc/botLifecycleService.ts create mode 100644 apps/desktop/src/main/maker-ipc/botProfileRuntime.ts create mode 100644 apps/desktop/src/main/maker-ipc/botRemoteWorkspaceService.ts create mode 100644 apps/desktop/src/main/maker-ipc/botSessionInputGuard.ts create mode 100644 apps/desktop/src/main/maker-ipc/botWorkspaceLeaseLifecycle.ts create mode 100644 apps/desktop/src/main/maker-ipc/botWorkspaceRuntime.ts create mode 100644 apps/desktop/src/main/mcp-integrations/__tests__/codexBuiltinToolPolicy.test.ts create mode 100644 apps/desktop/src/main/scheduler-host/__tests__/botAutomationRecovery.test.ts create mode 100644 apps/desktop/src/main/scheduler-host/bot-automation-runner.ts create mode 100644 apps/desktop/src/renderer/__tests__/toastDuration.test.ts create mode 100644 apps/desktop/src/renderer/features/bots/BotAutomationSettings.tsx create mode 100644 apps/desktop/src/renderer/features/bots/BotCapabilitySettings.tsx create mode 100644 apps/desktop/src/renderer/features/bots/BotHistorySessionView.tsx create mode 100644 apps/desktop/src/renderer/features/bots/BotLifecycleSettings.tsx create mode 100644 apps/desktop/src/renderer/features/bots/BotProjectSettings.tsx create mode 100644 apps/desktop/src/renderer/features/bots/BotRouteSettings.tsx create mode 100644 apps/desktop/src/renderer/features/bots/BotSessionView.tsx create mode 100644 apps/desktop/src/renderer/features/bots/BotsFeatureLayout.tsx create mode 100644 apps/desktop/src/renderer/features/bots/BotsHomeView.tsx create mode 100644 apps/desktop/src/renderer/features/bots/BotsSidebar.tsx create mode 100644 apps/desktop/src/renderer/features/bots/__tests__/botStore.test.ts create mode 100644 apps/desktop/src/renderer/features/bots/botStore.ts create mode 100644 apps/desktop/src/renderer/features/right-sidebar/lib/openBotDelegationsTab.ts create mode 100644 apps/desktop/src/renderer/features/right-sidebar/plugins/bot-delegations/BotDelegationsBody.tsx create mode 100644 apps/desktop/src/renderer/features/right-sidebar/plugins/bot-delegations/index.tsx create mode 100644 apps/desktop/src/shared/__tests__/botChannelRegistry.test.ts create mode 100644 apps/desktop/src/shared/__tests__/botDeliveryDiagnostic.test.ts create mode 100644 apps/desktop/src/shared/__tests__/botOutputArtifact.test.ts create mode 100644 apps/desktop/src/shared/__tests__/botRemoteCapabilities.test.ts create mode 100644 apps/desktop/src/shared/__tests__/liveTaskPriority.test.ts create mode 100644 apps/desktop/src/shared/botAutomation.ts create mode 100644 apps/desktop/src/shared/botChannelRegistry.ts create mode 100644 apps/desktop/src/shared/botDelegation.ts create mode 100644 apps/desktop/src/shared/botDelivery.ts create mode 100644 apps/desktop/src/shared/botDeliveryDiagnostic.ts create mode 100644 apps/desktop/src/shared/botImMigration.ts create mode 100644 apps/desktop/src/shared/botLifecycle.ts create mode 100644 apps/desktop/src/shared/botOutputArtifact.ts create mode 100644 apps/desktop/src/shared/botPortability.ts create mode 100644 apps/desktop/src/shared/botProfileDefaults.ts create mode 100644 apps/desktop/src/shared/botRemoteCapabilities.ts create mode 100644 apps/desktop/src/shared/botRoute.ts create mode 100644 apps/desktop/src/shared/botWorkspace.ts create mode 100644 apps/desktop/src/shared/liveTaskPriority.ts create mode 100644 apps/mobile/app/bots/[deviceId].tsx create mode 100644 apps/mobile/src/__tests__/remoteBots.test.ts create mode 100644 apps/mobile/src/bots/remoteBots.ts create mode 100644 packages/lizi-im/src/feishu/__tests__/feishuOpenerConsume.test.ts create mode 100644 packages/lizi-mcps/src/__tests__/botDurableNotesTool.test.ts create mode 100644 packages/lizi-mcps/src/__tests__/botHistoryScope.test.ts create mode 100644 packages/lizi-mcps/src/xdt-helper/_history_scope.ts create mode 100644 packages/lizi-mcps/src/xdt-helper/bot_delegation.ts create mode 100644 packages/lizi-mcps/src/xdt-helper/bot_durable_notes.ts create mode 100644 packages/maker-core/src/agents/claude-code/__tests__/bot-profile-prompt.test.ts create mode 100644 packages/maker-core/src/agents/pi/__tests__/bot-skill-policy.test.ts create mode 100644 packages/maker-core/src/agents/pi/bot-skill-policy.ts create mode 100644 packages/maker-core/src/agents/shared/bot-runtime-policy.test.ts create mode 100644 packages/maker-core/src/agents/shared/bot-runtime-policy.ts create mode 100644 packages/maker-core/src/agents/shared/remote-skill-scanner.test.ts create mode 100644 packages/maker-core/src/agents/shared/remote-skill-scanner.ts create mode 100644 packages/maker-core/src/agents/shared/workspace-write-policy.test.ts create mode 100644 packages/maker-core/src/agents/shared/workspace-write-policy.ts diff --git a/apps/desktop/drizzle/0091_amazing_blur.sql b/apps/desktop/drizzle/0091_amazing_blur.sql new file mode 100644 index 0000000000..e0ac49d1ec --- /dev/null +++ b/apps/desktop/drizzle/0091_amazing_blur.sql @@ -0,0 +1 @@ +SELECT 1; diff --git a/apps/desktop/drizzle/0092_motionless_hiroim.sql b/apps/desktop/drizzle/0092_motionless_hiroim.sql new file mode 100644 index 0000000000..57da687ed8 --- /dev/null +++ b/apps/desktop/drizzle/0092_motionless_hiroim.sql @@ -0,0 +1,350 @@ +CREATE TABLE IF NOT EXISTS `bot_automation_links` ( + `id` text PRIMARY KEY NOT NULL, + `bot_id` text NOT NULL, + `schedule_id` text, + `project_binding_id` text, + `target_route_id` text, + `created_with_profile_version` integer NOT NULL, + `durable_note_namespace` text, + `execution_policy_json` text DEFAULT '{}' NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `suspended_status` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`bot_id`) REFERENCES `bot_profiles`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`schedule_id`) REFERENCES `schedules`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`project_binding_id`) REFERENCES `bot_project_bindings`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`target_route_id`) REFERENCES `bot_routes`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_bot_automation_links_schedule` ON `bot_automation_links` (`schedule_id`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_automation_links_bot_status` ON `bot_automation_links` (`bot_id`,`status`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bot_automation_runs` ( + `id` text PRIMARY KEY NOT NULL, + `automation_link_id` text NOT NULL, + `schedule_run_id` text, + `session_id` text, + `workspace_lease_id` text, + `profile_version` integer NOT NULL, + `project_binding_id_snapshot` text, + `target_route_id_snapshot` text, + `target_route_owner_generation_snapshot` integer, + `working_dir_snapshot` text, + `remote_host_id_snapshot` text, + `worktree_path_snapshot` text, + `delivery_outbox_id` text, + `delivery_status` text DEFAULT 'not-requested' NOT NULL, + `delivery_error` text, + `result_text_snapshot` text, + `output_artifacts_json` text DEFAULT '[]' NOT NULL, + `error_message` text, + `execution_plan_json` text DEFAULT '{}' NOT NULL, + `status` text DEFAULT 'claimed' NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `finished_at` integer, + FOREIGN KEY (`automation_link_id`) REFERENCES `bot_automation_links`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`schedule_run_id`) REFERENCES `schedule_runs`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`workspace_lease_id`) REFERENCES `bot_workspace_leases`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`delivery_outbox_id`) REFERENCES `bot_delivery_outbox`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_bot_automation_runs_schedule_run` ON `bot_automation_runs` (`schedule_run_id`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_automation_runs_link_created` ON `bot_automation_runs` (`automation_link_id`,`created_at`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bot_channels` ( + `id` text PRIMARY KEY NOT NULL, + `bot_id` text NOT NULL, + `kind` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `config_json` text DEFAULT '{}' NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`bot_id`) REFERENCES `bot_profiles`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_channels_bot_kind` ON `bot_channels` (`bot_id`,`kind`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_channels_enabled` ON `bot_channels` (`enabled`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bot_delegations` ( + `id` text PRIMARY KEY NOT NULL, + `requesting_bot_id` text NOT NULL, + `target_bot_id` text NOT NULL, + `parent_session_id` text, + `child_session_id` text, + `objective` text NOT NULL, + `context_refs_json` text DEFAULT '[]' NOT NULL, + `artifact_refs_json` text DEFAULT '[]' NOT NULL, + `permission_snapshot_json` text DEFAULT '{}' NOT NULL, + `lineage_json` text DEFAULT '[]' NOT NULL, + `target_profile_version` integer NOT NULL, + `depth` integer DEFAULT 1 NOT NULL, + `budget_tokens` integer, + `tokens_used` integer DEFAULT 0 NOT NULL, + `status` text DEFAULT 'queued' NOT NULL, + `result_summary` text, + `output_artifacts_json` text DEFAULT '[]' NOT NULL, + `last_error` text, + `created_at` integer NOT NULL, + `accepted_at` integer, + `completed_at` integer, + `updated_at` integer NOT NULL, + FOREIGN KEY (`requesting_bot_id`) REFERENCES `bot_profiles`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`target_bot_id`) REFERENCES `bot_profiles`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`parent_session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`child_session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_delegations_requester_status` ON `bot_delegations` (`requesting_bot_id`,`status`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_delegations_target_status` ON `bot_delegations` (`target_bot_id`,`status`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_delegations_parent_session` ON `bot_delegations` (`parent_session_id`);--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_bot_delegations_child_session` ON `bot_delegations` (`child_session_id`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bot_delivery_outbox` ( + `id` text PRIMARY KEY NOT NULL, + `bot_id` text NOT NULL, + `channel_id` text, + `route_id` text, + `session_id` text, + `idempotency_key` text NOT NULL, + `payload_ref_json` text DEFAULT '{}' NOT NULL, + `owner_generation` integer DEFAULT 0 NOT NULL, + `status` text DEFAULT 'pending' NOT NULL, + `attempts` integer DEFAULT 0 NOT NULL, + `next_attempt_at` integer, + `last_error` text, + `delivery_receipt_json` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `delivered_at` integer, + FOREIGN KEY (`bot_id`) REFERENCES `bot_profiles`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`channel_id`) REFERENCES `bot_channels`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`route_id`) REFERENCES `bot_routes`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_bot_delivery_outbox_idempotency` ON `bot_delivery_outbox` (`idempotency_key`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_delivery_outbox_due` ON `bot_delivery_outbox` (`status`,`next_attempt_at`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_delivery_outbox_route_created` ON `bot_delivery_outbox` (`route_id`,`created_at`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bot_durable_notes` ( + `id` text PRIMARY KEY NOT NULL, + `bot_id` text NOT NULL, + `namespace` text NOT NULL, + `note_key` text NOT NULL, + `value_json` text DEFAULT '{}' NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`bot_id`) REFERENCES `bot_profiles`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_bot_durable_notes_bot_namespace_key` ON `bot_durable_notes` (`bot_id`,`namespace`,`note_key`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_durable_notes_bot_namespace` ON `bot_durable_notes` (`bot_id`,`namespace`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bot_im_migration_items` ( + `id` text PRIMARY KEY NOT NULL, + `migration_id` text NOT NULL, + `session_id` text NOT NULL, + `original_status` text NOT NULL, + `history_link_created` integer DEFAULT false NOT NULL, + `session_archived` integer DEFAULT false NOT NULL, + `applied_session_updated_at` integer NOT NULL, + `created_at` integer NOT NULL, + `rolled_back_at` integer, + FOREIGN KEY (`migration_id`) REFERENCES `bot_im_migrations`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_bot_im_migration_items_batch_session` ON `bot_im_migration_items` (`migration_id`,`session_id`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_im_migration_items_session` ON `bot_im_migration_items` (`session_id`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bot_im_migrations` ( + `id` text PRIMARY KEY NOT NULL, + `request_id` text NOT NULL, + `bot_id` text NOT NULL, + `channel_id` text NOT NULL, + `route_id` text NOT NULL, + `connection_id` text NOT NULL, + `ownership` text NOT NULL, + `kind` text NOT NULL, + `account_key` text NOT NULL, + `plan_hash` text NOT NULL, + `status` text DEFAULT 'applying' NOT NULL, + `channel_before_json` text, + `route_before_json` text, + `adapter_bindings_json` text DEFAULT '[]' NOT NULL, + `error_json` text, + `created_at` integer NOT NULL, + `applied_at` integer, + `rolled_back_at` integer, + FOREIGN KEY (`bot_id`) REFERENCES `bot_profiles`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`channel_id`) REFERENCES `bot_channels`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`route_id`) REFERENCES `bot_routes`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_bot_im_migrations_request` ON `bot_im_migrations` (`request_id`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_im_migrations_bot_created` ON `bot_im_migrations` (`bot_id`,`created_at`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_im_migrations_connection_status` ON `bot_im_migrations` (`connection_id`,`status`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bot_lifecycle_events` ( + `id` text PRIMARY KEY NOT NULL, + `bot_id` text NOT NULL, + `session_id` text, + `event_type` text NOT NULL, + `payload_json` text DEFAULT '{}' NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`bot_id`) REFERENCES `bot_profiles`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_lifecycle_events_bot_created` ON `bot_lifecycle_events` (`bot_id`,`created_at`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_lifecycle_events_session_created` ON `bot_lifecycle_events` (`session_id`,`created_at`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bot_profile_versions` ( + `id` text PRIMARY KEY NOT NULL, + `bot_id` text NOT NULL, + `version` integer NOT NULL, + `identity_source` text DEFAULT '' NOT NULL, + `capabilities_json` text DEFAULT '{}' NOT NULL, + `created_at` integer NOT NULL, + FOREIGN KEY (`bot_id`) REFERENCES `bot_profiles`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_bot_profile_versions_bot_version` ON `bot_profile_versions` (`bot_id`,`version`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_profile_versions_bot_created` ON `bot_profile_versions` (`bot_id`,`created_at`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bot_profiles` ( + `id` text PRIMARY KEY NOT NULL, + `display_name` text NOT NULL, + `description` text DEFAULT '' NOT NULL, + `avatar` text DEFAULT '🤖' NOT NULL, + `avatar_color` text DEFAULT 'violet' NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `current_version` integer DEFAULT 1 NOT NULL, + `canonical_session_id` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`canonical_session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_profiles_status_updated` ON `bot_profiles` (`status`,`updated_at`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_profiles_canonical_session` ON `bot_profiles` (`canonical_session_id`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bot_project_bindings` ( + `id` text PRIMARY KEY NOT NULL, + `bot_id` text NOT NULL, + `project_key` text NOT NULL, + `working_dir` text NOT NULL, + `remote_host_id` text, + `default_branch` text, + `workspace_policy` text DEFAULT 'none' NOT NULL, + `is_default` integer DEFAULT false NOT NULL, + `allowed_paths_json` text DEFAULT '[]' NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`bot_id`) REFERENCES `bot_profiles`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_bot_project_bindings_bot_project` ON `bot_project_bindings` (`bot_id`,`project_key`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_project_bindings_bot_status` ON `bot_project_bindings` (`bot_id`,`status`);--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_bot_project_bindings_default_per_bot` ON `bot_project_bindings` (`bot_id`) WHERE "bot_project_bindings"."is_default" = true AND "bot_project_bindings"."status" = 'active';--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bot_routes` ( + `id` text PRIMARY KEY NOT NULL, + `bot_id` text NOT NULL, + `channel_id` text NOT NULL, + `route_key` text NOT NULL, + `principal_key` text NOT NULL, + `scope_key` text NOT NULL, + `thread_key` text, + `current_session_id` text, + `project_binding_id` text, + `capabilities_json` text DEFAULT '{}' NOT NULL, + `owner_device_id` text, + `owner_generation` integer DEFAULT 0 NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `suspended_status` text, + `last_activity_at` integer, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`bot_id`) REFERENCES `bot_profiles`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`channel_id`) REFERENCES `bot_channels`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`current_session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE set null, + FOREIGN KEY (`project_binding_id`) REFERENCES `bot_project_bindings`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_bot_routes_channel_route` ON `bot_routes` (`channel_id`,`route_key`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_routes_bot_status` ON `bot_routes` (`bot_id`,`status`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_routes_session` ON `bot_routes` (`current_session_id`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bot_runtime_snapshots` ( + `id` text PRIMARY KEY NOT NULL, + `bot_id` text NOT NULL, + `session_id` text NOT NULL, + `profile_version` integer NOT NULL, + `agent_kind` text NOT NULL, + `working_dir` text NOT NULL, + `memory_scope_key` text, + `configured_json` text DEFAULT '{}' NOT NULL, + `resolved_json` text DEFAULT '{}' NOT NULL, + `status` text NOT NULL, + `prepared_at` integer DEFAULT 0 NOT NULL, + `applied_at` integer, + `failed_at` integer, + `failure_json` text, + FOREIGN KEY (`bot_id`) REFERENCES `bot_profiles`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_runtime_snapshots_bot_prepared` ON `bot_runtime_snapshots` (`bot_id`,`prepared_at`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_runtime_snapshots_session_prepared` ON `bot_runtime_snapshots` (`session_id`,`prepared_at`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bot_session_links` ( + `id` text PRIMARY KEY NOT NULL, + `bot_id` text NOT NULL, + `session_id` text NOT NULL, + `profile_version` integer DEFAULT 1 NOT NULL, + `role` text NOT NULL, + `channel_id` text, + `route_key` text, + `created_at` integer NOT NULL, + `archived_at` integer, + FOREIGN KEY (`bot_id`) REFERENCES `bot_profiles`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`channel_id`) REFERENCES `bot_channels`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_bot_session_links_session` ON `bot_session_links` (`session_id`);--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_bot_session_links_canonical_per_bot` ON `bot_session_links` (`bot_id`) WHERE "bot_session_links"."role" = 'canonical';--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_session_links_bot_role` ON `bot_session_links` (`bot_id`,`role`);--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_bot_session_links_route` ON `bot_session_links` (`channel_id`,`route_key`) WHERE "bot_session_links"."role" = 'route' AND "bot_session_links"."channel_id" IS NOT NULL AND "bot_session_links"."route_key" IS NOT NULL;--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bot_workspace_attachments` ( + `id` text PRIMARY KEY NOT NULL, + `lease_id` text NOT NULL, + `session_id` text NOT NULL, + `generation` integer NOT NULL, + `access` text DEFAULT 'read-write' NOT NULL, + `created_at` integer NOT NULL, + `detached_at` integer, + FOREIGN KEY (`lease_id`) REFERENCES `bot_workspace_leases`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_bot_workspace_attachments_lease_session` ON `bot_workspace_attachments` (`lease_id`,`session_id`,`generation`);--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_bot_workspace_attachments_active_session` ON `bot_workspace_attachments` (`session_id`) WHERE "bot_workspace_attachments"."detached_at" IS NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_workspace_attachments_lease_active` ON `bot_workspace_attachments` (`lease_id`,`detached_at`);--> statement-breakpoint +CREATE TABLE IF NOT EXISTS `bot_workspace_leases` ( + `id` text PRIMARY KEY NOT NULL, + `bot_id` text NOT NULL, + `project_binding_id` text NOT NULL, + `lease_key` text DEFAULT 'shared' NOT NULL, + `anchor_session_id` text, + `worktree_path` text, + `base_repo` text NOT NULL, + `branch` text, + `source_branch` text, + `remote_host_id` text, + `generation` integer DEFAULT 1 NOT NULL, + `status` text DEFAULT 'acquiring' NOT NULL, + `last_heartbeat_at` integer, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `released_at` integer, + FOREIGN KEY (`bot_id`) REFERENCES `bot_profiles`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`project_binding_id`) REFERENCES `bot_project_bindings`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`anchor_session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS `uniq_bot_workspace_leases_active_binding_key` ON `bot_workspace_leases` (`project_binding_id`,`lease_key`) WHERE "bot_workspace_leases"."status" IN ('acquiring', 'active', 'releasing');--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_workspace_leases_bot_status` ON `bot_workspace_leases` (`bot_id`,`status`);--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_bot_workspace_leases_anchor_session` ON `bot_workspace_leases` (`anchor_session_id`);--> statement-breakpoint +SELECT 1; \ No newline at end of file diff --git a/apps/desktop/drizzle/meta/0091_snapshot.json b/apps/desktop/drizzle/meta/0091_snapshot.json new file mode 100644 index 0000000000..bfc09469be --- /dev/null +++ b/apps/desktop/drizzle/meta/0091_snapshot.json @@ -0,0 +1,4253 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "678c1f41-343b-49e0-92d8-20247184dc60", + "prevId": "9b947440-3e27-474b-93c1-dc62b1bc9c35", + "tables": { + "account_usage_snapshots": { + "name": "account_usage_snapshots", + "columns": { + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "snapshot": { + "name": "snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_input_queue_snapshots": { + "name": "agent_input_queue_snapshots", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_input_queue_snapshots_session_id_sessions_id_fk": { + "name": "agent_input_queue_snapshots_session_id_sessions_id_fk", + "tableFrom": "agent_input_queue_snapshots", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "custom_mcp_servers": { + "name": "custom_mcp_servers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_custom_mcp_servers_sort_order": { + "name": "idx_custom_mcp_servers_sort_order", + "columns": [ + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "custom_providers": { + "name": "custom_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "runtimes": { + "name": "runtimes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_custom_providers_sort_order": { + "name": "idx_custom_providers_sort_order", + "columns": [ + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_model_usage": { + "name": "daily_model_usage", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_amount": { + "name": "cost_amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_currency": { + "name": "cost_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "cost_is_approximate": { + "name": "cost_is_approximate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_create_tokens": { + "name": "cache_create_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_model_usage_day_agent_kind_model_cost_currency_pk": { + "columns": [ + "day", + "agent_kind", + "model", + "cost_currency" + ], + "name": "daily_model_usage_day_agent_kind_model_cost_currency_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_spend": { + "name": "daily_spend", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_amount": { + "name": "cost_amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_currency": { + "name": "cost_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "cost_is_approximate": { + "name": "cost_is_approximate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_spend_day_cost_currency_pk": { + "columns": [ + "day", + "cost_currency" + ], + "name": "daily_spend_day_cost_currency_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_link_ownership": { + "name": "device_link_ownership", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_pid": { + "name": "owner_pid", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_label": { + "name": "owner_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "embedding_jobs": { + "name": "embedding_jobs", + "columns": { + "rowid": { + "name": "rowid", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vec_table": { + "name": "vec_table", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_embedding_jobs_natural": { + "name": "uniq_embedding_jobs_natural", + "columns": [ + "source", + "source_id", + "chunk_index", + "model_id" + ], + "isUnique": true + }, + "idx_embedding_jobs_status_scheduled": { + "name": "idx_embedding_jobs_status_scheduled", + "columns": [ + "status", + "scheduled_at" + ], + "isUnique": false + }, + "idx_embedding_jobs_source_id": { + "name": "idx_embedding_jobs_source_id", + "columns": [ + "source", + "source_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "embedding_meta": { + "name": "embedding_meta", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ghost_cards": { + "name": "ghost_cards", + "columns": { + "call_id": { + "name": "call_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ghost_id": { + "name": "ghost_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "html": { + "name": "html", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "v": { + "name": "v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ghost_cards_updated_at_idx": { + "name": "ghost_cards_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hook_group_context_cursors": { + "name": "hook_group_context_cursors", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cursor_key": { + "name": "cursor_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cursor_id": { + "name": "cursor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "hook_group_context_cursors_updated_at_idx": { + "name": "hook_group_context_cursors_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "hook_group_context_cursors_provider_cursor_key_pk": { + "columns": [ + "provider", + "cursor_key" + ], + "name": "hook_group_context_cursors_provider_cursor_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hook_group_message_stats": { + "name": "hook_group_message_stats", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_bytes": { + "name": "text_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hook_group_messages": { + "name": "hook_group_messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chat_name": { + "name": "chat_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_bot": { + "name": "is_bot", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_names": { + "name": "file_names", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "hook_group_messages_msg_idx": { + "name": "hook_group_messages_msg_idx", + "columns": [ + "provider", + "chat_id", + "thread_id", + "message_id" + ], + "isUnique": true + }, + "hook_group_messages_window_idx": { + "name": "hook_group_messages_window_idx", + "columns": [ + "provider", + "chat_id", + "thread_id", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "im_bindings": { + "name": "im_bindings", + "columns": { + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bot_context_id": { + "name": "bot_context_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "target_session_id": { + "name": "target_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attached_at": { + "name": "attached_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attached_via_card_message_id": { + "name": "attached_via_card_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_im_bindings_target": { + "name": "idx_im_bindings_target", + "columns": [ + "target_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "im_bindings_target_session_id_sessions_id_fk": { + "name": "im_bindings_target_session_id_sessions_id_fk", + "tableFrom": "im_bindings", + "tableTo": "sessions", + "columnsFrom": [ + "target_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "im_bindings_channel_bot_context_id_user_id_scope_key_pk": { + "columns": [ + "channel", + "bot_context_id", + "user_id", + "scope_key" + ], + "name": "im_bindings_channel_bot_context_id_user_id_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media_blobs": { + "name": "media_blobs", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ext": { + "name": "ext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_cache": { + "name": "is_cache", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_access_at": { + "name": "last_access_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media_refs": { + "name": "media_refs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_kind": { + "name": "ref_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_session_id": { + "name": "origin_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_refs_hash_idx": { + "name": "media_refs_hash_idx", + "columns": [ + "hash" + ], + "isUnique": false + }, + "media_refs_ref_idx": { + "name": "media_refs_ref_idx", + "columns": [ + "ref_kind", + "ref_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "media_refs_hash_media_blobs_hash_fk": { + "name": "media_refs_hash_media_blobs_hash_fk", + "tableFrom": "media_refs", + "tableTo": "media_blobs", + "columnsFrom": [ + "hash" + ], + "columnsTo": [ + "hash" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_use_id": { + "name": "tool_use_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_meta": { + "name": "agent_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rewind_at": { + "name": "rewind_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_messages_session_client": { + "name": "uniq_messages_session_client", + "columns": [ + "session_id", + "client_id" + ], + "isUnique": true + }, + "idx_messages_session_created": { + "name": "idx_messages_session_created", + "columns": [ + "session_id", + "created_at" + ], + "isUnique": false + }, + "idx_messages_created_at": { + "name": "idx_messages_created_at", + "columns": [ + "created_at", + "id" + ], + "isUnique": false + }, + "idx_messages_rewind_at": { + "name": "idx_messages_rewind_at", + "columns": [ + "rewind_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "messages_session_id_sessions_id_fk": { + "name": "messages_session_id_sessions_id_fk", + "tableFrom": "messages", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "migration_history": { + "name": "migration_history", + "columns": { + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "migration_meta": { + "name": "migration_meta", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orca_teams": { + "name": "orca_teams", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "lead_session_id": { + "name": "lead_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_active_team_per_lead": { + "name": "uniq_active_team_per_lead", + "columns": [ + "lead_session_id" + ], + "isUnique": true, + "where": "\"orca_teams\".\"status\" = 'active'" + }, + "idx_orca_teams_status": { + "name": "idx_orca_teams_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "orca_teams_lead_session_id_sessions_id_fk": { + "name": "orca_teams_lead_session_id_sessions_id_fk", + "tableFrom": "orca_teams", + "tableTo": "sessions", + "columnsFrom": [ + "lead_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orca_worker_creation_reservations": { + "name": "orca_worker_creation_reservations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_orca_worker_creation_reservations_team_label": { + "name": "uniq_orca_worker_creation_reservations_team_label", + "columns": [ + "team_id", + "lower(\"label\")" + ], + "isUnique": true + }, + "idx_orca_worker_creation_reservations_expires_at": { + "name": "idx_orca_worker_creation_reservations_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "orca_worker_creation_reservations_team_id_orca_teams_id_fk": { + "name": "orca_worker_creation_reservations_team_id_orca_teams_id_fk", + "tableFrom": "orca_worker_creation_reservations", + "tableTo": "orca_teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orca_workers": { + "name": "orca_workers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_branch": { + "name": "worktree_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'developer'" + }, + "focused": { + "name": "focused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "idle_since": { + "name": "idle_since", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_orca_workers_session_id": { + "name": "uniq_orca_workers_session_id", + "columns": [ + "session_id" + ], + "isUnique": true + }, + "uniq_orca_workers_team_label": { + "name": "uniq_orca_workers_team_label", + "columns": [ + "team_id", + "lower(\"label\")" + ], + "isUnique": true + }, + "uniq_orca_workers_focused_per_team": { + "name": "uniq_orca_workers_focused_per_team", + "columns": [ + "team_id" + ], + "isUnique": true, + "where": "\"orca_workers\".\"focused\" = true" + }, + "idx_orca_workers_team_id": { + "name": "idx_orca_workers_team_id", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "idx_orca_workers_status": { + "name": "idx_orca_workers_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "orca_workers_team_id_orca_teams_id_fk": { + "name": "orca_workers_team_id_orca_teams_id_fk", + "tableFrom": "orca_workers", + "tableTo": "orca_teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "orca_workers_session_id_sessions_id_fk": { + "name": "orca_workers_session_id_sessions_id_fk", + "tableFrom": "orca_workers", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_aliases": { + "name": "project_aliases", + "columns": { + "project_key": { + "name": "project_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "alias": { + "name": "alias", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_project_aliases_updated_at": { + "name": "idx_project_aliases_updated_at", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_automation_consents": { + "name": "project_automation_consents", + "columns": { + "working_dir": { + "name": "working_dir", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "consented_at": { + "name": "consented_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_hash": { + "name": "config_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "recent_workdirs": { + "name": "recent_workdirs", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_recent_workdirs_last_used_at": { + "name": "idx_recent_workdirs_last_used_at", + "columns": [ + "last_used_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "right_sidebar_tabs": { + "name": "right_sidebar_tabs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "right_sidebar_tabs_session_idx": { + "name": "right_sidebar_tabs_session_idx", + "columns": [ + "session_id", + "position" + ], + "isUnique": false + }, + "right_sidebar_tabs_subagents_singleton_idx": { + "name": "right_sidebar_tabs_subagents_singleton_idx", + "columns": [ + "session_id" + ], + "isUnique": true, + "where": "\"right_sidebar_tabs\".\"kind\" = 'subagents'" + } + }, + "foreignKeys": { + "right_sidebar_tabs_session_id_sessions_id_fk": { + "name": "right_sidebar_tabs_session_id_sessions_id_fk", + "tableFrom": "right_sidebar_tabs", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_runs": { + "name": "schedule_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fired_at": { + "name": "fired_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_msg": { + "name": "error_msg", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "estimated_value_usd": { + "name": "estimated_value_usd", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_amount": { + "name": "cost_amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "estimated_value_amount": { + "name": "estimated_value_amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_currency": { + "name": "cost_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_is_approximate": { + "name": "cost_is_approximate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cost_attribution": { + "name": "cost_attribution", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'legacy'" + }, + "result_text": { + "name": "result_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pre_run_hook_result": { + "name": "pre_run_hook_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_schedule_runs_schedule": { + "name": "idx_schedule_runs_schedule", + "columns": [ + "schedule_id", + "fired_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "schedule_runs_schedule_id_schedules_id_fk": { + "name": "schedule_runs_schedule_id_schedules_id_fk", + "tableFrom": "schedule_runs", + "tableTo": "schedules", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedule_runs_session_id_sessions_id_fk": { + "name": "schedule_runs_session_id_sessions_id_fk", + "tableFrom": "schedule_runs", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedules": { + "name": "schedules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'prompt'" + }, + "job_config": { + "name": "job_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_mode": { + "name": "execution_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "script_config": { + "name": "script_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'user'" + }, + "project_config_id": { + "name": "project_config_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_session_fallback": { + "name": "legacy_session_fallback", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cron'" + }, + "cron_expr": { + "name": "cron_expr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recurring": { + "name": "recurring", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "manual": { + "name": "manual", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "interval_ms": { + "name": "interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "effort": { + "name": "effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fast_mode": { + "name": "fast_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "working_dir": { + "name": "working_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_kind": { + "name": "workspace_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "use_worktree": { + "name": "use_worktree", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "target_session_id": { + "name": "target_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "persistent_session": { + "name": "persistent_session", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "silent_when_idle": { + "name": "silent_when_idle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pre_run_hook_command": { + "name": "pre_run_hook_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pre_run_hook_timeout_ms": { + "name": "pre_run_hook_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skip_log_session_id": { + "name": "skip_log_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notify_desktop": { + "name": "notify_desktop", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "notify_feishu": { + "name": "notify_feishu", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notify_wecom_group": { + "name": "notify_wecom_group", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_finished_at": { + "name": "last_finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_fire_at": { + "name": "next_fire_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expire_at": { + "name": "expire_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_schedules_active_next": { + "name": "idx_schedules_active_next", + "columns": [ + "status", + "next_fire_at" + ], + "isUnique": false + }, + "idx_schedules_target_session": { + "name": "idx_schedules_target_session", + "columns": [ + "target_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "schedules_target_session_id_sessions_id_fk": { + "name": "schedules_target_session_id_sessions_id_fk", + "tableFrom": "schedules", + "tableTo": "sessions", + "columnsFrom": [ + "target_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "schedules_skip_log_session_id_sessions_id_fk": { + "name": "schedules_skip_log_session_id_sessions_id_fk", + "tableFrom": "schedules", + "tableTo": "sessions", + "columnsFrom": [ + "skip_log_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_goals": { + "name": "session_goals", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "objective": { + "name": "objective", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "budget_tokens": { + "name": "budget_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_turns": { + "name": "max_turns", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "no_progress_limit": { + "name": "no_progress_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "turns_used": { + "name": "turns_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "no_progress_streak": { + "name": "no_progress_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "usage_reset_at": { + "name": "usage_reset_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_reason": { + "name": "last_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_session_goals_status": { + "name": "idx_session_goals_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_goals_session_id_sessions_id_fk": { + "name": "session_goals_session_id_sessions_id_fk", + "tableFrom": "session_goals", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_pr_refs": { + "name": "session_pr_refs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_session_pr_refs": { + "name": "uniq_session_pr_refs", + "columns": [ + "session_id", + "owner", + "repo", + "pr_number" + ], + "isUnique": true + }, + "idx_session_pr_refs_session_last_seen": { + "name": "idx_session_pr_refs_session_last_seen", + "columns": [ + "session_id", + "last_seen_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_pr_refs_session_id_sessions_id_fk": { + "name": "session_pr_refs_session_id_sessions_id_fk", + "tableFrom": "session_pr_refs", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'New Maker'" + }, + "working_dir": { + "name": "working_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_kind": { + "name": "workspace_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude-sonnet-4-6'" + }, + "effort": { + "name": "effort", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'high'" + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ask'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "sdk_session_id": { + "name": "sdk_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_token_usage": { + "name": "total_token_usage", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_cost_usd": { + "name": "total_cost_usd", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_cost_amount": { + "name": "total_cost_amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_cost_currency": { + "name": "total_cost_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_cost_is_approximate": { + "name": "total_cost_is_approximate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "context_tokens": { + "name": "context_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "fast_mode": { + "name": "fast_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "plan_mode_enabled": { + "name": "plan_mode_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cleared_at": { + "name": "cleared_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_send_at": { + "name": "user_send_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cc'" + }, + "orca_role": { + "name": "orca_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_session_id": { + "name": "parent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forked_at_message_id": { + "name": "forked_at_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'desktop'" + }, + "feishu_open_id": { + "name": "feishu_open_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feishu_bot_app_id": { + "name": "feishu_bot_app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "im_bot_context_id": { + "name": "im_bot_context_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "im_user_id": { + "name": "im_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_project_context": { + "name": "used_project_context", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "codex_history_has_product_prompt": { + "name": "codex_history_has_product_prompt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "codex_plan_json": { + "name": "codex_plan_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_dirs": { + "name": "extra_dirs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "remote_host_id": { + "name": "remote_host_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_turn_started_at": { + "name": "active_turn_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_turn_pid": { + "name": "active_turn_pid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_turn_ended_at": { + "name": "last_turn_ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_sessions_updated_at": { + "name": "idx_sessions_updated_at", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "idx_sessions_user_send_at": { + "name": "idx_sessions_user_send_at", + "columns": [ + "user_send_at" + ], + "isUnique": false + }, + "idx_sessions_sdk_session_id": { + "name": "idx_sessions_sdk_session_id", + "columns": [ + "sdk_session_id" + ], + "isUnique": false + }, + "idx_sessions_workdir_created": { + "name": "idx_sessions_workdir_created", + "columns": [ + "working_dir", + "created_at", + "id" + ], + "isUnique": false + }, + "idx_sessions_created_at": { + "name": "idx_sessions_created_at", + "columns": [ + "created_at", + "id" + ], + "isUnique": false + }, + "idx_sessions_workspace_kind": { + "name": "idx_sessions_workspace_kind", + "columns": [ + "workspace_kind" + ], + "isUnique": false + }, + "idx_sessions_parent_session_id": { + "name": "idx_sessions_parent_session_id", + "columns": [ + "parent_session_id" + ], + "isUnique": false + }, + "idx_sessions_orca_role": { + "name": "idx_sessions_orca_role", + "columns": [ + "orca_role" + ], + "isUnique": false + }, + "idx_sessions_worktree_path": { + "name": "idx_sessions_worktree_path", + "columns": [ + "worktree_path" + ], + "isUnique": false + }, + "idx_sessions_feishu_lookup": { + "name": "idx_sessions_feishu_lookup", + "columns": [ + "source", + "feishu_bot_app_id", + "feishu_open_id" + ], + "isUnique": false + }, + "idx_sessions_im_lookup": { + "name": "idx_sessions_im_lookup", + "columns": [ + "source", + "im_bot_context_id", + "im_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_parent_session_id_sessions_id_fk": { + "name": "sessions_parent_session_id_sessions_id_fk", + "tableFrom": "sessions", + "tableTo": "sessions", + "columnsFrom": [ + "parent_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skill_usage_exposures": { + "name": "skill_usage_exposures", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "analyzer_version": { + "name": "analyzer_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'6'" + }, + "raw_file_path": { + "name": "raw_file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw_line_no": { + "name": "raw_line_no", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sdk_session_id": { + "name": "sdk_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_name": { + "name": "skill_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_path": { + "name": "skill_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skill_document_hash": { + "name": "skill_document_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exposure_content_hash": { + "name": "exposure_content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_hash_source": { + "name": "document_hash_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_use_id": { + "name": "tool_use_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seen_at": { + "name": "seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_call_count": { + "name": "tool_call_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "repeated_tool_call_count": { + "name": "repeated_tool_call_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "tool_error_count": { + "name": "tool_error_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "command_call_count": { + "name": "command_call_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "command_failure_count": { + "name": "command_failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_skill_usage_exposures_skill_document_version": { + "name": "idx_skill_usage_exposures_skill_document_version", + "columns": [ + "analyzer_version", + "skill_name", + "skill_document_hash" + ], + "isUnique": false + }, + "idx_skill_usage_exposures_session": { + "name": "idx_skill_usage_exposures_session", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "idx_skill_usage_exposures_raw_file": { + "name": "idx_skill_usage_exposures_raw_file", + "columns": [ + "raw_file_path" + ], + "isUnique": false + } + }, + "foreignKeys": { + "skill_usage_exposures_raw_file_path_skill_usage_sources_raw_file_path_fk": { + "name": "skill_usage_exposures_raw_file_path_skill_usage_sources_raw_file_path_fk", + "tableFrom": "skill_usage_exposures", + "tableTo": "skill_usage_sources", + "columnsFrom": [ + "raw_file_path" + ], + "columnsTo": [ + "raw_file_path" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skill_usage_sources": { + "name": "skill_usage_sources", + "columns": { + "raw_file_path": { + "name": "raw_file_path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "analyzer_version": { + "name": "analyzer_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'6'" + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sdk_session_id": { + "name": "sdk_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mtime_ms": { + "name": "mtime_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_scanned_at": { + "name": "last_scanned_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ok'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_skill_usage_sources_session": { + "name": "idx_skill_usage_sources_session", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "idx_skill_usage_sources_sdk_session": { + "name": "idx_skill_usage_sources_sdk_session", + "columns": [ + "sdk_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subagent_run_aliases": { + "name": "subagent_run_aliases", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alias": { + "name": "alias", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "subagent_run_aliases_lookup_idx": { + "name": "subagent_run_aliases_lookup_idx", + "columns": [ + "session_id", + "provider", + "alias", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "subagent_run_aliases_session_id_sessions_id_fk": { + "name": "subagent_run_aliases_session_id_sessions_id_fk", + "tableFrom": "subagent_run_aliases", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "subagent_run_aliases_run_id_subagent_runs_id_fk": { + "name": "subagent_run_aliases_run_id_subagent_runs_id_fk", + "tableFrom": "subagent_run_aliases", + "tableTo": "subagent_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "subagent_run_aliases_run_id_alias_pk": { + "columns": [ + "run_id", + "alias" + ], + "name": "subagent_run_aliases_run_id_alias_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subagent_runs": { + "name": "subagent_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logical_agent_id": { + "name": "logical_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_tool_use_id": { + "name": "parent_tool_use_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "provider_run_ids": { + "name": "provider_run_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_uses": { + "name": "tool_uses", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "activity": { + "name": "activity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rewind_at": { + "name": "rewind_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "subagent_runs_logical_idx": { + "name": "subagent_runs_logical_idx", + "columns": [ + "session_id", + "provider", + "logical_agent_id" + ], + "isUnique": false + }, + "subagent_runs_session_idx": { + "name": "subagent_runs_session_idx", + "columns": [ + "session_id", + "rewind_at", + "deleted_at", + "started_at" + ], + "isUnique": false + }, + "subagent_runs_parent_tool_use_idx": { + "name": "subagent_runs_parent_tool_use_idx", + "columns": [ + "session_id", + "parent_tool_use_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "subagent_runs_session_id_sessions_id_fk": { + "name": "subagent_runs_session_id_sessions_id_fk", + "tableFrom": "subagent_runs", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vec_table_meta": { + "name": "vec_table_meta", + "columns": { + "vec_table": { + "name": "vec_table", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dim": { + "name": "dim", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registered_at": { + "name": "registered_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wechat_file_attachments": { + "name": "wechat_file_attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "binding_epoch": { + "name": "binding_epoch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abs_path": { + "name": "abs_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'staged'" + }, + "promoted_at": { + "name": "promoted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_wechat_file_attachments_task": { + "name": "idx_wechat_file_attachments_task", + "columns": [ + "binding_epoch", + "task_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "wechat_file_attachments_binding_epoch_wechat_sync_state_binding_epoch_fk": { + "name": "wechat_file_attachments_binding_epoch_wechat_sync_state_binding_epoch_fk", + "tableFrom": "wechat_file_attachments", + "tableTo": "wechat_sync_state", + "columnsFrom": [ + "binding_epoch" + ], + "columnsTo": [ + "binding_epoch" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wechat_file_attachments_task_id_wechat_inbox_id_fk": { + "name": "wechat_file_attachments_task_id_wechat_inbox_id_fk", + "tableFrom": "wechat_file_attachments", + "tableTo": "wechat_inbox", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wechat_file_attachments_session_id_sessions_id_fk": { + "name": "wechat_file_attachments_session_id_sessions_id_fk", + "tableFrom": "wechat_file_attachments", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wechat_inbox": { + "name": "wechat_inbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "binding_epoch": { + "name": "binding_epoch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform_message_id": { + "name": "platform_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform_seq": { + "name": "platform_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "peer_id": { + "name": "peer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform_created_at": { + "name": "platform_created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "conversation_epoch": { + "name": "conversation_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_nonce": { + "name": "context_nonce", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_ciphertext": { + "name": "context_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_tag": { + "name": "context_tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_wechat_inbox_platform_message": { + "name": "uniq_wechat_inbox_platform_message", + "columns": [ + "binding_epoch", + "platform_message_id" + ], + "isUnique": true + }, + "idx_wechat_inbox_queue": { + "name": "idx_wechat_inbox_queue", + "columns": [ + "binding_epoch", + "status", + "received_at" + ], + "isUnique": false + }, + "idx_wechat_inbox_lease": { + "name": "idx_wechat_inbox_lease", + "columns": [ + "binding_epoch", + "lease_until" + ], + "isUnique": false + }, + "idx_wechat_inbox_conversation": { + "name": "idx_wechat_inbox_conversation", + "columns": [ + "binding_epoch", + "peer_id", + "conversation_epoch" + ], + "isUnique": false + }, + "uniq_wechat_inbox_running_session": { + "name": "uniq_wechat_inbox_running_session", + "columns": [ + "binding_epoch", + "session_id" + ], + "isUnique": true, + "where": "\"wechat_inbox\".\"session_id\" IS NOT NULL AND \"wechat_inbox\".\"status\" IN ('dispatching', 'accepted_running', 'waiting_desktop', 'delivery_pending')" + } + }, + "foreignKeys": { + "wechat_inbox_binding_epoch_wechat_sync_state_binding_epoch_fk": { + "name": "wechat_inbox_binding_epoch_wechat_sync_state_binding_epoch_fk", + "tableFrom": "wechat_inbox", + "tableTo": "wechat_sync_state", + "columnsFrom": [ + "binding_epoch" + ], + "columnsTo": [ + "binding_epoch" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wechat_inbox_session_id_sessions_id_fk": { + "name": "wechat_inbox_session_id_sessions_id_fk", + "tableFrom": "wechat_inbox", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wechat_outbox": { + "name": "wechat_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "binding_epoch": { + "name": "binding_epoch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_json": { + "name": "media_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_wechat_outbox_client_id": { + "name": "uniq_wechat_outbox_client_id", + "columns": [ + "binding_epoch", + "client_id" + ], + "isUnique": true + }, + "idx_wechat_outbox_delivery": { + "name": "idx_wechat_outbox_delivery", + "columns": [ + "binding_epoch", + "status", + "next_retry_at" + ], + "isUnique": false + }, + "idx_wechat_outbox_task": { + "name": "idx_wechat_outbox_task", + "columns": [ + "binding_epoch", + "task_id", + "chunk_index" + ], + "isUnique": false + } + }, + "foreignKeys": { + "wechat_outbox_binding_epoch_wechat_sync_state_binding_epoch_fk": { + "name": "wechat_outbox_binding_epoch_wechat_sync_state_binding_epoch_fk", + "tableFrom": "wechat_outbox", + "tableTo": "wechat_sync_state", + "columnsFrom": [ + "binding_epoch" + ], + "columnsTo": [ + "binding_epoch" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wechat_outbox_task_id_wechat_inbox_id_fk": { + "name": "wechat_outbox_task_id_wechat_inbox_id_fk", + "tableFrom": "wechat_outbox", + "tableTo": "wechat_inbox", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wechat_sync_state": { + "name": "wechat_sync_state", + "columns": { + "binding_epoch": { + "name": "binding_epoch", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sync_cursor": { + "name": "sync_cursor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "last_poll_at": { + "name": "last_poll_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_wechat_sync_active": { + "name": "uniq_wechat_sync_active", + "columns": [ + "is_active" + ], + "isUnique": true, + "where": "\"wechat_sync_state\".\"is_active\" = 1" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "uniq_orca_worker_creation_reservations_team_label": { + "columns": { + "lower(\"label\")": { + "isExpression": true + } + } + }, + "uniq_orca_workers_team_label": { + "columns": { + "lower(\"label\")": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/apps/desktop/drizzle/meta/0092_snapshot.json b/apps/desktop/drizzle/meta/0092_snapshot.json new file mode 100644 index 0000000000..fbab9eed58 --- /dev/null +++ b/apps/desktop/drizzle/meta/0092_snapshot.json @@ -0,0 +1,6893 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b339599b-025f-4f94-bf69-483571695b1c", + "prevId": "678c1f41-343b-49e0-92d8-20247184dc60", + "tables": { + "account_usage_snapshots": { + "name": "account_usage_snapshots", + "columns": { + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "snapshot": { + "name": "snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_input_queue_snapshots": { + "name": "agent_input_queue_snapshots", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_input_queue_snapshots_session_id_sessions_id_fk": { + "name": "agent_input_queue_snapshots_session_id_sessions_id_fk", + "tableFrom": "agent_input_queue_snapshots", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_automation_links": { + "name": "bot_automation_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_binding_id": { + "name": "project_binding_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_route_id": { + "name": "target_route_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_with_profile_version": { + "name": "created_with_profile_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "durable_note_namespace": { + "name": "durable_note_namespace", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_policy_json": { + "name": "execution_policy_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "suspended_status": { + "name": "suspended_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_bot_automation_links_schedule": { + "name": "uniq_bot_automation_links_schedule", + "columns": [ + "schedule_id" + ], + "isUnique": true + }, + "idx_bot_automation_links_bot_status": { + "name": "idx_bot_automation_links_bot_status", + "columns": [ + "bot_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bot_automation_links_bot_id_bot_profiles_id_fk": { + "name": "bot_automation_links_bot_id_bot_profiles_id_fk", + "tableFrom": "bot_automation_links", + "tableTo": "bot_profiles", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_automation_links_schedule_id_schedules_id_fk": { + "name": "bot_automation_links_schedule_id_schedules_id_fk", + "tableFrom": "bot_automation_links", + "tableTo": "schedules", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "bot_automation_links_project_binding_id_bot_project_bindings_id_fk": { + "name": "bot_automation_links_project_binding_id_bot_project_bindings_id_fk", + "tableFrom": "bot_automation_links", + "tableTo": "bot_project_bindings", + "columnsFrom": [ + "project_binding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "bot_automation_links_target_route_id_bot_routes_id_fk": { + "name": "bot_automation_links_target_route_id_bot_routes_id_fk", + "tableFrom": "bot_automation_links", + "tableTo": "bot_routes", + "columnsFrom": [ + "target_route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_automation_runs": { + "name": "bot_automation_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "automation_link_id": { + "name": "automation_link_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schedule_run_id": { + "name": "schedule_run_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_lease_id": { + "name": "workspace_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "profile_version": { + "name": "profile_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_binding_id_snapshot": { + "name": "project_binding_id_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_route_id_snapshot": { + "name": "target_route_id_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_route_owner_generation_snapshot": { + "name": "target_route_owner_generation_snapshot", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "working_dir_snapshot": { + "name": "working_dir_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_host_id_snapshot": { + "name": "remote_host_id_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_path_snapshot": { + "name": "worktree_path_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delivery_outbox_id": { + "name": "delivery_outbox_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'not-requested'" + }, + "delivery_error": { + "name": "delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_text_snapshot": { + "name": "result_text_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_artifacts_json": { + "name": "output_artifacts_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_plan_json": { + "name": "execution_plan_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claimed'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_bot_automation_runs_schedule_run": { + "name": "uniq_bot_automation_runs_schedule_run", + "columns": [ + "schedule_run_id" + ], + "isUnique": true + }, + "idx_bot_automation_runs_link_created": { + "name": "idx_bot_automation_runs_link_created", + "columns": [ + "automation_link_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bot_automation_runs_automation_link_id_bot_automation_links_id_fk": { + "name": "bot_automation_runs_automation_link_id_bot_automation_links_id_fk", + "tableFrom": "bot_automation_runs", + "tableTo": "bot_automation_links", + "columnsFrom": [ + "automation_link_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_automation_runs_schedule_run_id_schedule_runs_id_fk": { + "name": "bot_automation_runs_schedule_run_id_schedule_runs_id_fk", + "tableFrom": "bot_automation_runs", + "tableTo": "schedule_runs", + "columnsFrom": [ + "schedule_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "bot_automation_runs_session_id_sessions_id_fk": { + "name": "bot_automation_runs_session_id_sessions_id_fk", + "tableFrom": "bot_automation_runs", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "bot_automation_runs_workspace_lease_id_bot_workspace_leases_id_fk": { + "name": "bot_automation_runs_workspace_lease_id_bot_workspace_leases_id_fk", + "tableFrom": "bot_automation_runs", + "tableTo": "bot_workspace_leases", + "columnsFrom": [ + "workspace_lease_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "bot_automation_runs_delivery_outbox_id_bot_delivery_outbox_id_fk": { + "name": "bot_automation_runs_delivery_outbox_id_bot_delivery_outbox_id_fk", + "tableFrom": "bot_automation_runs", + "tableTo": "bot_delivery_outbox", + "columnsFrom": [ + "delivery_outbox_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_channels": { + "name": "bot_channels", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "config_json": { + "name": "config_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_bot_channels_bot_kind": { + "name": "idx_bot_channels_bot_kind", + "columns": [ + "bot_id", + "kind" + ], + "isUnique": false + }, + "idx_bot_channels_enabled": { + "name": "idx_bot_channels_enabled", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bot_channels_bot_id_bot_profiles_id_fk": { + "name": "bot_channels_bot_id_bot_profiles_id_fk", + "tableFrom": "bot_channels", + "tableTo": "bot_profiles", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_delegations": { + "name": "bot_delegations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "requesting_bot_id": { + "name": "requesting_bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_bot_id": { + "name": "target_bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_session_id": { + "name": "parent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "child_session_id": { + "name": "child_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "objective": { + "name": "objective", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_refs_json": { + "name": "context_refs_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "artifact_refs_json": { + "name": "artifact_refs_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "permission_snapshot_json": { + "name": "permission_snapshot_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "lineage_json": { + "name": "lineage_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "target_profile_version": { + "name": "target_profile_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "depth": { + "name": "depth", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "budget_tokens": { + "name": "budget_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_artifacts_json": { + "name": "output_artifacts_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_bot_delegations_requester_status": { + "name": "idx_bot_delegations_requester_status", + "columns": [ + "requesting_bot_id", + "status" + ], + "isUnique": false + }, + "idx_bot_delegations_target_status": { + "name": "idx_bot_delegations_target_status", + "columns": [ + "target_bot_id", + "status" + ], + "isUnique": false + }, + "idx_bot_delegations_parent_session": { + "name": "idx_bot_delegations_parent_session", + "columns": [ + "parent_session_id" + ], + "isUnique": false + }, + "uniq_bot_delegations_child_session": { + "name": "uniq_bot_delegations_child_session", + "columns": [ + "child_session_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bot_delegations_requesting_bot_id_bot_profiles_id_fk": { + "name": "bot_delegations_requesting_bot_id_bot_profiles_id_fk", + "tableFrom": "bot_delegations", + "tableTo": "bot_profiles", + "columnsFrom": [ + "requesting_bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_delegations_target_bot_id_bot_profiles_id_fk": { + "name": "bot_delegations_target_bot_id_bot_profiles_id_fk", + "tableFrom": "bot_delegations", + "tableTo": "bot_profiles", + "columnsFrom": [ + "target_bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_delegations_parent_session_id_sessions_id_fk": { + "name": "bot_delegations_parent_session_id_sessions_id_fk", + "tableFrom": "bot_delegations", + "tableTo": "sessions", + "columnsFrom": [ + "parent_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "bot_delegations_child_session_id_sessions_id_fk": { + "name": "bot_delegations_child_session_id_sessions_id_fk", + "tableFrom": "bot_delegations", + "tableTo": "sessions", + "columnsFrom": [ + "child_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_delivery_outbox": { + "name": "bot_delivery_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "route_id": { + "name": "route_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_ref_json": { + "name": "payload_ref_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "owner_generation": { + "name": "owner_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delivery_receipt_json": { + "name": "delivery_receipt_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_bot_delivery_outbox_idempotency": { + "name": "uniq_bot_delivery_outbox_idempotency", + "columns": [ + "idempotency_key" + ], + "isUnique": true + }, + "idx_bot_delivery_outbox_due": { + "name": "idx_bot_delivery_outbox_due", + "columns": [ + "status", + "next_attempt_at" + ], + "isUnique": false + }, + "idx_bot_delivery_outbox_route_created": { + "name": "idx_bot_delivery_outbox_route_created", + "columns": [ + "route_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bot_delivery_outbox_bot_id_bot_profiles_id_fk": { + "name": "bot_delivery_outbox_bot_id_bot_profiles_id_fk", + "tableFrom": "bot_delivery_outbox", + "tableTo": "bot_profiles", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_delivery_outbox_channel_id_bot_channels_id_fk": { + "name": "bot_delivery_outbox_channel_id_bot_channels_id_fk", + "tableFrom": "bot_delivery_outbox", + "tableTo": "bot_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "bot_delivery_outbox_route_id_bot_routes_id_fk": { + "name": "bot_delivery_outbox_route_id_bot_routes_id_fk", + "tableFrom": "bot_delivery_outbox", + "tableTo": "bot_routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "bot_delivery_outbox_session_id_sessions_id_fk": { + "name": "bot_delivery_outbox_session_id_sessions_id_fk", + "tableFrom": "bot_delivery_outbox", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_durable_notes": { + "name": "bot_durable_notes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note_key": { + "name": "note_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_bot_durable_notes_bot_namespace_key": { + "name": "uniq_bot_durable_notes_bot_namespace_key", + "columns": [ + "bot_id", + "namespace", + "note_key" + ], + "isUnique": true + }, + "idx_bot_durable_notes_bot_namespace": { + "name": "idx_bot_durable_notes_bot_namespace", + "columns": [ + "bot_id", + "namespace" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bot_durable_notes_bot_id_bot_profiles_id_fk": { + "name": "bot_durable_notes_bot_id_bot_profiles_id_fk", + "tableFrom": "bot_durable_notes", + "tableTo": "bot_profiles", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_im_migration_items": { + "name": "bot_im_migration_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "migration_id": { + "name": "migration_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_status": { + "name": "original_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "history_link_created": { + "name": "history_link_created", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "session_archived": { + "name": "session_archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "applied_session_updated_at": { + "name": "applied_session_updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rolled_back_at": { + "name": "rolled_back_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_bot_im_migration_items_batch_session": { + "name": "uniq_bot_im_migration_items_batch_session", + "columns": [ + "migration_id", + "session_id" + ], + "isUnique": true + }, + "idx_bot_im_migration_items_session": { + "name": "idx_bot_im_migration_items_session", + "columns": [ + "session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bot_im_migration_items_migration_id_bot_im_migrations_id_fk": { + "name": "bot_im_migration_items_migration_id_bot_im_migrations_id_fk", + "tableFrom": "bot_im_migration_items", + "tableTo": "bot_im_migrations", + "columnsFrom": [ + "migration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_im_migration_items_session_id_sessions_id_fk": { + "name": "bot_im_migration_items_session_id_sessions_id_fk", + "tableFrom": "bot_im_migration_items", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_im_migrations": { + "name": "bot_im_migrations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "route_id": { + "name": "route_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ownership": { + "name": "ownership", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plan_hash": { + "name": "plan_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'applying'" + }, + "channel_before_json": { + "name": "channel_before_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "route_before_json": { + "name": "route_before_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "adapter_bindings_json": { + "name": "adapter_bindings_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "error_json": { + "name": "error_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rolled_back_at": { + "name": "rolled_back_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_bot_im_migrations_request": { + "name": "uniq_bot_im_migrations_request", + "columns": [ + "request_id" + ], + "isUnique": true + }, + "idx_bot_im_migrations_bot_created": { + "name": "idx_bot_im_migrations_bot_created", + "columns": [ + "bot_id", + "created_at" + ], + "isUnique": false + }, + "idx_bot_im_migrations_connection_status": { + "name": "idx_bot_im_migrations_connection_status", + "columns": [ + "connection_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bot_im_migrations_bot_id_bot_profiles_id_fk": { + "name": "bot_im_migrations_bot_id_bot_profiles_id_fk", + "tableFrom": "bot_im_migrations", + "tableTo": "bot_profiles", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_im_migrations_channel_id_bot_channels_id_fk": { + "name": "bot_im_migrations_channel_id_bot_channels_id_fk", + "tableFrom": "bot_im_migrations", + "tableTo": "bot_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_im_migrations_route_id_bot_routes_id_fk": { + "name": "bot_im_migrations_route_id_bot_routes_id_fk", + "tableFrom": "bot_im_migrations", + "tableTo": "bot_routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_lifecycle_events": { + "name": "bot_lifecycle_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_bot_lifecycle_events_bot_created": { + "name": "idx_bot_lifecycle_events_bot_created", + "columns": [ + "bot_id", + "created_at" + ], + "isUnique": false + }, + "idx_bot_lifecycle_events_session_created": { + "name": "idx_bot_lifecycle_events_session_created", + "columns": [ + "session_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bot_lifecycle_events_bot_id_bot_profiles_id_fk": { + "name": "bot_lifecycle_events_bot_id_bot_profiles_id_fk", + "tableFrom": "bot_lifecycle_events", + "tableTo": "bot_profiles", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_lifecycle_events_session_id_sessions_id_fk": { + "name": "bot_lifecycle_events_session_id_sessions_id_fk", + "tableFrom": "bot_lifecycle_events", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_profile_versions": { + "name": "bot_profile_versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "identity_source": { + "name": "identity_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "capabilities_json": { + "name": "capabilities_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_bot_profile_versions_bot_version": { + "name": "uniq_bot_profile_versions_bot_version", + "columns": [ + "bot_id", + "version" + ], + "isUnique": true + }, + "idx_bot_profile_versions_bot_created": { + "name": "idx_bot_profile_versions_bot_created", + "columns": [ + "bot_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bot_profile_versions_bot_id_bot_profiles_id_fk": { + "name": "bot_profile_versions_bot_id_bot_profiles_id_fk", + "tableFrom": "bot_profile_versions", + "tableTo": "bot_profiles", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_profiles": { + "name": "bot_profiles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'🤖'" + }, + "avatar_color": { + "name": "avatar_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'violet'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "current_version": { + "name": "current_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "canonical_session_id": { + "name": "canonical_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_bot_profiles_status_updated": { + "name": "idx_bot_profiles_status_updated", + "columns": [ + "status", + "updated_at" + ], + "isUnique": false + }, + "idx_bot_profiles_canonical_session": { + "name": "idx_bot_profiles_canonical_session", + "columns": [ + "canonical_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bot_profiles_canonical_session_id_sessions_id_fk": { + "name": "bot_profiles_canonical_session_id_sessions_id_fk", + "tableFrom": "bot_profiles", + "tableTo": "sessions", + "columnsFrom": [ + "canonical_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_project_bindings": { + "name": "bot_project_bindings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_key": { + "name": "project_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "working_dir": { + "name": "working_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_host_id": { + "name": "remote_host_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_policy": { + "name": "workspace_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "allowed_paths_json": { + "name": "allowed_paths_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_bot_project_bindings_bot_project": { + "name": "uniq_bot_project_bindings_bot_project", + "columns": [ + "bot_id", + "project_key" + ], + "isUnique": true + }, + "idx_bot_project_bindings_bot_status": { + "name": "idx_bot_project_bindings_bot_status", + "columns": [ + "bot_id", + "status" + ], + "isUnique": false + }, + "uniq_bot_project_bindings_default_per_bot": { + "name": "uniq_bot_project_bindings_default_per_bot", + "columns": [ + "bot_id" + ], + "isUnique": true, + "where": "\"bot_project_bindings\".\"is_default\" = true AND \"bot_project_bindings\".\"status\" = 'active'" + } + }, + "foreignKeys": { + "bot_project_bindings_bot_id_bot_profiles_id_fk": { + "name": "bot_project_bindings_bot_id_bot_profiles_id_fk", + "tableFrom": "bot_project_bindings", + "tableTo": "bot_profiles", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_routes": { + "name": "bot_routes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "route_key": { + "name": "route_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "principal_key": { + "name": "principal_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_key": { + "name": "thread_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_session_id": { + "name": "current_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_binding_id": { + "name": "project_binding_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "capabilities_json": { + "name": "capabilities_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "owner_device_id": { + "name": "owner_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_generation": { + "name": "owner_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "suspended_status": { + "name": "suspended_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_bot_routes_channel_route": { + "name": "uniq_bot_routes_channel_route", + "columns": [ + "channel_id", + "route_key" + ], + "isUnique": true + }, + "idx_bot_routes_bot_status": { + "name": "idx_bot_routes_bot_status", + "columns": [ + "bot_id", + "status" + ], + "isUnique": false + }, + "idx_bot_routes_session": { + "name": "idx_bot_routes_session", + "columns": [ + "current_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bot_routes_bot_id_bot_profiles_id_fk": { + "name": "bot_routes_bot_id_bot_profiles_id_fk", + "tableFrom": "bot_routes", + "tableTo": "bot_profiles", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_routes_channel_id_bot_channels_id_fk": { + "name": "bot_routes_channel_id_bot_channels_id_fk", + "tableFrom": "bot_routes", + "tableTo": "bot_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_routes_current_session_id_sessions_id_fk": { + "name": "bot_routes_current_session_id_sessions_id_fk", + "tableFrom": "bot_routes", + "tableTo": "sessions", + "columnsFrom": [ + "current_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "bot_routes_project_binding_id_bot_project_bindings_id_fk": { + "name": "bot_routes_project_binding_id_bot_project_bindings_id_fk", + "tableFrom": "bot_routes", + "tableTo": "bot_project_bindings", + "columnsFrom": [ + "project_binding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_runtime_snapshots": { + "name": "bot_runtime_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profile_version": { + "name": "profile_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "working_dir": { + "name": "working_dir", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memory_scope_key": { + "name": "memory_scope_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "configured_json": { + "name": "configured_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "resolved_json": { + "name": "resolved_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "applied_at": { + "name": "applied_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failed_at": { + "name": "failed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure_json": { + "name": "failure_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_bot_runtime_snapshots_bot_prepared": { + "name": "idx_bot_runtime_snapshots_bot_prepared", + "columns": [ + "bot_id", + "prepared_at" + ], + "isUnique": false + }, + "idx_bot_runtime_snapshots_session_prepared": { + "name": "idx_bot_runtime_snapshots_session_prepared", + "columns": [ + "session_id", + "prepared_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bot_runtime_snapshots_bot_id_bot_profiles_id_fk": { + "name": "bot_runtime_snapshots_bot_id_bot_profiles_id_fk", + "tableFrom": "bot_runtime_snapshots", + "tableTo": "bot_profiles", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_runtime_snapshots_session_id_sessions_id_fk": { + "name": "bot_runtime_snapshots_session_id_sessions_id_fk", + "tableFrom": "bot_runtime_snapshots", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_session_links": { + "name": "bot_session_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "profile_version": { + "name": "profile_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "route_key": { + "name": "route_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_bot_session_links_session": { + "name": "uniq_bot_session_links_session", + "columns": [ + "session_id" + ], + "isUnique": true + }, + "uniq_bot_session_links_canonical_per_bot": { + "name": "uniq_bot_session_links_canonical_per_bot", + "columns": [ + "bot_id" + ], + "isUnique": true, + "where": "\"bot_session_links\".\"role\" = 'canonical'" + }, + "idx_bot_session_links_bot_role": { + "name": "idx_bot_session_links_bot_role", + "columns": [ + "bot_id", + "role" + ], + "isUnique": false + }, + "uniq_bot_session_links_route": { + "name": "uniq_bot_session_links_route", + "columns": [ + "channel_id", + "route_key" + ], + "isUnique": true, + "where": "\"bot_session_links\".\"role\" = 'route' AND \"bot_session_links\".\"channel_id\" IS NOT NULL AND \"bot_session_links\".\"route_key\" IS NOT NULL" + } + }, + "foreignKeys": { + "bot_session_links_bot_id_bot_profiles_id_fk": { + "name": "bot_session_links_bot_id_bot_profiles_id_fk", + "tableFrom": "bot_session_links", + "tableTo": "bot_profiles", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_session_links_session_id_sessions_id_fk": { + "name": "bot_session_links_session_id_sessions_id_fk", + "tableFrom": "bot_session_links", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_session_links_channel_id_bot_channels_id_fk": { + "name": "bot_session_links_channel_id_bot_channels_id_fk", + "tableFrom": "bot_session_links", + "tableTo": "bot_channels", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_workspace_attachments": { + "name": "bot_workspace_attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "lease_id": { + "name": "lease_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access": { + "name": "access", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'read-write'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detached_at": { + "name": "detached_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_bot_workspace_attachments_lease_session": { + "name": "uniq_bot_workspace_attachments_lease_session", + "columns": [ + "lease_id", + "session_id", + "generation" + ], + "isUnique": true + }, + "uniq_bot_workspace_attachments_active_session": { + "name": "uniq_bot_workspace_attachments_active_session", + "columns": [ + "session_id" + ], + "isUnique": true, + "where": "\"bot_workspace_attachments\".\"detached_at\" IS NULL" + }, + "idx_bot_workspace_attachments_lease_active": { + "name": "idx_bot_workspace_attachments_lease_active", + "columns": [ + "lease_id", + "detached_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bot_workspace_attachments_lease_id_bot_workspace_leases_id_fk": { + "name": "bot_workspace_attachments_lease_id_bot_workspace_leases_id_fk", + "tableFrom": "bot_workspace_attachments", + "tableTo": "bot_workspace_leases", + "columnsFrom": [ + "lease_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_workspace_attachments_session_id_sessions_id_fk": { + "name": "bot_workspace_attachments_session_id_sessions_id_fk", + "tableFrom": "bot_workspace_attachments", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_workspace_leases": { + "name": "bot_workspace_leases", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bot_id": { + "name": "bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_binding_id": { + "name": "project_binding_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lease_key": { + "name": "lease_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'shared'" + }, + "anchor_session_id": { + "name": "anchor_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "base_repo": { + "name": "base_repo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_branch": { + "name": "source_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_host_id": { + "name": "remote_host_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'acquiring'" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "released_at": { + "name": "released_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_bot_workspace_leases_active_binding_key": { + "name": "uniq_bot_workspace_leases_active_binding_key", + "columns": [ + "project_binding_id", + "lease_key" + ], + "isUnique": true, + "where": "\"bot_workspace_leases\".\"status\" IN ('acquiring', 'active', 'releasing')" + }, + "idx_bot_workspace_leases_bot_status": { + "name": "idx_bot_workspace_leases_bot_status", + "columns": [ + "bot_id", + "status" + ], + "isUnique": false + }, + "idx_bot_workspace_leases_anchor_session": { + "name": "idx_bot_workspace_leases_anchor_session", + "columns": [ + "anchor_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bot_workspace_leases_bot_id_bot_profiles_id_fk": { + "name": "bot_workspace_leases_bot_id_bot_profiles_id_fk", + "tableFrom": "bot_workspace_leases", + "tableTo": "bot_profiles", + "columnsFrom": [ + "bot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_workspace_leases_project_binding_id_bot_project_bindings_id_fk": { + "name": "bot_workspace_leases_project_binding_id_bot_project_bindings_id_fk", + "tableFrom": "bot_workspace_leases", + "tableTo": "bot_project_bindings", + "columnsFrom": [ + "project_binding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_workspace_leases_anchor_session_id_sessions_id_fk": { + "name": "bot_workspace_leases_anchor_session_id_sessions_id_fk", + "tableFrom": "bot_workspace_leases", + "tableTo": "sessions", + "columnsFrom": [ + "anchor_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "custom_mcp_servers": { + "name": "custom_mcp_servers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_custom_mcp_servers_sort_order": { + "name": "idx_custom_mcp_servers_sort_order", + "columns": [ + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "custom_providers": { + "name": "custom_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "runtimes": { + "name": "runtimes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_custom_providers_sort_order": { + "name": "idx_custom_providers_sort_order", + "columns": [ + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_model_usage": { + "name": "daily_model_usage", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_amount": { + "name": "cost_amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_currency": { + "name": "cost_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "cost_is_approximate": { + "name": "cost_is_approximate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_create_tokens": { + "name": "cache_create_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_model_usage_day_agent_kind_model_cost_currency_pk": { + "columns": [ + "day", + "agent_kind", + "model", + "cost_currency" + ], + "name": "daily_model_usage_day_agent_kind_model_cost_currency_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "daily_spend": { + "name": "daily_spend", + "columns": { + "day": { + "name": "day", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_amount": { + "name": "cost_amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_currency": { + "name": "cost_currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'USD'" + }, + "cost_is_approximate": { + "name": "cost_is_approximate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "daily_spend_day_cost_currency_pk": { + "columns": [ + "day", + "cost_currency" + ], + "name": "daily_spend_day_cost_currency_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "device_link_ownership": { + "name": "device_link_ownership", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_pid": { + "name": "owner_pid", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_label": { + "name": "owner_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "embedding_jobs": { + "name": "embedding_jobs", + "columns": { + "rowid": { + "name": "rowid", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "vec_table": { + "name": "vec_table", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locked_at": { + "name": "locked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_embedding_jobs_natural": { + "name": "uniq_embedding_jobs_natural", + "columns": [ + "source", + "source_id", + "chunk_index", + "model_id" + ], + "isUnique": true + }, + "idx_embedding_jobs_status_scheduled": { + "name": "idx_embedding_jobs_status_scheduled", + "columns": [ + "status", + "scheduled_at" + ], + "isUnique": false + }, + "idx_embedding_jobs_source_id": { + "name": "idx_embedding_jobs_source_id", + "columns": [ + "source", + "source_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "embedding_meta": { + "name": "embedding_meta", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ghost_cards": { + "name": "ghost_cards", + "columns": { + "call_id": { + "name": "call_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ghost_id": { + "name": "ghost_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "html": { + "name": "html", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "v": { + "name": "v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ghost_cards_updated_at_idx": { + "name": "ghost_cards_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hook_group_context_cursors": { + "name": "hook_group_context_cursors", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cursor_key": { + "name": "cursor_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cursor_id": { + "name": "cursor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "hook_group_context_cursors_updated_at_idx": { + "name": "hook_group_context_cursors_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "hook_group_context_cursors_provider_cursor_key_pk": { + "columns": [ + "provider", + "cursor_key" + ], + "name": "hook_group_context_cursors_provider_cursor_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hook_group_message_stats": { + "name": "hook_group_message_stats", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_bytes": { + "name": "text_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hook_group_messages": { + "name": "hook_group_messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chat_name": { + "name": "chat_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_bot": { + "name": "is_bot", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_names": { + "name": "file_names", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "hook_group_messages_msg_idx": { + "name": "hook_group_messages_msg_idx", + "columns": [ + "provider", + "chat_id", + "thread_id", + "message_id" + ], + "isUnique": true + }, + "hook_group_messages_window_idx": { + "name": "hook_group_messages_window_idx", + "columns": [ + "provider", + "chat_id", + "thread_id", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "im_bindings": { + "name": "im_bindings", + "columns": { + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bot_context_id": { + "name": "bot_context_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "target_session_id": { + "name": "target_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attached_at": { + "name": "attached_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attached_via_card_message_id": { + "name": "attached_via_card_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_im_bindings_target": { + "name": "idx_im_bindings_target", + "columns": [ + "target_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "im_bindings_target_session_id_sessions_id_fk": { + "name": "im_bindings_target_session_id_sessions_id_fk", + "tableFrom": "im_bindings", + "tableTo": "sessions", + "columnsFrom": [ + "target_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "im_bindings_channel_bot_context_id_user_id_scope_key_pk": { + "columns": [ + "channel", + "bot_context_id", + "user_id", + "scope_key" + ], + "name": "im_bindings_channel_bot_context_id_user_id_scope_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media_blobs": { + "name": "media_blobs", + "columns": { + "hash": { + "name": "hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "ext": { + "name": "ext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_cache": { + "name": "is_cache", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_access_at": { + "name": "last_access_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media_refs": { + "name": "media_refs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_kind": { + "name": "ref_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "origin_session_id": { + "name": "origin_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_refs_hash_idx": { + "name": "media_refs_hash_idx", + "columns": [ + "hash" + ], + "isUnique": false + }, + "media_refs_ref_idx": { + "name": "media_refs_ref_idx", + "columns": [ + "ref_kind", + "ref_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "media_refs_hash_media_blobs_hash_fk": { + "name": "media_refs_hash_media_blobs_hash_fk", + "tableFrom": "media_refs", + "tableTo": "media_blobs", + "columnsFrom": [ + "hash" + ], + "columnsTo": [ + "hash" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "messages": { + "name": "messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_use_id": { + "name": "tool_use_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_meta": { + "name": "agent_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rewind_at": { + "name": "rewind_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_messages_session_client": { + "name": "uniq_messages_session_client", + "columns": [ + "session_id", + "client_id" + ], + "isUnique": true + }, + "idx_messages_session_created": { + "name": "idx_messages_session_created", + "columns": [ + "session_id", + "created_at" + ], + "isUnique": false + }, + "idx_messages_created_at": { + "name": "idx_messages_created_at", + "columns": [ + "created_at", + "id" + ], + "isUnique": false + }, + "idx_messages_rewind_at": { + "name": "idx_messages_rewind_at", + "columns": [ + "rewind_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "messages_session_id_sessions_id_fk": { + "name": "messages_session_id_sessions_id_fk", + "tableFrom": "messages", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "migration_history": { + "name": "migration_history", + "columns": { + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_at": { + "name": "applied_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "migration_meta": { + "name": "migration_meta", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orca_teams": { + "name": "orca_teams", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "lead_session_id": { + "name": "lead_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_active_team_per_lead": { + "name": "uniq_active_team_per_lead", + "columns": [ + "lead_session_id" + ], + "isUnique": true, + "where": "\"orca_teams\".\"status\" = 'active'" + }, + "idx_orca_teams_status": { + "name": "idx_orca_teams_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "orca_teams_lead_session_id_sessions_id_fk": { + "name": "orca_teams_lead_session_id_sessions_id_fk", + "tableFrom": "orca_teams", + "tableTo": "sessions", + "columnsFrom": [ + "lead_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orca_worker_creation_reservations": { + "name": "orca_worker_creation_reservations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_orca_worker_creation_reservations_team_label": { + "name": "uniq_orca_worker_creation_reservations_team_label", + "columns": [ + "team_id", + "lower(\"label\")" + ], + "isUnique": true + }, + "idx_orca_worker_creation_reservations_expires_at": { + "name": "idx_orca_worker_creation_reservations_expires_at", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "orca_worker_creation_reservations_team_id_orca_teams_id_fk": { + "name": "orca_worker_creation_reservations_team_id_orca_teams_id_fk", + "tableFrom": "orca_worker_creation_reservations", + "tableTo": "orca_teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "orca_workers": { + "name": "orca_workers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'idle'" + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_branch": { + "name": "worktree_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'developer'" + }, + "focused": { + "name": "focused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "idle_since": { + "name": "idle_since", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_orca_workers_session_id": { + "name": "uniq_orca_workers_session_id", + "columns": [ + "session_id" + ], + "isUnique": true + }, + "uniq_orca_workers_team_label": { + "name": "uniq_orca_workers_team_label", + "columns": [ + "team_id", + "lower(\"label\")" + ], + "isUnique": true + }, + "uniq_orca_workers_focused_per_team": { + "name": "uniq_orca_workers_focused_per_team", + "columns": [ + "team_id" + ], + "isUnique": true, + "where": "\"orca_workers\".\"focused\" = true" + }, + "idx_orca_workers_team_id": { + "name": "idx_orca_workers_team_id", + "columns": [ + "team_id" + ], + "isUnique": false + }, + "idx_orca_workers_status": { + "name": "idx_orca_workers_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "orca_workers_team_id_orca_teams_id_fk": { + "name": "orca_workers_team_id_orca_teams_id_fk", + "tableFrom": "orca_workers", + "tableTo": "orca_teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "orca_workers_session_id_sessions_id_fk": { + "name": "orca_workers_session_id_sessions_id_fk", + "tableFrom": "orca_workers", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_aliases": { + "name": "project_aliases", + "columns": { + "project_key": { + "name": "project_key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "alias": { + "name": "alias", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_project_aliases_updated_at": { + "name": "idx_project_aliases_updated_at", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_automation_consents": { + "name": "project_automation_consents", + "columns": { + "working_dir": { + "name": "working_dir", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "consented_at": { + "name": "consented_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_hash": { + "name": "config_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "recent_workdirs": { + "name": "recent_workdirs", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_recent_workdirs_last_used_at": { + "name": "idx_recent_workdirs_last_used_at", + "columns": [ + "last_used_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "right_sidebar_tabs": { + "name": "right_sidebar_tabs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "right_sidebar_tabs_session_idx": { + "name": "right_sidebar_tabs_session_idx", + "columns": [ + "session_id", + "position" + ], + "isUnique": false + }, + "right_sidebar_tabs_subagents_singleton_idx": { + "name": "right_sidebar_tabs_subagents_singleton_idx", + "columns": [ + "session_id" + ], + "isUnique": true, + "where": "\"right_sidebar_tabs\".\"kind\" = 'subagents'" + }, + "right_sidebar_tabs_bot_delegations_singleton_idx": { + "name": "right_sidebar_tabs_bot_delegations_singleton_idx", + "columns": [ + "session_id" + ], + "isUnique": true, + "where": "\"right_sidebar_tabs\".\"kind\" = 'bot-delegations'" + } + }, + "foreignKeys": { + "right_sidebar_tabs_session_id_sessions_id_fk": { + "name": "right_sidebar_tabs_session_id_sessions_id_fk", + "tableFrom": "right_sidebar_tabs", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedule_runs": { + "name": "schedule_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fired_at": { + "name": "fired_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_msg": { + "name": "error_msg", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "estimated_value_usd": { + "name": "estimated_value_usd", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_amount": { + "name": "cost_amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "estimated_value_amount": { + "name": "estimated_value_amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_currency": { + "name": "cost_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_is_approximate": { + "name": "cost_is_approximate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cost_attribution": { + "name": "cost_attribution", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'legacy'" + }, + "result_text": { + "name": "result_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pre_run_hook_result": { + "name": "pre_run_hook_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_schedule_runs_schedule": { + "name": "idx_schedule_runs_schedule", + "columns": [ + "schedule_id", + "fired_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "schedule_runs_schedule_id_schedules_id_fk": { + "name": "schedule_runs_schedule_id_schedules_id_fk", + "tableFrom": "schedule_runs", + "tableTo": "schedules", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedule_runs_session_id_sessions_id_fk": { + "name": "schedule_runs_session_id_sessions_id_fk", + "tableFrom": "schedule_runs", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "schedules": { + "name": "schedules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'prompt'" + }, + "job_config": { + "name": "job_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "execution_mode": { + "name": "execution_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'agent'" + }, + "script_config": { + "name": "script_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'user'" + }, + "project_config_id": { + "name": "project_config_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_session_fallback": { + "name": "legacy_session_fallback", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cron'" + }, + "cron_expr": { + "name": "cron_expr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recurring": { + "name": "recurring", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "manual": { + "name": "manual", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "interval_ms": { + "name": "interval_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "effort": { + "name": "effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fast_mode": { + "name": "fast_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "working_dir": { + "name": "working_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_kind": { + "name": "workspace_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "use_worktree": { + "name": "use_worktree", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "target_session_id": { + "name": "target_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "persistent_session": { + "name": "persistent_session", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "silent_when_idle": { + "name": "silent_when_idle", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "pre_run_hook_command": { + "name": "pre_run_hook_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pre_run_hook_timeout_ms": { + "name": "pre_run_hook_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skip_log_session_id": { + "name": "skip_log_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notify_desktop": { + "name": "notify_desktop", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "notify_feishu": { + "name": "notify_feishu", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notify_wecom_group": { + "name": "notify_wecom_group", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_finished_at": { + "name": "last_finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_fire_at": { + "name": "next_fire_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expire_at": { + "name": "expire_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_schedules_active_next": { + "name": "idx_schedules_active_next", + "columns": [ + "status", + "next_fire_at" + ], + "isUnique": false + }, + "idx_schedules_target_session": { + "name": "idx_schedules_target_session", + "columns": [ + "target_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "schedules_target_session_id_sessions_id_fk": { + "name": "schedules_target_session_id_sessions_id_fk", + "tableFrom": "schedules", + "tableTo": "sessions", + "columnsFrom": [ + "target_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "schedules_skip_log_session_id_sessions_id_fk": { + "name": "schedules_skip_log_session_id_sessions_id_fk", + "tableFrom": "schedules", + "tableTo": "sessions", + "columnsFrom": [ + "skip_log_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_goals": { + "name": "session_goals", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "objective": { + "name": "objective", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "budget_tokens": { + "name": "budget_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_turns": { + "name": "max_turns", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "no_progress_limit": { + "name": "no_progress_limit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "turns_used": { + "name": "turns_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "no_progress_streak": { + "name": "no_progress_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "usage_reset_at": { + "name": "usage_reset_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_reason": { + "name": "last_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_session_goals_status": { + "name": "idx_session_goals_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_goals_session_id_sessions_id_fk": { + "name": "session_goals_session_id_sessions_id_fk", + "tableFrom": "session_goals", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session_pr_refs": { + "name": "session_pr_refs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_session_pr_refs": { + "name": "uniq_session_pr_refs", + "columns": [ + "session_id", + "owner", + "repo", + "pr_number" + ], + "isUnique": true + }, + "idx_session_pr_refs_session_last_seen": { + "name": "idx_session_pr_refs_session_last_seen", + "columns": [ + "session_id", + "last_seen_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_pr_refs_session_id_sessions_id_fk": { + "name": "session_pr_refs_session_id_sessions_id_fk", + "tableFrom": "session_pr_refs", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'New Maker'" + }, + "working_dir": { + "name": "working_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_kind": { + "name": "workspace_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'project'" + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'claude-sonnet-4-6'" + }, + "effort": { + "name": "effort", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'high'" + }, + "permission_mode": { + "name": "permission_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ask'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "sdk_session_id": { + "name": "sdk_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_token_usage": { + "name": "total_token_usage", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_cost_usd": { + "name": "total_cost_usd", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_cost_amount": { + "name": "total_cost_amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_cost_currency": { + "name": "total_cost_currency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_cost_is_approximate": { + "name": "total_cost_is_approximate", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "context_tokens": { + "name": "context_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "fast_mode": { + "name": "fast_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "plan_mode_enabled": { + "name": "plan_mode_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cleared_at": { + "name": "cleared_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_send_at": { + "name": "user_send_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'cc'" + }, + "orca_role": { + "name": "orca_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_session_id": { + "name": "parent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forked_at_message_id": { + "name": "forked_at_message_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'desktop'" + }, + "feishu_open_id": { + "name": "feishu_open_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feishu_bot_app_id": { + "name": "feishu_bot_app_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "im_bot_context_id": { + "name": "im_bot_context_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "im_user_id": { + "name": "im_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_project_context": { + "name": "used_project_context", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "codex_history_has_product_prompt": { + "name": "codex_history_has_product_prompt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "codex_plan_json": { + "name": "codex_plan_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_dirs": { + "name": "extra_dirs", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "remote_host_id": { + "name": "remote_host_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_turn_started_at": { + "name": "active_turn_started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_turn_pid": { + "name": "active_turn_pid", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_turn_ended_at": { + "name": "last_turn_ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_sessions_updated_at": { + "name": "idx_sessions_updated_at", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "idx_sessions_user_send_at": { + "name": "idx_sessions_user_send_at", + "columns": [ + "user_send_at" + ], + "isUnique": false + }, + "idx_sessions_sdk_session_id": { + "name": "idx_sessions_sdk_session_id", + "columns": [ + "sdk_session_id" + ], + "isUnique": false + }, + "idx_sessions_workdir_created": { + "name": "idx_sessions_workdir_created", + "columns": [ + "working_dir", + "created_at", + "id" + ], + "isUnique": false + }, + "idx_sessions_created_at": { + "name": "idx_sessions_created_at", + "columns": [ + "created_at", + "id" + ], + "isUnique": false + }, + "idx_sessions_workspace_kind": { + "name": "idx_sessions_workspace_kind", + "columns": [ + "workspace_kind" + ], + "isUnique": false + }, + "idx_sessions_parent_session_id": { + "name": "idx_sessions_parent_session_id", + "columns": [ + "parent_session_id" + ], + "isUnique": false + }, + "idx_sessions_orca_role": { + "name": "idx_sessions_orca_role", + "columns": [ + "orca_role" + ], + "isUnique": false + }, + "idx_sessions_worktree_path": { + "name": "idx_sessions_worktree_path", + "columns": [ + "worktree_path" + ], + "isUnique": false + }, + "idx_sessions_feishu_lookup": { + "name": "idx_sessions_feishu_lookup", + "columns": [ + "source", + "feishu_bot_app_id", + "feishu_open_id" + ], + "isUnique": false + }, + "idx_sessions_im_lookup": { + "name": "idx_sessions_im_lookup", + "columns": [ + "source", + "im_bot_context_id", + "im_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_parent_session_id_sessions_id_fk": { + "name": "sessions_parent_session_id_sessions_id_fk", + "tableFrom": "sessions", + "tableTo": "sessions", + "columnsFrom": [ + "parent_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skill_usage_exposures": { + "name": "skill_usage_exposures", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "analyzer_version": { + "name": "analyzer_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'6'" + }, + "raw_file_path": { + "name": "raw_file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "raw_line_no": { + "name": "raw_line_no", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sdk_session_id": { + "name": "sdk_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_name": { + "name": "skill_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skill_path": { + "name": "skill_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "skill_document_hash": { + "name": "skill_document_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exposure_content_hash": { + "name": "exposure_content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_hash_source": { + "name": "document_hash_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_use_id": { + "name": "tool_use_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seen_at": { + "name": "seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tool_call_count": { + "name": "tool_call_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "repeated_tool_call_count": { + "name": "repeated_tool_call_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "tool_error_count": { + "name": "tool_error_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "command_call_count": { + "name": "command_call_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "command_failure_count": { + "name": "command_failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_skill_usage_exposures_skill_document_version": { + "name": "idx_skill_usage_exposures_skill_document_version", + "columns": [ + "analyzer_version", + "skill_name", + "skill_document_hash" + ], + "isUnique": false + }, + "idx_skill_usage_exposures_session": { + "name": "idx_skill_usage_exposures_session", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "idx_skill_usage_exposures_raw_file": { + "name": "idx_skill_usage_exposures_raw_file", + "columns": [ + "raw_file_path" + ], + "isUnique": false + } + }, + "foreignKeys": { + "skill_usage_exposures_raw_file_path_skill_usage_sources_raw_file_path_fk": { + "name": "skill_usage_exposures_raw_file_path_skill_usage_sources_raw_file_path_fk", + "tableFrom": "skill_usage_exposures", + "tableTo": "skill_usage_sources", + "columnsFrom": [ + "raw_file_path" + ], + "columnsTo": [ + "raw_file_path" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "skill_usage_sources": { + "name": "skill_usage_sources", + "columns": { + "raw_file_path": { + "name": "raw_file_path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "analyzer_version": { + "name": "analyzer_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'6'" + }, + "agent_kind": { + "name": "agent_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sdk_session_id": { + "name": "sdk_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mtime_ms": { + "name": "mtime_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_scanned_at": { + "name": "last_scanned_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ok'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_skill_usage_sources_session": { + "name": "idx_skill_usage_sources_session", + "columns": [ + "session_id" + ], + "isUnique": false + }, + "idx_skill_usage_sources_sdk_session": { + "name": "idx_skill_usage_sources_sdk_session", + "columns": [ + "sdk_session_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subagent_run_aliases": { + "name": "subagent_run_aliases", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "alias": { + "name": "alias", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "subagent_run_aliases_lookup_idx": { + "name": "subagent_run_aliases_lookup_idx", + "columns": [ + "session_id", + "provider", + "alias", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "subagent_run_aliases_session_id_sessions_id_fk": { + "name": "subagent_run_aliases_session_id_sessions_id_fk", + "tableFrom": "subagent_run_aliases", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "subagent_run_aliases_run_id_subagent_runs_id_fk": { + "name": "subagent_run_aliases_run_id_subagent_runs_id_fk", + "tableFrom": "subagent_run_aliases", + "tableTo": "subagent_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "subagent_run_aliases_run_id_alias_pk": { + "columns": [ + "run_id", + "alias" + ], + "name": "subagent_run_aliases_run_id_alias_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subagent_runs": { + "name": "subagent_runs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logical_agent_id": { + "name": "logical_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_tool_use_id": { + "name": "parent_tool_use_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "provider_run_ids": { + "name": "provider_run_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'running'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_uses": { + "name": "tool_uses", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "capabilities": { + "name": "capabilities", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "activity": { + "name": "activity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rewind_at": { + "name": "rewind_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "subagent_runs_logical_idx": { + "name": "subagent_runs_logical_idx", + "columns": [ + "session_id", + "provider", + "logical_agent_id" + ], + "isUnique": false + }, + "subagent_runs_session_idx": { + "name": "subagent_runs_session_idx", + "columns": [ + "session_id", + "rewind_at", + "deleted_at", + "started_at" + ], + "isUnique": false + }, + "subagent_runs_parent_tool_use_idx": { + "name": "subagent_runs_parent_tool_use_idx", + "columns": [ + "session_id", + "parent_tool_use_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "subagent_runs_session_id_sessions_id_fk": { + "name": "subagent_runs_session_id_sessions_id_fk", + "tableFrom": "subagent_runs", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "vec_table_meta": { + "name": "vec_table_meta", + "columns": { + "vec_table": { + "name": "vec_table", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dim": { + "name": "dim", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "registered_at": { + "name": "registered_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wechat_file_attachments": { + "name": "wechat_file_attachments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "binding_epoch": { + "name": "binding_epoch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abs_path": { + "name": "abs_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'staged'" + }, + "promoted_at": { + "name": "promoted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_wechat_file_attachments_task": { + "name": "idx_wechat_file_attachments_task", + "columns": [ + "binding_epoch", + "task_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "wechat_file_attachments_binding_epoch_wechat_sync_state_binding_epoch_fk": { + "name": "wechat_file_attachments_binding_epoch_wechat_sync_state_binding_epoch_fk", + "tableFrom": "wechat_file_attachments", + "tableTo": "wechat_sync_state", + "columnsFrom": [ + "binding_epoch" + ], + "columnsTo": [ + "binding_epoch" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wechat_file_attachments_task_id_wechat_inbox_id_fk": { + "name": "wechat_file_attachments_task_id_wechat_inbox_id_fk", + "tableFrom": "wechat_file_attachments", + "tableTo": "wechat_inbox", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wechat_file_attachments_session_id_sessions_id_fk": { + "name": "wechat_file_attachments_session_id_sessions_id_fk", + "tableFrom": "wechat_file_attachments", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wechat_inbox": { + "name": "wechat_inbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "binding_epoch": { + "name": "binding_epoch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform_message_id": { + "name": "platform_message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform_seq": { + "name": "platform_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "peer_id": { + "name": "peer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "platform_created_at": { + "name": "platform_created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "conversation_epoch": { + "name": "conversation_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_nonce": { + "name": "context_nonce", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_ciphertext": { + "name": "context_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_tag": { + "name": "context_tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_wechat_inbox_platform_message": { + "name": "uniq_wechat_inbox_platform_message", + "columns": [ + "binding_epoch", + "platform_message_id" + ], + "isUnique": true + }, + "idx_wechat_inbox_queue": { + "name": "idx_wechat_inbox_queue", + "columns": [ + "binding_epoch", + "status", + "received_at" + ], + "isUnique": false + }, + "idx_wechat_inbox_lease": { + "name": "idx_wechat_inbox_lease", + "columns": [ + "binding_epoch", + "lease_until" + ], + "isUnique": false + }, + "idx_wechat_inbox_conversation": { + "name": "idx_wechat_inbox_conversation", + "columns": [ + "binding_epoch", + "peer_id", + "conversation_epoch" + ], + "isUnique": false + }, + "uniq_wechat_inbox_running_session": { + "name": "uniq_wechat_inbox_running_session", + "columns": [ + "binding_epoch", + "session_id" + ], + "isUnique": true, + "where": "\"wechat_inbox\".\"session_id\" IS NOT NULL AND \"wechat_inbox\".\"status\" IN ('dispatching', 'accepted_running', 'waiting_desktop', 'delivery_pending')" + } + }, + "foreignKeys": { + "wechat_inbox_binding_epoch_wechat_sync_state_binding_epoch_fk": { + "name": "wechat_inbox_binding_epoch_wechat_sync_state_binding_epoch_fk", + "tableFrom": "wechat_inbox", + "tableTo": "wechat_sync_state", + "columnsFrom": [ + "binding_epoch" + ], + "columnsTo": [ + "binding_epoch" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wechat_inbox_session_id_sessions_id_fk": { + "name": "wechat_inbox_session_id_sessions_id_fk", + "tableFrom": "wechat_inbox", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wechat_outbox": { + "name": "wechat_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "binding_epoch": { + "name": "binding_epoch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_json": { + "name": "media_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_wechat_outbox_client_id": { + "name": "uniq_wechat_outbox_client_id", + "columns": [ + "binding_epoch", + "client_id" + ], + "isUnique": true + }, + "idx_wechat_outbox_delivery": { + "name": "idx_wechat_outbox_delivery", + "columns": [ + "binding_epoch", + "status", + "next_retry_at" + ], + "isUnique": false + }, + "idx_wechat_outbox_task": { + "name": "idx_wechat_outbox_task", + "columns": [ + "binding_epoch", + "task_id", + "chunk_index" + ], + "isUnique": false + } + }, + "foreignKeys": { + "wechat_outbox_binding_epoch_wechat_sync_state_binding_epoch_fk": { + "name": "wechat_outbox_binding_epoch_wechat_sync_state_binding_epoch_fk", + "tableFrom": "wechat_outbox", + "tableTo": "wechat_sync_state", + "columnsFrom": [ + "binding_epoch" + ], + "columnsTo": [ + "binding_epoch" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wechat_outbox_task_id_wechat_inbox_id_fk": { + "name": "wechat_outbox_task_id_wechat_inbox_id_fk", + "tableFrom": "wechat_outbox", + "tableTo": "wechat_inbox", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wechat_sync_state": { + "name": "wechat_sync_state", + "columns": { + "binding_epoch": { + "name": "binding_epoch", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sync_cursor": { + "name": "sync_cursor", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "last_poll_at": { + "name": "last_poll_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_wechat_sync_active": { + "name": "uniq_wechat_sync_active", + "columns": [ + "is_active" + ], + "isUnique": true, + "where": "\"wechat_sync_state\".\"is_active\" = 1" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "uniq_orca_worker_creation_reservations_team_label": { + "columns": { + "lower(\"label\")": { + "isExpression": true + } + } + }, + "uniq_orca_workers_team_label": { + "columns": { + "lower(\"label\")": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/apps/desktop/drizzle/meta/_journal.json b/apps/desktop/drizzle/meta/_journal.json index 8d3be3074f..4366de2f1f 100644 --- a/apps/desktop/drizzle/meta/_journal.json +++ b/apps/desktop/drizzle/meta/_journal.json @@ -638,6 +638,20 @@ "when": 1786726026223, "tag": "0090_distinguish_xai_provider_provenance", "breakpoints": true + }, + { + "idx": 91, + "version": "6", + "when": 1786794359638, + "tag": "0091_amazing_blur", + "breakpoints": true + }, + { + "idx": 92, + "version": "6", + "when": 1786875191178, + "tag": "0092_motionless_hiroim", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/desktop/drizzle/scripts/0091_amazing_blur.ts b/apps/desktop/drizzle/scripts/0091_amazing_blur.ts new file mode 100644 index 0000000000..8356514577 --- /dev/null +++ b/apps/desktop/drizzle/scripts/0091_amazing_blur.ts @@ -0,0 +1,13 @@ +import type Database from 'better-sqlite3'; + +function run(db: Database.Database): void { + const columns = db + .prepare(`PRAGMA table_info('sessions')`) + .all() + .map((row) => String((row as { name: unknown }).name)); + if (!columns.includes('codex_plan_json')) { + db.exec('ALTER TABLE sessions ADD COLUMN codex_plan_json text'); + } +} + +module.exports = { run }; diff --git a/apps/desktop/drizzle/scripts/0092_motionless_hiroim.ts b/apps/desktop/drizzle/scripts/0092_motionless_hiroim.ts new file mode 100644 index 0000000000..ab4d1363d0 --- /dev/null +++ b/apps/desktop/drizzle/scripts/0092_motionless_hiroim.ts @@ -0,0 +1,15 @@ +import type Database from 'better-sqlite3'; + +function run(db: Database.Database): void { + const rightSidebarTabs = db + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'right_sidebar_tabs'") + .get(); + if (!rightSidebarTabs) return; + db.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS right_sidebar_tabs_bot_delegations_singleton_idx + ON right_sidebar_tabs (session_id) + WHERE kind = 'bot-delegations' + `); +} + +module.exports = { run }; diff --git a/apps/desktop/src/main/__tests__/deviceLinkAutoTitleWiring.test.ts b/apps/desktop/src/main/__tests__/deviceLinkAutoTitleWiring.test.ts index 3c833b7dbb..a83a45a45b 100644 --- a/apps/desktop/src/main/__tests__/deviceLinkAutoTitleWiring.test.ts +++ b/apps/desktop/src/main/__tests__/deviceLinkAutoTitleWiring.test.ts @@ -203,7 +203,7 @@ describe('user rename notification ordering', () => { // local-db:sessions:update(本机重命名框) [ "if (typeof p.title === 'string') noteUserTitleWritten(sid);", - 'await withStatusWriteLock(sid, p.status, () => writeSessionPatch(db, sid, setObj, p.status));', + 'await withStatusWriteLock(sid, p.status, async () => {', ], // patchSessionMetaInDb(device-link 远程改名) [ diff --git a/apps/desktop/src/main/__tests__/messagePersistBroadcaster.test.ts b/apps/desktop/src/main/__tests__/messagePersistBroadcaster.test.ts index a83788e869..b73ff88ff1 100644 --- a/apps/desktop/src/main/__tests__/messagePersistBroadcaster.test.ts +++ b/apps/desktop/src/main/__tests__/messagePersistBroadcaster.test.ts @@ -29,6 +29,13 @@ vi.mock('../logger.js', () => ({ createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), })); +vi.mock('../localDb/codexPlanState.js', async (importOriginal) => ({ + ...(await importOriginal()), + markCodexPlanInterrupted: vi.fn(async () => undefined), + writeCodexPlanTerminal: vi.fn(async () => undefined), + writeCodexPlanUpdate: vi.fn(async () => undefined), +})); + const { mockSend } = vi.hoisted(() => ({ mockSend: vi.fn() })); const ownerScopeState = vi.hoisted(() => ({ current: true, @@ -51,6 +58,11 @@ import { patchMessageAgentMetaWithResult, updateMessageContent, } from '../localDb/ipc/messages.js'; +import { + markCodexPlanInterrupted, + writeCodexPlanTerminal, + writeCodexPlanUpdate, +} from '../localDb/codexPlanState.js'; import { recordMediaToolResult, __resetMediaToolResultPoolForTesting, @@ -112,6 +124,121 @@ beforeEach(() => { }); describe('update_plan tool_use persistence', () => { + it('persists native Codex plan state without relying on the session agent cache', async () => { + noteSessionAgentKind(SESSION, 'codex'); + onToolUseEvent( + SESSION, + { + toolUseId: 'plan:turn-state', + toolName: 'update_plan', + input: { plan: [{ step: 'Persist state', status: 'in_progress' }] }, + }, + null, + ); + await flushWrites(); + + expect(writeCodexPlanUpdate).toHaveBeenCalledWith(SESSION, { + turnId: 'turn-state', + plan: [{ step: 'Persist state', status: 'in_progress' }], + }); + }); + + it('does not restore a queued plan update after /clear advances the session boundary', async () => { + noteSessionAgentKind(SESSION, 'codex'); + onToolUseEvent( + SESSION, + { + toolUseId: 'plan:turn-before-clear', + toolName: 'update_plan', + input: { plan: [{ step: 'Old plan', status: 'in_progress' }] }, + }, + null, + ); + noteSessionClearBoundary(SESSION, Date.now()); + + await flushWrites(); + + expect(writeCodexPlanUpdate).not.toHaveBeenCalled(); + }); + + it('rejects a late old-turn plan update that arrives after /clear', async () => { + const nowSpy = vi.spyOn(Date, 'now'); + try { + nowSpy.mockReturnValue(1_700_000_000_000); + noteTurnStarted(SESSION); + noteSessionAgentKind(SESSION, 'codex'); + + nowSpy.mockReturnValue(1_700_000_001_000); + noteSessionClearBoundary(SESSION, Date.now()); + onToolUseEvent( + SESSION, + { + toolUseId: 'plan:late-old-turn', + toolName: 'update_plan', + input: { plan: [{ step: 'Late old plan', status: 'in_progress' }] }, + }, + null, + ); + + await flushWrites(); + + expect(writeCodexPlanUpdate).not.toHaveBeenCalled(); + } finally { + nowSpy.mockRestore(); + } + }); + + it('rejects an old-turn plan update without a token after a new turn has started', async () => { + const nowSpy = vi.spyOn(Date, 'now'); + try { + nowSpy.mockReturnValue(1_700_000_000_000); + noteTurnStarted(SESSION, 1); + noteSessionAgentKind(SESSION, 'codex'); + + nowSpy.mockReturnValue(1_700_000_001_000); + noteSessionClearBoundary(SESSION, Date.now()); + resetTurnPersistState(SESSION); + + nowSpy.mockReturnValue(1_700_000_002_000); + noteTurnStarted(SESSION, 2); + onToolUseEvent( + SESSION, + { + toolUseId: 'plan:late-replaced-turn', + toolName: 'update_plan', + input: { plan: [{ step: 'Late replaced plan', status: 'in_progress' }] }, + }, + null, + 'turn', + ); + + await flushWrites(); + + expect(writeCodexPlanUpdate).not.toHaveBeenCalled(); + + onToolUseEvent( + SESSION, + { + toolUseId: 'plan:current-replacement-turn', + toolName: 'update_plan', + input: { plan: [{ step: 'Current plan', status: 'in_progress' }] }, + }, + null, + 'turn', + undefined, + 2, + ); + await flushWrites(); + + expect(writeCodexPlanUpdate).toHaveBeenCalledWith(SESSION, { + turnId: 'current-replacement-turn', + plan: [{ step: 'Current plan', status: 'in_progress' }], + }); + } finally { + nowSpy.mockRestore(); + } + }); + it('updates the existing tool_use row when Codex repeats update_plan with the same toolUseId', async () => { const firstPersistId = onToolUseEvent( SESSION, @@ -190,7 +317,7 @@ describe('update_plan tool_use persistence', () => { ); }); - it('seals a successful turn plan as-is so reload cannot resurrect it', async () => { + it('keeps a successful turn with open plan steps available for reconciliation', async () => { const persistId = onToolUseEvent( SESSION, { @@ -216,6 +343,14 @@ describe('update_plan tool_use persistence', () => { })).toBe(true); await flushWrites(); + expect(writeCodexPlanTerminal).toHaveBeenCalledWith(SESSION, { + turnId: 'turn-1', + plan: [ + { step: 'Inspect', status: 'completed' }, + { step: 'Start dev', status: 'in_progress' }, + ], + state: 'interrupted', + }); expect(updateMessageContent).toHaveBeenCalledWith( SESSION, persistId, @@ -329,6 +464,9 @@ describe('update_plan tool_use persistence', () => { expect(persistCodexPlanOnTerminalError(SESSION, 'turn-current')).toBe(true); await flushWrites(); + expect(markCodexPlanInterrupted).toHaveBeenCalledTimes(1); + expect(markCodexPlanInterrupted).toHaveBeenCalledWith(SESSION, 'turn-current'); + expect(updateMessageContent).toHaveBeenCalledWith( SESSION, currentPersistId, @@ -342,6 +480,13 @@ describe('update_plan tool_use persistence', () => { ); }); + it('does not mutate durable plan state when an id-less error has no owned plan turn', async () => { + expect(persistCodexPlanOnTerminalError(SESSION)).toBe(false); + await flushWrites(); + + expect(markCodexPlanInterrupted).not.toHaveBeenCalled(); + }); + it('carries repeated update_plan snapshots into the terminal write', async () => { // 同一 turn 的第二次 update_plan 走 persistId 复用分支。按-turn 缓存若只在 // 首次记录,终态写入会拿首版快照整行覆盖,已勾完的进度在重载/远端同步后 @@ -431,6 +576,11 @@ describe('update_plan tool_use persistence', () => { })).toBe(true); await flushWrites(); + expect(writeCodexPlanTerminal).toHaveBeenCalledWith(SESSION, { + turnId: 'turn-complete', + plan: [{ step: 'Ship', status: 'completed' }], + state: 'sealed', + }); expect(updateMessageContent).toHaveBeenCalledWith( SESSION, persistId, diff --git a/apps/desktop/src/main/__tests__/worktreeSessionRemovalRecycle.test.ts b/apps/desktop/src/main/__tests__/worktreeSessionRemovalRecycle.test.ts index 2037970b51..2bf88794c3 100644 --- a/apps/desktop/src/main/__tests__/worktreeSessionRemovalRecycle.test.ts +++ b/apps/desktop/src/main/__tests__/worktreeSessionRemovalRecycle.test.ts @@ -13,6 +13,7 @@ import type { WorktreeMeta } from '../worktree/types'; interface SessionRow { id: string; status: string | null | undefined; + source: string; workingDir: string | null; worktreePath: string | null; } @@ -69,10 +70,12 @@ function addSession( id: string, status: string | null | undefined, paths: Partial> = {}, + source = 'desktop', ): SessionRow { const row: SessionRow = { id, status, + source, workingDir: paths.workingDir ?? null, worktreePath: paths.worktreePath ?? null, }; @@ -81,10 +84,11 @@ function addSession( } function dbFor(rows: Array<{ id: string; status: string | null }>) { + const normalized = rows.map((row) => ({ ...row, source: 'desktop' })); return { select: () => ({ from: () => ({ - where: () => rows, + where: () => normalized, }), }), }; @@ -152,6 +156,21 @@ describe('sessionRemovalRecycle', () => { expect(removeMock).not.toHaveBeenCalled(); }); + it('never recycles or owner-scans a Bot-managed Session', async () => { + const botMeta = makeMeta('bot'); + const ownerMeta = makeMeta('owner'); + storeMap.set('bot', botMeta); + storeMap.set('owner', ownerMeta); + addSession('bot', 'archived', { workingDir: ownerMeta.path }, 'bot'); + addSession('owner', 'archived', { worktreePath: ownerMeta.path }); + const recycleOwner = vi.fn(); + + await mod.recycleWorktreeForRemovedSession('bot', { recycleOwner }); + + expect(removeMock).not.toHaveBeenCalled(); + expect(recycleOwner).not.toHaveBeenCalled(); + }); + it('status lookup failure → preserves worktree', async () => { storeMap.set('s1', makeMeta('s1')); sessionLookupError = new Error('db closed'); @@ -359,7 +378,13 @@ describe('sessionRemovalRecycle', () => { it('uses the captured owner database and stops its live guard after owner switch', async () => { storeMap.set('shared-session-id', makeMeta('shared-session-id')); - sessionRows.push({ id: 'shared-session-id', status: 'active', workingDir: null, worktreePath: null }); + sessionRows.push({ + id: 'shared-session-id', + status: 'active', + source: 'desktop', + workingDir: null, + worktreePath: null, + }); const capturedRows = [{ id: 'shared-session-id', status: 'deleted' as string | null }]; let ownerCurrent = true; removeMock.mockImplementationOnce( @@ -393,8 +418,20 @@ describe('sessionRemovalRecycle', () => { await expect(mod.isSessionStillRemovable('s1')).resolves.toBe(false); }); + it('treats archived Bot Sessions as lease-owned and not removable', async () => { + addSession('bot', 'archived', {}, 'bot'); + + await expect(mod.isSessionStillRemovable('bot')).resolves.toBe(false); + }); + it('uses an explicitly captured database instead of the current global owner', async () => { - sessionRows.push({ id: 'shared-session-id', status: 'active', workingDir: null, worktreePath: null }); + sessionRows.push({ + id: 'shared-session-id', + status: 'active', + source: 'desktop', + workingDir: null, + worktreePath: null, + }); const capturedDb = dbFor([{ id: 'shared-session-id', status: 'archived' }]); await expect( @@ -409,10 +446,12 @@ describe('sessionRemovalRecycle', () => { storeMap.set('archived', makeMeta('archived')); storeMap.set('deleted', makeMeta('deleted')); storeMap.set('missing', makeMeta('missing')); + storeMap.set('bot-deleted', makeMeta('bot-deleted')); storeMap.set('eph', makeMeta('eph', true)); addSession('active', 'active'); addSession('archived', 'archived'); addSession('deleted', 'deleted'); + addSession('bot-deleted', 'deleted', {}, 'bot'); // 'missing' 无行 → 视为孤儿; 'eph' 是 ephemeral 不进候选 await mod.reconcileWorktreesForDeletedSessions(); diff --git a/apps/desktop/src/main/agent-island/__tests__/state.test.ts b/apps/desktop/src/main/agent-island/__tests__/state.test.ts index c6cb374008..758c1010da 100644 --- a/apps/desktop/src/main/agent-island/__tests__/state.test.ts +++ b/apps/desktop/src/main/agent-island/__tests__/state.test.ts @@ -244,6 +244,17 @@ describe('Agent Island display state', () => { expect(afterRunningUpdate.sessions.map((session) => session.sessionId)).toEqual(['done']); }); + it('keeps unread errors in the same waiting tier as needs-interaction, ahead of unread completions', () => { + const state = createAgentIslandState(); + applyAgentIslandEvent(state, { sessionId: 'done', title: 'Done' }, doneEvent(), 1_000); + applyAgentIslandEvent(state, { sessionId: 'err', title: 'Err' }, terminalErrorEvent('boom'), 1_100); + applyAgentIslandEvent(state, { sessionId: 'ask', title: 'Ask' }, statusEvent(true, 'Running'), 1_200); + applyAgentIslandInteractionRequest(state, { sessionId: 'ask', title: 'Ask' }, permissionRequest('r1'), 1_300); + + const display = buildAgentIslandDisplayState(state, 1_400); + expect(display.sessions.map((session) => session.sessionId)).toEqual(['ask', 'err', 'done']); + }); + it('builds a CodeIsland-style recent activity preview per session', () => { const state = createAgentIslandState(); @@ -827,10 +838,10 @@ describe('Agent Island display state', () => { ]); }); - it('prioritizes interaction, terminal error, completion, then running sessions', () => { + it('prioritizes waiting (interaction and error), then unread completion, then running', () => { const state = createAgentIslandState(); - // Deliberately create the lower-priority states later: phase priority must - // win before the sidebar-style prompt/fallback time is considered. + // Interaction and error share the waiting tier; later activity wins inside + // that tier. Completion and running stay below waiting even if newer. applyAgentIslandInteractionRequest(state, { sessionId: 'ask', title: 'Ask' }, permissionRequest('r1'), 1_000); applyAgentIslandEvent(state, { sessionId: 'error', title: 'Error' }, terminalErrorEvent('failed'), 1_100); applyAgentIslandEvent(state, { sessionId: 'completed', title: 'Completed' }, doneEvent(), 1_200); @@ -838,6 +849,8 @@ describe('Agent Island display state', () => { const display = buildAgentIslandDisplayState(state, 1_400); + // Waiting 同档按活动时间:更新的 error 排在更早的 ask 前。当前面仍给挡路交互 + // (权限 / 提问),那是岛上的展示面,不是第三套优先级。 expect(display.currentSessionId).toBe('ask'); expect(display.pillSnapshot).toMatchObject({ priorityId: 'ask', @@ -849,8 +862,8 @@ describe('Agent Island display state', () => { }); expect(display.shadowVisible).toBe(true); expect(display.sessions.map((session) => session.sessionId)).toEqual([ - 'ask', 'error', + 'ask', 'completed', 'running', ]); diff --git a/apps/desktop/src/main/agent-island/state.ts b/apps/desktop/src/main/agent-island/state.ts index c558f97d14..a52e44762b 100644 --- a/apps/desktop/src/main/agent-island/state.ts +++ b/apps/desktop/src/main/agent-island/state.ts @@ -6,6 +6,7 @@ import { import { DEFAULT_TOOL_ROW_WORDING, type ToolRowWording } from '@cindy/maker-shared/message-presentation'; import { isTurnContinuationBoundaryEvent } from '@cindy/maker-shared/turn-continuation'; +import { LIVE_TASK_PRIORITY, liveTaskPriorityRank } from '../../shared/liveTaskPriority'; import { stripTrailingPathSeparators } from '../../shared/pathText'; import { formatIslandToolDetail } from './toolDetail.js'; @@ -1588,7 +1589,8 @@ function orderSessionsForCurrentSurface( const rank = new Map(state.expandedSessionOrder.map((sessionId, index) => [sessionId, index])); const ordered = sortedSessions.slice().sort((a, b) => { - const priorityDelta = priorityRank(state, b, now) - priorityRank(state, a, now); + const priorityDelta = + islandDisplayPriorityRank(state, a, now) - islandDisplayPriorityRank(state, b, now); if (priorityDelta !== 0) return priorityDelta; const aRank = rank.get(a.sessionId); const bRank = rank.get(b.sessionId); @@ -2184,22 +2186,55 @@ function compareSessionsForDisplay( b: AgentIslandSessionState, now: number, ): number { - const rankDelta = priorityRank(state, b, now) - priorityRank(state, a, now); + const rankDelta = islandDisplayPriorityRank(state, a, now) - islandDisplayPriorityRank(state, b, now); if (rankDelta !== 0) return rankDelta; const activityDelta = b.sortActivityAt - a.sortActivityAt; if (activityDelta !== 0) return activityDelta; return b.startedAt - a.startedAt; } -function priorityRank(state: AgentIslandState, session: AgentIslandSessionState, now: number): number { - if (session.pendingInteractionIds.size > 0 || session.phase === 'needs-interaction') return 500; - if (session.phase === 'error' && (session.unread || (session.errorUntil && session.errorUntil > now))) return 400; - if (state.activeTransientSessionId === session.sessionId && session.revealUntil && session.revealUntil > now) { - return 350; +function isWaitingSession(session: AgentIslandSessionState, now: number): boolean { + return ( + session.pendingInteractionIds.size > 0 + || session.phase === 'needs-interaction' + || (session.phase === 'error' && (session.unread || (session.errorUntil !== null && session.errorUntil > now))) + ); +} + +function isUnreadCompletedSession(session: AgentIslandSessionState, now: number): boolean { + return session.phase === 'completed' && (session.unread || (session.completedUntil !== null && session.completedUntil > now)); +} + +function isTransientlyPinnedSession( + state: AgentIslandState, + session: AgentIslandSessionState, + now: number, +): boolean { + return ( + state.activeTransientSessionId === session.sessionId + && session.revealUntil !== null + && session.revealUntil > now + ); +} + +/** + * 岛上展示序 = 与侧栏共用的活任务档位,再叠一层「刚完成短暂置顶」。 + * 数字越小越靠前。短暂置顶只压过完成未读 / 运行中,压不过等你处理(含出错)。 + */ +function islandDisplayPriorityRank( + state: AgentIslandState, + session: AgentIslandSessionState, + now: number, +): number { + const live = liveTaskPriorityRank({ + waiting: isWaitingSession(session, now), + unread: isUnreadCompletedSession(session, now), + running: session.running, + }); + if (isTransientlyPinnedSession(state, session, now) && live > LIVE_TASK_PRIORITY.waiting) { + return LIVE_TASK_PRIORITY.waiting + 0.5; } - if (session.phase === 'completed' && (session.unread || (session.completedUntil && session.completedUntil > now))) return 300; - if (session.running) return 100; - return 0; + return live; } function detailForInteraction( diff --git a/apps/desktop/src/main/bootstrap-electron.ts b/apps/desktop/src/main/bootstrap-electron.ts index b9cca600ef..1363678e4f 100644 --- a/apps/desktop/src/main/bootstrap-electron.ts +++ b/apps/desktop/src/main/bootstrap-electron.ts @@ -394,6 +394,7 @@ import { WorktreePool, reconcileWorktreesForDeletedSessions, } from './worktree'; +import { reconcileBotWorkspaceLeases } from './maker-ipc/botWorkspaceRuntime'; // shadow savepoint 链的启动期对账(孤儿 refs/cindy/savepoints/* 清理) import { reconcileSavepointRefsForDeletedSessions } from './git-snapshot/savepointCleanup'; // session-git-pr-context: 会话分支感知 + PR 关联状态 IPC @@ -513,7 +514,9 @@ import { clearDeferredCodexRestartForOwnerBoundary, collectAgentInputQueueScanTexts, createAutomationUserTurnGitBaselineHooks, + enqueueBotDelivery, registerMakerIpc as registerMakerCoreIpc, + retryBotDelivery, isSessionTurnPendingCompletion, stopOrcaIdleWatcher, setGoalClearObserver, @@ -523,6 +526,7 @@ import { setGoalAskAnswerObserver, withSendToSessionLock, } from './maker-ipc/register.js'; +import { deliverBotRouteMessage } from './im/index.js'; import { cleanupActiveReviewArtifactSnapshots } from './reviewer/reviewArtifactSnapshot.js'; import { MAKER_INVOKE as MAKER_IPC_INVOKE, MAKER_PUSH, MAKER_SEND } from './maker-ipc/channels.js'; import { @@ -806,6 +810,7 @@ import { attachSchedulerEventListeners, resetSchedulerReady, } from './maker-ipc/schedule.js'; +import { registerBotAutomationHandlers } from './maker-ipc/bot-automation.js'; import { registerProjectAutomationIpc } from './maker-ipc/project-automation.js'; import { startGoalController, getGoalController } from './goal-host/index.js'; import { startLearnHost, getLearnController, resetLearnController } from './learn-host/index.js'; @@ -4656,6 +4661,7 @@ const registerIpcHandlers = () => { refreshXdGatewayModels, waitForAccountProviderModelsReady: waitForCurrentAccountProviderModelsReady, onProviderModelAutoRefreshConfigured: markMakerProviderRefreshConfigured, + deliverBotRouteMessage, }); registerMakerTitleIpc({ isSessionTurnPendingCompletion }); registerMakerHelpIpc(ipcMaker); @@ -4729,6 +4735,10 @@ const registerIpcHandlers = () => { return null; } }); + registerBotAutomationHandlers({ + enqueueDelivery: enqueueBotDelivery, + retryDelivery: retryBotDelivery, + }); // maker:goal:* handler 同样提前一次性注册(eager);handler 内部 getGoalController() // 取单例,invoke 时 controller 已由 attemptStartScheduler → startGoalController 启动。 registerGoalHandlers(); @@ -4775,6 +4785,9 @@ const registerIpcHandlers = () => { void reconcileWorktreesForDeletedSessions().catch((err) => { console.error('[bootstrap-electron] worktree reconcile failed (non-fatal):', err); }); + void reconcileBotWorkspaceLeases().catch((err) => { + console.error('[bootstrap-electron] Bot workspace reconcile failed (non-fatal):', err); + }); // 同窗口的 shadow savepoint 对账:owning session 已删除的孤儿保存点链 // (refs/cindy/savepoints/)启动期补删。fire-and-forget,不阻塞启动。 void reconcileSavepointRefsForDeletedSessions().catch((err) => { diff --git a/apps/desktop/src/main/cindy-brain/__tests__/cindyMediaCatalog.test.ts b/apps/desktop/src/main/cindy-brain/__tests__/cindyMediaCatalog.test.ts index 58c56febee..14387dc62b 100644 --- a/apps/desktop/src/main/cindy-brain/__tests__/cindyMediaCatalog.test.ts +++ b/apps/desktop/src/main/cindy-brain/__tests__/cindyMediaCatalog.test.ts @@ -106,6 +106,38 @@ describe('deriveCindyMediaConfig — 正常目录', () => { best: 'v1', }); }); + + it('xAI 动态发现的两个视频模型原样进入候选并保留 provider 归属', () => { + const xai: CindyMediaProviderSlice = { + id: 'xai', + videoModels: [ + { id: 'xai/grok-imagine-video', name: 'Grok Imagine Video' }, + { id: 'xai/grok-imagine-video-1.5', name: 'Grok Imagine Video 1.5' }, + ], + }; + + expect(deriveCindyMediaConfig([xai], 'video')).toEqual({ + models: [ + { + id: 'xai/grok-imagine-video', + label: 'Grok Imagine Video', + providerId: 'xai', + supportsEdit: true, + }, + { + id: 'xai/grok-imagine-video-1.5', + label: 'Grok Imagine Video 1.5', + providerId: 'xai', + supportsEdit: true, + }, + ], + defaults: { + standard: 'xai/grok-imagine-video', + draft: 'xai/grok-imagine-video', + best: 'xai/grok-imagine-video', + }, + }); + }); }); describe('deriveCindyMediaConfig — 空清单即不可用', () => { diff --git a/apps/desktop/src/main/cindy-brain/cindyMediaCatalog.ts b/apps/desktop/src/main/cindy-brain/cindyMediaCatalog.ts index e5428bb7b1..c6e8e37fb4 100644 --- a/apps/desktop/src/main/cindy-brain/cindyMediaCatalog.ts +++ b/apps/desktop/src/main/cindy-brain/cindyMediaCatalog.ts @@ -96,8 +96,8 @@ export function deriveCindyMediaConfig( // XD 声明的向量清单 —— 非 XD 供应商(远端目录可以给任何 provider 加这个字段) // 声明了也不能进白名单,否则会长出"界面可选、实际拿 XD 的凭证去计费"的型号, // 用户以为用的是自己填的 key(PR #1707 review)。 - // 视频出于同一原因在非 Global 构建里整段隐藏;向量这条更严:任何区域都只认 XD, - // 因为它连"本区域能不能路由"都还谈不上。provider-aware 路由落地后再放开。 + // 向量在任何区域都只认 XD,因为它连 provider-aware 路由都还没有。 + // 地区政策由 Gateway 通过目录下发负责,客户端不按构建区域裁剪模型。 if (kind === 'embed' && p.id !== EMBED_DISPATCH_PROVIDER_ID) continue; const list = kind === 'image' ? p.imageModels : kind === 'video' ? p.videoModels : p.embeddingModels; diff --git a/apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts b/apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts index 2dcefd9dc6..1ff18a8c45 100644 --- a/apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts +++ b/apps/desktop/src/main/cindy-brain/ghostInstallReceipt.ts @@ -200,8 +200,12 @@ export class GhostInstallReceiptStore { const receiptPath = this.receiptPath(id); let bytes: Buffer | null; try { + // readBoundedFileNoFollowSync 的 containWithin 契约要求传入 realpath。 + // macOS 的 os.tmpdir()/用户目录可能经过 /var -> /private/var 等系统链接; + // 直接传 path.resolve 结果会把根内普通 receipt 误判成越界。 + const realRoot = fs.realpathSync(this.rootDir()); bytes = readBoundedFileNoFollowSync(receiptPath, MAX_RECEIPT_BYTES, { - containWithin: this.rootDir(), + containWithin: realRoot, }); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { diff --git a/apps/desktop/src/main/cindy-brain/index.ts b/apps/desktop/src/main/cindy-brain/index.ts index ceb6e711f3..2c78f13f5b 100644 --- a/apps/desktop/src/main/cindy-brain/index.ts +++ b/apps/desktop/src/main/cindy-brain/index.ts @@ -305,7 +305,6 @@ import { storeGhostSecret, } from '../secrets/providerSecretStore.js'; import { getActiveCatalog } from '../maker-host/active-catalog.js'; -import { projectProviderCatalogForBuildRegion } from '../maker-host/provider-access-policy.js'; import { getGrokAccessToken, getGrokOAuthCredentialGeneration, @@ -3099,7 +3098,7 @@ function getCatalogMediaConfig(kind: CindyCapabilityKind): CindyMediaCatalogConf // 停用过滤:用户在 设置 → 模型供应商 停用的媒体模型 / 供应商不进候选清单 // (与对话模型的准入口径同源,见 model-disable-store)。 const access = readModelDisableOverrides(); - const catalog = projectProviderCatalogForBuildRegion(getActiveCatalog(), CURRENT_CINDY_REGION); + const catalog = getActiveCatalog(); return deriveCindyMediaConfig( catalog.providers, kind, diff --git a/apps/desktop/src/main/device-link/__tests__/crossProcessLock.test.ts b/apps/desktop/src/main/device-link/__tests__/crossProcessLock.test.ts index 5d68474a29..3de5f7eddc 100644 --- a/apps/desktop/src/main/device-link/__tests__/crossProcessLock.test.ts +++ b/apps/desktop/src/main/device-link/__tests__/crossProcessLock.test.ts @@ -689,7 +689,7 @@ describe('接管陈旧锁', () => { try { const started = performance.now(); await expect( - withCrossProcessLock(lock, { label: 'churn', waitMs: 500 }, async (s) => s), + withCrossProcessLock(lock, { label: 'churn', waitMs: 2_000 }, async (s) => s), ).resolves.toEqual({ held: false, reason: 'busy' }); expect(performance.now() - started).toBeLessThan(1_000); expect(takeovers).toBe(3); @@ -716,7 +716,7 @@ describe('接管陈旧锁', () => { }) as typeof fsp.rename); try { await expect( - withCrossProcessLock(lock, { label: 'final-takeover', waitMs: 500 }, async (s) => s), + withCrossProcessLock(lock, { label: 'final-takeover', waitMs: 2_000 }, async (s) => s), ).resolves.toEqual({ held: true }); expect(takeovers).toBe(3); } finally { @@ -754,7 +754,7 @@ describe('接管陈旧锁', () => { }) as typeof fsp.link); try { await expect( - withCrossProcessLock(lock, { label: 'final-takeover-busy', waitMs: 500 }, async (s) => s), + withCrossProcessLock(lock, { label: 'final-takeover-busy', waitMs: 2_000 }, async (s) => s), ).resolves.toEqual({ held: true }); expect(takeovers).toBe(3); expect(busyPublishes).toBe(1); diff --git a/apps/desktop/src/main/hook-control/__tests__/bindings.test.ts b/apps/desktop/src/main/hook-control/__tests__/bindings.test.ts index dfd7398e22..32e957e6a1 100644 --- a/apps/desktop/src/main/hook-control/__tests__/bindings.test.ts +++ b/apps/desktop/src/main/hook-control/__tests__/bindings.test.ts @@ -115,4 +115,45 @@ describe('hook binding store', () => { expect(store.get('conn-1', 'k')).toBeNull(); }); + + it('migration snapshot 批量删除并可恢复,且不覆盖无关绑定', () => { + const store = makeStore(); + store.set('conn-1', 'a', 'sess-a'); + store.set('conn-1', 'b', 'sess-b'); + store.set('conn-2', 'c', 'sess-c'); + const snapshot = store.list().filter((row) => row.connectionId === 'conn-1'); + + store.removeMany(snapshot); + expect(store.get('conn-1', 'a')).toBeNull(); + expect(store.get('conn-1', 'b')).toBeNull(); + expect(store.get('conn-2', 'c')).toBe('sess-c'); + + store.set('conn-2', 'new', 'sess-new'); + store.restoreMany(snapshot); + expect(store.get('conn-1', 'a')).toBe('sess-a'); + expect(store.get('conn-1', 'b')).toBe('sess-b'); + expect(store.get('conn-2', 'c')).toBe('sess-c'); + expect(store.get('conn-2', 'new')).toBe('sess-new'); + }); + + it('removeMany 只删除仍指向快照 session 的行', () => { + const store = makeStore(); + store.set('conn-1', 'a', 'old-session'); + const [snapshot] = store.list(); + store.set('conn-1', 'a', 'new-session'); + + store.removeMany([snapshot]); + expect(store.get('conn-1', 'a')).toBe('new-session'); + }); + + it('restoreMany 不覆盖迁移后新建的同 lane binding', () => { + const store = makeStore(); + store.set('conn-1', 'a', 'old-session'); + const [snapshot] = store.list(); + store.removeMany([snapshot]); + store.set('conn-1', 'a', 'new-session'); + + expect(store.restoreMany([snapshot])).toEqual({ restored: 0, skippedConflicts: 1 }); + expect(store.get('conn-1', 'a')).toBe('new-session'); + }); }); diff --git a/apps/desktop/src/main/hook-control/__tests__/botChannelConnections.test.ts b/apps/desktop/src/main/hook-control/__tests__/botChannelConnections.test.ts new file mode 100644 index 0000000000..d7c129ee4f --- /dev/null +++ b/apps/desktop/src/main/hook-control/__tests__/botChannelConnections.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import type { SlackHookView } from '../../../shared/hookControlIpc'; +import { hookViewToBotChannelConnections } from '../botChannelConnections'; + +function view(): SlackHookView { + return { + enabled: true, + lifecycleAnnouncement: false, + url: 'wss://hooks.example.test', + workspaces: {}, + status: 'connected', + lastError: null, + binding: null, + bindings: [ + { + teamId: 'T1', teamName: 'Acme', slackUserId: 'U1', slackUserName: 'Chris', displaced: false, + }, + { + teamId: 'T2', teamName: 'Old', slackUserId: 'U1', slackUserName: 'Chris', displaced: true, + }, + ], + pendingBind: null, + serverMultiTeam: true, + telegram: { + enabled: true, + url: 'wss://telegram.example.test', + status: 'connected', + lastError: null, + available: true, + capabilityPending: false, + defaultWorkspace: null, + binding: { + provider: 'telegram', + state: 'confirmed', + attemptId: null, + bindingId: 'binding-1', + principalId: '101', + principalName: 'Chris', + scopeId: 'bot-1', + scopeName: '@cindy_bot', + connectUrl: null, + expiresAt: null, + reason: null, + remediationUrl: null, + actions: [], + }, + }, + x: { + enabled: false, + url: '', + status: 'disabled', + lastError: null, + available: false, + capabilityPending: false, + binding: null, + defaultWorkspace: null, + }, + }; +} + +describe('hookViewToBotChannelConnections', () => { + it('exposes each live Slack team and the official Telegram bot as separate mount identities', () => { + const rows = hookViewToBotChannelConnections(view()); + expect(rows.map((row) => [row.kind, row.accountKey, row.ownership])).toEqual([ + ['slack', 'T1', 'server-relay'], + ['telegram', 'bot-1', 'server-relay'], + ]); + expect(rows[0]?.features).toContain('threads'); + expect(rows[1]?.features).toContain('group-history'); + }); + + it('retains configured accounts while offline but marks them disconnected', () => { + const offline = view(); + offline.status = 'disabled'; + offline.enabled = false; + offline.telegram.status = 'connecting'; + const rows = hookViewToBotChannelConnections(offline); + expect(rows).toHaveLength(2); + expect(rows.every((row) => row.connected === false)).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/hook-control/__tests__/botRouteTarget.test.ts b/apps/desktop/src/main/hook-control/__tests__/botRouteTarget.test.ts new file mode 100644 index 0000000000..1eb7f075a5 --- /dev/null +++ b/apps/desktop/src/main/hook-control/__tests__/botRouteTarget.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/tmp/cindy-bot-route-target-test') }, +})); + +import { + hookBotRouteSourceFromExternalKey, + slackBotRouteSourceFromExternalKey, + telegramBotRouteSourceFromExternalKey, +} from '../botRouteTarget'; + +describe('telegramBotRouteSourceFromExternalKey', () => { + it('keeps Bot identity on account plus chat/topic, not relay generation or sender', () => { + expect(telegramBotRouteSourceFromExternalKey('telegram:dm:bot-1:user-1:g2')).toEqual({ + platform: 'telegram', accountKey: 'bot-1', scopeKey: 'bot-1', principalKey: 'user-1', + deliveryKey: 'telegram:dm:bot-1:user-1:g2', + }); + expect(telegramBotRouteSourceFromExternalKey('telegram:group:bot-1:-100:owner:g2')).toEqual({ + platform: 'telegram', accountKey: 'bot-1', scopeKey: 'bot-1', principalKey: '-100', + deliveryKey: 'telegram:group:bot-1:-100:owner:g2', + }); + expect(telegramBotRouteSourceFromExternalKey('telegram:topic:bot-1:-100:42:owner:g2')).toEqual({ + platform: 'telegram', accountKey: 'bot-1', scopeKey: 'bot-1', principalKey: '-100', + parentPrincipalKey: '-100', threadKey: '42', + deliveryKey: 'telegram:topic:bot-1:-100:42:owner:g2', + }); + }); + + it('fails closed for legacy or malformed keys', () => { + expect(telegramBotRouteSourceFromExternalKey('telegram:123:456')).toBeNull(); + expect(telegramBotRouteSourceFromExternalKey('telegram:topic:bot-1:-100:g2')).toBeNull(); + expect(telegramBotRouteSourceFromExternalKey('slack:dm:T1:U1')).toBeNull(); + }); +}); + +describe('slackBotRouteSourceFromExternalKey', () => { + it('maps production channel/thread and DM keys without generation identity', () => { + expect(slackBotRouteSourceFromExternalKey('slack:T1:C1:171234.5678')).toEqual({ + platform: 'slack', accountKey: 'T1', scopeKey: 'T1', principalKey: 'C1', + parentPrincipalKey: 'C1', threadKey: '171234.5678', + deliveryKey: 'slack:T1:C1:171234.5678', + }); + expect(slackBotRouteSourceFromExternalKey('slack:dm:T1:U1:g7')).toEqual({ + platform: 'slack', accountKey: 'T1', scopeKey: 'T1', principalKey: 'U1', + deliveryKey: 'slack:dm:T1:U1:g7', + }); + }); + + it('uses TaskSource only to complete the old provider-prefixed channel key', () => { + expect(slackBotRouteSourceFromExternalKey('team-slack:C1:171234.5678')).toBeNull(); + expect( + slackBotRouteSourceFromExternalKey('team-slack:C1:171234.5678', { + im: 'slack', teamId: 'T1', + }), + ).toEqual({ + platform: 'slack', accountKey: 'T1', scopeKey: 'T1', principalKey: 'C1', + parentPrincipalKey: 'C1', threadKey: '171234.5678', + deliveryKey: 'team-slack:C1:171234.5678', + }); + }); + + it('fails closed for malformed, ambiguous, or source-mismatched Slack keys', () => { + expect(slackBotRouteSourceFromExternalKey('slack:dm:U1:g2')).toBeNull(); + expect(slackBotRouteSourceFromExternalKey('slack:dm:T1:U1:not-a-generation')).toBeNull(); + expect(slackBotRouteSourceFromExternalKey('slack:T1:C1:not-a-timestamp')).toBeNull(); + expect( + slackBotRouteSourceFromExternalKey('team-slack:C1:171234.5678', { + im: 'telegram', teamId: 'T1', + }), + ).toBeNull(); + }); +}); + +describe('hookBotRouteSourceFromExternalKey', () => { + it('keeps Telegram and Slack on their own route identity grammars', () => { + expect(hookBotRouteSourceFromExternalKey('telegram:dm:bot:user:g1')?.platform).toBe('telegram'); + expect(hookBotRouteSourceFromExternalKey('slack:dm:T1:U1:g1')?.platform).toBe('slack'); + }); +}); diff --git a/apps/desktop/src/main/hook-control/__tests__/dispatcher.test.ts b/apps/desktop/src/main/hook-control/__tests__/dispatcher.test.ts index 1a7b8a1454..c209e56bb0 100644 --- a/apps/desktop/src/main/hook-control/__tests__/dispatcher.test.ts +++ b/apps/desktop/src/main/hook-control/__tests__/dispatcher.test.ts @@ -197,6 +197,14 @@ async function tick(times = 10): Promise { for (let i = 0; i < times; i++) await Promise.resolve(); } +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + function makeDispatcher(overrides?: { getConnection?: HookDispatcherDeps['getConnection']; runner?: HookSessionRunner; @@ -211,6 +219,9 @@ function makeDispatcher(overrides?: { subscribeUiSessionIntervention?: HookDispatcherDeps['subscribeUiSessionIntervention']; subscribeUiTurnDispatching?: HookDispatcherDeps['subscribeUiTurnDispatching']; subscribeUiTurnUndispatched?: HookDispatcherDeps['subscribeUiTurnUndispatched']; + resolveBotRouteTarget?: HookDispatcherDeps['resolveBotRouteTarget']; + renewBotRouteTarget?: HookDispatcherDeps['renewBotRouteTarget']; + migrationScopeForDispatch?: HookDispatcherDeps['migrationScopeForDispatch']; accountInitiallyActive?: boolean; log?: HookDispatcherDeps['log']; }) { @@ -224,6 +235,9 @@ function makeDispatcher(overrides?: { bindings, terminalLedger: overrides?.terminalLedger, runner, + resolveBotRouteTarget: overrides?.resolveBotRouteTarget, + renewBotRouteTarget: overrides?.renewBotRouteTarget, + migrationScopeForDispatch: overrides?.migrationScopeForDispatch, prepareWorktree: overrides?.prepareWorktree, buildContextPrefix: overrides?.buildContextPrefix, dialogue: overrides?.dialogue, @@ -379,6 +393,54 @@ describe('normalizeTaskSource', () => { }); describe('dispatcher 核心语义', () => { + it('Bot migration gate blocks newly admitted hook dispatches until commit', async () => { + const fr = fakeRunner(); + const { d } = makeDispatcher({ runner: fr.runner }); + const c = collector(); + const gate = deferred(); + let migrationStarted = false; + const migration = d.runMigrationExclusive!('slack:T1', async () => { + migrationStarted = true; + await gate.promise; + }); + await vi.waitFor(() => expect(migrationStarted).toBe(true)); + + d.handleDispatch('conn-1', dispatch(), c.send); + await tick(); + expect(fr.calls).toHaveLength(0); + + gate.resolve(); + await migration; + await tick(); + expect(fr.calls).toHaveLength(1); + fr.finish(); + }); + + it('Bot migration gate leaves unrelated relay accounts available', async () => { + const fr = fakeRunner(); + const { d } = makeDispatcher({ + runner: fr.runner, + migrationScopeForDispatch: ({ externalKey }) => + externalKey.startsWith('telegram:') ? 'telegram:bot-2' : 'slack:T1', + }); + const c = collector(); + const gate = deferred(); + let migrationStarted = false; + const migration = d.runMigrationExclusive!('slack:T1', async () => { + migrationStarted = true; + await gate.promise; + }); + await vi.waitFor(() => expect(migrationStarted).toBe(true)); + + d.handleDispatch('conn-1', telegramDispatch({ requestId: 'req-other-account' }), c.send); + await tick(); + expect(fr.calls).toHaveLength(1); + fr.finish(); + + gate.resolve(); + await migration; + }); + it('账号 ingress 未打开时丢弃派发,activate 后才开始处理', async () => { const fr = fakeRunner(); const { d } = makeDispatcher({ runner: fr.runner, accountInitiallyActive: false }); @@ -3125,6 +3187,32 @@ describe('options 透传(model/effort/agentKind/permissionMode)', () => { }); describe('session.archive(/new 换代归档旧代会话)', () => { + it('mounted Bot Route renews before legacy binding archival', async () => { + const fr = fakeRunner(); + const bindings = memoryBindings(); + bindings.set('conn-1', 'telegram:dm:bot:user:g1', 'legacy-session'); + const archived: string[] = []; + const renewed: string[] = []; + const d = createHookDispatcher({ + getConnection: () => CONFIG, + bindings, + runner: fr.runner, + renewBotRouteTarget: async ({ externalKey }) => { + renewed.push(externalKey); + return { sessionId: 'bot-route-next', workingDir: '/bot-owned/project' }; + }, + archiveSessionRow: async (sessionId) => void archived.push(sessionId), + log: noopLog, + }); + + d.handleSessionArchive('conn-1', 'telegram:dm:bot:user:g1'); + await tick(); + + expect(renewed).toEqual(['telegram:dm:bot:user:g1']); + expect(archived).toEqual([]); + expect(bindings.get('conn-1', 'telegram:dm:bot:user:g1')).toBeNull(); + }); + it('安全接管旧 Slack 命名空间映射;跨白名单映射只清理不归档', async () => { const safeSession = 'legacy-safe'; const unsafeSession = 'legacy-unsafe'; @@ -3306,6 +3394,50 @@ describe('session.archive(/new 换代归档旧代会话)', () => { }); }); +describe('mounted Bot Route dispatch', () => { + it('uses the canonical Bot task ahead of a stale explicit session and bypasses legacy workspace aliases', async () => { + const fr = fakeRunner(); + const legacyBindings = memoryBindings(); + legacyBindings.set('conn-1', 'telegram:dm:bot:user:g1', 'legacy-session'); + const { d } = makeDispatcher({ + runner: fr.runner, + bindings: legacyBindings, + resolveBotRouteTarget: async () => ({ + sessionId: 'bot-canonical', + workingDir: '/bot-owned/project', + }), + }); + const c = collector(); + + d.handleDispatch( + 'conn-1', + dispatch({ + requestId: 'bot-route', + externalKey: 'telegram:dm:bot:user:g1', + workspace: null, + sessionId: 'stale-server-session', + source: { im: 'telegram' }, + }), + c.send, + ); + await tick(); + + expect(c.last('task.ack')?.payload).toMatchObject({ + result: 'accepted', + sessionId: 'bot-canonical', + }); + expect(fr.calls).toHaveLength(1); + expect(fr.calls[0]).toMatchObject({ + sessionId: 'bot-canonical', + isNew: false, + botRoute: true, + workingDir: '/bot-owned/project', + }); + expect(fr.calls[0].isDirAuthorized).toBeUndefined(); + fr.finish(); + }); +}); + describe('turn.progress 进度快照', () => { it('execute 给 runner 注入 onProgress, 调用即发 turn.progress 帧(带本任务 requestId)', async () => { const fr = fakeRunner(); @@ -4343,6 +4475,84 @@ describe('turn.reopen: 失败任务在桌面端被续跑后接回原消息', () }); describe('官方 bot ack 表情(msg.op)', () => { + it('主动投递必须等匹配 ACK,重复 opId 复用同一个在途请求', async () => { + const { d } = makeDispatcher(); + const c = collector(); + d.onConnected('conn-1', c.send, [HOOK_FEATURE_MESSAGE_OPS]); + const payload = { + opId: 'deliver-1', + scope: { externalKey: 'telegram:dm:bot:user:g1' }, + action: { kind: 'send' as const, text: 'done', tier: 'html' as const }, + }; + + const first = d.sendMessageOp('conn-1', payload); + const duplicate = d.sendMessageOp('conn-1', payload); + expect(first).toBe(duplicate); + expect(c.sent.filter((message) => message.type === 'msg.op')).toHaveLength(1); + + d.onMessageOpResult('conn-1', { opId: payload.opId, ok: true, messageId: '42' }); + await expect(first).resolves.toEqual({ opId: payload.opId, ok: true, messageId: '42' }); + }); + + it('断连让主动投递以可重试错误收口,迟到 ACK 不会复活旧请求', async () => { + const { d } = makeDispatcher(); + const c = collector(); + d.onConnected('conn-1', c.send, [HOOK_FEATURE_MESSAGE_OPS]); + const pending = d.sendMessageOp('conn-1', { + opId: 'deliver-offline', + scope: { externalKey: 'telegram:dm:bot:user:g1' }, + action: { kind: 'send', text: 'done', tier: 'html' }, + }); + + d.onDisconnected('conn-1'); + await expect(pending).resolves.toEqual({ + opId: 'deliver-offline', + ok: false, + error: 'HOOK_NOT_CONNECTED', + retryAfterMs: 1_000, + }); + d.onMessageOpResult('conn-1', { + opId: 'deliver-offline', + ok: true, + messageId: 'late', + }); + }); + + it('老 server fail closed,外来连接 ACK 不能收口当前投递', async () => { + const { d } = makeDispatcher(); + const old = collector(); + d.onConnected('old-conn', old.send, []); + await expect( + d.sendMessageOp('old-conn', { + opId: 'unsupported', + scope: { externalKey: 'telegram:dm:bot:user:g1' }, + action: { kind: 'send', text: 'done', tier: 'html' }, + }), + ).resolves.toEqual({ + opId: 'unsupported', + ok: false, + error: 'MESSAGE_OPS_UNSUPPORTED', + }); + expect(old.sent.filter((message) => message.type === 'msg.op')).toHaveLength(0); + + const current = collector(); + d.onConnected('conn-1', current.send, [HOOK_FEATURE_MESSAGE_OPS]); + const pending = d.sendMessageOp('conn-1', { + opId: 'owned-op', + scope: { externalKey: 'telegram:dm:bot:user:g1' }, + action: { kind: 'send', text: 'done', tier: 'html' }, + }); + let settled = false; + void pending.then(() => { + settled = true; + }); + d.onMessageOpResult('conn-2', { opId: 'owned-op', ok: true, messageId: 'foreign' }); + await tick(); + expect(settled).toBe(false); + d.onMessageOpResult('conn-1', { opId: 'owned-op', ok: true, messageId: 'owned' }); + await expect(pending).resolves.toMatchObject({ ok: true, messageId: 'owned' }); + }); + it('排队的任务也要给 👀 —— 用户分不清是在排队还是丢了', async () => { const fr = fakeRunner(); const { d } = makeDispatcher({ runner: fr.runner }); diff --git a/apps/desktop/src/main/hook-control/__tests__/transport-manager.test.ts b/apps/desktop/src/main/hook-control/__tests__/transport-manager.test.ts index 59af269a5d..efc03ed7dd 100644 --- a/apps/desktop/src/main/hook-control/__tests__/transport-manager.test.ts +++ b/apps/desktop/src/main/hook-control/__tests__/transport-manager.test.ts @@ -190,6 +190,7 @@ describe('hook-control runtime capability gate', () => { handleInteractionDecision: vi.fn(), handleTurnDelivery, onMessageOpResult: vi.fn(), + sendMessageOp: vi.fn(), setEmojiReactionsMode: vi.fn(), settleAckReactions: vi.fn(), activateAccount: vi.fn(), @@ -1547,6 +1548,84 @@ const TELEGRAM_CONFIRMED: ProviderBindStatusPayload = { }; describe('Telegram provider capability, binding and prefs', () => { + it('Bot 主动投递在 manager 边界校验 provider 与已绑定账号', async () => { + const sendMessageOp = vi.fn(async (_connectionId, payload) => ({ + opId: payload.opId, + ok: true, + messageId: 'telegram-message-1', + })); + const dispatcher = { + handleDispatch: vi.fn(), + onConnected: vi.fn(), + onDisconnected: vi.fn(), + onMessageOpResult: vi.fn(), + sendMessageOp, + setEmojiReactionsMode: vi.fn(), + settleAckReactions: vi.fn(), + cancel: vi.fn(), + handleSessionArchive: vi.fn(), + handleInteractionDecision: vi.fn(), + handleTurnDelivery: vi.fn(), + activateAccount: vi.fn(), + deactivateAccount: vi.fn(async () => undefined), + dispose: vi.fn(), + } as NonNullable; + const manager = makeManager( + memoryStore({ + url: 'wss://unused.example', + enabled: false, + telegramEnabled: false, + telegramBindingCache: { + bindingId: 'binding-telegram-1', + principalId: 'telegram-user-1', + principalName: 'Cindy User', + scopeId: 'bot-1', + scopeName: 'cindy_example_bot', + }, + }), + { dispatcher }, + ); + cleanups.push(() => manager.dispose()); + + await expect( + manager.sendBotRouteMessage({ + provider: 'telegram', + accountKey: 'bot-1', + externalKey: 'slack:T1:C1:171234.5678', + opId: 'provider-mismatch', + text: 'done', + }), + ).resolves.toMatchObject({ ok: false, error: 'ROUTE_PROVIDER_MISMATCH' }); + await expect( + manager.sendBotRouteMessage({ + provider: 'telegram', + accountKey: 'other-bot', + externalKey: 'telegram:dm:bot-1:user-1:g1', + opId: 'account-mismatch', + text: 'done', + }), + ).resolves.toMatchObject({ ok: false, error: 'ROUTE_ACCOUNT_MISMATCH' }); + expect(sendMessageOp).not.toHaveBeenCalled(); + + await expect( + manager.sendBotRouteMessage({ + provider: 'telegram', + accountKey: 'bot-1', + externalKey: 'telegram:dm:bot-1:user-1:g1', + opId: 'valid-route', + text: 'done', + }), + ).resolves.toMatchObject({ ok: true, messageId: 'telegram-message-1' }); + expect(sendMessageOp).toHaveBeenCalledWith( + expect.stringMatching(/:telegram$/), + { + opId: 'valid-route', + scope: { externalKey: 'telegram:dm:bot-1:user-1:g1' }, + action: { kind: 'send', text: 'done', tier: 'html' }, + }, + ); + }); + it('冷启动与账号重激活都从缓存恢复 Telegram actions', async () => { const store = memoryStore({ url: 'wss://unused.example', @@ -1585,6 +1664,7 @@ describe('Telegram provider capability, binding and prefs', () => { onConnected: vi.fn(), onDisconnected: vi.fn(), onMessageOpResult: vi.fn(), + sendMessageOp: vi.fn(), setEmojiReactionsMode: vi.fn(), settleAckReactions: vi.fn(), cancel: vi.fn(), diff --git a/apps/desktop/src/main/hook-control/bindings.ts b/apps/desktop/src/main/hook-control/bindings.ts index d086b84577..cccb9213a5 100644 --- a/apps/desktop/src/main/hook-control/bindings.ts +++ b/apps/desktop/src/main/hook-control/bindings.ts @@ -34,6 +34,25 @@ export interface HookBindingStore { remove(connectionId: string, externalKey: string): void; } +export interface HookBindingSnapshot { + connectionId: string; + externalKey: string; + sessionId: string; + updatedAt: number; +} + +export interface HookBindingMigrationStore extends HookBindingStore { + /** Stable snapshot used by Bot migration planning and rollback. */ + list(): HookBindingSnapshot[]; + /** Apply a batch delete with one atomic file replacement. */ + removeMany(rows: readonly HookBindingSnapshot[]): void; + /** Restore an earlier batch without replacing bindings created afterwards. */ + restoreMany(rows: readonly HookBindingSnapshot[]): { + restored: number; + skippedConflicts: number; + }; +} + interface BindingRow { sessionId: string; updatedAt: number; @@ -44,7 +63,7 @@ type BindingFile = Record>; export function createHookBindingStore(deps: { filePath: string; log: { warn(msg: string): void }; -}): HookBindingStore { +}): HookBindingMigrationStore { const { filePath, log } = deps; function readAll(): BindingFile { @@ -108,5 +127,71 @@ export function createHookBindingStore(deps: { delete (ns as Record)[externalKey]; writeAll(data); }, + list() { + const rows: HookBindingSnapshot[] = []; + for (const [connectionId, rawNamespace] of Object.entries(readAll())) { + if (!rawNamespace || typeof rawNamespace !== 'object' || Array.isArray(rawNamespace)) { + continue; + } + for (const [externalKey, rawRow] of Object.entries(rawNamespace)) { + if ( + !rawRow + || typeof rawRow !== 'object' + || typeof rawRow.sessionId !== 'string' + || typeof rawRow.updatedAt !== 'number' + ) { + continue; + } + rows.push({ + connectionId, + externalKey, + sessionId: rawRow.sessionId, + updatedAt: rawRow.updatedAt, + }); + } + } + return rows.sort((a, b) => + a.connectionId.localeCompare(b.connectionId) + || a.externalKey.localeCompare(b.externalKey), + ); + }, + removeMany(rows) { + if (rows.length === 0) return; + const data = readAll(); + let changed = false; + for (const row of rows) { + const ns: unknown = data[row.connectionId]; + if (!ns || typeof ns !== 'object' || Array.isArray(ns)) continue; + const current = (ns as Record)[row.externalKey]; + if (!current || current.sessionId !== row.sessionId) continue; + delete (ns as Record)[row.externalKey]; + changed = true; + } + if (changed) writeAll(data); + }, + restoreMany(rows) { + if (rows.length === 0) return { restored: 0, skippedConflicts: 0 }; + const data = readAll(); + let changed = false; + let restored = 0; + let skippedConflicts = 0; + for (const row of rows) { + const ns = namespaceFor(data, row.connectionId); + const current = ns[row.externalKey]; + if (current?.sessionId === row.sessionId && current.updatedAt === row.updatedAt) continue; + // A newer task may claim the lane after migration removed the old + // binding. Rollback must preserve that newer claim instead of + // resurrecting the stale task over it. + if (current) { + skippedConflicts += 1; + continue; + } + ns[row.externalKey] = { sessionId: row.sessionId, updatedAt: row.updatedAt }; + restored += 1; + changed = true; + } + if (changed) writeAll(data); + return { restored, skippedConflicts }; + }, }; } diff --git a/apps/desktop/src/main/hook-control/botChannelConnections.ts b/apps/desktop/src/main/hook-control/botChannelConnections.ts new file mode 100644 index 0000000000..d820c03fed --- /dev/null +++ b/apps/desktop/src/main/hook-control/botChannelConnections.ts @@ -0,0 +1,46 @@ +import type { SlackHookView } from '../../shared/hookControlIpc.js'; +import { + RELAY_BOT_CHANNEL_FEATURES, + type BotChannelConnection, +} from '../../shared/botChannelRegistry.js'; + +/** Convert the authenticated relay snapshot into concrete Bot-mount identities. */ +export function hookViewToBotChannelConnections(view: SlackHookView): BotChannelConnection[] { + const rows: BotChannelConnection[] = []; + for (const binding of view.bindings) { + if (binding.displaced || !binding.teamId.trim()) continue; + rows.push({ + id: `relay:slack:${binding.teamId}`, + kind: 'slack', + ownership: 'server-relay', + status: view.status, + connected: view.enabled && view.status === 'connected', + accountKey: binding.teamId, + accountName: binding.teamName, + scopeKey: binding.teamId, + routable: true, + features: [...(RELAY_BOT_CHANNEL_FEATURES.slack ?? [])], + }); + } + + const telegramBinding = view.telegram.binding; + const telegramAccountKey = telegramBinding?.scopeId?.trim() ?? ''; + if (telegramBinding?.state === 'confirmed' && telegramAccountKey) { + rows.push({ + id: `relay:telegram:${telegramBinding.bindingId ?? telegramAccountKey}`, + kind: 'telegram', + ownership: 'server-relay', + status: view.telegram.status, + connected: + view.telegram.enabled + && view.telegram.available + && view.telegram.status === 'connected', + accountKey: telegramAccountKey, + accountName: telegramBinding.scopeName, + scopeKey: telegramBinding.scopeId, + routable: true, + features: [...(RELAY_BOT_CHANNEL_FEATURES.telegram ?? [])], + }); + } + return rows; +} diff --git a/apps/desktop/src/main/hook-control/botRouteTarget.ts b/apps/desktop/src/main/hook-control/botRouteTarget.ts new file mode 100644 index 0000000000..74d40ff001 --- /dev/null +++ b/apps/desktop/src/main/hook-control/botRouteTarget.ts @@ -0,0 +1,208 @@ +/** Bot Route adapter for server-relayed Telegram and Slack Bots. */ + +import { eq } from 'drizzle-orm'; + +import { getDeviceId } from '../authManager.js'; +import { getDbClient } from '../localDb/client/current.js'; +import { + ensureBotRouteSession, + resolveOrCreateBotRoute, +} from '../localDb/botRouteService.js'; +import { sessions } from '../localDb/schema.js'; +import type { BotRouteSource } from '../../shared/botRoute.js'; +import type { TaskSource } from '@cindy/slack-hook-protocol'; + +export interface HookBotRouteTarget { + sessionId: string; + workingDir: string; +} + +/** + * Decodes only the official relay shapes and fails closed for legacy/unknown + * keys. The route identity intentionally excludes the relay generation and + * sender principal: it is the configured bot account plus chat/topic. + */ +export function telegramBotRouteSourceFromExternalKey(externalKey: string): BotRouteSource | null { + const parts = externalKey.split(':'); + if (parts[0] !== 'telegram') return null; + const kind = parts[1]; + const accountKey = parts[2]?.trim() ?? ''; + const principalKey = parts[3]?.trim() ?? ''; + if (!accountKey || !principalKey) return null; + const last = parts.length - 1; + const terminal = /^g\d+$/.test(parts[last] ?? '') ? last - 1 : last; + if (kind === 'dm') { + if (terminal < 3) return null; + return { + platform: 'telegram', accountKey, scopeKey: accountKey, principalKey, deliveryKey: externalKey, + }; + } + if (kind === 'group') { + if (terminal <= 3) return null; + return { + platform: 'telegram', accountKey, scopeKey: accountKey, principalKey, deliveryKey: externalKey, + }; + } + if (kind === 'topic') { + const threadKey = parts[4]?.trim() ?? ''; + if (!threadKey || terminal <= 4) return null; + return { + platform: 'telegram', accountKey, scopeKey: accountKey, principalKey, + parentPrincipalKey: principalKey, threadKey, deliveryKey: externalKey, + }; + } + return null; +} + +const SLACK_ID = /^[A-Z][A-Z0-9]*$/; +const SLACK_TS = /^\d+(?:\.\d+)?$/; +const ROUTE_GENERATION = /^g\d+$/; + +function sourceSlackTeamId(source?: TaskSource): string { + if (source?.im !== 'slack') return ''; + const teamId = source.teamId?.trim() ?? ''; + return SLACK_ID.test(teamId) ? teamId : ''; +} + +/** + * Production Slack keys are owned by slack-hook-server: + * - slack::: + * - slack:dm:::g + * + * Older servers used a provider prefix without the team id. Those keys are + * accepted only when the signed task source supplies the missing Slack team; + * source-less legacy keys stay on the existing binding path instead of being + * guessed into a Bot Route. + */ +export function slackBotRouteSourceFromExternalKey( + externalKey: string, + source?: TaskSource, +): BotRouteSource | null { + const parts = externalKey.split(':'); + if (parts[0] === 'slack' && parts[1] === 'dm') { + const teamId = parts[2]?.trim() ?? ''; + const userId = parts[3]?.trim() ?? ''; + const generation = parts[4]?.trim() ?? ''; + if ( + parts.length !== 5 + || !SLACK_ID.test(teamId) + || !SLACK_ID.test(userId) + || !ROUTE_GENERATION.test(generation) + ) { + return null; + } + return { + platform: 'slack', + accountKey: teamId, + scopeKey: teamId, + principalKey: userId, + deliveryKey: externalKey, + }; + } + + if (parts[0] === 'slack') { + const teamId = parts[1]?.trim() ?? ''; + const channelId = parts[2]?.trim() ?? ''; + const threadTs = parts[3]?.trim() ?? ''; + if ( + parts.length !== 4 + || !SLACK_ID.test(teamId) + || !SLACK_ID.test(channelId) + || !SLACK_TS.test(threadTs) + ) { + return null; + } + return { + platform: 'slack', + accountKey: teamId, + scopeKey: teamId, + principalKey: channelId, + parentPrincipalKey: channelId, + threadKey: threadTs, + deliveryKey: externalKey, + }; + } + + if (parts[0] === 'team-slack') { + const teamId = sourceSlackTeamId(source); + const channelId = parts[1]?.trim() ?? ''; + const threadTs = parts[2]?.trim() ?? ''; + if ( + parts.length !== 3 + || !teamId + || !SLACK_ID.test(channelId) + || !SLACK_TS.test(threadTs) + ) { + return null; + } + return { + platform: 'slack', + accountKey: teamId, + scopeKey: teamId, + principalKey: channelId, + parentPrincipalKey: channelId, + threadKey: threadTs, + deliveryKey: externalKey, + }; + } + + // Pre-provider-prefix channel key: ::. + const teamId = parts[0]?.trim() ?? ''; + const channelId = parts[1]?.trim() ?? ''; + const threadTs = parts[2]?.trim() ?? ''; + if ( + parts.length === 3 + && SLACK_ID.test(teamId) + && SLACK_ID.test(channelId) + && SLACK_TS.test(threadTs) + && (source === undefined || source.im === 'slack') + ) { + return { + platform: 'slack', + accountKey: teamId, + scopeKey: teamId, + principalKey: channelId, + parentPrincipalKey: channelId, + threadKey: threadTs, + deliveryKey: externalKey, + }; + } + return null; +} + +export function hookBotRouteSourceFromExternalKey( + externalKey: string, + source?: TaskSource, +): BotRouteSource | null { + return telegramBotRouteSourceFromExternalKey(externalKey) + ?? slackBotRouteSourceFromExternalKey(externalKey, source); +} + +async function readTarget(routeId: string, forceRenew = false): Promise { + const ensured = await ensureBotRouteSession({ routeId, ownerDeviceId: getDeviceId(), forceRenew }); + const [row] = await getDbClient().drizzle + .select({ id: sessions.id, workingDir: sessions.workingDir, source: sessions.source, status: sessions.status }) + .from(sessions).where(eq(sessions.id, ensured.sessionId)).limit(1); + if (!row?.workingDir || row.source !== 'bot' || row.status !== 'active') { + throw new Error('Bot Route task is unavailable after resolution'); + } + return { sessionId: row.id, workingDir: row.workingDir }; +} + +export async function resolveHookBotRouteTarget( + externalKey: string, + taskSource?: TaskSource, +): Promise { + const routeSource = hookBotRouteSourceFromExternalKey(externalKey, taskSource); + if (!routeSource) return null; + const route = await resolveOrCreateBotRoute(routeSource); + return route ? readTarget(route.id) : null; +} + +/** `/new` renews a mounted Route, never an unrelated legacy binding. */ +export async function renewHookBotRouteTarget(externalKey: string): Promise { + const routeSource = hookBotRouteSourceFromExternalKey(externalKey); + if (!routeSource) return null; + const route = await resolveOrCreateBotRoute(routeSource); + return route ? readTarget(route.id, true) : null; +} diff --git a/apps/desktop/src/main/hook-control/dispatcher.ts b/apps/desktop/src/main/hook-control/dispatcher.ts index 1097fc5795..aa093cc20d 100644 --- a/apps/desktop/src/main/hook-control/dispatcher.ts +++ b/apps/desktop/src/main/hook-control/dispatcher.ts @@ -39,7 +39,10 @@ import { randomUUID } from 'node:crypto'; import { makeInteractionCancel, makeInteractionRequest, + makeMessageOp, makeTaskAck, + HOOK_FEATURE_MESSAGE_OPS, + type MessageOpPayload, type MessageOpResultPayload, type TelegramEmojiReactions, makeTurnEnd, @@ -153,6 +156,8 @@ export interface HookRunRequest { laneKind?: 'dm' | 'group'; /** true = 新建 session(workingDir/title 生效); false = 复用/接管已有。 */ isNew: boolean; + /** Canonical Cindy Bot Route task; its workspace is Bot-owned, not alias-owned. */ + botRoute?: boolean; workingDir: string; /** dispatch options 显式指定的 agent; null = 桌面端按草稿默认落值。 */ agentKind: string | null; @@ -241,6 +246,22 @@ export interface HookDispatcherDeps { /** Durable terminal request state, injected by the Electron owner boundary. */ terminalLedger?: HookRequestLedger; runner: HookSessionRunner; + /** Mounted Bot Routes take precedence over legacy hook bindings/session ids. */ + resolveBotRouteTarget?: (input: { + connectionId: string; + externalKey: string; + source?: TaskSource; + }) => Promise<{ sessionId: string; workingDir: string } | null>; + /** `/new` counterpart for a mounted Bot Route. */ + renewBotRouteTarget?: (input: { + connectionId: string; + externalKey: string; + }) => Promise<{ sessionId: string; workingDir: string } | null>; + /** Stable provider/account key used to isolate Bot migration admission. */ + migrationScopeForDispatch?: (input: { + externalKey: string; + source?: TaskSource; + }) => string | null; /** * 可选: 为新建 hook 会话预建独立 git worktree(并发隔离 —— 每个 * thread/会话一个 worktree, 多任务同时跑互不踩工作树)。失败时 dispatcher @@ -350,7 +371,12 @@ export interface HookDispatcher { * msg.op.result: 消息操作的回执。当前只有 ack 表情用它 —— 表情是纯装饰, * 失败只记一行, 不重试、不影响任务本身。 */ - onMessageOpResult(payload: MessageOpResultPayload): void; + onMessageOpResult(connectionId: string, payload: MessageOpResultPayload): void; + /** Durable request/receipt bridge for proactive Bot relay delivery. */ + sendMessageOp( + connectionId: string, + payload: MessageOpPayload, + ): Promise; /** * 用户的表情档位(off / minimal / expressive)。服务端经 provider.behavior.state * 下发, manager 收到即转告。 @@ -387,6 +413,8 @@ export interface HookDispatcher { handleInteractionDecision(connectionId: string, payload: InteractionDecisionPayload): void; /** X server 对普通 turn.end 的持久接管 / 渠道发布状态回执。 */ handleTurnDelivery(connectionId: string, payload: TurnDeliveryPayload): void; + /** Drain one provider/account route and block only matching dispatches during migration. */ + runMigrationExclusive?(scope: string, operation: () => Promise): Promise; /** Re-open ingress after the next account DB is ready. */ activateAccount(): void; /** Close ingress, abort old-account turns and await their final async boundary. */ @@ -547,6 +575,7 @@ interface PendingTask { externalKey: string; run: HookRunRequest; accountGeneration: number; + migrationScope: string; /** 群上下文游标提交回调;仅在 provider 实际受理后调用。 */ commitContextCursor?: ContextCursorCommit; /** 会话定位阶段产生的一次性说明, 前置到本次 turn.end 的 finalText。 */ @@ -603,6 +632,8 @@ interface PendingReopen { externalKey: string; /** 那一轮真正跑的目录(执行前还要按当前映射复核一次)。 */ workingDir: string; + /** Bot Route tasks are authorized by the Route ownership, not hook aliases. */ + botRoute?: boolean; source?: TaskSource; accountGeneration: number; expiresAt: number; @@ -630,6 +661,9 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { bindings, terminalLedger, runner, + resolveBotRouteTarget, + renewBotRouteTarget, + migrationScopeForDispatch, prepareWorktree, buildContextPrefix, dialogue, @@ -678,13 +712,23 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { * 段的两帧在同一 tick 送达, 生产可达)。链式 promise 保证同 key 严格按序。 */ const keyChains = new Map>(); - function serializeByKey(key: string, fn: () => Promise): void { + const keyChainScopes = new Map(); + const GLOBAL_MIGRATION_SCOPE = '*'; + const migrationBarriers = new Map>(); + function migrationScope(externalKey: string, source?: TaskSource): string { + return migrationScopeForDispatch?.({ externalKey, source }) ?? GLOBAL_MIGRATION_SCOPE; + } + function serializeByKey(key: string, scope: string, fn: () => Promise): void { const prev = keyChains.get(key) ?? Promise.resolve(); const next = prev.then(fn, fn); const stored = next.catch(() => undefined); keyChains.set(key, stored); + keyChainScopes.set(key, scope); void stored.finally(() => { - if (keyChains.get(key) === stored) keyChains.delete(key); + if (keyChains.get(key) === stored) { + keyChains.delete(key); + keyChainScopes.delete(key); + } }); } /** 同一 session 的受理段串行化,避免不同 externalKey 并发判断空闲并同时占槽。 */ @@ -717,6 +761,15 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { } /** 每连接当前发送函数(transport 重建后由 onConnected / handleDispatch 刷新)。 */ const sendFns = new Map boolean>(); + const pendingMessageOps = new Map< + string, + { + connectionId: string; + promise: Promise; + resolve: (result: MessageOpResultPayload) => void; + timer: ReturnType; + } + >(); /** 离线积压的 turn.end, 按连接缓存; durable terminal 先记 pending, 发送成功后标 sent。 */ const pendingTurnEnds = new Map(); /** 双向 ACK 已协商时,等待 server durable accepted 的完整 turn.end 副本。 */ @@ -765,7 +818,10 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { /** 每 session 的 FIFO 等待队列。 */ const queues = new Map(); /** connectionId + requestId -> 正在执行它的 session(cancel 定位与归属校验用, 收口即清)。 */ - const runningByRequest = new Map(); + const runningByRequest = new Map< + string, + { sessionId: string; connectionId: string; migrationScope: string } + >(); /** * 已开始执行但 provider 尚未受理的 Telegram 群任务。账号边界必须把其 * accepted / queued ACK 收成 cancelled;accepted=true 后消息已交给 agent, @@ -789,6 +845,17 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { emojiReactions: () => emojiReactionsMode, log, }); + const finishPendingMessageOp = ( + opId: string, + result: MessageOpResultPayload, + ): boolean => { + const pending = pendingMessageOps.get(opId); + if (!pending) return false; + pendingMessageOps.delete(opId); + clearTimeout(pending.timer); + pending.resolve(result); + return true; + }; /** * 以失败收口、**还等着被续跑**的任务, 按 sessionId 记账(见协议阶段 18)。 * @@ -929,6 +996,7 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { let accountActive = accountInitiallyActive ?? true; let accountGeneration = 0; const executing = new Set>(); + const executingScopes = new WeakMap, string>(); let accountDeactivation: Promise | null = null; function isCurrentGeneration(generation: number): boolean { @@ -1242,7 +1310,7 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { if (running.has(sessionId) || activeContinuations.has(sessionId)) return; if (!supportsReopen(entry.connectionId)) return; // 目录授权按现场重算(与 execute 同一道收口): 等待期间映射可能已被改删。 - if (!dirStillAllowed(entry.connectionId, entry.workingDir)) { + if (!entry.botRoute && !dirStillAllowed(entry.connectionId, entry.workingDir)) { log.info( `hook continuation skipped: the session directory is no longer authorized (reopenOf=${entry.requestId})`, ); @@ -1250,11 +1318,7 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { } const requestId = randomUUID(); const requestKey = ackKey(entry.connectionId, requestId); - const messageLifecycle = telegramLegacyLifecycle( - entry.connectionId, - requestId, - entry.source, - ); + const messageLifecycle = telegramLegacyLifecycle(entry.connectionId, requestId, entry.source); let claimed = false; // runner 可能在 watch() 里**同步**收口(会话已不在进程里就直接 onAbandon), // 那时 cancelWatch 还没赋值 —— 用这个标记决定要不要登记, 不去碰它。 @@ -1303,7 +1367,9 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { workingDir: entry.workingDir, ...(entry.source ? { source: entry.source } : {}), laneKind: deriveLaneKind(entry.externalKey), - isDirAuthorized: (dir) => dirStillAllowed(entry.connectionId, dir), + ...(entry.botRoute + ? {} + : { isDirAuthorized: (dir: string) => dirStillAllowed(entry.connectionId, dir) }), onClaim: () => { if (revoked || !isCurrentGeneration(entry.accountGeneration)) return; const send = sendFns.get(entry.connectionId); @@ -1335,7 +1401,11 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { pendingReopens.delete(sessionId); // 认领成功后这一轮就等同于一个在执行的任务: 登记进 runningByRequest, // 渠道侧的 /stop 才能用新 requestId 命中它。 - runningByRequest.set(requestKey, { sessionId, connectionId: entry.connectionId }); + runningByRequest.set(requestKey, { + sessionId, + connectionId: entry.connectionId, + migrationScope: migrationScope(entry.externalKey, entry.source), + }); }, onProgress: (text) => { if (revoked || !claimed || !isCurrentGeneration(entry.accountGeneration)) return; @@ -1453,7 +1523,11 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { // 这条消息线交给新任务了: 撤掉上一轮失败留下的续跑观察与记账。连接还在, 所以要 // 发收口帧把那条旧消息定稿; 但不再记待续跑(它已经不是"最新一轮"了)。 dropContinuation(sessionId, { silent: false, remember: false }); - runningByRequest.set(requestKey, { sessionId, connectionId: task.connectionId }); + runningByRequest.set(requestKey, { + sessionId, + connectionId: task.connectionId, + migrationScope: task.migrationScope, + }); const messageLifecycle = telegramLegacyLifecycle( task.connectionId, task.requestId, @@ -1498,7 +1572,7 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { * 而空串过 isPathWithin 会 resolve 成 cwd, 那就成了一条假放行。 */ const guardDir = task.run.workingDir || null; - if (guardDir !== null && !dirStillAllowed(task.connectionId, guardDir)) { + if (guardDir !== null && !task.run.botRoute && !dirStillAllowed(task.connectionId, guardDir)) { // 路径不进日志(规则: 用集中 PII helper, 而 dispatcher 是不碰 Electron 的 // 纯逻辑模块, 拿不到它)—— requestId 足够定位。 log.info( @@ -1531,7 +1605,9 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { : {}), // runner 建/取到 session 后, 拿它真正要跑的那个目录回来问一次 —— // 那个目录可能与这里校验过的不是同一个(见 isDirAuthorized 的说明)。 - isDirAuthorized: (dir) => dirStillAllowed(task.connectionId, dir), + ...(task.run.botRoute + ? {} + : { isDirAuthorized: (dir: string) => dirStillAllowed(task.connectionId, dir) }), }); } catch (err) { outcome = { @@ -1611,6 +1687,7 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { requestId: task.requestId, externalKey: task.externalKey, workingDir: task.run.workingDir, + ...(task.run.botRoute ? { botRoute: true } : {}), ...(task.run.source ? { source: task.run.source } : {}), accountGeneration: task.accountGeneration, }, @@ -1651,6 +1728,7 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { function startExecution(task: PendingTask): void { const promise = execute(task); executing.add(promise); + executingScopes.set(promise, task.migrationScope); void promise.finally(() => executing.delete(promise)); } @@ -1730,6 +1808,36 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { connectionName: config.name, externalKey: payload.externalKey, }; + // A mounted Bot Route owns this lane. Resolve it before every legacy + // binding/sessionId path: the relay can lag a Bot migration, but must never + // revive an ordinary task for a Bot-owned conversation. + if (resolveBotRouteTarget) { + const botTarget = await resolveBotRouteTarget({ + connectionId, + externalKey: payload.externalKey, + ...(payload.source ? { source: payload.source } : {}), + }); + if (!isCurrentGeneration(generation)) return { reject: 'disabled' }; + if (botTarget) { + return { + run: { + sessionId: botTarget.sessionId, + isNew: false, + botRoute: true, + laneKind: deriveLaneKind(payload.externalKey), + workingDir: botTarget.workingDir, + agentKind: payload.options?.agentKind ?? null, + model: payload.options?.model ?? null, + effort: payload.options?.effort ?? null, + permissionMode: payload.options?.permissionMode ?? null, + title: null, + prompt: payload.prompt, + attachments: payload.attachments, + origin, + }, + }; + } + } /** * 同上, 但每次都重读连接配置 —— 撤权判定必须用**此刻**的映射: `config` 是 * 消息进来那一刻的快照, 而下面要 await `runner.inspect()`。用快照判定的话, @@ -2114,6 +2222,16 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { const admittedGeneration = accountGeneration; const source = payload.source === undefined ? undefined : normalizeTaskSource(payload.source); const dispatchPayload = source === undefined ? payload : { ...payload, source }; + const admittedMigrationScope = migrationScope(payload.externalKey, source); + // Capture at admission so work admitted before a migration gate remains + // drainable instead of waiting on the gate that is waiting for this chain. + const admittedMigrationBarriers = + admittedMigrationScope === GLOBAL_MIGRATION_SCOPE + ? [...migrationBarriers.values()] + : [ + migrationBarriers.get(GLOBAL_MIGRATION_SCOPE), + migrationBarriers.get(admittedMigrationScope), + ].filter((barrier): barrier is Promise => Boolean(barrier)); sendFns.set(connectionId, send); // Durable terminal replay comes first: an auto-update restarts the process @@ -2198,8 +2316,9 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { } // 同 key 串行化(见 keyChains 注释) —— 定位+入队作为一个原子段执行 - serializeByKey(`${connectionId} ${payload.externalKey}`, async () => { + serializeByKey(`${connectionId} ${payload.externalKey}`, admittedMigrationScope, async () => { try { + await Promise.all(admittedMigrationBarriers); let contextPrefix = ''; let commitContextCursor: ContextCursorCommit | undefined; if (buildContextPrefix) { @@ -2239,6 +2358,7 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { ...(groupHistoryAccess ? { groupHistoryAccess } : {}), }, accountGeneration: admittedGeneration, + migrationScope: admittedMigrationScope, reopenCapable: supportsReopen(connectionId), ...((payload.externalKey.startsWith('telegram:group:') || payload.externalKey.startsWith('telegram:topic:')) && @@ -2421,6 +2541,7 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { awaitingPersist.clear(); staleTakeoverReplacements.clear(); keyChains.clear(); + keyChainScopes.clear(); sessionAdmissionChains.clear(); })(); accountDeactivation = drain.finally(() => { @@ -2433,9 +2554,30 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { const admittedGeneration = accountGeneration; // 与 dispatch 同 key 串行: 避免在途 resolveTarget(即将落绑定建会话)与 // 归档并发穿插 —— 归档排在其后, 能看到刚落下的绑定。 - serializeByKey(`${connectionId} ${externalKey}`, async () => { + serializeByKey(`${connectionId} ${externalKey}`, migrationScope(externalKey), async () => { if (!isCurrentGeneration(admittedGeneration)) return; staleTakeoverReplacements.delete(bindingKey(connectionId, externalKey)); + if (renewBotRouteTarget) { + try { + const renewed = await renewBotRouteTarget({ connectionId, externalKey }); + if (!isCurrentGeneration(admittedGeneration)) return; + if (renewed) { + // The Route service atomically archives the old canonical task + // and creates its replacement. Do not race it with the legacy + // binding archive path. + bindings.remove(connectionId, externalKey); + log.info(`hook Bot Route renewed for ${externalKey}`); + return; + } + } catch (err) { + // A recognized Bot Route must fail closed. Falling through here + // could archive a stale, unrelated legacy session. + log.warn( + `hook Bot Route renewal failed for ${externalKey}: ${err instanceof Error ? err.message : String(err)}`, + ); + return; + } + } /** * 这个会话此刻还归远端管吗 —— 归档同样要过工作目录映射这道边界。 * 会话已被移出映射(或映射被改/删)时, 远端的 `/new` 不该还能归档它并 @@ -2544,6 +2686,53 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { `turn.end delivery ${payload.state}: requestId=${payload.requestId} attempt=${payload.attempt}`, ); }, + async runMigrationExclusive(scope: string, operation: () => Promise): Promise { + if (migrationBarriers.has(scope)) { + throw new Error('Another hook Bot migration is already running for this account'); + } + const admitted = [ + ...[...keyChains.entries()] + .filter(([key]) => { + const admittedScope = keyChainScopes.get(key) ?? GLOBAL_MIGRATION_SCOPE; + return admittedScope === GLOBAL_MIGRATION_SCOPE || admittedScope === scope; + }) + .map(([, promise]) => promise), + // Session admission is a short critical section and may bridge a + // legacy binding whose account scope is not yet known. Drain it + // globally, while long-running turns below remain account-scoped. + ...sessionAdmissionChains.values(), + ]; + let release!: () => void; + const barrier = new Promise((resolve) => { + release = resolve; + }); + migrationBarriers.set(scope, barrier); + try { + await Promise.allSettled(admitted); + const hasRunning = [...runningByRequest.values()].some( + (entry) => + entry.migrationScope === GLOBAL_MIGRATION_SCOPE || entry.migrationScope === scope, + ); + const hasQueued = [...queues.values()].some((queue) => + queue.some( + (task) => + task.migrationScope === GLOBAL_MIGRATION_SCOPE || task.migrationScope === scope, + ), + ); + const hasExecuting = [...executing].some((promise) => { + const executingScope = executingScopes.get(promise) ?? GLOBAL_MIGRATION_SCOPE; + return executingScope === GLOBAL_MIGRATION_SCOPE || executingScope === scope; + }); + if (hasRunning || hasQueued || hasExecuting) { + throw new Error('Hook channel has active or queued tasks'); + } + return await operation(); + } finally { + if (migrationBarriers.get(scope) === barrier) migrationBarriers.delete(scope); + release(); + await barrier; + } + }, cancel(connectionId, requestId) { if (!accountActive) return; // 1) 排队中的: 从队列摘除, 立即回 cancelled(任务从未开始) @@ -2581,6 +2770,15 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { }, onDisconnected(connectionId) { sendFns.delete(connectionId); + for (const [opId, pending] of [...pendingMessageOps]) { + if (pending.connectionId !== connectionId) continue; + finishPendingMessageOp(opId, { + opId, + ok: false, + error: 'HOOK_NOT_CONNECTED', + retryAfterMs: 1_000, + }); + } for (const pending of pendingDeliveryTurnEnds.values()) { if (pending.connectionId !== connectionId || pending.timer === null) continue; clearTimeout(pending.timer); @@ -2617,12 +2815,73 @@ export function createHookDispatcher(deps: HookDispatcherDeps): HookDispatcher { sendFns.clear(); pendingTurnEnds.clear(); for (const key of [...pendingDeliveryTurnEnds.keys()]) clearPendingDelivery(key); + for (const opId of [...pendingMessageOps.keys()]) { + finishPendingMessageOp(opId, { opId, ok: false, error: 'DISPOSED' }); + } }, - onMessageOpResult(payload: MessageOpResultPayload) { + onMessageOpResult(connectionId: string, payload: MessageOpResultPayload) { + const pending = pendingMessageOps.get(payload.opId); + if (pending && pending.connectionId !== connectionId) { + log.warn(`msg.op.result for foreign connection dropped: opId=${payload.opId}`); + return; + } + finishPendingMessageOp(payload.opId, payload); // 带上按连接取发送函数的钩子: 群限制了可用表情时要用基础款回落一次, // 而该发到哪条连接由 ackReactions 自己记的 task 决定。 ackReactions.onResult(payload, (connectionId) => sendFns.get(connectionId)); }, + sendMessageOp(connectionId, payload) { + const existing = pendingMessageOps.get(payload.opId); + if (existing) { + if (existing.connectionId !== connectionId) { + return Promise.resolve({ + opId: payload.opId, + ok: false, + error: 'OP_ID_OWNERSHIP_CONFLICT', + }); + } + return existing.promise; + } + const send = sendFns.get(connectionId); + if (!send) { + return Promise.resolve({ + opId: payload.opId, + ok: false, + error: 'HOOK_NOT_CONNECTED', + retryAfterMs: 1_000, + }); + } + if (!serverFeatures.get(connectionId)?.includes(HOOK_FEATURE_MESSAGE_OPS)) { + return Promise.resolve({ + opId: payload.opId, + ok: false, + error: 'MESSAGE_OPS_UNSUPPORTED', + }); + } + let resolve!: (result: MessageOpResultPayload) => void; + const promise = new Promise((done) => { + resolve = done; + }); + const timer = setTimeout(() => { + finishPendingMessageOp(payload.opId, { + opId: payload.opId, + ok: false, + error: 'MESSAGE_OP_TIMEOUT', + retryAfterMs: 5_000, + }); + }, 15_000); + timer.unref?.(); + pendingMessageOps.set(payload.opId, { connectionId, promise, resolve, timer }); + if (!send(makeMessageOp(payload))) { + finishPendingMessageOp(payload.opId, { + opId: payload.opId, + ok: false, + error: 'HOOK_NOT_CONNECTED', + retryAfterMs: 1_000, + }); + } + return promise; + }, setEmojiReactionsMode(mode: TelegramEmojiReactions | null) { emojiReactionsMode = mode; }, diff --git a/apps/desktop/src/main/hook-control/ipc.ts b/apps/desktop/src/main/hook-control/ipc.ts index d4f37a76f0..c1ae46d596 100644 --- a/apps/desktop/src/main/hook-control/ipc.ts +++ b/apps/desktop/src/main/hook-control/ipc.ts @@ -88,8 +88,13 @@ import { mergeTelegramGroupActivationViews, resetGroupContextCursorsSafely, } from './groupWindow.js'; -import { createHookDispatcher } from './dispatcher.js'; +import { createHookDispatcher, type HookDispatcher } from './dispatcher.js'; import { createMakerHookSessionRunner } from './session-runner.js'; +import { + hookBotRouteSourceFromExternalKey, + resolveHookBotRouteTarget, + renewHookBotRouteTarget, +} from './botRouteTarget.js'; import { resolveHookInteraction } from './interactions.js'; import { listRecentHookSessions } from './recentSessions.js'; import { validateTelegramExternalUrl } from './telegramDeepLink.js'; @@ -99,11 +104,14 @@ import { resetTelegramSpeakerRegistrationCache } from '../im/telegram/contactsAu import { assertTrustedAppRendererEvent } from '../security/trustedAppRenderer.js'; import { getAgentIslandService } from '../agent-island/service.js'; import { setLifecycleAnnouncementFromIpc } from './lifecycleAnnouncementIpc.js'; +import type { BotChannelConnection } from '../../shared/botChannelRegistry.js'; +import { hookViewToBotChannelConnections } from './botChannelConnections.js'; const log = createLogger('hook-control'); let store: SlackHookStore | null = null; let manager: HookControlManager | null = null; +let botMigrationDispatcher: HookDispatcher | null = null; let disposeAuthListener: (() => void) | null = null; let observedAuthRealm: ReturnType | null = null; let codexMcpRefreshPending = false; @@ -159,6 +167,58 @@ function disabledHookView(): SlackHookView { }; } +/** Concrete server-relay identities available to Cindy Bots. */ +export function listHookBotChannelConnections(): BotChannelConnection[] { + const view = hookControlAvailable() ? ensureInstances().manager.snapshot() : disabledHookView(); + return hookViewToBotChannelConnections(view); +} + +/** Server-relay Bot delivery with provider-owned idempotency and ACK. */ +export async function sendHookBotRouteMessage(input: { + provider: 'telegram' | 'slack'; + accountKey: string; + externalKey: string; + opId: string; + text: string; +}): Promise< + | { ok: true; messageId?: string | null } + | { ok: false; retryable: boolean; errorCode: string; message: string } +> { + if (!hookControlAvailable()) { + return { + ok: false, + retryable: true, + errorCode: 'HOOK_ACCOUNT_UNAVAILABLE', + message: 'Cindy account relay services are unavailable.', + }; + } + const result = await ensureInstances().manager.sendBotRouteMessage(input); + if (result.ok) return { ok: true, messageId: result.messageId }; + const code = result.error?.trim() || 'RELAY_DELIVERY_FAILED'; + return { + ok: false, + retryable: + result.retryAfterMs !== undefined + || code === 'HOOK_NOT_CONNECTED' + || code === 'MESSAGE_OP_TIMEOUT', + errorCode: code, + message: code, + }; +} + +/** Serialize a relay migration against every legacy hook admission. */ +export async function runHookBotMigrationExclusive( + scope: string, + operation: () => Promise, +): Promise { + if (!hookControlAvailable()) return operation(); + ensureInstances(); + if (!botMigrationDispatcher?.runMigrationExclusive) { + throw new Error('Hook dispatcher migration gate is unavailable'); + } + return botMigrationDispatcher.runMigrationExclusive(scope, operation); +} + /** * Slack 绑定态会改变 lizi_slack 是否出现在 Codex 的冻结 MCP 清单里。 * @@ -340,6 +400,13 @@ function ensureInstances(): { store: SlackHookStore; manager: HookControlManager log, }), runner: createMakerHookSessionRunner({ log }), + resolveBotRouteTarget: ({ externalKey, source }) => + resolveHookBotRouteTarget(externalKey, source), + renewBotRouteTarget: ({ externalKey }) => renewHookBotRouteTarget(externalKey), + migrationScopeForDispatch: ({ externalKey, source }) => { + const routeSource = hookBotRouteSourceFromExternalKey(externalKey, source); + return routeSource?.accountKey ? `${routeSource.platform}:${routeSource.accountKey}` : null; + }, buildContextPrefix: buildGroupContextPrefix, // 新建 hook 会话默认预建独立 worktree(并发隔离); deps 组装与 // maker-ipc/register.ts 的 use_worktree 分支同款。失败由 dispatcher @@ -410,6 +477,7 @@ function ensureInstances(): { store: SlackHookStore; manager: HookControlManager accountInitiallyActive: false, log, }); + botMigrationDispatcher = dispatcher; manager = createHookControlManager({ store, isAvailable: hookControlAvailable, @@ -451,32 +519,36 @@ function ensureInstances(): { store: SlackHookStore; manager: HookControlManager // permissionModes 仍取 capabilities(运行时能力, 与供应商无关), server // 侧据此渲染权限档下拉(选中值经 dispatch options.permissionMode 回流) listAgentModels: async () => { - const providers = await getDesktopProviderService().listProviders({ allowSideEffects: true }); + const providers = await getDesktopProviderService().listProviders({ + allowSideEffects: true, + }); // 动态取 runtime 已注册的 agent(含 Pi,若已安装);上游此处硬编码 cc/codex(早于 Pi), // 本 PR 的 Pi 接入以 listAvailableAgents() 为准 —— 与新建入口按注册结果门控同源。 - return getMaker().listAvailableAgents().map((agentKind) => { - const models = visibleModelUnion(providers, agentKind, (providerId, m) => - isModelVisible( - getModelVisibilityOverride(agentKind, providerId, m.id), - m.defaultEnabled, - ), - ); - return { - agentKind, - models: models.map((m) => ({ - id: m.id, - displayName: m.name, - efforts: m.efforts, - defaultEffort: m.defaultEffort, - // 分组随行: 折扣版(gpt-budget)与官方版 displayName 故意同名, - // Slack 卡与 Tina 下拉都靠 group 加区分后缀 - ...(m.group !== undefined ? { group: m.group } : {}), - })), - permissionModes: getMaker() - .getCapabilities(agentKind) - .permissionModes.map((pm) => ({ id: pm.id, displayName: pm.displayName })), - }; - }); + return getMaker() + .listAvailableAgents() + .map((agentKind) => { + const models = visibleModelUnion(providers, agentKind, (providerId, m) => + isModelVisible( + getModelVisibilityOverride(agentKind, providerId, m.id), + m.defaultEnabled, + ), + ); + return { + agentKind, + models: models.map((m) => ({ + id: m.id, + displayName: m.name, + efforts: m.efforts, + defaultEffort: m.defaultEffort, + // 分组随行: 折扣版(gpt-budget)与官方版 displayName 故意同名, + // Slack 卡与 Tina 下拉都靠 group 加区分后缀 + ...(m.group !== undefined ? { group: m.group } : {}), + })), + permissionModes: getMaker() + .getCapabilities(agentKind) + .permissionModes.map((pm) => ({ id: pm.id, displayName: pm.displayName })), + }; + }); }, // 绑定授权链接: 用系统浏览器打开(远程控制时落被控机, 设置页另给复制链接) openExternalUrl: (url) => { @@ -855,15 +927,18 @@ export function registerHookControlIpc(): void { } }); - registerTrustedHookControlHandler(HOOK_CONTROL_INVOKE.TELEGRAM_BEHAVIOR_GET, async (_e, payload) => { - requireHookControl(); - const bindingId = requireString(requireObject(payload).bindingId, 'bindingId'); - try { - return { behavior: await ensureInstances().manager.getTelegramBehavior(bindingId) }; - } catch (err) { - throwHookPrefsError(err); - } - }); + registerTrustedHookControlHandler( + HOOK_CONTROL_INVOKE.TELEGRAM_BEHAVIOR_GET, + async (_e, payload) => { + requireHookControl(); + const bindingId = requireString(requireObject(payload).bindingId, 'bindingId'); + try { + return { behavior: await ensureInstances().manager.getTelegramBehavior(bindingId) }; + } catch (err) { + throwHookPrefsError(err); + } + }, + ); registerTrustedHookControlHandler( HOOK_CONTROL_INVOKE.TELEGRAM_BEHAVIOR_SET, @@ -902,33 +977,36 @@ export function registerHookControlIpc(): void { }, ); - registerTrustedHookControlHandler(HOOK_CONTROL_INVOKE.TELEGRAM_GROUPS_LIST, async (_e, payload) => { - requireHookControl(); - const bindingId = requireString(requireObject(payload).bindingId, 'bindingId'); - try { - const { manager: m } = ensureInstances(); - const behavior = await m.getTelegramBehavior(bindingId); - const binding = m.snapshot().telegram.binding; - if ( - binding?.state !== 'confirmed' || - binding.bindingId !== bindingId || - binding.bindingId !== behavior.bindingId || - !binding.principalId - ) { - throw new HookNotConnectedError('telegram'); + registerTrustedHookControlHandler( + HOOK_CONTROL_INVOKE.TELEGRAM_GROUPS_LIST, + async (_e, payload) => { + requireHookControl(); + const bindingId = requireString(requireObject(payload).bindingId, 'bindingId'); + try { + const { manager: m } = ensureInstances(); + const behavior = await m.getTelegramBehavior(bindingId); + const binding = m.snapshot().telegram.binding; + if ( + binding?.state !== 'confirmed' || + binding.bindingId !== bindingId || + binding.bindingId !== behavior.bindingId || + !binding.principalId + ) { + throw new HookNotConnectedError('telegram'); + } + const knownGroups = await listTelegramKnownGroupsForStableBinding( + { bindingId: binding.bindingId, principalId: binding.principalId }, + () => m.snapshot().telegram.binding, + ); + if (knownGroups === null) throw new HookNotConnectedError('telegram'); + return { + groups: mergeTelegramGroupActivationViews(knownGroups, behavior.groupActivation), + }; + } catch (err) { + throwHookPrefsError(err); } - const knownGroups = await listTelegramKnownGroupsForStableBinding( - { bindingId: binding.bindingId, principalId: binding.principalId }, - () => m.snapshot().telegram.binding, - ); - if (knownGroups === null) throw new HookNotConnectedError('telegram'); - return { - groups: mergeTelegramGroupActivationViews(knownGroups, behavior.groupActivation), - }; - } catch (err) { - throwHookPrefsError(err); - } - }); + }, + ); registerTrustedHookControlHandler( HOOK_CONTROL_INVOKE.TELEGRAM_GROUP_ACTIVATION_SET, diff --git a/apps/desktop/src/main/hook-control/manager.ts b/apps/desktop/src/main/hook-control/manager.ts index 2654f68e8b..f51abebe19 100644 --- a/apps/desktop/src/main/hook-control/manager.ts +++ b/apps/desktop/src/main/hook-control/manager.ts @@ -52,6 +52,7 @@ import { type HelloInput, type HookMessage, type HookProvider, + type MessageOpResultPayload, type ProviderBindStatusPayload, type ProviderBehaviorSetPayload, type ProviderBehaviorStatePayload, @@ -357,6 +358,14 @@ export interface HookControlManager { chatId: string, mode: TelegramHookGroupActivationMode, ): Promise; + /** Proactive Bot notification through the relay's msg.op receipt path. */ + sendBotRouteMessage(input: { + provider: 'telegram' | 'slack'; + accountKey: string; + externalKey: string; + opId: string; + text: string; + }): Promise; /** 登录/切账号后重新打开 account ingress。 */ activateAccount(): void; /** 同步关闭 ingress、取消/中止旧账号任务并等它们越过最终异步边界。 */ @@ -2153,7 +2162,7 @@ export function createHookControlManager(deps: HookControlManagerDeps): HookCont } if (msg.type === 'msg.op.result') { // 表情回执: 纯装饰动作的结果, 失败只记一行 —— 不重试也不影响任务本身。 - dispatcher?.onMessageOpResult(msg.payload); + dispatcher?.onMessageOpResult(dispatchId(expectedProvider), msg.payload); return; } if (msg.type === 'tool.response') { @@ -3172,6 +3181,43 @@ export function createHookControlManager(deps: HookControlManagerDeps): HookCont }), ); }, + sendBotRouteMessage(input) { + const provider = providerForExternalKey(input.externalKey); + if (provider !== input.provider) { + return Promise.resolve({ + opId: input.opId, + ok: false, + error: 'ROUTE_PROVIDER_MISMATCH', + }); + } + if (input.provider === 'telegram') { + const binding = telegramLane.binding; + if ( + binding?.state !== 'confirmed' + || binding.scopeId !== input.accountKey + ) { + return Promise.resolve({ + opId: input.opId, + ok: false, + error: 'ROUTE_ACCOUNT_MISMATCH', + }); + } + } else if (!activeBindings().some((binding) => binding.teamId === input.accountKey)) { + return Promise.resolve({ + opId: input.opId, + ok: false, + error: 'ROUTE_ACCOUNT_MISMATCH', + }); + } + if (!dispatcher) { + return Promise.resolve({ opId: input.opId, ok: false, error: 'DISPATCHER_UNAVAILABLE' }); + } + return dispatcher.sendMessageOp(dispatchId(input.provider), { + opId: input.opId, + scope: { externalKey: input.externalKey }, + action: { kind: 'send', text: input.text, tier: 'html' }, + }); + }, activateAccount() { if (accountDeactivation !== null) { const generation = accountGeneration; diff --git a/apps/desktop/src/main/im/__tests__/accountBoundary.test.ts b/apps/desktop/src/main/im/__tests__/accountBoundary.test.ts index 1aa1ab3798..b1d0547a27 100644 --- a/apps/desktop/src/main/im/__tests__/accountBoundary.test.ts +++ b/apps/desktop/src/main/im/__tests__/accountBoundary.test.ts @@ -5,6 +5,7 @@ import { captureImAccountGeneration, deactivateImAccountBoundary, runInImAccountGeneration, + runImMigrationExclusive, waitForImAccountGenerationIdle, } from '../accountBoundary'; @@ -55,4 +56,76 @@ describe('IM account boundary', () => { await draining; expect(drained).toBe(true); }); + + it('drains pre-admitted work and blocks new handlers until migration commits', async () => { + const token = captureImAccountGeneration(); + expect(token).not.toBeNull(); + const oldGate = deferred(); + const migrationGate = deferred(); + let oldStarted = false; + let migrationStarted = false; + let newStarted = false; + const oldWork = runInImAccountGeneration(token!, async () => { + oldStarted = true; + await oldGate.promise; + }); + await vi.waitFor(() => expect(oldStarted).toBe(true)); + + const migration = runImMigrationExclusive('telegram', async () => { + migrationStarted = true; + await migrationGate.promise; + }); + const newWork = runInImAccountGeneration(token!, async () => { + newStarted = true; + }); + await Promise.resolve(); + expect(migrationStarted).toBe(false); + expect(newStarted).toBe(false); + + oldGate.resolve(); + await oldWork; + await vi.waitFor(() => expect(migrationStarted).toBe(true)); + expect(newStarted).toBe(false); + + migrationGate.resolve(); + await migration; + await newWork; + expect(newStarted).toBe(true); + }); + + it('does not deadlock work admitted in the same tick before the migration gate', async () => { + const token = captureImAccountGeneration(); + expect(token).not.toBeNull(); + const operation = vi.fn(async () => undefined); + const work = runInImAccountGeneration(token!, operation); + const migration = runImMigrationExclusive('telegram', async () => undefined); + + await expect(Promise.all([work, migration])).resolves.toEqual([undefined, undefined]); + expect(operation).toHaveBeenCalledOnce(); + }); + + it('does not freeze an unrelated IM provider during migration', async () => { + const token = captureImAccountGeneration(); + expect(token).not.toBeNull(); + const gate = deferred(); + let migrationStarted = false; + let feishuStarted = false; + const migration = runImMigrationExclusive('telegram', async () => { + migrationStarted = true; + await gate.promise; + }); + await vi.waitFor(() => expect(migrationStarted).toBe(true)); + + await runInImAccountGeneration( + token!, + async () => { + feishuStarted = true; + }, + 'feishu', + ); + expect(feishuStarted).toBe(true); + + gate.resolve(); + await migration; + }); }); diff --git a/apps/desktop/src/main/im/accountBoundary.ts b/apps/desktop/src/main/im/accountBoundary.ts index e9ef1e4711..8418a4a5b8 100644 --- a/apps/desktop/src/main/im/accountBoundary.ts +++ b/apps/desktop/src/main/im/accountBoundary.ts @@ -8,6 +8,9 @@ export type ImAccountGeneration = number; let active = true; let generation: ImAccountGeneration = 0; const inFlightByGeneration = new Map>>(); +const GLOBAL_MIGRATION_SCOPE = '*'; +const migrationBarriers = new Map>(); +const operationScopes = new WeakMap, string>(); const ACCOUNT_SCOPE_CLOSED_CODE = 'IM_ACCOUNT_SCOPE_CLOSED'; @@ -47,11 +50,23 @@ export function isImAccountGenerationCurrent(token: ImAccountGeneration): boolea export function runInImAccountGeneration( token: ImAccountGeneration, operation: () => Promise, + migrationScope = GLOBAL_MIGRATION_SCOPE, ): Promise { + // Capture at admission. Reading the global barrier later would make a + // pre-migration handler wait on a gate that is simultaneously draining it. + const admittedBarriers = + migrationScope === GLOBAL_MIGRATION_SCOPE + ? [...migrationBarriers.values()] + : [ + migrationBarriers.get(GLOBAL_MIGRATION_SCOPE), + migrationBarriers.get(migrationScope), + ].filter((barrier): barrier is Promise => Boolean(barrier)); const tracked = Promise.resolve().then(async () => { + await Promise.all(admittedBarriers); if (!isImAccountGenerationCurrent(token)) throw new ImAccountScopeClosedError(); return operation(); }); + operationScopes.set(tracked, migrationScope); const inFlight = inFlightByGeneration.get(token) ?? new Set>(); inFlightByGeneration.set(token, inFlight); inFlight.add(tracked); @@ -63,6 +78,43 @@ export function runInImAccountGeneration( return tracked; } +/** + * Blocks new local-IM handlers, drains the handlers admitted before the gate, + * then runs one migration mutation. Existing transports stay connected and + * queued messages resume against the newly committed Bot Route. + */ +export async function runImMigrationExclusive( + migrationScope: string, + operation: () => Promise, +): Promise { + if (migrationBarriers.has(migrationScope)) { + throw new Error('Another IM migration is already running for this Channel'); + } + const token = captureImAccountGeneration(); + const admitted = + token === null + ? [] + : [...(inFlightByGeneration.get(token) ?? [])].filter((promise) => { + const scope = operationScopes.get(promise) ?? GLOBAL_MIGRATION_SCOPE; + return scope === GLOBAL_MIGRATION_SCOPE || scope === migrationScope; + }); + let release!: () => void; + const barrier = new Promise((resolve) => { + release = resolve; + }); + migrationBarriers.set(migrationScope, barrier); + try { + await Promise.allSettled(admitted); + return await operation(); + } finally { + if (migrationBarriers.get(migrationScope) === barrier) { + migrationBarriers.delete(migrationScope); + } + release(); + await barrier; + } +} + /** Wait until every handler admitted by this account has crossed its final async boundary. */ export async function waitForImAccountGenerationIdle(token: ImAccountGeneration): Promise { while (true) { diff --git a/apps/desktop/src/main/im/index.ts b/apps/desktop/src/main/im/index.ts index 16f81a6661..78d65460cf 100644 --- a/apps/desktop/src/main/im/index.ts +++ b/apps/desktop/src/main/im/index.ts @@ -92,6 +92,12 @@ import { bindingStore, executeDetach } from './binding'; import { IM_DEFAULT_EFFORT_OVERRIDES, IM_DEFAULT_SETTINGS } from '../../shared/imDefaultSettings'; import { getAuthState } from '../authManager'; import { getUpdateStatus, isUpdateRelaunchImminent } from '../updateService'; +import { listHookBotChannelConnections } from '../hook-control/ipc.js'; +import { + LOCAL_BOT_CHANNEL_FEATURES, + type BotChannelConnection, +} from '../../shared/botChannelRegistry.js'; +import { materializeLocalMarkdownImages } from './shared/localMarkdownImages.js'; import { createLogger } from '../logger'; import { assertTrustedAppRendererEvent } from '../security/trustedAppRenderer'; @@ -114,6 +120,199 @@ export { const log = createLogger('main:im'); +export interface BotRouteDeliveryInput { + channel: string; + ownership: 'local-adapter' | 'server-relay'; + accountKey: string; + principalKey: string; + threadKey?: string | null; + deliveryKey?: string | null; + idempotencyKey: string; + text: string; + sessionId?: string | null; + workingDir?: string | null; + onProgress?: (receipt: Record) => Promise; +} + +export type BotRouteDeliveryResult = + | { ok: true; receipt: Record } + | { ok: false; retryable: boolean; errorCode: string; message: string }; + +/** + * Deliver a proactive Bot result through the already-wired local IM adapter. + * + * The Bot domain owns retry/idempotency; the adapter continues to own the + * channel's final formatting, thread semantics and provider acknowledgement. + * Server-relay delivery deliberately does not fall back to a local adapter: + * that would cross account/transport ownership and could send to the wrong + * bot identity. + */ +export async function deliverBotRouteMessage( + input: BotRouteDeliveryInput, +): Promise { + if (input.ownership === 'server-relay') { + if ( + (input.channel !== 'telegram' && input.channel !== 'slack') + || !input.deliveryKey?.trim() + ) { + return { + ok: false, + retryable: false, + errorCode: 'RELAY_ROUTE_UNADDRESSABLE', + message: 'The server-relay Bot Route has no authenticated delivery address.', + }; + } + const { sendHookBotRouteMessage } = await import('../hook-control/ipc.js'); + const result = await sendHookBotRouteMessage({ + provider: input.channel, + accountKey: input.accountKey, + externalKey: input.deliveryKey, + opId: input.idempotencyKey, + text: input.text, + }); + return result.ok + ? { + ok: true, + receipt: { + channel: input.channel, + ...(result.messageId ? { messageId: result.messageId } : {}), + }, + } + : result; + } + const connection = listBotChannelConnections().find( + (item) => item.kind === input.channel + && item.ownership === input.ownership + && item.accountKey === input.accountKey, + ); + if (!connection) { + return { + ok: false, + retryable: false, + errorCode: 'CHANNEL_IDENTITY_MISMATCH', + message: 'The mounted Bot Channel no longer matches the local adapter identity.', + }; + } + if (!connection.connected) { + return { + ok: false, + retryable: true, + errorCode: 'CHANNEL_OFFLINE', + message: `The ${input.channel} adapter is offline.`, + }; + } + const orchestrator = getImOrchestrator(input.channel); + if (!orchestrator) { + return { + ok: false, + retryable: true, + errorCode: 'CHANNEL_NOT_READY', + message: `The ${input.channel} adapter is not ready.`, + }; + } + try { + const materialized = input.sessionId && input.workingDir && input.text.includes('![') + ? await materializeLocalMarkdownImages({ + text: input.text, + workingDir: input.workingDir, + sessionId: input.sessionId, + maxImages: 4, + }) + : { text: input.text, absPaths: [] }; + // Personal WeChat supports proactive sends through its latest encrypted + // peer context, while commitFinal is intentionally restricted to a live + // inbound task. Its provider clientId is the Bot outbox key, so a host + // crash can safely replay the same operation without creating a duplicate. + if (input.channel === 'wechat') { + const sent = await orchestrator.adapter.output.im.sendMarkdownText( + input.principalKey, + materialized.text, + { + ...(input.threadKey ? { threadTs: input.threadKey } : {}), + idempotencyKey: input.idempotencyKey, + }, + ); + await input.onProgress?.({ textMessageId: sent.messageId, sentMediaCount: 0 }); + for (let index = 0; index < materialized.absPaths.length; index += 1) { + const result = await orchestrator.adapter.output.im.sendFile( + input.principalKey, + materialized.absPaths[index]!, + undefined, + { + idempotencyKey: `${input.idempotencyKey}:media:${index}`, + }, + ); + if (!result.ok) { + throw new Error(`WECHAT_MEDIA_SEND_FAILED:${result.reason}`); + } + await input.onProgress?.({ + sentMediaCount: index + 1, + ...(result.messageId ? { lastMediaMessageId: result.messageId } : {}), + }); + } + return { + ok: true, + receipt: { channel: input.channel, messageId: sent.messageId }, + }; + } + if (orchestrator.adapter.output.kind === 'chunked-text') { + await orchestrator.adapter.output.commitFinal({ + userId: input.principalKey, + text: materialized.text, + terminal: 'done', + ...(input.threadKey ? { threadTs: input.threadKey } : {}), + ...(materialized.absPaths.length > 0 + ? { mediaAbsPaths: materialized.absPaths } + : {}), + ...(input.workingDir ? { allowedFileRoots: [input.workingDir] } : {}), + }); + await input.onProgress?.({ committedFinal: true }); + return { + ok: true, + receipt: { channel: input.channel, accepted: true }, + }; + } + const sent = await orchestrator.adapter.output.im.sendMarkdownText( + input.principalKey, + materialized.text, + input.threadKey ? { threadTs: input.threadKey } : undefined, + ); + await input.onProgress?.({ textMessageId: sent.messageId, sentMediaCount: 0 }); + const attachmentMessageIds: string[] = []; + for (const absPath of materialized.absPaths) { + const attachment = await orchestrator.adapter.output.im.sendFile( + input.principalKey, + absPath, + undefined, + input.threadKey ? { threadTs: input.threadKey } : undefined, + ); + if (!attachment.ok) { + throw new Error(`CHANNEL_MEDIA_SEND_FAILED:${attachment.reason}`); + } + if (attachment.messageId) attachmentMessageIds.push(attachment.messageId); + await input.onProgress?.({ + sentMediaCount: attachmentMessageIds.length, + attachmentMessageIds: [...attachmentMessageIds], + }); + } + return { + ok: true, + receipt: { + channel: input.channel, + messageId: sent.messageId, + ...(attachmentMessageIds.length > 0 ? { attachmentMessageIds } : {}), + }, + }; + } catch (error) { + return { + ok: false, + retryable: true, + errorCode: 'CHANNEL_SEND_FAILED', + message: error instanceof Error ? error.message : String(error), + }; + } +} + let wired = false; export interface DesktopCcPrefs { @@ -229,6 +428,11 @@ export function startImOrchestrators(): void { wireWechatOrchestrator(wechatIm, WECHAT_CONFIG); wireWecomOrchestrator(wecomIm, WECOM_CONFIG); + ipcMain.handle('local-db:bots:channel-connections', (event) => { + assertTrustedAppRendererEvent(event); + return listBotChannelConnections(); + }); + ipcMain.handle('wechatBot:get-state', (event) => { assertTrustedAppRendererEvent(event); return wechatIm.getState(); @@ -403,6 +607,40 @@ export function startImOrchestrators(): void { // ready. See module header table for full lifecycle. } +/** + * Main-side authority for concrete Bot Channel identities. + * Migration and renderer listing must consume the same snapshot; renderer + * supplied account ids or capability flags are never trusted. + */ +export function listBotChannelConnections(): BotChannelConnection[] { + const channels = [ + ['telegram', telegramIm], + ['feishu', feishuIm], + ['discord', discordIm], + ['dingtalk', dingtalkIm], + ['wechat', wechatIm], + ['wecom', wecomIm], + ] as const; + const localConnections: BotChannelConnection[] = channels.map(([kind, channelIm]) => { + const status = channelIm.getStatus(); + const accountKey = + 'appId' in status && typeof status.appId === 'string' ? status.appId.trim() : ''; + return { + id: `local:${kind}:${accountKey || 'unconfigured'}`, + kind, + ownership: 'local-adapter' as const, + status: status.kind, + connected: status.kind === 'connected', + accountKey: accountKey || null, + accountName: null, + scopeKey: accountKey || null, + routable: accountKey.length > 0, + features: [...(LOCAL_BOT_CHANNEL_FEATURES[kind] ?? [])], + }; + }); + return [...localConnections, ...listHookBotChannelConnections()]; +} + async function initializeImConnection(): Promise { await reconcileOwnerScopedImWorkingDirs(); // 个人 Telegram 群窗口不做自动清理(Chris 2026-07-30: 本地群消息库即 bot diff --git a/apps/desktop/src/main/im/shared/__tests__/botRouteTarget.test.ts b/apps/desktop/src/main/im/shared/__tests__/botRouteTarget.test.ts new file mode 100644 index 0000000000..515cb2b991 --- /dev/null +++ b/apps/desktop/src/main/im/shared/__tests__/botRouteTarget.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { botRouteSourceForMessage } from '../botRouteSource'; + +describe('botRouteSourceForMessage', () => { + it('keeps account, chat surface, outbound principal and thread dimensions separate', () => { + expect( + botRouteSourceForMessage('feishu', { + contextId: 'cli_bot_app', + chatId: 'oc_group_chat', + senderId: 'g/oc_group_chat/omt_thread', + scopeKey: 'omt_thread', + threadTs: 'omt_thread', + }), + ).toEqual({ + platform: 'feishu', + accountKey: 'cli_bot_app', + scopeKey: 'oc_group_chat', + principalKey: 'g/oc_group_chat/omt_thread', + parentPrincipalKey: 'g/oc_group_chat/omt_thread', + threadKey: 'omt_thread', + }); + }); + + it('uses the adapter sender lane for direct-message delivery', () => { + expect( + botRouteSourceForMessage('telegram', { + contextId: 'telegram_bot_id', + chatId: 'chat_42', + senderId: 'user_42', + }), + ).toEqual({ + platform: 'telegram', + accountKey: 'telegram_bot_id', + scopeKey: 'chat_42', + principalKey: 'user_42', + }); + }); +}); diff --git a/apps/desktop/src/main/im/shared/__tests__/earlyUserMessagePersist.test.ts b/apps/desktop/src/main/im/shared/__tests__/earlyUserMessagePersist.test.ts index 18197887d4..a827ff7446 100644 --- a/apps/desktop/src/main/im/shared/__tests__/earlyUserMessagePersist.test.ts +++ b/apps/desktop/src/main/im/shared/__tests__/earlyUserMessagePersist.test.ts @@ -27,6 +27,8 @@ const mocks = vi.hoisted(() => ({ }, })); +vi.mock('../../../authManager.js', () => ({ getDeviceId: () => 'test-device' })); + vi.mock('../../../logger', () => ({ createLogger: () => mocks.logger, })); @@ -34,6 +36,18 @@ vi.mock('../../../logger', () => ({ vi.mock('../slashCommands', () => ({ looksLikeSlashCommand: (text: string) => text.startsWith('/'), })); +vi.mock('../botRouteTarget', () => ({ + botRouteSourceForMessage: (channel: string, event: { contextId: string; chatId: string; scopeKey?: string; threadTs?: string }) => { + const threadKey = event.threadTs?.trim() || event.scopeKey?.trim() || undefined; + return { + platform: channel, + accountKey: event.contextId, + scopeKey: event.contextId, + principalKey: event.chatId, + ...(threadKey ? { parentPrincipalKey: event.chatId, threadKey } : {}), + }; + }, +})); import { createMessageHandler } from '../messageHandler'; import { activateImAccountBoundary } from '../../accountBoundary'; @@ -106,6 +120,7 @@ function wire(opts: { withPrepare: boolean } = { withPrepare: true }): Harness { { runAgentTurn, persistInboundUserMessageEarly: persistEarly, + resolveRouteTarget: vi.fn(async () => null), stopActiveTurn: vi.fn(async () => ({ stopped: false, droppedQueued: 0 })), } as unknown as ImTurnRunner, ); diff --git a/apps/desktop/src/main/im/shared/__tests__/slashCommands.test.ts b/apps/desktop/src/main/im/shared/__tests__/slashCommands.test.ts index e7e42cde10..ebd1a4d1bd 100644 --- a/apps/desktop/src/main/im/shared/__tests__/slashCommands.test.ts +++ b/apps/desktop/src/main/im/shared/__tests__/slashCommands.test.ts @@ -84,6 +84,7 @@ function makeTurnRunner(overrides: Partial = {}): ImTurnRunner { return { runAgentTurn: vi.fn(), resolveRouteTarget: vi.fn(async () => ({ row: defaultRow, attached: false })), + renewBotRouteTarget: vi.fn(async () => ({ row: defaultRow, attached: false, created: true })), hasAuthForRoute: vi.fn(async () => true), prewireAttachedSession: vi.fn(), detachFromSession: vi.fn(), @@ -181,6 +182,91 @@ describe('IM slash commands', () => { expect(mocks.sendMarkdownText).toHaveBeenCalledWith('ou_user', ui.agent.apiKeyMissing); }); + it('首条 slash 带 consumePendingOpener: 首个文本回复 patch 开场白卡, 不另发', async () => { + const { handlers } = makeHarness(); + const withMarkdown = vi.fn(async () => true); + const withCard = vi.fn(async () => true); + + const handled = await handlers.handleSlashCommand('/help', { + botContextId: 'bot', + userId: 'ou_user', + consumePendingOpener: { withMarkdown, withCard }, + }); + + expect(handled).toBe(true); + expect(withMarkdown).toHaveBeenCalledWith('ou_user', ui.slash.help); + expect(mocks.sendMarkdownText).not.toHaveBeenCalled(); + }); + + it('首条 slash 消费失败时回落正常发送', async () => { + const { handlers } = makeHarness(); + const withMarkdown = vi.fn(async () => false); + + const handled = await handlers.handleSlashCommand('/help', { + botContextId: 'bot', + userId: 'ou_user', + consumePendingOpener: { withMarkdown, withCard: vi.fn(async () => false) }, + }); + + expect(handled).toBe(true); + expect(withMarkdown).toHaveBeenCalledTimes(1); + expect(mocks.sendMarkdownText).toHaveBeenCalledWith('ou_user', ui.slash.help); + }); + + it('opener patch 失败时回落正常发送(用户仍收到回复)', async () => { + const { handlers } = makeHarness(); + const withMarkdown = vi.fn(async () => { + throw new Error('patch failed'); + }); + + const handled = await handlers.handleSlashCommand('/help', { + botContextId: 'bot', + userId: 'ou_user', + consumePendingOpener: { withMarkdown, withCard: vi.fn(async () => false) }, + }); + + expect(handled).toBe(true); + expect(withMarkdown).toHaveBeenCalledTimes(1); + // patch 抛错不吞回复 — 回落正常发送。 + expect(mocks.sendMarkdownText).toHaveBeenCalledWith('ou_user', ui.slash.help); + }); + + it('opener 卡片替换失败时回落正常发卡', async () => { + const { handlers } = makeHarness(); + const withCard = vi.fn(async () => { + throw new Error('patch failed'); + }); + + const handled = await handlers.handleSlashCommand('/ctr', { + botContextId: 'bot', + userId: 'ou_user', + consumePendingOpener: { withMarkdown: vi.fn(async () => false), withCard }, + }); + + expect(handled).toBe(true); + expect(withCard).toHaveBeenCalledTimes(1); + expect(mocks.sendInteractiveCard).toHaveBeenCalled(); + }); + + it('/ctr 发卡失败时不 enterControl(用户不会被锁死)', async () => { + const { enterControl } = await import('../controlState'); + mocks.sendInteractiveCard.mockRejectedValueOnce(new Error('send failed')); + const { handlers } = makeHarness(); + + await handlers.handleSlashCommand('/ctr', SLASH_CTX); + + expect(enterControl).not.toHaveBeenCalled(); + }); + + it('/ctr 发卡成功才 enterControl', async () => { + const { enterControl } = await import('../controlState'); + const { handlers } = makeHarness(); + + await handlers.handleSlashCommand('/ctr', SLASH_CTX); + + expect(enterControl).toHaveBeenCalledWith('bot', 'ou_user'); + }); + it('explains the persisted provider when /new defaults are unauthenticated', async () => { const prepared = { ...defaultRow, agentKind: 'codex' as const, model: 'gpt-5.5' }; const repo = makeRepo({ prepareNewSession: vi.fn(async () => prepared) }); @@ -225,6 +311,41 @@ describe('IM slash commands', () => { expect(mocks.sendMarkdownText).toHaveBeenCalledWith('ou_user', ui.slash.new); }); + it('renews a mounted Cindy Bot Route without touching the legacy IM task row', async () => { + const routeSource = { + platform: 'telegram' as const, + accountKey: 'telegram-bot-1', + principalKey: '-1001', + }; + const renewBotRouteTarget = vi.fn(async () => ({ + row: { ...defaultRow, id: 'bot-route-next' }, + attached: false, + created: true, + })); + const turnRunner = makeTurnRunner({ + resolveRouteTarget: vi.fn(async () => ({ + row: { ...defaultRow, id: 'bot-route-current' }, + attached: false, + created: false, + })), + renewBotRouteTarget, + }); + const repo = makeRepo(); + const { handlers } = makeHarness({ repo, turnRunner }); + + await handlers.handleSlashCommand('/new', { + botContextId: 'telegram-bot-1', + userId: 'owner-1', + botRouteSource: routeSource, + }); + + expect(renewBotRouteTarget).toHaveBeenCalledWith(routeSource); + expect(repo.prepareNewSession).not.toHaveBeenCalled(); + expect(repo.createSession).not.toHaveBeenCalled(); + expect(mocks.resetSessionToDefaults).not.toHaveBeenCalled(); + expect(mocks.sendMarkdownText).toHaveBeenCalledWith('owner-1', ui.slash.new); + }); + it('does not send /model picker when creating the target session would fail auth', async () => { const turnRunner = makeTurnRunner({ resolveRouteTarget: vi.fn(async () => null), diff --git a/apps/desktop/src/main/im/shared/__tests__/stopCommandRouting.test.ts b/apps/desktop/src/main/im/shared/__tests__/stopCommandRouting.test.ts index 9b3d342da9..3557badd75 100644 --- a/apps/desktop/src/main/im/shared/__tests__/stopCommandRouting.test.ts +++ b/apps/desktop/src/main/im/shared/__tests__/stopCommandRouting.test.ts @@ -19,6 +19,8 @@ const mocks = vi.hoisted(() => ({ }, })); +vi.mock('../../../authManager.js', () => ({ getDeviceId: () => 'test-device' })); + vi.mock('../../../logger', () => ({ createLogger: () => mocks.logger, })); @@ -28,6 +30,18 @@ vi.mock('../../../logger', () => ({ vi.mock('../slashCommands', () => ({ looksLikeSlashCommand: (text: string) => text.startsWith('/'), })); +vi.mock('../botRouteTarget', () => ({ + botRouteSourceForMessage: (channel: string, event: { contextId: string; chatId: string; scopeKey?: string; threadTs?: string }) => { + const threadKey = event.threadTs?.trim() || event.scopeKey?.trim() || undefined; + return { + platform: channel, + accountKey: event.contextId, + scopeKey: event.contextId, + principalKey: event.chatId, + ...(threadKey ? { parentPrincipalKey: event.chatId, threadKey } : {}), + }; + }, +})); import { createMessageHandler, isStopCommand } from '../messageHandler'; import { @@ -92,6 +106,8 @@ describe('messageHandler !stop routing', () => { let handleSlashCommand: ReturnType; let sendMarkdownText: ReturnType; let sendText: ReturnType; + let consumePendingOpenerCard: ReturnType; + let consumePendingOpenerAsCard: ReturnType; let deliver: (event: IMMessageEvent) => void; function wire(threadScoped: boolean): void { @@ -100,6 +116,9 @@ describe('messageHandler !stop routing', () => { handleSlashCommand = vi.fn(async () => true); sendMarkdownText = vi.fn(async () => undefined); sendText = vi.fn(async () => undefined); + // 群主流 @ 开话题的开场白卡收口能力(仅 feishu 实现; 这里模拟富卡渠道)。 + consumePendingOpenerCard = vi.fn(async () => false); + consumePendingOpenerAsCard = vi.fn(async () => false); const im = { onMessage(handler: (event: IMMessageEvent) => void) { @@ -108,11 +127,14 @@ describe('messageHandler !stop routing', () => { }, sendMarkdownText, sendText, + consumePendingOpenerCard, + consumePendingOpenerAsCard, } as unknown as ChannelIM; const adapter = { channel: 'slack', im, + output: { kind: 'rich-card', im }, ui: slackUi, threadScoped, } as unknown as ImChannelAdapter; @@ -220,12 +242,22 @@ describe('messageHandler !stop routing', () => { botContextId: 'bot-ctx', userId: 'U123456789', scopeKey: '1234.5678', + botRouteSource: { + platform: 'slack', + accountKey: 'bot-ctx', + scopeKey: 'bot-ctx', + principalKey: 'D123456789', + parentPrincipalKey: 'D123456789', + threadKey: '1234.5678', + }, }); expect(runAgentTurn).not.toHaveBeenCalled(); expect(handleSlashCommand).not.toHaveBeenCalled(); - expect(sendMarkdownText).toHaveBeenCalledWith('U123456789', slackUi.agent.stopDone(0), { - threadTs: '1234.5678', - }); + await vi.waitFor(() => + expect(sendMarkdownText).toHaveBeenCalledWith('U123456789', slackUi.agent.stopDone(0), { + threadTs: '1234.5678', + }), + ); }); it('mentions dropped queued messages in the stopDone reply', async () => { @@ -258,6 +290,12 @@ describe('messageHandler !stop routing', () => { botContextId: 'bot-ctx', userId: 'U123456789', scopeKey: undefined, + botRouteSource: { + platform: 'slack', + accountKey: 'bot-ctx', + scopeKey: 'bot-ctx', + principalKey: 'D123456789', + }, }); }); @@ -405,4 +443,218 @@ describe('messageHandler !stop routing', () => { ); expect(runAgentTurn).not.toHaveBeenCalled(); }); + + // ── 群主流 @ 开话题的开场白卡收口(仅富卡渠道) ──────────────────────────── + // 非流式终态分支若让「思考中」开场白卡留在话题里: 卡卡住, 且同话题下一条 + // 真消息会把答案 patch 到这张旧卡上。终态分支要么消费卡 patch 回复, 要么 + // 撤回卡让自备回复成为第一条实质内容。 + + it('!stop 首条消费开场白卡: patch 回复, 不再另发', async () => { + consumePendingOpenerCard.mockResolvedValue(true); + deliver(makeEvent({ groupContextLane: { chatId: 'C1', threadId: '' } })); + await flushMicrotasks(); + + expect(consumePendingOpenerCard).toHaveBeenCalledWith( + 'U123456789', + slackUi.agent.stopDone(0), + ); + expect(sendMarkdownText).not.toHaveBeenCalled(); + expect(stopActiveTurn).toHaveBeenCalledTimes(1); + }); + + it('consume 返回 false 时回落正常发送(有 pending opener 但消费失败)', async () => { + deliver(makeEvent({ groupContextLane: { chatId: 'C1', threadId: '' } })); + await flushMicrotasks(); + + expect(consumePendingOpenerCard).toHaveBeenCalledTimes(1); + await vi.waitFor(() => + expect(sendMarkdownText).toHaveBeenCalledWith('U123456789', slackUi.agent.stopDone(0), { + threadTs: '1234.5678', + }), + ); + }); + + it('slash 抛错时用内部错误收口开场白卡(sink 未被调用过)', async () => { + handleSlashCommand.mockRejectedValueOnce(new Error('list projects failed')); + consumePendingOpenerCard.mockResolvedValue(true); + deliver(makeEvent({ text: '/ctr', groupContextLane: { chatId: 'C1', threadId: '' } })); + await flushMicrotasks(); + + expect(consumePendingOpenerCard).toHaveBeenCalledWith( + 'U123456789', + slackUi.agent.sendInternalError('list projects failed'), + ); + expect(sendMarkdownText).not.toHaveBeenCalled(); + expect(runAgentTurn).not.toHaveBeenCalled(); + }); + + it('slash 抛错且 opener 收口返回 false 时补发内部错误', async () => { + handleSlashCommand.mockRejectedValueOnce(new Error('list projects failed')); + consumePendingOpenerCard.mockResolvedValue(false); + deliver(makeEvent({ text: '/ctr', groupContextLane: { chatId: 'C1', threadId: '' } })); + await flushMicrotasks(); + + expect(consumePendingOpenerCard).toHaveBeenCalledWith( + 'U123456789', + slackUi.agent.sendInternalError('list projects failed'), + ); + await vi.waitFor(() => + expect(sendMarkdownText).toHaveBeenCalledWith( + 'U123456789', + slackUi.agent.sendInternalError('list projects failed'), + { threadTs: '1234.5678' }, + ), + ); + expect(runAgentTurn).not.toHaveBeenCalled(); + }); + + it('runAgentTurn 抛错且本条开了话题: 用内部错误收口开场白卡', async () => { + runAgentTurn.mockRejectedValueOnce(new Error('provider exploded')); + consumePendingOpenerCard.mockResolvedValue(true); + deliver(makeEvent({ text: '帮我看看', groupContextLane: { chatId: 'C1', threadId: '' } })); + await flushMicrotasks(); + + expect(consumePendingOpenerCard).toHaveBeenCalledWith( + 'U123456789', + slackUi.agent.sendInternalError('provider exploded'), + ); + expect(sendText).not.toHaveBeenCalled(); + }); + + it('早期拒绝终态(missing_auth)经 onEarlyReject 收口开场白卡', async () => { + let capturedArgs: Parameters[0] | undefined; + runAgentTurn.mockImplementationOnce(async (args: Parameters[0]) => { + capturedArgs = args; + const consumed = (await args.onEarlyReject?.('missing_auth', 'AUTH_MISSING_TEXT')) ?? false; + expect(consumed).toBe(true); + }); + consumePendingOpenerCard.mockResolvedValue(true); + deliver(makeEvent({ text: '帮我看看', groupContextLane: { chatId: 'C1', threadId: '' } })); + await flushMicrotasks(); + + expect(capturedArgs).toBeDefined(); + expect(consumePendingOpenerCard).toHaveBeenCalledWith('U123456789', 'AUTH_MISSING_TEXT'); + }); + + it('同话题后续 !stop 不消费上一轮的 pending opener(归属不抢占)', async () => { + // 消息 A 已开话题且其 pending opener 尚未被流式认领; 本条 B 是同一话题的 + // 后续消息(groupContextLane 缺省)— B 不得 patch A 的思考卡。 + consumePendingOpenerCard.mockResolvedValue(true); + deliver(makeEvent({ text: '!stop' })); + await flushMicrotasks(); + + expect(consumePendingOpenerCard).not.toHaveBeenCalled(); + await vi.waitFor(() => + expect(sendMarkdownText).toHaveBeenCalledWith('U123456789', slackUi.agent.stopDone(0), { + threadTs: '1234.5678', + }), + ); + }); + + it('同话题后续 slash 不注入 opener sink', async () => { + let capturedCtx: Parameters[1] | undefined; + handleSlashCommand.mockImplementation(async (_text: string, ctx: Parameters[1]) => { + capturedCtx = ctx; + return true; + }); + deliver(makeEvent({ text: '/project' })); + await flushMicrotasks(); + + expect(handleSlashCommand).toHaveBeenCalledTimes(1); + expect(capturedCtx?.consumePendingOpener).toBeUndefined(); + }); + + it('consume patch 抛错时回落正常发送(回复不丢, 认领已完成)', async () => { + consumePendingOpenerCard.mockRejectedValue(new Error('patch failed')); + deliver(makeEvent({ groupContextLane: { chatId: 'C1', threadId: '' } })); + await flushMicrotasks(); + + await vi.waitFor(() => + expect(sendMarkdownText).toHaveBeenCalledWith('U123456789', slackUi.agent.stopDone(0), { + threadTs: '1234.5678', + }), + ); + expect(runAgentTurn).not.toHaveBeenCalled(); + }); + + it('slash 首条注入 opener sink: 首个回复就地消费开场白卡', async () => { + let capturedCtx: Parameters[1] | undefined; + handleSlashCommand.mockImplementation(async (_text: string, ctx: Parameters[1]) => { + capturedCtx = ctx; + return true; + }); + consumePendingOpenerCard.mockResolvedValue(true); + deliver(makeEvent({ text: '/project', groupContextLane: { chatId: 'C1', threadId: '' } })); + await flushMicrotasks(); + + expect(handleSlashCommand).toHaveBeenCalledTimes(1); + expect(runAgentTurn).not.toHaveBeenCalled(); + + // sink 存在且首个 withMarkdown 消费开场白卡(第二次起回落)。 + expect(capturedCtx?.consumePendingOpener).toBeDefined(); + await expect(capturedCtx!.consumePendingOpener!.withMarkdown('U123456789', '回复')).resolves.toBe(true); + expect(consumePendingOpenerCard).toHaveBeenCalledWith('U123456789', '回复'); + await expect(capturedCtx!.consumePendingOpener!.withMarkdown('U123456789', '第二条')).resolves.toBe(false); + expect(consumePendingOpenerCard).toHaveBeenCalledTimes(1); + }); + + it('纯 unsupported 首条消费开场白卡: patch 提示, 不再另发', async () => { + consumePendingOpenerCard.mockResolvedValue(true); + deliver( + makeEvent({ + text: '', + unsupported: [{ type: 'audio', label: '语音(暂不支持)' }] as IMMessageEvent['unsupported'], + groupContextLane: { chatId: 'C1', threadId: '' }, + }), + ); + await flushMicrotasks(); + + expect(consumePendingOpenerCard).toHaveBeenCalledWith( + 'U123456789', + slackUi.agent.unsupportedOnly([{ type: 'audio', label: '语音(暂不支持)' }]), + ); + expect(sendText).not.toHaveBeenCalled(); + expect(runAgentTurn).not.toHaveBeenCalled(); + }); + + it('同话题后续 !stop 不消费上一轮的 pending opener(归属不抢占)', async () => { + // 消息 A 已开话题且其 pending opener 尚未被流式认领; 本条 B 是同一话题的 + // 后续消息(groupContextLane 缺省)— B 不得 patch A 的思考卡。 + consumePendingOpenerCard.mockResolvedValue(true); + deliver(makeEvent({ text: '!stop' })); + await flushMicrotasks(); + + expect(consumePendingOpenerCard).not.toHaveBeenCalled(); + expect(sendMarkdownText).toHaveBeenCalledWith('U123456789', slackUi.agent.stopDone(0), { + threadTs: '1234.5678', + }); + }); + + it('同话题后续 slash 不注入 opener sink', async () => { + let capturedCtx: Parameters[1] | undefined; + handleSlashCommand.mockImplementation(async (_text: string, ctx: Parameters[1]) => { + capturedCtx = ctx; + return true; + }); + deliver(makeEvent({ text: '/project' })); + await flushMicrotasks(); + + expect(handleSlashCommand).toHaveBeenCalledTimes(1); + expect(capturedCtx?.consumePendingOpener).toBeUndefined(); + }); + + it('同话题后续纯 unsupported 不消费上一轮的 pending opener', async () => { + consumePendingOpenerCard.mockResolvedValue(true); + deliver( + makeEvent({ + text: '', + unsupported: [{ type: 'audio', label: '语音(暂不支持)' }] as IMMessageEvent['unsupported'], + }), + ); + await flushMicrotasks(); + + expect(consumePendingOpenerCard).not.toHaveBeenCalled(); + expect(sendText).toHaveBeenCalledTimes(1); + expect(runAgentTurn).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/main/im/shared/__tests__/turnRunnerSendOutcome.test.ts b/apps/desktop/src/main/im/shared/__tests__/turnRunnerSendOutcome.test.ts index 538965ca7e..02dbf6d0fe 100644 --- a/apps/desktop/src/main/im/shared/__tests__/turnRunnerSendOutcome.test.ts +++ b/apps/desktop/src/main/im/shared/__tests__/turnRunnerSendOutcome.test.ts @@ -34,6 +34,9 @@ const mocks = vi.hoisted(() => ({ patchMarkdownCard: vi.fn(), sendInteractiveCard: vi.fn(), updateInteractiveCard: vi.fn(), + consumePendingOpenerCard: vi.fn(), + getPendingOpenerTrigger: vi.fn(), + takeNotedFallbackOpenerId: vi.fn(), }, getMaker: vi.fn(), listProviders: vi.fn(), @@ -419,6 +422,7 @@ interface TurnOverrides { protectedContent?: boolean; groupHistoryAccess?: GroupHistoryAccessScope; prePersistedUserMessage?: { sessionId: string; clientId: string }; + onEarlyReject?: (reason: string, text: string) => Promise | boolean; } async function runDefaultTurn(onTurnComplete = vi.fn(), overrides: TurnOverrides = {}) { @@ -441,6 +445,7 @@ async function startDefaultTurn(onTurnComplete = vi.fn(), overrides: TurnOverrid ...(overrides.prePersistedUserMessage ? { prePersistedUserMessage: overrides.prePersistedUserMessage } : {}), + ...(overrides.onEarlyReject ? { onEarlyReject: overrides.onEarlyReject } : {}), }); return { onTurnComplete, turnPromise }; } @@ -542,6 +547,9 @@ describe('turnRunner send outcome policy (feishu adapter characterization)', () mocks.feishuIm.removeMessageReaction.mockResolvedValue(undefined); mocks.feishuIm.sendText.mockResolvedValue(undefined); mocks.feishuIm.sendMarkdownText.mockResolvedValue(undefined); + mocks.feishuIm.consumePendingOpenerCard.mockResolvedValue(false); + mocks.feishuIm.getPendingOpenerTrigger.mockReturnValue(undefined); + mocks.feishuIm.takeNotedFallbackOpenerId.mockReturnValue(undefined); mocks.feishuIm.startStreamingText.mockResolvedValue({ messageId: 'stream-1', append: vi.fn(), @@ -1394,6 +1402,28 @@ describe('turnRunner send outcome policy (feishu adapter characterization)', () expect(mocks.feishuIm.sendText).toHaveBeenCalledTimes(1); }); + it('早期拒绝兜底发送认领暂存 opener, 避免排空后再发一份', async () => { + setupSession(async () => ({ + accepted: false, + reason: 'cancelled-before-dispatch', + })); + mocks.feishuIm.takeNotedFallbackOpenerId.mockReturnValue('om_deferred'); + await runDefaultTurn(vi.fn(), { + onEarlyReject: async () => false, + }); + await flushMicrotasks(); + + expect(mocks.feishuIm.takeNotedFallbackOpenerId).toHaveBeenCalledWith( + 'ou_user', + 'markdown', + ); + expect(mocks.feishuIm.sendText).toHaveBeenCalledWith( + 'ou_user', + expect.stringContaining('启动 agent 失败'), + expect.objectContaining({ fallbackOpenerId: 'om_deferred' }), + ); + }); + it('group history lease: live during the turn, released at terminal', async () => { resetGroupHistoryAccessForTests(); const h = setupSession(async () => ({ accepted: true })); @@ -3090,5 +3120,76 @@ describe('初始流式输出面创建失败的收口降级(#2164)', () => { ); }); }); + + it('无文本输出兜底发送认领暂存 opener', async () => { + mocks.feishuIm.startStreamingText.mockRejectedValue(new Error('card create denied')); + mocks.feishuIm.getPendingOpenerTrigger.mockReturnValue('msg-user'); + mocks.feishuIm.consumePendingOpenerCard.mockResolvedValue(false); + mocks.feishuIm.takeNotedFallbackOpenerId.mockReturnValue('om_deferred'); + const h = setupSession(async () => ({ accepted: true })); + const onTurnComplete = vi.fn(); + await runDefaultTurn(onTurnComplete); + h.emit({ + type: 'tool_use', + data: { name: 'Bash', input: { command: 'ls' } }, + source: 'claude-code', + }); + await flushMicrotasks(); + h.emit({ type: 'done', data: {}, source: 'claude-code' }); + await waitForAssertion(() => { + expect(mocks.feishuIm.sendText).toHaveBeenCalledWith( + 'ou_user', + expect.stringContaining('本轮无文本输出'), + expect.objectContaining({ fallbackOpenerId: 'om_deferred' }), + ); + }); + }); + + it('错误收口兜底发送认领暂存 opener', async () => { + mocks.feishuIm.getPendingOpenerTrigger.mockReturnValue('msg-user'); + mocks.feishuIm.consumePendingOpenerCard.mockResolvedValue(false); + mocks.feishuIm.takeNotedFallbackOpenerId.mockReturnValue('om_deferred'); + const h = setupSession(async () => ({ accepted: true })); + await runDefaultTurn(); + h.emit({ type: 'error', data: { message: 'boom', isTerminal: true } }); + await waitForAssertion(() => { + expect(mocks.feishuIm.sendText).toHaveBeenCalledWith( + 'ou_user', + expect.stringMatching(/❌ 错误:.*boom/), + expect.objectContaining({ fallbackOpenerId: 'om_deferred' }), + ); + }); + }); + + it('首个文本前 error 就地消费 pending opener, 同话题下一轮无文本不会误认领', async () => { + mocks.feishuIm.getPendingOpenerTrigger.mockReturnValue('msg-user'); + mocks.feishuIm.consumePendingOpenerCard.mockResolvedValueOnce(true); + const h = setupSession(async () => ({ accepted: true })); + const onTurnA = vi.fn(); + await runDefaultTurn(onTurnA); + h.emit({ type: 'error', data: { message: 'boom', isTerminal: true } }); + await waitForAssertion(() => { + expect(onTurnA).toHaveBeenCalledTimes(1); + expect(mocks.feishuIm.consumePendingOpenerCard).toHaveBeenCalledWith( + 'ou_user', + expect.stringMatching(/❌ 错误:.*boom/), + ); + }); + expect(mocks.feishuIm.sendText).not.toHaveBeenCalled(); + + mocks.feishuIm.getPendingOpenerTrigger.mockReturnValue(undefined); + const onTurnB = vi.fn(); + await runDefaultTurn(onTurnB, { userMessageId: 'msg-user-2', text: 'followup' }); + h.emit({ type: 'done', data: {}, source: 'claude-code' }); + await waitForAssertion(() => { + expect(onTurnB).toHaveBeenCalledTimes(1); + expect(mocks.feishuIm.consumePendingOpenerCard).toHaveBeenCalledTimes(1); + expect(mocks.feishuIm.sendText).toHaveBeenCalledWith( + 'ou_user', + expect.stringContaining('本轮无文本输出'), + expect.anything(), + ); + }); + }); }); }); diff --git a/apps/desktop/src/main/im/shared/botRouteSource.ts b/apps/desktop/src/main/im/shared/botRouteSource.ts new file mode 100644 index 0000000000..9aee698439 --- /dev/null +++ b/apps/desktop/src/main/im/shared/botRouteSource.ts @@ -0,0 +1,23 @@ +import type { IMMessageEvent } from '@cindy/im'; + +import type { BotRouteSource } from '../../../shared/botRoute.js'; +import type { ImChannelName } from './types.js'; + +/** Pure adapter-event to Bot Route identity mapping. */ +export function botRouteSourceForMessage( + channel: ImChannelName, + event: Pick, +): BotRouteSource { + const threadKey = event.threadTs?.trim() || event.scopeKey?.trim() || undefined; + return { + platform: channel, + accountKey: event.contextId, + // chatId identifies the concrete DM/group/channel surface; senderId is the + // adapter's outbound lane. Keeping them separate is required for route + // uniqueness and later proactive delivery. + scopeKey: event.chatId, + principalKey: event.senderId, + ...(threadKey ? { parentPrincipalKey: event.senderId } : {}), + ...(threadKey ? { threadKey } : {}), + }; +} diff --git a/apps/desktop/src/main/im/shared/botRouteTarget.ts b/apps/desktop/src/main/im/shared/botRouteTarget.ts new file mode 100644 index 0000000000..12aa114796 --- /dev/null +++ b/apps/desktop/src/main/im/shared/botRouteTarget.ts @@ -0,0 +1,83 @@ +import { getDeviceId } from '../../authManager.js'; +import { getDbClient } from '../../localDb/client/current.js'; +import { + ensureBotRouteSession, + resolveOrCreateBotRoute, +} from '../../localDb/botRouteService.js'; +import { sessions } from '../../localDb/schema.js'; +import type { BotRouteSource } from '../../../shared/botRoute.js'; +import { toCoreAgentKind } from './sessionRepo.js'; +import type { RouteTarget } from './turnRunner.js'; +import { eq } from 'drizzle-orm'; +export { botRouteSourceForMessage } from './botRouteSource.js'; + +async function toRouteTarget(args: { + sessionId: string; + scopeKey?: string; + created: boolean; +}): Promise { + const [row] = await getDbClient() + .drizzle.select() + .from(sessions) + .where(eq(sessions.id, args.sessionId)) + .limit(1); + if (!row?.workingDir || row.source !== 'bot' || row.status !== 'active') { + throw new Error('Bot Route task is unavailable after resolution'); + } + return { + row: { + id: row.id, + agentKind: toCoreAgentKind(row.agentKind), + workingDir: row.workingDir, + model: row.model, + effort: row.effort, + permissionMode: row.permissionMode, + fastMode: row.fastMode, + sdkSessionId: row.sdkSessionId, + providerId: row.providerId ?? null, + workspaceKind: row.workspaceKind, + }, + attached: false, + botRoute: true, + scopeKey: args.scopeKey, + created: args.created, + }; +} + +export async function resolveBotImRouteTarget(args: { + source: BotRouteSource; + scopeKey?: string; +}): Promise { + const route = await resolveOrCreateBotRoute(args.source); + if (!route) return null; + const ensured = await ensureBotRouteSession({ + routeId: route.id, + ownerDeviceId: getDeviceId(), + }); + return toRouteTarget({ + sessionId: ensured.sessionId, + scopeKey: args.scopeKey, + created: ensured.created, + }); +} + +export async function renewBotImRouteTarget(args: { + source: BotRouteSource; + scopeKey?: string; +}): Promise<{ target: RouteTarget; previousSessionId?: string } | null> { + const route = await resolveOrCreateBotRoute(args.source); + if (!route) return null; + const ensured = await ensureBotRouteSession({ + routeId: route.id, + ownerDeviceId: getDeviceId(), + forceRenew: true, + }); + return { + target: await toRouteTarget({ + sessionId: ensured.sessionId, + scopeKey: args.scopeKey, + created: true, + }), + ...(route.currentSessionId ? { previousSessionId: route.currentSessionId } : {}), + }; +} diff --git a/apps/desktop/src/main/im/shared/cardActionHandler.ts b/apps/desktop/src/main/im/shared/cardActionHandler.ts index 2b73b5c364..c8559391eb 100644 --- a/apps/desktop/src/main/im/shared/cardActionHandler.ts +++ b/apps/desktop/src/main/im/shared/cardActionHandler.ts @@ -1385,125 +1385,129 @@ export function createCardActionHandler( return; } try { - await runInImAccountGeneration(accountGeneration, async () => { - log.info( - `card action sender=...${event.senderId.slice(-8)} button=${event.buttonId} payload=${JSON.stringify(event.payload).slice(0, 200)}`, - ); + await runInImAccountGeneration( + accountGeneration, + async () => { + log.info( + `card action sender=...${event.senderId.slice(-8)} button=${event.buttonId} payload=${JSON.stringify(event.payload).slice(0, 200)}`, + ); - // model:pick is NOT an InteractionRequest reply — it's a direct command - // triggered by the /model slash command's picker card. Handle it - // separately: update DB + live session, patch card to "已切换". - if (event.buttonId === 'model:pick') { - await handleModelPick(im, event); - return; - } + // model:pick is NOT an InteractionRequest reply — it's a direct command + // triggered by the /model slash command's picker card. Handle it + // separately: update DB + live session, patch card to "已切换". + if (event.buttonId === 'model:pick') { + await handleModelPick(im, event); + return; + } - // Same shape as model:pick — direct command from /permission picker card. - if ( - event.buttonId === 'permmode:pick' - || event.buttonId === 'permmode:confirm-full-access' - ) { - await handlePermissionModePick(im, event); - return; - } - if (event.buttonId === 'permmode:cancel-full-access') { - await handlePermissionModeCancel(im, event); - return; - } - if (event.buttonId === 'permissionMode:fix-auto') { - await handlePermissionModeFixToAuto(im, event); - return; - } + // Same shape as model:pick — direct command from /permission picker card. + if ( + event.buttonId === 'permmode:pick' || + event.buttonId === 'permmode:confirm-full-access' + ) { + await handlePermissionModePick(im, event); + return; + } + if (event.buttonId === 'permmode:cancel-full-access') { + await handlePermissionModeCancel(im, event); + return; + } + if (event.buttonId === 'permissionMode:fix-auto') { + await handlePermissionModeFixToAuto(im, event); + return; + } - // /ctr picker — - // pick (workspace) → 替换为 session picker - // back → 替换回 workspace picker - // session-pick → attach binding + 接力 brief - // new → 新建 session + attach - // exit → patch 为 resolved 卡片, 不动 session - if (event.buttonId === 'control:pick') { - await handleControlPick(im, event); - return; - } - if (event.buttonId === 'control:back') { - await handleControlBack(im, event); - return; - } - if (event.buttonId === 'control:session-pick') { - await handleControlSessionPick(im, event); - return; - } - if (event.buttonId === 'control:new') { - await handleControlNewSession(im, event); - return; - } - if (event.buttonId === 'control:exit') { - await handleControlExit(im, event); - return; - } - if (event.buttonId === 'control:thread-exit') { - await handleControlThreadExit(im, event); - return; - } - if (event.buttonId === 'control:start') { - await handleControlStart(im, event); - return; - } + // /ctr picker — + // pick (workspace) → 替换为 session picker + // back → 替换回 workspace picker + // session-pick → attach binding + 接力 brief + // new → 新建 session + attach + // exit → patch 为 resolved 卡片, 不动 session + if (event.buttonId === 'control:pick') { + await handleControlPick(im, event); + return; + } + if (event.buttonId === 'control:back') { + await handleControlBack(im, event); + return; + } + if (event.buttonId === 'control:session-pick') { + await handleControlSessionPick(im, event); + return; + } + if (event.buttonId === 'control:new') { + await handleControlNewSession(im, event); + return; + } + if (event.buttonId === 'control:exit') { + await handleControlExit(im, event); + return; + } + if (event.buttonId === 'control:thread-exit') { + await handleControlThreadExit(im, event); + return; + } + if (event.buttonId === 'control:start') { + await handleControlStart(im, event); + return; + } - // /project picker — pick(项目) / dialogue(回托管对话目录) / cancel - if (event.buttonId === 'project:pick') { - await handleProjectSwitch(im, event, 'project'); - return; - } - if (event.buttonId === 'project:dialogue') { - await handleProjectSwitch(im, event, 'dialogue'); - return; - } - if (event.buttonId === 'project:cancel') { - const projectUi = adapter.ui.cards.project; - if (projectUi) { - await patchProjectCard(im, event.messageId, projectUi.resolvedCancel); + // /project picker — pick(项目) / dialogue(回托管对话目录) / cancel + if (event.buttonId === 'project:pick') { + await handleProjectSwitch(im, event, 'project'); + return; + } + if (event.buttonId === 'project:dialogue') { + await handleProjectSwitch(im, event, 'dialogue'); + return; + } + if (event.buttonId === 'project:cancel') { + const projectUi = adapter.ui.cards.project; + if (projectUi) { + await patchProjectCard(im, event.messageId, projectUi.resolvedCancel); + } + return; } - return; - } - const decision = decisionFromPress(event); - if (!decision) { - log.warn(`unknown buttonId=${event.buttonId} — ignoring`); - return; - } + const decision = decisionFromPress(event); + if (!decision) { + log.warn(`unknown buttonId=${event.buttonId} — ignoring`); + return; + } - const requestId = String(event.payload.requestId ?? ''); - if (!requestId) { - log.warn('no requestId in payload — ignoring'); - return; - } + const requestId = String(event.payload.requestId ?? ''); + if (!requestId) { + log.warn('no requestId in payload — ignoring'); + return; + } - const resolved = resolvePending(requestId, decision); - if (!resolved) { - // 卡片正文**不动**: 交互被作废时 turnRunner 已经把它收口了(dropInteractionCard), - // 而这里拿不到的另一半原因是"刚刚已经成功处理过" —— 那种情况改写成过期态 - // 等于把一次已经生效的授权报成失效。渠道侧的气泡提示已足够告知这次点击无效。 - log.warn( - `no pending interaction for requestId=...${requestId.slice(-8)} (already resolved? user double-tapped?)`, - ); - return; - } + const resolved = resolvePending(requestId, decision); + if (!resolved) { + // 卡片正文**不动**: 交互被作废时 turnRunner 已经把它收口了(dropInteractionCard), + // 而这里拿不到的另一半原因是"刚刚已经成功处理过" —— 那种情况改写成过期态 + // 等于把一次已经生效的授权报成失效。渠道侧的气泡提示已足够告知这次点击无效。 + log.warn( + `no pending interaction for requestId=...${requestId.slice(-8)} (already resolved? user double-tapped?)`, + ); + return; + } - // Patch the card to a resolved state so the user sees their choice took. - // 授权卡保留原始正文(工具名 + 参数预览)再追加决策结果 — 用户要能 - // 回看自己批准的是什么; 其它交互卡维持整卡替换的旧形态。 - const resolvedLabel = describeDecision(decision); - try { - const spec = resolved.permissionCard - ? cards.buildResolvedPermissionCard(resolved.permissionCard, resolvedLabel) - : cards.buildResolvedCard(resolvedLabel); - await im.updateInteractiveCard(event.messageId, spec); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - log.warn(`updateInteractiveCard failed (non-fatal): ${msg}`); - } - }); + // Patch the card to a resolved state so the user sees their choice took. + // 授权卡保留原始正文(工具名 + 参数预览)再追加决策结果 — 用户要能 + // 回看自己批准的是什么; 其它交互卡维持整卡替换的旧形态。 + const resolvedLabel = describeDecision(decision); + try { + const spec = resolved.permissionCard + ? cards.buildResolvedPermissionCard(resolved.permissionCard, resolvedLabel) + : cards.buildResolvedCard(resolvedLabel); + await im.updateInteractiveCard(event.messageId, spec); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.warn(`updateInteractiveCard failed (non-fatal): ${msg}`); + } + }, + channel, + ); } catch (err) { if (isImAccountScopeClosedError(err)) { log.info(`drop card action from stale account generation channel=${channel}`); diff --git a/apps/desktop/src/main/im/shared/messageHandler.ts b/apps/desktop/src/main/im/shared/messageHandler.ts index 0cef6ffcff..2ca923d97d 100644 --- a/apps/desktop/src/main/im/shared/messageHandler.ts +++ b/apps/desktop/src/main/im/shared/messageHandler.ts @@ -16,7 +16,7 @@ * 跨渠道互不影响。 */ -import type { IMAttachment, IMMessageEvent, TextChannelIM } from '@cindy/im'; +import type { IMAttachment, IMMessageEvent, InteractiveCardSpec, TextChannelIM } from '@cindy/im'; import { createLogger } from '../../logger'; import { @@ -32,6 +32,7 @@ import type { ImSlashHandlers } from './slashCommands'; import { looksLikeSlashCommand } from './slashCommands'; import type { ImTurnRunner } from './turnRunner'; import type { ImChannelAdapter } from './types'; +import { botRouteSourceForMessage } from './botRouteTarget'; /** * `!stop` 控制指令 — 半角/全角感叹号、大小写不敏感(issue #867)。 @@ -46,6 +47,25 @@ export function createMessageHandler( turnRunner: ImTurnRunner, ): (im: TextChannelIM) => () => void { const { ui, channel, threadScoped } = adapter; + // 富卡渠道(仅 feishu 实现这两个能力): 群主流 @ 开话题的「思考中」开场白卡 + // 在非流式终态分支的收口 — 见各分支内 consume/discard 调用点。 + const richIm = adapter.output?.kind === 'rich-card' ? adapter.output.im : null; + + /** + * 尝试消费 pending 开场白卡并把终态回复 patch 上去。消费成功返回 true + * (调用方跳过另发); 无 pending opener 或 patch 失败返回 false(回落正常 + * 发送 — 失败场景下发送兜底, 且认领已完成, 同话题下一条不会再 patch 错卡)。 + */ + async function consumeOpenerWithText(userId: string, text: string): Promise { + if (!richIm?.consumePendingOpenerCard) return false; + try { + return await richIm.consumePendingOpenerCard(userId, text); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.warn(`consumePendingOpenerCard failed (fallback to normal send): ${msg}`); + return false; + } + } const log = createLogger(`im:${channel}:msg`); /** Per-user serial lock — same shape as legacy messageRouter.turnLocks. */ @@ -58,10 +78,7 @@ export function createMessageHandler( * .ackReactionIdPromise), 由它负责撤掉, 这里不自己清理。 * 渠道没有表情能力或打失败 ⇒ null, turn 也不会再补打。 */ - async function ackProcessingEarly( - im: TextChannelIM, - messageId: string, - ): Promise { + async function ackProcessingEarly(im: TextChannelIM, messageId: string): Promise { try { return (await im.reactToMessage?.(messageId, adapter.processingEmoji)) ?? null; } catch { @@ -74,6 +91,7 @@ export function createMessageHandler( event: IMMessageEvent, accountGeneration: ImAccountGeneration, ): Promise { + const botRouteSource = botRouteSourceForMessage(channel, event); log.info( `processOne sender=...${event.senderId.slice(-8)} chat=...${event.chatId.slice(-8)} ` + `textLen=${event.text.length} att=${event.attachments.length} unsupported=${event.unsupported.length}`, @@ -144,6 +162,7 @@ export function createMessageHandler( botContextId: event.contextId, userId: event.senderId, scopeKey: threadScoped ? event.scopeKey : undefined, + botRouteSource, }); reply = result.stopped ? ui.agent.stopDone(result.droppedQueued) : ui.agent.stopIdle; log.info( @@ -154,25 +173,94 @@ export function createMessageHandler( log.error(`stopActiveTurn threw: ${msg}`); reply = ui.agent.sendInternalError(msg); } - try { - await im.sendMarkdownText(event.senderId, reply, { threadTs: event.scopeKey }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - log.warn(`!stop reply failed (non-fatal): ${msg}`); + // 群主流 @ 开话题的首条若是 !stop: 「思考中」开场白卡就地 patch 成 + // stop 回复(消费 pending opener), 不再另发一条; 消费不了再走正常发送。 + // 仅当本条消息**自己**开了话题(groupContextLane 存在)才消费 — 同话题 + // 后续消息 B 不得认领上一轮 A 的 pending opener, 否则 B 的终态回复会 + // 覆盖 A 的思考卡, A 的回答另发新卡造成归属错乱。 + const openerConsumed = event.groupContextLane + ? await consumeOpenerWithText(event.senderId, reply) + : false; + if (!openerConsumed) { + try { + await im.sendMarkdownText(event.senderId, reply, { + threadTs: event.scopeKey, + fallbackOpenerId: richIm?.takeNotedFallbackOpenerId?.(event.senderId, 'markdown'), + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.warn(`!stop reply failed (non-fatal): ${msg}`); + } } return; } // ── slash command (only on plain text: no attachments, no unsupported) ── if (pureTextCommandInput && looksLikeSlashCommand(event.text)) { + // 群主流 @ 开话题的首条若是 slash: slash 自备回复(文本/卡片), 把它的 + // **首个**回复就地消费开场白卡(patch 文本 / 替换卡片)— 卡不卡住, 也 + // 不用撤回后拿已删消息当回复锚点。消费过一次后后续回复正常发送。 + // 仅当本条消息**自己**开了话题(groupContextLane 存在)才注入 sink — + // 同话题后续 slash 不得认领上一轮的 pending opener(归属错乱, 同 + // !stop 分支的说明)。 + // bind 接收者: FeishuIM 方法内部访问 this.log, 裸函数引用会导致 catch + // 路径(撤回开场白卡)在 this=undefined 下抛错、卡残留。 + const consumeCard = event.groupContextLane + ? richIm?.consumePendingOpenerCard?.bind(richIm) + : undefined; + const consumeAsCard = event.groupContextLane + ? richIm?.consumePendingOpenerAsCard?.bind(richIm) + : undefined; + const sink = consumeCard + ? { + used: false, + async withMarkdown(userId: string, markdown: string): Promise { + if (this.used) return false; + const ok = await consumeCard(userId, markdown); + if (ok) this.used = true; + return ok; + }, + async withCard(userId: string, spec: InteractiveCardSpec): Promise { + if (this.used) return false; + const ok = (await consumeAsCard?.(userId, spec)) ?? false; + if (ok) this.used = true; + return ok; + }, + } + : undefined; try { await slash.handleSlashCommand(event.text, { botContextId: event.contextId, userId: event.senderId, + botRouteSource, + consumePendingOpener: sink, }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); log.error(`slash command threw: ${msg}`); + // slash 在首个回复发出前抛错(如 /ctr 枚举失败): sink 未被调用, + // 开场白卡用内部错误内容收口 — 否则「思考中」卡永久保留。 + // withMarkdown 返回 false(撤回/空窗暂存)或抛错时走正常发送兜底, + // 与 !stop / runAgentTurn 失败分支同一口径。 + const errorText = ui.agent.sendInternalError(msg); + let openerConsumed = false; + if (sink) { + try { + openerConsumed = await sink.withMarkdown(event.senderId, errorText); + } catch { + openerConsumed = false; + } + } + if (!openerConsumed) { + try { + await im.sendMarkdownText(event.senderId, errorText, { + threadTs: event.scopeKey, + fallbackOpenerId: richIm?.takeNotedFallbackOpenerId?.(event.senderId, 'markdown'), + }); + } catch { + /* 发送失败与卡残留同一最终边界 */ + } + } } return; } @@ -181,13 +269,22 @@ export function createMessageHandler( // ── pure-unsupported: reply directly, do NOT invoke agent ─────────────── if (!hasContent && event.unsupported.length > 0) { - try { - await im.sendText(event.senderId, ui.agent.unsupportedOnly(event.unsupported), { - threadTs: event.scopeKey, - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - log.warn(`unsupportedOnly send failed (non-fatal): ${msg}`); + const notice = ui.agent.unsupportedOnly(event.unsupported); + // 同 !stop: 开场白卡就地 patch 成 unsupported 提示, 消费不了再另发; + // 仅本条消息自己开了话题(groupContextLane)才消费。 + const openerConsumed = event.groupContextLane + ? await consumeOpenerWithText(event.senderId, notice) + : false; + if (!openerConsumed) { + try { + await im.sendText(event.senderId, notice, { + threadTs: event.scopeKey, + fallbackOpenerId: richIm?.takeNotedFallbackOpenerId?.(event.senderId, 'markdown'), + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.warn(`unsupportedOnly send failed (non-fatal): ${msg}`); + } } return; } @@ -235,6 +332,7 @@ export function createMessageHandler( botContextId: event.contextId, userId: event.senderId, scopeKey: threadScoped ? event.scopeKey : undefined, + botRouteSource, text: event.text, attachments: event.attachments, ...(event.protectedContent === true ? { protectedContent: true } : {}), @@ -243,8 +341,23 @@ export function createMessageHandler( const msg = err instanceof Error ? err.message : String(err); log.warn(`early user-message persist failed (non-fatal): ${msg}`); } + let botRoute = false; + if (botRouteSource) { + try { + const resolved = await turnRunner.resolveRouteTarget( + event.contextId, + event.senderId, + threadScoped ? event.scopeKey : undefined, + botRouteSource, + ); + botRoute = resolved?.botRoute === true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.warn(`Bot Route identity resolve failed before turn preparation: ${msg}`); + } + } try { - prepared = await adapter.prepareAgentTurnText(event); + prepared = await adapter.prepareAgentTurnText(event, { botRoute }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); log.warn(`prepareAgentTurnText failed (degraded to raw text): ${msg}`); @@ -265,6 +378,16 @@ export function createMessageHandler( ...(turnPermissionPolicy ? { turnPermissionPolicy } : {}), ...(groupHistoryAccess ? { groupHistoryAccess } : {}), ...(handedOverAck !== undefined ? { ackReactionIdPromise: handedOverAck } : {}), + // 早期拒绝终态(missing_auth / credential busy): 本条消息自己开了话题 + // 时, 用终态文案收口开场白卡 — 否则「思考中」卡残留且下一条误认领。 + ...(event.groupContextLane + ? { + onEarlyReject: async (reason: string, text: string) => { + void reason; + return consumeOpenerWithText(event.senderId, text); + }, + } + : {}), ...(prePersisted ? { prePersistedUserMessage: prePersisted } : {}), ...(prepared ? { agentText: prepared.agentText } : {}), // 群历史附件只进模型消息、不落库(见 ImRunAgentTurnArgs.contextAttachments)。 @@ -283,10 +406,11 @@ export function createMessageHandler( attachments: event.attachments, // threadScoped 渠道: scopeKey = thread root ts(thread = session 路由键) scopeKey: threadScoped ? event.scopeKey : undefined, + botRouteSource, // Title generation and similar detached work must stay visible to the // same account drain without delaying the foreground message dispatch. trackBackgroundTask: (operation) => { - void runInImAccountGeneration(accountGeneration, operation).catch((err) => { + void runInImAccountGeneration(accountGeneration, operation, channel).catch((err) => { if (isImAccountScopeClosedError(err)) { log.info(`drop background task from stale account generation channel=${channel}`); return; @@ -299,12 +423,20 @@ export function createMessageHandler( } catch (err) { const msg = err instanceof Error ? err.message : String(err); log.error(`runAgentTurn threw: ${msg}`); - try { - await im.sendText(event.senderId, ui.agent.sendInternalError(msg), { - threadTs: event.scopeKey, - }); - } catch { - /* swallow */ + // 本条消息自己开了话题(groupContextLane)时, 开场白卡还没被流式认领 — + // 用内部错误内容收口它, 否则卡永久残留且同话题下一条会 patch 错卡。 + const openerConsumed = event.groupContextLane + ? await consumeOpenerWithText(event.senderId, ui.agent.sendInternalError(msg)) + : false; + if (!openerConsumed) { + try { + await im.sendText(event.senderId, ui.agent.sendInternalError(msg), { + threadTs: event.scopeKey, + fallbackOpenerId: richIm?.takeNotedFallbackOpenerId?.(event.senderId, 'markdown'), + }); + } catch { + /* swallow */ + } } } } @@ -327,8 +459,10 @@ export function createMessageHandler( /* prior turn failure should not block subsequent messages */ }) .then(() => - runInImAccountGeneration(accountGeneration, () => - processOne(im, event, accountGeneration), + runInImAccountGeneration( + accountGeneration, + () => processOne(im, event, accountGeneration), + channel, ).catch((err) => { if (isImAccountScopeClosedError(err)) { log.info(`drop inbound message from stale account generation channel=${channel}`); diff --git a/apps/desktop/src/main/im/shared/orchestrator.ts b/apps/desktop/src/main/im/shared/orchestrator.ts index b79121a148..9956f62dd6 100644 --- a/apps/desktop/src/main/im/shared/orchestrator.ts +++ b/apps/desktop/src/main/im/shared/orchestrator.ts @@ -18,6 +18,7 @@ import { createTurnRunner, type ImTurnRunner } from './turnRunner'; import { createSlashHandlers } from './slashCommands'; import { createMessageHandler } from './messageHandler'; import { createCardActionHandler } from './cardActionHandler'; +import { renewBotImRouteTarget, resolveBotImRouteTarget } from './botRouteTarget'; import type { ImChannelAdapter, ImChannelName } from './types'; const log = createLogger('im:orchestrator'); @@ -74,6 +75,8 @@ export function createImOrchestrator(adapter: ImChannelAdapter): ImOrchestrator const cards = createCardBuilders(adapter.ui, repo.getDefaultEffortFor); const turnRunner = createTurnRunner(adapter, repo, cards, { acquirePendingAgentSwitch: acquirePendingAgentSwitchForDirectSend, + resolveBotRouteTarget: resolveBotImRouteTarget, + renewBotRouteTarget: renewBotImRouteTarget, }); const slash = createSlashHandlers(adapter, repo, cards, turnRunner); const attachMessageHandler = createMessageHandler(adapter, slash, turnRunner); diff --git a/apps/desktop/src/main/im/shared/slashCommands.ts b/apps/desktop/src/main/im/shared/slashCommands.ts index 24b6317f82..5643239d07 100644 --- a/apps/desktop/src/main/im/shared/slashCommands.ts +++ b/apps/desktop/src/main/im/shared/slashCommands.ts @@ -34,7 +34,7 @@ import { import { startThreadControlFlow } from './controlFlow'; import { enterControl } from './controlState'; import { bindingStore, executeDetach } from '../binding'; -import type { IdentityKey } from '@cindy/im'; +import type { IdentityKey, InteractiveCardSpec } from '@cindy/im'; import type { ImChannelAdapter } from './types'; import { permissionModeCommandContext, @@ -43,6 +43,7 @@ import { resolvePermissionMode, } from './permissionModeControl'; import { isBotCommandAvailableOnChannel, tokenizeBotCommand } from './botCommands'; +import type { BotRouteSource } from '../../../shared/botRoute.js'; /** Quick text-only check; treat anything starting with '/' (no spaces before) as a command. */ export function looksLikeSlashCommand(text: string): boolean { @@ -52,6 +53,17 @@ export function looksLikeSlashCommand(text: string): boolean { export interface SlashCtx { botContextId: string; userId: string; + botRouteSource?: BotRouteSource; + /** + * 群主流 @ 开话题的首条 slash 时由 messageHandler 注入: slash 的**首个** + * 回复(文本或卡片)就地消费开场白卡(patch 文本 / 替换卡片)而不是另发 — + * 「思考中」卡不会卡住, 也不会撤回后拿已删消息当回复锚点。消费过一次 + * 后返回 false, 后续回复正常发送。 + */ + consumePendingOpener?: { + withMarkdown(userId: string, markdown: string): Promise; + withCard(userId: string, spec: InteractiveCardSpec): Promise; + }; } export interface ImSlashHandlers { @@ -80,16 +92,68 @@ export function createSlashHandlers( * 所有 slash 反馈都走 markdown (body-only interactive card), 因为很多文案带 * **粗体** / `code` / emoji, 渠道原生 text 不渲染 markdown 标记会显示成原文。 * markdown 同样兼容纯文本: 没标记的字符串显示效果跟 text 一致。 + * + * 群主流 @ 开话题的首条 slash: 首个回复经 ctx.consumePendingOpener 就地 + * patch 开场白卡(消费过一次后回落正常发送)。 */ - async function safeSendText(userId: string, text: string): Promise { + async function safeSendText(ctx: SlashCtx, text: string): Promise { try { - await im.sendMarkdownText(userId, text); + if (ctx.consumePendingOpener) { + try { + if (await ctx.consumePendingOpener.withMarkdown(ctx.userId, text)) return; + } catch (err) { + // patch 失败不吞回复: 认领已完成(卡不会再被误 patch), 回落正常 + // 发送 — 用户至少收到结果。 + const msg = err instanceof Error ? err.message : String(err); + log.warn(`consumePendingOpener withMarkdown failed (fallback to normal send): ${msg}`); + } + } + const fallbackOpenerId = richIm?.takeNotedFallbackOpenerId?.(ctx.userId, 'markdown'); + if (fallbackOpenerId) { + await im.sendMarkdownText(ctx.userId, text, { fallbackOpenerId }); + } else { + await im.sendMarkdownText(ctx.userId, text); + } } catch (err) { const msg = err instanceof Error ? err.message : String(err); log.warn(`safeSendText failed (non-fatal): ${msg}`); } } + /** + * 卡片反馈: 首条经 consumePendingOpener 替换开场白卡, 替换失败回落正常发卡。 + * 返回是否送达(替换成功或发卡成功)— /ctr 等依赖「卡片确实可见」的调用方 + * 据此决定是否进入控制态。 + */ + async function safeSendCard(ctx: SlashCtx, spec: InteractiveCardSpec): Promise { + try { + if (ctx.consumePendingOpener) { + try { + if (await ctx.consumePendingOpener.withCard(ctx.userId, spec)) return true; + } catch (err) { + // 替换失败回落正常发卡: 认领已完成, 用户至少拿到一张可用卡片。 + const msg = err instanceof Error ? err.message : String(err); + log.warn(`consumePendingOpener withCard failed (fallback to normal send): ${msg}`); + } + } + if (!richIm) { + log.warn('safeSendCard failed: channel has no rich-card output'); + return false; + } + const fallbackOpenerId = richIm.takeNotedFallbackOpenerId?.(ctx.userId, 'spec'); + if (fallbackOpenerId) { + await richIm.sendInteractiveCard(ctx.userId, spec, { fallbackOpenerId }); + } else { + await richIm.sendInteractiveCard(ctx.userId, spec); + } + return true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + log.warn(`safeSendCard failed (non-fatal): ${msg}`); + return false; + } + } + /** * 工作区显示名。 * @@ -141,7 +205,7 @@ export function createSlashHandlers( ) ) { await safeSendText( - ctx.userId, + ctx, ui.slash.interactiveCommandUnsupported?.(cmd) ?? ui.slash.unknownCommand(cmd), ); return true; @@ -149,17 +213,17 @@ export function createSlashHandlers( switch (cmd) { case '/help': - await safeSendText(ctx.userId, ui.slash.help); + await safeSendText(ctx, ui.slash.help); return true; case '/start': { // Telegram 私聊首次交互必发 /start(START 按钮) — 有欢迎语的渠道回 // 欢迎语, 其它渠道走未知命令提示。 if (ui.slash.start) { - await safeSendText(ctx.userId, ui.slash.start); + await safeSendText(ctx, ui.slash.start); return true; } - await safeSendText(ctx.userId, ui.slash.unknownCommand(cmd)); + await safeSendText(ctx, ui.slash.unknownCommand(cmd)); return true; } @@ -171,6 +235,7 @@ export function createSlashHandlers( const result = await turnRunner.stopActiveTurn({ botContextId: ctx.botContextId, userId: ctx.userId, + botRouteSource: ctx.botRouteSource, }); reply = result.stopped ? ui.agent.stopDone(result.droppedQueued) : ui.agent.stopIdle; } catch (err) { @@ -178,21 +243,56 @@ export function createSlashHandlers( log.error(`/stop stopActiveTurn threw: ${msg}`); reply = ui.agent.sendInternalError(msg); } - await safeSendText(ctx.userId, reply); + await safeSendText(ctx, reply); return true; } case '/new': { // thread = session 模型: 发新顶层消息即新会话, /new 无意义 → 废弃提示。 if (threadScoped && threadUi) { - await safeSendText(ctx.userId, threadUi.newDeprecated); + await safeSendText(ctx, threadUi.newDeprecated); + return true; + } + if (ctx.botRouteSource) { + const current = await turnRunner.resolveRouteTarget( + ctx.botContextId, + ctx.userId, + undefined, + ctx.botRouteSource, + ); + if (!current) { + await safeSendText(ctx, ui.agent.apiKeyMissing); + return true; + } + const auth = await turnRunner.getAuthStatusForRoute?.(current.row); + if (auth ? !auth.ok : !(await turnRunner.hasAuthForRoute(current.row))) { + await safeSendText( + ctx, + auth && ui.agent.authMissing + ? ui.agent.authMissing({ + ...auth, + agentKind: current.row.agentKind, + model: current.row.model, + }) + : ui.agent.apiKeyMissing, + ); + return true; + } + // A missing Route task was just created by resolveRouteTarget and is + // already a fresh context. Existing Route tasks renew atomically: + // the old task becomes history and the Route points at the new task. + if (!current.created) { + const renewed = await turnRunner.renewBotRouteTarget(ctx.botRouteSource); + if (!renewed) throw new Error('Bot Route disappeared while renewing'); + } + await safeSendText(ctx, ui.slash.new); return true; } const prepared = await repo.prepareNewSession(ctx.botContextId, ctx.userId); const auth = await turnRunner.getAuthStatusForRoute?.(prepared); if (auth ? !auth.ok : !(await turnRunner.hasAuthForRoute(prepared))) { await safeSendText( - ctx.userId, + ctx, auth && ui.agent.authMissing ? ui.agent.authMissing({ ...auth, @@ -211,24 +311,29 @@ export function createSlashHandlers( await resetSessionToDefaults(row.id, adapter.config, prepared, adapter.channel); } await turnRunner.disposeOneSession(row.id); - await safeSendText(ctx.userId, ui.slash.new); + await safeSendText(ctx, ui.slash.new); return true; } case '/model': { if (!richIm) { - await safeSendText(ctx.userId, ui.slash.unknownCommand(cmd)); + await safeSendText(ctx, ui.slash.unknownCommand(cmd)); return true; } // thread 模型: slash 命令不携带 thread 上下文(Slack 平台限制), 无法定位 // 目标 thread/session;不拦的话 resolveRouteTarget 会误建空 scope session。 if (threadScoped && threadUi) { - await safeSendText(ctx.userId, threadUi.perThreadConfigUnsupported); + await safeSendText(ctx, threadUi.perThreadConfigUnsupported); return true; } - const target = await turnRunner.resolveRouteTarget(ctx.botContextId, ctx.userId); + const target = await turnRunner.resolveRouteTarget( + ctx.botContextId, + ctx.userId, + undefined, + ctx.botRouteSource, + ); if (!target) { - await safeSendText(ctx.userId, ui.agent.apiKeyMissing); + await safeSendText(ctx, ui.agent.apiKeyMissing); return true; } const { row } = target; @@ -268,7 +373,7 @@ export function createSlashHandlers( : undefined, }); try { - await richIm.sendInteractiveCard(ctx.userId, spec); + await safeSendCard(ctx, spec); } catch (err) { const msg = err instanceof Error ? err.message : String(err); log.error(`/model card send failed: ${msg}`); @@ -282,7 +387,7 @@ export function createSlashHandlers( if (threadScoped && threadUi) { const all = bindingStore.listByIdentity(channel, ctx.botContextId, ctx.userId); if (all.length === 0) { - await safeSendText(ctx.userId, threadUi.exctrNothing); + await safeSendText(ctx, threadUi.exctrNothing); return true; } let detached = 0; @@ -295,7 +400,7 @@ export function createSlashHandlers( log.error(`${invocation} executeDetach(scope) threw: ${msg}`); } } - await safeSendText(ctx.userId, threadUi.exctrAllDone(detached)); + await safeSendText(ctx, threadUi.exctrAllDone(detached)); return true; } // 结束当前 (bot, owner) 的接管, 让后续消息回到渠道默认 session。 @@ -309,21 +414,21 @@ export function createSlashHandlers( try { const result = await executeDetach(identity, `${channel}-slash`); if (!result.wasAttached) { - await safeSendText(ctx.userId, ui.slash.notAttached); + await safeSendText(ctx, ui.slash.notAttached); } else { - await safeSendText(ctx.userId, ui.slash.detachedBySlash); + await safeSendText(ctx, ui.slash.detachedBySlash); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); log.error(`${invocation} executeDetach threw: ${msg}`); - await safeSendText(ctx.userId, `❌ 结束接管失败:${msg}`); + await safeSendText(ctx, `❌ 结束接管失败:${msg}`); } return true; } case '/ctr': { if (!richIm) { - await safeSendText(ctx.userId, ui.slash.unknownCommand(cmd)); + await safeSendText(ctx, ui.slash.unknownCommand(cmd)); return true; } // ── thread 模型: 整个选择流程在"接管锚点卡"的 thread 里进行 ──────── @@ -364,12 +469,11 @@ export function createSlashHandlers( projects, currentAttachedTitle, }); - try { - await richIm.sendInteractiveCard(ctx.userId, spec); + const sent = await safeSendCard(ctx, spec); + if (sent) { enterControl(ctx.botContextId, ctx.userId); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - log.error(`/ctr card send failed: ${msg}`); + } else { + log.error('/ctr picker card send failed — control NOT entered (avoid locking the user)'); } return true; } @@ -379,7 +483,7 @@ export function createSlashHandlers( // 才放行; 选中走 control:session-pick 接管路径。 const recentUi = ui.cards.control.recentSessions; if (!richIm || !recentUi) { - await safeSendText(ctx.userId, ui.slash.unknownCommand(cmd)); + await safeSendText(ctx, ui.slash.unknownCommand(cmd)); return true; } const recent = await listRecentSessionsForPicker(); @@ -388,7 +492,7 @@ export function createSlashHandlers( sessions: recent, }); try { - await richIm.sendInteractiveCard(ctx.userId, spec); + await safeSendCard(ctx, spec); } catch (err) { const msg = err instanceof Error ? err.message : String(err); log.error(`/session card send failed: ${msg}`); @@ -401,7 +505,7 @@ export function createSlashHandlers( // 未知命令处理, 不暴露半成品入口。 const projectUi = ui.cards.project; if (!richIm || !adapter.projectSwitching || !projectUi) { - await safeSendText(ctx.userId, ui.slash.unknownCommand(cmd)); + await safeSendText(ctx, ui.slash.unknownCommand(cmd)); return true; } // /ctr 接管期间语义冲突(消息在被接管的 desktop 会话里跑): 先 /exctr。 @@ -411,7 +515,7 @@ export function createSlashHandlers( userId: ctx.userId, }; if (bindingStore.get(identity)) { - await safeSendText(ctx.userId, projectUi.attachedUnsupported); + await safeSendText(ctx, projectUi.attachedUnsupported); return true; } const [projects, current] = await Promise.all([ @@ -429,7 +533,7 @@ export function createSlashHandlers( currentName, }); try { - await richIm.sendInteractiveCard(ctx.userId, spec); + await safeSendCard(ctx, spec); } catch (err) { const msg = err instanceof Error ? err.message : String(err); log.error(`/project card send failed: ${msg}`); @@ -441,11 +545,11 @@ export function createSlashHandlers( // 只读总览 —— 不改任何配置, 所以不需要富卡, 纯文本渠道也照常可用。 const render = ui.slash.settings; if (!render) { - await safeSendText(ctx.userId, ui.slash.unknownCommand(cmd)); + await safeSendText(ctx, ui.slash.unknownCommand(cmd)); return true; } if (threadScoped && threadUi) { - await safeSendText(ctx.userId, threadUi.perThreadConfigUnsupported); + await safeSendText(ctx, threadUi.perThreadConfigUnsupported); return true; } // /ctr 接管期间, 下一条消息跑在**被接管的 desktop 会话**里 —— /model、 @@ -478,7 +582,7 @@ export function createSlashHandlers( effective.workspaceKind, ); await safeSendText( - ctx.userId, + ctx, render({ workspace, agent: effective.agentKind, @@ -492,12 +596,17 @@ export function createSlashHandlers( case '/permission': { if (threadScoped && threadUi) { - await safeSendText(ctx.userId, threadUi.perThreadConfigUnsupported); + await safeSendText(ctx, threadUi.perThreadConfigUnsupported); return true; } - const target = await turnRunner.resolveRouteTarget(ctx.botContextId, ctx.userId); + const target = await turnRunner.resolveRouteTarget( + ctx.botContextId, + ctx.userId, + undefined, + ctx.botRouteSource, + ); if (!target) { - await safeSendText(ctx.userId, ui.agent.apiKeyMissing); + await safeSendText(ctx, ui.agent.apiKeyMissing); return true; } const { row } = target; @@ -509,12 +618,12 @@ export function createSlashHandlers( if (!richIm) { const requested = commandArgs[0]; if (!requested) { - await safeSendText(ctx.userId, renderTextPermissionModePicker(ui, context)); + await safeSendText(ctx, renderTextPermissionModePicker(ui, context)); return true; } const mode = resolvePermissionMode(context.modes, requested); if (!mode) { - await safeSendText(ctx.userId, renderTextPermissionModePicker(ui, context)); + await safeSendText(ctx, renderTextPermissionModePicker(ui, context)); return true; } const confirmedFullAccess = ['confirm', '确认'].includes(commandArgs[1]?.toLowerCase()); @@ -524,7 +633,7 @@ export function createSlashHandlers( modes: context.modes, confirmedFullAccess, }); - await safeSendText(ctx.userId, renderTextPermissionModeResult(ui, result)); + await safeSendText(ctx, renderTextPermissionModeResult(ui, result)); return true; } const spec = cards.buildPermissionModePickerCard({ @@ -534,7 +643,7 @@ export function createSlashHandlers( currentMode: row.permissionMode, }); try { - await richIm.sendInteractiveCard(ctx.userId, spec); + await safeSendCard(ctx, spec); } catch (err) { const msg = err instanceof Error ? err.message : String(err); log.error(`/permission card send failed: ${msg}`); @@ -543,7 +652,7 @@ export function createSlashHandlers( } default: - await safeSendText(ctx.userId, ui.slash.unknownCommand(cmd)); + await safeSendText(ctx, ui.slash.unknownCommand(cmd)); return true; } } diff --git a/apps/desktop/src/main/im/shared/turnRunner.ts b/apps/desktop/src/main/im/shared/turnRunner.ts index 9c90c5a8d8..ec1dafb253 100644 --- a/apps/desktop/src/main/im/shared/turnRunner.ts +++ b/apps/desktop/src/main/im/shared/turnRunner.ts @@ -83,6 +83,7 @@ import type { UserMessage, } from '@cindy/maker-core'; import type { IMAttachment, InteractiveCardSpec, StreamingTextHandle } from '@cindy/im'; +import type { BotRouteSource } from '../../../shared/botRoute.js'; import { persistUserMessage } from '../messagePersistence'; import { bindingStore } from '../binding'; @@ -263,6 +264,8 @@ interface QueuedSend { /** 触发消息来自受保护群 —— 正文与附件不进会话存档(见 ImRunAgentTurnArgs)。 */ protectedContent?: boolean; groupHistoryAccess?: GroupHistoryAccessScope; + /** 早期拒绝终态回调(见 ImRunAgentTurnArgs.onEarlyReject)。 */ + onEarlyReject?: (reason: string, text: string) => Promise | boolean; /** 调用方在拼群上下文之前已落库的 user 行(见 ImRunAgentTurnArgs 同名字段)。 */ prePersistedUserMessage?: { sessionId: string; clientId: string }; } @@ -334,6 +337,8 @@ interface ScheduledTranspond { export interface RouteTarget { row: ImSessionRow; attached: boolean; + /** The lane is owned by a Cindy Bot Profile rather than the legacy IM task. */ + botRoute?: boolean; /** 路由时使用的会话维度键(thread root ts)— 透传给出站回复定位 thread。 */ scopeKey?: string; /** true = 这次路由新建了 session 行(thread 名片卡 / 标题生成的触发依据)。 */ @@ -355,6 +360,8 @@ export interface ImRunAgentTurnArgs { attachments: IMAttachment[]; /** thread = session 模型的会话维度键(slack);feishu 不传。 */ scopeKey?: string; + /** Optional Cindy Bot Route source. Missing/unmatched sources preserve legacy IM routing. */ + botRouteSource?: BotRouteSource; /** * 发给 agent 的正文覆盖(群上下文前缀拼装, 见 adapter.prepareAgentTurnText)。 * 缺省 = text。落库(persistUserMessage)与标题生成恒用 text(渠道原文)。 @@ -403,6 +410,13 @@ export interface ImRunAgentTurnArgs { * 重置), 那时这份预落库对不上号, dispatch 必须照常自己落一条。 */ prePersistedUserMessage?: { sessionId: string; clientId: string }; + /** + * 早期拒绝终态(missing_auth / credential_mode_switch 等正常 resolve 的 + * reject/busy)回调 — turnRunner 把本要另发的终态文案交给调用方: 返回 + * true 表示已消费(如群主流 @ 开话题时 patch 开场白卡), turnRunner 跳过 + * 自己的发送; false/缺省则照常发送。 + */ + onEarlyReject?(reason: string, text: string): Promise | boolean; } export interface ImTurnTerminal { @@ -445,6 +459,7 @@ export interface ImTurnRunner { botContextId: string; userId: string; scopeKey?: string; + botRouteSource?: BotRouteSource; text: string; attachments?: readonly IMAttachment[]; protectedContent?: boolean; @@ -463,6 +478,11 @@ export interface ImTurnRunner { botContextId: string, userId: string, scopeKey?: string, + botRouteSource?: BotRouteSource, + ): Promise; + renewBotRouteTarget( + botRouteSource: BotRouteSource, + scopeKey?: string, ): Promise; hasAuthForRoute(row: Pick): Promise; getAuthStatusForRoute?: ( @@ -495,12 +515,22 @@ export interface ImTurnRunner { botContextId: string; userId: string; scopeKey?: string; + botRouteSource?: BotRouteSource; }): Promise<{ stopped: boolean; droppedQueued: number }>; } export interface ImTurnRunnerDeps { /** 锁住 session 并落实 deferred switch;IM 在刷新 live session + send 后 release。 */ acquirePendingAgentSwitch?: (sessionId: string) => Promise<() => void>; + /** Resolve a mounted Cindy Bot Route. Returning null keeps the existing IM task path byte-for-byte. */ + resolveBotRouteTarget?: (args: { + source: BotRouteSource; + scopeKey?: string; + }) => Promise; + renewBotRouteTarget?: (args: { + source: BotRouteSource; + scopeKey?: string; + }) => Promise<{ target: RouteTarget; previousSessionId?: string } | null>; } export function createTurnRunner( @@ -511,6 +541,19 @@ export function createTurnRunner( ): ImTurnRunner { const { im, output, ui, channel } = adapter; const richIm = output.kind === 'rich-card' ? output.im : null; + + function sendTextClaimingOpener( + userId: string, + text: string, + threadTs: string | undefined, + ): Promise<{ messageId: string }> { + const fallbackOpenerId = richIm?.takeNotedFallbackOpenerId?.(userId, 'markdown'); + return im.sendText(userId, text, { + threadTs, + ...(fallbackOpenerId ? { fallbackOpenerId } : {}), + }); + } + /** 过程区耗时显示的低频刷新(5s)— 单个长工具调用期间状态行不冻结。 */ const ACTIVITY_TICK_MS = 5_000; @@ -557,12 +600,34 @@ export function createTurnRunner( botContextId: string, userId: string, scopeKey?: string, + botRouteSource?: BotRouteSource, ): Promise { - const existing = await resolveExistingRouteTarget(botContextId, userId, scopeKey); + const existing = await resolveExistingRouteTarget( + botContextId, + userId, + scopeKey, + botRouteSource, + ); if (existing) return existing; return (await createAuthenticatedDefaultRouteTarget(botContextId, userId, scopeKey)).target; } + async function renewBotRouteTarget( + botRouteSource: BotRouteSource, + scopeKey?: string, + ): Promise { + if (!deps.renewBotRouteTarget) return null; + const renewed = await deps.renewBotRouteTarget({ source: botRouteSource, scopeKey }); + if (!renewed) return null; + if ( + renewed.previousSessionId + && renewed.previousSessionId !== renewed.target.row.id + ) { + await disposeOneSession(renewed.previousSessionId); + } + return renewed.target; + } + async function createAuthenticatedDefaultRouteTarget( botContextId: string, userId: string, @@ -585,7 +650,15 @@ export function createTurnRunner( botContextId: string, userId: string, scopeKey?: string, + botRouteSource?: BotRouteSource, ): Promise { + // A configured Bot Route owns this IM lane. Resolve it before the legacy + // /ctr binding so attaching a Channel to Cindy Bots cannot be silently + // shadowed by an older desktop-session takeover record. + if (botRouteSource && deps.resolveBotRouteTarget) { + const mounted = await deps.resolveBotRouteTarget({ source: botRouteSource, scopeKey }); + if (mounted) return mounted; + } // 优先查 binding 是否命中 — bindingStore.get 走进程内 Map, 同步且 O(1)。 // threadScoped 渠道的 binding 按 (identity, scopeKey) 维度存(多重接管)。 const targetSessionId = bindingStore.get({ @@ -660,16 +733,33 @@ export function createTurnRunner( beforeProviderStart?: () => Promise; }, ): Promise { - const { botContextId, userId, userMessageId, text, attachments, scopeKey } = args; + const { + botContextId, + userId, + userMessageId, + text, + attachments, + scopeKey, + botRouteSource, + } = args; // 路由分流 — 先查 binding: 命中走 desktop session (接管模式 C), // 未命中走渠道默认 session (B' 行为)。这是 /ctr 接管能生效的关键入口。 - let target = await resolveExistingRouteTarget(botContextId, userId, scopeKey); + let target = await resolveExistingRouteTarget( + botContextId, + userId, + scopeKey, + botRouteSource, + ); if (!target) { const created = await createAuthenticatedDefaultRouteTarget(botContextId, userId, scopeKey); if (!created.target) { if (args.queueMode === 'internal') { - await replyMissingAuth(userId, created.missingAuth, scopeKey); + const text = + ui.agent.authMissing?.({ ...created.missingAuth, attached: false }) ?? + ui.agent.apiKeyMissing; + const consumed = (await args.onEarlyReject?.('missing_auth', text)) ?? false; + if (!consumed) await replyMissingAuth(userId, created.missingAuth, scopeKey); } await discardHandedOverAck(userMessageId, args.ackReactionIdPromise); return { kind: 'rejected', reason: 'missing_auth' }; @@ -681,12 +771,14 @@ export function createTurnRunner( const auth = await checkImRouteAuthDetailed(row, undefined, authCheckDeps()); if (!auth.ok) { if (args.queueMode === 'internal') { - await replyMissingAuth( - userId, - { ...auth, agentKind: row.agentKind, model: row.model }, - scopeKey, - target.attached, - ); + const authStatus = { ...auth, agentKind: row.agentKind, model: row.model }; + const text = + ui.agent.authMissing?.({ ...authStatus, attached: target.attached }) ?? + ui.agent.apiKeyMissing; + const consumed = (await args.onEarlyReject?.('missing_auth', text)) ?? false; + if (!consumed) { + await replyMissingAuth(userId, authStatus, scopeKey, target.attached); + } } await discardHandedOverAck(userMessageId, args.ackReactionIdPromise); return { kind: 'rejected', reason: 'missing_auth' }; @@ -775,7 +867,12 @@ export function createTurnRunner( } catch (err) { if (isCredentialModeSwitchBusyError(err)) { if (args.queueMode === 'internal') { - await handleSessionWiringBusy(userId, turn); + const consumed = (await args.onEarlyReject?.('credential_mode_switch', ui.agent.credentialBusy)) ?? false; + if (!consumed) { + await handleSessionWiringBusy(userId, turn); + } else { + await completeTurnCallbackAfterAck(turn); + } } else { await completeTurnCallbackAfterAck(turn); } @@ -853,6 +950,7 @@ export function createTurnRunner( ...(args.beforeProviderStart ? { beforeProviderStart: args.beforeProviderStart } : {}), ...(args.onRouteResolved ? { onRouteResolved: args.onRouteResolved } : {}), ...(args.turnPermissionPolicy ? { turnPermissionPolicy: args.turnPermissionPolicy } : {}), + ...(args.onEarlyReject ? { onEarlyReject: args.onEarlyReject } : {}), ...(args.protectedContent === true ? { protectedContent: true } : {}), ...(args.groupHistoryAccess ? { groupHistoryAccess: args.groupHistoryAccess } : {}), ...(args.prePersistedUserMessage @@ -1143,6 +1241,7 @@ export function createTurnRunner( source: outcome.source, reason: outcome.reason, context: outcome.context, + onEarlyReject: item.onEarlyReject, }); return { kind: 'rejected', reason: outcome.reason }; } @@ -1168,6 +1267,7 @@ export function createTurnRunner( source: `${channel}-runner`, reason: turnPolicyFailureReason, context: buildSendContext(rowId), + onEarlyReject: item.onEarlyReject, }); return { kind: 'rejected', reason: turnPolicyFailureReason }; } @@ -1216,6 +1316,7 @@ export function createTurnRunner( reason: normalized.reason, context: buildSendContext(rowId), error: normalized.error, + onEarlyReject: item.onEarlyReject, }); return { kind: 'rejected', reason: normalized.reason }; } finally { @@ -2474,6 +2575,7 @@ export function createTurnRunner( reason: string; context: string; error?: SanitizedSendOutcomeError; + onEarlyReject?: (reason: string, text: string) => Promise | boolean; }, ): Promise { log.error(`${channel} session send failed before dispatch`, { @@ -2511,21 +2613,25 @@ export function createTurnRunner( ? unsupportedCopy(rejectedMode) : unsupportedCopy : `❌ 启动 agent 失败:${failure.reason}`; - if ( - output.kind === 'chunked-text' && - failure.turn.chunkedReplyBegun - ) { - await output.commitFinal({ - userId, - text: message, - terminal: 'error', - threadTs: state.scopeKey, - errorCode: failure.reason, - }); - } else { - await im.sendText(userId, message, { - threadTs: state.scopeKey, - }); + // 早期拒绝终态交调用方消费(群主流 @ 开话题时 patch 开场白卡), + // 消费了就不再另发。 + const consumed = + (await failure.onEarlyReject?.(failure.reason, message)) ?? false; + if (!consumed) { + if ( + output.kind === 'chunked-text' && + failure.turn.chunkedReplyBegun + ) { + await output.commitFinal({ + userId, + text: message, + terminal: 'error', + threadTs: state.scopeKey, + errorCode: failure.reason, + }); + } else { + await sendTextClaimingOpener(userId, message, state.scopeKey); + } } // 群会话「完全访问」档被强确认策略拒绝: 报错文案之外, 再给 owner // 私聊发一张一键修复卡(切回 auto)。只对提供 permissionModeFix 文案 @@ -2823,9 +2929,19 @@ export function createTurnRunner( ...(turn.mediaAbsPaths.length > 0 ? { mediaAbsPaths: turn.mediaAbsPaths } : {}), }); } else { - await output.im.sendText(userId, '✅ (本轮无文本输出)', { - threadTs: state.scopeKey, - }); + // 群主流 @ 开话题但本轮无流式输出: pending opener 尚未被认领 — + // 仅当 opener 是本轮消息创建的(trigger 匹配)才消费, 否则另发 + // (避免消费上一轮遗留的 opener 造成归属错乱)。 + const triggerId = output.im.getPendingOpenerTrigger?.(userId); + const isMyOpener = triggerId === turn.userMessageId; + const consumed = + isMyOpener + ? ((await output.im.consumePendingOpenerCard?.(userId, '✅ (本轮无文本输出)')) ?? + false) + : false; + if (!consumed) { + await sendTextClaimingOpener(userId, '✅ (本轮无文本输出)', state.scopeKey); + } } } catch { /* swallow */ @@ -2929,9 +3045,16 @@ export function createTurnRunner( ...(turn && turn.mediaAbsPaths.length > 0 ? { mediaAbsPaths: turn.mediaAbsPaths } : {}), }); } else { - await output.im.sendText(userId, `❌ 错误:${msg}`, { - threadTs: state.scopeKey, - }); + const errorText = `❌ 错误:${msg}`; + const triggerId = output.im.getPendingOpenerTrigger?.(userId); + const isMyOpener = triggerId === turn?.userMessageId; + const consumed = + isMyOpener + ? ((await output.im.consumePendingOpenerCard?.(userId, errorText)) ?? false) + : false; + if (!consumed) { + await sendTextClaimingOpener(userId, errorText, state.scopeKey); + } } } catch { /* swallow */ @@ -3316,6 +3439,7 @@ export function createTurnRunner( botContextId: string; userId: string; scopeKey?: string; + botRouteSource?: BotRouteSource; text: string; attachments?: readonly IMAttachment[]; protectedContent?: boolean; @@ -3327,7 +3451,12 @@ export function createTurnRunner( try { // 只解析既有路由: 提前落库不该新建 session 行, 也不该抢在认证预检之前 // 制造出一条会话(新会话仍按原路径在 dispatch 时建行 + 落库)。 - target = await resolveExistingRouteTarget(args.botContextId, args.userId, args.scopeKey); + target = await resolveExistingRouteTarget( + args.botContextId, + args.userId, + args.scopeKey, + args.botRouteSource, + ); } catch (err) { const msg = err instanceof Error ? err.message : String(err); log.warn(`early persist route resolve failed (skipped): ${msg}`); @@ -3356,10 +3485,16 @@ export function createTurnRunner( botContextId: string; userId: string; scopeKey?: string; + botRouteSource?: BotRouteSource; }): Promise<{ stopped: boolean; droppedQueued: number }> { - const { botContextId, userId, scopeKey } = args; + const { botContextId, userId, scopeKey, botRouteSource } = args; // 只解析既有路由 — !stop 不该为不存在的会话新建 session 行。 - const target = await resolveExistingRouteTarget(botContextId, userId, scopeKey); + const target = await resolveExistingRouteTarget( + botContextId, + userId, + scopeKey, + botRouteSource, + ); const state = target ? sessionStates.get(target.row.id) : undefined; if (!state) return { stopped: false, droppedQueued: 0 }; const running = @@ -3433,6 +3568,7 @@ export function createTurnRunner( persistInboundUserMessageEarly, dispatchAgentTurn, resolveRouteTarget, + renewBotRouteTarget, hasAuthForRoute: (row) => hasAuthForImRoute(row, undefined, authCheckDeps()), getAuthStatusForRoute: (row) => checkImRouteAuthDetailed(row, undefined, authCheckDeps()), prewireAttachedSession, diff --git a/apps/desktop/src/main/im/shared/types.ts b/apps/desktop/src/main/im/shared/types.ts index 1562bf9f61..78165c67a8 100644 --- a/apps/desktop/src/main/im/shared/types.ts +++ b/apps/desktop/src/main/im/shared/types.ts @@ -206,7 +206,13 @@ export interface ImChannelAdapter { * transcript**(它们不是触发用户发的)。与用户自己 attachments 的语义边界 * 正在于此: 触发消息附件照常走 attachments 落库。 */ - prepareAgentTurnText?(event: IMMessageEvent): Promise<{ + prepareAgentTurnText?( + event: IMMessageEvent, + context?: { + /** Suppress legacy per-channel identity when a Cindy Bot Profile owns the lane. */ + botRoute: boolean; + }, + ): Promise<{ agentText: string; contextAttachments?: IMAttachment[]; commit?: () => void | Promise; diff --git a/apps/desktop/src/main/im/telegram/__tests__/adapter.test.ts b/apps/desktop/src/main/im/telegram/__tests__/adapter.test.ts index 378df02c07..624532ea3d 100644 --- a/apps/desktop/src/main/im/telegram/__tests__/adapter.test.ts +++ b/apps/desktop/src/main/im/telegram/__tests__/adapter.test.ts @@ -1,6 +1,13 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { IMMessageEvent } from '@cindy/im'; +vi.mock('../behaviorStore', () => ({ + readTelegramPersona: () => ({ + botName: 'Legacy Telegram Bot', + soul: 'You are the legacy Telegram identity.', + }), +})); + import { buildTelegramAdapter } from '../adapter'; describe('Telegram group history access scope', () => { @@ -54,3 +61,28 @@ describe('Telegram group history access scope', () => { }); }); }); + +describe('Telegram identity boundary', () => { + const adapter = buildTelegramAdapter({} as never, {} as never); + const dm = { + contextId: 'telegram-account', + senderId: '12345', + messageId: 'm-identity', + chatId: '12345', + text: 'who are you?', + attachments: [], + unsupported: [], + } as unknown as IMMessageEvent; + + it('keeps the legacy Telegram persona for a legacy IM task', async () => { + await expect(adapter.prepareAgentTurnText?.(dm, { botRoute: false })).resolves.toEqual({ + agentText: + '\n你的名字: Legacy Telegram Bot\n' + + 'You are the legacy Telegram identity.\n\n\nwho are you?', + }); + }); + + it('uses only the frozen Cindy Bot Profile identity for a mounted Bot Route', async () => { + await expect(adapter.prepareAgentTurnText?.(dm, { botRoute: true })).resolves.toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/im/telegram/adapter.ts b/apps/desktop/src/main/im/telegram/adapter.ts index 7d57d5abf9..df15689fe4 100644 --- a/apps/desktop/src/main/im/telegram/adapter.ts +++ b/apps/desktop/src/main/im/telegram/adapter.ts @@ -114,12 +114,15 @@ export function buildTelegramAdapter( lane: lane ? { provider, chatId: lane.chatId, threadId: lane.threadId } : null, }; }, - prepareAgentTurnText: async (event) => { + prepareAgentTurnText: async (event, context) => { const lane = decodeTelegramLaneUserId(event.senderId); const replyBlock = event.replyContext ? buildTelegramReplyContextBlock(event.replyContext) : ''; - const persona = personaBlock(); + // A mounted Cindy Bot Route gets its identity from the frozen ProfileVersion + // at session bootstrap. Keep Telegram's quote/group/speaker augmentation, + // but never stack the legacy channel persona on top of the Bot SOUL. + const persona = context?.botRoute === true ? '' : personaBlock(); if (!lane) { // DM: 无群窗口, 但人格块与引用注入(回复某条消息触发)同样生效。 if (!replyBlock && !persona) return null; diff --git a/apps/desktop/src/main/im/telegram/index.ts b/apps/desktop/src/main/im/telegram/index.ts index eac2f29f8c..669fdbb5ee 100644 --- a/apps/desktop/src/main/im/telegram/index.ts +++ b/apps/desktop/src/main/im/telegram/index.ts @@ -36,12 +36,14 @@ export function wireTelegramOrchestrator( telegramIm.onGroupWindowMessage((entry) => { const generation = captureImAccountGeneration(); if (generation === null) return; - void runInImAccountGeneration(generation, () => recordTelegramGroupMessage(entry)).catch( - (err) => { - if (isImAccountScopeClosedError(err)) return; - const msg = err instanceof Error ? err.message : String(err); - log.warn(`telegram group window record failed (non-fatal): ${msg}`); - }, - ); + void runInImAccountGeneration( + generation, + () => recordTelegramGroupMessage(entry), + 'telegram', + ).catch((err) => { + if (isImAccountScopeClosedError(err)) return; + const msg = err instanceof Error ? err.message : String(err); + log.warn(`telegram group window record failed (non-fatal): ${msg}`); + }); }); } diff --git a/apps/desktop/src/main/im/wechat/WechatIM.ts b/apps/desktop/src/main/im/wechat/WechatIM.ts index 89c7c40895..a3705ed7db 100644 --- a/apps/desktop/src/main/im/wechat/WechatIM.ts +++ b/apps/desktop/src/main/im/wechat/WechatIM.ts @@ -443,7 +443,11 @@ export class WechatIM extends BaseIM implements RichChannelIM { return { kind: 'idle' }; } - async sendText(userId: string, text: string): Promise<{ messageId: string }> { + async sendText( + userId: string, + text: string, + opts?: { threadTs?: string; idempotencyKey?: string }, + ): Promise<{ messageId: string }> { const active = this.#activeTasks.get(userId); const epoch = this.#epoch; if (!epoch || this.#compatibilityDisabled || epoch.abort.signal.aborted) { @@ -458,7 +462,7 @@ export class WechatIM extends BaseIM implements RichChannelIM { }) )?.contextToken; if (!contextToken) throw new Error('WECHAT_PEER_NOT_KNOWN'); - const clientId = randomUUID(); + const clientId = outboundClientId(opts?.idempotencyKey); await epoch.transport.sendMessage( { peerId: userId, @@ -535,11 +539,20 @@ export class WechatIM extends BaseIM implements RichChannelIM { return true; } - sendMarkdownText(userId: string, markdown: string): Promise<{ messageId: string }> { - return this.sendText(userId, filterWechatMarkdown(markdown)); + sendMarkdownText( + userId: string, + markdown: string, + opts?: { threadTs?: string; idempotencyKey?: string }, + ): Promise<{ messageId: string }> { + return this.sendText(userId, filterWechatMarkdown(markdown), opts); } - async sendFile(userId: string, absPath: string, displayName?: string): Promise { + async sendFile( + userId: string, + absPath: string, + displayName?: string, + opts?: { threadTs?: string; idempotencyKey?: string }, + ): Promise { const epoch = this.#epoch; if (!epoch || this.#compatibilityDisabled || epoch.abort.signal.aborted) { return { ok: false, reason: 'SEND_FAIL' }; @@ -572,7 +585,7 @@ export class WechatIM extends BaseIM implements RichChannelIM { if (this.#compatibilityDisabled || epoch.abort.signal.aborted) { return { ok: false, reason: 'SEND_FAIL' }; } - const clientId = randomUUID(); + const clientId = outboundClientId(opts?.idempotencyKey); await epoch.transport.sendMedia( { peerId: userId, @@ -2015,6 +2028,10 @@ function machineErrorCode(error: unknown): string { return error instanceof Error ? safeMachineCode(error.message) : 'unknown_error'; } +function outboundClientId(idempotencyKey?: string): string { + return idempotencyKey?.trim() || randomUUID(); +} + export const __testing = { activePeerIdForSession, acceptedPollTaskIds, @@ -2022,6 +2039,7 @@ export const __testing = { classifyOutboxSendError, hasWechatTaskContent, normalizeFinalOutputText, + outboundClientId, formatWechatInteractionPrompt, parseWechatInteractionReply, stopActiveWechatTurns, diff --git a/apps/desktop/src/main/im/wechat/__tests__/WechatIM.test.ts b/apps/desktop/src/main/im/wechat/__tests__/WechatIM.test.ts index 7cc2a9d4e0..0c80c5b92a 100644 --- a/apps/desktop/src/main/im/wechat/__tests__/WechatIM.test.ts +++ b/apps/desktop/src/main/im/wechat/__tests__/WechatIM.test.ts @@ -62,6 +62,18 @@ describe('WechatIM host boundary', () => { expect(__testing.normalizeFinalOutputText('hello')).toBe('hello'); }); + it('uses the caller idempotency key as the provider client id', () => { + expect(__testing.outboundClientId(' bot-outbox:delivery-1 ')).toBe( + 'bot-outbox:delivery-1', + ); + expect(__testing.outboundClientId(' bot-outbox:delivery-1:media:0 ')).toBe( + 'bot-outbox:delivery-1:media:0', + ); + expect(__testing.outboundClientId()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + it('distinguishes agent-unsupported from permission-mode-unsupported pre-dispatch failures', () => { // Agent 未声明 turnPermissionPolicy(如 Pi):换权限模式无效,文案引导换 Agent。 expect(__testing.wechatPreDispatchFailureText('TURN_PERMISSION_POLICY_UNSUPPORTED:agent:ask')).toContain( diff --git a/apps/desktop/src/main/localDb/__tests__/botImMigrationService.test.ts b/apps/desktop/src/main/localDb/__tests__/botImMigrationService.test.ts new file mode 100644 index 0000000000..4e4cc53d4c --- /dev/null +++ b/apps/desktop/src/main/localDb/__tests__/botImMigrationService.test.ts @@ -0,0 +1,514 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { BotChannelConnection } from '../../../shared/botChannelRegistry.js'; + +const h = vi.hoisted(() => ({ + db: null as ReturnType | null, + sqlite: null as Database.Database | null, + tx: null as null | ((name: string, args: unknown) => Promise), + bindingPath: '', + connection: { + id: 'local:telegram:bot-account', + kind: 'telegram' as const, + ownership: 'local-adapter' as const, + status: 'connected', + connected: true, + accountKey: 'bot-account', + accountName: 'Personal Telegram', + scopeKey: 'bot-account', + routable: true, + features: ['direct-messages', 'groups'] as const, + } as BotChannelConnection, + closeSession: vi.fn(async () => undefined), + broadcastSessionPatched: vi.fn(), + notifyAgentIslandSessionPatch: vi.fn(), +})); + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/tmp/cindy-bot-im-migration-test') }, +})); + +vi.mock('../client/current.js', () => ({ + getDbClient: () => ({ drizzle: h.db, tx: h.tx }), +})); +vi.mock('../../im/index.js', () => ({ + listBotChannelConnections: () => [h.connection], +})); +vi.mock('../../im/accountBoundary.js', () => ({ + runImMigrationExclusive: async (_scope: string, operation: () => Promise) => operation(), +})); +vi.mock('../../hook-control/ipc.js', () => ({ + runHookBotMigrationExclusive: async (_scope: string, operation: () => Promise) => + operation(), +})); +vi.mock('../../appSessionState.js', () => ({ + ownerScopedUserDataPath: () => h.bindingPath, +})); +vi.mock('../../maker-host/index.js', () => ({ + getMakerIfReady: () => ({ closeSession: h.closeSession }), +})); +vi.mock('../ipc/sessions.js', () => ({ + broadcastSessionPatched: h.broadcastSessionPatched, +})); +vi.mock('../agentIslandSessionPatch.js', () => ({ + notifyAgentIslandSessionPatch: h.notifyAgentIslandSessionPatch, +})); + +import { + applyBotImMigration, + planBotImMigration, + rollbackBotImMigration, +} from '../botImMigrationService.js'; +import { upsertBotRoute } from '../botRouteService.js'; +import { tx as runWorkerTx } from '../worker/opHandlers/tx.js'; + +function createDb(): ReturnType { + const sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + sqlite.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY NOT NULL, + title TEXT NOT NULL DEFAULT 'New Maker', + working_dir TEXT, + workspace_kind TEXT NOT NULL DEFAULT 'project', + model TEXT NOT NULL DEFAULT 'claude-sonnet-4-6', + effort TEXT NOT NULL DEFAULT 'high', + permission_mode TEXT NOT NULL DEFAULT 'ask', + status TEXT NOT NULL DEFAULT 'active', + sdk_session_id TEXT, + total_token_usage INTEGER NOT NULL DEFAULT 0, + total_cost_usd REAL NOT NULL DEFAULT 0, + total_cost_amount REAL NOT NULL DEFAULT 0, + total_cost_currency TEXT, + total_cost_is_approximate INTEGER NOT NULL DEFAULT 0, + context_tokens INTEGER NOT NULL DEFAULT 0, + context_window INTEGER NOT NULL DEFAULT 0, + fast_mode INTEGER NOT NULL DEFAULT 0, + plan_mode_enabled INTEGER NOT NULL DEFAULT 0, + cleared_at INTEGER, + pinned_at INTEGER, + summary TEXT, + provider_id TEXT, + user_send_at INTEGER, + agent_kind TEXT NOT NULL DEFAULT 'cc', + orca_role TEXT, + parent_session_id TEXT, + forked_at_message_id TEXT, + worktree_path TEXT, + source TEXT NOT NULL DEFAULT 'desktop', + feishu_bot_app_id TEXT, + feishu_open_id TEXT, + im_bot_context_id TEXT, + im_user_id TEXT, + used_project_context INTEGER NOT NULL DEFAULT 0, + one_m INTEGER NOT NULL DEFAULT 0, + codex_history_has_product_prompt INTEGER, + codex_plan_json TEXT, + extra_dirs TEXT NOT NULL DEFAULT '[]', + remote_host_id TEXT, + active_turn_started_at INTEGER, + active_turn_pid INTEGER, + last_turn_ended_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE bot_profiles ( + id TEXT PRIMARY KEY NOT NULL, + display_name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + avatar TEXT NOT NULL DEFAULT '🤖', + avatar_color TEXT NOT NULL DEFAULT 'violet', + status TEXT NOT NULL DEFAULT 'active', + current_version INTEGER NOT NULL DEFAULT 1, + canonical_session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE bot_channels ( + id TEXT PRIMARY KEY NOT NULL, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + enabled INTEGER NOT NULL, + config_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE bot_routes ( + id TEXT PRIMARY KEY NOT NULL, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + channel_id TEXT NOT NULL REFERENCES bot_channels(id) ON DELETE CASCADE, + route_key TEXT NOT NULL, + principal_key TEXT NOT NULL, + scope_key TEXT NOT NULL, + thread_key TEXT, + current_session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + project_binding_id TEXT, + capabilities_json TEXT NOT NULL, + owner_device_id TEXT, + owner_generation INTEGER NOT NULL, + status TEXT NOT NULL, + suspended_status TEXT, + last_activity_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX uniq_bot_routes_channel_route ON bot_routes(channel_id, route_key); + CREATE TABLE bot_session_links ( + id TEXT PRIMARY KEY NOT NULL, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + profile_version INTEGER NOT NULL, + role TEXT NOT NULL, + channel_id TEXT REFERENCES bot_channels(id) ON DELETE SET NULL, + route_key TEXT, + created_at INTEGER NOT NULL, + archived_at INTEGER + ); + CREATE UNIQUE INDEX uniq_bot_session_links_session ON bot_session_links(session_id); + CREATE TABLE im_bindings ( + channel TEXT NOT NULL, + bot_context_id TEXT NOT NULL, + user_id TEXT NOT NULL, + scope_key TEXT NOT NULL, + target_session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + attached_at INTEGER NOT NULL, + attached_via_card_message_id TEXT, + PRIMARY KEY(channel, bot_context_id, user_id, scope_key) + ); + CREATE TABLE bot_im_migrations ( + id TEXT PRIMARY KEY NOT NULL, + request_id TEXT NOT NULL UNIQUE, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + channel_id TEXT NOT NULL REFERENCES bot_channels(id) ON DELETE CASCADE, + route_id TEXT NOT NULL REFERENCES bot_routes(id) ON DELETE CASCADE, + connection_id TEXT NOT NULL, + ownership TEXT NOT NULL, + kind TEXT NOT NULL, + account_key TEXT NOT NULL, + plan_hash TEXT NOT NULL, + status TEXT NOT NULL, + channel_before_json TEXT, + route_before_json TEXT, + adapter_bindings_json TEXT NOT NULL, + error_json TEXT, + created_at INTEGER NOT NULL, + applied_at INTEGER, + rolled_back_at INTEGER + ); + CREATE TABLE bot_im_migration_items ( + id TEXT PRIMARY KEY NOT NULL, + migration_id TEXT NOT NULL REFERENCES bot_im_migrations(id) ON DELETE CASCADE, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + original_status TEXT NOT NULL, + history_link_created INTEGER NOT NULL, + session_archived INTEGER NOT NULL, + applied_session_updated_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + rolled_back_at INTEGER + ); + CREATE TABLE bot_lifecycle_events ( + id TEXT PRIMARY KEY NOT NULL, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + `); + h.sqlite = sqlite; + const db = drizzle(sqlite); + h.tx = async (name, args) => runWorkerTx(sqlite, { name: name as never, args } as never); + return db; +} + +beforeEach(async () => { + h.closeSession.mockClear(); + h.broadcastSessionPatched.mockClear(); + h.notifyAgentIslandSessionPatch.mockClear(); + h.bindingPath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-bot-im-migration-')), + 'hook-bindings.json', + ); + h.connection = { + id: 'local:telegram:bot-account', + kind: 'telegram', + ownership: 'local-adapter', + status: 'connected', + connected: true, + accountKey: 'bot-account', + accountName: 'Personal Telegram', + scopeKey: 'bot-account', + routable: true, + features: ['direct-messages', 'groups'], + }; + h.db = createDb(); + await h.db.insert((await import('../schema.js')).botProfiles).values({ + id: 'bot-1', + displayName: 'Bot One', + description: '', + avatar: '🤖', + avatarColor: 'violet', + status: 'active', + currentVersion: 3, + canonicalSessionId: null, + createdAt: 1, + updatedAt: 1, + }); + await h.db.insert((await import('../schema.js')).sessions).values({ + id: 'legacy-1', + title: 'Legacy Telegram task', + status: 'active', + source: 'telegram', + imBotContextId: 'bot-account', + updatedAt: 10, + createdAt: 10, + }); +}); + +afterEach(() => { + fs.rmSync(path.dirname(h.bindingPath), { recursive: true, force: true }); +}); + +describe('Bot IM migration service', () => { + it('returns the same persisted Route when a lane is upserted concurrently', async () => { + const schema = await import('../schema.js'); + await h.db!.insert(schema.botChannels).values({ + id: 'bot-1:telegram', + botId: 'bot-1', + kind: 'telegram', + enabled: true, + configJson: JSON.stringify({ + accountKey: 'bot-account', + ownership: 'local-adapter', + }), + createdAt: 1, + updatedAt: 1, + }); + + const [first, second] = await Promise.all([ + upsertBotRoute({ + botId: 'bot-1', + channelId: 'bot-1:telegram', + routeKey: 'lane:stable', + principalKey: 'chat-1', + }), + upsertBotRoute({ + botId: 'bot-1', + channelId: 'bot-1:telegram', + routeKey: 'lane:stable', + principalKey: 'chat-1', + }), + ]); + + expect(first.id).toBe(second.id); + expect(await h.db!.select().from(schema.botRoutes)).toHaveLength(1); + }); + + it('plans, atomically archives and links a legacy task without changing its source', async () => { + const plan = await planBotImMigration({ botId: 'bot-1', connectionId: h.connection.id }); + expect(plan.canApply).toBe(true); + expect(plan.candidates).toEqual([ + expect.objectContaining({ sessionId: 'legacy-1', status: 'active', source: 'telegram' }), + ]); + + const applied = await applyBotImMigration({ + botId: 'bot-1', + connectionId: h.connection.id, + planHash: plan.planHash, + requestId: 'request-1', + }); + expect(applied.status).toBe('applied'); + expect(applied.migratedSessionCount).toBe(1); + + const schema = await import('../schema.js'); + const [session] = await h.db!.select().from(schema.sessions); + expect(session.source).toBe('telegram'); + expect(session.status).toBe('archived'); + const [link] = await h.db!.select().from(schema.botSessionLinks); + expect(link).toMatchObject({ botId: 'bot-1', sessionId: 'legacy-1', role: 'history' }); + const [channel] = await h.db!.select().from(schema.botChannels); + expect(channel.enabled).toBe(true); + expect(JSON.parse(channel.configJson)).toMatchObject({ + accountKey: 'bot-account', + ownership: 'local-adapter', + }); + const [mountSentinel] = await h.db!.select().from(schema.botRoutes); + expect(JSON.parse(mountSentinel!.capabilitiesJson)).toMatchObject({ mountOnly: true }); + expect(mountSentinel!.currentSessionId).toBeNull(); + expect(h.closeSession).toHaveBeenCalledWith('legacy-1'); + expect(h.broadcastSessionPatched).toHaveBeenCalledWith('legacy-1', { + status: 'archived', + }); + + const replay = await applyBotImMigration({ + botId: 'bot-1', + connectionId: h.connection.id, + planHash: plan.planHash, + requestId: 'request-1', + }); + expect(replay.id).toBe(applied.id); + expect(await h.db!.select().from(schema.botImMigrationItems)).toHaveLength(1); + }); + + it('blocks migration while a legacy /ctr takeover still targets a candidate', async () => { + const schema = await import('../schema.js'); + await h.db!.insert(schema.imBindings).values({ + channel: 'telegram', + botContextId: 'bot-account', + userId: 'owner', + scopeKey: '', + targetSessionId: 'legacy-1', + attachedAt: 20, + attachedViaCardMessageId: null, + }); + + const plan = await planBotImMigration({ botId: 'bot-1', connectionId: h.connection.id }); + expect(plan.canApply).toBe(false); + expect(plan.conflicts).toContainEqual( + expect.objectContaining({ code: 'im-takeover-active', sessionId: 'legacy-1' }), + ); + }); + + it('rolls back migration-owned state but preserves later manual Session changes', async () => { + const schema = await import('../schema.js'); + const plan = await planBotImMigration({ botId: 'bot-1', connectionId: h.connection.id }); + const applied = await applyBotImMigration({ + botId: 'bot-1', + connectionId: h.connection.id, + planHash: plan.planHash, + requestId: 'request-rollback', + }); + await h + .db!.update(schema.sessions) + .set({ title: 'User changed this history', updatedAt: Date.now() + 10_000 }) + .where((await import('drizzle-orm')).eq(schema.sessions.id, 'legacy-1')); + + const rolledBack = await rollbackBotImMigration(applied.id); + expect(rolledBack.status).toBe('rolled-back'); + const [session] = await h.db!.select().from(schema.sessions); + expect(session.status).toBe('archived'); + expect(session.title).toBe('User changed this history'); + expect(await h.db!.select().from(schema.botSessionLinks)).toHaveLength(0); + const [channel] = await h.db!.select().from(schema.botChannels); + expect(channel.enabled).toBe(false); + }); + + it('restores an unchanged active legacy task on successful rollback', async () => { + const schema = await import('../schema.js'); + const plan = await planBotImMigration({ botId: 'bot-1', connectionId: h.connection.id }); + const applied = await applyBotImMigration({ + botId: 'bot-1', + connectionId: h.connection.id, + planHash: plan.planHash, + requestId: 'request-clean-rollback', + }); + + const rolledBack = await rollbackBotImMigration(applied.id); + expect(rolledBack.status).toBe('rolled-back'); + const [session] = await h.db!.select().from(schema.sessions); + expect(session.status).toBe('active'); + expect(h.broadcastSessionPatched).toHaveBeenLastCalledWith('legacy-1', { + status: 'active', + }); + }); + + it('blocks ambiguous legacy Slack bindings that have no workspace identity', async () => { + h.connection = { + id: 'relay:slack:T1', + kind: 'slack', + ownership: 'server-relay', + status: 'connected', + connected: true, + accountKey: 'T1', + accountName: 'Workspace One', + scopeKey: 'T1', + routable: true, + features: ['direct-messages', 'threads'], + }; + fs.writeFileSync( + h.bindingPath, + JSON.stringify({ + 'slack:account:slack': { + 'team-slack:C1:1.1': { sessionId: 'legacy-1', updatedAt: 10 }, + }, + }), + 'utf-8', + ); + + const plan = await planBotImMigration({ botId: 'bot-1', connectionId: h.connection.id }); + expect(plan.canApply).toBe(false); + expect(plan.conflicts).toContainEqual( + expect.objectContaining({ code: 'ambiguous-legacy-binding' }), + ); + }); + + it('removes and restores real relay binding files, then resumes rolling-back after a crash', async () => { + const schema = await import('../schema.js'); + h.connection = { + id: 'relay:telegram:binding-1', + kind: 'telegram', + ownership: 'server-relay', + status: 'connected', + connected: true, + accountKey: 'bot-account', + accountName: 'Official Telegram', + scopeKey: 'bot-account', + routable: true, + features: ['direct-messages', 'durable-delivery'], + }; + fs.writeFileSync( + h.bindingPath, + JSON.stringify({ + 'telegram:account:telegram': { + 'telegram:dm:bot-account:user-1:g1': { + sessionId: 'legacy-1', + updatedAt: 10, + }, + }, + }), + 'utf-8', + ); + const plan = await planBotImMigration({ botId: 'bot-1', connectionId: h.connection.id }); + const applied = await applyBotImMigration({ + botId: 'bot-1', + connectionId: h.connection.id, + planHash: plan.planHash, + requestId: 'request-relay-file', + }); + expect(fs.readFileSync(h.bindingPath, 'utf-8')).not.toContain('legacy-1'); + + // Simulate the process dying after the rollback transaction committed but + // before the binding file and terminal audit state were finalized. + const { eq } = await import('drizzle-orm'); + await h + .db!.update(schema.sessions) + .set({ status: 'active', updatedAt: 30 }) + .where(eq(schema.sessions.id, 'legacy-1')); + await h + .db!.delete(schema.botSessionLinks) + .where(eq(schema.botSessionLinks.sessionId, 'legacy-1')); + await h + .db!.update(schema.botChannels) + .set({ enabled: false, updatedAt: 30 }) + .where(eq(schema.botChannels.botId, 'bot-1')); + await h + .db!.update(schema.botRoutes) + .set({ currentSessionId: null, status: 'archived', updatedAt: 30 }) + .where(eq(schema.botRoutes.botId, 'bot-1')); + await h + .db!.update(schema.botImMigrations) + .set({ status: 'rolling-back' }) + .where(eq(schema.botImMigrations.id, applied.id)); + + const recovered = await rollbackBotImMigration(applied.id); + expect(recovered.status).toBe('rolled-back'); + expect(fs.readFileSync(h.bindingPath, 'utf-8')).toContain('legacy-1'); + expect((await h.db!.select().from(schema.sessions))[0]?.status).toBe('active'); + }); +}); diff --git a/apps/desktop/src/main/localDb/__tests__/botMigrationReplay.test.ts b/apps/desktop/src/main/localDb/__tests__/botMigrationReplay.test.ts new file mode 100644 index 0000000000..b0484931aa --- /dev/null +++ b/apps/desktop/src/main/localDb/__tests__/botMigrationReplay.test.ts @@ -0,0 +1,135 @@ +import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { createBetterSqliteDatabase } from '../betterSqliteFactory'; +import { runMigrationReplay } from '../migrationRunner'; + +const MIGRATION = '0092_motionless_hiroim.sql'; +const cleanups: Array<() => void> = []; + +afterEach(() => { + while (cleanups.length > 0) cleanups.pop()?.(); +}); + +function createDb() { + const dir = mkdtempSync(path.join(tmpdir(), 'cindy-bot-migration-')); + const db = createBetterSqliteDatabase(path.join(dir, 'bots.db')); + cleanups.push(() => { + db.close(); + rmSync(dir, { recursive: true, force: true }); + }); + db.pragma('foreign_keys = ON'); + db.exec(` + CREATE TABLE migration_meta (key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL); + CREATE TABLE migration_history ( + seq INTEGER PRIMARY KEY NOT NULL, + file_name TEXT NOT NULL, + content_hash TEXT NOT NULL, + applied_at INTEGER NOT NULL + ); + CREATE TABLE sessions (id TEXT PRIMARY KEY NOT NULL, status TEXT DEFAULT 'active' NOT NULL); + CREATE TABLE schedules (id TEXT PRIMARY KEY NOT NULL); + CREATE TABLE schedule_runs (id TEXT PRIMARY KEY NOT NULL); + CREATE TABLE right_sidebar_tabs ( + id TEXT PRIMARY KEY NOT NULL, + session_id TEXT NOT NULL, + kind TEXT NOT NULL + ); + `); + return db; +} + +function runBotMigration(db: ReturnType): void { + const desktopRoot = path.resolve(__dirname, '../../../..'); + const stagedDir = mkdtempSync(path.join(tmpdir(), 'cindy-bot-migration-step-')); + copyFileSync(path.join(desktopRoot, 'drizzle', MIGRATION), path.join(stagedDir, MIGRATION)); + mkdirSync(path.join(stagedDir, 'scripts')); + copyFileSync( + path.join(desktopRoot, 'drizzle', 'scripts', MIGRATION.replace(/\.sql$/, '.ts')), + path.join(stagedDir, 'scripts', MIGRATION.replace(/\.sql$/, '.ts')), + ); + try { + runMigrationReplay(db, { drizzleDir: stagedDir, currentVersion: 91 }); + } finally { + rmSync(stagedDir, { recursive: true, force: true }); + } +} + +function columns(db: ReturnType, table: string): string[] { + return db + .prepare(`PRAGMA table_info('${table}')`) + .all() + .map((row) => String((row as { name: unknown }).name)); +} + +function indexExists(db: ReturnType, name: string): boolean { + return Boolean( + db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?").get(name), + ); +} + +describe('Bot release migration', () => { + it('creates the final Bot schema after the published main migration lineage', () => { + const db = createDb(); + runBotMigration(db); + + expect(columns(db, 'bot_runtime_snapshots')).toEqual( + expect.arrayContaining(['prepared_at', 'applied_at', 'failed_at', 'failure_json']), + ); + expect(columns(db, 'bot_automation_runs')).toEqual( + expect.arrayContaining([ + 'execution_plan_json', + 'target_route_owner_generation_snapshot', + 'output_artifacts_json', + ]), + ); + expect(columns(db, 'bot_workspace_leases')).toContain('lease_key'); + expect(indexExists(db, 'uniq_bot_session_links_route')).toBe(true); + expect(indexExists(db, 'uniq_bot_workspace_leases_active_binding_key')).toBe(true); + expect(indexExists(db, 'right_sidebar_tabs_bot_delegations_singleton_idx')).toBe(true); + }); + + it('enforces canonical, route, project, and sidebar ownership uniqueness', () => { + const db = createDb(); + runBotMigration(db); + db.exec(` + INSERT INTO bot_profiles (id, display_name, created_at, updated_at) + VALUES ('bot-1', 'Bot One', 1, 1); + INSERT INTO bot_channels (id, bot_id, kind, created_at, updated_at) + VALUES ('channel-1', 'bot-1', 'telegram', 1, 1); + INSERT INTO sessions (id, status) VALUES + ('session-1', 'active'), ('session-2', 'active'), + ('session-3', 'active'), ('session-4', 'active'); + INSERT INTO bot_session_links + (id, bot_id, session_id, profile_version, role, channel_id, route_key, created_at) + VALUES ('route-1', 'bot-1', 'session-1', 1, 'route', 'channel-1', 'chat:1', 1); + INSERT INTO bot_session_links + (id, bot_id, session_id, profile_version, role, created_at) + VALUES ('canonical-1', 'bot-1', 'session-3', 1, 'canonical', 1); + INSERT INTO bot_project_bindings + (id, bot_id, project_key, working_dir, is_default, created_at, updated_at) + VALUES ('project-1', 'bot-1', 'local:/repo', '/repo', 1, 1, 1); + INSERT INTO right_sidebar_tabs (id, session_id, kind) + VALUES ('tab-1', 'session-1', 'bot-delegations'); + `); + + expect(() => db.prepare(`INSERT INTO bot_session_links + (id, bot_id, session_id, profile_version, role, channel_id, route_key, created_at) + VALUES ('route-2', 'bot-1', 'session-2', 1, 'route', 'channel-1', 'chat:1', 2)`).run()) + .toThrow(); + expect(() => db.prepare(`INSERT INTO bot_session_links + (id, bot_id, session_id, profile_version, role, created_at) + VALUES ('canonical-2', 'bot-1', 'session-4', 1, 'canonical', 2)`).run()).toThrow(); + expect(() => db.prepare(`INSERT INTO bot_channels + (id, bot_id, kind, created_at, updated_at) + VALUES ('channel-2', 'bot-1', 'telegram', 2, 2)`).run()).not.toThrow(); + expect(() => db.prepare(`INSERT INTO bot_project_bindings + (id, bot_id, project_key, working_dir, is_default, created_at, updated_at) + VALUES ('project-2', 'bot-1', 'local:/other', '/other', 1, 2, 2)`).run()).toThrow(); + expect(() => db.prepare(`INSERT INTO right_sidebar_tabs (id, session_id, kind) + VALUES ('tab-2', 'session-1', 'bot-delegations')`).run()).toThrow(); + }); +}); diff --git a/apps/desktop/src/main/localDb/__tests__/botPortabilityArchive.test.ts b/apps/desktop/src/main/localDb/__tests__/botPortabilityArchive.test.ts new file mode 100644 index 0000000000..9bfe998eb6 --- /dev/null +++ b/apps/desktop/src/main/localDb/__tests__/botPortabilityArchive.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { + inspectBotBundleEntries, + normalizeBotBundleEntryPath, +} from '../botPortabilityArchive'; + +describe('bot portability archive safety', () => { + it('accepts one normal top-level directory', () => { + expect( + inspectBotBundleEntries([ + { path: 'release-bot/', type: 'Directory', size: 0 }, + { path: 'release-bot/bot.json', type: 'File', size: 120 }, + { path: 'release-bot/SOUL.md', type: 'File', size: 30 }, + ]), + ).toBe('release-bot'); + }); + + it.each(['../escape', '/absolute/file', 'C:\\Users\\me\\file', 'root/../escape']) ( + 'rejects unsafe path %s', + (entryPath) => { + expect(() => normalizeBotBundleEntryPath(entryPath)).toThrow(); + }, + ); + + it('rejects multiple roots', () => { + expect(() => + inspectBotBundleEntries([ + { path: 'one/bot.json', type: 'File', size: 10 }, + { path: 'two/SOUL.md', type: 'File', size: 10 }, + ]), + ).toThrow('一个顶层目录'); + }); + + it.each(['SymbolicLink', 'Link', 'CharacterDevice', 'BlockDevice', 'FIFO']) ( + 'rejects %s entries', + (type) => { + expect(() => + inspectBotBundleEntries([{ path: 'bot/file', type, size: 0 }]), + ).toThrow('不支持的文件类型'); + }, + ); +}); diff --git a/apps/desktop/src/main/localDb/__tests__/botPortabilityService.test.ts b/apps/desktop/src/main/localDb/__tests__/botPortabilityService.test.ts new file mode 100644 index 0000000000..0113d8b0be --- /dev/null +++ b/apps/desktop/src/main/localDb/__tests__/botPortabilityService.test.ts @@ -0,0 +1,205 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as tar from 'tar'; + +const h = vi.hoisted(() => ({ + db: null as ReturnType | null, + tx: null as null | ((name: string, args: unknown) => Promise), +})); + +vi.mock('../client/current.js', () => ({ + getDbClient: () => ({ drizzle: h.db, tx: h.tx }), +})); + +import { + exportBotBehaviorBundle, + importBotBehaviorBundle, +} from '../botPortabilityService.js'; +import { tx as runWorkerTx } from '../worker/opHandlers/tx.js'; + +describe('Bot behavior bundle round trip', () => { + let sqlite: Database.Database; + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cindy-bot-portability-test-')); + sqlite = new Database(':memory:'); + sqlite.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE bot_profiles ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + avatar TEXT NOT NULL DEFAULT '🤖', + avatar_color TEXT NOT NULL DEFAULT 'violet', + status TEXT NOT NULL DEFAULT 'active', + current_version INTEGER NOT NULL DEFAULT 1, + canonical_session_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE bot_profile_versions ( + id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + identity_source TEXT NOT NULL DEFAULT '', + capabilities_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + UNIQUE(bot_id, version) + ); + CREATE TABLE bot_channels ( + id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + config_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE bot_automation_links ( + id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + schedule_id TEXT, + project_binding_id TEXT, + target_route_id TEXT, + created_with_profile_version INTEGER NOT NULL, + durable_note_namespace TEXT, + execution_policy_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'active', + suspended_status TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE bot_lifecycle_events ( + id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + session_id TEXT, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL + ); + CREATE TABLE schedules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + prompt TEXT NOT NULL, + job_type TEXT NOT NULL DEFAULT 'prompt', + job_config TEXT, + execution_mode TEXT NOT NULL DEFAULT 'agent', + script_config TEXT, + source TEXT, + project_config_id TEXT, + legacy_session_fallback INTEGER NOT NULL DEFAULT 0, + kind TEXT NOT NULL DEFAULT 'cron', + cron_expr TEXT NOT NULL, + timezone TEXT NOT NULL, + recurring INTEGER NOT NULL DEFAULT 1, + manual INTEGER NOT NULL DEFAULT 0, + interval_ms INTEGER, + agent_kind TEXT NOT NULL, + model TEXT, + provider_id TEXT, + effort TEXT, + fast_mode INTEGER NOT NULL DEFAULT 0, + working_dir TEXT, + workspace_kind TEXT NOT NULL DEFAULT 'project', + use_worktree INTEGER NOT NULL DEFAULT 0, + target_session_id TEXT, + persistent_session INTEGER NOT NULL DEFAULT 0, + silent_when_idle INTEGER NOT NULL DEFAULT 0, + pre_run_hook_command TEXT, + pre_run_hook_timeout_ms INTEGER, + skip_log_session_id TEXT, + notify_desktop INTEGER NOT NULL DEFAULT 1, + notify_feishu INTEGER NOT NULL DEFAULT 0, + notify_wecom_group INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + last_fired_at INTEGER, + last_finished_at INTEGER, + next_fire_at INTEGER, + expire_at INTEGER + ); + `); + const db = drizzle(sqlite); + h.db = db; + h.tx = async (name, args) => runWorkerTx(sqlite, { name: name as never, args } as never); + sqlite.exec(` + INSERT INTO bot_profiles VALUES + ('bot-source', 'Release Bot', 'Ships releases', '🚀', 'blue', 'active', 1, NULL, 1, 1); + INSERT INTO bot_profile_versions VALUES + ('bot-source:v1', 'bot-source', 1, + 'Use sk-abcdefghijklmnopqrstuvwxyz123456 safely', + '{"model":"claude-sonnet-4-6","harness":"claude","skills":["release"],"userContextSource":"Owner path /Users/chris/private"}', 1); + INSERT INTO bot_channels VALUES + ('local', 'bot-source', 'local', 1, '{}', 1, 1), + ('telegram', 'bot-source', 'telegram', 1, '{"accountKey":"secret-account"}', 1, 1); + `); + }); + + afterEach(async () => { + sqlite.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('exports a redacted behavior package and imports it without bindings or runtime state', async () => { + const archive = path.join(tempDir, 'release.cindybot'); + const exported = await exportBotBehaviorBundle('bot-source', archive); + expect(exported.redactionCount).toBeGreaterThan(0); + + sqlite.exec("DELETE FROM bot_profiles WHERE id = 'bot-source'"); + const imported = await importBotBehaviorBundle(archive); + expect(imported.canceled).toBe(false); + expect(imported.disabledChannels).toEqual(['telegram']); + + const profile = sqlite.prepare( + 'SELECT display_name, canonical_session_id FROM bot_profiles WHERE id = ?', + ).get(imported.botId) as { display_name: string; canonical_session_id: string | null }; + expect(profile).toEqual({ display_name: 'Release Bot', canonical_session_id: null }); + + const channels = sqlite.prepare( + 'SELECT kind, enabled, config_json FROM bot_channels WHERE bot_id = ? ORDER BY kind', + ).all(imported.botId); + expect(channels).toEqual([ + { kind: 'local', enabled: 1, config_json: '{}' }, + { kind: 'telegram', enabled: 0, config_json: '{}' }, + ]); + + const version = sqlite.prepare( + 'SELECT identity_source, capabilities_json FROM bot_profile_versions WHERE bot_id = ?', + ).get(imported.botId) as { identity_source: string; capabilities_json: string }; + expect(version.identity_source).not.toContain('sk-abcdefghijklmnopqrstuvwxyz123456'); + expect(version.capabilities_json).not.toContain('/Users/chris/private'); + expect(version.capabilities_json).not.toContain('secret-account'); + expect(JSON.parse(version.capabilities_json)).toMatchObject({ + permissions: 'ask', + automation: false, + }); + }); + + it('refuses to overwrite an existing Bot with the same name', async () => { + const archive = path.join(tempDir, 'release.cindybot'); + await exportBotBehaviorBundle('bot-source', archive); + await expect(importBotBehaviorBundle(archive)).rejects.toThrow('不会覆盖现有 Bot'); + expect(sqlite.prepare('SELECT count(*) AS count FROM bot_profiles').get()).toEqual({ count: 1 }); + }); + + it('rejects undeclared files even when the tar paths themselves are safe', async () => { + const root = path.join(tempDir, 'malicious'); + await fs.mkdir(root); + await Promise.all([ + fs.writeFile(path.join(root, 'bot.json'), '{}'), + fs.writeFile(path.join(root, 'SOUL.md'), ''), + fs.writeFile(path.join(root, 'USER.md'), ''), + fs.writeFile(path.join(root, 'auth.json'), '{"token":"secret"}'), + ]); + const archive = path.join(tempDir, 'malicious.cindybot'); + await tar.c({ gzip: true, cwd: tempDir, file: archive }, ['malicious']); + await expect(importBotBehaviorBundle(archive)).rejects.toThrow('未声明文件'); + }); +}); diff --git a/apps/desktop/src/main/localDb/__tests__/botProfileDeletionStore.test.ts b/apps/desktop/src/main/localDb/__tests__/botProfileDeletionStore.test.ts new file mode 100644 index 0000000000..f0bb5b0e32 --- /dev/null +++ b/apps/desktop/src/main/localDb/__tests__/botProfileDeletionStore.test.ts @@ -0,0 +1,80 @@ +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ + db: null as ReturnType | null, + tx: null as null | ((name: string, args: unknown) => Promise), +})); + +vi.mock('../client/current.js', () => ({ + getDbClient: () => ({ drizzle: h.db, tx: h.tx }), +})); + +import { commitBotProfileDeletion } from '../botProfileDeletionStore.js'; +import { tx as runWorkerTx } from '../worker/opHandlers/tx.js'; + +describe('Bot profile deletion transaction', () => { + let sqlite: Database.Database; + + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.exec(` + CREATE TABLE bot_profiles (id TEXT PRIMARY KEY, status TEXT NOT NULL); + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + status TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE bot_session_links (bot_id TEXT NOT NULL, session_id TEXT NOT NULL); + INSERT INTO bot_profiles VALUES ('bot-1', 'archived'); + INSERT INTO sessions VALUES + ('canonical', 'bot', 'archived', 1), + ('route', 'bot', 'archived', 1), + ('ordinary', 'desktop', 'active', 1); + INSERT INTO bot_session_links VALUES + ('bot-1', 'canonical'), + ('bot-1', 'route'); + `); + const db = drizzle(sqlite); + h.db = db; + h.tx = async (name, args) => runWorkerTx(sqlite, { name: name as never, args } as never); + }); + + it('atomically detaches kept transcripts and removes the Profile', async () => { + await commitBotProfileDeletion({ + botId: 'bot-1', + sessionIds: ['canonical', 'route', 'canonical'], + keepTaskHistory: true, + }); + + expect(sqlite.prepare("SELECT id FROM bot_profiles WHERE id = 'bot-1'").get()).toBeUndefined(); + expect(sqlite.prepare("SELECT source, status FROM sessions WHERE id = 'canonical'").get()) + .toEqual({ source: 'desktop', status: 'archived' }); + expect(sqlite.prepare("SELECT source, status FROM sessions WHERE id = 'route'").get()) + .toEqual({ source: 'desktop', status: 'archived' }); + }); + + it('deletes a Profile that has no task yet', async () => { + await commitBotProfileDeletion({ + botId: 'bot-1', + sessionIds: [], + keepTaskHistory: false, + }); + expect(sqlite.prepare("SELECT id FROM bot_profiles WHERE id = 'bot-1'").get()).toBeUndefined(); + }); + + it('refuses a foreign task before changing the Profile or task', async () => { + await expect(commitBotProfileDeletion({ + botId: 'bot-1', + sessionIds: ['ordinary'], + keepTaskHistory: false, + })).rejects.toThrow('只能分离属于该 Bot 的任务'); + + expect(sqlite.prepare("SELECT status FROM bot_profiles WHERE id = 'bot-1'").get()) + .toEqual({ status: 'archived' }); + expect(sqlite.prepare("SELECT source, status FROM sessions WHERE id = 'ordinary'").get()) + .toEqual({ source: 'desktop', status: 'active' }); + }); +}); diff --git a/apps/desktop/src/main/localDb/__tests__/botRouteService.test.ts b/apps/desktop/src/main/localDb/__tests__/botRouteService.test.ts new file mode 100644 index 0000000000..6ace83e088 --- /dev/null +++ b/apps/desktop/src/main/localDb/__tests__/botRouteService.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; + +import { + botChannelMountIdentity, + sameBotChannelMountIdentity, +} from '../../../shared/botChannelRegistry.js'; +import { + botChannelMatchesSource, + botRouteLaneKey, + botRouteMatches, + botRouteOwnsSourceLane, +} from '../botRouteService.js'; + +describe('botRouteMatches', () => { + it('requires every discriminator declared by a Hermes-style route to match', () => { + const route = { scopeKey: 'workspace-1', principalKey: 'channel-1', threadKey: 'thread-1' }; + expect( + botRouteMatches(route, { + platform: 'slack', + scopeKey: 'workspace-1', + principalKey: 'channel-1', + threadKey: 'thread-1', + }), + ).toBe(true); + expect( + botRouteMatches(route, { + platform: 'slack', + scopeKey: 'workspace-2', + principalKey: 'channel-1', + threadKey: 'thread-1', + }), + ).toBe(false); + }); + + it('lets a parent channel route match a child thread while keeping thread routes exact', () => { + const source = { + platform: 'discord' as const, + principalKey: 'post-1', + parentPrincipalKey: 'forum-1', + threadKey: 'post-1', + }; + expect(botRouteMatches({ principalKey: 'forum-1' }, source)).toBe(true); + expect(botRouteMatches({ principalKey: 'forum-1', threadKey: 'post-2' }, source)).toBe(false); + }); + + it('treats omitted discriminators as wildcards within the selected platform', () => { + expect( + botRouteMatches( + { scopeKey: '', principalKey: '', threadKey: null }, + { platform: 'telegram', principalKey: '-100123' }, + ), + ).toBe(true); + }); + + it('uses wildcard and parent routes only as templates, never as shared task owners', () => { + const threaded = { + platform: 'slack' as const, + accountKey: 'T1', + scopeKey: 'T1', + principalKey: 'C1', + threadKey: '100.1', + }; + expect( + botRouteOwnsSourceLane({ scopeKey: '', principalKey: '', threadKey: null }, threaded), + ).toBe(false); + expect( + botRouteOwnsSourceLane({ scopeKey: 'T1', principalKey: 'C1', threadKey: null }, threaded), + ).toBe(false); + expect( + botRouteOwnsSourceLane({ scopeKey: 'T1', principalKey: 'C1', threadKey: '100.1' }, threaded), + ).toBe(true); + }); + + it('derives a stable key per concrete lane without exposing raw IM identifiers', () => { + const base = { + platform: 'telegram' as const, + accountKey: 'bot-account', + scopeKey: 'bot-account', + principalKey: '-100123', + }; + expect(botRouteLaneKey(base)).toBe(botRouteLaneKey({ ...base })); + expect(botRouteLaneKey(base)).not.toContain('-100123'); + expect(botRouteLaneKey({ ...base, threadKey: 'topic-7' })).not.toBe(botRouteLaneKey(base)); + }); + + it('binds non-local Channels to one concrete IM account and fails closed when unbound', () => { + const source = { platform: 'telegram' as const, accountKey: 'bot-token-fingerprint' }; + expect( + botChannelMatchesSource( + 'telegram', + JSON.stringify({ accountKey: 'bot-token-fingerprint' }), + source, + ), + ).toBe(true); + expect(botChannelMatchesSource('telegram', '{}', source)).toBe(false); + expect( + botChannelMatchesSource( + 'telegram', + JSON.stringify({ accountKey: 'another-account' }), + source, + ), + ).toBe(false); + }); + + it('treats kind, ownership and account identity as one exclusive mount key', () => { + const local = botChannelMountIdentity('telegram', { + ownership: 'local-adapter', + accountKey: 'bot-account', + }); + expect(local).toEqual({ + kind: 'telegram', + ownership: 'local-adapter', + accountKey: 'bot-account', + }); + expect( + sameBotChannelMountIdentity( + local, + botChannelMountIdentity('telegram', { + ownership: 'local-adapter', + accountKey: ' bot-account ', + }), + ), + ).toBe(true); + expect( + sameBotChannelMountIdentity( + local, + botChannelMountIdentity('telegram', { + ownership: 'server-relay', + accountKey: 'bot-account', + }), + ), + ).toBe(false); + expect(botChannelMountIdentity('telegram', { accountKey: 'bot-account' })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/localDb/__tests__/conversationSearch.test.ts b/apps/desktop/src/main/localDb/__tests__/conversationSearch.test.ts index c0f6222e22..7665ee36d2 100644 --- a/apps/desktop/src/main/localDb/__tests__/conversationSearch.test.ts +++ b/apps/desktop/src/main/localDb/__tests__/conversationSearch.test.ts @@ -29,4 +29,10 @@ describe('conversationSearch source invariants', () => { it('keeps the raw stored title in the session summary', () => { expect(conversationSearchSource).toContain('title: row.title,'); }); + + it('keeps ordinary task search on the desktop-visible source boundary by default', () => { + expect(conversationSearchSource).toContain( + 'hostScope.sessionSources === undefined\n ? DESKTOP_VISIBLE_SESSION_SOURCES', + ); + }); }); diff --git a/apps/desktop/src/main/localDb/__tests__/sessionCreateToRow.providerId.test.ts b/apps/desktop/src/main/localDb/__tests__/sessionCreateToRow.providerId.test.ts index 9c334e3923..9ada504e61 100644 --- a/apps/desktop/src/main/localDb/__tests__/sessionCreateToRow.providerId.test.ts +++ b/apps/desktop/src/main/localDb/__tests__/sessionCreateToRow.providerId.test.ts @@ -33,3 +33,21 @@ describe('sessionCreateToRow providerId', () => { expect(sessionCreateToRow('id5', { workingDir: '/repo', providerId: ' ' }, now).providerId).toBeNull(); }); }); + +describe('sessionCreateToRow source', () => { + const now = 1_700_000_000_000; + + it('Bot canonical Session 使用 bot 来源', () => { + const row = sessionCreateToRow( + 'bot-session', + { workingDir: '/repo', source: 'bot', workspaceKind: 'dialogue' }, + now, + ); + expect(row.source).toBe('bot'); + expect(row.workspaceKind).toBe('dialogue'); + }); + + it('未传 source 仍保持 desktop 兼容默认值', () => { + expect(sessionCreateToRow('desktop-session', { workingDir: '/repo' }, now).source).toBe('desktop'); + }); +}); diff --git a/apps/desktop/src/main/localDb/botHistoryScope.ts b/apps/desktop/src/main/localDb/botHistoryScope.ts new file mode 100644 index 0000000000..6b226d82a2 --- /dev/null +++ b/apps/desktop/src/main/localDb/botHistoryScope.ts @@ -0,0 +1,56 @@ +import { getDbClient } from './client/current.js'; +import { createLogger } from '../logger.js'; + +const log = createLogger('bot-history-scope'); + +export type BotHistoryScope = + | { kind: 'unscoped' } + | { kind: 'bot'; botId: string } + | { kind: 'denied' }; + +/** + * Resolve history ownership from host-owned runtime attribution. + * A Bot memory scope must never fall back to account-wide history when the + * Session id is missing or its ownership link is damaged. + */ +export async function resolveBotHistoryScope( + callerSessionId: string | undefined, + callerMemoryScopeKey: string | undefined, +): Promise { + if (!callerSessionId) { + return callerMemoryScopeKey?.startsWith('bot:') ? { kind: 'denied' } : { kind: 'unscoped' }; + } + const row = await getDbClient().queryOne<{ source: string; botId: string | null }>( + `SELECT s.source AS source, + bsl.bot_id AS botId + FROM sessions s + LEFT JOIN bot_session_links bsl ON bsl.session_id = s.id + WHERE s.id = ? + LIMIT 1`, + [callerSessionId], + ); + if (!row) return { kind: 'denied' }; + if (row.source !== 'bot') return { kind: 'unscoped' }; + if (!row.botId) { + log.warn('Bot Session is missing its ownership link', { sessionId: callerSessionId }); + return { kind: 'denied' }; + } + return { kind: 'bot', botId: row.botId }; +} + +export async function resolveBotHistorySessionIds( + callerSessionId: string | undefined, + callerMemoryScopeKey: string | undefined, +): Promise { + const scope = await resolveBotHistoryScope(callerSessionId, callerMemoryScopeKey); + if (scope.kind === 'unscoped') return null; + if (scope.kind === 'denied') return []; + const rows = await getDbClient().query<{ sessionId: string }>( + `SELECT session_id AS sessionId + FROM bot_session_links + WHERE bot_id = ? + ORDER BY created_at DESC`, + [scope.botId], + ); + return rows.map((row) => row.sessionId); +} diff --git a/apps/desktop/src/main/localDb/botImMigrationService.ts b/apps/desktop/src/main/localDb/botImMigrationService.ts new file mode 100644 index 0000000000..2d7b221002 --- /dev/null +++ b/apps/desktop/src/main/localDb/botImMigrationService.ts @@ -0,0 +1,661 @@ +import { createHash, randomUUID } from 'node:crypto'; + +import { and, eq, inArray, ne } from 'drizzle-orm'; + +import { + botChannelMountIdentity, + sameBotChannelMountIdentity, + type BotChannelConnection, +} from '../../shared/botChannelRegistry.js'; +import type { + ApplyBotImMigrationInput, + BotImMigrationCandidate, + BotImMigrationConflict, + BotImMigrationPlan, + BotImMigrationRecord, + BotImMigrationWarning, +} from '../../shared/botImMigration.js'; +import { ownerScopedUserDataPath } from '../appSessionState.js'; +import { createHookBindingStore, type HookBindingSnapshot } from '../hook-control/bindings.js'; +import { hookBotRouteSourceFromExternalKey } from '../hook-control/botRouteTarget.js'; +import { getDbClient } from './client/current.js'; +import { + botChannels, + botImMigrationItems, + botImMigrations, + botLifecycleEvents, + botProfiles, + botRoutes, + botSessionLinks, + imBindings, + sessions, +} from './schema.js'; + +type Db = ReturnType['drizzle']; +type ChannelRow = typeof botChannels.$inferSelect; +type RouteRow = typeof botRoutes.$inferSelect; +type MigrationRow = typeof botImMigrations.$inferSelect; + +const ROUTE_KEY = 'default' as const; + +function parseObject(value: string | null): Record | null { + if (!value) return null; + try { + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +function parseArray(value: string | null): T[] { + if (!value) return []; + try { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) ? (parsed as T[]) : []; + } catch { + return []; + } +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`) + .join(',')}}`; + } + return JSON.stringify(value) ?? 'null'; +} + +function planHash(value: unknown): string { + return createHash('sha256').update(stableJson(value)).digest('base64url'); +} + +function connectionConfig(connection: BotChannelConnection): Record { + return { + accountKey: connection.accountKey, + accountName: connection.accountName, + scopeKey: connection.scopeKey, + connectionId: connection.id, + ownership: connection.ownership, + features: [...connection.features], + }; +} + +function channelMatchesConnection(row: ChannelRow, connection: BotChannelConnection): boolean { + if (row.kind !== connection.kind) return false; + const config = parseObject(row.configJson); + return config?.accountKey === connection.accountKey && config?.ownership === connection.ownership; +} + +function generatedChannelId(botId: string, connection: BotChannelConnection): string { + const digest = createHash('sha256') + .update(`${connection.kind}\0${connection.ownership}\0${connection.accountKey ?? ''}`) + .digest('base64url') + .slice(0, 22); + return `${botId}:${connection.kind}:${digest}`; +} + +async function authoritativeConnection(connectionId: string): Promise { + const { listBotChannelConnections } = await import('../im/index.js'); + return listBotChannelConnections().find((item) => item.id === connectionId) ?? null; +} + +function hookBindingStore() { + return createHookBindingStore({ + filePath: ownerScopedUserDataPath('hook-bindings.json'), + log: { warn: () => undefined }, + }); +} + +function relayBindings(connection: BotChannelConnection): HookBindingSnapshot[] { + if (connection.ownership !== 'server-relay' || !connection.accountKey) return []; + return hookBindingStore() + .list() + .filter((binding) => { + const source = hookBotRouteSourceFromExternalKey(binding.externalKey); + return source?.platform === connection.kind && source.accountKey === connection.accountKey; + }); +} + +function ambiguousRelayBindings(connection: BotChannelConnection): HookBindingSnapshot[] { + if ( + connection.ownership !== 'server-relay' || + connection.kind !== 'slack' || + !connection.accountKey + ) { + return []; + } + return hookBindingStore() + .list() + .filter((binding) => { + if (hookBotRouteSourceFromExternalKey(binding.externalKey)) return false; + // Historical Slack lanes such as team-slack:: and + // slack:dm::gN omit the workspace identity. With multi-workspace + // bindings they cannot be assigned safely, so migration must fail closed. + return /^(?:team-slack:|slack:|dm:)/.test(binding.externalKey); + }); +} + +async function legacySessionRows( + db: Db, + connection: BotChannelConnection, + bindings: readonly HookBindingSnapshot[], +) { + if (!connection.accountKey) return []; + if (connection.ownership === 'server-relay') { + const ids = [...new Set(bindings.map((row) => row.sessionId))]; + return ids.length === 0 + ? [] + : db + .select() + .from(sessions) + .where(and(inArray(sessions.id, ids), inArray(sessions.status, ['active', 'archived']))); + } + if (connection.kind === 'feishu') { + return db + .select() + .from(sessions) + .where( + and( + eq(sessions.source, 'feishu'), + eq(sessions.feishuBotAppId, connection.accountKey), + inArray(sessions.status, ['active', 'archived']), + ), + ); + } + return db + .select() + .from(sessions) + .where( + and( + eq(sessions.source, connection.kind), + eq(sessions.imBotContextId, connection.accountKey), + inArray(sessions.status, ['active', 'archived']), + ), + ); +} + +function migrationRecord(row: MigrationRow, migratedSessionCount: number): BotImMigrationRecord { + const error = parseObject(row.errorJson); + const bindingRestoreConflictCount = + typeof error?.bindingRestoreConflictCount === 'number' ? error.bindingRestoreConflictCount : 0; + return { + id: row.id, + requestId: row.requestId, + botId: row.botId, + channelId: row.channelId, + routeId: row.routeId, + connectionId: row.connectionId, + ownership: row.ownership, + kind: row.kind, + accountKey: row.accountKey, + planHash: row.planHash, + status: row.status, + migratedSessionCount, + createdAt: row.createdAt, + ...(row.appliedAt ? { appliedAt: row.appliedAt } : {}), + ...(row.rolledBackAt ? { rolledBackAt: row.rolledBackAt } : {}), + ...(bindingRestoreConflictCount > 0 + ? { + warnings: [ + { + code: 'binding-restore-conflict' as const, + count: bindingRestoreConflictCount, + }, + ], + } + : {}), + }; +} + +async function readMigrationRecord(db: Db, row: MigrationRow): Promise { + const items = await db + .select({ id: botImMigrationItems.id }) + .from(botImMigrationItems) + .where(eq(botImMigrationItems.migrationId, row.id)); + return migrationRecord(row, items.length); +} + +async function buildPlan( + db: Db, + botId: string, + connection: BotChannelConnection, +): Promise<{ plan: BotImMigrationPlan; bindings: HookBindingSnapshot[] }> { + const conflicts: BotImMigrationConflict[] = []; + const warnings: BotImMigrationWarning[] = []; + const [bot] = await db + .select({ id: botProfiles.id }) + .from(botProfiles) + .where(eq(botProfiles.id, botId)) + .limit(1); + if (!bot) throw new Error('Bot does not exist'); + if (!connection.accountKey) { + conflicts.push({ + code: 'connection-unavailable', + message: 'Channel connection has no account identity', + }); + } + if (!connection.routable) { + conflicts.push({ + code: 'connection-not-routable', + message: 'Channel connection is not routable', + }); + } + if (!connection.connected) warnings.push({ code: 'connection-offline' }); + + const channels = await db.select().from(botChannels).where(eq(botChannels.kind, connection.kind)); + const matchingChannels = channels.filter((row) => channelMatchesConnection(row, connection)); + for (const row of matchingChannels) { + if (row.botId !== botId && row.enabled) { + conflicts.push({ + code: 'channel-owned-by-another-bot', + message: 'This IM account is already mounted by another Bot', + botId: row.botId, + }); + } + } + const ownChannel = matchingChannels.find((row) => row.botId === botId); + const channelId = ownChannel?.id ?? generatedChannelId(botId, connection); + const bindings = relayBindings(connection); + const ambiguousBindings = ambiguousRelayBindings(connection); + if (ambiguousBindings.length > 0) { + conflicts.push({ + code: 'ambiguous-legacy-binding', + message: 'Legacy Slack tasks are missing a workspace identity', + }); + } + const sessionRows = await legacySessionRows(db, connection, bindings); + const sessionIds = sessionRows.map((row) => row.id); + const links = sessionIds.length + ? await db.select().from(botSessionLinks).where(inArray(botSessionLinks.sessionId, sessionIds)) + : []; + const linkBySession = new Map(links.map((row) => [row.sessionId, row])); + const takeoverRows = sessionIds.length + ? await db.select().from(imBindings).where(inArray(imBindings.targetSessionId, sessionIds)) + : []; + for (const takeover of takeoverRows) { + conflicts.push({ + code: 'im-takeover-active', + message: 'A legacy /ctr takeover still targets this task', + sessionId: takeover.targetSessionId, + }); + } + const candidates: BotImMigrationCandidate[] = sessionRows + .map((row) => { + const link = linkBySession.get(row.id); + if (link && link.botId !== botId) { + conflicts.push({ + code: 'session-owned-by-another-bot', + message: 'A legacy IM task is already linked to another Bot', + sessionId: row.id, + botId: link.botId, + }); + } else if (link && link.role !== 'history') { + conflicts.push({ + code: 'session-role-conflict', + message: 'A legacy IM task already has a non-history Bot role', + sessionId: row.id, + }); + } + return { + sessionId: row.id, + title: row.title, + status: row.status as 'active' | 'archived', + source: row.source, + updatedAt: row.updatedAt, + alreadyLinked: link?.botId === botId && link.role === 'history', + }; + }) + .sort((a, b) => a.sessionId.localeCompare(b.sessionId)); + if (candidates.length === 0) warnings.push({ code: 'no-legacy-tasks' }); + + const existingRoutes = ownChannel + ? await db.select().from(botRoutes).where(eq(botRoutes.channelId, ownChannel.id)) + : []; + const duplicateDefault = existingRoutes.filter((row) => row.routeKey === ROUTE_KEY); + if (duplicateDefault.length > 1) { + conflicts.push({ + code: 'route-overlap', + message: 'The account has overlapping default Bot Routes', + }); + } + const hash = planHash({ + botId, + connection: { + id: connection.id, + kind: connection.kind, + ownership: connection.ownership, + accountKey: connection.accountKey, + scopeKey: connection.scopeKey, + routable: connection.routable, + features: [...connection.features].sort(), + }, + channelId, + channel: ownChannel + ? { id: ownChannel.id, enabled: ownChannel.enabled, configJson: ownChannel.configJson } + : null, + route: duplicateDefault[0] + ? { + id: duplicateDefault[0].id, + status: duplicateDefault[0].status, + updatedAt: duplicateDefault[0].updatedAt, + } + : null, + candidates: candidates.map((row) => ({ + sessionId: row.sessionId, + status: row.status, + updatedAt: row.updatedAt, + alreadyLinked: row.alreadyLinked, + })), + bindings: bindings.map((row) => ({ + connectionId: row.connectionId, + externalKey: row.externalKey, + sessionId: row.sessionId, + updatedAt: row.updatedAt, + })), + ambiguousBindings: ambiguousBindings.map((row) => ({ + connectionId: row.connectionId, + externalKey: row.externalKey, + sessionId: row.sessionId, + updatedAt: row.updatedAt, + })), + conflicts, + }); + return { + plan: { + botId, + connection, + channelId, + routeKey: ROUTE_KEY, + planHash: hash, + candidates, + conflicts, + warnings, + alreadyMounted: Boolean(ownChannel?.enabled && duplicateDefault.length === 1), + canApply: conflicts.length === 0, + }, + bindings, + }; +} + +export async function planBotImMigration(input: { + botId: string; + connectionId: string; +}): Promise { + const connection = await authoritativeConnection(input.connectionId); + if (!connection) { + throw new Error('Channel connection is no longer available'); + } + return (await buildPlan(getDbClient().drizzle, input.botId, connection)).plan; +} + +export async function listBotImMigrations(botId: string): Promise { + const db = getDbClient().drizzle; + const rows = await db.select().from(botImMigrations).where(eq(botImMigrations.botId, botId)); + return Promise.all( + rows.sort((a, b) => b.createdAt - a.createdAt).map((row) => readMigrationRecord(db, row)), + ); +} + +async function runConnectionExclusive( + connection: BotChannelConnection, + operation: () => Promise, +): Promise { + if (connection.ownership === 'server-relay') { + const { runHookBotMigrationExclusive } = await import('../hook-control/ipc.js'); + return runHookBotMigrationExclusive( + `${connection.kind}:${connection.accountKey ?? connection.id}`, + operation, + ); + } + const { runImMigrationExclusive } = await import('../im/accountBoundary.js'); + return runImMigrationExclusive(connection.kind, operation); +} + +async function publishMigratedSessionStatus( + sessionIds: readonly string[], + status: 'active' | 'archived', +): Promise { + if (sessionIds.length === 0) return; + const [{ getMakerIfReady }, { broadcastSessionPatched }, { notifyAgentIslandSessionPatch }] = + await Promise.all([ + import('../maker-host/index.js'), + import('./ipc/sessions.js'), + import('./agentIslandSessionPatch.js'), + ]); + for (const sessionId of [...new Set(sessionIds)]) { + if (status === 'archived') { + // Migration is rollback-capable, so preserve the old task worktree and + // message stores. Only stop the live runtime; a later rollback can + // reactivate the exact same task without reconstructing user data. + await getMakerIfReady() + ?.closeSession(sessionId) + .catch(() => undefined); + } + notifyAgentIslandSessionPatch(sessionId, { status }); + broadcastSessionPatched(sessionId, { status }); + } +} + +async function migratedSessionIds( + db: Db, + migrationId: string, + status: 'active' | 'archived', +): Promise { + const items = await db + .select({ + sessionId: botImMigrationItems.sessionId, + sessionArchived: botImMigrationItems.sessionArchived, + originalStatus: botImMigrationItems.originalStatus, + currentStatus: sessions.status, + }) + .from(botImMigrationItems) + .innerJoin(sessions, eq(sessions.id, botImMigrationItems.sessionId)) + .where(eq(botImMigrationItems.migrationId, migrationId)); + return items + .filter((item) => + status === 'archived' + ? item.sessionArchived && item.currentStatus === 'archived' + : item.originalStatus === 'active' && item.currentStatus === 'active', + ) + .map((item) => item.sessionId); +} + +export async function applyBotImMigration( + input: ApplyBotImMigrationInput, +): Promise { + const db = getDbClient().drizzle; + const [replay] = await db + .select() + .from(botImMigrations) + .where(eq(botImMigrations.requestId, input.requestId)) + .limit(1); + if (replay && replay.status !== 'applying') return readMigrationRecord(db, replay); + if (replay) { + const replayConnection = + (await authoritativeConnection(replay.connectionId)) ?? + ({ + id: replay.connectionId, + kind: replay.kind, + ownership: replay.ownership, + status: 'unavailable', + connected: false, + accountKey: replay.accountKey, + accountName: null, + scopeKey: replay.accountKey, + routable: true, + features: [], + } satisfies BotChannelConnection); + return runConnectionExclusive(replayConnection, async () => { + const bindings = parseArray(replay.adapterBindingsJson); + if (replay.ownership === 'server-relay') hookBindingStore().removeMany(bindings); + await publishMigratedSessionStatus( + await migratedSessionIds(db, replay.id, 'archived'), + 'archived', + ); + await db + .update(botImMigrations) + .set({ status: 'applied', appliedAt: Date.now(), errorJson: null }) + .where(and(eq(botImMigrations.id, replay.id), eq(botImMigrations.status, 'applying'))); + const [recovered] = await db + .select() + .from(botImMigrations) + .where(eq(botImMigrations.id, replay.id)) + .limit(1); + if (!recovered) throw new Error('Migration audit record disappeared'); + return readMigrationRecord(db, recovered); + }); + } + const connection = await authoritativeConnection(input.connectionId); + if (!connection) throw new Error('Channel connection is no longer available'); + return runConnectionExclusive(connection, async () => { + const [insideReplay] = await db + .select() + .from(botImMigrations) + .where(eq(botImMigrations.requestId, input.requestId)) + .limit(1); + if (insideReplay) return readMigrationRecord(db, insideReplay); + const rebuilt = await buildPlan(db, input.botId, connection); + if (rebuilt.plan.planHash !== input.planHash) { + throw new Error('Migration plan changed; run the preflight again'); + } + if (!rebuilt.plan.canApply || !connection.accountKey) { + throw new Error('Migration preflight has blocking conflicts'); + } + const accountKey = connection.accountKey; + const now = Date.now(); + const migrationId = randomUUID(); + const channelId = rebuilt.plan.channelId; + const routeId = `${channelId}:${ROUTE_KEY}`; + const capabilities = { + ownership: connection.ownership, + connectionId: connection.id, + features: [...connection.features], + // Migration audit rows currently retain a Route FK. This hidden + // sentinel records mount ownership only; it is never eligible to own a + // task. Concrete DM/group/thread Routes are created lazily per lane. + mountOnly: true, + }; + await getDbClient().tx('bots.applyImMigration', { + migrationId, + requestId: input.requestId, + botId: input.botId, + channelId, + routeId, + connectionId: connection.id, + ownership: connection.ownership, + kind: connection.kind, + accountKey, + planHash: rebuilt.plan.planHash, + channelConfigJson: JSON.stringify(connectionConfig(connection)), + capabilitiesJson: JSON.stringify(capabilities), + adapterBindingsJson: JSON.stringify(rebuilt.bindings), + candidates: rebuilt.plan.candidates.map((candidate) => ({ + sessionId: candidate.sessionId, + status: candidate.status, + updatedAt: candidate.updatedAt, + })), + now, + eventId: `${input.botId}:im-migration:${migrationId}`, + }); + if (connection.ownership === 'server-relay') { + hookBindingStore().removeMany(rebuilt.bindings); + } + await publishMigratedSessionStatus( + rebuilt.plan.candidates + .filter((candidate) => candidate.status === 'active') + .map((candidate) => candidate.sessionId), + 'archived', + ); + const appliedAt = Date.now(); + await db + .update(botImMigrations) + .set({ status: 'applied', appliedAt, errorJson: null }) + .where(and(eq(botImMigrations.id, migrationId), eq(botImMigrations.status, 'applying'))); + const [row] = await db + .select() + .from(botImMigrations) + .where(eq(botImMigrations.id, migrationId)) + .limit(1); + if (!row) throw new Error('Migration audit record disappeared'); + return readMigrationRecord(db, row); + }); +} + +export async function rollbackBotImMigration(migrationId: string): Promise { + const db = getDbClient().drizzle; + const [migration] = await db + .select() + .from(botImMigrations) + .where(eq(botImMigrations.id, migrationId)) + .limit(1); + if (!migration) throw new Error('Migration does not exist'); + if (migration.status === 'rolled-back') return readMigrationRecord(db, migration); + if ( + migration.status !== 'applied' && + migration.status !== 'applying' && + migration.status !== 'rolling-back' + ) { + throw new Error(`Migration cannot be rolled back from ${migration.status}`); + } + const connection = + (await authoritativeConnection(migration.connectionId)) ?? + ({ + id: migration.connectionId, + kind: migration.kind, + ownership: migration.ownership, + status: 'unavailable', + connected: false, + accountKey: migration.accountKey, + accountName: null, + scopeKey: migration.accountKey, + routable: true, + features: [], + } satisfies BotChannelConnection); + return runConnectionExclusive(connection, async () => { + const [fresh] = await db + .select() + .from(botImMigrations) + .where(eq(botImMigrations.id, migrationId)) + .limit(1); + if (!fresh) throw new Error('Migration does not exist'); + if (fresh.status === 'rolled-back') return readMigrationRecord(db, fresh); + const bindings = parseArray(fresh.adapterBindingsJson); + if (fresh.status !== 'rolling-back') { + const now = Date.now(); + await getDbClient().tx('bots.beginImMigrationRollback', { + migrationId, + now, + eventId: `${fresh.botId}:im-migration-rollback:${migrationId}`, + }); + } + await publishMigratedSessionStatus(await migratedSessionIds(db, fresh.id, 'active'), 'active'); + const restoreResult = + fresh.ownership === 'server-relay' + ? hookBindingStore().restoreMany(bindings) + : { restored: 0, skippedConflicts: 0 }; + await db + .update(botImMigrations) + .set({ + status: 'rolled-back', + rolledBackAt: Date.now(), + errorJson: + restoreResult.skippedConflicts > 0 + ? JSON.stringify({ bindingRestoreConflictCount: restoreResult.skippedConflicts }) + : null, + }) + .where(and(eq(botImMigrations.id, migrationId), eq(botImMigrations.status, 'rolling-back'))); + const [row] = await db + .select() + .from(botImMigrations) + .where(eq(botImMigrations.id, migrationId)) + .limit(1); + if (!row) throw new Error('Migration audit record disappeared'); + return readMigrationRecord(db, row); + }); +} diff --git a/apps/desktop/src/main/localDb/botPortabilityArchive.ts b/apps/desktop/src/main/localDb/botPortabilityArchive.ts new file mode 100644 index 0000000000..b42af4aae8 --- /dev/null +++ b/apps/desktop/src/main/localDb/botPortabilityArchive.ts @@ -0,0 +1,97 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import * as tar from 'tar'; + +import { + BOT_BUNDLE_MAX_FILES, + BOT_BUNDLE_MAX_FILE_BYTES, + BOT_BUNDLE_MAX_TOTAL_BYTES, +} from '../../shared/botPortability.js'; + +export interface BotBundleArchiveEntry { + path: string; + type: string; + size: number; +} + +const WINDOWS_DRIVE = /^[a-zA-Z]:[\\/]/; +const ALLOWED_TYPES = new Set(['File', 'Directory']); + +export function normalizeBotBundleEntryPath(rawPath: string): string[] { + const portable = rawPath.replaceAll('\\', '/'); + if (!portable || portable.startsWith('/') || WINDOWS_DRIVE.test(rawPath)) { + throw new Error('Bot 配置包包含绝对路径'); + } + const parts = portable.split('/').filter(Boolean); + if (parts.length === 0 || parts.some((part) => part === '.' || part === '..')) { + throw new Error('Bot 配置包包含不安全路径'); + } + return parts; +} + +export function inspectBotBundleEntries(entries: BotBundleArchiveEntry[]): string { + if (entries.length === 0) throw new Error('Bot 配置包为空'); + if (entries.length > BOT_BUNDLE_MAX_FILES) throw new Error('Bot 配置包文件数量超过上限'); + let totalBytes = 0; + let root: string | null = null; + for (const entry of entries) { + if (!ALLOWED_TYPES.has(entry.type)) { + throw new Error(`Bot 配置包包含不支持的文件类型: ${entry.type}`); + } + const parts = normalizeBotBundleEntryPath(entry.path); + root ??= parts[0]; + if (parts[0] !== root) throw new Error('Bot 配置包必须只有一个顶层目录'); + if (!Number.isSafeInteger(entry.size) || entry.size < 0) { + throw new Error('Bot 配置包包含无效文件大小'); + } + if (entry.size > BOT_BUNDLE_MAX_FILE_BYTES) { + throw new Error('Bot 配置包单个文件超过大小上限'); + } + totalBytes += entry.size; + if (totalBytes > BOT_BUNDLE_MAX_TOTAL_BYTES) { + throw new Error('Bot 配置包总大小超过上限'); + } + } + if (!root) throw new Error('Bot 配置包为空'); + return root; +} + +export async function inspectBotBundleArchive(archivePath: string): Promise<{ + root: string; + entries: BotBundleArchiveEntry[]; +}> { + const entries: BotBundleArchiveEntry[] = []; + await tar.t({ + file: archivePath, + strict: true, + onentry: (entry) => { + entries.push({ path: entry.path, type: entry.type, size: entry.size }); + }, + }); + return { root: inspectBotBundleEntries(entries), entries }; +} + +export async function safelyExtractBotBundle(archivePath: string, targetDir: string): Promise { + const { root } = await inspectBotBundleArchive(archivePath); + await fs.mkdir(targetDir, { recursive: true, mode: 0o700 }); + await tar.x({ + file: archivePath, + cwd: targetDir, + strict: true, + preservePaths: false, + noChmod: true, + filter: (_entryPath, entry) => 'type' in entry && ALLOWED_TYPES.has(entry.type), + }); + const extractedRoot = path.join(targetDir, root); + const resolvedTarget = path.resolve(targetDir); + const resolvedRoot = path.resolve(extractedRoot); + if (resolvedRoot === resolvedTarget || !resolvedRoot.startsWith(`${resolvedTarget}${path.sep}`)) { + throw new Error('Bot 配置包解压目录越界'); + } + const stat = await fs.lstat(extractedRoot); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error('Bot 配置包顶层必须是普通目录'); + } + return extractedRoot; +} diff --git a/apps/desktop/src/main/localDb/botPortabilityService.ts b/apps/desktop/src/main/localDb/botPortabilityService.ts new file mode 100644 index 0000000000..d67adfa8bc --- /dev/null +++ b/apps/desktop/src/main/localDb/botPortabilityService.ts @@ -0,0 +1,344 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import { and, eq, inArray } from 'drizzle-orm'; +import * as tar from 'tar'; + +import { redactSensitive } from '../learn-host/redaction.js'; +import { getDbClient } from './client/current.js'; +import { + botAutomationLinks, + botChannels, + botLifecycleEvents, + botProfiles, + botProfileVersions, + schedules, +} from './schema.js'; +import { + inspectBotBundleArchive, + safelyExtractBotBundle, +} from './botPortabilityArchive.js'; +import { + CINDY_BOT_BUNDLE_FORMAT, + CINDY_BOT_BUNDLE_VERSION, + type BotBundleImportResult, + type CindyBotBundleManifest, + type PortableBotAutomationDefinition, + type PortableBotChannelKind, + isCindyBotBundleManifest, +} from '../../shared/botPortability.js'; + +const MAX_MANIFEST_BYTES = 2 * 1024 * 1024; +const MAX_PROFILE_TEXT_BYTES = 2 * 1024 * 1024; +const EXCLUSIONS = [ + 'credentials', + 'channel-bindings', + 'sessions', + 'history', + 'memory', + 'worktrees', + 'local-paths', + 'runtime-state', +] as const; + +function parseObject(value: string | null | undefined): Record { + try { + const parsed = JSON.parse(value ?? '{}') as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function portableCapabilities(raw: Record): Record { + const keys = [ + 'model', + 'harness', + 'skillMode', + 'skills', + 'toolsetMode', + 'toolsets', + 'mcpMode', + 'mcpServers', + 'memory', + 'automation', + 'permissions', + ] as const; + return Object.fromEntries(keys.filter((key) => key in raw).map((key) => [key, raw[key]])); +} + +function safeBundleName(name: string): string { + const base = name + .normalize('NFKC') + .replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 80); + return base || 'cindy-bot'; +} + +function redactText(value: string): { text: string; hits: number } { + const result = redactSensitive(value); + return { text: result.text, hits: result.hitCount }; +} + +function redactJsonObject(value: Record): { + value: Record; + hits: number; +} { + const redacted = redactText(JSON.stringify(value)); + try { + const parsed = JSON.parse(redacted.text) as unknown; + return { + value: parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}, + hits: redacted.hits, + }; + } catch { + return { value: {}, hits: redacted.hits }; + } +} + +export async function exportBotBehaviorBundle(botId: string, outputPath: string): Promise<{ + filePath: string; + redactionCount: number; + warnings: string[]; +}> { + const db = getDbClient().drizzle; + const [profile] = await db.select().from(botProfiles).where(eq(botProfiles.id, botId)).limit(1); + if (!profile) throw new Error('Bot 不存在'); + const [version] = await db + .select() + .from(botProfileVersions) + .where( + and( + eq(botProfileVersions.botId, botId), + eq(botProfileVersions.version, profile.currentVersion), + ), + ) + .limit(1); + if (!version) throw new Error('Bot 当前 Profile 版本不存在'); + const [channelRows, linkRows] = await Promise.all([ + db.select().from(botChannels).where(eq(botChannels.botId, botId)), + db.select().from(botAutomationLinks).where(eq(botAutomationLinks.botId, botId)), + ]); + const scheduleRows = linkRows.some((link) => link.scheduleId) + ? await db + .select() + .from(schedules) + .where( + inArray( + schedules.id, + linkRows.flatMap((link) => (link.scheduleId ? [link.scheduleId] : [])), + ), + ) + : []; + const scheduleById = new Map(scheduleRows.map((schedule) => [schedule.id, schedule])); + const rawCapabilities = parseObject(version.capabilitiesJson); + const userContext = typeof rawCapabilities.userContextSource === 'string' + ? rawCapabilities.userContextSource + : ''; + const soul = redactText(version.identitySource); + const user = redactText(userContext); + const profileName = redactText(profile.displayName); + const profileDescription = redactText(profile.description); + const capabilities = redactJsonObject(portableCapabilities(rawCapabilities)); + let redactionCount = + soul.hits + user.hits + profileName.hits + profileDescription.hits + capabilities.hits; + const automations: PortableBotAutomationDefinition[] = []; + for (const link of linkRows) { + if (!link.scheduleId) continue; + const schedule = scheduleById.get(link.scheduleId); + if (!schedule) continue; + const name = redactText(schedule.name); + const prompt = redactText(schedule.prompt); + const script = schedule.scriptConfig ? redactText(schedule.scriptConfig) : null; + redactionCount += name.hits + prompt.hits + (script?.hits ?? 0); + const executionPolicy = redactJsonObject(parseObject(link.executionPolicyJson)); + redactionCount += executionPolicy.hits; + automations.push({ + name: name.text, + prompt: prompt.text, + executionMode: schedule.executionMode, + ...(script?.text ? { scriptConfig: script.text } : {}), + cronExpr: schedule.cronExpr, + timezone: schedule.timezone, + recurring: schedule.recurring, + manual: schedule.manual, + ...(schedule.intervalMs != null ? { intervalMs: schedule.intervalMs } : {}), + agentKind: schedule.agentKind, + ...(schedule.model ? { model: schedule.model } : {}), + ...(schedule.providerId ? { providerId: schedule.providerId } : {}), + ...(schedule.effort ? { effort: schedule.effort } : {}), + fastMode: schedule.fastMode, + persistentSession: schedule.persistentSession, + silentWhenIdle: schedule.silentWhenIdle, + notifyDesktop: schedule.notifyDesktop, + notifyFeishu: false, + notifyWecomGroup: false, + executionPolicy: executionPolicy.value, + enabled: false, + }); + } + const manifest: CindyBotBundleManifest = { + format: CINDY_BOT_BUNDLE_FORMAT, + version: CINDY_BOT_BUNDLE_VERSION, + exportedAt: new Date().toISOString(), + bot: { + name: profileName.text, + description: profileDescription.text, + avatar: profile.avatar, + avatarColor: profile.avatarColor, + }, + profile: { + soul: 'SOUL.md', + user: 'USER.md', + capabilities: capabilities.value, + }, + channels: [...new Set(channelRows.map((channel) => channel.kind))].map((kind) => ({ + kind, + enabled: kind === 'local', + })), + automations, + exclusions: EXCLUSIONS, + }; + + const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cindy-bot-export-')); + const rootName = safeBundleName(profile.displayName); + const rootDir = path.join(stagingDir, rootName); + try { + await fs.mkdir(rootDir, { recursive: true, mode: 0o700 }); + await Promise.all([ + fs.writeFile(path.join(rootDir, 'SOUL.md'), soul.text, { mode: 0o600 }), + fs.writeFile(path.join(rootDir, 'USER.md'), user.text, { mode: 0o600 }), + fs.writeFile(path.join(rootDir, 'bot.json'), `${JSON.stringify(manifest, null, 2)}\n`, { + mode: 0o600, + }), + ]); + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await tar.c({ gzip: true, cwd: stagingDir, file: outputPath, portable: true }, [rootName]); + } finally { + await fs.rm(stagingDir, { recursive: true, force: true }); + } + return { + filePath: outputPath, + redactionCount, + warnings: redactionCount > 0 ? ['导出内容已自动移除可能的凭证、身份信息或本机路径'] : [], + }; +} + +async function readBoundedText(filePath: string, maxBytes: number): Promise { + const stat = await fs.lstat(filePath); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('Bot 配置包包含非法文件'); + if (stat.size > maxBytes) throw new Error('Bot 配置包文件超过大小上限'); + return fs.readFile(filePath, 'utf8'); +} + +export async function importBotBehaviorBundle(archivePath: string): Promise { + const stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cindy-bot-import-')); + try { + // Copy once into private staging so a caller cannot replace the archive + // between the validation pass and extraction (TOCTOU). + const stagedArchive = path.join(stagingDir, 'input.cindybot'); + await fs.copyFile(archivePath, stagedArchive); + await fs.chmod(stagedArchive, 0o600); + const inspection = await inspectBotBundleArchive(stagedArchive); + const expectedFiles = new Set([ + `${inspection.root}/bot.json`, + `${inspection.root}/SOUL.md`, + `${inspection.root}/USER.md`, + ]); + for (const entry of inspection.entries) { + const normalized = entry.path.replaceAll('\\', '/').replace(/\/$/, ''); + if (entry.type === 'File' && !expectedFiles.has(normalized)) { + throw new Error(`Bot 配置包包含未声明文件: ${normalized}`); + } + } + const extractedDir = path.join(stagingDir, 'extracted'); + const rootDir = await safelyExtractBotBundle(stagedArchive, extractedDir); + const manifestText = await readBoundedText(path.join(rootDir, 'bot.json'), MAX_MANIFEST_BYTES); + let parsed: unknown; + try { + parsed = JSON.parse(manifestText); + } catch { + throw new Error('Bot 配置包 manifest 不是有效 JSON'); + } + if (!isCindyBotBundleManifest(parsed)) throw new Error('Bot 配置包格式或版本不受支持'); + const manifest = parsed; + const soul = await readBoundedText(path.join(rootDir, manifest.profile.soul), MAX_PROFILE_TEXT_BYTES); + const user = await readBoundedText(path.join(rootDir, manifest.profile.user), MAX_PROFILE_TEXT_BYTES); + const now = Date.now(); + const botId = `bot_${randomUUID()}`; + const channelKinds = [...new Set([ + 'local', + ...manifest.channels.map((channel) => channel.kind), + ])]; + await getDbClient().tx('bots.importBehaviorBundle', { + bot: { + id: botId, + displayName: manifest.bot.name.trim(), + description: manifest.bot.description, + avatar: manifest.bot.avatar || '🤖', + avatarColor: manifest.bot.avatarColor || 'violet', + identitySource: soul, + capabilitiesJson: JSON.stringify({ + ...portableCapabilities(manifest.profile.capabilities), + userContextSource: user, + // A shared bundle is never authority to grant unattended execution. + // The user must explicitly re-enable Automation and trusted mode on + // this device after reviewing installed Skills/MCP/Toolsets. + permissions: 'ask', + automation: false, + }), + }, + channels: channelKinds.map((kind) => ({ + id: `${botId}:${kind}:${randomUUID()}`, + kind, + enabled: kind === 'local', + })), + automations: manifest.automations.map((automation) => ({ + scheduleId: `schedule_${randomUUID()}`, + linkId: `bot_automation_${randomUUID()}`, + name: automation.name, + prompt: automation.prompt, + executionMode: automation.executionMode, + scriptConfig: automation.scriptConfig ?? null, + cronExpr: automation.cronExpr, + timezone: automation.timezone, + recurring: automation.recurring, + manual: automation.manual, + intervalMs: automation.intervalMs ?? null, + agentKind: automation.agentKind, + model: automation.model ?? null, + providerId: automation.providerId ?? null, + effort: automation.effort ?? null, + fastMode: automation.fastMode, + persistentSession: automation.persistentSession, + silentWhenIdle: automation.silentWhenIdle, + notifyDesktop: automation.notifyDesktop, + executionPolicyJson: JSON.stringify(automation.executionPolicy ?? {}), + })), + now, + eventId: `${botId}:imported:${now}`, + }); + return { + canceled: false, + botId, + botName: manifest.bot.name, + disabledChannels: channelKinds.filter((kind) => kind !== 'local'), + pausedAutomations: manifest.automations.length, + warnings: [ + 'IM 账号与凭证未导入,外部 Channel 需要重新绑定', + 'Automation 已导入为暂停状态,需要检查项目与权限后手动启用', + ], + }; + } finally { + await fs.rm(stagingDir, { recursive: true, force: true }); + } +} diff --git a/apps/desktop/src/main/localDb/botProfileDeletionStore.ts b/apps/desktop/src/main/localDb/botProfileDeletionStore.ts new file mode 100644 index 0000000000..8fc34b51c4 --- /dev/null +++ b/apps/desktop/src/main/localDb/botProfileDeletionStore.ts @@ -0,0 +1,14 @@ +import { getDbClient } from './client/current.js'; + +export async function commitBotProfileDeletion(input: { + botId: string; + sessionIds: string[]; + keepTaskHistory: boolean; +}): Promise<{ sessionIds: string[]; status: 'archived' | 'deleted' }> { + return getDbClient().tx('bots.deleteProfile', { + botId: input.botId, + sessionIds: [...new Set(input.sessionIds)], + keepTaskHistory: input.keepTaskHistory, + at: Date.now(), + }); +} diff --git a/apps/desktop/src/main/localDb/botRouteService.ts b/apps/desktop/src/main/localDb/botRouteService.ts new file mode 100644 index 0000000000..9894c72c88 --- /dev/null +++ b/apps/desktop/src/main/localDb/botRouteService.ts @@ -0,0 +1,733 @@ +import fs from 'node:fs/promises'; +import { createHash, randomUUID } from 'node:crypto'; + +import { and, eq, inArray, ne } from 'drizzle-orm'; + +import type { + BotRoutePlatform, + BotRouteRecord, + BotRouteSource, + BotRouteStatus, + ClaimBotRouteInput, + UpdateBotRouteSessionInput, + UpsertBotRouteInput, +} from '../../shared/botRoute.js'; +import { getDbClient } from './client/current.js'; +import type { BotsCreateRouteSessionResult } from './client/tx/types.js'; +import { + botChannels, + botLifecycleEvents, + botProfileVersions, + botProfiles, + botProjectBindings, + botRoutes, + botSessionLinks, + sessions, +} from './schema.js'; +import { ensureDialogueWorkspaceDir } from './dialogueWorkspace.js'; +import { sessionCreateToRow } from './mapper.js'; +import { resolveBusinessSessionId } from '../sessionIds.js'; +import { ensureProjectGitInitialized } from '../git-snapshot/projectGitBootstrap.js'; +import { readGitSafetySettings } from '../maker-host/git-safety-settings-store.js'; +import { cancelBotDelegationsForParentIfReady } from '../maker-ipc/botDelegationLifecycle.js'; +import { coordinateBotCanonicalReplacement } from '../maker-ipc/botCanonicalReplacementCoordinator.js'; + +type Db = ReturnType['drizzle']; +type RouteRow = typeof botRoutes.$inferSelect; + +const MAX_KEY_LENGTH = 1_000; + +function schedulePerTaskWorkspaceReclaim(sessionId: string): void { + void import('../maker-ipc/botWorkspaceRuntime.js') + .then((module) => module.schedulePerTaskBotWorkspaceReclaim(sessionId)) + .catch(() => undefined); +} + +async function cancelBotDelegationChildren(sessionId: string, reason: string): Promise { + await cancelBotDelegationsForParentIfReady(sessionId, reason).catch(() => undefined); +} + +function normalizeKey(value: string | null | undefined, field: string, required = false): string { + const normalized = value?.trim() ?? ''; + if (required && !normalized) throw new Error(`${field} is required`); + if (normalized.length > MAX_KEY_LENGTH) throw new Error(`${field} is too long`); + return normalized; +} + +function parseCapabilities(value: string): Record { + try { + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +export function botChannelMatchesSource( + platform: BotRoutePlatform, + configJson: string, + source: BotRouteSource, +): boolean { + if (platform === 'local') return source.platform === 'local'; + const config = parseCapabilities(configJson); + const configuredAccount = + typeof config.accountKey === 'string' + ? config.accountKey.trim() + : typeof config.contextId === 'string' + ? config.contextId.trim() + : ''; + return configuredAccount.length > 0 && configuredAccount === (source.accountKey?.trim() ?? ''); +} + +function routeRecord(row: RouteRow, platform: BotRoutePlatform): BotRouteRecord { + return { + id: row.id, + botId: row.botId, + channelId: row.channelId, + platform, + routeKey: row.routeKey, + principalKey: row.principalKey, + scopeKey: row.scopeKey, + threadKey: row.threadKey ?? undefined, + currentSessionId: row.currentSessionId ?? undefined, + projectBindingId: row.projectBindingId ?? undefined, + capabilities: parseCapabilities(row.capabilitiesJson), + ownerDeviceId: row.ownerDeviceId ?? undefined, + ownerGeneration: row.ownerGeneration, + status: row.status, + lastActivityAt: row.lastActivityAt ?? undefined, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +type MatchableRoute = { + scopeKey?: string | null; + principalKey?: string | null; + threadKey?: string | null; +}; + +function routeSpecificity(route: MatchableRoute): number { + return (route.scopeKey ? 2 : 0) + (route.principalKey ? 4 : 0) + (route.threadKey ? 8 : 0); +} + +/** + * A Route may be a policy template (for example "all messages in this + * account" or "all threads in this channel"), but only an exact lane Route + * may own a task. Keeping those concepts separate prevents unrelated DMs, + * groups, channels, and topics from sharing one long-running Bot task. + */ +export function botRouteOwnsSourceLane(route: MatchableRoute, source: BotRouteSource): boolean { + const principalKey = source.principalKey?.trim() ?? ''; + if (!principalKey || !route.principalKey) return false; + if (route.principalKey !== principalKey) return false; + const threadKey = source.threadKey?.trim() ?? ''; + if (threadKey) return route.threadKey === threadKey; + return !route.threadKey; +} + +export function botRouteLaneKey(source: BotRouteSource): string { + const principalKey = normalizeKey(source.principalKey, 'principalKey', true); + const scopeKey = normalizeKey(source.scopeKey, 'scopeKey'); + const threadKey = normalizeKey(source.threadKey, 'threadKey'); + const digest = createHash('sha256') + .update(`${source.platform}\0${scopeKey}\0${principalKey}\0${threadKey}`) + .digest('base64url') + .slice(0, 32); + return `lane:${digest}`; +} + +export function botRouteMatches(route: MatchableRoute, source: BotRouteSource): boolean { + if (route.threadKey && route.threadKey !== (source.threadKey ?? '')) return false; + if ( + route.principalKey && + route.principalKey !== (source.principalKey ?? '') && + route.principalKey !== (source.parentPrincipalKey ?? '') + ) { + return false; + } + if (route.scopeKey && route.scopeKey !== (source.scopeKey ?? '')) return false; + return true; +} + +async function requireChannel(db: Db, botId: string, channelId: string) { + const [channel] = await db + .select() + .from(botChannels) + .where(and(eq(botChannels.id, channelId), eq(botChannels.botId, botId))) + .limit(1); + if (!channel) throw new Error('Bot Channel does not exist'); + if (!channel.enabled) throw new Error('Bot Channel is disabled'); + return channel; +} + +async function requireProjectBinding( + db: Db, + botId: string, + projectBindingId?: string, +): Promise { + if (!projectBindingId) return; + const [binding] = await db + .select({ id: botProjectBindings.id, status: botProjectBindings.status }) + .from(botProjectBindings) + .where(and(eq(botProjectBindings.id, projectBindingId), eq(botProjectBindings.botId, botId))) + .limit(1); + if (!binding || binding.status !== 'active') { + throw new Error('Bot Project binding is unavailable'); + } +} + +async function requireBotSession(db: Db, botId: string, sessionId?: string): Promise { + if (!sessionId) return; + const [owned] = await db + .select({ sessionId: botSessionLinks.sessionId, sessionStatus: sessions.status }) + .from(botSessionLinks) + .innerJoin(sessions, eq(sessions.id, botSessionLinks.sessionId)) + .where(and(eq(botSessionLinks.botId, botId), eq(botSessionLinks.sessionId, sessionId))) + .limit(1); + if (!owned || owned.sessionStatus === 'deleted') { + throw new Error('Bot task is unavailable'); + } +} + +async function readOwnedBotSession(db: Db, botId: string, sessionId: string) { + const [owned] = await db + .select({ status: sessions.status, source: sessions.source }) + .from(botSessionLinks) + .innerJoin(sessions, eq(sessions.id, botSessionLinks.sessionId)) + .where(and(eq(botSessionLinks.botId, botId), eq(botSessionLinks.sessionId, sessionId))) + .limit(1); + return owned; +} + +async function readRoute( + db: Db, + routeId: string, +): Promise<{ row: RouteRow; platform: BotRoutePlatform }> { + const [result] = await db + .select({ + route: botRoutes, + platform: botChannels.kind, + channelConfigJson: botChannels.configJson, + }) + .from(botRoutes) + .innerJoin(botChannels, eq(botChannels.id, botRoutes.channelId)) + .where(eq(botRoutes.id, routeId)) + .limit(1); + if (!result) throw new Error('Bot Route does not exist'); + return { row: result.route, platform: result.platform as BotRoutePlatform }; +} + +export async function upsertBotRoute(input: UpsertBotRouteInput): Promise { + const db = getDbClient().drizzle; + const botId = normalizeKey(input.botId, 'botId', true); + const channelId = normalizeKey(input.channelId, 'channelId', true); + const routeKey = normalizeKey(input.routeKey, 'routeKey', true); + const principalKey = normalizeKey(input.principalKey, 'principalKey'); + const scopeKey = normalizeKey(input.scopeKey, 'scopeKey'); + const threadKey = normalizeKey(input.threadKey, 'threadKey') || null; + const channel = await requireChannel(db, botId, channelId); + await requireProjectBinding(db, botId, input.projectBindingId); + const [profile] = await db + .select({ id: botProfiles.id, status: botProfiles.status }) + .from(botProfiles) + .where(eq(botProfiles.id, botId)) + .limit(1); + if (!profile || profile.status === 'archived') throw new Error('Bot is unavailable'); + const [existing] = await db + .select() + .from(botRoutes) + .where(and(eq(botRoutes.channelId, channelId), eq(botRoutes.routeKey, routeKey))) + .limit(1); + if (existing && input.id && existing.id !== input.id) { + throw new Error('Route key is already owned by another Bot Route'); + } + if (existing && existing.botId !== botId) { + throw new Error('Route key is already owned by another Bot'); + } + const now = Date.now(); + const id = existing?.id ?? (normalizeKey(input.id, 'routeId') || randomUUID()); + const capabilitiesJson = JSON.stringify(input.capabilities ?? {}); + await db + .insert(botRoutes) + .values({ + id, + botId, + channelId, + routeKey, + principalKey, + scopeKey, + threadKey, + currentSessionId: existing?.currentSessionId ?? null, + projectBindingId: input.projectBindingId ?? existing?.projectBindingId ?? null, + capabilitiesJson, + ownerDeviceId: existing?.ownerDeviceId ?? null, + ownerGeneration: existing?.ownerGeneration ?? 0, + status: existing?.status === 'archived' ? 'paused' : (existing?.status ?? 'offline'), + lastActivityAt: existing?.lastActivityAt ?? null, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [botRoutes.channelId, botRoutes.routeKey], + set: { + principalKey, + scopeKey, + threadKey, + projectBindingId: input.projectBindingId ?? null, + capabilitiesJson, + updatedAt: now, + }, + }); + // A concurrent first message for the same IM lane may have inserted the + // channel+route key with another generated id. Read through the unique + // business key instead of assuming our candidate id won the race. + const [persisted] = await db + .select() + .from(botRoutes) + .where(and(eq(botRoutes.channelId, channelId), eq(botRoutes.routeKey, routeKey))) + .limit(1); + if (!persisted) throw new Error('Bot Route disappeared after upsert'); + return routeRecord(persisted, channel.kind as BotRoutePlatform); +} + +export async function resolveBotRoute(source: BotRouteSource): Promise { + const db = getDbClient().drizzle; + const rows = await db + .select({ + route: botRoutes, + platform: botChannels.kind, + channelConfigJson: botChannels.configJson, + }) + .from(botRoutes) + .innerJoin(botChannels, eq(botChannels.id, botRoutes.channelId)) + .innerJoin(botProfiles, eq(botProfiles.id, botRoutes.botId)) + .where( + and( + eq(botChannels.kind, source.platform), + eq(botChannels.enabled, true), + eq(botProfiles.status, 'active'), + inArray(botRoutes.status, ['active', 'offline', 'recovering', 'error']), + ), + ); + const matches = rows + .filter( + ({ route, platform, channelConfigJson }) => + botChannelMatchesSource(platform as BotRoutePlatform, channelConfigJson, source) && + botRouteMatches(route, source) && + parseCapabilities(route.capabilitiesJson).mountOnly !== true && + botRouteOwnsSourceLane(route, source), + ) + .sort( + (a, b) => + routeSpecificity(b.route) - routeSpecificity(a.route) || + a.route.createdAt - b.route.createdAt, + ); + const winner = matches[0]; + return winner ? routeRecord(winner.route, winner.platform as BotRoutePlatform) : null; +} + +/** + * Resolve an exact IM lane Route, creating one from the best matching policy + * template (or the mounted Channel defaults) on first message. Channel + * mounting therefore never creates an account-wide task. + */ +export async function resolveOrCreateBotRoute( + source: BotRouteSource, +): Promise { + const existing = await resolveBotRoute(source); + if (existing) { + const deliveryKey = source.deliveryKey?.trim() ?? ''; + if (deliveryKey && existing.capabilities.deliveryKey !== deliveryKey) { + const capabilities = { ...existing.capabilities, deliveryKey }; + await getDbClient() + .drizzle.update(botRoutes) + .set({ capabilitiesJson: JSON.stringify(capabilities), updatedAt: Date.now() }) + .where(eq(botRoutes.id, existing.id)); + return { ...existing, capabilities, updatedAt: Date.now() }; + } + return existing; + } + if (source.platform === 'local') return null; + normalizeKey(source.accountKey, 'accountKey', true); + normalizeKey(source.principalKey, 'principalKey', true); + + const db = getDbClient().drizzle; + const channels = await db + .select({ channel: botChannels, profileStatus: botProfiles.status }) + .from(botChannels) + .innerJoin(botProfiles, eq(botProfiles.id, botChannels.botId)) + .where( + and( + eq(botChannels.kind, source.platform), + eq(botChannels.enabled, true), + eq(botProfiles.status, 'active'), + ), + ); + const matchingChannels = channels.filter(({ channel }) => + botChannelMatchesSource(source.platform, channel.configJson, source), + ); + if (matchingChannels.length === 0) return null; + if (matchingChannels.length > 1) { + throw new Error('Bot Channel account is mounted by more than one Bot'); + } + + const channel = matchingChannels[0]!.channel; + const templates = ( + await db + .select() + .from(botRoutes) + .where( + and( + eq(botRoutes.channelId, channel.id), + inArray(botRoutes.status, ['active', 'offline', 'recovering', 'error']), + ), + ) + ) + .filter((route) => !botRouteOwnsSourceLane(route, source) && botRouteMatches(route, source)) + .sort((a, b) => routeSpecificity(b) - routeSpecificity(a) || a.createdAt - b.createdAt); + const template = templates[0]; + const channelConfig = parseCapabilities(channel.configJson); + const templateCapabilities = template + ? parseCapabilities(template.capabilitiesJson) + : { + ownership: channelConfig.ownership, + connectionId: channelConfig.connectionId, + features: Array.isArray(channelConfig.features) ? channelConfig.features : [], + }; + const { mountOnly: _mountOnly, ...capabilities } = templateCapabilities; + if (source.deliveryKey?.trim()) capabilities.deliveryKey = source.deliveryKey.trim(); + return upsertBotRoute({ + botId: channel.botId, + channelId: channel.id, + routeKey: botRouteLaneKey(source), + principalKey: source.principalKey, + scopeKey: source.scopeKey, + threadKey: source.threadKey, + projectBindingId: template?.projectBindingId ?? undefined, + capabilities, + }); +} + +export async function claimBotRoute(input: ClaimBotRouteInput): Promise { + const db = getDbClient().drizzle; + const routeId = normalizeKey(input.routeId, 'routeId', true); + const ownerDeviceId = normalizeKey(input.ownerDeviceId, 'ownerDeviceId', true); + const current = await readRoute(db, routeId); + if (current.row.status === 'archived') throw new Error('Bot Route is archived'); + if (current.row.status === 'paused') throw new Error('Bot Route is paused'); + if ( + current.row.status === 'active' && + current.row.ownerDeviceId && + current.row.ownerDeviceId !== ownerDeviceId + ) { + throw new Error('Bot Route is owned by another device'); + } + await requireChannel(db, current.row.botId, current.row.channelId); + await requireProjectBinding( + db, + current.row.botId, + input.projectBindingId ?? current.row.projectBindingId ?? undefined, + ); + await requireBotSession(db, current.row.botId, input.currentSessionId); + const now = Date.now(); + const ownerChanged = + current.row.ownerDeviceId !== ownerDeviceId || current.row.status !== 'active'; + const nextGeneration = ownerChanged + ? current.row.ownerGeneration + 1 + : current.row.ownerGeneration; + const claimedWrite = await db + .update(botRoutes) + .set({ + ownerDeviceId, + ownerGeneration: nextGeneration, + currentSessionId: input.currentSessionId ?? current.row.currentSessionId, + projectBindingId: input.projectBindingId ?? current.row.projectBindingId, + status: 'active', + lastActivityAt: now, + updatedAt: now, + }) + .where( + and(eq(botRoutes.id, routeId), eq(botRoutes.ownerGeneration, current.row.ownerGeneration)), + ) + .run(); + if (claimedWrite.changes !== 1) { + throw new Error('Bot Route ownership changed concurrently'); + } + const next = await readRoute(db, routeId); + if (next.row.ownerGeneration !== nextGeneration || next.row.ownerDeviceId !== ownerDeviceId) { + throw new Error('Bot Route ownership changed concurrently'); + } + return routeRecord(next.row, next.platform); +} + +function botAgentKind(config: Record): 'cc' | 'codex' | 'pi' { + return config.harness === 'codex' ? 'codex' : config.harness === 'pi' ? 'pi' : 'cc'; +} + +function botPermissionMode(config: Record): 'ask' | 'bypassPermissions' { + return config.permissions === 'trusted' ? 'bypassPermissions' : 'ask'; +} + +export async function ensureBotRouteSession(input: { + routeId: string; + ownerDeviceId: string; + forceRenew?: boolean; +}): Promise<{ route: BotRouteRecord; sessionId: string; created: boolean }> { + const claimed = await claimBotRoute({ + routeId: input.routeId, + ownerDeviceId: input.ownerDeviceId, + }); + if (input.forceRenew && claimed.currentSessionId) { + return coordinateBotCanonicalReplacement(claimed.currentSessionId, async () => { + const current = await readRoute(getDbClient().drizzle, claimed.id); + if ( + current.row.currentSessionId !== claimed.currentSessionId || + current.row.ownerDeviceId !== input.ownerDeviceId || + current.row.ownerGeneration !== claimed.ownerGeneration + ) { + throw Object.assign(new Error('Bot Route changed while renewing its task'), { + code: 'PRECONDITION_FAILED', + }); + } + return createOrReuseBotRouteSession(input, claimed); + }); + } + return createOrReuseBotRouteSession(input, claimed); +} + +async function createOrReuseBotRouteSession( + input: { routeId: string; ownerDeviceId: string; forceRenew?: boolean }, + claimed: BotRouteRecord, +): Promise<{ route: BotRouteRecord; sessionId: string; created: boolean }> { + const db = getDbClient().drizzle; + if (!input.forceRenew && claimed.currentSessionId) { + const existing = await readOwnedBotSession(db, claimed.botId, claimed.currentSessionId); + if (existing?.source === 'bot' && existing.status === 'active') { + return { route: claimed, sessionId: claimed.currentSessionId, created: false }; + } + } + + const [profile] = await db + .select() + .from(botProfiles) + .where(eq(botProfiles.id, claimed.botId)) + .limit(1); + if (!profile || profile.status !== 'active') throw new Error('Bot is unavailable'); + const [version] = await db + .select() + .from(botProfileVersions) + .where( + and( + eq(botProfileVersions.botId, claimed.botId), + eq(botProfileVersions.version, profile.currentVersion), + ), + ) + .limit(1); + if (!version) throw new Error('Bot Profile version is unavailable'); + const projectBindingId = claimed.projectBindingId; + const [binding] = projectBindingId + ? await db + .select() + .from(botProjectBindings) + .where( + and( + eq(botProjectBindings.id, projectBindingId), + eq(botProjectBindings.botId, claimed.botId), + eq(botProjectBindings.status, 'active'), + ), + ) + .limit(1) + : []; + if (projectBindingId && !binding) throw new Error('Bot Route project binding is unavailable'); + + const now = Date.now(); + const sessionId = resolveBusinessSessionId(undefined); + const workspaceKind = binding ? 'project' : 'dialogue'; + const workingDir = binding?.workingDir ?? ensureDialogueWorkspaceDir(sessionId, now); + const config = parseCapabilities(version.capabilitiesJson); + try { + await ensureProjectGitInitialized({ + workingDir, + workspaceKind, + remoteHostId: binding?.remoteHostId ?? null, + sessionId, + autoSnapshotEnabled: readGitSafetySettings().autoSnapshotEnabled, + source: 'bot-route-session', + }); + } catch (error) { + if (!binding) await fs.rm(workingDir, { recursive: true, force: true }).catch(() => {}); + throw error; + } + + let created = false; + let resolvedSessionId = sessionId; + // A DB archive without closing the in-memory Maker runtime leaves the old + // Route task alive. It can keep emitting after `/new` and compete with the + // replacement canonical task, so remember only a verified Bot-owned row. + let archivedRuntimeSessionId: string | null = null; + try { + const sessionRow = { + ...sessionCreateToRow( + sessionId, + { + workspaceKind, + workingDir, + model: + typeof config.model === 'string' && config.model.trim() + ? config.model.trim() + : 'claude-sonnet-4-6', + providerId: + typeof config.providerId === 'string' && config.providerId.trim() + ? config.providerId.trim() + : config.providerId === null + ? null + : undefined, + effort: + typeof config.effort === 'string' && config.effort.trim() + ? config.effort.trim() + : undefined, + fastMode: config.fastMode === true, + agentKind: botAgentKind(config), + permissionMode: botPermissionMode(config), + remoteHostId: binding?.remoteHostId ?? undefined, + source: 'bot', + }, + now, + ), + title: `${profile.displayName} · ${claimed.routeKey}`.slice(0, 120), + }; + const result = await getDbClient().tx('bots.createRouteSession', { + routeId: claimed.id, + botId: claimed.botId, + channelId: claimed.channelId, + routeKey: claimed.routeKey, + ownerDeviceId: input.ownerDeviceId, + ownerGeneration: claimed.ownerGeneration, + expectedCurrentSessionId: claimed.currentSessionId ?? null, + profileVersion: profile.currentVersion, + forceRenew: input.forceRenew === true, + session: { + id: sessionRow.id, + title: sessionRow.title, + workingDir: sessionRow.workingDir ?? null, + workspaceKind: sessionRow.workspaceKind, + model: sessionRow.model, + effort: sessionRow.effort, + fastMode: sessionRow.fastMode, + permissionMode: sessionRow.permissionMode, + agentKind: sessionRow.agentKind, + remoteHostId: sessionRow.remoteHostId ?? null, + providerId: sessionRow.providerId ?? null, + extraDirs: sessionRow.extraDirs, + source: sessionRow.source, + createdAt: sessionRow.createdAt, + updatedAt: sessionRow.updatedAt, + }, + now, + }); + created = result.created; + resolvedSessionId = result.sessionId; + archivedRuntimeSessionId = result.archivedRuntimeSessionId; + } finally { + if (!created && !binding) { + const [persisted] = await db + .select({ id: sessions.id }) + .from(sessions) + .where(eq(sessions.id, sessionId)) + .limit(1); + if (!persisted) { + await fs.rm(workingDir, { recursive: true, force: true }).catch(() => {}); + } + } + } + if (archivedRuntimeSessionId) { + await cancelBotDelegationChildren( + archivedRuntimeSessionId, + 'Parent Bot route task was replaced by Renew.', + ); + const { getMakerIfReady } = await import('../maker-host/index.js'); + await getMakerIfReady() + ?.closeSession(archivedRuntimeSessionId) + .catch(() => undefined); + schedulePerTaskWorkspaceReclaim(archivedRuntimeSessionId); + } + const route = await readRoute(db, claimed.id); + return { + route: routeRecord(route.row, route.platform), + sessionId: resolvedSessionId, + created, + }; +} + +export async function updateBotRouteSession( + input: UpdateBotRouteSessionInput, +): Promise { + const db = getDbClient().drizzle; + const current = await readRoute(db, normalizeKey(input.routeId, 'routeId', true)); + if ( + current.row.status !== 'active' || + current.row.ownerDeviceId !== normalizeKey(input.ownerDeviceId, 'ownerDeviceId', true) || + current.row.ownerGeneration !== input.ownerGeneration + ) { + throw new Error('Bot Route ownership is stale'); + } + await requireBotSession(db, current.row.botId, input.currentSessionId); + const now = Date.now(); + const updated = await db + .update(botRoutes) + .set({ currentSessionId: input.currentSessionId, lastActivityAt: now, updatedAt: now }) + .where( + and( + eq(botRoutes.id, current.row.id), + eq(botRoutes.ownerGeneration, input.ownerGeneration), + eq(botRoutes.ownerDeviceId, input.ownerDeviceId), + ), + ) + .run(); + if (updated.changes !== 1) { + throw new Error('Bot Route ownership changed concurrently'); + } + const next = await readRoute(db, current.row.id); + if (next.row.currentSessionId !== input.currentSessionId) { + throw new Error('Bot Route ownership changed concurrently'); + } + return routeRecord(next.row, next.platform); +} + +export async function setBotRouteStatus( + routeId: string, + status: Extract, +): Promise { + const db = getDbClient().drizzle; + const current = await readRoute(db, normalizeKey(routeId, 'routeId', true)); + const now = Date.now(); + const archivedSessionId = status === 'archived' ? current.row.currentSessionId : null; + await getDbClient().tx('bots.setRouteStatus', { + routeId: current.row.id, + botId: current.row.botId, + expectedOwnerGeneration: current.row.ownerGeneration, + currentOwnerDeviceId: current.row.ownerDeviceId ?? null, + currentSessionId: current.row.currentSessionId ?? null, + status, + now, + }); + if (archivedSessionId) { + await cancelBotDelegationChildren( + archivedSessionId, + 'Parent Bot route was archived.', + ); + const { getMakerIfReady } = await import('../maker-host/index.js'); + await getMakerIfReady() + ?.closeSession(archivedSessionId) + .catch(() => undefined); + schedulePerTaskWorkspaceReclaim(archivedSessionId); + } + const next = await readRoute(db, current.row.id); + if (next.row.ownerGeneration !== current.row.ownerGeneration + 1) { + throw new Error('Bot Route ownership changed concurrently'); + } + return routeRecord(next.row, next.platform); +} diff --git a/apps/desktop/src/main/localDb/chatHistoryReader.ts b/apps/desktop/src/main/localDb/chatHistoryReader.ts index 39eb8d49d7..1cabb16a7a 100644 --- a/apps/desktop/src/main/localDb/chatHistoryReader.ts +++ b/apps/desktop/src/main/localDb/chatHistoryReader.ts @@ -47,6 +47,7 @@ export interface HistoryPage { // ── list_workdirs ─────────────────────────────────────────────────────────── export interface ListWorkdirsParams { + sessionIds?: string[] | null; limit: number; cursor: HistoryCursor | null; // cursor.createdAt = lastSessionAt 的 ms; cursor.id = workingDir 字符串 order: HistoryOrder; @@ -72,6 +73,10 @@ export interface WorkdirAggregate { export async function listWorkdirsForHistory( params: ListWorkdirsParams, ): Promise> { + const scopedSessionIds = params.sessionIds ?? null; + if (scopedSessionIds !== null && scopedSessionIds.length === 0) { + return { items: [], nextCursor: null, hasMore: false }; + } const db = getDbClient().drizzle; const orderFn = params.order === 'asc' ? asc : desc; // SQLite GROUP_CONCAT 没有内置 DISTINCT 区分; 用 sql 表达式更直观, 后端再 split @@ -82,6 +87,7 @@ export async function listWorkdirsForHistory( // 基础过滤: 不要 deleted, 不要无 workingDir const baseConds = [ne(sessions.status, 'deleted'), isNotNull(sessions.workingDir)]; + if (scopedSessionIds !== null) baseConds.push(inArray(sessions.id, scopedSessionIds)); // 排除 app-managed dialogue 子树(/dialogues//): // 每个 standalone dialogue 会话一个目录, 是无界增长源——不排除的话大聊天历史 // 用户一次 list_workdirs 会物化上千个单会话组(Codex review)。它们是内部 @@ -157,6 +163,7 @@ export async function listWorkdirsForHistory( // ── list_sessions ─────────────────────────────────────────────────────────── export interface ListSessionsParams { + sessionIds?: string[] | null; workdir: string | null; fromMs: number | null; toMs: number | null; @@ -187,10 +194,15 @@ export interface SessionSummary { export async function listSessionsForHistory( params: ListSessionsParams, ): Promise> { + const scopedSessionIds = params.sessionIds ?? null; + if (scopedSessionIds !== null && scopedSessionIds.length === 0) { + return { items: [], nextCursor: null, hasMore: false }; + } const db = getDbClient().drizzle; const orderFn = params.order === 'asc' ? asc : desc; const conds = []; + if (scopedSessionIds !== null) conds.push(inArray(sessions.id, scopedSessionIds)); if (!params.includeDeleted) conds.push(ne(sessions.status, 'deleted')); if (params.workdir !== null) { const candidates = await resolveStoredWorkingDirCandidates(params.workdir); @@ -341,6 +353,9 @@ const MAX_WORKDIR_SESSION_RESOLUTION = 5000; export async function getMessagesForHistory( params: GetMessagesParams, ): Promise> { + if (params.sessionIds !== null && params.sessionIds.length === 0) { + return { items: [], nextCursor: null, hasMore: false }; + } const db = getDbClient().drizzle; const orderFn = params.order === 'asc' ? asc : desc; diff --git a/apps/desktop/src/main/localDb/chatHistorySearch.ts b/apps/desktop/src/main/localDb/chatHistorySearch.ts index 33f7d2139b..0df9fcbeff 100644 --- a/apps/desktop/src/main/localDb/chatHistorySearch.ts +++ b/apps/desktop/src/main/localDb/chatHistorySearch.ts @@ -113,6 +113,18 @@ interface SearchChatHistoryEngineArgs extends SearchChatHistoryArgs { export async function searchChatHistoryHybrid( args: SearchChatHistoryEngineArgs, ): Promise { + if (args.sessionIds !== null && args.sessionIds.length === 0) { + return { + hits: [], + sessions: {}, + vectorUsed: false, + vectorSkipReason: '当前任务没有可访问的历史。', + nextOffset: null, + hasMore: false, + poolSize: 0, + poolCapped: false, + }; + } // 1) 两路召回(FTS 同步; vector 异步, 失败静默跳过) // workdir 候选按 DB 实际拼写解析一次, 两路 arm 复用(见 workingDirHistoryFilter) const workdirCandidates = diff --git a/apps/desktop/src/main/localDb/client/WorkerThreadTransport.ts b/apps/desktop/src/main/localDb/client/WorkerThreadTransport.ts index 2cfb196e8f..91c6af17cf 100644 --- a/apps/desktop/src/main/localDb/client/WorkerThreadTransport.ts +++ b/apps/desktop/src/main/localDb/client/WorkerThreadTransport.ts @@ -620,7 +620,7 @@ function sessionsSetStatus(readyDb, args) { throw Object.assign(new Error('invalid status: ' + status), { code: 'INVALID_ARGS' }); } const selectSession = readyDb.prepare( - 'SELECT id, title, working_dir AS workingDir, workspace_kind AS workspaceKind, status FROM sessions WHERE id = ? LIMIT 1', + 'SELECT id, title, working_dir AS workingDir, workspace_kind AS workspaceKind, status, source FROM sessions WHERE id = ? LIMIT 1', ); const updateSession = readyDb.prepare( 'UPDATE sessions SET status = ?, updated_at = ? WHERE id = ? RETURNING id, title, working_dir AS workingDir, workspace_kind AS workspaceKind', @@ -636,6 +636,11 @@ function sessionsSetStatus(readyDb, args) { code: 'PRECONDITION_FAILED', }); } + if (existing.source === 'bot') { + throw Object.assign(new Error('Bot 任务必须通过 Bot 生命周期管理: ' + sessionId), { + code: 'PRECONDITION_FAILED', + }); + } const updated = updateSession.get(status, now, sessionId); if (!updated) throw Object.assign(new Error('Session 不存在: ' + sessionId), { code: 'NOT_FOUND' }); applied.push({ @@ -1140,9 +1145,9 @@ function rewindCommit(readyDb, args) { rewindParentlessSubagentTail.run(now, sessionId, targetCreatedAt); } if (sdkSessionId) { - readyDb.prepare('UPDATE sessions SET user_send_at = ?, updated_at = ?, context_tokens = 0, context_window = 0, sdk_session_id = ? WHERE id = ?').run(now, now, sdkSessionId, sessionId); + readyDb.prepare('UPDATE sessions SET user_send_at = ?, updated_at = ?, context_tokens = 0, context_window = 0, codex_plan_json = NULL, sdk_session_id = ? WHERE id = ?').run(now, now, sdkSessionId, sessionId); } else { - readyDb.prepare('UPDATE sessions SET user_send_at = ?, updated_at = ?, context_tokens = 0, context_window = 0 WHERE id = ?').run(now, now, sessionId); + readyDb.prepare('UPDATE sessions SET user_send_at = ?, updated_at = ?, context_tokens = 0, context_window = 0, codex_plan_json = NULL WHERE id = ?').run(now, now, sessionId); } })(); } diff --git a/apps/desktop/src/main/localDb/client/__tests__/tx.test.ts b/apps/desktop/src/main/localDb/client/__tests__/tx.test.ts index c5ac70c7a1..b23b492f51 100644 --- a/apps/desktop/src/main/localDb/client/__tests__/tx.test.ts +++ b/apps/desktop/src/main/localDb/client/__tests__/tx.test.ts @@ -40,8 +40,10 @@ CREATE TABLE sessions ( user_send_at INTEGER, agent_kind TEXT NOT NULL DEFAULT 'cc', orca_role TEXT, + source TEXT NOT NULL DEFAULT 'desktop', workspace_kind TEXT NOT NULL DEFAULT 'project', codex_history_has_product_prompt INTEGER, + codex_plan_json TEXT, parent_session_id TEXT, forked_at_message_id TEXT, created_at INTEGER NOT NULL, @@ -158,6 +160,7 @@ interface TestSessionRow { userSendAt: number | null; agentKind: string; orcaRole: string | null; + source: string; workspaceKind: string; codexHistoryHasProductPrompt: boolean | null; parentSessionId: string | null; @@ -468,40 +471,48 @@ describe('db worker tx handlers', () => { }); }); - it('rewind.commit soft-deletes target-and-after messages and resets session context', async () => { - await withClient(async (client) => { - await seedSession(client, 's1'); - await client.exec( - 'INSERT INTO messages (id, client_id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?)', - ['m1', 'c1', 's1', 'user', 'before', 100, 'm2', 'c2', 's1', 'assistant', 'after', 200], - ); + it.each([false, true])( + 'rewind.commit soft-deletes messages and resets session context (inline=%s)', + async (useInlineWorker) => { + await withClient(async (client) => { + await seedSession(client, 's1'); + await client.exec('UPDATE sessions SET codex_plan_json = ? WHERE id = ?', [ + JSON.stringify({ turnId: 'turn-old', plan: [], state: 'sealed' }), + 's1', + ]); + await client.exec( + 'INSERT INTO messages (id, client_id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?)', + ['m1', 'c1', 's1', 'user', 'before', 100, 'm2', 'c2', 's1', 'assistant', 'after', 200], + ); - await client.tx('rewind.commit', { - sessionId: 's1', - targetCreatedAt: 200, - sdkSessionId: 'sdk-after-rewind', - now: 999, - }); + await client.tx('rewind.commit', { + sessionId: 's1', + targetCreatedAt: 200, + sdkSessionId: 'sdk-after-rewind', + now: 999, + }); - await expect(client.query('SELECT id, rewind_at FROM messages ORDER BY id')).resolves.toEqual( - [ - { id: 'm1', rewind_at: null }, - { id: 'm2', rewind_at: 999 }, - ], - ); - await expect( - client.queryOne( - 'SELECT user_send_at, context_tokens, context_window, sdk_session_id FROM sessions WHERE id = ?', - ['s1'], - ), - ).resolves.toEqual({ - user_send_at: 999, - context_tokens: 0, - context_window: 0, - sdk_session_id: 'sdk-after-rewind', - }); - }); - }); + await expect(client.query('SELECT id, rewind_at FROM messages ORDER BY id')).resolves.toEqual( + [ + { id: 'm1', rewind_at: null }, + { id: 'm2', rewind_at: 999 }, + ], + ); + await expect( + client.queryOne( + 'SELECT user_send_at, context_tokens, context_window, sdk_session_id, codex_plan_json FROM sessions WHERE id = ?', + ['s1'], + ), + ).resolves.toEqual({ + user_send_at: 999, + context_tokens: 0, + context_window: 0, + sdk_session_id: 'sdk-after-rewind', + codex_plan_json: null, + }); + }, { useInlineWorker }); + }, + ); it('rewind.commit uses target message id to avoid same-timestamp over-delete', async () => { await withClient(async (client) => { @@ -915,6 +926,32 @@ describe('db worker tx handlers', () => { }); }); + it.each([false, true])( + 'sessions.setStatus rejects Bot tasks atomically (inline=%s)', + async (useInlineWorker) => { + await withClient( + async (client) => { + await seedSession(client, 'regular'); + await seedSession(client, 'bot', { source: 'bot' }); + + await expect( + client.tx('sessions.setStatus', { + sessionIds: ['regular', 'bot'], + status: 'archived', + }), + ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + await expect( + client.query('SELECT id, status FROM sessions ORDER BY id'), + ).resolves.toEqual([ + { id: 'bot', status: 'active' }, + { id: 'regular', status: 'active' }, + ]); + }, + { useInlineWorker }, + ); + }, + ); + it('rewind.commit follows transcript parent links and preserves the prior assistant when timestamps are inverted', async () => { await withClient(async (client) => { await seedSession(client, 's1'); @@ -2193,8 +2230,8 @@ async function seedSession( sdk_session_id, total_token_usage, total_cost_usd, context_tokens, context_window, fast_mode, cleared_at, pinned_at, user_send_at, agent_kind, orca_role, workspace_kind, parent_session_id, forked_at_message_id, - created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + source, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, [ s.id, s.title, @@ -2217,6 +2254,7 @@ async function seedSession( s.workspaceKind, s.parentSessionId, s.forkedAtMessageId, + s.source, s.createdAt, s.updatedAt, ], @@ -2244,6 +2282,7 @@ function sessionRow(id: string, overrides: Partial = {}): TestSe userSendAt: 1, agentKind: 'cc', orcaRole: null, + source: 'desktop', workspaceKind: 'project', codexHistoryHasProductPrompt: null, parentSessionId: null, diff --git a/apps/desktop/src/main/localDb/client/tx/types.ts b/apps/desktop/src/main/localDb/client/tx/types.ts index f3f62999e8..baaf78ae24 100644 --- a/apps/desktop/src/main/localDb/client/tx/types.ts +++ b/apps/desktop/src/main/localDb/client/tx/types.ts @@ -23,6 +23,31 @@ export type DbTxName = | 'message.delete' | 'im.deleteBindings' | 'im.replaceBinding' + | 'bots.createProfile' + | 'bots.updateProfile' + | 'bots.replaceCanonicalSession' + | 'bots.createRouteSession' + | 'bots.setRouteStatus' + | 'bots.prepareRuntime' + | 'bots.finishRuntime' + | 'bots.createAutomationSession' + | 'bots.finalizeAutomationRun' + | 'bots.finishDelegation' + | 'bots.createDelegation' + | 'bots.retainWorkspaceLeases' + | 'bots.finalizeWorkspaceLeaseRelease' + | 'bots.attachWorkspaceLease' + | 'bots.pauseLifecycle' + | 'bots.resumeLifecycle' + | 'bots.archiveLifecycle' + | 'bots.deleteProfile' + | 'bots.linkSession' + | 'bots.upsertProjectBinding' + | 'bots.upsertChannel' + | 'bots.migrateLegacyProfile' + | 'bots.importBehaviorBundle' + | 'bots.applyImMigration' + | 'bots.beginImMigrationRollback' | 'wechatActivateBindingEpoch' | 'wechatCommitPollBatch' | 'wechatLeaseNextTask' @@ -457,6 +482,259 @@ export interface ImDeleteBindingsArgs { }>; } +export interface BotsCreateProfileArgs { + id: string; + displayName: string; + description: string; + avatar: string; + avatarColor: string; + identitySource: string; + capabilitiesJson: string; + now: number; +} + +export interface BotsUpdateProfileArgs { + id: string; + displayName?: string; + description?: string; + avatar?: string; + avatarColor?: string; + status?: string; + identitySource: string; + capabilitiesJson: string; + profileContentChanged: boolean; + expectedCurrentVersion: number; + now: number; +} + +export interface BotsReplaceCanonicalSessionArgs { + botId: string; + expectedCanonicalSessionId: string | null; + expectedProfileVersion: number; + session: { + id: string; + title: string; + workingDir: string | null; + workspaceKind: string; + model: string; + effort: string; + permissionMode: string; + agentKind: string; + remoteHostId: string | null; + providerId: string | null; + parentSessionId?: string | null; + extraDirs: string; + source: string; + createdAt: number; + updatedAt: number; + }; + now: number; +} + +export interface BotsReplaceCanonicalSessionResult { + created: boolean; + canonicalSessionId: string | null; + archivedCanonicalSessionId: string | null; +} + +export interface BotsCreateRouteSessionArgs { + routeId: string; + botId: string; + channelId: string; + routeKey: string; + ownerDeviceId: string; + ownerGeneration: number; + expectedCurrentSessionId: string | null; + profileVersion: number; + forceRenew: boolean; + session: BotsReplaceCanonicalSessionArgs['session']; + now: number; +} + +export interface BotsCreateRouteSessionResult { + created: boolean; + sessionId: string; + archivedRuntimeSessionId: string | null; +} + +export interface BotsSetRouteStatusArgs { + routeId: string; + botId: string; + expectedOwnerGeneration: number; + currentOwnerDeviceId: string | null; + currentSessionId: string | null; + status: string; + now: number; +} + +export interface BotsPrepareRuntimeArgs { + snapshot: { + id: string; botId: string; sessionId: string; profileVersion: number; agentKind: string; + workingDir: string; memoryScopeKey: string | null; configuredJson: string; resolvedJson: string; + preparedAt: number; + }; + eventId: string; + eventPayloadJson: string; +} + +export interface BotsFinishRuntimeArgs { + snapshotId: string; + botId: string; + sessionId: string; + status: 'applied' | 'degraded' | 'failed'; + finishedAt: number; + failureJson: string | null; + eventId: string; + eventType: 'runtime-applied' | 'runtime-failed'; + eventPayloadJson: string; +} + +export interface BotsCreateAutomationSessionArgs { + automationRunId: string; + botId: string; + localChannelId: string; + profileVersion: number; + routeKey: string; + workingDirSnapshot: string; + remoteHostIdSnapshot: string | null; + session: BotsReplaceCanonicalSessionArgs['session']; + now: number; +} + +export interface BotsFinalizeAutomationRunArgs { + automationRunId: string; + sessionId: string; + status: 'success' | 'failed' | 'aborted'; + errorMessage: string | null; + workspaceLeaseId: string | null; + worktreePathSnapshot: string | null; + finishedAt: number; +} + +export interface BotsFinishDelegationArgs { + delegationId: string; + status: 'completed' | 'failed' | 'cancelled' | 'timed-out'; + resultSummary: string | null; + outputArtifactsJson: string; + lastError: string | null; + tokensUsed?: number; + completedAt: number; +} + +export interface BotsFinishDelegationResult { + id: string; + parentSessionId: string | null; + childSessionId: string | null; + status: 'queued' | 'running' | 'waiting' | 'completed' | 'failed' | 'cancelled' | 'timed-out'; +} + +export interface BotsCreateDelegationArgs { + maxActiveChildren: number; + delegation: { + id: string; requestingBotId: string; targetBotId: string; parentSessionId: string; + childSessionId: string; objective: string; contextRefsJson: string; artifactRefsJson: string; + permissionSnapshotJson: string; lineageJson: string; targetProfileVersion: number; + depth: number; budgetTokens: number | null; createdAt: number; + }; + localChannelId: string; + session: BotsReplaceCanonicalSessionArgs['session']; +} + +export interface BotsRetainWorkspaceLeasesArgs { botId: string; at: number } +export interface BotsFinalizeWorkspaceLeaseReleaseArgs { + leaseId: string; botId: string; expectedGeneration: number; anchorSessionId: string | null; + releasedAt: number; eventId?: string; eventType?: string; +} +export interface BotsAttachWorkspaceLeaseArgs { + attachmentId: string; leaseId: string; sessionId: string; generation: number; + workingDir: string; remoteHostId: string | null; now: number; +} +export interface BotsLifecycleTransitionArgs { + botId: string; canonicalSessionId: string | null; expectedProfileStatus: string; + at: number; eventId: string; +} +export interface BotsArchiveLifecycleArgs extends BotsLifecycleTransitionArgs { + expectedProfileStatus: string; worktreeDisposition: string; +} +export interface BotsDeleteProfileArgs { + botId: string; + sessionIds: string[]; + keepTaskHistory: boolean; + at: number; +} +export interface BotsLinkSessionArgs { + botId: string; + sessionId: string; + role: 'canonical' | 'route' | 'history' | 'automation' | 'delegation'; + channelId: string | null; + routeKey: string | null; + hasExpectedCanonical: boolean; + expectedCanonicalSessionId: string | null; + now: number; + eventId: string; +} +export interface BotsUpsertProjectBindingArgs { + id: string; + botId: string; + projectKey: string; + workingDir: string; + remoteHostId: string | null; + defaultBranch: string | null; + workspacePolicy: 'none' | 'reuse' | 'per-task' | 'read-only'; + isDefault: boolean; + allowedPathsJson: string; + now: number; + eventId: string; +} +export interface BotsUpsertChannelArgs { + id: string; + botId: string; + kind: string; + enabled: boolean; + configJson: string | null; + now: number; +} +export interface BotsMigrateLegacyProfileArgs { + id: string; + displayName: string; + description: string; + avatar: string; + avatarColor: string; + identitySource: string; + capabilitiesJson: string; + channelKind: string | null; + legacySessionId: string | null; + now: number; +} +export interface BotsImportBehaviorBundleArgs { + bot: { + id: string; displayName: string; description: string; avatar: string; avatarColor: string; + identitySource: string; capabilitiesJson: string; + }; + channels: Array<{ id: string; kind: string; enabled: boolean }>; + automations: Array<{ + scheduleId: string; linkId: string; name: string; prompt: string; executionMode: string; + scriptConfig: string | null; cronExpr: string; timezone: string; recurring: boolean; + manual: boolean; intervalMs: number | null; agentKind: string; model: string | null; + providerId: string | null; effort: string | null; fastMode: boolean; + persistentSession: boolean; silentWhenIdle: boolean; notifyDesktop: boolean; + executionPolicyJson: string; + }>; + now: number; + eventId: string; +} +export interface BotsApplyImMigrationArgs { + migrationId: string; requestId: string; botId: string; channelId: string; routeId: string; + connectionId: string; ownership: 'local-adapter' | 'server-relay'; kind: string; + accountKey: string; planHash: string; channelConfigJson: string; capabilitiesJson: string; + adapterBindingsJson: string; + candidates: Array<{ sessionId: string; status: 'active' | 'archived'; updatedAt: number }>; + now: number; eventId: string; +} +export interface BotsBeginImMigrationRollbackArgs { + migrationId: string; now: number; eventId: string; +} + export type WechatInboxStatus = | 'pending' | 'dispatching' @@ -761,6 +1039,31 @@ export type DbTxArgsByName = { 'message.delete': MessageDeleteArgs; 'im.deleteBindings': ImDeleteBindingsArgs; 'im.replaceBinding': ImReplaceBindingArgs; + 'bots.createProfile': BotsCreateProfileArgs; + 'bots.updateProfile': BotsUpdateProfileArgs; + 'bots.replaceCanonicalSession': BotsReplaceCanonicalSessionArgs; + 'bots.createRouteSession': BotsCreateRouteSessionArgs; + 'bots.setRouteStatus': BotsSetRouteStatusArgs; + 'bots.prepareRuntime': BotsPrepareRuntimeArgs; + 'bots.finishRuntime': BotsFinishRuntimeArgs; + 'bots.createAutomationSession': BotsCreateAutomationSessionArgs; + 'bots.finalizeAutomationRun': BotsFinalizeAutomationRunArgs; + 'bots.finishDelegation': BotsFinishDelegationArgs; + 'bots.createDelegation': BotsCreateDelegationArgs; + 'bots.retainWorkspaceLeases': BotsRetainWorkspaceLeasesArgs; + 'bots.finalizeWorkspaceLeaseRelease': BotsFinalizeWorkspaceLeaseReleaseArgs; + 'bots.attachWorkspaceLease': BotsAttachWorkspaceLeaseArgs; + 'bots.pauseLifecycle': BotsLifecycleTransitionArgs; + 'bots.resumeLifecycle': BotsLifecycleTransitionArgs; + 'bots.archiveLifecycle': BotsArchiveLifecycleArgs; + 'bots.deleteProfile': BotsDeleteProfileArgs; + 'bots.linkSession': BotsLinkSessionArgs; + 'bots.upsertProjectBinding': BotsUpsertProjectBindingArgs; + 'bots.upsertChannel': BotsUpsertChannelArgs; + 'bots.migrateLegacyProfile': BotsMigrateLegacyProfileArgs; + 'bots.importBehaviorBundle': BotsImportBehaviorBundleArgs; + 'bots.applyImMigration': BotsApplyImMigrationArgs; + 'bots.beginImMigrationRollback': BotsBeginImMigrationRollbackArgs; wechatActivateBindingEpoch: WechatActivateBindingEpochArgs; wechatCommitPollBatch: WechatCommitPollBatchArgs; wechatLeaseNextTask: WechatLeaseNextTaskArgs; @@ -806,6 +1109,31 @@ export type DbTxResultByName = { 'message.delete': MessageDeleteResult; 'im.deleteBindings': undefined; 'im.replaceBinding': undefined; + 'bots.createProfile': undefined; + 'bots.updateProfile': { currentVersion: number }; + 'bots.replaceCanonicalSession': BotsReplaceCanonicalSessionResult; + 'bots.createRouteSession': BotsCreateRouteSessionResult; + 'bots.setRouteStatus': undefined; + 'bots.prepareRuntime': undefined; + 'bots.finishRuntime': boolean; + 'bots.createAutomationSession': undefined; + 'bots.finalizeAutomationRun': undefined; + 'bots.finishDelegation': BotsFinishDelegationResult | null; + 'bots.createDelegation': undefined; + 'bots.retainWorkspaceLeases': number; + 'bots.finalizeWorkspaceLeaseRelease': undefined; + 'bots.attachWorkspaceLease': undefined; + 'bots.pauseLifecycle': { routes: number; automations: number }; + 'bots.resumeLifecycle': { routes: number; automations: number }; + 'bots.archiveLifecycle': { sessions: number }; + 'bots.deleteProfile': { sessionIds: string[]; status: 'archived' | 'deleted' }; + 'bots.linkSession': { archivedCanonicalSessionIds: string[] }; + 'bots.upsertProjectBinding': undefined; + 'bots.upsertChannel': undefined; + 'bots.migrateLegacyProfile': undefined; + 'bots.importBehaviorBundle': undefined; + 'bots.applyImMigration': { routeId: string }; + 'bots.beginImMigrationRollback': undefined; wechatActivateBindingEpoch: WechatActivateBindingEpochResult; wechatCommitPollBatch: WechatCommitPollBatchResult; wechatLeaseNextTask: WechatLeasedTask | null; diff --git a/apps/desktop/src/main/localDb/codexPlanState.ts b/apps/desktop/src/main/localDb/codexPlanState.ts new file mode 100644 index 0000000000..841b8960a3 --- /dev/null +++ b/apps/desktop/src/main/localDb/codexPlanState.ts @@ -0,0 +1,194 @@ +import { and, eq, sql } from 'drizzle-orm'; + +import { getDbClient } from './client/current.js'; +import { sessions } from './schema.js'; + +export type CodexPlanLifecycle = 'active' | 'interrupted' | 'sealed'; + +export interface CodexPlanEntry { + step: string; + status: 'pending' | 'in_progress' | 'completed'; +} + +export interface CodexPlanStateSnapshot { + turnId: string; + plan: CodexPlanEntry[]; + state: CodexPlanLifecycle; +} + +function snapshotJson(snapshot: CodexPlanStateSnapshot): string { + return JSON.stringify(snapshot); +} + +function samePlanTurn(turnId: string) { + return sql`json_extract(${sessions.codexPlanJson}, '$.turnId') = ${turnId}`; +} + +function normalizePlanStatus(value: unknown): CodexPlanEntry['status'] | null { + return value === 'pending' || value === 'in_progress' || value === 'completed' ? value : null; +} + +export function normalizeCodexPlanEntries(value: unknown): CodexPlanEntry[] | null { + if (!Array.isArray(value)) return null; + const plan: CodexPlanEntry[] = []; + for (const item of value) { + if (!item || typeof item !== 'object' || Array.isArray(item)) return null; + const row = item as Record; + const status = normalizePlanStatus(row.status); + if (typeof row.step !== 'string' || !status) return null; + plan.push({ step: row.step, status }); + } + return plan; +} + +function planTurnId(toolUseId: unknown): string | null { + if (typeof toolUseId !== 'string' || !toolUseId.startsWith('plan:')) return null; + const turnId = toolUseId.slice('plan:'.length); + return turnId.length > 0 ? turnId : null; +} + +export interface CodexPlanUpdateData { + toolUseId?: unknown; + toolName?: unknown; + input?: unknown; +} + +export function parseCodexPlanUpdate( + data: CodexPlanUpdateData | null | undefined, +): { turnId: string; plan: CodexPlanEntry[] } | null { + if (data?.toolName !== 'update_plan') return null; + const turnId = planTurnId(data.toolUseId); + if (!turnId || !data.input || typeof data.input !== 'object' || Array.isArray(data.input)) { + return null; + } + const plan = normalizeCodexPlanEntries((data.input as Record).plan); + return plan ? { turnId, plan } : null; +} + +export async function writeCodexPlanUpdate( + sessionId: string, + update: { turnId: string; plan: CodexPlanEntry[] }, +): Promise { + const db = getDbClient().drizzle; + if (update.plan.length === 0) { + await db + .update(sessions) + .set({ codexPlanJson: null }) + .where(eq(sessions.id, sessionId)); + return; + } + await db + .update(sessions) + .set({ codexPlanJson: snapshotJson({ turnId: update.turnId, plan: update.plan, state: 'active' }) }) + .where(eq(sessions.id, sessionId)); +} + +export interface CodexPlanTerminalData { + cancelled?: unknown; + plan?: unknown; + raw?: { id?: unknown; status?: unknown }; +} + +export function parseCodexPlanTerminal( + data: CodexPlanTerminalData | null | undefined, +): { turnId: string; plan: CodexPlanEntry[] | null; state: 'interrupted' | 'sealed' } | null { + const turnId = typeof data?.raw?.id === 'string' && data.raw.id.length > 0 ? data.raw.id : null; + if (!turnId) return null; + const plan = data?.plan === undefined || data.plan === null + ? null + : normalizeCodexPlanEntries(data.plan); + const planCompleted = plan !== null && plan.every((item) => item.status === 'completed'); + return { + turnId, + plan, + state: + data?.cancelled !== true && data?.raw?.status === 'completed' && planCompleted + ? 'sealed' + : 'interrupted', + }; +} + +export async function writeCodexPlanTerminal( + sessionId: string, + terminal: { turnId: string; plan: CodexPlanEntry[] | null; state: 'interrupted' | 'sealed' }, +): Promise { + const db = getDbClient().drizzle; + if (terminal.plan?.length === 0) { + await db + .update(sessions) + .set({ codexPlanJson: null }) + .where(and(eq(sessions.id, sessionId), samePlanTurn(terminal.turnId))); + return; + } + if (terminal.plan === null) { + await db + .update(sessions) + .set({ codexPlanJson: sql`json_set(${sessions.codexPlanJson}, '$.state', ${terminal.state})` }) + .where(and(eq(sessions.id, sessionId), samePlanTurn(terminal.turnId))); + return; + } + await db + .update(sessions) + .set({ + codexPlanJson: snapshotJson({ turnId: terminal.turnId, plan: terminal.plan, state: terminal.state }), + }) + .where(and(eq(sessions.id, sessionId), samePlanTurn(terminal.turnId))); +} + +export async function markCodexPlanInterrupted( + sessionId: string, + turnId: string, +): Promise { + const db = getDbClient().drizzle; + await db + .update(sessions) + .set({ codexPlanJson: sql`json_set(${sessions.codexPlanJson}, '$.state', 'interrupted')` }) + .where(and(eq(sessions.id, sessionId), samePlanTurn(turnId))); +} + +export async function readCodexPlanState( + sessionId: string, +): Promise { + const db = getDbClient().drizzle; + const [row] = await db + .select({ + agentKind: sessions.agentKind, + planJson: sessions.codexPlanJson, + }) + .from(sessions) + .where(eq(sessions.id, sessionId)) + .limit(1); + if (row?.agentKind !== 'codex' || typeof row.planJson !== 'string') { + return null; + } + try { + const snapshot = JSON.parse(row.planJson) as Partial; + const plan = normalizeCodexPlanEntries(snapshot.plan); + if ( + typeof snapshot.turnId !== 'string' || + (snapshot.state !== 'active' && snapshot.state !== 'interrupted' && snapshot.state !== 'sealed') + ) { + return null; + } + return plan ? { turnId: snapshot.turnId, plan, state: snapshot.state } : null; + } catch { + return null; + } +} + +export async function clearSealedCodexPlanState( + sessionId: string, + turnId: string, +): Promise { + const db = getDbClient().drizzle; + await db + .update(sessions) + .set({ codexPlanJson: null }) + .where( + and( + eq(sessions.id, sessionId), + samePlanTurn(turnId), + sql`json_extract(${sessions.codexPlanJson}, '$.state') = 'sealed'`, + ), + ); +} diff --git a/apps/desktop/src/main/localDb/conversationSearch.ts b/apps/desktop/src/main/localDb/conversationSearch.ts index adbc141ab9..1e929a9ab9 100644 --- a/apps/desktop/src/main/localDb/conversationSearch.ts +++ b/apps/desktop/src/main/localDb/conversationSearch.ts @@ -14,6 +14,7 @@ import type { } from '../../shared/conversationSearch.js'; import { conversationSearchTitle } from '../../shared/conversationSearch.js'; import { DESKTOP_VISIBLE_SESSION_SOURCES } from '../../shared/sessionSource.js'; +import type { SessionSource } from '../../shared/sessionSource.js'; import { getDbClient } from './client/current.js'; import { messages, sessions } from './schema.js'; import { searchChatHistoryHybrid } from './chatHistorySearch.js'; @@ -46,8 +47,18 @@ const DAY_MS = 24 * 60 * 60 * 1000; type SessionRow = typeof sessions.$inferSelect; +export interface ConversationSearchHostScope { + /** + * Main-owned source scope. Undefined preserves the normal desktop task + * search boundary; null permits every source only when another authoritative + * filter (for example Bot-owned Session ids) already constrains the query. + */ + sessionSources?: readonly SessionSource[] | null; +} + export async function searchConversations( request: ConversationSearchRequest, + hostScope: ConversationSearchHostScope = {}, ): Promise { const query = request.query.trim(); if (!query) { @@ -62,6 +73,9 @@ export async function searchConversations( const limit = clampLimit(request.limit); const filters = normalizeFilters(request); + const sessionSources = hostScope.sessionSources === undefined + ? DESKTOP_VISIBLE_SESSION_SOURCES + : hostScope.sessionSources; const sortBy = normalizeSortBy(request.sortBy); const skipVector = request.semanticMode === 'keyword'; if (filters.sessionIds && filters.sessionIds.length === 0) { @@ -73,7 +87,7 @@ export async function searchConversations( poolCapped: false, }; } - const sessionRows = await listSearchableSessions(filters); + const sessionRows = await listSearchableSessions(filters, sessionSources); if (sessionRows.length === 0) { return { query, @@ -111,6 +125,7 @@ export async function searchConversations( filters, activityCutoff, skipVector, + sessionSources, }); const contentMessageIds = content.hits.map((hit) => hit.messageId); @@ -163,6 +178,7 @@ async function searchContentUntilUniqueSessions({ filters, activityCutoff, skipVector, + sessionSources, }: { query: string; limit: number; @@ -170,6 +186,7 @@ async function searchContentUntilUniqueSessions({ filters: NormalizedConversationSearchFilters; activityCutoff: number | null; skipVector: boolean; + sessionSources: readonly SessionSource[] | null; }) { const targetUniqueSessions = Math.min(limit * 2, allowedSessionIds.length); const queryEmbeddingCache = new Map(); @@ -189,7 +206,7 @@ async function searchContentUntilUniqueSessions({ contextRadius: 0, limit: pageLimit, offset, - sessionSources: DESKTOP_VISIBLE_SESSION_SOURCES, + sessionSources, sessionStatuses: sessionStatusesForFilter(filters.status), excludeCleared: true, sessionActivityFromMs: activityCutoff, @@ -259,7 +276,11 @@ function normalizeSortBy(value: ConversationSearchSortBy | undefined): Conversat return value === 'activityDesc' || value === 'activityAsc' ? value : 'relevance'; } -async function listSearchableSessions(filters: NormalizedConversationSearchFilters): Promise { +async function listSearchableSessions( + filters: NormalizedConversationSearchFilters, + sessionSources: readonly SessionSource[] | null, +): Promise { + if (sessionSources !== null && sessionSources.length === 0) return []; const db = getDbClient().drizzle; const statusCond = statusCondition(filters.status); const agentCond = filters.agentKind === 'all' ? undefined : eq(sessions.agentKind, filters.agentKind); @@ -274,7 +295,7 @@ async function listSearchableSessions(filters: NormalizedConversationSearchFilte .select() .from(sessions) .where(and( - inArray(sessions.source, DESKTOP_VISIBLE_SESSION_SOURCES), + sessionSources === null ? undefined : inArray(sessions.source, sessionSources), statusCond, agentCond, sessionIdsCond, diff --git a/apps/desktop/src/main/localDb/ipc/__tests__/botCanonicalSession.test.ts b/apps/desktop/src/main/localDb/ipc/__tests__/botCanonicalSession.test.ts new file mode 100644 index 0000000000..0a0cc65703 --- /dev/null +++ b/apps/desktop/src/main/localDb/ipc/__tests__/botCanonicalSession.test.ts @@ -0,0 +1,4048 @@ +import Database from 'better-sqlite3'; +import { createHash } from 'node:crypto'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + botChannels, + botAutomationLinks, + botAutomationRuns, + botDeliveryOutbox, + botDelegations, + botLifecycleEvents, + botProfiles, + botProfileVersions, + botProjectBindings, + botRuntimeSnapshots, + botRoutes, + botSessionLinks, + botWorkspaceAttachments, + botWorkspaceLeases, + sessions, +} from '../../schema'; + +const h = vi.hoisted(() => ({ + db: null as ReturnType | null, + sqlite: null as Database.Database | null, + tx: null as null | ((name: string, args: unknown) => Promise), + handlers: new Map unknown>(), + nextSession: 0, + worktrees: [] as Array<{ + sessionId: string; + name: string; + path: string; + baseRepo: string; + branch: string; + sourceBranch: string; + createdAt: string; + }>, + removeWorktree: vi.fn(async () => { + h.worktrees = []; + }), + isSessionAlive: vi.fn(() => false), + remove: vi.fn(async () => undefined), + ensureGit: vi.fn(async () => undefined), + closeSession: vi.fn(async () => undefined), + ensureDialogue: vi.fn((sessionId: string) => `/tmp/cindy-bot-test/${sessionId}`), + searchConversations: vi.fn(), +})); + +vi.mock('node:fs/promises', () => ({ default: { rm: h.remove } })); +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => '/tmp/cindy-bot-test'), + }, + ipcMain: { + handle: vi.fn((channel: string, handler: (...args: unknown[]) => unknown) => { + h.handlers.set(channel, handler); + }), + }, +})); +vi.mock('../../client/current', () => ({ + getDbClient: () => ({ drizzle: h.db, tx: h.tx }), + tryGetDbClient: () => ({ drizzle: h.db, tx: h.tx }), +})); +vi.mock('../../../security/trustedAppRenderer.js', () => ({ + assertTrustedAppRendererEvent: vi.fn(), +})); +vi.mock('../../../sessionIds.js', () => ({ + resolveBusinessSessionId: () => `session-${++h.nextSession}`, +})); +vi.mock('../../dialogueWorkspace.js', () => ({ + ensureDialogueWorkspaceDir: h.ensureDialogue, +})); +vi.mock('../../../git-snapshot/projectGitBootstrap.js', () => ({ + ensureProjectGitInitialized: h.ensureGit, +})); +vi.mock('../../../maker-host/git-safety-settings-store.js', () => ({ + readGitSafetySettings: () => ({ autoSnapshotEnabled: true }), +})); +vi.mock('../../../maker-host/index.js', () => ({ + getMakerIfReady: () => ({ isSessionAlive: h.isSessionAlive, closeSession: h.closeSession }), +})); +vi.mock('../../../worktree/index.js', () => ({ + WorktreeManager: { + createWorktree: vi.fn(), + getForSession: vi.fn( + (sessionId: string) => h.worktrees.find((meta) => meta.sessionId === sessionId) ?? null, + ), + listAll: vi.fn(() => h.worktrees), + removeWorktreeForSession: h.removeWorktree, + }, + restoreWorktreeForSession: vi.fn(async () => ({ ok: false, reason: 'gone' })), + worktreeStore: { + set: vi.fn(async () => undefined), + del: vi.fn(), + }, +})); +vi.mock('../../../maker-ipc/botRemoteWorkspaceService.js', () => ({ + createRemoteBotWorktree: vi.fn(), + inspectRemoteBotWorktree: vi.fn(), + removeRemoteBotWorktree: vi.fn(), +})); +vi.mock('../../conversationSearch.js', () => ({ + searchConversations: h.searchConversations, +})); + +import { registerBotIpc } from '../bots'; +import { tx as runWorkerTx } from '../../worker/opHandlers/tx.js'; +import { assertTrustedAppRendererEvent } from '../../../security/trustedAppRenderer.js'; +import { runDeviceLinkInvokeContext } from '../../../device-link/invoke-context.js'; +import { + claimBotRoute, + ensureBotRouteSession, + resolveBotRoute, + resolveOrCreateBotRoute, + setBotRouteStatus, + updateBotRouteSession, + upsertBotRoute, +} from '../../botRouteService'; +import { + prepareBotWorkspaceRuntime, + reclaimPerTaskBotWorkspaceForSession, + reconcileBotWorkspaceLeases, +} from '../../../maker-ipc/botWorkspaceRuntime'; +import { + hydrateBotProfileRuntime, + markBotProfileRuntimeApplied, + markBotProfileRuntimeFailed, +} from '../../../maker-ipc/botProfileRuntime'; +import { createBotDelegationService } from '../../../maker-ipc/botDelegationService'; +import { createBotDeliveryOutboxService } from '../../../maker-ipc/botDeliveryOutboxService'; +import { configureBotCanonicalReplacementCoordinator } from '../../../maker-ipc/botCanonicalReplacementCoordinator'; +import type { MakerSessionCreateOpts } from '../../../maker-ipc/sessionRequest'; +import { parseBotDelegationPlanSnapshot } from '../../../../shared/botDelegation'; + +function testSha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function createDb(): void { + const sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + sqlite.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY NOT NULL, + title TEXT NOT NULL DEFAULT 'New Maker', + working_dir TEXT, + workspace_kind TEXT NOT NULL DEFAULT 'project', + model TEXT NOT NULL DEFAULT 'claude-sonnet-4-6', + effort TEXT NOT NULL DEFAULT 'high', + permission_mode TEXT NOT NULL DEFAULT 'ask', + status TEXT NOT NULL DEFAULT 'active', + sdk_session_id TEXT, + total_token_usage INTEGER NOT NULL DEFAULT 0, + total_cost_usd REAL NOT NULL DEFAULT 0, + total_cost_amount REAL NOT NULL DEFAULT 0, + total_cost_currency TEXT, + total_cost_is_approximate INTEGER NOT NULL DEFAULT 0, + context_tokens INTEGER NOT NULL DEFAULT 0, + context_window INTEGER NOT NULL DEFAULT 0, + fast_mode INTEGER NOT NULL DEFAULT 0, + plan_mode_enabled INTEGER NOT NULL DEFAULT 0, + cleared_at INTEGER, + pinned_at INTEGER, + summary TEXT, + provider_id TEXT, + user_send_at INTEGER, + agent_kind TEXT NOT NULL DEFAULT 'cc', + orca_role TEXT, + parent_session_id TEXT, + forked_at_message_id TEXT, + worktree_path TEXT, + extra_dirs TEXT NOT NULL DEFAULT '[]', + remote_host_id TEXT, + source TEXT NOT NULL DEFAULT 'desktop', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + feishu_open_id TEXT, + feishu_bot_app_id TEXT, + used_project_context INTEGER NOT NULL DEFAULT 0, + one_m INTEGER NOT NULL DEFAULT 0, + codex_history_has_product_prompt INTEGER, + im_bot_context_id TEXT, + im_user_id TEXT, + active_turn_started_at INTEGER, + active_turn_pid INTEGER, + last_turn_ended_at INTEGER + ); + CREATE TABLE bot_profiles ( + id TEXT PRIMARY KEY NOT NULL, + display_name TEXT NOT NULL, + description TEXT DEFAULT '' NOT NULL, + avatar TEXT DEFAULT '🤖' NOT NULL, + avatar_color TEXT DEFAULT 'violet' NOT NULL, + status TEXT DEFAULT 'active' NOT NULL, + current_version INTEGER DEFAULT 1 NOT NULL, + canonical_session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE bot_profile_versions ( + id TEXT PRIMARY KEY NOT NULL, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + identity_source TEXT DEFAULT '' NOT NULL, + capabilities_json TEXT DEFAULT '{}' NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX uniq_bot_profile_versions_bot_version + ON bot_profile_versions(bot_id, version); + CREATE TABLE bot_channels ( + id TEXT PRIMARY KEY NOT NULL, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + enabled INTEGER DEFAULT 1 NOT NULL, + config_json TEXT DEFAULT '{}' NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX uniq_bot_channels_bot_kind ON bot_channels(bot_id, kind); + CREATE TABLE bot_session_links ( + id TEXT PRIMARY KEY NOT NULL, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + profile_version INTEGER DEFAULT 1 NOT NULL, + role TEXT NOT NULL, + channel_id TEXT REFERENCES bot_channels(id) ON DELETE SET NULL, + route_key TEXT, + created_at INTEGER NOT NULL, + archived_at INTEGER + ); + CREATE UNIQUE INDEX uniq_bot_session_links_session ON bot_session_links(session_id); + CREATE UNIQUE INDEX uniq_bot_session_links_canonical_per_bot + ON bot_session_links(bot_id) WHERE role = 'canonical'; + CREATE TABLE bot_runtime_snapshots ( + id TEXT PRIMARY KEY NOT NULL, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + profile_version INTEGER NOT NULL, + agent_kind TEXT NOT NULL, + working_dir TEXT NOT NULL, + memory_scope_key TEXT, + configured_json TEXT DEFAULT '{}' NOT NULL, + resolved_json TEXT DEFAULT '{}' NOT NULL, + status TEXT NOT NULL, + prepared_at INTEGER DEFAULT 0 NOT NULL, + applied_at INTEGER, + failed_at INTEGER, + failure_json TEXT + ); + CREATE TABLE bot_lifecycle_events ( + id TEXT PRIMARY KEY NOT NULL, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + event_type TEXT NOT NULL, + payload_json TEXT DEFAULT '{}' NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE bot_project_bindings ( + id TEXT PRIMARY KEY NOT NULL, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + project_key TEXT NOT NULL, + working_dir TEXT NOT NULL, + remote_host_id TEXT, + default_branch TEXT, + workspace_policy TEXT DEFAULT 'none' NOT NULL, + is_default INTEGER DEFAULT false NOT NULL, + allowed_paths_json TEXT DEFAULT '[]' NOT NULL, + status TEXT DEFAULT 'active' NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX uniq_bot_project_bindings_bot_project + ON bot_project_bindings(bot_id, project_key); + CREATE UNIQUE INDEX uniq_bot_project_bindings_default_per_bot + ON bot_project_bindings(bot_id) WHERE is_default = true AND status = 'active'; + CREATE TABLE bot_workspace_leases ( + id TEXT PRIMARY KEY NOT NULL, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + project_binding_id TEXT NOT NULL REFERENCES bot_project_bindings(id) ON DELETE CASCADE, + lease_key TEXT DEFAULT 'shared' NOT NULL, + anchor_session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + worktree_path TEXT, + base_repo TEXT NOT NULL, + branch TEXT, + source_branch TEXT, + remote_host_id TEXT, + generation INTEGER DEFAULT 1 NOT NULL, + status TEXT DEFAULT 'acquiring' NOT NULL, + last_heartbeat_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + released_at INTEGER + ); + CREATE UNIQUE INDEX uniq_bot_workspace_leases_active_binding_key + ON bot_workspace_leases(project_binding_id, lease_key) + WHERE status IN ('acquiring', 'active', 'releasing'); + CREATE TABLE bot_workspace_attachments ( + id TEXT PRIMARY KEY NOT NULL, + lease_id TEXT NOT NULL REFERENCES bot_workspace_leases(id) ON DELETE CASCADE, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + generation INTEGER NOT NULL, + access TEXT DEFAULT 'read-write' NOT NULL, + created_at INTEGER NOT NULL, + detached_at INTEGER + ); + CREATE UNIQUE INDEX uniq_bot_workspace_attachments_lease_session + ON bot_workspace_attachments(lease_id, session_id, generation); + CREATE UNIQUE INDEX uniq_bot_workspace_attachments_active_session + ON bot_workspace_attachments(session_id) WHERE detached_at IS NULL; + CREATE TABLE bot_delegations ( + id TEXT PRIMARY KEY NOT NULL, + requesting_bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + target_bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + parent_session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + child_session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + objective TEXT NOT NULL, + context_refs_json TEXT DEFAULT '[]' NOT NULL, + artifact_refs_json TEXT DEFAULT '[]' NOT NULL, + permission_snapshot_json TEXT DEFAULT '{}' NOT NULL, + lineage_json TEXT DEFAULT '[]' NOT NULL, + target_profile_version INTEGER NOT NULL, + depth INTEGER DEFAULT 1 NOT NULL, + budget_tokens INTEGER, + tokens_used INTEGER DEFAULT 0 NOT NULL, + status TEXT DEFAULT 'queued' NOT NULL, + result_summary TEXT, + output_artifacts_json TEXT DEFAULT '[]' NOT NULL, + last_error TEXT, + created_at INTEGER NOT NULL, + accepted_at INTEGER, + completed_at INTEGER, + updated_at INTEGER NOT NULL + ); + CREATE TABLE bot_automation_links ( + id TEXT PRIMARY KEY NOT NULL, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + schedule_id TEXT, + project_binding_id TEXT REFERENCES bot_project_bindings(id) ON DELETE SET NULL, + target_route_id TEXT REFERENCES bot_routes(id) ON DELETE SET NULL, + execution_policy_json TEXT DEFAULT '{}' NOT NULL, + created_with_profile_version INTEGER NOT NULL, + durable_note_namespace TEXT, + status TEXT DEFAULT 'active' NOT NULL, + suspended_status TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX uniq_bot_automation_links_schedule + ON bot_automation_links(schedule_id); + CREATE TABLE bot_automation_runs ( + id TEXT PRIMARY KEY NOT NULL, + automation_link_id TEXT NOT NULL, + schedule_run_id TEXT, + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + workspace_lease_id TEXT REFERENCES bot_workspace_leases(id) ON DELETE SET NULL, + profile_version INTEGER NOT NULL, + project_binding_id_snapshot TEXT, + target_route_id_snapshot TEXT, + target_route_owner_generation_snapshot INTEGER, + working_dir_snapshot TEXT, + remote_host_id_snapshot TEXT, + worktree_path_snapshot TEXT, + delivery_outbox_id TEXT, + delivery_status TEXT DEFAULT 'not-requested' NOT NULL, + delivery_error TEXT, + result_text_snapshot TEXT, + output_artifacts_json TEXT DEFAULT '[]' NOT NULL, + error_message TEXT, + execution_plan_json TEXT DEFAULT '{}' NOT NULL, + status TEXT DEFAULT 'claimed' NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + finished_at INTEGER + ); + CREATE TABLE bot_delivery_outbox ( + id TEXT PRIMARY KEY NOT NULL, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + channel_id TEXT REFERENCES bot_channels(id) ON DELETE SET NULL, + route_id TEXT, + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + idempotency_key TEXT NOT NULL, + payload_ref_json TEXT DEFAULT '{}' NOT NULL, + owner_generation INTEGER DEFAULT 0 NOT NULL, + status TEXT DEFAULT 'pending' NOT NULL, + attempts INTEGER DEFAULT 0 NOT NULL, + next_attempt_at INTEGER, + last_error TEXT, + delivery_receipt_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + delivered_at INTEGER + ); + CREATE UNIQUE INDEX uniq_bot_delivery_outbox_idempotency + ON bot_delivery_outbox(idempotency_key); + CREATE TABLE bot_routes ( + id TEXT PRIMARY KEY NOT NULL, + bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + channel_id TEXT NOT NULL REFERENCES bot_channels(id) ON DELETE CASCADE, + route_key TEXT NOT NULL, + principal_key TEXT NOT NULL, + scope_key TEXT NOT NULL, + thread_key TEXT, + current_session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + project_binding_id TEXT REFERENCES bot_project_bindings(id) ON DELETE SET NULL, + capabilities_json TEXT DEFAULT '{}' NOT NULL, + owner_device_id TEXT, + owner_generation INTEGER DEFAULT 0 NOT NULL, + status TEXT DEFAULT 'active' NOT NULL, + suspended_status TEXT, + last_activity_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE UNIQUE INDEX uniq_bot_routes_channel_route + ON bot_routes(channel_id, route_key); + `); + h.sqlite = sqlite; + const rawDb = drizzle(sqlite, { + schema: { + sessions, + botProfiles, + botProfileVersions, + botChannels, + botAutomationLinks, + botAutomationRuns, + botDeliveryOutbox, + botDelegations, + botSessionLinks, + botRuntimeSnapshots, + botRoutes, + botLifecycleEvents, + botProjectBindings, + botWorkspaceLeases, + botWorkspaceAttachments, + }, + }); + h.db = rawDb; + h.tx = async (name, args) => runWorkerTx(sqlite, { name: name as never, args } as never); +} + +async function invoke(channel: string, body: unknown): Promise { + const handler = h.handlers.get(channel); + if (!handler) throw new Error(`${channel} handler not registered`); + return handler({}, body); +} + +beforeEach(async () => { + vi.clearAllMocks(); + h.handlers.clear(); + h.nextSession = 0; + h.worktrees = []; + h.isSessionAlive.mockReturnValue(false); + h.ensureGit.mockResolvedValue(undefined); + h.closeSession.mockClear(); + h.searchConversations.mockResolvedValue({ + query: '', + results: [], + vectorUsed: false, + vectorSkipReason: null, + poolCapped: false, + }); + configureBotCanonicalReplacementCoordinator(async (_sessionId, operation) => operation()); + h.sqlite?.close(); + createDb(); + registerBotIpc(); + await invoke('local-db:bots:create', { + id: 'bot-1', + name: 'Release Bot', + capabilities: { + harness: 'pi', + model: 'grok-4.5', + permissions: 'trusted', + }, + }); +}); + +describe('Bot canonical Session lifecycle', () => { + it('allows device-link to read Bot projections without weakening local renderer trust', async () => { + const list = h.handlers.get('local-db:bots:list'); + const get = h.handlers.get('local-db:bots:get'); + expect(list).toBeTypeOf('function'); + expect(get).toBeTypeOf('function'); + + vi.mocked(assertTrustedAppRendererEvent).mockClear(); + await list!({}); + await get!({}, 'bot-1'); + expect(assertTrustedAppRendererEvent).toHaveBeenCalledTimes(2); + + vi.mocked(assertTrustedAppRendererEvent).mockClear(); + const remoteList = await runDeviceLinkInvokeContext( + { controllerDeviceId: 'mobile-1', channel: 'local-db:bots:list' }, + () => list!({}), + ); + const remoteGet = await runDeviceLinkInvokeContext( + { controllerDeviceId: 'mobile-1', channel: 'local-db:bots:get' }, + () => get!({}, 'bot-1'), + ); + expect(assertTrustedAppRendererEvent).not.toHaveBeenCalled(); + for (const projection of [...(remoteList as any[]), remoteGet]) { + expect(projection).toMatchObject({ + id: 'bot-1', + name: 'Release Bot', + channels: [{ kind: 'local', enabled: true }], + }); + expect(projection).not.toHaveProperty('identitySource'); + expect(projection).not.toHaveProperty('userContextSource'); + expect(projection).not.toHaveProperty('capabilities'); + expect(projection).not.toHaveProperty('projectBindings'); + expect(projection).not.toHaveProperty('workspaceLeases'); + expect(projection).not.toHaveProperty('routes'); + expect(projection.channels[0]).not.toHaveProperty('config'); + } + }); + + it('freezes provider, model, effort, and Fast Mode into the canonical Session', async () => { + await invoke('local-db:bots:create', { + id: 'bot-model-profile', + name: 'Model Profile Bot', + capabilities: { + harness: 'codex', + providerId: 'openai', + model: 'gpt-5.6-sol', + effort: 'xhigh', + fastMode: true, + permissions: 'ask', + }, + }); + + const created = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-model-profile', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + + expect(created.session).toMatchObject({ + agentKind: 'codex', + providerId: 'openai', + model: 'gpt-5.6-sol', + effort: 'xhigh', + fastMode: true, + }); + }); + + it('repairs a physically missing canonical task using the persisted pointer as its CAS', async () => { + h.sqlite!.pragma('foreign_keys = OFF'); + h.sqlite! + .prepare("UPDATE bot_profiles SET canonical_session_id = 'missing-canonical' WHERE id = 'bot-1'") + .run(); + h.sqlite!.pragma('foreign_keys = ON'); + + const repaired = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: 'missing-canonical', + expectedProfileVersion: 1, + recoverMissingOnly: true, + }); + + expect(repaired).toMatchObject({ created: true, canonicalSessionId: 'session-1' }); + expect( + h.sqlite!.prepare('SELECT canonical_session_id FROM bot_profiles WHERE id = ?').pluck().get('bot-1'), + ).toBe('session-1'); + expect( + h.sqlite! + .prepare('SELECT event_type, payload_json FROM bot_lifecycle_events WHERE session_id = ?') + .get('session-1'), + ).toMatchObject({ + event_type: 'canonical-recovered', + payload_json: expect.stringContaining('missing-canonical'), + }); + }); + + it('never turns a transient canonical read failure into an implicit Renew', async () => { + const created = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + + await expect( + invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: created.session.id, + expectedProfileVersion: 1, + recoverMissingOnly: true, + }), + ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + expect( + h.sqlite!.prepare('SELECT canonical_session_id FROM bot_profiles WHERE id = ?').pluck().get('bot-1'), + ).toBe(created.session.id); + expect( + h.sqlite!.prepare('SELECT status FROM sessions WHERE id = ?').pluck().get(created.session.id), + ).toBe('active'); + }); + + it('rejects ordinary canonical creation for an archived Bot', async () => { + h.sqlite!.prepare("UPDATE bot_profiles SET status = 'archived' WHERE id = 'bot-1'").run(); + + await expect( + invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }), + ).rejects.toThrow('archived'); + expect(h.sqlite!.prepare('SELECT COUNT(*) FROM sessions').pluck().get()).toBe(0); + }); + + it('reports canonical health and exposes lifecycle events without renderer-owned scope', async () => { + const created = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + + const health = await invoke('local-db:bots:health', 'bot-1'); + expect(health).toMatchObject({ + botId: 'bot-1', + status: 'healthy', + canonical: { + sessionId: created.session.id, + sessionStatus: 'active', + linked: true, + profileVersion: 1, + runtimeStatus: 'not-started', + }, + issues: [], + }); + + const events = await invoke('local-db:bots:lifecycle-events', { botId: 'bot-1' }); + expect(events.map((event: { eventType: string }) => event.eventType)).toEqual( + expect.arrayContaining(['created', 'canonical-created']), + ); + }); + + it('surfaces failed and dead-letter deliveries in Bot health', async () => { + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const insert = h.sqlite!.prepare(`INSERT INTO bot_delivery_outbox ( + id, bot_id, idempotency_key, payload_ref_json, owner_generation, status, + attempts, created_at, updated_at + ) VALUES (?, 'bot-1', ?, '{}', 0, ?, 1, 1, 1)`); + insert.run('delivery-failed', 'health-failed', 'failed'); + insert.run('delivery-dead-letter', 'health-dead-letter', 'dead-letter'); + + const health = await invoke('local-db:bots:health', 'bot-1'); + expect(health).toMatchObject({ + status: 'attention', + counts: { + deliveries: 2, + failedDeliveries: 1, + deadLetterDeliveries: 1, + }, + issues: expect.arrayContaining([ + { code: 'delivery-failed', count: 1 }, + { code: 'delivery-dead-letter', count: 1 }, + ]), + }); + }); + + it('resolves Bot history ids in main and never accepts a renderer-owned search scope', async () => { + const created = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + h.searchConversations.mockResolvedValue({ + query: 'release', + results: [], + vectorUsed: false, + vectorSkipReason: null, + poolCapped: false, + }); + + await invoke('local-db:bots:search-history', { + botId: 'bot-1', + query: 'release', + limit: 12, + sessionIds: ['foreign-session'], + }); + + expect(h.searchConversations).toHaveBeenCalledWith( + expect.objectContaining({ + query: 'release', + limit: 12, + filters: expect.objectContaining({ sessionIds: [created.session.id] }), + }), + { sessionSources: null }, + ); + }); + + it('records runtime preparation separately from successful Agent startup', async () => { + const created = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const snapshot = await hydrateBotProfileRuntime({ + id: created.session.id, + agentKind: 'pi', + workingDir: created.session.workingDir, + workspaceKind: 'dialogue', + model: 'grok-4.5', + permissionMode: 'bypassPermissions', + }); + + expect(snapshot).toMatchObject({ + botId: 'bot-1', + sessionId: created.session.id, + profileVersion: 1, + resolutionStatus: 'applied', + }); + expect( + h + .sqlite!.prepare( + 'SELECT status, prepared_at AS preparedAt, applied_at AS appliedAt, failed_at AS failedAt FROM bot_runtime_snapshots WHERE id = ?', + ) + .get(snapshot!.snapshotId), + ).toMatchObject({ + status: 'prepared', + appliedAt: null, + failedAt: null, + }); + + await expect(markBotProfileRuntimeApplied(snapshot!)).resolves.toBe(true); + expect( + h + .sqlite!.prepare( + 'SELECT status, applied_at AS appliedAt, failed_at AS failedAt FROM bot_runtime_snapshots WHERE id = ?', + ) + .get(snapshot!.snapshotId), + ).toMatchObject({ + status: 'applied', + failedAt: null, + }); + expect( + h + .sqlite!.prepare( + 'SELECT event_type FROM bot_lifecycle_events WHERE bot_id = ? ORDER BY created_at ASC', + ) + .all('bot-1'), + ).toEqual( + expect.arrayContaining([ + { event_type: 'runtime-prepared' }, + { event_type: 'runtime-applied' }, + ]), + ); + }); + + it('freezes Bot, project, and USER memory references into the exact runtime snapshot', async () => { + await invoke('local-db:bots:update', { + id: 'bot-1', + userContextSource: 'Call the user Chris. Prefer concise Chinese updates.', + capabilities: { memory: true }, + }); + const created = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 2, + }); + const opts: MakerSessionCreateOpts = { + id: created.session.id, + agentKind: 'pi', + workingDir: created.session.workingDir, + workspaceKind: 'dialogue', + model: 'grok-4.5', + permissionMode: 'bypassPermissions', + }; + const readMemoryIndex = vi.fn(async (scopeKey: string) => + scopeKey.startsWith('bot:') ? '# Bot facts\n- Durable fact' : '# Project facts\n- Read only', + ); + + const snapshot = await hydrateBotProfileRuntime(opts, { readMemoryIndex }); + + expect(snapshot?.memoryRefs).toEqual([ + expect.objectContaining({ kind: 'bot', access: 'read-write', status: 'captured' }), + expect.objectContaining({ kind: 'project', access: 'read-only', status: 'captured' }), + expect.objectContaining({ kind: 'user', access: 'read-only', status: 'captured' }), + ]); + expect(opts.makerMemoryIndexSnapshot).toContain('## Bot Memory'); + expect(opts.makerMemoryIndexSnapshot).toContain('Durable fact'); + expect(opts.makerMemoryIndexSnapshot).toContain('## Project Memory (read-only)'); + expect(opts.botUserProfilePrompt).toContain('## User Profile'); + expect(opts.botUserProfilePrompt).toContain('Call the user Chris'); + const row = h + .sqlite!.prepare( + `SELECT configured_json AS configuredJson, resolved_json AS resolvedJson + FROM bot_runtime_snapshots WHERE id = ?`, + ) + .get(snapshot!.snapshotId) as { configuredJson: string; resolvedJson: string }; + const configured = JSON.parse(row.configuredJson) as Record; + const resolved = JSON.parse(row.resolvedJson) as { memoryRefs: Array> }; + expect(configured).toMatchObject({ + schemaVersion: 1, + profile: { + botId: 'bot-1', + version: 2, + userContextSha256: testSha256('Call the user Chris. Prefer concise Chinese updates.'), + }, + execution: { + agentKind: 'pi', + model: 'grok-4.5', + providerId: null, + permissionMode: 'bypassPermissions', + workspaceKind: 'dialogue', + remote: false, + }, + memory: true, + automation: false, + }); + expect(resolved.memoryRefs).toHaveLength(3); + expect(row.resolvedJson).not.toContain('Durable fact'); + expect(row.resolvedJson).not.toContain('Call the user Chris'); + }); + + it('degrades without blocking when a frozen memory source cannot be read', async () => { + const created = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const opts: MakerSessionCreateOpts = { + id: created.session.id, + agentKind: 'pi', + workingDir: created.session.workingDir, + workspaceKind: 'dialogue', + model: 'grok-4.5', + permissionMode: 'bypassPermissions', + }; + + const snapshot = await hydrateBotProfileRuntime(opts, { + readMemoryIndex: async (scopeKey) => { + if (scopeKey.startsWith('bot:')) throw new Error('memory unavailable'); + return ''; + }, + }); + + expect(snapshot?.resolutionStatus).toBe('degraded'); + expect(snapshot?.memoryRefs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'bot', status: 'unavailable' }), + expect.objectContaining({ kind: 'project', status: 'captured' }), + ]), + ); + }); + + it('refuses to start a remote Bot when its native Skill catalog is unavailable', async () => { + const created = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const opts: MakerSessionCreateOpts = { + id: created.session.id, + agentKind: 'pi', + workingDir: '/srv/cindy-bot', + remoteHostId: 'remote-host-1', + workspaceKind: 'dialogue', + model: 'grok-4.5', + permissionMode: 'bypassPermissions', + }; + + await expect(hydrateBotProfileRuntime(opts, { + listSkills: async () => { + throw new Error('remote catalog unavailable'); + }, + })).rejects.toThrow('remote catalog unavailable'); + + const snapshots = h.sqlite! + .prepare('SELECT id FROM bot_runtime_snapshots WHERE session_id = ?') + .all(created.session.id); + expect(snapshots).toEqual([]); + }); + + it('resolves every remote capability catalog against the target host', async () => { + const created = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const inputs: Array<{ kind: string; remoteHostId?: string }> = []; + const opts: MakerSessionCreateOpts = { + id: created.session.id, + agentKind: 'codex', + workingDir: '/srv/cindy-bot', + remoteHostId: 'remote-host-1', + workspaceKind: 'project', + model: 'gpt-5.4', + permissionMode: 'ask', + }; + + await hydrateBotProfileRuntime(opts, { + listSkills: async (input) => { + inputs.push({ kind: 'skills', remoteHostId: input.remoteHostId }); + return []; + }, + listMcpServers: async (input) => { + inputs.push({ kind: 'mcp', remoteHostId: input.remoteHostId }); + return []; + }, + listToolsets: async (input) => { + inputs.push({ kind: 'toolsets', remoteHostId: input.remoteHostId }); + return []; + }, + }); + + expect(inputs).toEqual([ + { kind: 'skills', remoteHostId: 'remote-host-1' }, + { kind: 'mcp', remoteHostId: 'remote-host-1' }, + { kind: 'toolsets', remoteHostId: 'remote-host-1' }, + ]); + }); + + it('materializes inherited capabilities into an immutable runtime allowlist', async () => { + const created = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const opts: MakerSessionCreateOpts = { + id: created.session.id, + agentKind: 'pi', + workingDir: created.session.workingDir, + workspaceKind: 'dialogue', + model: 'grok-4.5', + permissionMode: 'ask', + }; + + await hydrateBotProfileRuntime(opts, { + listSkills: async () => [{ + name: 'research', + path: '/skills/research/SKILL.md', + enabled: true, + runtimeCommandName: 'skill:research', + }], + fingerprintSkillSource: async () => 'a'.repeat(64), + listMcpServers: async () => [{ + name: 'docs', + source: 'custom', + available: true, + }], + listToolsets: async () => [{ + id: 'browser', + name: 'Browser', + available: true, + }], + }); + + expect(opts.botRuntimeProfile).toMatchObject({ + skillPolicy: { mode: 'allowlist', configured: ['skill:research'] }, + mcpPolicy: { mode: 'allowlist', configured: ['docs'] }, + toolsetPolicy: { mode: 'allowlist', configured: ['browser'] }, + }); + }); + + it('freezes Skill content for a task and requires Renew when the resource changes', async () => { + await invoke('local-db:bots:update', { + id: 'bot-1', + capabilities: { skills: ['release'], skillMode: 'allowlist' }, + }); + const created = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 2, + }); + const makeOpts = (): MakerSessionCreateOpts => ({ + id: created.session.id, + agentKind: 'pi', + workingDir: created.session.workingDir, + workspaceKind: 'dialogue', + model: 'grok-4.5', + permissionMode: 'bypassPermissions', + }); + const listSkills = async () => [{ + name: 'release', + path: '/skills/release/SKILL.md', + enabled: true, + runtimeCommandName: 'skill:release', + }]; + + const first = await hydrateBotProfileRuntime(makeOpts(), { + listSkills, + readSkillSource: async () => '# Release\nVersion one', + }); + await markBotProfileRuntimeApplied(first!); + + const resumed = await hydrateBotProfileRuntime(makeOpts(), { + listSkills, + readSkillSource: async () => '# Release\nVersion one', + }); + expect(resumed?.resolvedSkillEntries).toEqual([ + expect.objectContaining({ + runtimeCommandName: 'skill:release', + contentSha256: expect.stringMatching(/^[a-f0-9]{64}$/), + }), + ]); + + await expect(hydrateBotProfileRuntime(makeOpts(), { + listSkills, + readSkillSource: async () => '# Release\nVersion two', + })).rejects.toMatchObject({ code: 'BOT_RUNTIME_RESOURCE_DRIFT' }); + }); + + it('removes a Skill from the native runtime catalog when its source cannot be fingerprinted', async () => { + await invoke('local-db:bots:update', { + id: 'bot-1', + capabilities: { skills: ['release'], skillMode: 'allowlist' }, + }); + const created = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 2, + }); + const opts: MakerSessionCreateOpts = { + id: created.session.id, + agentKind: 'pi', + workingDir: created.session.workingDir, + workspaceKind: 'dialogue', + model: 'grok-4.5', + permissionMode: 'bypassPermissions', + }; + + const snapshot = await hydrateBotProfileRuntime(opts, { + listSkills: async () => [{ + name: 'release', + path: '/skills/release/SKILL.md', + enabled: true, + runtimeCommandName: 'skill:release', + }], + fingerprintSkillSource: async () => { + throw new Error('unreadable'); + }, + }); + + expect(snapshot).toMatchObject({ + resolvedSkills: [], + unavailableSkills: ['skill:release'], + resolutionStatus: 'degraded', + }); + expect(opts.botRuntimeProfile?.skillPolicy).toMatchObject({ + mode: 'allowlist', + catalog: [], + }); + }); + + it('freezes secret-free MCP generations and Toolset versions for a task', async () => { + await invoke('local-db:bots:update', { + id: 'bot-1', + capabilities: { + mcpServers: ['docs'], + mcpMode: 'allowlist', + toolsets: ['contacts'], + toolsetMode: 'allowlist', + }, + }); + const created = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 2, + }); + const makeOpts = (): MakerSessionCreateOpts => ({ + id: created.session.id, + agentKind: 'codex', + workingDir: created.session.workingDir, + workspaceKind: 'dialogue', + model: 'gpt-5.4', + permissionMode: 'ask', + }); + const hydrate = (mcpGeneration: string, toolsetVersion: string) => + hydrateBotProfileRuntime(makeOpts(), { + listMcpServers: async () => [{ + name: 'docs', + source: 'custom', + available: true, + generation: mcpGeneration, + }], + listToolsets: async () => [{ + id: 'contacts', + name: 'Contacts', + available: true, + version: toolsetVersion, + }], + }); + + const first = await hydrate('http:1000', '1.0.0'); + await markBotProfileRuntimeApplied(first!); + await expect(hydrate('http:1000', '1.0.0')).resolves.toMatchObject({ + resolvedMcpServers: ['docs'], + resolvedToolsets: ['contacts'], + }); + await expect(hydrate('http:1001', '1.0.0')).rejects.toMatchObject({ + code: 'BOT_RUNTIME_RESOURCE_DRIFT', + }); + await expect(hydrate('http:1000', '2.0.0')).rejects.toMatchObject({ + code: 'BOT_RUNTIME_RESOURCE_DRIFT', + }); + }); + + it('preflights a frozen resource bundle without creating a runtime snapshot', async () => { + const created = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const opts: MakerSessionCreateOpts = { + id: created.session.id, + agentKind: 'codex', + workingDir: created.session.workingDir, + workspaceKind: 'dialogue', + model: 'gpt-5.4', + permissionMode: 'ask', + }; + + await hydrateBotProfileRuntime(opts, {}, { persistSnapshot: false }); + + const snapshots = h.sqlite! + .prepare('SELECT id FROM bot_runtime_snapshots WHERE session_id = ?') + .all(created.session.id); + expect(snapshots).toEqual([]); + }); + + it('marks startup failure without persisting the raw error message', async () => { + const created = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const snapshot = await hydrateBotProfileRuntime({ + id: created.session.id, + agentKind: 'pi', + workingDir: created.session.workingDir, + workspaceKind: 'dialogue', + model: 'grok-4.5', + permissionMode: 'bypassPermissions', + }); + const startupError = Object.assign(new Error('private prompt contents'), { + code: 'SPAWN_FAILED', + }); + + await expect( + markBotProfileRuntimeFailed(snapshot!, { + stage: 'agent-start', + error: startupError, + }), + ).resolves.toBe(true); + const row = h + .sqlite!.prepare( + 'SELECT status, applied_at AS appliedAt, failed_at AS failedAt, failure_json AS failureJson FROM bot_runtime_snapshots WHERE id = ?', + ) + .get(snapshot!.snapshotId) as { + status: string; + appliedAt: number | null; + failedAt: number | null; + failureJson: string; + }; + expect(row).toMatchObject({ status: 'failed', appliedAt: null }); + expect(row.failedAt).toEqual(expect.any(Number)); + expect(JSON.parse(row.failureJson)).toEqual({ + stage: 'agent-start', + errorName: 'Error', + errorCode: 'SPAWN_FAILED', + }); + expect(row.failureJson).not.toContain('private prompt contents'); + }); + + it('keeps the pinned ProfileVersion across resume and adopts the new version only after Renew', async () => { + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + await invoke('local-db:bots:update', { + id: 'bot-1', + identitySource: 'You are the version two identity.', + capabilities: { memory: false }, + }); + const resumedOpts: MakerSessionCreateOpts = { + id: 'session-1', + agentKind: 'pi' as const, + workingDir: '/tmp/cindy-bot-test/session-1', + workspaceKind: 'dialogue' as const, + model: 'grok-4.5', + permissionMode: 'bypassPermissions' as const, + resumeSessionId: '/tmp/pi-session.jsonl', + }; + + const resumedSnapshot = await hydrateBotProfileRuntime(resumedOpts); + expect(resumedSnapshot?.profileVersion).toBe(1); + expect(resumedOpts.botProfilePrompt).toContain('You are Release Bot'); + expect(resumedOpts.botProfilePrompt).not.toContain('version two identity'); + + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: 'session-1', + expectedProfileVersion: 2, + }); + const renewedOpts: MakerSessionCreateOpts = { + ...resumedOpts, + id: 'session-2', + resumeSessionId: undefined, + botProfilePrompt: undefined, + botProfileContextPrompt: undefined, + botRuntimeProfile: undefined, + }; + const renewedSnapshot = await hydrateBotProfileRuntime(renewedOpts); + expect(renewedSnapshot?.profileVersion).toBe(2); + expect(renewedOpts.botProfilePrompt).toBe('You are the version two identity.'); + expect(renewedOpts.makerMemoryEnabled).toBe(false); + }); + + it('persists a default SOUL in the first ProfileVersion', () => { + const identity = h + .sqlite!.prepare( + 'SELECT identity_source FROM bot_profile_versions WHERE bot_id = ? AND version = 1', + ) + .pluck() + .get('bot-1'); + + expect(identity).toContain('You are Release Bot'); + expect(identity).toContain('intelligent AI assistant running as a Cindy Bot'); + }); + + it('restores the persisted default SOUL when an identity is explicitly cleared', async () => { + await invoke('local-db:bots:update', { + id: 'bot-1', + name: 'Renamed Bot', + identitySource: ' ', + }); + + const row = h + .sqlite!.prepare( + 'SELECT version, identity_source AS identitySource FROM bot_profile_versions WHERE bot_id = ? ORDER BY version DESC LIMIT 1', + ) + .get('bot-1') as { version: number; identitySource: string }; + expect(row.version).toBe(2); + expect(row.identitySource).toContain('You are Renamed Bot'); + }); + + it('uses the default project binding for a new canonical Session', async () => { + await invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-1', + workingDir: '/repo/product', + defaultBranch: 'main', + workspacePolicy: 'reuse', + isDefault: true, + allowedPaths: ['/repo/product/docs'], + }); + + const result = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + + expect(result.session).toMatchObject({ + workingDir: '/repo/product', + workspaceKind: 'project', + }); + expect(h.ensureDialogue).not.toHaveBeenCalled(); + expect(h.ensureGit).toHaveBeenCalledWith( + expect.objectContaining({ + workingDir: '/repo/product', + workspaceKind: 'project', + }), + ); + }); + + it('mounts a read-only Bot project without allocating a worktree lease', async () => { + await invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-1', + workingDir: '/repo/reference', + remoteHostId: null, + workspacePolicy: 'read-only', + isDefault: true, + }); + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const createWorktree = vi.fn(); + const opts = { + id: 'session-1', + agentKind: 'pi' as const, + workingDir: '/tmp/placeholder', + workspaceKind: 'dialogue' as const, + model: 'grok-4.5', + permissionMode: 'bypassPermissions' as const, + }; + + await expect(prepareBotWorkspaceRuntime(opts, { createWorktree })).resolves.toMatchObject({ + workspacePolicy: 'read-only', + workingDir: '/repo/reference', + }); + + expect(opts).toMatchObject({ + workingDir: '/repo/reference', + workspaceKind: 'project', + workspaceAccess: 'read-only', + }); + expect(createWorktree).not.toHaveBeenCalled(); + expect(h.sqlite!.prepare('SELECT COUNT(*) FROM bot_workspace_leases').pluck().get()).toBe(0); + }); + + it('automatically releases a terminal per-task worktree without forcing unsafe cleanup', async () => { + await invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-1', + workingDir: '/repo/product', + defaultBranch: 'main', + workspacePolicy: 'per-task', + isDefault: true, + }); + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const meta = { + sessionId: 'session-1', + name: 'bot-task', + path: '/repo/product/.cindy-worktrees/bot-task', + baseRepo: '/repo/product', + branch: 'cindy/bot-task', + sourceBranch: 'main', + createdAt: new Date(0).toISOString(), + }; + await prepareBotWorkspaceRuntime( + { + id: 'session-1', + agentKind: 'pi', + workingDir: '/repo/product', + workspaceKind: 'project', + model: 'grok-4.5', + }, + { + createId: () => 'lease-per-task-1', + createWorktree: vi.fn(async () => ({ ok: true as const, meta })), + }, + ); + h.sqlite!.prepare("UPDATE sessions SET status = 'archived' WHERE id = 'session-1'").run(); + + const removeLocalWorktree = vi.fn(async () => undefined); + await expect( + reclaimPerTaskBotWorkspaceForSession('session-1', { + now: () => 10, + isSessionRuntimeAlive: () => false, + removeLocalWorktree, + listWorktrees: () => [], + pathExists: async () => false, + }), + ).resolves.toBe(true); + + expect(removeLocalWorktree).toHaveBeenCalledWith( + 'session-1', + expect.objectContaining({ + isSessionRuntimeAlive: expect.any(Function), + canRemove: expect.any(Function), + }), + ); + expect( + h + .sqlite!.prepare( + 'SELECT status, released_at AS releasedAt FROM bot_workspace_leases WHERE id = ?', + ) + .get('lease-per-task-1'), + ).toEqual({ status: 'released', releasedAt: 10 }); + expect( + h + .sqlite!.prepare( + 'SELECT detached_at AS detachedAt FROM bot_workspace_attachments WHERE lease_id = ?', + ) + .get('lease-per-task-1'), + ).toEqual({ detachedAt: 10 }); + }); + + it('keeps a per-task worktree visible as error when the safety remover refuses it', async () => { + await invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-1', + workingDir: '/repo/product', + defaultBranch: 'main', + workspacePolicy: 'per-task', + isDefault: true, + }); + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const meta = { + sessionId: 'session-1', + name: 'bot-task-dirty', + path: '/repo/product/.cindy-worktrees/bot-task-dirty', + baseRepo: '/repo/product', + branch: 'cindy/bot-task-dirty', + sourceBranch: 'main', + createdAt: new Date(0).toISOString(), + }; + await prepareBotWorkspaceRuntime( + { + id: 'session-1', + agentKind: 'pi', + workingDir: '/repo/product', + workspaceKind: 'project', + model: 'grok-4.5', + }, + { + createId: () => 'lease-per-task-dirty', + createWorktree: vi.fn(async () => ({ ok: true as const, meta })), + }, + ); + h.sqlite!.prepare("UPDATE sessions SET status = 'archived' WHERE id = 'session-1'").run(); + + await expect( + reclaimPerTaskBotWorkspaceForSession('session-1', { + now: () => 20, + isSessionRuntimeAlive: () => false, + removeLocalWorktree: vi.fn(async () => { + throw new Error('dirty worktree retained'); + }), + listWorktrees: () => [meta], + pathExists: async () => true, + }), + ).rejects.toThrow('dirty worktree retained'); + expect( + h + .sqlite!.prepare('SELECT status FROM bot_workspace_leases WHERE id = ?') + .get('lease-per-task-dirty'), + ).toEqual({ status: 'error' }); + expect( + h + .sqlite!.prepare( + 'SELECT detached_at AS detachedAt FROM bot_workspace_attachments WHERE lease_id = ?', + ) + .get('lease-per-task-dirty'), + ).toEqual({ detachedAt: null }); + }); + + it('reclaims a per-task lease when its owning task row was physically lost', async () => { + await invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-1', + workingDir: '/repo/product', + defaultBranch: 'main', + workspacePolicy: 'per-task', + isDefault: true, + }); + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const meta = { + sessionId: 'session-1', + name: 'bot-task-missing', + path: '/repo/product/.cindy-worktrees/bot-task-missing', + baseRepo: '/repo/product', + branch: 'cindy/bot-task-missing', + sourceBranch: 'main', + createdAt: new Date(0).toISOString(), + }; + await prepareBotWorkspaceRuntime( + { + id: 'session-1', + agentKind: 'pi', + workingDir: '/repo/product', + workspaceKind: 'project', + model: 'grok-4.5', + }, + { + createId: () => 'lease-per-task-missing', + createWorktree: vi.fn(async () => ({ ok: true as const, meta })), + }, + ); + h.sqlite!.pragma('foreign_keys = OFF'); + h.sqlite!.prepare("DELETE FROM sessions WHERE id = 'session-1'").run(); + h.sqlite!.pragma('foreign_keys = ON'); + + await expect( + reclaimPerTaskBotWorkspaceForSession('session-1', { + now: () => 30, + isSessionRuntimeAlive: () => false, + removeLocalWorktree: vi.fn(async () => undefined), + listWorktrees: () => [], + pathExists: async () => false, + }), + ).resolves.toBe(true); + expect( + h.sqlite! + .prepare('SELECT status FROM bot_workspace_leases WHERE id = ?') + .pluck() + .get('lease-per-task-missing'), + ).toBe('released'); + }); + + it('keeps one reuse lease across canonical Renew and attaches both Sessions', async () => { + await invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-1', + workingDir: '/repo/product', + defaultBranch: 'main', + workspacePolicy: 'reuse', + isDefault: true, + }); + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const meta = { + sessionId: 'session-1', + name: 'bot-product', + path: '/repo/product/.cindy-worktrees/bot-product', + baseRepo: '/repo/product', + branch: 'cindy/bot-product', + sourceBranch: 'main', + createdAt: new Date(0).toISOString(), + }; + const createWorktree = vi.fn(async () => ({ ok: true as const, meta })); + const getWorktreeForSession = vi.fn((sessionId: string) => + sessionId === 'session-1' ? meta : null, + ); + const setWorktreeForSession = vi.fn(async () => undefined); + const deleteWorktreeForSession = vi.fn(); + const firstOpts = { + id: 'session-1', + agentKind: 'pi' as const, + workingDir: '/repo/product', + workspaceKind: 'project' as const, + model: 'grok-4.5', + }; + await prepareBotWorkspaceRuntime(firstOpts, { + createId: () => 'lease-1', + createWorktree, + getWorktreeForSession, + setWorktreeForSession, + deleteWorktreeForSession, + }); + + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: 'session-1', + expectedProfileVersion: 1, + }); + const secondOpts = { + ...firstOpts, + id: 'session-2', + workingDir: '/repo/product', + }; + await prepareBotWorkspaceRuntime(secondOpts, { + createId: () => 'unused-lease', + createWorktree, + getWorktreeForSession, + setWorktreeForSession, + deleteWorktreeForSession, + }); + + expect(createWorktree).toHaveBeenCalledTimes(1); + expect(firstOpts.workingDir).toBe(meta.path); + expect(secondOpts.workingDir).toBe(meta.path); + expect(setWorktreeForSession).toHaveBeenCalledWith('session-2', { + ...meta, + sessionId: 'session-2', + }); + expect(deleteWorktreeForSession).toHaveBeenCalledWith('session-1'); + expect( + h + .sqlite!.prepare( + 'SELECT lease_key AS leaseKey, anchor_session_id AS anchorSessionId, status FROM bot_workspace_leases', + ) + .all(), + ).toEqual([{ leaseKey: 'shared', anchorSessionId: 'session-2', status: 'active' }]); + expect( + h + .sqlite!.prepare( + 'SELECT session_id AS sessionId FROM bot_workspace_attachments ORDER BY session_id', + ) + .all(), + ).toEqual([{ sessionId: 'session-1' }, { sessionId: 'session-2' }]); + }); + + it('creates and reuses a remote Bot worktree without registering a local worktree', async () => { + await invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-1', + workingDir: '/remote/repo', + defaultBranch: 'main', + workspacePolicy: 'reuse', + remoteHostId: 'host-1', + isDefault: true, + }); + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const createRemoteWorktree = vi.fn(async () => ({ + path: '/remote/repo/.cindy-worktrees/lease-remote-1', + baseRepo: '/remote/repo', + branch: 'cindy/bot-lease-remote-1', + sourceBranch: 'main', + })); + const inspectRemoteWorktree = vi.fn(async () => ({ + exists: true, + branch: 'cindy/bot-lease-remote-1', + })); + const firstOpts = { + id: 'session-1', + agentKind: 'pi' as const, + workingDir: '/remote/repo', + workspaceKind: 'project' as const, + model: 'grok-4.5', + }; + await prepareBotWorkspaceRuntime(firstOpts, { + createId: () => 'lease-remote-1', + createRemoteWorktree, + inspectRemoteWorktree, + }); + expect(firstOpts).toMatchObject({ + workingDir: '/remote/repo/.cindy-worktrees/lease-remote-1', + remoteHostId: 'host-1', + }); + expect(h.worktrees).toEqual([]); + + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: 'session-1', + expectedProfileVersion: 1, + }); + const secondOpts = { ...firstOpts, id: 'session-2', workingDir: '/remote/repo' }; + await prepareBotWorkspaceRuntime(secondOpts, { createRemoteWorktree, inspectRemoteWorktree }); + expect(createRemoteWorktree).toHaveBeenCalledTimes(1); + expect( + h + .sqlite!.prepare( + 'SELECT anchor_session_id AS anchorSessionId, remote_host_id AS remoteHostId FROM bot_workspace_leases', + ) + .all(), + ).toEqual([{ anchorSessionId: 'session-2', remoteHostId: 'host-1' }]); + }); + + it('rebuilds an interrupted remote acquisition from its durable lease id', async () => { + await invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-1', + workingDir: '/remote/repo', + defaultBranch: 'main', + workspacePolicy: 'reuse', + remoteHostId: 'host-1', + isDefault: true, + }); + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + h.sqlite!.prepare( + `INSERT INTO bot_workspace_leases ( + id, bot_id, project_binding_id, lease_key, anchor_session_id, worktree_path, + base_repo, branch, source_branch, remote_host_id, generation, status, created_at, updated_at + ) SELECT 'lease-remote-1', 'bot-1', id, 'shared', 'session-1', NULL, + '/remote/repo', NULL, 'main', 'host-1', 1, 'acquiring', 1, 1 + FROM bot_project_bindings WHERE bot_id = 'bot-1'`, + ).run(); + const createRemoteWorktree = vi.fn(async () => ({ + path: '/remote/repo/.cindy-worktrees/lease-remote-1', + baseRepo: '/remote/repo', + branch: 'cindy/bot-lease-remote-1', + sourceBranch: 'main', + })); + await reconcileBotWorkspaceLeases({ + now: () => 2, + createRemoteWorktree, + inspectRemoteWorktree: vi.fn(async () => ({ + exists: true, + branch: 'cindy/bot-lease-remote-1', + })), + }); + expect(createRemoteWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + remoteHostId: 'host-1', + leaseId: 'lease-remote-1', + generation: 1, + }), + ); + expect( + h + .sqlite!.prepare( + 'SELECT status, worktree_path AS worktreePath FROM bot_workspace_leases WHERE id = ?', + ) + .get('lease-remote-1'), + ).toEqual({ status: 'active', worktreePath: '/remote/repo/.cindy-worktrees/lease-remote-1' }); + }); + + it('blocks release while an active Bot Session still uses the lease', async () => { + await invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-1', + workingDir: '/repo/product', + defaultBranch: 'main', + workspacePolicy: 'reuse', + isDefault: true, + }); + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const meta = { + sessionId: 'session-1', + name: 'bot-product', + path: '/repo/product/.cindy-worktrees/bot-product', + baseRepo: '/repo/product', + branch: 'cindy/bot-product', + sourceBranch: 'main', + createdAt: new Date(0).toISOString(), + }; + h.worktrees = [meta]; + await prepareBotWorkspaceRuntime( + { + id: 'session-1', + agentKind: 'pi', + workingDir: '/repo/product', + workspaceKind: 'project', + model: 'grok-4.5', + }, + { + createId: () => 'lease-1', + createWorktree: vi.fn(async () => ({ ok: true as const, meta })), + getWorktreeForSession: (sessionId) => + h.worktrees.find((item) => item.sessionId === sessionId) ?? null, + }, + ); + + await expect( + invoke('local-db:bots:workspace-lease-release', { + botId: 'bot-1', + leaseId: 'lease-1', + expectedGeneration: 1, + }), + ).rejects.toThrow('active Bot Session'); + expect(h.removeWorktree).not.toHaveBeenCalled(); + }); + + it('releases an unreferenced lease and detaches its historical Sessions', async () => { + await invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-1', + workingDir: '/repo/product', + defaultBranch: 'main', + workspacePolicy: 'reuse', + isDefault: true, + }); + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const meta = { + sessionId: 'session-1', + name: 'bot-product', + path: '/repo/product/.cindy-worktrees/bot-product', + baseRepo: '/repo/product', + branch: 'cindy/bot-product', + sourceBranch: 'main', + createdAt: new Date(0).toISOString(), + }; + h.worktrees = [meta]; + await prepareBotWorkspaceRuntime( + { + id: 'session-1', + agentKind: 'pi', + workingDir: '/repo/product', + workspaceKind: 'project', + model: 'grok-4.5', + }, + { + createId: () => 'lease-1', + createWorktree: vi.fn(async () => ({ ok: true as const, meta })), + getWorktreeForSession: (sessionId) => + h.worktrees.find((item) => item.sessionId === sessionId) ?? null, + }, + ); + h.sqlite!.prepare("UPDATE sessions SET status = 'archived' WHERE id = 'session-1'").run(); + + const profile = await invoke('local-db:bots:workspace-lease-release', { + botId: 'bot-1', + leaseId: 'lease-1', + expectedGeneration: 1, + }); + + expect(h.removeWorktree).toHaveBeenCalledWith('session-1', expect.any(Object)); + expect(profile.workspaceLeases).toEqual([ + expect.objectContaining({ id: 'lease-1', status: 'released', generation: 1 }), + ]); + expect( + h + .sqlite!.prepare( + 'SELECT detached_at IS NOT NULL FROM bot_workspace_attachments WHERE lease_id = ?', + ) + .pluck() + .get('lease-1'), + ).toBe(1); + }); + + it('repairs a lost lease anchor from the durable attachment and registered worktree', async () => { + await invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-1', + workingDir: '/repo/product', + defaultBranch: 'main', + workspacePolicy: 'reuse', + isDefault: true, + }); + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const meta = { + sessionId: 'orphaned-store-owner', + name: 'bot-product', + path: '/repo/product/.cindy-worktrees/bot-product', + baseRepo: '/repo/product', + branch: 'cindy/bot-product', + sourceBranch: 'main', + createdAt: new Date(0).toISOString(), + }; + h.sqlite!.prepare( + `INSERT INTO bot_workspace_leases ( + id, bot_id, project_binding_id, lease_key, anchor_session_id, worktree_path, + base_repo, branch, source_branch, generation, status, created_at, updated_at + ) SELECT 'lease-1', 'bot-1', id, 'shared', NULL, ?, ?, ?, ?, 1, 'active', 1, 1 + FROM bot_project_bindings WHERE bot_id = 'bot-1'`, + ).run(meta.path, meta.baseRepo, meta.branch, meta.sourceBranch); + h.sqlite!.prepare( + `INSERT INTO bot_workspace_attachments ( + id, lease_id, session_id, generation, access, created_at, detached_at + ) VALUES ('attachment-1', 'lease-1', 'session-1', 1, 'read-write', 1, NULL)`, + ).run(); + const setWorktreeForSession = vi.fn(async () => undefined); + const deleteWorktreeForSession = vi.fn(); + + await reconcileBotWorkspaceLeases({ + now: () => 10, + listWorktrees: () => [meta], + pathExists: async () => true, + setWorktreeForSession, + deleteWorktreeForSession, + }); + + expect(setWorktreeForSession).toHaveBeenCalledWith('session-1', { + ...meta, + sessionId: 'session-1', + }); + expect(deleteWorktreeForSession).toHaveBeenCalledWith('orphaned-store-owner'); + expect( + h + .sqlite!.prepare('SELECT anchor_session_id FROM bot_workspace_leases WHERE id = ?') + .pluck() + .get('lease-1'), + ).toBe('session-1'); + }); + + it('finishes an interrupted release only when both the store and directory are gone', async () => { + await invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-1', + workingDir: '/repo/product', + defaultBranch: 'main', + workspacePolicy: 'reuse', + isDefault: true, + }); + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + h.sqlite!.prepare( + `INSERT INTO bot_workspace_leases ( + id, bot_id, project_binding_id, lease_key, anchor_session_id, worktree_path, + base_repo, branch, source_branch, generation, status, created_at, updated_at + ) SELECT 'lease-1', 'bot-1', id, 'shared', 'session-1', '/gone/worktree', + '/repo/product', 'cindy/bot-product', 'main', 1, 'releasing', 1, 1 + FROM bot_project_bindings WHERE bot_id = 'bot-1'`, + ).run(); + h.sqlite!.prepare( + `INSERT INTO bot_workspace_attachments ( + id, lease_id, session_id, generation, access, created_at, detached_at + ) VALUES ('attachment-1', 'lease-1', 'session-1', 1, 'read-write', 1, NULL)`, + ).run(); + + await reconcileBotWorkspaceLeases({ + now: () => 10, + listWorktrees: () => [], + pathExists: async () => false, + }); + + expect( + h + .sqlite!.prepare( + 'SELECT status, released_at AS releasedAt FROM bot_workspace_leases WHERE id = ?', + ) + .get('lease-1'), + ).toEqual({ status: 'released', releasedAt: 10 }); + expect( + h + .sqlite!.prepare('SELECT detached_at FROM bot_workspace_attachments WHERE id = ?') + .pluck() + .get('attachment-1'), + ).toBe(10); + }); + + it('marks an active lease without a durable worktree path as recoverable error', async () => { + await invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-1', + workingDir: '/repo/product', + defaultBranch: 'main', + workspacePolicy: 'reuse', + isDefault: true, + }); + h.sqlite!.prepare( + `INSERT INTO bot_workspace_leases ( + id, bot_id, project_binding_id, lease_key, anchor_session_id, worktree_path, + base_repo, branch, source_branch, generation, status, created_at, updated_at + ) SELECT 'lease-missing-path', 'bot-1', id, 'shared', NULL, NULL, + '/repo/product', NULL, 'main', 1, 'active', 1, 1 + FROM bot_project_bindings WHERE bot_id = 'bot-1'`, + ).run(); + + await reconcileBotWorkspaceLeases({ + now: () => 40, + listWorktrees: () => [], + pathExists: async () => false, + }); + + expect( + h.sqlite! + .prepare('SELECT status FROM bot_workspace_leases WHERE id = ?') + .pluck() + .get('lease-missing-path'), + ).toBe('error'); + }); + + it('rejects local allowed paths outside the bound project', async () => { + await expect( + invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-1', + workingDir: '/repo/product', + workspacePolicy: 'none', + isDefault: true, + allowedPaths: ['/repo/other'], + }), + ).rejects.toThrow('allowedPaths'); + }); + + it('removes a newly allocated dialogue workspace when Git initialization fails', async () => { + h.ensureGit.mockRejectedValueOnce(new Error('git init failed')); + + await expect( + invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }), + ).rejects.toThrow('git init failed'); + expect(h.remove).toHaveBeenCalledWith('/tmp/cindy-bot-test/session-1', { + recursive: true, + force: true, + }); + }); + + it('creates and links the first canonical Session atomically with the Profile version', async () => { + const result = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + + expect(result).toMatchObject({ + created: true, + canonicalSessionId: 'session-1', + session: { + id: 'session-1', + title: 'Release Bot', + source: 'bot', + agentKind: 'pi', + model: 'grok-4.5', + permissionMode: 'bypassPermissions', + }, + }); + expect( + h + .sqlite!.prepare('SELECT profile_version FROM bot_session_links WHERE session_id = ?') + .pluck() + .get('session-1'), + ).toBe(1); + }); + + it('returns the winner and removes the unused workspace when a stale create loses the CAS', async () => { + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const stale = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + + expect(stale).toMatchObject({ created: false, canonicalSessionId: 'session-1' }); + expect( + h.sqlite!.prepare("SELECT id FROM sessions WHERE source = 'bot' ORDER BY id").pluck().all(), + ).toEqual(['session-1']); + expect(h.remove).toHaveBeenCalledWith('/tmp/cindy-bot-test/session-2', { + recursive: true, + force: true, + }); + }); + + it('never removes a user project when a project-backed stale create loses the CAS', async () => { + await invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-1', + workingDir: '/repo/user-project', + workspacePolicy: 'none', + isDefault: true, + allowedPaths: [], + }); + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + h.remove.mockClear(); + + const stale = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + + expect(stale).toMatchObject({ created: false, canonicalSessionId: 'session-1' }); + expect(h.remove).not.toHaveBeenCalled(); + }); + + it('archives the previous Bot Session and promotes exactly one replacement on Renew', async () => { + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const renewed = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: 'session-1', + expectedProfileVersion: 1, + }); + + expect(renewed).toMatchObject({ created: true, canonicalSessionId: 'session-2' }); + expect(h.sqlite!.prepare('SELECT id, status FROM sessions ORDER BY id').all()).toEqual([ + { id: 'session-1', status: 'archived' }, + { id: 'session-2', status: 'active' }, + ]); + expect( + h + .sqlite!.prepare( + 'SELECT session_id AS sessionId, role FROM bot_session_links ORDER BY session_id', + ) + .all(), + ).toEqual([ + { sessionId: 'session-1', role: 'history' }, + { sessionId: 'session-2', role: 'canonical' }, + ]); + expect(h.closeSession).toHaveBeenCalledWith('session-1'); + }); + + it('recovers a soft-deleted canonical without resurrecting the deleted Session', async () => { + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + h.sqlite!.prepare("UPDATE sessions SET status = 'deleted' WHERE id = 'session-1'").run(); + + const recovered = await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: 'session-1', + expectedProfileVersion: 1, + }); + + expect(recovered).toMatchObject({ created: true, canonicalSessionId: 'session-2' }); + expect( + h.sqlite!.prepare('SELECT status FROM sessions WHERE id = ?').pluck().get('session-1'), + ).toBe('deleted'); + }); + + it('runs a Bot delegation in a separate target-owned child Session and returns the result', async () => { + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + await invoke('local-db:bots:create', { + id: 'bot-2', + name: 'Research Bot', + capabilities: { + harness: 'codex', + model: 'gpt-5.5', + permissions: 'trusted', + }, + }); + const dispatch = vi.fn( + async (params: { targetSessionId: string; onAccepted?: () => Promise | void }) => { + await params.onAccepted?.(); + return { + ok: true as const, + targetSessionId: params.targetSessionId, + wakeKind: 'already-active' as const, + }; + }, + ); + const abortSession = vi.fn(async () => undefined); + const archiveSession = vi.fn(async (sessionId: string) => { + h.sqlite!.prepare("UPDATE sessions SET status = 'archived' WHERE id = ?").run(sessionId); + }); + const closeSession = vi.fn(async () => undefined); + const broadcastSessionCreated = vi.fn(); + const service = createBotDelegationService({ + dispatch, + abortSession, + archiveSession, + closeSession, + broadcastSessionCreated, + now: () => 1_000, + createId: () => 'delegation-1', + }); + try { + const delegated = await service.delegateToBot({ + callerSessionId: 'session-1', + targetBotId: 'bot-2', + objective: 'Research the release compatibility matrix.', + budgetTokens: 2_000, + timeoutMs: 60_000, + }); + expect(delegated).toMatchObject({ + ok: true, + delegationId: 'delegation-1', + childSessionId: 'session-2', + targetBotId: 'bot-2', + depth: 1, + status: 'running', + }); + expect(broadcastSessionCreated).toHaveBeenCalledWith('session-2'); + expect( + h + .sqlite!.prepare( + 'SELECT source, parent_session_id AS parentSessionId, agent_kind AS agentKind FROM sessions WHERE id = ?', + ) + .get('session-2'), + ).toEqual({ source: 'bot', parentSessionId: 'session-1', agentKind: 'codex' }); + expect( + h + .sqlite!.prepare( + 'SELECT bot_id AS botId, role, route_key AS routeKey FROM bot_session_links WHERE session_id = ?', + ) + .get('session-2'), + ).toEqual({ botId: 'bot-2', role: 'route', routeKey: 'delegation:delegation-1' }); + + h.sqlite!.prepare('UPDATE sessions SET total_token_usage = 900 WHERE id = ?').run( + 'session-2', + ); + await service.settleSession({ + childSessionId: 'session-2', + outcome: 'done', + resultText: 'All supported clients remain compatible.', + }); + expect( + h + .sqlite!.prepare( + 'SELECT status, tokens_used AS tokensUsed, result_summary AS resultSummary FROM bot_delegations WHERE id = ?', + ) + .get('delegation-1'), + ).toEqual({ + status: 'completed', + tokensUsed: 900, + resultSummary: 'All supported clients remain compatible.', + }); + expect(dispatch).toHaveBeenLastCalledWith( + expect.objectContaining({ + targetSessionId: 'session-1', + message: expect.stringContaining('All supported clients remain compatible.'), + }), + ); + expect(archiveSession).toHaveBeenCalledWith('session-2'); + expect(closeSession).toHaveBeenCalledWith('session-2'); + expect( + h.sqlite!.prepare('SELECT status FROM sessions WHERE id = ?').pluck().get('session-2'), + ).toBe('archived'); + expect( + h + .sqlite!.prepare('SELECT role FROM bot_session_links WHERE session_id = ?') + .pluck() + .get('session-2'), + ).toBe('history'); + await expect( + service.delegateToBot({ + callerSessionId: 'session-2', + targetBotId: 'bot-1', + objective: 'A historical task must not start new work.', + }), + ).resolves.toMatchObject({ ok: false, errorCode: 'NOT_A_BOT_SESSION' }); + } finally { + service.dispose(); + } + }); + + it('confines Automation collaboration to the frozen target, deadline, depth, and aggregate budget', async () => { + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + for (const [id, name] of [['bot-2', 'Research Bot'], ['bot-3', 'Unapproved Bot']]) { + await invoke('local-db:bots:create', { + id, + name, + capabilities: { harness: 'pi', model: 'grok-4.5', permissions: 'trusted' }, + }); + } + const profileVersion = h.sqlite!.prepare(` + SELECT capabilities_json AS capabilitiesJson, identity_source AS identitySource + FROM bot_profile_versions WHERE bot_id = 'bot-1' AND version = 1 + `).get() as { capabilitiesJson: string; identitySource: string }; + const targetVersion = h.sqlite!.prepare(` + SELECT capabilities_json AS capabilitiesJson, identity_source AS identitySource + FROM bot_profile_versions WHERE bot_id = 'bot-2' AND version = 1 + `).get() as { capabilitiesJson: string; identitySource: string }; + const executionPlan = { + version: 1, + createdAt: 1_000, + deadlineAt: 61_000, + botId: 'bot-1', + profile: { + profileVersion: 1, + agentKind: 'pi', + model: 'grok-4.5', + capabilitiesSha256: testSha256(profileVersion.capabilitiesJson), + identitySha256: testSha256(profileVersion.identitySource), + skills: [], + skillMode: 'inherit', + mcpServers: [], + mcpMode: 'inherit', + toolsets: [], + toolsetMode: 'inherit', + memoryEnabled: true, + automationEnabled: false, + }, + workspace: null, + delivery: { targetRouteId: null, ownerGeneration: null }, + limits: { timeoutMs: 60_000, budgetTokens: 100, maxDelegationDepth: 1 }, + delegation: { + mode: 'allowlist', + targets: [{ + botId: 'bot-2', + profileVersion: 1, + capabilitiesSha256: testSha256(targetVersion.capabilitiesJson), + identitySha256: testSha256(targetVersion.identitySource), + defaultWorkspace: null, + }], + }, + }; + h.sqlite!.prepare(` + INSERT INTO bot_automation_links ( + id, bot_id, execution_policy_json, created_with_profile_version, + status, created_at, updated_at + ) VALUES ('automation-1', 'bot-1', '{}', 1, 'active', 1000, 1000) + `).run(); + h.sqlite!.prepare(` + INSERT INTO bot_automation_runs ( + id, automation_link_id, session_id, profile_version, + execution_plan_json, status, created_at, updated_at + ) VALUES ('automation-run-1', 'automation-1', 'session-1', 1, ?, 'running', 1000, 1000) + `).run(JSON.stringify(executionPlan)); + + const abortSession = vi.fn(async () => undefined); + let nextDelegation = 0; + const service = createBotDelegationService({ + dispatch: vi.fn(async (params: { targetSessionId: string }) => ({ + ok: true as const, + targetSessionId: params.targetSessionId, + wakeKind: 'queued' as const, + })), + abortSession, + createId: () => `automation-delegation-${++nextDelegation}`, + now: () => 1_000, + }); + try { + await expect(service.listBots('session-1')).resolves.toMatchObject({ + ok: true, + bots: [{ + id: 'bot-2', + automationAuthorization: { state: 'allowed', reason: null }, + }], + }); + await expect(service.delegateToBot({ + callerSessionId: 'session-1', + targetBotId: 'bot-3', + objective: 'This target was not frozen into the Automation plan.', + })).resolves.toMatchObject({ ok: false, errorCode: 'AUTOMATION_TARGET_NOT_ALLOWED' }); + + h.sqlite!.prepare("UPDATE bot_profiles SET current_version = 2 WHERE id = 'bot-2'").run(); + await expect(service.listBots('session-1')).resolves.toMatchObject({ + ok: true, + bots: [{ + id: 'bot-2', + automationAuthorization: { + state: 'stale', + reason: expect.stringContaining('Profile'), + }, + }], + }); + await expect(service.delegateToBot({ + callerSessionId: 'session-1', + targetBotId: 'bot-2', + objective: 'Do not adopt a changed target Profile.', + })).resolves.toMatchObject({ ok: false, errorCode: 'AUTOMATION_TARGET_STALE' }); + h.sqlite!.prepare("UPDATE bot_profiles SET current_version = 1 WHERE id = 'bot-2'").run(); + + h.sqlite!.prepare('UPDATE sessions SET total_token_usage = 60 WHERE id = ?').run('session-1'); + await expect(service.delegateToBot({ + callerSessionId: 'session-1', + targetBotId: 'bot-2', + objective: 'Do not reserve more than the Automation budget.', + budgetTokens: 41, + })).resolves.toMatchObject({ ok: false, errorCode: 'AUTOMATION_BUDGET_EXCEEDED' }); + await expect(service.delegateToBot({ + callerSessionId: 'session-1', + targetBotId: 'bot-2', + objective: 'Use the remaining bounded budget.', + budgetTokens: 40, + maxDepth: 5, + timeoutMs: 120_000, + })).resolves.toMatchObject({ + ok: true, + childSessionId: 'session-2', + depth: 1, + deadlineAt: 61_000, + }); + await expect(service.delegateToBot({ + callerSessionId: 'session-2', + targetBotId: 'bot-3', + objective: 'A nested Automation delegation must not exceed max depth.', + })).resolves.toMatchObject({ ok: false, errorCode: 'MAX_DEPTH' }); + + h.sqlite!.prepare('UPDATE sessions SET total_token_usage = 50 WHERE id = ?').run('session-2'); + await expect(service.enforceBudgetForSession('session-2', 50)).resolves.toBe(true); + expect(h.sqlite!.prepare( + 'SELECT status, error_message AS errorMessage FROM bot_automation_runs WHERE id = ?', + ).get('automation-run-1')).toMatchObject({ + status: 'failed', + errorMessage: expect.stringContaining('(110/100)'), + }); + expect(abortSession).toHaveBeenCalledWith('session-1'); + } finally { + service.dispose(); + } + }); + + it('freezes the target workspace and only accepts references within both Bot project grants', async () => { + await invoke('local-db:bots:create', { + id: 'bot-2', + name: 'Research Bot', + capabilities: { harness: 'pi', model: 'grok-4.5', permissions: 'ask' }, + }); + for (const botId of ['bot-1', 'bot-2']) { + await invoke('local-db:bots:project-binding-upsert', { + botId, + workingDir: '/repo/shared', + workspacePolicy: 'none', + isDefault: true, + allowedPaths: ['/repo/shared/docs'], + }); + } + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const service = createBotDelegationService({ + dispatch: vi.fn(async (params: { targetSessionId: string }) => ({ + ok: true as const, + targetSessionId: params.targetSessionId, + wakeKind: 'queued' as const, + })), + abortSession: vi.fn(async () => undefined), + createId: () => 'delegation-frozen-workspace', + now: () => 4_000, + }); + try { + await expect( + service.delegateToBot({ + callerSessionId: 'session-1', + targetBotId: 'bot-2', + objective: 'Read the release contract.', + contextRefs: ['docs/release.md'], + artifactRefs: ['docs/result.md'], + }), + ).resolves.toMatchObject({ ok: true, childSessionId: 'session-2' }); + + const snapshot = parseBotDelegationPlanSnapshot( + h + .sqlite!.prepare('SELECT permission_snapshot_json FROM bot_delegations WHERE id = ?') + .pluck() + .get('delegation-frozen-workspace') as string, + ); + expect(snapshot).not.toBeNull(); + expect(snapshot).toMatchObject({ + version: 1, + workspace: { + workingDir: '/repo/shared', + workspacePolicy: 'none', + allowedPaths: ['/repo/shared/docs'], + }, + access: { + contextRefs: ['docs/release.md'], + artifactRefs: ['docs/result.md'], + }, + }); + + h.sqlite!.prepare( + "UPDATE bot_project_bindings SET working_dir = '/repo/changed', updated_at = 5000 WHERE bot_id = 'bot-2'", + ).run(); + const opts = { + id: 'session-2', + agentKind: 'pi' as const, + workingDir: '/tmp/placeholder', + workspaceKind: 'dialogue' as const, + model: 'grok-4.5', + }; + await expect(prepareBotWorkspaceRuntime(opts)).resolves.toMatchObject({ + projectBindingId: snapshot!.workspace!.bindingId, + workingDir: '/repo/shared', + }); + expect(opts.workingDir).toBe('/repo/shared'); + } finally { + service.dispose(); + } + }); + + it('rejects traversal, cross-project, and ungranted Bot delegation references', async () => { + await invoke('local-db:bots:create', { + id: 'bot-2', + name: 'Research Bot', + capabilities: { harness: 'pi', model: 'grok-4.5', permissions: 'ask' }, + }); + await invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-1', + workingDir: '/repo/shared', + workspacePolicy: 'none', + isDefault: true, + allowedPaths: ['/repo/shared/docs'], + }); + await invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-2', + workingDir: '/repo/shared', + workspacePolicy: 'none', + isDefault: true, + allowedPaths: ['/repo/shared/docs'], + }); + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const service = createBotDelegationService({ + dispatch: vi.fn(async (params: { targetSessionId: string }) => ({ + ok: true as const, + targetSessionId: params.targetSessionId, + wakeKind: 'queued' as const, + })), + abortSession: vi.fn(async () => undefined), + }); + try { + await expect( + service.delegateToBot({ + callerSessionId: 'session-1', + targetBotId: 'bot-2', + objective: 'Escape the project.', + contextRefs: ['../secret.txt'], + }), + ).resolves.toMatchObject({ ok: false, errorCode: 'INVALID_REFERENCE' }); + await expect( + service.delegateToBot({ + callerSessionId: 'session-1', + targetBotId: 'bot-2', + objective: 'Read an ungranted path.', + contextRefs: ['src/private.ts'], + }), + ).resolves.toMatchObject({ ok: false, errorCode: 'REFERENCE_NOT_ALLOWED' }); + + h.sqlite!.prepare( + "UPDATE bot_project_bindings SET project_key = 'other-project' WHERE bot_id = 'bot-2'", + ).run(); + await expect( + service.delegateToBot({ + callerSessionId: 'session-1', + targetBotId: 'bot-2', + objective: 'Cross projects.', + contextRefs: ['docs/release.md'], + }), + ).resolves.toMatchObject({ ok: false, errorCode: 'REFERENCE_SCOPE_MISMATCH' }); + } finally { + service.dispose(); + } + }); + + it('cancels active delegation descendants when the parent Bot task is renewed', async () => { + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + await invoke('local-db:bots:create', { + id: 'bot-2', + name: 'Research Bot', + capabilities: { harness: 'pi', model: 'grok-4.5', permissions: 'ask' }, + }); + const abortSession = vi.fn(async () => undefined); + const service = createBotDelegationService({ + dispatch: vi.fn(async (params: { targetSessionId: string }) => ({ + ok: true as const, + targetSessionId: params.targetSessionId, + wakeKind: 'queued' as const, + })), + abortSession, + createId: () => 'delegation-parent-renew', + now: () => 6_000, + }); + try { + await expect( + service.delegateToBot({ + callerSessionId: 'session-1', + targetBotId: 'bot-2', + objective: 'Remain bounded to this parent task.', + }), + ).resolves.toMatchObject({ ok: true, childSessionId: 'session-2' }); + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: 'session-1', + expectedProfileVersion: 1, + }); + + expect( + h.sqlite!.prepare('SELECT status FROM bot_delegations WHERE id = ?').pluck().get( + 'delegation-parent-renew', + ), + ).toBe('cancelled'); + expect(h.sqlite!.prepare('SELECT status FROM sessions WHERE id = ?').pluck().get('session-2')).toBe( + 'archived', + ); + expect(abortSession).toHaveBeenCalledWith('session-2'); + } finally { + service.dispose(); + } + }); + + it('rejects delegation cycles and can cancel an active child', async () => { + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + await invoke('local-db:bots:create', { + id: 'bot-2', + name: 'Research Bot', + capabilities: { harness: 'pi', model: 'grok-4.5', permissions: 'ask' }, + }); + const dispatch = vi.fn(async (params: { targetSessionId: string }) => ({ + ok: true as const, + targetSessionId: params.targetSessionId, + wakeKind: 'queued' as const, + })); + const abortSession = vi.fn(async () => undefined); + let id = 0; + const service = createBotDelegationService({ + dispatch, + abortSession, + createId: () => `delegation-${++id}`, + now: () => 2_000, + }); + try { + const first = await service.delegateToBot({ + callerSessionId: 'session-1', + targetBotId: 'bot-2', + objective: 'Prepare research.', + maxDepth: 2, + }); + expect(first).toMatchObject({ ok: true, childSessionId: 'session-2' }); + await expect( + service.delegateToBot({ + callerSessionId: 'session-2', + targetBotId: 'bot-1', + objective: 'Send the same work back.', + maxDepth: 2, + }), + ).resolves.toMatchObject({ ok: false, errorCode: 'DELEGATION_CYCLE' }); + + await expect(service.cancelDelegation('session-1', 'delegation-1')).resolves.toMatchObject({ + ok: true, + childSessionId: 'session-2', + }); + expect(abortSession).toHaveBeenCalledWith('session-2'); + expect( + h + .sqlite!.prepare('SELECT status FROM bot_delegations WHERE id = ?') + .pluck() + .get('delegation-1'), + ).toBe('cancelled'); + } finally { + service.dispose(); + } + }); + + it('inherits the parent depth and token ceilings for nested Bot delegations', async () => { + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + for (const [id, name] of [ + ['bot-2', 'Research Bot'], + ['bot-3', 'Build Bot'], + ['bot-4', 'Review Bot'], + ]) { + await invoke('local-db:bots:create', { + id, + name, + capabilities: { harness: 'pi', model: 'grok-4.5', permissions: 'ask' }, + }); + } + let id = 0; + const service = createBotDelegationService({ + dispatch: vi.fn( + async (params: { targetSessionId: string; onAccepted?: () => Promise | void }) => { + await params.onAccepted?.(); + return { + ok: true as const, + targetSessionId: params.targetSessionId, + wakeKind: 'already-active' as const, + }; + }, + ), + abortSession: vi.fn(async () => undefined), + createId: () => `nested-${++id}`, + now: () => 2_500, + }); + try { + await expect( + service.delegateToBot({ + callerSessionId: 'session-1', + targetBotId: 'bot-2', + objective: 'Own the bounded parent task.', + maxDepth: 2, + budgetTokens: 1_000, + }), + ).resolves.toMatchObject({ ok: true, childSessionId: 'session-2', depth: 1 }); + + await expect( + service.delegateToBot({ + callerSessionId: 'session-2', + targetBotId: 'bot-3', + objective: 'Try to exceed the parent budget.', + maxDepth: 5, + budgetTokens: 1_001, + }), + ).resolves.toMatchObject({ ok: false, errorCode: 'BUDGET_EXCEEDED' }); + + await expect( + service.delegateToBot({ + callerSessionId: 'session-2', + targetBotId: 'bot-3', + objective: 'Use a bounded child budget.', + maxDepth: 5, + budgetTokens: 500, + }), + ).resolves.toMatchObject({ ok: true, childSessionId: 'session-3', depth: 2 }); + expect( + h + .sqlite!.prepare('SELECT budget_tokens AS budgetTokens FROM bot_delegations WHERE id = ?') + .get('nested-2'), + ).toEqual({ budgetTokens: 500 }); + + await expect( + service.delegateToBot({ + callerSessionId: 'session-3', + targetBotId: 'bot-4', + objective: 'Try to raise the inherited max depth.', + maxDepth: 5, + }), + ).resolves.toMatchObject({ ok: false, errorCode: 'MAX_DEPTH' }); + } finally { + service.dispose(); + } + }); + + it('durably enqueues a delegation completion instead of requiring the parent task to be online', async () => { + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + await invoke('local-db:bots:create', { + id: 'bot-2', + name: 'Research Bot', + capabilities: { harness: 'pi', model: 'grok-4.5', permissions: 'ask' }, + }); + const dispatch = vi.fn( + async (params: { targetSessionId: string; onAccepted?: () => Promise | void }) => { + await params.onAccepted?.(); + return { + ok: true as const, + targetSessionId: params.targetSessionId, + wakeKind: 'already-active' as const, + }; + }, + ); + const enqueueDelivery = vi.fn(async () => ({ id: 'outbox-1' })); + const service = createBotDelegationService({ + dispatch, + enqueueDelivery, + abortSession: vi.fn(async () => undefined), + createId: () => 'delegation-1', + now: () => 3_000, + }); + try { + const delegated = await service.delegateToBot({ + callerSessionId: 'session-1', + targetBotId: 'bot-2', + objective: 'Prepare a durable result.', + }); + expect(delegated).toMatchObject({ ok: true, childSessionId: 'session-2' }); + await service.settleSession({ + childSessionId: 'session-2', + outcome: 'done', + resultText: 'Result survives a temporarily unavailable parent.', + }); + + expect(dispatch).toHaveBeenCalledTimes(1); + expect(enqueueDelivery).toHaveBeenCalledWith( + expect.objectContaining({ + botId: 'bot-1', + sessionId: 'session-1', + idempotencyKey: 'bot-delegation-completion:delegation-1', + payload: expect.objectContaining({ + kind: 'session-message', + targetSessionId: 'session-1', + fallbackBotId: 'bot-1', + clientId: 'bot-delegation-completion:delegation-1', + message: expect.stringContaining('Result survives a temporarily unavailable parent.'), + }), + }), + ); + } finally { + service.dispose(); + } + }); + + it('exposes delegation completion delivery diagnostics for recovery', async () => { + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + await invoke('local-db:bots:create', { + id: 'bot-2', + name: 'Research Bot', + capabilities: { harness: 'pi', model: 'grok-4.5', permissions: 'ask' }, + }); + const outbox = createBotDeliveryOutboxService({ + createId: () => 'outbox-delegation-diagnostic', + deliver: async (_row, _payload, attempt) => { + await attempt.recordExternalDispatch({ retrySafe: false, transport: 'local-adapter' }); + await attempt.recordProgress({ textMessageId: 'possibly-sent', sentMediaCount: 1 }); + return { + ok: false as const, + retryable: true, + errorCode: 'CHANNEL_SEND_FAILED', + message: 'connection lost after dispatch', + }; + }, + now: () => 3_250, + }); + const service = createBotDelegationService({ + dispatch: async (params) => { + await params.onAccepted?.(); + return { + ok: true as const, + targetSessionId: params.targetSessionId, + wakeKind: 'already-active' as const, + }; + }, + enqueueDelivery: outbox.enqueue, + abortSession: vi.fn(async () => undefined), + createId: () => 'delegation-diagnostic', + now: () => 3_200, + }); + try { + const delegated = await service.delegateToBot({ + callerSessionId: 'session-1', + targetBotId: 'bot-2', + objective: 'Return a recoverable result.', + }); + expect(delegated).toMatchObject({ ok: true }); + if (!delegated.ok) throw new Error(delegated.message); + await service.settleSession({ + childSessionId: delegated.childSessionId, + outcome: 'done', + resultText: 'Result with cindy-media://blobs/result.png', + }); + await outbox.drain(); + + const listed = await service.listDelegations('session-1'); + expect(listed).toMatchObject({ + ok: true, + delegations: [ + { + id: 'delegation-diagnostic', + outputArtifacts: [{ ref: 'cindy-media://blobs/result.png', kind: 'image' }], + completionDelivery: { + id: 'outbox-delegation-diagnostic', + status: 'dead-letter', + attempts: 1, + diagnostic: { + retrySafe: false, + transport: 'local-adapter', + textMessageId: 'possibly-sent', + sentMediaCount: 1, + }, + }, + }, + ], + }); + } finally { + service.dispose(); + outbox.dispose(); + } + }); + + it('keeps the parent IM Route on a Bot delegation completion delivery', async () => { + const route = await upsertBotRoute({ + botId: 'bot-1', + channelId: 'bot-1:local', + routeKey: 'telegram:dm:bot-1:user-1', + }); + const routed = await ensureBotRouteSession({ + routeId: route.id, + ownerDeviceId: 'device-a', + }); + await invoke('local-db:bots:create', { + id: 'bot-2', + name: 'Research Bot', + capabilities: { harness: 'pi', model: 'grok-4.5', permissions: 'ask' }, + }); + const enqueueDelivery = vi.fn(async () => ({ id: 'outbox-route' })); + const service = createBotDelegationService({ + dispatch: vi.fn( + async (params: { targetSessionId: string; onAccepted?: () => Promise | void }) => { + await params.onAccepted?.(); + return { + ok: true as const, + targetSessionId: params.targetSessionId, + wakeKind: 'already-active' as const, + }; + }, + ), + enqueueDelivery, + abortSession: vi.fn(async () => undefined), + createId: () => 'delegation-route', + now: () => 3_500, + }); + try { + const delegated = await service.delegateToBot({ + callerSessionId: routed.sessionId, + targetBotId: 'bot-2', + objective: 'Return this result to the originating IM route.', + }); + expect(delegated).toMatchObject({ ok: true }); + if (!delegated.ok) throw new Error(delegated.message); + await service.settleSession({ + childSessionId: delegated.childSessionId, + outcome: 'done', + resultText: 'Route-aware result.', + }); + + expect(enqueueDelivery).toHaveBeenCalledWith( + expect.objectContaining({ + botId: 'bot-1', + channelId: 'bot-1:local', + routeId: route.id, + sessionId: routed.sessionId, + ownerGeneration: routed.route.ownerGeneration, + idempotencyKey: 'bot-delegation-completion:delegation-route', + }), + ); + } finally { + service.dispose(); + } + }); + + it('deduplicates Bot deliveries and retries transient failures until delivered', async () => { + let currentTime = 4_000; + let nextId = 0; + const deliver = vi + .fn() + .mockResolvedValueOnce({ + ok: false as const, + retryable: true, + errorCode: 'AGENT_NOT_READY', + message: 'temporarily offline', + }) + .mockResolvedValueOnce({ + ok: true as const, + receipt: { channel: 'telegram', messageId: 'message-42' }, + }); + const service = createBotDeliveryOutboxService({ + deliver, + now: () => currentTime, + createId: () => `outbox-${++nextId}`, + }); + try { + const input = { + botId: 'bot-1', + sessionId: null, + idempotencyKey: 'delegation-result:1', + payload: { + version: 1 as const, + kind: 'session-message', + targetSessionId: 'session-1', + message: 'done', + }, + }; + const first = await service.enqueue(input); + const duplicate = await service.enqueue(input); + expect(duplicate).toEqual(first); + + await service.drain(); + expect( + h + .sqlite!.prepare( + 'SELECT status, attempts, next_attempt_at AS nextAttemptAt FROM bot_delivery_outbox WHERE id = ?', + ) + .get(first.id), + ).toEqual({ status: 'failed', attempts: 1, nextAttemptAt: 5_000 }); + + currentTime = 5_000; + await service.drain(); + expect( + h + .sqlite!.prepare( + 'SELECT status, attempts, delivered_at AS deliveredAt, delivery_receipt_json AS deliveryReceiptJson FROM bot_delivery_outbox WHERE id = ?', + ) + .get(first.id), + ).toEqual({ + status: 'delivered', + attempts: 2, + deliveredAt: 5_000, + deliveryReceiptJson: JSON.stringify({ channel: 'telegram', messageId: 'message-42' }), + }); + expect(deliver).toHaveBeenCalledTimes(2); + expect(h.sqlite!.prepare('SELECT COUNT(*) FROM bot_delivery_outbox').pluck().get()).toBe(1); + } finally { + service.dispose(); + } + }); + + it('keeps multipart progress and the original dispatch time in the final receipt', async () => { + let currentTime = 5_500; + const service = createBotDeliveryOutboxService({ + now: () => currentTime, + createId: () => 'outbox-progress-final', + deliver: async (_row, _payload, attempt) => { + await attempt.recordExternalDispatch({ retrySafe: true, transport: 'server-relay' }); + currentTime = 5_700; + await attempt.recordProgress({ textMessageId: 'text-1', sentMediaCount: 1 }); + return { ok: true, receipt: { channel: 'telegram', messageId: 'media-1' } }; + }, + }); + try { + await service.enqueue({ + botId: 'bot-1', + idempotencyKey: 'progress-final', + payload: { version: 1, kind: 'session-message' }, + }); + await service.drain(); + const row = h.sqlite!.prepare( + 'SELECT delivery_receipt_json AS receipt FROM bot_delivery_outbox WHERE id = ?', + ).get('outbox-progress-final') as { receipt: string }; + expect(JSON.parse(row.receipt)).toEqual({ + externalDispatch: { retrySafe: true, transport: 'server-relay', startedAt: 5_500 }, + progress: { textMessageId: 'text-1', sentMediaCount: 1 }, + channel: 'telegram', + messageId: 'media-1', + }); + } finally { + service.dispose(); + } + }); + + it('recovers a stale sending Bot delivery after a host restart', async () => { + h.sqlite!.prepare( + ` + INSERT INTO bot_delivery_outbox ( + id, bot_id, session_id, idempotency_key, payload_ref_json, + owner_generation, status, attempts, next_attempt_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 0, 'sending', 1, NULL, ?, ?) + `, + ).run( + 'outbox-stale', + 'bot-1', + null, + 'stale-delivery', + JSON.stringify({ + version: 1, + kind: 'session-message', + targetSessionId: 'session-1', + message: 'recover me', + }), + 1_000, + 1_000, + ); + const deliver = vi.fn(async () => ({ ok: true as const })); + const service = createBotDeliveryOutboxService({ + deliver, + now: () => 70_000, + sendingLeaseMs: 60_000, + }); + try { + await service.restore(); + expect(deliver).toHaveBeenCalledTimes(1); + expect( + h + .sqlite!.prepare('SELECT status, attempts FROM bot_delivery_outbox WHERE id = ?') + .get('outbox-stale'), + ).toEqual({ status: 'delivered', attempts: 2 }); + } finally { + service.dispose(); + } + }); + + it('does not replay a stale local-adapter delivery whose provider outcome is unknown', async () => { + h.sqlite!.prepare( + ` + INSERT INTO bot_delivery_outbox ( + id, bot_id, session_id, idempotency_key, payload_ref_json, + owner_generation, status, attempts, next_attempt_at, delivery_receipt_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 0, 'sending', 1, NULL, ?, ?, ?) + `, + ).run( + 'outbox-local-ambiguous', + 'bot-1', + null, + 'local-ambiguous', + JSON.stringify({ + version: 1, + kind: 'session-message', + targetSessionId: 'session-1', + message: 'do not duplicate me', + }), + JSON.stringify({ + externalDispatch: { + retrySafe: false, + transport: 'local-adapter', + startedAt: 1_000, + }, + }), + 1_000, + 1_000, + ); + const deliver = vi.fn(async () => ({ ok: true as const })); + const service = createBotDeliveryOutboxService({ + deliver, + now: () => 70_000, + sendingLeaseMs: 60_000, + }); + try { + await service.restore(); + expect(deliver).not.toHaveBeenCalled(); + expect( + h.sqlite!.prepare( + 'SELECT status, attempts, last_error AS lastError FROM bot_delivery_outbox WHERE id = ?', + ).get('outbox-local-ambiguous'), + ).toEqual({ + status: 'dead-letter', + attempts: 1, + lastError: + 'DELIVERY_OUTCOME_UNKNOWN: local adapter may have delivered before the host stopped; automatic retry was suppressed to prevent a duplicate', + }); + } finally { + service.dispose(); + } + }); + + it('suppresses automatic retry when a local adapter fails after dispatch starts', async () => { + let currentTime = 12_000; + const deliver = vi.fn(async (_row, _payload, attempt) => { + await attempt.recordExternalDispatch({ retrySafe: false, transport: 'local-adapter' }); + return { + ok: false as const, + retryable: true, + errorCode: 'CHANNEL_SEND_FAILED', + message: 'connection closed before acknowledgement', + }; + }); + const service = createBotDeliveryOutboxService({ + deliver, + now: () => currentTime, + createId: () => 'outbox-local-failure', + }); + try { + const queued = await service.enqueue({ + botId: 'bot-1', + idempotencyKey: 'local-failure', + payload: { + version: 1, + kind: 'session-message', + targetSessionId: 'session-1', + message: 'possibly delivered', + }, + }); + await service.drain(); + expect( + h.sqlite!.prepare( + 'SELECT status, attempts, next_attempt_at AS nextAttemptAt, last_error AS lastError FROM bot_delivery_outbox WHERE id = ?', + ).get(queued.id), + ).toEqual({ + status: 'dead-letter', + attempts: 1, + nextAttemptAt: null, + lastError: + 'DELIVERY_OUTCOME_UNKNOWN: CHANNEL_SEND_FAILED: connection closed before acknowledgement; local adapter may already have delivered, so automatic retry was suppressed', + }); + currentTime += 60_000; + await service.drain(); + expect(deliver).toHaveBeenCalledTimes(1); + } finally { + service.dispose(); + } + }); + + it('persists multipart delivery progress before a local adapter failure', async () => { + const deliver = vi.fn(async (_row, _payload, attempt) => { + await attempt.recordExternalDispatch({ retrySafe: false, transport: 'local-adapter' }); + await attempt.recordProgress({ textMessageId: 'text-1', sentMediaCount: 1 }); + return { + ok: false as const, + retryable: true, + errorCode: 'CHANNEL_MEDIA_SEND_FAILED', + message: 'second attachment failed', + }; + }); + const service = createBotDeliveryOutboxService({ + deliver, + now: () => 15_000, + createId: () => 'outbox-multipart-progress', + }); + try { + const queued = await service.enqueue({ + botId: 'bot-1', + idempotencyKey: 'multipart-progress', + payload: { + version: 1, + kind: 'session-message', + targetSessionId: 'session-1', + message: 'result with attachments', + }, + }); + await service.drain(); + const row = h.sqlite!.prepare( + 'SELECT status, delivery_receipt_json AS receipt FROM bot_delivery_outbox WHERE id = ?', + ).get(queued.id) as { status: string; receipt: string }; + expect(row.status).toBe('dead-letter'); + expect(JSON.parse(row.receipt)).toMatchObject({ + externalDispatch: { retrySafe: false, transport: 'local-adapter' }, + progress: { textMessageId: 'text-1', sentMediaCount: 1 }, + }); + await expect(service.retry(queued.id, 'bot-1')).rejects.toThrow( + 'explicit duplicate-risk confirmation is required', + ); + await expect( + service.retry(queued.id, 'bot-1', { allowDuplicateRisk: true }), + ).resolves.toEqual({ id: queued.id }); + } finally { + service.dispose(); + } + }); + + it('lists Bot deliveries without exposing payload content and includes recovery diagnostics', async () => { + const service = createBotDeliveryOutboxService({ + deliver: async (_row, _payload, attempt) => { + await attempt.recordExternalDispatch({ retrySafe: false, transport: 'local-adapter' }); + await attempt.recordProgress({ textMessageId: 'text-visible', sentMediaCount: 2 }); + return { + ok: false as const, + retryable: true, + errorCode: 'CHANNEL_MEDIA_SEND_FAILED', + message: 'last attachment failed', + }; + }, + now: () => 16_000, + createId: () => 'outbox-list-diagnostic', + }); + try { + await service.enqueue({ + botId: 'bot-1', + channelId: 'bot-1:local', + idempotencyKey: 'list-diagnostic', + payload: { + version: 1, + kind: 'session-message', + message: 'private result must not be returned by the listing API', + }, + }); + await service.drain(); + const listed = await service.listForBot('bot-1', 10); + expect(listed).toContainEqual(expect.objectContaining({ + id: 'outbox-list-diagnostic', + channelKind: 'local', + payloadKind: 'session-message', + status: 'dead-letter', + diagnostic: expect.objectContaining({ + retrySafe: false, + transport: 'local-adapter', + textMessageId: 'text-visible', + sentMediaCount: 2, + }), + })); + expect(JSON.stringify(listed)).not.toContain('private result'); + } finally { + service.dispose(); + } + }); + + it('manually retries a dead-letter delivery from a fresh attempt budget', async () => { + let currentTime = 8_000; + const deliver = vi + .fn() + .mockResolvedValueOnce({ + ok: false as const, + retryable: false, + errorCode: 'REMOTE_REJECTED', + message: 'temporary account issue', + }) + .mockResolvedValueOnce({ ok: true as const }); + const service = createBotDeliveryOutboxService({ + deliver, + now: () => currentTime, + createId: () => 'outbox-manual-retry', + }); + try { + const queued = await service.enqueue({ + botId: 'bot-1', + idempotencyKey: 'manual-retry', + payload: { + version: 1, + kind: 'session-message', + targetSessionId: 'session-1', + message: 'deliver me', + }, + }); + await service.drain(); + expect( + h + .sqlite!.prepare( + 'SELECT status, attempts, last_error AS lastError FROM bot_delivery_outbox WHERE id = ?', + ) + .get(queued.id), + ).toEqual({ + status: 'dead-letter', + attempts: 1, + lastError: 'REMOTE_REJECTED: temporary account issue', + }); + + currentTime = 9_000; + await service.retry(queued.id, 'bot-1'); + await service.drain(); + expect( + h + .sqlite!.prepare( + 'SELECT status, attempts, last_error AS lastError FROM bot_delivery_outbox WHERE id = ?', + ) + .get(queued.id), + ).toEqual({ status: 'delivered', attempts: 1, lastError: null }); + expect(deliver).toHaveBeenCalledTimes(2); + } finally { + service.dispose(); + } + }); + + it('does not manually retry through a changed Route owner generation', async () => { + h.sqlite!.prepare( + ` + INSERT INTO bot_routes ( + id, bot_id, channel_id, route_key, principal_key, scope_key, + owner_generation, status, created_at, updated_at + ) VALUES ('route-retry-owner', 'bot-1', 'bot-1:local', 'retry-owner', + 'local-user', 'local-scope', 1, 'active', 1, 1) + `, + ).run(); + const service = createBotDeliveryOutboxService({ + deliver: vi.fn(async () => ({ + ok: false as const, + retryable: false, + errorCode: 'REMOTE_REJECTED', + message: 'retry manually', + })), + createId: () => 'outbox-stale-manual-retry', + }); + try { + const queued = await service.enqueue({ + botId: 'bot-1', + channelId: 'bot-1:local', + routeId: 'route-retry-owner', + ownerGeneration: 1, + idempotencyKey: 'stale-manual-retry', + payload: { version: 1, kind: 'channel-message', text: 'do not leak' }, + }); + await service.drain(); + h.sqlite!.prepare( + "UPDATE bot_routes SET owner_generation = 2 WHERE id = 'route-retry-owner'", + ).run(); + + await expect(service.retry(queued.id, 'bot-1')).rejects.toThrow('route ownership changed'); + expect( + h + .sqlite!.prepare('SELECT status FROM bot_delivery_outbox WHERE id = ?') + .pluck() + .get(queued.id), + ).toBe('dead-letter'); + } finally { + service.dispose(); + } + }); + + it('retries an offline route but cancels a stale route-owner generation', async () => { + h.sqlite!.prepare( + ` + INSERT INTO bot_routes ( + id, bot_id, channel_id, route_key, principal_key, scope_key, + owner_generation, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run( + 'route-1', + 'bot-1', + 'bot-1:local', + 'local:test', + 'local-user', + 'local-scope', + 1, + 'offline', + 1_000, + 1_000, + ); + let currentTime = 10_000; + let nextId = 0; + const deliver = vi.fn(async () => ({ ok: true as const })); + const service = createBotDeliveryOutboxService({ + deliver, + now: () => currentTime, + createId: () => `route-outbox-${++nextId}`, + }); + try { + const retryable = await service.enqueue({ + botId: 'bot-1', + channelId: 'bot-1:local', + routeId: 'route-1', + ownerGeneration: 1, + idempotencyKey: 'route-retry', + payload: { version: 1, kind: 'channel-message', text: 'retry later' }, + }); + await service.drain(); + expect(deliver).not.toHaveBeenCalled(); + expect( + h + .sqlite!.prepare('SELECT status, attempts FROM bot_delivery_outbox WHERE id = ?') + .get(retryable.id), + ).toEqual({ status: 'failed', attempts: 1 }); + + h.sqlite!.prepare("UPDATE bot_routes SET status = 'active' WHERE id = 'route-1'").run(); + currentTime = 11_000; + await service.drain(); + expect(deliver).toHaveBeenCalledTimes(1); + expect( + h + .sqlite!.prepare('SELECT status FROM bot_delivery_outbox WHERE id = ?') + .pluck() + .get(retryable.id), + ).toBe('delivered'); + + const stale = await service.enqueue({ + botId: 'bot-1', + channelId: 'bot-1:local', + routeId: 'route-1', + ownerGeneration: 1, + idempotencyKey: 'route-stale', + payload: { version: 1, kind: 'channel-message', text: 'must not leak' }, + }); + h.sqlite!.prepare("UPDATE bot_routes SET owner_generation = 2 WHERE id = 'route-1'").run(); + await service.drain(); + expect(deliver).toHaveBeenCalledTimes(1); + expect( + h + .sqlite!.prepare( + 'SELECT status, last_error AS lastError FROM bot_delivery_outbox WHERE id = ?', + ) + .get(stale.id), + ).toEqual({ + status: 'cancelled', + lastError: 'STALE_ROUTE_OWNER: expected generation 1, current 2', + }); + } finally { + service.dispose(); + } + }); + + it('restores a waiting delegation and recreates a missing completion delivery', async () => { + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + await invoke('local-db:bots:create', { + id: 'bot-2', + name: 'Research Bot', + capabilities: { harness: 'pi', model: 'grok-4.5', permissions: 'ask' }, + }); + const first = createBotDelegationService({ + dispatch: vi.fn(async (params: { targetSessionId: string }) => ({ + ok: true as const, + targetSessionId: params.targetSessionId, + wakeKind: 'queued' as const, + })), + abortSession: vi.fn(async () => undefined), + createId: () => 'delegation-restore', + now: () => 20_000, + }); + try { + await expect( + first.delegateToBot({ + callerSessionId: 'session-1', + targetBotId: 'bot-2', + objective: 'Resume after restart.', + }), + ).resolves.toMatchObject({ ok: true, childSessionId: 'session-2' }); + } finally { + first.dispose(); + } + h.sqlite!.prepare( + "UPDATE bot_delegations SET status = 'waiting' WHERE id = 'delegation-restore'", + ).run(); + + const dispatch = vi.fn( + async (params: { + targetSessionId: string; + clientId?: string; + onAccepted?: () => Promise | void; + }) => { + await params.onAccepted?.(); + return { + ok: true as const, + targetSessionId: params.targetSessionId, + wakeKind: 'already-active' as const, + }; + }, + ); + const enqueueDelivery = vi.fn(async () => ({ id: 'outbox-restored' })); + const restored = createBotDelegationService({ + dispatch, + enqueueDelivery, + abortSession: vi.fn(async () => undefined), + now: () => 21_000, + }); + try { + await restored.restore(); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + targetSessionId: 'session-2', + clientId: 'bot-delegation-start:delegation-restore', + }), + ); + expect( + h + .sqlite!.prepare('SELECT status FROM bot_delegations WHERE id = ?') + .pluck() + .get('delegation-restore'), + ).toBe('running'); + + h.sqlite!.prepare( + ` + UPDATE bot_delegations + SET status = 'completed', result_summary = ?, completed_at = ?, updated_at = ? + WHERE id = ? + `, + ).run('Recovered result.', 22_000, 22_000, 'delegation-restore'); + await restored.restore(); + expect(enqueueDelivery).toHaveBeenCalledWith( + expect.objectContaining({ + idempotencyKey: 'bot-delegation-completion:delegation-restore', + payload: expect.objectContaining({ + message: expect.stringContaining('Recovered result.'), + }), + }), + ); + } finally { + restored.dispose(); + } + }); + + it('resumes an interrupted running delegation with a stable restart client id', async () => { + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + await invoke('local-db:bots:create', { + id: 'bot-2', + name: 'Research Bot', + capabilities: { harness: 'pi', model: 'grok-4.5', permissions: 'ask' }, + }); + const first = createBotDelegationService({ + dispatch: vi.fn( + async (params: { targetSessionId: string; onAccepted?: () => Promise | void }) => { + await params.onAccepted?.(); + return { + ok: true as const, + targetSessionId: params.targetSessionId, + wakeKind: 'already-active' as const, + }; + }, + ), + abortSession: vi.fn(async () => undefined), + createId: () => 'delegation-running-restart', + now: () => 30_000, + }); + try { + await expect( + first.delegateToBot({ + callerSessionId: 'session-1', + targetBotId: 'bot-2', + objective: 'Continue after a host restart.', + }), + ).resolves.toMatchObject({ ok: true, childSessionId: 'session-2', status: 'running' }); + } finally { + first.dispose(); + } + h.sqlite!.prepare( + ` + UPDATE sessions + SET active_turn_started_at = 31000, last_turn_ended_at = 30000 + WHERE id = 'session-2' + `, + ).run(); + + const dispatch = vi.fn(async (params: { targetSessionId: string }) => ({ + ok: true as const, + targetSessionId: params.targetSessionId, + wakeKind: 'already-active' as const, + })); + const restored = createBotDelegationService({ + dispatch, + abortSession: vi.fn(async () => undefined), + now: () => 32_000, + }); + try { + await restored.restore(); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + targetSessionId: 'session-2', + clientId: 'bot-delegation-resume:delegation-running-restart:31000', + message: expect.stringContaining('Continue after a host restart.'), + }), + ); + expect( + h + .sqlite!.prepare( + 'SELECT status, last_error AS lastError FROM bot_delegations WHERE id = ?', + ) + .get('delegation-running-restart'), + ).toEqual({ status: 'running', lastError: null }); + } finally { + restored.dispose(); + } + }); + + it('times out an interrupted delegation before restart recovery can dispatch it again', async () => { + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + await invoke('local-db:bots:create', { + id: 'bot-2', + name: 'Research Bot', + capabilities: { harness: 'pi', model: 'grok-4.5', permissions: 'ask' }, + }); + const first = createBotDelegationService({ + dispatch: vi.fn( + async (params: { targetSessionId: string; onAccepted?: () => Promise | void }) => { + await params.onAccepted?.(); + return { + ok: true as const, + targetSessionId: params.targetSessionId, + wakeKind: 'already-active' as const, + }; + }, + ), + abortSession: vi.fn(async () => undefined), + createId: () => 'delegation-expired-restart', + now: () => 40_000, + }); + try { + await first.delegateToBot({ + callerSessionId: 'session-1', + targetBotId: 'bot-2', + objective: 'Do not resume after the deadline.', + timeoutMs: 1_000, + }); + } finally { + first.dispose(); + } + + const dispatch = vi.fn(); + const abortSession = vi.fn(async () => undefined); + const restored = createBotDelegationService({ + dispatch, + abortSession, + now: () => 42_000, + }); + try { + await restored.restore(); + expect(dispatch).not.toHaveBeenCalled(); + expect(abortSession).toHaveBeenCalledWith('session-2'); + expect( + h.sqlite! + .prepare('SELECT status FROM bot_delegations WHERE id = ?') + .pluck() + .get('delegation-expired-restart'), + ).toBe('timed-out'); + } finally { + restored.dispose(); + } + }); + + describe('Bot Route database lifecycle', () => { + it('keeps Channel, project binding, and task ownership inside one Bot', async () => { + await invoke('local-db:bots:create', { + id: 'bot-2', + name: 'Research Bot', + capabilities: { harness: 'pi', model: 'grok-4.5', permissions: 'ask' }, + }); + const bot2 = await invoke('local-db:bots:project-binding-upsert', { + botId: 'bot-2', + workingDir: '/repo/research', + workspacePolicy: 'reuse', + isDefault: true, + }); + + await expect( + upsertBotRoute({ + botId: 'bot-1', + channelId: 'bot-2:local', + routeKey: 'wrong-channel', + }), + ).rejects.toThrow('Bot Channel does not exist'); + await expect( + upsertBotRoute({ + botId: 'bot-1', + channelId: 'bot-1:local', + routeKey: 'wrong-project', + projectBindingId: bot2.projectBindings[0].id, + }), + ).rejects.toThrow('Bot Project binding is unavailable'); + + const route = await upsertBotRoute({ + botId: 'bot-1', + channelId: 'bot-1:local', + routeKey: 'owned-route', + }); + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-2', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + await expect( + claimBotRoute({ + routeId: route.id, + ownerDeviceId: 'device-a', + currentSessionId: 'session-1', + }), + ).rejects.toThrow('Bot task is unavailable'); + }); + + it('does not claim paused or archived Routes and prevents device stealing', async () => { + const route = await upsertBotRoute({ + botId: 'bot-1', + channelId: 'bot-1:local', + routeKey: 'claim-guard', + }); + await setBotRouteStatus(route.id, 'paused'); + await expect( + claimBotRoute({ + routeId: route.id, + ownerDeviceId: 'device-a', + }), + ).rejects.toThrow('Bot Route is paused'); + + await setBotRouteStatus(route.id, 'offline'); + const claimed = await claimBotRoute({ + routeId: route.id, + ownerDeviceId: 'device-a', + }); + expect(claimed).toMatchObject({ + status: 'active', + ownerDeviceId: 'device-a', + ownerGeneration: 3, + }); + await expect( + claimBotRoute({ + routeId: route.id, + ownerDeviceId: 'device-b', + }), + ).rejects.toThrow('Bot Route is owned by another device'); + + await setBotRouteStatus(route.id, 'archived'); + await expect( + claimBotRoute({ + routeId: route.id, + ownerDeviceId: 'device-a', + }), + ).rejects.toThrow('Bot Route is archived'); + }); + + it('rejects stale owner generations when a Route changes state', async () => { + const route = await upsertBotRoute({ + botId: 'bot-1', + channelId: 'bot-1:local', + routeKey: 'generation-cas', + }); + const first = await ensureBotRouteSession({ + routeId: route.id, + ownerDeviceId: 'device-a', + }); + await setBotRouteStatus(route.id, 'recovering'); + + await expect( + updateBotRouteSession({ + routeId: route.id, + ownerDeviceId: 'device-a', + ownerGeneration: first.route.ownerGeneration, + currentSessionId: first.sessionId, + }), + ).rejects.toThrow('Bot Route ownership is stale'); + }); + + it('creates on the first offline message, reuses while active, and archives on Renew', async () => { + const route = await upsertBotRoute({ + botId: 'bot-1', + channelId: 'bot-1:local', + routeKey: 'lifecycle', + }); + expect(route.status).toBe('offline'); + + const first = await ensureBotRouteSession({ + routeId: route.id, + ownerDeviceId: 'device-a', + }); + expect(first).toMatchObject({ sessionId: 'session-1', created: true }); + + const reused = await ensureBotRouteSession({ + routeId: route.id, + ownerDeviceId: 'device-a', + }); + expect(reused).toMatchObject({ sessionId: 'session-1', created: false }); + + const renewed = await ensureBotRouteSession({ + routeId: route.id, + ownerDeviceId: 'device-a', + forceRenew: true, + }); + expect(renewed).toMatchObject({ sessionId: 'session-2', created: true }); + expect(renewed.route.ownerGeneration).toBe(first.route.ownerGeneration + 1); + expect( + h.sqlite!.prepare('SELECT status FROM sessions WHERE id = ?').pluck().get('session-1'), + ).toBe('archived'); + expect( + h + .sqlite!.prepare('SELECT role FROM bot_session_links WHERE session_id = ?') + .pluck() + .get('session-1'), + ).toBe('history'); + expect( + h + .sqlite!.prepare('SELECT current_session_id FROM bot_routes WHERE id = ?') + .pluck() + .get(route.id), + ).toBe('session-2'); + expect( + h + .sqlite!.prepare('SELECT event_type FROM bot_lifecycle_events WHERE session_id = ?') + .pluck() + .get('session-2'), + ).toBe('route-session-renewed'); + expect(h.closeSession).toHaveBeenCalledWith('session-1'); + }); + + it('does not replace a Route task when the shared runtime guard reports it busy', async () => { + const route = await upsertBotRoute({ + botId: 'bot-1', + channelId: 'bot-1:local', + routeKey: 'busy-renew', + }); + const first = await ensureBotRouteSession({ + routeId: route.id, + ownerDeviceId: 'device-a', + }); + configureBotCanonicalReplacementCoordinator(async (sessionId, operation) => { + if (sessionId === first.sessionId) { + throw Object.assign(new Error('Bot task is busy'), { code: 'SESSION_RUNNING' }); + } + return operation(); + }); + + await expect( + ensureBotRouteSession({ + routeId: route.id, + ownerDeviceId: 'device-a', + forceRenew: true, + }), + ).rejects.toMatchObject({ code: 'SESSION_RUNNING' }); + expect( + h.sqlite!.prepare('SELECT current_session_id FROM bot_routes WHERE id = ?').pluck().get(route.id), + ).toBe(first.sessionId); + expect( + h.sqlite!.prepare('SELECT COUNT(*) FROM sessions').pluck().get(), + ).toBe(1); + expect(h.closeSession).not.toHaveBeenCalled(); + }); + + it('allows only one replacement when duplicate Route renews race', async () => { + const route = await upsertBotRoute({ + botId: 'bot-1', + channelId: 'bot-1:local', + routeKey: 'renew-race', + }); + const first = await ensureBotRouteSession({ + routeId: route.id, + ownerDeviceId: 'device-a', + }); + let entered = 0; + let release!: () => void; + const bothEntered = new Promise((resolve) => { + release = resolve; + }); + configureBotCanonicalReplacementCoordinator(async (_sessionId, operation) => { + entered += 1; + if (entered === 2) release(); + else await bothEntered; + return operation(); + }); + + const results = await Promise.allSettled([ + ensureBotRouteSession({ routeId: route.id, ownerDeviceId: 'device-a', forceRenew: true }), + ensureBotRouteSession({ routeId: route.id, ownerDeviceId: 'device-a', forceRenew: true }), + ]); + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + expect(results.filter((result) => result.status === 'rejected')).toHaveLength(1); + expect( + h.sqlite!.prepare('SELECT COUNT(*) FROM sessions').pluck().get(), + ).toBe(2); + expect( + h.sqlite!.prepare('SELECT owner_generation FROM bot_routes WHERE id = ?').pluck().get(route.id), + ).toBe(first.route.ownerGeneration + 1); + }); + + it('freezes the same provider and model configuration into each Route Session', async () => { + await invoke('local-db:bots:create', { + id: 'bot-route-model-profile', + name: 'Route Model Bot', + capabilities: { + harness: 'pi', + providerId: 'xai', + model: 'grok-4.5', + effort: 'max', + fastMode: true, + permissions: 'ask', + }, + }); + const route = await upsertBotRoute({ + botId: 'bot-route-model-profile', + channelId: 'bot-route-model-profile:local', + routeKey: 'model-freeze', + }); + + const created = await ensureBotRouteSession({ + routeId: route.id, + ownerDeviceId: 'device-a', + }); + + expect( + h.sqlite!.prepare(` + SELECT model, provider_id AS providerId, effort, + fast_mode AS fastMode, agent_kind AS agentKind + FROM sessions WHERE id = ? + `).get(created.sessionId), + ).toEqual({ + model: 'grok-4.5', + providerId: 'xai', + effort: 'max', + fastMode: 1, + agentKind: 'pi', + }); + }); + + it('repairs a foreign task pointer without archiving the other Bot task', async () => { + await invoke('local-db:bots:create', { + id: 'bot-2', + name: 'Research Bot', + capabilities: { harness: 'pi', model: 'grok-4.5', permissions: 'ask' }, + }); + await invoke('local-db:bots:create-canonical-session', { + botId: 'bot-2', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + const route = await upsertBotRoute({ + botId: 'bot-1', + channelId: 'bot-1:local', + routeKey: 'corrupt-pointer', + }); + h.sqlite!.prepare('UPDATE bot_routes SET current_session_id = ? WHERE id = ?').run( + 'session-1', + route.id, + ); + + const repaired = await ensureBotRouteSession({ + routeId: route.id, + ownerDeviceId: 'device-a', + }); + expect(repaired).toMatchObject({ sessionId: 'session-2', created: true }); + expect( + h.sqlite!.prepare('SELECT status FROM sessions WHERE id = ?').pluck().get('session-1'), + ).toBe('active'); + expect( + h + .sqlite!.prepare('SELECT role FROM bot_session_links WHERE session_id = ?') + .pluck() + .get('session-1'), + ).toBe('canonical'); + }); + + it('removes an unused dialogue workspace when the write transaction fails', async () => { + const route = await upsertBotRoute({ + botId: 'bot-1', + channelId: 'bot-1:local', + routeKey: 'transaction-cleanup', + }); + const baseTx = h.tx; + h.tx = async () => { + throw new Error('simulated transaction failure'); + }; + try { + await expect( + ensureBotRouteSession({ + routeId: route.id, + ownerDeviceId: 'device-a', + }), + ).rejects.toThrow('simulated transaction failure'); + expect(h.remove).toHaveBeenCalledWith('/tmp/cindy-bot-test/session-1', { + recursive: true, + force: true, + }); + } finally { + h.tx = baseTx; + } + }); + + it('resolves only the concrete IM account bound to a Channel', async () => { + await invoke('local-db:bots:create', { + id: 'bot-2', + name: 'Research Bot', + capabilities: { harness: 'pi', model: 'grok-4.5', permissions: 'ask' }, + }); + await invoke('local-db:bots:channel-upsert', { + botId: 'bot-1', + kind: 'telegram', + enabled: true, + config: { accountKey: 'telegram-account-a', ownership: 'local-adapter' }, + }); + await invoke('local-db:bots:channel-upsert', { + botId: 'bot-2', + kind: 'telegram', + enabled: true, + config: { accountKey: 'telegram-account-b', ownership: 'local-adapter' }, + }); + + await expect( + resolveOrCreateBotRoute({ + platform: 'telegram', + accountKey: 'telegram-account-a', + principalKey: '-1001', + }), + ).resolves.toMatchObject({ botId: 'bot-1' }); + await expect( + resolveOrCreateBotRoute({ + platform: 'telegram', + accountKey: 'telegram-account-b', + principalKey: '-1001', + }), + ).resolves.toMatchObject({ botId: 'bot-2' }); + await expect( + resolveBotRoute({ + platform: 'telegram', + accountKey: 'telegram-account-c', + principalKey: '-1001', + }), + ).resolves.toBeNull(); + }); + + it('rejects mounting the same concrete IM account on two Bots', async () => { + await invoke('local-db:bots:create', { + id: 'bot-2', + name: 'Research Bot', + capabilities: { harness: 'pi', model: 'grok-4.5', permissions: 'ask' }, + }); + const config = { accountKey: 'telegram-account-a', ownership: 'local-adapter' }; + await invoke('local-db:bots:channel-upsert', { + botId: 'bot-1', + kind: 'telegram', + enabled: true, + config, + }); + + await expect( + invoke('local-db:bots:channel-upsert', { + botId: 'bot-2', + kind: 'telegram', + enabled: true, + config, + }), + ).rejects.toThrow('这个 IM 账号已挂载到另一个 Bot'); + }); + }); +}); diff --git a/apps/desktop/src/main/localDb/ipc/__tests__/botProfileVersioning.test.ts b/apps/desktop/src/main/localDb/ipc/__tests__/botProfileVersioning.test.ts new file mode 100644 index 0000000000..d543965edd --- /dev/null +++ b/apps/desktop/src/main/localDb/ipc/__tests__/botProfileVersioning.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { + botProfileContentChanged, + mergeBotProfileCapabilities, +} from '../botProfileVersioning'; + +describe('Bot Profile versioning', () => { + it('creates a new version when only the SOUL identity changes', () => { + expect( + botProfileContentChanged({ + previousCapabilities: { skills: ['recipe'] }, + nextCapabilities: { skills: ['recipe'] }, + previousIdentitySource: 'A helpful cook', + nextIdentitySource: 'A playful pastry chef', + }), + ).toBe(true); + }); + + it('does not create a version for metadata-only updates', () => { + expect( + botProfileContentChanged({ + previousCapabilities: { skills: ['recipe'] }, + nextCapabilities: { skills: ['recipe'] }, + previousIdentitySource: 'A helpful cook', + nextIdentitySource: 'A helpful cook', + }), + ).toBe(false); + }); + + it('keeps capability updates and Skills from the same save', () => { + expect( + mergeBotProfileCapabilities({ + previous: { model: 'old-model', memory: true, skills: ['old-skill'] }, + capabilities: { model: 'new-model', memory: false }, + skills: [' new-skill ', 42, '', 'second-skill'], + hasSkills: true, + }), + ).toEqual({ + model: 'new-model', + memory: false, + skills: ['new-skill', 'second-skill'], + }); + }); +}); diff --git a/apps/desktop/src/main/localDb/ipc/__tests__/sessionsRestoreIfArchived.test.ts b/apps/desktop/src/main/localDb/ipc/__tests__/sessionsRestoreIfArchived.test.ts index 514117df28..1bd850a147 100644 --- a/apps/desktop/src/main/localDb/ipc/__tests__/sessionsRestoreIfArchived.test.ts +++ b/apps/desktop/src/main/localDb/ipc/__tests__/sessionsRestoreIfArchived.test.ts @@ -224,4 +224,12 @@ describe('local-db:sessions:restore-if-archived', () => { it('throws NOT_FOUND when the session no longer exists', async () => { await expect(restore('missing')).rejects.toThrow('[NOT_FOUND]'); }); + + it('does not restore Bot history through the ordinary task lifecycle', async () => { + h.sqlite!.prepare("UPDATE sessions SET source = 'bot' WHERE id = 'target'").run(); + + await expect(restore()).rejects.toThrow(/Bot task lifecycle/); + expect(readStatus()).toBe('archived'); + expect(h.tapWindowBroadcast).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/main/localDb/ipc/__tests__/sessionsUpdate.test.ts b/apps/desktop/src/main/localDb/ipc/__tests__/sessionsUpdate.test.ts index 18119b36e9..30ebd35ab7 100644 --- a/apps/desktop/src/main/localDb/ipc/__tests__/sessionsUpdate.test.ts +++ b/apps/desktop/src/main/localDb/ipc/__tests__/sessionsUpdate.test.ts @@ -142,6 +142,11 @@ function createDb(): void { id, working_dir, agent_kind, remote_host_id, workspace_kind, source, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, 'review', 1, 1) `).run('review-local', '/review/dir', 'codex', null, 'dialogue'); + sqlite.prepare(` + INSERT INTO sessions ( + id, working_dir, agent_kind, remote_host_id, workspace_kind, source, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'bot', 1, 1) + `).run('bot-local', '/bot/dir', 'pi', null, 'dialogue'); h.sqlite = sqlite; h.db = drizzle(sqlite, { schema: { messages, sessions } }); } @@ -194,6 +199,18 @@ describe('local-db:sessions:update handler wiring', () => { expect(persisted).toEqual({ effort: 'high', title: '审查记录' }); }); + it('keeps Bot metadata editable but rejects ordinary lifecycle writes', async () => { + await invokeUpdate('bot-local', { title: 'Release Bot' }); + await expect(invokeUpdate('bot-local', { status: 'archived' })).rejects.toThrow( + /Bot task lifecycle/, + ); + + const persisted = h + .sqlite!.prepare('SELECT title, status FROM sessions WHERE id = ?') + .get('bot-local') as { title: string; status: string }; + expect(persisted).toEqual({ title: 'Release Bot', status: 'active' }); + }); + it('persists and broadcasts title-only patches to device-link subscribers', async () => { await invokeUpdate('codex-local', { title: '排查远程标题同步' }); diff --git a/apps/desktop/src/main/localDb/ipc/botProfileVersioning.ts b/apps/desktop/src/main/localDb/ipc/botProfileVersioning.ts new file mode 100644 index 0000000000..e6472892a0 --- /dev/null +++ b/apps/desktop/src/main/localDb/ipc/botProfileVersioning.ts @@ -0,0 +1,32 @@ +export function botProfileContentChanged(input: { + previousCapabilities: Record; + nextCapabilities: Record; + previousIdentitySource: string; + nextIdentitySource: string; +}): boolean { + return ( + JSON.stringify(input.previousCapabilities) !== JSON.stringify(input.nextCapabilities) || + input.previousIdentitySource !== input.nextIdentitySource + ); +} + +export function mergeBotProfileCapabilities(input: { + previous: Record; + capabilities?: Record; + skills?: unknown; + hasSkills: boolean; +}): Record { + const next = input.capabilities + ? { ...input.previous, ...input.capabilities } + : { ...input.previous }; + if (input.hasSkills) { + next.skills = Array.isArray(input.skills) + ? input.skills + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, 100) + : []; + } + return next; +} diff --git a/apps/desktop/src/main/localDb/ipc/bots.ts b/apps/desktop/src/main/localDb/ipc/bots.ts new file mode 100644 index 0000000000..71f6ccce0f --- /dev/null +++ b/apps/desktop/src/main/localDb/ipc/bots.ts @@ -0,0 +1,1603 @@ +/** Cindy Bots 的 main-side 权威数据边界。 + * + * Bot profile / channel / Session 归属只在这里写入 SQLite;renderer 的 + * localStorage 只能作为旧版本迁移的临时来源,不能决定 canonical Session。 + */ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import { BrowserWindow, dialog, ipcMain } from 'electron'; +import type { OpenDialogOptions } from 'electron'; +import { and, desc, eq, inArray, isNull, ne } from 'drizzle-orm'; + +import { getDbClient, tryGetDbClient } from '../client/current'; +import type { BotsReplaceCanonicalSessionResult } from '../client/tx/types.js'; +import { + botChannels, + botAutomationLinks, + botAutomationRuns, + botDelegations, + botDeliveryOutbox, + botLifecycleEvents, + botProfileVersions, + botProfiles, + botProjectBindings, + botRoutes, + botRuntimeSnapshots, + botSessionLinks, + botWorkspaceAttachments, + botWorkspaceLeases, + sessions, +} from '../schema'; +import { assertTrustedAppRendererEvent } from '../../security/trustedAppRenderer.js'; +import { isDeviceLinkInvoke } from '../../device-link/invoke-context.js'; +import { requireString, throwIpcError } from '../../utils/ipcValidate.js'; +import { resolveBusinessSessionId } from '../../sessionIds.js'; +import { ensureProjectGitInitialized } from '../../git-snapshot/projectGitBootstrap.js'; +import { readGitSafetySettings } from '../../maker-host/git-safety-settings-store.js'; +import { ensureDialogueWorkspaceDir } from '../dialogueWorkspace.js'; +import { sessionCreateToRow, sessionToCamel } from '../mapper.js'; +import { botProfileContentChanged, mergeBotProfileCapabilities } from './botProfileVersioning.js'; +import { buildDefaultBotIdentity } from '../../../shared/botProfileDefaults.js'; +import { BOT_WORKSPACE_POLICIES, type BotWorkspacePolicy } from '../../../shared/botWorkspace.js'; +import { projectIdentityKey } from '../../../shared/projectKeys.js'; +import { normalizeWorkingDirForStorage } from '../../../shared/workingDir.js'; +import { BOT_ROUTE_STATUSES, type BotRouteStatus } from '../../../shared/botRoute.js'; +import { + botChannelMountIdentity, + sameBotChannelMountIdentity, +} from '../../../shared/botChannelRegistry.js'; +import { setBotRouteStatus, upsertBotRoute } from '../botRouteService.js'; +import { + applyBotImMigration, + listBotImMigrations, + planBotImMigration, + rollbackBotImMigration, +} from '../botImMigrationService.js'; +import { cancelBotDelegationsForParentIfReady } from '../../maker-ipc/botDelegationLifecycle.js'; +import { coordinateBotCanonicalReplacement } from '../../maker-ipc/botCanonicalReplacementCoordinator.js'; +import { searchConversations } from '../conversationSearch.js'; +import type { + BotHealthIssue, + BotHealthReport, + BotLifecycleEventView, +} from '../../../shared/botLifecycle.js'; +import { CINDY_BOT_BUNDLE_EXTENSION } from '../../../shared/botPortability.js'; +import { + exportBotBehaviorBundle, + importBotBehaviorBundle, +} from '../botPortabilityService.js'; + +type ChannelKind = + 'local' | 'telegram' | 'feishu' | 'slack' | 'discord' | 'wechat' | 'dingtalk' | 'wecom' | 'x'; +type BotRole = 'canonical' | 'route' | 'history'; + +const CHANNELS = new Set([ + 'local', + 'telegram', + 'feishu', + 'slack', + 'discord', + 'wechat', + 'dingtalk', + 'wecom', + 'x', +]); +const ROLES = new Set(['canonical', 'route', 'history']); +const WORKSPACE_POLICIES = new Set(BOT_WORKSPACE_POLICIES); +const MAX_TEXT = 4000; + +export interface CreateBotCanonicalSessionInput { + botId: string; + expectedCanonicalSessionId: string | null; + expectedProfileVersion: number; + /** Repair a dangling profile pointer only; never renew a task that still exists. */ + recoverMissingOnly?: boolean; +} + +type CreateBotCanonicalSessionResult = { + created: boolean; + canonicalSessionId: string; + session: ReturnType; +}; + +let createBotCanonicalSessionImpl: + | ((input: CreateBotCanonicalSessionInput) => Promise) + | null = null; + +/** Main-side canonical creator shared by Renew, restore and repair. */ +export async function createBotCanonicalSession( + input: CreateBotCanonicalSessionInput, +): Promise { + if (!createBotCanonicalSessionImpl) { + throwIpcError('PRECONDITION_FAILED', 'Bot 数据服务尚未初始化'); + } + return createBotCanonicalSessionImpl(input); +} + +async function cancelBotDelegationChildren(sessionId: string, reason: string): Promise { + await cancelBotDelegationsForParentIfReady(sessionId, reason).catch(() => undefined); +} + +function botSessionAgentKind(config: Record): 'cc' | 'codex' | 'pi' { + return config.harness === 'codex' ? 'codex' : config.harness === 'pi' ? 'pi' : 'cc'; +} + +function botSessionPermissionMode(config: Record): 'ask' | 'bypassPermissions' { + return config.permissions === 'trusted' ? 'bypassPermissions' : 'ask'; +} + +function readText(value: unknown, field: string, max = MAX_TEXT, required = false): string { + if (typeof value !== 'string') { + if (!required && (value === undefined || value === null)) return ''; + throwIpcError('INVALID_PARAMS', `${field} 必须是字符串`); + } + const text = value.trim(); + if (required && !text) throwIpcError('INVALID_PARAMS', `${field} 不能为空`); + if (text.length > max) throwIpcError('INVALID_PARAMS', `${field} 超过长度上限 ${max}`); + return text; +} + +function parseJson(value: string, fallback: Record = {}): Record { + try { + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : fallback; + } catch { + return fallback; + } +} + +function safeJson(value: unknown): string { + try { + return JSON.stringify(value ?? {}); + } catch { + return '{}'; + } +} + +function readStringList(value: unknown, field: string, maxItems = 100): string[] { + if (value === undefined || value === null) return []; + if (!Array.isArray(value)) throwIpcError('INVALID_PARAMS', `${field} 必须是字符串数组`); + const out = value.map((item) => readText(item, field, 4000, true)); + if (out.length > maxItems) throwIpcError('INVALID_PARAMS', `${field} 超过数量上限 ${maxItems}`); + return [...new Set(out)]; +} + +async function readProfile(db: ReturnType['drizzle'], botId: string) { + const [profile] = await db.select().from(botProfiles).where(eq(botProfiles.id, botId)).limit(1); + if (!profile) throwIpcError('NOT_FOUND', 'Bot 不存在'); + const channels = await db.select().from(botChannels).where(eq(botChannels.botId, botId)); + const links = await db + .select() + .from(botSessionLinks) + .where(eq(botSessionLinks.botId, botId)) + .orderBy(desc(botSessionLinks.createdAt)); + const sessionRows = links.length + ? await db + .select() + .from(sessions) + .where( + inArray( + sessions.id, + links.map((link) => link.sessionId), + ), + ) + : []; + const byId = new Map(sessionRows.map((row) => [row.id, row])); + const runtimeRows = links.length + ? await db + .select() + .from(botRuntimeSnapshots) + .where( + inArray( + botRuntimeSnapshots.sessionId, + links.map((link) => link.sessionId), + ), + ) + .orderBy(desc(botRuntimeSnapshots.preparedAt), desc(botRuntimeSnapshots.appliedAt)) + : []; + const runtimeBySession = new Map(); + for (const row of runtimeRows) { + if (!runtimeBySession.has(row.sessionId)) runtimeBySession.set(row.sessionId, row); + } + const [version] = await db + .select() + .from(botProfileVersions) + .where( + and( + eq(botProfileVersions.botId, botId), + eq(botProfileVersions.version, profile.currentVersion), + ), + ) + .limit(1); + const projectRows = await db + .select() + .from(botProjectBindings) + .where(eq(botProjectBindings.botId, botId)) + .orderBy(desc(botProjectBindings.updatedAt)); + const leaseRows = await db + .select() + .from(botWorkspaceLeases) + .where(eq(botWorkspaceLeases.botId, botId)) + .orderBy(desc(botWorkspaceLeases.updatedAt)); + const routeRows = await db + .select() + .from(botRoutes) + .where(eq(botRoutes.botId, botId)) + .orderBy(desc(botRoutes.updatedAt)); + const config = parseJson(version?.capabilitiesJson ?? '{}'); + return { + id: profile.id, + name: profile.displayName, + description: profile.description, + identitySource: version?.identitySource ?? '', + userContextSource: typeof config.userContextSource === 'string' ? config.userContextSource : '', + avatar: profile.avatar, + avatarColor: profile.avatarColor, + enabled: profile.status === 'active', + status: profile.status, + currentVersion: profile.currentVersion, + canonicalSessionId: profile.canonicalSessionId ?? undefined, + createdAt: profile.createdAt, + updatedAt: profile.updatedAt, + skills: Array.isArray(config.skills) + ? config.skills.filter((item): item is string => typeof item === 'string') + : [], + capabilities: { + model: typeof config.model === 'string' ? config.model : 'claude-sonnet-4-6', + providerId: + typeof config.providerId === 'string' + ? config.providerId + : config.providerId === null + ? null + : undefined, + effort: typeof config.effort === 'string' ? config.effort : '', + fastMode: config.fastMode === true, + harness: config.harness === 'codex' || config.harness === 'pi' ? config.harness : 'claude', + skillMode: + config.skillMode === 'allowlist' + ? 'allowlist' + : config.skillMode === 'inherit' + ? 'inherit' + : Array.isArray(config.skills) && config.skills.length > 0 + ? 'allowlist' + : 'inherit', + toolsetMode: + config.toolsetMode === 'allowlist' + ? 'allowlist' + : config.toolsetMode === 'inherit' + ? 'inherit' + : Array.isArray(config.toolsets) && config.toolsets.length > 0 + ? 'allowlist' + : 'inherit', + toolsets: Array.isArray(config.toolsets) + ? config.toolsets.filter((item): item is string => typeof item === 'string') + : [], + mcpMode: + config.mcpMode === 'allowlist' + ? 'allowlist' + : config.mcpMode === 'inherit' + ? 'inherit' + : Array.isArray(config.mcpServers) && config.mcpServers.length > 0 + ? 'allowlist' + : 'inherit', + mcpServers: Array.isArray(config.mcpServers) + ? config.mcpServers.filter((item): item is string => typeof item === 'string') + : [], + memory: config.memory !== false, + automation: config.automation === true, + permissions: config.permissions === 'trusted' ? 'trusted' : 'ask', + }, + channels: channels.map((channel) => ({ + id: channel.id, + kind: channel.kind as ChannelKind, + enabled: !!channel.enabled, + config: parseJson(channel.configJson), + })), + projectBindings: projectRows.map((binding) => ({ + id: binding.id, + projectKey: binding.projectKey, + workingDir: binding.workingDir, + remoteHostId: binding.remoteHostId ?? undefined, + defaultBranch: binding.defaultBranch ?? undefined, + workspacePolicy: binding.workspacePolicy, + isDefault: binding.isDefault, + allowedPaths: readStringListFromJson(binding.allowedPathsJson), + status: binding.status, + createdAt: binding.createdAt, + updatedAt: binding.updatedAt, + })), + workspaceLeases: leaseRows.map((lease) => ({ + id: lease.id, + projectBindingId: lease.projectBindingId, + leaseKey: lease.leaseKey, + anchorSessionId: lease.anchorSessionId ?? undefined, + worktreePath: lease.worktreePath ?? undefined, + baseRepo: lease.baseRepo, + branch: lease.branch ?? undefined, + sourceBranch: lease.sourceBranch ?? undefined, + remoteHostId: lease.remoteHostId ?? undefined, + generation: lease.generation, + status: lease.status, + lastHeartbeatAt: lease.lastHeartbeatAt ?? undefined, + createdAt: lease.createdAt, + updatedAt: lease.updatedAt, + releasedAt: lease.releasedAt ?? undefined, + })), + routes: routeRows + .filter((route) => parseJson(route.capabilitiesJson).mountOnly !== true) + .map((route) => ({ + id: route.id, + channelId: route.channelId, + routeKey: route.routeKey, + principalKey: route.principalKey, + scopeKey: route.scopeKey, + threadKey: route.threadKey ?? undefined, + currentSessionId: route.currentSessionId ?? undefined, + projectBindingId: route.projectBindingId ?? undefined, + capabilities: parseJson(route.capabilitiesJson), + ownerDeviceId: route.ownerDeviceId ?? undefined, + ownerGeneration: route.ownerGeneration, + status: route.status, + lastActivityAt: route.lastActivityAt ?? undefined, + createdAt: route.createdAt, + updatedAt: route.updatedAt, + })), + channel: (channels.find((channel) => channel.kind === 'local')?.kind ?? + channels[0]?.kind ?? + 'local') as ChannelKind, + sessions: links.flatMap((link) => { + const row = byId.get(link.sessionId); + if (!row) return []; + return [ + { + id: row.id, + title: row.title, + kind: link.role === 'canonical' ? 'chat' : link.role === 'route' ? 'route' : 'history', + channel: (channels.find((channel) => channel.id === link.channelId)?.kind ?? + 'local') as ChannelKind, + updatedAt: row.updatedAt, + status: row.status, + role: link.role, + profileVersion: link.profileVersion, + routeKey: link.routeKey ?? undefined, + runtimeSnapshot: runtimeBySession.has(row.id) + ? (() => { + const runtime = runtimeBySession.get(row.id)!; + return { + profileVersion: runtime.profileVersion, + agentKind: runtime.agentKind, + status: runtime.status, + preparedAt: runtime.preparedAt || runtime.appliedAt || 0, + appliedAt: runtime.appliedAt ?? undefined, + failedAt: runtime.failedAt ?? undefined, + failure: runtime.failureJson ? parseJson(runtime.failureJson) : undefined, + configured: parseJson(runtime.configuredJson), + resolved: parseJson(runtime.resolvedJson), + }; + })() + : undefined, + }, + ]; + }), + }; +} + +/** + * Device-link only needs enough Bot metadata to render the Mobile directory and + * open the canonical task. Keep this projection main-side so profile prompts, + * channel credentials, project paths and runtime state never cross the wire. + */ +async function readRemoteBotProfile( + db: ReturnType['drizzle'], + botId: string, +) { + const [profile] = await db + .select({ + id: botProfiles.id, + name: botProfiles.displayName, + description: botProfiles.description, + avatar: botProfiles.avatar, + avatarColor: botProfiles.avatarColor, + status: botProfiles.status, + currentVersion: botProfiles.currentVersion, + canonicalSessionId: botProfiles.canonicalSessionId, + }) + .from(botProfiles) + .where(eq(botProfiles.id, botId)) + .limit(1); + if (!profile) throwIpcError('NOT_FOUND', 'Bot 不存在'); + const channels = await db + .select({ kind: botChannels.kind, enabled: botChannels.enabled }) + .from(botChannels) + .where(eq(botChannels.botId, botId)); + return { + ...profile, + canonicalSessionId: profile.canonicalSessionId ?? undefined, + channels: channels.map((channel) => ({ + kind: channel.kind, + enabled: !!channel.enabled, + })), + }; +} + +function readStringListFromJson(value: string): string[] { + try { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === 'string') + : []; + } catch { + return []; + } +} + +function normalizeAllowedPaths( + value: unknown, + workingDir: string, + remoteHostId: string | null, +): string[] { + const paths = readStringList(value, 'allowedPaths'); + if (remoteHostId) return paths.map((item) => normalizeWorkingDirForStorage(item) ?? item); + const root = path.resolve(workingDir); + return paths.map((item) => { + const candidate = path.resolve(item); + const relative = path.relative(root, candidate); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throwIpcError('INVALID_PARAMS', 'allowedPaths 必须位于绑定项目目录内'); + } + return normalizeWorkingDirForStorage(candidate) ?? candidate; + }); +} + +async function fileExists(candidate: string): Promise { + try { + await fs.access(candidate); + return true; + } catch { + return false; + } +} + +export function registerBotIpc(): void { + ipcMain.handle('local-db:bots:list', async (event) => { + const remote = isDeviceLinkInvoke(); + if (!remote) assertTrustedAppRendererEvent(event); + const client = tryGetDbClient(); + if (!client) return []; + const db = client.drizzle; + const profiles = await db + .select({ id: botProfiles.id }) + .from(botProfiles) + .orderBy(desc(botProfiles.updatedAt)); + return Promise.all( + profiles.map(({ id }) => remote ? readRemoteBotProfile(db, id) : readProfile(db, id)), + ); + }); + + ipcMain.handle('local-db:bots:get', async (event, rawId: unknown) => { + const remote = isDeviceLinkInvoke(); + if (!remote) assertTrustedAppRendererEvent(event); + const db = getDbClient().drizzle; + const botId = requireString(rawId, 'botId'); + return remote ? readRemoteBotProfile(db, botId) : readProfile(db, botId); + }); + + ipcMain.handle('local-db:bots:export', async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = raw && typeof raw === 'object' && !Array.isArray(raw) + ? (raw as Record) + : {}; + const botId = readText(body.botId, 'botId', 128, true); + const db = getDbClient().drizzle; + const [profile] = await db + .select({ displayName: botProfiles.displayName }) + .from(botProfiles) + .where(eq(botProfiles.id, botId)) + .limit(1); + if (!profile) throwIpcError('NOT_FOUND', 'Bot 不存在'); + const safeName = profile.displayName + .normalize('NFKC') + .replace(/[\\/:*?"<>|\u0000-\u001f]/g, '-') + .replace(/\s+/g, '-') + .slice(0, 80) || 'cindy-bot'; + const owner = BrowserWindow.fromWebContents(event.sender); + const picked = owner + ? await dialog.showSaveDialog(owner, { + title: '导出 Bot 配置', + defaultPath: `${safeName}${CINDY_BOT_BUNDLE_EXTENSION}`, + filters: [{ name: 'Cindy Bot', extensions: ['cindybot'] }], + }) + : await dialog.showSaveDialog({ + title: '导出 Bot 配置', + defaultPath: `${safeName}${CINDY_BOT_BUNDLE_EXTENSION}`, + filters: [{ name: 'Cindy Bot', extensions: ['cindybot'] }], + }); + if (picked.canceled || !picked.filePath) return { canceled: true }; + const outputPath = picked.filePath.endsWith(CINDY_BOT_BUNDLE_EXTENSION) + ? picked.filePath + : `${picked.filePath}${CINDY_BOT_BUNDLE_EXTENSION}`; + return { canceled: false, ...(await exportBotBehaviorBundle(botId, outputPath)) }; + }); + + ipcMain.handle('local-db:bots:import', async (event) => { + assertTrustedAppRendererEvent(event); + const owner = BrowserWindow.fromWebContents(event.sender); + const options: OpenDialogOptions = { + title: '导入 Bot 配置', + properties: ['openFile'], + filters: [{ name: 'Cindy Bot', extensions: ['cindybot'] }], + }; + const picked = owner + ? await dialog.showOpenDialog(owner, options) + : await dialog.showOpenDialog(options); + if (picked.canceled || !picked.filePaths[0]) return { canceled: true }; + return importBotBehaviorBundle(picked.filePaths[0]); + }); + + ipcMain.handle('local-db:bots:health', async (event, rawBotId: unknown) => { + assertTrustedAppRendererEvent(event); + const botId = readText(rawBotId, 'botId', 128, true); + const db = getDbClient().drizzle; + const [profile] = await db.select().from(botProfiles).where(eq(botProfiles.id, botId)).limit(1); + if (!profile) throwIpcError('NOT_FOUND', 'Bot 不存在'); + const canonicalSessionId = profile.canonicalSessionId ?? null; + const [canonicalSession, canonicalLink, runtimeSnapshots, routes, automations, deliveries, leases] = + await Promise.all([ + canonicalSessionId + ? db.select().from(sessions).where(eq(sessions.id, canonicalSessionId)).limit(1) + : Promise.resolve([]), + canonicalSessionId + ? db + .select() + .from(botSessionLinks) + .where(eq(botSessionLinks.sessionId, canonicalSessionId)) + .limit(1) + : Promise.resolve([]), + canonicalSessionId + ? db + .select() + .from(botRuntimeSnapshots) + .where(eq(botRuntimeSnapshots.sessionId, canonicalSessionId)) + .orderBy(desc(botRuntimeSnapshots.preparedAt)) + .limit(1) + : Promise.resolve([]), + db.select({ status: botRoutes.status }).from(botRoutes).where(eq(botRoutes.botId, botId)), + db + .select({ status: botAutomationLinks.status }) + .from(botAutomationLinks) + .where(eq(botAutomationLinks.botId, botId)), + db + .select({ status: botDeliveryOutbox.status }) + .from(botDeliveryOutbox) + .where(eq(botDeliveryOutbox.botId, botId)), + db + .select({ status: botWorkspaceLeases.status }) + .from(botWorkspaceLeases) + .where(eq(botWorkspaceLeases.botId, botId)), + ]); + + const sessionRow = canonicalSession[0]; + const linkRow = canonicalLink[0]; + const runtime = runtimeSnapshots[0]; + const activeRoutes = routes.filter((row) => row.status !== 'archived'); + const activeAutomations = automations.filter((row) => row.status !== 'archived'); + const activeLeases = leases.filter((row) => row.status !== 'released'); + const recoveringRoutes = activeRoutes.filter((row) => row.status === 'recovering').length; + const errorRoutes = activeRoutes.filter((row) => row.status === 'error').length; + const errorAutomations = activeAutomations.filter((row) => row.status === 'error').length; + const failedDeliveries = deliveries.filter((row) => row.status === 'failed').length; + const deadLetterDeliveries = deliveries.filter((row) => row.status === 'dead-letter').length; + const errorWorkspaceLeases = activeLeases.filter((row) => row.status === 'error').length; + const issues: BotHealthIssue[] = []; + + if (profile.status === 'error') issues.push({ code: 'profile-error' }); + if (profile.status === 'deleting') issues.push({ code: 'lifecycle-incomplete' }); + if (profile.status === 'active' && !canonicalSessionId) issues.push({ code: 'missing-canonical' }); + if (canonicalSessionId && !sessionRow) issues.push({ code: 'canonical-session-missing' }); + if (sessionRow?.status === 'deleted') issues.push({ code: 'canonical-session-deleted' }); + if (canonicalSessionId && !linkRow) issues.push({ code: 'canonical-link-missing' }); + if ( + linkRow && + (linkRow.botId !== botId || linkRow.role !== 'canonical') + ) { + issues.push({ code: 'canonical-link-mismatch' }); + } + if (linkRow && linkRow.profileVersion < profile.currentVersion) { + issues.push({ code: 'profile-update-pending' }); + } + if (runtime?.status === 'degraded') issues.push({ code: 'runtime-degraded' }); + if (runtime?.status === 'failed') issues.push({ code: 'runtime-failed' }); + if (recoveringRoutes > 0) issues.push({ code: 'routes-recovering', count: recoveringRoutes }); + if (errorRoutes > 0) issues.push({ code: 'routes-error', count: errorRoutes }); + if (errorAutomations > 0) issues.push({ code: 'automation-error', count: errorAutomations }); + if (failedDeliveries > 0) issues.push({ code: 'delivery-failed', count: failedDeliveries }); + if (deadLetterDeliveries > 0) { + issues.push({ code: 'delivery-dead-letter', count: deadLetterDeliveries }); + } + if (errorWorkspaceLeases > 0) { + issues.push({ code: 'workspace-error', count: errorWorkspaceLeases }); + } + + const recoveringCodes = new Set([ + 'missing-canonical', + 'canonical-session-missing', + 'canonical-session-deleted', + 'routes-recovering', + ]); + const status: BotHealthReport['status'] = + profile.status === 'paused' || profile.status === 'archived' + ? 'paused' + : issues.some((issue) => recoveringCodes.has(issue.code)) + ? 'recovering' + : issues.length > 0 + ? 'attention' + : 'healthy'; + const report: BotHealthReport = { + botId, + status, + checkedAt: Date.now(), + canonical: { + sessionId: canonicalSessionId, + sessionStatus: canonicalSessionId + ? sessionRow?.status ?? 'missing' + : null, + linked: !!linkRow && linkRow.botId === botId && linkRow.role === 'canonical', + profileVersion: linkRow?.profileVersion ?? null, + runtimeStatus: runtime?.status ?? 'not-started', + }, + counts: { + routes: activeRoutes.length, + recoveringRoutes, + errorRoutes, + automations: activeAutomations.length, + errorAutomations, + deliveries: deliveries.length, + failedDeliveries, + deadLetterDeliveries, + workspaceLeases: activeLeases.length, + errorWorkspaceLeases, + }, + issues, + }; + return report; + }); + + ipcMain.handle('local-db:bots:lifecycle-events', async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = raw && typeof raw === 'object' && !Array.isArray(raw) + ? (raw as Record) + : {}; + const botId = readText(body.botId, 'botId', 128, true); + const limit = typeof body.limit === 'number' && Number.isFinite(body.limit) + ? Math.max(1, Math.min(200, Math.floor(body.limit))) + : 50; + const rows = await getDbClient().drizzle + .select() + .from(botLifecycleEvents) + .where(eq(botLifecycleEvents.botId, botId)) + .orderBy(desc(botLifecycleEvents.createdAt)) + .limit(limit); + return rows.map((row): BotLifecycleEventView => ({ + id: row.id, + botId: row.botId, + sessionId: row.sessionId ?? null, + eventType: row.eventType, + payload: parseJson(row.payloadJson), + createdAt: row.createdAt, + })); + }); + + ipcMain.handle('local-db:bots:search-history', async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = raw && typeof raw === 'object' && !Array.isArray(raw) + ? (raw as Record) + : {}; + const botId = readText(body.botId, 'botId', 128, true); + const query = readText(body.query, 'query', 500, true); + const limit = typeof body.limit === 'number' && Number.isFinite(body.limit) + ? Math.max(1, Math.min(50, Math.floor(body.limit))) + : 20; + const db = getDbClient().drizzle; + const [profile] = await db + .select({ id: botProfiles.id }) + .from(botProfiles) + .where(eq(botProfiles.id, botId)) + .limit(1); + if (!profile) throwIpcError('NOT_FOUND', 'Bot 不存在'); + const links = await db + .select({ sessionId: botSessionLinks.sessionId }) + .from(botSessionLinks) + .where(eq(botSessionLinks.botId, botId)); + return searchConversations( + { + query, + limit, + semanticMode: 'hybrid', + filters: { + status: 'all', + sessionIds: links.map((row) => row.sessionId), + }, + }, + // The Bot-owned Session id set above is authoritative. Passing null here + // allows migrated IM history to retain its original source while keeping + // the renderer unable to widen the search scope. + { sessionSources: null }, + ); + }); + + ipcMain.handle('local-db:bots:create', async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = + raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; + const name = readText(body.name ?? body.displayName, 'name', 200, true); + const description = readText(body.description, 'description'); + const id = + readText(body.id, 'id', 128) || `bot_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const avatar = readText(body.avatar, 'avatar', 16) || '🤖'; + const avatarColor = readText(body.avatarColor, 'avatarColor', 32) || 'violet'; + const identitySource = + readText(body.identitySource, 'identitySource', 12000) || buildDefaultBotIdentity(name); + const skills = Array.isArray(body.skills) + ? body.skills.filter((item): item is string => typeof item === 'string').slice(0, 100) + : []; + const capabilities = + body.capabilities && + typeof body.capabilities === 'object' && + !Array.isArray(body.capabilities) + ? body.capabilities + : {}; + const now = Date.now(); + const client = getDbClient(); + const db = client.drizzle; + await client.tx('bots.createProfile', { + id, + displayName: name, + description, + avatar, + avatarColor, + identitySource, + capabilitiesJson: safeJson({ ...capabilities, skills }), + now, + }); + return readProfile(db, id); + }); + + ipcMain.handle('local-db:bots:migrate-legacy', async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = + raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; + const id = readText(body.id, 'id', 128, true); + const name = readText(body.name ?? body.displayName, 'name', 200, true); + const description = readText(body.description, 'description'); + const avatar = readText(body.avatar, 'avatar', 16) || '🤖'; + const avatarColor = readText(body.avatarColor, 'avatarColor', 32) || 'violet'; + const identitySource = + readText(body.identitySource, 'identitySource', 12000) || buildDefaultBotIdentity(name); + const capabilities = + body.capabilities && + typeof body.capabilities === 'object' && + !Array.isArray(body.capabilities) + ? (body.capabilities as Record) + : {}; + const skills = Array.isArray(body.skills) + ? body.skills.filter((item): item is string => typeof item === 'string').slice(0, 100) + : []; + const channelKind = readText(body.channel, 'channel', 32) as ChannelKind; + if (channelKind && !CHANNELS.has(channelKind)) { + throwIpcError('INVALID_PARAMS', `invalid channel kind: ${channelKind}`); + } + const legacySessionId = readText(body.canonicalSessionId, 'canonicalSessionId', 128); + const db = getDbClient().drizzle; + const now = Date.now(); + await getDbClient().tx('bots.migrateLegacyProfile', { + id, + displayName: name, + description, + avatar, + avatarColor, + identitySource, + capabilitiesJson: safeJson({ ...capabilities, skills }), + channelKind: channelKind || null, + legacySessionId: legacySessionId || null, + now, + }); + return readProfile(db, id); + }); + + ipcMain.handle('local-db:bots:update', async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = + raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; + const id = readText(body.id, 'botId', 128, true); + const db = getDbClient().drizzle; + const [current] = await db.select().from(botProfiles).where(eq(botProfiles.id, id)).limit(1); + if (!current) throwIpcError('NOT_FOUND', 'Bot 不存在'); + const now = Date.now(); + const patch: Partial = { updatedAt: now }; + if (body.name !== undefined || body.displayName !== undefined) + patch.displayName = readText(body.name ?? body.displayName, 'name', 200, true); + if (body.description !== undefined) + patch.description = readText(body.description, 'description'); + if (body.avatar !== undefined) patch.avatar = readText(body.avatar, 'avatar', 16, true); + if (body.avatarColor !== undefined) + patch.avatarColor = readText(body.avatarColor, 'avatarColor', 32, true); + if (body.enabled !== undefined) { + if (typeof body.enabled !== 'boolean') + throwIpcError('INVALID_PARAMS', 'enabled 必须是 boolean'); + patch.status = body.enabled ? 'active' : 'paused'; + } + const [version] = await db + .select() + .from(botProfileVersions) + .where( + and( + eq(botProfileVersions.botId, id), + eq(botProfileVersions.version, current.currentVersion), + ), + ) + .limit(1); + const previous = parseJson(version?.capabilitiesJson ?? '{}'); + const nextConfig = mergeBotProfileCapabilities({ + previous, + capabilities: + body.capabilities && + typeof body.capabilities === 'object' && + !Array.isArray(body.capabilities) + ? (body.capabilities as Record) + : undefined, + skills: body.skills, + hasSkills: Object.prototype.hasOwnProperty.call(body, 'skills'), + }); + if (Object.prototype.hasOwnProperty.call(body, 'userContextSource')) { + nextConfig.userContextSource = readText(body.userContextSource, 'userContextSource', 12000); + } + const nextIdentitySource = + body.identitySource !== undefined + ? readText(body.identitySource, 'identitySource', 12000) || + buildDefaultBotIdentity(patch.displayName ?? current.displayName) + : (version?.identitySource ?? ''); + const profileContentChanged = botProfileContentChanged({ + previousCapabilities: previous, + nextCapabilities: nextConfig, + previousIdentitySource: version?.identitySource ?? '', + nextIdentitySource, + }); + await getDbClient().tx('bots.updateProfile', { + id, + ...(patch.displayName !== undefined ? { displayName: patch.displayName } : {}), + ...(patch.description !== undefined ? { description: patch.description } : {}), + ...(patch.avatar !== undefined ? { avatar: patch.avatar } : {}), + ...(patch.avatarColor !== undefined ? { avatarColor: patch.avatarColor } : {}), + ...(patch.status !== undefined ? { status: patch.status } : {}), + identitySource: nextIdentitySource, + capabilitiesJson: safeJson(nextConfig), + profileContentChanged, + expectedCurrentVersion: current.currentVersion, + now, + }); + return readProfile(db, id); + }); + + ipcMain.handle('local-db:bots:channel-upsert', async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = + raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; + const botId = readText(body.botId, 'botId', 128, true); + const kind = readText(body.kind, 'kind', 32, true) as ChannelKind; + if (!CHANNELS.has(kind)) throwIpcError('INVALID_PARAMS', `invalid channel kind: ${kind}`); + const id = readText(body.id, 'channelId', 256) || `${botId}:${kind}`; + const enabled = body.enabled === undefined ? true : body.enabled; + if (typeof enabled !== 'boolean') throwIpcError('INVALID_PARAMS', 'enabled 必须是 boolean'); + const now = Date.now(); + const db = getDbClient().drizzle; + await getDbClient().tx('bots.upsertChannel', { + id, + botId, + kind, + enabled, + configJson: + body.config && typeof body.config === 'object' && !Array.isArray(body.config) + ? safeJson(body.config) + : body.config === undefined + ? null + : '{}', + now, + }); + // Mounting an IM account does not create an account-wide Route task. + // The first message in each concrete DM/group/thread lane creates its own + // Route lazily in botRouteService, preserving independent context/history. + return readProfile(db, botId); + }); + + ipcMain.handle('local-db:bots:im-migration-plan', async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = + raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; + return planBotImMigration({ + botId: readText(body.botId, 'botId', 128, true), + connectionId: readText(body.connectionId, 'connectionId', 512, true), + }); + }); + + ipcMain.handle('local-db:bots:im-migration-apply', async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = + raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; + return applyBotImMigration({ + botId: readText(body.botId, 'botId', 128, true), + connectionId: readText(body.connectionId, 'connectionId', 512, true), + planHash: readText(body.planHash, 'planHash', 128, true), + requestId: readText(body.requestId, 'requestId', 128, true), + }); + }); + + ipcMain.handle('local-db:bots:im-migrations-list', async (event, rawBotId: unknown) => { + assertTrustedAppRendererEvent(event); + return listBotImMigrations(readText(rawBotId, 'botId', 128, true)); + }); + + ipcMain.handle('local-db:bots:im-migration-rollback', async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = + raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; + return rollbackBotImMigration(readText(body.migrationId, 'migrationId', 128, true)); + }); + + ipcMain.handle('local-db:bots:route-upsert', async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = + raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; + const botId = readText(body.botId, 'botId', 128, true); + const capabilities = + body.capabilities && + typeof body.capabilities === 'object' && + !Array.isArray(body.capabilities) + ? (body.capabilities as Record) + : {}; + await upsertBotRoute({ + id: readText(body.id, 'routeId', 128) || undefined, + botId, + channelId: readText(body.channelId, 'channelId', 128, true), + routeKey: readText(body.routeKey, 'routeKey', 1_000, true), + principalKey: readText(body.principalKey, 'principalKey', 1_000) || undefined, + scopeKey: readText(body.scopeKey, 'scopeKey', 1_000) || undefined, + threadKey: readText(body.threadKey, 'threadKey', 1_000) || undefined, + projectBindingId: readText(body.projectBindingId, 'projectBindingId', 128) || undefined, + capabilities, + }); + return readProfile(getDbClient().drizzle, botId); + }); + + ipcMain.handle('local-db:bots:route-set-status', async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = + raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; + const routeId = readText(body.routeId ?? body.id, 'routeId', 128, true); + const status = readText(body.status, 'status', 32, true) as BotRouteStatus; + if ( + !BOT_ROUTE_STATUSES.includes(status) || + (status !== 'paused' && status !== 'offline' && status !== 'archived') + ) { + throwIpcError('INVALID_PARAMS', 'renderer 只能暂停、恢复或归档 Bot Route'); + } + const route = await setBotRouteStatus(routeId, status); + return readProfile(getDbClient().drizzle, route.botId); + }); + + ipcMain.handle('local-db:bots:project-binding-upsert', async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = + raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; + const botId = readText(body.botId, 'botId', 128, true); + const workingDirRaw = readText(body.workingDir, 'workingDir', 4000, true); + const workingDir = normalizeWorkingDirForStorage(workingDirRaw); + if (!workingDir) throwIpcError('INVALID_PARAMS', 'workingDir 无效'); + const remoteHostId = readText(body.remoteHostId, 'remoteHostId', 256) || null; + const workspacePolicy = readText( + body.workspacePolicy ?? 'none', + 'workspacePolicy', + 32, + true, + ) as BotWorkspacePolicy; + if (!WORKSPACE_POLICIES.has(workspacePolicy)) { + throwIpcError('INVALID_PARAMS', `invalid workspace policy: ${workspacePolicy}`); + } + const defaultBranch = readText(body.defaultBranch, 'defaultBranch', 512) || null; + const isDefault = body.isDefault === undefined ? false : body.isDefault; + if (typeof isDefault !== 'boolean') throwIpcError('INVALID_PARAMS', 'isDefault 必须是 boolean'); + const allowedPaths = normalizeAllowedPaths(body.allowedPaths, workingDir, remoteHostId); + const projectKey = projectIdentityKey( + remoteHostId ? 'remote' : 'local', + workingDir, + remoteHostId, + ); + const id = readText(body.id, 'projectBindingId', 128) || randomUUID(); + const now = Date.now(); + const db = getDbClient().drizzle; + await getDbClient().tx('bots.upsertProjectBinding', { + id, + botId, + projectKey, + workingDir, + remoteHostId, + defaultBranch, + workspacePolicy, + isDefault, + allowedPathsJson: JSON.stringify(allowedPaths), + now, + eventId: `${botId}:project-binding:${now}`, + }); + return readProfile(db, botId); + }); + + ipcMain.handle('local-db:bots:project-binding-archive', async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = + raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; + const botId = readText(body.botId, 'botId', 128, true); + const id = readText(body.id, 'projectBindingId', 128, true); + const db = getDbClient().drizzle; + const activeLeases = await db + .select({ id: botWorkspaceLeases.id }) + .from(botWorkspaceLeases) + .where( + and( + eq(botWorkspaceLeases.botId, botId), + eq(botWorkspaceLeases.projectBindingId, id), + inArray(botWorkspaceLeases.status, ['acquiring', 'active', 'releasing', 'error']), + ), + ) + .limit(1); + if (activeLeases.length > 0) { + throwIpcError('PRECONDITION_FAILED', '项目仍有 active Bot workspace lease'); + } + const now = Date.now(); + await db + .update(botProjectBindings) + .set({ status: 'archived', isDefault: false, updatedAt: now }) + .where(and(eq(botProjectBindings.id, id), eq(botProjectBindings.botId, botId))); + return readProfile(db, botId); + }); + + ipcMain.handle('local-db:bots:workspace-lease-release', async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = + raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; + const botId = readText(body.botId, 'botId', 128, true); + const leaseId = readText(body.leaseId, 'leaseId', 128, true); + if (!Number.isInteger(body.expectedGeneration) || Number(body.expectedGeneration) < 1) { + throwIpcError('INVALID_PARAMS', 'expectedGeneration 必须是正整数'); + } + const expectedGeneration = Number(body.expectedGeneration); + const db = getDbClient().drizzle; + const [lease] = await db + .select() + .from(botWorkspaceLeases) + .where(and(eq(botWorkspaceLeases.id, leaseId), eq(botWorkspaceLeases.botId, botId))) + .limit(1); + if (!lease) throwIpcError('NOT_FOUND', 'Bot workspace lease 不存在'); + if (lease.generation !== expectedGeneration) { + throwIpcError('PRECONDITION_FAILED', 'Bot workspace lease 已被另一处操作更新'); + } + if (lease.status === 'released') return readProfile(db, botId); + if (lease.status !== 'active' && lease.status !== 'error' && lease.status !== 'retained') { + throwIpcError('PRECONDITION_FAILED', `Bot workspace lease 当前状态为 ${lease.status}`); + } + + const attachments = await db + .select() + .from(botWorkspaceAttachments) + .where( + and( + eq(botWorkspaceAttachments.leaseId, leaseId), + isNull(botWorkspaceAttachments.detachedAt), + ), + ); + const attachedSessionIds = attachments.map((attachment) => attachment.sessionId); + const attachedSessions = attachedSessionIds.length + ? await db + .select({ id: sessions.id, status: sessions.status }) + .from(sessions) + .where(inArray(sessions.id, attachedSessionIds)) + : []; + const statusBySession = new Map( + attachedSessions.map((session) => [session.id, session.status]), + ); + if ( + attachedSessionIds.some((sessionId) => { + const status = statusBySession.get(sessionId); + return status !== 'archived' && status !== 'deleted'; + }) + ) { + throwIpcError('PRECONDITION_FAILED', '仍有 active Bot Session 使用该 workspace lease'); + } + + const activeAutomation = await db + .select({ id: botAutomationRuns.id }) + .from(botAutomationRuns) + .where( + and( + eq(botAutomationRuns.workspaceLeaseId, leaseId), + inArray(botAutomationRuns.status, ['claimed', 'running', 'completing']), + ), + ) + .limit(1); + if (activeAutomation.length > 0) { + throwIpcError('PRECONDITION_FAILED', '仍有 Bot Automation 使用该 workspace lease'); + } + + if (attachedSessionIds.length > 0) { + const activeDelegations = await db + .select({ + id: botDelegations.id, + parentSessionId: botDelegations.parentSessionId, + childSessionId: botDelegations.childSessionId, + }) + .from(botDelegations) + .where(inArray(botDelegations.status, ['queued', 'running', 'waiting'])); + const attached = new Set(attachedSessionIds); + if ( + activeDelegations.some( + (delegation) => + (delegation.parentSessionId && attached.has(delegation.parentSessionId)) || + (delegation.childSessionId && attached.has(delegation.childSessionId)), + ) + ) { + throwIpcError('PRECONDITION_FAILED', '仍有 Bot delegation 使用该 workspace lease'); + } + } + + const now = Date.now(); + const [claimed] = await db + .update(botWorkspaceLeases) + .set({ status: 'releasing', updatedAt: now }) + .where( + and( + eq(botWorkspaceLeases.id, leaseId), + eq(botWorkspaceLeases.generation, expectedGeneration), + inArray(botWorkspaceLeases.status, ['active', 'error', 'retained']), + ), + ) + .returning(); + if (!claimed) { + throwIpcError('PRECONDITION_FAILED', 'Bot workspace lease 已被另一处操作更新'); + } + + try { + const [{ getMakerIfReady }, worktree, remoteWorkspace] = await Promise.all([ + import('../../maker-host/index.js'), + import('../../worktree/index.js'), + import('../../maker-ipc/botRemoteWorkspaceService.js'), + ]); + const maker = getMakerIfReady(); + if (attachedSessionIds.some((sessionId) => maker?.isSessionAlive(sessionId) === true)) { + throwIpcError('PRECONDITION_FAILED', '仍有 Bot Session runtime 使用该 workspace lease'); + } + if (claimed.worktreePath && !claimed.anchorSessionId) { + throwIpcError('PRECONDITION_FAILED', 'workspace lease 缺少可恢复的 anchor Session'); + } + if (claimed.remoteHostId && claimed.worktreePath) { + if (!claimed.branch) { + throwIpcError('PRECONDITION_FAILED', '远程 workspace lease 缺少受管分支信息'); + } + await remoteWorkspace.removeRemoteBotWorktree({ + remoteHostId: claimed.remoteHostId, + baseRepo: claimed.baseRepo, + worktreePath: claimed.worktreePath, + branch: claimed.branch, + }); + } else if (claimed.anchorSessionId) { + await worktree.WorktreeManager.removeWorktreeForSession(claimed.anchorSessionId, { + isSessionRuntimeAlive: (sessionId) => maker?.isSessionAlive(sessionId) ?? false, + canRemove: async () => { + const [current] = await db + .select({ + status: botWorkspaceLeases.status, + generation: botWorkspaceLeases.generation, + }) + .from(botWorkspaceLeases) + .where(eq(botWorkspaceLeases.id, leaseId)) + .limit(1); + return current?.status === 'releasing' && current.generation === expectedGeneration; + }, + }); + } + const registered = + !claimed.remoteHostId && claimed.worktreePath + ? worktree.WorktreeManager.listAll().some( + (meta) => path.resolve(meta.path) === path.resolve(claimed.worktreePath!), + ) + : false; + const remainsOnDisk = claimed.worktreePath + ? claimed.remoteHostId + ? ( + await remoteWorkspace.inspectRemoteBotWorktree({ + remoteHostId: claimed.remoteHostId, + worktreePath: claimed.worktreePath, + baseRepo: claimed.baseRepo, + branch: claimed.branch, + }) + ).exists + : await fileExists(claimed.worktreePath) + : false; + if (registered || remainsOnDisk) { + throwIpcError( + 'PRECONDITION_FAILED', + 'worktree 被安全保护策略保留;请处理运行中引用、分支状态或 .worktree-keep 后重试', + ); + } + + await getDbClient().tx('bots.finalizeWorkspaceLeaseRelease', { + leaseId, + botId, + expectedGeneration, + anchorSessionId: claimed.anchorSessionId, + releasedAt: now, + eventId: `${botId}:workspace-released:${leaseId}:${now}`, + eventType: 'workspace-lease-released', + }); + return readProfile(db, botId); + } catch (error) { + await db + .update(botWorkspaceLeases) + .set({ status: 'error', updatedAt: Date.now() }) + .where( + and( + eq(botWorkspaceLeases.id, leaseId), + eq(botWorkspaceLeases.generation, expectedGeneration), + eq(botWorkspaceLeases.status, 'releasing'), + ), + ); + throw error; + } + }); + + const createBotCanonicalSessionUnlocked = async ( + input: CreateBotCanonicalSessionInput, + ): Promise => { + const botId = readText(input.botId, 'botId', 128, true); + const expectedCanonicalSessionId = input.expectedCanonicalSessionId; + if (!Number.isInteger(input.expectedProfileVersion) || input.expectedProfileVersion < 1) { + throwIpcError('INVALID_PARAMS', 'expectedProfileVersion 必须是正整数'); + } + const expectedProfileVersion = input.expectedProfileVersion; + const db = getDbClient().drizzle; + const [profile] = await db.select().from(botProfiles).where(eq(botProfiles.id, botId)).limit(1); + if (!profile) throwIpcError('NOT_FOUND', 'Bot 不存在'); + if (profile.status !== 'active' && profile.status !== 'paused') { + throwIpcError('PRECONDITION_FAILED', `Bot 当前状态为 ${profile.status}`); + } + if (input.recoverMissingOnly) { + if (!expectedCanonicalSessionId || profile.canonicalSessionId !== expectedCanonicalSessionId) { + throwIpcError('PRECONDITION_FAILED', 'Bot 主任务已变化,请刷新后重试'); + } + const [existingCanonical] = await db + .select({ id: sessions.id }) + .from(sessions) + .where(eq(sessions.id, expectedCanonicalSessionId)) + .limit(1); + if (existingCanonical) { + throwIpcError('PRECONDITION_FAILED', 'Bot 主任务仍然存在,不能按丢失任务恢复'); + } + } + const [profileVersion] = await db + .select() + .from(botProfileVersions) + .where( + and( + eq(botProfileVersions.botId, botId), + eq(botProfileVersions.version, profile.currentVersion), + ), + ) + .limit(1); + if (!profileVersion) throwIpcError('PRECONDITION_FAILED', 'Bot 当前 Profile 版本不存在'); + const [defaultProjectBinding] = await db + .select() + .from(botProjectBindings) + .where( + and( + eq(botProjectBindings.botId, botId), + eq(botProjectBindings.status, 'active'), + eq(botProjectBindings.isDefault, true), + ), + ) + .limit(1); + + // Allocate the app-owned workspace before opening the SQLite write + // transaction. The transaction re-checks the canonical CAS; if another + // window wins while the workspace is being prepared, the unused exact + // UUID directory is removed and no hidden Session row is left behind. + const now = Date.now(); + const sessionId = resolveBusinessSessionId(undefined); + const workspaceKind = defaultProjectBinding ? 'project' : 'dialogue'; + const workingDir = + defaultProjectBinding?.workingDir ?? ensureDialogueWorkspaceDir(sessionId, now); + const config = parseJson(profileVersion.capabilitiesJson); + const insertRow = { + ...sessionCreateToRow( + sessionId, + { + workspaceKind, + workingDir, + model: + typeof config.model === 'string' && config.model.trim() + ? config.model.trim() + : 'claude-sonnet-4-6', + providerId: + typeof config.providerId === 'string' && config.providerId.trim() + ? config.providerId.trim() + : config.providerId === null + ? null + : undefined, + effort: + typeof config.effort === 'string' && config.effort.trim() + ? config.effort.trim() + : undefined, + fastMode: config.fastMode === true, + agentKind: botSessionAgentKind(config), + permissionMode: botSessionPermissionMode(config), + remoteHostId: defaultProjectBinding?.remoteHostId ?? undefined, + source: 'bot', + }, + now, + ), + title: profile.displayName, + }; + try { + await ensureProjectGitInitialized({ + workingDir, + workspaceKind, + remoteHostId: defaultProjectBinding?.remoteHostId ?? null, + sessionId, + autoSnapshotEnabled: readGitSafetySettings().autoSnapshotEnabled, + source: 'local-db:bots:create-canonical-session', + }); + } catch (error) { + if (!defaultProjectBinding) { + await fs.rm(workingDir, { recursive: true, force: true }).catch(() => {}); + } + throw error; + } + + let canonicalSessionId: string | null = null; + let archivedCanonicalSessionId: string | null = null; + let created = false; + try { + const result = await getDbClient().tx( + 'bots.replaceCanonicalSession', { + botId, + expectedCanonicalSessionId, + expectedProfileVersion, + session: { + id: insertRow.id, + title: insertRow.title, + workingDir: insertRow.workingDir ?? null, + workspaceKind: insertRow.workspaceKind, + model: insertRow.model, + effort: insertRow.effort, + fastMode: insertRow.fastMode, + permissionMode: insertRow.permissionMode, + agentKind: insertRow.agentKind, + remoteHostId: insertRow.remoteHostId ?? null, + providerId: insertRow.providerId ?? null, + extraDirs: insertRow.extraDirs, + source: insertRow.source, + createdAt: insertRow.createdAt, + updatedAt: insertRow.updatedAt, + }, + now, + }, + ); + canonicalSessionId = result.canonicalSessionId; + archivedCanonicalSessionId = result.archivedCanonicalSessionId; + created = result.created; + } finally { + if (!created) { + const [persisted] = await db + .select({ id: sessions.id }) + .from(sessions) + .where(eq(sessions.id, sessionId)) + .limit(1); + // Project bindings are user-owned. Only the exact dialogue workspace + // allocated by this attempt is eligible for failure compensation. + if (!persisted && workspaceKind === 'dialogue') { + await fs.rm(workingDir, { recursive: true, force: true }).catch(() => {}); + } + } + } + + if (!canonicalSessionId) { + throwIpcError('PRECONDITION_FAILED', 'Bot 主任务创建失败'); + } + if (archivedCanonicalSessionId) { + await cancelBotDelegationChildren( + archivedCanonicalSessionId, + 'Parent Bot task was replaced by Renew.', + ); + const [{ getMakerIfReady }, workspaceRuntime] = await Promise.all([ + import('../../maker-host/index.js'), + import('../../maker-ipc/botWorkspaceRuntime.js'), + ]); + await getMakerIfReady() + ?.closeSession(archivedCanonicalSessionId) + .catch(() => undefined); + workspaceRuntime.schedulePerTaskBotWorkspaceReclaim(archivedCanonicalSessionId); + } + const [canonical] = await db + .select() + .from(sessions) + .where(eq(sessions.id, canonicalSessionId)) + .limit(1); + if (!canonical) throwIpcError('NOT_FOUND', 'Bot 主任务不存在'); + return { + created, + canonicalSessionId, + session: sessionToCamel({ + ...canonical, + messageCount: 0, + latestMessageContent: null, + latestMessageRole: null, + }), + }; + }; + + createBotCanonicalSessionImpl = async (input) => { + const previousSessionId = input.expectedCanonicalSessionId; + if (!previousSessionId) return createBotCanonicalSessionUnlocked(input); + return coordinateBotCanonicalReplacement( + previousSessionId, + () => createBotCanonicalSessionUnlocked(input), + ); + }; + + ipcMain.handle('local-db:bots:create-canonical-session', async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = + raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; + const botId = readText(body.botId, 'botId', 128, true); + if (!Object.prototype.hasOwnProperty.call(body, 'expectedCanonicalSessionId')) { + throwIpcError('INVALID_PARAMS', 'expectedCanonicalSessionId 必须显式提供'); + } + const expectedCanonicalSessionId = + body.expectedCanonicalSessionId === null + ? null + : readText(body.expectedCanonicalSessionId, 'expectedCanonicalSessionId', 128, true); + if (!Number.isInteger(body.expectedProfileVersion) || Number(body.expectedProfileVersion) < 1) { + throwIpcError('INVALID_PARAMS', 'expectedProfileVersion 必须是正整数'); + } + if (body.recoverMissingOnly !== undefined && typeof body.recoverMissingOnly !== 'boolean') { + throwIpcError('INVALID_PARAMS', 'recoverMissingOnly 必须是 boolean'); + } + return createBotCanonicalSession({ + botId, + expectedCanonicalSessionId, + expectedProfileVersion: Number(body.expectedProfileVersion), + recoverMissingOnly: body.recoverMissingOnly === true, + }); + }); + + ipcMain.handle('local-db:bots:link-session', async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = + raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record) : {}; + const botId = readText(body.botId, 'botId', 128, true); + const sessionId = readText(body.sessionId, 'sessionId', 128, true); + const role = readText(body.role, 'role', 16, true) as BotRole; + if (!ROLES.has(role)) throwIpcError('INVALID_PARAMS', `invalid Bot Session role: ${role}`); + const db = getDbClient().drizzle; + const [bot] = await db.select().from(botProfiles).where(eq(botProfiles.id, botId)).limit(1); + const [session] = await db.select().from(sessions).where(eq(sessions.id, sessionId)).limit(1); + if (!bot || !session) throwIpcError('NOT_FOUND', 'Bot 或 Session 不存在'); + if (session.source !== 'bot') { + // A renderer-held id is never authority to reclassify an existing task. + // Reclassification would hide arbitrary desktop/Review tasks from the + // normal Session list and would also bypass their immutable-field rules. + throwIpcError('INVALID_PARAMS', '只有 source=bot 的 Session 才能绑定到 Bot'); + } + const channelId = + typeof body.channelId === 'string' && body.channelId.trim() ? body.channelId.trim() : null; + const routeKey = typeof body.routeKey === 'string' ? body.routeKey.trim().slice(0, 500) : ''; + const hasExpectedCanonical = Object.prototype.hasOwnProperty.call( + body, + 'expectedCanonicalSessionId', + ); + const expectedCanonicalSessionId = hasExpectedCanonical + ? body.expectedCanonicalSessionId === null + ? null + : readText(body.expectedCanonicalSessionId, 'expectedCanonicalSessionId', 128) + : undefined; + if (role === 'route' && (!channelId || !routeKey)) { + throwIpcError('INVALID_PARAMS', 'route Session 必须带 Channel 和 routeKey'); + } + if (channelId) { + const [channel] = await db + .select({ id: botChannels.id, botId: botChannels.botId }) + .from(botChannels) + .where(eq(botChannels.id, channelId)) + .limit(1); + if (!channel || channel.botId !== botId) + throwIpcError('INVALID_PARAMS', 'Channel 不属于该 Bot'); + } + const existing = await db + .select({ + id: botSessionLinks.id, + botId: botSessionLinks.botId, + role: botSessionLinks.role, + sessionId: botSessionLinks.sessionId, + }) + .from(botSessionLinks) + .where(eq(botSessionLinks.sessionId, sessionId)) + .limit(1); + if (existing[0] && existing[0].botId !== botId) { + throwIpcError('PRECONDITION_FAILED', 'Session 已绑定到另一个 Bot'); + } + if (role === 'history' && bot.canonicalSessionId === sessionId) { + throwIpcError('PRECONDITION_FAILED', 'canonical Session 必须通过 Renew 原子替换'); + } + if (role === 'route' && bot.canonicalSessionId === sessionId) { + throwIpcError('PRECONDITION_FAILED', 'canonical Session 不能同时作为 route Session'); + } + const now = Date.now(); + const { archivedCanonicalSessionIds } = await getDbClient().tx('bots.linkSession', { + botId, + sessionId, + role, + channelId, + routeKey: routeKey || null, + hasExpectedCanonical, + expectedCanonicalSessionId: expectedCanonicalSessionId ?? null, + now, + eventId: `${botId}:linked:${sessionId}:${now}`, + }); + if (archivedCanonicalSessionIds.length > 0) { + const [{ getMakerIfReady }, workspaceRuntime] = await Promise.all([ + import('../../maker-host/index.js'), + import('../../maker-ipc/botWorkspaceRuntime.js'), + ]); + for (const archivedSessionId of archivedCanonicalSessionIds) { + await cancelBotDelegationChildren( + archivedSessionId, + 'Parent Bot task was replaced by a new canonical task.', + ); + await getMakerIfReady() + ?.closeSession(archivedSessionId) + .catch(() => undefined); + workspaceRuntime.schedulePerTaskBotWorkspaceReclaim(archivedSessionId); + } + } + return readProfile(db, botId); + }); + + ipcMain.handle('local-db:bots:history', async (event, rawBotId: unknown) => { + assertTrustedAppRendererEvent(event); + const botId = readText(rawBotId, 'botId', 128, true); + const db = getDbClient().drizzle; + const links = await db + .select() + .from(botSessionLinks) + .where(and(eq(botSessionLinks.botId, botId), eq(botSessionLinks.role, 'history'))) + .orderBy(desc(botSessionLinks.archivedAt)); + const result = []; + for (const link of links) { + const [row] = await db + .select() + .from(sessions) + .where(eq(sessions.id, link.sessionId)) + .limit(1); + if (!row) continue; + result.push( + sessionToCamel({ + ...row, + messageCount: 0, + latestMessageContent: null, + latestMessageRole: null, + }), + ); + } + return result; + }); +} diff --git a/apps/desktop/src/main/localDb/ipc/registerAll.ts b/apps/desktop/src/main/localDb/ipc/registerAll.ts index ff2f1f7893..b67b37ac48 100644 --- a/apps/desktop/src/main/localDb/ipc/registerAll.ts +++ b/apps/desktop/src/main/localDb/ipc/registerAll.ts @@ -27,6 +27,7 @@ import { registerSubagentRunsIpc } from './subagentRuns'; import { registerDevSqliteVecIpc } from './dev/sqliteVec'; import { registerSearchIpc } from './search'; import { registerRemoteHistoryIpc } from './history'; +import { registerBotIpc } from './bots'; import { createLogger } from '../../logger'; import { recordDesktopDevLocalDbStartupResult } from '../../devStartupStatus'; @@ -230,6 +231,7 @@ export function registerLocalDbIpc(opts: RegisterLocalDbIpcOpts = {}): void { registerSessionIpc(getCurrentDbClientUserId); registerMessageIpc(); registerRemoteHistoryIpc(); + registerBotIpc(); registerSessionImportIpc(); registerSessionShareIpc(); registerOrcaWorkflowIpc(); diff --git a/apps/desktop/src/main/localDb/ipc/rightSidebarTabs.ts b/apps/desktop/src/main/localDb/ipc/rightSidebarTabs.ts index baa9ce1df6..0230a7d121 100644 --- a/apps/desktop/src/main/localDb/ipc/rightSidebarTabs.ts +++ b/apps/desktop/src/main/localDb/ipc/rightSidebarTabs.ts @@ -35,7 +35,7 @@ const log = createLogger('rightSidebarTabs'); /** 单 session 最多 20 个 tab,超抛 RIGHT_SIDEBAR_TOO_MANY_TABS。 */ const MAX_TABS_PER_SESSION = 20; -const SINGLETON_TAB_KINDS = new Set(['subagents']); +const SINGLETON_TAB_KINDS = new Set(['subagents', 'bot-delegations']); export interface TabRow { id: string; diff --git a/apps/desktop/src/main/localDb/ipc/sessions.ts b/apps/desktop/src/main/localDb/ipc/sessions.ts index 3fbb72a9d5..0ad5712f46 100644 --- a/apps/desktop/src/main/localDb/ipc/sessions.ts +++ b/apps/desktop/src/main/localDb/ipc/sessions.ts @@ -16,6 +16,7 @@ import { DEFAULT_DRAFT_SESSION_TITLE, normalizeAutoTitle } from '@cindy/maker-sh import { getDbClient } from '../client/current'; import type { DbClient } from '../client/DbClient'; import { sessions, messages } from '../schema'; +import { commitBotProfileDeletion } from '../botProfileDeletionStore.js'; import { throwIpcError, requireString, requireObject } from '../../utils/ipcValidate'; import { resolveBusinessSessionId } from '../../sessionIds'; import { normalizeDbAgentKind } from '../../../shared/agentKindConversion'; @@ -909,6 +910,7 @@ export async function clearSessionContextInDb(sessionId: string, atMs?: number): .update(sessions) .set({ sdkSessionId: null, + codexPlanJson: null, // Concurrent /clear calls may finish their DB awaits out of order. Keep // both persisted boundaries monotonic so the older completion cannot // make pre-clear history visible again or invalidate a newer input token. @@ -1062,6 +1064,12 @@ export function registerSessionIpc( ) { throwIpcError('INVALID_PARAMS', `invalid orcaRole: ${String(bodyObj.orcaRole)}`); } + if (bodyObj.source !== undefined) { + throwIpcError( + 'UNSUPPORTED_CAPABILITY', + 'Bot task creation is only available through the Bot lifecycle service', + ); + } const workspaceKind = (createBody?.workspaceKind as 'project' | 'dialogue' | undefined) ?? 'project'; const explicitWorkingDir = @@ -1289,6 +1297,7 @@ export function registerSessionIpc( const db = getDbClient().drizzle; const updated = await withSessionRouteLock(sid, async () => { if (!isOwnerScopeCurrent(ownerScope)) return null; + await assertGenericSessionLifecycleAllowed(db, sid); // 显式 .run() 才能从生产 DbClient.drizzle proxy 拿到 changes;隐式 await // 会丢弃写结果。CAS 是否命中必须以该原子 UPDATE 的 changes 判定。 const writeResult = await db @@ -1429,7 +1438,10 @@ export function registerSessionIpc( // 先记号后写库,代价只是写库失败时该会话本进程内不再自动起名 —— 用户毕竟确实 // 按下过保存,这个方向的偏差是安全的。 if (typeof p.title === 'string') noteUserTitleWritten(sid); - await withStatusWriteLock(sid, p.status, () => writeSessionPatch(db, sid, setObj, p.status)); + await withStatusWriteLock(sid, p.status, async () => { + if (p.status !== undefined) await assertGenericSessionLifecycleAllowed(db, sid); + await writeSessionPatch(db, sid, setObj, p.status); + }); // session-git-pr-context:/clear 经此处写 clearedAt——边界之前的消息对用户 // 不可见,PR 引用同步重算(fire-and-forget,内部按 clearedAt/rewindAt 过滤)。 if (p.clearedAt !== undefined) { @@ -1579,6 +1591,7 @@ export async function patchSessionMetaInDb( // 控制端远程改名走这条,与本机改名同口径(同样先记号后写库)。 if (patch.title !== undefined) noteUserTitleWritten(sessionId); const updated = await withStatusWriteLock(sessionId, patch.status, async () => { + if (patch.status !== undefined) await assertGenericSessionLifecycleAllowed(db, sessionId); await writeSessionPatch(db, sessionId, setObj, patch.status); const row = await selectSessionWithCount(db, sessionId); if (!row) throwIpcError('NOT_FOUND', 'Session 不存在'); @@ -1615,6 +1628,28 @@ export async function patchSessionMetaInDb( return updated; } +/** + * Bot tasks are absent from the ordinary task pool, so their active/history/ + * route transitions must go through the Bot lifecycle service. That service + * updates the Profile pointer and Session projection atomically. + */ +async function assertGenericSessionLifecycleAllowed( + db: DbClient['drizzle'], + sessionId: string, +): Promise { + const [target] = await db + .select({ source: sessions.source }) + .from(sessions) + .where(eq(sessions.id, sessionId)) + .limit(1); + if (target?.source === 'bot') { + throwIpcError( + 'PRECONDITION_FAILED', + 'Bot task lifecycle is managed by Bot Renew and history controls', + ); + } +} + export interface RenameSessionMetaChange { sessionId: string; title: string; @@ -1765,6 +1800,49 @@ export async function setSessionsStatusInDb( })); } +/** + * Detach Bot-owned tasks before the owning Profile is permanently removed. + * Kept transcripts become ordinary archived tasks; discarded transcripts + * become ordinary deleted tombstones so no inaccessible source=bot orphan is + * left after the Bot FK graph is cascaded away. + */ +export async function deleteBotProfileAndDetachSessionsInDb( + botId: string, + sessionIds: string[], + keepTaskHistory: boolean, +): Promise { + const ids = [...new Set(sessionIds)]; + const ownerScope = captureOwnerScope(); + const db = getDbClient().drizzle; + const commitDeletion = () => commitBotProfileDeletion({ + botId, + sessionIds: ids, + keepTaskHistory, + }); + const committed = ids.length > 0 + ? await withSessionRouteLocks(ids, commitDeletion) + : await commitDeletion(); + const status = committed.status; + + for (const id of ids) { + notifyAgentIslandSessionPatch(id, { status }); + broadcastSessionPatched(id, { status, source: 'desktop' }, ownerScope); + notifyGhostSessionStatusChange(id, status, null); + removeHookAttachmentDir(id, status); + if (status === 'deleted') { + void imageCacheStore.removeSession(id).catch((error) => { + log.warn('Bot task image cleanup failed', { sessionId: id, error: String(error) }); + }); + void removeWechatSessionAttachmentDir(id).catch((error) => { + log.warn('Bot task attachment cleanup failed', { sessionId: id, error: String(error) }); + }); + void removeDeletedSessionMediaRefs(id, db).catch((error) => { + log.warn('Bot task media cleanup failed', { sessionId: id, error: String(error) }); + }); + } + } +} + /** * hook 入站附件目录回收(fire-and-forget): deleted/archived 都是终态, * 文件在 turn 送出后即无用。所有把 session 置为终态的路径都应调用。 diff --git a/apps/desktop/src/main/localDb/mapper.ts b/apps/desktop/src/main/localDb/mapper.ts index e63661d389..bde479e23f 100644 --- a/apps/desktop/src/main/localDb/mapper.ts +++ b/apps/desktop/src/main/localDb/mapper.ts @@ -42,6 +42,7 @@ import type { PreRunHookRunResult, } from '@cindy/maker-scheduler'; import { normalizeSessionSource } from '../../shared/sessionSource.js'; +import type { SessionSource } from '../../shared/sessionSource.js'; import { normalizeWorkingDirForStorage } from '../../shared/workingDir.js'; import { isSyntheticTriggerText } from '../../shared/interruptedTurn.js'; import { @@ -269,6 +270,7 @@ export function sessionCreateToRow( * create 由 renderer 透传用户在草稿里选定的来源,使新会话首个请求就走对供应商。 */ providerId?: string | null; + source?: 'bot'; } | undefined, now: number, @@ -307,6 +309,7 @@ export function sessionCreateToRow( typeof body?.providerId === 'string' && body.providerId.trim().length > 0 ? body.providerId.trim() : null, + source: body?.source ?? 'desktop', createdAt: now, updatedAt: now, }; @@ -531,7 +534,7 @@ export function scheduleToCamel(row: ScheduleRow): Schedule { jobConfig: row.jobConfig ?? undefined, executionMode: row.executionMode === 'script' ? 'script' : 'agent', scriptConfig: parseScriptConfig(row.scriptConfig), - source: row.source === 'project' ? 'project' : 'user', + source: row.source === 'project' ? 'project' : row.source === 'bot' ? 'bot' : 'user', projectConfigId: row.projectConfigId ?? undefined, kind: row.kind, cronExpr: row.cronExpr, diff --git a/apps/desktop/src/main/localDb/schema.ts b/apps/desktop/src/main/localDb/schema.ts index 04701ef989..be41c48fad 100644 --- a/apps/desktop/src/main/localDb/schema.ts +++ b/apps/desktop/src/main/localDb/schema.ts @@ -35,6 +35,7 @@ const SESSION_SOURCES = [ 'review', 'shared', 'plugin', + 'bot', ] as const satisfies readonly SessionSource[]; export const sessions = sqliteTable( @@ -183,6 +184,8 @@ export const sessions = sqliteTable( * "restore once if leaving proxy mode" by maker-core. */ codexHistoryHasProductPrompt: integer('codex_history_has_product_prompt', { mode: 'boolean' }), + /** Codex-only: latest native update_plan snapshot. */ + codexPlanJson: text('codex_plan_json'), /** * Session 附加只读引用目录列表(JSON 字符串数组,绝对路径)。 * agent 在每 turn 透传:Claude Code 使用 options.additionalDirectories, @@ -244,6 +247,528 @@ export const sessions = sqliteTable( }), ); +/** + * Cindy Bots 的 Profile 权威记录。 + * + * Renderer 只能通过 local-db:bots:* 读取/修改,不能把 Bot 身份或 canonical + * Session 关系留在 localStorage。JSON 字段保留 Profile runtime 的版本化扩展空间; + * 具体 Skill/MCP/Memory 引用仍由各自能力系统解析,不在这里复制凭证。 + */ +export const botProfiles = sqliteTable( + 'bot_profiles', + { + id: text('id').primaryKey(), + displayName: text('display_name').notNull(), + description: text('description').notNull().default(''), + avatar: text('avatar').notNull().default('🤖'), + avatarColor: text('avatar_color').notNull().default('violet'), + status: text('status', { enum: ['active', 'paused', 'error', 'archived', 'deleting'] }) + .notNull() + .default('active'), + currentVersion: integer('current_version').notNull().default(1), + canonicalSessionId: text('canonical_session_id').references(() => sessions.id, { + onDelete: 'set null', + }), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), + }, + (t) => ({ + idxStatusUpdated: index('idx_bot_profiles_status_updated').on(t.status, t.updatedAt), + idxCanonicalSession: index('idx_bot_profiles_canonical_session').on(t.canonicalSessionId), + }), +); + +/** Immutable-ish Profile snapshots used for runtime binding, audit and rollback. */ +export const botProfileVersions = sqliteTable( + 'bot_profile_versions', + { + id: text('id').primaryKey(), + botId: text('bot_id') + .notNull() + .references(() => botProfiles.id, { onDelete: 'cascade' }), + version: integer('version').notNull(), + identitySource: text('identity_source').notNull().default(''), + capabilitiesJson: text('capabilities_json').notNull().default('{}'), + createdAt: integer('created_at').notNull(), + }, + (t) => ({ + uniqBotVersion: uniqueIndex('uniq_bot_profile_versions_bot_version').on(t.botId, t.version), + idxBotCreated: index('idx_bot_profile_versions_bot_created').on(t.botId, t.createdAt), + }), +); + +/** A Bot may mount multiple message surfaces; local is the default mount. */ +export const botChannels = sqliteTable( + 'bot_channels', + { + id: text('id').primaryKey(), + botId: text('bot_id') + .notNull() + .references(() => botProfiles.id, { onDelete: 'cascade' }), + kind: text('kind', { + enum: [ + 'local', + 'telegram', + 'feishu', + 'slack', + 'discord', + 'wechat', + 'dingtalk', + 'wecom', + 'x', + ], + }).notNull(), + enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true), + configJson: text('config_json').notNull().default('{}'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), + }, + (t) => ({ + idxBotKind: index('idx_bot_channels_bot_kind').on(t.botId, t.kind), + idxEnabled: index('idx_bot_channels_enabled').on(t.enabled), + }), +); + +/** Canonical, route and archived/history Session projections for a Bot. */ +export const botSessionLinks = sqliteTable( + 'bot_session_links', + { + id: text('id').primaryKey(), + botId: text('bot_id') + .notNull() + .references(() => botProfiles.id, { onDelete: 'cascade' }), + sessionId: text('session_id') + .notNull() + .references(() => sessions.id, { onDelete: 'cascade' }), + /** ProfileVersion pinned when this Session became canonical/route. */ + profileVersion: integer('profile_version').notNull().default(1), + role: text('role', { enum: ['canonical', 'route', 'history'] }).notNull(), + channelId: text('channel_id').references(() => botChannels.id, { onDelete: 'set null' }), + routeKey: text('route_key'), + createdAt: integer('created_at').notNull(), + archivedAt: integer('archived_at'), + }, + (t) => ({ + uniqSession: uniqueIndex('uniq_bot_session_links_session').on(t.sessionId), + uniqCanonicalPerBot: uniqueIndex('uniq_bot_session_links_canonical_per_bot') + .on(t.botId) + .where(sql`${t.role} = 'canonical'`), + idxBotRole: index('idx_bot_session_links_bot_role').on(t.botId, t.role), + uniqRoute: uniqueIndex('uniq_bot_session_links_route') + .on(t.channelId, t.routeKey) + .where(sql`${t.role} = 'route' AND ${t.channelId} IS NOT NULL AND ${t.routeKey} IS NOT NULL`), + }), +); + +/** Prepared and terminal native runtime capability snapshot for each Bot Session start. */ +export const botRuntimeSnapshots = sqliteTable( + 'bot_runtime_snapshots', + { + id: text('id').primaryKey(), + botId: text('bot_id') + .notNull() + .references(() => botProfiles.id, { onDelete: 'cascade' }), + sessionId: text('session_id') + .notNull() + .references(() => sessions.id, { onDelete: 'cascade' }), + profileVersion: integer('profile_version').notNull(), + agentKind: text('agent_kind', { enum: ['claude-code', 'codex', 'pi'] }).notNull(), + workingDir: text('working_dir').notNull(), + memoryScopeKey: text('memory_scope_key'), + configuredJson: text('configured_json').notNull().default('{}'), + resolvedJson: text('resolved_json').notNull().default('{}'), + status: text('status', { enum: ['prepared', 'applied', 'degraded', 'failed'] }).notNull(), + /** Resolution finished and the exact Profile/runtime bytes were frozen. */ + preparedAt: integer('prepared_at').notNull().default(0), + /** Agent startup returned and Session storage succeeded. */ + appliedAt: integer('applied_at'), + /** Startup failed before the Session became visible. */ + failedAt: integer('failed_at'), + /** Sanitized stage/error metadata only; never stores prompt or user content. */ + failureJson: text('failure_json'), + }, + (t) => ({ + idxBotPrepared: index('idx_bot_runtime_snapshots_bot_prepared').on(t.botId, t.preparedAt), + idxSessionPrepared: index('idx_bot_runtime_snapshots_session_prepared').on( + t.sessionId, + t.preparedAt, + ), + }), +); + +/** Lifecycle audit trail for renew/archive/recovery and future migration events. */ +export const botLifecycleEvents = sqliteTable( + 'bot_lifecycle_events', + { + id: text('id').primaryKey(), + botId: text('bot_id') + .notNull() + .references(() => botProfiles.id, { onDelete: 'cascade' }), + sessionId: text('session_id').references(() => sessions.id, { onDelete: 'set null' }), + eventType: text('event_type').notNull(), + payloadJson: text('payload_json').notNull().default('{}'), + createdAt: integer('created_at').notNull(), + }, + (t) => ({ + idxBotCreated: index('idx_bot_lifecycle_events_bot_created').on(t.botId, t.createdAt), + idxSessionCreated: index('idx_bot_lifecycle_events_session_created').on(t.sessionId, t.createdAt), + }), +); + +/** Stable project/workspace policy owned by a Bot Profile, not by one Session. */ +export const botProjectBindings = sqliteTable( + 'bot_project_bindings', + { + id: text('id').primaryKey(), + botId: text('bot_id') + .notNull() + .references(() => botProfiles.id, { onDelete: 'cascade' }), + /** host + canonical workingDir fingerprint supplied by main-side normalization. */ + projectKey: text('project_key').notNull(), + workingDir: text('working_dir').notNull(), + remoteHostId: text('remote_host_id'), + defaultBranch: text('default_branch'), + workspacePolicy: text('workspace_policy', { + enum: ['none', 'reuse', 'per-task', 'read-only'], + }) + .notNull() + .default('none'), + isDefault: integer('is_default', { mode: 'boolean' }).notNull().default(false), + allowedPathsJson: text('allowed_paths_json').notNull().default('[]'), + status: text('status', { enum: ['active', 'paused', 'error', 'archived'] }) + .notNull() + .default('active'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), + }, + (t) => ({ + uniqBotProject: uniqueIndex('uniq_bot_project_bindings_bot_project').on( + t.botId, + t.projectKey, + ), + idxBotStatus: index('idx_bot_project_bindings_bot_status').on(t.botId, t.status), + uniqDefaultPerBot: uniqueIndex('uniq_bot_project_bindings_default_per_bot') + .on(t.botId) + .where(sql`${t.isDefault} = true AND ${t.status} = 'active'`), + }), +); + +/** A concrete channel/thread/principal route mounted on a Bot Channel. */ +export const botRoutes = sqliteTable( + 'bot_routes', + { + id: text('id').primaryKey(), + botId: text('bot_id') + .notNull() + .references(() => botProfiles.id, { onDelete: 'cascade' }), + channelId: text('channel_id') + .notNull() + .references(() => botChannels.id, { onDelete: 'cascade' }), + routeKey: text('route_key').notNull(), + principalKey: text('principal_key').notNull(), + scopeKey: text('scope_key').notNull(), + threadKey: text('thread_key'), + currentSessionId: text('current_session_id').references(() => sessions.id, { + onDelete: 'set null', + }), + projectBindingId: text('project_binding_id').references(() => botProjectBindings.id, { + onDelete: 'set null', + }), + capabilitiesJson: text('capabilities_json').notNull().default('{}'), + ownerDeviceId: text('owner_device_id'), + ownerGeneration: integer('owner_generation').notNull().default(0), + status: text('status', { + enum: ['active', 'paused', 'offline', 'recovering', 'error', 'archived'], + }) + .notNull() + .default('active'), + /** + * Status captured when the whole Bot is paused. Null means this Route was + * already paused by the user and must not be resumed automatically. + */ + suspendedStatus: text('suspended_status', { + enum: ['active', 'offline', 'recovering', 'error'], + }), + lastActivityAt: integer('last_activity_at'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), + }, + (t) => ({ + uniqChannelRoute: uniqueIndex('uniq_bot_routes_channel_route').on(t.channelId, t.routeKey), + idxBotStatus: index('idx_bot_routes_bot_status').on(t.botId, t.status), + idxSession: index('idx_bot_routes_session').on(t.currentSessionId), + }), +); + +/** Stable Bot/project lease; Sessions attach to it but do not own its lifetime. */ +export const botWorkspaceLeases = sqliteTable( + 'bot_workspace_leases', + { + id: text('id').primaryKey(), + botId: text('bot_id') + .notNull() + .references(() => botProfiles.id, { onDelete: 'cascade' }), + projectBindingId: text('project_binding_id') + .notNull() + .references(() => botProjectBindings.id, { onDelete: 'cascade' }), + /** reuse='shared'; per-task uses a durable task/session key. */ + leaseKey: text('lease_key').notNull().default('shared'), + /** Compatibility owner used by the existing Session-keyed WorktreeManager store. */ + anchorSessionId: text('anchor_session_id').references(() => sessions.id, { + onDelete: 'set null', + }), + worktreePath: text('worktree_path'), + baseRepo: text('base_repo').notNull(), + branch: text('branch'), + sourceBranch: text('source_branch'), + remoteHostId: text('remote_host_id'), + generation: integer('generation').notNull().default(1), + status: text('status', { + enum: ['acquiring', 'active', 'releasing', 'released', 'retained', 'error'], + }) + .notNull() + .default('acquiring'), + lastHeartbeatAt: integer('last_heartbeat_at'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), + releasedAt: integer('released_at'), + }, + (t) => ({ + uniqActiveBindingLease: uniqueIndex('uniq_bot_workspace_leases_active_binding_key') + .on(t.projectBindingId, t.leaseKey) + .where(sql`${t.status} IN ('acquiring', 'active', 'releasing')`), + idxBotStatus: index('idx_bot_workspace_leases_bot_status').on(t.botId, t.status), + idxAnchorSession: index('idx_bot_workspace_leases_anchor_session').on(t.anchorSessionId), + }), +); + +/** Session access to a stable workspace lease; detach preserves historical lineage. */ +export const botWorkspaceAttachments = sqliteTable( + 'bot_workspace_attachments', + { + id: text('id').primaryKey(), + leaseId: text('lease_id') + .notNull() + .references(() => botWorkspaceLeases.id, { onDelete: 'cascade' }), + sessionId: text('session_id') + .notNull() + .references(() => sessions.id, { onDelete: 'cascade' }), + generation: integer('generation').notNull(), + access: text('access', { enum: ['read-write', 'read-only'] }) + .notNull() + .default('read-write'), + createdAt: integer('created_at').notNull(), + detachedAt: integer('detached_at'), + }, + (t) => ({ + uniqLeaseSession: uniqueIndex('uniq_bot_workspace_attachments_lease_session').on( + t.leaseId, + t.sessionId, + t.generation, + ), + uniqActiveSessionLease: uniqueIndex('uniq_bot_workspace_attachments_active_session') + .on(t.sessionId) + .where(sql`${t.detachedAt} IS NULL`), + idxLeaseActive: index('idx_bot_workspace_attachments_lease_active').on( + t.leaseId, + t.detachedAt, + ), + }), +); + +/** Durable Bot-to-Bot handoff lineage using Cindy child Sessions as execution units. */ +export const botDelegations = sqliteTable( + 'bot_delegations', + { + id: text('id').primaryKey(), + requestingBotId: text('requesting_bot_id') + .notNull() + .references(() => botProfiles.id, { onDelete: 'cascade' }), + targetBotId: text('target_bot_id') + .notNull() + .references(() => botProfiles.id, { onDelete: 'cascade' }), + parentSessionId: text('parent_session_id').references(() => sessions.id, { + onDelete: 'set null', + }), + childSessionId: text('child_session_id').references(() => sessions.id, { + onDelete: 'set null', + }), + objective: text('objective').notNull(), + contextRefsJson: text('context_refs_json').notNull().default('[]'), + artifactRefsJson: text('artifact_refs_json').notNull().default('[]'), + permissionSnapshotJson: text('permission_snapshot_json').notNull().default('{}'), + lineageJson: text('lineage_json').notNull().default('[]'), + targetProfileVersion: integer('target_profile_version').notNull(), + depth: integer('depth').notNull().default(1), + budgetTokens: integer('budget_tokens'), + tokensUsed: integer('tokens_used').notNull().default(0), + status: text('status', { + enum: ['queued', 'running', 'waiting', 'completed', 'failed', 'cancelled', 'timed-out'], + }) + .notNull() + .default('queued'), + resultSummary: text('result_summary'), + /** Output artifacts produced by the child task; never input authorization refs. */ + outputArtifactsJson: text('output_artifacts_json').notNull().default('[]'), + lastError: text('last_error'), + createdAt: integer('created_at').notNull(), + acceptedAt: integer('accepted_at'), + completedAt: integer('completed_at'), + updatedAt: integer('updated_at').notNull(), + }, + (t) => ({ + idxRequesterStatus: index('idx_bot_delegations_requester_status').on( + t.requestingBotId, + t.status, + ), + idxTargetStatus: index('idx_bot_delegations_target_status').on(t.targetBotId, t.status), + idxParentSession: index('idx_bot_delegations_parent_session').on(t.parentSessionId), + uniqChildSession: uniqueIndex('uniq_bot_delegations_child_session').on(t.childSessionId), + }), +); + +/** Small durable Bot/automation state; large transcripts remain in sessions/messages. */ +export const botDurableNotes = sqliteTable( + 'bot_durable_notes', + { + id: text('id').primaryKey(), + botId: text('bot_id') + .notNull() + .references(() => botProfiles.id, { onDelete: 'cascade' }), + namespace: text('namespace').notNull(), + noteKey: text('note_key').notNull(), + valueJson: text('value_json').notNull().default('{}'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), + }, + (t) => ({ + uniqBotNote: uniqueIndex('uniq_bot_durable_notes_bot_namespace_key').on( + t.botId, + t.namespace, + t.noteKey, + ), + idxBotNamespace: index('idx_bot_durable_notes_bot_namespace').on(t.botId, t.namespace), + }), +); + +/** Delivery is durable and idempotent; adapters still own the final message format. */ +export const botDeliveryOutbox = sqliteTable( + 'bot_delivery_outbox', + { + id: text('id').primaryKey(), + botId: text('bot_id') + .notNull() + .references(() => botProfiles.id, { onDelete: 'cascade' }), + channelId: text('channel_id').references(() => botChannels.id, { onDelete: 'set null' }), + routeId: text('route_id').references(() => botRoutes.id, { onDelete: 'set null' }), + sessionId: text('session_id').references(() => sessions.id, { onDelete: 'set null' }), + idempotencyKey: text('idempotency_key').notNull(), + payloadRefJson: text('payload_ref_json').notNull().default('{}'), + ownerGeneration: integer('owner_generation').notNull().default(0), + status: text('status', { + enum: ['pending', 'sending', 'suspended', 'delivered', 'failed', 'dead-letter', 'cancelled'], + }) + .notNull() + .default('pending'), + attempts: integer('attempts').notNull().default(0), + nextAttemptAt: integer('next_attempt_at'), + lastError: text('last_error'), + /** Adapter/server ACK retained for support diagnostics and later edit/delete operations. */ + deliveryReceiptJson: text('delivery_receipt_json'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), + deliveredAt: integer('delivered_at'), + }, + (t) => ({ + uniqIdempotency: uniqueIndex('uniq_bot_delivery_outbox_idempotency').on(t.idempotencyKey), + idxDue: index('idx_bot_delivery_outbox_due').on(t.status, t.nextAttemptAt), + idxRouteCreated: index('idx_bot_delivery_outbox_route_created').on(t.routeId, t.createdAt), + }), +); + +/** + * Audited ownership transfer from a legacy IM account/session set to a Bot. + * + * The original IM Session rows and adapter-specific message stores are never + * rewritten. Snapshots make the cross-store hook binding cleanup recoverable + * and let rollback restore only state owned by this migration. + */ +export const botImMigrations = sqliteTable( + 'bot_im_migrations', + { + id: text('id').primaryKey(), + requestId: text('request_id').notNull(), + botId: text('bot_id') + .notNull() + .references(() => botProfiles.id, { onDelete: 'cascade' }), + channelId: text('channel_id') + .notNull() + .references(() => botChannels.id, { onDelete: 'cascade' }), + routeId: text('route_id') + .notNull() + .references(() => botRoutes.id, { onDelete: 'cascade' }), + connectionId: text('connection_id').notNull(), + ownership: text('ownership', { enum: ['local-adapter', 'server-relay'] }).notNull(), + kind: text('kind', { + enum: ['telegram', 'feishu', 'slack', 'discord', 'wechat', 'dingtalk', 'wecom', 'x'], + }).notNull(), + accountKey: text('account_key').notNull(), + planHash: text('plan_hash').notNull(), + status: text('status', { + enum: ['applying', 'applied', 'rolling-back', 'rolled-back', 'failed'], + }) + .notNull() + .default('applying'), + channelBeforeJson: text('channel_before_json'), + routeBeforeJson: text('route_before_json'), + adapterBindingsJson: text('adapter_bindings_json').notNull().default('[]'), + errorJson: text('error_json'), + createdAt: integer('created_at').notNull(), + appliedAt: integer('applied_at'), + rolledBackAt: integer('rolled_back_at'), + }, + (t) => ({ + uniqRequest: uniqueIndex('uniq_bot_im_migrations_request').on(t.requestId), + idxBotCreated: index('idx_bot_im_migrations_bot_created').on(t.botId, t.createdAt), + idxConnectionStatus: index('idx_bot_im_migrations_connection_status').on( + t.connectionId, + t.status, + ), + }), +); + +/** Per-Session rollback facts for one IM migration batch. */ +export const botImMigrationItems = sqliteTable( + 'bot_im_migration_items', + { + id: text('id').primaryKey(), + migrationId: text('migration_id') + .notNull() + .references(() => botImMigrations.id, { onDelete: 'cascade' }), + sessionId: text('session_id') + .notNull() + .references(() => sessions.id, { onDelete: 'cascade' }), + originalStatus: text('original_status', { enum: ['active', 'archived'] }).notNull(), + historyLinkCreated: integer('history_link_created', { mode: 'boolean' }) + .notNull() + .default(false), + sessionArchived: integer('session_archived', { mode: 'boolean' }) + .notNull() + .default(false), + /** sessions.updated_at written by apply; rollback uses it as a CAS guard. */ + appliedSessionUpdatedAt: integer('applied_session_updated_at').notNull(), + createdAt: integer('created_at').notNull(), + rolledBackAt: integer('rolled_back_at'), + }, + (t) => ({ + uniqMigrationSession: uniqueIndex('uniq_bot_im_migration_items_batch_session').on( + t.migrationId, + t.sessionId, + ), + idxSession: index('idx_bot_im_migration_items_session').on(t.sessionId), + }), +); + export const orcaTeams = sqliteTable( 'orca_teams', { @@ -1061,6 +1586,110 @@ export const scheduleRuns = sqliteTable( }), ); +/** Stable Bot ownership for a Scheduler definition; no expiring Session identity. */ +export const botAutomationLinks = sqliteTable( + 'bot_automation_links', + { + id: text('id').primaryKey(), + botId: text('bot_id') + .notNull() + .references(() => botProfiles.id, { onDelete: 'cascade' }), + scheduleId: text('schedule_id').references(() => schedules.id, { onDelete: 'set null' }), + projectBindingId: text('project_binding_id').references(() => botProjectBindings.id, { + onDelete: 'set null', + }), + targetRouteId: text('target_route_id').references(() => botRoutes.id, { + onDelete: 'set null', + }), + createdWithProfileVersion: integer('created_with_profile_version').notNull(), + durableNoteNamespace: text('durable_note_namespace'), + /** Mutable definition policy; every fire normalizes and freezes it into the run plan. */ + executionPolicyJson: text('execution_policy_json').notNull().default('{}'), + status: text('status', { enum: ['active', 'paused', 'error', 'archived'] }) + .notNull() + .default('active'), + /** + * Status captured when the owning Bot is paused. Null means the user had + * already paused this Automation, so Bot resume must leave it paused. + */ + suspendedStatus: text('suspended_status', { enum: ['active', 'error'] }), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), + }, + (t) => ({ + uniqSchedule: uniqueIndex('uniq_bot_automation_links_schedule').on(t.scheduleId), + idxBotStatus: index('idx_bot_automation_links_bot_status').on(t.botId, t.status), + }), +); + +/** Per-fire Bot snapshot layered on top of the existing Scheduler run row. */ +export const botAutomationRuns = sqliteTable( + 'bot_automation_runs', + { + id: text('id').primaryKey(), + automationLinkId: text('automation_link_id') + .notNull() + .references(() => botAutomationLinks.id, { onDelete: 'cascade' }), + scheduleRunId: text('schedule_run_id').references(() => scheduleRuns.id, { + onDelete: 'set null', + }), + sessionId: text('session_id').references(() => sessions.id, { onDelete: 'set null' }), + workspaceLeaseId: text('workspace_lease_id').references(() => botWorkspaceLeases.id, { + onDelete: 'set null', + }), + profileVersion: integer('profile_version').notNull(), + /** Immutable per-run routing/workspace snapshot; deliberately no FK. */ + projectBindingIdSnapshot: text('project_binding_id_snapshot'), + targetRouteIdSnapshot: text('target_route_id_snapshot'), + targetRouteOwnerGenerationSnapshot: integer('target_route_owner_generation_snapshot'), + workingDirSnapshot: text('working_dir_snapshot'), + remoteHostIdSnapshot: text('remote_host_id_snapshot'), + worktreePathSnapshot: text('worktree_path_snapshot'), + deliveryOutboxId: text('delivery_outbox_id').references(() => botDeliveryOutbox.id, { + onDelete: 'set null', + }), + deliveryStatus: text('delivery_status', { + enum: ['not-requested', 'enqueue-failed', 'queued'], + }) + .notNull() + .default('not-requested'), + deliveryError: text('delivery_error'), + /** Result captured before delivery/archive so restart recovery cannot lose completion. */ + resultTextSnapshot: text('result_text_snapshot'), + /** Structured, transport-neutral outputs extracted before task archival. */ + outputArtifactsJson: text('output_artifacts_json').notNull().default('[]'), + /** Runtime/deadline/budget failure owned by the Bot Automation layer. */ + errorMessage: text('error_message'), + /** Immutable profile/capability/workspace/delegation/deadline plan for this fire. */ + executionPlanJson: text('execution_plan_json').notNull().default('{}'), + status: text('status', { + enum: [ + 'claimed', + 'running', + 'completing', + 'success', + 'failed', + 'aborted', + 'interrupted', + 'skipped', + 'unknown', + ], + }) + .notNull() + .default('claimed'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), + finishedAt: integer('finished_at'), + }, + (t) => ({ + uniqScheduleRun: uniqueIndex('uniq_bot_automation_runs_schedule_run').on(t.scheduleRunId), + idxAutomationCreated: index('idx_bot_automation_runs_link_created').on( + t.automationLinkId, + t.createdAt, + ), + }), +); + /** * embedding-host (Phase 1.1): 待处理 embedding 任务队列。 * @@ -1435,6 +2064,9 @@ export const rightSidebarTabs = sqliteTable( uniqSubagents: uniqueIndex('right_sidebar_tabs_subagents_singleton_idx') .on(t.sessionId) .where(sql`${t.kind} = 'subagents'`), + uniqBotDelegations: uniqueIndex('right_sidebar_tabs_bot_delegations_singleton_idx') + .on(t.sessionId) + .where(sql`${t.kind} = 'bot-delegations'`), }), ); diff --git a/apps/desktop/src/main/localDb/worker/opHandlers/__tests__/botTx.test.ts b/apps/desktop/src/main/localDb/worker/opHandlers/__tests__/botTx.test.ts new file mode 100644 index 0000000000..7051c8f9dc --- /dev/null +++ b/apps/desktop/src/main/localDb/worker/opHandlers/__tests__/botTx.test.ts @@ -0,0 +1,101 @@ +import Database from 'better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { tx } from '../tx.js'; + +describe('Bot named worker transactions', () => { + let db: Database.Database; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + db.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, title TEXT NOT NULL, working_dir TEXT, workspace_kind TEXT NOT NULL, + model TEXT NOT NULL, effort TEXT NOT NULL, permission_mode TEXT NOT NULL, status TEXT NOT NULL, + sdk_session_id TEXT, total_token_usage INTEGER NOT NULL, total_cost_usd REAL NOT NULL, + context_tokens INTEGER NOT NULL, context_window INTEGER NOT NULL, fast_mode INTEGER NOT NULL, + plan_mode_enabled INTEGER NOT NULL, cleared_at INTEGER, pinned_at INTEGER, user_send_at INTEGER, + agent_kind TEXT NOT NULL, orca_role TEXT, parent_session_id TEXT, forked_at_message_id TEXT, + worktree_path TEXT, extra_dirs TEXT NOT NULL, remote_host_id TEXT, provider_id TEXT, + source TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL + ); + CREATE TABLE bot_profiles ( + id TEXT PRIMARY KEY, display_name TEXT NOT NULL, description TEXT NOT NULL, avatar TEXT NOT NULL, + avatar_color TEXT NOT NULL, status TEXT NOT NULL, current_version INTEGER NOT NULL, + canonical_session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL + ); + CREATE TABLE bot_profile_versions ( + id TEXT PRIMARY KEY, bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + version INTEGER NOT NULL, identity_source TEXT NOT NULL, capabilities_json TEXT NOT NULL, + created_at INTEGER NOT NULL, UNIQUE(bot_id, version) + ); + CREATE TABLE bot_channels ( + id TEXT PRIMARY KEY, bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + kind TEXT NOT NULL, enabled INTEGER NOT NULL, config_json TEXT NOT NULL, + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL + ); + CREATE TABLE bot_session_links ( + id TEXT PRIMARY KEY, bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + session_id TEXT NOT NULL UNIQUE REFERENCES sessions(id) ON DELETE CASCADE, + profile_version INTEGER NOT NULL, role TEXT NOT NULL, channel_id TEXT, route_key TEXT, + created_at INTEGER NOT NULL, archived_at INTEGER + ); + CREATE UNIQUE INDEX uniq_bot_canonical ON bot_session_links(bot_id) WHERE role = 'canonical'; + CREATE TABLE bot_lifecycle_events ( + id TEXT PRIMARY KEY, bot_id TEXT NOT NULL REFERENCES bot_profiles(id) ON DELETE CASCADE, + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, event_type TEXT NOT NULL, + payload_json TEXT NOT NULL, created_at INTEGER NOT NULL + ); + `); + }); + + afterEach(() => db.close()); + + it('creates a local-first profile and atomically installs and renews canonical sessions', () => { + tx(db, { name: 'bots.createProfile', args: { + id: 'bot-1', displayName: 'Hermes', description: '', avatar: '🤖', avatarColor: 'violet', + identitySource: 'identity', capabilitiesJson: '{}', now: 1, + } }); + expect(db.prepare('SELECT kind FROM bot_channels').pluck().all()).toEqual(['local']); + + const session = (id: string, now: number) => ({ + id, title: 'Hermes', workingDir: `/tmp/${id}`, workspaceKind: 'dialogue', + model: 'claude-sonnet-4-6', effort: 'high', permissionMode: 'ask', agentKind: 'cc', + remoteHostId: null, providerId: null, extraDirs: '[]', source: 'bot', + createdAt: now, updatedAt: now, + }); + expect(tx(db, { name: 'bots.replaceCanonicalSession', args: { + botId: 'bot-1', expectedCanonicalSessionId: null, expectedProfileVersion: 1, + session: session('session-1', 2), now: 2, + } })).toMatchObject({ created: true, canonicalSessionId: 'session-1' }); + expect(tx(db, { name: 'bots.replaceCanonicalSession', args: { + botId: 'bot-1', expectedCanonicalSessionId: 'session-1', expectedProfileVersion: 1, + session: session('session-2', 3), now: 3, + } })).toEqual({ + created: true, canonicalSessionId: 'session-2', archivedCanonicalSessionId: 'session-1', + }); + expect(db.prepare('SELECT status FROM sessions WHERE id = ?').pluck().get('session-1')) + .toBe('archived'); + expect(db.prepare("SELECT session_id FROM bot_session_links WHERE role = 'canonical'").pluck().get()) + .toBe('session-2'); + }); + + it('does not insert a losing canonical CAS session', () => { + tx(db, { name: 'bots.createProfile', args: { + id: 'bot-1', displayName: 'Hermes', description: '', avatar: '🤖', avatarColor: 'violet', + identitySource: 'identity', capabilitiesJson: '{}', now: 1, + } }); + const result = tx(db, { name: 'bots.replaceCanonicalSession', args: { + botId: 'bot-1', expectedCanonicalSessionId: 'stale', expectedProfileVersion: 1, + session: { + id: 'loser', title: 'Hermes', workingDir: '/tmp/loser', workspaceKind: 'dialogue', + model: 'claude-sonnet-4-6', effort: 'high', permissionMode: 'ask', agentKind: 'cc', + remoteHostId: null, providerId: null, extraDirs: '[]', source: 'bot', createdAt: 2, updatedAt: 2, + }, now: 2, + } }); + expect(result).toEqual({ created: false, canonicalSessionId: null, archivedCanonicalSessionId: null }); + expect(db.prepare('SELECT COUNT(*) FROM sessions').pluck().get()).toBe(0); + }); +}); diff --git a/apps/desktop/src/main/localDb/worker/opHandlers/tx.ts b/apps/desktop/src/main/localDb/worker/opHandlers/tx.ts index ee50faedc2..0acf6d34e9 100644 --- a/apps/desktop/src/main/localDb/worker/opHandlers/tx.ts +++ b/apps/desktop/src/main/localDb/worker/opHandlers/tx.ts @@ -83,6 +83,56 @@ export function tx(db: Database.Database, args: unknown): unknown { return imDeleteBindings(db, txArgs); case 'im.replaceBinding': return imReplaceBinding(db, txArgs); + case 'bots.createProfile': + return botsCreateProfile(db, txArgs); + case 'bots.updateProfile': + return botsUpdateProfile(db, txArgs); + case 'bots.replaceCanonicalSession': + return botsReplaceCanonicalSession(db, txArgs); + case 'bots.createRouteSession': + return botsCreateRouteSession(db, txArgs); + case 'bots.setRouteStatus': + return botsSetRouteStatus(db, txArgs); + case 'bots.prepareRuntime': + return botsPrepareRuntime(db, txArgs); + case 'bots.finishRuntime': + return botsFinishRuntime(db, txArgs); + case 'bots.createAutomationSession': + return botsCreateAutomationSession(db, txArgs); + case 'bots.finalizeAutomationRun': + return botsFinalizeAutomationRun(db, txArgs); + case 'bots.finishDelegation': + return botsFinishDelegation(db, txArgs); + case 'bots.createDelegation': + return botsCreateDelegation(db, txArgs); + case 'bots.retainWorkspaceLeases': + return botsRetainWorkspaceLeases(db, txArgs); + case 'bots.finalizeWorkspaceLeaseRelease': + return botsFinalizeWorkspaceLeaseRelease(db, txArgs); + case 'bots.attachWorkspaceLease': + return botsAttachWorkspaceLease(db, txArgs); + case 'bots.pauseLifecycle': + return botsPauseLifecycle(db, txArgs); + case 'bots.resumeLifecycle': + return botsResumeLifecycle(db, txArgs); + case 'bots.archiveLifecycle': + return botsArchiveLifecycle(db, txArgs); + case 'bots.deleteProfile': + return botsDeleteProfile(db, txArgs); + case 'bots.linkSession': + return botsLinkSession(db, txArgs); + case 'bots.upsertProjectBinding': + return botsUpsertProjectBinding(db, txArgs); + case 'bots.upsertChannel': + return botsUpsertChannel(db, txArgs); + case 'bots.migrateLegacyProfile': + return botsMigrateLegacyProfile(db, txArgs); + case 'bots.importBehaviorBundle': + return botsImportBehaviorBundle(db, txArgs); + case 'bots.applyImMigration': + return botsApplyImMigration(db, txArgs); + case 'bots.beginImMigrationRollback': + return botsBeginImMigrationRollback(db, txArgs); case 'wechatActivateBindingEpoch': return wechatActivateBindingEpoch(db, txArgs); case 'wechatCommitPollBatch': @@ -124,6 +174,1293 @@ export function tx(db: Database.Database, args: unknown): unknown { } } +function botsCreateProfile(db: Database.Database, args: unknown): void { + const p = asRecord(args, 'bots.createProfile args'); + const id = expectString(p.id, 'id'); + const now = expectNumber(p.now, 'now'); + db.transaction(() => { + db.prepare(`INSERT INTO bot_profiles + (id, display_name, description, avatar, avatar_color, status, current_version, + canonical_session_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'active', 1, NULL, ?, ?)`) + .run(id, expectString(p.displayName, 'displayName'), expectString(p.description, 'description'), + expectString(p.avatar, 'avatar'), expectString(p.avatarColor, 'avatarColor'), now, now); + db.prepare(`INSERT INTO bot_profile_versions + (id, bot_id, version, identity_source, capabilities_json, created_at) + VALUES (?, ?, 1, ?, ?, ?)`) + .run(`${id}:v1`, id, expectString(p.identitySource, 'identitySource'), + expectString(p.capabilitiesJson, 'capabilitiesJson'), now); + db.prepare(`INSERT INTO bot_channels + (id, bot_id, kind, enabled, config_json, created_at, updated_at) + VALUES (?, ?, 'local', 1, '{}', ?, ?)`) + .run(`${id}:local`, id, now, now); + db.prepare(`INSERT INTO bot_lifecycle_events + (id, bot_id, session_id, event_type, payload_json, created_at) + VALUES (?, ?, NULL, 'created', '{}', ?)`) + .run(`${id}:created:${now}`, id, now); + })(); +} + +function botsUpdateProfile(db: Database.Database, args: unknown): { currentVersion: number } { + const p = asRecord(args, 'bots.updateProfile args'); + const id = expectString(p.id, 'id'); + const expectedVersion = expectNumber(p.expectedCurrentVersion, 'expectedCurrentVersion'); + const now = expectNumber(p.now, 'now'); + return db.transaction(() => { + const current = db.prepare('SELECT current_version AS currentVersion FROM bot_profiles WHERE id = ?') + .get(id) as { currentVersion: number } | undefined; + if (!current) throw Object.assign(new Error('Bot 不存在'), { code: 'NOT_FOUND' }); + if (current.currentVersion !== expectedVersion) { + throw Object.assign(new Error('Bot Profile 已被另一处更新,请刷新后重试'), { code: 'PRECONDITION_FAILED' }); + } + const fields = ['updated_at = ?']; + const values: unknown[] = [now]; + for (const [key, column] of [ + ['displayName', 'display_name'], ['description', 'description'], ['avatar', 'avatar'], + ['avatarColor', 'avatar_color'], ['status', 'status'], + ] as const) { + if (p[key] !== undefined) { + fields.push(`${column} = ?`); + values.push(expectString(p[key], key)); + } + } + const changed = p.profileContentChanged === true; + const nextVersion = changed ? expectedVersion + 1 : expectedVersion; + if (changed) { + fields.push('current_version = ?'); + values.push(nextVersion); + } + values.push(id); + db.prepare(`UPDATE bot_profiles SET ${fields.join(', ')} WHERE id = ?`).run(...values); + if (changed) { + db.prepare(`INSERT INTO bot_profile_versions + (id, bot_id, version, identity_source, capabilities_json, created_at) + VALUES (?, ?, ?, ?, ?, ?)`) + .run(`${id}:v${nextVersion}`, id, nextVersion, expectString(p.identitySource, 'identitySource'), + expectString(p.capabilitiesJson, 'capabilitiesJson'), now); + } + return { currentVersion: nextVersion }; + })(); +} + +function botsReplaceCanonicalSession( + db: Database.Database, + args: unknown, +): { created: boolean; canonicalSessionId: string | null; archivedCanonicalSessionId: string | null } { + const p = asRecord(args, 'bots.replaceCanonicalSession args'); + const botId = expectString(p.botId, 'botId'); + const expectedCanonical = p.expectedCanonicalSessionId === null + ? null : expectString(p.expectedCanonicalSessionId, 'expectedCanonicalSessionId'); + const expectedVersion = expectNumber(p.expectedProfileVersion, 'expectedProfileVersion'); + const s = asRecord(p.session, 'session'); + const now = expectNumber(p.now, 'now'); + return db.transaction(() => { + const bot = db.prepare(`SELECT canonical_session_id AS canonicalSessionId, + current_version AS currentVersion FROM bot_profiles WHERE id = ?`).get(botId) as + | { canonicalSessionId: string | null; currentVersion: number } | undefined; + if (!bot) throw Object.assign(new Error('Bot 不存在'), { code: 'NOT_FOUND' }); + if (bot.canonicalSessionId !== expectedCanonical) { + return { created: false, canonicalSessionId: bot.canonicalSessionId, archivedCanonicalSessionId: null }; + } + if (bot.currentVersion !== expectedVersion) { + throw Object.assign(new Error('Bot Profile 已更新,请刷新后再 Renew'), { code: 'PRECONDITION_FAILED' }); + } + const version = db.prepare('SELECT version FROM bot_profile_versions WHERE bot_id = ? AND version = ?') + .get(botId, bot.currentVersion) as { version: number } | undefined; + if (!version) throw Object.assign(new Error('Bot 当前 Profile 版本不存在'), { code: 'PRECONDITION_FAILED' }); + const sessionId = expectString(s.id, 'session.id'); + db.prepare(`INSERT INTO sessions + (id, title, working_dir, workspace_kind, model, effort, permission_mode, status, + sdk_session_id, total_token_usage, total_cost_usd, context_tokens, context_window, + fast_mode, plan_mode_enabled, cleared_at, pinned_at, user_send_at, agent_kind, + orca_role, parent_session_id, forked_at_message_id, worktree_path, extra_dirs, + remote_host_id, provider_id, source, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'active', NULL, 0, 0, 0, 0, ?, 0, NULL, NULL, NULL, + ?, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?)`) + .run(sessionId, expectString(s.title, 'session.title'), nullableString(s.workingDir), + expectString(s.workspaceKind, 'session.workspaceKind'), expectString(s.model, 'session.model'), + expectString(s.effort, 'session.effort'), expectString(s.permissionMode, 'session.permissionMode'), + s.fastMode === true ? 1 : 0, expectString(s.agentKind, 'session.agentKind'), + expectString(s.extraDirs, 'session.extraDirs'), + nullableString(s.remoteHostId), nullableString(s.providerId), expectString(s.source, 'session.source'), + expectNumber(s.createdAt, 'session.createdAt'), expectNumber(s.updatedAt, 'session.updatedAt')); + let archived: string | null = null; + let missingCanonicalSessionId: string | null = null; + if (bot.canonicalSessionId) { + const previous = db.prepare('SELECT source, status FROM sessions WHERE id = ?') + .get(bot.canonicalSessionId) as { source: string; status: string } | undefined; + if (!previous) missingCanonicalSessionId = bot.canonicalSessionId; + db.prepare(`UPDATE bot_session_links SET role = 'history', archived_at = ? + WHERE bot_id = ? AND session_id = ? AND role = 'canonical'`) + .run(now, botId, bot.canonicalSessionId); + if (previous?.source === 'bot' && previous.status !== 'deleted') { + db.prepare("UPDATE sessions SET status = 'archived', updated_at = ? WHERE id = ?") + .run(now, bot.canonicalSessionId); + archived = bot.canonicalSessionId; + } + } + db.prepare(`INSERT INTO bot_session_links + (id, bot_id, session_id, profile_version, role, channel_id, route_key, created_at, archived_at) + VALUES (?, ?, ?, ?, 'canonical', NULL, NULL, ?, NULL)`) + .run(`${botId}:${sessionId}`, botId, sessionId, version.version, now); + db.prepare('UPDATE bot_profiles SET canonical_session_id = ?, updated_at = ? WHERE id = ?') + .run(sessionId, now, botId); + db.prepare(`INSERT INTO bot_lifecycle_events + (id, bot_id, session_id, event_type, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?)`) + .run(`${botId}:canonical-created:${sessionId}`, botId, sessionId, + missingCanonicalSessionId + ? 'canonical-recovered' + : bot.canonicalSessionId + ? 'canonical-renewed' + : 'canonical-created', + JSON.stringify({ + previousCanonicalSessionId: bot.canonicalSessionId, + missingCanonicalSessionId, + profileVersion: version.version, + }), now); + return { created: true, canonicalSessionId: sessionId, archivedCanonicalSessionId: archived }; + })(); +} + +function insertBotSession(db: Database.Database, s: Record): void { + db.prepare(`INSERT INTO sessions + (id, title, working_dir, workspace_kind, model, effort, permission_mode, status, + sdk_session_id, total_token_usage, total_cost_usd, context_tokens, context_window, + fast_mode, plan_mode_enabled, cleared_at, pinned_at, user_send_at, agent_kind, + orca_role, parent_session_id, forked_at_message_id, worktree_path, extra_dirs, + remote_host_id, provider_id, source, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'active', NULL, 0, 0, 0, 0, ?, 0, NULL, NULL, NULL, + ?, NULL, ?, NULL, NULL, ?, ?, ?, ?, ?, ?)`) + .run(expectString(s.id, 'session.id'), expectString(s.title, 'session.title'), + nullableString(s.workingDir), expectString(s.workspaceKind, 'session.workspaceKind'), + expectString(s.model, 'session.model'), expectString(s.effort, 'session.effort'), + expectString(s.permissionMode, 'session.permissionMode'), s.fastMode === true ? 1 : 0, + expectString(s.agentKind, 'session.agentKind'), + nullableString(s.parentSessionId), + expectString(s.extraDirs, 'session.extraDirs'), nullableString(s.remoteHostId), nullableString(s.providerId), + expectString(s.source, 'session.source'), expectNumber(s.createdAt, 'session.createdAt'), + expectNumber(s.updatedAt, 'session.updatedAt')); +} + +function botsCreateRouteSession( + db: Database.Database, + args: unknown, +): { created: boolean; sessionId: string; archivedRuntimeSessionId: string | null } { + const p = asRecord(args, 'bots.createRouteSession args'); + const routeId = expectString(p.routeId, 'routeId'); + const botId = expectString(p.botId, 'botId'); + const ownerDeviceId = expectString(p.ownerDeviceId, 'ownerDeviceId'); + const ownerGeneration = expectNumber(p.ownerGeneration, 'ownerGeneration'); + const expectedCurrentSessionId = nullableString(p.expectedCurrentSessionId); + const profileVersion = expectNumber(p.profileVersion, 'profileVersion'); + const forceRenew = p.forceRenew === true; + const s = asRecord(p.session, 'session'); + const candidateSessionId = expectString(s.id, 'session.id'); + const now = expectNumber(p.now, 'now'); + return db.transaction(() => { + const route = db.prepare(`SELECT status, owner_device_id AS ownerDeviceId, + owner_generation AS ownerGeneration, current_session_id AS currentSessionId + FROM bot_routes WHERE id = ?`).get(routeId) as + | { status: string; ownerDeviceId: string | null; ownerGeneration: number; currentSessionId: string | null } + | undefined; + if (!route || route.status !== 'active' || route.ownerDeviceId !== ownerDeviceId + || route.ownerGeneration !== ownerGeneration) { + throw Object.assign(new Error('Bot Route ownership changed while creating its task'), { code: 'PRECONDITION_FAILED' }); + } + if (route.currentSessionId !== expectedCurrentSessionId) { + throw Object.assign(new Error('Bot Route task changed while creating its replacement'), { code: 'PRECONDITION_FAILED' }); + } + let archivedRuntimeSessionId: string | null = null; + if (route.currentSessionId) { + const currentSession = db.prepare(`SELECT s.source, s.status FROM sessions s + JOIN bot_session_links l ON l.session_id = s.id + WHERE s.id = ? AND l.bot_id = ? LIMIT 1`).get(route.currentSessionId, botId) as + | { source: string; status: string } | undefined; + if (!forceRenew && currentSession?.source === 'bot' && currentSession.status === 'active') { + return { created: false, sessionId: route.currentSessionId, archivedRuntimeSessionId: null }; + } + db.prepare(`UPDATE bot_session_links SET role = 'history', channel_id = NULL, + route_key = NULL, archived_at = ? WHERE bot_id = ? AND session_id = ?`) + .run(now, botId, route.currentSessionId); + if (currentSession?.source === 'bot' && currentSession.status !== 'deleted') { + db.prepare("UPDATE sessions SET status = 'archived', updated_at = ? WHERE id = ?") + .run(now, route.currentSessionId); + archivedRuntimeSessionId = route.currentSessionId; + } + } + insertBotSession(db, s); + db.prepare(`INSERT INTO bot_session_links + (id, bot_id, session_id, profile_version, role, channel_id, route_key, created_at, archived_at) + VALUES (?, ?, ?, ?, 'route', ?, ?, ?, NULL)`) + .run(`${botId}:${candidateSessionId}`, botId, candidateSessionId, profileVersion, + expectString(p.channelId, 'channelId'), expectString(p.routeKey, 'routeKey'), now); + const nextOwnerGeneration = forceRenew ? ownerGeneration + 1 : ownerGeneration; + const write = db.prepare(`UPDATE bot_routes SET current_session_id = ?, owner_generation = ?, last_activity_at = ?, updated_at = ? + WHERE id = ? AND owner_generation = ? AND owner_device_id = ?`) + .run(candidateSessionId, nextOwnerGeneration, now, now, routeId, ownerGeneration, ownerDeviceId); + if (write.changes !== 1) throw Object.assign(new Error('Bot Route ownership changed while creating its task'), { code: 'PRECONDITION_FAILED' }); + db.prepare(`INSERT INTO bot_lifecycle_events + (id, bot_id, session_id, event_type, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?)`) + .run(`${botId}:route-session-${forceRenew ? 'renewed' : 'created'}:${candidateSessionId}`, + botId, candidateSessionId, forceRenew ? 'route-session-renewed' : 'route-session-created', + JSON.stringify({ routeId, routeKey: p.routeKey, profileVersion, previousSessionId: route.currentSessionId }), now); + return { created: true, sessionId: candidateSessionId, archivedRuntimeSessionId }; + })(); +} + +function botsSetRouteStatus(db: Database.Database, args: unknown): void { + const p = asRecord(args, 'bots.setRouteStatus args'); + const routeId = expectString(p.routeId, 'routeId'); + const botId = expectString(p.botId, 'botId'); + const expectedGeneration = expectNumber(p.expectedOwnerGeneration, 'expectedOwnerGeneration'); + const status = expectString(p.status, 'status'); + const now = expectNumber(p.now, 'now'); + const currentSessionId = nullableString(p.currentSessionId); + db.transaction(() => { + const write = db.prepare(`UPDATE bot_routes SET status = ?, owner_device_id = ?, + owner_generation = ?, current_session_id = ?, updated_at = ? + WHERE id = ? AND owner_generation = ?`).run( + status, + status === 'recovering' ? nullableString(p.currentOwnerDeviceId) : null, + expectedGeneration + 1, + status === 'archived' ? null : currentSessionId, + now, + routeId, + expectedGeneration, + ); + if (write.changes !== 1) throw Object.assign(new Error('Bot Route ownership changed concurrently'), { code: 'PRECONDITION_FAILED' }); + if (status === 'archived' && currentSessionId) { + db.prepare(`UPDATE bot_session_links SET role = 'history', channel_id = NULL, + route_key = NULL, archived_at = ? WHERE bot_id = ? AND session_id = ?`) + .run(now, botId, currentSessionId); + db.prepare("UPDATE sessions SET status = 'archived', updated_at = ? WHERE id = ? AND source = 'bot' AND status != 'deleted'") + .run(now, currentSessionId); + } + })(); +} + +function botsPrepareRuntime(db: Database.Database, args: unknown): void { + const p = asRecord(args, 'bots.prepareRuntime args'); + const s = asRecord(p.snapshot, 'snapshot'); + const preparedAt = expectNumber(s.preparedAt, 'snapshot.preparedAt'); + db.transaction(() => { + db.prepare(`INSERT INTO bot_runtime_snapshots + (id, bot_id, session_id, profile_version, agent_kind, working_dir, memory_scope_key, + configured_json, resolved_json, status, prepared_at, applied_at, failed_at, failure_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'prepared', ?, NULL, NULL, NULL)`) + .run(expectString(s.id, 'snapshot.id'), expectString(s.botId, 'snapshot.botId'), + expectString(s.sessionId, 'snapshot.sessionId'), expectNumber(s.profileVersion, 'snapshot.profileVersion'), + expectString(s.agentKind, 'snapshot.agentKind'), expectString(s.workingDir, 'snapshot.workingDir'), + nullableString(s.memoryScopeKey), expectString(s.configuredJson, 'snapshot.configuredJson'), + expectString(s.resolvedJson, 'snapshot.resolvedJson'), preparedAt); + db.prepare(`INSERT INTO bot_lifecycle_events + (id, bot_id, session_id, event_type, payload_json, created_at) + VALUES (?, ?, ?, 'runtime-prepared', ?, ?)`) + .run(expectString(p.eventId, 'eventId'), expectString(s.botId, 'snapshot.botId'), + expectString(s.sessionId, 'snapshot.sessionId'), expectString(p.eventPayloadJson, 'eventPayloadJson'), preparedAt); + })(); +} + +function botsFinishRuntime(db: Database.Database, args: unknown): boolean { + const p = asRecord(args, 'bots.finishRuntime args'); + const status = expectString(p.status, 'status'); + const finishedAt = expectNumber(p.finishedAt, 'finishedAt'); + return db.transaction(() => { + const write = status === 'failed' + ? db.prepare(`UPDATE bot_runtime_snapshots SET status = 'failed', applied_at = NULL, + failed_at = ?, failure_json = ? WHERE id = ? AND status = 'prepared'`) + .run(finishedAt, nullableString(p.failureJson), expectString(p.snapshotId, 'snapshotId')) + : db.prepare(`UPDATE bot_runtime_snapshots SET status = ?, applied_at = ?, + failed_at = NULL, failure_json = NULL WHERE id = ? AND status = 'prepared'`) + .run(status, finishedAt, expectString(p.snapshotId, 'snapshotId')); + if (write.changes !== 1) return false; + db.prepare(`INSERT INTO bot_lifecycle_events + (id, bot_id, session_id, event_type, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?)`) + .run(expectString(p.eventId, 'eventId'), expectString(p.botId, 'botId'), + expectString(p.sessionId, 'sessionId'), expectString(p.eventType, 'eventType'), + expectString(p.eventPayloadJson, 'eventPayloadJson'), finishedAt); + return true; + })(); +} + +function botsCreateAutomationSession(db: Database.Database, args: unknown): void { + const p = asRecord(args, 'bots.createAutomationSession args'); + const s = asRecord(p.session, 'session'); + const botId = expectString(p.botId, 'botId'); + const localChannelId = expectString(p.localChannelId, 'localChannelId'); + const now = expectNumber(p.now, 'now'); + db.transaction(() => { + db.prepare(`INSERT INTO bot_channels + (id, bot_id, kind, enabled, config_json, created_at, updated_at) + VALUES (?, ?, 'local', 1, '{}', ?, ?) + ON CONFLICT(id) DO NOTHING`).run(localChannelId, botId, now, now); + insertBotSession(db, s); + const sessionId = expectString(s.id, 'session.id'); + db.prepare(`INSERT INTO bot_session_links + (id, bot_id, session_id, profile_version, role, channel_id, route_key, created_at, archived_at) + VALUES (?, ?, ?, ?, 'route', ?, ?, ?, NULL)`) + .run(`${botId}:${sessionId}`, botId, sessionId, expectNumber(p.profileVersion, 'profileVersion'), + localChannelId, expectString(p.routeKey, 'routeKey'), now); + const write = db.prepare(`UPDATE bot_automation_runs SET session_id = ?, working_dir_snapshot = ?, + remote_host_id_snapshot = ?, updated_at = ? WHERE id = ?`) + .run(sessionId, expectString(p.workingDirSnapshot, 'workingDirSnapshot'), + nullableString(p.remoteHostIdSnapshot), now, expectString(p.automationRunId, 'automationRunId')); + if (write.changes !== 1) { + throw Object.assign(new Error('Bot Automation run is unavailable'), { code: 'NOT_FOUND' }); + } + })(); +} + +function botsFinalizeAutomationRun(db: Database.Database, args: unknown): void { + const p = asRecord(args, 'bots.finalizeAutomationRun args'); + const finishedAt = expectNumber(p.finishedAt, 'finishedAt'); + db.transaction(() => { + const write = db.prepare(`UPDATE bot_automation_runs SET status = ?, error_message = ?, + workspace_lease_id = ?, worktree_path_snapshot = ?, updated_at = ?, finished_at = ? WHERE id = ?`) + .run(expectString(p.status, 'status'), nullableString(p.errorMessage), + nullableString(p.workspaceLeaseId), nullableString(p.worktreePathSnapshot), finishedAt, finishedAt, + expectString(p.automationRunId, 'automationRunId')); + if (write.changes !== 1) { + throw Object.assign(new Error('Bot Automation run is unavailable'), { code: 'NOT_FOUND' }); + } + db.prepare(`UPDATE bot_session_links SET role = 'history', channel_id = NULL, + route_key = NULL, archived_at = ? WHERE session_id = ?`) + .run(finishedAt, expectString(p.sessionId, 'sessionId')); + })(); +} + +function botsFinishDelegation( + db: Database.Database, + args: unknown, +): { id: string; parentSessionId: string | null; childSessionId: string | null; status: string } | null { + const p = asRecord(args, 'bots.finishDelegation args'); + return db.transaction(() => { + const values: unknown[] = [ + expectString(p.status, 'status'), nullableString(p.resultSummary), + expectString(p.outputArtifactsJson, 'outputArtifactsJson'), nullableString(p.lastError), + ]; + const tokenSet = p.tokensUsed === undefined ? '' : ', tokens_used = ?'; + if (p.tokensUsed !== undefined) values.push(expectNumber(p.tokensUsed, 'tokensUsed')); + const completedAt = expectNumber(p.completedAt, 'completedAt'); + values.push(completedAt, completedAt, expectString(p.delegationId, 'delegationId')); + const row = db.prepare(`UPDATE bot_delegations SET status = ?, result_summary = ?, output_artifacts_json = ?, last_error = ? + ${tokenSet}, completed_at = ?, updated_at = ? + WHERE id = ? AND status IN ('queued','running','waiting') + RETURNING id, parent_session_id AS parentSessionId, child_session_id AS childSessionId, status`) + .get(...values) as + | { id: string; parentSessionId: string | null; childSessionId: string | null; status: string } + | undefined; + if (!row) return null; + if (row.childSessionId) { + db.prepare(`UPDATE bot_session_links SET role = 'history', channel_id = NULL, + route_key = NULL, archived_at = ? WHERE session_id = ?`) + .run(completedAt, row.childSessionId); + } + return row; + })(); +} + +function botsCreateDelegation(db: Database.Database, args: unknown): void { + const p = asRecord(args, 'bots.createDelegation args'); + const d = asRecord(p.delegation, 'delegation'); + const requestingBotId = expectString(d.requestingBotId, 'delegation.requestingBotId'); + const maxActiveChildren = expectNumber(p.maxActiveChildren, 'maxActiveChildren'); + const createdAt = expectNumber(d.createdAt, 'delegation.createdAt'); + db.transaction(() => { + const count = db.prepare(`SELECT COUNT(*) AS count FROM bot_delegations + WHERE requesting_bot_id = ? AND status IN ('queued','running','waiting')`) + .get(requestingBotId) as { count: number }; + if (count.count >= maxActiveChildren) throw new Error('BOT_DELEGATION_CONCURRENCY_LIMIT'); + const targetBotId = expectString(d.targetBotId, 'delegation.targetBotId'); + const localChannelId = expectString(p.localChannelId, 'localChannelId'); + db.prepare(`INSERT INTO bot_channels + (id, bot_id, kind, enabled, config_json, created_at, updated_at) + VALUES (?, ?, 'local', 1, '{}', ?, ?) ON CONFLICT(id) DO NOTHING`) + .run(localChannelId, targetBotId, createdAt, createdAt); + const s = asRecord(p.session, 'session'); + insertBotSession(db, s); + const childSessionId = expectString(d.childSessionId, 'delegation.childSessionId'); + const delegationId = expectString(d.id, 'delegation.id'); + db.prepare(`INSERT INTO bot_session_links + (id, bot_id, session_id, profile_version, role, channel_id, route_key, created_at, archived_at) + VALUES (?, ?, ?, ?, 'route', ?, ?, ?, NULL)`) + .run(`${targetBotId}:${childSessionId}`, targetBotId, childSessionId, + expectNumber(d.targetProfileVersion, 'delegation.targetProfileVersion'), localChannelId, + `delegation:${delegationId}`, createdAt); + db.prepare(`INSERT INTO bot_delegations + (id, requesting_bot_id, target_bot_id, parent_session_id, child_session_id, objective, + context_refs_json, artifact_refs_json, permission_snapshot_json, lineage_json, + target_profile_version, depth, budget_tokens, tokens_used, status, result_summary, + last_error, created_at, accepted_at, completed_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 'queued', NULL, NULL, ?, NULL, NULL, ?)`) + .run(delegationId, requestingBotId, targetBotId, + expectString(d.parentSessionId, 'delegation.parentSessionId'), childSessionId, + expectString(d.objective, 'delegation.objective'), expectString(d.contextRefsJson, 'delegation.contextRefsJson'), + expectString(d.artifactRefsJson, 'delegation.artifactRefsJson'), + expectString(d.permissionSnapshotJson, 'delegation.permissionSnapshotJson'), + expectString(d.lineageJson, 'delegation.lineageJson'), + expectNumber(d.targetProfileVersion, 'delegation.targetProfileVersion'), + expectNumber(d.depth, 'delegation.depth'), d.budgetTokens === null ? null : expectNumber(d.budgetTokens, 'delegation.budgetTokens'), + createdAt, createdAt); + })(); +} + +function botsRetainWorkspaceLeases(db: Database.Database, args: unknown): number { + const p = asRecord(args, 'bots.retainWorkspaceLeases args'); + const botId = expectString(p.botId, 'botId'); + const at = expectNumber(p.at, 'at'); + return db.transaction(() => { + const unstable = db.prepare(`SELECT 1 FROM bot_workspace_leases + WHERE bot_id = ? AND status IN ('acquiring','releasing') LIMIT 1`).get(botId); + if (unstable) throw Object.assign(new Error('Bot workspace 正在创建或释放,请等待状态稳定后重试'), { code: 'PRECONDITION_FAILED' }); + const leases = db.prepare(`SELECT id, generation, anchor_session_id AS anchorSessionId + FROM bot_workspace_leases WHERE bot_id = ? AND status IN ('active','error')`).all(botId) as + Array<{ id: string; generation: number; anchorSessionId: string | null }>; + for (const lease of leases) { + db.prepare('UPDATE bot_workspace_attachments SET detached_at = ? WHERE lease_id = ? AND detached_at IS NULL') + .run(at, lease.id); + const write = db.prepare(`UPDATE bot_workspace_leases SET status = 'retained', released_at = ?, updated_at = ? + WHERE id = ? AND generation = ? AND status IN ('active','error')`) + .run(at, at, lease.id, lease.generation); + if (write.changes !== 1) throw Object.assign(new Error('Bot workspace lease 已被另一处操作更新'), { code: 'PRECONDITION_FAILED' }); + db.prepare(`INSERT INTO bot_lifecycle_events + (id, bot_id, session_id, event_type, payload_json, created_at) VALUES (?, ?, ?, 'workspace-lease-retained', ?, ?)`) + .run(`${botId}:workspace-retained:${lease.id}:${at}`, botId, lease.anchorSessionId, + JSON.stringify({ leaseId: lease.id, generation: lease.generation }), at); + } + return leases.length; + })(); +} + +function botsFinalizeWorkspaceLeaseRelease(db: Database.Database, args: unknown): void { + const p = asRecord(args, 'bots.finalizeWorkspaceLeaseRelease args'); + const leaseId = expectString(p.leaseId, 'leaseId'); + const generation = expectNumber(p.expectedGeneration, 'expectedGeneration'); + const releasedAt = expectNumber(p.releasedAt, 'releasedAt'); + db.transaction(() => { + const current = db.prepare('SELECT status, generation FROM bot_workspace_leases WHERE id = ?') + .get(leaseId) as { status: string; generation: number } | undefined; + if (current?.status !== 'releasing' || current.generation !== generation) { + throw Object.assign(new Error('Bot workspace lease 已被另一处操作更新'), { code: 'PRECONDITION_FAILED' }); + } + db.prepare('UPDATE bot_workspace_attachments SET detached_at = ? WHERE lease_id = ? AND detached_at IS NULL') + .run(releasedAt, leaseId); + db.prepare("UPDATE bot_workspace_leases SET status = 'released', released_at = ?, updated_at = ? WHERE id = ?") + .run(releasedAt, releasedAt, leaseId); + if (p.eventId !== undefined && p.eventType !== undefined) { + const requestedAnchorSessionId = nullableString(p.anchorSessionId); + const eventSessionId = requestedAnchorSessionId + && db.prepare('SELECT 1 FROM sessions WHERE id = ?').get(requestedAnchorSessionId) + ? requestedAnchorSessionId + : null; + db.prepare(`INSERT INTO bot_lifecycle_events + (id, bot_id, session_id, event_type, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?)`) + .run(expectString(p.eventId, 'eventId'), expectString(p.botId, 'botId'), eventSessionId, + expectString(p.eventType, 'eventType'), JSON.stringify({ leaseId, generation }), releasedAt); + } + })(); +} + +function botsAttachWorkspaceLease(db: Database.Database, args: unknown): void { + const p = asRecord(args, 'bots.attachWorkspaceLease args'); + const leaseId = expectString(p.leaseId, 'leaseId'); + const sessionId = expectString(p.sessionId, 'sessionId'); + const now = expectNumber(p.now, 'now'); + db.transaction(() => { + const conflict = db.prepare(`SELECT lease_id AS leaseId FROM bot_workspace_attachments + WHERE session_id = ? AND detached_at IS NULL LIMIT 1`).get(sessionId) as { leaseId: string } | undefined; + if (conflict && conflict.leaseId !== leaseId) { + throw Object.assign(new Error('Bot Session is already attached to another active workspace lease.'), { code: 'PRECONDITION_FAILED' }); + } + if (!conflict) { + db.prepare(`INSERT INTO bot_workspace_attachments + (id, lease_id, session_id, generation, access, created_at, detached_at) + VALUES (?, ?, ?, ?, 'read-write', ?, NULL)`) + .run(expectString(p.attachmentId, 'attachmentId'), leaseId, sessionId, + expectNumber(p.generation, 'generation'), now); + } + db.prepare(`UPDATE sessions SET working_dir = ?, workspace_kind = 'project', worktree_path = ?, + remote_host_id = ?, updated_at = ? WHERE id = ?`) + .run(expectString(p.workingDir, 'workingDir'), expectString(p.workingDir, 'workingDir'), + nullableString(p.remoteHostId), now, sessionId); + db.prepare('UPDATE bot_workspace_leases SET last_heartbeat_at = ?, updated_at = ? WHERE id = ?') + .run(now, now, leaseId); + })(); +} + +function botsPauseLifecycle(db: Database.Database, args: unknown): { routes: number; automations: number } { + const p = asRecord(args, 'bots.pauseLifecycle args'); + const botId = expectString(p.botId, 'botId'); + const at = expectNumber(p.at, 'at'); + return db.transaction(() => { + const routes = db.prepare(`UPDATE bot_routes SET suspended_status = status, status = 'paused', + owner_generation = owner_generation + 1, updated_at = ? + WHERE bot_id = ? AND status NOT IN ('paused','archived')`).run(at, botId).changes; + const automations = db.prepare(`UPDATE bot_automation_links SET suspended_status = status, + status = 'paused', updated_at = ? WHERE bot_id = ? AND status IN ('active','error')`) + .run(at, botId).changes; + const profile = db.prepare(`UPDATE bot_profiles SET status = 'paused', updated_at = ? + WHERE id = ? AND status = ?`) + .run(at, botId, expectString(p.expectedProfileStatus, 'expectedProfileStatus')); + if (profile.changes !== 1) throw Object.assign( + new Error('Bot 生命周期已被另一处操作更新'), + { code: 'PRECONDITION_FAILED' }, + ); + db.prepare(`INSERT INTO bot_lifecycle_events + (id, bot_id, session_id, event_type, payload_json, created_at) + VALUES (?, ?, ?, 'pause-requested', ?, ?)`) + .run(expectString(p.eventId, 'eventId'), botId, nullableString(p.canonicalSessionId), + JSON.stringify({ routes, automations }), at); + return { routes, automations }; + })(); +} + +function botsResumeLifecycle(db: Database.Database, args: unknown): { routes: number; automations: number } { + const p = asRecord(args, 'bots.resumeLifecycle args'); + const botId = expectString(p.botId, 'botId'); + const at = expectNumber(p.at, 'at'); + return db.transaction(() => { + const routes = db.prepare(`UPDATE bot_routes SET status = suspended_status, + suspended_status = NULL, updated_at = ? + WHERE bot_id = ? AND status = 'paused' AND suspended_status IS NOT NULL`).run(at, botId).changes; + const automations = db.prepare(`UPDATE bot_automation_links SET status = suspended_status, + suspended_status = NULL, updated_at = ? + WHERE bot_id = ? AND status = 'paused' AND suspended_status IS NOT NULL`).run(at, botId).changes; + const profile = db.prepare(`UPDATE bot_profiles SET status = 'active', updated_at = ? + WHERE id = ? AND status = ?`) + .run(at, botId, expectString(p.expectedProfileStatus, 'expectedProfileStatus')); + if (profile.changes !== 1) throw Object.assign( + new Error('Bot 生命周期已被另一处操作更新'), + { code: 'PRECONDITION_FAILED' }, + ); + db.prepare(`INSERT INTO bot_lifecycle_events + (id, bot_id, session_id, event_type, payload_json, created_at) + VALUES (?, ?, ?, 'resumed', ?, ?)`) + .run(expectString(p.eventId, 'eventId'), botId, nullableString(p.canonicalSessionId), + JSON.stringify({ routes, automations }), at); + return { routes, automations }; + })(); +} + +function botsArchiveLifecycle(db: Database.Database, args: unknown): { sessions: number } { + const p = asRecord(args, 'bots.archiveLifecycle args'); + const botId = expectString(p.botId, 'botId'); + const at = expectNumber(p.at, 'at'); + return db.transaction(() => { + const sessions = db.prepare(`UPDATE sessions SET status = 'archived', updated_at = ? + WHERE source = 'bot' AND id IN (SELECT session_id FROM bot_session_links WHERE bot_id = ?)`) + .run(at, botId).changes; + db.prepare("UPDATE bot_session_links SET role = 'history', archived_at = ? WHERE bot_id = ?") + .run(at, botId); + const profile = db.prepare(`UPDATE bot_profiles SET status = 'archived', canonical_session_id = NULL, + updated_at = ? WHERE id = ? AND status = ?`) + .run(at, botId, expectString(p.expectedProfileStatus, 'expectedProfileStatus')); + if (profile.changes !== 1) throw Object.assign(new Error('Bot 生命周期已被另一处操作更新'), { code: 'PRECONDITION_FAILED' }); + db.prepare(`INSERT INTO bot_lifecycle_events + (id, bot_id, session_id, event_type, payload_json, created_at) + VALUES (?, ?, ?, 'archived', ?, ?)`) + .run(expectString(p.eventId, 'eventId'), botId, nullableString(p.canonicalSessionId), + JSON.stringify({ worktreeDisposition: expectString(p.worktreeDisposition, 'worktreeDisposition'), sessions }), at); + return { sessions }; + })(); +} + +function botsDeleteProfile( + db: Database.Database, + args: unknown, +): { sessionIds: string[]; status: 'archived' | 'deleted' } { + const p = asRecord(args, 'bots.deleteProfile args'); + const botId = expectString(p.botId, 'botId'); + const sessionIds = [...new Set(expectArray(p.sessionIds, 'sessionIds').map((value, index) => + expectString(value, `sessionIds.${index}`), + ))]; + if (typeof p.keepTaskHistory !== 'boolean') { + throw new Error('keepTaskHistory must be a boolean'); + } + const keepTaskHistory = p.keepTaskHistory; + const at = expectNumber(p.at, 'at'); + const status: 'archived' | 'deleted' = keepTaskHistory ? 'archived' : 'deleted'; + return db.transaction(() => { + const profile = db.prepare('SELECT status FROM bot_profiles WHERE id = ?').get(botId) as + | { status: string } + | undefined; + if (!profile) throw Object.assign(new Error('Bot 不存在'), { code: 'NOT_FOUND' }); + if (profile.status !== 'archived') throw Object.assign( + new Error('永久删除前 Bot 必须已归档'), + { code: 'PRECONDITION_FAILED' }, + ); + + if (sessionIds.length > 0) { + const placeholders = sessionIds.map(() => '?').join(','); + const owned = db.prepare(`SELECT DISTINCT sessions.id + FROM sessions + INNER JOIN bot_session_links ON bot_session_links.session_id = sessions.id + WHERE bot_session_links.bot_id = ? AND sessions.source = 'bot' + AND sessions.id IN (${placeholders})`) + .all(botId, ...sessionIds) as Array<{ id: string }>; + if (owned.length !== sessionIds.length) throw Object.assign( + new Error('只能分离属于该 Bot 的任务'), + { code: 'PRECONDITION_FAILED' }, + ); + db.prepare(`UPDATE sessions SET source = 'desktop', status = ?, updated_at = ? + WHERE source = 'bot' AND id IN (${placeholders})`) + .run(status, at, ...sessionIds); + } + + const deleted = db.prepare("DELETE FROM bot_profiles WHERE id = ? AND status = 'archived'") + .run(botId); + if (deleted.changes !== 1) throw Object.assign( + new Error('Bot 生命周期已被另一处操作更新'), + { code: 'PRECONDITION_FAILED' }, + ); + return { sessionIds, status }; + })(); +} + +function botsLinkSession( + db: Database.Database, + args: unknown, +): { archivedCanonicalSessionIds: string[] } { + const p = asRecord(args, 'bots.linkSession args'); + const botId = expectString(p.botId, 'botId'); + const sessionId = expectString(p.sessionId, 'sessionId'); + const role = expectString(p.role, 'role'); + const allowedRoles = new Set(['canonical', 'route', 'history', 'automation', 'delegation']); + if (!allowedRoles.has(role)) throw Object.assign(new Error('invalid Bot Session role'), { code: 'INVALID_PARAMS' }); + const channelId = nullableString(p.channelId); + const routeKey = nullableString(p.routeKey); + if (typeof p.hasExpectedCanonical !== 'boolean') throw new Error('hasExpectedCanonical must be a boolean'); + const expectedCanonicalSessionId = nullableString(p.expectedCanonicalSessionId); + const now = expectNumber(p.now, 'now'); + const eventId = expectString(p.eventId, 'eventId'); + return db.transaction(() => { + const bot = db.prepare('SELECT current_version AS currentVersion, canonical_session_id AS canonicalSessionId FROM bot_profiles WHERE id = ?') + .get(botId) as { currentVersion: number; canonicalSessionId: string | null } | undefined; + const session = db.prepare('SELECT source FROM sessions WHERE id = ?').get(sessionId) as + | { source: string } + | undefined; + if (!bot || !session) throw Object.assign(new Error('Bot 或 Session 不存在'), { code: 'NOT_FOUND' }); + if (session.source !== 'bot') throw Object.assign( + new Error('只有 source=bot 的 Session 才能绑定到 Bot'), + { code: 'INVALID_PARAMS' }, + ); + if (role === 'route' && (!channelId || !routeKey)) throw Object.assign( + new Error('route Session 必须带 Channel 和 routeKey'), + { code: 'INVALID_PARAMS' }, + ); + if (channelId) { + const channel = db.prepare('SELECT bot_id AS botId FROM bot_channels WHERE id = ?').get(channelId) as + | { botId: string } + | undefined; + if (!channel || channel.botId !== botId) throw Object.assign( + new Error('Channel 不属于该 Bot'), + { code: 'INVALID_PARAMS' }, + ); + } + const existing = db.prepare('SELECT bot_id AS botId FROM bot_session_links WHERE session_id = ?').get(sessionId) as + | { botId: string } + | undefined; + if (existing && existing.botId !== botId) throw Object.assign( + new Error('Session 已绑定到另一个 Bot'), + { code: 'PRECONDITION_FAILED' }, + ); + if (role === 'history' && bot.canonicalSessionId === sessionId) throw Object.assign( + new Error('canonical Session 必须通过 Renew 原子替换'), + { code: 'PRECONDITION_FAILED' }, + ); + if (role === 'route' && bot.canonicalSessionId === sessionId) throw Object.assign( + new Error('canonical Session 不能同时作为 route Session'), + { code: 'PRECONDITION_FAILED' }, + ); + if (role === 'canonical' && p.hasExpectedCanonical + && bot.canonicalSessionId !== expectedCanonicalSessionId) throw Object.assign( + new Error('Bot 主任务已被另一处操作更新,请刷新后重试'), + { code: 'PRECONDITION_FAILED' }, + ); + if (role === 'route' && channelId && routeKey) { + const conflict = db.prepare(`SELECT session_id AS sessionId FROM bot_session_links + WHERE channel_id = ? AND route_key = ? LIMIT 1`).get(channelId, routeKey) as + | { sessionId: string } + | undefined; + if (conflict && conflict.sessionId !== sessionId) throw Object.assign( + new Error('这个消息路由已经绑定到另一个 Bot Session'), + { code: 'PRECONDITION_FAILED' }, + ); + } + + const archivedCanonicalSessionIds: string[] = []; + if (role === 'canonical') { + const old = db.prepare(`SELECT id, session_id AS sessionId FROM bot_session_links + WHERE bot_id = ? AND role = 'canonical' LIMIT 1`).get(botId) as + | { id: string; sessionId: string } + | undefined; + if (old && old.sessionId !== sessionId) { + db.prepare("UPDATE bot_session_links SET role = 'history', archived_at = ? WHERE id = ?") + .run(now, old.id); + db.prepare("UPDATE sessions SET status = 'archived', updated_at = ? WHERE id = ? AND source = 'bot'") + .run(now, old.sessionId); + archivedCanonicalSessionIds.push(old.sessionId); + } + const profileUpdate = db.prepare('UPDATE bot_profiles SET canonical_session_id = ?, updated_at = ? WHERE id = ?') + .run(sessionId, now, botId); + if (profileUpdate.changes !== 1) throw Object.assign( + new Error('Bot 主任务已被另一处操作更新,请刷新后重试'), + { code: 'PRECONDITION_FAILED' }, + ); + } + db.prepare(`INSERT INTO bot_session_links + (id, bot_id, session_id, profile_version, role, channel_id, route_key, created_at, archived_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET bot_id = excluded.bot_id, role = excluded.role, + channel_id = excluded.channel_id, route_key = excluded.route_key, + archived_at = excluded.archived_at`) + .run(`${botId}:${sessionId}`, botId, sessionId, bot.currentVersion, role, channelId, routeKey, + now, role === 'history' ? now : null); + db.prepare(`INSERT INTO bot_lifecycle_events + (id, bot_id, session_id, event_type, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?)`) + .run(eventId, botId, sessionId, role === 'canonical' ? 'canonical-linked' : 'session-linked', + JSON.stringify({ role }), now); + return { archivedCanonicalSessionIds }; + })(); +} + +function botsUpsertProjectBinding(db: Database.Database, args: unknown): void { + const p = asRecord(args, 'bots.upsertProjectBinding args'); + const id = expectString(p.id, 'id'); + const botId = expectString(p.botId, 'botId'); + const projectKey = expectString(p.projectKey, 'projectKey'); + const workingDir = expectString(p.workingDir, 'workingDir'); + const remoteHostId = nullableString(p.remoteHostId); + const defaultBranch = nullableString(p.defaultBranch); + const workspacePolicy = expectString(p.workspacePolicy, 'workspacePolicy'); + if (!new Set(['none', 'reuse', 'per-task', 'read-only']).has(workspacePolicy)) { + throw Object.assign(new Error('invalid workspace policy'), { code: 'INVALID_PARAMS' }); + } + if (typeof p.isDefault !== 'boolean') throw new Error('isDefault must be a boolean'); + const isDefault = p.isDefault; + const allowedPathsJson = expectString(p.allowedPathsJson, 'allowedPathsJson'); + const now = expectNumber(p.now, 'now'); + const eventId = expectString(p.eventId, 'eventId'); + db.transaction(() => { + const profile = db.prepare('SELECT 1 FROM bot_profiles WHERE id = ?').get(botId); + if (!profile) throw Object.assign(new Error('Bot 不存在'), { code: 'NOT_FOUND' }); + const existing = db.prepare(`SELECT id, working_dir AS workingDir, remote_host_id AS remoteHostId, + default_branch AS defaultBranch, workspace_policy AS workspacePolicy, + allowed_paths_json AS allowedPathsJson FROM bot_project_bindings + WHERE bot_id = ? AND project_key = ? LIMIT 1`).get(botId, projectKey) as + | { id: string; workingDir: string; remoteHostId: string | null; defaultBranch: string | null; + workspacePolicy: string; allowedPathsJson: string } + | undefined; + const bindingShapeChanged = existing && ( + existing.workingDir !== workingDir + || existing.remoteHostId !== remoteHostId + || existing.defaultBranch !== defaultBranch + || existing.workspacePolicy !== workspacePolicy + || existing.allowedPathsJson !== allowedPathsJson + ); + if (existing && bindingShapeChanged) { + const liveLease = db.prepare(`SELECT 1 FROM bot_workspace_leases + WHERE project_binding_id = ? AND status IN ('acquiring','active','releasing','error') LIMIT 1`) + .get(existing.id); + if (liveLease) throw Object.assign( + new Error('项目仍有 Bot workspace lease;释放后才能修改目录、分支、Host 或 workspace policy'), + { code: 'PRECONDITION_FAILED' }, + ); + } + if (isDefault) { + db.prepare('UPDATE bot_project_bindings SET is_default = 0, updated_at = ? WHERE bot_id = ?') + .run(now, botId); + } + db.prepare(`INSERT INTO bot_project_bindings + (id, bot_id, project_key, working_dir, remote_host_id, default_branch, workspace_policy, + is_default, allowed_paths_json, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?) + ON CONFLICT(bot_id, project_key) DO UPDATE SET working_dir = excluded.working_dir, + remote_host_id = excluded.remote_host_id, default_branch = excluded.default_branch, + workspace_policy = excluded.workspace_policy, is_default = excluded.is_default, + allowed_paths_json = excluded.allowed_paths_json, status = 'active', updated_at = excluded.updated_at`) + .run(id, botId, projectKey, workingDir, remoteHostId, defaultBranch, workspacePolicy, + isDefault ? 1 : 0, allowedPathsJson, now, now); + db.prepare(`INSERT INTO bot_lifecycle_events + (id, bot_id, session_id, event_type, payload_json, created_at) + VALUES (?, ?, NULL, 'project-binding-upserted', ?, ?)`) + .run(eventId, botId, JSON.stringify({ projectKey, workspacePolicy, isDefault, remoteHostId }), now); + })(); +} + +function botsUpsertChannel(db: Database.Database, args: unknown): void { + const p = asRecord(args, 'bots.upsertChannel args'); + const id = expectString(p.id, 'id'); + const botId = expectString(p.botId, 'botId'); + const kind = expectString(p.kind, 'kind'); + const allowedKinds = new Set([ + 'local', 'telegram', 'feishu', 'slack', 'discord', 'wechat', 'dingtalk', 'wecom', 'x', + ]); + if (!allowedKinds.has(kind)) throw Object.assign(new Error('invalid channel kind'), { code: 'INVALID_PARAMS' }); + if (typeof p.enabled !== 'boolean') throw new Error('enabled must be a boolean'); + const enabled = p.enabled; + const requestedConfigJson = p.configJson === null ? null : expectString(p.configJson, 'configJson'); + const now = expectNumber(p.now, 'now'); + db.transaction(() => { + const profile = db.prepare('SELECT 1 FROM bot_profiles WHERE id = ?').get(botId); + if (!profile) throw Object.assign(new Error('Bot 不存在'), { code: 'NOT_FOUND' }); + const existing = db.prepare('SELECT bot_id AS botId, config_json AS configJson FROM bot_channels WHERE id = ?') + .get(id) as { botId: string; configJson: string } | undefined; + if (existing && existing.botId !== botId) throw Object.assign( + new Error('Channel 已属于另一个 Bot'), + { code: 'PRECONDITION_FAILED' }, + ); + const configJson = requestedConfigJson ?? existing?.configJson ?? '{}'; + const mountIdentity = (channelKind: string, raw: string) => { + if (channelKind === 'local') return null; + let config: Record = {}; + try { + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + config = parsed as Record; + } + } catch { + config = {}; + } + const accountKey = typeof config.accountKey === 'string' ? config.accountKey.trim() : ''; + const ownership = config.ownership; + return accountKey && (ownership === 'local-adapter' || ownership === 'server-relay') + ? { accountKey, ownership } + : null; + }; + const identity = mountIdentity(kind, configJson); + if (kind !== 'local' && enabled && !identity) throw Object.assign( + new Error('启用 IM Channel 前必须绑定具体账号和托管方式'), + { code: 'INVALID_PARAMS' }, + ); + if (enabled && identity) { + const candidates = db.prepare(`SELECT config_json AS configJson FROM bot_channels + WHERE kind = ? AND enabled = 1 AND id <> ?`).all(kind, id) as Array<{ configJson: string }>; + if (candidates.some((candidate) => { + const other = mountIdentity(kind, candidate.configJson); + return other?.accountKey === identity.accountKey && other.ownership === identity.ownership; + })) throw Object.assign( + new Error('这个 IM 账号已挂载到另一个 Bot'), + { code: 'PRECONDITION_FAILED' }, + ); + } + db.prepare(`INSERT INTO bot_channels + (id, bot_id, kind, enabled, config_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET kind = excluded.kind, enabled = excluded.enabled, + config_json = excluded.config_json, updated_at = excluded.updated_at`) + .run(id, botId, kind, enabled ? 1 : 0, configJson, now, now); + })(); +} + +function botsMigrateLegacyProfile(db: Database.Database, args: unknown): void { + const p = asRecord(args, 'bots.migrateLegacyProfile args'); + const id = expectString(p.id, 'id'); + const displayName = expectString(p.displayName, 'displayName'); + const description = expectString(p.description, 'description'); + const avatar = expectString(p.avatar, 'avatar'); + const avatarColor = expectString(p.avatarColor, 'avatarColor'); + const identitySource = expectString(p.identitySource, 'identitySource'); + const capabilitiesJson = expectString(p.capabilitiesJson, 'capabilitiesJson'); + const channelKind = nullableString(p.channelKind); + const legacySessionId = nullableString(p.legacySessionId); + const now = expectNumber(p.now, 'now'); + db.transaction(() => { + const existingProfile = db.prepare(`SELECT current_version AS currentVersion, + canonical_session_id AS canonicalSessionId FROM bot_profiles WHERE id = ?`).get(id) as + | { currentVersion: number; canonicalSessionId: string | null } + | undefined; + if (!existingProfile) { + db.prepare(`INSERT INTO bot_profiles + (id, display_name, description, avatar, avatar_color, status, current_version, + canonical_session_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'active', 1, NULL, ?, ?)`) + .run(id, displayName, description, avatar, avatarColor, now, now); + db.prepare(`INSERT INTO bot_profile_versions + (id, bot_id, version, identity_source, capabilities_json, created_at) + VALUES (?, ?, 1, ?, ?, ?)`) + .run(`${id}:v1`, id, identitySource, capabilitiesJson, now); + } + db.prepare(`INSERT INTO bot_channels + (id, bot_id, kind, enabled, config_json, created_at, updated_at) + VALUES (?, ?, 'local', 1, '{}', ?, ?) + ON CONFLICT(id) DO UPDATE SET enabled = 1, updated_at = excluded.updated_at`) + .run(`${id}:local`, id, now, now); + if (channelKind && channelKind !== 'local') { + // Legacy data only recorded a channel label, not a concrete account or + // ownership mode. Preserve it as a disabled mount instead of creating a + // misleading enabled IM connection that cannot route safely. + db.prepare(`INSERT INTO bot_channels + (id, bot_id, kind, enabled, config_json, created_at, updated_at) + VALUES (?, ?, ?, 0, '{}', ?, ?) + ON CONFLICT(id) DO UPDATE SET updated_at = excluded.updated_at`) + .run(`${id}:${channelKind}`, id, channelKind, now, now); + } + const currentProfile = db.prepare(`SELECT current_version AS currentVersion, + canonical_session_id AS canonicalSessionId FROM bot_profiles WHERE id = ?`).get(id) as + { currentVersion: number; canonicalSessionId: string | null }; + if (legacySessionId) { + const legacySession = db.prepare('SELECT source FROM sessions WHERE id = ?').get(legacySessionId) as + | { source: string } + | undefined; + const existingLink = db.prepare('SELECT bot_id AS botId FROM bot_session_links WHERE session_id = ?') + .get(legacySessionId) as { botId: string } | undefined; + const canonicalConflict = legacySession?.source === 'bot' + && currentProfile.canonicalSessionId !== null + && currentProfile.canonicalSessionId !== legacySessionId; + if (legacySession && (!existingLink || existingLink.botId === id) && !canonicalConflict) { + const role = legacySession.source === 'bot' ? 'canonical' : 'history'; + db.prepare(`INSERT INTO bot_session_links + (id, bot_id, session_id, profile_version, role, channel_id, route_key, created_at, archived_at) + VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET bot_id = excluded.bot_id, + profile_version = excluded.profile_version, role = excluded.role, + archived_at = excluded.archived_at`) + .run(`${id}:${legacySessionId}`, id, legacySessionId, currentProfile.currentVersion, + role, now, role === 'history' ? now : null); + if (role === 'canonical') { + db.prepare('UPDATE bot_profiles SET canonical_session_id = ?, updated_at = ? WHERE id = ? AND canonical_session_id IS NULL') + .run(legacySessionId, now, id); + } + } else { + const reason = !legacySession + ? 'missing' + : existingLink && existingLink.botId !== id + ? 'owned-by-another-bot' + : 'canonical-already-exists'; + db.prepare(`INSERT INTO bot_lifecycle_events + (id, bot_id, session_id, event_type, payload_json, created_at) + VALUES (?, ?, ?, 'legacy-session-skipped', ?, ?)`) + .run(`${id}:legacy-session-skipped:${now}`, id, legacySession ? legacySessionId : null, + JSON.stringify({ legacySessionId, reason }), now); + } + } + db.prepare(`INSERT INTO bot_lifecycle_events + (id, bot_id, session_id, event_type, payload_json, created_at) + VALUES (?, ?, NULL, 'legacy-profile-migrated', ?, ?)`) + .run(`${id}:legacy-migrated:${now}`, id, JSON.stringify({ legacySessionId }), now); + })(); +} + +function botsImportBehaviorBundle(db: Database.Database, args: unknown): void { + const p = asRecord(args, 'bots.importBehaviorBundle args'); + const bot = asRecord(p.bot, 'bot'); + const botId = expectString(bot.id, 'bot.id'); + const displayName = expectString(bot.displayName, 'bot.displayName'); + const channels = expectArray(p.channels, 'channels').map((value, index) => + asRecord(value, `channels.${index}`), + ); + const automations = expectArray(p.automations, 'automations').map((value, index) => + asRecord(value, `automations.${index}`), + ); + const now = expectNumber(p.now, 'now'); + const eventId = expectString(p.eventId, 'eventId'); + db.transaction(() => { + if (db.prepare('SELECT 1 FROM bot_profiles WHERE display_name = ? LIMIT 1').get(displayName)) { + throw Object.assign(new Error('已存在同名 Bot;导入不会覆盖现有 Bot'), { code: 'PRECONDITION_FAILED' }); + } + db.prepare(`INSERT INTO bot_profiles + (id, display_name, description, avatar, avatar_color, status, current_version, + canonical_session_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'active', 1, NULL, ?, ?)`) + .run(botId, displayName, expectString(bot.description, 'bot.description'), + expectString(bot.avatar, 'bot.avatar'), expectString(bot.avatarColor, 'bot.avatarColor'), now, now); + db.prepare(`INSERT INTO bot_profile_versions + (id, bot_id, version, identity_source, capabilities_json, created_at) + VALUES (?, ?, 1, ?, ?, ?)`) + .run(`${botId}:v1`, botId, expectString(bot.identitySource, 'bot.identitySource'), + expectString(bot.capabilitiesJson, 'bot.capabilitiesJson'), now); + const insertChannel = db.prepare(`INSERT INTO bot_channels + (id, bot_id, kind, enabled, config_json, created_at, updated_at) + VALUES (?, ?, ?, ?, '{}', ?, ?)`); + for (const [index, channel] of channels.entries()) { + if (typeof channel.enabled !== 'boolean') throw new Error(`channels.${index}.enabled must be a boolean`); + insertChannel.run(expectString(channel.id, `channels.${index}.id`), botId, + expectString(channel.kind, `channels.${index}.kind`), channel.enabled ? 1 : 0, now, now); + } + const insertSchedule = db.prepare(`INSERT INTO schedules + (id, name, prompt, execution_mode, script_config, source, cron_expr, timezone, recurring, + manual, interval_ms, agent_kind, model, provider_id, effort, fast_mode, working_dir, + workspace_kind, use_worktree, target_session_id, persistent_session, silent_when_idle, + notify_desktop, notify_feishu, notify_wecom_group, status, created_at, updated_at, next_fire_at) + VALUES (?, ?, ?, ?, ?, 'bot-import', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'dialogue', 0, + NULL, ?, ?, ?, 0, 0, 'paused', ?, ?, NULL)`); + const insertLink = db.prepare(`INSERT INTO bot_automation_links + (id, bot_id, schedule_id, project_binding_id, target_route_id, created_with_profile_version, + durable_note_namespace, execution_policy_json, status, suspended_status, created_at, updated_at) + VALUES (?, ?, ?, NULL, NULL, 1, NULL, ?, 'paused', NULL, ?, ?)`); + for (const [index, automation] of automations.entries()) { + const boolean = (key: string) => { + const value = automation[key]; + if (typeof value !== 'boolean') throw new Error(`automations.${index}.${key} must be a boolean`); + return value ? 1 : 0; + }; + const optionalNumber = automation.intervalMs === null + ? null + : expectNumber(automation.intervalMs, `automations.${index}.intervalMs`); + const scheduleId = expectString(automation.scheduleId, `automations.${index}.scheduleId`); + insertSchedule.run(scheduleId, expectString(automation.name, `automations.${index}.name`), + expectString(automation.prompt, `automations.${index}.prompt`), + expectString(automation.executionMode, `automations.${index}.executionMode`), + nullableString(automation.scriptConfig), expectString(automation.cronExpr, `automations.${index}.cronExpr`), + expectString(automation.timezone, `automations.${index}.timezone`), boolean('recurring'), + boolean('manual'), optionalNumber, expectString(automation.agentKind, `automations.${index}.agentKind`), + nullableString(automation.model), nullableString(automation.providerId), nullableString(automation.effort), + boolean('fastMode'), boolean('persistentSession'), boolean('silentWhenIdle'), + boolean('notifyDesktop'), now, now); + insertLink.run(expectString(automation.linkId, `automations.${index}.linkId`), botId, scheduleId, + expectString(automation.executionPolicyJson, `automations.${index}.executionPolicyJson`), now, now); + } + db.prepare(`INSERT INTO bot_lifecycle_events + (id, bot_id, session_id, event_type, payload_json, created_at) + VALUES (?, ?, NULL, 'imported', ?, ?)`) + .run(eventId, botId, JSON.stringify({ + disabledChannels: channels.filter((channel) => channel.enabled === false).map((channel) => channel.kind), + pausedAutomations: automations.length, + }), now); + })(); +} + +function botsApplyImMigration( + db: Database.Database, + args: unknown, +): { routeId: string } { + const p = asRecord(args, 'bots.applyImMigration args'); + const migrationId = expectString(p.migrationId, 'migrationId'); + const requestId = expectString(p.requestId, 'requestId'); + const botId = expectString(p.botId, 'botId'); + const channelId = expectString(p.channelId, 'channelId'); + const fallbackRouteId = expectString(p.routeId, 'routeId'); + const connectionId = expectString(p.connectionId, 'connectionId'); + const ownership = expectString(p.ownership, 'ownership'); + const kind = expectString(p.kind, 'kind'); + const accountKey = expectString(p.accountKey, 'accountKey'); + const planHash = expectString(p.planHash, 'planHash'); + const channelConfigJson = expectString(p.channelConfigJson, 'channelConfigJson'); + const capabilitiesJson = expectString(p.capabilitiesJson, 'capabilitiesJson'); + const adapterBindingsJson = expectString(p.adapterBindingsJson, 'adapterBindingsJson'); + const candidates = expectArray(p.candidates, 'candidates').map((value, index) => { + const row = asRecord(value, `candidates.${index}`); + const status = expectString(row.status, `candidates.${index}.status`); + if (status !== 'active' && status !== 'archived') throw new Error(`invalid candidates.${index}.status`); + return { sessionId: expectString(row.sessionId, `candidates.${index}.sessionId`), status, + updatedAt: expectNumber(row.updatedAt, `candidates.${index}.updatedAt`) }; + }); + const now = expectNumber(p.now, 'now'); + const eventId = expectString(p.eventId, 'eventId'); + return db.transaction(() => { + if (db.prepare('SELECT 1 FROM bot_im_migrations WHERE request_id = ?').get(requestId)) { + throw Object.assign(new Error('Migration request already exists'), { code: 'PRECONDITION_FAILED' }); + } + const profile = db.prepare('SELECT current_version AS currentVersion FROM bot_profiles WHERE id = ?') + .get(botId) as { currentVersion: number } | undefined; + if (!profile) throw Object.assign(new Error('Bot disappeared during migration'), { code: 'NOT_FOUND' }); + const existingChannel = db.prepare(`SELECT bot_id AS botId, kind, enabled, + config_json AS configJson, created_at AS createdAt, updated_at AS updatedAt + FROM bot_channels WHERE id = ?`).get(channelId) as + | { botId: string; kind: string; enabled: number; configJson: string; createdAt: number; updatedAt: number } + | undefined; + if (existingChannel && existingChannel.botId !== botId) throw Object.assign( + new Error('This Channel belongs to another Bot'), { code: 'PRECONDITION_FAILED' }); + const identity = (raw: string) => { + try { + const value = JSON.parse(raw) as Record; + return { accountKey: typeof value.accountKey === 'string' ? value.accountKey.trim() : '', ownership: value.ownership }; + } catch { return { accountKey: '', ownership: null }; } + }; + const desired = identity(channelConfigJson); + const enabled = db.prepare(`SELECT config_json AS configJson FROM bot_channels + WHERE kind = ? AND enabled = 1 AND id <> ?`).all(kind, channelId) as Array<{ configJson: string }>; + if (enabled.some((candidate) => { + const other = identity(candidate.configJson); + return other.accountKey === desired.accountKey && other.ownership === desired.ownership; + })) throw Object.assign(new Error('This IM account was mounted by another Bot during migration'), + { code: 'PRECONDITION_FAILED' }); + const routeBefore = db.prepare(`SELECT * FROM bot_routes + WHERE channel_id = ? AND route_key = 'default' LIMIT 1`).get(channelId) as Record | undefined; + db.prepare(`INSERT INTO bot_channels + (id, bot_id, kind, enabled, config_json, created_at, updated_at) + VALUES (?, ?, ?, 1, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET kind = excluded.kind, enabled = 1, + config_json = excluded.config_json, updated_at = excluded.updated_at`) + .run(channelId, botId, kind, channelConfigJson, existingChannel?.createdAt ?? now, now); + const persistedRouteId = typeof routeBefore?.id === 'string' ? routeBefore.id : fallbackRouteId; + const priorStatus = typeof routeBefore?.status === 'string' ? routeBefore.status : 'offline'; + const routeStatus = priorStatus === 'paused' || priorStatus === 'archived' ? 'offline' : priorStatus; + db.prepare(`INSERT INTO bot_routes + (id, bot_id, channel_id, route_key, principal_key, scope_key, thread_key, current_session_id, + project_binding_id, capabilities_json, owner_device_id, owner_generation, status, + last_activity_at, created_at, updated_at) + VALUES (?, ?, ?, 'default', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(channel_id, route_key) DO UPDATE SET capabilities_json = excluded.capabilities_json, + status = excluded.status, updated_at = excluded.updated_at`) + .run(persistedRouteId, botId, channelId, + typeof routeBefore?.principal_key === 'string' ? routeBefore.principal_key : '', + typeof routeBefore?.scope_key === 'string' ? routeBefore.scope_key : '', + nullableString(routeBefore?.thread_key), nullableString(routeBefore?.current_session_id), + nullableString(routeBefore?.project_binding_id), capabilitiesJson, + nullableString(routeBefore?.owner_device_id), + typeof routeBefore?.owner_generation === 'number' ? routeBefore.owner_generation : 0, + routeStatus, typeof routeBefore?.last_activity_at === 'number' ? routeBefore.last_activity_at : null, + typeof routeBefore?.created_at === 'number' ? routeBefore.created_at : now, now); + const channelBeforeJson = existingChannel ? JSON.stringify({ + id: channelId, botId: existingChannel.botId, kind: existingChannel.kind, + enabled: existingChannel.enabled === 1, configJson: existingChannel.configJson, + createdAt: existingChannel.createdAt, updatedAt: existingChannel.updatedAt, + }) : null; + const routeBeforeJson = routeBefore ? JSON.stringify({ + id: routeBefore.id, botId: routeBefore.bot_id, channelId: routeBefore.channel_id, + routeKey: routeBefore.route_key, principalKey: routeBefore.principal_key, + scopeKey: routeBefore.scope_key, threadKey: routeBefore.thread_key, + currentSessionId: routeBefore.current_session_id, projectBindingId: routeBefore.project_binding_id, + capabilitiesJson: routeBefore.capabilities_json, ownerDeviceId: routeBefore.owner_device_id, + ownerGeneration: routeBefore.owner_generation, status: routeBefore.status, + lastActivityAt: routeBefore.last_activity_at, createdAt: routeBefore.created_at, + updatedAt: routeBefore.updated_at, + }) : null; + db.prepare(`INSERT INTO bot_im_migrations + (id, request_id, bot_id, channel_id, route_id, connection_id, ownership, kind, account_key, + plan_hash, status, channel_before_json, route_before_json, adapter_bindings_json, error_json, + created_at, applied_at, rolled_back_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'applying', ?, ?, ?, NULL, ?, NULL, NULL)`) + .run(migrationId, requestId, botId, channelId, persistedRouteId, connectionId, ownership, kind, + accountKey, planHash, channelBeforeJson, routeBeforeJson, adapterBindingsJson, now); + const insertItem = db.prepare(`INSERT INTO bot_im_migration_items + (id, migration_id, session_id, original_status, history_link_created, session_archived, + applied_session_updated_at, created_at, rolled_back_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`); + for (const candidate of candidates) { + const session = db.prepare('SELECT status, updated_at AS updatedAt FROM sessions WHERE id = ?') + .get(candidate.sessionId) as { status: string; updatedAt: number } | undefined; + if (!session || session.status !== candidate.status || session.updatedAt !== candidate.updatedAt) { + throw Object.assign(new Error('Migration plan changed; run the preflight again'), { code: 'PRECONDITION_FAILED' }); + } + const link = db.prepare('SELECT bot_id AS botId FROM bot_session_links WHERE session_id = ?') + .get(candidate.sessionId) as { botId: string } | undefined; + if (link && link.botId !== botId) throw Object.assign(new Error('A migration candidate was linked to another Bot'), + { code: 'PRECONDITION_FAILED' }); + const historyLinkCreated = !link; + if (historyLinkCreated) { + db.prepare(`INSERT INTO bot_session_links + (id, bot_id, session_id, profile_version, role, channel_id, route_key, created_at, archived_at) + VALUES (?, ?, ?, ?, 'history', NULL, NULL, ?, ?)`) + .run(`${botId}:${candidate.sessionId}`, botId, candidate.sessionId, profile.currentVersion, now, now); + } + const sessionArchived = candidate.status === 'active'; + if (sessionArchived) { + const archived = db.prepare(`UPDATE sessions SET status = 'archived', updated_at = ? + WHERE id = ? AND status = 'active' AND updated_at = ?`).run(now, candidate.sessionId, candidate.updatedAt); + if (archived.changes !== 1) throw Object.assign(new Error('Migration plan changed; run the preflight again'), + { code: 'PRECONDITION_FAILED' }); + } + insertItem.run(`${migrationId}:${candidate.sessionId}`, migrationId, candidate.sessionId, + candidate.status, historyLinkCreated ? 1 : 0, sessionArchived ? 1 : 0, + sessionArchived ? now : candidate.updatedAt, now); + } + db.prepare(`INSERT INTO bot_lifecycle_events + (id, bot_id, session_id, event_type, payload_json, created_at) + VALUES (?, ?, NULL, 'im-migration-applied', ?, ?)`) + .run(eventId, botId, JSON.stringify({ migrationId, connectionId, channelId, + routeId: persistedRouteId, migratedSessionCount: candidates.length }), now); + return { routeId: persistedRouteId }; + })(); +} + +function botsBeginImMigrationRollback(db: Database.Database, args: unknown): void { + const p = asRecord(args, 'bots.beginImMigrationRollback args'); + const migrationId = expectString(p.migrationId, 'migrationId'); + const now = expectNumber(p.now, 'now'); + const eventId = expectString(p.eventId, 'eventId'); + db.transaction(() => { + const migration = db.prepare('SELECT * FROM bot_im_migrations WHERE id = ?').get(migrationId) as + Record | undefined; + if (!migration) throw Object.assign(new Error('Migration does not exist'), { code: 'NOT_FOUND' }); + if (migration.status !== 'applied' && migration.status !== 'applying') throw Object.assign( + new Error(`Migration cannot be rolled back from ${String(migration.status)}`), + { code: 'PRECONDITION_FAILED' }, + ); + const transitioned = db.prepare(`UPDATE bot_im_migrations SET status = 'rolling-back' + WHERE id = ? AND status IN ('applied','applying')`).run(migrationId); + if (transitioned.changes !== 1) throw Object.assign( + new Error('Migration state changed while rolling back'), { code: 'PRECONDITION_FAILED' }); + const parse = (raw: unknown): Record | null => { + if (typeof raw !== 'string' || !raw) return null; + try { + const value = JSON.parse(raw) as unknown; + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; + } catch { return null; } + }; + const routeBefore = parse(migration.route_before_json); + const channelBefore = parse(migration.channel_before_json); + const routeId = expectString(migration.route_id, 'migration.routeId'); + const botId = expectString(migration.bot_id, 'migration.botId'); + const channelId = expectString(migration.channel_id, 'migration.channelId'); + const currentRoute = db.prepare(`SELECT current_session_id AS currentSessionId, + owner_generation AS ownerGeneration FROM bot_routes WHERE id = ?`).get(routeId) as + | { currentSessionId: string | null; ownerGeneration: number } + | undefined; + if (currentRoute?.currentSessionId) { + db.prepare(`UPDATE bot_session_links SET role = 'history', channel_id = NULL, + route_key = NULL, archived_at = ? WHERE bot_id = ? AND session_id = ?`) + .run(now, botId, currentRoute.currentSessionId); + db.prepare(`UPDATE sessions SET status = 'archived', updated_at = ? + WHERE id = ? AND source = 'bot' AND status <> 'deleted'`) + .run(now, currentRoute.currentSessionId); + } + if (routeBefore) { + db.prepare(`UPDATE bot_routes SET principal_key = ?, scope_key = ?, thread_key = ?, + current_session_id = ?, project_binding_id = ?, capabilities_json = ?, owner_device_id = ?, + owner_generation = ?, status = ?, last_activity_at = ?, updated_at = ? WHERE id = ?`) + .run(expectString(routeBefore.principalKey, 'routeBefore.principalKey'), + expectString(routeBefore.scopeKey, 'routeBefore.scopeKey'), nullableString(routeBefore.threadKey), + nullableString(routeBefore.currentSessionId), nullableString(routeBefore.projectBindingId), + expectString(routeBefore.capabilitiesJson, 'routeBefore.capabilitiesJson'), + nullableString(routeBefore.ownerDeviceId), + Math.max(currentRoute?.ownerGeneration ?? 0, + expectNumber(routeBefore.ownerGeneration, 'routeBefore.ownerGeneration')) + 1, + expectString(routeBefore.status, 'routeBefore.status'), + routeBefore.lastActivityAt === null + ? null + : expectNumber(routeBefore.lastActivityAt, 'routeBefore.lastActivityAt'), + now, routeId); + } else { + db.prepare(`UPDATE bot_routes SET current_session_id = NULL, owner_device_id = NULL, + owner_generation = ?, status = 'archived', updated_at = ? WHERE id = ?`) + .run((currentRoute?.ownerGeneration ?? 0) + 1, now, routeId); + } + if (channelBefore) { + if (typeof channelBefore.enabled !== 'boolean') throw new Error('channelBefore.enabled must be boolean'); + db.prepare('UPDATE bot_channels SET enabled = ?, config_json = ?, updated_at = ? WHERE id = ?') + .run(channelBefore.enabled ? 1 : 0, + expectString(channelBefore.configJson, 'channelBefore.configJson'), now, channelId); + } else { + db.prepare('UPDATE bot_channels SET enabled = 0, updated_at = ? WHERE id = ?').run(now, channelId); + } + const items = db.prepare('SELECT * FROM bot_im_migration_items WHERE migration_id = ?').all(migrationId) as + Array>; + for (const item of items) { + const sessionId = expectString(item.session_id, 'item.sessionId'); + if (item.history_link_created === 1) { + db.prepare(`DELETE FROM bot_session_links WHERE bot_id = ? AND session_id = ? + AND role = 'history' AND archived_at = ?`) + .run(botId, sessionId, expectNumber(item.created_at, 'item.createdAt')); + } + if (item.session_archived === 1 && item.original_status === 'active') { + db.prepare(`UPDATE sessions SET status = 'active', updated_at = ? + WHERE id = ? AND status = 'archived' AND updated_at = ?`) + .run(now, sessionId, expectNumber(item.applied_session_updated_at, 'item.appliedSessionUpdatedAt')); + } + db.prepare('UPDATE bot_im_migration_items SET rolled_back_at = ? WHERE id = ?') + .run(now, expectString(item.id, 'item.id')); + } + db.prepare(`INSERT INTO bot_lifecycle_events + (id, bot_id, session_id, event_type, payload_json, created_at) + VALUES (?, ?, NULL, 'im-migration-rolled-back', ?, ?)`) + .run(eventId, botId, JSON.stringify({ migrationId, channelId, routeId }), now); + })(); +} + /** Remove every stale startup binding as one all-or-nothing repair. */ function imDeleteBindings(db: Database.Database, args: unknown): void { const payload = asRecord(args, 'im.deleteBindings args'); @@ -511,7 +1848,7 @@ function sessionsSetStatus(db: Database.Database, args: unknown): Array<{ throw invalidArgs(`invalid status: ${status}`); } const selectSession = db.prepare( - 'SELECT id, title, working_dir AS workingDir, workspace_kind AS workspaceKind, status FROM sessions WHERE id = ? LIMIT 1', + 'SELECT id, title, working_dir AS workingDir, workspace_kind AS workspaceKind, status, source FROM sessions WHERE id = ? LIMIT 1', ); const updateSession = db.prepare( 'UPDATE sessions SET status = ?, updated_at = ? WHERE id = ? RETURNING id, title, working_dir AS workingDir, workspace_kind AS workspaceKind', @@ -535,6 +1872,11 @@ function sessionsSetStatus(db: Database.Database, args: unknown): Array<{ code: 'PRECONDITION_FAILED', }); } + if ((existing as { source?: unknown }).source === 'bot') { + throw Object.assign(new Error(`Bot 任务必须通过 Bot 生命周期管理: ${sessionId}`), { + code: 'PRECONDITION_FAILED', + }); + } const updated = updateSession.get(status, now, sessionId) as | { id: string; title: string | null; workingDir: string | null; workspaceKind: string | null } | undefined; @@ -760,13 +2102,14 @@ function rewindCommit(db: Database.Database, args: unknown): void { db.prepare( `UPDATE sessions SET user_send_at = ?, updated_at = ?, context_tokens = 0, context_window = 0, - sdk_session_id = ? + codex_plan_json = NULL, sdk_session_id = ? WHERE id = ?`, ).run(now, now, sdkSessionId, sessionId); } else { db.prepare( `UPDATE sessions - SET user_send_at = ?, updated_at = ?, context_tokens = 0, context_window = 0 + SET user_send_at = ?, updated_at = ?, context_tokens = 0, context_window = 0, + codex_plan_json = NULL WHERE id = ?`, ).run(now, now, sessionId); } diff --git a/apps/desktop/src/main/maker-host/__tests__/activeCatalogXdAvailability.test.ts b/apps/desktop/src/main/maker-host/__tests__/activeCatalogXdAvailability.test.ts index 02700014a1..1eed5d0118 100644 --- a/apps/desktop/src/main/maker-host/__tests__/activeCatalogXdAvailability.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/activeCatalogXdAvailability.test.ts @@ -47,12 +47,17 @@ describe('XD 网关权威模型清单重建', () => { expect(xdModels('codex')).toEqual([]); }); - it('远端 Catalog 不能覆盖 XD Provider 壳或注入 XD 模型', () => { + it('current Catalog controls the XD media shell while /models controls chat membership', () => { const catalog = JSON.parse(JSON.stringify(BUNDLED_CATALOG)) as typeof BUNDLED_CATALOG; const catalogXd = catalog.providers.find((provider) => provider.id === 'xd'); - const builtinXd = BUNDLED_CATALOG.providers.find((provider) => provider.id === 'xd'); - if (!catalogXd || !builtinXd) throw new Error('missing XD provider fixture'); + if (!catalogXd) throw new Error('missing XD provider fixture'); catalogXd.name = 'Catalog-supplied XD'; + catalogXd.imageModels = []; + delete catalogXd.imageDefaults; + catalogXd.embeddingModels = []; + delete catalogXd.embeddingDefaults; + catalogXd.videoModels = [{ id: 'seedance-fast', name: 'Seedance Fast' }]; + catalogXd.videoDefaults = { standard: 'seedance-fast' }; catalogXd.models['claude-code'] = [ { id: 'catalog-only-model', @@ -66,7 +71,10 @@ describe('XD 网关权威模型清单重建', () => { setActiveCatalog(catalog); const activeXd = getActiveCatalog().providers.find((provider) => provider.id === 'xd'); - expect(activeXd?.name).toBe(builtinXd.name); + expect(activeXd?.name).toBe('Catalog-supplied XD'); + expect(activeXd?.imageModels).toEqual([]); + expect(activeXd?.embeddingModels).toEqual([]); + expect(activeXd?.videoModels).toEqual([{ id: 'seedance-fast', name: 'Seedance Fast' }]); expect(xdModels('claude-code')).toEqual([]); }); diff --git a/apps/desktop/src/main/maker-host/__tests__/autoPermissionReviewer.test.ts b/apps/desktop/src/main/maker-host/__tests__/autoPermissionReviewer.test.ts index a846ab4f4b..21a6107f7d 100644 --- a/apps/desktop/src/main/maker-host/__tests__/autoPermissionReviewer.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/autoPermissionReviewer.test.ts @@ -341,4 +341,28 @@ describe('createAutoPermissionReviewer', () => { await expect(pending).resolves.toEqual({ verdict: 'allow', reason: 'Routine test' }); }); + + it('runs a retry-owning request chain once and aborts it at the reviewer deadline', async () => { + vi.useFakeTimers(); + const observedSignals: AbortSignal[] = []; + const requestText = vi.fn((_request, _prompt, context: { signal: AbortSignal }) => { + observedSignals.push(context.signal); + return new Promise((resolve) => { + context.signal.addEventListener('abort', () => resolve(null), { once: true }); + }); + }); + const reviewer = createAutoPermissionReviewer({ + requestText, + logger: { debug: vi.fn(), warn: vi.fn() }, + managesRetries: true, + resolveRequestTimeoutMs: () => 50, + }); + + const pending = reviewer(request()); + await vi.advanceTimersByTimeAsync(50); + + await expect(pending).resolves.toBeNull(); + expect(requestText).toHaveBeenCalledTimes(1); + expect(observedSignals[0]?.aborted).toBe(true); + }); }); diff --git a/apps/desktop/src/main/maker-host/__tests__/autoReviewBudget.test.ts b/apps/desktop/src/main/maker-host/__tests__/autoReviewBudget.test.ts deleted file mode 100644 index ed0ba5cb03..0000000000 --- a/apps/desktop/src/main/maker-host/__tests__/autoReviewBudget.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import type { CatalogModel } from '@cindy/model-providers'; - -import { - findCatalogModel, - modelCanSuppressReasoning, - resolveAutoReviewBudget, -} from '../auto-review-budget.js'; - -const model = (over: Partial = {}): CatalogModel => ({ - id: 'm1', - name: 'M1', - contextWindow: 200_000, - efforts: [], - defaultEffort: null, - ...over, -} as CatalogModel); - -describe('modelCanSuppressReasoning', () => { - it('treats an explicit low/minimal tier as suppressible', () => { - expect(modelCanSuppressReasoning(model({ efforts: ['low', 'medium', 'high'] }))).toBe(true); - expect(modelCanSuppressReasoning(model({ efforts: ['minimal', 'high', 'max'] }))).toBe(true); - }); - - it('treats a model with no effort switch as suppressible', () => { - // Kimi K2.6 这类:不支持切档,但也不会强制长思考,紧凑额度够用。 - expect(modelCanSuppressReasoning(model({ efforts: [] }))).toBe(true); - }); - - it('treats a forced-reasoning model as NOT suppressible', () => { - // DeepSeek V4 Pro/Flash:档位只有 high/max,传 low 会被上调 —— 384 token - // 会被思考烧光、正文为空,正是审核静默失效的根因。 - expect(modelCanSuppressReasoning(model({ efforts: ['high', 'max'] }))).toBe(false); - }); - - it('is conservative when the model is unknown', () => { - // 目录里查不到(自定义供应商未声明能力)时宁可多给额度,也不要重演 384 token - // 判不出来的静默失败。 - expect(modelCanSuppressReasoning(undefined)).toBe(false); - }); -}); - -describe('resolveAutoReviewBudget', () => { - it('keeps the compact budget for models that can turn reasoning down', () => { - const budget = resolveAutoReviewBudget(model({ efforts: ['low', 'high'] })); - expect(budget.maxTokens).toBe(384); - expect(budget.timeoutMs).toBe(12_000); - expect(budget.reasoningEffort).toBe('low'); - }); - - it('widens the budget for forced-reasoning models and drops the ineffective effort hint', () => { - const budget = resolveAutoReviewBudget(model({ efforts: ['high', 'max'] })); - expect(budget.maxTokens).toBeGreaterThan(384); - expect(budget.timeoutMs).toBeGreaterThan(12_000); - // 传 low 会被上调成模型支持的最低档,平白带一个不生效的字段;个别上游还会 - // 因为不认的值直接 400。 - expect(budget.reasoningEffort).toBeUndefined(); - }); - - it('widens the budget when the model is unknown', () => { - const budget = resolveAutoReviewBudget(undefined); - expect(budget.maxTokens).toBeGreaterThan(384); - expect(budget.reasoningEffort).toBeUndefined(); - }); - - it('sends minimal — not low — when that is the lowest tier the model declares', () => { - // 回归 PR #2474 review P1:目录里 z-ai/glm-5.2 是 ['minimal','high','max'], - // 固定发 low 会被上游拒绝或悄悄提到更高档,反而烧掉 384 token 的正文空间。 - const budget = resolveAutoReviewBudget(model({ efforts: ['minimal', 'high', 'max'] })); - expect(budget.maxTokens).toBe(384); - expect(budget.reasoningEffort).toBe('minimal'); - }); - - it('omits the effort field entirely for models with no effort tiers', () => { - // efforts: [] 的模型目录里有 10 个(Haiku 4.5 / Kimi K2.6 / grok 系 / qwen3.7-max - // 等)。它们不强制长思考,所以照常走紧凑额度,但带一个不认的字段是白冒 400 的险。 - const budget = resolveAutoReviewBudget(model({ efforts: [] })); - expect(budget.maxTokens).toBe(384); - expect(budget.timeoutMs).toBe(12_000); - expect(budget.reasoningEffort).toBeUndefined(); - }); - - it('prefers low over minimal when the model declares both', () => { - const budget = resolveAutoReviewBudget(model({ efforts: ['minimal', 'low', 'high'] })); - expect(budget.reasoningEffort).toBe('low'); - }); -}); - -describe('findCatalogModel', () => { - const providers = [ - { id: 'xd', models: { 'claude-code': [model({ id: 'shared' })], pi: [model({ id: 'shared' })] } }, - { id: 'other', models: { 'claude-code': [model({ id: 'only-here', efforts: ['high'] })] } }, - ]; - - it('prefers the named provider', () => { - expect(findCatalogModel(providers, 'xd', 'pi', 'shared')?.id).toBe('shared'); - }); - - it('falls back to a catalog-wide lookup when no provider is named', () => { - // Pi 的 providerId=null 表示走默认网关路由;这里只读能力元数据,不参与路由。 - expect(findCatalogModel(providers, null, 'claude-code', 'only-here')?.efforts) - .toEqual(['high']); - }); - - it('returns undefined for an unknown model instead of guessing', () => { - expect(findCatalogModel(providers, 'xd', 'pi', 'nope')).toBeUndefined(); - expect(findCatalogModel(providers, 'xd', 'pi', ' ')).toBeUndefined(); - }); - - it('never borrows another provider capability when a provider is named', () => { - // 回归 PR #2474 review:同一个模型 id 在两家目录下能力不同时,跨家借用会把 - // 强制思考的路由误判成"能关思考",于是又拿回��凑额度 —— 正是本 PR 要修的故障。 - const crossProvider = [ - { id: 'xd', models: { 'claude-code': [model({ id: 'dual', efforts: ['high'] })] } }, - { id: 'other', models: { 'claude-code': [model({ id: 'dual', efforts: ['low', 'high'] })] } }, - ]; - // 点名 'nowhere' 这家没有该模型 → 返回 undefined,由调用方走保守宽裕档; - // 绝不落到 'other' 的 low 档。 - expect(findCatalogModel(crossProvider, 'nowhere', 'claude-code', 'dual')).toBeUndefined(); - // 点名 'xd' 时只认 xd 自己的声明。 - expect(findCatalogModel(crossProvider, 'xd', 'claude-code', 'dual')?.efforts).toEqual(['high']); - }); -}); diff --git a/apps/desktop/src/main/maker-host/__tests__/autoReviewModelRouter.test.ts b/apps/desktop/src/main/maker-host/__tests__/autoReviewModelRouter.test.ts new file mode 100644 index 0000000000..4b3f9039b0 --- /dev/null +++ b/apps/desktop/src/main/maker-host/__tests__/autoReviewModelRouter.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { UtilityTextResult } from '../../../shared/utilityTextResult.js'; +import { DEDICATED_AUTO_REVIEW_CANDIDATES } from '../../utility-model/oneShotCandidates.js'; +import { + AUTO_REVIEW_CHAIN_TIMEOUT_MS, + createAutoReviewModelRouter, +} from '../auto-review-model-router.js'; + +const logger = () => ({ debug: vi.fn(), warn: vi.fn() }); + +function failed( + candidate: (typeof DEDICATED_AUTO_REVIEW_CANDIDATES)[number], + reason: 'timeout' | 'empty_response' | 'request_failed' | 'http_error', + httpStatus?: number, +): UtilityTextResult { + return { + ok: false, + reason: reason === 'timeout' || reason === 'empty_response' ? reason : 'all_candidates_failed', + attempts: [reason === 'http_error' + ? { + providerId: candidate.providerId, + model: candidate.model, + transport: candidate.transport, + status: 'failed', + reason, + httpStatus: httpStatus ?? 500, + } + : { + providerId: candidate.providerId, + model: candidate.model, + transport: candidate.transport, + status: 'failed', + reason, + }], + }; +} + +function succeeded( + candidate: (typeof DEDICATED_AUTO_REVIEW_CANDIDATES)[number], + text: string, +): UtilityTextResult { + return { + ok: true, + text, + providerId: candidate.providerId, + model: candidate.model, + transport: candidate.transport, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('dedicated Auto-review candidate policy', () => { + it('contains only the managed Gateway and supported subscription models in fixed order', () => { + expect(DEDICATED_AUTO_REVIEW_CANDIDATES.map((candidate) => [ + candidate.providerId, + candidate.model, + ])).toEqual([ + ['xd', 'cindy/auto-review'], + ['openai', 'gpt-5.4-nano'], + ['openai', 'gpt-5.6-luna'], + ['anthropic', 'claude-haiku-4-5'], + ]); + expect(JSON.stringify(DEDICATED_AUTO_REVIEW_CANDIDATES)).not.toMatch( + /xai|deepseek|kimi|custom/i, + ); + }); + + it('continues across malformed JSON, HTTP errors, and empty responses', async () => { + const log = logger(); + const calls: string[] = []; + const requestCandidate = vi.fn(async (_prompt, candidate) => { + calls.push(candidate.id); + switch (candidate.id) { + case 'cindy-gateway': + return succeeded(candidate, 'not json'); + case 'chatgpt-nano': + return failed(candidate, 'http_error', 400); + case 'chatgpt-luna': + return failed(candidate, 'empty_response'); + case 'claude-haiku': + return succeeded(candidate, '{"verdict":"allow","reason":"Routine"}'); + default: + throw new Error('Unexpected Auto-review candidate'); + } + }); + const route = createAutoReviewModelRouter({ logger: log, requestCandidate }); + + await expect(route('classify')).resolves.toBe( + '{"verdict":"allow","reason":"Routine"}', + ); + expect(calls).toEqual([ + 'cindy-gateway', + 'chatgpt-nano', + 'chatgpt-luna', + 'chatgpt-luna', + 'claude-haiku', + ]); + }); + + it('retries a quick transient provider failure in place', async () => { + const candidate = DEDICATED_AUTO_REVIEW_CANDIDATES[0]; + const requestCandidate = vi.fn() + .mockRejectedValueOnce(new Error('credential refresh failed with sensitive details')) + .mockResolvedValueOnce(succeeded(candidate, '{"verdict":"block"}')); + const route = createAutoReviewModelRouter({ logger: logger(), requestCandidate }); + + await expect(route('classify')).resolves.toBe('{"verdict":"block"}'); + expect(requestCandidate).toHaveBeenCalledTimes(2); + expect(requestCandidate.mock.calls.map((call) => call[1].id)).toEqual([ + 'cindy-gateway', + 'cindy-gateway', + ]); + }); + + it('moves to the next provider after a full candidate timeout instead of starving fallback', async () => { + vi.useFakeTimers(); + const calls: string[] = []; + const requestCandidate = vi.fn((_prompt, candidate) => { + calls.push(candidate.id); + if (candidate.id === 'cindy-gateway') { + // Simulates credential refresh that ignores AbortSignal and never settles. + return new Promise(() => undefined); + } + return Promise.resolve(succeeded(candidate, '{"verdict":"allow"}')); + }); + const route = createAutoReviewModelRouter({ logger: logger(), requestCandidate }); + + const pending = route('classify'); + await vi.advanceTimersByTimeAsync(12_000); + + await expect(pending).resolves.toBe('{"verdict":"allow"}'); + expect(calls).toEqual(['cindy-gateway', 'chatgpt-nano']); + }); + + it('aborts the in-flight request at the total deadline without starting another chain', async () => { + vi.useFakeTimers(); + const observedSignals: AbortSignal[] = []; + const repeatedCandidates = Array.from( + { length: 8 }, + () => DEDICATED_AUTO_REVIEW_CANDIDATES[0], + ); + const requestCandidate = vi.fn((_prompt, _candidate, opts) => { + observedSignals.push(opts.signal as AbortSignal); + return new Promise(() => undefined); + }); + const route = createAutoReviewModelRouter({ + logger: logger(), + candidates: repeatedCandidates, + requestCandidate, + }); + + const pending = route('classify'); + await vi.advanceTimersByTimeAsync(AUTO_REVIEW_CHAIN_TIMEOUT_MS); + + await expect(pending).resolves.toBeNull(); + expect(requestCandidate).toHaveBeenCalledTimes(5); + expect(observedSignals).toHaveLength(5); + expect(observedSignals.every((signal) => signal.aborted)).toBe(true); + }); + + it('stops immediately when the owning reviewer aborts', async () => { + const controller = new AbortController(); + const requestCandidate = vi.fn((_prompt, candidate, opts) => + new Promise((resolve) => { + opts.signal?.addEventListener( + 'abort', + () => resolve(failed(candidate, 'timeout')), + { once: true }, + ); + })); + const route = createAutoReviewModelRouter({ logger: logger(), requestCandidate }); + + const pending = route('classify', controller.signal); + controller.abort(); + + await expect(pending).resolves.toBeNull(); + expect(requestCandidate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/main/maker-host/__tests__/cc-remote-mcp.test.ts b/apps/desktop/src/main/maker-host/__tests__/cc-remote-mcp.test.ts index 30e0d2819c..4eccba8e26 100644 --- a/apps/desktop/src/main/maker-host/__tests__/cc-remote-mcp.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/cc-remote-mcp.test.ts @@ -10,24 +10,33 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { RemoteHost } from '@cindy/maker-remote-ssh'; import type { CodexHttpBridge } from '../../mcp-integrations/codexHttpBridge.js'; +import { CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY } from '../../mcp-integrations/codexBuiltinToolPolicy.js'; import { buildCcRemoteHttpMcpServers } from '../cc-remote-mcp.js'; function fakeBridge() { - const registered = new Map(); - const bridge = { - registerSessionCtx: vi.fn((sessionId: string, ctx: { + const registered = new Map< + string, + { sessionId: string; sessionInstanceId?: string; agentKind: string; vendorOptions: unknown; - }) => { - registered.set(sessionId, ctx); - }), + } + >(); + const bridge = { + registerSessionCtx: vi.fn( + ( + sessionId: string, + ctx: { + sessionId: string; + sessionInstanceId?: string; + agentKind: string; + vendorOptions: unknown; + }, + ) => { + registered.set(sessionId, ctx); + }, + ), unregisterSessionCtx: vi.fn((sessionId: string, expectedCtx?: unknown) => { if (expectedCtx !== undefined && registered.get(sessionId) !== expectedCtx) return; registered.delete(sessionId); @@ -81,6 +90,33 @@ describe('buildCcRemoteHttpMcpServers', () => { expect(() => cleanup()).not.toThrow(); }); + it('does not inject collaboration when the frozen Bot Toolset disables it', async () => { + const { bridge, registered } = fakeBridge(); + const { servers, fingerprint } = await buildCcRemoteHttpMcpServers( + { + host: HOST, + sessionId: 'bot-no-collab', + workingDir: '/remote/repo', + vendorOptions: { + [CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY]: ['collab'], + }, + }, + { + ensureBridgeStarted: async () => ({ + port: 38080, + serverNames: ['cindy_orca', 'orca_worker_bridge'], + bridge, + }), + ensureForward: vi.fn(async () => 47921), + getBridgeToken: async () => 'persistent-test-token', + isCollabEnabled: () => true, + }, + ); + expect(servers).toEqual({}); + expect(fingerprint).toBe('disabled'); + expect(registered.size).toBe(0); + }); + it('flags needsFreshStart when the bridge token is unavailable (R21 P2)', async () => { // token 失效但本要注入:调用方必须 forceFresh — 否则 attach 回带旧 // Authorization header 的 alive query, 协同 MCP 持续 401。 @@ -122,7 +158,11 @@ describe('buildCcRemoteHttpMcpServers', () => { // 此前注入注册过 ctx 的 session, collab 禁用后 build 必须摘掉它 — // 否则 ?session= 的授权路由在禁用后仍可用到 bridge 关闭。 const { bridge, registered } = fakeBridge(); - registered.set('s-disable', { sessionId: 's-disable', agentKind: 'claude-code', vendorOptions: {} }); + registered.set('s-disable', { + sessionId: 's-disable', + agentKind: 'claude-code', + vendorOptions: {}, + }); const { servers, fingerprint } = await buildCcRemoteHttpMcpServers( { host: HOST, sessionId: 's-disable', workingDir: '/remote/repo' }, { @@ -243,7 +283,10 @@ describe('buildCcRemoteHttpMcpServers', () => { }, ); cleanup(); - expect(spies.unregisterSessionCtx).toHaveBeenCalledWith('s1', expect.objectContaining({ sessionId: 's1' })); + expect(spies.unregisterSessionCtx).toHaveBeenCalledWith( + 's1', + expect.objectContaining({ sessionId: 's1' }), + ); }); it('re-registering the same session overwrites instead of accumulating (resume/rebuild)', async () => { @@ -272,7 +315,10 @@ describe('buildCcRemoteHttpMcpServers', () => { getBridgeToken: async () => 'tok', synthesizeVendorOptions: async () => ({}), }; - const first = await buildCcRemoteHttpMcpServers({ host: HOST, sessionId: 's1', workingDir: '/a' }, deps); + const first = await buildCcRemoteHttpMcpServers( + { host: HOST, sessionId: 's1', workingDir: '/a' }, + deps, + ); await buildCcRemoteHttpMcpServers({ host: HOST, sessionId: 's1', workingDir: '/b' }, deps); first.cleanup(); expect(registered.has('s1')).toBe(true); @@ -292,7 +338,12 @@ describe('buildCcRemoteHttpMcpServers', () => { orcaWorkerSessionId: 's1', }; await buildCcRemoteHttpMcpServers( - { host: HOST, sessionId: 's1', workingDir: '/remote/repo', vendorOptions: workerVendorOptions }, + { + host: HOST, + sessionId: 's1', + workingDir: '/remote/repo', + vendorOptions: workerVendorOptions, + }, { ensureBridgeStarted: async () => ({ port: 38080, serverNames: ['cindy_orca'], bridge }), ensureForward: vi.fn(async () => 47921), @@ -386,7 +437,11 @@ describe('buildCcRemoteHttpMcpServers', () => { synthesizeVendorOptions: async () => ({}), }, ); - expect(Object.keys(servers).sort()).toEqual(['cindy_memory', 'cindy_orca', 'orca_worker_bridge']); + expect(Object.keys(servers).sort()).toEqual([ + 'cindy_memory', + 'cindy_orca', + 'orca_worker_bridge', + ]); expect(servers.cindy_memory).toEqual({ type: 'http', url: 'http://127.0.0.1:47921/mcp/cindy_memory?session=s1', @@ -394,7 +449,10 @@ describe('buildCcRemoteHttpMcpServers', () => { }); // ctx 必须带 remoteHostId — cindy_memory 据此把远端路径隔离到 // ssh:: 的独立 store。 - expect(registered.get('s1')).toMatchObject({ remoteHostId: 'host-1', workingDir: '/remote/repo' }); + expect(registered.get('s1')).toMatchObject({ + remoteHostId: 'host-1', + workingDir: '/remote/repo', + }); }); it('injects only cindy_memory when collab is disabled but the session Maker Memory flag is on', async () => { diff --git a/apps/desktop/src/main/maker-host/__tests__/custom-provider-store.test.ts b/apps/desktop/src/main/maker-host/__tests__/custom-provider-store.test.ts index e31753e64a..0e3183d664 100644 --- a/apps/desktop/src/main/maker-host/__tests__/custom-provider-store.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/custom-provider-store.test.ts @@ -182,6 +182,48 @@ describe('validateCustomProviderConfig (per-runtime)', () => { } }); + it('accepts a same-origin model route and rejects unsafe route variants', () => { + const config = (route: unknown) => ({ + ...valid, + runtimes: { + codex: { + baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', + wireProtocol: 'openai-chat', + models: [{ id: 'glm-5.3', name: 'GLM-5.3', route }], + }, + }, + }); + expect( + validateCustomProviderConfig( + config({ + baseUrl: 'https://open.bigmodel.cn/api/v1', + wireProtocol: 'openai-responses', + }), + ), + ).toEqual({ ok: true }); + expect( + validateCustomProviderConfig( + config({ + baseUrl: 'https://evil.example/api/v1', + wireProtocol: 'openai-responses', + }), + ).ok, + ).toBe(false); + expect( + validateCustomProviderConfig( + config({ + baseUrl: 'https://user:secret@open.bigmodel.cn/api/v1', + wireProtocol: 'openai-responses', + }), + ).ok, + ).toBe(false); + expect( + validateCustomProviderConfig( + config({ baseUrl: 'https://open.bigmodel.cn/api/v1', wireProtocol: 'bogus' }), + ).ok, + ).toBe(false); + }); + it('accepts supported per-model piApi values only on a Pi runtime', () => { for (const piApi of [ 'anthropic-messages', @@ -756,6 +798,34 @@ describe('custom-provider-store CRUD (per-runtime)', () => { .toBe('/tenant/acme/v2/infer?stream=1'); }); + it('round-trips a validated model-specific route', async () => { + mountDb(); + await createCustomProvider({ + ...valid, + runtimes: { + codex: { + baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', + wireProtocol: 'openai-chat', + models: [ + { + id: 'glm-5.3', + name: 'GLM-5.3', + route: { + baseUrl: 'https://open.bigmodel.cn/api/v1', + wireProtocol: 'openai-responses', + }, + }, + ], + }, + }, + }); + + expect((await getCustomProvider('openrouter'))?.runtimes.codex?.models[0]?.route).toEqual({ + baseUrl: 'https://open.bigmodel.cn/api/v1', + wireProtocol: 'openai-responses', + }); + }); + it('strips requestPath from Pi native runtime records', async () => { mountDb(); await createCustomProvider({ diff --git a/apps/desktop/src/main/maker-host/__tests__/iosSimulatorCodexDynamicTools.test.ts b/apps/desktop/src/main/maker-host/__tests__/iosSimulatorCodexDynamicTools.test.ts index c1d54014bc..43e4106160 100644 --- a/apps/desktop/src/main/maker-host/__tests__/iosSimulatorCodexDynamicTools.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/iosSimulatorCodexDynamicTools.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { createIOSSimulatorCodexDynamicToolProvider } from '../ios-simulator-codex-dynamic-tools.js'; +import { CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY } from '../../mcp-integrations/codexBuiltinToolPolicy.js'; const CONTEXT = { sessionId: 'session-a', @@ -11,6 +12,31 @@ const CONTEXT = { }; describe('iOS Simulator Codex dynamic tools', () => { + it('does not advertise or execute the eager gateway when the Bot snapshot disables it', async () => { + const callTool = vi.fn(); + const provider = createIOSSimulatorCodexDynamicToolProvider({ deps: { callTool } }); + const context = { + ...CONTEXT, + vendorOptions: { [CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY]: ['ios-simulator'] }, + }; + + expect(provider.listTools(context)).toEqual([]); + await expect( + provider.callTool( + { + threadId: 'thread-disabled', + turnId: 'turn-disabled', + callId: 'call-disabled', + namespace: null, + tool: 'cindy_ios_simulator__list_tools', + arguments: {}, + }, + context, + ), + ).resolves.toMatchObject({ success: false }); + expect(callTool).not.toHaveBeenCalled(); + }); + it('keeps the lightweight gateway discoverable so it can explain installation', () => { const provider = createIOSSimulatorCodexDynamicToolProvider({ deps: { callTool: vi.fn() }, diff --git a/apps/desktop/src/main/maker-host/__tests__/piRemoteTransport.test.ts b/apps/desktop/src/main/maker-host/__tests__/piRemoteTransport.test.ts index 79ae7a2865..9764117af4 100644 --- a/apps/desktop/src/main/maker-host/__tests__/piRemoteTransport.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/piRemoteTransport.test.ts @@ -169,6 +169,18 @@ describe('pi remote file ops command hygiene', () => { const ops = createRemotePiFileOps(host); expect(await ops.stat('/nope')).toBeNull(); }); + + it('hashes the complete remote file in place without transferring its contents', async () => { + const digest = 'a'.repeat(64); + const { calls, host } = makeHostExec(`${digest}\n`); + const ops = createRemotePiFileOps(host); + + await expect(ops.sha256File('/srv/bot/SKILL.md')).resolves.toBe(digest); + expect(calls[0].cmd).toContain('sha256sum'); + expect(calls[0].cmd).toContain('shasum -a 256'); + expect(calls[0].cmd).toContain('openssl dgst -sha256'); + expect(calls[0].cmd).not.toContain('head -c'); + }); }); describe('killRemotePiManagerSession — pi-manager exclusive path (post python retirement)', () => { diff --git a/apps/desktop/src/main/maker-host/__tests__/providerAccessPolicy.test.ts b/apps/desktop/src/main/maker-host/__tests__/providerAccessPolicy.test.ts index a82ef8ac89..1f1984f137 100644 --- a/apps/desktop/src/main/maker-host/__tests__/providerAccessPolicy.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/providerAccessPolicy.test.ts @@ -6,7 +6,7 @@ import { deriveAvailableModels } from '../catalog-to-descriptors.js'; import { filterProviderCatalogForAccount, isProviderSelectable, - projectProviderCatalogForBuildRegion, + projectUnverifiedCatalogFallbackForBuildRegion, } from '../provider-access-policy.js'; function model(id: string): CatalogModel { @@ -96,33 +96,81 @@ describe('provider access policy', () => { expect(filterProviderCatalogForAccount(input, {})).toBe(input); }); - it('preserves the full media catalog and object identity for Global', () => { - const input = catalog(); - expect(projectProviderCatalogForBuildRegion(input, 'global')).toBe(input); + it('keeps Gateway and locally connected xAI models verbatim; account-free mode only removes XD', () => { + const xai: Provider = { + id: 'xai', + name: 'xAI', + source: 'builtin', + agents: ['claude-code'], + auth: { method: 'oauth' }, + routing: {}, + models: { + 'claude-code': [ + model('xai/grok-4'), + { ...model('xai/grok-imagine-image'), group: 'image' }, + { ...model('xai/grok-imagine-video'), group: 'video' }, + { ...model('xai/grok-imagine-video-1.5'), group: 'video' }, + ], + }, + imageModels: [{ id: 'xai/grok-imagine-image', name: 'Grok Imagine Image' }], + videoModels: [ + { id: 'xai/grok-imagine-video', name: 'Grok Imagine Video' }, + { id: 'xai/grok-imagine-video-1.5', name: 'Grok Imagine Video 1.5' }, + ], + }; + const base = catalog(); + const input: Catalog = { ...base, providers: [...base.providers, xai] }; + + expect(filterProviderCatalogForAccount(input, { canUseCindyGateway: true })).toBe(input); + + const local = filterProviderCatalogForAccount(input, { canUseCindyGateway: false }); + expect(local.providers.map((item) => item.id)).toEqual(['anthropic', 'xai']); + expect(local.providers.find((item) => item.id === 'xai')).toBe(xai); + expect(xai.models['claude-code']?.map((item) => item.id)).toEqual([ + 'xai/grok-4', + 'xai/grok-imagine-image', + 'xai/grok-imagine-video', + 'xai/grok-imagine-video-1.5', + ]); + expect(xai.imageModels?.map((item) => item.id)).toEqual(['xai/grok-imagine-image']); + expect(xai.videoModels?.map((item) => item.id)).toEqual([ + 'xai/grok-imagine-video', + 'xai/grok-imagine-video-1.5', + ]); }); - it.each(['cn', 'dev'] as const)( - 'projects the Cindy AI media catalog to Mainland capabilities for %s', - (region) => { - const projected = projectProviderCatalogForBuildRegion(catalog(), region); - const xd = projected.providers.find((item) => item.id === 'xd'); + it('projects only unverified XD fallback media in CN/dev and preserves xAI discovery verbatim', () => { + const xai: Provider = { + id: 'xai', + name: 'xAI', + source: 'builtin', + agents: ['claude-code'], + auth: { method: 'oauth' }, + routing: {}, + models: { + 'claude-code': [ + { ...model('xai/grok-imagine-image'), group: 'image' }, + { ...model('xai/grok-imagine-video'), group: 'video' }, + ], + }, + imageModels: [{ id: 'xai/grok-imagine-image', name: 'Grok Imagine Image' }], + videoModels: [{ id: 'xai/grok-imagine-video', name: 'Grok Imagine Video' }], + }; + const input = catalog(); + input.providers.push(xai); + expect(projectUnverifiedCatalogFallbackForBuildRegion(input, 'global')).toBe(input); + for (const region of ['cn', 'dev'] as const) { + const projected = projectUnverifiedCatalogFallbackForBuildRegion(input, region); + const xd = projected.providers.find((item) => item.id === 'xd'); expect(xd?.imageModels).toEqual([]); - expect(xd?.imageDefaults).toBeUndefined(); - // 向量与图像同口径:整段清空 —— 该 endpoint 后面全是境外模型, - // 留着清单只会让插件拿到"可选但必失败"的型号。 expect(xd?.embeddingModels).toEqual([]); - expect(xd?.embeddingDefaults).toBeUndefined(); expect(xd?.videoModels?.map((item) => item.id)).toEqual([ 'seedance-fast', 'seedance-pro', 'bytedance/seedance-2.5', ]); - expect(xd?.videoDefaults).toEqual({ - standard: 'seedance-fast', - best: 'seedance-pro', - }); - // 聊天目录里被归到 embedding 的条目也一并滤掉(设置页的「向量」分组同样清空)。 + expect(xd?.videoDefaults).toEqual({ standard: 'seedance-fast', best: 'seedance-pro' }); expect(xd?.models['claude-code']?.map((item) => item.id)).toEqual([ 'shared-model', 'xd-only-model', @@ -130,138 +178,34 @@ describe('provider access policy', () => { 'seedance-pro', 'bytedance/seedance-2.5', ]); - expect(xd?.models.codex).toEqual([]); - }, - ); -}); + expect(projected.providers.find((item) => item.id === 'xai')).toBe(xai); + } + }); -describe('projectProviderCatalogForBuildRegion — 用户自有媒体来源', () => { - it('非 global 区域只投影 Cindy AI,保留用户连接的图像来源', () => { - const gemini: Provider = { - id: 'gemini', - name: 'Google Gemini', - source: 'builtin', - agents: ['claude-code'], - auth: { method: 'oauth' }, - routing: {}, - models: { 'claude-code': [] }, - imageModels: [{ id: 'gemini/gemini-3-pro-image', name: 'Gemini 3 Pro Image' }], - }; - const openai: Provider = { - id: 'openai', - name: 'OpenAI', - source: 'builtin', - agents: ['claude-code'], - auth: { method: 'oauth' }, - routing: {}, - models: { 'claude-code': [] }, - imageModels: [{ id: 'openai/gpt-image-2', name: 'GPT Image 2' }], - // 防御对象:即使未来用户来源声明 defaults,区域投影也不应改写它。 - imageDefaults: { standard: 'openai/gpt-image-2' }, - }; - const base = catalog(); - const projected = projectProviderCatalogForBuildRegion( - { ...base, providers: [...base.providers, gemini, openai] }, + it('projects only the XD media fields that were inherited from bundled', () => { + const input = catalog(); + const projected = projectUnverifiedCatalogFallbackForBuildRegion( + input, 'cn', + new Set(['embedding']), ); - expect(projected.providers.find((p) => p.id === 'gemini')).toBe(gemini); - expect(projected.providers.find((p) => p.id === 'openai')).toBe(openai); - const xd = projected.providers.find((p) => p.id === 'xd'); - expect(xd?.imageModels).toEqual([]); - expect(xd?.imageDefaults).toBeUndefined(); - expect(xd?.videoModels?.map((m) => m.id)).toEqual([ + const originalXd = input.providers.find((provider) => provider.id === 'xd'); + const xd = projected.providers.find((provider) => provider.id === 'xd'); + + expect(xd?.imageModels).toEqual(originalXd?.imageModels); + expect(xd?.imageDefaults).toEqual(originalXd?.imageDefaults); + expect(xd?.videoModels).toEqual(originalXd?.videoModels); + expect(xd?.videoDefaults).toEqual(originalXd?.videoDefaults); + expect(xd?.embeddingModels).toEqual([]); + expect(xd?.embeddingDefaults).toBeUndefined(); + expect(xd?.models['claude-code']?.map((model) => model.id)).toEqual([ + 'shared-model', + 'xd-only-model', + 'gpt-image-2', 'seedance-fast', 'seedance-pro', 'bytedance/seedance-2.5', + 'happyhorse', ]); }); - - it.each(['cn', 'dev'] as const)( - '非 xd 供应商在 %s 保留图像与聊天能力,暂不暴露视频能力', - (region) => { - const thirdParty: Provider = { - id: 'third', - name: 'Third Party', - source: 'builtin', - agents: ['claude-code'], - auth: { method: 'oauth' }, - routing: {}, - models: { - 'claude-code': [ - { ...model('seedance-fast'), group: 'video' }, - { ...model('third/image'), group: 'image' }, - model('chat-model'), - ], - codex: undefined, - }, - imageModels: [{ id: 'third/image', name: 'Third Image' }], - imageDefaults: { standard: 'third/image' }, - videoModels: [{ id: 'seedance-fast', name: 'Seedance Fast' }], - videoDefaults: { standard: 'seedance-fast' }, - }; - const base = catalog(); - const projected = projectProviderCatalogForBuildRegion( - { ...base, providers: [...base.providers, thirdParty] }, - region, - ); - const third = projected.providers.find((p) => p.id === 'third'); - expect(third).not.toBe(thirdParty); - expect(third?.models['claude-code']?.map((m) => m.id)).toEqual(['third/image', 'chat-model']); - expect(third?.models.codex).toEqual([]); - expect(third?.imageModels).toBe(thirdParty.imageModels); - expect(third?.imageDefaults).toBe(thirdParty.imageDefaults); - expect(third?.videoModels).toEqual([]); - expect(third?.videoDefaults).toBeUndefined(); - }, - ); - - it('mode: image_generation 的用户模型在 cn 区域保持可用', () => { - const modeOnly: Provider = { - id: 'xai', - name: 'xAI', - source: 'builtin', - agents: ['claude-code'], - auth: { method: 'oauth' }, - routing: {}, - models: { - 'claude-code': [ - { ...model('xai/aurora'), mode: 'image_generation' }, - model('xai/grok-chat'), - ], - }, - }; - const base = catalog(); - const projected = projectProviderCatalogForBuildRegion( - { ...base, providers: [...base.providers, modeOnly] }, - 'cn', - ); - const xai = projected.providers.find((p) => p.id === 'xai'); - expect(xai).toBe(modeOnly); - expect(xai?.models['claude-code']?.map((m) => m.id)).toEqual([ - 'xai/aurora', - 'xai/grok-chat', - ]); - }); - - it('global 区域原样返回(含新来源的媒体清单)', () => { - const gemini: Provider = { - id: 'gemini', - name: 'Google Gemini', - source: 'builtin', - agents: ['claude-code'], - auth: { method: 'oauth' }, - routing: {}, - models: { 'claude-code': [] }, - imageModels: [{ id: 'gemini/gemini-3-pro-image', name: 'Gemini 3 Pro Image' }], - }; - const base = catalog(); - const input = { ...base, providers: [...base.providers, gemini] }; - const projected = projectProviderCatalogForBuildRegion(input, 'global'); - expect(projected).toBe(input); - }); - - it('目录没有 Cindy AI 时非 global 也保持原对象', () => { - const input: Catalog = { version: 'test', providers: [] }; - expect(projectProviderCatalogForBuildRegion(input, 'cn')).toBe(input); - }); }); diff --git a/apps/desktop/src/main/maker-host/__tests__/providerCatalogRealmReload.test.ts b/apps/desktop/src/main/maker-host/__tests__/providerCatalogRealmReload.test.ts index 260af588ec..369509c941 100644 --- a/apps/desktop/src/main/maker-host/__tests__/providerCatalogRealmReload.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/providerCatalogRealmReload.test.ts @@ -9,9 +9,14 @@ const h = vi.hoisted(() => ({ endpoint: 'https://model.cn.example', buildEndpoint: 'https://model.cn.example', owner: 'owner-default', + canUseCindyGateway: true, loads: [] as Array<{ source: Record; - resolve: (catalog: unknown) => void; + resolve: ( + catalog: unknown, + capabilityEvidence?: 'current' | 'fallback', + unverifiedXdMediaKinds?: readonly ('image' | 'video' | 'embedding')[], + ) => void; }>, refreshLoads: [] as Array<{ source: Record; @@ -34,9 +39,30 @@ vi.mock('@cindy/model-providers', async (importOriginal) => { return { ...actual, loadCatalog: vi.fn( - (source: Record) => + ( + source: Record, + _io: unknown, + onResolved?: (result: unknown) => void, + ) => new Promise((resolve) => { - h.loads.push({ source, resolve }); + h.loads.push({ + source, + resolve: ( + catalog, + capabilityEvidence = 'current', + unverifiedXdMediaKinds = capabilityEvidence === 'current' + ? [] + : ['image', 'video', 'embedding'], + ) => { + onResolved?.({ + catalog, + source: capabilityEvidence === 'current' ? 'remote' : 'cache', + capabilityEvidence, + unverifiedXdMediaKinds, + }); + resolve(catalog); + }, + }); }), ), loadCatalogWithSource: vi.fn( @@ -72,8 +98,12 @@ vi.mock('../../appSessionState.js', () => ({ ownerScopedUserDataPath: (...segments: string[]) => path.join(os.tmpdir(), 'provider-catalog-realm-reload', h.owner, ...segments), })); +vi.mock('../../../shared/brandRegion.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, CURRENT_CINDY_REGION: 'cn' }; +}); vi.mock('../../appCapabilities.js', () => ({ - getAppCapabilities: () => ({ canUseCindyGateway: false }), + getAppCapabilities: () => ({ canUseCindyGateway: h.canUseCindyGateway }), })); vi.mock('../../ownerNamespaceMigration.js', () => ({ hasLegacyOwnerNamespaceClaim: () => false, @@ -139,6 +169,7 @@ import { type Catalog, type CustomProviderConfig, } from '@cindy/model-providers'; +import { deriveCindyMediaConfig } from '../../cindy-brain/cindyMediaCatalog.js'; import { getActiveCatalog, commitModelPlaneFromCatalog, @@ -150,6 +181,7 @@ import { import { __testing, ensureActiveCatalogLoaded, + getDesktopSelectableCatalog, refreshActiveCatalogFromSource, refreshCustomProvidersIntoCatalog, reloadActiveCatalogForEndpointChange, @@ -513,9 +545,19 @@ describe('provider catalog realm reload', () => { baseUrl: 'https://model.cn.example', fallbackBaseUrl: 'https://legacy-build-cdn.example', }); - h.loads[0]!.resolve(catalogNamed('catalog-cn-initial')); + h.loads[0]!.resolve(catalogNamed('catalog-cn-initial'), 'current', ['embedding']); await initial; expect(activeMarker()).toBe('catalog-cn-initial'); + const startupXd = getDesktopSelectableCatalog().providers.find( + (provider) => provider.id === 'xd', + ); + expect(startupXd?.imageModels).toEqual( + BUNDLED_CATALOG.providers.find((provider) => provider.id === 'xd')?.imageModels, + ); + expect(startupXd?.videoModels).toEqual( + BUNDLED_CATALOG.providers.find((provider) => provider.id === 'xd')?.videoModels, + ); + expect(startupXd?.embeddingModels).toEqual([]); h.endpoint = 'https://model.global.example'; const globalReload = reloadActiveCatalogForEndpointChange(); @@ -538,10 +580,178 @@ describe('provider catalog realm reload', () => { expect(activeMarker()).toBe('catalog-cn-latest'); }); + it('keeps bundled/LKG/waiting catalogs region-safe without hiding discovered xAI media', async () => { + const catalogWithXaiMedia = structuredClone(catalogNamed('catalog-with-xai-media')); + const xai = catalogWithXaiMedia.providers.find((provider) => provider.id === 'xai'); + if (!xai) throw new Error('expected xAI provider'); + xai.imageModels = [ + { id: 'xai/grok-imagine-image', name: 'Grok Imagine Image' }, + ]; + xai.imageDefaults = { standard: 'xai/grok-imagine-image' }; + xai.videoModels = [ + { id: 'xai/grok-imagine-video', name: 'Grok Imagine Video' }, + { id: 'xai/grok-imagine-video-1.5', name: 'Grok Imagine Video 1.5' }, + ]; + xai.videoDefaults = { standard: 'xai/grok-imagine-video' }; + + h.endpoint = 'https://model.cn-fallback.example'; + const fallbackReload = reloadActiveCatalogForEndpointChange(); + + // The endpoint-switch window uses bundled data, but it cannot advertise Global XD media. + const waitingXd = getDesktopSelectableCatalog().providers.find((provider) => provider.id === 'xd'); + expect(waitingXd?.imageModels).toEqual([]); + expect(waitingXd?.embeddingModels).toEqual([]); + expect(waitingXd?.videoModels?.map((model) => model.id)).not.toContain('happyhorse'); + + h.loads.at(-1)!.resolve(catalogWithXaiMedia, 'fallback'); + await fallbackReload; + + const fallbackCatalog = getDesktopSelectableCatalog(); + const fallbackXd = fallbackCatalog.providers.find((provider) => provider.id === 'xd'); + expect(fallbackXd?.imageModels).toEqual([]); + expect(fallbackXd?.embeddingModels).toEqual([]); + expect(fallbackXd?.videoModels?.map((model) => model.id)).not.toContain('happyhorse'); + expect( + fallbackCatalog.providers.find((provider) => provider.id === 'xai')?.videoModels?.map( + (model) => model.id, + ), + ).toEqual(['xai/grok-imagine-video', 'xai/grok-imagine-video-1.5']); + expect(deriveCindyMediaConfig(fallbackCatalog.providers, 'embed')).toEqual({ + models: [], + defaults: null, + }); + expect( + deriveCindyMediaConfig(fallbackCatalog.providers, 'video').models.map((model) => model.id), + ).toContain('xai/grok-imagine-video-1.5'); + + h.endpoint = 'https://model.cn-current.example'; + const currentReload = reloadActiveCatalogForEndpointChange(); + expect( + getDesktopSelectableCatalog() + .providers.find((provider) => provider.id === 'xd') + ?.videoModels?.map((model) => model.id), + ).not.toContain('happyhorse'); + const currentCatalogWithExplicitXd = structuredClone(catalogWithXaiMedia); + const explicitXd = currentCatalogWithExplicitXd.providers.find( + (provider) => provider.id === 'xd', + ); + if (!explicitXd) throw new Error('expected XD provider'); + explicitXd.imageModels = []; + delete explicitXd.imageDefaults; + explicitXd.embeddingModels = []; + delete explicitXd.embeddingDefaults; + explicitXd.videoModels = [{ id: 'seedance-fast', name: 'Seedance Fast' }]; + explicitXd.videoDefaults = { standard: 'seedance-fast' }; + h.loads.at(-1)!.resolve(currentCatalogWithExplicitXd, 'current'); + await currentReload; + + const currentCatalog = getDesktopSelectableCatalog(); + const currentXd = currentCatalog.providers.find((provider) => provider.id === 'xd'); + expect(currentXd?.imageModels).toEqual([]); + expect(currentXd?.embeddingModels).toEqual([]); + expect(currentXd?.videoModels).toEqual([{ id: 'seedance-fast', name: 'Seedance Fast' }]); + expect(deriveCindyMediaConfig(currentCatalog.providers, 'embed')).toEqual({ + models: [], + defaults: null, + }); + expect( + currentCatalog.providers.find((provider) => provider.id === 'xai')?.videoModels?.map( + (model) => model.id, + ), + ).toEqual(['xai/grok-imagine-video', 'xai/grok-imagine-video-1.5']); + + // Restore the baseline expected by the following realm-race tests. + h.endpoint = 'https://model.cn.example'; + const reset = reloadActiveCatalogForEndpointChange(); + h.loads.at(-1)!.resolve(catalogNamed('catalog-cn-latest')); + await reset; + }); + + it('keeps bundled XD backfill field-scoped across endpoint reload and refresh', async () => { + const inheritedAll = structuredClone(catalogNamed('current-with-bundled-xd')); + const inheritedXai = inheritedAll.providers.find((provider) => provider.id === 'xai'); + if (!inheritedXai) throw new Error('expected xAI provider'); + inheritedXai.imageModels = [ + { id: 'xai/grok-imagine-image', name: 'Grok Imagine Image' }, + ]; + inheritedXai.videoModels = [ + { id: 'xai/grok-imagine-video', name: 'Grok Imagine Video' }, + ]; + + h.endpoint = 'https://model.cn-primary-missing-xd.example'; + const reload = reloadActiveCatalogForEndpointChange(); + h.loads.at(-1)!.resolve( + inheritedAll, + 'current', + ['image', 'video', 'embedding'], + ); + await reload; + + const projectedAll = getDesktopSelectableCatalog(); + const projectedAllXd = projectedAll.providers.find((provider) => provider.id === 'xd'); + expect(projectedAllXd?.imageModels).toEqual([]); + expect(projectedAllXd?.embeddingModels).toEqual([]); + expect(projectedAllXd?.videoModels?.map((model) => model.id)).not.toContain('happyhorse'); + expect(projectedAll.providers.find((provider) => provider.id === 'xai')?.videoModels).toEqual( + inheritedXai.videoModels, + ); + + const inheritedEmbedding = structuredClone(inheritedAll); + const inheritedEmbeddingXd = inheritedEmbedding.providers.find( + (provider) => provider.id === 'xd', + ); + if (!inheritedEmbeddingXd) throw new Error('expected XD provider'); + inheritedEmbeddingXd.imageModels = [ + { id: 'gateway-current-image', name: 'Gateway Current Image' }, + ]; + inheritedEmbeddingXd.videoModels = [ + { id: 'gateway-current-video', name: 'Gateway Current Video' }, + ]; + + const partialRefresh = refreshActiveCatalogFromSource(); + await Promise.resolve(); + h.refreshLoads.at(-1)!.resolve({ + catalog: inheritedEmbedding, + source: 'remote', + capabilityEvidence: 'current', + unverifiedXdMediaKinds: ['embedding'], + }); + await partialRefresh; + + const projectedEmbedding = getDesktopSelectableCatalog().providers.find( + (provider) => provider.id === 'xd', + ); + expect(projectedEmbedding?.imageModels).toEqual(inheritedEmbeddingXd.imageModels); + expect(projectedEmbedding?.videoModels).toEqual(inheritedEmbeddingXd.videoModels); + expect(projectedEmbedding?.embeddingModels).toEqual([]); + + const evidenceUpgrade = refreshActiveCatalogFromSource(); + await Promise.resolve(); + h.refreshLoads.at(-1)!.resolve({ + catalog: structuredClone(inheritedEmbedding), + source: 'remote', + capabilityEvidence: 'current', + unverifiedXdMediaKinds: [], + }); + await evidenceUpgrade; + + expect( + getDesktopSelectableCatalog() + .providers.find((provider) => provider.id === 'xd') + ?.embeddingModels, + ).toEqual(inheritedEmbeddingXd.embeddingModels); + + h.endpoint = 'https://model.cn.example'; + const reset = reloadActiveCatalogForEndpointChange(); + h.loads.at(-1)!.resolve(catalogNamed('catalog-cn-latest')); + await reset; + }); + it('ignores an automatic refresh response from a superseded realm', async () => { + const staleRefreshIndex = h.refreshLoads.length; const staleRefresh = refreshActiveCatalogFromSource(); await Promise.resolve(); - expect(h.refreshLoads[0]?.source).toMatchObject({ + expect(h.refreshLoads[staleRefreshIndex]?.source).toMatchObject({ baseUrl: 'https://model.cn.example', }); @@ -553,24 +763,25 @@ describe('provider catalog realm reload', () => { ); await globalReload; + const currentRefreshIndex = h.refreshLoads.length; const currentRefresh = refreshActiveCatalogFromSource(); await Promise.resolve(); - expect(h.refreshLoads[1]?.source).toMatchObject({ + expect(h.refreshLoads[currentRefreshIndex]?.source).toMatchObject({ baseUrl: 'https://model.global.example', }); - h.refreshLoads[1]!.resolve({ + h.refreshLoads[currentRefreshIndex]!.resolve({ catalog: catalogNamed('catalog-global-refreshed', '2026-07-31T12:30:00.000Z'), source: 'remote', }); await currentRefresh; - h.refreshLoads[0]!.resolve({ + h.refreshLoads[staleRefreshIndex]!.resolve({ catalog: catalogNamed('catalog-cn-stale', '2026-07-31T13:00:00.000Z'), source: 'remote', }); await staleRefresh; - expect(activeMarker()).toBe('catalog-global-current'); + expect(activeMarker()).toBe('catalog-global-refreshed'); expect(getActiveCatalog().modelRegistry?.updatedAt).toBe('2026-07-31T12:30:00.000Z'); }); @@ -606,6 +817,175 @@ describe('provider catalog realm reload', () => { ).toEqual(activeXaiModels); }); + it('installs a same-registry fallback snapshot and propagates evidence atomically', async () => { + const current = structuredClone( + catalogNamed('catalog-current-before-same-registry-fallback', '2026-07-31T12:30:00.000Z'), + ); + setActiveCatalog(current, { capabilityEvidence: 'current' }); + + const fallback = structuredClone(current); + fallback.providers[0] = { + ...fallback.providers[0]!, + name: 'catalog-fallback-same-registry', + }; + const fallbackXd = fallback.providers.find((provider) => provider.id === 'xd'); + const fallbackXai = fallback.providers.find((provider) => provider.id === 'xai'); + if (!fallbackXd || !fallbackXai) throw new Error('expected fallback providers'); + fallbackXd.imageModels = [{ id: 'fallback-image', name: 'Fallback Image' }]; + fallbackXd.videoModels = [{ id: 'fallback-video', name: 'Fallback Video' }]; + fallbackXd.embeddingModels = [{ id: 'fallback-embedding', name: 'Fallback Embedding' }]; + fallbackXai.videoModels = [ + { id: 'xai/grok-imagine-video-1.5', name: 'Grok Imagine Video 1.5' }, + ]; + + const events: number[] = []; + setActiveCatalogChangedListener((revision) => events.push(revision)); + try { + const refresh = refreshActiveCatalogFromSource(); + await Promise.resolve(); + h.refreshLoads.at(-1)!.resolve({ + catalog: fallback, + source: 'cache', + capabilityEvidence: 'fallback', + }); + await refresh; + + expect(activeMarker()).toBe('catalog-fallback-same-registry'); + const projected = getDesktopSelectableCatalog(); + expect(projected.providers.find((provider) => provider.id === 'xd')).toMatchObject({ + imageModels: [], + videoModels: [], + embeddingModels: [], + }); + expect(projected.providers.find((provider) => provider.id === 'xai')?.videoModels).toEqual( + fallbackXai.videoModels, + ); + expect(events).toHaveLength(1); + + const exactFallbackRepeat = refreshActiveCatalogFromSource(); + await Promise.resolve(); + h.refreshLoads.at(-1)!.resolve({ + catalog: structuredClone(fallback), + source: 'cache', + capabilityEvidence: 'fallback', + }); + await exactFallbackRepeat; + + expect(events).toHaveLength(1); + + const evidenceUpgrade = refreshActiveCatalogFromSource(); + await Promise.resolve(); + h.refreshLoads.at(-1)!.resolve({ + catalog: structuredClone(fallback), + source: 'remote', + capabilityEvidence: 'current', + unverifiedXdMediaKinds: ['image'], + }); + await evidenceUpgrade; + + expect( + getDesktopSelectableCatalog().providers.find((provider) => provider.id === 'xd'), + ).toMatchObject({ + imageModels: [], + videoModels: fallbackXd.videoModels, + embeddingModels: fallbackXd.embeddingModels, + }); + expect(events).toHaveLength(2); + + const exactRepeat = refreshActiveCatalogFromSource(); + await Promise.resolve(); + h.refreshLoads.at(-1)!.resolve({ + catalog: structuredClone(fallback), + source: 'remote', + capabilityEvidence: 'current', + unverifiedXdMediaKinds: ['image'], + }); + await exactRepeat; + + expect(events).toHaveLength(2); + } finally { + setActiveCatalogChangedListener(null); + } + }); + + it('projects a newer fallback refresh, then promotes identical current evidence', async () => { + const current = structuredClone( + catalogNamed('catalog-current-before-fallback-refresh', '2026-07-31T12:30:00.000Z'), + ); + setActiveCatalog(current, { capabilityEvidence: 'current' }); + expect( + getDesktopSelectableCatalog() + .providers.find((provider) => provider.id === 'xd') + ?.embeddingModels?.map((model) => model.id), + ).toContain('voyage/voyage-4'); + + const fallback = structuredClone( + catalogNamed('catalog-fallback-refresh', '2026-07-31T13:00:00.000Z'), + ); + const xai = fallback.providers.find((provider) => provider.id === 'xai'); + if (!xai) throw new Error('expected xAI provider'); + xai.imageModels = [{ id: 'xai/grok-imagine-image', name: 'Grok Imagine Image' }]; + xai.videoModels = [ + { id: 'xai/grok-imagine-video', name: 'Grok Imagine Video' }, + { id: 'xai/grok-imagine-video-1.5', name: 'Grok Imagine Video 1.5' }, + ]; + + const refresh = refreshActiveCatalogFromSource(); + await Promise.resolve(); + h.refreshLoads.at(-1)!.resolve({ + catalog: fallback, + source: 'cache', + capabilityEvidence: 'fallback', + }); + await refresh; + + const projected = getDesktopSelectableCatalog(); + const xd = projected.providers.find((provider) => provider.id === 'xd'); + expect(xd?.imageModels).toEqual([]); + expect(xd?.embeddingModels).toEqual([]); + expect(xd?.videoModels?.map((model) => model.id)).not.toContain('happyhorse'); + expect(projected.providers.find((provider) => provider.id === 'xai')?.videoModels).toEqual( + xai.videoModels, + ); + + const recoveredCatalog = structuredClone(fallback); + recoveredCatalog.providers[0] = { + ...recoveredCatalog.providers[0]!, + name: 'catalog-current-same-registry', + }; + const recoveredXd = recoveredCatalog.providers.find((provider) => provider.id === 'xd'); + const recoveredXai = recoveredCatalog.providers.find((provider) => provider.id === 'xai'); + if (!recoveredXd || !recoveredXai) throw new Error('expected recovered providers'); + recoveredXd.imageModels = []; + delete recoveredXd.imageDefaults; + recoveredXd.embeddingModels = []; + delete recoveredXd.embeddingDefaults; + recoveredXd.videoModels = [{ id: 'seedance-fast', name: 'Seedance Fast' }]; + recoveredXd.videoDefaults = { standard: 'seedance-fast' }; + recoveredXai.videoModels = [ + { id: 'xai/grok-imagine-video-1.5', name: 'Grok Imagine Video 1.5' }, + ]; + + const recovered = refreshActiveCatalogFromSource(); + await Promise.resolve(); + h.refreshLoads.at(-1)!.resolve({ + catalog: recoveredCatalog, + source: 'remote', + capabilityEvidence: 'current', + }); + await recovered; + + const promoted = getDesktopSelectableCatalog(); + const promotedXd = promoted.providers.find((provider) => provider.id === 'xd'); + expect(activeMarker()).toBe('catalog-current-same-registry'); + expect(promotedXd?.imageModels).toEqual([]); + expect(promotedXd?.embeddingModels).toEqual([]); + expect(promotedXd?.videoModels).toEqual([{ id: 'seedance-fast', name: 'Seedance Fast' }]); + expect(promoted.providers.find((provider) => provider.id === 'xai')?.videoModels).toEqual( + recoveredXai.videoModels, + ); + }); + it('按时间语义守卫 offset/Z 等价 revision,拒收真实旧值和坏值,接受真实新值', async () => { setActiveCatalog(catalogNamed('current-offset', '2026-08-02T10:00:00+08:00')); h.warn.mockClear(); @@ -618,13 +998,15 @@ describe('provider catalog realm reload', () => { }; try { - // 同一时刻仅 ISO 表示不同:Registry 内容相同,应 no-op 且不误报警。 + // 同一时刻仅 ISO 表示不同:Registry 关系仍为 same;完整 Catalog 快照不同则 + // 原子安装 incoming,但不应误报同 revision 冲突。 await refreshWith('equivalent-z', '2026-08-02T02:00:00.000Z'); - expect(getActiveCatalog().modelRegistry?.updatedAt).toBe('2026-08-02T10:00:00+08:00'); + expect(activeMarker()).toBe('equivalent-z'); + expect(getActiveCatalog().modelRegistry?.updatedAt).toBe('2026-08-02T02:00:00.000Z'); expect(h.warn).not.toHaveBeenCalled(); await refreshWith('actually-older', '2026-08-02T01:59:59.000Z'); - expect(getActiveCatalog().modelRegistry?.updatedAt).toBe('2026-08-02T10:00:00+08:00'); + expect(getActiveCatalog().modelRegistry?.updatedAt).toBe('2026-08-02T02:00:00.000Z'); await refreshWith('actually-newer', '2026-08-02T03:00:00.000Z'); expect(getActiveCatalog().modelRegistry?.updatedAt).toBe('2026-08-02T03:00:00.000Z'); diff --git a/apps/desktop/src/main/maker-host/__tests__/providerDiagnostics.test.ts b/apps/desktop/src/main/maker-host/__tests__/providerDiagnostics.test.ts index 03500add1a..9223b7c68f 100644 --- a/apps/desktop/src/main/maker-host/__tests__/providerDiagnostics.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/providerDiagnostics.test.ts @@ -425,6 +425,83 @@ describe('resolveSavedProbeSpec / testProviderConnection(saved)', () => { expect(seenUrl).toBe('https://api.deepseek.com/chat/completions'); }); + it('仅选择 glm-5.3 时 saved 探测使用模型级 Responses 路由', async () => { + setCustomProviders([ + buildUserProvider({ + id: 'glm-responses-only', + name: 'GLM Responses Only', + runtimes: { + codex: { + baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', + wireProtocol: 'openai-chat', + requestPath: '/legacy/chat', + models: [ + { + id: 'glm-5.3', + name: 'GLM-5.3', + route: { + baseUrl: 'https://open.bigmodel.cn/api/v1', + wireProtocol: 'openai-responses', + }, + }, + ], + }, + }, + }), + ]); + setDiagnosticsKeyReader(() => 'glm-key'); + + expect(resolveSavedProbeSpec('glm-responses-only', 'codex')).toMatchObject({ + baseUrl: 'https://open.bigmodel.cn/api/v1', + modelId: 'glm-5.3', + wireProtocol: 'openai-responses', + requestPath: undefined, + }); + let seenUrl = ''; + await testProviderConnection( + { kind: 'saved', providerId: 'glm-responses-only', agent: 'codex' }, + async (url) => { + seenUrl = String(url); + return fakeResponse(200, '{}'); + }, + ); + expect(seenUrl).toBe('https://open.bigmodel.cn/api/v1/responses'); + }); + + it('无模型级 route 的 glm-5.2 saved 探测继续使用 runtime V4 Chat 路由', async () => { + setCustomProviders([ + buildUserProvider({ + id: 'glm-chat-only', + name: 'GLM Chat Only', + runtimes: { + codex: { + baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', + wireProtocol: 'openai-chat', + requestPath: '/chat/completions', + models: [{ id: 'glm-5.2', name: 'GLM-5.2' }], + }, + }, + }), + ]); + setDiagnosticsKeyReader(() => 'glm-key'); + + expect(resolveSavedProbeSpec('glm-chat-only', 'codex')).toMatchObject({ + baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', + modelId: 'glm-5.2', + wireProtocol: 'openai-chat', + requestPath: '/chat/completions', + }); + let seenUrl = ''; + await testProviderConnection( + { kind: 'saved', providerId: 'glm-chat-only', agent: 'codex' }, + async (url) => { + seenUrl = String(url); + return fakeResponse(200, 'data: [DONE]\n\n'); + }, + ); + expect(seenUrl).toBe('https://open.bigmodel.cn/api/coding/paas/v4/chat/completions'); + }); + it('saved 探测沿用自定义供应商的精确请求路径', async () => { setCustomProviders([ buildUserProvider({ diff --git a/apps/desktop/src/main/maker-host/__tests__/providerModelFetch.test.ts b/apps/desktop/src/main/maker-host/__tests__/providerModelFetch.test.ts index 38e514a700..038f84e6f0 100644 --- a/apps/desktop/src/main/maker-host/__tests__/providerModelFetch.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/providerModelFetch.test.ts @@ -181,6 +181,23 @@ describe('fetchProviderModels', () => { ]); }); + it('parses GLM Responses catalog entries keyed by slug', async () => { + const result = await fetchProviderModels(spec({ agent: 'codex' }), async () => + fakeResponse( + 200, + JSON.stringify({ + models: [ + { slug: 'glm-5.3', display_name: 'GLM-5.3', context_window: 202_752 }, + ], + }), + ), + ); + + expect(result.models).toEqual([ + { id: 'glm-5.3', name: 'GLM-5.3', contextWindow: 202_752 }, + ]); + }); + it('classifies 401 as AUTH_INVALID with status', async () => { const r = await fetchProviderModels(spec(), async () => fakeResponse(401, JSON.stringify({ error: { message: 'invalid api key' } })), diff --git a/apps/desktop/src/main/maker-host/__tests__/providerRoute.test.ts b/apps/desktop/src/main/maker-host/__tests__/providerRoute.test.ts index a44bbe2a76..9acfad6c82 100644 --- a/apps/desktop/src/main/maker-host/__tests__/providerRoute.test.ts +++ b/apps/desktop/src/main/maker-host/__tests__/providerRoute.test.ts @@ -761,6 +761,56 @@ describe('resolveSessionRouteDecision — 自定义供应商(resolve 时注入 k }); }); + it('同一 GLM key 下普通模型走 V4 Chat,glm-5.3 走 V1 Responses', () => { + setCustomProviders([ + buildUserProvider({ + id: 'zhipu-plan', + name: 'Zhipu Plan', + runtimes: { + codex: { + baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', + wireProtocol: 'openai-chat', + requestPath: '/chat/completions', + models: [ + { id: 'glm-5.2', name: 'GLM-5.2' }, + { + id: 'glm-5.3', + name: 'GLM-5.3', + route: { + baseUrl: 'https://open.bigmodel.cn/api/v1', + wireProtocol: 'openai-responses', + }, + }, + ], + }, + }, + }), + ]); + setCustomProviderKeyReader(() => 'glm-key'); + setSessionProvider('s-user', 'zhipu-plan'); + + expect(getSessionRoutingDescriptor('s-user', 'codex', 'glm-5.2')).toMatchObject({ + upstream: 'https://open.bigmodel.cn/api/coding/paas/v4', + wireProtocol: 'openai-chat', + requestPath: '/chat/completions', + }); + expect(getSessionRoutingDescriptor('s-user', 'codex', 'glm-5.3')).toEqual( + expect.objectContaining({ + upstream: 'https://open.bigmodel.cn/api/v1', + wireProtocol: 'openai-responses', + authStrategy: 'api-key-header', + }), + ); + expect(getSessionRoutingDescriptor('s-user', 'codex', 'glm-5.3')).not.toHaveProperty( + 'requestPath', + ); + expect(resolveSessionRouteDecision('s-user', 'codex', KEY, 'glm-5.3')).toEqual({ + headerOverride: { authorization: 'Bearer glm-key' }, + upstreamOverride: 'https://open.bigmodel.cn/api/v1', + headerDelete: CODEX_ACCOUNT_HEADER_DELETE, + }); + }); + it('routes a legacy custom xai row independently from the built-in SuperGrok source', () => { setCustomProviders([ buildUserProvider({ diff --git a/apps/desktop/src/main/maker-host/__tests__/sessionSearch.test.ts b/apps/desktop/src/main/maker-host/__tests__/sessionSearch.test.ts new file mode 100644 index 0000000000..baa1111e71 --- /dev/null +++ b/apps/desktop/src/main/maker-host/__tests__/sessionSearch.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getDbClient: vi.fn(), + queryOne: vi.fn(), + query: vi.fn(), +})); + +vi.mock('../../localDb/client/current.js', () => ({ + getDbClient: mocks.getDbClient, +})); + +import { searchSessionsFn } from '../session-search.js'; + +describe('session_search Bot ownership boundary', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getDbClient.mockReturnValue({ + queryOne: mocks.queryOne, + query: mocks.query, + }); + mocks.query.mockResolvedValue([]); + }); + + it('restricts a Bot caller to Sessions linked to the same Bot', async () => { + mocks.queryOne.mockResolvedValue({ source: 'bot', botId: 'bot-a' }); + + await searchSessionsFn('release', { callerSessionId: 'bot-session-a' }); + + expect(mocks.queryOne).toHaveBeenCalledWith(expect.stringContaining('LEFT JOIN bot_session_links'), [ + 'bot-session-a', + ]); + expect(mocks.query).toHaveBeenCalledWith( + expect.stringContaining('FROM bot_session_links scoped'), + ['"release"', 'bot-a', 10], + ); + }); + + it('keeps a model-supplied session filter inside the same Bot scope', async () => { + mocks.queryOne.mockResolvedValue({ source: 'bot', botId: 'bot-a' }); + + await searchSessionsFn('release', { + callerSessionId: 'bot-session-a', + sessionId: 'foreign-session', + }); + + expect(mocks.query).toHaveBeenCalledWith( + expect.stringMatching(/m\.session_id = \?[\s\S]*scoped\.bot_id = \?/), + ['"release"', 'foreign-session', 'bot-a', 10], + ); + }); + + it('fails closed when a Bot Session has lost its ownership link', async () => { + mocks.queryOne.mockResolvedValue({ source: 'bot', botId: null }); + + await expect( + searchSessionsFn('release', { callerSessionId: 'orphan-bot-session' }), + ).resolves.toEqual([]); + expect(mocks.query).not.toHaveBeenCalled(); + }); + + it('fails closed when Bot memory attribution survives but caller Session attribution is lost', async () => { + await expect( + searchSessionsFn('release', { callerMemoryScopeKey: 'bot:bot-a' }), + ).resolves.toEqual([]); + expect(mocks.queryOne).not.toHaveBeenCalled(); + expect(mocks.query).not.toHaveBeenCalled(); + }); + + it('preserves the existing account history behavior for non-Bot Sessions', async () => { + mocks.queryOne.mockResolvedValue({ source: 'desktop', botId: null }); + + await searchSessionsFn('release', { callerSessionId: 'desktop-session' }); + + const [sql, params] = mocks.query.mock.calls[0] as [string, unknown[]]; + expect(sql).not.toContain('FROM bot_session_links scoped'); + expect(params).toEqual(['"release"', 10]); + }); +}); diff --git a/apps/desktop/src/main/maker-host/active-catalog.ts b/apps/desktop/src/main/maker-host/active-catalog.ts index 6d7955f81d..34b01315f6 100644 --- a/apps/desktop/src/main/maker-host/active-catalog.ts +++ b/apps/desktop/src/main/maker-host/active-catalog.ts @@ -24,19 +24,25 @@ * createDesktopProviderService.ts,这样依赖本 holder 的纯逻辑模块(及其单测)不被 electron 污染。 */ +import { isDeepStrictEqual } from 'node:util'; + import { BUNDLED_CATALOG, buildUserProvider, findModelRegistryRoute, type AgentKind, type Catalog, + type CatalogCapabilityEvidence, + type CatalogXdMediaKind, type CatalogModel, type CustomProviderConfig, type Provider, type ProviderWireProtocol, } from '@cindy/model-providers'; +import { CURRENT_CINDY_REGION } from '../../shared/brandRegion.js'; import { CHATGPT_MODEL_PREFIX } from '../../shared/subscriptionModels.js'; +import { projectUnverifiedCatalogFallbackForBuildRegion } from './provider-access-policy.js'; import { applyLocalConsumerOverrides, applyLocalOverridesToRoot, @@ -58,6 +64,14 @@ import { /** OSS / bundled 加载来的基础目录;null = 尚未加载(回落 BUNDLED_CATALOG)。 */ let base: Catalog | null = null; +/** 当前基础目录是否由本次配置的 Catalog 真源明确证明;fallback 只保兼容元数据。 */ +let baseCapabilityEvidence: CatalogCapabilityEvidence = 'fallback'; +/** XD media fields inherited from bundled rather than proven by the current source. */ +let baseUnverifiedXdMediaKinds: ReadonlySet = new Set([ + 'image', + 'video', + 'embedding', +]); /** Last trusted Registry used only to re-project user configs; it never changes catalog membership. */ let trustedCustomProviderRegistry: Catalog['modelRegistry'] = BUNDLED_CATALOG.modelRegistry; /** 用户自定义供应商(已 buildUserProvider 展开的标准 Provider),追加在 base 之后。 */ @@ -675,7 +689,15 @@ function withEmptyModels(p: Provider): Provider { } function computeMerged(): Catalog { - const b = base ?? BUNDLED_CATALOG; + const source = base ?? BUNDLED_CATALOG; + const b = + baseCapabilityEvidence === 'current' + ? projectUnverifiedCatalogFallbackForBuildRegion( + source, + CURRENT_CINDY_REGION, + baseUnverifiedXdMediaKinds, + ) + : projectUnverifiedCatalogFallbackForBuildRegion(source, CURRENT_CINDY_REGION); // Dynamic OpenAI/Anthropic roots are rebuilt from discovery/registry below, // but the daily OSS catalog may carry sparse PI protocol annotations. Keep // only that metadata and apply it after existence has been independently @@ -712,13 +734,24 @@ function computeMerged(): Catalog { // 作者错误隔离进 warnings,由刷新路径读走打日志,不拖垮其余条目。 const plan = planRegistryRoots(b.modelRegistry); lastPlanWarnings = plan.warnings; - // XD Provider 使用随客户端发布的固定壳,远端 Catalog 只能管理非 XD Provider。 - // `/models` 会在下方重建固定壳的全部模型,Catalog 缺失、刷新或异常都不能改变 XD。 - const builtinXd = BUNDLED_CATALOG.providers.find((provider) => provider.id === 'xd'); + // XD 的对话模型成员仍由下方 `/models` 权威重建,但 provider 壳中的媒体清单、 + // 默认项和显式空值必须服从当前 Catalog。只有目录本身缺少 XD(生产加载经 + // mergeWithBundled 后通常不会发生)才回落与 evidence 同级安全的 bundled 壳。 + const fallbackXdCatalog = + baseCapabilityEvidence === 'current' + ? projectUnverifiedCatalogFallbackForBuildRegion( + BUNDLED_CATALOG, + CURRENT_CINDY_REGION, + baseUnverifiedXdMediaKinds, + ) + : projectUnverifiedCatalogFallbackForBuildRegion(BUNDLED_CATALOG, CURRENT_CINDY_REGION); + const catalogXd = b.providers.find((provider) => provider.id === 'xd'); + const xdShell = + catalogXd ?? fallbackXdCatalog.providers.find((provider) => provider.id === 'xd'); const bundledXai = BUNDLED_CATALOG.providers.find((provider) => provider.id === 'xai'); const remoteXdIndex = b.providers.findIndex((provider) => provider.id === 'xd'); const providerSources = b.providers.filter((provider) => provider.id !== 'xd'); - if (builtinXd) providerSources.splice(Math.max(0, remoteXdIndex), 0, builtinXd); + if (xdShell) providerSources.splice(Math.max(0, remoteXdIndex), 0, xdShell); let providers: Provider[] = providerSources; // 先清零已退役的静态 providers.models 段:无论目录来自 bundled 还是远端, @@ -1006,18 +1039,75 @@ export function getCatalogModelContextWindow( } /** 由 host 的目录加载器(ensureActiveCatalogLoaded)在拉取成功后写入基础目录。 */ -export function setActiveCatalog(catalog: Catalog): void { +function installActiveCatalog( + catalog: Catalog, + capabilityEvidence: CatalogCapabilityEvidence, + unverifiedXdMediaKinds: readonly CatalogXdMediaKind[], + force: boolean, +): boolean { const previous = base ?? BUNDLED_CATALOG; + const nextUnverifiedXdMediaKinds = new Set(unverifiedXdMediaKinds); + if ( + !force && + base !== null && + baseCapabilityEvidence === capabilityEvidence && + isDeepStrictEqual(baseUnverifiedXdMediaKinds, nextUnverifiedXdMediaKinds) && + isDeepStrictEqual(base, catalog) + ) { + return false; + } const projectionRegistry = catalog.modelRegistry ?? previous.modelRegistry ?? trustedCustomProviderRegistry; trustedCustomProviderRegistry = projectionRegistry; base = catalog; + baseCapabilityEvidence = capabilityEvidence; + baseUnverifiedXdMediaKinds = nextUnverifiedXdMediaKinds; if (customConfigs) { custom = customConfigs.map((config) => buildUserProvider(config, { modelRegistry: projectionRegistry }), ); } markChanged(); + return true; +} + +export function setActiveCatalog( + catalog: Catalog, + options: { + capabilityEvidence?: CatalogCapabilityEvidence; + unverifiedXdMediaKinds?: readonly CatalogXdMediaKind[]; + } = {}, +): void { + const capabilityEvidence = options.capabilityEvidence ?? 'current'; + installActiveCatalog( + catalog, + capabilityEvidence, + options.unverifiedXdMediaKinds ?? + (capabilityEvidence === 'fallback' ? ['image', 'video', 'embedding'] : []), + true, + ); +} + +/** + * Atomically install one complete source snapshot and its capability evidence. Refresh + * callers use this instead of comparing only modelRegistry: media lists, presets and + * explicit empty fields are part of the same catalog truth. Exact no-ops stay silent. + */ +export function commitActiveCatalogSnapshot( + catalog: Catalog, + options: { + capabilityEvidence?: CatalogCapabilityEvidence; + unverifiedXdMediaKinds?: readonly CatalogXdMediaKind[]; + } = {}, +): boolean { + const capabilityEvidence = options.capabilityEvidence ?? 'current'; + return installActiveCatalog( + catalog, + capabilityEvidence, + options.unverifiedXdMediaKinds ?? + (capabilityEvidence === 'fallback' ? ['image', 'video', 'embedding'] : []), + false, + ); } /** @@ -1028,7 +1118,13 @@ export function setActiveCatalog(catalog: Catalog): void { * 「xai 新表 + registry 旧表」的可观测混态窗口。 * 目标不变量:成功且有变化 = 恰 1 revision / 1 broadcast;no-op/拒收 = 0。 */ -export function commitModelPlaneFromCatalog(catalog: Catalog): void { +export function commitModelPlaneFromCatalog( + catalog: Catalog, + options: { + capabilityEvidence?: CatalogCapabilityEvidence; + unverifiedXdMediaKinds?: readonly CatalogXdMediaKind[]; + } = {}, +): void { const current = base ?? BUNDLED_CATALOG; const incomingXai = catalog.providers.find((provider) => provider.id === 'xai'); const providers = @@ -1041,6 +1137,12 @@ export function commitModelPlaneFromCatalog(catalog: Catalog): void { providers, ...(catalog.modelRegistry ? { modelRegistry: catalog.modelRegistry } : {}), }; + baseCapabilityEvidence = options.capabilityEvidence ?? 'current'; + if (options.unverifiedXdMediaKinds !== undefined) { + baseUnverifiedXdMediaKinds = new Set(options.unverifiedXdMediaKinds); + } else if (baseCapabilityEvidence === 'fallback') { + baseUnverifiedXdMediaKinds = new Set(['image', 'video', 'embedding']); + } if (customConfigs) { custom = customConfigs.map((config) => buildUserProvider(config, { modelRegistry: trustedCustomProviderRegistry }), diff --git a/apps/desktop/src/main/maker-host/auto-permission-reviewer.ts b/apps/desktop/src/main/maker-host/auto-permission-reviewer.ts index eb9b513617..905aece143 100644 --- a/apps/desktop/src/main/maker-host/auto-permission-reviewer.ts +++ b/apps/desktop/src/main/maker-host/auto-permission-reviewer.ts @@ -17,14 +17,20 @@ interface AutoPermissionReviewerLogger { } export interface AutoPermissionReviewerDeps { - requestText(request: AutoReviewRequest, prompt: string): Promise; + requestText( + request: AutoReviewRequest, + prompt: string, + context: { signal: AbortSignal }, + ): Promise; logger: AutoPermissionReviewerLogger; /** - * 本次请求允许的单次耗时。缺省用构造时的 timeoutPolicy.requestTimeoutMs。 - * - * 存在的理由:额度是**按模型能力逐请求**决定的(强制思考的模型要 30s,能关 - * 思考的 12s 就够,见 auto-review-budget.ts),而 timeoutPolicy 是构造期固定的。 - * 如果这里的守卫仍按固定值计时,放宽额度的那一档会被自己的守卫切断。 + * `true` when requestText owns candidate fallback and transient retries. The reviewer then + * invokes it once, avoiding duplicate full-chain runs after an inner timeout. + */ + managesRetries?: boolean; + /** + * 本次 requestText 执行边界允许的总耗时。缺省用构造时的 + * timeoutPolicy.requestTimeoutMs;专用候选链用它把整链预算交给同一个取消守卫。 */ resolveRequestTimeoutMs?(request: AutoReviewRequest): number; } @@ -209,11 +215,15 @@ async function attemptReview( requestTimeoutMs: number, ): Promise<{ decision: AutoReviewDecision } | { failure: AttemptFailure; error?: string }> { let timeout: ReturnType | undefined; + const controller = new AbortController(); try { const text = await Promise.race([ - deps.requestText(request, prompt), + deps.requestText(request, prompt, { signal: controller.signal }), new Promise((resolve) => { - timeout = setTimeout(() => resolve(REVIEW_TIMEOUT), requestTimeoutMs); + timeout = setTimeout(() => { + controller.abort(); + resolve(REVIEW_TIMEOUT); + }, requestTimeoutMs); }), ]); if (text === REVIEW_TIMEOUT) return { failure: 'timeout' }; @@ -269,8 +279,10 @@ export function createAutoPermissionReviewer( // 切分是错的:抖动恢复往往就差那几秒,把 12s 切成 4s 会把本来能成功的请求 // 也判成超时,反而制造失败。总耗时的上界由 maker-core 的 // AUTO_REVIEW_DELEGATE_HARD_CEILING_MS 兜住,那里已按最慢一档 + 重试留足。 - const attempts = 1 + REVIEW_RETRIES; - // 单次超时按本次请求的模型能力取(强制思考的模型需要更久),缺省回到构造期策略。 + // 专用模型路由在一次 requestText 内完成候选回退与短暂错误重试;外层若再按 + // 旧规则重试,会在首轮超时后重新启动整条候选链。普通调用方继续沿用三次尝试。 + const attempts = deps.managesRetries ? 1 : 1 + REVIEW_RETRIES; + // 单次超时按本次请求的执行边界取,缺省回到构造期策略。 const requestTimeoutMs = deps.resolveRequestTimeoutMs?.(request) ?? timeoutPolicy.requestTimeoutMs; // 重试的**意图是次数**(试满 attempts 次),不是"在某个时间窗内尽量试"。 diff --git a/apps/desktop/src/main/maker-host/auto-review-budget.ts b/apps/desktop/src/main/maker-host/auto-review-budget.ts deleted file mode 100644 index a81b3e3124..0000000000 --- a/apps/desktop/src/main/maker-host/auto-review-budget.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Auto-review 请求的额度自适应。 - * - * 背景:审核请求原本固定 384 token / 12 秒 / reasoningEffort='low'。这套参数 - * 对「能关掉思考」的模型足够,但对**强制思考**的模型是致命的 —— 它们的档位里 - * 根本没有 low/minimal,传了也会被上调,于是 384 token 全烧在思考上、正文为空, - * 审核判定失败。实测(2026-08-11)DeepSeek V4 Pro 在部分灰区用例上三轮全部 - * `empty_content_reasoning_only`,而且越是需要斟酌的场景越容易触发 —— - * 审核最该发挥作用的时刻恰好最容易失效。 - * - * 参考 Codex Guardian 的取舍:它给审阅器的是 272k 窗口、无输出上限、medium 档, - * 还允许调工具查证。我们不做到那个量级(那需要嵌套 agent 会话),但至少要让 - * 强制思考的模型有写完结论的空间 —— 慢一点可以接受,判不出来不行。 - */ - -import type { AgentKind, CatalogModel } from '@cindy/model-providers'; - -/** 能关思考的模型:够写一个 JSON 裁决即可。 */ -const COMPACT_MAX_TOKENS = 384; -const COMPACT_TIMEOUT_MS = 12_000; - -/** - * 强制思考的模型:思考段 + 结论段都要装得下。 - * - * 4096 是按实测的思考长度留的余量(DeepSeek 类模型在灰区用例上的思考通常 - * 1-2k token),不是拍脑袋的大数 —— 再大只会让超时更容易先触发。 - */ -const REASONING_MAX_TOKENS = 4_096; - -/** - * 相应放宽的超时。思考模型多花的是**输出**时间,首 token 延迟差别不大, - * 所以按输出量等比放宽而不是无限等 —— 用户仍在等这次工具调用。 - */ -const REASONING_TIMEOUT_MS = 30_000; - -/** 审核请求的执行参数。 */ -export interface AutoReviewBudget { - maxTokens: number; - timeoutMs: number; - /** `undefined` = 不传该字段,让模型走自己的默认档。 */ - reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high'; -} - -/** - * 该模型能否把思考关到最低档。 - * - * 判据是模型目录声明的 `efforts`: - * - 含 `minimal` 或 `low` → 能关,给紧凑额度; - * - 明确非空但不含二者(如 DeepSeek 的 `['high','max']`)→ 强制思考,给宽裕额度; - * - 空数组 `[]` → 该模型不支持切档(如 Kimi K2.6),它也不会强制长思考,按紧凑处理; - * - `undefined`(目录里没这个模型 / 自定义供应商未声明)→ **保守按强制思考处理**。 - * 宁可多给额度慢一点,也不要重演 384 token 判不出来的静默失败。 - */ -export function modelCanSuppressReasoning(model: CatalogModel | undefined): boolean { - const efforts = model?.efforts; - if (efforts === undefined) return false; - if (efforts.length === 0) return true; - return efforts.includes('minimal') || efforts.includes('low'); -} - -/** - * 紧凑档实际该发的 effort:该模型**自己声明过**的最低档,没有声明就不发。 - * - * 只在 `modelCanSuppressReasoning` 为真时调用,所以这里只需区分 low / minimal / - * 空数组三种;`undefined`(目录查不到)走的是宽裕分支,压根到不了这里。 - */ -function lowestDeclaredEffort( - model: CatalogModel | undefined, -): 'minimal' | 'low' | undefined { - const efforts = model?.efforts; - if (!efforts || efforts.length === 0) return undefined; - if (efforts.includes('low')) return 'low'; - if (efforts.includes('minimal')) return 'minimal'; - return undefined; -} - -/** - * 按模型能力选额度。 - * - * `model` 传 `undefined` 表示目录里查不到 —— 走保守分支(宽裕额度)。 - */ -export function resolveAutoReviewBudget(model: CatalogModel | undefined): AutoReviewBudget { - if (modelCanSuppressReasoning(model)) { - return { - maxTokens: COMPACT_MAX_TOKENS, - timeoutMs: COMPACT_TIMEOUT_MS, - // 只发该模型真正声明的最低档 —— 紧凑分支覆盖三种模型,不能一律发 low: - // - 声明了 low → low - // - 只声明 minimal → minimal(如 z-ai/glm-5.2;发 low 会被上游拒绝或 - // 悄悄提到更高档,反而烧掉 384 token 的正文空间) - // - efforts: [] → 省略(如 Haiku 4.5 / Kimi K2.6 / grok 系共 10 个; - // 它们根本没有档位概念,带一个不认的字段是白白冒 400 的险) - // 发错档会让审阅请求连续失败 → 重试耗尽 → 每个灰区操作降级成用户确认, - // 正好绕回本 PR 要修的故障(PR #2474 review P1)。 - reasoningEffort: lowestDeclaredEffort(model), - }; - } - return { - maxTokens: REASONING_MAX_TOKENS, - timeoutMs: REASONING_TIMEOUT_MS, - // 强制思考的模型不传 effort:传 low 会被���调成它支持的最低档,平白让请求 - // 带一个不生效的字段;个别上游还会因为不认的值直接 400。 - reasoningEffort: undefined, - }; -} - -/** 从当前目录里查一个 (供应商, agent, 模型) 的目录条目。 */ -export function findCatalogModel( - providers: ReadonlyArray<{ - id: string; - models: Partial>; - }>, - providerId: string | null | undefined, - agentKind: AgentKind, - modelId: string, -): CatalogModel | undefined { - const normalizedModel = modelId.trim(); - if (!normalizedModel) return undefined; - const normalizedProvider = providerId?.trim(); - if (normalizedProvider) { - // 点名了供应商就**只**在它的目录里找:同一个模型 id 在不同供应商下可能声明不同 - // 的 efforts,跨家借用会把强制思考的路由误判成"能关思考",于是又拿回 384/12s 的 - // 紧凑额度 —— 正是本 PR 要修的那个空正文故障(PR #2474 review)。 - // 未命中返回 undefined,由调用方走保守宽裕档。 - const provider = providers.find((item) => item.id === normalizedProvider); - return (provider?.models[agentKind] ?? []).find((m) => m.id === normalizedModel); - } - // 没有 providerId(Pi 的 null = 走默认网关路由)时按模型 id 全目录找第一个命中。 - // 只用于读能力元数据,不参与路由决策,所以首见即可。 - for (const provider of providers) { - const hit = (provider.models[agentKind] ?? []).find((m) => m.id === normalizedModel); - if (hit) return hit; - } - return undefined; -} diff --git a/apps/desktop/src/main/maker-host/auto-review-model-router.ts b/apps/desktop/src/main/maker-host/auto-review-model-router.ts new file mode 100644 index 0000000000..722bb626ee --- /dev/null +++ b/apps/desktop/src/main/maker-host/auto-review-model-router.ts @@ -0,0 +1,247 @@ +import type { AutoReviewDecision } from '@cindy/maker-core'; + +import type { UtilityTextResult } from '../../shared/utilityTextResult.js'; +import { + DEDICATED_AUTO_REVIEW_CANDIDATES, + requestDedicatedAutoReviewCandidateText, + type DedicatedAutoReviewCandidate, +} from '../utility-model/oneShotCandidates.js'; +import { parseAutoPermissionReviewDecision } from './auto-permission-reviewer.js'; + +export const AUTO_REVIEW_CANDIDATE_TIMEOUT_MS = 12_000; +export const AUTO_REVIEW_CHAIN_TIMEOUT_MS = 52_000; +export const AUTO_REVIEW_ROUTER_GUARD_TIMEOUT_MS = AUTO_REVIEW_CHAIN_TIMEOUT_MS + 1_000; +const AUTO_REVIEW_TRANSIENT_RETRY_ATTEMPTS = 2; +const AUTO_REVIEW_TRANSIENT_RETRY_BACKOFF_MS = 100; +const AUTO_REVIEW_CANDIDATE_TIMEOUT = Symbol('auto-review-candidate-timeout'); + +interface AutoReviewModelRouterLogger { + debug(message: string, fields?: Record): void; + warn(message: string, fields?: Record): void; +} + +interface AutoReviewModelRouterDeps { + logger: AutoReviewModelRouterLogger; + candidates?: readonly DedicatedAutoReviewCandidate[]; + requestCandidate?: ( + prompt: string, + candidate: DedicatedAutoReviewCandidate, + opts: { timeoutMs: number; signal?: AbortSignal }, + ) => Promise; + parseDecision?: (text: string) => AutoReviewDecision | null; + now?: () => number; + sleep?: (ms: number, signal: AbortSignal) => Promise; +} + +function sleepWithSignal(ms: number, signal: AbortSignal): Promise { + if (signal.aborted || ms <= 0) return Promise.resolve(); + return new Promise((resolve) => { + const finish = () => { + clearTimeout(timer); + signal.removeEventListener('abort', finish); + resolve(); + }; + const timer = setTimeout(finish, ms); + signal.addEventListener('abort', finish, { once: true }); + }); +} + +function transientCandidateFailure(result: UtilityTextResult): boolean { + if (result.ok) return false; + return result.attempts.some((attempt) => { + if (attempt.status !== 'failed') return false; + if ( + attempt.reason === 'timeout' + || attempt.reason === 'empty_response' + || attempt.reason === 'request_failed' + ) { + return true; + } + return attempt.reason === 'http_error' + && (attempt.httpStatus === 408 || attempt.httpStatus === 429 || attempt.httpStatus >= 500); + }); +} + +function candidateRequestFailure( + candidate: DedicatedAutoReviewCandidate, +): UtilityTextResult { + return { + ok: false, + reason: 'all_candidates_failed', + attempts: [{ + providerId: candidate.providerId, + model: candidate.model, + transport: candidate.transport, + status: 'failed', + reason: 'request_failed', + }], + }; +} + +function candidateTimeoutFailure( + candidate: DedicatedAutoReviewCandidate, +): UtilityTextResult { + return { + ok: false, + reason: 'timeout', + attempts: [{ + providerId: candidate.providerId, + model: candidate.model, + transport: candidate.transport, + status: 'failed', + reason: 'timeout', + }], + }; +} + +async function requestCandidateWithinTimeout( + requestCandidate: NonNullable, + prompt: string, + candidate: DedicatedAutoReviewCandidate, + timeoutMs: number, + chainSignal: AbortSignal, +): Promise { + if (chainSignal.aborted) return candidateTimeoutFailure(candidate); + const controller = new AbortController(); + let timeout: ReturnType | undefined; + let abortFromChain: (() => void) | undefined; + const cutoff = new Promise((resolve) => { + const abort = () => { + controller.abort(); + resolve(AUTO_REVIEW_CANDIDATE_TIMEOUT); + }; + abortFromChain = abort; + if (chainSignal.aborted) abort(); + else chainSignal.addEventListener('abort', abort, { once: true }); + timeout = setTimeout(abort, timeoutMs); + }); + + try { + const result = await Promise.race([ + requestCandidate(prompt, candidate, { timeoutMs, signal: controller.signal }), + cutoff, + ]); + return result === AUTO_REVIEW_CANDIDATE_TIMEOUT + ? candidateTimeoutFailure(candidate) + : result; + } finally { + if (timeout) clearTimeout(timeout); + if (abortFromChain) chainSignal.removeEventListener('abort', abortFromChain); + } +} + +function safeFailureReason(result: UtilityTextResult): string { + if (result.ok) return 'none'; + const failed = result.attempts.find((attempt) => attempt.status === 'failed'); + return failed?.reason ?? result.reason; +} + +/** + * Runs the dedicated Auto-review model chain once. + * + * Each candidate owns its HTTP timeout and receives the chain AbortSignal. Only a quick, + * infrastructure-shaped failure is retried in place; a full timeout immediately yields to + * the next provider so one outage cannot consume the entire fallback budget. + */ +export function createAutoReviewModelRouter( + deps: AutoReviewModelRouterDeps, +): (prompt: string, signal?: AbortSignal) => Promise { + const candidates = deps.candidates ?? DEDICATED_AUTO_REVIEW_CANDIDATES; + const requestCandidate = deps.requestCandidate ?? requestDedicatedAutoReviewCandidateText; + const parseDecision = deps.parseDecision ?? parseAutoPermissionReviewDecision; + const now = deps.now ?? Date.now; + const sleep = deps.sleep ?? sleepWithSignal; + + return async (prompt, parentSignal) => { + const startedAt = now(); + const deadlineAt = startedAt + AUTO_REVIEW_CHAIN_TIMEOUT_MS; + const controller = new AbortController(); + const abortFromParent = () => controller.abort(); + if (parentSignal?.aborted) abortFromParent(); + else parentSignal?.addEventListener('abort', abortFromParent, { once: true }); + const deadline = setTimeout(() => controller.abort(), AUTO_REVIEW_CHAIN_TIMEOUT_MS); + + try { + for (const [candidateIndex, candidate] of candidates.entries()) { + if (controller.signal.aborted) break; + + for (let attempt = 1; attempt <= AUTO_REVIEW_TRANSIENT_RETRY_ATTEMPTS; attempt++) { + const remainingMs = deadlineAt - now(); + if (remainingMs <= 0 || controller.signal.aborted) break; + const attemptStartedAt = now(); + let result: UtilityTextResult; + try { + result = await requestCandidateWithinTimeout( + requestCandidate, + prompt, + candidate, + Math.min(AUTO_REVIEW_CANDIDATE_TIMEOUT_MS, remainingMs), + controller.signal, + ); + } catch { + // Credential refresh and catalog probes are runtime boundaries too. A thrown + // candidate must not skip the remaining controlled providers or leak details. + result = candidateRequestFailure(candidate); + } + const durationMs = now() - attemptStartedAt; + + if (result.ok) { + const decision = parseDecision(result.text); + if (decision) { + deps.logger.debug('auto-review model candidate completed', { + candidateId: candidate.id, + providerId: candidate.providerId, + model: candidate.model, + attempt, + verdict: decision.verdict, + durationMs: now() - startedAt, + }); + return JSON.stringify(decision); + } + deps.logger.warn('auto-review model candidate returned malformed decision', { + candidateId: candidate.id, + providerId: candidate.providerId, + model: candidate.model, + attempt, + durationMs, + }); + break; + } + + const remainingAfterAttemptMs = deadlineAt - now(); + const laterCandidateReserveMs = + (candidates.length - candidateIndex - 1) * AUTO_REVIEW_CANDIDATE_TIMEOUT_MS; + const canRetry = attempt < AUTO_REVIEW_TRANSIENT_RETRY_ATTEMPTS + && transientCandidateFailure(result) + && remainingAfterAttemptMs >= ( + AUTO_REVIEW_CANDIDATE_TIMEOUT_MS + + AUTO_REVIEW_TRANSIENT_RETRY_BACKOFF_MS + + laterCandidateReserveMs + ) + && !controller.signal.aborted; + deps.logger.warn('auto-review model candidate failed', { + candidateId: candidate.id, + providerId: candidate.providerId, + model: candidate.model, + attempt, + reason: safeFailureReason(result), + retrying: canRetry, + durationMs, + }); + if (!canRetry) break; + await sleep(AUTO_REVIEW_TRANSIENT_RETRY_BACKOFF_MS, controller.signal); + } + } + } finally { + clearTimeout(deadline); + parentSignal?.removeEventListener('abort', abortFromParent); + } + + deps.logger.warn('auto-review model chain exhausted', { + candidates: candidates.length, + aborted: controller.signal.aborted, + durationMs: now() - startedAt, + }); + return null; + }; +} diff --git a/apps/desktop/src/main/maker-host/cc-remote-mcp.ts b/apps/desktop/src/main/maker-host/cc-remote-mcp.ts index 04bd89d4f0..405a04a9eb 100644 --- a/apps/desktop/src/main/maker-host/cc-remote-mcp.ts +++ b/apps/desktop/src/main/maker-host/cc-remote-mcp.ts @@ -27,6 +27,7 @@ import { withMcpRouteIdentity, } from '../mcp-integrations/codexHttpBridge.js'; import { getRemoteMcpBridgeToken } from '../mcp-integrations/remoteMcpBridgeToken.js'; +import { readDisabledBuiltinPluginIds } from '../mcp-integrations/codexBuiltinToolPolicy.js'; import { getSessionOrcaRole, getWorkerLink } from '../localDb/orcaTeamStore.js'; /** @@ -137,18 +138,25 @@ export async function buildCcRemoteHttpMcpServers( */ fingerprint?: string; }> { - const empty: { servers: Record; cleanup: () => void; needsFreshStart?: boolean } = { + const empty: { + servers: Record; + cleanup: () => void; + needsFreshStart?: boolean; + } = { servers: {}, cleanup: () => {}, }; const started = await deps.ensureBridgeStarted(); if (!started) return empty; + const synthesize = deps.synthesizeVendorOptions ?? synthesizeCcRemoteVendorOptions; + const vendorOptions = args.vendorOptions ?? (await synthesize(args.sessionId)); + const disabledPluginIds = readDisabledBuiltinPluginIds(vendorOptions) ?? []; // collab 全局禁用时 bridge 名单不反映开关 (keepOrcaProviderStable) — // 远端注入以同一闸门为准, 协同段整个不注入 (codex-connector R20 P2)。 // cindy_memory 独立走 per-session Maker Memory 开关, 与 collab 互不牵连。 // 合成规则唯一真源在 selectRemoteInjectableServerNames (codexHttpBridge.ts)。 const names = selectRemoteInjectableServerNames(started.serverNames, { - collabEnabled: deps.isCollabEnabled?.() ?? true, + collabEnabled: (deps.isCollabEnabled?.() ?? true) && !disabledPluginIds.includes('collab'), memoryEnabled: args.makerMemoryEnabled === true, }); if (names.length === 0) { @@ -172,7 +180,6 @@ export async function buildCcRemoteHttpMcpServers( // 的 alive query, 协同 MCP 持续 401 (codex-connector R21 P2)。 return { ...empty, needsFreshStart: names.length > 0 }; } - const synthesize = deps.synthesizeVendorOptions ?? synthesizeCcRemoteVendorOptions; const ctx = { agentKind: 'claude-code' as const, sessionId: args.sessionId, @@ -182,7 +189,7 @@ export async function buildCcRemoteHttpMcpServers( workingDir: args.workingDir, // remote ctx: scope key 语义见 maker-core buildMemoryScopeKey。 remoteHostId: args.host.id, - vendorOptions: args.vendorOptions ?? (await synthesize(args.sessionId)), + vendorOptions, }; // 同 session 重建 (resume/rebuild/reattach) 直接覆盖注册,注册表以 sessionId // 为 key,天然不累积。 diff --git a/apps/desktop/src/main/maker-host/createDesktopProviderService.ts b/apps/desktop/src/main/maker-host/createDesktopProviderService.ts index 407d893934..224767911e 100644 --- a/apps/desktop/src/main/maker-host/createDesktopProviderService.ts +++ b/apps/desktop/src/main/maker-host/createDesktopProviderService.ts @@ -31,7 +31,9 @@ import { parseCatalog, storedCustomProviderId, type Catalog, + type CatalogCapabilityEvidence, type CatalogIO, + type CatalogLoadResult, type CatalogSourceConfig, } from '@cindy/model-providers'; @@ -40,7 +42,7 @@ import { getBaseUrl } from '../manifestService.js'; import { throwIpcError } from '../utils/ipcValidate.js'; import { getBuildClientEndpoint, getClientEndpoint } from '../clientEndpointsService.js'; import { - commitModelPlaneFromCatalog, + commitActiveCatalogSnapshot, getActiveCatalog, getModelPlaneWarnings, setActiveCatalog, @@ -98,12 +100,8 @@ import { import { clearXaiMediaModels } from './model-discovery/xai-media.js'; import { getAuthState } from '../authManager.js'; import { getActiveAppSession } from '../appSessionState.js'; -import { - filterProviderCatalogForAccount, - projectProviderCatalogForBuildRegion, -} from './provider-access-policy.js'; +import { filterProviderCatalogForAccount } from './provider-access-policy.js'; import { getAppCapabilities } from '../appCapabilities.js'; -import { CURRENT_CINDY_REGION } from '../../shared/brandRegion.js'; import { claimDetectedNativeProviderAuth, migrateLegacyNativeProviderAuthBindings, @@ -482,10 +480,19 @@ export function ensureActiveCatalogLoaded(): Promise { const sourceKey = catalogSourceKey(source); // 首次加载时顺手清掉旧版磁盘缓存孤儿(fire-and-forget,每进程一次)。 void cleanupLegacyCatalogCache(); - activeInflight = loadCatalog(source, io) + let capabilityEvidence: CatalogCapabilityEvidence = 'fallback'; + let unverifiedXdMediaKinds: CatalogLoadResult['unverifiedXdMediaKinds'] = [ + 'image', + 'video', + 'embedding', + ]; + activeInflight = loadCatalog(source, io, (result) => { + capabilityEvidence = result.capabilityEvidence; + unverifiedXdMediaKinds = result.unverifiedXdMediaKinds; + }) .then(async (catalog) => { activeCatalogSourceKey = sourceKey; - setActiveCatalog(catalog); + setActiveCatalog(catalog, { capabilityEvidence, unverifiedXdMediaKinds }); syncLocalCatalogOverridesIntoActiveCatalog(); getActiveCatalog(); logModelPlaneWarnings(); @@ -544,10 +551,19 @@ export function reloadActiveCatalogForEndpointChange(): Promise { const generation = ++endpointReloadGeneration; activeCatalogSourceKey = null; // 必须在网络 await 前失效:登录提交后 renderer/agent 可能立即读取目录。 - setActiveCatalog(BUNDLED_CATALOG); + setActiveCatalog(BUNDLED_CATALOG, { capabilityEvidence: 'fallback' }); broadcastReferenceModelPricing(); - const flight = loadCatalog(source, io) + let capabilityEvidence: CatalogCapabilityEvidence = 'fallback'; + let unverifiedXdMediaKinds: CatalogLoadResult['unverifiedXdMediaKinds'] = [ + 'image', + 'video', + 'embedding', + ]; + const flight = loadCatalog(source, io, (result) => { + capabilityEvidence = result.capabilityEvidence; + unverifiedXdMediaKinds = result.unverifiedXdMediaKinds; + }) .then((catalog) => { if ( endpointReloadGeneration !== generation || @@ -556,7 +572,7 @@ export function reloadActiveCatalogForEndpointChange(): Promise { return getActiveCatalog(); } activeCatalogSourceKey = sourceKey; - setActiveCatalog(catalog); + setActiveCatalog(catalog, { capabilityEvidence, unverifiedXdMediaKinds }); broadcastReferenceModelPricing(); return getActiveCatalog(); }) @@ -574,9 +590,9 @@ export function reloadActiveCatalogForEndpointChange(): Promise { * 已完成,再复用同一 `loadCatalogWithSource` 源选择;bundled fallback 视为失败保 * LKG。守卫序:realm/generation → updatedAt 时间单调 → 同一时刻规范化 digest * (同=no-op,异=拒收+告警——线上纠错必须 forward-fix 抬 updatedAt)。通过后经 - * `commitModelPlaneFromCatalog` **单次 swap、单次 markChanged** 提交,成功且有 + * `commitActiveCatalogSnapshot` **单次 swap、单次 markChanged** 提交,成功且有 * 变化 = 恰 1 revision/1 广播(出口只有 active-catalog changedListener), - * no-op/失败/拒收 = 0。 + * fallback → current 会连同完整目录快照一起替换;精确 no-op/失败/拒收 = 0。 */ export async function refreshActiveCatalogFromSource(): Promise { await ensureActiveCatalogLoaded(); @@ -596,7 +612,7 @@ export async function refreshActiveCatalogFromSource(): Promise { } const generation = endpointReloadGeneration; const flight = loadCatalogWithSource(sourceConfig, io) - .then(({ catalog, source }) => { + .then(({ catalog, source, capabilityEvidence, unverifiedXdMediaKinds }) => { if (source === 'bundled') { throw new Error('catalog refresh exhausted configured sources; keeping current snapshot'); } @@ -611,8 +627,18 @@ export async function refreshActiveCatalogFromSource(): Promise { const incomingRegistry = catalog.modelRegistry; if (currentRegistry && incomingRegistry) { const relation = compareModelRegistryRevisions(incomingRegistry, currentRegistry); - if (relation === 'older' || relation === 'same') { - // 旧版拒收;同版同内容纯 no-op。两者都不 commit,零 revision 零广播。 + if (relation === 'older') { + // 旧版拒收,不 commit,零 revision 零广播。 + return getActiveCatalog(); + } + if (relation === 'same') { + // Registry 相同不代表 provider media / presets 等完整目录相同;无论本次 + // 选中 current 还是 fallback,都必须把完整快照与能力证据原子安装。 + // commitActiveCatalogSnapshot 会保留完整快照与证据均相同的精确 no-op。 + commitActiveCatalogSnapshot(catalog, { + capabilityEvidence, + unverifiedXdMediaKinds, + }); return getActiveCatalog(); } if (relation === 'invalid-incoming') { @@ -634,7 +660,7 @@ export async function refreshActiveCatalogFromSource(): Promise { return getActiveCatalog(); } } - commitModelPlaneFromCatalog(catalog); + commitActiveCatalogSnapshot(catalog, { capabilityEvidence, unverifiedXdMediaKinds }); // computeMerged 在这里同步完成,确保告警属于刚提交的同一代目录;不能读取 // 上一代惰性缓存留下的 warnings。 const activeCatalog = getActiveCatalog(); @@ -849,11 +875,7 @@ let singleton: ProviderService | null = null; * Cindy account session keeps the full active catalog. */ export function getDesktopSelectableCatalog(): Catalog { - const regionCatalog = projectProviderCatalogForBuildRegion( - getActiveCatalog(), - CURRENT_CINDY_REGION, - ); - return filterProviderCatalogForAccount(regionCatalog, { + return filterProviderCatalogForAccount(getActiveCatalog(), { canUseCindyGateway: getAppCapabilities().canUseCindyGateway, }); } diff --git a/apps/desktop/src/main/maker-host/custom-mcp-store.ts b/apps/desktop/src/main/maker-host/custom-mcp-store.ts index 8f1c138532..0021a1a3c1 100644 --- a/apps/desktop/src/main/maker-host/custom-mcp-store.ts +++ b/apps/desktop/src/main/maker-host/custom-mcp-store.ts @@ -185,6 +185,29 @@ export async function listCustomMcpServers(): Promise { return rows.map(rowToConfig); } +/** Secret-free generation markers for freezing a Bot task's MCP capability set. */ +export async function listCustomMcpRuntimeGenerations(): Promise> { + const rows = await getDbClient().drizzle + .select({ + id: customMcpServers.id, + transport: customMcpServers.transport, + updatedAt: customMcpServers.updatedAt, + }) + .from(customMcpServers) + .orderBy(asc(customMcpServers.sortOrder), asc(customMcpServers.createdAt)); + return rows.map((row) => ({ + id: row.id, + transport: (MCP_TRANSPORTS.includes(row.transport as McpTransport) + ? row.transport + : 'http') as McpTransport, + updatedAt: row.updatedAt, + })); +} + /** 取单个;不存在返回 null。 */ export async function getCustomMcpServer(id: string): Promise { const db = getDbClient().drizzle; diff --git a/apps/desktop/src/main/maker-host/custom-provider-store.ts b/apps/desktop/src/main/maker-host/custom-provider-store.ts index 2414f911a4..590323c5e0 100644 --- a/apps/desktop/src/main/maker-host/custom-provider-store.ts +++ b/apps/desktop/src/main/maker-host/custom-provider-store.ts @@ -22,6 +22,7 @@ import type { OAuthProviderDescriptor, PiReasoningEffort, PiModelApi, + ProviderWireProtocol, ProviderRuntimeModelConfig, } from '@cindy/model-providers'; import { @@ -90,6 +91,43 @@ function parseStoredPiReasoningCapability( }; } +function parseStoredModelRoute( + agent: AgentKind, + runtimeBaseUrl: string, + value: unknown, +): ProviderRuntimeModelConfig['route'] | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const route = value as Record; + if ( + typeof route.baseUrl !== 'string' || + !isAllowedWireProtocol(agent, route.wireProtocol) + ) { + return undefined; + } + try { + const runtimeUrl = new URL(runtimeBaseUrl); + const routeUrl = new URL(route.baseUrl); + if ( + (routeUrl.protocol !== 'http:' && routeUrl.protocol !== 'https:') || + routeUrl.username || + routeUrl.password || + routeUrl.origin !== runtimeUrl.origin + ) { + return undefined; + } + } catch { + return undefined; + } + if (route.requestPath !== undefined && !isProviderRequestPath(route.requestPath)) { + return undefined; + } + return { + baseUrl: route.baseUrl, + wireProtocol: route.wireProtocol, + ...(typeof route.requestPath === 'string' ? { requestPath: route.requestPath } : {}), + }; +} + function validateNoAuthLoopbackBoundary( auth: CustomProviderConfig['auth'], runtimes: Partial>, @@ -103,22 +141,46 @@ function validateNoAuthLoopbackBoundary( if (runtime.modelsUrl !== undefined && !isLoopbackProviderUrl(runtime.modelsUrl)) { return invalid(`runtime '${agent}' modelsUrl must be loopback when auth method is none`); } + for (const model of runtime.models) { + if (model.route && !isLoopbackProviderUrl(model.route.baseUrl)) { + return invalid( + `runtime '${agent}' model '${model.id}' route.baseUrl must be loopback when auth method is none`, + ); + } + } } return { ok: true }; } +function allowedWireProtocols(agent: string): readonly ProviderWireProtocol[] { + return agent === 'claude-code' + ? ['anthropic-messages'] + : ['openai-responses', 'openai-chat', 'anthropic-messages']; +} + +function isAllowedWireProtocol( + agent: string, + value: unknown, +): value is ProviderWireProtocol { + return ( + typeof value === 'string' && + allowedWireProtocols(agent).includes(value as ProviderWireProtocol) + ); +} + function validateRuntime(agent: string, rt: unknown): ValidationResult { if (!rt || typeof rt !== 'object') return invalid(`runtime '${agent}' must be an object`); const r = rt as Record; if (typeof r.baseUrl !== 'string' || r.baseUrl.trim().length === 0) { return invalid(`runtime '${agent}' baseUrl required`); } + let runtimeUrl: URL; try { - const u = new URL(r.baseUrl); - if (u.protocol !== 'http:' && u.protocol !== 'https:') { + runtimeUrl = new URL(r.baseUrl); + if (runtimeUrl.protocol !== 'http:' && runtimeUrl.protocol !== 'https:') { return invalid(`runtime '${agent}' baseUrl must be http(s)`); } - if (u.username || u.password) { + if (runtimeUrl.username || runtimeUrl.password) { return invalid(`runtime '${agent}' baseUrl must not contain embedded credentials`); } } catch { @@ -154,6 +216,40 @@ function validateRuntime(agent: string, rt: unknown): ValidationResult { if (mm.piApi !== undefined && (agent !== 'pi' || !isPiModelApi(mm.piApi))) { return invalid(`runtime '${agent}' model.piApi invalid`); } + if (mm.route !== undefined) { + if (!mm.route || typeof mm.route !== 'object' || Array.isArray(mm.route)) { + return invalid(`runtime '${agent}' model.route must be an object`); + } + const route = mm.route as Record; + if (typeof route.baseUrl !== 'string' || route.baseUrl.trim().length === 0) { + return invalid(`runtime '${agent}' model.route.baseUrl required`); + } + let routeUrl: URL; + try { + routeUrl = new URL(route.baseUrl); + } catch { + return invalid(`runtime '${agent}' model.route.baseUrl is not a valid URL`); + } + if (routeUrl.protocol !== 'http:' && routeUrl.protocol !== 'https:') { + return invalid(`runtime '${agent}' model.route.baseUrl must be http(s)`); + } + if (routeUrl.username || routeUrl.password) { + return invalid( + `runtime '${agent}' model.route.baseUrl must not contain embedded credentials`, + ); + } + if (routeUrl.origin !== runtimeUrl.origin) { + return invalid(`runtime '${agent}' model.route.baseUrl must use the runtime origin`); + } + if ( + !isAllowedWireProtocol(agent, route.wireProtocol) + ) { + return invalid(`runtime '${agent}' model.route.wireProtocol invalid`); + } + if (route.requestPath !== undefined && !isProviderRequestPath(route.requestPath)) { + return invalid(`runtime '${agent}' model.route.requestPath invalid`); + } + } const hasReasoningCapability = mm.reasoning !== undefined || mm.reasoningEfforts !== undefined || @@ -188,11 +284,7 @@ function validateRuntime(agent: string, rt: unknown): ValidationResult { if (r.wireProtocol !== undefined) { // Pi 是多协议 harness;Codex 也具备 Responses / Chat / Anthropic bridge。 // Claude Code 只接受原生 Anthropic Messages。 - const allowed = - agent === 'claude-code' - ? ['anthropic-messages'] - : ['openai-responses', 'openai-chat', 'anthropic-messages']; - if (typeof r.wireProtocol !== 'string' || !allowed.includes(r.wireProtocol)) { + if (!isAllowedWireProtocol(agent, r.wireProtocol)) { return invalid(`runtime '${agent}' wireProtocol invalid`); } } @@ -404,6 +496,17 @@ function normalizeRuntime( id: m.id.trim(), name: m.name.trim(), ...(agent === 'pi' && m.piApi ? { piApi: m.piApi } : {}), + ...(m.route + ? { + route: { + baseUrl: m.route.baseUrl.trim(), + wireProtocol: m.route.wireProtocol, + ...(m.route.requestPath?.trim() + ? { requestPath: m.route.requestPath.trim() } + : {}), + }, + } + : {}), ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}), ...(m.defaultEnabled === false ? { defaultEnabled: false } : {}), ...(m.supportsImageInput === true ? { supportsImageInput: true } : {}), @@ -575,28 +678,33 @@ function parseRuntimes(raw: string): Partial; + const baseUrl = typeof r.baseUrl === 'string' ? r.baseUrl : ''; const models = Array.isArray(r.models) ? r.models .filter( (m): m is Record => !!m && typeof m === 'object' && typeof (m as { id?: unknown }).id === 'string', ) - .map((m) => ({ - id: String(m.id), - name: String(m.name ?? ''), - ...(agent === 'pi' && isPiModelApi(m.piApi) ? { piApi: m.piApi } : {}), - ...(typeof m.contextWindow === 'number' && - Number.isFinite(m.contextWindow) && - m.contextWindow > 0 - ? { contextWindow: m.contextWindow } - : {}), - ...(m.defaultEnabled === false ? { defaultEnabled: false } : {}), - ...(m.supportsImageInput === true ? { supportsImageInput: true } : {}), - ...parseStoredPiReasoningCapability(agent, m), - })) + .map((m) => { + const route = parseStoredModelRoute(agent, baseUrl, m.route); + return { + id: String(m.id), + name: String(m.name ?? ''), + ...(agent === 'pi' && isPiModelApi(m.piApi) ? { piApi: m.piApi } : {}), + ...(route ? { route } : {}), + ...(typeof m.contextWindow === 'number' && + Number.isFinite(m.contextWindow) && + m.contextWindow > 0 + ? { contextWindow: m.contextWindow } + : {}), + ...(m.defaultEnabled === false ? { defaultEnabled: false } : {}), + ...(m.supportsImageInput === true ? { supportsImageInput: true } : {}), + ...parseStoredPiReasoningCapability(agent, m), + }; + }) : []; const entry: CustomProviderRuntimeConfig = { - baseUrl: typeof r.baseUrl === 'string' ? r.baseUrl : '', + baseUrl, models, }; if ( diff --git a/apps/desktop/src/main/maker-host/generic-oauth.ts b/apps/desktop/src/main/maker-host/generic-oauth.ts index 7c67317f7c..4a2e9efe8e 100644 --- a/apps/desktop/src/main/maker-host/generic-oauth.ts +++ b/apps/desktop/src/main/maker-host/generic-oauth.ts @@ -830,7 +830,7 @@ export async function discoverGenericOAuthModels( } /** - * 解析 OpenAI / Anthropic「列模型」响应的三种形状(`{data:[{id}]}` / `{models:[{id}]}` / + * 解析 OpenAI / Anthropic「列模型」响应的三种形状(`{data:[{id}]}` / `{models:[{id|slug}]}` / * 字符串数组)为去重后的 `{id, name, contextWindow?}[]`;无法识别返回 null。显示名优先取 * 条目的 `display_name`(Anthropic 形状)/ `name` 字段,缺省回退 id。 * contextWindow 尽力从常见字段读取(OpenRouter `context_length` / 通用 `context_window` / @@ -856,8 +856,12 @@ export function parseModelsListResponse( const id = typeof item === 'string' ? item - : item && typeof item === 'object' && typeof (item as { id?: unknown }).id === 'string' - ? (item as { id: string }).id + : item && typeof item === 'object' + ? typeof (item as { id?: unknown }).id === 'string' + ? (item as { id: string }).id + : typeof (item as { slug?: unknown }).slug === 'string' + ? (item as { slug: string }).slug + : null : null; if (!id || seen.has(id)) continue; seen.add(id); diff --git a/apps/desktop/src/main/maker-host/index.ts b/apps/desktop/src/main/maker-host/index.ts index 13c2fbe771..62213d25be 100644 --- a/apps/desktop/src/main/maker-host/index.ts +++ b/apps/desktop/src/main/maker-host/index.ts @@ -9,6 +9,9 @@ */ import { app, BrowserWindow } from 'electron'; +import { createHash } from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import fsSync from 'node:fs'; import path from 'node:path'; import { @@ -16,7 +19,6 @@ import { ClaudeCodeAgent, CodexAgent, configureDefaultImageResizer, - type AutoReviewRequest, type McpProvider, } from '@cindy/maker-core'; import { @@ -31,6 +33,7 @@ import { import { createOrcaWorkerBridgeMcpProvider, type OrcaBridgeMcpDeps } from '@cindy/orca-workflow'; import { LspServerPool, type IOSSimulatorMcpCallContext } from '@cindy/mcps'; import { effectiveXdGatewayBaseUrl } from '../model-access/effectiveEndpoint.js'; +import { listCustomMcpRuntimeGenerations } from './custom-mcp-store.js'; import { createMessage } from '../localDb/ipc/messages.js'; import { getMessagesForHistory } from '../localDb/chatHistoryReader.js'; @@ -39,6 +42,14 @@ import { cleanupSessionTempAttachments } from '../maker-ipc/normalizeAttachments import { markKnownOrcaWorkerSession } from '../maker-ipc/orcaManualInterrupt.js'; import { markOrcaMcpHydratedIfNeeded } from '../maker-ipc/orcaMcpHydrationCache.js'; import { preparePersistedOrcaSessionStart } from '../maker-ipc/orcaSessionStartOptions.js'; +import { + hydrateBotProfileRuntime, + markBotProfileRuntimeApplied, + markBotProfileRuntimeFailed, + type BotProfileRuntimeDeps, + type BotProfileRuntimeSnapshot, +} from '../maker-ipc/botProfileRuntime.js'; +import { prepareBotWorkspaceRuntime } from '../maker-ipc/botWorkspaceRuntime.js'; import type { MakerSessionCreateOpts } from '../maker-ipc/sessionRequest.js'; import { dispatchInterAgentMessage, @@ -132,8 +143,10 @@ import { import { resolveRemoteClaudeRoute } from './remote-claude-route.js'; import { claudeSubagentUsageBridge } from './claude-subagent-usage-bridge.js'; import { createAutoPermissionReviewer } from './auto-permission-reviewer.js'; -import { findCatalogModel, resolveAutoReviewBudget } from './auto-review-budget.js'; -import { requestUtilityText } from '../utility-model/oneShotCandidates.js'; +import { + AUTO_REVIEW_ROUTER_GUARD_TIMEOUT_MS, + createAutoReviewModelRouter, +} from './auto-review-model-router.js'; import { accountProviderReadinessBarrier } from './account-provider-readiness-barrier.js'; import { hasClaudeAiOAuth } from './claude-credentials-store.js'; import { @@ -170,10 +183,12 @@ import { captureKnownFileBefore, noteOpaqueTurnChange } from '../turn-change-set */ let codexAppliedContactsEnabled: boolean | null = null; import { + getBuiltinMcpServerNames, registerCustomMcpArrays, refreshCustomMcpProviders, resetCustomMcpRegistry, } from '../mcp-integrations/custom-mcp-registry.js'; +import { ESSENTIAL_PLUGIN_IDS } from './plugins/types.js'; import { cleanupComputerDriverSession } from '../mcp-integrations/computer.js'; import { createPluginRegistry, resetPluginRegistry } from './plugins/index.js'; import { @@ -185,6 +200,7 @@ import { } from '../mcp-integrations/codexEnvironment.js'; import type { CodexHttpBridge } from '../mcp-integrations/codexHttpBridge.js'; import { setRemoteMcpBridgeTokenRotatedHook } from '../mcp-integrations/remoteMcpBridgeToken.js'; +import { isBotToolsetAvailableOnTarget } from '../../shared/botRemoteCapabilities.js'; import { ensureRemoteMcpForward, setRemoteMcpForwardRearmedHook, @@ -208,6 +224,7 @@ import { maybeDetachStaleRemoteCcQuery, } from './remote-codex-mcp-recovery.js'; import { + CODEX_ALLOWED_BUILTIN_PLUGIN_IDS_KEY, CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY, readDisabledBuiltinPluginIds, } from '../mcp-integrations/codexBuiltinToolPolicy.js'; @@ -257,23 +274,14 @@ type RemoteCcQuery = Awaited< >; let _maker: Maker | null = null; +/** Prepared Bot runtime records waiting for the matching Maker startup result. */ +const pendingBotRuntimeSnapshots = new Map(); +let botRuntimeResourcePreflight: + | ((opts: MakerSessionCreateOpts) => Promise) + | null = null; /** 视觉桥实例(层 A/B/C 共用),在 resetMaker 时释放缓存。 */ let _visionBridgeInstance: ReturnType | null = null; -/** - * 本次审核请求的额度。按模型能力自适应:能关思考的走紧凑档,强制思考的 - * (以及目录里查不到的)给足思考+结论的空间 —— 固定 384 token 会让 DeepSeek - * 这类模型正文恒为空。详见 auto-review-budget.ts。 - */ -function autoReviewBudgetFor(request: AutoReviewRequest) { - return resolveAutoReviewBudget(findCatalogModel( - getActiveCatalog().providers, - request.providerId, - request.agentKind, - request.model, - )); -} - let providerAccessRuntimeRefreshListener: (() => void) | null = null; /** Register the bootstrap-owned runtime reconciliation that follows provider access changes. */ @@ -281,24 +289,15 @@ export function setProviderAccessRuntimeRefreshListener(listener: (() => void) | providerAccessRuntimeRefreshListener = listener; } +const requestAutoReviewText = createAutoReviewModelRouter({ + logger: desktopMakerLogger, +}); + const reviewAutoPermissionAction = createAutoPermissionReviewer({ logger: desktopMakerLogger, - // 重试守卫必须按同一份额度计时,否则放宽额度的那一档会被自己的守卫切断。 - resolveRequestTimeoutMs: (request) => autoReviewBudgetFor(request).timeoutMs, - requestText: async (request, prompt) => { - const maker = _maker; - if (!maker) return null; - const budget = autoReviewBudgetFor(request); - const result = await requestUtilityText(maker, prompt, { - providerId: request.providerId?.trim() || undefined, - agentKind: request.agentKind, - model: request.model, - maxTokens: budget.maxTokens, - timeoutMs: budget.timeoutMs, - ...(budget.reasoningEffort ? { reasoningEffort: budget.reasoningEffort } : {}), - }); - return result.ok ? result.text : null; - }, + managesRetries: true, + resolveRequestTimeoutMs: () => AUTO_REVIEW_ROUTER_GUARD_TIMEOUT_MS, + requestText: (_request, prompt, { signal }) => requestAutoReviewText(prompt, signal), }); /** @@ -967,6 +966,13 @@ export function getMaker(): Maker { mcpProviders: claudeMcpProviders, capabilityRouting: DESKTOP_CAPABILITY_ROUTING_POLICY, makerMemory: makerMemoryManager, + getRemoteAgentFileOps: (remoteHostId) => { + const remoteHost = getRemoteSshPool().get(remoteHostId); + if (!remoteHost) { + throw new Error(`remote SSH host "${remoteHostId}" not found in pool — connect it first under Settings → Remote`); + } + return createRemotePiFileOps(remoteHost); + }, // 智能通讯录 prompt 段的「本会话有效状态」: 与 mcp-providers.ts 的 provider // 包装同一判定链(PluginRegistry 工作区/用户覆盖 → 全局开关), 保证工具面与 // prompt 不分叉; agent 侧对 enabled 还会与实际注册的 server 集合取交。 @@ -1483,6 +1489,7 @@ export function getMaker(): Maker { mcpCallerKind, mcpCallerAttested, workingDir, + memoryScopeKey, remoteHostId, vendorOptions, }) => { @@ -1499,6 +1506,7 @@ export function getMaker(): Maker { mcpCallerAttested, ...(sessionInstanceId ? { sessionInstanceId } : {}), workingDir, + ...(memoryScopeKey ? { memoryScopeKey } : {}), // remote thread ctx: scope key 语义见 buildMemoryScopeKey。 ...(remoteHostId ? { remoteHostId } : {}), vendorOptions: { @@ -1871,6 +1879,13 @@ export function getMaker(): Maker { } return createRemotePiFileOps(remoteHost); }, + getRemoteAgentFileOps: (remoteHostId) => { + const remoteHost = getRemoteSshPool().get(remoteHostId); + if (!remoteHost) { + throw new Error(`remote SSH host "${remoteHostId}" not found in pool — connect it first under Settings → Remote`); + } + return createRemotePiFileOps(remoteHost); + }, // 远端 pi 二进制路径:probe(远端 `pi --version`)+ cache。 resolveRemotePiBinaryPath: async (remoteHostId) => { const remoteHost = getRemoteSshPool().get(remoteHostId); @@ -1949,6 +1964,86 @@ export function getMaker(): Maker { }, }); + const buildBotRuntimeDeps = (skillLinksChanged = false): BotProfileRuntimeDeps => ({ + listSkills: async ({ agentKind, workingDir, remoteHostId }) => { + if (!_maker) throw new Error('Maker is not ready while hydrating Bot runtime'); + const result = await _maker.listAgentSkills(agentKind, { + workingDir, + remoteHostId, + forceReload: agentKind === 'codex' && skillLinksChanged, + }); + return result.skills; + }, + listMcpServers: async ({ agentKind }) => { + const providers = + agentKind === 'claude-code' + ? claudeMcpProviders + : agentKind === 'codex' + ? codexMcpProviders + : piMcpProviders; + const builtinNames = new Set(getBuiltinMcpServerNames()); + const customGenerations = new Map( + (await listCustomMcpRuntimeGenerations()).map((entry) => [ + entry.id, + `${entry.transport}:${entry.updatedAt}`, + ]), + ); + return [...new Map( + providers.map((provider) => [ + provider.name, + { + name: provider.name, + source: builtinNames.has(provider.name) ? 'builtin' as const : 'custom' as const, + available: true, + generation: builtinNames.has(provider.name) + ? 'builtin:1' + : customGenerations.get(provider.name) ?? 'custom:unknown', + }, + ]), + ).values()]; + }, + listToolsets: async ({ agentKind, workingDir, remoteHostId }) => { + const registry = getPluginRegistry(); + return Promise.all( + registry.getPlugins().map(async (plugin) => { + const state = await registry.getEnableState(plugin.id, workingDir); + return { + id: plugin.id, + name: plugin.name, + essential: ESSENTIAL_PLUGIN_IDS.has(plugin.id), + available: + state.effectiveEnabled && + isBotToolsetAvailableOnTarget({ + agentKind, + remoteHostId, + toolsetId: plugin.id, + }), + version: plugin.version, + }; + }), + ); + }, + readMemoryIndex: async (scopeKey) => + (await makerMemoryManager.getStore(scopeKey)).getIndex(), + readSkillSource: async ({ path: skillPath, remoteHostId }) => { + if (!remoteHostId) return fs.readFile(skillPath, 'utf8'); + const remoteHost = getRemoteSshPool().get(remoteHostId); + if (!remoteHost) throw new Error(`remote SSH host "${remoteHostId}" not found`); + return createRemotePiFileOps(remoteHost).readFile(skillPath); + }, + fingerprintSkillSource: async ({ path: skillPath, remoteHostId }) => { + if (remoteHostId) { + const remoteHost = getRemoteSshPool().get(remoteHostId); + if (!remoteHost) throw new Error(`remote SSH host "${remoteHostId}" not found`); + return createRemotePiFileOps(remoteHost).sha256File(skillPath); + } + const hash = createHash('sha256'); + const stream = fsSync.createReadStream(skillPath); + for await (const chunk of stream) hash.update(chunk); + return hash.digest('hex'); + }, + }); + _maker = new Maker({ agents: { 'claude-code': claudeAgent, @@ -1963,6 +2058,7 @@ export function getMaker(): Maker { // 启动前的 Skill 共享与关闭后的清理都由 desktop host 注入。 lifecycleHooks: { prepareStartOptions: async (sessionId, opts) => { + pendingBotRuntimeSnapshots.delete(sessionId); const providerScopeKey = activeOwnerScopeKey(); const providerReady = await accountProviderReadinessBarrier.waitForScope(providerScopeKey); @@ -1974,15 +2070,62 @@ export function getMaker(): Maker { throw new Error('Account provider models are not ready for this app session; retry.'); } await preparePersistedOrcaSessionStart(sessionId, opts as MakerSessionCreateOpts); - if (opts.agentKind === 'codex') { - const disabledPluginIds = getPluginRegistry().getDisabledRuntimePluginIds( - opts.workingDir, - ); - opts.vendorOptions = { - ...(opts.vendorOptions ?? {}), - [CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY]: disabledPluginIds, - }; + const createOpts = opts as MakerSessionCreateOpts; + createOpts.id ??= sessionId; + await prepareBotWorkspaceRuntime(createOpts); + let skillLinksChanged = false; + if (!createOpts.remoteHostId && createOpts.workingDir) { + const result = await prepareSharedProjectSkillLinks({ + workingDir: createOpts.workingDir, + }); + skillLinksChanged = result.changed; + for (const warning of result.warnings) { + desktopMakerLogger.warn('shared project skill link warning', { + workingDir: createOpts.workingDir, + warning, + }); + } + } + const botRuntimeSnapshot = await hydrateBotProfileRuntime( + createOpts, + buildBotRuntimeDeps(skillLinksChanged), + ); + if (botRuntimeSnapshot) { + pendingBotRuntimeSnapshots.set(sessionId, botRuntimeSnapshot); + } + if (botRuntimeSnapshot?.unavailableSkills.length) { + desktopMakerLogger.warn('Bot configured Skills unavailable for runtime', { + botId: botRuntimeSnapshot.botId, + profileVersion: botRuntimeSnapshot.profileVersion, + agentKind: createOpts.agentKind, + skills: botRuntimeSnapshot.unavailableSkills, + }); } + const disabledPluginIds = [ + ...new Set([ + ...getPluginRegistry().getDisabledRuntimePluginIds(opts.workingDir), + ...(botRuntimeSnapshot?.disabledToolsets ?? []), + ]), + ]; + opts.vendorOptions = { + ...(opts.vendorOptions ?? {}), + [CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY]: disabledPluginIds, + ...(botRuntimeSnapshot + ? { + [CODEX_ALLOWED_BUILTIN_PLUGIN_IDS_KEY]: [ + ...new Set( + createOpts.botRuntimeProfile?.toolsetPolicy.catalog + .filter( + (item) => + item.essential === true || + createOpts.botRuntimeProfile?.toolsetPolicy.configured.includes(item.id), + ) + .map((item) => item.id) ?? [], + ), + ], + } + : {}), + }; }, onBeforeStart: async ({ agentKind, workingDir, remoteHostId }) => { // 延迟记忆重启 pending 时,本地 Codex 新会话加入 shared host 前先尝试 @@ -2006,12 +2149,42 @@ export function getMaker(): Maker { await codexAgent.listAgentSkills({ workingDir, forceReload: true }); } }, - onStartSucceeded: (sessionId, opts) => { + onStartSucceeded: async (sessionId, opts) => { const createOpts = opts as MakerSessionCreateOpts; markOrcaMcpHydratedIfNeeded(sessionId, createOpts); if (createOpts.orcaRole === 'worker') { markKnownOrcaWorkerSession(sessionId); } + const snapshot = pendingBotRuntimeSnapshots.get(sessionId); + if (snapshot) { + try { + const transitioned = await markBotProfileRuntimeApplied(snapshot); + if (!transitioned) { + desktopMakerLogger.warn('Bot runtime snapshot was not prepared at success boundary', { + sessionId, + snapshotId: snapshot.snapshotId, + }); + } + } finally { + pendingBotRuntimeSnapshots.delete(sessionId); + } + } + }, + onStartFailed: async ({ sessionId, stage, error }) => { + const snapshot = pendingBotRuntimeSnapshots.get(sessionId); + if (!snapshot) return; + try { + const transitioned = await markBotProfileRuntimeFailed(snapshot, { stage, error }); + if (!transitioned) { + desktopMakerLogger.warn('Bot runtime snapshot was not prepared at failure boundary', { + sessionId, + snapshotId: snapshot.snapshotId, + stage, + }); + } + } finally { + pendingBotRuntimeSnapshots.delete(sessionId); + } }, getCodexHistoryHasProductPrompt: (sessionId) => readCodexHistoryHasProductPrompt(sessionId), onCodexProductPromptDelivery: async ({ sessionId, historyHasProductPrompt }) => { @@ -2034,6 +2207,12 @@ export function getMaker(): Maker { }, }, }); + botRuntimeResourcePreflight = async (opts) => { + const preflightOpts = { ...opts }; + await hydrateBotProfileRuntime(preflightOpts, buildBotRuntimeDeps(), { + persistSnapshot: false, + }); + }; setVisionBridgeController({ shouldBridge: _visionBridgeInstance.isTargetModel, describeImage: _visionBridgeInstance.describeImage, @@ -2098,12 +2277,26 @@ export function getMakerIfReady(): Maker | null { return _maker; } +/** + * Resolve and compare the complete frozen Bot resource bundle without creating + * a runtime snapshot or touching the currently live Agent process. + */ +export async function preflightBotRuntimeResources( + opts: MakerSessionCreateOpts, +): Promise { + if (!botRuntimeResourcePreflight) { + throw new Error('Bot runtime resource preflight is unavailable before Maker initialization'); + } + await botRuntimeResourcePreflight(opts); +} + /** * 重置 Maker 单例(切账号 / 测试用)。 */ export function resetMaker(): void { cancelCodexAuthModeChange(); _maker = null; + botRuntimeResourcePreflight = null; _codexAgent = null; // coordinator 闭包捕获了刚作废的那个 maker —— 不清掉的话,换账号窗口期内到达的 auth // 事件会拿旧实例去拉模型清单(串号)。下次 getMaker() 会带着干净记账重建它。 diff --git a/apps/desktop/src/main/maker-host/ios-simulator-codex-dynamic-tools.ts b/apps/desktop/src/main/maker-host/ios-simulator-codex-dynamic-tools.ts index a3f992da23..238f79e42d 100644 --- a/apps/desktop/src/main/maker-host/ios-simulator-codex-dynamic-tools.ts +++ b/apps/desktop/src/main/maker-host/ios-simulator-codex-dynamic-tools.ts @@ -7,6 +7,7 @@ import { registerIOSSimulatorTools, type IOSSimulatorMcpDeps, } from '@cindy/mcps'; +import { isFrozenBuiltinPluginAllowed } from '../mcp-integrations/codexBuiltinToolPolicy.js'; const NAMESPACE = 'cindy_ios_simulator'; const FLAT_TOOL_SEPARATOR = '__'; @@ -84,10 +85,28 @@ export function createIOSSimulatorCodexDynamicToolProvider(options: { deps: IOSSimulatorMcpDeps; }): CodexHostDynamicToolProvider { return { - listTools: () => (process.platform === 'darwin' ? TOOLS : []), + listTools: (context) => ( + process.platform === 'darwin' + && isFrozenBuiltinPluginAllowed(context.vendorOptions, 'ios-simulator') + ? TOOLS + : [] + ), callTool: async (params, context) => { const toolName = innerToolName(params); if (!toolName) return undefined; + if (!isFrozenBuiltinPluginAllowed(context.vendorOptions, 'ios-simulator')) { + return textResponse( + { + ok: false, + errorCode: 'IOS_SIMULATOR_DISABLED', + data: { + reason: 'disabled-by-bot-profile', + message: 'The embedded iOS Simulator is not enabled in this Bot runtime snapshot.', + }, + }, + false, + ); + } if (process.platform !== 'darwin') { return textResponse( { diff --git a/apps/desktop/src/main/maker-host/pi-host.ts b/apps/desktop/src/main/maker-host/pi-host.ts index 436332e8a0..2404fd1237 100644 --- a/apps/desktop/src/main/maker-host/pi-host.ts +++ b/apps/desktop/src/main/maker-host/pi-host.ts @@ -681,6 +681,7 @@ export interface BuildPiAgentOpts { getRemotePiTransport?: AgentDeps['getRemotePiTransport']; /** SSH remote pi 会话的 agentHome 文件操作原语(host 装配;缺省 = 远端 fs 走本地,错误语义)。 */ getRemotePiFileOps?: AgentDeps['getRemotePiFileOps']; + getRemoteAgentFileOps?: AgentDeps['getRemoteAgentFileOps']; /** 远端 pi 二进制解析(host probe;缺省 = 回落本地路径)。 */ resolveRemotePiBinaryPath?: AgentDeps['resolveRemotePiBinaryPath']; /** 远端会话是否跳过 in-process MCP bridge(Phase 1 不桥 orca/memory/ghost)。 */ @@ -1369,6 +1370,7 @@ export function buildPiAgent(opts: BuildPiAgentOpts): PiAgent | null { resolvePiVisionBridgeEnv: opts.resolvePiVisionBridgeEnv, getRemotePiTransport: opts.getRemotePiTransport, getRemotePiFileOps: opts.getRemotePiFileOps, + getRemoteAgentFileOps: opts.getRemoteAgentFileOps, resolveRemotePiBinaryPath: opts.resolveRemotePiBinaryPath, remotePiSkipMcpBridge: opts.remotePiSkipMcpBridge, getRemotePiAgentProxyEnv: opts.getRemotePiAgentProxyEnv, diff --git a/apps/desktop/src/main/maker-host/pi-remote-transport.ts b/apps/desktop/src/main/maker-host/pi-remote-transport.ts index 5acabcc6be..478bbfdb7f 100644 --- a/apps/desktop/src/main/maker-host/pi-remote-transport.ts +++ b/apps/desktop/src/main/maker-host/pi-remote-transport.ts @@ -197,6 +197,37 @@ fi return null; }, + async readFile(file: string, maxBytes = 1_048_576): Promise { + const boundedBytes = Math.max(1, Math.min(Math.trunc(maxBytes), 4_194_304)); + const script = `P=${shellQuote(file)}; case "$P" in '$HOME'/*) H=$(printf '%s' "$HOME"); [ "\${P#\\$HOME}" != "$P" ] && P="\${H}\${P#\\$HOME}";; esac; [ -f "$P" ] || exit 44; head -c ${boundedBytes} "$P"`; + const result = await remoteHost.exec(`bash -c ${shellQuote(script)}`, { + timeoutMs: 10_000, + label: 'agent-remote-read-file', + }); + if (result.exitCode !== 0) { + throw new Error(`remote read failed (exit ${result.exitCode})`); + } + return result.stdout; + }, + + async sha256File(file: string): Promise { + const script = [ + `P=${shellQuote(file)}`, + `case "$P" in '$HOME'/*) H=$(printf '%s' "$HOME"); [ "\${P#\\$HOME}" != "$P" ] && P="\${H}\${P#\\$HOME}";; esac`, + `[ -f "$P" ] || exit 44`, + `if command -v sha256sum >/dev/null 2>&1; then sha256sum "$P" | awk '{print $1}'; elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$P" | awk '{print $1}'; elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$P" | awk '{print $NF}'; else exit 45; fi`, + ].join('\n'); + const result = await remoteHost.exec(`bash -c ${shellQuote(script)}`, { + timeoutMs: 30_000, + label: 'agent-remote-sha256-file', + }); + const digest = result.stdout.trim().split(/\r?\n/).pop()?.toLowerCase() ?? ''; + if (result.exitCode !== 0 || !/^[a-f0-9]{64}$/.test(digest)) { + throw new Error(`remote sha256 failed (exit ${result.exitCode})`); + } + return digest; + }, + async rm(fileOrDir: string, opts?: { recursive?: boolean }): Promise { const flag = opts?.recursive === true ? ' -rf' : ' -f'; // 轮 43 P1(codex-connector):eval 换 H=$(printf) + 参数替换, 无注入风险。 diff --git a/apps/desktop/src/main/maker-host/provider-access-policy.ts b/apps/desktop/src/main/maker-host/provider-access-policy.ts index 3eade7c8f4..d340449e29 100644 --- a/apps/desktop/src/main/maker-host/provider-access-policy.ts +++ b/apps/desktop/src/main/maker-host/provider-access-policy.ts @@ -1,11 +1,18 @@ /** * provider-access-policy — runtime gates for user-selectable model providers. * - * Routing keeps consuming the full active catalog. This projection only controls the - * providers and models exposed as selectable capabilities to product surfaces. + * Routing and product surfaces consume the same active catalog. Successful Gateway + * catalogs and local provider discovery are never region-filtered here. The only + * regional projection below applies to unverified compatibility fallbacks, which must + * not claim that bundled XD media is currently routable by a regional endpoint. */ -import { classifyModel, type Catalog, type Provider } from '@cindy/model-providers'; +import { + classifyModel, + type Catalog, + type CatalogXdMediaKind, + type Provider, +} from '@cindy/model-providers'; import type { CindyRegion } from '@cindy/maker-shared/brand-identity'; export interface ProviderAccessContext { @@ -14,13 +21,18 @@ export interface ProviderAccessContext { } const CINDY_AI_PROVIDER_ID = 'xd'; -const MAINLAND_VIDEO_MODEL_IDS: ReadonlySet = new Set([ +const MAINLAND_FALLBACK_VIDEO_MODEL_IDS: ReadonlySet = new Set([ 'seedance-fast', 'seedance-pro', 'bytedance/seedance-2.5', ]); +const ALL_XD_MEDIA_KINDS: ReadonlySet = new Set([ + 'image', + 'video', + 'embedding', +]); -function projectVideoDefaults( +function projectFallbackVideoDefaults( defaults: Provider['videoDefaults'], allowedIds: ReadonlySet, ): Provider['videoDefaults'] | undefined { @@ -33,90 +45,63 @@ function projectVideoDefaults( } /** - * Build-region projection for Cindy-managed media capabilities. Global keeps - * the catalog source verbatim; Mainland China and dev expose only the media - * capabilities that the regional Cindy service supports. - * - * Region is a build-time choice for Cindy-owned services, not a restriction on - * providers the user connected locally. OpenAI/Codex OAuth, xAI OAuth, Gemini - * API keys and custom providers therefore keep their image catalog in every - * build. Video dispatch is not provider-aware yet, so non-XD video capabilities - * stay hidden outside Global until the runtime can route them by provider. - * - * Embedding clears the Cindy-managed list for Mainland China builds, because - * every model behind that endpoint is an overseas one. Clearing the list (rather - * than leaving it and failing at dispatch) is what makes the plugin capability - * report "unavailable" instead of offering models that are guaranteed to fail. - * - * Non-XD providers are left untouched *here* on purpose, but that is not the - * same as exposing them: unlike image, embedding dispatch is not provider-aware - * yet (one EmbeddingService holding a single XD Gateway base URL and key), so a - * non-XD `embeddingModels` list would be dispatched with Cindy's credential - * rather than the user's own. `deriveCindyMediaConfig` therefore drops non-XD - * embedding lists in *every* region — see the `kind === 'embed'` guard there. - * This projection stays region-only; the routing constraint lives with the - * derivation so both builds get it (PR #1707 review). + * A bundled/LKG/legacy snapshot is useful for compatibility metadata, but it is not + * evidence that the current regional Gateway can execute every XD media model baked + * into that snapshot. Keep the historical CN-safe XD subset until the configured + * current catalog source succeeds. Non-XD providers are intentionally untouched: + * locally connected providers and their discovery results do not belong to Gateway + * regional policy. */ -export function projectProviderCatalogForBuildRegion( +export function projectUnverifiedCatalogFallbackForBuildRegion( catalog: Catalog, region: CindyRegion, + unverifiedKinds: ReadonlySet = ALL_XD_MEDIA_KINDS, ): Catalog { - if (region === 'global') return catalog; + if (region === 'global' || unverifiedKinds.size === 0) return catalog; let changed = false; const providers = catalog.providers.map((provider) => { - const isCindyAi = provider.id === CINDY_AI_PROVIDER_ID; - if (!isCindyAi) { - const models = Object.fromEntries( - Object.entries(provider.models).map(([agent, list]) => [ - agent, - (list ?? []).filter((model) => classifyModel(model) !== 'video'), - ]), - ) as Provider['models']; - const hasVideoModels = Object.values(provider.models).some( - (list) => list?.some((model) => classifyModel(model) === 'video') ?? false, - ); - const hasVideoCatalog = - provider.videoModels !== undefined || provider.videoDefaults !== undefined; - if (!hasVideoModels && !hasVideoCatalog) return provider; + if (provider.id !== CINDY_AI_PROVIDER_ID) return provider; - changed = true; - const projected: Provider = { - ...provider, - models, - videoModels: [], - }; - delete projected.videoDefaults; - return projected; - } - changed = true; - - const videoModels = (provider.videoModels ?? []).filter((model) => - MAINLAND_VIDEO_MODEL_IDS.has(model.id), - ); - const videoIds = new Set(videoModels.map((model) => model.id)); - const videoDefaults = projectVideoDefaults(provider.videoDefaults, videoIds); + const projectImage = unverifiedKinds.has('image'); + const projectVideo = unverifiedKinds.has('video'); + const projectEmbedding = unverifiedKinds.has('embedding'); + const videoModels = projectVideo + ? (provider.videoModels ?? []).filter((model) => + MAINLAND_FALLBACK_VIDEO_MODEL_IDS.has(model.id), + ) + : provider.videoModels; + const videoIds = new Set((videoModels ?? []).map((model) => model.id)); + const videoDefaults = projectVideo + ? projectFallbackVideoDefaults(provider.videoDefaults, videoIds) + : provider.videoDefaults; const models = Object.fromEntries( Object.entries(provider.models).map(([agent, list]) => [ agent, (list ?? []).filter((model) => { const group = classifyModel(model); - if (group === 'image' || group === 'embedding') return false; - return group !== 'video' || MAINLAND_VIDEO_MODEL_IDS.has(model.id); + if (group === 'image' && projectImage) return false; + if (group === 'embedding' && projectEmbedding) return false; + return ( + group !== 'video' || + !projectVideo || + MAINLAND_FALLBACK_VIDEO_MODEL_IDS.has(model.id) + ); }), ]), ) as Provider['models']; const projected: Provider = { ...provider, models, - imageModels: [], - videoModels, - embeddingModels: [], + ...(projectImage ? { imageModels: [] } : {}), + ...(projectVideo ? { videoModels } : {}), + ...(projectEmbedding ? { embeddingModels: [] } : {}), }; - delete projected.imageDefaults; - delete projected.videoDefaults; - delete projected.embeddingDefaults; + if (projectImage) delete projected.imageDefaults; + if (projectVideo) delete projected.videoDefaults; + if (projectEmbedding) delete projected.embeddingDefaults; if (videoDefaults) projected.videoDefaults = videoDefaults; + changed = true; return projected; }); diff --git a/apps/desktop/src/main/maker-host/provider-diagnostics.ts b/apps/desktop/src/main/maker-host/provider-diagnostics.ts index f4a5aecada..1923097c10 100644 --- a/apps/desktop/src/main/maker-host/provider-diagnostics.ts +++ b/apps/desktop/src/main/maker-host/provider-diagnostics.ts @@ -333,8 +333,14 @@ export function resolveSavedProbeSpec(providerId: string, agent: AgentKind): Pro isAgentSelectableModel(m, { userProvider: provider.source === 'user' }), ); if (!model) throw new Error(`provider '${providerId}' has no chat models for '${agent}'`); + const baseUrl = model.route?.baseUrl ?? routing.upstream; + const wireProtocol = model.route?.wireProtocol ?? routing.wireProtocol; // Pi derives its inference path from wireProtocol and does not consume requestPath. - const requestPath = agent === 'pi' ? undefined : routing.requestPath; + const requestPath = agent === 'pi' + ? undefined + : model.route + ? model.route.requestPath + : routing.requestPath; // OAuth 形态:探测凭证用 Runner 持有的 access_token(与 oauth-token 路由同源),未登录时 // 无 token → 探测会得到 AUTH_INVALID,这本身就是「先去登录」的正确结论。 // token 走 authorization 头而**不走 apiKey 字段**——apiKey 会让 cc 探测同时发 @@ -344,9 +350,9 @@ export function resolveSavedProbeSpec(providerId: string, agent: AgentKind): Pro const oauthToken = oauthProbeTokenReader(providerId); return { agent, - baseUrl: routing.upstream, + baseUrl, modelId: model.id, - wireProtocol: routing.wireProtocol, + wireProtocol, requestPath, apiKey: null, headers: { @@ -358,9 +364,9 @@ export function resolveSavedProbeSpec(providerId: string, agent: AgentKind): Pro if (routing.authStrategy === 'none') { return { agent, - baseUrl: routing.upstream, + baseUrl, modelId: model.id, - wireProtocol: routing.wireProtocol, + wireProtocol, requestPath, apiKey: null, headers: withoutCredentialHeaders(routing.headerOverride), @@ -369,12 +375,12 @@ export function resolveSavedProbeSpec(providerId: string, agent: AgentKind): Pro const apiKey = keyReader(providerId, agent); return { agent, - baseUrl: routing.upstream, + baseUrl, modelId: model.id, // 与 oauth-token 分支对齐:Chat 桥接供应商(api-key-header + openai-chat)的 saved 探测 // 必须带上 wireProtocol,否则 buildProbeRequest 回落到原生 /responses,对 Chat-only 上游 // 误报连接失败(真实会话走 resolveSessionRoute 不受影响,探测结论会与真实会话相反)。 - wireProtocol: routing.wireProtocol, + wireProtocol, requestPath, apiKey, // 与真实会话路由保持 legacy 兼容:safeStorage 已有 key 时清掉旧凭证头,由 apiKey diff --git a/apps/desktop/src/main/maker-host/provider-route.ts b/apps/desktop/src/main/maker-host/provider-route.ts index c69243af02..a3ce700a01 100644 --- a/apps/desktop/src/main/maker-host/provider-route.ts +++ b/apps/desktop/src/main/maker-host/provider-route.ts @@ -464,9 +464,24 @@ function routingServesWireModel(routing: RoutingDescriptor, wireModel: string | function providerRoutingForModel( provider: Provider, agent: AgentKind, - _wireModel: string | undefined, + wireModel: string | undefined, ): RoutingDescriptor | null { - return provider.routing[agent] ?? null; + const routing = provider.routing[agent]; + if (!routing) return null; + const modelRoute = wireModel + ? provider.models[agent]?.find((model) => model.id === wireModel)?.route + : undefined; + if (!modelRoute) return routing; + + // 鉴权、固定 headers 与模型 namespace 门继续继承 provider/runtime;请求路径不能在 + // 协议切换后误继承旧 runtime 的路径,只有模型覆盖显式声明时才带回。 + const { requestPath: _runtimeRequestPath, ...inherited } = routing; + return { + ...inherited, + upstream: modelRoute.baseUrl, + wireProtocol: modelRoute.wireProtocol, + ...(modelRoute.requestPath ? { requestPath: modelRoute.requestPath } : {}), + }; } /** diff --git a/apps/desktop/src/main/maker-host/session-search.ts b/apps/desktop/src/main/maker-host/session-search.ts index 14eb18df1b..c2b5b3006f 100644 --- a/apps/desktop/src/main/maker-host/session-search.ts +++ b/apps/desktop/src/main/maker-host/session-search.ts @@ -20,6 +20,7 @@ import type { SessionSearchOptions, SessionSearchHit, SessionSearchFn } from '@c import { getDbClient } from '../localDb/client/current.js'; import { createLogger } from '../logger.js'; +import { resolveBotHistoryScope } from '../localDb/botHistoryScope.js'; const log = createLogger('session-search'); const TABLE = 'messages_fts'; @@ -41,6 +42,12 @@ export const searchSessionsFn: SessionSearchFn = async ( throw new Error('DbClient not ready'); } + const callerScope = await resolveBotHistoryScope( + opts.callerSessionId, + opts.callerMemoryScopeKey, + ); + if (callerScope.kind === 'denied') return []; + // messages_fts 表自带 message_id / session_id / role / content; ts 走 messages 表 join let sql = ` SELECT m.id AS messageId, @@ -59,6 +66,17 @@ export const searchSessionsFn: SessionSearchFn = async ( sql += ` AND m.session_id = ?`; params.push(opts.sessionId); } + if (callerScope.kind === 'bot') { + // Bot ownership is resolved from the current runtime Session. The model can + // optionally narrow within that set, but cannot widen it by supplying an + // arbitrary sessionId. + sql += ` AND m.session_id IN ( + SELECT scoped.session_id + FROM bot_session_links scoped + WHERE scoped.bot_id = ? + )`; + params.push(callerScope.botId); + } if (opts.role) { sql += ` AND m.role = ?`; params.push(opts.role); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/botAutomationMutationLock.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/botAutomationMutationLock.test.ts new file mode 100644 index 0000000000..36b123cb42 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/botAutomationMutationLock.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + resetBotAutomationMutationLocksForTest, + withBotAutomationMutationLock, +} from '../botAutomationMutationLock'; + +beforeEach(() => resetBotAutomationMutationLocksForTest()); + +describe('Bot automation mutation lock', () => { + it('serializes one schedule while allowing unrelated schedules to proceed', async () => { + const order: string[] = []; + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { releaseFirst = resolve; }); + + const first = withBotAutomationMutationLock('schedule-1', async () => { + order.push('first:start'); + await firstGate; + order.push('first:end'); + }); + const second = withBotAutomationMutationLock('schedule-1', async () => { + order.push('second'); + }); + const unrelated = withBotAutomationMutationLock('schedule-2', async () => { + order.push('unrelated'); + }); + + await unrelated; + expect(order).toEqual(['first:start', 'unrelated']); + releaseFirst(); + await Promise.all([first, second]); + expect(order).toEqual(['first:start', 'unrelated', 'first:end', 'second']); + }); + + it('releases the next mutation after a failure', async () => { + const first = withBotAutomationMutationLock('schedule-1', async () => { + throw new Error('failed mutation'); + }); + const second = withBotAutomationMutationLock('schedule-1', async () => 'continued'); + + await expect(first).rejects.toThrow('failed mutation'); + await expect(second).resolves.toBe('continued'); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/botCanonicalReplacementGuard.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/botCanonicalReplacementGuard.test.ts new file mode 100644 index 0000000000..9ae9ad8874 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/botCanonicalReplacementGuard.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { isBotCanonicalReplacementBusy } from '../botCanonicalReplacementGuard'; + +const idle = { + turnRunning: false, + backgroundTaskCount: 0, + trackedTurn: false, + leasedTurn: false, + pendingInteraction: false, +}; + +describe('Bot canonical replacement guard', () => { + it('allows Renew only when every runtime owner is idle', () => { + expect(isBotCanonicalReplacementBusy(idle)).toBe(false); + }); + + it.each([ + ['live turn', { turnRunning: true }], + ['background task', { backgroundTaskCount: 1 }], + ['tracked turn', { trackedTurn: true }], + ['leased IM turn', { leasedTurn: true }], + ['pending interaction', { pendingInteraction: true }], + ])('blocks Renew for %s', (_label, patch) => { + expect(isBotCanonicalReplacementBusy({ ...idle, ...patch })).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/botCompactRuntimeRefresh.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/botCompactRuntimeRefresh.test.ts new file mode 100644 index 0000000000..34cfe2e6f7 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/botCompactRuntimeRefresh.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + createBotCompactRuntimeRefreshCoordinator, + replaceBotRuntimeAfterPreflight, + type BotCompactRuntimeSession, +} from '../botCompactRuntimeRefresh'; + +function createSession(id = 'bot-session', instanceId = 'runtime-1') { + let running = false; + let backgroundTasks = 0; + const session: BotCompactRuntimeSession = { + id, + instanceId, + isTurnRunning: () => running, + listBackgroundTasks: () => Array.from({ length: backgroundTasks }), + }; + return { + session, + setRunning: (value: boolean) => { running = value; }, + setBackgroundTasks: (value: number) => { backgroundTasks = value; }, + }; +} + +describe('Bot compact runtime refresh coordinator', () => { + it('keeps the live runtime open when frozen resource preflight fails', async () => { + const calls: string[] = []; + await expect(replaceBotRuntimeAfterPreflight({ + preflight: async () => { + calls.push('preflight'); + throw Object.assign(new Error('resource drift'), { + code: 'BOT_RUNTIME_RESOURCE_DRIFT', + }); + }, + isCurrentOwner: () => true, + close: async () => { calls.push('close'); }, + bootstrap: async () => { calls.push('bootstrap'); }, + })).rejects.toMatchObject({ code: 'BOT_RUNTIME_RESOURCE_DRIFT' }); + expect(calls).toEqual(['preflight']); + }); + + it('rechecks ownership after preflight and swaps only in the safe order', async () => { + const calls: string[] = []; + await expect(replaceBotRuntimeAfterPreflight({ + preflight: async () => { calls.push('preflight'); }, + isCurrentOwner: () => { + calls.push('owner'); + return true; + }, + close: async () => { calls.push('close'); }, + bootstrap: async () => { + calls.push('bootstrap'); + return 'new-runtime'; + }, + })).resolves.toBe('new-runtime'); + expect(calls).toEqual(['preflight', 'owner', 'close', 'bootstrap']); + + await expect(replaceBotRuntimeAfterPreflight({ + preflight: async () => undefined, + isCurrentOwner: () => false, + close: async () => { throw new Error('must not close'); }, + bootstrap: async () => 'unreachable', + })).rejects.toThrow('owner changed'); + }); + + it('waits for the final idle boundary instead of refreshing at compact_boundary', async () => { + const h = createSession(); + let hasInteraction = false; + const refresh = vi.fn(async () => 'refreshed' as const); + const coordinator = createBotCompactRuntimeRefreshCoordinator({ + hasPendingInteraction: () => hasInteraction, + refresh, + now: () => 100, + }); + + h.setRunning(true); + coordinator.noteBoundary(h.session); + expect(refresh).not.toHaveBeenCalled(); + await expect(coordinator.attempt(h.session)).resolves.toBe('deferred'); + + h.setRunning(false); + hasInteraction = true; + await expect(coordinator.attempt(h.session)).resolves.toBe('deferred'); + hasInteraction = false; + h.setBackgroundTasks(1); + await expect(coordinator.attempt(h.session)).resolves.toBe('deferred'); + + h.setBackgroundTasks(0); + await expect(coordinator.attempt(h.session)).resolves.toBe('refreshed'); + expect(refresh).toHaveBeenCalledTimes(1); + expect(coordinator.hasPending(h.session.id)).toBe(false); + }); + + it('is scoped to the exact runtime instance and ignores late events from the old one', async () => { + const oldRuntime = createSession('bot-session', 'runtime-old'); + const replacement = createSession('bot-session', 'runtime-new'); + const refresh = vi.fn(async () => 'refreshed' as const); + const coordinator = createBotCompactRuntimeRefreshCoordinator({ + hasPendingInteraction: () => false, + refresh, + }); + + coordinator.noteBoundary(oldRuntime.session); + await expect(coordinator.attempt(replacement.session)).resolves.toBe('not-bot'); + expect(refresh).not.toHaveBeenCalled(); + await expect(coordinator.attempt(oldRuntime.session)).resolves.toBe('refreshed'); + }); + + it('deduplicates concurrent settle signals and keeps a failed refresh pending for retry', async () => { + const h = createSession(); + let resolveRefresh!: (value: 'refreshed') => void; + const refresh = vi.fn(() => new Promise<'refreshed'>((resolve) => { + resolveRefresh = resolve; + })); + const coordinator = createBotCompactRuntimeRefreshCoordinator({ + hasPendingInteraction: () => false, + refresh, + }); + coordinator.noteBoundary(h.session); + + const first = coordinator.attempt(h.session); + const second = coordinator.attempt(h.session); + expect(refresh).toHaveBeenCalledTimes(1); + resolveRefresh('refreshed'); + await expect(Promise.all([first, second])).resolves.toEqual(['refreshed', 'refreshed']); + + const retry = vi.fn() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce('refreshed'); + const retryCoordinator = createBotCompactRuntimeRefreshCoordinator({ + hasPendingInteraction: () => false, + refresh: retry, + }); + retryCoordinator.noteBoundary(h.session); + await expect(retryCoordinator.attempt(h.session)).resolves.toBe('deferred'); + expect(retryCoordinator.hasPending(h.session.id)).toBe(true); + await expect(retryCoordinator.attempt(h.session)).resolves.toBe('refreshed'); + expect(retryCoordinator.hasPending(h.session.id)).toBe(false); + }); + + it('clears only the matching closed runtime', () => { + const oldRuntime = createSession('bot-session', 'runtime-old'); + const otherRuntime = createSession('bot-session', 'runtime-new'); + const coordinator = createBotCompactRuntimeRefreshCoordinator({ + hasPendingInteraction: () => false, + refresh: async () => 'refreshed', + }); + coordinator.noteBoundary(oldRuntime.session); + coordinator.clearForClosedSession(otherRuntime.session); + expect(coordinator.hasPending(oldRuntime.session.id)).toBe(true); + coordinator.clearForClosedSession(oldRuntime.session); + expect(coordinator.hasPending(oldRuntime.session.id)).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/botDelegationCapabilityTarget.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/botDelegationCapabilityTarget.test.ts new file mode 100644 index 0000000000..5582f36daf --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/botDelegationCapabilityTarget.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { isBotRuntimeSnapshotForCapabilityTarget } from '../botDelegationService'; + +describe('Bot delegation capability target runtime', () => { + it('uses only the canonical task for ordinary roster capability claims', () => { + expect(isBotRuntimeSnapshotForCapabilityTarget({ + runtimeSessionId: 'canonical-1', + runtimeWorkingDir: '/repo/main', + canonicalSessionId: 'canonical-1', + })).toBe(true); + expect(isBotRuntimeSnapshotForCapabilityTarget({ + runtimeSessionId: 'telegram-route-1', + runtimeWorkingDir: '/repo/other', + canonicalSessionId: 'canonical-1', + })).toBe(false); + }); + + it('uses the frozen Automation workspace instead of an unrelated canonical or Route task', () => { + expect(isBotRuntimeSnapshotForCapabilityTarget({ + runtimeSessionId: 'automation-child', + runtimeWorkingDir: '/repo/frozen', + canonicalSessionId: 'canonical-1', + automationWorkingDir: '/repo/frozen', + })).toBe(true); + expect(isBotRuntimeSnapshotForCapabilityTarget({ + runtimeSessionId: 'canonical-1', + runtimeWorkingDir: '/repo/main', + canonicalSessionId: 'canonical-1', + automationWorkingDir: '/repo/frozen', + })).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/botDurableNoteService.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/botDurableNoteService.test.ts new file mode 100644 index 0000000000..3088f2660e --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/botDurableNoteService.test.ts @@ -0,0 +1,128 @@ +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ + db: null as ReturnType | null, + sqlite: null as Database.Database | null, +})); + +vi.mock('../../localDb/client/current.js', () => ({ + getDbClient: () => ({ drizzle: h.db }), +})); + +import { + deleteBotDurableNote, + getBotDurableNote, + listBotDurableNotes, + setBotDurableNote, +} from '../botDurableNoteService.js'; + +describe('botDurableNoteService', () => { + beforeEach(() => { + const sqlite = new Database(':memory:'); + sqlite.exec(` + CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT NOT NULL); + CREATE TABLE bot_session_links ( + id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL, + session_id TEXT NOT NULL, + profile_version INTEGER NOT NULL DEFAULT 1, + role TEXT NOT NULL DEFAULT 'canonical', + channel_id TEXT, + route_key TEXT, + created_at INTEGER NOT NULL, + archived_at INTEGER + ); + CREATE TABLE bot_durable_notes ( + id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL, + namespace TEXT NOT NULL, + note_key TEXT NOT NULL, + value_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE(bot_id, namespace, note_key) + ); + CREATE TABLE bot_automation_links ( + id TEXT PRIMARY KEY, + durable_note_namespace TEXT + ); + CREATE TABLE bot_automation_runs ( + id TEXT PRIMARY KEY, + automation_link_id TEXT NOT NULL, + session_id TEXT + ); + INSERT INTO sessions VALUES ('a-main', 'bot'), ('b-main', 'bot'), ('ordinary', 'desktop'); + INSERT INTO bot_session_links (id, bot_id, session_id, created_at) + VALUES ('a:a-main', 'bot-a', 'a-main', 1), ('b:b-main', 'bot-b', 'b-main', 1); + `); + h.sqlite = sqlite; + h.db = drizzle(sqlite); + }); + + it('persists JSON per Bot and prevents cross-Bot reads', async () => { + const saved = await setBotDurableNote({ + callerSessionId: 'a-main', + namespace: 'automation', + key: 'cursor', + value: { page: 2 }, + }); + expect(saved).toMatchObject({ ok: true, note: { value: { page: 2 } } }); + expect(await listBotDurableNotes({ callerSessionId: 'a-main' })).toMatchObject({ + ok: true, + notes: [{ namespace: 'automation', key: 'cursor', value: { page: 2 } }], + }); + expect(await getBotDurableNote({ + callerSessionId: 'b-main', + namespace: 'automation', + key: 'cursor', + })).toMatchObject({ ok: false, errorCode: 'NOT_FOUND' }); + expect(await getBotDurableNote({ + callerSessionId: 'ordinary', + namespace: 'automation', + key: 'cursor', + })).toMatchObject({ ok: false, errorCode: 'NOT_A_BOT_SESSION' }); + }); + + it('enforces key and value bounds and supports deletion', async () => { + expect(await setBotDurableNote({ + callerSessionId: 'a-main', + namespace: '../escape', + key: 'x', + value: true, + })).toMatchObject({ ok: false, errorCode: 'INVALID_ARGS' }); + expect(await setBotDurableNote({ + callerSessionId: 'a-main', + namespace: 'automation', + key: 'large', + value: 'x'.repeat(40_000), + })).toMatchObject({ ok: false, errorCode: 'VALUE_TOO_LARGE' }); + await setBotDurableNote({ + callerSessionId: 'a-main', + namespace: 'automation', + key: 'cursor', + value: 1, + }); + expect(await deleteBotDurableNote({ + callerSessionId: 'a-main', + namespace: 'automation', + key: 'cursor', + })).toEqual({ ok: true, deleted: true }); + }); + + it('uses the automation-bound namespace when the tool omits one', async () => { + h.sqlite!.exec(` + INSERT INTO sessions VALUES ('a-automation', 'bot'); + INSERT INTO bot_session_links (id, bot_id, session_id, created_at) + VALUES ('a:a-automation', 'bot-a', 'a-automation', 2); + INSERT INTO bot_automation_links VALUES ('automation-a', 'nightly'); + INSERT INTO bot_automation_runs VALUES ('run-a', 'automation-a', 'a-automation'); + `); + expect(await setBotDurableNote({ + callerSessionId: 'a-automation', + key: 'cursor', + value: 7, + })).toMatchObject({ ok: true, note: { namespace: 'nightly', key: 'cursor', value: 7 } }); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/botLifecycleService.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/botLifecycleService.test.ts new file mode 100644 index 0000000000..77ba9b609d --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/botLifecycleService.test.ts @@ -0,0 +1,426 @@ +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Maker } from '@cindy/maker-core'; + +const h = vi.hoisted(() => ({ + db: null as ReturnType | null, + tx: null as null | ((name: string, args: unknown) => Promise), +})); + +vi.mock('electron', () => ({ + app: { getPath: () => '/tmp/cindy-bot-lifecycle-test' }, + BrowserWindow: { getAllWindows: () => [] }, + ipcMain: { handle: vi.fn() }, +})); + +vi.mock('../schedule.js', () => ({ + awaitReadyWithTimeout: vi.fn(), +})); + +vi.mock('../../localDb/client/current.js', () => ({ + getDbClient: () => ({ drizzle: h.db, tx: h.tx }), +})); + +import { createBotLifecycleService } from '../botLifecycleService.js'; +import { tx as runWorkerTx } from '../../localDb/worker/opHandlers/tx.js'; + +function createDatabase(): Database.Database { + const sqlite = new Database(':memory:'); + sqlite.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + status TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE bot_profiles ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + avatar TEXT NOT NULL DEFAULT '🤖', + avatar_color TEXT NOT NULL DEFAULT 'violet', + status TEXT NOT NULL DEFAULT 'active', + current_version INTEGER NOT NULL DEFAULT 1, + canonical_session_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE bot_routes ( + id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + route_key TEXT NOT NULL, + principal_key TEXT NOT NULL, + scope_key TEXT NOT NULL, + thread_key TEXT, + current_session_id TEXT, + project_binding_id TEXT, + capabilities_json TEXT NOT NULL DEFAULT '{}', + owner_device_id TEXT, + owner_generation INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + suspended_status TEXT, + last_activity_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE bot_automation_links ( + id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL, + schedule_id TEXT, + project_binding_id TEXT, + target_route_id TEXT, + created_with_profile_version INTEGER NOT NULL, + durable_note_namespace TEXT, + execution_policy_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'active', + suspended_status TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE bot_session_links ( + id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL, + session_id TEXT NOT NULL, + profile_version INTEGER NOT NULL DEFAULT 1, + role TEXT NOT NULL, + channel_id TEXT, + route_key TEXT, + created_at INTEGER NOT NULL, + archived_at INTEGER + ); + CREATE TABLE bot_lifecycle_events ( + id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL, + session_id TEXT, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL + ); + INSERT INTO bot_profiles VALUES ( + 'bot-1', 'Helper', '', '🤖', 'violet', 'active', 1, 'canonical', 1, 1 + ); + INSERT INTO bot_routes VALUES + ('route-active', 'bot-1', 'channel-1', 'dm:a', 'a', 'dm:a', NULL, 'route-session', NULL, '{}', 'device-1', 3, 'active', NULL, 1, 1, 1), + ('route-user-paused', 'bot-1', 'channel-1', 'dm:b', 'b', 'dm:b', NULL, NULL, NULL, '{}', NULL, 0, 'paused', NULL, NULL, 1, 1); + INSERT INTO bot_automation_links VALUES + ('auto-active', 'bot-1', 'schedule-active', NULL, NULL, 1, NULL, '{}', 'active', NULL, 1, 1), + ('auto-user-paused', 'bot-1', 'schedule-paused', NULL, NULL, 1, NULL, '{}', 'paused', NULL, 1, 1); + INSERT INTO bot_session_links VALUES + ('link-canonical', 'bot-1', 'canonical', 1, 'canonical', NULL, NULL, 1, NULL), + ('link-route', 'bot-1', 'route-session', 1, 'route', 'channel-1', 'dm:a', 1, NULL); + INSERT INTO sessions VALUES + ('canonical', 'bot', 'active', 1), + ('route-session', 'bot', 'active', 1); + `); + return sqlite; +} + +function row(sqlite: Database.Database, table: string, id: string) { + return sqlite.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(id) as Record; +} + +describe('Bot lifecycle coordinator', () => { + let sqlite: Database.Database; + let closeSession: ReturnType; + let pauseSchedule: ReturnType; + let resumeSchedule: ReturnType; + let cancelDelegationsForBot: ReturnType; + let suspendForBot: ReturnType; + let resumeForBot: ReturnType; + let cancelForBot: ReturnType; + let retainWorktrees: ReturnType; + let releaseWorktrees: ReturnType; + let createCanonicalSession: ReturnType; + let deleteProfileAndDetachSessions: ReturnType; + + beforeEach(() => { + sqlite = createDatabase(); + const db = drizzle(sqlite); + h.db = db; + h.tx = async (name, args) => runWorkerTx(sqlite, { name: name as never, args } as never); + closeSession = vi.fn(async () => undefined); + pauseSchedule = vi.fn(async () => undefined); + resumeSchedule = vi.fn(async () => undefined); + cancelDelegationsForBot = vi.fn(async () => 2); + suspendForBot = vi.fn(async () => 3); + resumeForBot = vi.fn(async () => 3); + cancelForBot = vi.fn(async () => 4); + retainWorktrees = vi.fn(async () => 2); + releaseWorktrees = vi.fn(async () => 2); + createCanonicalSession = vi.fn(async () => { + sqlite.prepare("INSERT INTO sessions VALUES ('restored-canonical', 'bot', 'active', 10)").run(); + sqlite.prepare( + "INSERT INTO bot_session_links VALUES ('link-restored', 'bot-1', 'restored-canonical', 1, 'canonical', NULL, NULL, 10, NULL)", + ).run(); + sqlite.prepare( + "UPDATE bot_profiles SET canonical_session_id = 'restored-canonical', updated_at = 10 WHERE id = 'bot-1'", + ).run(); + return { canonicalSessionId: 'restored-canonical' }; + }); + deleteProfileAndDetachSessions = vi.fn(async ( + botId: string, + sessionIds: string[], + keepTaskHistory: boolean, + ) => { + const status = keepTaskHistory ? 'archived' : 'deleted'; + for (const sessionId of sessionIds) { + sqlite.prepare('UPDATE sessions SET source = ?, status = ? WHERE id = ?') + .run('desktop', status, sessionId); + } + sqlite.prepare('DELETE FROM bot_profiles WHERE id = ?').run(botId); + }); + }); + + function service() { + return createBotLifecycleService({ + maker: { closeSession } as unknown as Maker, + getDelegationService: () => ({ cancelDelegationsForBot } as never), + getOutboxService: () => ({ suspendForBot, resumeForBot, cancelForBot } as never), + pauseSchedule, + resumeSchedule, + retainWorktrees, + releaseWorktrees, + createCanonicalSession, + deleteProfileAndDetachSessions, + now: () => 10, + }); + } + + it('pauses only active resources and preserves user-paused state', async () => { + const result = await service().run({ botId: 'bot-1', action: 'pause' }); + + expect(row(sqlite, 'bot_profiles', 'bot-1').status).toBe('paused'); + expect(row(sqlite, 'bot_routes', 'route-active')).toMatchObject({ + status: 'paused', + suspended_status: 'active', + owner_generation: 4, + }); + expect(row(sqlite, 'bot_routes', 'route-user-paused')).toMatchObject({ + status: 'paused', + suspended_status: null, + }); + expect(row(sqlite, 'bot_automation_links', 'auto-active')).toMatchObject({ + status: 'paused', + suspended_status: 'active', + }); + expect(row(sqlite, 'bot_automation_links', 'auto-user-paused')).toMatchObject({ + status: 'paused', + suspended_status: null, + }); + expect(pauseSchedule).toHaveBeenCalledTimes(1); + expect(pauseSchedule).toHaveBeenCalledWith('schedule-active'); + expect(cancelDelegationsForBot).toHaveBeenCalledWith('bot-1', expect.any(String)); + expect(suspendForBot).toHaveBeenCalledWith('bot-1'); + expect(closeSession).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ + status: 'paused', + affected: { routes: 1, automations: 1, delegations: 2, deliveries: 3, sessions: 2 }, + }); + }); + + it('restores only resources suspended by the Bot lifecycle', async () => { + const lifecycle = service(); + await lifecycle.run({ botId: 'bot-1', action: 'pause' }); + const result = await lifecycle.run({ botId: 'bot-1', action: 'resume' }); + + expect(row(sqlite, 'bot_profiles', 'bot-1').status).toBe('active'); + expect(row(sqlite, 'bot_routes', 'route-active')).toMatchObject({ + status: 'active', + suspended_status: null, + }); + expect(row(sqlite, 'bot_routes', 'route-user-paused')).toMatchObject({ + status: 'paused', + suspended_status: null, + }); + expect(row(sqlite, 'bot_automation_links', 'auto-active')).toMatchObject({ + status: 'active', + suspended_status: null, + }); + expect(row(sqlite, 'bot_automation_links', 'auto-user-paused')).toMatchObject({ + status: 'paused', + suspended_status: null, + }); + expect(resumeSchedule).toHaveBeenCalledTimes(1); + expect(resumeSchedule).toHaveBeenCalledWith('schedule-active'); + expect(resumeForBot).toHaveBeenCalledWith('bot-1'); + expect(result.status).toBe('active'); + }); + + it('fails closed when a suspended schedule cannot resume', async () => { + const lifecycle = service(); + await lifecycle.run({ botId: 'bot-1', action: 'pause' }); + resumeSchedule.mockRejectedValueOnce(new Error('scheduler offline')); + + await expect(lifecycle.run({ botId: 'bot-1', action: 'resume' })).rejects.toThrow( + 'Bot 仍保持暂停', + ); + expect(row(sqlite, 'bot_profiles', 'bot-1').status).toBe('paused'); + expect(row(sqlite, 'bot_automation_links', 'auto-active')).toMatchObject({ + status: 'paused', + suspended_status: 'active', + }); + expect(resumeForBot).not.toHaveBeenCalled(); + }); + + it('fails closed before closing tasks or archiving when an active Automation cannot stop', async () => { + pauseSchedule.mockRejectedValueOnce(new Error('scheduler did not acknowledge abort')); + const lifecycle = service(); + + await expect(lifecycle.run({ botId: 'bot-1', action: 'archive' })).rejects.toThrow( + '无法安全停止', + ); + + expect(row(sqlite, 'bot_profiles', 'bot-1').status).toBe('paused'); + expect(row(sqlite, 'bot_automation_links', 'auto-active')).toMatchObject({ + status: 'paused', + suspended_status: 'active', + }); + expect(row(sqlite, 'sessions', 'canonical').status).toBe('active'); + expect(closeSession).not.toHaveBeenCalled(); + expect(cancelDelegationsForBot).not.toHaveBeenCalled(); + expect(cancelForBot).not.toHaveBeenCalled(); + expect(retainWorktrees).not.toHaveBeenCalled(); + expect(releaseWorktrees).not.toHaveBeenCalled(); + + pauseSchedule.mockResolvedValueOnce(undefined); + await expect(lifecycle.run({ botId: 'bot-1', action: 'archive' })).resolves.toMatchObject({ + status: 'archived', + }); + }); + + it('coalesces simultaneous lifecycle actions for one Bot', async () => { + let release!: () => void; + pauseSchedule.mockImplementationOnce(() => new Promise((resolve) => { release = resolve; })); + const lifecycle = service(); + const first = lifecycle.run({ botId: 'bot-1', action: 'pause' }); + const second = lifecycle.run({ botId: 'bot-1', action: 'pause' }); + await vi.waitFor(() => expect(pauseSchedule).toHaveBeenCalledTimes(1)); + release(); + const [a, b] = await Promise.all([first, second]); + expect(a).toEqual(b); + expect(pauseSchedule).toHaveBeenCalledTimes(1); + }); + + it('queues a different lifecycle action instead of losing it behind pause', async () => { + let release!: () => void; + pauseSchedule.mockImplementationOnce(() => new Promise((resolve) => { release = resolve; })); + const lifecycle = service(); + const pausing = lifecycle.run({ botId: 'bot-1', action: 'pause' }); + const resuming = lifecycle.run({ botId: 'bot-1', action: 'resume' }); + await vi.waitFor(() => expect(pauseSchedule).toHaveBeenCalledTimes(1)); + expect(resumeSchedule).not.toHaveBeenCalled(); + release(); + await pausing; + await resuming; + expect(resumeSchedule).toHaveBeenCalledWith('schedule-active'); + expect(row(sqlite, 'bot_profiles', 'bot-1').status).toBe('active'); + }); + + it('archives every Bot task and retains worktrees by default', async () => { + const result = await service().run({ botId: 'bot-1', action: 'archive' }); + + expect(row(sqlite, 'bot_profiles', 'bot-1')).toMatchObject({ + status: 'archived', + canonical_session_id: null, + }); + expect(row(sqlite, 'sessions', 'canonical').status).toBe('archived'); + expect(row(sqlite, 'sessions', 'route-session').status).toBe('archived'); + expect(row(sqlite, 'bot_session_links', 'link-canonical').role).toBe('history'); + expect(row(sqlite, 'bot_routes', 'route-active')).toMatchObject({ + status: 'paused', + suspended_status: 'active', + }); + expect(row(sqlite, 'bot_automation_links', 'auto-active')).toMatchObject({ + status: 'paused', + suspended_status: 'active', + }); + expect(cancelForBot).toHaveBeenCalledWith('bot-1', 'Bot archived'); + expect(retainWorktrees).toHaveBeenCalledWith('bot-1'); + expect(releaseWorktrees).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + status: 'archived', + affected: { sessions: 2, routes: 2, automations: 2, deliveries: 4, worktrees: 2 }, + }); + }); + + it('keeps the Bot archived when worktree recycling is safely refused', async () => { + releaseWorktrees.mockRejectedValueOnce(new Error('worktree is dirty')); + const result = await service().run({ + botId: 'bot-1', + action: 'archive', + worktreeDisposition: 'recycle', + }); + + expect(row(sqlite, 'bot_profiles', 'bot-1').status).toBe('archived'); + expect(result.warnings?.[0]).toContain('WORKTREE_DISPOSITION_FAILED'); + expect(retainWorktrees).not.toHaveBeenCalled(); + }); + + it('restores into a fresh canonical task without reviving archived history', async () => { + const lifecycle = service(); + await lifecycle.run({ botId: 'bot-1', action: 'archive' }); + const result = await lifecycle.run({ botId: 'bot-1', action: 'restore' }); + + expect(createCanonicalSession).toHaveBeenCalledWith({ + botId: 'bot-1', + expectedCanonicalSessionId: null, + expectedProfileVersion: 1, + }); + expect(row(sqlite, 'bot_profiles', 'bot-1')).toMatchObject({ + status: 'active', + canonical_session_id: 'restored-canonical', + }); + expect(row(sqlite, 'sessions', 'canonical').status).toBe('archived'); + expect(row(sqlite, 'sessions', 'route-session').status).toBe('archived'); + expect(row(sqlite, 'sessions', 'restored-canonical').status).toBe('active'); + expect(row(sqlite, 'bot_routes', 'route-active')).toMatchObject({ + status: 'active', + suspended_status: null, + }); + expect(row(sqlite, 'bot_routes', 'route-user-paused')).toMatchObject({ + status: 'paused', + suspended_status: null, + }); + expect(result).toMatchObject({ + action: 'restore', + status: 'active', + canonicalSessionId: 'restored-canonical', + }); + }); + + it('requires an exact Bot name before permanent deletion', async () => { + await expect(service().run({ + botId: 'bot-1', + action: 'delete', + confirmName: 'helper', + })).rejects.toThrow('完整 Bot 名称'); + expect(row(sqlite, 'bot_profiles', 'bot-1').status).toBe('active'); + expect(deleteProfileAndDetachSessions).not.toHaveBeenCalled(); + }); + + it('keeps transcripts as ordinary archived tasks when deleting a Bot', async () => { + const result = await service().run({ + botId: 'bot-1', + action: 'delete', + confirmName: 'Helper', + keepTaskHistory: true, + worktreeDisposition: 'retain', + }); + + expect( + sqlite.prepare("SELECT id FROM bot_profiles WHERE id = 'bot-1'").get(), + ).toBeUndefined(); + expect(deleteProfileAndDetachSessions).toHaveBeenCalledWith( + 'bot-1', + ['canonical', 'route-session'], + true, + ); + expect(row(sqlite, 'sessions', 'canonical')).toMatchObject({ + source: 'desktop', + status: 'archived', + }); + expect(result).toMatchObject({ action: 'delete', status: 'deleted' }); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/botProfileRuntime.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/botProfileRuntime.test.ts new file mode 100644 index 0000000000..2c5f0e16e1 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/botProfileRuntime.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildBotProfileContextPrompt, + buildBotProfilePrompt, + resolveBotMcpReferences, + resolveBotSkillReferences, + resolveBotToolsetReferences, +} from '../botProfileRuntime'; +import { buildDefaultBotIdentity } from '../../../shared/botProfileDefaults'; + +describe('Bot Profile runtime prompt', () => { + it('uses the SOUL source verbatim as the complete identity slot', () => { + const prompt = buildBotProfilePrompt({ + displayName: 'Kitchen helper', + identitySource: 'A calm chef who explains recipes clearly.', + }); + expect(prompt).toBe('A calm chef who explains recipes clearly.'); + expect(prompt).not.toContain('Cindy Bot Profile'); + expect(prompt).not.toContain('Profile version'); + expect(prompt).not.toContain('Configured skill'); + expect(prompt).not.toContain('tool/MCP'); + expect(prompt).not.toContain('memory policy'); + expect(prompt).not.toContain('Automation policy'); + }); + + it('seeds a useful Hermes-style identity when the SOUL source is empty', () => { + const prompt = buildBotProfilePrompt({ + displayName: 'Research helper', + identitySource: '', + }); + expect(prompt).toContain('You are Research helper'); + }); + + it('uses the same persisted default SOUL as the runtime fallback', () => { + const soul = buildDefaultBotIdentity('Research helper'); + expect( + buildBotProfilePrompt({ displayName: 'Research helper', identitySource: soul }), + ).toBe(soul); + }); + + it('keeps the active profile marker separate from SOUL', () => { + expect(buildBotProfileContextPrompt('Kitchen helper')).toBe( + 'Active Cindy Bot profile: Kitchen helper.', + ); + }); + + it('admits only Skills proven by the selected harness catalog', () => { + expect( + resolveBotSkillReferences( + ['recipe-planner', 'missing', 'broken'], + [ + { name: 'recipe-planner', runtimeCommandName: 'recipe', enabled: true }, + { name: 'broken', runtimeStatus: 'failed' }, + ], + ), + ).toEqual({ + resolvedSkills: ['recipe'], + unavailableSkills: ['missing', 'broken'], + resolvedSkillEntries: [ + { name: 'recipe-planner', runtimeCommandName: 'recipe', enabled: true }, + ], + }); + }); + + it('keeps builtin MCP outside the custom MCP allowlist', () => { + expect( + resolveBotMcpReferences({ + mode: 'allowlist', + configured: ['search', 'missing', 'cindy_memory'], + catalog: [ + { name: 'search', source: 'custom', available: true }, + { name: 'cindy_memory', source: 'builtin', available: true }, + ], + }), + ).toEqual({ + resolved: ['search'], + unavailable: ['missing', 'cindy_memory'], + }); + }); + + it('combines Bot toolset policy with project availability', () => { + expect( + resolveBotToolsetReferences({ + mode: 'allowlist', + configured: ['browser', 'contacts', 'missing'], + catalog: [ + { id: 'core', name: 'Core', essential: true, available: true }, + { id: 'browser', name: 'Browser', available: true }, + { id: 'contacts', name: 'Contacts', available: false }, + { id: 'calendar', name: 'Calendar', available: true }, + ], + }), + ).toEqual({ + resolved: ['browser'], + unavailable: ['contacts', 'missing'], + disabled: ['contacts', 'calendar'], + }); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/botRemoteWorkspaceService.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/botRemoteWorkspaceService.test.ts new file mode 100644 index 0000000000..ff09548171 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/botRemoteWorkspaceService.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const remote = vi.hoisted(() => ({ + ensureReady: vi.fn(async () => undefined), + exec: vi.fn(), +})); + +vi.mock('../../remote-ssh/index.js', () => ({ + ensureRemoteHostReady: remote.ensureReady, + getRemoteSshPool: () => ({ + get: () => ({ + getStatus: () => 'ready', + exec: remote.exec, + }), + }), +})); + +import { + inspectRemoteBotWorktree, + removeRemoteBotWorktree, +} from '../botRemoteWorkspaceService.js'; + +const input = { + remoteHostId: 'remote-1', + baseRepo: '/repo', + worktreePath: '/repo/.cindy-worktrees/bot-1', + branch: 'cindy/bot-bot-1', +}; + +describe('remote Bot workspace ownership', () => { + beforeEach(() => { + remote.ensureReady.mockClear(); + remote.exec.mockReset(); + }); + + it('treats only an absent path as a missing worktree', async () => { + remote.exec.mockResolvedValue({ exitCode: 44, stdout: '', stderr: '', truncated: false }); + + await expect(inspectRemoteBotWorktree(input)).resolves.toEqual({ exists: false }); + }); + + it('does not treat a replaced path as safely released', async () => { + remote.exec.mockResolvedValue({ + exitCode: 45, + stdout: '', + stderr: 'remote path is not a directory', + truncated: false, + }); + + await expect(inspectRemoteBotWorktree(input)).rejects.toThrow( + 'remote path is not a directory', + ); + }); + + it('makes a repeated remote removal safe after an ambiguous successful delete', async () => { + remote.exec.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '', truncated: false }); + + await expect(removeRemoteBotWorktree(input)).resolves.toBeUndefined(); + const options = remote.exec.mock.calls[0]?.[1] as { input?: string } | undefined; + expect(options?.input).toContain('[ ! -e "$worktree_path" ] && exit 0'); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/botSessionInputGuard.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/botSessionInputGuard.test.ts new file mode 100644 index 0000000000..934ac45f8d --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/botSessionInputGuard.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { botSessionInputBlockReason } from '../botSessionInputGuard.js'; + +describe('Bot task input guard', () => { + it('does not affect ordinary Cindy tasks', () => { + expect( + botSessionInputBlockReason({ source: 'desktop', role: null, profileStatus: null }), + ).toBeNull(); + }); + + it('blocks a canonical task while its Bot is paused', () => { + expect( + botSessionInputBlockReason({ source: 'bot', role: 'canonical', profileStatus: 'paused' }), + ).toContain('未启用'); + }); + + it('keeps archived history read-only even after the Bot resumes', () => { + expect( + botSessionInputBlockReason({ source: 'bot', role: 'history', profileStatus: 'active' }), + ).toContain('只读'); + }); + + it('fails closed when a Bot task loses its ownership link', () => { + expect( + botSessionInputBlockReason({ source: 'bot', role: null, profileStatus: null }), + ).toContain('归属信息不完整'); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/makerSendTransaction.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/makerSendTransaction.test.ts index 3a6bfe1b2b..eb44d81547 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/makerSendTransaction.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/makerSendTransaction.test.ts @@ -1421,7 +1421,7 @@ describe('session-agent-switch handoff injection', () => { it('计划对账段命中时前置进 wire payload,落库内容保持用户原文', async () => { const { deps, session } = createDeps({ - peekPlanReconcileNote: vi.fn(async () => 'RECONCILE-NOTE'), + peekPlanReconcileNote: vi.fn(async () => ({ note: 'RECONCILE-NOTE' })), }); const transaction = createMakerSendTransaction(deps); @@ -1441,7 +1441,7 @@ describe('session-agent-switch handoff injection', () => { const { deps, session } = createDeps({ peekPendingHandoff: vi.fn(async () => 'HANDOFF-TEXT'), consumePendingHandoff: vi.fn(), - peekPlanReconcileNote: vi.fn(async () => 'RECONCILE-NOTE'), + peekPlanReconcileNote: vi.fn(async () => ({ note: 'RECONCILE-NOTE' })), }); const transaction = createMakerSendTransaction(deps); @@ -1455,7 +1455,7 @@ describe('session-agent-switch handoff injection', () => { }); it('内部派发(scheduler / 自动续跑)不注入对账', async () => { - const peekPlanReconcileNote = vi.fn(async () => 'RECONCILE-NOTE'); + const peekPlanReconcileNote = vi.fn(async () => ({ note: 'RECONCILE-NOTE' })); const { deps, session } = createDeps({ peekPlanReconcileNote }); const transaction = createMakerSendTransaction(deps); @@ -1507,7 +1507,7 @@ describe('session-agent-switch handoff injection', () => { }); it('按信封里的 slash 范围区分控制指令与绝对路径开头的真实提问', async () => { - const peekPlanReconcileNote = vi.fn(async () => 'RECONCILE-NOTE'); + const peekPlanReconcileNote = vi.fn(async () => ({ note: 'RECONCILE-NOTE' })); const { deps, session } = createDeps({ peekPlanReconcileNote }); const transaction = createMakerSendTransaction(deps); @@ -1565,7 +1565,7 @@ describe('session-agent-switch handoff injection', () => { }); it('计划对账覆盖仅附件轮次(正文空,带图片/文件)', async () => { - const peekPlanReconcileNote = vi.fn(async () => 'RECONCILE-NOTE'); + const peekPlanReconcileNote = vi.fn(async () => ({ note: 'RECONCILE-NOTE' })); const { deps, session } = createDeps({ peekPlanReconcileNote }); const transaction = createMakerSendTransaction(deps); @@ -1596,6 +1596,72 @@ describe('session-agent-switch handoff injection', () => { expect(session.send).toHaveBeenCalledWith({ type: 'user', content: '新消息' }, expect.anything()); }); + it('仅在 sealed 保护已被 vendor accepted 后消费', async () => { + const consumeSealedPlanReconcileNote = vi.fn(async () => undefined); + const { deps } = createDeps({ + peekPlanReconcileNote: vi.fn(async () => ({ + note: 'COMPLETED-GUARD', + sealedTurnId: 'turn-sealed', + })), + consumeSealedPlanReconcileNote, + }); + const transaction = createMakerSendTransaction(deps); + + await transaction.sendToAgentAccepted('session-1', { type: 'user', content: '继续' }, undefined, { + persistUserMessage: { clientId: 'guard-accepted', content: '继续' }, + }); + + expect(consumeSealedPlanReconcileNote).toHaveBeenCalledWith('session-1', 'turn-sealed'); + }); + + it('vendor 未 accepted 时保留 sealed 保护供重试', async () => { + const consumeSealedPlanReconcileNote = vi.fn(async () => undefined); + const session = createSession({ + send: vi.fn(async () => ({ accepted: false, reason: 'cancelled-before-dispatch' }) as SessionSendResult), + }); + const { deps } = createDeps({ + getSession: vi.fn(() => session), + peekPlanReconcileNote: vi.fn(async () => ({ + note: 'COMPLETED-GUARD', + sealedTurnId: 'turn-sealed', + })), + consumeSealedPlanReconcileNote, + }); + const transaction = createMakerSendTransaction(deps); + + await transaction.sendToAgentAccepted('session-1', { type: 'user', content: '继续' }, undefined, { + persistUserMessage: { clientId: 'guard-rejected', content: '继续' }, + }); + + expect(consumeSealedPlanReconcileNote).not.toHaveBeenCalled(); + }); + + it('vendor 抛错时保留 sealed 保护供重试', async () => { + const consumeSealedPlanReconcileNote = vi.fn(async () => undefined); + const session = createSession({ + send: vi.fn(async () => { + throw new Error('vendor unavailable'); + }), + }); + const { deps } = createDeps({ + getSession: vi.fn(() => session), + peekPlanReconcileNote: vi.fn(async () => ({ + note: 'COMPLETED-GUARD', + sealedTurnId: 'turn-sealed', + })), + consumeSealedPlanReconcileNote, + }); + const transaction = createMakerSendTransaction(deps); + + await expect( + transaction.sendToAgentAccepted('session-1', { type: 'user', content: '继续' }, undefined, { + persistUserMessage: { clientId: 'guard-error', content: '继续' }, + }), + ).rejects.toThrow('vendor unavailable'); + + expect(consumeSealedPlanReconcileNote).not.toHaveBeenCalled(); + }); + it('lazy-create 前调用 reconcileCreateOptsWithDb 以 DB 行校正 createOpts', async () => { const reconcile = vi.fn(async (_sessionId: string, co: MakerSessionCreateOpts) => { co.agentKind = 'codex'; diff --git a/apps/desktop/src/main/maker-ipc/__tests__/planReconcile.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/planReconcile.test.ts index d81ead3354..83cf22d9e7 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/planReconcile.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/planReconcile.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + buildCompletedPlanGuardNote, buildPlanReconcileNote, summarizeOpenPlan, type PlanReconcileCandidateRow, @@ -203,3 +204,14 @@ describe('buildPlanReconcileNote', () => { expect(note).toContain('另有 2 项未列出'); }); }); + +describe('buildCompletedPlanGuardNote', () => { + it('prevents an ambiguous continue from reopening a completed plan', () => { + const note = buildCompletedPlanGuardNote(); + + expect(note).toContain('已全部完成并收口'); + expect(note).toContain('不要因为用户只说“继续”'); + expect(note).toContain('没有未完成计划'); + expect(note).toContain('以下是用户的新消息'); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/scheduleReadiness.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/scheduleReadiness.test.ts index 6ea69d4143..59dec891ed 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/scheduleReadiness.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/scheduleReadiness.test.ts @@ -59,7 +59,7 @@ import { // Minimal stand-ins — holder 只持有引用并 resolve 出去,不调任何方法。 const scheduler1 = { id: 'scheduler-1' } as never; -const storage1 = { id: 'storage-1' } as never; +const storage1 = { id: 'storage-1', get: vi.fn(async () => ({ source: 'user' })) } as never; const scheduler2 = { id: 'scheduler-2' } as never; const storage2 = { id: 'storage-2' } as never; @@ -69,6 +69,7 @@ beforeEach(() => { h.getAllWindows.mockClear(); h.tapWindowBroadcast.mockClear(); h.handleScheduleEvent.mockClear(); + (storage1 as unknown as { get: ReturnType }).get.mockResolvedValue({ source: 'user' }); }); afterEach(() => { @@ -224,7 +225,7 @@ describe('scheduler readiness holder', () => { expect(r.scheduler).toBe(scheduler2); }); - it('broadcasts scheduler events before isolated Agent Island updates', () => { + it('broadcasts scheduler events before isolated Agent Island updates', async () => { const handlers = new Map void>(); const scheduler = { on: vi.fn((name: string, handler: (event: never) => void) => { @@ -243,6 +244,8 @@ describe('scheduler readiness holder', () => { const event = { type: 'completed', scheduleId: 'schedule-1', runId: 'run-1' } as never; expect(() => handlers.get('completed')!(event)).not.toThrow(); + await vi.waitFor(() => expect(h.tapWindowBroadcast).toHaveBeenCalled()); + expect(h.tapWindowBroadcast).toHaveBeenCalledWith('maker:schedule:event', event); expect(h.webContentsSend).toHaveBeenCalledWith('maker:schedule:event', event); expect(h.handleScheduleEvent).toHaveBeenCalledWith(event); @@ -250,4 +253,30 @@ describe('scheduler readiness holder', () => { h.handleScheduleEvent.mock.invocationCallOrder[0], ); }); + + it('does not project Bot automation events into the generic Scheduler surface', async () => { + const handlers = new Map void>(); + const scheduler = { + on: vi.fn((name: string, handler: (event: never) => void) => { + handlers.set(name, handler); + }), + } as never; + (storage1 as unknown as { get: ReturnType }).get.mockResolvedValue({ source: 'bot' }); + + attachSchedulerEventListeners(scheduler, storage1); + h.webContentsSend.mockClear(); + h.tapWindowBroadcast.mockClear(); + h.handleScheduleEvent.mockClear(); + + handlers.get('completed')!({ + type: 'completed', + scheduleId: 'bot-schedule-1', + runId: 'run-1', + } as never); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(h.tapWindowBroadcast).not.toHaveBeenCalled(); + expect(h.webContentsSend).not.toHaveBeenCalled(); + expect(h.handleScheduleEvent).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/main/maker-ipc/bot-automation.ts b/apps/desktop/src/main/maker-ipc/bot-automation.ts new file mode 100644 index 0000000000..6cd2031843 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/bot-automation.ts @@ -0,0 +1,792 @@ +import { randomUUID } from 'node:crypto'; + +import { BrowserWindow, ipcMain } from 'electron'; +import { and, desc, eq, inArray } from 'drizzle-orm'; +import type { AgentKind } from '@cindy/maker-core'; +import type { + CreateScheduleInput, + PreRunHookConfig, + Schedule, + UpdateScheduleInput, +} from '@cindy/maker-scheduler'; + +import type { + BotAutomation, + BotAutomationDeliveryStatus, + BotAutomationRun, + CreateBotAutomationInput, + UpdateBotAutomationInput, +} from '../../shared/botAutomation.js'; +import { + normalizeBotAutomationExecutionPolicy, + parseBotAutomationExecutionPlan, +} from '../../shared/botAutomation.js'; +import { tapWindowBroadcast } from '../device-link/broadcast-tap.js'; +import { createLogger } from '../logger.js'; +import { getDbClient } from '../localDb/client/current.js'; +import { + botAutomationLinks, + botAutomationRuns, + botDeliveryOutbox, + botProfileVersions, + botProfiles, + botProjectBindings, + botRoutes, + botWorkspaceLeases, + scheduleRuns, + schedules, +} from '../localDb/schema.js'; +import { assertTrustedAppRendererEvent } from '../security/trustedAppRenderer.js'; +import { requireObject, requireString, throwIpcError } from '../utils/ipcValidate.js'; +import { MAKER_INVOKE, MAKER_PUSH } from './channels.js'; +import { awaitReadyWithTimeout } from './schedule.js'; +import type { EnqueueBotDeliveryInput } from './botDeliveryOutboxService.js'; +import { withBotAutomationMutationLock } from './botAutomationMutationLock.js'; +import { parseBotOutputArtifacts } from '../../shared/botOutputArtifact.js'; +import { parseBotDeliveryDiagnostic } from '../../shared/botDeliveryDiagnostic.js'; + +const log = createLogger('maker-ipc:bot-automation'); +const MAX_TEXT = 12_000; + +function broadcast(payload: { botId: string; automationId?: string; runId?: string }): void { + tapWindowBroadcast(MAKER_PUSH.BOT_AUTOMATION_CHANGED, payload); + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed()) continue; + try { + win.webContents.send(MAKER_PUSH.BOT_AUTOMATION_CHANGED, payload); + } catch (error) { + log.warn('Bot automation broadcast failed', { error: String(error) }); + } + } +} + +function readOptionalString(value: unknown, field: string, max = MAX_TEXT): string | undefined { + if (value === undefined || value === null || value === '') return undefined; + if (typeof value !== 'string') throwIpcError('INVALID_PARAMS', `${field} must be a string`); + const text = value.trim(); + if (!text) return undefined; + if (text.length > max) throwIpcError('INVALID_PARAMS', `${field} is too long`); + return text; +} + +function readBoolean(value: unknown, field: string, fallback: boolean): boolean { + if (value === undefined) return fallback; + if (typeof value !== 'boolean') throwIpcError('INVALID_PARAMS', `${field} must be boolean`); + return value; +} + +function readInterval(value: unknown): number | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== 'number' || !Number.isFinite(value) || value < 60_000) { + throwIpcError('INVALID_PARAMS', 'intervalMs must be at least 60000'); + } + return Math.floor(value); +} + +function readPreRunHook(value: unknown): PreRunHookConfig | undefined { + if (value === undefined || value === null) return undefined; + const body = requireObject(value, 'preRunHook'); + const command = requireString(body.command, 'preRunHook.command').trim(); + if (!command || command.length > 8_000) { + throwIpcError('INVALID_PARAMS', 'preRunHook.command is invalid'); + } + const timeoutMs = body.timeoutMs; + if ( + timeoutMs !== undefined + && (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) + ) { + throwIpcError('INVALID_PARAMS', 'preRunHook.timeoutMs must be a positive number'); + } + return { command, ...(timeoutMs ? { timeoutMs: Math.floor(timeoutMs) } : {}) }; +} + +function parseConfig(value: string): Record { + try { + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + return {}; + } +} + +function agentKindFor(config: Record): AgentKind { + return config.harness === 'codex' + ? 'codex' + : config.harness === 'pi' + ? 'pi' + : 'claude-code'; +} + +async function readBotAutomationPolicy(botId: string): Promise<{ + profileVersion: number; + agentKind: AgentKind; +}> { + const db = getDbClient().drizzle; + const [profile] = await db + .select() + .from(botProfiles) + .where(eq(botProfiles.id, botId)) + .limit(1); + if (!profile || profile.status !== 'active') throwIpcError('NOT_FOUND', 'Bot is unavailable'); + const [version] = await db + .select() + .from(botProfileVersions) + .where( + and( + eq(botProfileVersions.botId, botId), + eq(botProfileVersions.version, profile.currentVersion), + ), + ) + .limit(1); + if (!version) throwIpcError('INTERNAL', 'Bot Profile version is unavailable'); + const config = parseConfig(version.capabilitiesJson); + if (config.automation !== true) { + throwIpcError('INVALID_PARAMS', 'Enable automation in the Bot Profile first'); + } + if (config.permissions !== 'trusted') { + throwIpcError('INVALID_PARAMS', 'Bot automations require trusted operations'); + } + return { profileVersion: profile.currentVersion, agentKind: agentKindFor(config) }; +} + +async function validateTargets(input: { + botId: string; + projectBindingId?: string; + targetRouteId?: string; +}): Promise { + const db = getDbClient().drizzle; + if (input.projectBindingId) { + const [binding] = await db + .select({ id: botProjectBindings.id }) + .from(botProjectBindings) + .where( + and( + eq(botProjectBindings.id, input.projectBindingId), + eq(botProjectBindings.botId, input.botId), + eq(botProjectBindings.status, 'active'), + ), + ) + .limit(1); + if (!binding) throwIpcError('INVALID_PARAMS', 'Project binding does not belong to this Bot'); + } + if (input.targetRouteId) { + const [route] = await db + .select({ id: botRoutes.id }) + .from(botRoutes) + .where(and(eq(botRoutes.id, input.targetRouteId), eq(botRoutes.botId, input.botId))) + .limit(1); + if (!route) throwIpcError('INVALID_PARAMS', 'Delivery route does not belong to this Bot'); + } +} + +function automationFromRows( + link: typeof botAutomationLinks.$inferSelect, + schedule: typeof schedules.$inferSelect | null, + activeRunCount: number, +): BotAutomation { + return { + id: link.id, + botId: link.botId, + scheduleId: schedule?.id ?? undefined, + name: schedule?.name ?? 'Archived automation', + prompt: schedule?.prompt ?? '', + cronExpr: schedule?.cronExpr ?? '0 0 * * *', + timezone: schedule?.timezone ?? 'UTC', + recurring: schedule?.recurring ?? false, + manual: schedule?.manual ?? true, + intervalMs: schedule?.intervalMs ?? undefined, + preRunHook: schedule?.preRunHookCommand + ? { + command: schedule.preRunHookCommand, + timeoutMs: schedule.preRunHookTimeoutMs ?? undefined, + } + : undefined, + projectBindingId: link.projectBindingId ?? undefined, + targetRouteId: link.targetRouteId ?? undefined, + durableNoteNamespace: link.durableNoteNamespace ?? undefined, + executionPolicy: normalizeBotAutomationExecutionPolicy(parseConfig(link.executionPolicyJson)), + createdWithProfileVersion: link.createdWithProfileVersion, + status: link.status, + scheduleStatus: schedule?.status ?? undefined, + nextFireAt: schedule?.nextFireAt ?? undefined, + lastFiredAt: schedule?.lastFiredAt ?? undefined, + lastFinishedAt: schedule?.lastFinishedAt ?? undefined, + createdAt: link.createdAt, + updatedAt: Math.max(link.updatedAt, schedule?.updatedAt ?? 0), + activeRunCount, + }; +} + +async function readAutomation(automationId: string): Promise<{ + link: typeof botAutomationLinks.$inferSelect; + schedule: Schedule | null; +}> { + const db = getDbClient().drizzle; + const [link] = await db + .select() + .from(botAutomationLinks) + .where(eq(botAutomationLinks.id, automationId)) + .limit(1); + if (!link) throwIpcError('NOT_FOUND', 'Bot automation not found'); + const { scheduler } = await awaitReadyWithTimeout(); + const schedule = link.scheduleId ? await scheduler.get(link.scheduleId) : null; + return { link, schedule }; +} + +function schedulePatchFromInput(input: UpdateBotAutomationInput): UpdateScheduleInput { + const patch: UpdateScheduleInput = {}; + if (input.name !== undefined) patch.name = requireString(input.name, 'name').trim(); + if (input.prompt !== undefined) patch.prompt = requireString(input.prompt, 'prompt').trim(); + if (input.cronExpr !== undefined) patch.cronExpr = requireString(input.cronExpr, 'cronExpr').trim(); + if (input.timezone !== undefined) patch.timezone = requireString(input.timezone, 'timezone').trim(); + if (input.recurring !== undefined) patch.recurring = readBoolean(input.recurring, 'recurring', true); + if (input.manual !== undefined) patch.manual = readBoolean(input.manual, 'manual', false); + if (Object.prototype.hasOwnProperty.call(input, 'intervalMs')) { + patch.intervalMs = readInterval(input.intervalMs); + } + if (Object.prototype.hasOwnProperty.call(input, 'preRunHook')) { + patch.preRunHook = readPreRunHook(input.preRunHook); + } + return patch; +} + +function previousSchedulePatch(schedule: Schedule): UpdateScheduleInput { + return { + name: schedule.name, + prompt: schedule.prompt, + cronExpr: schedule.cronExpr, + timezone: schedule.timezone, + recurring: schedule.recurring, + manual: schedule.manual, + intervalMs: schedule.intervalMs, + preRunHook: schedule.preRunHook, + }; +} + +function deliveryStatus( + run: typeof botAutomationRuns.$inferSelect, + outbox: typeof botDeliveryOutbox.$inferSelect | null, +): BotAutomationDeliveryStatus { + if (outbox) return outbox.status; + return run.deliveryStatus === 'queued' ? 'pending' : run.deliveryStatus; +} + +export interface BotAutomationHandlerDeps { + enqueueDelivery: (input: EnqueueBotDeliveryInput) => Promise<{ id: string }>; + retryDelivery: ( + id: string, + botId: string, + opts?: { allowDuplicateRisk?: boolean }, + ) => Promise<{ id: string }>; +} + +export function registerBotAutomationHandlers(deps: BotAutomationHandlerDeps): void { + ipcMain.handle(MAKER_INVOKE.BOT_AUTOMATIONS_LIST, async (event, rawBotId: unknown) => { + assertTrustedAppRendererEvent(event); + const botId = requireString(rawBotId, 'botId'); + const db = getDbClient().drizzle; + const links = await db + .select() + .from(botAutomationLinks) + .where(eq(botAutomationLinks.botId, botId)) + .orderBy(desc(botAutomationLinks.updatedAt)); + if (links.length === 0) return []; + const scheduleIds = links.flatMap((link) => link.scheduleId ? [link.scheduleId] : []); + const scheduleRows = scheduleIds.length + ? await db.select().from(schedules).where(inArray(schedules.id, scheduleIds)) + : []; + const activeRuns = await db + .select({ automationLinkId: botAutomationRuns.automationLinkId }) + .from(botAutomationRuns) + .where(inArray(botAutomationRuns.status, ['claimed', 'running', 'completing'])); + const counts = new Map(); + for (const run of activeRuns) counts.set(run.automationLinkId, (counts.get(run.automationLinkId) ?? 0) + 1); + const bySchedule = new Map(scheduleRows.map((row) => [row.id, row])); + return links.map((link) => automationFromRows( + link, + link.scheduleId ? bySchedule.get(link.scheduleId) ?? null : null, + counts.get(link.id) ?? 0, + )); + }); + + ipcMain.handle(MAKER_INVOKE.BOT_AUTOMATION_CREATE, async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = requireObject(raw, 'input') as unknown as CreateBotAutomationInput; + const botId = requireString(body.botId, 'botId'); + const name = requireString(body.name, 'name').trim(); + const prompt = requireString(body.prompt, 'prompt').trim(); + const cronExpr = requireString(body.cronExpr, 'cronExpr').trim(); + const timezone = requireString(body.timezone, 'timezone').trim(); + if (!name || !prompt || !cronExpr || !timezone) { + throwIpcError('INVALID_PARAMS', 'name, prompt, cronExpr and timezone are required'); + } + const projectBindingId = readOptionalString(body.projectBindingId, 'projectBindingId', 128); + const targetRouteId = readOptionalString(body.targetRouteId, 'targetRouteId', 128); + const durableNoteNamespace = readOptionalString( + body.durableNoteNamespace, + 'durableNoteNamespace', + 256, + ); + const executionPolicy = normalizeBotAutomationExecutionPolicy(body.executionPolicy); + const policy = await readBotAutomationPolicy(botId); + await validateTargets({ botId, projectBindingId, targetRouteId }); + const { scheduler } = await awaitReadyWithTimeout(); + const createInput: CreateScheduleInput & { source: 'bot' } = { + source: 'bot', + name, + prompt, + kind: 'cron', + cronExpr, + timezone, + recurring: readBoolean(body.recurring, 'recurring', true), + manual: readBoolean(body.manual, 'manual', false), + intervalMs: readInterval(body.intervalMs), + agentKind: policy.agentKind, + workspaceKind: projectBindingId ? 'project' : 'dialogue', + useWorktree: false, + persistentSession: false, + silentWhenIdle: true, + executionMode: 'agent', + preRunHook: readPreRunHook(body.preRunHook), + notify: { desktop: false, feishu: false, wecomGroup: false }, + }; + const schedule = await scheduler.create(createInput); + const now = Date.now(); + const automationId = randomUUID(); + try { + await getDbClient().drizzle.insert(botAutomationLinks).values({ + id: automationId, + botId, + scheduleId: schedule.id, + projectBindingId: projectBindingId ?? null, + targetRouteId: targetRouteId ?? null, + createdWithProfileVersion: policy.profileVersion, + durableNoteNamespace: durableNoteNamespace ?? null, + executionPolicyJson: JSON.stringify(executionPolicy), + status: 'active', + createdAt: now, + updatedAt: now, + }); + } catch (error) { + await scheduler.delete(schedule.id).catch((cleanupError) => { + log.error('Bot automation create compensation failed', { + scheduleId: schedule.id, + error: String(cleanupError), + }); + }); + throw error; + } + broadcast({ botId, automationId }); + const [row] = await getDbClient().drizzle + .select() + .from(botAutomationLinks) + .where(eq(botAutomationLinks.id, automationId)) + .limit(1); + return automationFromRows(row!, await getDbClient().drizzle + .select() + .from(schedules) + .where(eq(schedules.id, schedule.id)) + .limit(1) + .then((rows) => rows[0] ?? null), 0); + }); + + ipcMain.handle( + MAKER_INVOKE.BOT_AUTOMATION_UPDATE, + async (event, rawId: unknown, rawPatch: unknown) => { + assertTrustedAppRendererEvent(event); + const automationId = requireString(rawId, 'automationId'); + const patch = requireObject(rawPatch, 'patch') as unknown as UpdateBotAutomationInput; + const initial = await readAutomation(automationId); + if (!initial.schedule || !initial.link.scheduleId) { + throwIpcError('NOT_FOUND', 'Automation schedule is missing'); + } + return withBotAutomationMutationLock(initial.link.scheduleId, async () => { + const { link, schedule } = await readAutomation(automationId); + if (!schedule || !link.scheduleId) throwIpcError('NOT_FOUND', 'Automation schedule is missing'); + const [activeRun] = await getDbClient().drizzle + .select({ id: botAutomationRuns.id }) + .from(botAutomationRuns) + .where( + and( + eq(botAutomationRuns.automationLinkId, automationId), + inArray(botAutomationRuns.status, ['claimed', 'running', 'completing']), + ), + ) + .limit(1); + if (activeRun) { + throwIpcError('PRECONDITION_FAILED', 'Wait for the active Bot automation run to finish'); + } + await readBotAutomationPolicy(link.botId); + const projectBindingId = Object.prototype.hasOwnProperty.call(patch, 'projectBindingId') + ? readOptionalString(patch.projectBindingId, 'projectBindingId', 128) + : link.projectBindingId ?? undefined; + const targetRouteId = Object.prototype.hasOwnProperty.call(patch, 'targetRouteId') + ? readOptionalString(patch.targetRouteId, 'targetRouteId', 128) + : link.targetRouteId ?? undefined; + await validateTargets({ botId: link.botId, projectBindingId, targetRouteId }); + const durableNoteNamespace = Object.prototype.hasOwnProperty.call(patch, 'durableNoteNamespace') + ? readOptionalString(patch.durableNoteNamespace, 'durableNoteNamespace', 256) + : link.durableNoteNamespace ?? undefined; + const executionPolicy = Object.prototype.hasOwnProperty.call(patch, 'executionPolicy') + ? normalizeBotAutomationExecutionPolicy(patch.executionPolicy) + : normalizeBotAutomationExecutionPolicy(parseConfig(link.executionPolicyJson)); + const schedulePatch = schedulePatchFromInput(patch); + const { scheduler } = await awaitReadyWithTimeout(); + const updatedSchedule = Object.keys(schedulePatch).length > 0 + ? await scheduler.update(link.scheduleId, schedulePatch) + : schedule; + try { + await getDbClient().drizzle + .update(botAutomationLinks) + .set({ + projectBindingId: projectBindingId ?? null, + targetRouteId: targetRouteId ?? null, + durableNoteNamespace: durableNoteNamespace ?? null, + executionPolicyJson: JSON.stringify(executionPolicy), + updatedAt: Date.now(), + }) + .where(eq(botAutomationLinks.id, automationId)); + } catch (error) { + if (Object.keys(schedulePatch).length > 0) { + await scheduler.update(link.scheduleId, previousSchedulePatch(schedule)).catch((rollbackError) => { + log.error('Bot automation update compensation failed', { + automationId, + error: String(rollbackError), + }); + }); + } + throw error; + } + broadcast({ botId: link.botId, automationId }); + const [updatedLink] = await getDbClient().drizzle + .select() + .from(botAutomationLinks) + .where(eq(botAutomationLinks.id, automationId)) + .limit(1); + return automationFromRows(updatedLink!, await getDbClient().drizzle + .select() + .from(schedules) + .where(eq(schedules.id, updatedSchedule.id)) + .limit(1) + .then((rows) => rows[0] ?? null), 0); + }); + }, + ); + + ipcMain.handle(MAKER_INVOKE.BOT_AUTOMATION_PAUSE, async (event, rawId: unknown) => { + assertTrustedAppRendererEvent(event); + const automationId = requireString(rawId, 'automationId'); + const initial = await readAutomation(automationId); + if (!initial.link.scheduleId) throwIpcError('NOT_FOUND', 'Automation schedule is missing'); + return withBotAutomationMutationLock(initial.link.scheduleId, async () => { + const { link } = await readAutomation(automationId); + if (!link.scheduleId) throwIpcError('NOT_FOUND', 'Automation schedule is missing'); + const db = getDbClient().drizzle; + await db.update(botAutomationLinks).set({ status: 'paused', updatedAt: Date.now() }) + .where(eq(botAutomationLinks.id, automationId)); + try { + const { scheduler } = await awaitReadyWithTimeout(); + await scheduler.pause(link.scheduleId); + } catch (error) { + await db.update(botAutomationLinks).set({ status: link.status, updatedAt: Date.now() }) + .where(eq(botAutomationLinks.id, automationId)); + throw error; + } + broadcast({ botId: link.botId, automationId }); + }); + }); + + ipcMain.handle(MAKER_INVOKE.BOT_AUTOMATION_RESUME, async (event, rawId: unknown) => { + assertTrustedAppRendererEvent(event); + const automationId = requireString(rawId, 'automationId'); + const initial = await readAutomation(automationId); + if (!initial.link.scheduleId) throwIpcError('NOT_FOUND', 'Automation schedule is missing'); + return withBotAutomationMutationLock(initial.link.scheduleId, async () => { + const { link } = await readAutomation(automationId); + if (!link.scheduleId) throwIpcError('NOT_FOUND', 'Automation schedule is missing'); + await readBotAutomationPolicy(link.botId); + const db = getDbClient().drizzle; + await db.update(botAutomationLinks).set({ status: 'active', updatedAt: Date.now() }) + .where(eq(botAutomationLinks.id, automationId)); + try { + const { scheduler } = await awaitReadyWithTimeout(); + await scheduler.resume(link.scheduleId); + } catch (error) { + await db.update(botAutomationLinks).set({ status: link.status, updatedAt: Date.now() }) + .where(eq(botAutomationLinks.id, automationId)); + throw error; + } + broadcast({ botId: link.botId, automationId }); + }); + }); + + ipcMain.handle(MAKER_INVOKE.BOT_AUTOMATION_RUN_NOW, async (event, rawId: unknown) => { + assertTrustedAppRendererEvent(event); + const automationId = requireString(rawId, 'automationId'); + const { link } = await readAutomation(automationId); + if (link.status !== 'active' || !link.scheduleId) { + throwIpcError('INVALID_PARAMS', 'Resume this Bot automation before running it'); + } + await readBotAutomationPolicy(link.botId); + const { scheduler } = await awaitReadyWithTimeout(); + const result = await scheduler.runNow(link.scheduleId); + broadcast({ botId: link.botId, automationId, runId: result.runId }); + return result; + }); + + ipcMain.handle(MAKER_INVOKE.BOT_AUTOMATION_DELETE, async (event, rawId: unknown) => { + assertTrustedAppRendererEvent(event); + const automationId = requireString(rawId, 'automationId'); + const initial = await readAutomation(automationId); + if (!initial.link.scheduleId) throwIpcError('NOT_FOUND', 'Automation schedule is missing'); + return withBotAutomationMutationLock(initial.link.scheduleId, async () => { + const { link } = await readAutomation(automationId); + const db = getDbClient().drizzle; + const [activeRun] = await db + .select({ id: botAutomationRuns.id }) + .from(botAutomationRuns) + .where( + and( + eq(botAutomationRuns.automationLinkId, automationId), + inArray(botAutomationRuns.status, ['claimed', 'running', 'completing']), + ), + ) + .limit(1); + if (activeRun) { + throwIpcError('PRECONDITION_FAILED', 'Wait for the active Bot automation run to finish'); + } + await db.update(botAutomationLinks).set({ status: 'archived', updatedAt: Date.now() }) + .where(eq(botAutomationLinks.id, automationId)); + try { + if (link.scheduleId) { + const { scheduler } = await awaitReadyWithTimeout(); + await scheduler.pause(link.scheduleId); + } + } catch (error) { + await db.update(botAutomationLinks).set({ status: link.status, updatedAt: Date.now() }) + .where(eq(botAutomationLinks.id, automationId)); + throw error; + } + // Archive instead of deleting the Scheduler row: schedule_runs contain the + // result/error history that Bot users must still be able to inspect. + broadcast({ botId: link.botId, automationId }); + }); + }); + + ipcMain.handle( + MAKER_INVOKE.BOT_AUTOMATION_LIST_RUNS, + async (event, rawId: unknown, rawLimit: unknown) => { + assertTrustedAppRendererEvent(event); + const automationId = requireString(rawId, 'automationId'); + const limit = typeof rawLimit === 'number' && Number.isFinite(rawLimit) + ? Math.max(1, Math.min(200, Math.floor(rawLimit))) + : 50; + const { link } = await readAutomation(automationId); + const db = getDbClient().drizzle; + const rows = await db + .select({ + run: botAutomationRuns, + scheduleRun: scheduleRuns, + outbox: botDeliveryOutbox, + lease: botWorkspaceLeases, + }) + .from(botAutomationRuns) + .leftJoin(scheduleRuns, eq(scheduleRuns.id, botAutomationRuns.scheduleRunId)) + .leftJoin(botDeliveryOutbox, eq(botDeliveryOutbox.id, botAutomationRuns.deliveryOutboxId)) + .leftJoin(botWorkspaceLeases, eq(botWorkspaceLeases.id, botAutomationRuns.workspaceLeaseId)) + .where(eq(botAutomationRuns.automationLinkId, automationId)) + .orderBy(desc(botAutomationRuns.createdAt)) + .limit(limit); + return rows.map(({ run, scheduleRun, outbox, lease }): BotAutomationRun => ({ + id: run.id, + automationLinkId: run.automationLinkId, + scheduleRunId: run.scheduleRunId ?? undefined, + sessionId: run.sessionId ?? undefined, + workspaceLeaseId: run.workspaceLeaseId ?? undefined, + worktreePath: run.worktreePathSnapshot ?? lease?.worktreePath ?? undefined, + profileVersion: run.profileVersion, + executionPlan: parseBotAutomationExecutionPlan(run.executionPlanJson) ?? undefined, + projectBindingId: run.projectBindingIdSnapshot ?? undefined, + targetRouteId: run.targetRouteIdSnapshot ?? undefined, + workingDir: run.workingDirSnapshot ?? undefined, + remoteHostId: run.remoteHostIdSnapshot ?? undefined, + status: run.status, + scheduleStatus: scheduleRun?.status ?? undefined, + resultText: scheduleRun?.resultText ?? run.resultTextSnapshot ?? undefined, + outputArtifacts: parseBotOutputArtifacts(run.outputArtifactsJson), + errorMessage: run.errorMessage ?? scheduleRun?.errorMsg ?? undefined, + deliveryOutboxId: run.deliveryOutboxId ?? undefined, + deliveryStatus: deliveryStatus(run, outbox), + deliveryError: outbox?.lastError ?? run.deliveryError ?? undefined, + deliveryDiagnostic: parseBotDeliveryDiagnostic(outbox?.deliveryReceiptJson), + createdAt: run.createdAt, + updatedAt: run.updatedAt, + firedAt: scheduleRun?.firedAt ?? undefined, + finishedAt: run.finishedAt ?? scheduleRun?.finishedAt ?? undefined, + })); + }, + ); + + ipcMain.handle( + MAKER_INVOKE.BOT_AUTOMATION_RETRY_DELIVERY, + async (event, rawAutomationId: unknown, rawRunId: unknown, rawAllowDuplicateRisk: unknown) => { + assertTrustedAppRendererEvent(event); + const automationId = requireString(rawAutomationId, 'automationId'); + const runId = requireString(rawRunId, 'runId'); + const allowDuplicateRisk = rawAllowDuplicateRisk === true; + const { link } = await readAutomation(automationId); + const db = getDbClient().drizzle; + const [row] = await db + .select({ + run: botAutomationRuns, + scheduleRun: scheduleRuns, + schedule: schedules, + }) + .from(botAutomationRuns) + .leftJoin(scheduleRuns, eq(scheduleRuns.id, botAutomationRuns.scheduleRunId)) + .leftJoin(schedules, eq(schedules.id, scheduleRuns.scheduleId)) + .where( + and( + eq(botAutomationRuns.id, runId), + eq(botAutomationRuns.automationLinkId, automationId), + ), + ) + .limit(1); + if (!row) throwIpcError('NOT_FOUND', 'Bot automation run not found'); + if (row.run.status !== 'success') { + throwIpcError('INVALID_PARAMS', 'Only a completed Bot automation can retry delivery'); + } + const [activeProfile] = await db + .select({ + status: botProfiles.status, + canonicalSessionId: botProfiles.canonicalSessionId, + }) + .from(botProfiles) + .where(eq(botProfiles.id, link.botId)) + .limit(1); + if (!activeProfile || activeProfile.status !== 'active') { + throwIpcError( + 'PRECONDITION_FAILED', + 'Restore the Bot before retrying an automation delivery', + ); + } + + let outboxId = row.run.deliveryOutboxId; + if (outboxId) { + await deps.retryDelivery(outboxId, link.botId, { allowDuplicateRisk }); + } else { + if (row.run.deliveryStatus !== 'enqueue-failed') { + throwIpcError('INVALID_PARAMS', 'This Bot automation delivery cannot be retried'); + } + if (!row.run.sessionId) { + throwIpcError('PRECONDITION_FAILED', 'The completed Bot task is unavailable'); + } + + let target: + | { + sessionId: string; + channelId: string | null; + routeId: string | null; + ownerGeneration: number; + } + | undefined; + if (row.run.targetRouteIdSnapshot) { + const [route] = await db + .select({ + botId: botRoutes.botId, + channelId: botRoutes.channelId, + currentSessionId: botRoutes.currentSessionId, + ownerGeneration: botRoutes.ownerGeneration, + status: botRoutes.status, + }) + .from(botRoutes) + .where(eq(botRoutes.id, row.run.targetRouteIdSnapshot)) + .limit(1); + if ( + !route + || route.botId !== link.botId + || route.status !== 'active' + || !route.currentSessionId + ) { + throwIpcError('PRECONDITION_FAILED', 'The frozen Bot delivery route is unavailable'); + } + if (row.run.targetRouteOwnerGenerationSnapshot === null) { + throwIpcError( + 'PRECONDITION_FAILED', + 'The Bot delivery owner snapshot is unavailable; run the automation again', + ); + } + if (route.ownerGeneration !== row.run.targetRouteOwnerGenerationSnapshot) { + throwIpcError( + 'PRECONDITION_FAILED', + 'The Bot delivery route ownership changed; the old result will not be redirected', + ); + } + target = { + sessionId: route.currentSessionId, + channelId: route.channelId, + routeId: row.run.targetRouteIdSnapshot, + ownerGeneration: route.ownerGeneration, + }; + } else { + if (!activeProfile.canonicalSessionId) { + throwIpcError('PRECONDITION_FAILED', 'The Bot canonical task is unavailable'); + } + target = { + sessionId: activeProfile.canonicalSessionId, + channelId: null, + routeId: null, + ownerGeneration: 0, + }; + } + + const automationName = row.schedule?.name ?? 'Automation'; + const stableRunIdentity = row.run.scheduleRunId ?? row.run.id; + const deliveryKey = `bot-automation-completion:${stableRunIdentity}`; + const text = [ + `[Cindy Bot automation ${automationName} completed]`, + (row.scheduleRun?.resultText ?? row.run.resultTextSnapshot)?.trim() + ? `Result:\n${(row.scheduleRun?.resultText ?? row.run.resultTextSnapshot)!.trim()}` + : '', + `Run task: ${row.run.sessionId}`, + ].filter(Boolean).join('\n\n'); + const delivery = await deps.enqueueDelivery({ + botId: link.botId, + channelId: target.channelId, + routeId: target.routeId, + sessionId: target.sessionId, + ownerGeneration: target.ownerGeneration, + idempotencyKey: deliveryKey, + payload: { + version: 1, + kind: 'session-message', + targetSessionId: target.sessionId, + fallbackBotId: link.botId, + clientId: deliveryKey, + message: text, + persistedContent: text, + }, + }); + outboxId = delivery.id; + } + + await db + .update(botAutomationRuns) + .set({ + deliveryOutboxId: outboxId, + deliveryStatus: 'queued', + deliveryError: null, + updatedAt: Date.now(), + }) + .where( + and( + eq(botAutomationRuns.id, runId), + eq(botAutomationRuns.automationLinkId, automationId), + ), + ); + broadcast({ botId: link.botId, automationId, runId }); + }, + ); +} diff --git a/apps/desktop/src/main/maker-ipc/botAutomationMutationLock.ts b/apps/desktop/src/main/maker-ipc/botAutomationMutationLock.ts new file mode 100644 index 0000000000..0dc51a58f6 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/botAutomationMutationLock.ts @@ -0,0 +1,33 @@ +const tails = new Map>(); + +/** + * Scheduler definitions and Bot ownership links live in different state + * machines. Serialize a fire-time snapshot with edit/archive mutations so a + * run can never freeze half of the old configuration and half of the new one. + */ +export async function withBotAutomationMutationLock( + scheduleId: string, + task: () => Promise, +): Promise { + const previous = tails.get(scheduleId); + const waitPrevious = previous ? previous.catch(() => undefined) : Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const current = waitPrevious.then(() => gate); + const tracked = current.finally(() => { + if (tails.get(scheduleId) === tracked) tails.delete(scheduleId); + }); + tails.set(scheduleId, tracked); + await waitPrevious; + try { + return await task(); + } finally { + release(); + } +} + +export function resetBotAutomationMutationLocksForTest(): void { + tails.clear(); +} diff --git a/apps/desktop/src/main/maker-ipc/botCanonicalReplacementCoordinator.ts b/apps/desktop/src/main/maker-ipc/botCanonicalReplacementCoordinator.ts new file mode 100644 index 0000000000..c0fe1a559f --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/botCanonicalReplacementCoordinator.ts @@ -0,0 +1,25 @@ +type BotCanonicalReplacementOperation = () => Promise; +type BotCanonicalReplacementCoordinator = ( + sessionId: string, + operation: BotCanonicalReplacementOperation, +) => Promise; + +// Before Maker wiring there cannot be a live Agent runtime to race. The +// composition root installs the send-lock + busy guard as soon as maker IPC is +// loaded; keeping this leaf module dependency-free also lets LocalDB tests run +// without importing Electron/device-link runtime side effects. +let coordinator: BotCanonicalReplacementCoordinator = async (_sessionId, operation) => + operation(); + +export function configureBotCanonicalReplacementCoordinator( + next: BotCanonicalReplacementCoordinator, +): void { + coordinator = next; +} + +export function coordinateBotCanonicalReplacement( + sessionId: string, + operation: BotCanonicalReplacementOperation, +): Promise { + return coordinator(sessionId, operation); +} diff --git a/apps/desktop/src/main/maker-ipc/botCanonicalReplacementGuard.ts b/apps/desktop/src/main/maker-ipc/botCanonicalReplacementGuard.ts new file mode 100644 index 0000000000..7268ac2467 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/botCanonicalReplacementGuard.ts @@ -0,0 +1,20 @@ +export interface BotCanonicalReplacementActivity { + turnRunning: boolean; + backgroundTaskCount: number; + trackedTurn: boolean; + leasedTurn: boolean; + pendingInteraction: boolean; +} + +/** Renew may archive a canonical task only after every execution owner is idle. */ +export function isBotCanonicalReplacementBusy( + activity: BotCanonicalReplacementActivity, +): boolean { + return ( + activity.turnRunning || + activity.backgroundTaskCount > 0 || + activity.trackedTurn || + activity.leasedTurn || + activity.pendingInteraction + ); +} diff --git a/apps/desktop/src/main/maker-ipc/botCompactRuntimeRefresh.ts b/apps/desktop/src/main/maker-ipc/botCompactRuntimeRefresh.ts new file mode 100644 index 0000000000..dbdf421a20 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/botCompactRuntimeRefresh.ts @@ -0,0 +1,138 @@ +export interface BotCompactRuntimeSession { + readonly id: string; + readonly instanceId: string; + isTurnRunning(): boolean; + listBackgroundTasks(): ReadonlyArray; +} + +export interface BotCompactBoundary { + readonly sessionId: string; + readonly sessionInstanceId: string; + readonly firstObservedAt: number; + readonly lastObservedAt: number; + readonly boundaryCount: number; +} + +export type BotCompactRuntimeRefreshOutcome = 'refreshed' | 'not-bot' | 'deferred'; + +export interface BotCompactRuntimeRefreshDeps { + hasPendingInteraction(sessionId: string): boolean; + refresh( + session: BotCompactRuntimeSession, + boundary: BotCompactBoundary, + ): Promise; + now?: () => number; + onError?: (sessionId: string, error: unknown) => void; +} + +/** + * Preserve the live runtime when a frozen Bot resource changed. The preflight + * must finish before close, and ownership is checked again immediately before + * the destructive half of the swap. + */ +export async function replaceBotRuntimeAfterPreflight(deps: { + preflight(): Promise; + isCurrentOwner(): boolean; + close(): Promise; + bootstrap(): Promise; +}): Promise { + await deps.preflight(); + if (!deps.isCurrentOwner()) { + throw new Error('Bot runtime owner changed before compact refresh'); + } + await deps.close(); + return deps.bootstrap(); +} + +/** + * A provider can emit compact_boundary in the middle of one product turn. The + * Bot Profile runtime must therefore be rebuilt only after the final product + * boundary, never directly from the compact event. This coordinator owns the + * small instance-scoped state machine; the host callback owns close/bootstrap. + */ +export function createBotCompactRuntimeRefreshCoordinator( + deps: BotCompactRuntimeRefreshDeps, +) { + const now = deps.now ?? Date.now; + const pending = new Map< + string, + { session: BotCompactRuntimeSession; boundary: BotCompactBoundary } + >(); + const inFlight = new Map>(); + + function noteBoundary(session: BotCompactRuntimeSession): BotCompactBoundary { + const at = now(); + const existing = pending.get(session.id); + const sameInstance = existing?.session === session + && existing.boundary.sessionInstanceId === session.instanceId; + const boundary: BotCompactBoundary = { + sessionId: session.id, + sessionInstanceId: session.instanceId, + firstObservedAt: sameInstance ? existing.boundary.firstObservedAt : at, + lastObservedAt: at, + boundaryCount: sameInstance ? existing.boundary.boundaryCount + 1 : 1, + }; + pending.set(session.id, { session, boundary }); + return boundary; + } + + async function attempt( + session: BotCompactRuntimeSession, + ): Promise { + const entry = pending.get(session.id); + if (!entry || entry.session !== session || entry.boundary.sessionInstanceId !== session.instanceId) { + return 'not-bot'; + } + if ( + session.isTurnRunning() + || session.listBackgroundTasks().length > 0 + || deps.hasPendingInteraction(session.id) + ) { + return 'deferred'; + } + const existing = inFlight.get(session.id); + if (existing) return existing; + + const operation = deps.refresh(session, entry.boundary) + .then((outcome) => { + if ( + outcome !== 'deferred' + && pending.get(session.id)?.session === session + && pending.get(session.id)?.boundary.sessionInstanceId === session.instanceId + ) { + pending.delete(session.id); + } + return outcome; + }) + .catch((error) => { + deps.onError?.(session.id, error); + return 'deferred' as const; + }) + .finally(() => { + if (inFlight.get(session.id) === operation) inFlight.delete(session.id); + }); + inFlight.set(session.id, operation); + return operation; + } + + function clearForClosedSession(session: BotCompactRuntimeSession): void { + if (pending.get(session.id)?.session === session) pending.delete(session.id); + } + + function resetForTest(): void { + pending.clear(); + inFlight.clear(); + } + + return { + noteBoundary, + attempt, + clearForClosedSession, + hasPending: (sessionId: string) => pending.has(sessionId), + resetForTest, + }; +} + +export type BotCompactRuntimeRefreshCoordinator = ReturnType< + typeof createBotCompactRuntimeRefreshCoordinator +>; diff --git a/apps/desktop/src/main/maker-ipc/botDelegationLifecycle.ts b/apps/desktop/src/main/maker-ipc/botDelegationLifecycle.ts new file mode 100644 index 0000000000..66f850faa5 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/botDelegationLifecycle.ts @@ -0,0 +1,25 @@ +type ParentCancellationHandler = (parentSessionId: string, reason: string) => Promise; + +let activeHandler: ParentCancellationHandler | null = null; + +/** + * Small process-local bridge from Bot lifecycle writers to the delegation + * runtime. Keeping this holder dependency-free avoids importing maker-ipc's + * full register graph from localDb/route code (which also keeps tests and + * Electron startup free of a circular side effect). + */ +export function registerBotDelegationParentCancellation( + handler: ParentCancellationHandler, +): () => void { + activeHandler = handler; + return () => { + if (activeHandler === handler) activeHandler = null; + }; +} + +export async function cancelBotDelegationsForParentIfReady( + parentSessionId: string, + reason: string, +): Promise { + return activeHandler ? activeHandler(parentSessionId, reason) : 0; +} diff --git a/apps/desktop/src/main/maker-ipc/botDelegationService.ts b/apps/desktop/src/main/maker-ipc/botDelegationService.ts new file mode 100644 index 0000000000..4412e660f5 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/botDelegationService.ts @@ -0,0 +1,2412 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createHash, randomUUID } from 'node:crypto'; + +import { and, desc, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm'; + +import { ensureProjectGitInitialized } from '../git-snapshot/projectGitBootstrap.js'; +import { getDbClient } from '../localDb/client/current.js'; +import type { BotsFinishDelegationResult } from '../localDb/client/tx/types.js'; +import { visibleMessageTextForConversationSearch } from '../localDb/conversationSearch.pure.js'; +import { ensureDialogueWorkspaceDir } from '../localDb/dialogueWorkspace.js'; +import { sessionCreateToRow } from '../localDb/mapper.js'; +import { + botAutomationLinks, + botAutomationRuns, + botChannels, + botDelegations, + botDeliveryOutbox, + botProfileVersions, + botProfiles, + botProjectBindings, + botRoutes, + botRuntimeSnapshots, + botSessionLinks, + botWorkspaceAttachments, + botWorkspaceLeases, + messages, + scheduleRuns, + sessions, +} from '../localDb/schema.js'; +import { readGitSafetySettings } from '../maker-host/git-safety-settings-store.js'; +import { resolveBusinessSessionId } from '../sessionIds.js'; +import { registerBotDelegationParentCancellation } from './botDelegationLifecycle.js'; +import type { + BotCapabilityCatalogEntry, + BotDelegationChangedPayload, + BotDelegationCapabilitySnapshot, + BotDelegationPlanSnapshot, + BotDelegationStatus, + BotDelegationView, + BotDelegationWorkspaceSnapshot, +} from '../../shared/botDelegation.js'; +import { parseBotDelegationPlanSnapshot } from '../../shared/botDelegation.js'; +import type { + BotAutomationDelegateTargetSnapshot, + BotAutomationExecutionPlan, +} from '../../shared/botAutomation.js'; +import { parseBotAutomationExecutionPlan } from '../../shared/botAutomation.js'; +import { + collectBotOutputArtifacts, + parseBotOutputArtifacts, +} from '../../shared/botOutputArtifact.js'; +import { parseBotDeliveryDiagnostic } from '../../shared/botDeliveryDiagnostic.js'; + +const ACTIVE_DELEGATION_STATUSES = ['queued', 'running', 'waiting'] as const; +const DEFAULT_MAX_DEPTH = 1; +const HARD_MAX_DEPTH = 5; +const DEFAULT_MAX_ACTIVE_CHILDREN = 10; +const DEFAULT_TIMEOUT_MS = 30 * 60_000; +const MAX_TIMEOUT_MS = 24 * 60 * 60_000; +const MAX_OBJECTIVE_CHARS = 12_000; +const MAX_RESULT_CHARS = 12_000; +const MAX_RETRY_DELAY_MS = 60_000; +const messageRowid = sql`"messages"."rowid"`; + +function schedulePerTaskWorkspaceReclaim(sessionId: string): void { + void import('./botWorkspaceRuntime.js') + .then((module) => module.schedulePerTaskBotWorkspaceReclaim(sessionId)) + .catch(() => undefined); +} + +type DelegationStatus = BotDelegationStatus; +type DelegationRow = typeof botDelegations.$inferSelect; +type ProjectBindingRow = typeof botProjectBindings.$inferSelect; +type AutomationRunRow = typeof botAutomationRuns.$inferSelect; + +interface AutomationDelegationContext { + run: AutomationRunRow; + rootSessionId: string; + plan: BotAutomationExecutionPlan; +} + +type DispatchResult = + | { + ok: true; + targetSessionId: string; + wakeKind: 'resumed' | 'already-active' | 'created' | 'queued'; + } + | { ok: false; errorCode: string; message: string }; + +export interface BotDelegationServiceDeps { + dispatch: (params: { + targetSessionId: string; + message: string; + persistedContent?: string; + clientId?: string; + onAccepted?: () => void | Promise; + }) => Promise; + enqueueDelivery?: (params: { + botId: string; + channelId?: string | null; + routeId?: string | null; + sessionId: string | null; + idempotencyKey: string; + ownerGeneration?: number; + payload: { + version: 1; + kind: 'session-message'; + targetSessionId: string; + fallbackBotId: string; + clientId: string; + message: string; + persistedContent: string; + }; + }) => Promise<{ id: string }>; + abortSession: (sessionId: string) => Promise; + archiveSession?: (sessionId: string) => Promise; + closeSession?: (sessionId: string) => Promise; + broadcastSessionCreated?: (sessionId: string) => void; + onChanged?: (payload: BotDelegationChangedPayload) => void; + now?: () => number; + createId?: () => string; + maxActiveChildren?: number; + /** Production requires the native runtime snapshot before accepting work. */ + requireRuntimeSnapshot?: boolean; +} + +export function isBotRuntimeSnapshotForCapabilityTarget(input: { + runtimeSessionId: string; + runtimeWorkingDir: string; + canonicalSessionId: string | null; + automationWorkingDir?: string | null; +}): boolean { + if (input.automationWorkingDir) { + return input.runtimeWorkingDir === input.automationWorkingDir; + } + return Boolean(input.canonicalSessionId) + && input.runtimeSessionId === input.canonicalSessionId; +} + +export interface DelegateToBotInput { + callerSessionId: string; + targetBotId: string; + objective: string; + contextRefs?: string[]; + artifactRefs?: string[]; + budgetTokens?: number; + maxDepth?: number; + timeoutMs?: number; +} + +export type BotDelegationResult = + | ({ ok: true } & T) + | { ok: false; errorCode: string; message: string }; + +function parseRecord(value: string | null | undefined): Record { + try { + const parsed = JSON.parse(value ?? '{}') as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function parseStringArray(value: string | null | undefined): string[] { + try { + const parsed = JSON.parse(value ?? '[]') as unknown; + return Array.isArray(parsed) + ? parsed.filter((item): item is string => typeof item === 'string') + : []; + } catch { + return []; + } +} + +function boundedStringList(value: string[] | undefined, max = 32): string[] { + if (!value) return []; + return [...new Set(value.map((item) => item.trim()).filter(Boolean))] + .slice(0, max) + .map((item) => item.slice(0, 4_000)); +} + +function botAgentKind(config: Record): 'cc' | 'codex' | 'pi' { + return config.harness === 'codex' ? 'codex' : config.harness === 'pi' ? 'pi' : 'cc'; +} + +function targetPermissionMode( + config: Record, + requesterPermissionMode: string | null | undefined, +): 'ask' | 'bypassPermissions' { + return config.permissions === 'trusted' && requesterPermissionMode === 'bypassPermissions' + ? 'bypassPermissions' + : 'ask'; +} + +function readDeadline(permissionSnapshotJson: string): number | null { + const plan = parseBotDelegationPlanSnapshot(permissionSnapshotJson); + const deadlineAt = plan?.limits.deadlineAt ?? parseRecord(permissionSnapshotJson).deadlineAt; + return typeof deadlineAt === 'number' && Number.isFinite(deadlineAt) ? deadlineAt : null; +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function configStringList(config: Record, key: string): string[] { + const value = config[key]; + return Array.isArray(value) + ? [...new Set(value.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter(Boolean))] + : []; +} + +function unknownStringList(value: unknown): string[] { + return Array.isArray(value) + ? [...new Set( + value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean), + )] + : []; +} + +function configuredToolsets(config: Record): string[] { + const configured = configStringList(config, 'toolsets'); + if (configured.length > 0) return configured; + return configStringList(config, 'tools').filter( + (item) => !['files', 'browser', 'mcp'].includes(item), + ); +} + +function configuredCapabilitySnapshot(input: { + version: number; + capabilitiesJson: string; + identitySource: string; +}): BotDelegationCapabilitySnapshot { + const config = parseRecord(input.capabilitiesJson); + const skills = configStringList(config, 'skills'); + const mcpServers = configStringList(config, 'mcpServers'); + const toolsets = configuredToolsets(config); + return { + profileVersion: input.version, + agentKind: botAgentKind(config), + model: + typeof config.model === 'string' && config.model.trim() + ? config.model.trim() + : 'claude-sonnet-4-6', + capabilitiesSha256: sha256(input.capabilitiesJson), + identitySha256: sha256(input.identitySource), + skills, + skillMode: configuredMode(config.skillMode, skills), + mcpServers, + mcpMode: configuredMode(config.mcpMode, mcpServers), + toolsets, + toolsetMode: configuredMode(config.toolsetMode, toolsets), + memoryEnabled: config.memory !== false, + automationEnabled: config.automation === true, + }; +} + +function configuredMode( + value: unknown, + configured: string[], +): 'inherit' | 'allowlist' { + if (value === 'allowlist' || value === 'inherit') return value; + return configured.length > 0 ? 'allowlist' : 'inherit'; +} + +function parseAllowedPaths(value: string): string[] { + try { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) + ? [...new Set(parsed.filter((item): item is string => typeof item === 'string').map((item) => item.trim()).filter(Boolean))] + : []; + } catch { + return []; + } +} + +function workspaceSnapshot(binding: ProjectBindingRow | undefined): BotDelegationWorkspaceSnapshot | null { + if (!binding) return null; + return { + bindingId: binding.id, + bindingUpdatedAt: binding.updatedAt, + projectKey: binding.projectKey, + workingDir: binding.workingDir, + remoteHostId: binding.remoteHostId, + defaultBranch: binding.defaultBranch, + workspacePolicy: binding.workspacePolicy, + allowedPaths: parseAllowedPaths(binding.allowedPathsJson), + }; +} + +function bindingAllowsRelativePath(binding: ProjectBindingRow, relativeRef: string): boolean { + const configured = parseAllowedPaths(binding.allowedPathsJson); + if (configured.length === 0) return true; + const pathApi = binding.remoteHostId ? path.posix : path; + const root = pathApi.resolve(binding.workingDir); + return configured.some((candidate) => { + const allowedRelative = pathApi.relative(root, pathApi.resolve(candidate)); + if ( + allowedRelative === '..' + || allowedRelative.startsWith(`..${pathApi.sep}`) + || pathApi.isAbsolute(allowedRelative) + ) return false; + if (!allowedRelative || allowedRelative === '.') return true; + return relativeRef === allowedRelative || relativeRef.startsWith(`${allowedRelative}${pathApi.sep}`); + }); +} + +function normalizeDelegationReferences(input: { + refs: string[] | undefined; + callerBinding: ProjectBindingRow | undefined; + targetBinding: ProjectBindingRow | undefined; + field: 'context_refs' | 'artifact_refs'; +}): BotDelegationResult<{ refs: string[] }> { + const refs = boundedStringList(input.refs); + if (refs.length === 0) return { ok: true, refs: [] }; + if (!input.callerBinding || !input.targetBinding) { + return { + ok: false, + errorCode: 'REFERENCE_SCOPE_REQUIRED', + message: `${input.field} 只能引用调用方与目标 Bot 共同绑定的项目路径`, + }; + } + if ( + input.callerBinding.projectKey !== input.targetBinding.projectKey + || input.callerBinding.remoteHostId !== input.targetBinding.remoteHostId + ) { + return { + ok: false, + errorCode: 'REFERENCE_SCOPE_MISMATCH', + message: `${input.field} 不能跨 Bot 项目或远程主机传递`, + }; + } + const pathApi = input.targetBinding.remoteHostId ? path.posix : path; + const normalized: string[] = []; + for (const raw of refs) { + if (raw.includes('\0') || raw.includes('\n') || raw.includes('\r') || pathApi.isAbsolute(raw)) { + return { + ok: false, + errorCode: 'INVALID_REFERENCE', + message: `${input.field} 只接受不含换行的项目相对路径`, + }; + } + const ref = pathApi.normalize(raw); + if ( + !ref + || ref === '.' + || ref === '..' + || ref.startsWith(`..${pathApi.sep}`) + || pathApi.isAbsolute(ref) + ) { + return { + ok: false, + errorCode: 'INVALID_REFERENCE', + message: `${input.field} 包含越出项目范围的路径`, + }; + } + if ( + !bindingAllowsRelativePath(input.callerBinding, ref) + || !bindingAllowsRelativePath(input.targetBinding, ref) + ) { + return { + ok: false, + errorCode: 'REFERENCE_NOT_ALLOWED', + message: `${input.field} 包含未同时授权给调用方和目标 Bot 的路径`, + }; + } + normalized.push(ref); + } + return { ok: true, refs: [...new Set(normalized)] }; +} + +export function createBotDelegationService(deps: BotDelegationServiceDeps) { + const timers = new Map>(); + const retryTimers = new Map>(); + const now = deps.now ?? Date.now; + const createId = deps.createId ?? randomUUID; + const maxActiveChildren = Math.max(1, deps.maxActiveChildren ?? DEFAULT_MAX_ACTIVE_CHILDREN); + + const clearTimer = (delegationId: string): void => { + const timer = timers.get(delegationId); + if (timer) clearTimeout(timer); + timers.delete(delegationId); + }; + + const clearRetryTimer = (delegationId: string): void => { + const timer = retryTimers.get(delegationId); + if (timer) clearTimeout(timer); + retryTimers.delete(delegationId); + }; + + const emitChanged = (payload: BotDelegationChangedPayload): void => { + deps.onChanged?.(payload); + }; + + const isActiveDelegation = (status: DelegationStatus): boolean => + ACTIVE_DELEGATION_STATUSES.includes( + status as (typeof ACTIVE_DELEGATION_STATUSES)[number], + ); + + const buildDelegationGraph = (rows: DelegationRow[]) => { + const byId = new Map(rows.map((row) => [row.id, row])); + const byChildSessionId = new Map( + rows.flatMap((row) => row.childSessionId ? [[row.childSessionId, row] as const] : []), + ); + const childrenByParentSessionId = new Map(); + for (const row of rows) { + if (!row.parentSessionId) continue; + const children = childrenByParentSessionId.get(row.parentSessionId) ?? []; + children.push(row); + childrenByParentSessionId.set(row.parentSessionId, children); + } + return { byId, byChildSessionId, childrenByParentSessionId }; + }; + + /** + * Resolve the Automation run that owns a Bot task, including nested + * delegation descendants. The execution plan is the authorization source; + * current Bot settings never widen an already-running Automation. + */ + const resolveAutomationContextForSession = async ( + sessionId: string, + ): Promise => { + const db = getDbClient().drizzle; + const activeStatuses = ['claimed', 'running', 'completing'] as const; + const readRun = async (rootSessionId: string) => { + const [run] = await db + .select() + .from(botAutomationRuns) + .where( + and( + eq(botAutomationRuns.sessionId, rootSessionId), + inArray(botAutomationRuns.status, [...activeStatuses]), + ), + ) + .orderBy(desc(botAutomationRuns.createdAt)) + .limit(1); + if (!run) return null; + const plan = parseBotAutomationExecutionPlan(run.executionPlanJson); + return plan ? { run, rootSessionId, plan } : null; + }; + + const direct = await readRun(sessionId); + if (direct) return direct; + + const graph = buildDelegationGraph(await db.select().from(botDelegations)); + let currentSessionId = sessionId; + const seen = new Set(); + while (!seen.has(currentSessionId)) { + seen.add(currentSessionId); + const parent = graph.byChildSessionId.get(currentSessionId); + if (!parent?.parentSessionId) return null; + currentSessionId = parent.parentSessionId; + const context = await readRun(currentSessionId); + if (context) return context; + } + return null; + }; + + const validateAutomationTargetSnapshot = async ( + target: BotAutomationDelegateTargetSnapshot, + ): Promise<{ + ok: true; + profile: typeof botProfiles.$inferSelect; + version: typeof botProfileVersions.$inferSelect; + binding: ProjectBindingRow | undefined; + } | { + ok: false; + reason: string; + }> => { + const db = getDbClient().drizzle; + const [[profile], [version], [binding]] = await Promise.all([ + db + .select() + .from(botProfiles) + .where(eq(botProfiles.id, target.botId)) + .limit(1), + db + .select() + .from(botProfileVersions) + .where( + and( + eq(botProfileVersions.botId, target.botId), + eq(botProfileVersions.version, target.profileVersion), + ), + ) + .limit(1), + db + .select() + .from(botProjectBindings) + .where( + and( + eq(botProjectBindings.botId, target.botId), + eq(botProjectBindings.status, 'active'), + eq(botProjectBindings.isDefault, true), + ), + ) + .limit(1), + ]); + if (!profile || profile.status !== 'active') { + return { ok: false, reason: '目标 Bot 已停用或归档' }; + } + if (profile.currentVersion !== target.profileVersion) { + return { ok: false, reason: '目标 Bot Profile 已在本轮 Automation 启动后更新' }; + } + if ( + !version + || sha256(version.capabilitiesJson) !== target.capabilitiesSha256 + || sha256(version.identitySource) !== target.identitySha256 + ) { + return { ok: false, reason: '目标 Bot Profile 冻结内容已失效' }; + } + const workspaceMatches = target.defaultWorkspace === null + ? binding === undefined + : !!binding + && binding.id === target.defaultWorkspace.bindingId + && binding.updatedAt === target.defaultWorkspace.bindingUpdatedAt + && binding.projectKey === target.defaultWorkspace.projectKey + && binding.remoteHostId === target.defaultWorkspace.remoteHostId + && binding.workspacePolicy === target.defaultWorkspace.workspacePolicy; + if (!workspaceMatches) { + return { ok: false, reason: '目标 Bot 的默认项目或工作区授权已变更' }; + } + return { ok: true, profile, version, binding }; + }; + + const subtreeActualTokens = ( + root: DelegationRow, + graph: ReturnType, + seen = new Set(), + ): number => { + if (seen.has(root.id)) return 0; + seen.add(root.id); + let total = Math.max(0, root.tokensUsed); + if (!root.childSessionId) return total; + for (const child of graph.childrenByParentSessionId.get(root.childSessionId) ?? []) { + total += subtreeActualTokens(child, graph, seen); + } + return total; + }; + + const committedSubtreeTokens = ( + root: DelegationRow, + graph: ReturnType, + ): number => { + let committed = subtreeActualTokens(root, graph); + if (!root.childSessionId) return committed; + for (const child of graph.childrenByParentSessionId.get(root.childSessionId) ?? []) { + if (!isActiveDelegation(child.status) || child.budgetTokens === null) continue; + committed += Math.max(0, child.budgetTokens - subtreeActualTokens(child, graph)); + } + return committed; + }; + + const descendantRows = ( + root: DelegationRow, + graph: ReturnType, + ): DelegationRow[] => { + const result: DelegationRow[] = []; + const pending = root.childSessionId + ? [...(graph.childrenByParentSessionId.get(root.childSessionId) ?? [])] + : []; + const seen = new Set(); + while (pending.length > 0) { + const next = pending.shift()!; + if (seen.has(next.id)) continue; + seen.add(next.id); + result.push(next); + if (next.childSessionId) { + pending.push(...(graph.childrenByParentSessionId.get(next.childSessionId) ?? [])); + } + } + return result; + }; + + const deliverCompletion = async (params: { + id: string; + requestingBotId: string; + targetBotId: string; + parentSessionId: string | null; + childSessionId: string | null; + objective: string; + status: Extract; + resultSummary?: string | null; + lastError?: string | null; + }): Promise => { + if (!params.parentSessionId) return; + const db = getDbClient().drizzle; + const [parent] = await db + .select({ + status: sessions.status, + role: botSessionLinks.role, + botId: botSessionLinks.botId, + }) + .from(sessions) + .innerJoin(botSessionLinks, eq(botSessionLinks.sessionId, sessions.id)) + .where(eq(sessions.id, params.parentSessionId)) + .limit(1); + // Renew/archive owns the parent lifecycle. Completion must never resurrect + // or enqueue durable work against a task that is no longer active. + if (parent?.status !== 'active') return; + const [parentRoute] = parent.role === 'route' + ? await db + .select({ + id: botRoutes.id, + channelId: botRoutes.channelId, + ownerGeneration: botRoutes.ownerGeneration, + }) + .from(botRoutes) + .where( + and( + eq(botRoutes.currentSessionId, params.parentSessionId), + eq(botRoutes.botId, params.requestingBotId), + ), + ) + .limit(1) + : []; + const completionMessage = [ + `[Cindy Bot delegation ${params.id} ${params.status}]`, + `Target Bot: ${params.targetBotId}`, + `Objective: ${params.objective}`, + params.resultSummary ? `Result:\n${params.resultSummary}` : '', + params.lastError ? `Error: ${params.lastError}` : '', + params.childSessionId ? `Child task: ${params.childSessionId}` : '', + ] + .filter(Boolean) + .join('\n\n'); + const completionClientId = `bot-delegation-completion:${params.id}`; + if (deps.enqueueDelivery) { + await deps.enqueueDelivery({ + botId: params.requestingBotId, + channelId: parentRoute?.channelId ?? null, + routeId: parentRoute?.id ?? null, + sessionId: params.parentSessionId, + idempotencyKey: completionClientId, + ownerGeneration: parentRoute?.ownerGeneration ?? 0, + payload: { + version: 1, + kind: 'session-message', + targetSessionId: params.parentSessionId, + fallbackBotId: params.requestingBotId, + clientId: completionClientId, + message: completionMessage, + persistedContent: completionMessage, + }, + }); + return; + } + await deps.dispatch({ + targetSessionId: params.parentSessionId, + message: completionMessage, + persistedContent: completionMessage, + clientId: completionClientId, + }); + }; + + const updateTerminal = async (params: { + delegationId: string; + status: Extract; + resultSummary?: string | null; + outputArtifactsJson?: string; + lastError?: string | null; + tokensUsed?: number; + abortChild?: boolean; + }): Promise<{ + id: string; + parentSessionId: string | null; + childSessionId: string | null; + status: DelegationStatus; + } | null> => { + const db = getDbClient().drizzle; + const at = now(); + const updated = await getDbClient().tx( + 'bots.finishDelegation', + { + delegationId: params.delegationId, + status: params.status, + resultSummary: params.resultSummary?.slice(0, MAX_RESULT_CHARS) ?? null, + outputArtifactsJson: params.outputArtifactsJson ?? '[]', + lastError: params.lastError?.slice(0, 4_000) ?? null, + ...(typeof params.tokensUsed === 'number' ? { tokensUsed: params.tokensUsed } : {}), + completedAt: at, + }, + ); + if (updated) { + clearTimer(params.delegationId); + clearRetryTimer(params.delegationId); + emitChanged({ + delegationId: updated.id, + parentSessionId: updated.parentSessionId, + childSessionId: updated.childSessionId, + status: updated.status as DelegationStatus, + }); + if (updated.childSessionId) { + if (params.abortChild) { + await deps.abortSession(updated.childSessionId).catch(() => undefined); + } + await (deps.archiveSession?.(updated.childSessionId) ?? db + .update(sessions) + .set({ status: 'archived', updatedAt: at }) + .where(eq(sessions.id, updated.childSessionId)) + .then(() => undefined)) + .catch(() => undefined); + await deps.closeSession?.(updated.childSessionId).catch(() => undefined); + schedulePerTaskWorkspaceReclaim(updated.childSessionId); + } + } + return updated; + }; + + const failBudgetSubtree = async ( + root: DelegationRow, + graph: ReturnType, + lastError: string, + ): Promise => { + const affected = [root, ...descendantRows(root, graph)] + .filter((row) => isActiveDelegation(row.status)) + .sort((a, b) => b.depth - a.depth); + let rootChanged = false; + for (const row of affected) { + const changed = await updateTerminal({ + delegationId: row.id, + status: 'failed', + lastError, + tokensUsed: row.tokensUsed, + abortChild: true, + }); + rootChanged ||= row.id === root.id && changed !== null; + } + if (rootChanged) { + await deliverCompletion({ + ...root, + status: 'failed', + resultSummary: root.resultSummary, + lastError, + }); + } + return rootChanged; + }; + + const readLatestAssistantText = async (sessionId: string): Promise => { + const db = getDbClient().drizzle; + const [latest] = await db + .select({ content: messages.content }) + .from(messages) + .where( + and( + eq(messages.sessionId, sessionId), + eq(messages.role, 'assistant'), + isNull(messages.rewindAt), + ), + ) + .orderBy(desc(messages.createdAt), desc(messageRowid)) + .limit(1); + const text = visibleMessageTextForConversationSearch('assistant', latest?.content ?? '').trim(); + return text || null; + }; + + const timeoutDelegation = async (delegationId: string): Promise => { + const db = getDbClient().drizzle; + const [row] = await db + .select() + .from(botDelegations) + .where(eq(botDelegations.id, delegationId)) + .limit(1); + if (!row) return; + const lastError = 'Bot delegation exceeded its configured timeout.'; + const changed = await updateTerminal({ + delegationId, + status: 'timed-out', + lastError, + abortChild: true, + }); + if (changed) { + await deliverCompletion({ + ...row, + status: 'timed-out', + resultSummary: row.resultSummary, + lastError, + }); + } + }; + + const scheduleTimeout = (delegationId: string, deadlineAt: number): void => { + clearTimer(delegationId); + const delay = deadlineAt - now(); + if (delay <= 0) { + void timeoutDelegation(delegationId); + return; + } + const timer = setTimeout(() => void timeoutDelegation(delegationId), delay); + timer.unref?.(); + timers.set(delegationId, timer); + }; + + const resolveCaller = async (callerSessionId: string) => { + const db = getDbClient().drizzle; + const [link] = await db + .select({ + botId: botSessionLinks.botId, + role: botSessionLinks.role, + profileVersion: botSessionLinks.profileVersion, + sessionStatus: sessions.status, + permissionMode: sessions.permissionMode, + workingDir: sessions.workingDir, + remoteHostId: sessions.remoteHostId, + }) + .from(botSessionLinks) + .innerJoin(sessions, eq(sessions.id, botSessionLinks.sessionId)) + .where(eq(botSessionLinks.sessionId, callerSessionId)) + .limit(1); + if ( + !link + || link.sessionStatus !== 'active' + || (link.role !== 'canonical' && link.role !== 'route') + ) return null; + return link; + }; + + const resolveSessionProjectBinding = async ( + sessionId: string, + botId: string, + ): Promise => { + const db = getDbClient().drizzle; + const [attached] = await db + .select({ binding: botProjectBindings }) + .from(botWorkspaceAttachments) + .innerJoin(botWorkspaceLeases, eq(botWorkspaceLeases.id, botWorkspaceAttachments.leaseId)) + .innerJoin( + botProjectBindings, + eq(botProjectBindings.id, botWorkspaceLeases.projectBindingId), + ) + .where( + and( + eq(botWorkspaceAttachments.sessionId, sessionId), + isNull(botWorkspaceAttachments.detachedAt), + eq(botProjectBindings.botId, botId), + eq(botProjectBindings.status, 'active'), + ), + ) + .limit(1); + if (attached?.binding) return attached.binding; + + const [routed] = await db + .select({ binding: botProjectBindings }) + .from(botRoutes) + .innerJoin(botProjectBindings, eq(botProjectBindings.id, botRoutes.projectBindingId)) + .where( + and( + eq(botRoutes.currentSessionId, sessionId), + eq(botRoutes.botId, botId), + eq(botProjectBindings.status, 'active'), + ), + ) + .limit(1); + if (routed?.binding) return routed.binding; + + const [fallback] = await db + .select() + .from(botProjectBindings) + .where( + and( + eq(botProjectBindings.botId, botId), + eq(botProjectBindings.status, 'active'), + eq(botProjectBindings.isDefault, true), + ), + ) + .limit(1); + return fallback; + }; + + const buildDelegationPrompt = (row: { + id: string; + requestingBotId: string; + objective: string; + contextRefsJson: string; + artifactRefsJson: string; + }): string => [ + `You are receiving a task delegated by Cindy Bot ${row.requestingBotId}.`, + `Delegation ID: ${row.id}`, + `Objective:\n${row.objective}`, + parseStringArray(row.contextRefsJson).length + ? `Context references:\n${parseStringArray(row.contextRefsJson).join('\n')}` + : '', + parseStringArray(row.artifactRefsJson).length + ? `Artifacts:\n${parseStringArray(row.artifactRefsJson).join('\n')}` + : '', + 'Work independently using your own Bot profile. Return a concise result and artifact references to the requesting Bot.', + ] + .filter(Boolean) + .join('\n\n'); + + const validateDispatchPlan = async ( + row: DelegationRow, + ): Promise> => { + const plan = parseBotDelegationPlanSnapshot(row.permissionSnapshotJson); + if (!plan || plan.targetBotId !== row.targetBotId) { + return { + ok: false, + errorCode: 'PLAN_SNAPSHOT_INVALID', + message: 'Bot delegation 缺少有效的冻结执行计划', + }; + } + if (!row.childSessionId) { + return { ok: false, errorCode: 'CHILD_SESSION_MISSING', message: 'Bot delegation 子任务不存在' }; + } + const db = getDbClient().drizzle; + const [[parent], [child], [profile], [version]] = await Promise.all([ + row.parentSessionId + ? db.select({ status: sessions.status }).from(sessions).where(eq(sessions.id, row.parentSessionId)).limit(1) + : Promise.resolve([]), + db + .select({ + status: sessions.status, + source: sessions.source, + botId: botSessionLinks.botId, + role: botSessionLinks.role, + profileVersion: botSessionLinks.profileVersion, + }) + .from(sessions) + .innerJoin(botSessionLinks, eq(botSessionLinks.sessionId, sessions.id)) + .where(eq(sessions.id, row.childSessionId)) + .limit(1), + db + .select({ status: botProfiles.status }) + .from(botProfiles) + .where(eq(botProfiles.id, row.targetBotId)) + .limit(1), + db + .select({ + capabilitiesJson: botProfileVersions.capabilitiesJson, + identitySource: botProfileVersions.identitySource, + }) + .from(botProfileVersions) + .where( + and( + eq(botProfileVersions.botId, row.targetBotId), + eq(botProfileVersions.version, row.targetProfileVersion), + ), + ) + .limit(1), + ]); + if (row.parentSessionId && parent?.status !== 'active') { + return { ok: false, errorCode: 'PARENT_SESSION_INACTIVE', message: '委派来源任务已归档或删除' }; + } + if ( + !child + || child.status !== 'active' + || child.source !== 'bot' + || child.botId !== row.targetBotId + || child.role !== 'route' + || child.profileVersion !== row.targetProfileVersion + ) { + return { ok: false, errorCode: 'CHILD_SESSION_INVALID', message: 'Bot delegation 子任务归属已失效' }; + } + if (profile?.status !== 'active') { + return { ok: false, errorCode: 'TARGET_BOT_UNAVAILABLE', message: '目标 Bot 已暂停或归档' }; + } + if ( + !version + || sha256(version.capabilitiesJson) !== plan.target.capabilitiesSha256 + || sha256(version.identitySource) !== plan.target.identitySha256 + ) { + return { ok: false, errorCode: 'PROFILE_SNAPSHOT_STALE', message: '目标 Bot 的冻结 Profile 已失效' }; + } + if (plan.workspace) { + const [binding] = await db + .select() + .from(botProjectBindings) + .where(eq(botProjectBindings.id, plan.workspace.bindingId)) + .limit(1); + if ( + !binding + || binding.botId !== row.targetBotId + || binding.status !== 'active' + || binding.updatedAt !== plan.workspace.bindingUpdatedAt + || binding.projectKey !== plan.workspace.projectKey + ) { + return { + ok: false, + errorCode: 'WORKSPACE_SNAPSHOT_STALE', + message: '目标 Bot 的项目或路径授权在排队期间发生变化', + }; + } + } + return { ok: true, plan }; + }; + + const runtimeSnapshotUnavailable = async ( + childSessionId: string, + plan: BotDelegationPlanSnapshot, + ): Promise => { + const db = getDbClient().drizzle; + const [runtime] = await db + .select() + .from(botRuntimeSnapshots) + .where(eq(botRuntimeSnapshots.sessionId, childSessionId)) + .orderBy(desc(botRuntimeSnapshots.preparedAt)) + .limit(1); + if (!runtime) { + return deps.requireRuntimeSnapshot + ? '目标 Bot runtime 未按冻结 Profile 准备完成' + : null; + } + if (runtime.profileVersion !== plan.target.profileVersion) { + return '目标 Bot runtime 未按冻结 Profile 准备完成'; + } + if (runtime.status === 'failed') return '目标 Bot runtime 启动失败'; + const resolved = parseRecord(runtime.resolvedJson); + const unavailable = [ + ...parseStringArray(JSON.stringify(resolved.unavailableSkills ?? [])), + ...parseStringArray(JSON.stringify(resolved.unavailableMcpServers ?? [])), + ...parseStringArray(JSON.stringify(resolved.unavailableToolsets ?? [])), + ]; + const memoryRefs = Array.isArray(resolved.memoryRefs) ? resolved.memoryRefs : []; + const memoryUnavailable = memoryRefs.some( + (ref) => ref && typeof ref === 'object' && (ref as Record).status === 'unavailable', + ); + if (unavailable.length > 0 || memoryUnavailable) { + return `目标 Bot 缺少冻结能力: ${unavailable.join(', ') || 'memory'}`; + } + return null; + }; + + function scheduleDispatchRetry(delegationId: string, attempt: number): void { + clearRetryTimer(delegationId); + const delay = Math.min(MAX_RETRY_DELAY_MS, 1_000 * 2 ** Math.min(attempt, 6)); + const timer = setTimeout(() => { + retryTimers.delete(delegationId); + void attemptDispatch(delegationId, attempt + 1); + }, delay); + timer.unref?.(); + retryTimers.set(delegationId, timer); + } + + async function attemptDispatch( + delegationId: string, + attempt = 0, + ): Promise<{ ok: boolean; status: 'queued' | 'waiting' | 'running'; error?: DispatchResult }> { + const db = getDbClient().drizzle; + const [row] = await db + .select() + .from(botDelegations) + .where(eq(botDelegations.id, delegationId)) + .limit(1); + if ( + !row + || !row.childSessionId + || (row.status !== 'queued' && row.status !== 'waiting') + ) { + return { ok: true, status: row?.status === 'running' ? 'running' : 'queued' }; + } + const deadlineAt = readDeadline(row.permissionSnapshotJson); + if (deadlineAt !== null && deadlineAt <= now()) { + await timeoutDelegation(delegationId); + return { ok: false, status: 'waiting' }; + } + const validation = await validateDispatchPlan(row); + if (!validation.ok) { + const changed = await updateTerminal({ + delegationId: row.id, + status: 'failed', + lastError: `${validation.errorCode}: ${validation.message}`, + abortChild: true, + }); + if (changed) { + await deliverCompletion({ + ...row, + status: 'failed', + lastError: `${validation.errorCode}: ${validation.message}`, + }); + } + return { ok: false, status: 'waiting' }; + } + const dispatched = await deps.dispatch({ + targetSessionId: row.childSessionId, + message: buildDelegationPrompt(row), + persistedContent: row.objective, + clientId: `bot-delegation-start:${row.id}`, + onAccepted: async () => { + const unavailable = await runtimeSnapshotUnavailable(row.childSessionId!, validation.plan); + if (unavailable) { + const changed = await updateTerminal({ + delegationId: row.id, + status: 'failed', + lastError: `TARGET_CAPABILITY_UNAVAILABLE: ${unavailable}`, + abortChild: true, + }); + if (changed) { + await deliverCompletion({ + ...row, + status: 'failed', + lastError: `TARGET_CAPABILITY_UNAVAILABLE: ${unavailable}`, + }); + } + return; + } + const acceptedAt = now(); + const [accepted] = await db + .update(botDelegations) + .set({ status: 'running', acceptedAt, lastError: null, updatedAt: acceptedAt }) + .where( + and( + eq(botDelegations.id, row.id), + inArray(botDelegations.status, ['queued', 'waiting']), + ), + ) + .returning({ + id: botDelegations.id, + parentSessionId: botDelegations.parentSessionId, + childSessionId: botDelegations.childSessionId, + status: botDelegations.status, + }); + if (accepted) { + clearRetryTimer(accepted.id); + emitChanged({ + delegationId: accepted.id, + parentSessionId: accepted.parentSessionId, + childSessionId: accepted.childSessionId, + status: accepted.status as DelegationStatus, + }); + } + }, + }); + if (dispatched.ok) { + const [current] = await db + .select({ status: botDelegations.status }) + .from(botDelegations) + .where(eq(botDelegations.id, row.id)) + .limit(1); + return { + ok: true, + status: current?.status === 'running' + ? 'running' + : current?.status === 'waiting' + ? 'waiting' + : 'queued', + }; + } + const failedAt = now(); + const [waiting] = await db + .update(botDelegations) + .set({ + status: 'waiting', + lastError: `${dispatched.errorCode}: ${dispatched.message}`.slice(0, 4_000), + updatedAt: failedAt, + }) + .where( + and( + eq(botDelegations.id, row.id), + inArray(botDelegations.status, ['queued', 'waiting']), + ), + ) + .returning({ + id: botDelegations.id, + parentSessionId: botDelegations.parentSessionId, + childSessionId: botDelegations.childSessionId, + status: botDelegations.status, + }); + if (waiting) { + emitChanged({ + delegationId: waiting.id, + parentSessionId: waiting.parentSessionId, + childSessionId: waiting.childSessionId, + status: 'waiting', + }); + scheduleDispatchRetry(waiting.id, attempt); + } + return { ok: false, status: 'waiting', error: dispatched }; + } + + async function resumeRunningDelegation(delegationId: string, attempt = 0): Promise { + const db = getDbClient().drizzle; + const [row] = await db + .select() + .from(botDelegations) + .where(eq(botDelegations.id, delegationId)) + .limit(1); + if (!row || row.status !== 'running') return; + const deadlineAt = readDeadline(row.permissionSnapshotJson); + if (deadlineAt !== null && deadlineAt <= now()) { + await timeoutDelegation(row.id); + return; + } + if (!row.childSessionId) { + const lastError = 'Bot delegation child task is missing after restart.'; + const changed = await updateTerminal({ + delegationId: row.id, + status: 'failed', + lastError, + }); + if (changed) await deliverCompletion({ ...row, status: 'failed', lastError }); + return; + } + const [child] = await db + .select({ + status: sessions.status, + activeTurnStartedAt: sessions.activeTurnStartedAt, + lastTurnEndedAt: sessions.lastTurnEndedAt, + }) + .from(sessions) + .where(eq(sessions.id, row.childSessionId)) + .limit(1); + if (!child || child.status !== 'active') { + const lastError = `Bot delegation child task is ${child?.status ?? 'missing'} after restart.`; + const changed = await updateTerminal({ + delegationId: row.id, + status: 'failed', + lastError, + }); + if (changed) await deliverCompletion({ ...row, status: 'failed', lastError }); + return; + } + + if ( + child.activeTurnStartedAt !== null + && child.lastTurnEndedAt !== null + && child.lastTurnEndedAt >= child.activeTurnStartedAt + ) { + const resultText = await readLatestAssistantText(row.childSessionId); + if (resultText) { + await settleSession({ + childSessionId: row.childSessionId, + outcome: 'done', + resultText, + }); + } else { + const lastError = 'Bot delegation ended before restart without a recoverable result.'; + const changed = await updateTerminal({ + delegationId: row.id, + status: 'failed', + lastError, + }); + if (changed) await deliverCompletion({ ...row, status: 'failed', lastError }); + } + return; + } + + const validation = await validateDispatchPlan(row); + if (!validation.ok) { + const lastError = `${validation.errorCode}: ${validation.message}`; + const changed = await updateTerminal({ + delegationId: row.id, + status: 'failed', + lastError, + abortChild: true, + }); + if (changed) await deliverCompletion({ ...row, status: 'failed', lastError }); + return; + } + + const resumeEpoch = child.activeTurnStartedAt ?? row.acceptedAt ?? row.createdAt; + const clientId = `bot-delegation-resume:${row.id}:${resumeEpoch}`; + const message = [ + 'The previous delegated turn was interrupted by a Cindy host restart.', + 'Inspect the existing task history, continue the original objective, and return the final result.', + `Delegation ID: ${row.id}`, + `Objective:\n${row.objective}`, + ].join('\n\n'); + const dispatched = await deps.dispatch({ + targetSessionId: row.childSessionId, + message, + persistedContent: row.objective, + clientId, + }); + if (dispatched.ok) { + clearRetryTimer(row.id); + await db + .update(botDelegations) + .set({ lastError: null, updatedAt: now() }) + .where(and(eq(botDelegations.id, row.id), eq(botDelegations.status, 'running'))); + return; + } + await db + .update(botDelegations) + .set({ + lastError: `${dispatched.errorCode}: ${dispatched.message}`.slice(0, 4_000), + updatedAt: now(), + }) + .where(and(eq(botDelegations.id, row.id), eq(botDelegations.status, 'running'))); + clearRetryTimer(row.id); + const delay = Math.min(MAX_RETRY_DELAY_MS, 1_000 * 2 ** Math.min(attempt, 6)); + const timer = setTimeout(() => { + retryTimers.delete(row.id); + void resumeRunningDelegation(row.id, attempt + 1); + }, delay); + timer.unref?.(); + retryTimers.set(row.id, timer); + } + + const listBots = async ( + callerSessionId: string, + ): Promise> => { + const caller = await resolveCaller(callerSessionId); + if (!caller) { + return { ok: false, errorCode: 'NOT_A_BOT_SESSION', message: '当前任务不属于 Cindy Bot' }; + } + const db = getDbClient().drizzle; + const automationContext = await resolveAutomationContextForSession(callerSessionId); + const automationTargets = automationContext?.plan.delegation.targets ?? []; + if (automationContext && automationTargets.length === 0) { + return { ok: true, bots: [] }; + } + const automationTargetByBot = new Map( + automationTargets.map((target) => [target.botId, target]), + ); + const rows = await db + .select({ + id: botProfiles.id, + name: botProfiles.displayName, + description: botProfiles.description, + currentVersion: botProfiles.currentVersion, + canonicalSessionId: botProfiles.canonicalSessionId, + status: botProfiles.status, + }) + .from(botProfiles) + .where( + automationContext + ? inArray(botProfiles.id, automationTargets.map((target) => target.botId)) + : eq(botProfiles.status, 'active'), + ) + .orderBy(desc(botProfiles.updatedAt)); + if (rows.length === 0) return { ok: true, bots: [] }; + + const botIds = rows.map((row) => row.id); + const [versions, runtimes, bindings, delegations, automations] = await Promise.all([ + db + .select({ + botId: botProfileVersions.botId, + version: botProfileVersions.version, + capabilitiesJson: botProfileVersions.capabilitiesJson, + identitySource: botProfileVersions.identitySource, + }) + .from(botProfileVersions) + .where(inArray(botProfileVersions.botId, botIds)), + db + .select() + .from(botRuntimeSnapshots) + .where(inArray(botRuntimeSnapshots.botId, botIds)) + .orderBy(desc(botRuntimeSnapshots.preparedAt)), + db + .select() + .from(botProjectBindings) + .where( + and( + inArray(botProjectBindings.botId, botIds), + eq(botProjectBindings.status, 'active'), + ), + ), + db + .select({ + requestingBotId: botDelegations.requestingBotId, + targetBotId: botDelegations.targetBotId, + }) + .from(botDelegations) + .where(inArray(botDelegations.status, [...ACTIVE_DELEGATION_STATUSES])), + db + .select({ botId: botAutomationLinks.botId }) + .from(botAutomationLinks) + .where( + and( + inArray(botAutomationLinks.botId, botIds), + eq(botAutomationLinks.status, 'active'), + ), + ), + ]); + + const versionByBot = new Map( + versions + .filter((version) => rows.some( + (row) => row.id === version.botId + && version.version === ( + automationTargetByBot.get(row.id)?.profileVersion ?? row.currentVersion + ), + )) + .map((version) => [version.botId, version]), + ); + const runtimeByBot = new Map(); + for (const runtime of runtimes) { + const profile = rows.find((row) => row.id === runtime.botId); + const automationWorkspace = profile + ? automationTargetByBot.get(profile.id)?.defaultWorkspace + : null; + if ( + profile + && runtime.profileVersion === ( + automationTargetByBot.get(profile.id)?.profileVersion ?? profile.currentVersion + ) + && isBotRuntimeSnapshotForCapabilityTarget({ + runtimeSessionId: runtime.sessionId, + runtimeWorkingDir: runtime.workingDir, + canonicalSessionId: profile.canonicalSessionId, + automationWorkingDir: automationWorkspace?.workingDir, + }) + && !runtimeByBot.has(runtime.botId) + ) { + runtimeByBot.set(runtime.botId, runtime); + } + } + const projectsByBot = new Map(); + for (const binding of bindings) { + const items = projectsByBot.get(binding.botId) ?? []; + items.push(binding); + projectsByBot.set(binding.botId, items); + } + const inboundCounts = new Map(); + const outboundCounts = new Map(); + for (const delegation of delegations) { + inboundCounts.set( + delegation.targetBotId, + (inboundCounts.get(delegation.targetBotId) ?? 0) + 1, + ); + outboundCounts.set( + delegation.requestingBotId, + (outboundCounts.get(delegation.requestingBotId) ?? 0) + 1, + ); + } + const automationCounts = new Map(); + for (const automation of automations) { + automationCounts.set(automation.botId, (automationCounts.get(automation.botId) ?? 0) + 1); + } + + const automationAuthorizationByBot = new Map(); + if (automationContext) { + await Promise.all(automationTargets.map(async (target) => { + const validation = await validateAutomationTargetSnapshot(target); + automationAuthorizationByBot.set( + target.botId, + validation.ok + ? { state: 'allowed', reason: null } + : { state: 'stale', reason: validation.reason }, + ); + })); + } + + const bots: BotCapabilityCatalogEntry[] = rows.flatMap((row) => { + const version = versionByBot.get(row.id); + if (!version) return []; + const configured = configuredCapabilitySnapshot(version); + const runtime = runtimeByBot.get(row.id); + const resolved = runtime ? parseRecord(runtime.resolvedJson) : {}; + const failure = runtime ? parseRecord(runtime.failureJson) : {}; + const unavailableMemoryRefs = Array.isArray(resolved.memoryRefs) + ? resolved.memoryRefs.flatMap((value) => { + if (!value || typeof value !== 'object') return []; + const ref = value as Record; + return ref.status === 'unavailable' && typeof ref.kind === 'string' ? [ref.kind] : []; + }) + : []; + const runtimeStatus = runtime?.status === 'applied' + ? 'ready' + : runtime?.status === 'degraded' + ? 'degraded' + : runtime?.status === 'failed' + ? 'failed' + : 'unverified'; + const runtimeReason = runtimeStatus === 'degraded' + ? 'Some configured capabilities are unavailable in the current runtime' + : runtimeStatus === 'failed' + ? [failure.stage, failure.errorCode ?? failure.errorName] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .join(': ') || 'The current Profile failed to start' + : runtimeStatus === 'unverified' + ? runtime + ? 'The current Profile runtime was prepared but has not completed startup' + : 'The current Profile has not produced a native runtime snapshot yet' + : null; + const activeInboundDelegations = inboundCounts.get(row.id) ?? 0; + const activeOutboundDelegations = outboundCounts.get(row.id) ?? 0; + const activeAutomations = automationCounts.get(row.id) ?? 0; + const resolvedSkills = unknownStringList(resolved.skills); + const resolvedMcpServers = unknownStringList(resolved.mcpServers); + const resolvedToolsets = unknownStringList(resolved.toolsets); + const capabilityTags = [ + `harness:${configured.agentKind}`, + `model:${configured.model}`, + ...resolvedSkills.map((item) => `skill:${item}`), + ...resolvedMcpServers.map((item) => `mcp:${item}`), + ...resolvedToolsets.map((item) => `toolset:${item}`), + ...(configured.memoryEnabled && unavailableMemoryRefs.length === 0 ? ['memory'] : []), + ...(configured.automationEnabled ? ['automation'] : []), + ...( + automationTargetByBot.get(row.id)?.defaultWorkspace + ? [`workspace:${automationTargetByBot.get(row.id)!.defaultWorkspace!.workspacePolicy}`] + : (projectsByBot.get(row.id) ?? []).map( + (binding) => `workspace:${binding.workspacePolicy}`, + ) + ), + ]; + const frozenWorkspace = automationTargetByBot.get(row.id)?.defaultWorkspace; + return [{ + id: row.id, + name: row.name, + description: row.description, + currentVersion: row.currentVersion, + canonicalSessionId: row.canonicalSessionId, + isCurrent: row.id === caller.botId, + configured, + runtime: { + status: runtimeStatus, + snapshotId: runtime?.id ?? null, + sessionId: runtime?.sessionId ?? null, + preparedAt: runtime?.preparedAt ?? null, + reason: runtimeReason, + resolvedSkills, + unavailableSkills: unknownStringList(resolved.unavailableSkills), + resolvedMcpServers, + unavailableMcpServers: unknownStringList(resolved.unavailableMcpServers), + resolvedToolsets, + unavailableToolsets: unknownStringList(resolved.unavailableToolsets), + unavailableMemoryRefs, + }, + projects: automationContext + ? frozenWorkspace + ? [{ ...frozenWorkspace, isDefault: true }] + : [] + : (projectsByBot.get(row.id) ?? []).map((binding) => ({ + bindingId: binding.id, + projectKey: binding.projectKey, + workingDir: binding.workingDir, + remoteHostId: binding.remoteHostId, + defaultBranch: binding.defaultBranch, + workspacePolicy: binding.workspacePolicy, + allowedPaths: parseAllowedPaths(binding.allowedPathsJson), + isDefault: binding.isDefault, + })), + activeInboundDelegations, + activeOutboundDelegations, + activeAutomations, + busy: activeInboundDelegations > 0 || activeOutboundDelegations > 0, + capabilityTags: [...new Set(capabilityTags)], + ...(automationContext + ? { automationAuthorization: automationAuthorizationByBot.get(row.id) ?? { + state: 'stale' as const, + reason: '目标 Bot 不在本轮 Automation 的冻结协作计划中', + } } + : {}), + }]; + }); + return { + ok: true, + bots, + }; + }; + + const delegateToBot = async ( + input: DelegateToBotInput, + ): Promise> => { + const objective = input.objective.trim(); + if (!objective || objective.length > MAX_OBJECTIVE_CHARS) { + return { + ok: false, + errorCode: 'INVALID_ARGS', + message: `objective 必须为 1-${MAX_OBJECTIVE_CHARS} 个字符`, + }; + } + const requestedMaxDepth = Math.min( + HARD_MAX_DEPTH, + Math.max(1, Math.floor(input.maxDepth ?? DEFAULT_MAX_DEPTH)), + ); + const requestedTimeoutMs = Math.min( + MAX_TIMEOUT_MS, + Math.max(1_000, Math.floor(input.timeoutMs ?? DEFAULT_TIMEOUT_MS)), + ); + if ( + input.budgetTokens !== undefined + && (!Number.isSafeInteger(input.budgetTokens) || input.budgetTokens <= 0) + ) { + return { ok: false, errorCode: 'INVALID_ARGS', message: 'budget_tokens 必须是正整数' }; + } + + const db = getDbClient().drizzle; + const caller = await resolveCaller(input.callerSessionId); + if (!caller) { + return { ok: false, errorCode: 'NOT_A_BOT_SESSION', message: '当前任务不属于 Cindy Bot' }; + } + const automationContext = await resolveAutomationContextForSession(input.callerSessionId); + if (automationContext && now() >= automationContext.plan.deadlineAt) { + return { + ok: false, + errorCode: 'AUTOMATION_DEADLINE_EXPIRED', + message: '本轮 Bot Automation 已超过冻结期限,不能再创建委派', + }; + } + const [parentDelegation] = await db + .select() + .from(botDelegations) + .where(eq(botDelegations.childSessionId, input.callerSessionId)) + .orderBy(desc(botDelegations.createdAt)) + .limit(1); + if (parentDelegation && !isActiveDelegation(parentDelegation.status)) { + return { + ok: false, + errorCode: 'PARENT_DELEGATION_TERMINAL', + message: '当前 Bot 委派已经结束,不能继续创建子委派', + }; + } + const parentPlan = parentDelegation + ? parseBotDelegationPlanSnapshot(parentDelegation.permissionSnapshotJson) + : null; + const legacyParentPermissionSnapshot = parentDelegation && !parentPlan + ? parseRecord(parentDelegation.permissionSnapshotJson) + : {}; + const configuredParentMaxDepth = parentPlan?.limits.maxDepth + ?? legacyParentPermissionSnapshot.maxDepth; + const parentMaxDepth = typeof configuredParentMaxDepth === 'number' + && Number.isSafeInteger(configuredParentMaxDepth) + ? Math.max(1, Math.min(HARD_MAX_DEPTH, configuredParentMaxDepth)) + : HARD_MAX_DEPTH; + const automationMaxDepth = automationContext?.plan.limits.maxDelegationDepth ?? HARD_MAX_DEPTH; + const maxDepth = Math.min(requestedMaxDepth, parentMaxDepth, automationMaxDepth); + const parentDepth = parentDelegation?.depth ?? 0; + if (parentDepth >= maxDepth) { + return { + ok: false, + errorCode: 'MAX_DEPTH', + message: `当前 Bot 委派深度 ${parentDepth} 已达到 max_depth=${maxDepth}`, + }; + } + const lineage = parentDelegation + ? parseStringArray(parentDelegation.lineageJson) + : [caller.botId]; + if (!lineage.includes(caller.botId)) lineage.push(caller.botId); + if (lineage.includes(input.targetBotId)) { + return { + ok: false, + errorCode: 'DELEGATION_CYCLE', + message: '目标 Bot 已在当前委派链中,拒绝形成循环', + }; + } + + const parentDeadlineAt = parentPlan?.limits.deadlineAt + ?? (typeof legacyParentPermissionSnapshot.deadlineAt === 'number' + ? legacyParentPermissionSnapshot.deadlineAt + : null); + const hardDeadlineAt = Math.min( + automationContext?.plan.deadlineAt ?? Number.POSITIVE_INFINITY, + typeof parentDeadlineAt === 'number' && Number.isFinite(parentDeadlineAt) + ? parentDeadlineAt + : Number.POSITIVE_INFINITY, + ); + const remainingDeadlineMs = Number.isFinite(hardDeadlineAt) + ? Math.max(0, hardDeadlineAt - now()) + : requestedTimeoutMs; + if (remainingDeadlineMs < 1_000) { + return { + ok: false, + errorCode: 'DELEGATION_DEADLINE_EXPIRED', + message: '上级 Bot 任务或 Automation 的剩余时间不足以启动新委派', + }; + } + const timeoutMs = Math.min(requestedTimeoutMs, remainingDeadlineMs); + + let effectiveBudgetTokens = input.budgetTokens ?? null; + if (parentDelegation) { + const allDelegations = await db.select().from(botDelegations); + const graph = buildDelegationGraph(allDelegations); + const parent = graph.byId.get(parentDelegation.id) ?? parentDelegation; + const ceilings: DelegationRow[] = []; + const seen = new Set(); + let cursor: DelegationRow | undefined = parent; + while (cursor && !seen.has(cursor.id)) { + seen.add(cursor.id); + if (cursor.budgetTokens !== null) ceilings.push(cursor); + cursor = cursor.parentSessionId + ? graph.byChildSessionId.get(cursor.parentSessionId) + : undefined; + } + if (ceilings.length > 0) { + const available = Math.min(...ceilings.map((ceiling) => + Math.max(0, ceiling.budgetTokens! - committedSubtreeTokens(ceiling, graph)))); + if (available <= 0) { + return { + ok: false, + errorCode: 'BUDGET_EXHAUSTED', + message: '上级 Bot 委派的 token 预算已经用完', + }; + } + if (input.budgetTokens !== undefined && input.budgetTokens > available) { + return { + ok: false, + errorCode: 'BUDGET_EXCEEDED', + message: `子委派预算不能超过上级剩余额度 ${available}`, + }; + } + effectiveBudgetTokens = input.budgetTokens ?? available; + } + } + if (automationContext && automationContext.plan.limits.budgetTokens !== null) { + const automationBudget = automationContext.plan.limits.budgetTokens; + const [rootSession] = await db + .select({ tokensUsed: sessions.totalTokenUsage }) + .from(sessions) + .where(eq(sessions.id, automationContext.rootSessionId)) + .limit(1); + const graph = buildDelegationGraph(await db.select().from(botDelegations)); + const rootDelegations = graph.childrenByParentSessionId.get( + automationContext.rootSessionId, + ) ?? []; + const committed = Math.max(0, rootSession?.tokensUsed ?? 0) + + rootDelegations.reduce( + (sum, delegation) => sum + committedSubtreeTokens(delegation, graph), + 0, + ); + const available = Math.max(0, automationBudget - committed); + if (available <= 0) { + return { + ok: false, + errorCode: 'AUTOMATION_BUDGET_EXHAUSTED', + message: '本轮 Bot Automation 的 token 预算已经用完', + }; + } + if (input.budgetTokens !== undefined && input.budgetTokens > available) { + return { + ok: false, + errorCode: 'AUTOMATION_BUDGET_EXCEEDED', + message: `子委派预算不能超过本轮 Automation 剩余额度 ${available}`, + }; + } + if (effectiveBudgetTokens !== null && effectiveBudgetTokens > available) { + effectiveBudgetTokens = available; + } + } + + const active = await db + .select({ id: botDelegations.id }) + .from(botDelegations) + .where( + and( + eq(botDelegations.requestingBotId, caller.botId), + inArray(botDelegations.status, [...ACTIVE_DELEGATION_STATUSES]), + ), + ); + if (active.length >= maxActiveChildren) { + return { + ok: false, + errorCode: 'CONCURRENCY_LIMIT', + message: `当前 Bot 已有 ${active.length} 个进行中的委派,最多 ${maxActiveChildren} 个`, + }; + } + + let target: typeof botProfiles.$inferSelect; + let version: typeof botProfileVersions.$inferSelect; + let binding: ProjectBindingRow | undefined; + if (automationContext) { + if (automationContext.plan.delegation.mode === 'none') { + return { + ok: false, + errorCode: 'AUTOMATION_DELEGATION_DISABLED', + message: '本轮 Bot Automation 的冻结计划不允许调用其它 Bot', + }; + } + const frozenTarget = automationContext.plan.delegation.targets.find( + (candidate) => candidate.botId === input.targetBotId, + ); + if (!frozenTarget) { + return { + ok: false, + errorCode: 'AUTOMATION_TARGET_NOT_ALLOWED', + message: '目标 Bot 不在本轮 Automation 的冻结协作名单中', + }; + } + const validation = await validateAutomationTargetSnapshot(frozenTarget); + if (!validation.ok) { + return { + ok: false, + errorCode: 'AUTOMATION_TARGET_STALE', + message: validation.reason, + }; + } + ({ profile: target, version, binding } = validation); + } else { + const [currentTarget] = await db + .select() + .from(botProfiles) + .where(and(eq(botProfiles.id, input.targetBotId), eq(botProfiles.status, 'active'))) + .limit(1); + if (!currentTarget) { + return { ok: false, errorCode: 'BOT_NOT_FOUND', message: '目标 Bot 不存在或已停用' }; + } + const [currentVersion] = await db + .select() + .from(botProfileVersions) + .where( + and( + eq(botProfileVersions.botId, currentTarget.id), + eq(botProfileVersions.version, currentTarget.currentVersion), + ), + ) + .limit(1); + if (!currentVersion) { + return { ok: false, errorCode: 'PROFILE_NOT_FOUND', message: '目标 Bot Profile 版本不存在' }; + } + [binding] = await db + .select() + .from(botProjectBindings) + .where( + and( + eq(botProjectBindings.botId, currentTarget.id), + eq(botProjectBindings.status, 'active'), + eq(botProjectBindings.isDefault, true), + ), + ) + .limit(1); + target = currentTarget; + version = currentVersion; + } + const callerBinding = await resolveSessionProjectBinding(input.callerSessionId, caller.botId); + const contextRefs = normalizeDelegationReferences({ + refs: input.contextRefs, + callerBinding, + targetBinding: binding, + field: 'context_refs', + }); + if (!contextRefs.ok) return contextRefs; + const artifactRefs = normalizeDelegationReferences({ + refs: input.artifactRefs, + callerBinding, + targetBinding: binding, + field: 'artifact_refs', + }); + if (!artifactRefs.ok) return artifactRefs; + const delegationId = createId(); + const childSessionId = resolveBusinessSessionId(undefined); + const createdAt = now(); + const deadlineAt = Math.min(createdAt + timeoutMs, hardDeadlineAt); + const workspaceKind = binding ? 'project' : 'dialogue'; + const workingDir = binding?.workingDir ?? ensureDialogueWorkspaceDir(childSessionId, createdAt); + const config = parseRecord(version.capabilitiesJson); + const permissionMode = targetPermissionMode(config, caller.permissionMode); + const skills = configStringList(config, 'skills'); + const mcpServers = configStringList(config, 'mcpServers'); + const toolsets = configStringList(config, 'toolsets').length > 0 + ? configStringList(config, 'toolsets') + : configStringList(config, 'tools').filter((item) => !['files', 'browser', 'mcp'].includes(item)); + const plan: BotDelegationPlanSnapshot = { + version: 1, + createdAt, + targetBotId: target.id, + target: { + profileVersion: target.currentVersion, + agentKind: botAgentKind(config), + model: + typeof config.model === 'string' && config.model.trim() + ? config.model.trim() + : 'claude-sonnet-4-6', + capabilitiesSha256: sha256(version.capabilitiesJson), + identitySha256: sha256(version.identitySource), + skills, + skillMode: configuredMode(config.skillMode, skills), + mcpServers, + mcpMode: configuredMode(config.mcpMode, mcpServers), + toolsets, + toolsetMode: configuredMode(config.toolsetMode, toolsets), + memoryEnabled: config.memory !== false, + automationEnabled: config.automation === true, + }, + workspace: workspaceSnapshot(binding), + access: { + callerProjectBindingId: callerBinding?.id ?? null, + projectKey: binding?.projectKey ?? null, + remoteHostId: binding?.remoteHostId ?? null, + contextRefs: contextRefs.refs, + artifactRefs: artifactRefs.refs, + }, + limits: { + maxDepth, + budgetTokens: effectiveBudgetTokens, + timeoutMs, + deadlineAt, + }, + permission: { + mode: permissionMode, + requesterMode: caller.permissionMode ?? null, + targetConfigured: config.permissions === 'trusted' ? 'trusted' : 'ask', + }, + }; + const childRow = { + ...sessionCreateToRow( + childSessionId, + { + workspaceKind, + workingDir, + model: + plan.target.model, + agentKind: plan.target.agentKind, + permissionMode, + remoteHostId: binding?.remoteHostId ?? undefined, + source: 'bot', + parentSessionId: input.callerSessionId, + }, + createdAt, + ), + title: `${target.displayName} · ${objective.split('\n')[0]!.slice(0, 60)}`, + }; + + try { + await ensureProjectGitInitialized({ + workingDir, + workspaceKind, + remoteHostId: binding?.remoteHostId ?? null, + sessionId: childSessionId, + autoSnapshotEnabled: readGitSafetySettings().autoSnapshotEnabled, + source: 'bot-delegation', + }); + const localChannelId = `${target.id}:local`; + await getDbClient().tx('bots.createDelegation', { + maxActiveChildren, + localChannelId, + session: { + id: childRow.id, + title: childRow.title, + workingDir: childRow.workingDir ?? null, + workspaceKind: childRow.workspaceKind, + model: childRow.model, + effort: childRow.effort, + permissionMode: childRow.permissionMode, + agentKind: childRow.agentKind, + remoteHostId: childRow.remoteHostId ?? null, + providerId: childRow.providerId ?? null, + parentSessionId: input.callerSessionId, + extraDirs: childRow.extraDirs, + source: childRow.source, + createdAt: childRow.createdAt, + updatedAt: childRow.updatedAt, + }, + delegation: { + id: delegationId, + requestingBotId: caller.botId, + targetBotId: target.id, + parentSessionId: input.callerSessionId, + childSessionId, + objective, + contextRefsJson: JSON.stringify(contextRefs.refs), + artifactRefsJson: JSON.stringify(artifactRefs.refs), + permissionSnapshotJson: JSON.stringify(plan), + lineageJson: JSON.stringify([...lineage, target.id]), + targetProfileVersion: target.currentVersion, + depth: parentDepth + 1, + budgetTokens: effectiveBudgetTokens ?? null, + createdAt, + }, + }); + emitChanged({ + delegationId, + parentSessionId: input.callerSessionId, + childSessionId, + status: 'queued', + }); + } catch (error) { + if (!binding) await fs.rm(workingDir, { recursive: true, force: true }).catch(() => {}); + if (error instanceof Error && error.message === 'BOT_DELEGATION_CONCURRENCY_LIMIT') { + return { + ok: false, + errorCode: 'CONCURRENCY_LIMIT', + message: `当前 Bot 的进行中委派已达到 ${maxActiveChildren} 个`, + }; + } + throw error; + } + + deps.broadcastSessionCreated?.(childSessionId); + scheduleTimeout(delegationId, deadlineAt); + const dispatchResult = await attemptDispatch(delegationId); + return { + ok: true, + delegationId, + childSessionId, + status: dispatchResult.status, + targetBotId: target.id, + targetBotName: target.displayName, + depth: parentDepth + 1, + deadlineAt, + }; + }; + + const listDelegations = async ( + callerSessionId: string, + status?: DelegationStatus, + ): Promise> => { + const caller = await resolveCaller(callerSessionId); + if (!caller) { + return { ok: false, errorCode: 'NOT_A_BOT_SESSION', message: '当前任务不属于 Cindy Bot' }; + } + const db = getDbClient().drizzle; + const rows = await db + .select() + .from(botDelegations) + .where( + status + ? and(eq(botDelegations.requestingBotId, caller.botId), eq(botDelegations.status, status)) + : eq(botDelegations.requestingBotId, caller.botId), + ) + .orderBy(desc(botDelegations.createdAt)) + .limit(100); + const profiles = await db + .select({ id: botProfiles.id, displayName: botProfiles.displayName }) + .from(botProfiles); + const profileNames = new Map(profiles.map((profile) => [profile.id, profile.displayName])); + const deliveryKeys = rows.map((row) => `bot-delegation-completion:${row.id}`); + const deliveryRows = deliveryKeys.length > 0 + ? await db + .select({ + id: botDeliveryOutbox.id, + idempotencyKey: botDeliveryOutbox.idempotencyKey, + status: botDeliveryOutbox.status, + attempts: botDeliveryOutbox.attempts, + lastError: botDeliveryOutbox.lastError, + deliveryReceiptJson: botDeliveryOutbox.deliveryReceiptJson, + }) + .from(botDeliveryOutbox) + .where(inArray(botDeliveryOutbox.idempotencyKey, deliveryKeys)) + : []; + const completionDeliveryByKey = new Map( + deliveryRows.map((delivery) => [delivery.idempotencyKey, delivery]), + ); + return { + ok: true, + delegations: rows.map((row) => { + const completionDelivery = completionDeliveryByKey.get( + `bot-delegation-completion:${row.id}`, + ); + return { + ...row, + targetBotName: profileNames.get(row.targetBotId) ?? row.targetBotId, + contextRefs: parseStringArray(row.contextRefsJson), + artifactRefs: parseStringArray(row.artifactRefsJson), + outputArtifacts: parseBotOutputArtifacts(row.outputArtifactsJson), + completionDelivery: completionDelivery + ? { + id: completionDelivery.id, + status: completionDelivery.status, + attempts: completionDelivery.attempts, + lastError: completionDelivery.lastError, + diagnostic: parseBotDeliveryDiagnostic(completionDelivery.deliveryReceiptJson), + } + : null, + lineage: parseStringArray(row.lineageJson), + permissionSnapshot: parseRecord(row.permissionSnapshotJson), + }; + }) as BotDelegationView[], + }; + }; + + const cancelDelegationTree = async ( + root: DelegationRow, + reason: string, + deliverRoot: boolean, + ): Promise => { + const db = getDbClient().drizzle; + const graph = buildDelegationGraph(await db.select().from(botDelegations)); + const currentRoot = graph.byId.get(root.id) ?? root; + const affected = [currentRoot, ...descendantRows(currentRoot, graph)] + .filter((row) => isActiveDelegation(row.status)) + .sort((a, b) => b.depth - a.depth); + let rootChanged = false; + for (const row of affected) { + const changed = await updateTerminal({ + delegationId: row.id, + status: 'cancelled', + lastError: reason, + abortChild: true, + }); + rootChanged ||= row.id === currentRoot.id && changed !== null; + } + if (deliverRoot && rootChanged) { + await deliverCompletion({ + ...currentRoot, + status: 'cancelled', + resultSummary: currentRoot.resultSummary, + lastError: reason, + }); + } + return rootChanged; + }; + + const cancelDelegationsForParentSession = async ( + parentSessionId: string, + reason = 'Parent Bot task was renewed, archived, or deleted.', + ): Promise => { + const db = getDbClient().drizzle; + const roots = await db + .select() + .from(botDelegations) + .where( + and( + eq(botDelegations.parentSessionId, parentSessionId), + inArray(botDelegations.status, [...ACTIVE_DELEGATION_STATUSES]), + ), + ); + let cancelled = 0; + for (const root of roots) { + if (await cancelDelegationTree(root, reason, false)) cancelled += 1; + } + return cancelled; + }; + + const cancelDelegationsForBot = async ( + botId: string, + reason = 'The owning Bot was paused, archived, or deleted.', + ): Promise => { + const db = getDbClient().drizzle; + const rows = await db + .select() + .from(botDelegations) + .where( + and( + inArray(botDelegations.status, [...ACTIVE_DELEGATION_STATUSES]), + or( + eq(botDelegations.requestingBotId, botId), + eq(botDelegations.targetBotId, botId), + ), + ), + ) + .orderBy(desc(botDelegations.depth), desc(botDelegations.createdAt)); + let cancelled = 0; + for (const row of rows) { + if (await cancelDelegationTree(row, reason, false)) cancelled += 1; + } + return cancelled; + }; + + const cancelDelegation = async ( + callerSessionId: string, + delegationId: string, + ): Promise> => { + const caller = await resolveCaller(callerSessionId); + if (!caller) { + return { ok: false, errorCode: 'NOT_A_BOT_SESSION', message: '当前任务不属于 Cindy Bot' }; + } + const db = getDbClient().drizzle; + const [row] = await db + .select() + .from(botDelegations) + .where( + and( + eq(botDelegations.id, delegationId), + eq(botDelegations.requestingBotId, caller.botId), + ), + ) + .limit(1); + if (!row) return { ok: false, errorCode: 'NOT_FOUND', message: 'Bot delegation 不存在' }; + if (!ACTIVE_DELEGATION_STATUSES.includes(row.status as (typeof ACTIVE_DELEGATION_STATUSES)[number])) { + return { + ok: false, + errorCode: 'ALREADY_TERMINAL', + message: `Bot delegation 已是终态 ${row.status}`, + }; + } + const changed = await cancelDelegationTree( + row, + 'Cancelled by the requesting Bot.', + true, + ); + if (!changed) { + return { ok: false, errorCode: 'ALREADY_TERMINAL', message: 'Bot delegation 已被另一操作收口' }; + } + return { ok: true, delegationId, childSessionId: row.childSessionId }; + }; + + const settleSession = async (params: { + childSessionId: string; + outcome: 'done' | 'error'; + resultText?: string; + error?: string; + }): Promise => { + const db = getDbClient().drizzle; + const [row] = await db + .select() + .from(botDelegations) + .where(eq(botDelegations.childSessionId, params.childSessionId)) + .orderBy(desc(botDelegations.createdAt)) + .limit(1); + if (!row || !ACTIVE_DELEGATION_STATUSES.includes(row.status as (typeof ACTIVE_DELEGATION_STATUSES)[number])) return; + const [child] = await db + .select({ tokensUsed: sessions.totalTokenUsage }) + .from(sessions) + .where(eq(sessions.id, params.childSessionId)) + .limit(1); + const tokensUsed = child?.tokensUsed ?? 0; + const overBudget = row.budgetTokens !== null && tokensUsed > row.budgetTokens; + const status: Extract = + params.outcome === 'done' && !overBudget ? 'completed' : 'failed'; + const lastError = overBudget + ? `Bot delegation token budget exceeded (${tokensUsed}/${row.budgetTokens}).` + : params.error ?? null; + const resultSummary = params.resultText?.trim().slice(0, MAX_RESULT_CHARS) || null; + const outputArtifactsJson = JSON.stringify(collectBotOutputArtifacts(params.resultText)); + const changed = await updateTerminal({ + delegationId: row.id, + status, + resultSummary, + outputArtifactsJson, + lastError, + tokensUsed, + }); + if (!changed || !row.parentSessionId) return; + await deliverCompletion({ + ...row, + status, + resultSummary, + lastError, + }); + }; + + const enforceBudgetForSession = async ( + childSessionId: string, + tokensUsed: number, + ): Promise => { + const db = getDbClient().drizzle; + let delegationBudgetFailed = false; + const [row] = await db + .select() + .from(botDelegations) + .where( + and( + eq(botDelegations.childSessionId, childSessionId), + inArray(botDelegations.status, [...ACTIVE_DELEGATION_STATUSES]), + ), + ) + .orderBy(desc(botDelegations.createdAt)) + .limit(1); + if (row) { + await db + .update(botDelegations) + .set({ tokensUsed: Math.max(0, Math.floor(tokensUsed)), updatedAt: now() }) + .where(eq(botDelegations.id, row.id)); + } + + const allDelegations = await db.select().from(botDelegations); + const graph = buildDelegationGraph(allDelegations); + const current = row ? graph.byId.get(row.id) : undefined; + if (current) { + const ancestry: DelegationRow[] = []; + const seen = new Set(); + let cursor: DelegationRow | undefined = current; + while (cursor && !seen.has(cursor.id)) { + seen.add(cursor.id); + ancestry.push(cursor); + cursor = cursor.parentSessionId + ? graph.byChildSessionId.get(cursor.parentSessionId) + : undefined; + } + const exceeded = ancestry + .reverse() + .find((candidate) => + candidate.budgetTokens !== null + && committedSubtreeTokens(candidate, graph) > candidate.budgetTokens); + if (exceeded?.budgetTokens !== null && exceeded !== undefined) { + const committed = committedSubtreeTokens(exceeded, graph); + const lastError = `Bot delegation subtree token budget exceeded (${committed}/${exceeded.budgetTokens}).`; + delegationBudgetFailed = await failBudgetSubtree(exceeded, graph, lastError); + } + } + + const automationContext = await resolveAutomationContextForSession(childSessionId); + const automationBudget = automationContext?.plan.limits.budgetTokens ?? null; + if (!automationContext || automationBudget === null) return delegationBudgetFailed; + + const roots = graph.childrenByParentSessionId.get(automationContext.rootSessionId) ?? []; + const automationDelegations = roots.flatMap((root) => [root, ...descendantRows(root, graph)]); + const sessionIds = [ + automationContext.rootSessionId, + ...automationDelegations.flatMap((delegation) => delegation.childSessionId + ? [delegation.childSessionId] + : []), + ]; + const usageRows = await db + .select({ id: sessions.id, tokensUsed: sessions.totalTokenUsage }) + .from(sessions) + .where(inArray(sessions.id, [...new Set(sessionIds)])); + const automationTokensUsed = usageRows.reduce( + (sum, usage) => sum + Math.max(0, usage.tokensUsed), + 0, + ); + if (automationTokensUsed <= automationBudget) return delegationBudgetFailed; + + const lastError = `Bot automation token budget exceeded (${automationTokensUsed}/${automationBudget}).`; + for (const delegation of automationDelegations + .filter((candidate) => isActiveDelegation(candidate.status)) + .sort((left, right) => right.depth - left.depth)) { + await updateTerminal({ + delegationId: delegation.id, + status: 'failed', + lastError, + tokensUsed: delegation.tokensUsed, + abortChild: true, + }); + } + await db + .update(botAutomationRuns) + .set({ + status: 'failed', + errorMessage: lastError, + updatedAt: now(), + }) + .where( + and( + eq(botAutomationRuns.id, automationContext.run.id), + inArray(botAutomationRuns.status, ['claimed', 'running', 'completing']), + ), + ); + if (automationContext.run.scheduleRunId) { + await db + .update(scheduleRuns) + .set({ errorMsg: lastError }) + .where(eq(scheduleRuns.id, automationContext.run.scheduleRunId)); + } + await deps.abortSession(automationContext.rootSessionId).catch(() => undefined); + return true; + }; + + const restore = async (): Promise => { + const db = getDbClient().drizzle; + const rows = await db + .select({ + id: botDelegations.id, + status: botDelegations.status, + requestingBotId: botDelegations.requestingBotId, + parentSessionId: botDelegations.parentSessionId, + childSessionId: botDelegations.childSessionId, + objective: botDelegations.objective, + contextRefsJson: botDelegations.contextRefsJson, + artifactRefsJson: botDelegations.artifactRefsJson, + permissionSnapshotJson: botDelegations.permissionSnapshotJson, + }) + .from(botDelegations) + .where(inArray(botDelegations.status, [...ACTIVE_DELEGATION_STATUSES])); + for (const row of rows) { + const deadlineAt = readDeadline(row.permissionSnapshotJson); + if (deadlineAt !== null) scheduleTimeout(row.id, deadlineAt); + if (row.status === 'queued' || row.status === 'waiting') { + if (row.childSessionId) await attemptDispatch(row.id); + continue; + } + if (row.status === 'running') await resumeRunningDelegation(row.id); + } + if (deps.enqueueDelivery) { + const missingCompletions = await db + .select() + .from(botDelegations) + .leftJoin( + botDeliveryOutbox, + eq( + botDeliveryOutbox.idempotencyKey, + sql`'bot-delegation-completion:' || ${botDelegations.id}`, + ), + ) + .where( + and( + inArray(botDelegations.status, ['completed', 'failed', 'cancelled', 'timed-out']), + isNotNull(botDelegations.parentSessionId), + isNull(botDeliveryOutbox.id), + ), + ); + for (const item of missingCompletions) { + const delegation = item.bot_delegations; + await deliverCompletion({ + ...delegation, + status: delegation.status as Extract< + DelegationStatus, + 'completed' | 'failed' | 'cancelled' | 'timed-out' + >, + resultSummary: delegation.resultSummary, + lastError: delegation.lastError, + }); + } + } + }; + + const unregisterParentCancellation = registerBotDelegationParentCancellation( + cancelDelegationsForParentSession, + ); + + const dispose = (): void => { + unregisterParentCancellation(); + for (const timer of timers.values()) clearTimeout(timer); + timers.clear(); + for (const timer of retryTimers.values()) clearTimeout(timer); + retryTimers.clear(); + }; + + return { + listBots, + delegateToBot, + listDelegations, + cancelDelegation, + cancelDelegationsForParentSession, + cancelDelegationsForBot, + enforceBudgetForSession, + settleSession, + restore, + dispose, + }; +} + +export type BotDelegationService = ReturnType; diff --git a/apps/desktop/src/main/maker-ipc/botDeliveryOutboxService.ts b/apps/desktop/src/main/maker-ipc/botDeliveryOutboxService.ts new file mode 100644 index 0000000000..ca92c9e61c --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/botDeliveryOutboxService.ts @@ -0,0 +1,727 @@ +import { randomUUID } from 'node:crypto'; + +import { and, asc, desc, eq, inArray, isNull, lte, or } from 'drizzle-orm'; + +import { getDbClient } from '../localDb/client/current.js'; +import { botChannels, botDeliveryOutbox, botRoutes } from '../localDb/schema.js'; +import type { BotDeliveryView } from '../../shared/botDelivery.js'; +import { parseBotDeliveryDiagnostic } from '../../shared/botDeliveryDiagnostic.js'; + +const RETRYABLE_STATUSES = ['pending', 'failed'] as const; +const DEFAULT_MAX_ATTEMPTS = 8; +const DEFAULT_SENDING_LEASE_MS = 60_000; +const MAX_TIMER_DELAY_MS = 2_147_000_000; +const MAX_PAYLOAD_BYTES = 256 * 1024; + +export interface BotDeliveryEnvelope { + version: 1; + kind: string; + [key: string]: unknown; +} + +export interface BotDeliveryRow { + id: string; + botId: string; + channelId: string | null; + routeId: string | null; + sessionId: string | null; + idempotencyKey: string; + ownerGeneration: number; + attempts: number; +} + +export type BotDeliveryAttemptResult = + | { ok: true; receipt?: Record } + | { ok: false; retryable: boolean; errorCode: string; message: string }; + +export interface BotDeliveryOutboxServiceDeps { + deliver: ( + row: BotDeliveryRow, + payload: BotDeliveryEnvelope, + attempt: { + /** + * Persist the exact point at which an adapter call may have crossed the + * process boundary. A provider-idempotent transport can be replayed + * after a crash; a local adapter cannot be retried automatically because + * the provider may have accepted the message before the process died. + */ + recordExternalDispatch(input: { + retrySafe: boolean; + transport: string; + }): Promise; + /** Persist provider acknowledgements as a multipart send advances. */ + recordProgress(receipt: Record): Promise; + }, + ) => Promise; + now?: () => number; + createId?: () => string; + maxAttempts?: number; + sendingLeaseMs?: number; + onChanged?: (payload: { botId: string; deliveryId?: string }) => void; +} + +export interface EnqueueBotDeliveryInput { + botId: string; + channelId?: string | null; + routeId?: string | null; + sessionId?: string | null; + idempotencyKey: string; + ownerGeneration?: number; + payload: BotDeliveryEnvelope; +} + +function parseEnvelope(value: string): BotDeliveryEnvelope | null { + try { + const parsed = JSON.parse(value) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const record = parsed as Record; + if (record.version !== 1 || typeof record.kind !== 'string' || !record.kind.trim()) return null; + return record as BotDeliveryEnvelope; + } catch { + return null; + } +} + +function parseReceipt(value: string | null | undefined): Record { + try { + const parsed = JSON.parse(value ?? '{}') as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + return {}; + } +} + +function retryDelayMs(attempts: number): number { + const schedule = [1_000, 5_000, 30_000, 120_000, 600_000, 1_800_000, 3_600_000]; + return schedule[Math.min(Math.max(0, attempts - 1), schedule.length - 1)]!; +} + +export function createBotDeliveryOutboxService(deps: BotDeliveryOutboxServiceDeps) { + const now = deps.now ?? Date.now; + const createId = deps.createId ?? randomUUID; + const maxAttempts = Math.max(1, deps.maxAttempts ?? DEFAULT_MAX_ATTEMPTS); + const sendingLeaseMs = Math.max(1_000, deps.sendingLeaseMs ?? DEFAULT_SENDING_LEASE_MS); + let timer: ReturnType | null = null; + let drainPromise: Promise | null = null; + let disposed = false; + + const emitChanged = (botId: string, deliveryId?: string): void => { + deps.onChanged?.({ botId, ...(deliveryId ? { deliveryId } : {}) }); + }; + + const clearTimer = (): void => { + if (timer) clearTimeout(timer); + timer = null; + }; + + const scheduleDrain = (delayMs = 0): void => { + if (disposed) return; + clearTimer(); + timer = setTimeout(() => { + timer = null; + void drain(); + }, Math.min(MAX_TIMER_DELAY_MS, Math.max(0, delayMs))); + timer.unref?.(); + }; + + const markTerminal = async ( + id: string, + status: 'delivered' | 'dead-letter' | 'cancelled', + lastError: string | null, + receipt?: Record, + ): Promise => { + const at = now(); + const [current] = await getDbClient() + .drizzle.select({ deliveryReceiptJson: botDeliveryOutbox.deliveryReceiptJson }) + .from(botDeliveryOutbox) + .where(and(eq(botDeliveryOutbox.id, id), eq(botDeliveryOutbox.status, 'sending'))) + .limit(1); + const existingReceipt = parseReceipt(current?.deliveryReceiptJson); + const [updated] = await getDbClient() + .drizzle.update(botDeliveryOutbox) + .set({ + status, + lastError, + deliveryReceiptJson: status === 'delivered' + ? JSON.stringify({ ...existingReceipt, ...(receipt ?? {}) }) + : current?.deliveryReceiptJson ?? null, + nextAttemptAt: null, + updatedAt: at, + deliveredAt: status === 'delivered' ? at : null, + }) + .where(and(eq(botDeliveryOutbox.id, id), eq(botDeliveryOutbox.status, 'sending'))) + .returning({ botId: botDeliveryOutbox.botId }); + if (updated) emitChanged(updated.botId, id); + }; + + const markFailure = async ( + row: BotDeliveryRow, + result: Extract, + ): Promise => { + const at = now(); + const exhausted = row.attempts >= maxAttempts; + const terminal = !result.retryable || exhausted; + const [updated] = await getDbClient() + .drizzle.update(botDeliveryOutbox) + .set({ + status: terminal ? 'dead-letter' : 'failed', + lastError: `${result.errorCode}: ${result.message}`.slice(0, 4_000), + nextAttemptAt: terminal ? null : at + retryDelayMs(row.attempts), + updatedAt: at, + deliveredAt: null, + }) + .where(and(eq(botDeliveryOutbox.id, row.id), eq(botDeliveryOutbox.status, 'sending'))) + .returning({ botId: botDeliveryOutbox.botId }); + if (updated) emitChanged(updated.botId, row.id); + }; + + const validateRouteOwnership = async (row: BotDeliveryRow): Promise => { + if (!row.routeId) return true; + const [route] = await getDbClient() + .drizzle.select({ + ownerGeneration: botRoutes.ownerGeneration, + status: botRoutes.status, + }) + .from(botRoutes) + .where(eq(botRoutes.id, row.routeId)) + .limit(1); + if (!route) { + await markTerminal(row.id, 'cancelled', 'ROUTE_NOT_FOUND: delivery route no longer exists'); + return false; + } + if (route.ownerGeneration !== row.ownerGeneration) { + await markTerminal( + row.id, + 'cancelled', + `STALE_ROUTE_OWNER: expected generation ${row.ownerGeneration}, current ${route.ownerGeneration}`, + ); + return false; + } + if (route.status === 'active') return true; + if (route.status === 'archived') { + await markTerminal(row.id, 'cancelled', 'ROUTE_ARCHIVED: delivery route is archived'); + return false; + } + await markFailure(row, { + ok: false, + retryable: true, + errorCode: 'ROUTE_UNAVAILABLE', + message: `delivery route is ${route.status}`, + }); + return false; + }; + + const requeueExpiredSending = async (): Promise => { + const at = now(); + const db = getDbClient().drizzle; + const stale = await db + .select({ + id: botDeliveryOutbox.id, + deliveryReceiptJson: botDeliveryOutbox.deliveryReceiptJson, + }) + .from(botDeliveryOutbox) + .where( + and( + eq(botDeliveryOutbox.status, 'sending'), + lte(botDeliveryOutbox.updatedAt, at - sendingLeaseMs), + ), + ); + for (const row of stale) { + let retrySafe = true; + try { + const marker = row.deliveryReceiptJson + ? JSON.parse(row.deliveryReceiptJson) as Record + : null; + const dispatch = marker?.externalDispatch; + if (dispatch && typeof dispatch === 'object' && !Array.isArray(dispatch)) { + retrySafe = (dispatch as Record).retrySafe !== false; + } + } catch { + // A malformed diagnostic marker must not turn an otherwise recoverable + // pre-dispatch lease into permanent message loss. + } + await db + .update(botDeliveryOutbox) + .set( + retrySafe + ? { status: 'failed', nextAttemptAt: at, updatedAt: at } + : { + status: 'dead-letter', + nextAttemptAt: null, + lastError: + 'DELIVERY_OUTCOME_UNKNOWN: local adapter may have delivered before the host stopped; automatic retry was suppressed to prevent a duplicate', + updatedAt: at, + }, + ) + .where( + and( + eq(botDeliveryOutbox.id, row.id), + eq(botDeliveryOutbox.status, 'sending'), + lte(botDeliveryOutbox.updatedAt, at - sendingLeaseMs), + ), + ); + } + }; + + const claimNext = async (): Promise<{ + row: BotDeliveryRow; + payloadRefJson: string; + } | null> => { + const db = getDbClient().drizzle; + const at = now(); + const [candidate] = await db + .select({ + id: botDeliveryOutbox.id, + botId: botDeliveryOutbox.botId, + channelId: botDeliveryOutbox.channelId, + routeId: botDeliveryOutbox.routeId, + sessionId: botDeliveryOutbox.sessionId, + idempotencyKey: botDeliveryOutbox.idempotencyKey, + ownerGeneration: botDeliveryOutbox.ownerGeneration, + attempts: botDeliveryOutbox.attempts, + payloadRefJson: botDeliveryOutbox.payloadRefJson, + }) + .from(botDeliveryOutbox) + .where( + and( + inArray(botDeliveryOutbox.status, [...RETRYABLE_STATUSES]), + or(isNull(botDeliveryOutbox.nextAttemptAt), lte(botDeliveryOutbox.nextAttemptAt, at)), + ), + ) + .orderBy(asc(botDeliveryOutbox.createdAt)) + .limit(1); + if (!candidate) return null; + const [claimed] = await db + .update(botDeliveryOutbox) + .set({ status: 'sending', attempts: candidate.attempts + 1, updatedAt: at }) + .where( + and( + eq(botDeliveryOutbox.id, candidate.id), + inArray(botDeliveryOutbox.status, [...RETRYABLE_STATUSES]), + eq(botDeliveryOutbox.attempts, candidate.attempts), + ), + ) + .returning({ id: botDeliveryOutbox.id }); + if (!claimed) return null; + return { + row: { + id: candidate.id, + botId: candidate.botId, + channelId: candidate.channelId, + routeId: candidate.routeId, + sessionId: candidate.sessionId, + idempotencyKey: candidate.idempotencyKey, + ownerGeneration: candidate.ownerGeneration, + attempts: candidate.attempts + 1, + }, + payloadRefJson: candidate.payloadRefJson, + }; + }; + + const scheduleNextDue = async (): Promise => { + if (disposed) return; + const [next] = await getDbClient() + .drizzle.select({ nextAttemptAt: botDeliveryOutbox.nextAttemptAt }) + .from(botDeliveryOutbox) + .where(inArray(botDeliveryOutbox.status, [...RETRYABLE_STATUSES])) + .orderBy(asc(botDeliveryOutbox.nextAttemptAt), asc(botDeliveryOutbox.createdAt)) + .limit(1); + if (!next) return; + scheduleDrain(Math.max(0, (next.nextAttemptAt ?? now()) - now())); + }; + + const runDrain = async (): Promise => { + await requeueExpiredSending(); + let processed = 0; + while (!disposed && processed < 100) { + const claimed = await claimNext(); + if (!claimed) break; + processed += 1; + const payload = parseEnvelope(claimed.payloadRefJson); + if (!payload) { + await markTerminal(claimed.row.id, 'dead-letter', 'INVALID_PAYLOAD: malformed envelope'); + continue; + } + if (!(await validateRouteOwnership(claimed.row))) continue; + let result: BotDeliveryAttemptResult; + const attemptState: { + externalDispatch: { retrySafe: boolean; transport: string; startedAt: number } | null; + progress: Record; + } = { externalDispatch: null, progress: {} }; + const persistAttemptReceipt = async (): Promise => { + const [updated] = await getDbClient() + .drizzle.update(botDeliveryOutbox) + .set({ + deliveryReceiptJson: JSON.stringify({ + ...(attemptState.externalDispatch + ? { + externalDispatch: { + ...attemptState.externalDispatch, + }, + } + : {}), + progress: attemptState.progress, + }), + updatedAt: now(), + }) + .where( + and( + eq(botDeliveryOutbox.id, claimed.row.id), + eq(botDeliveryOutbox.status, 'sending'), + ), + ) + .returning({ id: botDeliveryOutbox.id }); + if (!updated) throw new Error('Bot delivery claim was lost during external dispatch'); + }; + try { + result = await deps.deliver(claimed.row, payload, { + recordExternalDispatch: async (input) => { + attemptState.externalDispatch = { + retrySafe: input.retrySafe, + transport: input.transport.trim() || 'unknown', + startedAt: now(), + }; + await persistAttemptReceipt(); + }, + recordProgress: async (receipt) => { + attemptState.progress = { ...attemptState.progress, ...receipt }; + await persistAttemptReceipt(); + }, + }); + } catch (error) { + result = { + ok: false, + retryable: true, + errorCode: 'DELIVERY_EXCEPTION', + message: error instanceof Error ? error.message : String(error), + }; + } + if ( + !result.ok + && attemptState.externalDispatch?.retrySafe === false + && result.retryable + ) { + result = { + ok: false, + retryable: false, + errorCode: 'DELIVERY_OUTCOME_UNKNOWN', + message: + `${result.errorCode}: ${result.message}; local adapter may already have delivered, so automatic retry was suppressed`, + }; + } + if (result.ok) await markTerminal(claimed.row.id, 'delivered', null, result.receipt); + else await markFailure(claimed.row, result); + } + await scheduleNextDue(); + if (processed >= 100) scheduleDrain(0); + }; + + function drain(): Promise { + if (disposed) return Promise.resolve(); + if (drainPromise) return drainPromise; + drainPromise = runDrain().finally(() => { + drainPromise = null; + }); + return drainPromise; + } + + const enqueue = async (input: EnqueueBotDeliveryInput): Promise<{ id: string }> => { + const idempotencyKey = input.idempotencyKey.trim(); + if (!idempotencyKey) throw new Error('Bot delivery idempotencyKey is required'); + const payloadRefJson = JSON.stringify(input.payload); + if (Buffer.byteLength(payloadRefJson, 'utf8') > MAX_PAYLOAD_BYTES) { + throw new Error(`Bot delivery payload exceeds ${MAX_PAYLOAD_BYTES} bytes`); + } + const db = getDbClient().drizzle; + const at = now(); + const id = createId(); + await db + .insert(botDeliveryOutbox) + .values({ + id, + botId: input.botId, + channelId: input.channelId ?? null, + routeId: input.routeId ?? null, + sessionId: input.sessionId ?? null, + idempotencyKey, + payloadRefJson, + ownerGeneration: input.ownerGeneration ?? 0, + status: 'pending', + attempts: 0, + nextAttemptAt: at, + lastError: null, + deliveryReceiptJson: null, + createdAt: at, + updatedAt: at, + deliveredAt: null, + }) + .onConflictDoNothing({ target: botDeliveryOutbox.idempotencyKey }); + const [row] = await db + .select({ + id: botDeliveryOutbox.id, + botId: botDeliveryOutbox.botId, + channelId: botDeliveryOutbox.channelId, + routeId: botDeliveryOutbox.routeId, + sessionId: botDeliveryOutbox.sessionId, + ownerGeneration: botDeliveryOutbox.ownerGeneration, + payloadRefJson: botDeliveryOutbox.payloadRefJson, + }) + .from(botDeliveryOutbox) + .where(eq(botDeliveryOutbox.idempotencyKey, idempotencyKey)) + .limit(1); + if (!row) throw new Error('Bot delivery enqueue failed'); + if ( + row.botId !== input.botId + || row.channelId !== (input.channelId ?? null) + || row.routeId !== (input.routeId ?? null) + || row.sessionId !== (input.sessionId ?? null) + || row.ownerGeneration !== (input.ownerGeneration ?? 0) + || row.payloadRefJson !== payloadRefJson + ) { + throw new Error(`Bot delivery idempotency conflict for ${idempotencyKey}`); + } + scheduleDrain(0); + emitChanged(row.botId, row.id); + return { id: row.id }; + }; + + const retry = async ( + id: string, + botId: string, + opts: { allowDuplicateRisk?: boolean } = {}, + ): Promise<{ id: string }> => { + const db = getDbClient().drizzle; + const [row] = await db + .select({ + id: botDeliveryOutbox.id, + botId: botDeliveryOutbox.botId, + routeId: botDeliveryOutbox.routeId, + ownerGeneration: botDeliveryOutbox.ownerGeneration, + status: botDeliveryOutbox.status, + deliveryReceiptJson: botDeliveryOutbox.deliveryReceiptJson, + }) + .from(botDeliveryOutbox) + .where(eq(botDeliveryOutbox.id, id)) + .limit(1); + if (!row || row.botId !== botId) throw new Error('Bot delivery is unavailable'); + if (row.status === 'pending' || row.status === 'sending' || row.status === 'delivered') { + return { id: row.id }; + } + if (row.status !== 'failed' && row.status !== 'dead-letter') { + throw new Error(`Bot delivery in status ${row.status} cannot be retried`); + } + const priorReceipt = parseReceipt(row.deliveryReceiptJson); + const priorDispatch = priorReceipt.externalDispatch; + const duplicateRisk = priorDispatch + && typeof priorDispatch === 'object' + && !Array.isArray(priorDispatch) + && (priorDispatch as Record).retrySafe === false; + if (duplicateRisk && opts.allowDuplicateRisk !== true) { + throw new Error( + 'Bot delivery may already be partially visible; explicit duplicate-risk confirmation is required', + ); + } + if (row.routeId) { + const [route] = await db + .select({ + botId: botRoutes.botId, + ownerGeneration: botRoutes.ownerGeneration, + status: botRoutes.status, + }) + .from(botRoutes) + .where(eq(botRoutes.id, row.routeId)) + .limit(1); + if (!route || route.botId !== botId) throw new Error('Bot delivery route is unavailable'); + if (route.status !== 'active') { + throw new Error(`Bot delivery route is ${route.status}`); + } + if (route.ownerGeneration !== row.ownerGeneration) { + throw new Error('Bot delivery route ownership changed; create a new delivery instead'); + } + } + const at = now(); + const [updated] = await db + .update(botDeliveryOutbox) + .set({ + status: 'pending', + attempts: 0, + nextAttemptAt: at, + lastError: null, + deliveryReceiptJson: null, + deliveredAt: null, + updatedAt: at, + }) + .where( + and( + eq(botDeliveryOutbox.id, id), + eq(botDeliveryOutbox.botId, botId), + inArray(botDeliveryOutbox.status, ['failed', 'dead-letter']), + ), + ) + .returning({ id: botDeliveryOutbox.id }); + if (!updated) return retry(id, botId); + scheduleDrain(0); + emitChanged(botId, id); + return updated; + }; + + const listForBot = async (botId: string, limit = 100): Promise => { + const rows = await getDbClient() + .drizzle.select({ + id: botDeliveryOutbox.id, + botId: botDeliveryOutbox.botId, + channelId: botDeliveryOutbox.channelId, + channelKind: botChannels.kind, + routeId: botDeliveryOutbox.routeId, + routeKey: botRoutes.routeKey, + routeStatus: botRoutes.status, + sessionId: botDeliveryOutbox.sessionId, + payloadRefJson: botDeliveryOutbox.payloadRefJson, + ownerGeneration: botDeliveryOutbox.ownerGeneration, + attempts: botDeliveryOutbox.attempts, + status: botDeliveryOutbox.status, + lastError: botDeliveryOutbox.lastError, + deliveryReceiptJson: botDeliveryOutbox.deliveryReceiptJson, + createdAt: botDeliveryOutbox.createdAt, + updatedAt: botDeliveryOutbox.updatedAt, + deliveredAt: botDeliveryOutbox.deliveredAt, + }) + .from(botDeliveryOutbox) + .leftJoin(botChannels, eq(botDeliveryOutbox.channelId, botChannels.id)) + .leftJoin(botRoutes, eq(botDeliveryOutbox.routeId, botRoutes.id)) + .where(eq(botDeliveryOutbox.botId, botId)) + .orderBy(desc(botDeliveryOutbox.updatedAt), desc(botDeliveryOutbox.createdAt)) + .limit(Math.min(500, Math.max(1, Math.floor(limit)))); + return rows.map((row) => ({ + id: row.id, + botId: row.botId, + channelId: row.channelId, + channelKind: row.channelKind, + routeId: row.routeId, + routeKey: row.routeKey, + routeStatus: row.routeStatus, + sessionId: row.sessionId, + payloadKind: parseEnvelope(row.payloadRefJson)?.kind ?? 'invalid', + ownerGeneration: row.ownerGeneration, + attempts: row.attempts, + status: row.status, + lastError: row.lastError, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deliveredAt: row.deliveredAt, + diagnostic: parseBotDeliveryDiagnostic(row.deliveryReceiptJson), + })); + }; + + /** + * Stop every non-terminal delivery owned by a paused Bot. A row that was + * already claimed as `sending` may finish its adapter call, but its later + * CAS write no longer matches and therefore cannot resurrect the row. + */ + const suspendForBot = async (botId: string): Promise => { + const at = now(); + const rows = await getDbClient() + .drizzle.update(botDeliveryOutbox) + .set({ + status: 'suspended', + nextAttemptAt: null, + lastError: 'BOT_PAUSED: delivery is suspended until the Bot resumes', + deliveryReceiptJson: null, + updatedAt: at, + deliveredAt: null, + }) + .where( + and( + eq(botDeliveryOutbox.botId, botId), + inArray(botDeliveryOutbox.status, ['pending', 'sending', 'failed']), + ), + ) + .returning({ id: botDeliveryOutbox.id }); + if (rows.length > 0) emitChanged(botId); + return rows.length; + }; + + const resumeForBot = async (botId: string): Promise => { + const at = now(); + const rows = await getDbClient() + .drizzle.update(botDeliveryOutbox) + .set({ + status: 'pending', + nextAttemptAt: at, + lastError: null, + deliveryReceiptJson: null, + updatedAt: at, + deliveredAt: null, + }) + .where( + and( + eq(botDeliveryOutbox.botId, botId), + eq(botDeliveryOutbox.status, 'suspended'), + ), + ) + .returning({ id: botDeliveryOutbox.id }); + if (rows.length > 0) { + scheduleDrain(0); + emitChanged(botId); + } + if (rows.length > 0) emitChanged(botId); + return rows.length; + }; + + const cancelForBot = async (botId: string, reason: string): Promise => { + const at = now(); + const rows = await getDbClient() + .drizzle.update(botDeliveryOutbox) + .set({ + status: 'cancelled', + nextAttemptAt: null, + lastError: reason.slice(0, 4_000), + deliveryReceiptJson: null, + updatedAt: at, + deliveredAt: null, + }) + .where( + and( + eq(botDeliveryOutbox.botId, botId), + inArray(botDeliveryOutbox.status, ['pending', 'sending', 'suspended', 'failed']), + ), + ) + .returning({ id: botDeliveryOutbox.id }); + return rows.length; + }; + + const restore = async (): Promise => { + const db = getDbClient().drizzle; + const at = now(); + await requeueExpiredSending(); + const [leased] = await db + .select({ updatedAt: botDeliveryOutbox.updatedAt }) + .from(botDeliveryOutbox) + .where(eq(botDeliveryOutbox.status, 'sending')) + .orderBy(asc(botDeliveryOutbox.updatedAt)) + .limit(1); + if (leased) scheduleDrain(Math.max(0, leased.updatedAt + sendingLeaseMs - at)); + await drain(); + }; + + const dispose = (): void => { + disposed = true; + clearTimer(); + }; + + return { + enqueue, + listForBot, + retry, + drain, + restore, + suspendForBot, + resumeForBot, + cancelForBot, + dispose, + }; +} + +export type BotDeliveryOutboxService = ReturnType; diff --git a/apps/desktop/src/main/maker-ipc/botDurableNoteService.ts b/apps/desktop/src/main/maker-ipc/botDurableNoteService.ts new file mode 100644 index 0000000000..d71a33aeb5 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/botDurableNoteService.ts @@ -0,0 +1,193 @@ +import { randomUUID } from 'node:crypto'; + +import { and, desc, eq } from 'drizzle-orm'; + +import { getDbClient } from '../localDb/client/current.js'; +import { + botAutomationLinks, + botAutomationRuns, + botDurableNotes, + botSessionLinks, + sessions, +} from '../localDb/schema.js'; + +const MAX_NAMESPACE_CHARS = 128; +const MAX_KEY_CHARS = 128; +const MAX_VALUE_BYTES = 32 * 1024; +const MAX_LIST_LIMIT = 200; +const SAFE_NAME = /^[\p{L}\p{N}][\p{L}\p{N}._:/-]*$/u; + +export type BotDurableNoteResult> = + | ({ ok: true } & T) + | { ok: false; errorCode: string; message: string }; + +function validateName(value: string, field: string, max: number): string | null { + const trimmed = value.trim(); + if (!trimmed || trimmed.length > max || !SAFE_NAME.test(trimmed)) return null; + return trimmed; +} + +async function resolveBotContext(callerSessionId: string): Promise<{ + botId: string; + defaultNamespace: string | null; +} | null> { + const db = getDbClient().drizzle; + const [row] = await db + .select({ + botId: botSessionLinks.botId, + defaultNamespace: botAutomationLinks.durableNoteNamespace, + }) + .from(botSessionLinks) + .innerJoin(sessions, eq(sessions.id, botSessionLinks.sessionId)) + .leftJoin(botAutomationRuns, eq(botAutomationRuns.sessionId, sessions.id)) + .leftJoin( + botAutomationLinks, + eq(botAutomationLinks.id, botAutomationRuns.automationLinkId), + ) + .where(and(eq(botSessionLinks.sessionId, callerSessionId), eq(sessions.source, 'bot'))) + .limit(1); + return row ?? null; +} + +function resolveNamespace( + requested: string | undefined, + defaultNamespace: string | null, +): string | null { + return validateName(requested ?? defaultNamespace ?? '', 'namespace', MAX_NAMESPACE_CHARS); +} + +function parseValue(valueJson: string): unknown { + try { + return JSON.parse(valueJson) as unknown; + } catch { + return null; + } +} + +export async function listBotDurableNotes(input: { + callerSessionId: string; + namespace?: string; + limit?: number; +}): Promise> { + const context = await resolveBotContext(input.callerSessionId); + if (!context) return { ok: false, errorCode: 'NOT_A_BOT_SESSION', message: '当前任务不属于 Cindy Bot' }; + const namespace = input.namespace === undefined && !context.defaultNamespace + ? undefined + : resolveNamespace(input.namespace, context.defaultNamespace); + if ((input.namespace !== undefined || context.defaultNamespace) && !namespace) { + return { ok: false, errorCode: 'INVALID_ARGS', message: 'namespace 格式无效' }; + } + const limit = Math.max(1, Math.min(Math.floor(input.limit ?? 100), MAX_LIST_LIMIT)); + const db = getDbClient().drizzle; + const rows = await db + .select() + .from(botDurableNotes) + .where( + namespace + ? and(eq(botDurableNotes.botId, context.botId), eq(botDurableNotes.namespace, namespace)) + : eq(botDurableNotes.botId, context.botId), + ) + .orderBy(desc(botDurableNotes.updatedAt), desc(botDurableNotes.id)) + .limit(limit); + return { + ok: true, + notes: rows.map((row) => ({ + namespace: row.namespace, + key: row.noteKey, + value: parseValue(row.valueJson), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })), + }; +} + +export async function getBotDurableNote(input: { + callerSessionId: string; + namespace?: string; + key: string; +}): Promise> { + const context = await resolveBotContext(input.callerSessionId); + if (!context) return { ok: false, errorCode: 'NOT_A_BOT_SESSION', message: '当前任务不属于 Cindy Bot' }; + const namespace = resolveNamespace(input.namespace, context.defaultNamespace); + const key = validateName(input.key, 'key', MAX_KEY_CHARS); + if (!namespace || !key) return { ok: false, errorCode: 'INVALID_ARGS', message: 'namespace 或 key 格式无效' }; + const db = getDbClient().drizzle; + const [row] = await db + .select() + .from(botDurableNotes) + .where( + and( + eq(botDurableNotes.botId, context.botId), + eq(botDurableNotes.namespace, namespace), + eq(botDurableNotes.noteKey, key), + ), + ) + .limit(1); + if (!row) return { ok: false, errorCode: 'NOT_FOUND', message: 'Durable note 不存在' }; + return { + ok: true, + note: { + namespace: row.namespace, + key: row.noteKey, + value: parseValue(row.valueJson), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }, + }; +} + +export async function setBotDurableNote(input: { + callerSessionId: string; + namespace?: string; + key: string; + value: unknown; +}): Promise> { + const context = await resolveBotContext(input.callerSessionId); + if (!context) return { ok: false, errorCode: 'NOT_A_BOT_SESSION', message: '当前任务不属于 Cindy Bot' }; + const namespace = resolveNamespace(input.namespace, context.defaultNamespace); + const key = validateName(input.key, 'key', MAX_KEY_CHARS); + if (!namespace || !key) return { ok: false, errorCode: 'INVALID_ARGS', message: 'namespace 或 key 格式无效' }; + let valueJson: string; + try { + valueJson = JSON.stringify(input.value); + } catch { + return { ok: false, errorCode: 'INVALID_ARGS', message: 'value 必须是可序列化 JSON' }; + } + if (valueJson === undefined || Buffer.byteLength(valueJson, 'utf8') > MAX_VALUE_BYTES) { + return { ok: false, errorCode: 'VALUE_TOO_LARGE', message: `value 最大 ${MAX_VALUE_BYTES} bytes` }; + } + const db = getDbClient().drizzle; + const now = Date.now(); + const id = randomUUID(); + await db + .insert(botDurableNotes) + .values({ id, botId: context.botId, namespace, noteKey: key, valueJson, createdAt: now, updatedAt: now }) + .onConflictDoUpdate({ + target: [botDurableNotes.botId, botDurableNotes.namespace, botDurableNotes.noteKey], + set: { valueJson, updatedAt: now }, + }); + return getBotDurableNote({ callerSessionId: input.callerSessionId, namespace, key }); +} + +export async function deleteBotDurableNote(input: { + callerSessionId: string; + namespace?: string; + key: string; +}): Promise> { + const context = await resolveBotContext(input.callerSessionId); + if (!context) return { ok: false, errorCode: 'NOT_A_BOT_SESSION', message: '当前任务不属于 Cindy Bot' }; + const namespace = resolveNamespace(input.namespace, context.defaultNamespace); + const key = validateName(input.key, 'key', MAX_KEY_CHARS); + if (!namespace || !key) return { ok: false, errorCode: 'INVALID_ARGS', message: 'namespace 或 key 格式无效' }; + const rows = await getDbClient().drizzle + .delete(botDurableNotes) + .where( + and( + eq(botDurableNotes.botId, context.botId), + eq(botDurableNotes.namespace, namespace), + eq(botDurableNotes.noteKey, key), + ), + ) + .returning({ id: botDurableNotes.id }); + return { ok: true, deleted: rows.length > 0 }; +} diff --git a/apps/desktop/src/main/maker-ipc/botLifecycleService.ts b/apps/desktop/src/main/maker-ipc/botLifecycleService.ts new file mode 100644 index 0000000000..46ac25ef37 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/botLifecycleService.ts @@ -0,0 +1,546 @@ +import { randomUUID } from 'node:crypto'; + +import { BrowserWindow, ipcMain } from 'electron'; +import { and, eq } from 'drizzle-orm'; +import type { Maker } from '@cindy/maker-core'; + +import type { + BotLifecycleActionRequest, + BotLifecycleActionResult, +} from '../../shared/botLifecycle.js'; +import { getDbClient } from '../localDb/client/current.js'; +import { deleteBotProfileAndDetachSessionsInDb } from '../localDb/ipc/sessions.js'; +import { + botAutomationLinks, + botLifecycleEvents, + botProfiles, + botRoutes, + botSessionLinks, + sessions, +} from '../localDb/schema.js'; +import { createLogger } from '../logger.js'; +import { assertTrustedAppRendererEvent } from '../security/trustedAppRenderer.js'; +import { requireObject, requireString, throwIpcError } from '../utils/ipcValidate.js'; +import { MAKER_INVOKE, MAKER_PUSH } from './channels.js'; +import { awaitReadyWithTimeout } from './schedule.js'; +import type { BotDelegationService } from './botDelegationService.js'; +import type { BotDeliveryOutboxService } from './botDeliveryOutboxService.js'; +import { + releaseAllBotWorkspaceLeases, + retainBotWorkspaceLeases, +} from './botWorkspaceLeaseLifecycle.js'; + +const log = createLogger('maker-ipc:bot-lifecycle'); + +type RouteSuspendStatus = 'active' | 'offline' | 'recovering' | 'error'; +type AutomationSuspendStatus = 'active' | 'error'; + +export interface BotLifecycleServiceDeps { + maker: Maker; + getDelegationService: () => BotDelegationService | null; + getOutboxService: () => BotDeliveryOutboxService | null; + pauseSchedule?: (scheduleId: string) => Promise; + resumeSchedule?: (scheduleId: string) => Promise; + retainWorktrees?: (botId: string) => Promise; + releaseWorktrees?: (botId: string) => Promise; + createCanonicalSession?: (input: { + botId: string; + expectedCanonicalSessionId: string | null; + expectedProfileVersion: number; + }) => Promise<{ canonicalSessionId: string }>; + deleteProfileAndDetachSessions?: ( + botId: string, + sessionIds: string[], + keepTaskHistory: boolean, + ) => Promise; + now?: () => number; +} + +const lifecycleLocks = new Map< + string, + { action: BotLifecycleActionRequest['action']; promise: Promise } +>(); + +function withBotLifecycleLock( + botId: string, + action: BotLifecycleActionRequest['action'], + run: () => Promise, +): Promise { + const current = lifecycleLocks.get(botId); + if (current?.action === action) return current.promise; + const start = current ? current.promise.catch(() => undefined).then(run) : run(); + const next = start.finally(() => { + if (lifecycleLocks.get(botId)?.promise === next) lifecycleLocks.delete(botId); + }); + lifecycleLocks.set(botId, { action, promise: next }); + return next; +} + +function broadcastBotLifecycleChanged(payload: { + botId: string; + action: BotLifecycleActionRequest['action']; +}): void { + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed()) continue; + try { + win.webContents.send(MAKER_PUSH.BOT_LIFECYCLE_CHANGED, payload); + } catch (error) { + log.warn('Bot lifecycle broadcast failed', { error: String(error) }); + } + } +} + +function lifecycleResult( + botId: string, + action: BotLifecycleActionRequest['action'], + status: BotLifecycleActionResult['status'], + affected: Partial, + warnings: string[] = [], +): BotLifecycleActionResult { + return { + botId, + action, + status, + affected: { + sessions: affected.sessions ?? 0, + routes: affected.routes ?? 0, + automations: affected.automations ?? 0, + delegations: affected.delegations ?? 0, + deliveries: affected.deliveries ?? 0, + worktrees: affected.worktrees ?? 0, + }, + ...(warnings.length > 0 ? { warnings } : {}), + }; +} + +export function createBotLifecycleService(deps: BotLifecycleServiceDeps) { + const now = deps.now ?? Date.now; + const pauseSchedule = deps.pauseSchedule ?? (async (scheduleId: string) => { + const { scheduler } = await awaitReadyWithTimeout(); + await scheduler.pause(scheduleId); + }); + const resumeSchedule = deps.resumeSchedule ?? (async (scheduleId: string) => { + const { scheduler } = await awaitReadyWithTimeout(); + await scheduler.resume(scheduleId); + }); + const retainWorktrees = deps.retainWorktrees ?? retainBotWorkspaceLeases; + const releaseWorktrees = deps.releaseWorktrees ?? releaseAllBotWorkspaceLeases; + const createCanonicalSession = deps.createCanonicalSession ?? (async (input) => { + const { createBotCanonicalSession } = await import('../localDb/ipc/bots.js'); + return createBotCanonicalSession(input); + }); + const deleteProfileAndDetachSessions = + deps.deleteProfileAndDetachSessions ?? deleteBotProfileAndDetachSessionsInDb; + + const readProfile = async (botId: string) => { + const [profile] = await getDbClient() + .drizzle.select() + .from(botProfiles) + .where(eq(botProfiles.id, botId)) + .limit(1); + if (!profile) throwIpcError('NOT_FOUND', 'Bot 不存在'); + return profile; + }; + + const closeBotSessions = async (botId: string): Promise<{ count: number; warnings: string[] }> => { + const links = await getDbClient() + .drizzle.select({ sessionId: botSessionLinks.sessionId }) + .from(botSessionLinks) + .where(eq(botSessionLinks.botId, botId)); + const ids = [...new Set(links.map((row) => row.sessionId))]; + const settled = await Promise.allSettled(ids.map((sessionId) => deps.maker.closeSession(sessionId))); + const warnings = settled.flatMap((result, index) => + result.status === 'rejected' + ? [`SESSION_CLOSE_FAILED:${ids[index]}:${String(result.reason)}`] + : [], + ); + return { count: ids.length, warnings }; + }; + + const pause = async (botId: string): Promise => { + const profile = await readProfile(botId); + if (profile.status === 'archived' || profile.status === 'deleting') { + throwIpcError('PRECONDITION_FAILED', `Bot 当前状态为 ${profile.status}`); + } + const db = getDbClient().drizzle; + const [routes, automations] = await Promise.all([ + db.select().from(botRoutes).where(eq(botRoutes.botId, botId)), + db.select().from(botAutomationLinks).where(eq(botAutomationLinks.botId, botId)), + ]); + const schedulesToPause = automations + .filter((row) => row.suspendedStatus === 'active' || row.status === 'active') + .map((row) => row.scheduleId) + .filter((value): value is string => !!value); + const at = now(); + const paused = await getDbClient().tx<{ routes: number; automations: number }>( + 'bots.pauseLifecycle', + { + botId, + canonicalSessionId: profile.canonicalSessionId, + expectedProfileStatus: profile.status, + at, + eventId: randomUUID(), + }, + ); + + const warnings: string[] = []; + const uniqueSchedulesToPause = [...new Set(schedulesToPause)]; + const scheduleResults = await Promise.allSettled( + uniqueSchedulesToPause.map((scheduleId) => pauseSchedule(scheduleId)), + ); + scheduleResults.forEach((result, index) => { + if (result.status === 'rejected') { + warnings.push( + `SCHEDULE_PAUSE_FAILED:${uniqueSchedulesToPause[index]}:${String(result.reason)}`, + ); + } + }); + if (warnings.length > 0) { + // Profile/routes/links are already paused, so no new run can be claimed. Do not + // continue closing tasks, detaching worktrees or archiving the Bot until every + // in-flight Scheduler run has acknowledged cancellation. A retry is idempotent: + // suspendedStatus keeps the original active schedules discoverable. + await db.insert(botLifecycleEvents).values({ + id: randomUUID(), + botId, + sessionId: profile.canonicalSessionId, + eventType: 'pause-failed', + payloadJson: JSON.stringify({ warnings }), + createdAt: now(), + }); + throwIpcError( + 'PRECONDITION_FAILED', + '部分 Bot Automation 无法安全停止,Bot 已保持暂停;请重试后再归档或删除', + ); + } + const delegationService = deps.getDelegationService(); + const outboxService = deps.getOutboxService(); + const [delegations, deliveries, closed] = await Promise.all([ + delegationService?.cancelDelegationsForBot( + botId, + 'The Bot was paused by the user.', + ) ?? Promise.resolve(0), + outboxService?.suspendForBot(botId) ?? Promise.resolve(0), + closeBotSessions(botId), + ]); + warnings.push(...closed.warnings); + const completedAt = now(); + await db.insert(botLifecycleEvents).values({ + id: randomUUID(), + botId, + sessionId: profile.canonicalSessionId, + eventType: warnings.length > 0 ? 'paused-with-warnings' : 'paused', + payloadJson: JSON.stringify({ warnings }), + createdAt: completedAt, + }); + const result = lifecycleResult( + botId, + 'pause', + 'paused', + { + sessions: closed.count, + routes: paused.routes, + automations: paused.automations, + delegations, + deliveries, + }, + warnings, + ); + broadcastBotLifecycleChanged({ botId, action: 'pause' }); + return result; + }; + + const resume = async (botId: string): Promise => { + const profile = await readProfile(botId); + if (profile.status === 'archived' || profile.status === 'deleting') { + throwIpcError('PRECONDITION_FAILED', `Bot 当前状态为 ${profile.status}`); + } + const db = getDbClient().drizzle; + const [routes, automations] = await Promise.all([ + db + .select() + .from(botRoutes) + .where(and(eq(botRoutes.botId, botId), eq(botRoutes.status, 'paused'))), + db + .select() + .from(botAutomationLinks) + .where(and(eq(botAutomationLinks.botId, botId), eq(botAutomationLinks.status, 'paused'))), + ]); + const suspendedRoutes = routes.filter( + (row): row is typeof row & { suspendedStatus: RouteSuspendStatus } => + row.suspendedStatus !== null, + ); + const suspendedAutomations = automations.filter( + (row): row is typeof row & { suspendedStatus: AutomationSuspendStatus } => + row.suspendedStatus !== null, + ); + const schedulesToResume = suspendedAutomations + .filter((row) => row.suspendedStatus === 'active' && row.scheduleId) + .map((row) => row.scheduleId!); + const uniqueSchedulesToResume = [...new Set(schedulesToResume)]; + const scheduleResults = await Promise.allSettled( + uniqueSchedulesToResume.map((scheduleId) => resumeSchedule(scheduleId)), + ); + const failures = scheduleResults.flatMap((result, index) => + result.status === 'rejected' + ? [`SCHEDULE_RESUME_FAILED:${uniqueSchedulesToResume[index]}:${String(result.reason)}`] + : [], + ); + if (failures.length > 0) { + await db.insert(botLifecycleEvents).values({ + id: randomUUID(), + botId, + sessionId: profile.canonicalSessionId, + eventType: 'resume-failed', + payloadJson: JSON.stringify({ warnings: failures }), + createdAt: now(), + }); + throwIpcError('PRECONDITION_FAILED', '部分 Bot Automation 无法恢复,Bot 仍保持暂停'); + } + + const at = now(); + const resumed = await getDbClient().tx<{ routes: number; automations: number }>( + 'bots.resumeLifecycle', + { + botId, + canonicalSessionId: profile.canonicalSessionId, + expectedProfileStatus: profile.status, + at, + eventId: randomUUID(), + }, + ); + const deliveries = await (deps.getOutboxService()?.resumeForBot(botId) ?? Promise.resolve(0)); + const result = lifecycleResult(botId, 'resume', 'active', { + routes: resumed.routes, + automations: resumed.automations, + deliveries, + }); + broadcastBotLifecycleChanged({ botId, action: 'resume' }); + return result; + }; + + const archive = async ( + request: BotLifecycleActionRequest, + ): Promise => { + let profile = await readProfile(request.botId); + if (profile.status === 'deleting') { + throwIpcError('PRECONDITION_FAILED', 'Bot 正在永久删除'); + } + if (profile.status === 'archived') { + return lifecycleResult(request.botId, 'archive', 'archived', {}); + } + + if (profile.status !== 'paused') { + await pause(request.botId); + profile = await readProfile(request.botId); + } + + const db = getDbClient().drizzle; + const [links, routes, automations] = await Promise.all([ + db + .select({ sessionId: botSessionLinks.sessionId }) + .from(botSessionLinks) + .where(eq(botSessionLinks.botId, request.botId)), + db.select({ id: botRoutes.id }).from(botRoutes).where(eq(botRoutes.botId, request.botId)), + db + .select({ id: botAutomationLinks.id }) + .from(botAutomationLinks) + .where(eq(botAutomationLinks.botId, request.botId)), + ]); + const sessionIds = [...new Set(links.map((row) => row.sessionId))]; + const at = now(); + const archived = await getDbClient().tx<{ sessions: number }>('bots.archiveLifecycle', { + botId: request.botId, + canonicalSessionId: profile.canonicalSessionId, + expectedProfileStatus: profile.status, + worktreeDisposition: request.worktreeDisposition ?? 'retain', + at, + eventId: randomUUID(), + }); + + const warnings: string[] = []; + let deliveries = 0; + try { + deliveries = await ( + deps.getOutboxService()?.cancelForBot(request.botId, 'Bot archived') + ?? Promise.resolve(0) + ); + } catch (error) { + warnings.push(`OUTBOX_CANCEL_FAILED:${String(error)}`); + } + let worktrees = 0; + try { + worktrees = request.worktreeDisposition === 'recycle' + ? await releaseWorktrees(request.botId) + : await retainWorktrees(request.botId); + } catch (error) { + warnings.push(`WORKTREE_DISPOSITION_FAILED:${String(error)}`); + } + const result = lifecycleResult(request.botId, 'archive', 'archived', { + sessions: archived.sessions, + routes: routes.length, + automations: automations.length, + deliveries, + worktrees, + }, warnings); + broadcastBotLifecycleChanged({ botId: request.botId, action: 'archive' }); + return result; + }; + + const restore = async (botId: string): Promise => { + const profile = await readProfile(botId); + if (profile.status !== 'archived') { + throwIpcError('PRECONDITION_FAILED', `Bot 当前状态为 ${profile.status}`); + } + if (profile.canonicalSessionId) { + throwIpcError('PRECONDITION_FAILED', '已归档 Bot 不应保留主任务指针'); + } + const db = getDbClient().drizzle; + const at = now(); + const [claimed] = await db + .update(botProfiles) + .set({ status: 'paused', updatedAt: at }) + .where(and(eq(botProfiles.id, botId), eq(botProfiles.status, 'archived'))) + .returning({ id: botProfiles.id }); + if (!claimed) throwIpcError('PRECONDITION_FAILED', 'Bot 生命周期已被另一处操作更新'); + + let canonicalSessionId: string; + try { + const created = await createCanonicalSession({ + botId, + expectedCanonicalSessionId: null, + expectedProfileVersion: profile.currentVersion, + }); + canonicalSessionId = created.canonicalSessionId; + } catch (error) { + await db + .update(botProfiles) + .set({ status: 'archived', canonicalSessionId: null, updatedAt: now() }) + .where(and(eq(botProfiles.id, botId), eq(botProfiles.status, 'paused'))); + throw error; + } + + try { + const resumed = await resume(botId); + const completedAt = now(); + await db.insert(botLifecycleEvents).values({ + id: randomUUID(), + botId, + sessionId: canonicalSessionId, + eventType: 'restored', + payloadJson: JSON.stringify({ profileVersion: profile.currentVersion }), + createdAt: completedAt, + }); + const result = { + ...resumed, + action: 'restore' as const, + canonicalSessionId, + affected: { ...resumed.affected, sessions: 1 }, + }; + broadcastBotLifecycleChanged({ botId, action: 'restore' }); + return result; + } catch (error) { + // Resume is fail-closed and leaves the Bot paused. Keep the fresh + // canonical task so the user can diagnose and retry Resume without + // creating another task or reviving archived history. + await db.insert(botLifecycleEvents).values({ + id: randomUUID(), + botId, + sessionId: canonicalSessionId, + eventType: 'restore-paused', + payloadJson: JSON.stringify({ error: String(error) }), + createdAt: now(), + }); + throw error; + } + }; + + const remove = async ( + request: BotLifecycleActionRequest, + ): Promise => { + let profile = await readProfile(request.botId); + if (request.confirmName !== profile.displayName) { + throwIpcError('INVALID_PARAMS', '请输入完整 Bot 名称以确认永久删除'); + } + if (profile.status === 'deleting') { + throwIpcError('PRECONDITION_FAILED', 'Bot 已在永久删除流程中'); + } + if (profile.status !== 'archived') { + await archive({ + ...request, + action: 'archive', + worktreeDisposition: request.worktreeDisposition ?? 'retain', + }); + profile = await readProfile(request.botId); + } else if (request.worktreeDisposition === 'recycle') { + await releaseWorktrees(request.botId); + } else { + await retainWorktrees(request.botId); + } + + const db = getDbClient().drizzle; + const links = await db + .select({ sessionId: botSessionLinks.sessionId }) + .from(botSessionLinks) + .where(eq(botSessionLinks.botId, request.botId)); + const sessionIds = [...new Set(links.map((row) => row.sessionId))]; + const [delegations, deliveries, closed] = await Promise.all([ + deps.getDelegationService()?.cancelDelegationsForBot( + request.botId, + 'The Bot was permanently deleted by the user.', + ) ?? Promise.resolve(0), + deps.getOutboxService()?.cancelForBot(request.botId, 'Bot permanently deleted') + ?? Promise.resolve(0), + closeBotSessions(request.botId), + ]); + + await deleteProfileAndDetachSessions( + request.botId, + sessionIds, + request.keepTaskHistory === true, + ); + + const result = lifecycleResult(request.botId, 'delete', 'deleted', { + sessions: sessionIds.length, + delegations, + deliveries, + }, closed.warnings); + broadcastBotLifecycleChanged({ botId: request.botId, action: 'delete' }); + return result; + }; + + const run = (request: BotLifecycleActionRequest): Promise => + withBotLifecycleLock(request.botId, request.action, async () => { + if (request.action === 'pause') return pause(request.botId); + if (request.action === 'resume') return resume(request.botId); + if (request.action === 'archive') return archive(request); + if (request.action === 'restore') return restore(request.botId); + if (request.action === 'delete') return remove(request); + throwIpcError('PRECONDITION_FAILED', `${request.action} 尚未接入 Bot 生命周期协调器`); + }); + + return { run }; +} + +export function registerBotLifecycleHandlers(deps: BotLifecycleServiceDeps): void { + const service = createBotLifecycleService(deps); + ipcMain.handle(MAKER_INVOKE.BOT_LIFECYCLE_ACTION, async (event, raw: unknown) => { + assertTrustedAppRendererEvent(event); + const body = requireObject(raw, 'request'); + const botId = requireString(body.botId, 'botId'); + const action = requireString(body.action, 'action'); + if (!['pause', 'resume', 'archive', 'restore', 'delete'].includes(action)) { + throwIpcError('INVALID_PARAMS', '未知 Bot 生命周期操作'); + } + return service.run({ + botId, + action: action as BotLifecycleActionRequest['action'], + confirmName: typeof body.confirmName === 'string' ? body.confirmName : undefined, + worktreeDisposition: + body.worktreeDisposition === 'retain' || body.worktreeDisposition === 'recycle' + ? body.worktreeDisposition + : undefined, + keepTaskHistory: body.keepTaskHistory === true, + }); + }); +} diff --git a/apps/desktop/src/main/maker-ipc/botProfileRuntime.ts b/apps/desktop/src/main/maker-ipc/botProfileRuntime.ts new file mode 100644 index 0000000000..9b7ad84c16 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/botProfileRuntime.ts @@ -0,0 +1,760 @@ +import { and, desc, eq, inArray } from 'drizzle-orm'; +import { buildBotMemoryScopeKey, buildMemoryScopeKey } from '@cindy/maker-core'; +import { createHash, randomUUID } from 'node:crypto'; + +import type { MakerSessionCreateOpts } from './sessionRequest.js'; +import { buildDefaultBotIdentity } from '../../shared/botProfileDefaults.js'; +import { getDbClient } from '../localDb/client/current.js'; +import { + botLifecycleEvents, + botProfileVersions, + botProfiles, + botRuntimeSnapshots, + botSessionLinks, + sessions, +} from '../localDb/schema.js'; + +interface BotSkillCatalogItem { + name: string; + enabled?: boolean; + runtimeStatus?: 'discovered' | 'approved' | 'loaded' | 'failed' | 'unknown'; + runtimeCommandName?: string; + path?: string; + scope?: string; + contentSha256?: string; +} + +interface BotMcpCatalogItem { + name: string; + source: 'builtin' | 'custom'; + available?: boolean; + generation?: string; +} + +interface BotToolsetCatalogItem { + id: string; + name: string; + essential?: boolean; + available?: boolean; + version?: string; +} + +export interface BotProfileRuntimeSnapshot { + snapshotId: string; + botId: string; + sessionId: string; + profileVersion: number; + resolutionStatus: 'applied' | 'degraded'; + configuredSkills: string[]; + resolvedSkills: string[]; + unavailableSkills: string[]; + resolvedSkillEntries: BotSkillCatalogItem[]; + skillCatalogAvailable: boolean; + skillMode: 'inherit' | 'allowlist'; + configuredMcpServers: string[]; + resolvedMcpServers: string[]; + unavailableMcpServers: string[]; + mcpMode: 'inherit' | 'allowlist'; + configuredToolsets: string[]; + resolvedToolsets: string[]; + unavailableToolsets: string[]; + disabledToolsets: string[]; + toolsetMode: 'inherit' | 'allowlist'; + memoryRefs: BotMemoryRuntimeRef[]; +} + +export interface BotMemoryRuntimeRef { + kind: 'bot' | 'project' | 'user'; + scopeKey: string; + access: 'read-write' | 'read-only'; + status: 'captured' | 'unavailable'; + sha256?: string; + bytes?: number; +} + +export type BotRuntimeFailureStage = 'prepare' | 'agent-start' | 'storage'; + +export interface BotProfileRuntimeDeps { + listSkills?: (input: { + agentKind: MakerSessionCreateOpts['agentKind']; + workingDir: string; + remoteHostId?: string; + }) => Promise; + listMcpServers?: (input: { + agentKind: MakerSessionCreateOpts['agentKind']; + workingDir: string; + remoteHostId?: string; + }) => Promise; + listToolsets?: (input: { + agentKind: MakerSessionCreateOpts['agentKind']; + workingDir: string; + remoteHostId?: string; + }) => Promise; + readMemoryIndex?: (scopeKey: string) => Promise; + readSkillSource?: (input: { + path: string; + remoteHostId?: string; + }) => Promise; + fingerprintSkillSource?: (input: { + path: string; + remoteHostId?: string; + }) => Promise; +} + +function runtimeFailureMetadata( + stage: BotRuntimeFailureStage, + error: unknown, +): Record { + const source = error && typeof error === 'object' ? error as Record : {}; + const name = error instanceof Error && error.name.trim() ? error.name.trim() : 'Error'; + const code = typeof source.code === 'string' ? source.code.trim().slice(0, 120) : ''; + return { + stage, + errorName: name.slice(0, 120), + ...(code ? { errorCode: code } : {}), + }; +} + +export async function markBotProfileRuntimeApplied( + snapshot: BotProfileRuntimeSnapshot, +): Promise { + const appliedAt = Date.now(); + return getDbClient().tx('bots.finishRuntime', { + snapshotId: snapshot.snapshotId, + botId: snapshot.botId, + sessionId: snapshot.sessionId, + status: snapshot.resolutionStatus, + finishedAt: appliedAt, + failureJson: null, + eventId: randomUUID(), + eventType: 'runtime-applied', + eventPayloadJson: JSON.stringify({ + snapshotId: snapshot.snapshotId, + profileVersion: snapshot.profileVersion, + status: snapshot.resolutionStatus, + unavailableSkills: snapshot.unavailableSkills, + unavailableMcpServers: snapshot.unavailableMcpServers, + unavailableToolsets: snapshot.unavailableToolsets, + }), + }); +} + +export async function markBotProfileRuntimeFailed( + snapshot: BotProfileRuntimeSnapshot, + input: { stage: BotRuntimeFailureStage; error: unknown }, +): Promise { + const failedAt = Date.now(); + const failure = runtimeFailureMetadata(input.stage, input.error); + return getDbClient().tx('bots.finishRuntime', { + snapshotId: snapshot.snapshotId, + botId: snapshot.botId, + sessionId: snapshot.sessionId, + status: 'failed', + finishedAt: failedAt, + failureJson: JSON.stringify(failure), + eventId: randomUUID(), + eventType: 'runtime-failed', + eventPayloadJson: JSON.stringify({ + snapshotId: snapshot.snapshotId, + profileVersion: snapshot.profileVersion, + ...failure, + }), + }); +} + +function parseObject(value: string | null | undefined): Record { + try { + const parsed = JSON.parse(value ?? '{}') as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function readStringList(value: unknown): string[] { + return Array.isArray(value) + ? [ + ...new Set( + value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean), + ), + ] + : []; +} + +export function buildBotProfilePrompt(input: { + displayName: string; + identitySource: string; +}): string { + const displayName = input.displayName.trim(); + return input.identitySource.trim() || buildDefaultBotIdentity(displayName); +} + +/** + * Hermes keeps SOUL as the complete identity slot and renders the active + * profile marker as a separate stable prompt section. Keeping the two values + * separate prevents Cindy-owned metadata from silently changing a user's SOUL + * bytes or being mistaken for part of the identity document. + */ +export function buildBotProfileContextPrompt(displayName: string): string { + const name = displayName.trim() || 'Cindy Bot'; + return `Active Cindy Bot profile: ${name}.`; +} + +export function buildBotUserProfilePrompt(userContextSource: string): string { + const content = userContextSource.trim(); + return content ? `## User Profile\n${content}` : ''; +} + +function memoryRef( + kind: BotMemoryRuntimeRef['kind'], + scopeKey: string, + access: BotMemoryRuntimeRef['access'], + content: string | null, +): BotMemoryRuntimeRef { + if (content === null) return { kind, scopeKey, access, status: 'unavailable' }; + return { + kind, + scopeKey, + access, + status: 'captured', + sha256: createHash('sha256').update(content, 'utf8').digest('hex'), + bytes: Buffer.byteLength(content, 'utf8'), + }; +} + +function formatMemorySnapshot(title: string, content: string): string { + const body = content.trim(); + return body ? `## ${title}\n${body}` : ''; +} + +export function resolveBotSkillReferences( + configuredSkills: string[], + catalog: BotSkillCatalogItem[], +): { + resolvedSkills: string[]; + unavailableSkills: string[]; + resolvedSkillEntries: BotSkillCatalogItem[]; +} { + const available = new Map(); + for (const item of catalog) { + if (!item || typeof item.name !== 'string' || !item.name.trim()) continue; + available.set(item.name.trim(), item); + if (item.runtimeCommandName?.trim()) available.set(item.runtimeCommandName.trim(), item); + } + const resolvedSkills: string[] = []; + const resolvedSkillEntries: BotSkillCatalogItem[] = []; + const unavailableSkills: string[] = []; + for (const raw of configuredSkills) { + const name = raw.trim(); + if (!name) continue; + const item = available.get(name); + if (!item || item.enabled === false || item.runtimeStatus === 'failed') { + unavailableSkills.push(name); + continue; + } + resolvedSkills.push(item.runtimeCommandName?.trim() || item.name.trim()); + resolvedSkillEntries.push(item); + } + return { + resolvedSkills: [...new Set(resolvedSkills)], + unavailableSkills: [...new Set(unavailableSkills)], + resolvedSkillEntries: [ + ...new Map( + resolvedSkillEntries.map((item) => [ + item.runtimeCommandName?.trim() || item.name.trim(), + item, + ]), + ).values(), + ], + }; +} + +export function resolveBotMcpReferences(input: { + configured: string[]; + mode: 'inherit' | 'allowlist'; + catalog: BotMcpCatalogItem[]; +}): { resolved: string[]; unavailable: string[] } { + const available = new Set( + input.catalog + .filter((item) => item.source === 'custom' && item.available !== false) + .map((item) => item.name), + ); + if (input.mode === 'inherit') { + return { resolved: [...available], unavailable: [] }; + } + return { + resolved: input.configured.filter((name) => available.has(name)), + unavailable: input.configured.filter((name) => !available.has(name)), + }; +} + +export function resolveBotToolsetReferences(input: { + configured: string[]; + mode: 'inherit' | 'allowlist'; + catalog: BotToolsetCatalogItem[]; +}): { + resolved: string[]; + unavailable: string[]; + disabled: string[]; +} { + const configurable = input.catalog.filter((item) => !item.essential); + const available = new Set( + configurable.filter((item) => item.available !== false).map((item) => item.id), + ); + if (input.mode === 'inherit') { + return { + resolved: [...available], + unavailable: [], + disabled: configurable.filter((item) => item.available === false).map((item) => item.id), + }; + } + const resolved = input.configured.filter((id) => available.has(id)); + const resolvedSet = new Set(resolved); + return { + resolved, + unavailable: input.configured.filter((id) => !available.has(id)), + disabled: configurable.filter((item) => !resolvedSet.has(item.id)).map((item) => item.id), + }; +} + +/** + * Resolve the Bot Profile snapshot at the main-side session-start boundary. + * + * This deliberately produces only the SOUL-equivalent identity segment. + * Skills, MCP, toolsets, memory and automation must be applied by their native + * runtime owners; declaring them in natural language would create a fake + * capability surface that can drift from what the harness actually loaded. + */ +export async function hydrateBotProfileRuntime( + opts: MakerSessionCreateOpts, + deps: BotProfileRuntimeDeps = {}, + options: { persistSnapshot?: boolean } = {}, +): Promise { + if (!opts.id) return null; + const db = getDbClient().drizzle; + const [row] = await db + .select({ + botId: botSessionLinks.botId, + role: botSessionLinks.role, + profileVersion: botSessionLinks.profileVersion, + }) + .from(botSessionLinks) + .innerJoin(sessions, eq(sessions.id, botSessionLinks.sessionId)) + .where(and(eq(botSessionLinks.sessionId, opts.id), eq(sessions.source, 'bot'))) + .limit(1); + if (!row || (row.role !== 'canonical' && row.role !== 'route')) return null; + const [profile] = await db + .select() + .from(botProfiles) + .where(eq(botProfiles.id, row.botId)) + .limit(1); + if (!profile) return null; + const [version] = await db + .select() + .from(botProfileVersions) + .where( + and( + eq(botProfileVersions.botId, row.botId), + eq(botProfileVersions.version, row.profileVersion), + ), + ) + .limit(1); + if (!version) return null; + const config = parseObject(version.capabilitiesJson); + const configuredSkills = Array.isArray(config.skills) + ? config.skills.filter((item): item is string => typeof item === 'string') + : []; + const skillMode = + config.skillMode === 'allowlist' + ? 'allowlist' + : config.skillMode === 'inherit' + ? 'inherit' + : configuredSkills.length > 0 + ? 'allowlist' + : 'inherit'; + const configuredMcpServers = readStringList(config.mcpServers); + const mcpMode = + config.mcpMode === 'allowlist' + ? 'allowlist' + : config.mcpMode === 'inherit' + ? 'inherit' + : configuredMcpServers.length > 0 + ? 'allowlist' + : 'inherit'; + const rawToolsets = readStringList(config.toolsets ?? config.tools); + const legacyToolPlaceholders = new Set(['files', 'browser', 'mcp']); + const hasOnlyLegacyToolPlaceholders = + rawToolsets.length > 0 && rawToolsets.every((item) => legacyToolPlaceholders.has(item)); + const configuredToolsets = hasOnlyLegacyToolPlaceholders ? [] : rawToolsets; + const toolsetMode = + config.toolsetMode === 'allowlist' + ? 'allowlist' + : config.toolsetMode === 'inherit' + ? 'inherit' + : configuredToolsets.length > 0 + ? 'allowlist' + : 'inherit'; + if (typeof config.memory === 'boolean') opts.makerMemoryEnabled = config.memory; + const botMemoryScopeKey = buildBotMemoryScopeKey(row.botId); + if (config.memory !== false) opts.makerMemoryScopeKey = botMemoryScopeKey; + const projectMemoryScopeKey = buildMemoryScopeKey(opts.workingDir, opts.remoteHostId); + let botMemoryIndex: string | null = ''; + let projectMemoryIndex: string | null = ''; + if (config.memory !== false && deps.readMemoryIndex) { + const [botMemory, projectMemory] = await Promise.allSettled([ + deps.readMemoryIndex(botMemoryScopeKey), + deps.readMemoryIndex(projectMemoryScopeKey), + ]); + botMemoryIndex = botMemory.status === 'fulfilled' ? botMemory.value : null; + projectMemoryIndex = projectMemory.status === 'fulfilled' ? projectMemory.value : null; + opts.makerMemoryIndexSnapshot = [ + formatMemorySnapshot('Bot Memory', botMemoryIndex ?? ''), + formatMemorySnapshot('Project Memory (read-only)', projectMemoryIndex ?? ''), + ].filter(Boolean).join('\n\n'); + } + const userContextSource = typeof config.userContextSource === 'string' + ? config.userContextSource + : ''; + opts.botUserProfilePrompt = buildBotUserProfilePrompt(userContextSource); + const memoryRefs: BotMemoryRuntimeRef[] = config.memory === false + ? [] + : [ + memoryRef('bot', botMemoryScopeKey, 'read-write', botMemoryIndex), + memoryRef('project', projectMemoryScopeKey, 'read-only', projectMemoryIndex), + memoryRef('user', `profile:${row.botId}:v${row.profileVersion}`, 'read-only', userContextSource), + ]; + let resolvedSkills = configuredSkills; + let unavailableSkills: string[] = []; + let catalog: BotSkillCatalogItem[] = []; + let resolvedSkillEntries: BotSkillCatalogItem[] = []; + let skillCatalogAvailable = true; + let runtimeSkillMode: 'inherit' | 'allowlist' = skillMode; + if (deps.listSkills) { + try { + catalog = await deps.listSkills({ + agentKind: opts.agentKind, + workingDir: opts.workingDir, + remoteHostId: opts.remoteHostId, + }); + if (skillMode === 'inherit') { + resolvedSkillEntries = catalog.filter( + (item) => item.enabled !== false && item.runtimeStatus !== 'failed', + ); + resolvedSkills = resolvedSkillEntries.map( + (item) => item.runtimeCommandName?.trim() || item.name.trim(), + ); + } else { + ({ resolvedSkills, unavailableSkills, resolvedSkillEntries } = resolveBotSkillReferences( + configuredSkills, + catalog, + )); + } + } catch (error) { + // A remote Bot must freeze the catalog from the same machine that will + // execute the Agent. Continuing with an empty/local catalog can leave + // harness-default Skills enabled while the snapshot claims otherwise. + if (opts.remoteHostId) throw error; + // Fail closed: a configured Skill is not advertised to the Bot when the + // native harness catalog cannot prove that it exists for this runtime. + skillCatalogAvailable = false; + runtimeSkillMode = 'allowlist'; + resolvedSkills = []; + unavailableSkills = skillMode === 'allowlist' ? [...new Set(configuredSkills)] : []; + resolvedSkillEntries = []; + } + } + if ((deps.fingerprintSkillSource || deps.readSkillSource) && resolvedSkillEntries.length > 0) { + const fingerprinted: BotSkillCatalogItem[] = []; + for (const entry of resolvedSkillEntries) { + const skillPath = entry.path?.trim(); + const runtimeName = entry.runtimeCommandName?.trim() || entry.name.trim(); + if (!skillPath) { + unavailableSkills.push(runtimeName); + continue; + } + try { + const contentSha256 = deps.fingerprintSkillSource + ? await deps.fingerprintSkillSource({ + path: skillPath, + remoteHostId: opts.remoteHostId, + }) + : createHash('sha256').update(await deps.readSkillSource!({ + path: skillPath, + remoteHostId: opts.remoteHostId, + }), 'utf8').digest('hex'); + if (!/^[a-f0-9]{64}$/i.test(contentSha256)) { + throw new Error('Skill source fingerprint is invalid'); + } + fingerprinted.push({ + ...entry, + contentSha256: contentSha256.toLowerCase(), + }); + } catch { + unavailableSkills.push(runtimeName); + } + } + resolvedSkillEntries = fingerprinted; + // Runtime policy must only expose entries whose complete source was + // fingerprinted. Otherwise a failed read can still leave the native + // harness free to load a configured Skill that the frozen snapshot omitted. + catalog = fingerprinted; + const usableNames = new Set( + fingerprinted.map((entry) => entry.runtimeCommandName?.trim() || entry.name.trim()), + ); + resolvedSkills = resolvedSkills.filter((name) => usableNames.has(name)); + unavailableSkills = [...new Set(unavailableSkills)]; + } + // A Bot task freezes the catalog at start. `inherit` is a profile-authoring + // convenience, not permission for a live harness to discover future Skills. + if (deps.listSkills && skillCatalogAvailable) runtimeSkillMode = 'allowlist'; + const runtimeConfiguredSkills = + skillMode === 'inherit' ? [...resolvedSkills] : [...configuredSkills]; + let mcpCatalog: BotMcpCatalogItem[] = []; + let resolvedMcpServers: string[] = []; + let unavailableMcpServers: string[] = []; + let runtimeMcpMode: 'inherit' | 'allowlist' = mcpMode; + if (deps.listMcpServers) { + runtimeMcpMode = 'allowlist'; + try { + mcpCatalog = await deps.listMcpServers({ + agentKind: opts.agentKind, + workingDir: opts.workingDir, + remoteHostId: opts.remoteHostId, + }); + const resolvedMcp = resolveBotMcpReferences({ + configured: configuredMcpServers, + mode: mcpMode, + catalog: mcpCatalog, + }); + resolvedMcpServers = resolvedMcp.resolved; + unavailableMcpServers = resolvedMcp.unavailable; + } catch { + mcpCatalog = []; + resolvedMcpServers = []; + unavailableMcpServers = mcpMode === 'allowlist' ? configuredMcpServers : []; + } + } else if (mcpMode === 'allowlist') { + unavailableMcpServers = configuredMcpServers; + } + const runtimeConfiguredMcpServers = + mcpMode === 'inherit' ? [...resolvedMcpServers] : [...configuredMcpServers]; + let toolsetCatalog: BotToolsetCatalogItem[] = []; + let resolvedToolsets: string[] = []; + let unavailableToolsets: string[] = []; + let disabledToolsets: string[] = []; + let runtimeToolsetMode: 'inherit' | 'allowlist' = toolsetMode; + if (deps.listToolsets) { + runtimeToolsetMode = 'allowlist'; + try { + toolsetCatalog = await deps.listToolsets({ + agentKind: opts.agentKind, + workingDir: opts.workingDir, + remoteHostId: opts.remoteHostId, + }); + const resolvedToolsetsResult = resolveBotToolsetReferences({ + configured: configuredToolsets, + mode: toolsetMode, + catalog: toolsetCatalog, + }); + resolvedToolsets = resolvedToolsetsResult.resolved; + unavailableToolsets = resolvedToolsetsResult.unavailable; + disabledToolsets = resolvedToolsetsResult.disabled; + } catch { + toolsetCatalog = []; + resolvedToolsets = []; + unavailableToolsets = toolsetMode === 'allowlist' ? configuredToolsets : []; + disabledToolsets = []; + } + } else if (toolsetMode === 'allowlist') { + unavailableToolsets = configuredToolsets; + } + const runtimeConfiguredToolsets = + toolsetMode === 'inherit' ? [...resolvedToolsets] : [...configuredToolsets]; + const identity = version.identitySource.trim(); + opts.botProfilePrompt = buildBotProfilePrompt({ + displayName: profile.displayName, + identitySource: identity, + }); + opts.botProfileContextPrompt = buildBotProfileContextPrompt(profile.displayName); + opts.botRuntimeProfile = { + botId: row.botId, + profileVersion: row.profileVersion, + skillPolicy: { + mode: runtimeSkillMode, + configured: runtimeConfiguredSkills, + catalog: catalog.map((item) => ({ + name: item.name.trim(), + ...(item.runtimeCommandName?.trim() + ? { runtimeCommandName: item.runtimeCommandName.trim() } + : {}), + ...(item.path?.trim() ? { path: item.path.trim() } : {}), + ...(item.enabled !== undefined ? { enabled: item.enabled } : {}), + ...(item.runtimeStatus ? { runtimeStatus: item.runtimeStatus } : {}), + ...(item.scope?.trim() ? { scope: item.scope.trim() } : {}), + ...(item.contentSha256 ? { contentSha256: item.contentSha256 } : {}), + })), + }, + mcpPolicy: { + mode: runtimeMcpMode, + configured: runtimeConfiguredMcpServers, + catalog: mcpCatalog.map((item) => ({ ...item })), + }, + toolsetPolicy: { + mode: runtimeToolsetMode, + configured: runtimeConfiguredToolsets, + catalog: toolsetCatalog.map((item) => ({ ...item })), + }, + }; + const preparedAt = Date.now(); + const resolutionStatus = + !skillCatalogAvailable || + unavailableSkills.length > 0 || + unavailableMcpServers.length > 0 || + unavailableToolsets.length > 0 || + memoryRefs.some((ref) => ref.status === 'unavailable') + ? 'degraded' + : 'applied'; + const snapshotId = randomUUID(); + const profileProvenance = { + botId: row.botId, + version: row.profileVersion, + identitySha256: createHash('sha256').update(identity, 'utf8').digest('hex'), + userContextSha256: createHash('sha256').update(userContextSource, 'utf8').digest('hex'), + }; + const executionProvenance = { + agentKind: opts.agentKind, + model: opts.model, + providerId: typeof opts.providerId === 'string' ? opts.providerId : null, + effort: typeof opts.effort === 'string' ? opts.effort : null, + fastMode: opts.fastMode === true, + permissionMode: opts.permissionMode, + workspaceKind: opts.workspaceKind, + remote: Boolean(opts.remoteHostId), + }; + const configuredJson = JSON.stringify({ + schemaVersion: 1, + profile: profileProvenance, + execution: executionProvenance, + skillMode, + skills: configuredSkills, + memory: config.memory !== false, + userContext: userContextSource.length > 0, + automation: config.automation === true, + mcpMode, + mcpServers: configuredMcpServers, + toolsetMode, + toolsets: configuredToolsets, + }); + const resolvedJson = JSON.stringify({ + schemaVersion: 1, + profile: profileProvenance, + execution: executionProvenance, + skills: resolvedSkills, + skillCatalogAvailable, + unavailableSkills, + mcpServers: resolvedMcpServers, + unavailableMcpServers, + toolsets: resolvedToolsets, + unavailableToolsets, + disabledToolsets, + memoryScopeKey: opts.makerMemoryScopeKey ?? null, + memoryRefs, + skillResources: resolvedSkillEntries.map((entry) => ({ + name: entry.runtimeCommandName?.trim() || entry.name.trim(), + path: entry.path?.trim() || null, + sha256: entry.contentSha256 ?? null, + })), + mcpResources: resolvedMcpServers.map((name) => { + const entry = mcpCatalog.find((item) => item.name === name); + return { name, generation: entry?.generation ?? null }; + }), + toolsetResources: resolvedToolsets.map((id) => { + const entry = toolsetCatalog.find((item) => item.id === id); + return { id, version: entry?.version ?? null }; + }), + }); + const [previousSnapshot] = await db + .select({ resolvedJson: botRuntimeSnapshots.resolvedJson }) + .from(botRuntimeSnapshots) + .where( + and( + eq(botRuntimeSnapshots.sessionId, opts.id), + eq(botRuntimeSnapshots.profileVersion, row.profileVersion), + inArray(botRuntimeSnapshots.status, ['applied', 'degraded']), + ), + ) + .orderBy(desc(botRuntimeSnapshots.preparedAt)) + .limit(1); + if (previousSnapshot) { + const previousResolved = parseObject(previousSnapshot.resolvedJson); + const currentResolved = parseObject(resolvedJson); + for (const key of ['skillResources', 'mcpResources', 'toolsetResources'] as const) { + if (Array.isArray(previousResolved[key])) { + const previousFingerprint = JSON.stringify(previousResolved[key]); + const currentFingerprint = JSON.stringify(currentResolved[key]); + if (previousFingerprint === currentFingerprint) continue; + throw Object.assign( + new Error('Bot runtime resources changed after this task was frozen; Renew the Bot task to apply the new versions'), + { code: 'BOT_RUNTIME_RESOURCE_DRIFT' }, + ); + } + } + } + if (options.persistSnapshot !== false) { + await getDbClient().tx('bots.prepareRuntime', { + snapshot: { + id: snapshotId, + botId: row.botId, + sessionId: opts.id!, + profileVersion: row.profileVersion, + agentKind: opts.agentKind, + workingDir: opts.workingDir, + memoryScopeKey: opts.makerMemoryScopeKey ?? null, + configuredJson, + resolvedJson, + preparedAt, + }, + eventId: randomUUID(), + eventPayloadJson: JSON.stringify({ + snapshotId, + profileVersion: row.profileVersion, + agentKind: opts.agentKind, + resolutionStatus, + unavailableSkills, + unavailableMcpServers, + unavailableToolsets, + unavailableMemoryRefs: memoryRefs + .filter((ref) => ref.status === 'unavailable') + .map((ref) => ref.kind), + }), + }); + } + return { + snapshotId, + botId: row.botId, + sessionId: opts.id, + profileVersion: row.profileVersion, + resolutionStatus, + configuredSkills, + resolvedSkills, + unavailableSkills, + resolvedSkillEntries, + skillCatalogAvailable, + skillMode, + configuredMcpServers, + resolvedMcpServers, + unavailableMcpServers, + mcpMode, + configuredToolsets, + resolvedToolsets, + unavailableToolsets, + disabledToolsets, + toolsetMode, + memoryRefs, + }; +} diff --git a/apps/desktop/src/main/maker-ipc/botRemoteWorkspaceService.ts b/apps/desktop/src/main/maker-ipc/botRemoteWorkspaceService.ts new file mode 100644 index 0000000000..e9655c11c3 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/botRemoteWorkspaceService.ts @@ -0,0 +1,206 @@ +import { ensureRemoteHostReady, getRemoteSshPool } from '../remote-ssh/index.js'; + +export interface RemoteBotWorktreeMeta { + path: string; + baseRepo: string; + branch: string; + sourceBranch: string; +} + +function encoded(value: string): string { + return Buffer.from(value, 'utf8').toString('base64'); +} + +function shellBase64(value: string): string { + const value64 = encoded(value); + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value64)) { + throw new Error('Bot remote workspace argument encoding failed'); + } + return `'${value64}'`; +} + +function decodeLine(value: string | undefined, field: string): string { + if (!value || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) { + throw new Error(`Bot remote workspace returned an invalid ${field}`); + } + return Buffer.from(value, 'base64').toString('utf8'); +} + +async function readyHost(remoteHostId: string) { + await ensureRemoteHostReady(remoteHostId); + const host = getRemoteSshPool().get(remoteHostId); + if (!host || host.getStatus() !== 'ready') { + throw new Error(`Remote Bot workspace host is unavailable: ${remoteHostId}`); + } + return host; +} + +const CREATE_SCRIPT = String.raw` +set -euo pipefail +decode() { node -e 'process.stdout.write(Buffer.from(process.argv[1], "base64"))' "$1"; } +encode() { node -e 'process.stdout.write(Buffer.from(process.argv[1]).toString("base64"))' "$1"; } +base_repo="$(decode "$1")" +source_branch="$(decode "$2")" +slug="$(decode "$3")" +repo="$(git -C "$base_repo" rev-parse --show-toplevel)" +source_ref="\${source_branch:-HEAD}" +commit="$(git -C "$repo" rev-parse --verify "\${source_ref}^{commit}")" +worktree_root="$repo/.cindy-worktrees" +worktree_path="$worktree_root/$slug" +branch="cindy/bot-$slug" +mkdir -p "$worktree_root" +if [ -e "$worktree_path" ]; then + actual="$(git -C "$worktree_path" rev-parse --show-toplevel)" + [ "$actual" = "$worktree_path" ] || { + printf '%s\n' 'existing remote path is not the expected worktree' >&2 + exit 23 + } + actual_common="$(git -C "$worktree_path" rev-parse --path-format=absolute --git-common-dir)" + repo_common="$(git -C "$repo" rev-parse --path-format=absolute --git-common-dir)" + [ "$actual_common" = "$repo_common" ] || { + printf '%s\n' 'existing remote worktree belongs to another repository' >&2 + exit 24 + } + actual_branch="$(git -C "$worktree_path" symbolic-ref --quiet --short HEAD || true)" + [ "$actual_branch" = "$branch" ] || { + printf '%s\n' 'existing remote worktree is on an unexpected branch' >&2 + exit 25 + } +else + if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then + git -C "$repo" worktree add "$worktree_path" "$branch" >&2 + else + git -C "$repo" worktree add -b "$branch" "$worktree_path" "$commit" >&2 + fi +fi +printf '%s\n' "$(encode "$worktree_path")" +printf '%s\n' "$(encode "$repo")" +printf '%s\n' "$(encode "$branch")" +printf '%s\n' "$(encode "$source_ref")" +`; + +const INSPECT_SCRIPT = String.raw` +set -euo pipefail +decode() { node -e 'process.stdout.write(Buffer.from(process.argv[1], "base64"))' "$1"; } +encode() { node -e 'process.stdout.write(Buffer.from(process.argv[1]).toString("base64"))' "$1"; } +worktree_path="$(decode "$1")" +expected_repo="$(decode "$2")" +expected_branch="$(decode "$3")" +[ ! -e "$worktree_path" ] && exit 44 +[ -d "$worktree_path" ] || exit 45 +actual="$(git -C "$worktree_path" rev-parse --show-toplevel)" +[ "$actual" = "$worktree_path" ] || exit 46 +actual_common="$(git -C "$worktree_path" rev-parse --path-format=absolute --git-common-dir)" +repo="$(git -C "$expected_repo" rev-parse --show-toplevel)" +repo_common="$(git -C "$repo" rev-parse --path-format=absolute --git-common-dir)" +[ "$actual_common" = "$repo_common" ] || exit 47 +branch="$(git -C "$worktree_path" symbolic-ref --quiet --short HEAD || true)" +[ -z "$expected_branch" ] || [ "$branch" = "$expected_branch" ] || exit 48 +printf '%s\n' "$(encode "$actual")" +printf '%s\n' "$(encode "$branch")" +`; + +const REMOVE_SCRIPT = String.raw` +set -euo pipefail +decode() { node -e 'process.stdout.write(Buffer.from(process.argv[1], "base64"))' "$1"; } +base_repo="$(decode "$1")" +worktree_path="$(decode "$2")" +expected_branch="$(decode "$3")" +repo="$(git -C "$base_repo" rev-parse --show-toplevel)" +[ ! -e "$worktree_path" ] && exit 0 +[ ! -e "$worktree_path/.worktree-keep" ] || { + printf '%s\n' 'remote worktree has a .worktree-keep protection marker' >&2 + exit 61 +} +actual="$(git -C "$worktree_path" rev-parse --show-toplevel)" +[ "$actual" = "$worktree_path" ] || exit 62 +actual_common="$(git -C "$worktree_path" rev-parse --path-format=absolute --git-common-dir)" +repo_common="$(git -C "$repo" rev-parse --path-format=absolute --git-common-dir)" +[ "$actual_common" = "$repo_common" ] || exit 63 +branch="$(git -C "$worktree_path" symbolic-ref --quiet --short HEAD || true)" +[ -n "$expected_branch" ] && [ "$branch" = "$expected_branch" ] || exit 64 +git -C "$repo" worktree remove "$worktree_path" +[ ! -e "$worktree_path" ] +`; + +export async function createRemoteBotWorktree(input: { + remoteHostId: string; + baseRepo: string; + sourceBranch: string | null; + leaseId: string; + generation: number; +}): Promise { + const host = await readyHost(input.remoteHostId); + const slug = `${input.leaseId.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 48)}-${input.generation}`; + const result = await host.exec( + `bash -s -- ${shellBase64(input.baseRepo)} ${shellBase64(input.sourceBranch || 'HEAD')} ${shellBase64(slug)}`, + { + input: CREATE_SCRIPT, + label: 'create remote Bot worktree', + timeoutMs: 120_000, + maxOutputBytes: 64 * 1024, + }, + ); + if (result.exitCode !== 0 || result.truncated) { + throw new Error(result.stderr.trim() || 'Remote Bot worktree creation failed'); + } + const lines = result.stdout.trim().split(/\r?\n/); + return { + path: decodeLine(lines[0], 'worktree path'), + baseRepo: decodeLine(lines[1], 'repository path'), + branch: decodeLine(lines[2], 'branch'), + sourceBranch: decodeLine(lines[3], 'source branch'), + }; +} + +export async function inspectRemoteBotWorktree(input: { + remoteHostId: string; + worktreePath: string; + baseRepo: string; + branch?: string | null; +}): Promise<{ exists: boolean; branch?: string }> { + const host = await readyHost(input.remoteHostId); + const result = await host.exec( + `bash -s -- ${shellBase64(input.worktreePath)} ${shellBase64(input.baseRepo)} ${shellBase64(input.branch || '')}`, + { + input: INSPECT_SCRIPT, + label: 'inspect remote Bot worktree', + timeoutMs: 30_000, + maxOutputBytes: 16 * 1024, + }, + ); + // Only a genuinely absent path is safe to treat as gone. A replaced path, + // foreign repository, or changed branch must keep the lease recoverable. + if (result.exitCode === 44) return { exists: false }; + if (result.exitCode !== 0 || result.truncated) { + throw new Error(result.stderr.trim() || 'Remote Bot worktree inspection failed'); + } + const lines = result.stdout.trim().split(/\r?\n/); + decodeLine(lines[0], 'worktree path'); + const branch = decodeLine(lines[1], 'branch'); + return { exists: true, ...(branch ? { branch } : {}) }; +} + +export async function removeRemoteBotWorktree(input: { + remoteHostId: string; + baseRepo: string; + worktreePath: string; + branch: string; +}): Promise { + const host = await readyHost(input.remoteHostId); + const result = await host.exec( + `bash -s -- ${shellBase64(input.baseRepo)} ${shellBase64(input.worktreePath)} ${shellBase64(input.branch)}`, + { + input: REMOVE_SCRIPT, + label: 'remove remote Bot worktree', + timeoutMs: 120_000, + maxOutputBytes: 64 * 1024, + }, + ); + if (result.exitCode !== 0 || result.truncated) { + throw new Error( + result.stderr.trim() + || 'Remote Bot worktree was retained because it is dirty, locked, or still in use', + ); + } +} diff --git a/apps/desktop/src/main/maker-ipc/botSessionInputGuard.ts b/apps/desktop/src/main/maker-ipc/botSessionInputGuard.ts new file mode 100644 index 0000000000..9fe7bcd073 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/botSessionInputGuard.ts @@ -0,0 +1,21 @@ +export interface BotSessionInputSnapshot { + source: string; + role: string | null; + profileStatus: string | null; +} + +export function botSessionInputBlockReason( + snapshot: BotSessionInputSnapshot | null, +): string | null { + if (!snapshot || snapshot.source !== 'bot') return null; + if (!snapshot.role || !snapshot.profileStatus) { + return 'Bot 任务的归属信息不完整,无法继续发送消息'; + } + if (snapshot.role === 'history') { + return 'Bot 历史任务为只读,不能继续发送消息'; + } + if (snapshot.profileStatus !== 'active') { + return 'Bot 当前未启用,请先恢复 Bot 后再发送消息'; + } + return null; +} diff --git a/apps/desktop/src/main/maker-ipc/botWorkspaceLeaseLifecycle.ts b/apps/desktop/src/main/maker-ipc/botWorkspaceLeaseLifecycle.ts new file mode 100644 index 0000000000..521a4919f4 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/botWorkspaceLeaseLifecycle.ts @@ -0,0 +1,264 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +import { and, eq, inArray, isNull } from 'drizzle-orm'; + +import { getDbClient } from '../localDb/client/current.js'; +import { + botAutomationRuns, + botDelegations, + botLifecycleEvents, + botWorkspaceAttachments, + botWorkspaceLeases, + sessions, +} from '../localDb/schema.js'; +import { throwIpcError } from '../utils/ipcValidate.js'; + +async function pathExists(candidate: string): Promise { + try { + await fs.access(candidate); + return true; + } catch { + return false; + } +} + +async function assertNoActiveWorkspaceLeaseReferences(leaseId: string): Promise { + const db = getDbClient().drizzle; + const attachments = await db + .select() + .from(botWorkspaceAttachments) + .where( + and( + eq(botWorkspaceAttachments.leaseId, leaseId), + isNull(botWorkspaceAttachments.detachedAt), + ), + ); + const attachedSessionIds = attachments.map((attachment) => attachment.sessionId); + const attachedSessions = attachedSessionIds.length + ? await db + .select({ id: sessions.id, status: sessions.status }) + .from(sessions) + .where(inArray(sessions.id, attachedSessionIds)) + : []; + const statusBySession = new Map(attachedSessions.map((session) => [session.id, session.status])); + if ( + attachedSessionIds.some((sessionId) => { + const status = statusBySession.get(sessionId); + return status !== undefined && status !== 'archived' && status !== 'deleted'; + }) + ) { + throwIpcError('PRECONDITION_FAILED', '仍有 active Bot Session 使用该 workspace lease'); + } + + const activeAutomation = await db + .select({ id: botAutomationRuns.id }) + .from(botAutomationRuns) + .where( + and( + eq(botAutomationRuns.workspaceLeaseId, leaseId), + inArray(botAutomationRuns.status, ['claimed', 'running', 'completing']), + ), + ) + .limit(1); + if (activeAutomation.length > 0) { + throwIpcError('PRECONDITION_FAILED', '仍有 Bot Automation 使用该 workspace lease'); + } + if (attachedSessionIds.length > 0) { + const activeDelegations = await db + .select({ + parentSessionId: botDelegations.parentSessionId, + childSessionId: botDelegations.childSessionId, + }) + .from(botDelegations) + .where(inArray(botDelegations.status, ['queued', 'running', 'waiting'])); + const attached = new Set(attachedSessionIds); + if ( + activeDelegations.some( + (delegation) => + (delegation.parentSessionId && attached.has(delegation.parentSessionId)) + || (delegation.childSessionId && attached.has(delegation.childSessionId)), + ) + ) { + throwIpcError('PRECONDITION_FAILED', '仍有 Bot delegation 使用该 workspace lease'); + } + } + return attachedSessionIds; +} + +export async function retainBotWorkspaceLeases(botId: string): Promise { + const db = getDbClient().drizzle; + const at = Date.now(); + return getDbClient().tx('bots.retainWorkspaceLeases', { botId, at }); +} + +export async function releaseBotWorkspaceLease(input: { + botId: string; + leaseId: string; + expectedGeneration: number; +}): Promise { + const db = getDbClient().drizzle; + const [lease] = await db + .select() + .from(botWorkspaceLeases) + .where( + and( + eq(botWorkspaceLeases.id, input.leaseId), + eq(botWorkspaceLeases.botId, input.botId), + ), + ) + .limit(1); + if (!lease) throwIpcError('NOT_FOUND', 'Bot workspace lease 不存在'); + if (lease.generation !== input.expectedGeneration) { + throwIpcError('PRECONDITION_FAILED', 'Bot workspace lease 已被另一处操作更新'); + } + if (lease.status === 'released') return; + if (lease.status !== 'active' && lease.status !== 'error' && lease.status !== 'retained') { + throwIpcError('PRECONDITION_FAILED', `Bot workspace lease 当前状态为 ${lease.status}`); + } + + let attachedSessionIds = await assertNoActiveWorkspaceLeaseReferences(lease.id); + + const at = Date.now(); + const [claimed] = await db + .update(botWorkspaceLeases) + .set({ status: 'releasing', updatedAt: at }) + .where( + and( + eq(botWorkspaceLeases.id, lease.id), + eq(botWorkspaceLeases.generation, input.expectedGeneration), + inArray(botWorkspaceLeases.status, ['active', 'error', 'retained']), + ), + ) + .returning(); + if (!claimed) throwIpcError('PRECONDITION_FAILED', 'Bot workspace lease 已被另一处操作更新'); + + try { + // The pre-claim reads are advisory only. A scheduler fire or delegation can + // acquire a reference between those reads and the CAS. Once `releasing` is + // owned, workspace acquisition is closed; repeat the complete reference + // check before the first destructive filesystem operation. + attachedSessionIds = await assertNoActiveWorkspaceLeaseReferences(claimed.id); + const [{ getMakerIfReady }, worktree, remoteWorkspace] = await Promise.all([ + import('../maker-host/index.js'), + import('../worktree/index.js'), + import('./botRemoteWorkspaceService.js'), + ]); + const maker = getMakerIfReady(); + if (attachedSessionIds.some((sessionId) => maker?.isSessionAlive(sessionId) === true)) { + throwIpcError('PRECONDITION_FAILED', '仍有 Bot Session runtime 使用该 workspace lease'); + } + if (claimed.worktreePath && !claimed.anchorSessionId) { + throwIpcError('PRECONDITION_FAILED', 'workspace lease 缺少可恢复的 anchor Session'); + } + if (claimed.remoteHostId && claimed.worktreePath) { + if (!claimed.branch) { + throwIpcError('PRECONDITION_FAILED', '远程 workspace lease 缺少受管分支信息'); + } + await remoteWorkspace.removeRemoteBotWorktree({ + remoteHostId: claimed.remoteHostId, + baseRepo: claimed.baseRepo, + worktreePath: claimed.worktreePath, + branch: claimed.branch, + }); + } else if (claimed.anchorSessionId) { + await worktree.WorktreeManager.removeWorktreeForSession(claimed.anchorSessionId, { + isSessionRuntimeAlive: (sessionId) => maker?.isSessionAlive(sessionId) ?? false, + canRemove: async () => { + const [current] = await db + .select({ + status: botWorkspaceLeases.status, + generation: botWorkspaceLeases.generation, + }) + .from(botWorkspaceLeases) + .where(eq(botWorkspaceLeases.id, claimed.id)) + .limit(1); + return current?.status === 'releasing' && current.generation === claimed.generation; + }, + }); + } + const registered = !claimed.remoteHostId && claimed.worktreePath + ? worktree.WorktreeManager.listAll().some( + (meta) => path.resolve(meta.path) === path.resolve(claimed.worktreePath!), + ) + : false; + const remainsOnDisk = claimed.worktreePath + ? claimed.remoteHostId + ? ( + await remoteWorkspace.inspectRemoteBotWorktree({ + remoteHostId: claimed.remoteHostId, + worktreePath: claimed.worktreePath, + baseRepo: claimed.baseRepo, + branch: claimed.branch, + }) + ).exists + : await pathExists(claimed.worktreePath) + : false; + if (registered || remainsOnDisk) { + throwIpcError( + 'PRECONDITION_FAILED', + 'worktree 被安全保护策略保留;请处理运行中引用、分支状态或 .worktree-keep 后重试', + ); + } + + const releasedAt = Date.now(); + await getDbClient().tx('bots.finalizeWorkspaceLeaseRelease', { + leaseId: claimed.id, + botId: input.botId, + expectedGeneration: claimed.generation, + anchorSessionId: claimed.anchorSessionId, + releasedAt, + eventId: randomUUID(), + eventType: 'workspace-lease-released', + }); + } catch (error) { + await db + .update(botWorkspaceLeases) + .set({ status: 'error', updatedAt: Date.now() }) + .where( + and( + eq(botWorkspaceLeases.id, lease.id), + eq(botWorkspaceLeases.generation, input.expectedGeneration), + eq(botWorkspaceLeases.status, 'releasing'), + ), + ); + throw error; + } +} + +export async function releaseAllBotWorkspaceLeases(botId: string): Promise { + const db = getDbClient().drizzle; + const unstable = await db + .select({ id: botWorkspaceLeases.id, status: botWorkspaceLeases.status }) + .from(botWorkspaceLeases) + .where( + and( + eq(botWorkspaceLeases.botId, botId), + inArray(botWorkspaceLeases.status, ['acquiring', 'releasing']), + ), + ); + if (unstable.length > 0) { + throwIpcError( + 'PRECONDITION_FAILED', + 'Bot workspace 正在创建或释放,请等待状态稳定后重试', + ); + } + const leases = await db + .select({ id: botWorkspaceLeases.id, generation: botWorkspaceLeases.generation }) + .from(botWorkspaceLeases) + .where( + and( + eq(botWorkspaceLeases.botId, botId), + inArray(botWorkspaceLeases.status, ['active', 'error', 'retained']), + ), + ); + for (const lease of leases) { + await releaseBotWorkspaceLease({ + botId, + leaseId: lease.id, + expectedGeneration: lease.generation, + }); + } + return leases.length; +} diff --git a/apps/desktop/src/main/maker-ipc/botWorkspaceRuntime.ts b/apps/desktop/src/main/maker-ipc/botWorkspaceRuntime.ts new file mode 100644 index 0000000000..6221c48f59 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/botWorkspaceRuntime.ts @@ -0,0 +1,1216 @@ +import { randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { and, desc, eq, inArray, isNull } from 'drizzle-orm'; + +import type { MakerSessionCreateOpts } from './sessionRequest.js'; +import { + createRemoteBotWorktree, + inspectRemoteBotWorktree, + removeRemoteBotWorktree, +} from './botRemoteWorkspaceService.js'; +import { getDbClient } from '../localDb/client/current.js'; +import { + botAutomationLinks, + botAutomationRuns, + botDelegations, + botLifecycleEvents, + botProjectBindings, + botSessionLinks, + botWorkspaceAttachments, + botWorkspaceLeases, + sessions, +} from '../localDb/schema.js'; +import { + WorktreeManager, + restoreWorktreeForSession, + worktreeStore, +} from '../worktree/index.js'; +import type { CreateWorktreeResp, WorktreeMeta } from '../worktree/types.js'; +import { parseBotDelegationPlanSnapshot } from '../../shared/botDelegation.js'; + +const ACQUIRING_STALE_MS = 5 * 60_000; +const leaseQueues = new Map>(); + +export interface BotWorkspaceRuntimeResult { + botId: string; + projectBindingId: string; + workspacePolicy: 'none' | 'reuse' | 'per-task' | 'read-only'; + leaseId?: string; + leaseKey?: string; + generation?: number; + workingDir: string; + worktreePath?: string; +} + +export interface BotWorkspaceRuntimeDeps { + now?: () => number; + createId?: () => string; + createWorktree?: (input: { + sessionId: string; + baseRepo: string; + name: string; + sourceBranch: string; + }) => Promise; + getWorktreeForSession?: (sessionId: string) => WorktreeMeta | null; + setWorktreeForSession?: (sessionId: string, meta: WorktreeMeta) => Promise; + deleteWorktreeForSession?: (sessionId: string) => void; + restoreWorktree?: (sessionId: string) => Promise<{ ok: boolean; message?: string }>; + createRemoteWorktree?: typeof createRemoteBotWorktree; + inspectRemoteWorktree?: typeof inspectRemoteBotWorktree; +} + +export interface BotWorkspaceReconcileDeps { + now?: () => number; + listWorktrees?: () => WorktreeMeta[]; + setWorktreeForSession?: (sessionId: string, meta: WorktreeMeta) => Promise; + deleteWorktreeForSession?: (sessionId: string) => void; + pathExists?: (candidate: string) => Promise; + inspectRemoteWorktree?: typeof inspectRemoteBotWorktree; + createRemoteWorktree?: typeof createRemoteBotWorktree; + removeRemoteWorktree?: typeof removeRemoteBotWorktree; + removeLocalWorktree?: ( + sessionId: string, + options: { + isSessionRuntimeAlive: (sessionId: string) => boolean; + canRemove: () => Promise; + }, + ) => Promise; + isSessionRuntimeAlive?: (sessionId: string) => boolean; +} + +async function defaultPathExists(candidate: string): Promise { + try { + await fs.access(candidate); + return true; + } catch { + return false; + } +} + +async function perTaskLeaseHasActiveReferences(input: { + leaseId: string; + isSessionRuntimeAlive: (sessionId: string) => boolean; +}): Promise<{ active: boolean; sessionIds: string[] }> { + const db = getDbClient().drizzle; + const attachments = await db + .select({ sessionId: botWorkspaceAttachments.sessionId }) + .from(botWorkspaceAttachments) + .where( + and( + eq(botWorkspaceAttachments.leaseId, input.leaseId), + isNull(botWorkspaceAttachments.detachedAt), + ), + ); + const sessionIds = attachments.map((attachment) => attachment.sessionId); + if (sessionIds.length === 0) return { active: true, sessionIds }; + const sessionRows = await db + .select({ id: sessions.id, status: sessions.status }) + .from(sessions) + .where(inArray(sessions.id, sessionIds)); + const statusById = new Map(sessionRows.map((row) => [row.id, row.status])); + if ( + sessionIds.some((sessionId) => { + const status = statusById.get(sessionId); + return status !== undefined && status !== 'archived' && status !== 'deleted'; + }) + ) return { active: true, sessionIds }; + if (sessionIds.some(input.isSessionRuntimeAlive)) return { active: true, sessionIds }; + + const activeAutomation = await db + .select({ id: botAutomationRuns.id }) + .from(botAutomationRuns) + .where( + and( + eq(botAutomationRuns.workspaceLeaseId, input.leaseId), + inArray(botAutomationRuns.status, ['claimed', 'running', 'completing']), + ), + ) + .limit(1); + if (activeAutomation.length > 0) return { active: true, sessionIds }; + + const activeDelegations = await db + .select({ + parentSessionId: botDelegations.parentSessionId, + childSessionId: botDelegations.childSessionId, + }) + .from(botDelegations) + .where(inArray(botDelegations.status, ['queued', 'running', 'waiting'])); + const attached = new Set(sessionIds); + if ( + activeDelegations.some( + (delegation) => + (delegation.parentSessionId && attached.has(delegation.parentSessionId)) + || (delegation.childSessionId && attached.has(delegation.childSessionId)), + ) + ) return { active: true, sessionIds }; + return { active: false, sessionIds }; +} + +/** + * Release a per-task Bot worktree after its attached task reaches a terminal + * state. The lease is the durable owner; cleanup is CAS-claimed and every + * active reference is checked again after the claim. Dirty/locked/kept + * worktrees are never forced away: the remover throws and the lease remains + * visible as error for a later retry. + */ +export async function reclaimPerTaskBotWorkspaceForSession( + sessionId: string, + deps: BotWorkspaceReconcileDeps = {}, +): Promise { + const db = getDbClient().drizzle; + const [attachment] = await db + .select({ leaseId: botWorkspaceAttachments.leaseId }) + .from(botWorkspaceAttachments) + .where( + and( + eq(botWorkspaceAttachments.sessionId, sessionId), + isNull(botWorkspaceAttachments.detachedAt), + ), + ) + .limit(1); + if (!attachment) return false; + const [initialLease] = await db + .select() + .from(botWorkspaceLeases) + .where(eq(botWorkspaceLeases.id, attachment.leaseId)) + .limit(1); + if (!initialLease) return false; + const [binding] = await db + .select({ workspacePolicy: botProjectBindings.workspacePolicy }) + .from(botProjectBindings) + .where(eq(botProjectBindings.id, initialLease.projectBindingId)) + .limit(1); + if (binding?.workspacePolicy !== 'per-task') return false; + + return withLeaseQueue(`${initialLease.projectBindingId}\0${initialLease.leaseKey}`, async () => { + const [lease] = await db + .select() + .from(botWorkspaceLeases) + .where(eq(botWorkspaceLeases.id, initialLease.id)) + .limit(1); + if (!lease || (lease.status !== 'active' && lease.status !== 'error')) return false; + + const maker = deps.isSessionRuntimeAlive + ? null + : (await import('../maker-host/index.js')).getMakerIfReady(); + const isSessionRuntimeAlive = deps.isSessionRuntimeAlive + ?? ((id: string) => maker?.isSessionAlive(id) ?? false); + const beforeClaim = await perTaskLeaseHasActiveReferences({ + leaseId: lease.id, + isSessionRuntimeAlive, + }); + if (beforeClaim.active) return false; + + const now = (deps.now ?? Date.now)(); + const [claimed] = await db + .update(botWorkspaceLeases) + .set({ status: 'releasing', updatedAt: now }) + .where( + and( + eq(botWorkspaceLeases.id, lease.id), + eq(botWorkspaceLeases.generation, lease.generation), + inArray(botWorkspaceLeases.status, ['active', 'error']), + ), + ) + .returning(); + if (!claimed) return false; + + try { + const afterClaim = await perTaskLeaseHasActiveReferences({ + leaseId: claimed.id, + isSessionRuntimeAlive, + }); + if (afterClaim.active) { + await db + .update(botWorkspaceLeases) + .set({ status: 'active', updatedAt: (deps.now ?? Date.now)() }) + .where( + and( + eq(botWorkspaceLeases.id, claimed.id), + eq(botWorkspaceLeases.generation, claimed.generation), + eq(botWorkspaceLeases.status, 'releasing'), + ), + ); + return false; + } + if (claimed.worktreePath && !claimed.anchorSessionId) { + throw new Error('Per-task Bot workspace lease is missing its anchor Session.'); + } + if (claimed.remoteHostId && claimed.worktreePath) { + if (!claimed.branch) { + throw new Error('Per-task remote Bot workspace lease is missing its managed branch.'); + } + await (deps.removeRemoteWorktree ?? removeRemoteBotWorktree)({ + remoteHostId: claimed.remoteHostId, + baseRepo: claimed.baseRepo, + worktreePath: claimed.worktreePath, + branch: claimed.branch, + }); + } else if (claimed.anchorSessionId) { + await (deps.removeLocalWorktree ?? WorktreeManager.removeWorktreeForSession)( + claimed.anchorSessionId, + { + isSessionRuntimeAlive, + canRemove: async () => { + const [current] = await db + .select({ status: botWorkspaceLeases.status, generation: botWorkspaceLeases.generation }) + .from(botWorkspaceLeases) + .where(eq(botWorkspaceLeases.id, claimed.id)) + .limit(1); + return current?.status === 'releasing' && current.generation === claimed.generation; + }, + }, + ); + } + + const listWorktrees = deps.listWorktrees ?? WorktreeManager.listAll; + const registered = !claimed.remoteHostId && claimed.worktreePath + ? listWorktrees().some((meta) => path.resolve(meta.path) === path.resolve(claimed.worktreePath!)) + : false; + const remainsOnDisk = claimed.worktreePath + ? claimed.remoteHostId + ? (await (deps.inspectRemoteWorktree ?? inspectRemoteBotWorktree)({ + remoteHostId: claimed.remoteHostId, + worktreePath: claimed.worktreePath, + baseRepo: claimed.baseRepo, + branch: claimed.branch, + })).exists + : await (deps.pathExists ?? defaultPathExists)(claimed.worktreePath) + : false; + if (registered || remainsOnDisk) { + throw new Error('Per-task Bot worktree was retained by its safety policy.'); + } + + const releasedAt = (deps.now ?? Date.now)(); + await getDbClient().tx('bots.finalizeWorkspaceLeaseRelease', { + leaseId: claimed.id, + botId: claimed.botId, + expectedGeneration: claimed.generation, + anchorSessionId: claimed.anchorSessionId, + releasedAt, + eventId: `${claimed.botId}:workspace-auto-released:${claimed.id}:${releasedAt}`, + eventType: 'workspace-lease-auto-released', + }); + return true; + } catch (error) { + await db + .update(botWorkspaceLeases) + .set({ status: 'error', updatedAt: (deps.now ?? Date.now)() }) + .where( + and( + eq(botWorkspaceLeases.id, claimed.id), + eq(botWorkspaceLeases.generation, claimed.generation), + eq(botWorkspaceLeases.status, 'releasing'), + ), + ); + throw error; + } + }); +} + +export function schedulePerTaskBotWorkspaceReclaim(sessionId: string): void { + void reclaimPerTaskBotWorkspaceForSession(sessionId).catch(() => undefined); +} + +async function withLeaseQueue(key: string, task: () => Promise): Promise { + const previous = leaseQueues.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.then( + () => current, + () => current, + ); + leaseQueues.set(key, tail); + await previous.catch(() => undefined); + try { + return await task(); + } finally { + release(); + if (leaseQueues.get(key) === tail) leaseQueues.delete(key); + } +} + +function isTransientRemoteWorkspaceError(error: unknown): boolean { + const code = + error && typeof error === 'object' && 'code' in error + ? String((error as { code?: unknown }).code ?? '') + : ''; + const message = error instanceof Error ? error.message : String(error); + return code.startsWith('SSH_') || /ssh host|not connected|connection|unavailable/i.test(message); +} + +/** + * Repair interrupted Bot workspace ownership without guessing from process memory. + * Destructive cleanup is intentionally excluded: uncertain paths become error and + * remain user-retryable; only a release whose store entry and directory are both + * already gone is finalized as released. + */ +export async function reconcileBotWorkspaceLeases( + deps: BotWorkspaceReconcileDeps = {}, +): Promise { + const db = getDbClient().drizzle; + const now = (deps.now ?? Date.now)(); + const listWorktrees = deps.listWorktrees ?? WorktreeManager.listAll; + const setWorktreeForSession = deps.setWorktreeForSession ?? worktreeStore.set; + const deleteWorktreeForSession = deps.deleteWorktreeForSession ?? worktreeStore.del; + const pathExists = deps.pathExists ?? defaultPathExists; + const inspectRemote = deps.inspectRemoteWorktree ?? inspectRemoteBotWorktree; + const createRemote = deps.createRemoteWorktree ?? createRemoteBotWorktree; + const leases = await db + .select() + .from(botWorkspaceLeases) + .where(inArray(botWorkspaceLeases.status, ['acquiring', 'active', 'releasing', 'error'])); + + for (const snapshot of leases) { + await withLeaseQueue(`${snapshot.projectBindingId}\0${snapshot.leaseKey}`, async () => { + const [lease] = await db + .select() + .from(botWorkspaceLeases) + .where(eq(botWorkspaceLeases.id, snapshot.id)) + .limit(1); + if (!lease) return; + let remoteState: Awaited> | undefined; + if (lease.remoteHostId && lease.worktreePath) { + try { + remoteState = await inspectRemote({ + remoteHostId: lease.remoteHostId, + worktreePath: lease.worktreePath, + baseRepo: lease.baseRepo, + branch: lease.branch, + }); + } catch (error) { + if (isTransientRemoteWorkspaceError(error)) return; + if (lease.status !== 'acquiring') { + await db + .update(botWorkspaceLeases) + .set({ status: 'error', updatedAt: now }) + .where( + and( + eq(botWorkspaceLeases.id, lease.id), + eq(botWorkspaceLeases.generation, lease.generation), + ), + ); + return; + } + } + } + const registered = lease.worktreePath + && !lease.remoteHostId + ? listWorktrees().find( + (meta) => path.resolve(meta.path) === path.resolve(lease.worktreePath!), + ) + : undefined; + const existsOnDisk = lease.worktreePath + ? lease.remoteHostId + ? remoteState?.exists === true + : await pathExists(lease.worktreePath) + : false; + + if (lease.status === 'releasing') { + if (registered || existsOnDisk) { + await db + .update(botWorkspaceLeases) + .set({ status: 'error', updatedAt: now }) + .where( + and( + eq(botWorkspaceLeases.id, lease.id), + eq(botWorkspaceLeases.generation, lease.generation), + eq(botWorkspaceLeases.status, 'releasing'), + ), + ); + return; + } + await getDbClient().tx('bots.finalizeWorkspaceLeaseRelease', { + leaseId: lease.id, + botId: lease.botId, + expectedGeneration: lease.generation, + anchorSessionId: lease.anchorSessionId, + releasedAt: now, + }); + return; + } + + if (lease.status === 'acquiring') { + if (lease.remoteHostId) { + try { + const meta = existsOnDisk && lease.worktreePath + ? { + path: lease.worktreePath, + baseRepo: lease.baseRepo, + branch: remoteState?.branch || lease.branch || '', + sourceBranch: lease.sourceBranch || 'HEAD', + } + : await createRemote({ + remoteHostId: lease.remoteHostId, + baseRepo: lease.baseRepo, + sourceBranch: lease.sourceBranch, + leaseId: lease.id, + generation: lease.generation, + }); + await db + .update(botWorkspaceLeases) + .set({ + worktreePath: meta.path, + baseRepo: meta.baseRepo, + branch: meta.branch, + sourceBranch: meta.sourceBranch, + status: 'active', + lastHeartbeatAt: now, + updatedAt: now, + }) + .where( + and( + eq(botWorkspaceLeases.id, lease.id), + eq(botWorkspaceLeases.generation, lease.generation), + eq(botWorkspaceLeases.status, 'acquiring'), + ), + ); + } catch (error) { + if ( + !isTransientRemoteWorkspaceError(error) + && now - lease.updatedAt >= ACQUIRING_STALE_MS + ) { + await db + .update(botWorkspaceLeases) + .set({ status: 'error', updatedAt: now }) + .where( + and( + eq(botWorkspaceLeases.id, lease.id), + eq(botWorkspaceLeases.generation, lease.generation), + eq(botWorkspaceLeases.status, 'acquiring'), + ), + ); + } + } + return; + } + if (registered && lease.anchorSessionId) { + await db + .update(botWorkspaceLeases) + .set({ + worktreePath: registered.path, + baseRepo: registered.baseRepo, + branch: registered.branch, + sourceBranch: registered.sourceBranch, + status: 'active', + lastHeartbeatAt: now, + updatedAt: now, + }) + .where( + and( + eq(botWorkspaceLeases.id, lease.id), + eq(botWorkspaceLeases.generation, lease.generation), + eq(botWorkspaceLeases.status, 'acquiring'), + ), + ); + } else if (now - lease.updatedAt >= ACQUIRING_STALE_MS) { + await db + .update(botWorkspaceLeases) + .set({ status: 'error', updatedAt: now }) + .where( + and( + eq(botWorkspaceLeases.id, lease.id), + eq(botWorkspaceLeases.generation, lease.generation), + eq(botWorkspaceLeases.status, 'acquiring'), + ), + ); + } + return; + } + + if (!lease.anchorSessionId && registered) { + const attachments = await db + .select() + .from(botWorkspaceAttachments) + .where( + and( + eq(botWorkspaceAttachments.leaseId, lease.id), + isNull(botWorkspaceAttachments.detachedAt), + ), + ) + .orderBy(desc(botWorkspaceAttachments.createdAt)); + if (attachments.length > 0) { + const candidateIds = attachments.map((attachment) => attachment.sessionId); + const rows = await db + .select({ id: sessions.id, status: sessions.status }) + .from(sessions) + .where(inArray(sessions.id, candidateIds)); + const statusById = new Map(rows.map((row) => [row.id, row.status])); + const candidate = + attachments.find((attachment) => statusById.get(attachment.sessionId) === 'active') ?? + attachments.find((attachment) => statusById.has(attachment.sessionId)); + if (candidate) { + const nextMeta = { ...registered, sessionId: candidate.sessionId }; + await setWorktreeForSession(candidate.sessionId, nextMeta); + const [updated] = await db + .update(botWorkspaceLeases) + .set({ anchorSessionId: candidate.sessionId, updatedAt: now }) + .where( + and( + eq(botWorkspaceLeases.id, lease.id), + eq(botWorkspaceLeases.generation, lease.generation), + ), + ) + .returning(); + if (updated && registered.sessionId !== candidate.sessionId) { + deleteWorktreeForSession(registered.sessionId); + } + return; + } + } + } + + if ( + lease.status === 'active' + && (!lease.worktreePath || (!registered && !existsOnDisk)) + ) { + await db + .update(botWorkspaceLeases) + .set({ status: 'error', updatedAt: now }) + .where( + and( + eq(botWorkspaceLeases.id, lease.id), + eq(botWorkspaceLeases.generation, lease.generation), + eq(botWorkspaceLeases.status, 'active'), + ), + ); + } + }); + } + + // Startup recovery also retries per-task leases whose owning tasks already + // reached a terminal state. A prior crash, transient SSH outage, or dirty + // safety refusal therefore never turns automatic cleanup into a lost event. + const reclaimCandidates = await db + .select({ sessionId: botWorkspaceAttachments.sessionId }) + .from(botWorkspaceAttachments) + .innerJoin(botWorkspaceLeases, eq(botWorkspaceLeases.id, botWorkspaceAttachments.leaseId)) + .innerJoin(botProjectBindings, eq(botProjectBindings.id, botWorkspaceLeases.projectBindingId)) + .where( + and( + isNull(botWorkspaceAttachments.detachedAt), + eq(botProjectBindings.workspacePolicy, 'per-task'), + inArray(botWorkspaceLeases.status, ['active', 'error']), + ), + ); + for (const sessionId of new Set(reclaimCandidates.map((row) => row.sessionId))) { + await reclaimPerTaskBotWorkspaceForSession(sessionId, deps).catch(() => undefined); + } +} + +function applyWorkspaceToCreateOpts( + opts: MakerSessionCreateOpts, + input: { + workingDir: string; + remoteHostId: string | null; + workspaceAccess?: 'read-write' | 'read-only'; + workspaceWritePaths?: string[]; + }, +): void { + opts.workingDir = input.workingDir; + opts.workspaceKind = 'project'; + opts.remoteHostId = input.remoteHostId ?? undefined; + opts.workspaceAccess = input.workspaceAccess; + opts.workspaceWritePaths = input.workspaceWritePaths; +} + +function bindingWorkspaceWritePaths(input: { + bindingRoot: string; + runtimeRoot: string; + allowedPathsJson: string; + remoteHostId: string | null; +}): string[] | undefined { + let configured: string[] = []; + try { + const parsed = JSON.parse(input.allowedPathsJson) as unknown; + if (Array.isArray(parsed)) { + configured = parsed.filter((value): value is string => typeof value === 'string' && value.length > 0); + } + } catch { + return undefined; + } + if (configured.length === 0) return undefined; + const pathApi = input.remoteHostId ? path.posix : path; + const root = pathApi.resolve(input.bindingRoot); + const runtimeRoot = pathApi.resolve(input.runtimeRoot); + const mapped = configured.flatMap((candidate) => { + const relative = pathApi.relative(root, pathApi.resolve(candidate)); + if (relative === '..' || relative.startsWith(`..${pathApi.sep}`) || pathApi.isAbsolute(relative)) { + return []; + } + return [pathApi.resolve(runtimeRoot, relative)]; + }); + return mapped.length > 0 ? [...new Set(mapped)] : undefined; +} + +/** + * Resolve a Bot Session's durable project/worktree before the Agent starts. + * The Session is an attachment; the lease owns the workspace lifetime. + */ +export async function prepareBotWorkspaceRuntime( + opts: MakerSessionCreateOpts, + deps: BotWorkspaceRuntimeDeps = {}, +): Promise { + const sessionId = opts.id; + if (!sessionId) return null; + const db = getDbClient().drizzle; + const [link] = await db + .select({ botId: botSessionLinks.botId, role: botSessionLinks.role }) + .from(botSessionLinks) + .where(eq(botSessionLinks.sessionId, sessionId)) + .limit(1); + if (!link) return null; + const [delegation] = await db + .select({ + targetBotId: botDelegations.targetBotId, + permissionSnapshotJson: botDelegations.permissionSnapshotJson, + }) + .from(botDelegations) + .where(eq(botDelegations.childSessionId, sessionId)) + .limit(1); + const delegationPlan = delegation + ? parseBotDelegationPlanSnapshot(delegation.permissionSnapshotJson) + : null; + if (delegation && (!delegationPlan || delegation.targetBotId !== link.botId)) { + throw new Error('Bot delegation workspace plan is missing or no longer owns this task.'); + } + const [automationBinding] = await db + .select({ projectBindingId: botAutomationLinks.projectBindingId }) + .from(botAutomationRuns) + .innerJoin( + botAutomationLinks, + eq(botAutomationLinks.id, botAutomationRuns.automationLinkId), + ) + .where(eq(botAutomationRuns.sessionId, sessionId)) + .limit(1); + const [currentBinding] = delegationPlan + ? [] + : await db + .select() + .from(botProjectBindings) + .where( + and( + eq(botProjectBindings.botId, link.botId), + eq(botProjectBindings.status, 'active'), + automationBinding?.projectBindingId + ? eq(botProjectBindings.id, automationBinding.projectBindingId) + : eq(botProjectBindings.isDefault, true), + ), + ) + .limit(1); + const binding: typeof botProjectBindings.$inferSelect | undefined = delegationPlan?.workspace + ? { + id: delegationPlan.workspace.bindingId, + botId: link.botId, + projectKey: delegationPlan.workspace.projectKey, + workingDir: delegationPlan.workspace.workingDir, + remoteHostId: delegationPlan.workspace.remoteHostId, + defaultBranch: delegationPlan.workspace.defaultBranch, + workspacePolicy: delegationPlan.workspace.workspacePolicy, + isDefault: false, + allowedPathsJson: JSON.stringify(delegationPlan.workspace.allowedPaths), + status: 'active', + createdAt: delegationPlan.createdAt, + updatedAt: delegationPlan.workspace.bindingUpdatedAt, + } + : currentBinding; + if (!binding) return null; + + if (binding.workspacePolicy === 'read-only') { + applyWorkspaceToCreateOpts(opts, { + ...binding, + workspaceAccess: 'read-only', + }); + await db + .update(sessions) + .set({ + workingDir: binding.workingDir, + workspaceKind: 'project', + remoteHostId: binding.remoteHostId, + updatedAt: (deps.now ?? Date.now)(), + }) + .where(eq(sessions.id, sessionId)); + return { + botId: link.botId, + projectBindingId: binding.id, + workspacePolicy: binding.workspacePolicy, + workingDir: binding.workingDir, + }; + } + if (binding.workspacePolicy === 'none') { + applyWorkspaceToCreateOpts(opts, { + ...binding, + workspaceWritePaths: bindingWorkspaceWritePaths({ + bindingRoot: binding.workingDir, + runtimeRoot: binding.workingDir, + allowedPathsJson: binding.allowedPathsJson, + remoteHostId: binding.remoteHostId, + }), + }); + await db + .update(sessions) + .set({ + workingDir: binding.workingDir, + workspaceKind: 'project', + remoteHostId: binding.remoteHostId, + updatedAt: (deps.now ?? Date.now)(), + }) + .where(eq(sessions.id, sessionId)); + return { + botId: link.botId, + projectBindingId: binding.id, + workspacePolicy: binding.workspacePolicy, + workingDir: binding.workingDir, + }; + } + const leaseKey = binding.workspacePolicy === 'reuse' ? 'shared' : sessionId; + return withLeaseQueue(`${binding.id}\0${leaseKey}`, async () => { + const now = (deps.now ?? Date.now)(); + const createId = deps.createId ?? randomUUID; + const createWorktree = deps.createWorktree ?? WorktreeManager.createWorktree; + const getWorktreeForSession = deps.getWorktreeForSession ?? WorktreeManager.getForSession; + const setWorktreeForSession = deps.setWorktreeForSession ?? worktreeStore.set; + const deleteWorktreeForSession = + deps.deleteWorktreeForSession ?? worktreeStore.del; + const restoreWorktree = deps.restoreWorktree ?? restoreWorktreeForSession; + const createRemote = deps.createRemoteWorktree ?? createRemoteBotWorktree; + const inspectRemote = deps.inspectRemoteWorktree ?? inspectRemoteBotWorktree; + + const leaseRows = await db + .select() + .from(botWorkspaceLeases) + .where( + and( + eq(botWorkspaceLeases.projectBindingId, binding.id), + eq(botWorkspaceLeases.leaseKey, leaseKey), + inArray(botWorkspaceLeases.status, ['acquiring', 'active', 'releasing']), + ), + ) + .orderBy(desc(botWorkspaceLeases.generation)) + .limit(1); + let lease: typeof botWorkspaceLeases.$inferSelect | undefined = leaseRows[0]; + + if (lease?.status === 'releasing') { + throw new Error('Bot workspace is being released; retry after release completes.'); + } + if (lease?.status === 'acquiring') { + if (binding.remoteHostId) { + try { + const inspected = lease.worktreePath + ? await inspectRemote({ + remoteHostId: binding.remoteHostId, + worktreePath: lease.worktreePath, + baseRepo: lease.baseRepo, + branch: lease.branch, + }) + : { exists: false }; + const meta = inspected.exists && lease.worktreePath + ? { + path: lease.worktreePath, + baseRepo: lease.baseRepo, + branch: inspected.branch || lease.branch || '', + sourceBranch: lease.sourceBranch || 'HEAD', + } + : await createRemote({ + remoteHostId: binding.remoteHostId, + baseRepo: lease.baseRepo, + sourceBranch: lease.sourceBranch, + leaseId: lease.id, + generation: lease.generation, + }); + const [activated] = await db + .update(botWorkspaceLeases) + .set({ + worktreePath: meta.path, + baseRepo: meta.baseRepo, + branch: meta.branch, + sourceBranch: meta.sourceBranch, + status: 'active', + lastHeartbeatAt: now, + updatedAt: now, + }) + .where( + and( + eq(botWorkspaceLeases.id, lease.id), + eq(botWorkspaceLeases.generation, lease.generation), + eq(botWorkspaceLeases.status, 'acquiring'), + ), + ) + .returning(); + lease = activated ?? lease; + } catch (error) { + if ( + !isTransientRemoteWorkspaceError(error) + && now - lease.updatedAt >= ACQUIRING_STALE_MS + ) { + await db + .update(botWorkspaceLeases) + .set({ status: 'error', updatedAt: now }) + .where( + and( + eq(botWorkspaceLeases.id, lease.id), + eq(botWorkspaceLeases.generation, lease.generation), + eq(botWorkspaceLeases.status, 'acquiring'), + ), + ); + } + throw error; + } + } else { + const meta = lease.anchorSessionId ? getWorktreeForSession(lease.anchorSessionId) : null; + if (meta && (!lease.worktreePath || meta.path === lease.worktreePath)) { + const [activated] = await db + .update(botWorkspaceLeases) + .set({ + worktreePath: meta.path, + baseRepo: meta.baseRepo, + branch: meta.branch, + sourceBranch: meta.sourceBranch, + status: 'active', + lastHeartbeatAt: now, + updatedAt: now, + }) + .where( + and( + eq(botWorkspaceLeases.id, lease.id), + eq(botWorkspaceLeases.generation, lease.generation), + eq(botWorkspaceLeases.status, 'acquiring'), + ), + ) + .returning(); + lease = activated ?? lease; + } else if (now - lease.updatedAt >= ACQUIRING_STALE_MS) { + await db + .update(botWorkspaceLeases) + .set({ status: 'error', updatedAt: now }) + .where( + and( + eq(botWorkspaceLeases.id, lease.id), + eq(botWorkspaceLeases.generation, lease.generation), + eq(botWorkspaceLeases.status, 'acquiring'), + ), + ); + lease = undefined; + } else { + throw new Error('Bot workspace is still being acquired; retry shortly.'); + } + } + } + + if (lease?.status === 'active') { + if (!lease.anchorSessionId || !lease.worktreePath) { + throw new Error('Active Bot workspace lease is missing its anchor or path.'); + } + if (binding.remoteHostId) { + const remoteWorktreePath = lease.worktreePath; + const inspected = await inspectRemote({ + remoteHostId: binding.remoteHostId, + worktreePath: remoteWorktreePath, + baseRepo: lease.baseRepo, + branch: lease.branch, + }); + if (!inspected.exists) { + await db + .update(botWorkspaceLeases) + .set({ status: 'error', updatedAt: now }) + .where( + and( + eq(botWorkspaceLeases.id, lease.id), + eq(botWorkspaceLeases.generation, lease.generation), + eq(botWorkspaceLeases.status, 'active'), + ), + ); + throw new Error('Remote Bot workspace no longer exists.'); + } + await attachSessionToLease({ + sessionId, + lease, + workingDir: remoteWorktreePath, + now, + }); + if ( + binding.workspacePolicy === 'reuse' + && link.role === 'canonical' + && lease.anchorSessionId !== sessionId + ) { + const [updated] = await db + .update(botWorkspaceLeases) + .set({ anchorSessionId: sessionId, lastHeartbeatAt: now, updatedAt: now }) + .where( + and( + eq(botWorkspaceLeases.id, lease.id), + eq(botWorkspaceLeases.generation, lease.generation), + eq(botWorkspaceLeases.status, 'active'), + ), + ) + .returning(); + if (!updated) throw new Error('Bot workspace lease changed while its anchor was being migrated.'); + lease = updated; + } + applyWorkspaceToCreateOpts(opts, { + workingDir: remoteWorktreePath, + remoteHostId: binding.remoteHostId, + workspaceWritePaths: bindingWorkspaceWritePaths({ + bindingRoot: binding.workingDir, + runtimeRoot: remoteWorktreePath, + allowedPathsJson: binding.allowedPathsJson, + remoteHostId: binding.remoteHostId, + }), + }); + return { + botId: link.botId, + projectBindingId: binding.id, + workspacePolicy: binding.workspacePolicy, + leaseId: lease.id, + leaseKey, + generation: lease.generation, + workingDir: remoteWorktreePath, + worktreePath: remoteWorktreePath, + }; + } + let meta = getWorktreeForSession(lease.anchorSessionId); + if (!meta || meta.path !== lease.worktreePath) { + const restored = await restoreWorktree(lease.anchorSessionId); + if (!restored.ok) { + const detail = + 'detail' in restored && typeof restored.detail === 'string' + ? restored.detail + : undefined; + const reason = + 'reason' in restored && typeof restored.reason === 'string' + ? restored.reason + : undefined; + const message = + 'message' in restored && typeof restored.message === 'string' + ? restored.message + : undefined; + throw new Error( + detail || reason || message || 'Bot workspace restore failed.', + ); + } + meta = getWorktreeForSession(lease.anchorSessionId); + } + if (!meta || meta.path !== lease.worktreePath) { + throw new Error('Bot workspace ownership could not be restored.'); + } + await attachSessionToLease({ + sessionId, + lease, + workingDir: meta.path, + now, + }); + if ( + binding.workspacePolicy === 'reuse' + && link.role === 'canonical' + && lease.anchorSessionId !== sessionId + ) { + lease = await migrateLeaseAnchor({ + lease, + sessionId, + meta, + now, + setWorktreeForSession, + deleteWorktreeForSession, + }); + } + applyWorkspaceToCreateOpts(opts, { + workingDir: meta.path, + remoteHostId: null, + workspaceWritePaths: bindingWorkspaceWritePaths({ + bindingRoot: binding.workingDir, + runtimeRoot: meta.path, + allowedPathsJson: binding.allowedPathsJson, + remoteHostId: null, + }), + }); + return { + botId: link.botId, + projectBindingId: binding.id, + workspacePolicy: binding.workspacePolicy, + leaseId: lease.id, + leaseKey, + generation: lease.generation, + workingDir: meta.path, + worktreePath: meta.path, + }; + } + + const previous = await db + .select({ generation: botWorkspaceLeases.generation }) + .from(botWorkspaceLeases) + .where( + and( + eq(botWorkspaceLeases.projectBindingId, binding.id), + eq(botWorkspaceLeases.leaseKey, leaseKey), + ), + ) + .orderBy(desc(botWorkspaceLeases.generation)) + .limit(1); + const generation = (previous[0]?.generation ?? 0) + 1; + const leaseId = createId(); + await db.insert(botWorkspaceLeases).values({ + id: leaseId, + botId: link.botId, + projectBindingId: binding.id, + leaseKey, + anchorSessionId: sessionId, + worktreePath: null, + baseRepo: binding.workingDir, + branch: null, + sourceBranch: binding.defaultBranch, + remoteHostId: binding.remoteHostId, + generation, + status: 'acquiring', + lastHeartbeatAt: now, + createdAt: now, + updatedAt: now, + releasedAt: null, + }); + + let created: Awaited> | CreateWorktreeResp; + try { + created = binding.remoteHostId + ? await createRemote({ + remoteHostId: binding.remoteHostId, + baseRepo: binding.workingDir, + sourceBranch: binding.defaultBranch, + leaseId, + generation, + }) + : await createWorktree({ + sessionId, + baseRepo: binding.workingDir, + name: '', + sourceBranch: binding.defaultBranch || 'HEAD', + }); + } catch (error) { + if (!isTransientRemoteWorkspaceError(error)) { + await db + .update(botWorkspaceLeases) + .set({ status: 'error', updatedAt: now }) + .where( + and( + eq(botWorkspaceLeases.id, leaseId), + eq(botWorkspaceLeases.generation, generation), + eq(botWorkspaceLeases.status, 'acquiring'), + ), + ); + } + throw error; + } + if ('ok' in created && !created.ok) { + await db + .update(botWorkspaceLeases) + .set({ status: 'error', updatedAt: now }) + .where(eq(botWorkspaceLeases.id, leaseId)); + throw new Error(created.error.message); + } + const [activeLease] = await db + .update(botWorkspaceLeases) + .set({ + worktreePath: 'meta' in created ? created.meta.path : created.path, + baseRepo: 'meta' in created ? created.meta.baseRepo : created.baseRepo, + branch: 'meta' in created ? created.meta.branch : created.branch, + sourceBranch: 'meta' in created ? created.meta.sourceBranch : created.sourceBranch, + status: 'active', + lastHeartbeatAt: now, + updatedAt: now, + }) + .where( + and( + eq(botWorkspaceLeases.id, leaseId), + eq(botWorkspaceLeases.generation, generation), + eq(botWorkspaceLeases.status, 'acquiring'), + ), + ) + .returning(); + if (!activeLease) { + throw new Error('Bot workspace lease changed while the worktree was being created.'); + } + await attachSessionToLease({ + sessionId, + lease: activeLease, + workingDir: activeLease.worktreePath!, + now, + }); + applyWorkspaceToCreateOpts(opts, { + workingDir: activeLease.worktreePath!, + remoteHostId: binding.remoteHostId, + workspaceWritePaths: bindingWorkspaceWritePaths({ + bindingRoot: binding.workingDir, + runtimeRoot: activeLease.worktreePath!, + allowedPathsJson: binding.allowedPathsJson, + remoteHostId: binding.remoteHostId, + }), + }); + return { + botId: link.botId, + projectBindingId: binding.id, + workspacePolicy: binding.workspacePolicy, + leaseId, + leaseKey, + generation, + workingDir: activeLease.worktreePath!, + worktreePath: activeLease.worktreePath!, + }; + }); +} + +async function migrateLeaseAnchor(input: { + lease: typeof botWorkspaceLeases.$inferSelect; + sessionId: string; + meta: WorktreeMeta; + now: number; + setWorktreeForSession: (sessionId: string, meta: WorktreeMeta) => Promise; + deleteWorktreeForSession: (sessionId: string) => void; +}): Promise { + const previousAnchor = input.lease.anchorSessionId; + if (previousAnchor === input.sessionId) return input.lease; + + const nextMeta: WorktreeMeta = { ...input.meta, sessionId: input.sessionId }; + await input.setWorktreeForSession(input.sessionId, nextMeta); + const db = getDbClient().drizzle; + const [updated] = await db + .update(botWorkspaceLeases) + .set({ + anchorSessionId: input.sessionId, + lastHeartbeatAt: input.now, + updatedAt: input.now, + }) + .where( + and( + eq(botWorkspaceLeases.id, input.lease.id), + eq(botWorkspaceLeases.generation, input.lease.generation), + eq(botWorkspaceLeases.status, 'active'), + ), + ) + .returning(); + if (!updated) { + input.deleteWorktreeForSession(input.sessionId); + throw new Error('Bot workspace lease changed while its anchor was being migrated.'); + } + if (previousAnchor) input.deleteWorktreeForSession(previousAnchor); + return updated; +} + +async function attachSessionToLease(input: { + sessionId: string; + lease: typeof botWorkspaceLeases.$inferSelect; + workingDir: string; + now: number; +}): Promise { + const db = getDbClient().drizzle; + await getDbClient().tx('bots.attachWorkspaceLease', { + attachmentId: randomUUID(), + leaseId: input.lease.id, + sessionId: input.sessionId, + generation: input.lease.generation, + workingDir: input.workingDir, + remoteHostId: input.lease.remoteHostId, + now: input.now, + }); +} diff --git a/apps/desktop/src/main/maker-ipc/channels.ts b/apps/desktop/src/main/maker-ipc/channels.ts index 4ac12800ae..bc1cc20fb8 100644 --- a/apps/desktop/src/main/maker-ipc/channels.ts +++ b/apps/desktop/src/main/maker-ipc/channels.ts @@ -708,6 +708,22 @@ export const MAKER_INVOKE = { GOAL_PAUSE: 'maker:goal:pause', GOAL_RESUME: 'maker:goal:resume', GOAL_UPDATE: 'maker:goal:update', + /** Cindy Bot 父任务列出自己发起的 Bot 间委派。 */ + BOT_DELEGATIONS_LIST: 'maker:bot-delegations:list', + /** Cindy Bot 父任务取消仍在运行或等待中的委派。 */ + BOT_DELEGATION_CANCEL: 'maker:bot-delegation:cancel', + BOT_AUTOMATIONS_LIST: 'maker:bot-automations:list', + BOT_AUTOMATION_CREATE: 'maker:bot-automation:create', + BOT_AUTOMATION_UPDATE: 'maker:bot-automation:update', + BOT_AUTOMATION_PAUSE: 'maker:bot-automation:pause', + BOT_AUTOMATION_RESUME: 'maker:bot-automation:resume', + BOT_AUTOMATION_RUN_NOW: 'maker:bot-automation:run-now', + BOT_AUTOMATION_DELETE: 'maker:bot-automation:delete', + BOT_AUTOMATION_LIST_RUNS: 'maker:bot-automation:list-runs', + BOT_AUTOMATION_RETRY_DELIVERY: 'maker:bot-automation:retry-delivery', + BOT_DELIVERIES_LIST: 'maker:bot-deliveries:list', + BOT_DELIVERY_RETRY: 'maker:bot-delivery:retry', + BOT_LIFECYCLE_ACTION: 'maker:bot-lifecycle:action', } as const; /** @@ -839,6 +855,11 @@ export const MAKER_PUSH = { DESKTOP_COMMAND_TRIGGERED: 'maker:desktop-command-triggered', /** multi-worker: worker 增删改 / focus 切换时 broadcast, renderer useWorkers hook 订阅刷新。 */ ORCA_WORKER_CHANGED: 'maker:orca:worker-changed', + /** Bot 间委派状态改变;payload 带父/子任务 id,广播自动附 owner generation。 */ + BOT_DELEGATION_CHANGED: 'maker:bot-delegation:changed', + BOT_AUTOMATION_CHANGED: 'maker:bot-automation:changed', + BOT_DELIVERY_CHANGED: 'maker:bot-delivery:changed', + BOT_LIFECYCLE_CHANGED: 'maker:bot-lifecycle:changed', /** * 被控端「当前 New Maker 草稿」全量变更广播。SYNC_NEW_MAKER_DRAFT 落 main 缓存后随即发, * 经 device-link tap 转发给控制端(account 级 → sessions topic),控制端刷新远程草稿显示镜像。 diff --git a/apps/desktop/src/main/maker-ipc/makerSendTransaction.ts b/apps/desktop/src/main/maker-ipc/makerSendTransaction.ts index fcfff36bf4..f4d5bdd48d 100644 --- a/apps/desktop/src/main/maker-ipc/makerSendTransaction.ts +++ b/apps/desktop/src/main/maker-ipc/makerSendTransaction.ts @@ -222,12 +222,14 @@ export interface MakerSendTransactionDeps { peekPendingHandoff?(sessionId: string): Promise; consumePendingHandoff?(sessionId: string): void; /** - * 计划对账:会话里若有未收口的旧计划(未盖终态章且有未完成步骤),返回一段 - * "顺手收拾"指示文本,与交接同通道前置进 wire payload(不进落库显示)。每次 - * 发送现算,无 pending 状态、无 consume——旧清单被 agent 更新/清掉后,下一轮 - * 自然算不出注入。undefined = 不启用(测试最小 harness)。 + * 计划对账:会话里若有待处理计划,返回一段只进 wire payload 的指示文本。 + * sealedTurnId 只用于已完成计划的一次性保护,跨过 accepted 后才消费。 */ - peekPlanReconcileNote?(sessionId: string): Promise; + peekPlanReconcileNote?(sessionId: string): Promise<{ + note: string; + sealedTurnId?: string; + } | null>; + consumeSealedPlanReconcileNote?(sessionId: string, turnId: string): void | Promise; /** * 本次调用是否来自手机控制端(缺省 = 否)。**纯体验分流,不是安全判据。** * @@ -813,11 +815,11 @@ export function createMakerSendTransaction(deps: MakerSendTransactionDeps): Make (reconcilePersistText.length > 0 || reconcileHasAttachments) && !startsWithSlashCommand && !reconcilePersistText.startsWith('[UI_ACTION_TRIGGER]'); - const planReconcileNote = isOrdinaryUserTurn + const planReconcile = isOrdinaryUserTurn ? ((await deps.peekPlanReconcileNote?.(sessionId).catch(() => null)) ?? null) : null; - const withPlanReconcile = planReconcileNote - ? prependNoteToWireUserMessage(withHandoff as HandoffWireMessage, planReconcileNote) + const withPlanReconcile = planReconcile + ? prependNoteToWireUserMessage(withHandoff as HandoffWireMessage, planReconcile.note) : withHandoff; const so = (outgoingSendOpts ?? {}) as MakerSendOptions; // 手机客户端说明:同样只进 wire payload,落库/显示内容(persistUserMessage.content) @@ -1077,6 +1079,17 @@ export function createMakerSendTransaction(deps: MakerSendTransactionDeps): Make // 只有跨过不可逆 dispatch 边界才消费;未派发保留 pending 下次重试。 deps.consumePendingHandoff?.(sessionId); } + if (planReconcile?.sealedTurnId && sendResult.accepted) { + try { + await deps.consumeSealedPlanReconcileNote?.(sessionId, planReconcile.sealedTurnId); + } catch (err) { + deps.log.warn('send: completed plan guard consume failed', { + sessionId, + turnId: planReconcile.sealedTurnId, + err: err instanceof Error ? err.message : String(err), + }); + } + } if (userPromptPreviewSessionId && userPromptPreviewClientId) { if (sendResult.accepted) { deps.commitUserPromptPreview?.(userPromptPreviewSessionId, userPromptPreviewClientId); diff --git a/apps/desktop/src/main/maker-ipc/planReconcile.ts b/apps/desktop/src/main/maker-ipc/planReconcile.ts index bd88c3b5dd..70ddb08a5a 100644 --- a/apps/desktop/src/main/maker-ipc/planReconcile.ts +++ b/apps/desktop/src/main/maker-ipc/planReconcile.ts @@ -147,3 +147,12 @@ export function buildPlanReconcileNote(summary: OpenPlanSummary): string { '== 对账说明结束,以下是用户的新消息 ==', ].join('\n'); } + +export function buildCompletedPlanGuardNote(): string { + return [ + '[计划状态]上一轮计划已全部完成并收口。', + '不要因为用户只说“继续”或类似模糊指令就恢复、重开或修改该计划。', + '若用户没有明确要求重做旧步骤,应说明当前没有未完成计划,并优先处理用户的新消息。', + '== 状态说明结束,以下是用户的新消息 ==', + ].join('\n'); +} diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index b47be9ca70..73b64dc659 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -254,6 +254,7 @@ import { getSessionRowSnapshotStrict, persistSessionFields, recycleSessionWorktreeForStatusChange, + setSessionsStatusInDb, } from '../localDb/ipc/sessions.js'; // sidebar-card-mode: turn-done 后触发任务现状摘要生成 import { maybeGenerateSessionTaskSummary } from '../sessionTaskSummary.js'; @@ -280,7 +281,17 @@ import { setWorkerFocus, updateWorkerStatus, } from '../localDb/orcaTeamStore.js'; -import { messages, orcaTeams, orcaWorkers, sessions } from '../localDb/schema.js'; +import { + botLifecycleEvents, + botChannels, + botProfiles, + botRoutes, + botSessionLinks, + messages, + orcaTeams, + orcaWorkers, + sessions, +} from '../localDb/schema.js'; import { isOrcaWorkerPermissionMode, type OrcaWorkerPermissionMode, @@ -316,6 +327,26 @@ import { writeAgentResourceSetting, } from '../maker-host/agent-resource-settings-store.js'; import { createAgentResourceSettingsIpc } from './agent-resource-settings-ipc.js'; +import { + createBotDelegationService, + type BotDelegationService, +} from './botDelegationService.js'; +import { + createBotDeliveryOutboxService, + type EnqueueBotDeliveryInput, + type BotDeliveryOutboxService, +} from './botDeliveryOutboxService.js'; +import { registerBotLifecycleHandlers } from './botLifecycleService.js'; +import { + createBotCompactRuntimeRefreshCoordinator, + replaceBotRuntimeAfterPreflight, + type BotCompactBoundary, + type BotCompactRuntimeRefreshOutcome, + type BotCompactRuntimeSession, +} from './botCompactRuntimeRefresh.js'; +import { isBotCanonicalReplacementBusy } from './botCanonicalReplacementGuard.js'; +import { configureBotCanonicalReplacementCoordinator } from './botCanonicalReplacementCoordinator.js'; +import { botSessionInputBlockReason } from './botSessionInputGuard.js'; import { createGitSnapshotCoordinator } from '../maker-host/git-snapshot-host.js'; import { cancelCodexAuthModeChange, @@ -324,6 +355,7 @@ import { getMaker, getMakerIfReady, getPluginRegistry, + preflightBotRuntimeResources, prepareCodexForAuthModeChange, restartCodexAfterAuthModeChange, setBeforeLocalCodexSessionStartHook, @@ -440,6 +472,7 @@ import { recordSessionContextSnapshot, recordSessionTurnSpend, recordSessionTurnTokens, + setSessionTokenUsageObserver, } from '../sessionSpendBroadcaster.js'; import { codexUsageToTokens, @@ -528,6 +561,7 @@ import { cancelIOSSimulatorSessionOperations, } from '../mcp-integrations/ios-simulator.js'; import { MAKER_INVOKE, MAKER_PUSH, MAKER_SEND } from './channels.js'; +import { BOT_DELEGATION_STATUSES } from '../../shared/botDelegation.js'; import type { CollabDispatchOutcome } from './collabSendOutcome.js'; import { runAcceptedCallback } from './acceptedCallbackRunner.js'; import { createElectronIpcHandlerRegistry } from './electronIpcRegistry.js'; @@ -659,7 +693,14 @@ import { } from './agentHandoff.js'; import { hydrateQueuedAgentReferences } from './agentInputReferences.js'; import { agentHandoffPending } from './agentHandoffPendingSingleton.js'; -import { buildPlanReconcileNote, summarizeOpenPlan } from './planReconcile.js'; +import { + clearSealedCodexPlanState, + readCodexPlanState, +} from '../localDb/codexPlanState.js'; +import { + buildCompletedPlanGuardNote, + buildPlanReconcileNote, +} from './planReconcile.js'; import { type MakerSessionCreateOpts, withCreateSessionStderr } from './sessionRequest.js'; import { persistAndHydrateSessionProvider } from './sessionProviderBootstrap.js'; import { registerMakerSessionSendHandler } from './sessionSendHandler.js'; @@ -1687,6 +1728,20 @@ interface EnableOrcaOptions { } let orcaCollabServiceHolder: OrcaCollabService | null = null; +let botDelegationServiceHolder: BotDelegationService | null = null; +let botDeliveryOutboxServiceHolder: BotDeliveryOutboxService | null = null; + +export function enqueueBotDelivery(input: EnqueueBotDeliveryInput): Promise<{ id: string }> { + const outbox = botDeliveryOutboxServiceHolder; + if (!outbox) throw new Error('Bot delivery outbox is not initialized'); + return outbox.enqueue(input); +} + +export function retryBotDelivery(id: string, botId: string): Promise<{ id: string }> { + const outbox = botDeliveryOutboxServiceHolder; + if (!outbox) throw new Error('Bot delivery outbox is not initialized'); + return outbox.retry(id, botId); +} // session event wiring 是模块级函数;service 在 registerMakerIpc 内构造后注入给事件回调。 let orcaTeamServiceForEvents: OrcaTeamService | null = null; @@ -1748,6 +1803,10 @@ export function tryGetOrcaCollabService(): OrcaCollabService | null { return orcaCollabServiceHolder; } +export function tryGetBotDelegationService(): BotDelegationService | null { + return botDelegationServiceHolder; +} + function createBridgeWorkerLabel(task: string): string { const suffix = createId().slice(0, 6).toLowerCase(); const base = task @@ -2060,6 +2119,42 @@ function hasPendingAgentInteractionForSession(sessionId: string): boolean { ); } +type BotCompactRuntimeRefreshHandler = ( + session: BotCompactRuntimeSession, + boundary: BotCompactBoundary, +) => Promise; + +/** + * `wireSessionToIpc` is module-scoped because IM adapters and scheduler paths + * create Sessions outside the renderer IPC handler. The real refresh routine + * needs the register-time Maker/bootstrap closure, so keep one narrow holder + * and let the instance-scoped coordinator own all compact settle signals. + */ +let botCompactRuntimeRefreshHandler: BotCompactRuntimeRefreshHandler | null = null; +const botCompactRuntimeRefreshCoordinator = createBotCompactRuntimeRefreshCoordinator({ + hasPendingInteraction: hasPendingAgentInteractionForSession, + refresh: (session, boundary) => + botCompactRuntimeRefreshHandler?.(session, boundary) ?? Promise.resolve('deferred'), + onError: (sessionId, error) => { + log.warn('Bot compact runtime refresh failed; lazy resume remains available', { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + }, +}); + +function attemptBotCompactRuntimeRefresh(session: WiredSession, trigger: string): void { + if (!botCompactRuntimeRefreshCoordinator.hasPending(session.id)) return; + void botCompactRuntimeRefreshCoordinator.attempt(session).then((outcome) => { + if (outcome === 'refreshed') { + log.info('Bot compact runtime refreshed at idle boundary', { + sessionId: session.id, + trigger, + }); + } + }); +} + function isPendingDesktopOnlyConfirmation(requestId: string): boolean { return ( issueConfirmBridge.pendingSnapshots().some(({ request }) => request.requestId === requestId) || @@ -2533,6 +2628,35 @@ const sessionTurnLeaseTracker = new SessionTurnLeaseTracker({ now: Date.now, warn: (message, fields) => log.warn(message, fields), }); + +/** + * Renew replaces the canonical task and closes its live runtime. The same + * per-session lock used by message dispatch must therefore cover the final + * busy check and the SQLite CAS; otherwise a turn can start between a renderer + * precheck and the archive transaction and lose its terminal output. + */ +export async function assertBotCanonicalReplacementIdle(sessionId: string): Promise { + const live = getMakerIfReady()?.getSession(sessionId); + const busy = isBotCanonicalReplacementBusy({ + turnRunning: live?.isTurnRunning() === true, + backgroundTaskCount: live?.listBackgroundTasks().length ?? 0, + trackedTurn: sessionTurnActivityTracker.isSessionInTurn(sessionId), + leasedTurn: await sessionTurnLeaseTracker.isTurnActive(sessionId), + pendingInteraction: hasPendingAgentInteractionForSession(sessionId), + }); + if (busy) { + throwIpcError( + 'SESSION_RUNNING', + 'Bot 主任务仍在运行或等待交互,请等待本轮结束后再 Renew', + ); + } +} +configureBotCanonicalReplacementCoordinator((sessionId, operation) => + withSendToSessionLock(sessionId, async () => { + await assertBotCanonicalReplacementIdle(sessionId); + return operation(); + }), +); const silentStopTurnLeaseGate = new SilentStopTurnLeaseGate(); function providerTurnLeaseId(sessionInstanceId: string, turnGeneration: number): string { return `${sessionInstanceId}:${turnGeneration}`; @@ -3578,6 +3702,12 @@ export function wireSessionToIpc(session: ReturnType): void // on-demand detail IPC; forwarding the raw diff through maker:event would duplicate a // potentially multi-megabyte payload to every renderer and device-link controller. if (event.type === 'turn_diff') return; + if (event.type === 'compact_boundary') { + // A provider may continue the same product turn after compacting. Only + // remember the exact runtime incarnation here; the final idle boundary + // below owns close/bootstrap so paired done/usage events are not lost. + botCompactRuntimeRefreshCoordinator.noteBoundary(session); + } if ( event.turnScope === 'background' && Object.prototype.hasOwnProperty.call(event, 'backgroundTurnStartedAt') && @@ -3691,7 +3821,7 @@ export function wireSessionToIpc(session: ReturnType): void // 新 turn 启动: 上一轮未配对的失败记账交接 id 已无归属, 丢弃防错配。 pendingFailedTurnAssistantPersistId.delete(session.id); // 记录 turn 开始时刻,供 onTurnErrorEvent 判断 error 是否属于 /clear 之前的旧 turn。 - noteTurnStarted(session.id); + noteTurnStarted(session.id, event.turnAttemptToken); noteSubagentObservationTurnStarted(session.id); // silent-stop 守卫:新 turn 开始 → 清 pendingResume + 记录时刻(陈旧判定)。 silentStopAutoResumeGuard.noteTurnStarted(session.id); @@ -3926,6 +4056,7 @@ export function wireSessionToIpc(session: ReturnType): void eventAgentMeta, event.turnScope === 'background' ? 'background' : 'turn', event.backgroundTurnStartedAt, + event.turnAttemptToken, ); } else if (event.type === 'tool_result') { const r = onToolResultEvent( @@ -4035,6 +4166,13 @@ export function wireSessionToIpc(session: ReturnType): void if (event.type === 'done' && !isContinuationBoundary) { void gitSnapshotCoordinator?.onTurnEnd(session.id); } + if ( + (event.type === 'done' && !isContinuationBoundary) || + (event.type === 'status' && shouldMarkTurnStatusIdleAfterBroadcast) || + event.type === 'agent_task_update' + ) { + attemptBotCompactRuntimeRefresh(session, `event:${event.type}`); + } if (isTerminalTurnErrorEvent(event)) { gitSnapshotCoordinator?.onTurnAbort(session.id); } @@ -4250,6 +4388,30 @@ export function wireSessionToIpc(session: ReturnType): void /* non-fatal */ } })(); + void (async () => { + try { + const doneData = event.data as { + result?: unknown; + message?: unknown; + reason?: unknown; + } | null; + const finalText = + typeof doneData?.result === 'string' ? doneData.result : ''; + const errorText = [doneData?.message, doneData?.reason] + .find((value): value is string => typeof value === 'string' && value.length > 0); + await botDelegationServiceHolder?.settleSession({ + childSessionId: session.id, + outcome: isTerminalTurnErrorEvent(event) ? 'error' : 'done', + resultText: finalText, + error: errorText, + }); + } catch (error) { + log.warn('Bot delegation terminal settlement failed', { + sessionId: session.id, + error: error instanceof Error ? error.message : String(error), + }); + } + })(); } } if (pendingContextSnapshot) { @@ -5085,6 +5247,7 @@ export function wireSessionToIpc(session: ReturnType): void // session reachable from the in-memory routing map. cancelDirectAbortReconciliation(session.id); pendingFailedTurnAssistantPersistId.delete(session.id); + botCompactRuntimeRefreshCoordinator.clearForClosedSession(session); wiredSessionsById.delete(session.id); for (const dispose of registration.disposers) { try { @@ -5184,6 +5347,23 @@ export interface RegisterMakerIpcOptions { waitForAccountProviderModelsReady(): Promise; /** Provider 刷新协调器已可用;紧跟 configure 发出,避免后续 handler 失败造成永久等待。 */ onProviderModelAutoRefreshConfigured(): void; + /** Final adapter-owned delivery for proactive Bot route notifications. */ + deliverBotRouteMessage?(input: { + channel: string; + ownership: 'local-adapter' | 'server-relay'; + accountKey: string; + principalKey: string; + threadKey?: string | null; + deliveryKey?: string | null; + idempotencyKey: string; + text: string; + sessionId?: string | null; + workingDir?: string | null; + onProgress?: (receipt: Record) => Promise; + }): Promise< + | { ok: true; receipt: Record } + | { ok: false; retryable: boolean; errorCode: string; message: string } + >; } export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions): void { @@ -5969,16 +6149,19 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) const kind = requireAgentKind(agentKind); const skillParams = (params ?? {}) as { workingDir?: string; + remoteHostId?: string; forceReload?: boolean; sessionId?: string; }; - const linksChanged = await prepareProjectSkillLinksFailSoft(skillParams?.workingDir); + const linksChanged = skillParams.remoteHostId + ? false + : await prepareProjectSkillLinksFailSoft(skillParams.workingDir); if (kind === 'codex' && linksChanged) { skillParams.forceReload = true; } - if (kind === 'codex') { + if (kind === 'codex' && !skillParams.remoteHostId) { await desktopCodexAuthAdapter.ensureGlobalCodexAssets(); - } else if (kind === 'claude-code') { + } else if (kind === 'claude-code' && !skillParams.remoteHostId) { await desktopClaudeAuthAdapter.ensureSharedGlobalSkills(); } const result = await maker.listAgentSkills(kind, skillParams); @@ -6515,6 +6698,205 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) return { session, didInjectOrcaInstructions, didInjectProjectContext }; } + async function recordBotCompactRuntimeLifecycle(input: { + botId: string; + sessionId: string; + eventType: 'compact-runtime-refresh-requested' | 'compact-runtime-refresh-applied' | + 'compact-runtime-refresh-deferred' | 'compact-runtime-refresh-failed'; + boundary: BotCompactBoundary; + profileVersion: number; + reason?: string; + }): Promise { + await getDbClient().drizzle.insert(botLifecycleEvents).values({ + id: randomUUID(), + botId: input.botId, + sessionId: input.sessionId, + eventType: input.eventType, + payloadJson: JSON.stringify({ + profileVersion: input.profileVersion, + runtimeInstanceId: input.boundary.sessionInstanceId, + compactBoundaryCount: input.boundary.boundaryCount, + firstObservedAt: input.boundary.firstObservedAt, + lastObservedAt: input.boundary.lastObservedAt, + ...(input.reason ? { reason: input.reason } : {}), + }), + createdAt: Date.now(), + }); + } + + botCompactRuntimeRefreshHandler = async ( + compactSession, + boundary, + ): Promise => { + const expectedSession = compactSession as WiredSession; + return withSendToSessionLock(expectedSession.id, async () => { + if (maker.getSession(expectedSession.id) !== expectedSession) return 'not-bot'; + + const db = getDbClient().drizzle; + const [row] = await db + .select({ + botId: botSessionLinks.botId, + role: botSessionLinks.role, + routeKey: botSessionLinks.routeKey, + profileVersion: botSessionLinks.profileVersion, + source: sessions.source, + status: sessions.status, + title: sessions.title, + workingDir: sessions.workingDir, + workspaceKind: sessions.workspaceKind, + agentKind: sessions.agentKind, + model: sessions.model, + providerId: sessions.providerId, + effort: sessions.effort, + fastMode: sessions.fastMode, + permissionMode: sessions.permissionMode, + planModeEnabled: sessions.planModeEnabled, + sdkSessionId: sessions.sdkSessionId, + remoteHostId: sessions.remoteHostId, + orcaRole: sessions.orcaRole, + codexHistoryHasProductPrompt: sessions.codexHistoryHasProductPrompt, + }) + .from(sessions) + .innerJoin(botSessionLinks, eq(botSessionLinks.sessionId, sessions.id)) + .where(eq(sessions.id, expectedSession.id)) + .limit(1); + if ( + !row || + row.source !== 'bot' || + row.status !== 'active' || + (row.role !== 'canonical' && row.role !== 'route') + ) { + return 'not-bot'; + } + if ( + row.role === 'route' && + (row.routeKey?.startsWith('automation:') || row.routeKey?.startsWith('delegation:')) + ) { + // These short-lived runs own their own terminal archive transaction. + // Rebuilding one here would race that owner and could resurrect it. + return 'not-bot'; + } + if ( + expectedSession.isTurnRunning() || + expectedSession.listBackgroundTasks().length > 0 || + hasPendingAgentInteractionForSession(expectedSession.id) + ) { + await recordBotCompactRuntimeLifecycle({ + botId: row.botId, + sessionId: expectedSession.id, + eventType: 'compact-runtime-refresh-deferred', + boundary, + profileVersion: row.profileVersion, + reason: 'runtime-busy', + }); + return 'deferred'; + } + if (!row.workingDir) { + await recordBotCompactRuntimeLifecycle({ + botId: row.botId, + sessionId: expectedSession.id, + eventType: 'compact-runtime-refresh-failed', + boundary, + profileVersion: row.profileVersion, + reason: 'working-dir-missing', + }); + return 'not-bot'; + } + + const createOpts = buildCreateOptsWithStderr({ + id: expectedSession.id, + agentKind: dbToMakerAgentKind(row.agentKind), + workingDir: row.workingDir, + workspaceKind: row.workspaceKind, + model: row.model ?? undefined, + providerId: row.providerId, + effort: (row.effort ?? undefined) as CreateOpts['effort'], + fastMode: !!row.fastMode, + permissionMode: permissionModeOrAsk(row.permissionMode), + planMode: !!row.planModeEnabled, + title: row.title ?? undefined, + resumeSessionId: row.sdkSessionId ?? undefined, + remoteHostId: row.remoteHostId ?? undefined, + orcaRole: row.orcaRole as CreateOpts['orcaRole'], + codexHistoryHasProductPrompt: row.codexHistoryHasProductPrompt ?? undefined, + }); + await synthesizeOrcaVendorOptionsFromDb(expectedSession.id, createOpts); + const extraDirs = await readSessionExtraDirsFromDb(expectedSession.id).catch((error) => { + log.warn('Bot compact refresh could not read extra dirs; continuing without them', { + sessionId: expectedSession.id, + error: error instanceof Error ? error.message : String(error), + }); + return []; + }); + if (extraDirs.length > 0) createOpts.extraDirs = extraDirs; + + const workDirReady = await checkWorkDirExists( + expectedSession.id, + createOpts.workingDir, + createOpts.agentKind, + createOpts.remoteHostId, + ); + if (!workDirReady) { + await recordBotCompactRuntimeLifecycle({ + botId: row.botId, + sessionId: expectedSession.id, + eventType: 'compact-runtime-refresh-failed', + boundary, + profileVersion: row.profileVersion, + reason: 'working-dir-unavailable', + }); + return 'not-bot'; + } + + await recordBotCompactRuntimeLifecycle({ + botId: row.botId, + sessionId: expectedSession.id, + eventType: 'compact-runtime-refresh-requested', + boundary, + profileVersion: row.profileVersion, + }); + try { + await ensureRemoteReadyForSessionStart({ createOpts }); + // Resource drift is a Renew boundary, not a reason to destroy the + // currently healthy runtime. Resolve the exact native Skill/MCP/ + // Toolset bundle before closeSession so a failed preflight leaves the + // old process and its resume ownership untouched. + await withRehydrateCloseSuppressed(expectedSession.id, async () => { + const refreshed = await replaceBotRuntimeAfterPreflight({ + preflight: () => + preflightBotRuntimeResources(createOpts as MakerSessionCreateOpts), + isCurrentOwner: () => maker.getSession(expectedSession.id) === expectedSession, + close: () => maker.closeSession(expectedSession.id, 'runtime-refresh'), + bootstrap: async () => (await bootstrapSession(createOpts)).session, + }); + await markOrcaRoleIfNeeded(refreshed.id, createOpts.orcaRole); + broadcastSessionCreated(refreshed.id); + }); + await recordBotCompactRuntimeLifecycle({ + botId: row.botId, + sessionId: expectedSession.id, + eventType: 'compact-runtime-refresh-applied', + boundary, + profileVersion: row.profileVersion, + }); + return 'refreshed'; + } catch (error) { + await recordBotCompactRuntimeLifecycle({ + botId: row.botId, + sessionId: expectedSession.id, + eventType: 'compact-runtime-refresh-failed', + boundary, + profileVersion: row.profileVersion, + reason: + error instanceof Error && error.name.trim() + ? error.name.trim().slice(0, 120) + : 'Error', + }).catch(() => undefined); + throw error; + } + }); + }; + // switchFocus 和 sendToWorker 都可能唤醒 idle worker;统一走这里才能保留 extraDirs。 async function resumeOrcaWorkerSessionIfMissing(target: { id: string; @@ -7943,6 +8325,10 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) message: error instanceof Error ? error.message : String(error), }; } + const compactedRuntime = maker.getSession(targetSessionId); + if (compactedRuntime && botCompactRuntimeRefreshCoordinator.hasPending(targetSessionId)) { + await botCompactRuntimeRefreshCoordinator.attempt(compactedRuntime); + } } // ── create 分支 ────────────────────────────────────────────────────────── @@ -8570,6 +8956,331 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) return tracked; } + const dispatchBotSessionMessage = async (params: { + targetSessionId: string; + message: string; + persistedContent?: string; + clientId?: string; + onAccepted?: () => void | Promise; + }) => { + if (params.clientId) { + const [persisted] = await getDbClient() + .drizzle.select({ id: messages.id }) + .from(messages) + .where( + and( + eq(messages.sessionId, params.targetSessionId), + eq(messages.clientId, params.clientId), + ), + ) + .limit(1); + if (persisted) { + await params.onAccepted?.(); + return { + ok: true as const, + targetSessionId: params.targetSessionId, + wakeKind: 'already-active' as const, + }; + } + } + return sendToSessionInternal(params); + }; + + botDeliveryOutboxServiceHolder?.dispose(); + botDeliveryOutboxServiceHolder = createBotDeliveryOutboxService({ + onChanged: (payload) => broadcastToAllWindows(MAKER_PUSH.BOT_DELIVERY_CHANGED, payload), + deliver: async (row, payload, attempt) => { + if (payload.kind !== 'session-message') { + return { + ok: false as const, + retryable: false, + errorCode: 'UNSUPPORTED_DELIVERY_KIND', + message: `Unsupported Bot delivery kind: ${payload.kind}`, + }; + } + const targetSessionId = + typeof payload.targetSessionId === 'string' ? payload.targetSessionId : row.sessionId; + const fallbackBotId = + typeof payload.fallbackBotId === 'string' ? payload.fallbackBotId : row.botId; + const clientId = + typeof payload.clientId === 'string' && payload.clientId + ? payload.clientId + : `bot-outbox:${row.id}`; + const message = typeof payload.message === 'string' ? payload.message : ''; + const persistedContent = + typeof payload.persistedContent === 'string' ? payload.persistedContent : message; + if (!targetSessionId || !message) { + return { + ok: false as const, + retryable: false, + errorCode: 'INVALID_PAYLOAD', + message: 'Bot session delivery requires targetSessionId and message', + }; + } + + const [fallback] = await getDbClient() + .drizzle.select({ canonicalSessionId: botProfiles.canonicalSessionId }) + .from(botProfiles) + .where(eq(botProfiles.id, fallbackBotId)) + .limit(1); + const candidates = [...new Set([targetSessionId, fallback?.canonicalSessionId].filter( + (value): value is string => typeof value === 'string' && value.length > 0, + ))]; + let lastFailure: { errorCode: string; message: string } | null = null; + for (const candidate of candidates) { + const result = await dispatchBotSessionMessage({ + targetSessionId: candidate, + message, + persistedContent, + clientId, + }); + if (result.ok) { + if (!row.routeId) return { ok: true as const }; + const [targetTask] = await getDbClient() + .drizzle.select({ workingDir: sessions.workingDir }) + .from(sessions) + .where(eq(sessions.id, candidate)) + .limit(1); + const [route] = await getDbClient() + .drizzle.select({ + botId: botRoutes.botId, + channelId: botRoutes.channelId, + principalKey: botRoutes.principalKey, + threadKey: botRoutes.threadKey, + capabilitiesJson: botRoutes.capabilitiesJson, + routeStatus: botRoutes.status, + channelKind: botChannels.kind, + channelEnabled: botChannels.enabled, + channelConfigJson: botChannels.configJson, + }) + .from(botRoutes) + .innerJoin(botChannels, eq(botChannels.id, botRoutes.channelId)) + .where(eq(botRoutes.id, row.routeId)) + .limit(1); + if (!route || route.botId !== row.botId || route.channelId !== row.channelId) { + return { + ok: false as const, + retryable: false, + errorCode: 'ROUTE_OWNERSHIP_MISMATCH', + message: 'Bot delivery route no longer belongs to the mounted Channel.', + }; + } + if (route.routeStatus !== 'active' || !route.channelEnabled) { + return { + ok: false as const, + retryable: true, + errorCode: 'ROUTE_UNAVAILABLE', + message: `Bot delivery route is ${route.routeStatus}.`, + }; + } + let channelConfig: Record = {}; + let routeCapabilities: Record = {}; + try { + const parsed = JSON.parse(route.channelConfigJson) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + channelConfig = parsed as Record; + } + } catch { + // Invalid durable config is terminal: retries cannot repair it. + } + try { + const parsed = JSON.parse(route.capabilitiesJson) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + routeCapabilities = parsed as Record; + } + } catch { + // Missing relay address is handled below as a terminal route error. + } + const ownership = channelConfig.ownership; + const accountKey = typeof channelConfig.accountKey === 'string' + ? channelConfig.accountKey.trim() + : ''; + if ( + (ownership !== 'local-adapter' && ownership !== 'server-relay') + || !accountKey + ) { + return { + ok: false as const, + retryable: false, + errorCode: 'INVALID_CHANNEL_CONFIG', + message: 'Bot Channel delivery identity is incomplete.', + }; + } + if (!options.deliverBotRouteMessage) { + return { + ok: false as const, + retryable: true, + errorCode: 'CHANNEL_DELIVERY_NOT_READY', + message: 'Bot Channel delivery is not initialized.', + }; + } + await attempt.recordExternalDispatch({ + retrySafe: ownership === 'server-relay' || route.channelKind === 'wechat', + transport: ownership, + }); + const delivered = await options.deliverBotRouteMessage({ + channel: route.channelKind, + ownership, + accountKey, + principalKey: route.principalKey, + threadKey: route.threadKey, + deliveryKey: + typeof routeCapabilities.deliveryKey === 'string' + ? routeCapabilities.deliveryKey + : null, + idempotencyKey: row.idempotencyKey, + text: persistedContent, + sessionId: candidate, + workingDir: targetTask?.workingDir ?? null, + onProgress: attempt.recordProgress, + }); + return delivered.ok + ? { ok: true as const, receipt: delivered.receipt } + : { + ok: false as const, + retryable: delivered.retryable, + errorCode: delivered.errorCode, + message: delivered.message, + }; + } + lastFailure = { errorCode: result.errorCode, message: result.message }; + if (result.errorCode !== 'ARCHIVED' && result.errorCode !== 'DELETED' && result.errorCode !== 'NOT_FOUND') { + break; + } + } + return { + ok: false as const, + retryable: true, + errorCode: lastFailure?.errorCode ?? 'TARGET_UNAVAILABLE', + message: lastFailure?.message ?? 'No active Bot task is available for delivery', + }; + }, + }); + botDelegationServiceHolder?.dispose(); + botDelegationServiceHolder = createBotDelegationService({ + dispatch: ({ targetSessionId, message, persistedContent, clientId, onAccepted }) => + dispatchBotSessionMessage({ + targetSessionId, + message, + persistedContent, + clientId, + onAccepted, + }), + enqueueDelivery: (params) => { + const outbox = botDeliveryOutboxServiceHolder; + if (!outbox) throw new Error('Bot delivery outbox is not initialized'); + return outbox.enqueue(params); + }, + abortSession: (async (sessionId) => { + const session = maker.getSession(sessionId); + if (session?.isTurnRunning?.()) await session.abort(); + }), + archiveSession: async (sessionId) => { + await setSessionsStatusInDb([sessionId], 'archived'); + }, + closeSession: (sessionId) => maker.closeSession(sessionId), + broadcastSessionCreated, + onChanged: (payload) => broadcastToAllWindows(MAKER_PUSH.BOT_DELEGATION_CHANGED, payload), + requireRuntimeSnapshot: true, + }); + registerBotLifecycleHandlers({ + maker, + getDelegationService: () => botDelegationServiceHolder, + getOutboxService: () => botDeliveryOutboxServiceHolder, + }); + const outboxForRestore = botDeliveryOutboxServiceHolder; + const delegationForRestore = botDelegationServiceHolder; + setSessionTokenUsageObserver(async ({ sessionId, totalTokens }) => { + await delegationForRestore.enforceBudgetForSession(sessionId, totalTokens); + }); + void (async () => { + try { + await outboxForRestore.restore(); + } catch (error) { + log.warn('Bot delivery outbox restore failed', { + error: error instanceof Error ? error.message : String(error), + }); + } + try { + await delegationForRestore.restore(); + } catch (error) { + log.warn('Bot delegation restore failed', { + error: error instanceof Error ? error.message : String(error), + }); + } + })(); + + ipcMain.handle( + MAKER_INVOKE.BOT_DELEGATIONS_LIST, + async (event, parentSessionId: unknown, status?: unknown) => { + assertTrustedAppRendererEvent(event); + if (typeof parentSessionId !== 'string' || parentSessionId.length === 0) { + throwIpcError('INVALID_PARAMS', 'parentSessionId required'); + } + if ( + status !== undefined + && (typeof status !== 'string' + || !BOT_DELEGATION_STATUSES.includes( + status as (typeof BOT_DELEGATION_STATUSES)[number], + )) + ) { + throwIpcError('INVALID_PARAMS', 'invalid Bot delegation status'); + } + return delegationForRestore.listDelegations( + parentSessionId, + status as (typeof BOT_DELEGATION_STATUSES)[number] | undefined, + ); + }, + ); + ipcMain.handle( + MAKER_INVOKE.BOT_DELEGATION_CANCEL, + async (event, parentSessionId: unknown, delegationId: unknown) => { + assertTrustedAppRendererEvent(event); + if ( + typeof parentSessionId !== 'string' + || parentSessionId.length === 0 + || typeof delegationId !== 'string' + || delegationId.length === 0 + ) { + throwIpcError('INVALID_PARAMS', 'parentSessionId + delegationId required'); + } + return delegationForRestore.cancelDelegation(parentSessionId, delegationId); + }, + ); + ipcMain.handle( + MAKER_INVOKE.BOT_DELIVERIES_LIST, + async (event, botId: unknown, limit?: unknown) => { + assertTrustedAppRendererEvent(event); + if (typeof botId !== 'string' || botId.length === 0) { + throwIpcError('INVALID_PARAMS', 'botId required'); + } + if (limit !== undefined && (typeof limit !== 'number' || !Number.isFinite(limit))) { + throwIpcError('INVALID_PARAMS', 'limit must be a finite number'); + } + return outboxForRestore.listForBot(botId, limit as number | undefined); + }, + ); + ipcMain.handle( + MAKER_INVOKE.BOT_DELIVERY_RETRY, + async (event, botId: unknown, deliveryId: unknown, allowDuplicateRisk?: unknown) => { + assertTrustedAppRendererEvent(event); + if ( + typeof botId !== 'string' + || botId.length === 0 + || typeof deliveryId !== 'string' + || deliveryId.length === 0 + ) { + throwIpcError('INVALID_PARAMS', 'botId + deliveryId required'); + } + if (allowDuplicateRisk !== undefined && typeof allowDuplicateRisk !== 'boolean') { + throwIpcError('INVALID_PARAMS', 'allowDuplicateRisk must be boolean'); + } + return outboxForRestore.retry(deliveryId, botId, { + allowDuplicateRisk: allowDuplicateRisk === true, + }); + }, + ); + // Ghost 的 Agent 槽只负责验证权限和整理 prompt;真正的新回合仍走 // sendToSessionInternal 这一条主机通路,因此会话恢复、繁忙排队、消息落库与 // 费用行为都和用户亲自在聊天框发送一致。runner 通过回调注入,避免 @@ -10134,23 +10845,29 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) reconcileCreateOptsWithDb: reconcileCreateOptsAgainstDb, peekPendingHandoff: (sessionId) => agentHandoffPending.peek(sessionId), consumePendingHandoff: (sessionId) => agentHandoffPending.consume(sessionId), - // 计划对账:未收口的旧计划让 agent 在下一轮顺手交代。读近段历史现算, - // 无 pending 状态(清单被更新/清掉后下一轮自然不再注入)。 - // 窗口 1000 行(交接用 400):计划行被长工具流挤出窗口时 summarize 返回 - // null → 本轮不注入——降级方向是"少提醒",不会误提醒,可接受;不做全量 - // 分页回溯,避免每次发送为一个提示扫全历史。 peekPlanReconcileNote: async (sessionId) => { - // 读排在同一条持久化 FIFO 之后:终态章(persistCodexPlanOnDone)与失败印记 - // 只是 enqueueWrite 入队,不等它们落库就查,会读到未盖章的旧快照 → 给已经 - // 收口的计划多注入一次对账;反过来,全勾完但失败的计划可能因 turnCompleted:false - // 还没写进去而漏掉唯一的收口通道(review P2)。调用方对失败已经 catch 成 null, - // 队列被 app-session 边界打断时最多这一轮不注入。 - const rows = await enqueueDurableWrite(`plan-reconcile-read:${sessionId}`, () => - listMessagesForAgentHandoff(sessionId, 1000), + const snapshot = await enqueueDurableWrite(`plan-reconcile-read:${sessionId}`, () => + readCodexPlanState(sessionId), ); - const summary = summarizeOpenPlan(rows); - return summary ? buildPlanReconcileNote(summary) : null; + if (!snapshot) return null; + if (snapshot.state === 'sealed') { + return { + note: buildCompletedPlanGuardNote(), + sealedTurnId: snapshot.turnId, + }; + } + const openSteps = snapshot.plan + .filter((item) => item.status !== 'completed') + .map((item) => item.step); + if (openSteps.length === 0 && snapshot.state !== 'interrupted') return null; + return { + note: buildPlanReconcileNote({ openSteps, totalSteps: snapshot.plan.length }), + }; }, + consumeSealedPlanReconcileNote: (sessionId, turnId) => + enqueueDurableWrite(`plan-seal-consume:${sessionId}:${turnId}`, () => + clearSealedCodexPlanState(sessionId, turnId), + ), // 手机客户端说明的开关:被控端盖章的来源判据(本机 renderer / 桌面控制端 / 平台 // 未知一律 false)。必须在这里现取,不能提前求值缓存——同一个装配好的事务会服务 // 后续所有 send,来源是逐次调用的属性。 @@ -10164,7 +10881,26 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) const [sessionId] = args; if (typeof sessionId !== 'string') return await sendToAgentAcceptedUnlocked(...args); await assertReviewExternalInputAllowed(sessionId); - return await withSendToSessionLock(sessionId, () => sendToAgentAcceptedUnlocked(...args)); + const compactedRuntime = maker.getSession(sessionId); + if (compactedRuntime && botCompactRuntimeRefreshCoordinator.hasPending(sessionId)) { + await botCompactRuntimeRefreshCoordinator.attempt(compactedRuntime); + } + return await withSendToSessionLock(sessionId, async () => { + const [botInput] = await getDbClient() + .drizzle.select({ + source: sessions.source, + role: botSessionLinks.role, + profileStatus: botProfiles.status, + }) + .from(sessions) + .leftJoin(botSessionLinks, eq(botSessionLinks.sessionId, sessions.id)) + .leftJoin(botProfiles, eq(botProfiles.id, botSessionLinks.botId)) + .where(eq(sessions.id, sessionId)) + .limit(1); + const blocked = botSessionInputBlockReason(botInput ?? null); + if (blocked) throwIpcError('PRECONDITION_FAILED', blocked); + return sendToAgentAcceptedUnlocked(...args); + }); }; /** * Same-turn steer contract: resolved STEER means maker-core accepted the @@ -10182,6 +10918,19 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) ): Promise => { if (typeof sessionId !== 'string') throwIpcError('INVALID_PARAMS', 'sessionId required'); await assertReviewExternalInputAllowed(sessionId); + const [botInput] = await getDbClient() + .drizzle.select({ + source: sessions.source, + role: botSessionLinks.role, + profileStatus: botProfiles.status, + }) + .from(sessions) + .leftJoin(botSessionLinks, eq(botSessionLinks.sessionId, sessions.id)) + .leftJoin(botProfiles, eq(botProfiles.id, botSessionLinks.botId)) + .where(eq(sessions.id, sessionId)) + .limit(1); + const blocked = botSessionInputBlockReason(botInput ?? null); + if (blocked) throwIpcError('PRECONDITION_FAILED', blocked); const sess = maker.getSession(sessionId); if (!sess) { log.warn('steer: session not running', { sessionId }); diff --git a/apps/desktop/src/main/maker-ipc/schedule.ts b/apps/desktop/src/main/maker-ipc/schedule.ts index 5ab1001f70..a7e53b75d3 100644 --- a/apps/desktop/src/main/maker-ipc/schedule.ts +++ b/apps/desktop/src/main/maker-ipc/schedule.ts @@ -43,11 +43,13 @@ import { ipcMain, BrowserWindow, app } from 'electron'; import type { AgentKind, Maker } from '@cindy/maker-core'; import type { Scheduler, + Schedule, CreateScheduleInput, UpdateScheduleInput, ListFilter, ScheduleTemplate, SchedulerEvent, + SchedulerRuntimeSnapshot, } from '@cindy/maker-scheduler'; import { BUILTIN_TEMPLATES, @@ -104,6 +106,55 @@ function broadcastSchedulerEvent(event: SchedulerEvent): void { } } +async function filterGenericRuntimeSnapshot( + snapshot: SchedulerRuntimeSnapshot, + storage: DrizzleScheduleStorage, +): Promise { + const scheduleIds = new Set([ + ...snapshot.inFlightRuns.map((run) => run.scheduleId), + ...snapshot.waitingSchedules.map((waiting) => waiting.scheduleId), + ]); + const sourceById = new Map( + await Promise.all( + [...scheduleIds].map(async (scheduleId) => [ + scheduleId, + (await storage.get(scheduleId))?.source, + ] as const), + ), + ); + const inFlightRuns = snapshot.inFlightRuns.filter( + (run) => sourceById.get(run.scheduleId) !== 'bot', + ); + const waitingSchedules = snapshot.waitingSchedules.filter( + (waiting) => sourceById.get(waiting.scheduleId) !== 'bot', + ); + return { + ...snapshot, + inFlight: inFlightRuns.length, + slotsInUse: inFlightRuns.filter( + (run) => run.phase !== 'queued' && run.phase !== 'cancelling', + ).length, + inFlightRuns, + waitingSchedules, + }; +} + +async function broadcastGenericSchedulerEvent( + event: SchedulerEvent, + storage: DrizzleScheduleStorage, +): Promise { + if (event.type === 'runtime-state') { + const snapshot = await filterGenericRuntimeSnapshot(event.snapshot, storage); + broadcastSchedulerEvent({ ...event, snapshot }); + return; + } + if ('scheduleId' in event) { + const schedule = await storage.get(event.scheduleId); + if (schedule?.source === 'bot') return; + } + broadcastSchedulerEvent(event); +} + /** 非 Scheduler 引擎写入 run 衍生数据后,通知各端重新读取该任务。 */ export function broadcastSchedulerChanged(scheduleId: string): void { if (!scheduleId) return; @@ -218,6 +269,31 @@ async function withScheduler(cb: (deps: SchedulerDeps) => Promise): Promis } } +async function requireGenericSchedule(scheduler: Scheduler, scheduleId: string): Promise { + const schedule = await scheduler.get(scheduleId); + if (!schedule || schedule.source === 'bot') { + throwIpcError('NOT_FOUND', `schedule ${scheduleId} not found`); + } + return schedule; +} + +async function requireGenericScheduleRun( + storage: DrizzleScheduleStorage, + runId: string, +): Promise { + const schedule = await storage.getScheduleForRun(runId); + if (!schedule || schedule.source === 'bot') { + throwIpcError('NOT_FOUND', `schedule run ${runId} not found`); + } + return schedule; +} + +function rejectReservedBotSource(input: Record, field: string): void { + if (Object.prototype.hasOwnProperty.call(input, 'source')) { + throwIpcError('INVALID_PARAMS', `${field}.source is managed by its owning domain`); + } +} + /** * Device-link JSON 边界翻译:mobile 清空 intervalMs 用可序列化的 null 表达 * (`JSON.stringify` 会丢掉值为 undefined 的 key,见 maker-shared @@ -330,14 +406,17 @@ export function registerScheduleHandlers(getMaker?: () => Maker | null): void { ipcMain.handle(MAKER_INVOKE.SCHEDULE_GET, async (_e, id: unknown) => { const scheduleId = requireString(id, 'id'); - return withScheduler(({ scheduler }) => scheduler.get(scheduleId)); + return withScheduler(({ scheduler }) => requireGenericSchedule(scheduler, scheduleId)); }); ipcMain.handle(MAKER_INVOKE.SCHEDULE_CREATE, async (_e, input: unknown) => { - requireObject(input, 'input'); + const body = requireObject(input, 'input'); + rejectReservedBotSource(body, 'input'); return withScheduler(async ({ scheduler }) => { const normalized = await stabilizePreRunHookForCreate( - normalizeNullableIntervalMs(input as CreateScheduleInput & { intervalMs?: number | null }), + normalizeNullableIntervalMs( + body as unknown as CreateScheduleInput & { intervalMs?: number | null }, + ), hookPathDeps, ); return scheduler.create(normalized); @@ -346,26 +425,29 @@ export function registerScheduleHandlers(getMaker?: () => Maker | null): void { ipcMain.handle(MAKER_INVOKE.SCHEDULE_UPDATE, async (_e, id: unknown, patch: unknown) => { const scheduleId = requireString(id, 'id'); - requireObject(patch, 'patch'); - return withScheduler(({ scheduler }) => - scheduler.updateFromCurrent(scheduleId, (existing) => + const body = requireObject(patch, 'patch'); + rejectReservedBotSource(body, 'patch'); + return withScheduler(async ({ scheduler }) => { + await requireGenericSchedule(scheduler, scheduleId); + return scheduler.updateFromCurrent(scheduleId, (existing) => stabilizePreRunHookForUpdate( existing, normalizeLegacyDeviceLinkIntervalClear( normalizeNullableIntervalMs( - patch as UpdateScheduleInput & { intervalMs?: number | null }, + body as UpdateScheduleInput & { intervalMs?: number | null }, ), isDeviceLinkInvoke(), ), hookPathDeps, ), - ), - ); + ); + }); }); ipcMain.handle(MAKER_INVOKE.SCHEDULE_DELETE, async (_e, id: unknown) => { const scheduleId = requireString(id, 'id'); return withScheduler(async ({ scheduler, storage }) => { + await requireGenericSchedule(scheduler, scheduleId); // 删 automation 前先把它名下所有未读历史标记为已读 —— 用户都决定删了, // 残留 unread badge 没意义。失败仅记日志,不阻断 delete 主流程。 try { @@ -383,6 +465,7 @@ export function registerScheduleHandlers(getMaker?: () => Maker | null): void { ipcMain.handle(MAKER_INVOKE.SCHEDULE_PAUSE, async (_e, id: unknown) => { const scheduleId = requireString(id, 'id'); return withScheduler(async ({ scheduler, storage }) => { + await requireGenericSchedule(scheduler, scheduleId); const result = await scheduler.pause(scheduleId); // pause 后顺手清掉这条 schedule 名下的未读历史 —— 用户主动暂停就视为 // 已不再关心当前一批结果,badge 一并消化。失败仅记日志,不影响 pause 本身。 @@ -400,12 +483,18 @@ export function registerScheduleHandlers(getMaker?: () => Maker | null): void { ipcMain.handle(MAKER_INVOKE.SCHEDULE_RESUME, async (_e, id: unknown) => { const scheduleId = requireString(id, 'id'); - return withScheduler(({ scheduler }) => scheduler.resume(scheduleId)); + return withScheduler(async ({ scheduler }) => { + await requireGenericSchedule(scheduler, scheduleId); + return scheduler.resume(scheduleId); + }); }); ipcMain.handle(MAKER_INVOKE.SCHEDULE_RUN_NOW, async (_e, id: unknown) => { const scheduleId = requireString(id, 'id'); - return withScheduler(({ scheduler }) => scheduler.runNow(scheduleId)); + return withScheduler(async ({ scheduler }) => { + await requireGenericSchedule(scheduler, scheduleId); + return scheduler.runNow(scheduleId); + }); }); // 表单「AI 生成」:按用户自然语言描述生成前置检查脚本(utility model 单次生成, @@ -542,7 +631,10 @@ export function registerScheduleHandlers(getMaker?: () => Maker | null): void { ipcMain.handle(MAKER_INVOKE.SCHEDULE_LIST_RUNS, async (_e, scheduleId: unknown, limit: unknown) => { const id = requireString(scheduleId, 'scheduleId'); const lim = typeof limit === 'number' && Number.isFinite(limit) ? limit : undefined; - return withScheduler(({ scheduler }) => scheduler.listRuns(id, lim)); + return withScheduler(async ({ scheduler }) => { + await requireGenericSchedule(scheduler, id); + return scheduler.listRuns(id, lim); + }); }); // 一并回传引擎的 in-flight runId 快照:renderer 的通知抑制标记要靠它区分「DB 里查不到 @@ -554,10 +646,13 @@ export function registerScheduleHandlers(getMaker?: () => Maker | null): void { // 会出现「行还是 running、controller 已注销」。消费方(reconcileRunMarkers)能识别这种 // 不一致并安排一次重查,所以这里不为它忙等重采样。 ipcMain.handle(MAKER_INVOKE.SCHEDULE_LIST_SIDEBAR_INDEX_RUNS, async () => - withScheduler(async ({ storage, scheduler }) => ({ - runs: await storage.listSidebarIndexRuns(), - inflightRunIds: scheduler.listInflightRunIds(), - })), + withScheduler(async ({ storage, scheduler }) => { + const snapshot = await filterGenericRuntimeSnapshot(scheduler.getRuntimeSnapshot(), storage); + return { + runs: await storage.listSidebarIndexRuns(), + inflightRunIds: snapshot.inFlightRuns.map((run) => run.runId), + }; + }), ); ipcMain.handle(MAKER_INVOKE.SCHEDULE_LIST_COST_SUMMARIES, async () => @@ -566,7 +661,10 @@ export function registerScheduleHandlers(getMaker?: () => Maker | null): void { ipcMain.handle(MAKER_INVOKE.SCHEDULE_DELETE_RUN, async (_e, runId: unknown) => { const id = requireString(runId, 'runId'); - return withScheduler(({ scheduler }) => scheduler.deleteRun(id)); + return withScheduler(async ({ scheduler, storage }) => { + await requireGenericScheduleRun(storage, id); + return scheduler.deleteRun(id); + }); }); // Renderer 在 delete/pause 前查 in-flight 数量 —— >0 时弹合并文案的二次确认 @@ -574,14 +672,16 @@ export function registerScheduleHandlers(getMaker?: () => Maker | null): void { // 不查 DB,几乎无延迟。 ipcMain.handle(MAKER_INVOKE.SCHEDULE_GET_INFLIGHT_COUNT, async (_e, id: unknown) => { const scheduleId = requireString(id, 'id'); - return withScheduler(({ scheduler }) => - Promise.resolve(scheduler.getInflightCount(scheduleId)), - ); + return withScheduler(async ({ scheduler }) => { + await requireGenericSchedule(scheduler, scheduleId); + return scheduler.getInflightCount(scheduleId); + }); }); // Renderer 首次进入页面时补取一次运行快照,避免错过更早广播的 runtime-state。 ipcMain.handle(MAKER_INVOKE.SCHEDULE_GET_RUNTIME_STATE, async () => - withScheduler(({ scheduler }) => Promise.resolve(scheduler.getRuntimeSnapshot())), + withScheduler(({ scheduler, storage }) => + filterGenericRuntimeSnapshot(scheduler.getRuntimeSnapshot(), storage)), ); ipcMain.handle(MAKER_INVOKE.SCHEDULE_GET_UNREAD_COUNT, async () => @@ -603,6 +703,7 @@ export function registerScheduleHandlers(getMaker?: () => Maker | null): void { ipcMain.handle(MAKER_INVOKE.SCHEDULE_MARK_RUN_READ, async (_e, runId: unknown) => { const id = requireString(runId, 'runId'); return withScheduler(async ({ storage }) => { + await requireGenericScheduleRun(storage, id); const scheduleId = await storage.markRunRead(id); // markRunRead 已自带 "已读 / 非终态 / 不存在" 三种 no-op 短路; // 拿到 scheduleId 才广播 —— 让 useRuns 拉到带 readAt 的新 row、badge hook 重算总数。 @@ -614,7 +715,8 @@ export function registerScheduleHandlers(getMaker?: () => Maker | null): void { ipcMain.handle(MAKER_INVOKE.SCHEDULE_MARK_SCHEDULE_RUNS_READ, async (_e, scheduleId: unknown) => { const id = requireString(scheduleId, 'scheduleId'); - return withScheduler(async ({ storage }) => { + return withScheduler(async ({ scheduler, storage }) => { + await requireGenericSchedule(scheduler, id); const updated = await storage.markAllRunsRead(id); // 仅在有真实更新时广播,避免 no-op 也触发下游 refetch。 if (updated > 0) { @@ -639,9 +741,13 @@ export function registerScheduleHandlers(getMaker?: () => Maker | null): void { : {}; const overrides = body.overrides && typeof body.overrides === 'object' - ? normalizeNullableIntervalMs( - body.overrides as Partial & { intervalMs?: number | null }, - ) + ? (() => { + const rawOverrides = body.overrides as Record; + rejectReservedBotSource(rawOverrides, 'overrides'); + return normalizeNullableIntervalMs( + rawOverrides as Partial & { intervalMs?: number | null }, + ); + })() : {}; return withScheduler(({ scheduler }) => { const template = findTemplate(templateId); @@ -687,16 +793,21 @@ export function attachSchedulerEventListeners( storage: DrizzleScheduleStorage, ): void { // 单一 channel 多事件类型:renderer 按 event.type 分支 - scheduler.on('fired', broadcastSchedulerEvent); - scheduler.on('completed', broadcastSchedulerEvent); - scheduler.on('failed', broadcastSchedulerEvent); - scheduler.on('silenced', broadcastSchedulerEvent); - scheduler.on('notified', broadcastSchedulerEvent); - scheduler.on('deferred', broadcastSchedulerEvent); - scheduler.on('skipped', broadcastSchedulerEvent); - scheduler.on('session-bound', broadcastSchedulerEvent); - scheduler.on('changed', broadcastSchedulerEvent); - scheduler.on('runtime-state', broadcastSchedulerEvent); + const broadcastGeneric = (event: SchedulerEvent): void => { + void broadcastGenericSchedulerEvent(event, storage).catch((error) => { + log.warn(`generic schedule event filtering failed: ${String(error)}`); + }); + }; + scheduler.on('fired', broadcastGeneric); + scheduler.on('completed', broadcastGeneric); + scheduler.on('failed', broadcastGeneric); + scheduler.on('silenced', broadcastGeneric); + scheduler.on('notified', broadcastGeneric); + scheduler.on('deferred', broadcastGeneric); + scheduler.on('skipped', broadcastGeneric); + scheduler.on('session-bound', broadcastGeneric); + scheduler.on('changed', broadcastGeneric); + scheduler.on('runtime-state', broadcastGeneric); // 必须在 .on 全挂完之后调:setSchedulerReady 会立即 resolve 在途 await, // 之后业务 IPC 跑起来可能触发 changed/fired,listener 漏挂会丢事件。 diff --git a/apps/desktop/src/main/mcp-integrations/__tests__/codexBuiltinToolPolicy.test.ts b/apps/desktop/src/main/mcp-integrations/__tests__/codexBuiltinToolPolicy.test.ts new file mode 100644 index 0000000000..5e0481ab3f --- /dev/null +++ b/apps/desktop/src/main/mcp-integrations/__tests__/codexBuiltinToolPolicy.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { + CODEX_ALLOWED_BUILTIN_PLUGIN_IDS_KEY, + CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY, + isFrozenBuiltinPluginAllowed, +} from '../codexBuiltinToolPolicy.js'; + +describe('frozen built-in tool policy', () => { + it('blocks stable collab and iOS gateways when the Bot Profile disables them', () => { + const vendorOptions = { + [CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY]: ['collab', 'ios-simulator'], + }; + expect(isFrozenBuiltinPluginAllowed(vendorOptions, 'collab')).toBe(false); + expect(isFrozenBuiltinPluginAllowed(vendorOptions, 'ios-simulator')).toBe(false); + expect(isFrozenBuiltinPluginAllowed(vendorOptions, 'memory')).toBe(true); + }); + + it('fails open only when no valid frozen policy exists', () => { + expect(isFrozenBuiltinPluginAllowed(undefined, 'collab')).toBe(true); + expect( + isFrozenBuiltinPluginAllowed( + { + [CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY]: ['collab', 1], + }, + 'collab', + ), + ).toBe(true); + }); + + it('uses a Bot allowlist to block Toolsets installed after the task was frozen', () => { + const vendorOptions = { + [CODEX_ALLOWED_BUILTIN_PLUGIN_IDS_KEY]: ['memory', 'collab'], + [CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY]: [], + }; + expect(isFrozenBuiltinPluginAllowed(vendorOptions, 'memory')).toBe(true); + expect(isFrozenBuiltinPluginAllowed(vendorOptions, 'browser')).toBe(false); + expect(isFrozenBuiltinPluginAllowed(vendorOptions, 'newly-installed')).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/mcp-integrations/__tests__/ghostWorkdirGate.test.ts b/apps/desktop/src/main/mcp-integrations/__tests__/ghostWorkdirGate.test.ts index 042c3251a3..3e227f2521 100644 --- a/apps/desktop/src/main/mcp-integrations/__tests__/ghostWorkdirGate.test.ts +++ b/apps/desktop/src/main/mcp-integrations/__tests__/ghostWorkdirGate.test.ts @@ -209,11 +209,12 @@ function makeDeps( agentKind: TestAgentKind = 'claude-code', sessionId: string | null = 's1', sessionInstanceId: string | null = sessionId ? `${sessionId}-instance` : null, + vendorOptions: Record = {}, ) { const ctx = { agentKind, workingDir: WORKDIR, - vendorOptions: {}, + vendorOptions, ...(sessionId ? { sessionId } : {}), ...(sessionInstanceId ? { sessionInstanceId } : {}), } as unknown as LiziMcpSessionContext; @@ -440,6 +441,26 @@ describe('花名册 / ghost_list 过滤', () => { expect((await deps.listAwakeGhosts()).map((g) => g.id)).toEqual(['art', 'other']); }); + it('Bot 冻结 Toolset 从花名册、info 与 manual 同时隐藏未授权插件', async () => { + const deps = makeDeps('claude-code', 'bot-session', 'bot-instance', { + __cindyDisabledBuiltinPluginIds: ['art'], + }); + + expect((deps.getRosterItems?.() ?? []).map((item) => item.id)).toEqual(['other']); + await expect(deps.listAwakeGhosts()).resolves.toMatchObject([{ id: 'other' }]); + await expect(deps.getAwakeGhost('art')).resolves.toMatchObject({ + ok: false, + errorCode: 'GHOST_DISABLED_IN_WORKDIR', + message: expect.stringContaining('Bot Profile'), + }); + await expect(deps.readGhostManual({ ghostId: 'art' })).resolves.toMatchObject({ + ok: false, + errorCode: 'GHOST_DISABLED_IN_WORKDIR', + manual: [], + content: '', + }); + }); + it('缺 workingDir 时 system 花名册 fail closed,不回退全量', () => { expect(getGhostRosterPrompt({})).toBe(''); expect(getGhostRosterPrompt({ workingDir: '' })).toBe(''); @@ -713,6 +734,22 @@ describe('ghost_call 兜底拒绝', () => { expect(dispatchMock).not.toHaveBeenCalled(); }); + it('Bot 冻结 Toolset 在 ghost_call 主机边界拒绝猜 ID 绕过', async () => { + const deps = makeDeps('claude-code', 'bot-session', 'bot-instance', { + __cindyDisabledBuiltinPluginIds: ['art'], + }); + + const result = await deps.callGhostTool({ ghostId: 'art', tool: 'run', args: {} }); + + expect(result).toMatchObject({ + ok: false, + errorCode: 'GHOST_DISABLED_IN_WORKDIR', + message: expect.stringContaining('Bot Profile'), + }); + expect(ensureReadyMock).not.toHaveBeenCalled(); + expect(dispatchMock).not.toHaveBeenCalled(); + }); + it('未禁用的意识照常派发;别的目录的禁用不误伤', async () => { setGhostDisabledForWorkdir('/proj/beta', 'art', true); const deps = makeDeps(); diff --git a/apps/desktop/src/main/mcp-integrations/__tests__/piEnvironment.test.ts b/apps/desktop/src/main/mcp-integrations/__tests__/piEnvironment.test.ts index 2b1cb17d0c..0ebeb540db 100644 --- a/apps/desktop/src/main/mcp-integrations/__tests__/piEnvironment.test.ts +++ b/apps/desktop/src/main/mcp-integrations/__tests__/piEnvironment.test.ts @@ -17,7 +17,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { getLiziMcpSessionContext } from '@cindy/mcps'; import { createOrcaWorkerBridgeMcpProvider } from '@cindy/orca-workflow'; +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/tmp/cindy-pi-environment-test') }, +})); + import type { Logger, McpProvider } from '@cindy/maker-core'; +import { CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY } from '../codexBuiltinToolPolicy.js'; import { getPiExtraSpawnConfig, invalidatePiEnvironment, @@ -39,7 +44,10 @@ function noopLogger(): Logger { return logger; } -function recordingLogger(): { logger: Logger; entries: Array<{ message: string; ctx?: Record }> } { +function recordingLogger(): { + logger: Logger; + entries: Array<{ message: string; ctx?: Record }>; +} { const entries: Array<{ message: string; ctx?: Record }> = []; const record = (message: string, ctx?: Record): void => { entries.push({ message, ...(ctx ? { ctx } : {}) }); @@ -62,20 +70,36 @@ function recordingLogger(): { logger: Logger; entries: Array<{ message: string; function createTestServer(name: string): McpServer { const server = new McpServer({ name, version: '1.0.0' }); server.tool('current_session', 'Return the active lizi MCP session id.', {}, async () => ({ - content: [{ type: 'text' as const, text: getLiziMcpSessionContext()?.sessionId ?? 'no-session' }], - })); - server.tool('current_instance', 'Return the active runtime session instance id.', {}, async () => ({ - content: [{ - type: 'text' as const, - text: getLiziMcpSessionContext()?.sessionInstanceId ?? 'no-instance', - }], - })); - server.tool('current_vendor_options', 'Return the active lizi MCP vendor options.', {}, async () => ({ - content: [{ - type: 'text' as const, - text: JSON.stringify(getLiziMcpSessionContext()?.vendorOptions ?? {}), - }], + content: [ + { type: 'text' as const, text: getLiziMcpSessionContext()?.sessionId ?? 'no-session' }, + ], })); + server.tool( + 'current_instance', + 'Return the active runtime session instance id.', + {}, + async () => ({ + content: [ + { + type: 'text' as const, + text: getLiziMcpSessionContext()?.sessionInstanceId ?? 'no-instance', + }, + ], + }), + ); + server.tool( + 'current_vendor_options', + 'Return the active lizi MCP vendor options.', + {}, + async () => ({ + content: [ + { + type: 'text' as const, + text: JSON.stringify(getLiziMcpSessionContext()?.vendorOptions ?? {}), + }, + ], + }), + ); return server; } @@ -183,6 +207,18 @@ describe('piEnvironment per-session identity', () => { await after.text(); }); + it('omits a stable built-in server when the frozen Bot Toolset disables it', async () => { + const config = await getPiExtraSpawnConfig([makeProvider()], noopLogger(), { + sessionId: 'pi-bot-no-collab', + workingDir: '/repo', + vendorOptions: { + [CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY]: ['collab'], + }, + }); + expect(config?.mcpBridge?.servers).toEqual([]); + config?.disposeSessionCtx?.(); + }); + it('keeps the registered Pi MCP vendorOptions live for start_team Lead activation', async () => { const vendorOptions: Record = { source: 'draft' }; const config = await getPiExtraSpawnConfig([makeProvider('custom_probe')], noopLogger(), { @@ -219,7 +255,7 @@ describe('piEnvironment per-session identity', () => { }), }); expect(callResp.status).toBe(200); - const result = await readRpcText(callResp) as { + const result = (await readRpcText(callResp)) as { result?: { content?: Array<{ text?: string }> }; }; expect(JSON.parse(result.result?.content?.[0]?.text ?? '{}')).toMatchObject({ @@ -348,7 +384,11 @@ describe('piEnvironment per-session identity', () => { accept: 'application/json, text/event-stream', 'content-type': 'application/json', }; - const oldInit = await fetch(oldServer.url, { method: 'POST', headers: oldHeaders, body: INIT_BODY(41) }); + const oldInit = await fetch(oldServer.url, { + method: 'POST', + headers: oldHeaders, + body: INIT_BODY(41), + }); expect(oldInit.status).toBe(200); await oldInit.text(); @@ -357,7 +397,11 @@ describe('piEnvironment per-session identity', () => { accept: 'application/json, text/event-stream', 'content-type': 'application/json', }; - const newInit = await fetch(newServer.url, { method: 'POST', headers: newHeaders, body: INIT_BODY(42) }); + const newInit = await fetch(newServer.url, { + method: 'POST', + headers: newHeaders, + body: INIT_BODY(42), + }); expect(newInit.status).toBe(200); await newInit.text(); @@ -467,7 +511,9 @@ describe('piEnvironment per-session identity', () => { { name: 'missing_bearer', toCodexMcpConfig: () => ({ - type: 'http', url: 'https://missing.example.test/mcp', bearerTokenEnvVar: 'MISSING', + type: 'http', + url: 'https://missing.example.test/mcp', + bearerTokenEnvVar: 'MISSING', }), getExtraEnv: () => ({ UNUSED: logCanary }), }, @@ -509,11 +555,15 @@ describe('piEnvironment per-session identity', () => { { name: 'throwing_environment', toCodexMcpConfig: () => ({ type: 'http', url: 'https://throw-env.example.test/mcp' }), - getExtraEnv: () => { throw new Error(logCanary); }, + getExtraEnv: () => { + throw new Error(logCanary); + }, }, { name: 'throwing_config', - toCodexMcpConfig: () => { throw new Error(logCanary); }, + toCodexMcpConfig: () => { + throw new Error(logCanary); + }, }, valid, validLoopback, @@ -607,7 +657,9 @@ describe('piEnvironment per-session identity', () => { }), }); expect(callResp.status).toBe(200); - const result = await readRpcText(callResp) as { result?: { isError?: boolean; content?: { text?: string }[] } }; + const result = (await readRpcText(callResp)) as { + result?: { isError?: boolean; content?: { text?: string }[] }; + }; expect(result.result?.isError).toBe(true); expect(result.result?.content?.[0]?.text).toContain('verified Cindy session'); }); @@ -632,9 +684,7 @@ describe('piEnvironment per-session identity', () => { }, }); - expect(config?.mcpBridge?.servers.map((server) => server.name)).toContain( - 'orca_worker_bridge', - ); + expect(config?.mcpBridge?.servers.map((server) => server.name)).toContain('orca_worker_bridge'); }); // ── 轮 40-w4 HIGH 回归保护:ensureBridge 成功路径的 30s 超时 timer 必须取消 ── @@ -660,7 +710,9 @@ describe('piEnvironment per-session identity', () => { // 断言仍复用同一 bridge:两次返回的 URL 端口一致 = 未重建新 HTTP server // (URL 的 ?session= 因 sessionId 不同而不同, 只比端口)。 const portOf = (u: string | undefined) => new URL(u ?? '').port; - expect(portOf(second?.mcpBridge?.servers[0]?.url)).toBe(portOf(first?.mcpBridge?.servers[0]?.url)); + expect(portOf(second?.mcpBridge?.servers[0]?.url)).toBe( + portOf(first?.mcpBridge?.servers[0]?.url), + ); } finally { vi.useRealTimers(); } diff --git a/apps/desktop/src/main/mcp-integrations/codexBuiltinToolPolicy.ts b/apps/desktop/src/main/mcp-integrations/codexBuiltinToolPolicy.ts index 83c66e9632..77a94e0c6a 100644 --- a/apps/desktop/src/main/mcp-integrations/codexBuiltinToolPolicy.ts +++ b/apps/desktop/src/main/mcp-integrations/codexBuiltinToolPolicy.ts @@ -3,14 +3,36 @@ * one Codex thread across maker-core and the desktop HTTP MCP bridge. */ export const CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY = '__cindyDisabledBuiltinPluginIds'; +export const CODEX_ALLOWED_BUILTIN_PLUGIN_IDS_KEY = '__cindyAllowedBuiltinPluginIds'; + +function readStringArray( + vendorOptions: Readonly> | undefined, + key: string, +): string[] | null { + const raw = vendorOptions?.[key]; + if (!Array.isArray(raw) || !raw.every((value) => typeof value === 'string')) return null; + return [...raw]; +} /** Read a valid frozen built-in policy without widening malformed input. */ export function readDisabledBuiltinPluginIds( vendorOptions: Readonly> | undefined, ): string[] | null { - const raw = vendorOptions?.[CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY]; - if (!Array.isArray(raw) || !raw.every((value) => typeof value === 'string')) { - return null; - } - return [...raw]; + return readStringArray(vendorOptions, CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY); +} + +export function readAllowedBuiltinPluginIds( + vendorOptions: Readonly> | undefined, +): string[] | null { + return readStringArray(vendorOptions, CODEX_ALLOWED_BUILTIN_PLUGIN_IDS_KEY); +} + +/** Frozen per-runtime policy always wins over live/stable provider exceptions. */ +export function isFrozenBuiltinPluginAllowed( + vendorOptions: Readonly> | undefined, + pluginId: string, +): boolean { + const allowed = readAllowedBuiltinPluginIds(vendorOptions); + if (allowed) return allowed.includes(pluginId); + return !readDisabledBuiltinPluginIds(vendorOptions)?.includes(pluginId); } diff --git a/apps/desktop/src/main/mcp-integrations/codexEnvironment.ts b/apps/desktop/src/main/mcp-integrations/codexEnvironment.ts index 2ef670366e..3a80c90f8f 100644 --- a/apps/desktop/src/main/mcp-integrations/codexEnvironment.ts +++ b/apps/desktop/src/main/mcp-integrations/codexEnvironment.ts @@ -10,7 +10,10 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { Logger, McpProvider, McpProviderContext } from '@cindy/maker-core'; import { getLiziMcpSessionContext, type LiziMcpSessionContext } from '@cindy/mcps'; import { pluginIdForKnownProviderName } from '../maker-host/plugins/builtin-plugins.js'; -import { CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY } from './codexBuiltinToolPolicy.js'; +import { + CODEX_ALLOWED_BUILTIN_PLUGIN_IDS_KEY, + CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY, +} from './codexBuiltinToolPolicy.js'; import { startCodexHttpBridge, @@ -57,7 +60,7 @@ let activeBridge: CodexHttpBridge | null = null; let activeBridgeServerNames: string[] | null = null; const disabledPluginPolicyByThread = new Map< string, - { sessionInstanceId?: string; policy: unknown } + { sessionInstanceId?: string; disabledPolicy: unknown; allowedPolicy: unknown } >(); const callerProvenanceByThread = new Map< string, @@ -157,14 +160,17 @@ export function registerCodexMcpThreadContext( ctx: LiziMcpSessionContext, ): void { const requestedPolicy = ctx.vendorOptions?.[CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY]; + const requestedAllowedPolicy = ctx.vendorOptions?.[CODEX_ALLOWED_BUILTIN_PLUGIN_IDS_KEY]; const frozen = disabledPluginPolicyByThread.get(threadId); if (!frozen || frozen.sessionInstanceId !== ctx.sessionInstanceId) { disabledPluginPolicyByThread.set(threadId, { ...(ctx.sessionInstanceId ? { sessionInstanceId: ctx.sessionInstanceId } : {}), - policy: requestedPolicy, + disabledPolicy: requestedPolicy, + allowedPolicy: requestedAllowedPolicy, }); } - const effectivePolicy = disabledPluginPolicyByThread.get(threadId)?.policy; + const effectivePolicy = disabledPluginPolicyByThread.get(threadId)?.disabledPolicy; + const effectiveAllowedPolicy = disabledPluginPolicyByThread.get(threadId)?.allowedPolicy; const previousProvenance = callerProvenanceByThread.get(threadId); const sameSessionInstance = ctx.sessionInstanceId !== undefined && @@ -202,6 +208,7 @@ export function registerCodexMcpThreadContext( vendorOptions: { ...ctx.vendorOptions, [CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY]: effectivePolicy, + [CODEX_ALLOWED_BUILTIN_PLUGIN_IDS_KEY]: effectiveAllowedPolicy, }, }); } @@ -248,6 +255,7 @@ async function doStart( return { agentKind: active.agentKind, workingDir: active.workingDir, + ...(active.memoryScopeKey ? { memoryScopeKey: active.memoryScopeKey } : {}), // SSH remote 会话的 ctx 字段必须透传 — cindy_memory 用它算 scope key // (buildMemoryScopeKey);丢掉的话远端工具会落到本地路径 key 的 store, // 与 agent prompt 注入读的 ssh:: store 分家 (review R4 P1)。 diff --git a/apps/desktop/src/main/mcp-integrations/codexHttpBridge.ts b/apps/desktop/src/main/mcp-integrations/codexHttpBridge.ts index ba585425a7..bf0660ef38 100644 --- a/apps/desktop/src/main/mcp-integrations/codexHttpBridge.ts +++ b/apps/desktop/src/main/mcp-integrations/codexHttpBridge.ts @@ -28,7 +28,7 @@ import { createCodexMcpThreadContextStore, isSameCodexMcpSessionContext, } from './codexMcpThreadContextStore.js'; -import { CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY } from './codexBuiltinToolPolicy.js'; +import { isFrozenBuiltinPluginAllowed } from './codexBuiltinToolPolicy.js'; const SERVER_HEADER = 'Lizi_MCPS/1.0'; const MCP_PATH_PREFIX = '/mcp/'; @@ -787,8 +787,7 @@ function findBlockedToolCall( toolCallContexts.push(context); } for (const context of toolCallContexts) { - const raw = context?.vendorOptions?.[CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY]; - if (Array.isArray(raw) && raw.some((id) => id === pluginId)) { + if (!isFrozenBuiltinPluginAllowed(context?.vendorOptions, pluginId)) { return { reason: 'disabled', context }; } } diff --git a/apps/desktop/src/main/mcp-integrations/ghost.ts b/apps/desktop/src/main/mcp-integrations/ghost.ts index d1bb7c5097..3c3fd033f2 100644 --- a/apps/desktop/src/main/mcp-integrations/ghost.ts +++ b/apps/desktop/src/main/mcp-integrations/ghost.ts @@ -86,6 +86,7 @@ import { resolveGhostAttachmentUrl } from './ghostAttachmentResolve.js'; import { ghostSetupInteractionSessionId } from './ghostSetupInteractionSurface.js'; import { createForgeIconConverter } from './forgeIconConversion.js'; import { forkForgeIconConversionHost } from './forgeIconConversionHost.js'; +import { isFrozenBuiltinPluginAllowed } from './codexBuiltinToolPolicy.js'; import { t } from '../i18n.js'; import { createLogger } from '../logger.js'; @@ -1094,7 +1095,10 @@ export function collectCindyMediaUrls( } } -function visibleChipGhosts(workdir: string | null): InstalledGhost[] { +function visibleChipGhosts( + workdir: string | null, + vendorOptions?: Readonly>, +): InstalledGhost[] { return getGhostManager() .list() .filter( @@ -1103,6 +1107,7 @@ function visibleChipGhosts(workdir: string | null): InstalledGhost[] { isGhostAvailableForActiveSession(ghost.manifest.id) && ghost.manifest.kind === 'chip' && ghostHasTools(ghost) && + isFrozenBuiltinPluginAllowed(vendorOptions, ghost.manifest.id) && !isGhostDisabledForWorkdir(ghost.manifest.id, workdir), ); } @@ -1182,6 +1187,13 @@ export function getCindyGhostsMcpDeps( ): CindyGhostsMcpDeps { const resolveSessionContext = (): LiziMcpSessionContext | undefined => getLiziMcpSessionContext() ?? sessionCtx; + const isGhostAllowedByFrozenProfile = (ghostId: string): boolean => + isFrozenBuiltinPluginAllowed(resolveSessionContext()?.vendorOptions, ghostId); + const frozenProfileDenied = () => ({ + ok: false as const, + errorCode: 'GHOST_DISABLED_IN_WORKDIR' as const, + message: '当前 Bot Profile 未启用该插件;不要重试,改用已授权能力或让用户更新 Bot 配置并 Renew。', + }); return { // 花名册快照(server 装配时取一次):唤醒的芯片意识 + 召回线索,进 // ghost_list 工具描述做语义召回。system 段由 getGhostRosterPrompt 在每个 @@ -1197,9 +1209,10 @@ export function getCindyGhostsMcpDeps( // 宁缺勿全,不注入工具描述;Codex 正常 startSession 的 developerInstructions // 会在拿到真实 workdir 后单独装配 system 段。 getRosterItems() { - const workdir = resolveSessionContext()?.workingDir; + const context = resolveSessionContext(); + const workdir = context?.workingDir; if (!workdir) return []; - return visibleChipGhosts(workdir) + return visibleChipGhosts(workdir, context?.vendorOptions) .map((g) => { const recall = ghostRecall(g); return { @@ -1213,15 +1226,20 @@ export function getCindyGhostsMcpDeps( async listAwakeGhosts(): Promise { // 现查同样按会话 workdir 滤掉目录级禁用的意识(ALS 恢复的真实语境 // 优先)——模型主动 ghost_list 也看不到被禁用的条目,清单层面干净。 - const workdir = resolveSessionContext()?.workingDir ?? null; - return visibleChipGhosts(workdir) + const context = resolveSessionContext(); + const workdir = context?.workingDir ?? null; + return visibleChipGhosts(workdir, context?.vendorOptions) .map(toCindyGhostInfo); }, async getAwakeGhost(ghostId) { + if (!isGhostAllowedByFrozenProfile(ghostId)) return frozenProfileDenied(); const workdir = resolveSessionContext()?.workingDir ?? null; const visibility = classifyGhostVisibility(ghostId, workdir, ghostVisibilityDeps); if (!visibility.ok) return visibility; - const visible = visibleChipGhosts(workdir).find( + const visible = visibleChipGhosts( + workdir, + resolveSessionContext()?.vendorOptions, + ).find( (ghost) => ghost.manifest.id === ghostId, ); if (visible) { @@ -1234,6 +1252,9 @@ export function getCindyGhostsMcpDeps( }; }, async readGhostManual({ ghostId, path: manualPath }) { + if (!isGhostAllowedByFrozenProfile(ghostId)) { + return { ...frozenProfileDenied(), manual: [], content: '' }; + } const workdir = resolveSessionContext()?.workingDir ?? null; const visibility = classifyGhostVisibility(ghostId, workdir, ghostVisibilityDeps); if (!visibility.ok) { @@ -1271,6 +1292,7 @@ export function getCindyGhostsMcpDeps( const sessionIdForConfirm = sessionContext?.sessionId ?? null; const sessionInstanceIdForGrant = sessionContext?.sessionInstanceId ?? null; const sessionWorkdir = sessionContext?.workingDir ?? null; + if (!isGhostAllowedByFrozenProfile(ghostId)) return frozenProfileDenied(); const initialVisibility = classifyGhostVisibility( ghostId, sessionWorkdir, @@ -1519,6 +1541,7 @@ export function getCindyGhostsMcpDeps( // Pre-dispatch revalidation: attachment grants and dir tickets may have // taken time; confirm the target is still available before committing the // callId and dispatching to the sandbox. + if (!isGhostAllowedByFrozenProfile(ghostId)) return frozenProfileDenied(); const preDispatchVisibility = classifyGhostVisibility( ghostId, sessionWorkdir, @@ -1563,6 +1586,7 @@ export function getCindyGhostsMcpDeps( } } // Full revalidation after session-context await (DB query may take time) + if (!isGhostAllowedByFrozenProfile(ghostId)) return frozenProfileDenied(); const postCtxVisibility = classifyGhostVisibility( ghostId, sessionWorkdir, diff --git a/apps/desktop/src/main/mcp-integrations/mcp-providers.ts b/apps/desktop/src/main/mcp-integrations/mcp-providers.ts index 12461d6ece..53f879c7e9 100644 --- a/apps/desktop/src/main/mcp-integrations/mcp-providers.ts +++ b/apps/desktop/src/main/mcp-integrations/mcp-providers.ts @@ -28,7 +28,7 @@ import { getScheduler } from '../scheduler-host/index.js'; import { stabilizeHookCommand } from '../scheduler-host/hook-script-generator.js'; import { searchSessionsFn } from '../maker-host/session-search.js'; import { readLspModeSettings } from '../maker-host/lsp-mode-store.js'; -import { tryGetOrcaCollabService } from '../maker-ipc/register.js'; +import { tryGetBotDelegationService, tryGetOrcaCollabService } from '../maker-ipc/register.js'; import { submitGithubIssueForSession } from '../github-issue/index.js'; import { listWorkdirsForHistory, @@ -37,6 +37,13 @@ import { } from '../localDb/chatHistoryReader.js'; import { tryGetDbClient } from '../localDb/client/current.js'; import { searchChatHistoryHybrid } from '../localDb/chatHistorySearch.js'; +import { resolveBotHistorySessionIds } from '../localDb/botHistoryScope.js'; +import { + deleteBotDurableNote, + getBotDurableNote, + listBotDurableNotes, + setBotDurableNote, +} from '../maker-ipc/botDurableNoteService.js'; import { patchSessionMetaInDb, renameSessionTitlesInDb, @@ -48,12 +55,13 @@ import { getDesktopContactsManager } from '../maker-host/maker-contacts-host.js' import { broadcastContactsChanged } from '../maker-host/contacts-change-broadcast.js'; import { readContactsSettings } from '../maker-host/contacts-settings-store.js'; import { readSystemContacts, writeSystemContacts } from '../maker-host/system-contacts.js'; -import { BUILTIN_LIZI_MCP_IDS, pluginIdForProviderName } from '../maker-host/plugins/builtin-plugins.js'; -import { GLOBAL_PLUGIN_IDS } from '../maker-host/plugins/types.js'; import { - readChatHistoryMessages, - type ChatHistoryReaderDeps, -} from './remoteChatHistory.js'; + BUILTIN_LIZI_MCP_IDS, + pluginIdForProviderName, +} from '../maker-host/plugins/builtin-plugins.js'; +import { isFrozenBuiltinPluginAllowed } from './codexBuiltinToolPolicy.js'; +import { GLOBAL_PLUGIN_IDS } from '../maker-host/plugins/types.js'; +import { readChatHistoryMessages, type ChatHistoryReaderDeps } from './remoteChatHistory.js'; export interface DesktopMcpProvidersDeps { getMakerMemoryManager: () => MakerMemoryManager; @@ -61,9 +69,7 @@ export interface DesktopMcpProvidersDeps { /** 按会话控制启用状态的 plugin registry。 */ pluginRegistry: PluginRegistry; /** Live installed/enabled plugin gate; evaluated again for every tool call. */ - resolveIOSSimulatorAccess: ( - context?: { workingDir?: string }, - ) => IOSSimulatorMcpAccessDecision; + resolveIOSSimulatorAccess: (context?: { workingDir?: string }) => IOSSimulatorMcpAccessDecision; /** Device-link transport stays host-injected so provider tests do not load Electron runtime services. */ invokeRemote: ChatHistoryReaderDeps['invokeRemote']; /** 插件文件交接只认活跃 Session 的实时权限;缺失时由 ghost.ts fail closed。 */ @@ -89,12 +95,26 @@ export function createDesktopMcpProviders(deps: DesktopMcpProvidersDeps): LiziMc // 辅助桥接 OrcaCollabService → OrcaMcpDeps / sendToSession, // 并在边界捕获 HOST_NOT_READY / INTERNAL。 - function wrap(fn: (svc: NonNullable>, ...args: Args) => Promise): (...args: Args) => Promise { + function wrap( + fn: (svc: NonNullable>, ...args: Args) => Promise, + ): (...args: Args) => Promise { return async (...args) => { const s = tryGetOrcaCollabService(); - if (!s) return { ok: false, errorCode: 'HOST_NOT_READY' as const, message: 'orca collab service not initialized' } as R; - try { return await fn(s, ...args); } - catch (err) { return { ok: false, errorCode: 'INTERNAL' as const, message: err instanceof Error ? err.message : String(err) } as R; } + if (!s) + return { + ok: false, + errorCode: 'HOST_NOT_READY' as const, + message: 'orca collab service not initialized', + } as R; + try { + return await fn(s, ...args); + } catch (err) { + return { + ok: false, + errorCode: 'INTERNAL' as const, + message: err instanceof Error ? err.message : String(err), + } as R; + } }; } @@ -157,7 +177,9 @@ export function createDesktopMcpProviders(deps: DesktopMcpProvidersDeps): LiziMc const { messageId } = await feishuIm.sendMarkdownText(chatId, markdown); return { ok: true, messageId }; } catch (err) { - const r = (err as { response?: { data?: { code?: number; msg?: string }; status?: number } }).response; + const r = ( + err as { response?: { data?: { code?: number; msg?: string }; status?: number } } + ).response; const detail = r ? `status=${r.status ?? 'n/a'} code=${r.data?.code ?? '?'} msg=${r.data?.msg ?? '?'}` : err instanceof Error @@ -174,8 +196,7 @@ export function createDesktopMcpProviders(deps: DesktopMcpProvidersDeps): LiziMc logger: createLogger('mcp/cindy_feishu_bot'), }, wechatBot: { - getActivePeerIdForSession: (sessionId) => - wechatIm.getActivePeerIdForSession(sessionId), + getActivePeerIdForSession: (sessionId) => wechatIm.getActivePeerIdForSession(sessionId), getMostRecentPeerId: () => wechatIm.getMostRecentPeerId(), sendMessage: async (peerId, text) => { try { @@ -367,14 +388,18 @@ export function createDesktopMcpProviders(deps: DesktopMcpProvidersDeps): LiziMc }) => { const svc = tryGetOrcaCollabService(); if (!svc) { - return { ok: false, errorCode: 'HOST_NOT_READY', message: 'orca collab service not initialized' }; + return { + ok: false, + errorCode: 'HOST_NOT_READY', + message: 'orca collab service not initialized', + }; } try { const hasExecutionOverrides = - agentKind !== undefined - || model !== undefined - || effort !== undefined - || fast !== undefined; + agentKind !== undefined || + model !== undefined || + effort !== undefined || + fast !== undefined; return await svc.sendToSession({ targetSessionId, message, @@ -394,17 +419,106 @@ export function createDesktopMcpProviders(deps: DesktopMcpProvidersDeps): LiziMc : {}), }); } catch (err) { - return { ok: false, errorCode: 'INTERNAL', message: err instanceof Error ? err.message : String(err) }; + return { + ok: false, + errorCode: 'INTERNAL', + message: err instanceof Error ? err.message : String(err), + }; } }, + botDelegation: { + listBots: async ({ callerSessionId }) => { + const svc = tryGetBotDelegationService(); + if (!svc) { + return { + ok: false, + errorCode: 'HOST_NOT_READY', + message: 'Bot delegation service not initialized', + }; + } + return svc.listBots(callerSessionId); + }, + delegateToBot: async (params) => { + const svc = tryGetBotDelegationService(); + if (!svc) { + return { + ok: false, + errorCode: 'HOST_NOT_READY', + message: 'Bot delegation service not initialized', + }; + } + try { + return await svc.delegateToBot(params); + } catch (err) { + return { + ok: false, + errorCode: 'INTERNAL', + message: err instanceof Error ? err.message : String(err), + }; + } + }, + listDelegations: async ({ callerSessionId, status }) => { + const svc = tryGetBotDelegationService(); + if (!svc) { + return { + ok: false, + errorCode: 'HOST_NOT_READY', + message: 'Bot delegation service not initialized', + }; + } + return svc.listDelegations(callerSessionId, status); + }, + cancelDelegation: async ({ callerSessionId, delegationId }) => { + const svc = tryGetBotDelegationService(); + if (!svc) { + return { + ok: false, + errorCode: 'HOST_NOT_READY', + message: 'Bot delegation service not initialized', + }; + } + return svc.cancelDelegation(callerSessionId, delegationId); + }, + }, + botDurableNotes: { + list: listBotDurableNotes, + get: getBotDurableNote, + set: setBotDurableNote, + delete: deleteBotDurableNote, + }, history: { + resolveSessionScope: async ({ callerSessionId, callerMemoryScopeKey }) => { + try { + const sessionIds = await resolveBotHistorySessionIds( + callerSessionId, + callerMemoryScopeKey, + ); + return { ok: true, sessionIds }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const errorCode = /localDb not ready/i.test(message) ? 'HOST_NOT_READY' : 'INTERNAL'; + return { ok: false, errorCode, message }; + } + }, listWorkdirs: async (args) => { - try { const page = await listWorkdirsForHistory(args); return { ok: true, page }; } - catch (err) { const msg = err instanceof Error ? err.message : String(err); const errorCode = /localDb not ready/i.test(msg) ? 'HOST_NOT_READY' : 'INTERNAL'; return { ok: false, errorCode, message: msg }; } + try { + const page = await listWorkdirsForHistory(args); + return { ok: true, page }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const errorCode = /localDb not ready/i.test(msg) ? 'HOST_NOT_READY' : 'INTERNAL'; + return { ok: false, errorCode, message: msg }; + } }, listSessions: async (args) => { - try { const page = await listSessionsForHistory(args); return { ok: true, page }; } - catch (err) { const msg = err instanceof Error ? err.message : String(err); const errorCode = /localDb not ready/i.test(msg) ? 'HOST_NOT_READY' : 'INTERNAL'; return { ok: false, errorCode, message: msg }; } + try { + const page = await listSessionsForHistory(args); + return { ok: true, page }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const errorCode = /localDb not ready/i.test(msg) ? 'HOST_NOT_READY' : 'INTERNAL'; + return { ok: false, errorCode, message: msg }; + } }, getMessages: async (args) => { return readChatHistoryMessages(args, { @@ -413,8 +527,14 @@ export function createDesktopMcpProviders(deps: DesktopMcpProvidersDeps): LiziMc }); }, searchChatHistory: async (args) => { - try { const result = await searchChatHistoryHybrid(args); return { ok: true, result }; } - catch (err) { const msg = err instanceof Error ? err.message : String(err); const errorCode = /localDb not ready/i.test(msg) ? 'HOST_NOT_READY' : 'INTERNAL'; return { ok: false, errorCode, message: msg }; } + try { + const result = await searchChatHistoryHybrid(args); + return { ok: true, result }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const errorCode = /localDb not ready/i.test(msg) ? 'HOST_NOT_READY' : 'INTERNAL'; + return { ok: false, errorCode, message: msg }; + } }, }, // submit_github_issue: 官方反馈提交(确认卡片 → serverApiFetch)。 @@ -458,9 +578,9 @@ export function createDesktopMcpProviders(deps: DesktopMcpProvidersDeps): LiziMc // 全局启用但项目停用的工具仍暴露、反向配置则永久缺席,codex review)。机器级工具 // 仍沿用现有 spawn-time gate + 环境重建语义。 const deferOrdinaryGate = - (ctx.agentKind === 'codex' || ctx.agentKind === 'pi') - && !ctx.workingDir - && !GLOBAL_PLUGIN_IDS.has(pluginId); + (ctx.agentKind === 'codex' || ctx.agentKind === 'pi') && + !ctx.workingDir && + !GLOBAL_PLUGIN_IDS.has(pluginId); // Orca 工具面必须在会话生命周期内保持稳定:Claude query 不会在项目策略 // 动态启用后重建 MCP。创建入口仍由 Main 按调用时的项目策略 fail closed。 const keepOrcaProviderStable = pluginId === 'collab'; @@ -468,6 +588,12 @@ export function createDesktopMcpProviders(deps: DesktopMcpProvidersDeps): LiziMc // is installed. Its live call gate returns an actionable install/enable // result, while every runtime mutation remains blocked in Main. const keepIOSSimulatorGatewayStable = pluginId === 'ios-simulator'; + // Stable providers may ignore a live global/project toggle so an + // already-running ordinary task can recover, but a Bot Profile is an + // immutable per-runtime capability boundary and must always win. + if (!isFrozenBuiltinPluginAllowed(ctx.vendorOptions, pluginId)) { + return false; + } // Plugin gate:registry 负责 essential / machine / project / user / default 判定。 if ( !keepOrcaProviderStable && diff --git a/apps/desktop/src/main/mcp-integrations/piEnvironment.ts b/apps/desktop/src/main/mcp-integrations/piEnvironment.ts index 64efcfe4b4..34d0528d56 100644 --- a/apps/desktop/src/main/mcp-integrations/piEnvironment.ts +++ b/apps/desktop/src/main/mcp-integrations/piEnvironment.ts @@ -46,7 +46,10 @@ import { type CodexHttpBridge, withMcpRouteIdentity, } from './codexHttpBridge.js'; -import { CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY } from './codexBuiltinToolPolicy.js'; +import { + CODEX_DISABLED_BUILTIN_PLUGIN_IDS_KEY, + readDisabledBuiltinPluginIds, +} from './codexBuiltinToolPolicy.js'; import { pluginIdForKnownProviderName } from '../maker-host/plugins/builtin-plugins.js'; // 直接取 plugins 模块的 registry 单例,不经 maker-host/index.ts —— 后者 import pi-host, // 从 mcp-integrations 反向 import 会成环。 @@ -96,10 +99,12 @@ function cloneRemoteServers( function isLoopbackMcpHostname(hostname: string): boolean { const normalized = hostname.toLowerCase(); - return normalized === 'localhost' - || normalized === '127.0.0.1' - || normalized === '::1' - || normalized === '[::1]'; + return ( + normalized === 'localhost' || + normalized === '127.0.0.1' || + normalized === '::1' || + normalized === '[::1]' + ); } function isAllowedRemoteMcpUrl(url: URL): boolean { @@ -123,10 +128,10 @@ function shutdownGeneration(started: StartedPiBridge): Promise { // 否则 promise 已 settled(reject), 后续 shutdownGeneration 复用同一 // promise 永远不重试, 该 generation 残留到进程结束。 started.shutdownPromise = null; - console.error( - '[pi-env] bridge shutdown failed — generation retained for retry/diagnosis', - { generation: started.generation, error: err instanceof Error ? err.message : String(err) }, - ); + console.error('[pi-env] bridge shutdown failed — generation retained for retry/diagnosis', { + generation: started.generation, + error: err instanceof Error ? err.message : String(err), + }); }); } return started.shutdownPromise; @@ -168,15 +173,22 @@ export async function getPiExtraSpawnConfig( releaseGeneration(started); }; + const disabledPluginIds = sessionId + ? (readDisabledBuiltinPluginIds(sessionCtx?.vendorOptions) ?? + createPluginRegistry().getDisabledRuntimePluginIds(sessionCtx?.workingDir ?? '')) + : []; // collab 全局禁用时与 CC/Codex 同闸门:剥掉 orca 类协同 server(cindy_orca / // orca_worker_bridge),避免禁用后 pi 仍能建队/发消息(R5 配置审计 H-7)。 // 只按名字剥协同 server —— cindy_memory / ghost / 外部 HTTP MCP 与 collab // 无关,照常注入(CC 的 selectRemoteInjectableServerNames 同语义)。 - const collabEnabled = createPluginRegistry().isEnabled('collab'); - const collabGated = (servers: NonNullable['servers']) => - collabEnabled - ? servers - : servers.filter((server) => !REMOTE_COLLAB_SERVER_NAMES.has(server.name)); + const collabEnabled = + createPluginRegistry().isEnabled('collab') && !disabledPluginIds.includes('collab'); + const capabilityGated = (servers: NonNullable['servers']) => + servers.filter((server) => { + if (!collabEnabled && REMOTE_COLLAB_SERVER_NAMES.has(server.name)) return false; + const pluginId = pluginIdForKnownProviderName(server.name); + return !pluginId || !disabledPluginIds.includes(pluginId); + }); // 匿名会话:不注册身份、URL 不带 query。工具 handler 拿不到 ctx 时回落业务 // 错误码(如 LEAD_NOT_SUPPORTED)—— 与改动前一致,不打 401。 @@ -184,7 +196,7 @@ export async function getPiExtraSpawnConfig( return { mcpBridge: { token: bridge?.token ?? '', - servers: collabGated([ + servers: capabilityGated([ ...(bridge ? serverNames.map((name) => ({ name, url: bridge.url(name) })) : []), ...cloneRemoteServers(remoteServers), ]), @@ -198,7 +210,7 @@ export async function getPiExtraSpawnConfig( // 让配置变更后的旧活动会话继续使用其启动时快照。 if (!bridge) { return { - mcpBridge: { token: '', servers: collabGated(cloneRemoteServers(remoteServers)) }, + mcpBridge: { token: '', servers: capabilityGated(cloneRemoteServers(remoteServers)) }, mcpEnv: { ...mcpEnv }, disposeSessionCtx: disposeLease, }; @@ -208,9 +220,6 @@ export async function getPiExtraSpawnConfig( // 项目级普通工具策略在此按会话 workdir 冻结进 vendorOptions(与 Codex 的 // registerCodexMcpThreadContext 同键同语义):bridge 的 per-call gate 据此阻断本项目 // 停用的内置工具,后续 Settings 变更不影响已在跑的会话(codex review)。 - const disabledPluginIds = createPluginRegistry().getDisabledRuntimePluginIds( - sessionCtx?.workingDir ?? '', - ); // PiAgent 传入的是该 session 专属的可变副本。这里必须保留同一引用:start_team // 成功后 MakerSession.setVendorOptions 会原地写入 Lead 身份,既有 HTTP MCP handler // 要在下一次 create_worker 调用时立即看到。复制对象会把 bridge 永久冻结在启动态。 @@ -219,9 +228,7 @@ export async function getPiExtraSpawnConfig( const liziCtx: LiziMcpSessionContext = { agentKind: 'pi', sessionId, - ...(sessionCtx?.sessionInstanceId - ? { sessionInstanceId: sessionCtx.sessionInstanceId } - : {}), + ...(sessionCtx?.sessionInstanceId ? { sessionInstanceId: sessionCtx.sessionInstanceId } : {}), workingDir: sessionCtx?.workingDir ?? '', vendorOptions, mcpCallerKind: sessionCtx?.mcpCallerKind ?? 'unknown', @@ -243,9 +250,7 @@ export async function getPiExtraSpawnConfig( // 轮 41 CRITICAL:确定性派生(进程级 key + sessionId HMAC, 64 hex 与旧形状一致) // —— 同 session 断链重连/恢复复用同一 token, envHash 稳定, daemon 纯 attach // 保活生效;桌面重启 → key 变 → 新 token → pi 重建, 两两一致。 - const sessionToken = createHmac('sha256', PI_BRIDGE_SESSION_KEY) - .update(sessionId) - .digest('hex'); + const sessionToken = createHmac('sha256', PI_BRIDGE_SESSION_KEY).update(sessionId).digest('hex'); let tokenGeneration: number | undefined; try { tokenGeneration = bridge.registerSessionToken(sessionId, sessionToken); @@ -255,7 +260,7 @@ export async function getPiExtraSpawnConfig( throw error; } try { - const servers = collabGated([ + const servers = capabilityGated([ ...serverNames.map((name) => ({ name, url: withMcpRouteIdentity(bridge.url(name), { @@ -317,10 +322,12 @@ export async function shutdownPiEnvironment(): Promise { if (current) current.retired = true; await pending?.catch(() => null); // 退出/换账号是硬边界:Maker 会话也在关闭,强制收掉所有代际,不等 lease。 - await Promise.all([...generations].map((generation) => { - generation.retired = true; - return shutdownGeneration(generation); - })); + await Promise.all( + [...generations].map((generation) => { + generation.retired = true; + return shutdownGeneration(generation); + }), + ); environmentShuttingDown = false; } @@ -337,7 +344,10 @@ export function resetPiEnvironmentForTest(): void { } /** bridge 单例懒启动(首个会话触发,失败下次重试)。 */ -async function ensureBridge(providers: McpProvider[], logger: MakerLogger): Promise { +async function ensureBridge( + providers: McpProvider[], + logger: MakerLogger, +): Promise { // 轮 40-w4-t10 MEDIUM:关停期间 fail-closed —— 不重启 bridge。 if (environmentShuttingDown) return null; for (;;) { @@ -389,7 +399,10 @@ async function ensureBridge(providers: McpProvider[], logger: MakerLogger): Prom timer.unref?.(); // pending settle(成功/失败)即取消超时 —— 成功路径 startPromise 保留 // 该已完成的 pending(缓存命中), 超时不再触发。 - void pending.then(() => clearTimeout(timer), () => clearTimeout(timer)); + void pending.then( + () => clearTimeout(timer), + () => clearTimeout(timer), + ); }), pending, ]); @@ -422,13 +435,13 @@ async function doStart( return { agentKind: active.agentKind, workingDir: active.workingDir, + ...(active.memoryScopeKey ? { memoryScopeKey: active.memoryScopeKey } : {}), + ...(active.remoteHostId ? { remoteHostId: active.remoteHostId } : {}), vendorOptions: active.vendorOptions, sessionId: active.sessionId, mcpCallerKind: active.mcpCallerKind, mcpCallerAttested: active.mcpCallerAttested, - ...(active.sessionInstanceId - ? { sessionInstanceId: active.sessionInstanceId } - : {}), + ...(active.sessionInstanceId ? { sessionInstanceId: active.sessionInstanceId } : {}), getSessionContext: ctx.getSessionContext, }; }, @@ -459,9 +472,12 @@ async function doStart( try { providerEnv = await provider.getExtraEnv?.(ctx); } catch { - logger.warn('pi bridge: skipping remote HTTP MCP provider whose environment could not be built', { - providerName: provider.name, - }); + logger.warn( + 'pi bridge: skipping remote HTTP MCP provider whose environment could not be built', + { + providerName: provider.name, + }, + ); continue; } @@ -480,9 +496,12 @@ async function doStart( // mergeLoopbackNoProxy 完全一致,避免 127/8 中其它地址经 HTTP_PROXY 泄密;同时 // 拒绝 URL 内嵌凭证和非 web 协议。 if (!isAllowedRemoteMcpUrl(parsedUrl)) { - logger.warn('pi bridge: skipping remote HTTP MCP provider outside the URL security boundary', { - providerName: provider.name, - }); + logger.warn( + 'pi bridge: skipping remote HTTP MCP provider outside the URL security boundary', + { + providerName: provider.name, + }, + ); continue; } diff --git a/apps/desktop/src/main/messagePersistBroadcaster.ts b/apps/desktop/src/main/messagePersistBroadcaster.ts index fe374c2ae1..64467adbdf 100644 --- a/apps/desktop/src/main/messagePersistBroadcaster.ts +++ b/apps/desktop/src/main/messagePersistBroadcaster.ts @@ -40,6 +40,13 @@ import { updateMessageContent as updateDbMessageContent, } from './localDb/ipc/messages.js'; import { getDbClient } from './localDb/client/current.js'; +import { + markCodexPlanInterrupted, + parseCodexPlanTerminal, + parseCodexPlanUpdate, + writeCodexPlanTerminal, + writeCodexPlanUpdate, +} from './localDb/codexPlanState.js'; import { isTopLevelTitleAssistant } from './localDb/latestMessageText.logic.js'; import { messages as messagesTable } from './localDb/schema.js'; import { createLogger } from './logger.js'; @@ -154,6 +161,7 @@ const lastAgentMetaBySession = new Map(); * 若 turnStartedAt <= clearBoundary,该 error 行必须 cap 在 clear 边界之下,防止出现在清空后的新会话。 * resetTurnPersistState / clearSessionPersistState 时清除。 */ const _turnStartedAtBySession = new Map(); +const _turnAttemptTokenBySession = new Map(); const _turnDedupIdBySession = new Map(); let _turnDedupSeq = 0; @@ -167,10 +175,13 @@ const _savedTurnDedupIdForDeferred = new Map(); * 只在首次调用(Map 中无条目)时写入,忽略后续 isRunning:true 的覆盖, * 防止 Claude 工具进度 / Codex stage 等 mid-turn 进度事件在 /clear 之后 * 用 post-clear 时间戳覆盖原始 pre-clear 起点,导致 /clear 竞态 cap 失效。 */ -export function noteTurnStarted(sessionId: string): void { +export function noteTurnStarted(sessionId: string, turnAttemptToken?: number): void { if (!_turnStartedAtBySession.has(sessionId)) { const now = Date.now(); _turnStartedAtBySession.set(sessionId, now); + if (typeof turnAttemptToken === 'number') { + _turnAttemptTokenBySession.set(sessionId, turnAttemptToken); + } _turnDedupIdBySession.set(sessionId, `${now}:${++_turnDedupSeq}`); // 新 turn 第一次记录时,旧的 deferred 保存值已无效,清掉防止 deferred IPC 用到旧 turn 的时刻。 _savedTurnStartedAtForDeferred.delete(sessionId); @@ -708,11 +719,33 @@ export function onToolUseEvent( agentMeta: AgentMeta | null, scope: 'turn' | 'background' = 'turn', backgroundTurnStartedAt?: number, + turnAttemptToken?: number, ): string | undefined { const createdAt = Date.now(); const toolUseId = typeof data.toolUseId === 'string' ? data.toolUseId : ''; const toolName = typeof data.toolName === 'string' ? data.toolName : ''; + if (scope === 'turn' && getSessionDbAgentKind(sessionId) === 'codex') { + const planUpdate = parseCodexPlanUpdate(data); + const currentTurnAttemptToken = _turnAttemptTokenBySession.get(sessionId); + const turnStartedAt = + currentTurnAttemptToken !== undefined && currentTurnAttemptToken !== turnAttemptToken + ? undefined + : _turnStartedAtBySession.get(sessionId); + if (planUpdate && !backgroundTurnPredatesSessionClear(sessionId, turnStartedAt)) { + const clearBoundaryAtEnqueue = clearBoundaryBySession.get(sessionId); + enqueueWrite(`codex_plan_state_update:${sessionId}:${planUpdate.turnId}`, () => { + if ( + clearBoundaryBySession.get(sessionId) !== clearBoundaryAtEnqueue || + backgroundTurnPredatesSessionClear(sessionId, turnStartedAt) + ) { + return Promise.resolve(); + } + return writeCodexPlanUpdate(sessionId, planUpdate); + }); + } + } + if (scope === 'background') { if (backgroundTurnPredatesSessionClear(sessionId, backgroundTurnStartedAt)) { return undefined; @@ -804,6 +837,12 @@ export function persistCodexPlanOnDone( | null | undefined, ): boolean { + const planTerminal = parseCodexPlanTerminal(data); + if (planTerminal) { + enqueueWrite(`codex_plan_state_terminal:${sessionId}:${planTerminal.turnId}`, () => + writeCodexPlanTerminal(sessionId, planTerminal), + ); + } const turnId = typeof data?.raw?.id === 'string' ? data.raw.id : null; if (!turnId) return false; @@ -883,6 +922,12 @@ export function persistCodexPlanOnTerminalError(sessionId: string, turnId?: stri ? planRow.input as Record : null; if (!input || !Array.isArray(input.plan)) continue; + const ownedTurnId = toolUseId.startsWith('plan:') ? toolUseId.slice('plan:'.length) : ''; + if (ownedTurnId) { + enqueueWrite(`codex_plan_state_error:${sessionId}:${ownedTurnId}`, () => + markCodexPlanInterrupted(sessionId, ownedTurnId), + ); + } enqueueWrite(`codex_plan_terminal_error:${sessionId}:${persistId}`, async (ownerScope) => { const updated = await updateDbMessageContent(sessionId, persistId, { toolUseId, @@ -1369,6 +1414,7 @@ export function resetTurnPersistState(sessionId: string): void { // clearCodexPlanRowsForSession(逻辑 turn 结束 / 会话清理)负责回收。 lastAgentMetaBySession.delete(sessionId); _turnStartedAtBySession.delete(sessionId); + _turnAttemptTokenBySession.delete(sessionId); _turnDedupIdBySession.delete(sessionId); // turn 边界必须清 lastPersistedMsgBySession:within-turn 的重复 isFinal / result 兜底 // 补推都在 done 之前已去重(translator fallback isFinal@translator.ts:897 早于 done@922, @@ -1687,6 +1733,7 @@ export function clearSessionPersistState(sessionId: string): void { lastAssistantTranscriptUuidBySession.delete(sessionId); dbAgentKindBySession.delete(sessionId); _turnStartedAtBySession.delete(sessionId); + _turnAttemptTokenBySession.delete(sessionId); _turnDedupIdBySession.delete(sessionId); _savedTurnStartedAtForDeferred.delete(sessionId); _savedTurnDedupIdForDeferred.delete(sessionId); diff --git a/apps/desktop/src/main/right-sidebar-window/ipc.ts b/apps/desktop/src/main/right-sidebar-window/ipc.ts index 1cfe651453..f865f468fb 100644 --- a/apps/desktop/src/main/right-sidebar-window/ipc.ts +++ b/apps/desktop/src/main/right-sidebar-window/ipc.ts @@ -162,6 +162,33 @@ function parseCommand(raw: unknown): RsbWindowCommand { ...(typeof r.revealSidebar === 'boolean' ? { revealSidebar: r.revealSidebar } : {}), }; } + if (r.type === 'open-bot-delegations-tab') { + const hasFocusDelegationId = + Object.prototype.hasOwnProperty.call(r, 'focusDelegationId') + && r.focusDelegationId !== undefined; + if ( + hasFocusDelegationId + && r.focusDelegationId !== null + && typeof r.focusDelegationId !== 'string' + ) { + throwIpcError('INVALID_PARAMS', 'command.focusDelegationId must be string | null'); + } + if (r.focusTab !== undefined && typeof r.focusTab !== 'boolean') { + throwIpcError('INVALID_PARAMS', 'command.focusTab must be boolean'); + } + if (r.revealSidebar !== undefined && typeof r.revealSidebar !== 'boolean') { + throwIpcError('INVALID_PARAMS', 'command.revealSidebar must be boolean'); + } + return { + type: 'open-bot-delegations-tab', + sessionId: r.sessionId, + ...(hasFocusDelegationId + ? { focusDelegationId: r.focusDelegationId as string | null } + : {}), + ...(typeof r.focusTab === 'boolean' ? { focusTab: r.focusTab } : {}), + ...(typeof r.revealSidebar === 'boolean' ? { revealSidebar: r.revealSidebar } : {}), + }; + } if (r.type === 'open-turn-review') { if ( !Array.isArray(r.changeSetIds) diff --git a/apps/desktop/src/main/scheduler-host/__tests__/botAutomationRecovery.test.ts b/apps/desktop/src/main/scheduler-host/__tests__/botAutomationRecovery.test.ts new file mode 100644 index 0000000000..3c2452ba83 --- /dev/null +++ b/apps/desktop/src/main/scheduler-host/__tests__/botAutomationRecovery.test.ts @@ -0,0 +1,355 @@ +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import { describe, expect, it, vi } from 'vitest'; + +import type { Maker } from '@cindy/maker-core'; +import * as schema from '../../localDb/schema'; +import { + botAutomationRuns, + botSessionLinks, + scheduleRuns, + sessions, +} from '../../localDb/schema'; +import { + reconcileBotAutomationRuns, + requireStrictAutomationRuntime, +} from '../bot-automation-runner'; + +function createDb() { + const sqlite = new Database(':memory:'); + sqlite.exec(` + CREATE TABLE sessions ( + id TEXT PRIMARY KEY NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + updated_at INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE bot_profiles ( + id TEXT PRIMARY KEY NOT NULL, + canonical_session_id TEXT, + status TEXT NOT NULL DEFAULT 'active' + ); + CREATE TABLE schedules ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL + ); + CREATE TABLE schedule_runs ( + id TEXT PRIMARY KEY NOT NULL, + schedule_id TEXT NOT NULL, + status TEXT NOT NULL, + result_text TEXT, + finished_at INTEGER, + heartbeat_at INTEGER + ); + CREATE TABLE bot_automation_links ( + id TEXT PRIMARY KEY NOT NULL, + bot_id TEXT NOT NULL, + schedule_id TEXT + ); + CREATE TABLE bot_automation_runs ( + id TEXT PRIMARY KEY NOT NULL, + automation_link_id TEXT NOT NULL, + schedule_run_id TEXT, + session_id TEXT, + target_route_id_snapshot TEXT, + target_route_owner_generation_snapshot INTEGER, + delivery_outbox_id TEXT, + delivery_status TEXT NOT NULL DEFAULT 'not-requested', + delivery_error TEXT, + result_text_snapshot TEXT, + output_artifacts_json TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL, + updated_at INTEGER NOT NULL, + finished_at INTEGER + ); + CREATE TABLE bot_runtime_snapshots ( + id TEXT PRIMARY KEY NOT NULL, + bot_id TEXT NOT NULL, + session_id TEXT NOT NULL, + profile_version INTEGER NOT NULL, + agent_kind TEXT NOT NULL, + working_dir TEXT NOT NULL, + memory_scope_key TEXT, + configured_json TEXT NOT NULL DEFAULT '{}', + resolved_json TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL, + prepared_at INTEGER NOT NULL DEFAULT 0, + applied_at INTEGER, + failed_at INTEGER, + failure_json TEXT + ); + CREATE TABLE bot_session_links ( + id TEXT PRIMARY KEY NOT NULL, + session_id TEXT NOT NULL, + role TEXT NOT NULL, + channel_id TEXT, + route_key TEXT, + archived_at INTEGER + ); + CREATE TABLE bot_routes ( + id TEXT PRIMARY KEY NOT NULL, + bot_id TEXT NOT NULL, + current_session_id TEXT, + channel_id TEXT, + owner_generation INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active' + ); + `); + return { sqlite, db: drizzle(sqlite, { schema }) }; +} + +describe('Bot automation restart recovery', () => { + it('fails closed before dispatch when the frozen runtime is degraded', async () => { + const { sqlite, db } = createDb(); + sqlite.prepare(` + INSERT INTO bot_runtime_snapshots ( + id, bot_id, session_id, profile_version, agent_kind, working_dir, + resolved_json, status, prepared_at + ) VALUES ('runtime-1', 'bot-1', 'automation-session', 3, 'pi', '/tmp/bot', ?, 'degraded', 10) + `).run(JSON.stringify({ unavailableSkills: ['release-review'] })); + const plan = { + version: 1 as const, + createdAt: 1, + deadlineAt: 100, + botId: 'bot-1', + profile: { + profileVersion: 3, + agentKind: 'pi' as const, + model: 'grok-4.5', + capabilitiesSha256: 'capabilities', + identitySha256: 'identity', + skills: ['release-review'], + skillMode: 'allowlist' as const, + mcpServers: [], + mcpMode: 'inherit' as const, + toolsets: [], + toolsetMode: 'inherit' as const, + memoryEnabled: true, + automationEnabled: true, + }, + workspace: null, + delivery: { targetRouteId: null, ownerGeneration: null }, + limits: { timeoutMs: 99, budgetTokens: null, maxDelegationDepth: 1 }, + delegation: { mode: 'none' as const, targets: [] }, + }; + + await expect( + requireStrictAutomationRuntime(db, 'automation-session', plan), + ).rejects.toThrow(/degraded/); + + sqlite.prepare(` + UPDATE bot_runtime_snapshots + SET status = 'applied', resolved_json = '{"unavailableSkills":[],"memoryRefs":[]}' + WHERE id = 'runtime-1' + `).run(); + await expect( + requireStrictAutomationRuntime(db, 'automation-session', plan), + ).resolves.toBeUndefined(); + sqlite.close(); + }); + + it('persists and delivers a captured completion before archiving its task', async () => { + const { sqlite, db } = createDb(); + sqlite.prepare("INSERT INTO sessions (id, status, updated_at) VALUES ('parent', 'active', 1), ('child', 'active', 1)").run(); + sqlite.prepare("INSERT INTO bot_profiles (id, canonical_session_id) VALUES ('bot-1', 'parent')").run(); + sqlite.prepare("INSERT INTO schedules (id, name) VALUES ('schedule-1', 'Daily report')").run(); + sqlite.prepare("INSERT INTO schedule_runs (id, schedule_id, status) VALUES ('schedule-run-1', 'schedule-1', 'running')").run(); + sqlite.prepare("INSERT INTO bot_automation_links (id, bot_id, schedule_id) VALUES ('automation-1', 'bot-1', 'schedule-1')").run(); + sqlite.prepare(` + INSERT INTO bot_automation_runs ( + id, automation_link_id, schedule_run_id, session_id, + delivery_status, result_text_snapshot, status, updated_at + ) VALUES ('automation-run-1', 'automation-1', 'schedule-run-1', 'child', + 'not-requested', 'Recovered report.', 'completing', 10) + `).run(); + sqlite.prepare(` + INSERT INTO bot_session_links (id, session_id, role, channel_id, route_key) + VALUES ('link-1', 'child', 'route', 'bot-1:local', 'automation:schedule-run-1') + `).run(); + + const enqueueDelivery = vi.fn(async () => ({ id: 'outbox-1' })); + const archiveSession = vi.fn(async (sessionId: string) => { + db.update(sessions) + .set({ status: 'archived', updatedAt: 20 }) + .where(eq(sessions.id, sessionId)) + .run(); + }); + const closeSession = vi.fn(async () => undefined); + await reconcileBotAutomationRuns({ + getDb: () => db, + maker: { closeSession } as unknown as Maker, + archiveSession, + enqueueDelivery, + }); + + expect(enqueueDelivery).toHaveBeenCalledWith(expect.objectContaining({ + botId: 'bot-1', + sessionId: 'parent', + idempotencyKey: 'bot-automation-completion:schedule-run-1', + payload: expect.objectContaining({ + targetSessionId: 'parent', + message: expect.stringContaining('Recovered report.'), + }), + })); + expect( + db.select({ + status: botAutomationRuns.status, + outboxId: botAutomationRuns.deliveryOutboxId, + deliveryStatus: botAutomationRuns.deliveryStatus, + }).from(botAutomationRuns).get(), + ).toEqual({ status: 'success', outboxId: 'outbox-1', deliveryStatus: 'queued' }); + expect( + db.select({ status: scheduleRuns.status, resultText: scheduleRuns.resultText }) + .from(scheduleRuns).get(), + ).toEqual({ status: 'success', resultText: 'Recovered report.' }); + expect(db.select({ status: sessions.status }).from(sessions).where( + eq(sessions.id, 'child'), + ).get()).toEqual({ status: 'archived' }); + expect(db.select({ role: botSessionLinks.role }).from(botSessionLinks).get()) + .toEqual({ role: 'history' }); + expect(archiveSession).toHaveBeenCalledWith('child'); + expect(closeSession).toHaveBeenCalledWith('child'); + sqlite.close(); + }); + + it('restores the frozen IM Route target when a completing run recovers after restart', async () => { + const { sqlite, db } = createDb(); + sqlite.prepare("INSERT INTO sessions (id, status, updated_at) VALUES ('canonical', 'active', 1), ('route-task', 'active', 1), ('child', 'active', 1)").run(); + sqlite.prepare("INSERT INTO bot_profiles (id, canonical_session_id) VALUES ('bot-1', 'canonical')").run(); + sqlite.prepare("INSERT INTO schedules (id, name) VALUES ('schedule-1', 'Route report')").run(); + sqlite.prepare("INSERT INTO schedule_runs (id, schedule_id, status) VALUES ('schedule-run-1', 'schedule-1', 'running')").run(); + sqlite.prepare("INSERT INTO bot_automation_links (id, bot_id, schedule_id) VALUES ('automation-1', 'bot-1', 'schedule-1')").run(); + sqlite.prepare(` + INSERT INTO bot_routes ( + id, bot_id, current_session_id, channel_id, owner_generation, status + ) VALUES ('route-1', 'bot-1', 'route-task', 'telegram-account-1', 7, 'active') + `).run(); + sqlite.prepare(` + INSERT INTO bot_automation_runs ( + id, automation_link_id, schedule_run_id, session_id, + target_route_id_snapshot, target_route_owner_generation_snapshot, + delivery_status, result_text_snapshot, status, updated_at + ) VALUES ('automation-run-1', 'automation-1', 'schedule-run-1', 'child', + 'route-1', 7, 'not-requested', 'Recovered route report.', 'completing', 10) + `).run(); + sqlite.prepare(` + INSERT INTO bot_session_links (id, session_id, role, channel_id, route_key) + VALUES ('link-1', 'child', 'route', 'bot-1:local', 'automation:schedule-run-1') + `).run(); + + const enqueueDelivery = vi.fn(async () => ({ id: 'outbox-route-1' })); + await reconcileBotAutomationRuns({ + getDb: () => db, + maker: { closeSession: vi.fn(async () => undefined) } as unknown as Maker, + archiveSession: vi.fn(async () => undefined), + enqueueDelivery, + }); + + expect(enqueueDelivery).toHaveBeenCalledWith(expect.objectContaining({ + botId: 'bot-1', + channelId: 'telegram-account-1', + routeId: 'route-1', + sessionId: 'route-task', + ownerGeneration: 7, + idempotencyKey: 'bot-automation-completion:schedule-run-1', + payload: expect.objectContaining({ + targetSessionId: 'route-task', + message: expect.stringContaining('Recovered route report.'), + }), + })); + expect(db.select({ + status: botAutomationRuns.status, + outboxId: botAutomationRuns.deliveryOutboxId, + deliveryStatus: botAutomationRuns.deliveryStatus, + }).from(botAutomationRuns).get()).toEqual({ + status: 'success', + outboxId: 'outbox-route-1', + deliveryStatus: 'queued', + }); + sqlite.close(); + }); + + it('does not deliver a recovered completion after the Bot was paused', async () => { + const { sqlite, db } = createDb(); + sqlite.prepare("INSERT INTO sessions (id, status, updated_at) VALUES ('parent', 'active', 1), ('child', 'active', 1)").run(); + sqlite.prepare("INSERT INTO bot_profiles (id, canonical_session_id, status) VALUES ('bot-1', 'parent', 'paused')").run(); + sqlite.prepare("INSERT INTO schedules (id, name) VALUES ('schedule-1', 'Daily report')").run(); + sqlite.prepare("INSERT INTO schedule_runs (id, schedule_id, status) VALUES ('schedule-run-1', 'schedule-1', 'running')").run(); + sqlite.prepare("INSERT INTO bot_automation_links (id, bot_id, schedule_id) VALUES ('automation-1', 'bot-1', 'schedule-1')").run(); + sqlite.prepare(` + INSERT INTO bot_automation_runs ( + id, automation_link_id, schedule_run_id, session_id, + delivery_status, result_text_snapshot, status, updated_at + ) VALUES ('automation-run-1', 'automation-1', 'schedule-run-1', 'child', + 'not-requested', 'Recovered report.', 'completing', 10) + `).run(); + sqlite.prepare(` + INSERT INTO bot_session_links (id, session_id, role, channel_id, route_key) + VALUES ('link-1', 'child', 'route', 'bot-1:local', 'automation:schedule-run-1') + `).run(); + + const enqueueDelivery = vi.fn(async () => ({ id: 'outbox-1' })); + await reconcileBotAutomationRuns({ + getDb: () => db, + maker: { closeSession: vi.fn(async () => undefined) } as unknown as Maker, + enqueueDelivery, + }); + + expect(enqueueDelivery).not.toHaveBeenCalled(); + expect(db.select({ + status: botAutomationRuns.status, + deliveryStatus: botAutomationRuns.deliveryStatus, + deliveryError: botAutomationRuns.deliveryError, + }).from(botAutomationRuns).get()).toEqual({ + status: 'success', + deliveryStatus: 'enqueue-failed', + deliveryError: 'Bot is no longer active; completion was not delivered', + }); + sqlite.close(); + }); + + it('repairs a Scheduler run interrupted after the Bot result was durably completed', async () => { + const { sqlite, db } = createDb(); + sqlite.prepare("INSERT INTO sessions (id, status, updated_at) VALUES ('child', 'active', 1)").run(); + sqlite.prepare("INSERT INTO bot_profiles (id, canonical_session_id) VALUES ('bot-1', NULL)").run(); + sqlite.prepare("INSERT INTO schedules (id, name) VALUES ('schedule-1', 'Crash window')").run(); + sqlite.prepare(` + INSERT INTO schedule_runs (id, schedule_id, status, result_text, finished_at) + VALUES ('schedule-run-1', 'schedule-1', 'interrupted', NULL, 20) + `).run(); + sqlite.prepare("INSERT INTO bot_automation_links (id, bot_id, schedule_id) VALUES ('automation-1', 'bot-1', 'schedule-1')").run(); + sqlite.prepare(` + INSERT INTO bot_automation_runs ( + id, automation_link_id, schedule_run_id, session_id, + delivery_status, result_text_snapshot, status, updated_at, finished_at + ) VALUES ('automation-run-1', 'automation-1', 'schedule-run-1', 'child', + 'not-requested', 'Durable result.', 'success', 19, 19) + `).run(); + sqlite.prepare(` + INSERT INTO bot_session_links (id, session_id, role, channel_id, route_key) + VALUES ('link-1', 'child', 'route', 'bot-1:local', 'automation:schedule-run-1') + `).run(); + + await reconcileBotAutomationRuns({ + getDb: () => db, + maker: { closeSession: vi.fn(async () => undefined) } as unknown as Maker, + archiveSession: async (sessionId) => { + db.update(sessions) + .set({ status: 'archived', updatedAt: 21 }) + .where(eq(sessions.id, sessionId)) + .run(); + }, + }); + + expect( + db.select({ status: scheduleRuns.status, resultText: scheduleRuns.resultText }) + .from(scheduleRuns) + .get(), + ).toEqual({ status: 'success', resultText: 'Durable result.' }); + expect(db.select({ status: botAutomationRuns.status }).from(botAutomationRuns).get()) + .toEqual({ status: 'success' }); + expect(db.select({ status: sessions.status }).from(sessions).get()) + .toEqual({ status: 'archived' }); + sqlite.close(); + }); +}); diff --git a/apps/desktop/src/main/scheduler-host/bot-automation-runner.ts b/apps/desktop/src/main/scheduler-host/bot-automation-runner.ts new file mode 100644 index 0000000000..5792902563 --- /dev/null +++ b/apps/desktop/src/main/scheduler-host/bot-automation-runner.ts @@ -0,0 +1,1322 @@ +import { createHash, randomUUID } from 'node:crypto'; +import fs from 'node:fs/promises'; + +import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm'; +import type { Maker, AgentKind, Effort } from '@cindy/maker-core'; +import type { + FireContext, + FireResult, + Logger, + Schedule, + ScheduleRunner, +} from '@cindy/maker-scheduler'; + +import { ensureProjectGitInitialized } from '../git-snapshot/projectGitBootstrap.js'; +import { ensureDialogueWorkspaceDir } from '../localDb/dialogueWorkspace.js'; +import { getDbClient } from '../localDb/client/current.js'; +import { sessionCreateToRow } from '../localDb/mapper.js'; +import { + botAutomationLinks, + botAutomationRuns, + botChannels, + botProfileVersions, + botProfiles, + botProjectBindings, + botRoutes, + botRuntimeSnapshots, + botSessionLinks, + botWorkspaceAttachments, + botWorkspaceLeases, + scheduleRuns, + schedules, + sessions, +} from '../localDb/schema.js'; +import { readGitSafetySettings } from '../maker-host/git-safety-settings-store.js'; +import { withBotAutomationMutationLock } from '../maker-ipc/botAutomationMutationLock.js'; +import { + buildSkipResultText, + executePreRunHook, + formatPreRunHookFailure, +} from './pre-run-hook.js'; +import type { SchedulerDrizzleDb } from './storage.js'; +import type { + BotAutomationDelegateTargetSnapshot, + BotAutomationExecutionPlan, +} from '../../shared/botAutomation.js'; +import { normalizeBotAutomationExecutionPolicy } from '../../shared/botAutomation.js'; +import type { + BotDelegationCapabilitySnapshot, + BotDelegationWorkspaceSnapshot, +} from '../../shared/botDelegation.js'; +import { collectBotOutputArtifacts } from '../../shared/botOutputArtifact.js'; + +function parseObject(value: string | null | undefined): Record { + try { + const parsed = JSON.parse(value ?? '{}') as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + return {}; + } +} + +function sha256(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function stringList(value: unknown): string[] { + return Array.isArray(value) + ? [...new Set( + value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean), + )] + : []; +} + +function configuredMode(value: unknown, configured: string[]): 'inherit' | 'allowlist' { + return value === 'inherit' || value === 'allowlist' + ? value + : configured.length > 0 + ? 'allowlist' + : 'inherit'; +} + +function configuredEffort(value: unknown, fallback: unknown): Effort | undefined { + const isEffort = (candidate: unknown): candidate is Effort => + candidate === 'minimal' + || candidate === 'low' + || candidate === 'medium' + || candidate === 'high' + || candidate === 'xhigh' + || candidate === 'max' + || candidate === 'ultra'; + return isEffort(value) ? value : isEffort(fallback) ? fallback : undefined; +} + +function capabilitySnapshot(input: { + profileVersion: number; + capabilitiesJson: string; + identitySource: string; +}): BotDelegationCapabilitySnapshot { + const config = parseObject(input.capabilitiesJson); + const skills = stringList(config.skills); + const mcpServers = stringList(config.mcpServers); + const explicitToolsets = stringList(config.toolsets); + const toolsets = explicitToolsets.length > 0 + ? explicitToolsets + : stringList(config.tools).filter((item) => !['files', 'browser', 'mcp'].includes(item)); + const agentKind = config.harness === 'codex' ? 'codex' : config.harness === 'pi' ? 'pi' : 'cc'; + return { + profileVersion: input.profileVersion, + agentKind, + model: + typeof config.model === 'string' && config.model.trim() + ? config.model.trim() + : agentKind === 'codex' + ? 'gpt-5.4' + : agentKind === 'pi' + ? 'grok-4.5' + : 'claude-sonnet-4-6', + capabilitiesSha256: sha256(input.capabilitiesJson), + identitySha256: sha256(input.identitySource), + skills, + skillMode: configuredMode(config.skillMode, skills), + mcpServers, + mcpMode: configuredMode(config.mcpMode, mcpServers), + toolsets, + toolsetMode: configuredMode(config.toolsetMode, toolsets), + memoryEnabled: config.memory !== false, + automationEnabled: config.automation === true, + }; +} + +function workspaceSnapshot( + binding: typeof botProjectBindings.$inferSelect | undefined, +): BotDelegationWorkspaceSnapshot | null { + if (!binding) return null; + let allowedPaths: string[] = []; + try { + allowedPaths = stringList(JSON.parse(binding.allowedPathsJson) as unknown); + } catch { + allowedPaths = []; + } + return { + bindingId: binding.id, + bindingUpdatedAt: binding.updatedAt, + projectKey: binding.projectKey, + workingDir: binding.workingDir, + remoteHostId: binding.remoteHostId, + defaultBranch: binding.defaultBranch, + workspacePolicy: binding.workspacePolicy, + allowedPaths, + }; +} + +async function buildAutomationExecutionPlan(input: { + db: SchedulerDrizzleDb; + profile: typeof botProfiles.$inferSelect; + version: typeof botProfileVersions.$inferSelect; + binding: typeof botProjectBindings.$inferSelect | undefined; + executionPolicyJson: string; + targetRouteId: string | null; + targetRouteOwnerGeneration: number | null; + createdAt: number; +}): Promise { + const policy = normalizeBotAutomationExecutionPolicy(parseObject(input.executionPolicyJson)); + let targetBotIds: string[] = []; + if (policy.delegateTargetMode === 'allowlist') { + targetBotIds = policy.allowedDelegateBotIds.filter((botId) => botId !== input.profile.id); + } else if (policy.delegateTargetMode === 'all-active') { + targetBotIds = (await input.db + .select({ id: botProfiles.id }) + .from(botProfiles) + .where(eq(botProfiles.status, 'active'))) + .map((row) => row.id) + .filter((botId) => botId !== input.profile.id); + } + const uniqueTargetIds = [...new Set(targetBotIds)]; + const targetSnapshots: BotAutomationDelegateTargetSnapshot[] = []; + if (uniqueTargetIds.length > 0) { + const targets = await input.db + .select() + .from(botProfiles) + .where( + and( + inArray(botProfiles.id, uniqueTargetIds), + eq(botProfiles.status, 'active'), + ), + ); + if (targets.length !== uniqueTargetIds.length) { + throw new Error('One or more Automation delegate targets are unavailable'); + } + const versions = await input.db + .select() + .from(botProfileVersions) + .where(inArray(botProfileVersions.botId, uniqueTargetIds)); + const defaultBindings = await input.db + .select() + .from(botProjectBindings) + .where( + and( + inArray(botProjectBindings.botId, uniqueTargetIds), + eq(botProjectBindings.status, 'active'), + eq(botProjectBindings.isDefault, true), + ), + ); + const defaultBindingByBot = new Map(defaultBindings.map((binding) => [binding.botId, binding])); + for (const target of targets) { + const version = versions.find( + (candidate) => candidate.botId === target.id && candidate.version === target.currentVersion, + ); + if (!version) throw new Error(`Automation delegate target Profile is unavailable: ${target.id}`); + targetSnapshots.push({ + botId: target.id, + profileVersion: target.currentVersion, + capabilitiesSha256: sha256(version.capabilitiesJson), + identitySha256: sha256(version.identitySource), + defaultWorkspace: workspaceSnapshot(defaultBindingByBot.get(target.id)), + }); + } + } + return { + version: 1, + createdAt: input.createdAt, + deadlineAt: input.createdAt + policy.timeoutMs, + botId: input.profile.id, + profile: capabilitySnapshot({ + profileVersion: input.profile.currentVersion, + capabilitiesJson: input.version.capabilitiesJson, + identitySource: input.version.identitySource, + }), + workspace: workspaceSnapshot(input.binding), + delivery: { + targetRouteId: input.targetRouteId, + ownerGeneration: input.targetRouteOwnerGeneration, + }, + limits: { + timeoutMs: policy.timeoutMs, + budgetTokens: policy.budgetTokens, + maxDelegationDepth: policy.maxDelegationDepth, + }, + delegation: { + mode: policy.delegateTargetMode, + targets: targetSnapshots, + }, + }; +} + +async function validateAutomationExecutionPlan( + db: SchedulerDrizzleDb, + plan: BotAutomationExecutionPlan, +): Promise { + if (Date.now() >= plan.deadlineAt) throw new Error('Bot automation execution deadline expired'); + const [profile] = await db + .select() + .from(botProfiles) + .where(eq(botProfiles.id, plan.botId)) + .limit(1); + if ( + !profile + || profile.status !== 'active' + || profile.currentVersion !== plan.profile.profileVersion + ) { + throw new Error('Bot automation Profile changed after this run was claimed'); + } + const [version] = await db + .select() + .from(botProfileVersions) + .where( + and( + eq(botProfileVersions.botId, plan.botId), + eq(botProfileVersions.version, plan.profile.profileVersion), + ), + ) + .limit(1); + if ( + !version + || sha256(version.capabilitiesJson) !== plan.profile.capabilitiesSha256 + || sha256(version.identitySource) !== plan.profile.identitySha256 + ) { + throw new Error('Bot automation Profile bytes changed after this run was claimed'); + } + if (plan.workspace) { + const [binding] = await db + .select() + .from(botProjectBindings) + .where(eq(botProjectBindings.id, plan.workspace.bindingId)) + .limit(1); + if ( + !binding + || binding.status !== 'active' + || binding.botId !== plan.botId + || binding.updatedAt !== plan.workspace.bindingUpdatedAt + || binding.projectKey !== plan.workspace.projectKey + ) { + throw new Error('Bot automation workspace authorization changed after this run was claimed'); + } + } + for (const target of plan.delegation.targets) { + const [targetProfile] = await db + .select() + .from(botProfiles) + .where(eq(botProfiles.id, target.botId)) + .limit(1); + if ( + !targetProfile + || targetProfile.status !== 'active' + || targetProfile.currentVersion !== target.profileVersion + ) { + throw new Error(`Automation delegate target changed: ${target.botId}`); + } + const [targetVersion] = await db + .select() + .from(botProfileVersions) + .where( + and( + eq(botProfileVersions.botId, target.botId), + eq(botProfileVersions.version, target.profileVersion), + ), + ) + .limit(1); + if ( + !targetVersion + || sha256(targetVersion.capabilitiesJson) !== target.capabilitiesSha256 + || sha256(targetVersion.identitySource) !== target.identitySha256 + ) { + throw new Error(`Automation delegate target Profile bytes changed: ${target.botId}`); + } + const [currentBinding] = await db + .select() + .from(botProjectBindings) + .where( + and( + eq(botProjectBindings.botId, target.botId), + eq(botProjectBindings.status, 'active'), + eq(botProjectBindings.isDefault, true), + ), + ) + .limit(1); + if ( + target.defaultWorkspace === null + ? currentBinding !== undefined + : !currentBinding + || currentBinding.id !== target.defaultWorkspace.bindingId + || currentBinding.updatedAt !== target.defaultWorkspace.bindingUpdatedAt + || currentBinding.projectKey !== target.defaultWorkspace.projectKey + ) { + throw new Error(`Automation delegate target workspace changed: ${target.botId}`); + } + } +} + +export async function requireStrictAutomationRuntime( + db: SchedulerDrizzleDb, + sessionId: string, + plan: BotAutomationExecutionPlan, +): Promise { + const [runtime] = await db + .select() + .from(botRuntimeSnapshots) + .where(eq(botRuntimeSnapshots.sessionId, sessionId)) + .orderBy(desc(botRuntimeSnapshots.preparedAt)) + .limit(1); + if (!runtime || runtime.profileVersion !== plan.profile.profileVersion) { + throw new Error('Bot automation runtime did not produce the frozen Profile snapshot'); + } + if (runtime.status !== 'applied') { + const failure = parseObject(runtime.failureJson); + const detail = [failure.stage, failure.errorCode ?? failure.errorName] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .join(': '); + throw new Error( + runtime.status === 'degraded' + ? 'Bot automation runtime is degraded because one or more frozen capabilities are unavailable' + : `Bot automation runtime failed to start${detail ? ` (${detail})` : ''}`, + ); + } + const resolved = parseObject(runtime.resolvedJson); + const unavailable = [ + ...stringList(resolved.unavailableSkills), + ...stringList(resolved.unavailableMcpServers), + ...stringList(resolved.unavailableToolsets), + ]; + const memoryUnavailable = Array.isArray(resolved.memoryRefs) + && resolved.memoryRefs.some( + (ref) => ref + && typeof ref === 'object' + && (ref as Record).status === 'unavailable', + ); + if (unavailable.length > 0 || memoryUnavailable) { + throw new Error( + `Bot automation runtime is missing frozen capabilities: ${[ + ...unavailable, + ...(memoryUnavailable ? ['memory'] : []), + ].join(', ')}`, + ); + } +} + +function schedulePerTaskWorkspaceReclaim(sessionId: string): void { + void import('../maker-ipc/botWorkspaceRuntime.js') + .then((module) => module.schedulePerTaskBotWorkspaceReclaim(sessionId)) + .catch(() => undefined); +} + +function agentKindFor(config: Record): AgentKind { + return config.harness === 'codex' + ? 'codex' + : config.harness === 'pi' + ? 'pi' + : 'claude-code'; +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isAbort(error: unknown, signal: AbortSignal): boolean { + if (signal.aborted) return true; + const message = errorText(error).toLowerCase(); + return error instanceof DOMException && error.name === 'AbortError' + || message.includes('abort'); +} + +export interface BotAutomationScheduleRunnerDeps { + delegate: ScheduleRunner; + maker: Maker; + getDb: () => SchedulerDrizzleDb; + logger?: Logger; + onSessionCreated?: (sessionId: string) => void; + archiveSession?: (sessionId: string) => Promise; + enqueueDelivery?: (params: { + botId: string; + channelId?: string | null; + routeId?: string | null; + sessionId: string | null; + idempotencyKey: string; + ownerGeneration?: number; + payload: { + version: 1; + kind: 'session-message'; + targetSessionId: string; + fallbackBotId: string; + clientId: string; + message: string; + persistedContent: string; + }; + }) => Promise<{ id: string }>; +} + +type BotAutomationDeliveryTarget = { + sessionId: string; + channelId: string | null; + routeId: string | null; + ownerGeneration: number; +}; + +async function resolveBotAutomationDeliveryTarget( + db: SchedulerDrizzleDb, + targetRouteId: string | null, + botId: string, + expectedOwnerGeneration: number | null, +): Promise< + | { ok: true; target: BotAutomationDeliveryTarget | null } + | { ok: false; error: string } +> { + if (targetRouteId) { + const [route] = await db + .select({ + currentSessionId: botRoutes.currentSessionId, + channelId: botRoutes.channelId, + ownerGeneration: botRoutes.ownerGeneration, + status: botRoutes.status, + }) + .from(botRoutes) + .where(and(eq(botRoutes.id, targetRouteId), eq(botRoutes.botId, botId))) + .limit(1); + if (route?.status === 'active' && route.currentSessionId) { + if ( + expectedOwnerGeneration !== null + && route.ownerGeneration !== expectedOwnerGeneration + ) { + return { + ok: false, + error: 'Target route ownership changed while the automation was running', + }; + } + return { + ok: true, + target: { + sessionId: route.currentSessionId, + channelId: route.channelId, + routeId: targetRouteId, + ownerGeneration: route.ownerGeneration, + }, + }; + } + return { + ok: false, + error: route + ? `Target route is ${route.status} or has no active task` + : 'Target route no longer exists', + }; + } + const [profile] = await db + .select({ + canonicalSessionId: botProfiles.canonicalSessionId, + status: botProfiles.status, + }) + .from(botProfiles) + .where(eq(botProfiles.id, botId)) + .limit(1); + if (!profile || profile.status !== 'active') { + return { ok: false, error: 'Bot is no longer active; completion was not delivered' }; + } + return { + ok: true, + target: profile.canonicalSessionId + ? { + sessionId: profile.canonicalSessionId, + channelId: null, + routeId: null, + ownerGeneration: 0, + } + : null, + }; +} + +export class BotAutomationScheduleRunner implements ScheduleRunner { + constructor(private readonly deps: BotAutomationScheduleRunnerDeps) {} + + async fire(schedule: Schedule, ctx: FireContext): Promise { + const db = this.deps.getDb(); + const claimedState = await withBotAutomationMutationLock(schedule.id, async () => { + const [link] = await db + .select() + .from(botAutomationLinks) + .where(eq(botAutomationLinks.scheduleId, schedule.id)) + .limit(1); + if (!link) { + if (schedule.source === 'bot') { + throw new Error('Bot automation ownership link is unavailable'); + } + return null; + } + if (schedule.source !== 'bot') { + throw new Error('Bot automation ownership mismatch'); + } + if (link.status !== 'active') { + throw new Error(`Bot automation is ${link.status}`); + } + + const [activeRun] = await db + .select({ id: botAutomationRuns.id }) + .from(botAutomationRuns) + .where( + and( + eq(botAutomationRuns.automationLinkId, link.id), + inArray(botAutomationRuns.status, ['claimed', 'running', 'completing']), + ), + ) + .limit(1); + if (activeRun) { + return { busy: true as const, automationRunId: activeRun.id }; + } + + const [profile] = await db + .select() + .from(botProfiles) + .where(and(eq(botProfiles.id, link.botId), eq(botProfiles.status, 'active'))) + .limit(1); + if (!profile) throw new Error('Bot automation profile is unavailable'); + const [version] = await db + .select() + .from(botProfileVersions) + .where( + and( + eq(botProfileVersions.botId, profile.id), + eq(botProfileVersions.version, profile.currentVersion), + ), + ) + .limit(1); + if (!version) throw new Error('Bot automation Profile version is unavailable'); + const config = parseObject(version.capabilitiesJson); + if (config.automation !== true) { + throw new Error('Bot automation is disabled in the current Profile'); + } + if (config.permissions !== 'trusted') { + throw new Error( + 'Bot automation requires trusted operations because no user is present to approve', + ); + } + + let binding: typeof botProjectBindings.$inferSelect | undefined; + if (link.projectBindingId) { + [binding] = await db + .select() + .from(botProjectBindings) + .where( + and( + eq(botProjectBindings.id, link.projectBindingId), + eq(botProjectBindings.botId, profile.id), + eq(botProjectBindings.status, 'active'), + ), + ) + .limit(1); + if (!binding) throw new Error('Bot automation project binding is unavailable'); + } else { + [binding] = await db + .select() + .from(botProjectBindings) + .where( + and( + eq(botProjectBindings.botId, profile.id), + eq(botProjectBindings.status, 'active'), + eq(botProjectBindings.isDefault, true), + ), + ) + .limit(1); + } + + let targetRouteOwnerGenerationSnapshot: number | null = null; + if (link.targetRouteId) { + const [targetRoute] = await db + .select({ + botId: botRoutes.botId, + ownerGeneration: botRoutes.ownerGeneration, + currentSessionId: botRoutes.currentSessionId, + status: botRoutes.status, + }) + .from(botRoutes) + .where(eq(botRoutes.id, link.targetRouteId)) + .limit(1); + if ( + !targetRoute + || targetRoute.botId !== profile.id + || targetRoute.status !== 'active' + || !targetRoute.currentSessionId + ) { + throw new Error('Bot automation target route is unavailable'); + } + targetRouteOwnerGenerationSnapshot = targetRoute.ownerGeneration; + } + + const createdAt = Date.now(); + const automationRunId = randomUUID(); + const executionPlan = await buildAutomationExecutionPlan({ + db, + profile, + version, + binding, + executionPolicyJson: link.executionPolicyJson, + targetRouteId: link.targetRouteId, + targetRouteOwnerGeneration: targetRouteOwnerGenerationSnapshot, + createdAt, + }); + const claimed = await db + .insert(botAutomationRuns) + .values({ + id: automationRunId, + automationLinkId: link.id, + scheduleRunId: ctx.runId, + sessionId: null, + workspaceLeaseId: null, + profileVersion: profile.currentVersion, + projectBindingIdSnapshot: binding?.id ?? null, + targetRouteIdSnapshot: link.targetRouteId, + targetRouteOwnerGenerationSnapshot, + workingDirSnapshot: binding?.workingDir ?? null, + remoteHostIdSnapshot: binding?.remoteHostId ?? null, + worktreePathSnapshot: null, + deliveryOutboxId: null, + deliveryStatus: 'not-requested', + deliveryError: null, + executionPlanJson: JSON.stringify(executionPlan), + status: 'claimed', + createdAt, + updatedAt: createdAt, + finishedAt: null, + }) + .onConflictDoNothing() + .returning({ id: botAutomationRuns.id }); + if (claimed.length === 0) { + throw new Error('Bot automation run was already claimed'); + } + return { + busy: false as const, + link, + profile, + config, + binding, + createdAt, + automationRunId, + targetRouteOwnerGenerationSnapshot, + executionPlan, + }; + }); + if (!claimedState) return this.deps.delegate.fire(schedule, ctx); + if (claimedState.busy) { + return { + sessionId: '', + skipped: true, + resultText: `Bot automation is already running (${claimedState.automationRunId}).`, + }; + } + const { + link, + profile, + config, + binding, + createdAt, + automationRunId, + targetRouteOwnerGenerationSnapshot, + executionPlan, + } = claimedState; + + try { + if (schedule.preRunHook?.command?.trim()) { + const hook = await executePreRunHook({ + command: schedule.preRunHook.command, + timeoutMs: schedule.preRunHook.timeoutMs, + cwd: binding?.workingDir, + signal: ctx.signal, + stdinPayload: { + event: 'schedule-pre-run', + scheduleId: schedule.id, + scheduleName: schedule.name, + runId: ctx.runId, + firedAt: ctx.firedAt, + workingDir: binding?.workingDir, + lastFinishedAt: schedule.lastFinishedAt, + }, + }); + await ctx.onPreRunHookCompleted?.(hook); + if (hook.status === 'aborted' || ctx.signal.aborted) { + throw new Error('fire aborted during Bot automation pre-run hook'); + } + if (hook.decision === 'skip') { + await this.finalizeClaimOnly(automationRunId, 'skipped'); + return { sessionId: '', skipped: true, resultText: buildSkipResultText(hook) }; + } + if (hook.decision === 'block') { + throw new Error(formatPreRunHookFailure(hook)); + } + } + } catch (error) { + await this.finalizeClaimOnly( + automationRunId, + isAbort(error, ctx.signal) ? 'aborted' : 'failed', + ); + throw error; + } + + try { + await validateAutomationExecutionPlan(db, executionPlan); + } catch (error) { + await this.finalizeClaimOnly( + automationRunId, + isAbort(error, ctx.signal) ? 'aborted' : 'failed', + ); + throw error; + } + + const sessionId = randomUUID(); + const workspaceKind = binding ? 'project' : 'dialogue'; + const workingDir = binding?.workingDir ?? ensureDialogueWorkspaceDir(sessionId, createdAt); + const agentKind: AgentKind = executionPlan.profile.agentKind === 'cc' + ? 'claude-code' + : executionPlan.profile.agentKind; + const model = executionPlan.profile.model; + const localChannelId = `${profile.id}:local`; + try { + await ensureProjectGitInitialized({ + workingDir, + workspaceKind, + remoteHostId: binding?.remoteHostId ?? null, + sessionId, + autoSnapshotEnabled: readGitSafetySettings().autoSnapshotEnabled, + source: 'bot-automation', + }); + const sessionRow = { + ...sessionCreateToRow( + sessionId, + { + workspaceKind, + workingDir, + model, + agentKind: agentKind === 'claude-code' ? 'cc' : agentKind, + permissionMode: 'bypassPermissions', + remoteHostId: binding?.remoteHostId ?? undefined, + source: 'bot', + }, + createdAt, + ), + title: `${profile.displayName} · ${schedule.name}`.slice(0, 120), + }; + await getDbClient().tx('bots.createAutomationSession', { + automationRunId, + botId: profile.id, + localChannelId, + profileVersion: profile.currentVersion, + routeKey: `automation:${ctx.runId}`, + workingDirSnapshot: workingDir, + remoteHostIdSnapshot: binding?.remoteHostId ?? null, + session: { + id: sessionRow.id, + title: sessionRow.title, + workingDir: sessionRow.workingDir ?? null, + workspaceKind: sessionRow.workspaceKind, + model: sessionRow.model, + effort: sessionRow.effort, + permissionMode: sessionRow.permissionMode, + agentKind: sessionRow.agentKind, + remoteHostId: sessionRow.remoteHostId ?? null, + providerId: sessionRow.providerId ?? null, + extraDirs: sessionRow.extraDirs, + source: sessionRow.source, + createdAt: sessionRow.createdAt, + updatedAt: sessionRow.updatedAt, + }, + now: Date.now(), + }); + } catch (error) { + // Only reclaim the exact app-owned dialogue directory allocated above. + // A project binding is user-owned and must never be failure-cleaned here. + if (workspaceKind === 'dialogue') { + await fs.rm(workingDir, { recursive: true, force: true }).catch(() => undefined); + } + await this.finalizeClaimOnly( + automationRunId, + isAbort(error, ctx.signal) ? 'aborted' : 'failed', + ); + throw error; + } + this.deps.onSessionCreated?.(sessionId); + try { + await ctx.onSessionBound?.(sessionId); + } catch (error) { + this.deps.logger?.warn?.('[bot-automation] session bind broadcast failed (non-fatal)', { + scheduleId: schedule.id, + runId: ctx.runId, + sessionId, + error: errorText(error), + }); + } + + const runStartedAt = Date.now(); + await db + .update(botAutomationRuns) + .set({ status: 'running', updatedAt: runStartedAt }) + .where(eq(botAutomationRuns.id, automationRunId)); + + const delegatedSchedule: Schedule = { + ...schedule, + prompt: [ + `Cindy Bot automation: ${schedule.name}`, + `Automation run: ${ctx.runId}`, + schedule.prompt, + ].join('\n\n'), + targetSessionId: sessionId, + persistentSession: false, + agentKind, + model, + providerId: typeof config.providerId === 'string' ? config.providerId : undefined, + effort: typeof config.effort === 'string' ? config.effort : schedule.effort, + fastMode: typeof config.fastMode === 'boolean' ? config.fastMode : schedule.fastMode, + workspaceKind, + workingDir, + useWorktree: false, + preRunHook: undefined, + }; + + let result: FireResult; + let deadlineExpired = false; + const runAbort = new AbortController(); + const abortFromScheduler = () => runAbort.abort(ctx.signal.reason); + if (ctx.signal.aborted) abortFromScheduler(); + else ctx.signal.addEventListener('abort', abortFromScheduler, { once: true }); + const remainingMs = Math.max(1, executionPlan.deadlineAt - Date.now()); + const deadlineTimer = setTimeout(() => { + deadlineExpired = true; + runAbort.abort(new Error('Bot automation execution deadline expired')); + }, remainingMs); + deadlineTimer.unref?.(); + try { + await this.deps.maker.createSession({ + id: sessionId, + agentKind, + workingDir, + model, + effort: configuredEffort(config.effort, schedule.effort), + fastMode: typeof config.fastMode === 'boolean' ? config.fastMode : schedule.fastMode, + permissionMode: 'bypassPermissions', + providerId: typeof config.providerId === 'string' ? config.providerId : undefined, + remoteHostId: binding?.remoteHostId ?? undefined, + title: `${profile.displayName} · ${schedule.name}`.slice(0, 120), + vendorOptions: { source: 'scheduler' }, + }); + if (runAbort.signal.aborted) { + throw runAbort.signal.reason ?? new Error('Bot automation aborted during runtime startup'); + } + await requireStrictAutomationRuntime(db, sessionId, executionPlan); + result = await this.deps.delegate.fire( + delegatedSchedule, + { ...ctx, signal: runAbort.signal }, + ); + // The Bot may have been paused/archived while the agent was running. Scheduler + // cancellation is cooperative, so a runner that settles at the same moment must + // still re-check the frozen lifecycle generation before entering completion. + await validateAutomationExecutionPlan(db, executionPlan); + const [runState] = await db + .select({ status: botAutomationRuns.status, errorMessage: botAutomationRuns.errorMessage }) + .from(botAutomationRuns) + .where(eq(botAutomationRuns.id, automationRunId)) + .limit(1); + if (runState?.status === 'failed') { + throw new Error(runState.errorMessage ?? 'Bot automation failed during execution'); + } + } catch (error) { + const [runState] = await db + .select({ status: botAutomationRuns.status, errorMessage: botAutomationRuns.errorMessage }) + .from(botAutomationRuns) + .where(eq(botAutomationRuns.id, automationRunId)) + .limit(1); + const externallyFailed = runState?.status === 'failed'; + const status = deadlineExpired || externallyFailed + ? 'failed' + : isAbort(error, ctx.signal) + ? 'aborted' + : 'failed'; + await this.finalizeRun({ + automationRunId, + sessionId, + status, + errorMessage: externallyFailed + ? runState.errorMessage + : errorText(error), + }); + if (deadlineExpired) throw new Error('Bot automation execution deadline expired'); + if (externallyFailed && runState.errorMessage) throw new Error(runState.errorMessage); + throw error; + } finally { + clearTimeout(deadlineTimer); + ctx.signal.removeEventListener('abort', abortFromScheduler); + } + + const resultTextSnapshot = result.resultText?.trim() || null; + const outputArtifactsJson = JSON.stringify(collectBotOutputArtifacts(resultTextSnapshot)); + await db + .update(botAutomationRuns) + .set({ + status: 'completing', + resultTextSnapshot, + outputArtifactsJson, + updatedAt: Date.now(), + }) + .where(eq(botAutomationRuns.id, automationRunId)); + + const deliveryTarget = await this.resolveDeliveryTarget( + link.targetRouteId, + profile.id, + targetRouteOwnerGenerationSnapshot, + ); + if (deliveryTarget.ok && deliveryTarget.target && this.deps.enqueueDelivery) { + const text = [ + `[Cindy Bot automation ${schedule.name} completed]`, + resultTextSnapshot ? `Result:\n${resultTextSnapshot}` : '', + `Run task: ${sessionId}`, + ].filter(Boolean).join('\n\n'); + try { + const delivery = await this.deps.enqueueDelivery({ + botId: profile.id, + channelId: deliveryTarget.target.channelId, + routeId: deliveryTarget.target.routeId, + sessionId: deliveryTarget.target.sessionId, + ownerGeneration: deliveryTarget.target.ownerGeneration, + idempotencyKey: `bot-automation-completion:${ctx.runId}`, + payload: { + version: 1, + kind: 'session-message', + targetSessionId: deliveryTarget.target.sessionId, + fallbackBotId: profile.id, + clientId: `bot-automation-completion:${ctx.runId}`, + message: text, + persistedContent: text, + }, + }); + await db + .update(botAutomationRuns) + .set({ + deliveryOutboxId: delivery.id, + deliveryStatus: 'queued', + deliveryError: null, + updatedAt: Date.now(), + }) + .where(eq(botAutomationRuns.id, automationRunId)); + } catch (error) { + // Agent execution and result delivery are independent state machines. + // Never rewrite a successful run as failed merely because its notification + // could not be enqueued; the outbox/diagnostics layer owns retries. + this.deps.logger?.warn?.('[bot-automation] completion delivery enqueue failed', { + scheduleId: schedule.id, + runId: ctx.runId, + sessionId, + routeId: deliveryTarget.target.routeId, + error: errorText(error), + }); + await db + .update(botAutomationRuns) + .set({ + deliveryStatus: 'enqueue-failed', + deliveryError: errorText(error).slice(0, 4_000), + updatedAt: Date.now(), + }) + .where(eq(botAutomationRuns.id, automationRunId)); + } + } else if (!deliveryTarget.ok) { + await db + .update(botAutomationRuns) + .set({ + deliveryStatus: 'enqueue-failed', + deliveryError: deliveryTarget.error, + updatedAt: Date.now(), + }) + .where(eq(botAutomationRuns.id, automationRunId)); + } else if (deliveryTarget.target && !this.deps.enqueueDelivery) { + await db + .update(botAutomationRuns) + .set({ + deliveryStatus: 'enqueue-failed', + deliveryError: 'Bot delivery outbox is not initialized', + updatedAt: Date.now(), + }) + .where(eq(botAutomationRuns.id, automationRunId)); + } + await this.finalizeRun({ automationRunId, sessionId, status: 'success' }); + return result; + } + + private async finalizeClaimOnly( + automationRunId: string, + status: 'failed' | 'aborted' | 'skipped', + ): Promise { + const finishedAt = Date.now(); + await this.deps + .getDb() + .update(botAutomationRuns) + .set({ status, updatedAt: finishedAt, finishedAt }) + .where(eq(botAutomationRuns.id, automationRunId)); + } + + private async finalizeRun(input: { + automationRunId: string; + sessionId: string; + status: 'success' | 'failed' | 'aborted'; + errorMessage?: string | null; + }): Promise { + const db = this.deps.getDb(); + const finishedAt = Date.now(); + const [attachment] = await db + .select({ + leaseId: botWorkspaceAttachments.leaseId, + worktreePath: botWorkspaceLeases.worktreePath, + }) + .from(botWorkspaceAttachments) + .leftJoin(botWorkspaceLeases, eq(botWorkspaceLeases.id, botWorkspaceAttachments.leaseId)) + .where( + and( + eq(botWorkspaceAttachments.sessionId, input.sessionId), + isNull(botWorkspaceAttachments.detachedAt), + ), + ) + .orderBy(desc(botWorkspaceAttachments.createdAt)) + .limit(1); + await getDbClient().tx('bots.finalizeAutomationRun', { + automationRunId: input.automationRunId, + sessionId: input.sessionId, + status: input.status, + errorMessage: input.status === 'success' + ? null + : input.errorMessage?.slice(0, 4_000) ?? null, + workspaceLeaseId: attachment?.leaseId ?? null, + worktreePathSnapshot: attachment?.worktreePath ?? null, + finishedAt, + }); + await (this.deps.archiveSession?.(input.sessionId) ?? db + .update(sessions) + .set({ status: 'archived', updatedAt: finishedAt }) + .where(eq(sessions.id, input.sessionId)) + .then(() => undefined)).catch((error) => { + this.deps.logger?.warn?.('[bot-automation] task archive failed; startup reconcile will retry', { + sessionId: input.sessionId, + error: errorText(error), + }); + }); + await this.deps.maker.closeSession(input.sessionId).catch((error) => { + this.deps.logger?.warn?.('[bot-automation] runtime close failed (non-fatal)', { + sessionId: input.sessionId, + error: errorText(error), + }); + }); + schedulePerTaskWorkspaceReclaim(input.sessionId); + } + + private async resolveDeliveryTarget( + targetRouteId: string | null, + botId: string, + expectedOwnerGeneration: number | null, + ): ReturnType { + return resolveBotAutomationDeliveryTarget( + this.deps.getDb(), + targetRouteId, + botId, + expectedOwnerGeneration, + ); + } +} + +export async function reconcileBotAutomationRuns( + deps: { + getDb: () => SchedulerDrizzleDb; + maker: Maker; + logger?: Logger; + archiveSession?: (sessionId: string) => Promise; + enqueueDelivery?: BotAutomationScheduleRunnerDeps['enqueueDelivery']; + }, +): Promise { + const db = deps.getDb(); + const rows = await db + .select({ + id: botAutomationRuns.id, + automationLinkId: botAutomationRuns.automationLinkId, + status: botAutomationRuns.status, + scheduleRunId: botAutomationRuns.scheduleRunId, + sessionId: botAutomationRuns.sessionId, + finishedAt: botAutomationRuns.finishedAt, + resultTextSnapshot: botAutomationRuns.resultTextSnapshot, + targetRouteIdSnapshot: botAutomationRuns.targetRouteIdSnapshot, + targetRouteOwnerGenerationSnapshot: botAutomationRuns.targetRouteOwnerGenerationSnapshot, + deliveryOutboxId: botAutomationRuns.deliveryOutboxId, + deliveryStatus: botAutomationRuns.deliveryStatus, + botId: botAutomationLinks.botId, + scheduleName: schedules.name, + sessionStatus: sessions.status, + }) + .from(botAutomationRuns) + .innerJoin( + botAutomationLinks, + eq(botAutomationLinks.id, botAutomationRuns.automationLinkId), + ) + .leftJoin(sessions, eq(sessions.id, botAutomationRuns.sessionId)) + .leftJoin(scheduleRuns, eq(scheduleRuns.id, botAutomationRuns.scheduleRunId)) + .leftJoin(schedules, eq(schedules.id, scheduleRuns.scheduleId)) + .where( + or( + inArray(botAutomationRuns.status, ['claimed', 'running', 'completing']), + and( + eq(botAutomationRuns.status, 'success'), + inArray(scheduleRuns.status, ['running', 'interrupted']), + ), + ), + ); + const now = Date.now(); + for (const row of rows) { + let terminalStatus = row.status; + let terminalAt = row.finishedAt ?? now; + if (row.status === 'success') { + terminalAt = row.finishedAt ?? now; + if (row.scheduleRunId) { + await db + .update(scheduleRuns) + .set({ + status: 'success', + resultText: row.resultTextSnapshot, + finishedAt: terminalAt, + heartbeatAt: null, + }) + .where( + and( + eq(scheduleRuns.id, row.scheduleRunId), + inArray(scheduleRuns.status, ['running', 'interrupted']), + ), + ); + } + } else if (row.status === 'completing') { + terminalStatus = 'success'; + terminalAt = now; + if (row.scheduleRunId) { + await db + .update(scheduleRuns) + .set({ + status: 'success', + resultText: row.resultTextSnapshot, + finishedAt: terminalAt, + heartbeatAt: null, + }) + .where(eq(scheduleRuns.id, row.scheduleRunId)); + } + if (!row.deliveryOutboxId && row.deliveryStatus === 'not-requested' && row.sessionId) { + const deliveryTarget = await resolveBotAutomationDeliveryTarget( + db, + row.targetRouteIdSnapshot, + row.botId, + row.targetRouteOwnerGenerationSnapshot, + ); + if (deliveryTarget.ok && deliveryTarget.target && deps.enqueueDelivery) { + const stableRunIdentity = row.scheduleRunId ?? row.id; + const deliveryKey = `bot-automation-completion:${stableRunIdentity}`; + const text = [ + `[Cindy Bot automation ${row.scheduleName ?? 'Automation'} completed]`, + row.resultTextSnapshot ? `Result:\n${row.resultTextSnapshot}` : '', + `Run task: ${row.sessionId}`, + ].filter(Boolean).join('\n\n'); + try { + const delivery = await deps.enqueueDelivery({ + botId: row.botId, + channelId: deliveryTarget.target.channelId, + routeId: deliveryTarget.target.routeId, + sessionId: deliveryTarget.target.sessionId, + ownerGeneration: deliveryTarget.target.ownerGeneration, + idempotencyKey: deliveryKey, + payload: { + version: 1, + kind: 'session-message', + targetSessionId: deliveryTarget.target.sessionId, + fallbackBotId: row.botId, + clientId: deliveryKey, + message: text, + persistedContent: text, + }, + }); + await db + .update(botAutomationRuns) + .set({ + deliveryOutboxId: delivery.id, + deliveryStatus: 'queued', + deliveryError: null, + updatedAt: now, + }) + .where(eq(botAutomationRuns.id, row.id)); + } catch (error) { + await db + .update(botAutomationRuns) + .set({ + deliveryStatus: 'enqueue-failed', + deliveryError: errorText(error).slice(0, 4_000), + updatedAt: now, + }) + .where(eq(botAutomationRuns.id, row.id)); + } + } else if (!deliveryTarget.ok || (deliveryTarget.target && !deps.enqueueDelivery)) { + await db + .update(botAutomationRuns) + .set({ + deliveryStatus: 'enqueue-failed', + deliveryError: deliveryTarget.ok + ? 'Bot delivery outbox is not initialized' + : deliveryTarget.error, + updatedAt: now, + }) + .where(eq(botAutomationRuns.id, row.id)); + } + } + await db + .update(botAutomationRuns) + .set({ status: 'success', updatedAt: now, finishedAt: terminalAt }) + .where(eq(botAutomationRuns.id, row.id)); + } else if (row.status === 'claimed' || row.status === 'running') { + const [scheduleRun] = row.scheduleRunId + ? await db + .select({ status: scheduleRuns.status, finishedAt: scheduleRuns.finishedAt }) + .from(scheduleRuns) + .where(eq(scheduleRuns.id, row.scheduleRunId)) + .limit(1) + : []; + if (scheduleRun?.status === 'running') continue; + terminalStatus = scheduleRun?.status === 'success' + ? 'success' + : scheduleRun?.status === 'failed' + ? 'failed' + : scheduleRun?.status === 'aborted' + ? 'aborted' + : scheduleRun?.status === 'interrupted' + ? 'interrupted' + : 'unknown'; + terminalAt = scheduleRun?.finishedAt ?? now; + await db + .update(botAutomationRuns) + .set({ status: terminalStatus, updatedAt: now, finishedAt: terminalAt }) + .where(eq(botAutomationRuns.id, row.id)); + } + if (row.sessionId) { + await db + .update(botSessionLinks) + .set({ role: 'history', channelId: null, routeKey: null, archivedAt: terminalAt }) + .where(eq(botSessionLinks.sessionId, row.sessionId)); + if (row.sessionStatus === 'active') { + await (deps.archiveSession?.(row.sessionId) ?? db + .update(sessions) + .set({ status: 'archived', updatedAt: terminalAt }) + .where(eq(sessions.id, row.sessionId)) + .then(() => undefined)).catch((error) => { + deps.logger?.warn?.('[bot-automation] reconcile archive failed', { + automationRunId: row.id, + sessionId: row.sessionId, + error: errorText(error), + }); + }); + } + await deps.maker.closeSession(row.sessionId).catch((error) => { + deps.logger?.warn?.('[bot-automation] reconcile runtime close failed', { + automationRunId: row.id, + sessionId: row.sessionId, + error: errorText(error), + }); + }); + schedulePerTaskWorkspaceReclaim(row.sessionId); + } + } +} diff --git a/apps/desktop/src/main/scheduler-host/index.ts b/apps/desktop/src/main/scheduler-host/index.ts index f9206be645..6b4e7902df 100644 --- a/apps/desktop/src/main/scheduler-host/index.ts +++ b/apps/desktop/src/main/scheduler-host/index.ts @@ -24,6 +24,7 @@ import type { FeishuIM } from '@cindy/im'; import { dialogueWorkspaceRootDir } from '../localDb/dialogueWorkspace'; import { sessions } from '../localDb/schema.js'; +import { setSessionsStatusInDb } from '../localDb/ipc/sessions.js'; import { isReviewSessionSource } from '../../shared/sessionSource.js'; import { resolveDefaultScheduleRoute, @@ -43,6 +44,7 @@ import { isSchedulerTargetSessionBusy, onSchedulerAutoResumeFailed, removeQueuedSchedulerPrompt, + enqueueBotDelivery, } from '../maker-ipc/register.js'; import { DrizzleScheduleStorage, type SchedulerDrizzleDb } from './storage'; import { ProjectAutomationLoader } from './project-automation-loader'; @@ -53,6 +55,10 @@ import { SchedulerScriptCapabilityBroker } from './script-capability-broker'; import { DesktopNotifier } from './notifier'; import { withScheduleLock } from './scheduleLock'; import { wecomGroupNotificationService } from '../wecomGroupNotification'; +import { + BotAutomationScheduleRunner, + reconcileBotAutomationRuns, +} from './bot-automation-runner.js'; export interface StartSchedulerDeps { maker: Maker; @@ -116,12 +122,23 @@ export async function startScheduler(deps: StartSchedulerDeps): Promise { + await setSessionsStatusInDb([sessionId], 'archived'); + }, + enqueueDelivery: enqueueBotDelivery, + }); const runner: ScheduleRunner = { fire: (schedule, ctx) => withScheduleLock(schedule.id, ctx.signal, () => schedule.executionMode === 'script' ? scriptRunner.fire(schedule, ctx) - : promptRunner.fire(schedule, ctx), + : botAutomationRunner.fire(schedule, ctx), ), }; @@ -180,6 +197,38 @@ export async function startScheduler(deps: StartSchedulerDeps): Promise => { + botAutomationReconcileRequested = true; + if (botAutomationReconcileRunning) return; + botAutomationReconcileRunning = true; + try { + while (botAutomationReconcileRequested) { + botAutomationReconcileRequested = false; + await reconcileBotAutomationRuns({ + getDb: deps.getDb, + maker: deps.maker, + logger: deps.logger, + archiveSession: async (sessionId) => { + await setSessionsStatusInDb([sessionId], 'archived'); + }, + enqueueDelivery: enqueueBotDelivery, + }); + } + } finally { + botAutomationReconcileRunning = false; + } + }; + // A recently crashed Scheduler run may still look live during startup's + // heartbeat grace period. When the periodic stale-run sweep later marks it + // interrupted, `changed` is the durable handoff that closes the matching Bot + // run and archives its hidden task without waiting for another app restart. + scheduler.on('changed', () => { + void reconcileBotRuns().catch((err) => { + deps.logger.warn?.(`[bot-automation] event reconcile failed (non-fatal): ${String(err)}`); + }); + }); const loader = new ProjectAutomationLoader({ scheduler, storage, @@ -191,6 +240,11 @@ export async function startScheduler(deps: StartSchedulerDeps): Promise 0) deps.logger.info?.(`[scheduler-host] cleaned ${orphans} orphan run(s)`); diff --git a/apps/desktop/src/main/scheduler-host/storage.ts b/apps/desktop/src/main/scheduler-host/storage.ts index 69b92b07fb..e6c5b47001 100644 --- a/apps/desktop/src/main/scheduler-host/storage.ts +++ b/apps/desktop/src/main/scheduler-host/storage.ts @@ -172,7 +172,7 @@ const UNREAD_TERMINAL_RUN_STATUSES: ScheduleRun['status'][] = [ ]; function toScheduleSource(value: string | null): Schedule['source'] | undefined { - if (value === 'user' || value === 'project') return value; + if (value === 'user' || value === 'project' || value === 'bot') return value; return undefined; } @@ -325,9 +325,13 @@ export class DrizzleScheduleStorage implements ScheduleStorage { async list(filter?: ListFilter): Promise { const db = this.getDb(); const query = db.select().from(schedules); + // Bot-owned definitions have a dedicated settings/history surface. Keep + // them out of the generic Automations list while listActive() still loads + // them into the Scheduler engine for automatic firing. + const nonBot = or(isNull(schedules.source), sql`${schedules.source} <> 'bot'`); const rows = filter?.status - ? await query.where(eq(schedules.status, filter.status)) - : await query; + ? await query.where(and(eq(schedules.status, filter.status), nonBot)) + : await query.where(nonBot); return rows.map(scheduleToCamel); } @@ -604,11 +608,14 @@ export class DrizzleScheduleStorage implements ScheduleStorage { .from(scheduleRuns) .innerJoin(schedules, eq(scheduleRuns.scheduleId, schedules.id)) .where( - or( - isNotNull(scheduleRuns.sessionId), - and( - isNull(scheduleRuns.readAt), - inArray(scheduleRuns.status, UNREAD_TERMINAL_RUN_STATUSES), + and( + or(isNull(schedules.source), sql`${schedules.source} <> 'bot'`), + or( + isNotNull(scheduleRuns.sessionId), + and( + isNull(scheduleRuns.readAt), + inArray(scheduleRuns.status, UNREAD_TERMINAL_RUN_STATUSES), + ), ), ), ); @@ -683,7 +690,14 @@ export class DrizzleScheduleStorage implements ScheduleStorage { */ async listCostSummaries(): Promise { const db = this.getDb(); - const runCostRows = (await db.select().from(scheduleRuns)).map(scheduleRunToCamel); + const nonBotScheduleRows = await db + .select({ id: schedules.id }) + .from(schedules) + .where(or(isNull(schedules.source), sql`${schedules.source} <> 'bot'`)); + const nonBotScheduleIds = new Set(nonBotScheduleRows.map((row) => row.id)); + const runCostRows = (await db.select().from(scheduleRuns)) + .map(scheduleRunToCamel) + .filter((row) => nonBotScheduleIds.has(row.scheduleId)); const bySchedule = new Map(); const linkedSessionIds = new Set(); @@ -1001,6 +1015,18 @@ export class DrizzleScheduleStorage implements ScheduleStorage { return scheduleRunToCamel(row); } + /** Resolve the owning Schedule without exposing the run payload itself. */ + async getScheduleForRun(runId: string): Promise { + const db = this.getDb(); + const [row] = await db + .select({ schedule: schedules }) + .from(scheduleRuns) + .innerJoin(schedules, eq(schedules.id, scheduleRuns.scheduleId)) + .where(eq(scheduleRuns.id, runId)) + .limit(1); + return row?.schedule ? scheduleToCamel(row.schedule) : null; + } + async deleteOrphanRuns(): Promise { const db = this.getDb(); // 显式 .run() 才能经 drizzleProxy 拿到 changes(见 claimDueFire 注释) @@ -1117,8 +1143,10 @@ export class DrizzleScheduleStorage implements ScheduleStorage { const [row] = await db .select({ n: sql`count(*)` }) .from(scheduleRuns) + .innerJoin(schedules, eq(scheduleRuns.scheduleId, schedules.id)) .where( and( + or(isNull(schedules.source), sql`${schedules.source} <> 'bot'`), isNull(scheduleRuns.readAt), inArray(scheduleRuns.status, ['success', 'failed', 'aborted', 'interrupted']), ), @@ -1181,6 +1209,11 @@ export class DrizzleScheduleStorage implements ScheduleStorage { .set({ readAt: Date.now() }) .where( and( + sql`${scheduleRuns.scheduleId} IN ( + SELECT ${schedules.id} + FROM ${schedules} + WHERE ${schedules.source} IS NULL OR ${schedules.source} <> 'bot' + )`, isNull(scheduleRuns.readAt), inArray(scheduleRuns.status, ['success', 'failed', 'aborted', 'interrupted']), ), diff --git a/apps/desktop/src/main/sessionSpendBroadcaster.ts b/apps/desktop/src/main/sessionSpendBroadcaster.ts index 6d7fc3efaa..3e04a226ee 100644 --- a/apps/desktop/src/main/sessionSpendBroadcaster.ts +++ b/apps/desktop/src/main/sessionSpendBroadcaster.ts @@ -35,6 +35,14 @@ import { currentLedgerCurrency } from './usage/ledgerCurrency.js'; const log = createLogger('sessionSpendBroadcaster'); +type SessionTokenUsageObserver = (payload: SessionTokensPayload) => void | Promise; +let sessionTokenUsageObserver: SessionTokenUsageObserver | null = null; + +/** Runtime-owned observer; used by bounded child tasks without coupling this module to Maker IPC. */ +export function setSessionTokenUsageObserver(observer: SessionTokenUsageObserver | null): void { + sessionTokenUsageObserver = observer; +} + /** IPC channel: main → renderer 推单 session 累计 cost 变化。 */ export const USAGE_SESSION_SPEND_CHANGED = 'usage:session-spend-changed'; /** IPC channel: main → renderer 推单 session 累计 token 变化。 */ @@ -195,8 +203,17 @@ export async function recordSessionTurnTokens( .from(sessions) .where(sql`${sessions.id} = ${sessionId}`) .get(); + const payload = { sessionId, totalTokens: row?.totalTokenUsage ?? 0 }; + try { + await sessionTokenUsageObserver?.(payload); + } catch (observerError) { + log.warn( + 'session token usage observer failed:', + observerError instanceof Error ? observerError.message : String(observerError), + ); + } if (!isOwnerScopeCurrent(ownerScope)) return; - broadcastTokens({ sessionId, totalTokens: row?.totalTokenUsage ?? 0 }, ownerScope); + broadcastTokens(payload, ownerScope); } catch (err) { log.warn('recordSessionTurnTokens failed:', err instanceof Error ? err.message : String(err)); } diff --git a/apps/desktop/src/main/utility-model/__tests__/oneShotCandidates.test.ts b/apps/desktop/src/main/utility-model/__tests__/oneShotCandidates.test.ts index 6ac57261db..14b476ca30 100644 --- a/apps/desktop/src/main/utility-model/__tests__/oneShotCandidates.test.ts +++ b/apps/desktop/src/main/utility-model/__tests__/oneShotCandidates.test.ts @@ -11,6 +11,10 @@ vi.mock('electron', () => ({ }, })); +vi.mock('../../appCapabilities.js', () => ({ + getAppCapabilities: vi.fn(() => ({ canUseCindyGateway: true })), +})); + vi.mock('../UtilityModelSelection.js', () => ({ getUtilityModelChainProfiles: vi.fn(), })); @@ -70,6 +74,7 @@ vi.mock('../../model-access/effectiveEndpoint.js', async () => { import type { Maker } from '@cindy/maker-core'; import { fetch as undiciFetch } from 'undici'; +import { getAppCapabilities } from '../../appCapabilities.js'; import { readClaudeApiKey } from '../../maker-host/auth-adapters.js'; import { getChatgptBridgeAuth } from '../../maker-host/anthropic-responses-bridge-host.js'; import { getValidClaudeAiOAuth } from '../../maker-host/claude-oauth-refresh.js'; @@ -80,9 +85,16 @@ import { readModelDisableOverrides } from '../../maker-host/model-disable-store. import { isProviderRouteMutationInProgress } from '../../maker-host/provider-route.js'; import { readCustomProviderKey } from '../../secrets/providerSecretStore.js'; import { getUtilityModelChainProfiles } from '../UtilityModelSelection.js'; -import { getUtilityTextCandidates, requestUtilityText, toAnthropicApiModelId } from '../oneShotCandidates.js'; +import { + DEDICATED_AUTO_REVIEW_CANDIDATES, + getUtilityTextCandidates, + requestDedicatedAutoReviewCandidateText, + requestUtilityText, + toAnthropicApiModelId, +} from '../oneShotCandidates.js'; const getProfiles = vi.mocked(getUtilityModelChainProfiles); +const appCapabilities = vi.mocked(getAppCapabilities); const readKey = vi.mocked(readClaudeApiKey); const readCodexCreds = vi.mocked(getChatgptBridgeAuth); const readClaudeOAuth = vi.mocked(getValidClaudeAiOAuth); @@ -105,6 +117,8 @@ function makerMock(authenticated: boolean): Maker { describe('utility one-shot candidates', () => { beforeEach(() => { vi.clearAllMocks(); + fetchMock.mockReset(); + appCapabilities.mockReturnValue({ canUseCindyGateway: true } as never); readKey.mockReturnValue(null); readCodexCreds.mockRejectedValue(new Error('not authenticated')); readClaudeOAuth.mockResolvedValue(null); @@ -1619,6 +1633,203 @@ describe('utility one-shot candidates', () => { expect(body).not.toHaveProperty('max_output_tokens'); }); + it('routes the fixed cindy/auto-review alias without requiring a catalog entry', async () => { + readKey.mockReturnValue('xd-key'); + activeCatalog.mockReturnValue({ providers: [] } as never); + fetchMock.mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ + choices: [{ message: { content: '{"verdict":"allow"}' } }], + }), + } as never); + + const result = await requestDedicatedAutoReviewCandidateText( + 'classify', + DEDICATED_AUTO_REVIEW_CANDIDATES[0], + { timeoutMs: 8_000 }, + ); + + expect(result).toMatchObject({ + ok: true, + providerId: 'xd', + model: 'cindy/auto-review', + }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://gateway.test.invalid/v1/chat/completions', + expect.anything(), + ); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + model: 'cindy/auto-review', + max_tokens: 384, + messages: [{ role: 'user', content: 'classify' }], + }); + }); + + it('cancels the Gateway HTTP request through the candidate signal', async () => { + readKey.mockReturnValue('xd-key'); + const controller = new AbortController(); + fetchMock.mockImplementationOnce((_url, init) => + new Promise((_resolve, reject) => { + (init?.signal as AbortSignal | undefined)?.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ); + })); + + const pending = requestDedicatedAutoReviewCandidateText( + 'classify', + DEDICATED_AUTO_REVIEW_CANDIDATES[0], + { timeoutMs: 12_000, signal: controller.signal }, + ); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + controller.abort(); + + await expect(pending).resolves.toMatchObject({ ok: false, reason: 'timeout' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('does not use the Gateway alias outside a Cindy cloud session', async () => { + appCapabilities.mockReturnValue({ canUseCindyGateway: false } as never); + readKey.mockReturnValue('xd-key'); + + const result = await requestDedicatedAutoReviewCandidateText( + 'classify', + DEDICATED_AUTO_REVIEW_CANDIDATES[0], + { timeoutMs: 8_000 }, + ); + + expect(result).toMatchObject({ + ok: false, + reason: 'no_candidate', + attempts: [expect.objectContaining({ reason: 'not_authenticated' })], + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('uses a supported ChatGPT subscription model with a tool-free Responses body', async () => { + activeCatalog.mockReturnValue({ + providers: [{ + id: 'openai', + name: 'OpenAI', + source: 'builtin', + agents: ['codex'], + auth: { method: 'oauth' }, + routing: { + codex: { + upstream: 'https://chatgpt.example/backend-api/codex', + authStrategy: 'oauth-passthrough', + }, + }, + models: { + codex: [{ id: 'gpt-5.4-nano', name: 'Nano', contextWindow: 272_000 }], + }, + }], + } as never); + readCodexCreds.mockResolvedValue({ accessToken: 'codex-token', accountId: 'account-1' }); + fetchMock.mockResolvedValueOnce({ + ok: true, + text: async () => + 'data: {"type":"response.output_text.delta","delta":"{\\"verdict\\":\\"allow\\"}"}\ndata: [DONE]\n', + } as never); + + const result = await requestDedicatedAutoReviewCandidateText( + 'classify', + DEDICATED_AUTO_REVIEW_CANDIDATES[1], + { timeoutMs: 8_000 }, + ); + + expect(result).toMatchObject({ + ok: true, + providerId: 'openai', + model: 'gpt-5.4-nano', + }); + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as Record; + expect(body).toMatchObject({ + model: 'gpt-5.4-nano', + reasoning: { effort: 'low' }, + }); + expect(body).not.toHaveProperty('tools'); + expect(body).not.toHaveProperty('tool_choice'); + expect(body).not.toHaveProperty('parallel_tool_calls'); + }); + + it('does not start OpenAI HTTP after credential refresh outlives the candidate', async () => { + activeCatalog.mockReturnValue({ + providers: [{ + id: 'openai', + name: 'OpenAI', + source: 'builtin', + agents: ['codex'], + auth: { method: 'oauth' }, + routing: { + codex: { + upstream: 'https://chatgpt.example/backend-api/codex', + authStrategy: 'oauth-passthrough', + }, + }, + models: { + codex: [{ id: 'gpt-5.4-nano', name: 'Nano', contextWindow: 272_000 }], + }, + }], + } as never); + let resolveAuth: ((auth: { accessToken: string; accountId: string }) => void) | undefined; + readCodexCreds.mockImplementationOnce(() => new Promise((resolve) => { + resolveAuth = resolve; + })); + const controller = new AbortController(); + + const pending = requestDedicatedAutoReviewCandidateText( + 'classify', + DEDICATED_AUTO_REVIEW_CANDIDATES[1], + { timeoutMs: 12_000, signal: controller.signal }, + ); + await Promise.resolve(); + controller.abort(); + resolveAuth?.({ accessToken: 'late-token', accountId: 'late-account' }); + + await expect(pending).resolves.toMatchObject({ ok: false, reason: 'timeout' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not start Anthropic HTTP after credential refresh outlives the candidate', async () => { + activeCatalog.mockReturnValue({ + providers: [{ + id: 'anthropic', + name: 'Anthropic', + source: 'builtin', + agents: ['claude-code'], + auth: { method: 'oauth' }, + routing: { + 'claude-code': { + upstream: 'https://anthropic.example', + authStrategy: 'oauth-passthrough', + }, + }, + models: { + 'claude-code': [{ id: 'claude-haiku-4-5', name: 'Haiku', contextWindow: 200_000 }], + }, + }], + } as never); + let resolveOAuth: ((auth: { accessToken: string }) => void) | undefined; + readClaudeOAuth.mockImplementationOnce(() => new Promise((resolve) => { + resolveOAuth = resolve; + })); + const controller = new AbortController(); + + const pending = requestDedicatedAutoReviewCandidateText( + 'classify', + DEDICATED_AUTO_REVIEW_CANDIDATES[3], + { timeoutMs: 12_000, signal: controller.signal }, + ); + await Promise.resolve(); + controller.abort(); + resolveOAuth?.({ accessToken: 'late-token' }); + + await expect(pending).resolves.toMatchObject({ ok: false, reason: 'timeout' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('uses xAI OAuth and the selected xAI Responses route', async () => { activeCatalog.mockReturnValue({ providers: [{ @@ -1646,10 +1857,14 @@ describe('utility one-shot candidates', () => { expect(result).toMatchObject({ ok: true, providerId: 'xai', model: 'xai/grok-4.3' }); expect(fetchMock).toHaveBeenCalledWith('https://xai.example/v1/responses', expect.anything()); - expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({ + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as Record; + expect(body).toMatchObject({ model: 'grok-4.3', reasoning: { effort: 'low' }, }); + expect(body).not.toHaveProperty('tools'); + expect(body).not.toHaveProperty('tool_choice'); + expect(body).not.toHaveProperty('parallel_tool_calls'); }); it.each(['xai/grok-code-fast', 'xai/grok-build-preview'])( diff --git a/apps/desktop/src/main/utility-model/oneShotCandidates.ts b/apps/desktop/src/main/utility-model/oneShotCandidates.ts index 0b99284c73..459f2a1b81 100644 --- a/apps/desktop/src/main/utility-model/oneShotCandidates.ts +++ b/apps/desktop/src/main/utility-model/oneShotCandidates.ts @@ -4,6 +4,7 @@ import { type AgentKind, type Maker } from '@cindy/maker-core'; import { appendProviderRequestPath, storedCustomProviderId } from '@cindy/model-providers'; import { createLogger } from '../logger.js'; +import { getAppCapabilities } from '../appCapabilities.js'; import { readClaudeApiKey } from '../maker-host/auth-adapters.js'; import { getChatgptBridgeAuth } from '../maker-host/anthropic-responses-bridge-host.js'; import { getValidClaudeAiOAuth } from '../maker-host/claude-oauth-refresh.js'; @@ -57,6 +58,8 @@ export type UtilityTextRequestOptions = { timeoutMs?: number; /** Optional lightweight reasoning hint for short internal classifiers. */ reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high'; + /** Abort an in-flight direct HTTP request when the owning workflow ends. */ + signal?: AbortSignal; /** 显式任务来源;存在时禁止跨来源 fallback。 */ providerId?: string; agentKind?: AgentKind; @@ -271,6 +274,144 @@ export async function requestUtilityText( return requestDefaultUtilityText(maker, prompt, opts); } +const DEDICATED_AUTO_REVIEW_MAX_TOKENS = 384; + +/** + * Auto-review 的封闭候选表。它刻意不接受调用方传 provider/model:待审内容只能 + * 发往 Cindy 托管网关或用户已连接的 OpenAI/Anthropic 订阅,不能跟随主会话 + * 落到 xAI、DeepSeek、Kimi 或自定义 BYOM。 + */ +export const DEDICATED_AUTO_REVIEW_CANDIDATES = Object.freeze([ + { + id: 'cindy-gateway', + providerId: 'xd', + agentKind: 'codex', + model: 'cindy/auto-review', + transport: 'litellm-chat-completions', + reasoningEffort: undefined, + }, + { + id: 'chatgpt-nano', + providerId: 'openai', + agentKind: 'codex', + model: 'gpt-5.4-nano', + transport: 'codex-responses', + reasoningEffort: 'low', + }, + { + id: 'chatgpt-luna', + providerId: 'openai', + agentKind: 'codex', + model: 'gpt-5.6-luna', + transport: 'codex-responses', + reasoningEffort: 'low', + }, + { + id: 'claude-haiku', + providerId: 'anthropic', + agentKind: 'claude-code', + model: 'claude-haiku-4-5', + transport: 'litellm-chat-completions', + reasoningEffort: undefined, + }, +] as const satisfies ReadonlyArray<{ + id: string; + providerId: 'xd' | 'openai' | 'anthropic'; + agentKind: AgentKind; + model: string; + transport: UtilityModelTransport; + reasoningEffort: 'low' | undefined; +}>); + +export type DedicatedAutoReviewCandidate = (typeof DEDICATED_AUTO_REVIEW_CANDIDATES)[number]; + +/** + * 执行一个专用 Auto-review 候选。 + * + * Gateway 别名不属于用户模型目录,必须走这条受限入口绕过普通显式路由的目录 + * 校验;订阅候选反过来必须存在于实时目录,防止对账号不支持的模型盲发请求。 + */ +export async function requestDedicatedAutoReviewCandidateText( + prompt: string, + candidate: DedicatedAutoReviewCandidate, + opts: { timeoutMs: number; signal?: AbortSignal }, +): Promise { + const profile: UtilityModelProfile = { + id: candidate.providerId, + model: candidate.model, + transport: candidate.transport, + auth: candidate.providerId === 'xd' ? 'api-key' : 'codex', + settingsTab: 'providers', + missingCredentialMessage: 'The Auto-review provider is not authenticated.', + }; + + if (opts.signal?.aborted) return cancelledUtilityTextResult(profile); + if (isProviderDisabled(readModelDisableOverrides(), candidate.providerId)) { + return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'model_unavailable')] }; + } + + if (candidate.id === 'cindy-gateway') { + if (!getAppCapabilities().canUseCindyGateway) { + return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'not_authenticated')] }; + } + const apiKey = readClaudeApiKey(); + const baseUrl = effectiveXdGatewayBaseUrl().trim(); + if (!apiKey) { + return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'api_key_missing')] }; + } + if (!baseUrl) { + return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'endpoint_missing')] }; + } + return executeCandidates([{ + providerId: candidate.providerId, + model: candidate.model, + transport: candidate.transport, + profile, + execute: (text, requestOpts) => requestProviderHttpText({ + wire: 'chat-completions', + endpoint: joinProxyPath(baseUrl, '/v1/chat/completions'), + headers: { Authorization: `Bearer ${apiKey}` }, + model: candidate.model, + prompt: text, + maxTokens: DEDICATED_AUTO_REVIEW_MAX_TOKENS, + timeoutMs: requestOpts?.timeoutMs ?? opts.timeoutMs, + signal: requestOpts?.signal ?? opts.signal, + }), + }], prompt, [], { + maxTokens: DEDICATED_AUTO_REVIEW_MAX_TOKENS, + timeoutMs: opts.timeoutMs, + signal: opts.signal, + }); + } + + const provider = getActiveCatalog().providers.find((item) => item.id === candidate.providerId); + const configured = provider?.models[candidate.agentKind] ?? []; + if ( + !provider + || !provider.agents.includes(candidate.agentKind) + || !provider.routing[candidate.agentKind] + ) { + return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'agent_unavailable')] }; + } + if (!configured.some((model) => model.id === candidate.model)) { + return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'model_unavailable')] }; + } + if (isProviderModelRouteDisabled(candidate.providerId, candidate.model)) { + return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'model_unavailable')] }; + } + + return requestBuiltinProviderText(prompt, { + provider, + agentKind: candidate.agentKind, + model: candidate.model, + transport: candidate.transport, + maxTokens: DEDICATED_AUTO_REVIEW_MAX_TOKENS, + timeoutMs: opts.timeoutMs, + reasoningEffort: candidate.reasoningEffort, + signal: opts.signal, + }); +} + /** Older remote/mobile callers may omit providerId; a model unique to one * non-XD provider is still enough to preserve the selected route. */ function inferUniqueProviderId(agentKind: AgentKind | undefined, model: string | undefined): string | undefined { @@ -524,6 +665,7 @@ async function requestExplicitProviderText( maxTokens: requestOpts?.maxTokens, timeoutMs: requestOpts?.timeoutMs, reasoningEffort: requestOpts?.reasoningEffort, + signal: requestOpts?.signal, }), }; return executeCandidates([candidate], prompt, [], opts); @@ -542,6 +684,20 @@ function supportsXaiReasoning(model: string): boolean { return !(normalized.startsWith('grok-code') || normalized.startsWith('grok-build')); } +function cancelledUtilityTextResult(profile: UtilityModelProfile): UtilityTextResult { + return { + ok: false, + reason: 'timeout', + attempts: [{ + providerId: profile.id, + model: profile.model, + transport: profile.transport, + status: 'failed', + reason: 'timeout', + }], + }; +} + // 内置供应商的执行分支只认下面硬编码的 xd/anthropic/openai/xai 四家;钉档 // 清单侧(textOneshotPinOptions.isRoutableForOneshot)按同一集合过滤——新增 // 第五个聊天型内置供应商时两边一起动,否则清单会列出这里接不住的模型。 @@ -555,6 +711,7 @@ async function requestBuiltinProviderText( maxTokens?: number; timeoutMs?: number; reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high'; + signal?: AbortSignal; }, ): Promise { const profile: UtilityModelProfile = { @@ -565,6 +722,7 @@ async function requestBuiltinProviderText( settingsTab: 'providers', missingCredentialMessage: 'The selected provider is not authenticated.', }; + if (input.signal?.aborted) return cancelledUtilityTextResult(profile); const routing = input.provider.routing[input.agentKind]; if (!routing) { return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'agent_unavailable')] }; @@ -602,12 +760,14 @@ async function requestBuiltinProviderText( maxTokens: requestOpts?.maxTokens ?? input.maxTokens, timeoutMs: requestOpts?.timeoutMs ?? input.timeoutMs, reasoningEffort: requestOpts?.reasoningEffort ?? input.reasoningEffort, + signal: requestOpts?.signal ?? input.signal, }), }], prompt, [], input); } if (input.provider.id === 'anthropic') { const oauth = await getValidClaudeAiOAuth(); + if (input.signal?.aborted) return cancelledUtilityTextResult(profile); if (!oauth?.accessToken) { return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'not_authenticated')] }; } @@ -633,6 +793,7 @@ async function requestBuiltinProviderText( maxTokens: requestOpts?.maxTokens ?? input.maxTokens ?? catalogModel?.maxOutput ?? 81_920, timeoutMs: requestOpts?.timeoutMs ?? input.timeoutMs, reasoningEffort: requestOpts?.reasoningEffort ?? input.reasoningEffort, + signal: requestOpts?.signal ?? input.signal, }), }], prompt, [], input); } @@ -644,6 +805,7 @@ async function requestBuiltinProviderText( } catch { return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'not_authenticated')] }; } + if (input.signal?.aborted) return cancelledUtilityTextResult(profile); if (!creds.accountId) { return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'not_authenticated')] }; } @@ -673,6 +835,7 @@ async function requestBuiltinProviderText( // parameter with HTTP 400. The Auto reviewer enforces its own compact // output ceiling after the response instead. supportsMaxOutputTokens: false, + signal: requestOpts?.signal ?? input.signal, }), }], prompt, [], input); } @@ -684,6 +847,7 @@ async function requestBuiltinProviderText( } catch { return { ok: false, reason: 'no_candidate', attempts: [skippedAttempt(profile, 'not_authenticated')] }; } + if (input.signal?.aborted) return cancelledUtilityTextResult(profile); return executeCandidates([{ providerId: input.provider.id, model: input.model, @@ -699,6 +863,7 @@ async function requestBuiltinProviderText( timeoutMs: requestOpts?.timeoutMs ?? input.timeoutMs, reasoningEffort: requestOpts?.reasoningEffort ?? input.reasoningEffort, supportsReasoning: supportsXaiReasoning(input.model), + signal: requestOpts?.signal ?? input.signal, }), }], prompt, [], input); } @@ -814,6 +979,7 @@ function resolveLiteLlmCandidate(profile: UtilityModelProfile): UtilityTextCandi maxTokens: opts?.maxTokens, timeoutMs: opts?.timeoutMs, reasoningEffort: opts?.reasoningEffort, + signal: opts?.signal, }), }, }; @@ -827,9 +993,13 @@ async function requestLiteLlmText(input: { maxTokens?: number; timeoutMs?: number; reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high'; + signal?: AbortSignal; }): Promise { const controller = new AbortController(); const timeoutMs = input.timeoutMs ?? 20_000; + const abortFromParent = () => controller.abort(); + if (input.signal?.aborted) abortFromParent(); + else input.signal?.addEventListener('abort', abortFromParent, { once: true }); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await undiciFetch(joinProxyPath(input.baseUrl, '/v1/chat/completions'), { @@ -872,6 +1042,7 @@ async function requestLiteLlmText(input: { throw new UtilityTextExecutionError({ reason: 'request_failed' }); } finally { clearTimeout(timeout); + input.signal?.removeEventListener('abort', abortFromParent); } } @@ -955,9 +1126,14 @@ async function requestProviderHttpText(input: { retryWithMinimalBodyOnInvalidRequest?: boolean; /** Some private Responses-compatible endpoints reject max_output_tokens. */ supportsMaxOutputTokens?: boolean; + /** Owning workflow cancellation; linked with the candidate timeout below. */ + signal?: AbortSignal; }): Promise { const controller = new AbortController(); const timeoutMs = input.timeoutMs ?? 90_000; + const abortFromParent = () => controller.abort(); + if (input.signal?.aborted) abortFromParent(); + else input.signal?.addEventListener('abort', abortFromParent, { once: true }); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const supportsRequestedReasoning = Boolean( @@ -973,9 +1149,6 @@ async function requestProviderHttpText(input: { model: input.model, input: [{ type: 'message', role: 'user', content: [{ type: 'input_text', text: input.prompt }] }], ...(!minimal ? { - tools: [], - tool_choice: 'auto', - parallel_tool_calls: false, store: false, stream: true, } : {}), @@ -1053,6 +1226,7 @@ async function requestProviderHttpText(input: { throw new UtilityTextExecutionError({ reason: 'request_failed' }); } finally { clearTimeout(timeout); + input.signal?.removeEventListener('abort', abortFromParent); } } @@ -1136,6 +1310,7 @@ async function requestCustomProviderText(input: { maxTokens?: number; timeoutMs?: number; reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high'; + signal?: AbortSignal; }): Promise { const headers: Record = { ...(input.headers ?? {}), @@ -1182,6 +1357,7 @@ async function requestCustomProviderText(input: { timeoutMs: input.timeoutMs, reasoningEffort: input.reasoningEffort, retryWithMinimalBodyOnInvalidRequest: true, + signal: input.signal, }); } diff --git a/apps/desktop/src/main/worktree/sessionRemovalRecycle.ts b/apps/desktop/src/main/worktree/sessionRemovalRecycle.ts index 96427e8f43..1876052cc9 100644 --- a/apps/desktop/src/main/worktree/sessionRemovalRecycle.ts +++ b/apps/desktop/src/main/worktree/sessionRemovalRecycle.ts @@ -117,7 +117,12 @@ async function recycleOwnWorktreeForRemovedSession( */ async function findOwningWorktreeSessionIds(sessionId: string): Promise { const row = await readSessionRecycleSnapshot(sessionId); - if (!row || (row.status !== 'deleted' && row.status !== 'archived')) return []; + if ( + !row || + row.source === 'bot' || + (row.status !== 'deleted' && row.status !== 'archived') + ) + return []; const sharedPathKeys = new Set(); const workingDirKey = pathKey(row.workingDir); @@ -139,6 +144,7 @@ async function findOwningWorktreeSessionIds(sessionId: string): Promise { try { const [row] = await db - .select({ status: sessions.status }) + .select({ status: sessions.status, source: sessions.source }) .from(sessions) .where(eq(sessions.id, sessionId)); + if (row?.source === 'bot') return null; return row?.status ?? null; } catch (err) { log.warn( @@ -209,11 +217,11 @@ export async function reconcileWorktreesForDeletedSessions(): Promise { const candidates = store.getAll().filter((m) => !m.ephemeral); if (candidates.length === 0) return; - let rows: Array<{ id: string; status: string | null }>; + let rows: Array<{ id: string; status: string | null; source: string | null }>; try { const db = getDbClient().drizzle; rows = await db - .select({ id: sessions.id, status: sessions.status }) + .select({ id: sessions.id, status: sessions.status, source: sessions.source }) .from(sessions) .where( inArray( @@ -230,10 +238,11 @@ export async function reconcileWorktreesForDeletedSessions(): Promise { return; } - const statusById = new Map(rows.map((r) => [r.id, r.status])); + const rowById = new Map(rows.map((r) => [r.id, r])); for (const meta of candidates) { - const status = statusById.get(meta.sessionId); - const orphaned = status === undefined || status === 'deleted'; + const row = rowById.get(meta.sessionId); + const status = row?.status; + const orphaned = !row || (row.source !== 'bot' && status === 'deleted'); if (!orphaned) continue; log.info( `[sessionRemovalRecycle] reconciling orphaned worktree at ${meta.path} (session ${meta.sessionId}, status=${status ?? 'missing'})`, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 6b1ce91a35..70d8c2c502 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -47,9 +47,7 @@ import { type TerminateAgentProcessRequest, type TerminateAgentProcessResult, } from '../shared/processMonitor'; -import { - RESOURCE_USAGE_WINDOW_OPEN_CHANNEL, -} from '../shared/resourceUsageWindow'; +import { RESOURCE_USAGE_WINDOW_OPEN_CHANNEL } from '../shared/resourceUsageWindow'; import { SESSION_ATTENTION_CLEARED_CHANNEL } from '../shared/sessionAttention'; import { VOICE_INPUT_POWER_STATE_CHANNEL } from '../shared/voiceInputPowerIpc'; import { @@ -630,6 +628,10 @@ const fanOutIOSSimulatorRouteStatus = createIpcFanOut(IOS_SIMULATOR_ROUTE_STATUS const fanOutMakerSessionBackgroundActivityChanged = createIpcFanOut( 'maker:session-background-activity-changed', ); +const fanOutBotDelegationChanged = createIpcFanOut('maker:bot-delegation:changed'); +const fanOutBotAutomationChanged = createIpcFanOut('maker:bot-automation:changed'); +const fanOutBotLifecycleChanged = createIpcFanOut('maker:bot-lifecycle:changed'); +const fanOutBotDeliveryChanged = createIpcFanOut('maker:bot-delivery:changed'); const fanOutMakerUsageTodaySpend = createIpcFanOut('usage:today-spend-changed'); // Claude USD const fanOutMakerUsageTodayTokens = createIpcFanOut('usage:today-tokens-changed'); // Codex token const fanOutMakerUsageModelPricing = createIpcFanOut('usage:model-pricing-changed'); @@ -1044,8 +1046,7 @@ contextBridge.exposeInMainWorld('electronAPI', { expectedPackageSha256: string; expectedInstalledApproval: string; }, - ): Promise<{ ghost: unknown }> => - ipcRenderer.invoke('ghosts:update', lizFilePath, opts), + ): Promise<{ ghost: unknown }> => ipcRenderer.invoke('ghosts:update', lizFilePath, opts), cindyPrefsSync: ( id: string, ): { @@ -1115,7 +1116,9 @@ contextBridge.exposeInMainWorld('electronAPI', { iconDataUrl?: string; }> => ipcRenderer.invoke('ghosts:inspect', lizFilePath), /** 本地包第三条恢复路径:从已装目录读确认卡事实(零副作用)。 */ - reapproveInspect: (id: string): Promise<{ + reapproveInspect: ( + id: string, + ): Promise<{ manifest: unknown; trust: unknown; manifestSha256: string; @@ -1133,8 +1136,7 @@ contextBridge.exposeInMainWorld('electronAPI', { expectedInstalledApproval: string; inspectTicket: string; }, - ): Promise<{ ghost: unknown }> => - ipcRenderer.invoke('ghosts:reapprove-installed', id, opts), + ): Promise<{ ghost: unknown }> => ipcRenderer.invoke('ghosts:reapprove-installed', id, opts), uninstall: (id: string): Promise<{ ok: true }> => ipcRenderer.invoke('ghosts:uninstall', id), /** 详情页「导出 .cindy」:main 打包安装目录 → 系统保存对话框落盘。 */ export: ( @@ -4101,9 +4103,17 @@ contextBridge.exposeInMainWorld('electronAPI', { ): Promise<{ ok: true; daemonReady: boolean }> => ipcRenderer.invoke('maker:remote-ssh:cc-mgr-force-upgrade', { hostId, sessionId, agent }), ccMgrListPendingUpgrades: (): Promise<{ - pending: Array<{ hostId: string; currentVersion: string; availableVersion: string; agent: 'cc' | 'pi' }>; + pending: Array<{ + hostId: string; + currentVersion: string; + availableVersion: string; + agent: 'cc' | 'pi'; + }>; }> => ipcRenderer.invoke('maker:remote-ssh:cc-mgr-list-pending-upgrades'), - ccMgrDismissPendingUpgrade: (hostId: string, agent: 'cc' | 'pi' = 'cc'): Promise<{ ok: true }> => + ccMgrDismissPendingUpgrade: ( + hostId: string, + agent: 'cc' | 'pi' = 'cc', + ): Promise<{ ok: true }> => ipcRenderer.invoke('maker:remote-ssh:cc-mgr-dismiss-pending-upgrade', { hostId, agent }), // ── Codex auth sync (Phase B+) ──────────────────────────────────────── @@ -4594,6 +4604,51 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('local-db:sessions:ack-interrupted', id), // Stage 2 C2: fork 已迁到 electronAPI.maker.fork (走 maker:fork IPC)。 }, + bots: { + list: (): Promise => ipcRenderer.invoke('local-db:bots:list'), + listChannelConnections: (): Promise => + ipcRenderer.invoke('local-db:bots:channel-connections'), + get: (botId: string): Promise => ipcRenderer.invoke('local-db:bots:get', botId), + export: (body: { botId: string }): Promise => + ipcRenderer.invoke('local-db:bots:export', body), + import: (): Promise => ipcRenderer.invoke('local-db:bots:import'), + health: (botId: string): Promise => + ipcRenderer.invoke('local-db:bots:health', botId), + lifecycleEvents: (body: unknown): Promise => + ipcRenderer.invoke('local-db:bots:lifecycle-events', body), + searchHistory: (body: unknown): Promise => + ipcRenderer.invoke('local-db:bots:search-history', body), + create: (body: unknown): Promise => ipcRenderer.invoke('local-db:bots:create', body), + migrateLegacy: (body: unknown): Promise => + ipcRenderer.invoke('local-db:bots:migrate-legacy', body), + update: (body: unknown): Promise => ipcRenderer.invoke('local-db:bots:update', body), + upsertChannel: (body: unknown): Promise => + ipcRenderer.invoke('local-db:bots:channel-upsert', body), + planImMigration: (body: unknown): Promise => + ipcRenderer.invoke('local-db:bots:im-migration-plan', body), + applyImMigration: (body: unknown): Promise => + ipcRenderer.invoke('local-db:bots:im-migration-apply', body), + listImMigrations: (botId: string): Promise => + ipcRenderer.invoke('local-db:bots:im-migrations-list', botId), + rollbackImMigration: (body: unknown): Promise => + ipcRenderer.invoke('local-db:bots:im-migration-rollback', body), + upsertRoute: (body: unknown): Promise => + ipcRenderer.invoke('local-db:bots:route-upsert', body), + setRouteStatus: (body: unknown): Promise => + ipcRenderer.invoke('local-db:bots:route-set-status', body), + upsertProjectBinding: (body: unknown): Promise => + ipcRenderer.invoke('local-db:bots:project-binding-upsert', body), + archiveProjectBinding: (body: unknown): Promise => + ipcRenderer.invoke('local-db:bots:project-binding-archive', body), + releaseWorkspaceLease: (body: unknown): Promise => + ipcRenderer.invoke('local-db:bots:workspace-lease-release', body), + createCanonicalSession: (body: unknown): Promise => + ipcRenderer.invoke('local-db:bots:create-canonical-session', body), + linkSession: (body: unknown): Promise => + ipcRenderer.invoke('local-db:bots:link-session', body), + history: (botId: string): Promise => + ipcRenderer.invoke('local-db:bots:history', botId), + }, conversations: { search: (request: unknown): Promise => ipcRenderer.invoke('local-db:conversations:search', request), @@ -4918,6 +4973,67 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('maker:list-available-agents'), getCapabilities: (agentKind: 'claude-code' | 'codex' | 'pi'): Promise => ipcRenderer.invoke('maker:get-capabilities', agentKind), + listBotDelegations: ( + parentSessionId: string, + status?: import('../shared/botDelegation').BotDelegationStatus, + ): Promise => + ipcRenderer.invoke('maker:bot-delegations:list', parentSessionId, status), + cancelBotDelegation: ( + parentSessionId: string, + delegationId: string, + ): Promise => + ipcRenderer.invoke('maker:bot-delegation:cancel', parentSessionId, delegationId), + onBotDelegationChanged: fanOutBotDelegationChanged, + runBotLifecycleAction: ( + request: import('../shared/botLifecycle').BotLifecycleActionRequest, + ): Promise => + ipcRenderer.invoke('maker:bot-lifecycle:action', request), + onBotLifecycleChanged: fanOutBotLifecycleChanged, + botDeliveries: { + list: (botId: string, limit?: number): Promise => + ipcRenderer.invoke('maker:bot-deliveries:list', botId, limit), + retry: (botId: string, deliveryId: string, allowDuplicateRisk = false): Promise<{ id: string }> => + ipcRenderer.invoke('maker:bot-delivery:retry', botId, deliveryId, allowDuplicateRisk), + onChanged: fanOutBotDeliveryChanged, + }, + botAutomations: { + list: (botId: string): Promise => + ipcRenderer.invoke('maker:bot-automations:list', botId), + create: ( + input: import('../shared/botAutomation').CreateBotAutomationInput, + ): Promise => + ipcRenderer.invoke('maker:bot-automation:create', input), + update: ( + automationId: string, + patch: import('../shared/botAutomation').UpdateBotAutomationInput, + ): Promise => + ipcRenderer.invoke('maker:bot-automation:update', automationId, patch), + pause: (automationId: string): Promise => + ipcRenderer.invoke('maker:bot-automation:pause', automationId), + resume: (automationId: string): Promise => + ipcRenderer.invoke('maker:bot-automation:resume', automationId), + runNow: (automationId: string): Promise<{ runId: string }> => + ipcRenderer.invoke('maker:bot-automation:run-now', automationId), + delete: (automationId: string): Promise => + ipcRenderer.invoke('maker:bot-automation:delete', automationId), + listRuns: ( + automationId: string, + limit?: number, + ): Promise => + ipcRenderer.invoke('maker:bot-automation:list-runs', automationId, limit), + retryDelivery: ( + automationId: string, + runId: string, + allowDuplicateRisk = false, + ): Promise => + ipcRenderer.invoke( + 'maker:bot-automation:retry-delivery', + automationId, + runId, + allowDuplicateRisk, + ), + onChanged: fanOutBotAutomationChanged, + }, listTurnChangeSets: ( sessionId: string, ): Promise => @@ -5114,9 +5230,7 @@ contextBridge.exposeInMainWorld('electronAPI', { ): Promise => ipcRenderer.invoke('maker:model-visibility:sync', dataOwnerId, ownerGeneration, map), claimLegacyModelVisibilityOwner: (): ModelVisibilityLegacyOwnerClaim => { - const value: unknown = ipcRenderer.sendSync( - 'maker:model-visibility:legacy-owner-claim-sync', - ); + const value: unknown = ipcRenderer.sendSync('maker:model-visibility:legacy-owner-claim-sync'); return isModelVisibilityLegacyOwnerClaim(value) ? value : { @@ -5232,7 +5346,12 @@ contextBridge.exposeInMainWorld('electronAPI', { listAgentSkills: ( agentKind: 'claude-code' | 'codex' | 'pi', - params: { workingDir?: string; forceReload?: boolean; sessionId?: string }, + params: { + workingDir?: string; + remoteHostId?: string; + forceReload?: boolean; + sessionId?: string; + }, ): Promise<{ success: boolean; error?: string; diff --git a/apps/desktop/src/renderer/__tests__/addProviderWizardPresetEntry.test.tsx b/apps/desktop/src/renderer/__tests__/addProviderWizardPresetEntry.test.tsx index 86ce595b14..da65e06450 100644 --- a/apps/desktop/src/renderer/__tests__/addProviderWizardPresetEntry.test.tsx +++ b/apps/desktop/src/renderer/__tests__/addProviderWizardPresetEntry.test.tsx @@ -129,6 +129,45 @@ const dualDiscoveryPreset = { }, }; +const zhipuCodingPreset = { + id: 'zhipu-coding-plan-cn', + name: 'Zhipu GLM Coding Plan', + runtimes: { + codex: { + baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', + wireProtocol: 'openai-chat' as const, + models: [{ id: 'glm-5.2', name: 'GLM-5.2' }], + modelDiscovery: [ + { + baseUrl: 'https://open.bigmodel.cn/api/v1', + modelsUrl: 'https://open.bigmodel.cn/api/v1/models', + wireProtocol: 'openai-responses' as const, + }, + ], + }, + }, +}; + +const editableDiscoveryPreset = { + id: 'editable-discovery', + name: 'Editable Discovery', + runtimes: { + codex: { + baseUrl: 'https://editable.example/api/v4', + baseUrlEditable: true, + wireProtocol: 'openai-chat' as const, + models: [{ id: 'chat-model', name: 'Chat Model' }], + modelDiscovery: [ + { + baseUrl: 'https://editable.example/api/v1', + modelsUrl: 'https://editable.example/api/v1/models', + wireProtocol: 'openai-responses' as const, + }, + ], + }, + }, +}; + const piReasoningPreset = { id: 'pi-reasoning', name: 'Pi Reasoning', @@ -198,6 +237,8 @@ beforeEach(() => { unsafeNoAuthDiscoveryPreset, openCodePreset, dualDiscoveryPreset, + zhipuCodingPreset, + editableDiscoveryPreset, piReasoningPreset, explicitPiPreset, claudeRequestPathPreset, @@ -619,6 +660,165 @@ describe('AddProviderWizard — preset 直达', () => { ]); }); + it('智谱绑定合并 V4 与 V1 目录,并给 glm-5.3 保存 Responses 路由', async () => { + vi.mocked(window.electronAPI.maker.fetchProviderModels).mockImplementation( + async ({ baseUrl }: { baseUrl: string }) => + baseUrl === 'https://open.bigmodel.cn/api/v1' + ? { ok: true, models: [{ id: 'glm-5.3', name: 'GLM-5.3' }] } + : { ok: true, models: [{ id: 'glm-5.2', name: 'GLM-5.2' }] }, + ); + renderWizard('zhipu-coding-plan-cn'); + + await waitFor(() => expect(screen.getByDisplayValue('Zhipu GLM Coding Plan')).not.toBeNull()); + fireEvent.change(screen.getByPlaceholderText('sk-…'), { target: { value: 'glm-key' } }); + fireEvent.click(screen.getByText('settings.providers.wizard.next')); + fireEvent.click(await screen.findByText('GLM-5.3')); + fireEvent.click(screen.getByText('settings.providers.wizard.finish')); + + await waitFor(() => expect(createCustomProvider).toHaveBeenCalledTimes(1)); + expect(window.electronAPI.maker.fetchProviderModels).toHaveBeenCalledTimes(2); + expect(window.electronAPI.maker.fetchProviderModels).toHaveBeenCalledWith( + expect.objectContaining({ + agent: 'codex', + baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', + wireProtocol: 'openai-chat', + }), + ); + expect(window.electronAPI.maker.fetchProviderModels).toHaveBeenCalledWith( + expect.objectContaining({ + agent: 'codex', + baseUrl: 'https://open.bigmodel.cn/api/v1', + modelsUrl: 'https://open.bigmodel.cn/api/v1/models', + wireProtocol: 'openai-responses', + }), + ); + const [config, keys] = vi.mocked(createCustomProvider).mock.calls[0]; + expect(config.runtimes.codex).toMatchObject({ + baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', + wireProtocol: 'openai-chat', + models: [ + { id: 'glm-5.2', name: 'GLM-5.2' }, + { + id: 'glm-5.3', + name: 'GLM-5.3', + route: { + baseUrl: 'https://open.bigmodel.cn/api/v1', + wireProtocol: 'openai-responses', + }, + }, + ], + }); + expect(keys.codex).toBe('glm-key'); + }); + + it('智谱主目录失败时附加目录不会改写预设模型的 V4 路由', async () => { + vi.mocked(window.electronAPI.maker.fetchProviderModels).mockImplementation( + async ({ baseUrl }: { baseUrl: string }) => + baseUrl === 'https://open.bigmodel.cn/api/v1' + ? { + ok: true, + models: [ + { id: 'glm-5.2', name: 'GLM-5.2' }, + { id: 'glm-5.3', name: 'GLM-5.3' }, + ], + } + : { ok: false }, + ); + renderWizard('zhipu-coding-plan-cn'); + + await waitFor(() => expect(screen.getByDisplayValue('Zhipu GLM Coding Plan')).not.toBeNull()); + fireEvent.change(screen.getByPlaceholderText('sk-…'), { target: { value: 'glm-key' } }); + fireEvent.click(screen.getByText('settings.providers.wizard.next')); + await screen.findByText('GLM-5.3'); + fireEvent.click(screen.getByText('settings.providers.wizard.finish')); + + await waitFor(() => expect(createCustomProvider).toHaveBeenCalledTimes(1)); + const runtime = vi.mocked(createCustomProvider).mock.calls[0][0].runtimes.codex; + expect(runtime).toMatchObject({ + baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4', + wireProtocol: 'openai-chat', + }); + expect(runtime?.models).toEqual([{ id: 'glm-5.2', name: 'GLM-5.2' }]); + }); + + it('可编辑预设改为同源 endpoint 后继续合并 Responses 目录', async () => { + vi.mocked(window.electronAPI.maker.fetchProviderModels).mockImplementation( + async ({ baseUrl }: { baseUrl: string }) => + baseUrl === 'https://editable.example/api/v1' + ? { ok: true, models: [{ id: 'responses-model', name: 'Responses Model' }] } + : { ok: true, models: [{ id: 'chat-model', name: 'Chat Model' }] }, + ); + renderWizard('editable-discovery'); + + const endpoint = await screen.findByDisplayValue('https://editable.example/api/v4'); + fireEvent.change(endpoint, { target: { value: 'https://editable.example/custom/v4' } }); + fireEvent.change(screen.getByPlaceholderText('sk-…'), { target: { value: 'edited-key' } }); + fireEvent.click(screen.getByText('settings.providers.wizard.next')); + fireEvent.click(await screen.findByText('Responses Model')); + fireEvent.click(screen.getByText('settings.providers.wizard.finish')); + + await waitFor(() => expect(createCustomProvider).toHaveBeenCalledTimes(1)); + expect(window.electronAPI.maker.fetchProviderModels).toHaveBeenCalledTimes(2); + expect(window.electronAPI.maker.fetchProviderModels).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: 'https://editable.example/custom/v4', + apiKey: 'edited-key', + }), + ); + expect(window.electronAPI.maker.fetchProviderModels).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: 'https://editable.example/api/v1', + wireProtocol: 'openai-responses', + apiKey: 'edited-key', + }), + ); + expect(vi.mocked(createCustomProvider).mock.calls[0][0].runtimes.codex?.models).toEqual([ + { id: 'chat-model', name: 'Chat Model' }, + { + id: 'responses-model', + name: 'Responses Model', + route: { + baseUrl: 'https://editable.example/api/v1', + wireProtocol: 'openai-responses', + }, + }, + ]); + }); + + it('可编辑预设改为异源 endpoint 后不向旧目录发送 API Key', async () => { + vi.mocked(window.electronAPI.maker.fetchProviderModels).mockResolvedValue({ + ok: true, + models: [{ id: 'self-hosted-model', name: 'Self-hosted Model' }], + }); + renderWizard('editable-discovery'); + + const endpoint = await screen.findByDisplayValue('https://editable.example/api/v4'); + fireEvent.change(endpoint, { target: { value: 'https://self-hosted.example/v4' } }); + fireEvent.change(screen.getByPlaceholderText('sk-…'), { target: { value: 'self-hosted-key' } }); + fireEvent.click(screen.getByText('settings.providers.wizard.next')); + fireEvent.click(await screen.findByText('Self-hosted Model')); + fireEvent.click(screen.getByText('settings.providers.wizard.finish')); + + await waitFor(() => expect(createCustomProvider).toHaveBeenCalledTimes(1)); + expect(window.electronAPI.maker.fetchProviderModels).toHaveBeenCalledTimes(1); + expect(window.electronAPI.maker.fetchProviderModels).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: 'https://self-hosted.example/v4', + apiKey: 'self-hosted-key', + }), + ); + expect(window.electronAPI.maker.fetchProviderModels).not.toHaveBeenCalledWith( + expect.objectContaining({ baseUrl: 'https://editable.example/api/v1' }), + ); + expect(vi.mocked(createCustomProvider).mock.calls[0][0].runtimes.codex).toMatchObject({ + baseUrl: 'https://self-hosted.example/v4', + models: [ + { id: 'chat-model', name: 'Chat Model' }, + { id: 'self-hosted-model', name: 'Self-hosted Model' }, + ], + }); + }); + it('LiteLLM:清空可编辑端点后不回退预设地址,也不能继续', async () => { renderWizard('litellm'); diff --git a/apps/desktop/src/renderer/__tests__/automationGeneratedSessions.test.ts b/apps/desktop/src/renderer/__tests__/automationGeneratedSessions.test.ts index a84f61cba8..5a3a18b274 100644 --- a/apps/desktop/src/renderer/__tests__/automationGeneratedSessions.test.ts +++ b/apps/desktop/src/renderer/__tests__/automationGeneratedSessions.test.ts @@ -787,7 +787,9 @@ describe('automation-generated sessions', () => { expect(source).toContain('runningSessionIds,'); // 组头右侧的时间文字与普通任务行的信息槽(SessionInfoMeta)同款字体 / 色号, // 含 tabular-nums(2026-08-12 用户裁决;C 期给普通行加等宽数字时组头漏跟)。 - expect(source).toContain("'flex items-center gap-1 text-xs font-medium tabular-nums'"); + expect(source).toContain( + "'col-start-1 row-start-1 flex items-center gap-1 text-xs font-medium tabular-nums'", + ); expect(source).toContain("'text-sidebar-action-icon'"); }); diff --git a/apps/desktop/src/renderer/__tests__/customProviderDialogPresetLocale.test.tsx b/apps/desktop/src/renderer/__tests__/customProviderDialogPresetLocale.test.tsx index 4ee887e9f2..c7714d08ee 100644 --- a/apps/desktop/src/renderer/__tests__/customProviderDialogPresetLocale.test.tsx +++ b/apps/desktop/src/renderer/__tests__/customProviderDialogPresetLocale.test.tsx @@ -65,7 +65,15 @@ const localizedPreset: ProviderPreset = { runtimes: { codex: { baseUrl: 'http://127.0.0.1:4000/v1', - models: [{ id: 'local-model', name: 'Local Model' }], + models: [{ + id: 'local-model', + name: 'Local Model', + route: { + baseUrl: 'http://127.0.0.1:4000/v1', + wireProtocol: 'openai-responses', + requestPath: '/responses', + }, + }], }, }, }; @@ -286,6 +294,38 @@ describe('CustomProviderDialog preset locale ownership', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it('keeps an existing model route through fetch picker confirmation and save', async () => { + i18nState.language = 'zh-TW'; + renderDialog(); + + const trigger = await findReadyPresetTrigger(); + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole('option', { name: '繁體供應商' })); + fireEvent.click(screen.getByRole('tab', { name: 'settings.providers.custom.protocol.codex' })); + fireEvent.click(screen.getByRole('button', { name: 'settings.providers.custom.fetch.button' })); + await screen.findByRole('heading', { + name: 'settings.providers.custom.fetch.pickerTitle', + }); + fireEvent.click(screen.getByRole('button', { name: /settings\.providers\.custom\.fetch\.confirm/ })); + await waitFor(() => { + expect(screen.queryByRole('heading', { + name: 'settings.providers.custom.fetch.pickerTitle', + })).toBeNull(); + }); + fireEvent.click(screen.getByRole('button', { name: 'settings.providers.custom.save' })); + + await waitFor(() => expect(createCustomProvider).toHaveBeenCalledTimes(1)); + expect(vi.mocked(createCustomProvider).mock.calls[0][0].runtimes.codex?.models[0]).toEqual({ + id: 'local-model', + name: 'Local Model', + route: { + baseUrl: 'http://127.0.0.1:4000/v1', + wireProtocol: 'openai-responses', + requestPath: '/responses', + }, + }); + }); + it.each([ ['settings.providers.custom.wireProtocol.piChat', 'openai-chat'], ['settings.providers.custom.wireProtocol.piAnthropic', 'anthropic-messages'], diff --git a/apps/desktop/src/renderer/__tests__/dialogueSidebarSection.test.ts b/apps/desktop/src/renderer/__tests__/dialogueSidebarSection.test.ts index 4b39ec9bec..4a38fdde04 100644 --- a/apps/desktop/src/renderer/__tests__/dialogueSidebarSection.test.ts +++ b/apps/desktop/src/renderer/__tests__/dialogueSidebarSection.test.ts @@ -72,6 +72,14 @@ describe('Mixed main list (sidebar-redesign D 期)', () => { expect(projectsSectionSource).toContain("filter.sortBy === 'manual' ? ("); }); + it('holds the current priority rank before click-path attention clear', () => { + const clickHandler = extractHandlerBlock(sidebarSource, 'handleSessionClick'); + expect(clickHandler.indexOf('holdSidebarViewedPriority')).toBeGreaterThan(-1); + expect(clickHandler.indexOf('holdSidebarViewedPriority')).toBeLessThan( + clickHandler.indexOf('clearNotification(id)'), + ); + }); + it('offers the dialogue group as an opt-in toggle, not a fixed section', () => { expect(mainListModelSource).toContain('groupDialogue'); expect(projectsSectionSource).toContain('DialogueGroupNode'); diff --git a/apps/desktop/src/renderer/__tests__/mainListModel.test.ts b/apps/desktop/src/renderer/__tests__/mainListModel.test.ts index 9e01f767a4..caaa45ebcc 100644 --- a/apps/desktop/src/renderer/__tests__/mainListModel.test.ts +++ b/apps/desktop/src/renderer/__tests__/mainListModel.test.ts @@ -6,10 +6,13 @@ import { describe, expect, it } from 'vitest'; +import { LIVE_TASK_PRIORITY } from '../../shared/liveTaskPriority'; import type { Session } from '@/lib/ccAgent.types'; import type { ProjectNode } from '../features/cc-agent/lib/projectGrouping'; import { + advanceViewedPriorityHold, buildMainListEntries, + holdViewedPriorityRank, sessionPriorityRank, splitEntriesByDevice, type MainListEntry, @@ -150,10 +153,10 @@ describe('buildMainListEntries — 排序口径', () => { attentionSessionIds: new Set([waiting.id, unread.id]), waitingSessionIds: new Set([waiting.id]), }; - expect(sessionPriorityRank(waiting, ctx)).toBe(0); - expect(sessionPriorityRank(unread, ctx)).toBe(1); - expect(sessionPriorityRank(running, ctx)).toBe(2); - expect(sessionPriorityRank(idleNewest, ctx)).toBe(3); + expect(sessionPriorityRank(waiting, ctx)).toBe(LIVE_TASK_PRIORITY.waiting); + expect(sessionPriorityRank(unread, ctx)).toBe(LIVE_TASK_PRIORITY.unread); + expect(sessionPriorityRank(running, ctx)).toBe(LIVE_TASK_PRIORITY.running); + expect(sessionPriorityRank(idleNewest, ctx)).toBe(LIVE_TASK_PRIORITY.rest); const entries = buildMainListEntries({ projects: [], dialogues: [idleNewest, running, unread, waiting], @@ -166,6 +169,171 @@ describe('buildMainListEntries — 排序口径', () => { expect(labels(entries)).toEqual(['s:needs-input', 's:done-unread', 's:running', 's:idle']); }); + it('keeps the open unread task in place, then parks it at the top of the rest tier after leave', () => { + const unread = session({ updatedAt: '2026-07-01T00:00:00Z', title: 'just-read' }); + const olderRest = session({ updatedAt: '2026-08-12T00:00:00Z', title: 'older-rest' }); + const waiting = session({ updatedAt: '2026-07-20T00:00:00Z', title: 'needs-input' }); + const hold = { + heldPriorityRanks: new Map(), + recentlyViewedAtMs: new Map(), + }; + const opened = advanceViewedPriorityHold( + hold, + unread.id, + { + runningSessionIds: new Set(), + attentionSessionIds: new Set([unread.id, waiting.id]), + waitingSessionIds: new Set([waiting.id]), + }, + 1_000, + ); + expect(opened.heldPriorityRanks.get(unread.id)).toBe(1); + const stillOpen = buildMainListEntries({ + projects: [], + dialogues: [olderRest, unread, waiting], + groupBy: 'project', + groupDialogue: false, + sortBy: 'priority', + manualProjectOrder: [], + priorityContext: { + runningSessionIds: new Set(), + attentionSessionIds: new Set([waiting.id]), + waitingSessionIds: new Set([waiting.id]), + heldPriorityRanks: opened.heldPriorityRanks, + recentlyViewedAtMs: opened.recentlyViewedAtMs, + }, + }); + expect(labels(stillOpen)).toEqual(['s:needs-input', 's:just-read', 's:older-rest']); + + const leaveAt = Date.parse('2026-08-13T00:00:00Z'); + const left = advanceViewedPriorityHold( + opened, + olderRest.id, + { + runningSessionIds: new Set(), + attentionSessionIds: new Set([waiting.id]), + waitingSessionIds: new Set([waiting.id]), + }, + leaveAt, + ); + expect(left.heldPriorityRanks.has(unread.id)).toBe(false); + expect(left.recentlyViewedAtMs.get(unread.id)).toBe(leaveAt); + const afterLeave = buildMainListEntries({ + projects: [], + dialogues: [olderRest, unread, waiting], + groupBy: 'project', + groupDialogue: false, + sortBy: 'priority', + manualProjectOrder: [], + priorityContext: { + runningSessionIds: new Set(), + attentionSessionIds: new Set([waiting.id]), + waitingSessionIds: new Set([waiting.id]), + heldPriorityRanks: left.heldPriorityRanks, + recentlyViewedAtMs: left.recentlyViewedAtMs, + }, + }); + expect(labels(afterLeave)).toEqual(['s:needs-input', 's:just-read', 's:older-rest']); + expect(sessionPriorityRank(unread, { + runningSessionIds: new Set(), + attentionSessionIds: new Set([waiting.id]), + waitingSessionIds: new Set([waiting.id]), + recentlyViewedAtMs: left.recentlyViewedAtMs, + })).toBe(LIVE_TASK_PRIORITY.rest); + + const sunkWithoutViewedAt = buildMainListEntries({ + projects: [], + dialogues: [olderRest, unread, waiting], + groupBy: 'project', + groupDialogue: false, + sortBy: 'priority', + manualProjectOrder: [], + priorityContext: { + runningSessionIds: new Set(), + attentionSessionIds: new Set([waiting.id]), + waitingSessionIds: new Set([waiting.id]), + }, + }); + expect(labels(sunkWithoutViewedAt)).toEqual(['s:needs-input', 's:older-rest', 's:just-read']); + + const recencyIgnoresLeave = buildMainListEntries({ + projects: [], + dialogues: [olderRest, unread], + groupBy: 'project', + groupDialogue: false, + sortBy: 'recency', + manualProjectOrder: [], + priorityContext: { + runningSessionIds: new Set(), + attentionSessionIds: new Set(), + recentlyViewedAtMs: left.recentlyViewedAtMs, + }, + }); + expect(labels(recencyIgnoresLeave)).toEqual(['s:older-rest', 's:just-read']); + }); + + it('holds the unread rank even if attention is cleared before the first viewed render', () => { + const unread = session({ updatedAt: '2026-07-01T00:00:00Z', title: 'just-read' }); + const olderRest = session({ updatedAt: '2026-08-12T00:00:00Z', title: 'older-rest' }); + const waiting = session({ updatedAt: '2026-07-20T00:00:00Z', title: 'needs-input' }); + const hold = { + heldPriorityRanks: new Map(), + recentlyViewedAtMs: new Map(), + }; + holdViewedPriorityRank(hold, unread.id, { + runningSessionIds: new Set(), + attentionSessionIds: new Set([unread.id, waiting.id]), + waitingSessionIds: new Set([waiting.id]), + }); + const afterClear = advanceViewedPriorityHold( + hold, + unread.id, + { + runningSessionIds: new Set(), + attentionSessionIds: new Set([waiting.id]), + waitingSessionIds: new Set([waiting.id]), + }, + 1_000, + ); + expect(afterClear.heldPriorityRanks.get(unread.id)).toBe(LIVE_TASK_PRIORITY.unread); + const entries = buildMainListEntries({ + projects: [], + dialogues: [olderRest, unread, waiting], + groupBy: 'project', + groupDialogue: false, + sortBy: 'priority', + manualProjectOrder: [], + priorityContext: { + runningSessionIds: new Set(), + attentionSessionIds: new Set([waiting.id]), + waitingSessionIds: new Set([waiting.id]), + heldPriorityRanks: afterClear.heldPriorityRanks, + }, + }); + expect(labels(entries)).toEqual(['s:needs-input', 's:just-read', 's:older-rest']); + }); + + it('does not let leave time promote a still-waiting or running task', () => { + const waitingOld = session({ updatedAt: '2026-07-01T00:00:00Z', title: 'waiting-old' }); + const waitingNew = session({ updatedAt: '2026-08-12T00:00:00Z', title: 'waiting-new' }); + const leaveAt = Date.parse('2026-08-13T00:00:00Z'); + const entries = buildMainListEntries({ + projects: [], + dialogues: [waitingOld, waitingNew], + groupBy: 'project', + groupDialogue: false, + sortBy: 'priority', + manualProjectOrder: [], + priorityContext: { + runningSessionIds: new Set(), + attentionSessionIds: new Set([waitingOld.id, waitingNew.id]), + waitingSessionIds: new Set([waitingOld.id, waitingNew.id]), + recentlyViewedAtMs: new Map([[waitingOld.id, leaveAt]]), + }, + }); + expect(labels(entries)).toEqual(['s:waiting-new', 's:waiting-old']); + }); + it('waitingSessionIds 缺省时全部 attention 落 unread 档(老调用方零迁移)', () => { const attention = session({ updatedAt: '2026-08-01T00:00:00Z', title: 'attn' }); const running = session({ updatedAt: '2026-08-05T00:00:00Z', title: 'running' }); @@ -173,8 +341,8 @@ describe('buildMainListEntries — 排序口径', () => { runningSessionIds: new Set([running.id]), attentionSessionIds: new Set([attention.id]), }; - expect(sessionPriorityRank(attention, ctx)).toBe(1); - expect(sessionPriorityRank(running, ctx)).toBe(2); + expect(sessionPriorityRank(attention, ctx)).toBe(LIVE_TASK_PRIORITY.unread); + expect(sessionPriorityRank(running, ctx)).toBe(LIVE_TASK_PRIORITY.running); }); it('a project inherits its best session priority (group floats with its members)', () => { diff --git a/apps/desktop/src/renderer/__tests__/newMakerOrcaCreateOrder.test.ts b/apps/desktop/src/renderer/__tests__/newMakerOrcaCreateOrder.test.ts index 9c91e1cd81..b491e20542 100644 --- a/apps/desktop/src/renderer/__tests__/newMakerOrcaCreateOrder.test.ts +++ b/apps/desktop/src/renderer/__tests__/newMakerOrcaCreateOrder.test.ts @@ -138,7 +138,7 @@ describe('NewMakerDraftRoute Orca worker create order', () => { expect(sessionViewSource).toContain('if (sessionHandoffPreparing) return false;'); expect(sessionViewSource).not.toContain('if (worktreePreparing) return false;'); expect(sessionViewSource).toContain( - "disabled={remoteHandoffPreparing || session?.source === 'review'}", + "disabled={readOnly || remoteHandoffPreparing || session?.source === 'review'}", ); }); diff --git a/apps/desktop/src/renderer/__tests__/orcaWorkflowRoute.test.ts b/apps/desktop/src/renderer/__tests__/orcaWorkflowRoute.test.ts index caa05a556c..430d31df78 100644 --- a/apps/desktop/src/renderer/__tests__/orcaWorkflowRoute.test.ts +++ b/apps/desktop/src/renderer/__tests__/orcaWorkflowRoute.test.ts @@ -437,7 +437,7 @@ describe('OrcaWorkflowRoute source invariants', () => { /sidebarPanelHostSessionId=\{\s*ownsRoute \|\| navigationMode === 'split-pane' \? sessionId : undefined\s*\}/, ); expect(sessionViewSource).toContain( - 'onForkStripEncrypted={canNavigateSession ? handleForkStripEncrypted : undefined}', + '!readOnly && canNavigateSession ? handleForkStripEncrypted : undefined', ); }); diff --git a/apps/desktop/src/renderer/__tests__/projectsSidebarSection.test.ts b/apps/desktop/src/renderer/__tests__/projectsSidebarSection.test.ts index 317626d48d..c97d09975c 100644 --- a/apps/desktop/src/renderer/__tests__/projectsSidebarSection.test.ts +++ b/apps/desktop/src/renderer/__tests__/projectsSidebarSection.test.ts @@ -118,6 +118,9 @@ describe('Projects sidebar section', () => { ); // 三个集合作为一个整体喂给混排模型。 expect(projectsSectionSource).toContain('priorityContext,'); + expect(projectsSectionSource).toContain('advanceViewedPriorityHold('); + expect(projectsSectionSource).toContain('holdViewedPriorityRank('); + expect(projectsSectionSource).toContain('viewedSessionId ?? activeSessionId'); // 折叠豁免与排序同一口径(含远程),不再用只有本地的 notifications。 expect(projectsSectionSource).toContain( 'entrySessions(entry).some((s) => priorityContext.attentionSessionIds.has(s.id))', diff --git a/apps/desktop/src/renderer/__tests__/remoteSessionSyncInvariants.test.ts b/apps/desktop/src/renderer/__tests__/remoteSessionSyncInvariants.test.ts index 9a5fb3fa21..ff0849277a 100644 --- a/apps/desktop/src/renderer/__tests__/remoteSessionSyncInvariants.test.ts +++ b/apps/desktop/src/renderer/__tests__/remoteSessionSyncInvariants.test.ts @@ -89,7 +89,7 @@ describe('CCAgentSessionView 接线不变式', () => { // device-link 远程交接期间仍要禁用(见 remoteHandoffPreparing):那几段 await // 可能数十秒,不禁用的话用户补发的消息会插到草稿提交的首条之前。 expect(sessionViewSrc).toContain( - "disabled={remoteHandoffPreparing || session?.source === 'review'}", + "disabled={readOnly || remoteHandoffPreparing || session?.source === 'review'}", ); }); it('补选目录后的续发保持 delivery mode,并按本地/远端策略清理原 composer', () => { diff --git a/apps/desktop/src/renderer/__tests__/sidebarRemoteProjectIcon.test.ts b/apps/desktop/src/renderer/__tests__/sidebarRemoteProjectIcon.test.ts index b300c28e31..92c3def9e7 100644 --- a/apps/desktop/src/renderer/__tests__/sidebarRemoteProjectIcon.test.ts +++ b/apps/desktop/src/renderer/__tests__/sidebarRemoteProjectIcon.test.ts @@ -60,7 +60,7 @@ describe('sidebar remote project icon', () => { ); // C 期起右侧时间槽由 SessionInfoMeta 承担(任务信息复选),仍在同一让位容器内。 expect(sessionItemSource).toMatch( - /
[\s\S]*?[\s\S]*? { it('keeps the archive shortcut in the same right-side slot instead of crowding the time', () => { expect(sessionItemSource).toContain( - 'relative ml-auto flex h-6 shrink-0 items-center justify-end min-w-14', + 'relative ml-auto flex h-6 shrink-0 items-center justify-end', + ); + // 文字模式信息槽按内容收缩:「任务信息 = 无」或短时间都不能再预留 56px 空位挤标题。 + expect(sessionItemSource).not.toMatch( + /group\/slot relative ml-auto flex h-6 shrink-0 items-center justify-end min-w-14/, ); expect(sessionItemSource).toContain(' { 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100', ); expect(sessionItemSource).toContain('absolute right-0 top-0 flex h-6 items-center gap-0.5'); + // hover / 菜单打开时不可见操作钮与信息层叠在同一格,槽宽取较大值而不是相加。 + expect(sessionItemSource).toContain('grid h-6 grid-cols-[max-content] items-center justify-items-end'); + expect(sessionItemSource).toContain( + "menuPos === null && 'hidden group-hover:flex group-focus-within/slot:flex'", + ); }); }); diff --git a/apps/desktop/src/renderer/__tests__/toastDuration.test.ts b/apps/desktop/src/renderer/__tests__/toastDuration.test.ts new file mode 100644 index 0000000000..25cbcde36b --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/toastDuration.test.ts @@ -0,0 +1,68 @@ +/** + * toastDuration.test.ts + * --------------------------------------------------------------------------- + * warning 与 error 共用 8000ms 默认停留:警告常带操作指引,1.2s 读不完。 + * info / success 仍保持 1200ms。显式 duration 覆盖默认。 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getToastSnapshot, toast } from '../lib/toast'; + +/** 退出动画时长(与 lib/toast.ts 的 EXIT_ANIMATION_MS 对齐)+ 少量余量 */ +const EXIT_MS = 300 + 50; + +function item(id: string) { + return getToastSnapshot().find((t) => t.id === id); +} + +function isVisible(id: string): boolean { + return getToastSnapshot().some((t) => t.id === id); +} + +describe('toast default duration', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + toast.dismissAll(); + vi.advanceTimersByTime(EXIT_MS); + vi.useRealTimers(); + }); + + it('info / success 默认 1200ms', () => { + const infoId = toast.info('i'); + const successId = toast.success('s'); + expect(item(infoId)?.duration).toBe(1200); + expect(item(successId)?.duration).toBe(1200); + }); + + it('warning / error 默认 8000ms', () => { + const warningId = toast.warning('w'); + const errorId = toast.error('e'); + expect(item(warningId)?.duration).toBe(8000); + expect(item(errorId)?.duration).toBe(8000); + }); + + it('warning 在 1200ms 后仍可见,8000ms 后退出', () => { + const id = toast.warning('switch provider'); + vi.advanceTimersByTime(1200); + expect(isVisible(id)).toBe(true); + expect(item(id)?.exiting).toBe(false); + + vi.advanceTimersByTime(6800); + expect(item(id)?.exiting).toBe(true); + + vi.advanceTimersByTime(EXIT_MS); + expect(isVisible(id)).toBe(false); + }); + + it('显式 duration 覆盖 warning 默认', () => { + const id = toast.warning('custom', { duration: 4000 }); + expect(item(id)?.duration).toBe(4000); + + vi.advanceTimersByTime(4000); + expect(item(id)?.exiting).toBe(true); + }); +}); diff --git a/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx b/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx index 59ea422e78..751123b23e 100644 --- a/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx +++ b/apps/desktop/src/renderer/components/settings/AddProviderWizard.tsx @@ -35,12 +35,15 @@ import { derivePiRuntimeFromClaudeRuntime } from '@/../shared/piRuntimeInitializ import { isLoopbackProviderUrl, + isProviderRequestPath, presetDisplayName, sortPresetsForRegion, } from '@cindy/model-providers'; import type { AgentKind, CustomProviderConfig, + ProviderModelDiscoverySource, + ProviderModelRouteConfig, ProviderPreset, ProviderView, } from '@cindy/model-providers'; @@ -90,18 +93,54 @@ function presetRuntimeBaseUrl( } function isValidEditablePresetBaseUrl(value: string): boolean { + return parseSafePresetHttpUrl(value) !== null; +} + +function parseSafePresetHttpUrl(value: string): URL | null { try { const url = new URL(value); - return ( - (url.protocol === 'http:' || url.protocol === 'https:') - && !url.username - && !url.password - ); + if ( + (url.protocol !== 'http:' && url.protocol !== 'https:') + || url.username + || url.password + ) return null; + return url; } catch { - return false; + return null; } } +function isAllowedDiscoveryWireProtocol( + agent: AgentKind, + value: unknown, +): value is ProviderModelRouteConfig['wireProtocol'] { + const supported = + value === 'anthropic-messages' + || value === 'openai-responses' + || value === 'openai-chat'; + return supported && (agent !== 'claude-code' || value === 'anthropic-messages'); +} + +function isDiscoverySourceValidForRuntime( + agent: AgentKind, + runtimeBaseUrl: string, + source: ProviderModelDiscoverySource, +): boolean { + const runtimeUrl = parseSafePresetHttpUrl(runtimeBaseUrl); + const sourceUrl = parseSafePresetHttpUrl(source.baseUrl); + if ( + !runtimeUrl + || !sourceUrl + || sourceUrl.origin !== runtimeUrl.origin + || !isAllowedDiscoveryWireProtocol(agent, source.wireProtocol) + ) return false; + if (source.modelsUrl !== undefined) { + const modelsUrl = parseSafePresetHttpUrl(source.modelsUrl); + if (!modelsUrl || modelsUrl.origin !== sourceUrl.origin) return false; + } + return source.requestPath === undefined || isProviderRequestPath(source.requestPath); +} + /** * bespoke OAuth 渠道的官方 API 预设——授权步「改用 API Key 接入」的替代路径 * (API 用户没有订阅,OAuth 授权对其是错误路径)。 @@ -313,6 +352,8 @@ export function AddProviderWizard({ /** 列模型端点上报的上下文窗口,**按 agent 分槽**(同一 id 双端可不同,如 * cc=1M / codex=272K);完成创建时按所属 runtime 取值,预设值优先、本值兜底。 */ contextWindows?: Partial>; + /** 附加目录发现出的模型级路由;主 runtime 目录发现的模型保持缺省路由。 */ + routes?: Partial>; } > >(new Map()); @@ -560,6 +601,7 @@ export function AddProviderWizard({ recommended: boolean; agents: AgentKind[]; contextWindows?: Partial>; + routes?: Partial>; } >(); for (const agent of Object.keys(preset.runtimes) as AgentKind[]) { @@ -567,8 +609,17 @@ export function AddProviderWizard({ const existing = initial.get(m.id); if (existing) { if (!existing.agents.includes(agent)) existing.agents.push(agent); + if (m.route && !existing.routes?.[agent]) { + existing.routes = { ...existing.routes, [agent]: m.route }; + } } else { - initial.set(m.id, { name: m.name, checked: true, recommended: true, agents: [agent] }); + initial.set(m.id, { + name: m.name, + checked: true, + recommended: true, + agents: [agent], + ...(m.route ? { routes: { [agent]: m.route } } : {}), + }); } } } @@ -605,41 +656,76 @@ export function AddProviderWizard({ .map(([modelsUrl]) => modelsUrl), ); const results = await Promise.all( - agents.map(async (agent) => { + agents.flatMap((agent) => { const rt = preset.runtimes[agent]; if (!rt) { - return { - agent, - ok: false, - models: [] as { id: string; name: string; contextWindow?: number }[], - }; + return []; } - try { - const r = await window.electronAPI.maker.fetchProviderModels({ - agent, - baseUrl: presetRuntimeBaseUrl(preset, agent, presetBaseUrls), - authMethod: preset.authMethod ?? 'apiKey', + const runtimeBaseUrl = presetRuntimeBaseUrl(preset, agent, presetBaseUrls); + const sources: { + baseUrl: string; + modelsUrl: string | null; + wireProtocol?: ProviderModelRouteConfig['wireProtocol']; + route?: ProviderModelRouteConfig; + }[] = [ + { + baseUrl: runtimeBaseUrl, modelsUrl: rt.modelsUrl ?? null, - apiKey: apiKey.trim() || null, ...(rt.wireProtocol ? { wireProtocol: rt.wireProtocol } : {}), - ...(rt.headers ? { headers: rt.headers } : {}), - }); - return { agent, ok: !!(r.ok && r.models), models: r.models ?? [] }; - } catch { - return { - agent, - ok: false, - models: [] as { id: string; name: string; contextWindow?: number }[], - }; - } + }, + ...(rt.modelDiscovery ?? []) + .filter((source) => isDiscoverySourceValidForRuntime(agent, runtimeBaseUrl, source)) + .map((source) => ({ + baseUrl: source.baseUrl, + modelsUrl: source.modelsUrl ?? null, + wireProtocol: source.wireProtocol, + route: { + baseUrl: source.baseUrl, + wireProtocol: source.wireProtocol, + ...(source.requestPath ? { requestPath: source.requestPath } : {}), + }, + })), + ]; + return sources.map(async (source) => { + try { + const r = await window.electronAPI.maker.fetchProviderModels({ + agent, + baseUrl: source.baseUrl, + authMethod: preset.authMethod ?? 'apiKey', + modelsUrl: source.modelsUrl, + apiKey: apiKey.trim() || null, + ...(source.wireProtocol ? { wireProtocol: source.wireProtocol } : {}), + ...(rt.headers ? { headers: rt.headers } : {}), + }); + return { + agent, + modelsUrl: source.modelsUrl, + route: source.route, + ok: !!(r.ok && r.models), + models: r.models ?? [], + }; + } catch { + return { + agent, + modelsUrl: source.modelsUrl, + route: source.route, + ok: false, + models: [] as { id: string; name: string; contextWindow?: number }[], + }; + } + }); }), ); // 过期响应丢弃:用户已返回 / 换选了其它供应商,旧结果不得合入当前清单。 if (seq !== fetchSeqRef.current) return; + const defaultDiscoveredModels = new Set( + results + .filter((result) => !result.route) + .flatMap((result) => result.models.map((model) => `${result.agent}\u0000${model.id}`)), + ); setPicks((prev) => { const next = new Map(prev); - for (const { agent, models } of results) { - const modelsUrl = preset.runtimes[agent]?.modelsUrl; + for (const { agent, models, modelsUrl, route } of results) { const preservePresetOwnership = !!modelsUrl && splitDiscoveryUrls.has(modelsUrl); for (const m of models) { const existing = next.get(m.id); @@ -653,13 +739,25 @@ export function AddProviderWizard({ // 「预设没写窗口」的兜底)。 const backfillWindow = existing.contextWindows?.[agent] === undefined && m.contextWindow !== undefined; - if (mergedAgents !== existing.agents || backfillWindow) { + const presetOwnsModel = + preset.runtimes[agent]?.models.some((model) => model.id === m.id) === true; + const discoveredRoute = + route && + !presetOwnsModel && + !defaultDiscoveredModels.has(`${agent}\u0000${m.id}`) && + !existing.routes?.[agent] + ? route + : undefined; + if (mergedAgents !== existing.agents || backfillWindow || discoveredRoute) { next.set(m.id, { ...existing, agents: mergedAgents, ...(backfillWindow ? { contextWindows: { ...existing.contextWindows, [agent]: m.contextWindow } } : {}), + ...(discoveredRoute + ? { routes: { ...existing.routes, [agent]: discoveredRoute } } + : {}), }); } } else if (!preservePresetOwnership) { @@ -671,6 +769,7 @@ export function AddProviderWizard({ ...(m.contextWindow !== undefined ? { contextWindows: { [agent]: m.contextWindow } } : {}), + ...(route ? { routes: { [agent]: route } } : {}), }); } } @@ -757,7 +856,13 @@ export function AddProviderWizard({ const preset = sel.preset; const selected = [...picks.entries()] .filter(([, v]) => v.checked) - .map(([id, v]) => ({ id, name: v.name, agents: v.agents, contextWindows: v.contextWindows })); + .map(([id, v]) => ({ + id, + name: v.name, + agents: v.agents, + contextWindows: v.contextWindows, + routes: v.routes, + })); if (selected.length === 0) { toast.error(t('settings.providers.wizard.noModelSelected')); return; @@ -787,6 +892,9 @@ export function AddProviderWizard({ id: m.id, name: m.name, ...(agent === 'pi' && presetModel?.piApi ? { piApi: presetModel.piApi } : {}), + ...(presetModel?.route ?? m.routes?.[agent] + ? { route: presetModel?.route ?? m.routes?.[agent] } + : {}), ...(contextWindow !== undefined ? { contextWindow } : {}), ...(presetModel?.supportsImageInput === true ? { supportsImageInput: true } : {}), ...(presetModel?.reasoning === true && presetModel.reasoningEfforts?.length diff --git a/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx b/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx index cc1a9dbbec..d9e861fbdd 100644 --- a/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/CustomProviderDialog.tsx @@ -1142,6 +1142,7 @@ export function CustomProviderDialog({ id: m.id.trim(), name: m.name.trim(), ...(agent === 'pi' && m.piApi ? { piApi: m.piApi } : {}), + ...(m.route ? { route: { ...m.route } } : {}), ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}), ...(m.defaultEnabled === false ? { defaultEnabled: false } : {}), ...(m.supportsImageInput === true ? { supportsImageInput: true } : {}), @@ -1173,6 +1174,7 @@ export function CustomProviderDialog({ id: m.id, name: cur?.name || m.name, ...(agent === 'pi' && cur?.piApi ? { piApi: cur.piApi } : {}), + ...(cur?.route ? { route: { ...cur.route } } : {}), ...(contextWindow !== undefined ? { contextWindow } : {}), ...(cur?.defaultEnabled === false ? { defaultEnabled: false } : {}), ...(cur?.supportsImageInput === true ? { supportsImageInput: true } : {}), @@ -1251,6 +1253,9 @@ export function CustomProviderDialog({ id: m.id, name: latest?.name.trim() ? latest.name.trim() : m.name, ...(picker.agent === 'pi' && piApi ? { piApi } : {}), + ...(latest?.route ?? m.route + ? { route: { ...(latest?.route ?? m.route)! } } + : {}), ...(contextWindow !== undefined ? { contextWindow } : {}), ...(defaultEnabled === false ? { defaultEnabled: false } : {}), ...(supportsImageInput === true ? { supportsImageInput: true } : {}), @@ -1270,6 +1275,7 @@ export function CustomProviderDialog({ id, name: m.name.trim() || id, ...(picker.agent === 'pi' && m.piApi ? { piApi: m.piApi } : {}), + ...(m.route ? { route: { ...m.route } } : {}), ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}), ...(m.defaultEnabled === false ? { defaultEnabled: false } : {}), ...(m.supportsImageInput === true ? { supportsImageInput: true } : {}), @@ -1405,6 +1411,7 @@ export function CustomProviderDialog({ id: m.id.trim(), name: m.name.trim(), ...(a === 'pi' && m.piApi ? { piApi: m.piApi } : {}), + ...(m.route ? { route: { ...m.route } } : {}), ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}), ...(m.defaultEnabled === false ? { defaultEnabled: false } : {}), ...(m.supportsImageInput === true ? { supportsImageInput: true } : {}), diff --git a/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx b/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx index 777dba5888..4c52889953 100644 --- a/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx +++ b/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx @@ -167,6 +167,50 @@ describe('CustomProviderDialog accessibility', () => { expect(customProviderMocks.updateCustomProvider.mock.calls[0]?.[1]).toEqual({}); }); + it('keeps model-level routes when saving an existing provider', async () => { + const initial: CustomProviderConfig = { + id: 'glm-coding-plan', + name: 'GLM Coding Plan', + auth: { method: 'apiKey' }, + runtimes: { + codex: { + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + wireProtocol: 'openai-chat', + requestPath: '/chat/completions', + models: [ + { + id: 'glm-5.3', + name: 'GLM-5.3', + route: { + baseUrl: 'https://open.bigmodel.cn/api/v1', + wireProtocol: 'openai-responses', + requestPath: '/responses', + }, + }, + ], + }, + }, + }; + customProviderMocks.readCustomProviderKey.mockResolvedValue(null); + + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: 'settings.providers.custom.save' })); + + await waitFor(() => expect(customProviderMocks.updateCustomProvider).toHaveBeenCalledOnce()); + expect(customProviderMocks.updateCustomProvider.mock.calls[0]?.[0].runtimes.codex?.models).toEqual([ + { + id: 'glm-5.3', + name: 'GLM-5.3', + route: { + baseUrl: 'https://open.bigmodel.cn/api/v1', + wireProtocol: 'openai-responses', + requestPath: '/responses', + }, + }, + ]); + }); + it('restores an untouched hydrated key when returning to API-key mode on the saved endpoint', async () => { const initial: CustomProviderConfig = { id: 'existing-provider', diff --git a/apps/desktop/src/renderer/components/sidebar/SidebarTopNav.tsx b/apps/desktop/src/renderer/components/sidebar/SidebarTopNav.tsx index fd8609347a..6a9b76a826 100644 --- a/apps/desktop/src/renderer/components/sidebar/SidebarTopNav.tsx +++ b/apps/desktop/src/renderer/components/sidebar/SidebarTopNav.tsx @@ -1,7 +1,7 @@ /** * SidebarTopNav —— 侧栏顶部常驻动作/导航列表(取代原 HorizontalTabbar)。 * --------------------------------------------------------------------------- - * 一条同级、等权的列表行,按顺序:新建 / 自动任务 / Plugins / 搜索。 + * 一条同级、等权的列表行,按顺序:新建 / 自动任务 / Plugins / Bots / 搜索。 * - 新建 / 自动任务:项目(cc-agent)视图的动作 —— 在任意视图点击都跳回项目视图并执行。 * - Plugins:主视图切换(navigateToView),命中当前视图时高亮。 * - 搜索(SidebarInlineSearch):静息态与其余行同款「🔍 搜索」;hover / 聚焦 @@ -19,7 +19,7 @@ */ import { useCallback } from 'react'; -import { CirclePlus, Plug, Timer } from 'lucide-react'; +import { Bot, CirclePlus, Plug, Timer } from 'lucide-react'; import { useNavigate, useMatch } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -124,6 +124,26 @@ export function SidebarTopNav({ {hasGhostUnread && } ) : null; + const botsRow = showScrollable ? ( + + ) : null; const searchRow = showScrollable ? ( {automationsRow} {pluginsRow} + {botsRow}
); diff --git a/apps/desktop/src/renderer/features/bots/BotAutomationSettings.tsx b/apps/desktop/src/renderer/features/bots/BotAutomationSettings.tsx new file mode 100644 index 0000000000..a335aa34c9 --- /dev/null +++ b/apps/desktop/src/renderer/features/bots/BotAutomationSettings.tsx @@ -0,0 +1,731 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + Archive, + CalendarClock, + ChevronDown, + ChevronRight, + CirclePause, + CirclePlay, + ExternalLink, + Pencil, + Paperclip, + Plus, + RefreshCcw, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import type { + BotAutomation, + BotAutomationExecutionPolicy, + BotAutomationRun, + CreateBotAutomationInput, + UpdateBotAutomationInput, +} from '../../../shared/botAutomation'; +import { DEFAULT_BOT_AUTOMATION_EXECUTION_POLICY } from '../../../shared/botAutomation'; +import { useBotProfiles, type BotProfile } from './botStore'; +import { useConfirmDialog } from '@/components/ui/confirm-dialog-provider'; + +type ScheduleMode = 'manual' | 'daily' | 'interval' | 'cron'; + +interface AutomationPolicyDraft { + timeoutMinutes: number; + budgetTokens: string; + maxDelegationDepth: number; + delegateTargetMode: BotAutomationExecutionPolicy['delegateTargetMode']; + allowedDelegateBotIds: string[]; +} + +function policyDraft(policy: BotAutomationExecutionPolicy): AutomationPolicyDraft { + return { + timeoutMinutes: Math.max(1, Math.round(policy.timeoutMs / 60_000)), + budgetTokens: policy.budgetTokens === null ? '' : String(policy.budgetTokens), + maxDelegationDepth: policy.maxDelegationDepth, + delegateTargetMode: policy.delegateTargetMode, + allowedDelegateBotIds: policy.allowedDelegateBotIds, + }; +} + +function executionPolicyFromDraft(draft: AutomationPolicyDraft): BotAutomationExecutionPolicy { + const parsedBudget = Number(draft.budgetTokens); + return { + timeoutMs: Math.max(1, Math.floor(draft.timeoutMinutes)) * 60_000, + budgetTokens: draft.budgetTokens.trim() && Number.isSafeInteger(parsedBudget) && parsedBudget > 0 + ? parsedBudget + : null, + maxDelegationDepth: Math.max(1, Math.min(5, Math.floor(draft.maxDelegationDepth))), + delegateTargetMode: draft.delegateTargetMode, + allowedDelegateBotIds: draft.delegateTargetMode === 'allowlist' + ? draft.allowedDelegateBotIds + : [], + }; +} + +function AutomationPolicyFields({ + draft, + onChange, + bots, + currentBotId, +}: { + draft: AutomationPolicyDraft; + onChange: (draft: AutomationPolicyDraft) => void; + bots: BotProfile[]; + currentBotId: string; +}) { + const { t } = useTranslation(); + const delegateBots = bots.filter((candidate) => candidate.id !== currentBotId && candidate.enabled); + return ( +
+

+ {t('bots.automations.executionPolicy')} +

+

+ {t('bots.automations.executionPolicyDescription')} +

+
+ + + +
+ + {draft.delegateTargetMode === 'allowlist' ? ( +
+ {delegateBots.length === 0 ? ( + {t('bots.automations.noDelegateBots')} + ) : delegateBots.map((candidate) => { + const checked = draft.allowedDelegateBotIds.includes(candidate.id); + return ( + + ); + })} +
+ ) : null} +
+ ); +} + +function localTimezone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; +} + +function readError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function formatTime(value?: number): string { + return value ? new Date(value).toLocaleString() : '—'; +} + +function statusTone(status: string): string { + if (status === 'success' || status === 'delivered' || status === 'active') { + return 'text-[var(--status-success)]'; + } + if (status === 'failed' || status === 'dead-letter' || status === 'error') { + return 'text-[var(--text-danger)]'; + } + return 'text-[var(--text-secondary)]'; +} + +function modeForAutomation(automation: BotAutomation): ScheduleMode { + if (automation.manual) return 'manual'; + if (automation.intervalMs) return 'interval'; + return /^\d{1,2} \d{1,2} \* \* \*$/.test(automation.cronExpr) ? 'daily' : 'cron'; +} + +function dailyTimeForAutomation(automation: BotAutomation): string { + const match = /^(\d{1,2}) (\d{1,2}) \* \* \*$/.exec(automation.cronExpr); + if (!match) return '09:00'; + return `${match[2]!.padStart(2, '0')}:${match[1]!.padStart(2, '0')}`; +} + +function RunHistory({ + automation, + onOpenTask, +}: { + automation: BotAutomation; + onOpenTask: (sessionId: string) => void; +}) { + const { t } = useTranslation(); + const { confirm } = useConfirmDialog(); + const [runs, setRuns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [retryingRunId, setRetryingRunId] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setRuns(await window.electronAPI.maker.botAutomations.listRuns(automation.id, 50)); + } catch (cause) { + setError(readError(cause)); + } finally { + setLoading(false); + } + }, [automation.id]); + + useEffect(() => { + void load(); + return window.electronAPI.maker.botAutomations.onChanged((payload) => { + if (payload.automationId === automation.id) void load(); + }); + }, [automation.id, load]); + + const retryDelivery = async (run: BotAutomationRun) => { + const allowDuplicateRisk = run.deliveryDiagnostic?.retrySafe === false; + if (allowDuplicateRisk) { + const confirmed = await confirm({ + title: t('bots.automations.retryDuplicateTitle'), + description: t('bots.automations.retryDuplicateDescription', { + count: run.deliveryDiagnostic?.sentMediaCount ?? 0, + }), + confirmText: t('bots.automations.retryDuplicateConfirm'), + cancelText: t('commonUi.confirmDialog.cancel'), + confirmVariant: 'destructive', + }); + if (!confirmed) return; + } + setRetryingRunId(run.id); + setError(null); + try { + await window.electronAPI.maker.botAutomations.retryDelivery( + automation.id, + run.id, + allowDuplicateRisk, + ); + await load(); + } catch (cause) { + setError(readError(cause)); + } finally { + setRetryingRunId(null); + } + }; + + if (loading) { + return

{t('bots.automations.loading')}

; + } + if (error) { + return

{error}

; + } + if (runs.length === 0) { + return

{t('bots.automations.noRuns')}

; + } + + return ( +
+ {runs.map((run) => ( +
+
+
+ {t(`bots.automations.runStatus.${run.status}`)} + v{run.profileVersion} +
+ {formatTime(run.finishedAt ?? run.firedAt)} +
+ {run.resultText ? ( +

{run.resultText}

+ ) : null} + {run.errorMessage ? ( +

{run.errorMessage}

+ ) : null} + {run.worktreePath ? ( +

+ {t('bots.automations.worktree')}: {run.worktreePath} +

+ ) : null} + {run.outputArtifacts.length > 0 ? ( +

+ + {t('bots.automations.outputArtifacts', { count: run.outputArtifacts.length })} +

+ ) : null} + {run.deliveryDiagnostic ? ( +

+ {[ + run.deliveryDiagnostic.textMessageId + ? t('bots.automations.deliveryProgressText') + : null, + run.deliveryDiagnostic.sentMediaCount > 0 + ? t('bots.automations.deliveryProgressMedia', { + count: run.deliveryDiagnostic.sentMediaCount, + }) + : null, + run.deliveryDiagnostic.committedFinal + ? t('bots.automations.deliveryCommitted') + : null, + ].filter(Boolean).join(' · ')} +

+ ) : null} +
+ + {t('bots.automations.delivery')}: {t(`bots.automations.deliveryStatus.${run.deliveryStatus}`)} + + + {run.deliveryStatus === 'failed' + || run.deliveryStatus === 'dead-letter' + || run.deliveryStatus === 'enqueue-failed' ? ( + + ) : null} + {run.sessionId ? ( + + ) : null} + +
+ {run.deliveryError ? ( +

{run.deliveryError}

+ ) : null} +
+ ))} +
+ ); +} + +function AutomationEditForm({ + automation, + projects, + routes, + bots, + currentBotId, + onSaved, + onCancel, +}: { + automation: BotAutomation; + projects: NonNullable; + routes: NonNullable; + bots: BotProfile[]; + currentBotId: string; + onSaved: () => Promise; + onCancel: () => void; +}) { + const { t } = useTranslation(); + const [name, setName] = useState(automation.name); + const [prompt, setPrompt] = useState(automation.prompt); + const [mode, setMode] = useState(() => modeForAutomation(automation)); + const [dailyTime, setDailyTime] = useState(() => dailyTimeForAutomation(automation)); + const [intervalMinutes, setIntervalMinutes] = useState( + Math.max(1, Math.round((automation.intervalMs ?? 60 * 60_000) / 60_000)), + ); + const [cronExpr, setCronExpr] = useState(automation.cronExpr); + const [timezone, setTimezone] = useState(automation.timezone); + const [projectBindingId, setProjectBindingId] = useState(automation.projectBindingId ?? ''); + const [targetRouteId, setTargetRouteId] = useState(automation.targetRouteId ?? ''); + const [durableNoteNamespace, setDurableNoteNamespace] = useState( + automation.durableNoteNamespace ?? '', + ); + const [executionPolicy, setExecutionPolicy] = useState(() => + policyDraft(automation.executionPolicy), + ); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const save = async () => { + if (!name.trim() || !prompt.trim()) return; + setSaving(true); + setError(null); + try { + let cadence: Pick< + UpdateBotAutomationInput, + 'cronExpr' | 'recurring' | 'manual' | 'intervalMs' + >; + if (mode === 'manual') { + cadence = { + cronExpr: automation.cronExpr || '0 0 * * *', + recurring: false, + manual: true, + intervalMs: undefined, + }; + } else if (mode === 'daily') { + const [hour, minute] = dailyTime.split(':').map(Number); + cadence = { + cronExpr: `${minute || 0} ${hour || 0} * * *`, + recurring: true, + manual: false, + intervalMs: undefined, + }; + } else if (mode === 'interval') { + cadence = { + cronExpr: automation.cronExpr || '0 * * * *', + recurring: true, + manual: false, + intervalMs: Math.max(1, intervalMinutes) * 60_000, + }; + } else { + cadence = { + cronExpr: cronExpr.trim(), + recurring: true, + manual: false, + intervalMs: undefined, + }; + } + await window.electronAPI.maker.botAutomations.update(automation.id, { + name: name.trim(), + prompt: prompt.trim(), + timezone: timezone.trim() || 'UTC', + ...cadence, + projectBindingId: projectBindingId || null, + targetRouteId: targetRouteId || null, + durableNoteNamespace: durableNoteNamespace.trim() || null, + executionPolicy: executionPolicyFromDraft(executionPolicy), + }); + await onSaved(); + } catch (cause) { + setError(readError(cause)); + return; + } finally { + setSaving(false); + } + onCancel(); + }; + + return ( +
+
+ + +
+