From bca321223d8b88aef67b73c5e072a244737790f2 Mon Sep 17 00:00:00 2001 From: DavidShen Date: Sun, 20 Sep 2026 19:58:03 +0800 Subject: [PATCH 01/29] feat(sharing): share tasks across accounts with unified desktop and mobile flows Signed-off-by: DavidShen --- .gitignore | 3 + AGENTS.md | 2 + .../drizzle/0114_shared_task_events.sql | 14 + apps/desktop/drizzle/meta/0114_snapshot.json | 6066 +++++++++++++++++ apps/desktop/drizzle/meta/_journal.json | 7 + .../scripts/0114_shared_task_events.ts | 39 + .../src/main/__tests__/codexAuthLink.test.ts | 9 +- .../__tests__/mobileClientPromptNote.test.ts | 6 + .../main/__tests__/serverApiClient.test.ts | 14 + .../__tests__/setSessionsStatusInDb.test.ts | 3 + apps/desktop/src/main/bootstrap-electron.ts | 6 + apps/desktop/src/main/cindy-media/ledger.ts | 10 + .../__tests__/dispatchWeakNetwork.test.ts | 25 + .../device-link/__tests__/mediaFetch.test.ts | 47 +- .../__tests__/outboundMedia.test.ts | 35 + .../__tests__/sharedTaskAccess.test.ts | 85 + .../__tests__/sharedTaskApi.test.ts | 70 + .../__tests__/sharedTaskCommands.test.ts | 62 + .../__tests__/sharedTaskDispatch.test.ts | 86 + .../__tests__/sharedTaskHost.test.ts | 434 ++ .../__tests__/sharedTaskMediaAccess.test.ts | 64 + .../__tests__/sharedTaskPush.test.ts | 130 + .../__tests__/sharedTaskRuntime.test.ts | 172 + .../sharedTaskTransientAccess.test.ts | 134 + apps/desktop/src/main/device-link/dispatch.ts | 141 +- apps/desktop/src/main/device-link/index.ts | 56 +- .../src/main/device-link/invoke-context.ts | 5 + apps/desktop/src/main/device-link/ipc.ts | 19 +- .../src/main/device-link/mediaFetch.ts | 23 +- .../src/main/device-link/mediaTransfer.ts | 3 +- .../src/main/device-link/outboundMedia.ts | 25 +- .../src/main/device-link/sharedTaskAccess.ts | 105 + .../src/main/device-link/sharedTaskApi.ts | 61 + .../main/device-link/sharedTaskCommands.ts | 76 + .../main/device-link/sharedTaskDispatch.ts | 177 + .../src/main/device-link/sharedTaskHost.ts | 395 ++ .../src/main/device-link/sharedTaskIpc.ts | 28 + .../main/device-link/sharedTaskMediaAccess.ts | 53 + .../device-link/sharedTaskMediaContext.ts | 8 + .../src/main/device-link/sharedTaskRuntime.ts | 137 + .../__tests__/remoteFileCacheOwner.test.ts | 6 +- .../localDb/__tests__/sharedTasks.test.ts | 92 + .../ipc/__tests__/sessionsUpdate.test.ts | 12 +- apps/desktop/src/main/localDb/ipc/sessions.ts | 12 + apps/desktop/src/main/localDb/schema.ts | 15 + apps/desktop/src/main/localDb/sharedTasks.ts | 55 + .../__tests__/agent-input-coordinator.test.ts | 16 + .../__tests__/sharedTaskContextUsage.test.ts | 68 + .../__tests__/sharedTaskInput.test.ts | 39 + .../__tests__/sharedTaskSetting.test.ts | 56 + .../main/maker-ipc/agent-input-coordinator.ts | 19 + .../main/maker-ipc/contextOverflowRollover.ts | 1 + .../main/maker-ipc/makerSendTransaction.ts | 4 + .../main/maker-ipc/mobileClientPromptNote.ts | 9 +- apps/desktop/src/main/maker-ipc/register.ts | 103 +- .../src/main/maker-ipc/runtimeSetModel.ts | 6 + .../maker-ipc/sessionAgentSwitchHandler.ts | 5 + .../main/maker-ipc/sharedTaskContextUsage.ts | 26 + .../src/main/maker-ipc/sharedTaskInput.ts | 51 + .../src/main/maker-ipc/sharedTaskSetting.ts | 19 + apps/desktop/src/main/serverApiClient.ts | 4 +- apps/desktop/src/preload/preload.ts | 6 + .../__tests__/cindyMakeInlineEntry.test.ts | 35 +- .../deviceLinkInteractionScenarios.test.ts | 2 +- .../__tests__/machineSwitcher.test.ts | 9 + .../remoteDataOwnerPushFence.test.ts | 106 + .../remoteSessionSyncInvariants.test.ts | 2 +- .../sessionContentHeaderDragRegion.test.ts | 8 +- .../voiceInputEditorEditability.test.ts | 2 +- .../components/chat/AssistantMessage.tsx | 6 +- .../components/chat/MessageStream.tsx | 1 + .../renderer/components/chat/UserMessage.tsx | 13 +- .../ChromeActions.fullscreen.test.tsx | 3 + .../components/new-chat/ChatInput.tsx | 12 +- .../components/title-bar/MenuButton.tsx | 12 +- .../title-bar/__tests__/MenuButton.test.tsx | 3 + .../ui/__tests__/confirmDialogA11y.test.tsx | 8 + .../renderer/components/ui/confirm-dialog.tsx | 26 +- .../bots/__tests__/botChatSurface.test.ts | 2 +- .../features/cc-agent/CCAgentSessionView.tsx | 21 +- .../features/cc-agent/CCAgentSidebarUpper.tsx | 12 +- .../cc-agent/SessionContentHeader.tsx | 20 +- .../features/cc-agent/sidebar/SessionItem.tsx | 3 +- .../device-link/JoinSharedTaskDialog.tsx | 201 + .../features/device-link/SharedTaskButton.tsx | 277 + .../device-link/SharedTaskEndedNotice.tsx | 50 + .../device-link/SharedTasksSection.tsx | 126 + .../__tests__/JoinSharedTaskDialog.test.tsx | 116 + .../__tests__/SharedTaskButton.test.tsx | 153 + .../__tests__/SharedTaskEndedNotice.test.tsx | 33 + .../__tests__/SharedTasksSection.test.tsx | 87 + .../__tests__/sharedTaskCompatibility.test.ts | 19 + .../device-link/sharedTaskCompatibility.ts | 11 + .../features/device-link/switcherDevices.ts | 3 + .../useDeviceLinkRemoteProjects.ts | 2 + .../device-link/useSharedTaskTasks.ts | 71 + .../src/renderer/i18n/locales/en/common.json | 99 + .../src/renderer/i18n/locales/ja/common.json | 99 + .../src/renderer/i18n/locales/ko/common.json | 99 + .../renderer/i18n/locales/zh-CN/common.json | 99 + .../renderer/i18n/locales/zh-TW/common.json | 99 + .../src/renderer/lib/makerChatStore.ts | 9 +- .../renderer/lib/remoteDataOwnerPushFence.ts | 41 +- apps/desktop/src/renderer/vite-env.d.ts | 4 + apps/desktop/src/shared/agentInputQueue.ts | 3 + apps/desktop/src/shared/ipc-errors.ts | 6 + apps/mobile/app/devices/index.tsx | 19 +- apps/mobile/app/sessions/[sessionId].tsx | 59 +- apps/mobile/app/shared-session.tsx | 308 + apps/mobile/metro.config.js | 1 + .../SessionMenuContextEntry.test.tsx | 14 + .../src/__tests__/accessRevoked.test.ts | 15 + .../__tests__/composerDraftSource.test.tsx | 60 +- .../composerRichInputLifecycle.test.tsx | 28 +- .../connectionBannerVisibility.test.ts | 13 + .../src/__tests__/envBundleTransform.test.ts | 54 + .../fixtures/theme-colors-snapshot.json | 4 + .../__tests__/historyViewProvider.test.tsx | 19 + .../src/__tests__/interactionModel.test.ts | 5 +- .../__tests__/mobileAttachmentUpload.test.ts | 9 + .../src/__tests__/sendEnqueueRetry.test.ts | 2 +- .../sessionComposerDesktopFirst.test.ts | 2 +- .../sessionMainLayerDesktopFirst.test.ts | 14 +- .../sharedSessionCompatibility.test.tsx | 82 + .../__tests__/sharedTaskAccessWatch.test.ts | 73 + .../__tests__/sharedTaskCompatibility.test.ts | 22 + .../__tests__/sharedTaskConfirmation.test.tsx | 75 + .../src/__tests__/sharedTaskFlow.test.tsx | 183 + .../src/__tests__/useLeaveSharedTask.test.tsx | 86 + .../usePromptRecommendation.test.tsx | 3 + .../useSessionResourceCards.test.tsx | 14 + .../src/__tests__/useSharedTasks.test.tsx | 41 + .../src/components/MobilePrimitives.tsx | 15 +- .../components/connectionBannerVisibility.ts | 16 + apps/mobile/src/config/env.ts | 18 +- .../src/device-link/DeviceLinkContext.tsx | 50 +- apps/mobile/src/device-link/accessRevoked.ts | 6 +- .../src/device-link/revokedDevicesStore.ts | 8 + .../src/device-link/sharedTaskAccessWatch.ts | 41 + .../device-link/sharedTaskCompatibility.ts | 14 + .../src/device-link/useLeaveSharedTask.ts | 60 + .../src/device-link/useSharedTaskAccess.ts | 32 + .../src/device-link/useSharedTaskApi.ts | 20 + apps/mobile/src/device-link/useSharedTasks.ts | 53 + apps/mobile/src/i18n/locales/en/index.ts | 2 + .../src/i18n/locales/en/sharedTask.json | 89 + apps/mobile/src/i18n/locales/ja/index.ts | 2 + .../src/i18n/locales/ja/sharedTask.json | 89 + apps/mobile/src/i18n/locales/ko/index.ts | 2 + .../src/i18n/locales/ko/sharedTask.json | 89 + apps/mobile/src/i18n/locales/zh-CN/index.ts | 2 + .../src/i18n/locales/zh-CN/sharedTask.json | 89 + apps/mobile/src/i18n/locales/zh-TW/index.ts | 2 + .../src/i18n/locales/zh-TW/sharedTask.json | 89 + .../src/platform/chrome/showActionMenu.ts | 3 +- apps/mobile/src/session/ComposerRichInput.tsx | 6 + apps/mobile/src/session/HomeChromeDrawer.tsx | 12 + apps/mobile/src/session/InteractionPanel.tsx | 6 +- apps/mobile/src/session/MessageRenderer.tsx | 3 + apps/mobile/src/session/SessionMenuSheet.tsx | 34 +- .../src/session/SharedTaskEndedState.tsx | 26 + apps/mobile/src/session/SharedTaskScreen.tsx | 35 + .../mobile/src/session/composerDraftSource.ts | 22 +- .../src/session/mobileAttachmentUpload.ts | 6 +- .../session/mobileLocalAttachmentUpload.ts | 6 +- .../src/session/useMobileLocalAttachments.ts | 5 +- .../src/session/useSessionResourceCards.ts | 5 +- .../src/session/useSharedTaskConfirmation.tsx | 57 + apps/mobile/src/theme/tokens.ts | 7 + docs/design-rules/DESIGN.md | 1 + docs/design-rules/design-inventory.md | 39 +- docs/product-rules/shared-task-mode.md | 204 + i18n/GLOSSARY.md | 4 + i18n/glossary.json | 7 + packages/device-link-protocol/src/protocol.ts | 4 + .../device-link-protocol/src/sharedTask.ts | 37 + .../device-link/src/__tests__/client.test.ts | 42 + .../src/__tests__/sharedTask.test.ts | 116 + .../src/__tests__/sharedTaskApi.test.ts | 90 + .../src/__tests__/sharedTaskProbe.test.ts | 58 + packages/device-link/src/allowlist.ts | 3 + packages/device-link/src/client.ts | 13 + packages/device-link/src/index.ts | 3 + packages/device-link/src/protocol.ts | 3 + packages/device-link/src/sharedTask.ts | 182 + packages/device-link/src/sharedTaskApi.ts | 141 + packages/device-link/src/sharedTaskProbe.ts | 26 + packages/maker-shared/src/index.ts | 1 + packages/maker-shared/src/sharedTask.ts | 20 + scripts/shared/design-inventory.mjs | 1 + 190 files changed, 14514 insertions(+), 179 deletions(-) create mode 100644 apps/desktop/drizzle/0114_shared_task_events.sql create mode 100644 apps/desktop/drizzle/meta/0114_snapshot.json create mode 100644 apps/desktop/drizzle/scripts/0114_shared_task_events.ts create mode 100644 apps/desktop/src/main/device-link/__tests__/sharedTaskAccess.test.ts create mode 100644 apps/desktop/src/main/device-link/__tests__/sharedTaskApi.test.ts create mode 100644 apps/desktop/src/main/device-link/__tests__/sharedTaskCommands.test.ts create mode 100644 apps/desktop/src/main/device-link/__tests__/sharedTaskDispatch.test.ts create mode 100644 apps/desktop/src/main/device-link/__tests__/sharedTaskHost.test.ts create mode 100644 apps/desktop/src/main/device-link/__tests__/sharedTaskMediaAccess.test.ts create mode 100644 apps/desktop/src/main/device-link/__tests__/sharedTaskPush.test.ts create mode 100644 apps/desktop/src/main/device-link/__tests__/sharedTaskRuntime.test.ts create mode 100644 apps/desktop/src/main/device-link/__tests__/sharedTaskTransientAccess.test.ts create mode 100644 apps/desktop/src/main/device-link/sharedTaskAccess.ts create mode 100644 apps/desktop/src/main/device-link/sharedTaskApi.ts create mode 100644 apps/desktop/src/main/device-link/sharedTaskCommands.ts create mode 100644 apps/desktop/src/main/device-link/sharedTaskDispatch.ts create mode 100644 apps/desktop/src/main/device-link/sharedTaskHost.ts create mode 100644 apps/desktop/src/main/device-link/sharedTaskIpc.ts create mode 100644 apps/desktop/src/main/device-link/sharedTaskMediaAccess.ts create mode 100644 apps/desktop/src/main/device-link/sharedTaskMediaContext.ts create mode 100644 apps/desktop/src/main/device-link/sharedTaskRuntime.ts create mode 100644 apps/desktop/src/main/localDb/__tests__/sharedTasks.test.ts create mode 100644 apps/desktop/src/main/localDb/sharedTasks.ts create mode 100644 apps/desktop/src/main/maker-ipc/__tests__/sharedTaskContextUsage.test.ts create mode 100644 apps/desktop/src/main/maker-ipc/__tests__/sharedTaskInput.test.ts create mode 100644 apps/desktop/src/main/maker-ipc/__tests__/sharedTaskSetting.test.ts create mode 100644 apps/desktop/src/main/maker-ipc/sharedTaskContextUsage.ts create mode 100644 apps/desktop/src/main/maker-ipc/sharedTaskInput.ts create mode 100644 apps/desktop/src/main/maker-ipc/sharedTaskSetting.ts create mode 100644 apps/desktop/src/renderer/__tests__/remoteDataOwnerPushFence.test.ts create mode 100644 apps/desktop/src/renderer/features/device-link/JoinSharedTaskDialog.tsx create mode 100644 apps/desktop/src/renderer/features/device-link/SharedTaskButton.tsx create mode 100644 apps/desktop/src/renderer/features/device-link/SharedTaskEndedNotice.tsx create mode 100644 apps/desktop/src/renderer/features/device-link/SharedTasksSection.tsx create mode 100644 apps/desktop/src/renderer/features/device-link/__tests__/JoinSharedTaskDialog.test.tsx create mode 100644 apps/desktop/src/renderer/features/device-link/__tests__/SharedTaskButton.test.tsx create mode 100644 apps/desktop/src/renderer/features/device-link/__tests__/SharedTaskEndedNotice.test.tsx create mode 100644 apps/desktop/src/renderer/features/device-link/__tests__/SharedTasksSection.test.tsx create mode 100644 apps/desktop/src/renderer/features/device-link/__tests__/sharedTaskCompatibility.test.ts create mode 100644 apps/desktop/src/renderer/features/device-link/sharedTaskCompatibility.ts create mode 100644 apps/desktop/src/renderer/features/device-link/useSharedTaskTasks.ts create mode 100644 apps/mobile/app/shared-session.tsx create mode 100644 apps/mobile/src/__tests__/envBundleTransform.test.ts create mode 100644 apps/mobile/src/__tests__/sharedSessionCompatibility.test.tsx create mode 100644 apps/mobile/src/__tests__/sharedTaskAccessWatch.test.ts create mode 100644 apps/mobile/src/__tests__/sharedTaskCompatibility.test.ts create mode 100644 apps/mobile/src/__tests__/sharedTaskConfirmation.test.tsx create mode 100644 apps/mobile/src/__tests__/sharedTaskFlow.test.tsx create mode 100644 apps/mobile/src/__tests__/useLeaveSharedTask.test.tsx create mode 100644 apps/mobile/src/__tests__/useSharedTasks.test.tsx create mode 100644 apps/mobile/src/device-link/sharedTaskAccessWatch.ts create mode 100644 apps/mobile/src/device-link/sharedTaskCompatibility.ts create mode 100644 apps/mobile/src/device-link/useLeaveSharedTask.ts create mode 100644 apps/mobile/src/device-link/useSharedTaskAccess.ts create mode 100644 apps/mobile/src/device-link/useSharedTaskApi.ts create mode 100644 apps/mobile/src/device-link/useSharedTasks.ts create mode 100644 apps/mobile/src/i18n/locales/en/sharedTask.json create mode 100644 apps/mobile/src/i18n/locales/ja/sharedTask.json create mode 100644 apps/mobile/src/i18n/locales/ko/sharedTask.json create mode 100644 apps/mobile/src/i18n/locales/zh-CN/sharedTask.json create mode 100644 apps/mobile/src/i18n/locales/zh-TW/sharedTask.json create mode 100644 apps/mobile/src/session/SharedTaskEndedState.tsx create mode 100644 apps/mobile/src/session/SharedTaskScreen.tsx create mode 100644 apps/mobile/src/session/useSharedTaskConfirmation.tsx create mode 100644 docs/product-rules/shared-task-mode.md create mode 100644 packages/device-link-protocol/src/sharedTask.ts create mode 100644 packages/device-link/src/__tests__/sharedTask.test.ts create mode 100644 packages/device-link/src/__tests__/sharedTaskApi.test.ts create mode 100644 packages/device-link/src/__tests__/sharedTaskProbe.test.ts create mode 100644 packages/device-link/src/sharedTask.ts create mode 100644 packages/device-link/src/sharedTaskApi.ts create mode 100644 packages/device-link/src/sharedTaskProbe.ts create mode 100644 packages/maker-shared/src/sharedTask.ts diff --git a/.gitignore b/.gitignore index 5f67254f3c1..f8163ea3305 100644 --- a/.gitignore +++ b/.gitignore @@ -198,3 +198,6 @@ github-result-*.json # 内置插件种子已废弃(改走 plugin-store 安装);历史克隆含私有插件与凭证,永不入仓。 apps/desktop/resources/builtin-ghosts/ + +# 本地验收截图与设计稿等任务产物(只在本地保存,不入仓)。 +artifacts/ diff --git a/AGENTS.md b/AGENTS.md index 3c9eb63a7cf..0eb9afeb026 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,6 +52,8 @@ 提的,提交者身份不构成例外。 - 新增或调整产品功能、判断能力应进入 Core / Skill / 插件、设计人机交互或多端体验 前,必须先读 `docs/product-rules/core-product-principles.md`。 +- 修改共享任务、跨账号访客邀请、共享上下文或共享成员权限前,必须先读 + `docs/product-rules/shared-task-mode.md`;复用 device-link,同账号远控行为不变。 - 修改伙伴(Bot)的身份、Session 生命周期、模型 fallback、工作目录、Skill / MCP 装配、 委派协作或伙伴设置前,必须先读 `docs/product-rules/cindy-bots-runtime.md`。 - 新增或修改 `/review`、Reviewer 任务、成果快照、Finding 协议、复核入口、结果呈现或 diff --git a/apps/desktop/drizzle/0114_shared_task_events.sql b/apps/desktop/drizzle/0114_shared_task_events.sql new file mode 100644 index 00000000000..3a907f3989e --- /dev/null +++ b/apps/desktop/drizzle/0114_shared_task_events.sql @@ -0,0 +1,14 @@ +CREATE TABLE `shared_task_events` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `shared_task_id` text NOT NULL, + `session_id` text NOT NULL, + `revision` integer NOT NULL, + `kind` text NOT NULL, + `terminal` integer NOT NULL, + `snapshot` text, + `recorded_at` integer NOT NULL, + FOREIGN KEY (`session_id`) REFERENCES `sessions`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `shared_task_events_revision_idx` ON `shared_task_events` (`shared_task_id`,`kind`,`revision`);--> statement-breakpoint +CREATE INDEX `shared_task_events_session_idx` ON `shared_task_events` (`session_id`,`id`); \ No newline at end of file diff --git a/apps/desktop/drizzle/meta/0114_snapshot.json b/apps/desktop/drizzle/meta/0114_snapshot.json new file mode 100644 index 00000000000..590683e2243 --- /dev/null +++ b/apps/desktop/drizzle/meta/0114_snapshot.json @@ -0,0 +1,6066 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c5d29ae9-c017-4e7f-ba01-9e305d7b4cb8", + "prevId": "454a5925-19b5-4cb0-a1ef-9d2e0d6858d5", + "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_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": false, + "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": false, + "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": "'[]'" + }, + "pending_interaction_json": { + "name": "pending_interaction_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_sequence": { + "name": "run_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "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 + }, + "completion_delivered_at": { + "name": "completion_delivered_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_direct_message_threads": { + "name": "bot_direct_message_threads", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "bot_a_id": { + "name": "bot_a_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bot_b_id": { + "name": "bot_b_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_messages": { + "name": "max_messages", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "blocked_until": { + "name": "blocked_until", + "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 + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uniq_bot_dm_threads_active_pair": { + "name": "uniq_bot_dm_threads_active_pair", + "columns": [ + "bot_a_id", + "bot_b_id" + ], + "isUnique": true, + "where": "\"bot_direct_message_threads\".\"status\" = 'active'" + }, + "idx_bot_dm_threads_pair_updated": { + "name": "idx_bot_dm_threads_pair_updated", + "columns": [ + "bot_a_id", + "bot_b_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "bot_direct_messages": { + "name": "bot_direct_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_bot_id": { + "name": "sender_bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_bot_id": { + "name": "recipient_bot_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_session_id": { + "name": "sender_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_session_id": { + "name": "recipient_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delivery_status": { + "name": "delivery_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_name": { + "name": "recipient_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bridge_session_id": { + "name": "bridge_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "uniq_bot_direct_messages_thread_sequence": { + "name": "uniq_bot_direct_messages_thread_sequence", + "columns": [ + "thread_id", + "sequence" + ], + "isUnique": true + }, + "idx_bot_direct_messages_thread_created": { + "name": "idx_bot_direct_messages_thread_created", + "columns": [ + "thread_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bot_direct_messages_thread_id_bot_direct_message_threads_id_fk": { + "name": "bot_direct_messages_thread_id_bot_direct_message_threads_id_fk", + "tableFrom": "bot_direct_messages", + "tableTo": "bot_direct_message_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_direct_messages_sender_session_id_sessions_id_fk": { + "name": "bot_direct_messages_sender_session_id_sessions_id_fk", + "tableFrom": "bot_direct_messages", + "tableTo": "sessions", + "columnsFrom": [ + "sender_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "bot_direct_messages_recipient_session_id_sessions_id_fk": { + "name": "bot_direct_messages_recipient_session_id_sessions_id_fk", + "tableFrom": "bot_direct_messages", + "tableTo": "sessions", + "columnsFrom": [ + "recipient_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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'" + }, + "hidden_at": { + "name": "hidden_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attention_reason": { + "name": "attention_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attention_at": { + "name": "attention_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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_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 + }, + "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 + } + }, + "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" + } + }, + "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": {} + }, + "im_notification_origins": { + "name": "im_notification_origins", + "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 + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "im_notification_origins_channel_bot_context_id_user_id_message_id_pk": { + "columns": [ + "channel", + "bot_context_id", + "user_id", + "message_id" + ], + "name": "im_notification_origins_channel_bot_context_id_user_id_message_id_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_invocations": { + "name": "media_invocations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capability": { + "name": "capability", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guide_revision": { + "name": "guide_revision", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "guide_json": { + "name": "guide_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_json": { + "name": "response_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 + } + }, + "indexes": { + "media_invocations_owner_created_at_idx": { + "name": "media_invocations_owner_created_at_idx", + "columns": [ + "owner", + "created_at" + ], + "isUnique": false + }, + "media_invocations_owner_state_idx": { + "name": "media_invocations_owner_state_idx", + "columns": [ + "owner", + "state" + ], + "isUnique": false + } + }, + "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 + }, + "idx_messages_active_error_tail": { + "name": "idx_messages_active_error_tail", + "columns": [ + "session_id", + "created_at" + ], + "isUnique": false, + "where": "\"messages\".\"role\" = 'error' AND \"messages\".\"rewind_at\" IS NULL" + } + }, + "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": {} + }, + "messages_fts_rows": { + "name": "messages_fts_rows", + "columns": { + "fts_rowid": { + "name": "fts_rowid", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "messages_fts_rows_message_id_idx": { + "name": "messages_fts_rows_message_id_idx", + "columns": [ + "message_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "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_artifacts_singleton_idx": { + "name": "right_sidebar_tabs_bot_artifacts_singleton_idx", + "columns": [ + "session_id" + ], + "isUnique": true, + "where": "\"right_sidebar_tabs\".\"kind\" = 'bot-artifacts'" + } + }, + "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 + }, + "idx_schedule_runs_running_schedule": { + "name": "idx_schedule_runs_running_schedule", + "columns": [ + "schedule_id" + ], + "isUnique": false, + "where": "\"schedule_runs\".\"status\" = 'running'" + }, + "idx_schedule_runs_running_heartbeat": { + "name": "idx_schedule_runs_running_heartbeat", + "columns": [ + "heartbeat_at" + ], + "isUnique": false, + "where": "\"schedule_runs\".\"status\" = 'running' AND \"schedule_runs\".\"heartbeat_at\" IS NOT NULL" + }, + "idx_schedule_runs_running_legacy": { + "name": "idx_schedule_runs_running_legacy", + "columns": [ + "fired_at" + ], + "isUnique": false, + "where": "\"schedule_runs\".\"status\" = 'running' AND \"schedule_runs\".\"heartbeat_at\" IS NULL" + }, + "idx_schedule_runs_unread_terminal": { + "name": "idx_schedule_runs_unread_terminal", + "columns": [ + "schedule_id", + "status", + "fired_at" + ], + "isUnique": false, + "where": "\"schedule_runs\".\"read_at\" IS NULL AND \"schedule_runs\".\"status\" IN ('success', 'failed', 'aborted', 'interrupted')" + }, + "idx_schedule_runs_session_latest": { + "name": "idx_schedule_runs_session_latest", + "columns": [ + "session_id", + "fired_at", + "id" + ], + "isUnique": false, + "where": "\"schedule_runs\".\"session_id\" IS NOT NULL" + } + }, + "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": {} + }, + "schedule_session_latest_runs": { + "name": "schedule_session_latest_runs", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fired_at": { + "name": "fired_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_schedule_session_latest_runs_run": { + "name": "idx_schedule_session_latest_runs_run", + "columns": [ + "run_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "schedule_session_latest_runs_session_id_sessions_id_fk": { + "name": "schedule_session_latest_runs_session_id_sessions_id_fk", + "tableFrom": "schedule_session_latest_runs", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "schedule_session_latest_runs_run_id_schedule_runs_id_fk": { + "name": "schedule_session_latest_runs_run_id_schedule_runs_id_fk", + "tableFrom": "schedule_session_latest_runs", + "tableTo": "schedule_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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_agent_kind": { + "name": "model_agent_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "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": {} + }, + "session_task_tags": { + "name": "session_task_tags", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_task_tags_tag_idx": { + "name": "session_task_tags_tag_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_task_tags_session_id_sessions_id_fk": { + "name": "session_task_tags_session_id_sessions_id_fk", + "tableFrom": "session_task_tags", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_task_tags_tag_id_task_tags_id_fk": { + "name": "session_task_tags_tag_id_task_tags_id_fk", + "tableFrom": "session_task_tags", + "tableTo": "task_tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "session_task_tags_session_id_tag_id_pk": { + "columns": [ + "session_id", + "tag_id" + ], + "name": "session_task_tags_session_id_tag_id_pk" + } + }, + "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 + }, + "context_window_runtime": { + "name": "context_window_runtime", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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": "'[]'" + }, + "writable_dirs": { + "name": "writable_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 + }, + "list_preview": { + "name": "list_preview", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "list_preview_role": { + "name": "list_preview_role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "list_message_count": { + "name": "list_message_count", + "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": {} + }, + "shared_task_events": { + "name": "shared_task_events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "shared_task_id": { + "name": "shared_task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "terminal": { + "name": "terminal", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot": { + "name": "snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recorded_at": { + "name": "recorded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "shared_task_events_revision_idx": { + "name": "shared_task_events_revision_idx", + "columns": [ + "shared_task_id", + "kind", + "revision" + ], + "isUnique": true + }, + "shared_task_events_session_idx": { + "name": "shared_task_events_session_idx", + "columns": [ + "session_id", + "id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "shared_task_events_session_id_sessions_id_fk": { + "name": "shared_task_events_session_id_sessions_id_fk", + "tableFrom": "shared_task_events", + "tableTo": "sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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_skill_recent": { + "name": "idx_skill_usage_exposures_skill_recent", + "columns": [ + "skill_name", + "analyzer_version", + "seen_at" + ], + "isUnique": false + }, + "idx_skill_usage_exposures_skill_recent_any_version": { + "name": "idx_skill_usage_exposures_skill_recent_any_version", + "columns": [ + "skill_name", + "seen_at" + ], + "isUnique": false + }, + "idx_skill_usage_exposures_analyzer_recent_source": { + "name": "idx_skill_usage_exposures_analyzer_recent_source", + "columns": [ + "analyzer_version", + "seen_at", + "raw_file_path" + ], + "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 + }, + "returned_result": { + "name": "returned_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "returned_result_empty": { + "name": "returned_result_empty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "returned_result_truncated": { + "name": "returned_result_truncated", + "type": "integer", + "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 + }, + "cost_usd": { + "name": "cost_usd", + "type": "real", + "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": {} + }, + "task_tags": { + "name": "task_tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name_customized": { + "name": "name_customized", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "favorite_order": { + "name": "favorite_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "task_tags_name_idx": { + "name": "task_tags_name_idx", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "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 10b6787346a..e0598f08140 100644 --- a/apps/desktop/drizzle/meta/_journal.json +++ b/apps/desktop/drizzle/meta/_journal.json @@ -799,6 +799,13 @@ "when": 1789868885746, "tag": "0113_grey_cannonball", "breakpoints": true + }, + { + "idx": 114, + "version": "6", + "when": 1789905526324, + "tag": "0114_shared_task_events", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/desktop/drizzle/scripts/0114_shared_task_events.ts b/apps/desktop/drizzle/scripts/0114_shared_task_events.ts new file mode 100644 index 00000000000..04c15214467 --- /dev/null +++ b/apps/desktop/drizzle/scripts/0114_shared_task_events.ts @@ -0,0 +1,39 @@ +function run(db) { + if (db.prepare("SELECT 1 FROM sqlite_master WHERE type = ? AND name = ?").get("table", "session_meeting_events")) { + db.exec("INSERT INTO shared_task_events (id, shared_task_id, session_id, revision, kind, terminal, snapshot, recorded_at) SELECT id, meeting_id, session_id, revision, kind, terminal, snapshot, recorded_at FROM session_meeting_events"); + db.exec("DROP TABLE session_meeting_events"); + } + function renameId(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return; + if (Object.prototype.hasOwnProperty.call(value, "meetingId")) { + value.sharedTaskId = value.meetingId; + delete value.meetingId; + } + } + function renameAuthor(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return; + if (Object.prototype.hasOwnProperty.call(value, "meetingAuthor")) { + value.sharedTaskAuthor = value.meetingAuthor; + renameId(value.sharedTaskAuthor); + delete value.meetingAuthor; + } + } + function updateJson(table, column, change) { + if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type = ? AND name = ?").get("table", table)) return; + const update = db.prepare(`UPDATE ${table} SET ${column} = ? WHERE rowid = ?`); + for (const row of db.prepare(`SELECT rowid AS rid, ${column} AS value FROM ${table} WHERE ${column} IS NOT NULL`).all()) { + let value; + try { value = JSON.parse(row.value); } catch { continue; } + const before = JSON.stringify(value); + change(value); + const after = JSON.stringify(value); + if (after !== before) update.run(after, row.rid); + } + } + updateJson("shared_task_events", "snapshot", renameId); + updateJson("messages", "agent_meta", renameAuthor); + updateJson("agent_input_queue_snapshots", "payload", (items) => { + if (Array.isArray(items)) items.forEach(renameAuthor); + }); +} +module.exports = { run }; diff --git a/apps/desktop/src/main/__tests__/codexAuthLink.test.ts b/apps/desktop/src/main/__tests__/codexAuthLink.test.ts index 5b5d5222dbd..fb14cc09df9 100644 --- a/apps/desktop/src/main/__tests__/codexAuthLink.test.ts +++ b/apps/desktop/src/main/__tests__/codexAuthLink.test.ts @@ -62,8 +62,9 @@ afterEach(() => { /** 两个路径最终是否解析到同一个 inode。 */ function sameInode(a: string, b: string): boolean { - const sa = fs.statSync(a); - const sb = fs.statSync(b); + // NTFS file IDs can exceed Number.MAX_SAFE_INTEGER; compare without rounding. + const sa = fs.statSync(a, { bigint: true }); + const sb = fs.statSync(b, { bigint: true }); return sa.dev === sb.dev && sa.ino === sb.ino; } @@ -103,14 +104,14 @@ describe('relinkSharedCodexAuth', () => { it.skipIf(!canLinkFile)('POSIX:系统 auth 原子替换后 symlink 自动跟随新 inode', async () => { fs.writeFileSync(systemAuth, SYSTEM_CONTENT); await relinkSharedCodexAuth(systemAuth, myAuth, 'darwin'); - const oldInode = fs.statSync(myAuth).ino; + const oldInode = fs.statSync(myAuth, { bigint: true }).ino; const replacement = `${systemAuth}.new`; fs.writeFileSync(replacement, JSON.stringify({ tokens: { access_token: 'rotated' } })); fs.renameSync(replacement, systemAuth); expect(fs.lstatSync(myAuth).isSymbolicLink()).toBe(true); - expect(fs.statSync(myAuth).ino).not.toBe(oldInode); + expect(fs.statSync(myAuth, { bigint: true }).ino).not.toBe(oldInode); expect(fs.readFileSync(myAuth, 'utf-8')).toContain('rotated'); }); diff --git a/apps/desktop/src/main/__tests__/mobileClientPromptNote.test.ts b/apps/desktop/src/main/__tests__/mobileClientPromptNote.test.ts index 6abd3e76fdb..b2dfa828250 100644 --- a/apps/desktop/src/main/__tests__/mobileClientPromptNote.test.ts +++ b/apps/desktop/src/main/__tests__/mobileClientPromptNote.test.ts @@ -383,6 +383,12 @@ describe('stripMainOnlySendOpts(直连路径消毒)', () => { expect(stripMainOnlySendOpts(opts)).toEqual(opts); }); + it('strips nested sharedTask authors without mutating the input', () => { + const opts = { persistUserMessage: { clientId: 'message', content: 'text', sharedTaskAuthor: { accountId: 'forged' } } }; + expect(stripMainOnlySendOpts(opts)).toEqual({ persistUserMessage: { clientId: 'message', content: 'text' } }); + expect(opts.persistUserMessage.sharedTaskAuthor).toEqual({ accountId: 'forged' }); + }); + it('非对象输入原样返回(事务自己 ?? {} 兜底)', () => { expect(stripMainOnlySendOpts(undefined)).toBeUndefined(); expect(stripMainOnlySendOpts(null)).toBeNull(); diff --git a/apps/desktop/src/main/__tests__/serverApiClient.test.ts b/apps/desktop/src/main/__tests__/serverApiClient.test.ts index e6292e0d261..b2ce152cd42 100644 --- a/apps/desktop/src/main/__tests__/serverApiClient.test.ts +++ b/apps/desktop/src/main/__tests__/serverApiClient.test.ts @@ -156,6 +156,20 @@ describe('serverApiFetch', () => { expect(mocks.invalidateSession).toHaveBeenCalledWith('account-unavailable'); }); + it.each(['ACCOUNT_UNAVAILABLE', 'TOKEN_EXPIRED'])('detached teardown %s cannot refresh or invalidate the next account', async (code) => { + mocks.getAccessToken.mockReturnValue('test-new-token'); + mocks.netFetch.mockResolvedValue({ ok: false, status: 401, json: async () => ({ error: { code } }) }); + await expect(serverApiFetch('/api/resource', { + baseUrl: 'https://old-resource.example.test', token: 'test-old-token', + skipAutoRefresh: true, skipSessionInvalidation: true, + })).rejects.toMatchObject({ code, statusCode: 401 }); + expect(mocks.netFetch).toHaveBeenCalledWith('https://old-resource.example.test/api/resource', + expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer test-old-token' }) })); + expect(mocks.refresh).not.toHaveBeenCalled(); + expect(mocks.invalidateSession).not.toHaveBeenCalled(); + expect(mocks.getAccessToken).not.toHaveBeenCalled(); + }); + it.each(['INVALID_TOKEN', 'UNAUTHORIZED'])('%s refresh 一次后重试', async (code) => { mocks.getAccessToken.mockReturnValueOnce('token-a').mockReturnValueOnce('token-b'); mocks.refresh.mockResolvedValue(true); diff --git a/apps/desktop/src/main/__tests__/setSessionsStatusInDb.test.ts b/apps/desktop/src/main/__tests__/setSessionsStatusInDb.test.ts index 8c2dc81a8da..e24b0c9b739 100644 --- a/apps/desktop/src/main/__tests__/setSessionsStatusInDb.test.ts +++ b/apps/desktop/src/main/__tests__/setSessionsStatusInDb.test.ts @@ -28,6 +28,7 @@ const h = vi.hoisted(() => ({ cancelSessionOperations: vi.fn(), cleanupRemovedSession: vi.fn(), runtimeCleanup: vi.fn(), + closeSharedTask: vi.fn(), removeSessionRefs: vi.fn(), hasRegisteredWorktreeForSession: vi.fn(), recycleWorktreeForRemovedSession: vi.fn(), @@ -65,6 +66,7 @@ vi.mock('../cindy-media/ledger', () => ({ removeSessionRefsIfDeleted: h.removeSessionRefs, })); vi.mock('../localDb/dialogueWorkspace', () => ({ ensureDialogueWorkspaceDir: vi.fn() })); +vi.mock('../device-link/sharedTaskRuntime.js', () => ({ closeSharedTaskForTask: h.closeSharedTask })); vi.mock('../git-context/prRefsStore', () => ({ recomputePrRefsForSession: vi.fn() })); vi.mock('../localDb/ipc/recentWorkdirs', () => ({ upsertRecentWorkdir: h.upsertRecentWorkdir, @@ -154,6 +156,7 @@ describe('setSessionsStatusInDb', () => { }), ); await setSessionsStatusInDb(ids, 'archived'); + expect(h.closeSharedTask.mock.calls.map(([id]) => id)).toEqual(ids); try { await vi.waitFor(() => expect(h.recycleWorktreeForRemovedSession).toHaveBeenCalledOnce()); expect(h.closeSession).toHaveBeenCalledExactlyOnceWith('one'); diff --git a/apps/desktop/src/main/bootstrap-electron.ts b/apps/desktop/src/main/bootstrap-electron.ts index 3d3ca553c3c..b6bec7ecda7 100644 --- a/apps/desktop/src/main/bootstrap-electron.ts +++ b/apps/desktop/src/main/bootstrap-electron.ts @@ -586,9 +586,12 @@ import { issueWritableDirectoryPickerGrant } from './maker-ipc/writableDirectory // 设备互联(跨设备远程控制): relay 连接 host + 开关/设备列表 IPC import { initDeviceLinkService, + isSharedTaskAvailable, releaseDeviceLinkOwnershipBeforeLogout, handleDeviceLinkSystemResume, } from './device-link'; +import { closeSharedTasksBeforeLogout } from './device-link/sharedTaskRuntime.js'; +import { registerSharedTaskIpc } from './device-link/sharedTaskIpc.js'; import { getUpdateRelaunchControllers, hasInFlightRemoteInvokes, @@ -2049,6 +2052,7 @@ async function teardownAuthAccountBoundary(reason: string): Promise { // (dispose 同步 clearCurrentDbClient,之后 store 不可用,只能等 15s+ 心跳 // 过期,同机幸存实例接管变慢)。内部带 1.5s 超时,不会卡住登出。 try { + await closeSharedTasksBeforeLogout(); await releaseDeviceLinkOwnershipBeforeLogout(); } catch (err) { authBoundaryLog.error( @@ -2073,6 +2077,7 @@ async function teardownAuthAccountBoundary(reason: string): Promise { // (dispose 同步 clearCurrentDbClient,之后 store 不可用,只能等 15s+ 心跳 // 过期,同机幸存实例接管变慢)。内部带 1.5s 超时,不会卡住登出。 try { + await closeSharedTasksBeforeLogout(); await releaseDeviceLinkOwnershipBeforeLogout(); } catch (err) { authBoundaryLog.error( @@ -9563,6 +9568,7 @@ app.on('ready', async () => { // owning modules above; future collections/actions do not add tunnel channels. registerRemoteResourcesIpc(); registerDeviceLinkIpc(); + registerSharedTaskIpc(isSharedTaskAvailable); registerFilePeerIpc(); registerRemoteDesktopIpc(isGlobalVoiceInputOverlaySender); void startupPurgeDrain diff --git a/apps/desktop/src/main/cindy-media/ledger.ts b/apps/desktop/src/main/cindy-media/ledger.ts index e28848ddeba..81981725a2c 100644 --- a/apps/desktop/src/main/cindy-media/ledger.ts +++ b/apps/desktop/src/main/cindy-media/ledger.ts @@ -31,6 +31,16 @@ function defaultDb(): LedgerDb { return getDbClient().drizzle; } +/** Shared task reads use existing provenance; knowing a blob hash grants nothing. */ +export async function sessionCanRead(hash: string, sessionId: string, db: LedgerDb = defaultDb()): Promise { + const rows = await db.select({ one: sql`1` }).from(mediaRefs).where(and( + eq(mediaRefs.hash, hash), + or(eq(mediaRefs.originSessionId, sessionId), + and(eq(mediaRefs.refKind, 'session-attachment'), eq(mediaRefs.refId, sessionId))), + )).limit(1).all(); + return rows.length > 0; +} + /** * 引用方类型(多态引用,详见 schema.ts mediaRefs 注释)。 * 'ghost-grant':用户显式引渡给某意识的图(随 ghost_call attachments diff --git a/apps/desktop/src/main/device-link/__tests__/dispatchWeakNetwork.test.ts b/apps/desktop/src/main/device-link/__tests__/dispatchWeakNetwork.test.ts index 4edeb9d713b..a37ec6befc9 100644 --- a/apps/desktop/src/main/device-link/__tests__/dispatchWeakNetwork.test.ts +++ b/apps/desktop/src/main/device-link/__tests__/dispatchWeakNetwork.test.ts @@ -13,6 +13,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { DeviceLinkClient, + SHARED_TASK_CAPABILITY, DeviceLinkError, DEVICE_LINK_CAPABILITY_HISTORY_VIEW_V1, DL_SUBSCRIBE_CHANNEL, @@ -47,6 +48,12 @@ vi.mock('../../logger', async (importOriginal) => ({ vi.mock('../settings-store', () => ({ readDeviceLinkSettings: () => deviceLinkSettings.value, })); +const sharedTask = vi.hoisted(() => ({ refresh: vi.fn(), capture: vi.fn() })); +vi.mock('../sharedTaskDispatch.js', async (original) => ({ + ...await original(), + refreshSharedTaskPeer: sharedTask.refresh, + captureSharedTaskPeer: sharedTask.capture, +})); import { __testing, @@ -237,6 +244,24 @@ afterEach(() => { }); describe('[1] link-accept 发送失败的有限重试', () => { + it('refreshes first-join authority and ignores an older failed open after a newer success', async () => { + const client = mkClient(); + __testing.setActiveClient(client as never); + const peer = 'shared-task~m~guest~g~d'; + const payload = { controllerName: 'Guest', protocolVersion: PROTOCOL_VERSION, appVersion: '0.0.0-test', capabilities: [SHARED_TASK_CAPABILITY] }; + let rejectOld!: (error: Error) => void; + sharedTask.capture.mockReturnValue({ author: { displayName: 'Guest' } }); + sharedTask.refresh.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectOld = reject; })).mockResolvedValue(undefined); + __testing.handleLinkOpen(client as never, peer, 'old', payload); + expect(client.sendLinkAccept).not.toHaveBeenCalled(); + __testing.handleLinkOpen(client as never, peer, 'new', payload); + await Promise.resolve(); + expect(client.sendLinkAccept).toHaveBeenCalledWith(peer, 'new', expect.anything()); + rejectOld(new Error('old network failure')); + await Promise.resolve(); await Promise.resolve(); + expect(client.closeLink).not.toHaveBeenCalled(); + expect(client.sendLinkAccept).toHaveBeenCalledTimes(1); + }); it('declares history projection support in the host accept, including for legacy controllers', () => { const client = mkClient(); __testing.setActiveClient(client as never); diff --git a/apps/desktop/src/main/device-link/__tests__/mediaFetch.test.ts b/apps/desktop/src/main/device-link/__tests__/mediaFetch.test.ts index 65cc19cb177..c80735aaf98 100644 --- a/apps/desktop/src/main/device-link/__tests__/mediaFetch.test.ts +++ b/apps/desktop/src/main/device-link/__tests__/mediaFetch.test.ts @@ -33,7 +33,13 @@ const realpathMock = vi.hoisted(() => vi.fn()); const openMock = vi.hoisted(() => vi.fn()); vi.mock('node:fs/promises', () => ({ stat: statMock, realpath: realpathMock, open: openMock })); -import { fetchLocalMediaToOss, __testing } from '../mediaFetch.js'; +const assertSharedTaskMedia = vi.hoisted(() => vi.fn()); +vi.mock('../sharedTaskMediaAccess.js', () => ({ assertSharedTaskMedia })); + +import { fetchLocalMediaToOss, resolveAuthorizedMedia, __testing } from '../mediaFetch.js'; +import { runDeviceLinkInvokeContext } from '../invoke-context.js'; +import { sharedTaskMediaId } from '../sharedTaskMediaContext.js'; +import type { SharedTaskPeerCapture } from '../sharedTaskDispatch.js'; async function codeOf(fn: () => Promise): Promise { try { @@ -46,6 +52,7 @@ async function codeOf(fn: () => Promise): Promise { beforeEach(() => { vi.clearAllMocks(); + assertSharedTaskMedia.mockReset(); __testing.uploadCache.clear(); statMock.mockResolvedValue({ size: 42, mtimeMs: 1000 }); realpathMock.mockImplementation(async (p: string) => p); @@ -76,6 +83,44 @@ afterEach(() => { }); describe('fetchLocalMediaToOss — scheme 路由', () => { + const sharedTask: SharedTaskPeerCapture = { + author: { sharedTaskId: 'shared', sessionId: 'task', memberId: 'guest', accountId: 'user', displayName: 'Guest' }, + isCurrent: () => true, + authorize: () => true, + }; + const shared = (work: () => T) => runDeviceLinkInvokeContext({ + controllerDeviceId: 'guest-device', channel: 'device-link:media:fetch', sharedTask, + }, work); + + it('checks task authorization in the shared resolver before inline reads or file transfer', async () => { + assertSharedTaskMedia.mockRejectedValue(new Error('[PERMISSION_DENIED] Outside shared task')); + const arg = { url: 'xdt-image://other/a.png', prepareOnly: true }; + await expect(shared(() => resolveAuthorizedMedia(arg, 1024))).rejects.toThrow('PERMISSION_DENIED'); + await expect(shared(() => fetchLocalMediaToOss(arg))).rejects.toThrow('PERMISSION_DENIED'); + expect(imageResolve).not.toHaveBeenCalled(); + expect(openMock).not.toHaveBeenCalled(); + expect(uploadLocalFile).not.toHaveBeenCalled(); + }); + + it('preserves upload size limits and isolates shared-task upload cache', async () => { + const url = 'xdt-file://local?path=' + encodeURIComponent(path.resolve('work/a.png')) + '&maxBytes=100'; + let scope: string | undefined; + uploadLocalFile.mockImplementation(async () => { + scope = sharedTaskMediaId(); + return { key: 'shared-key', size: 42, contentType: 'image/png' }; + }); + await shared(() => fetchLocalMediaToOss({ url })); + expect(scope).toBe('shared'); + expect(uploadLocalFile).toHaveBeenLastCalledWith(expect.any(String), expect.objectContaining({ maxBytes: 100 })); + imageResolve.mockReturnValue({ absPath: '/cache/a.png', mimeType: 'image/png' }); + const image = { url: 'xdt-image://task/a.png' }; + await fetchLocalMediaToOss(image); + await shared(() => fetchLocalMediaToOss(image)); + await shared(() => fetchLocalMediaToOss(image)); + expect(uploadLocalFile).toHaveBeenCalledTimes(3); + expect(scope).toBe('shared'); + }); + it.each([0, 65_536, 65_537])( 'prepares %s bytes inline only within the shared limit', async (size) => { diff --git a/apps/desktop/src/main/device-link/__tests__/outboundMedia.test.ts b/apps/desktop/src/main/device-link/__tests__/outboundMedia.test.ts index 994733339f6..ffea54a288d 100644 --- a/apps/desktop/src/main/device-link/__tests__/outboundMedia.test.ts +++ b/apps/desktop/src/main/device-link/__tests__/outboundMedia.test.ts @@ -17,6 +17,9 @@ vi.mock('../../logger', () => ({ })); import { rewriteOutboundMedia, __testing } from '../outboundMedia'; +import { buildUserMessageAttachmentPayload } from '../../../renderer/lib/messageAttachmentPayload'; +import { withSharedTaskMedia } from '../sharedTaskMediaContext.js'; +import { assertSharedTaskReferences } from '../sharedTaskDispatch.js'; import { parseAttachmentOssRef, isAttachmentOssRef } from '../../../shared/attachmentOssRef'; const SHA256 = 'a'.repeat(64); @@ -38,6 +41,27 @@ beforeEach(() => { }); describe('rewriteOutboundMedia — channel gating', () => { + it('uploads a real Desktop composer image even when its payload says desktop-host', async () => { + const attachment = buildUserMessageAttachmentPayload([{ id: 'image', name: 'a.png', path: '/controller/a.png', url: 'xdt-image://task/a.png', size: 10, ext: '.png', category: 'image', mimeType: 'image/png' }]); + expect(attachment.serializedFiles?.[0].pathOrigin).toBe('desktop-host'); + resolveSafe.mockReturnValue({ absPath: '/cache/a.png', mimeType: 'image/png' }); + uploadLocalFile.mockResolvedValue({ key: 'cindy/shared-task/sharedTask/u/a.png', contentType: 'image/png', size: 10, sha256: SHA256 }); + const item = { files: attachment.serializedFiles, persistedContent: JSON.stringify({ images: attachment.persistImageRefs }), chatMessage: { images: attachment.imageAttachments } }; + const result = await withSharedTaskMedia('sharedTask', () => rewriteOutboundMedia('maker:input:enqueue', ['task', item])); + expect(uploadLocalFile).toHaveBeenCalledWith('/cache/a.png', { contentType: 'image/png' }); + expect(() => assertSharedTaskReferences(result[1], 'task', 0, 'sharedTask')).not.toThrow(); + }); + it('rewrites newly added shared queue-edit attachments and preserves host-owned existing files', async () => { + uploadLocalFile.mockResolvedValue({ key: 'cindy/shared-task/sharedTask/u/new.png', contentType: 'image/png', size: 10, sha256: SHA256 }); + const item = { files: [{ path: '/host/cache/old.png', pathOrigin: 'desktop-host' }, { path: '/controller/new.png' }], persistedContent: JSON.stringify({ files: [{ path: '/host/cache/old.png' }, { path: '/controller/new.png' }] }) }; + const result = await withSharedTaskMedia('sharedTask', () => rewriteOutboundMedia('maker:input:update-content', ['task', 'client', item], new Set(['/host/cache/old.png']))); + expect(uploadLocalFile).toHaveBeenCalledTimes(1); + expect(uploadLocalFile).toHaveBeenCalledWith('/controller/new.png', {}); + const rewritten = result[2] as typeof item; + expect(rewritten.files[0].path).toBe('/host/cache/old.png'); + expect(parseAttachmentOssRef(rewritten.files[1].path)?.ossKey).toBe('cindy/shared-task/sharedTask/u/new.png'); + expect(rewritten.persistedContent).not.toContain('/controller/new.png'); + }); it('非媒体 channel → 原样,不上传', async () => { const args = [{ a: 1 }]; const out = await rewriteOutboundMedia('maker:set-model', args); @@ -62,6 +86,17 @@ describe('rewriteOutboundMedia — channel gating', () => { }); describe('rewriteQueued — persistedContent 同批改写 + 去重单上传', () => { + it('sends a shared Desktop attachment without leaking local optimistic/retry paths', async () => { + uploadLocalFile.mockResolvedValue({ key: 'cindy/shared-task/shared/u/file.pdf', size: 10, contentType: 'application/pdf', sha256: SHA256 }); + const file = { name: 'file.pdf', path: '/local/file.pdf', mimeType: 'application/pdf', category: 'file' }; + const input = { text: 'Read this', files: [file], persistedContent: JSON.stringify({ text: 'Read this', files: [{ name: file.name, path: file.path }] }), + chatMessage: { role: 'user', content: 'Read this', files: [file], images: [{ url: '/local/preview.png' }], retryFiles: [file], retryMentions: [{ path: '/local/file.pdf' }] } }; + const result = await withSharedTaskMedia('shared', () => rewriteOutboundMedia('maker:input:enqueue', ['task', input])); + expect(() => assertSharedTaskReferences(result[1], 'task', 0, 'shared')).not.toThrow(); + expect(uploadLocalFile).toHaveBeenCalledTimes(1); + expect((result[1] as typeof input).chatMessage).toEqual({ role: 'user', content: 'Read this' }); + expect(input.chatMessage.files).toEqual([file]); + }); it('files[] 与 persistedContent 的同一附件用同一 OSS 引用,只上传一次', async () => { resolveSafe.mockReturnValue({ absPath: '/abs/a.png', mimeType: 'image/png' }); uploadLocalFile diff --git a/apps/desktop/src/main/device-link/__tests__/sharedTaskAccess.test.ts b/apps/desktop/src/main/device-link/__tests__/sharedTaskAccess.test.ts new file mode 100644 index 00000000000..8acf2bbf9f0 --- /dev/null +++ b/apps/desktop/src/main/device-link/__tests__/sharedTaskAccess.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import type { SharedTaskQueueItem } from '@cindy/device-link'; +import { SharedTaskAccess } from '../sharedTaskAccess'; + +const identity = { sharedTaskId: 'sharedTask-1', sessionId: 'task-1', ownerAccountId: 'owner', hostDeviceId: 'host' }; +const a = { memberId: 'member-a', accountId: 'account-a', version: 1, deviceIds: ['device-a'] }; +const b = { memberId: 'member-b', accountId: 'account-b', version: 1, deviceIds: ['device-b'] }; +const caller = { accountId: a.accountId, deviceId: a.deviceIds[0] }; +const snapshot = (revision = 1, guests = [a, b]) => ({ ...identity, revision, status: 'active', guests }); +const loaded = () => { + const access = new SharedTaskAccess(identity); + access.applyVerifiedSnapshot(snapshot()); + return access; +}; + +describe('task host sharedTask authority', () => { + it('revokes queued delivery after removal and ignores an older snapshot', () => { + const access = loaded(); + const ticket = access.capture(caller, 'task-1', 'history.read'); + expect(ticket.isCurrent()).toBe(true); + access.applyVerifiedSnapshot(snapshot(2, [b])); + expect(access.applyVerifiedSnapshot(snapshot(1))).toBe(false); + expect(ticket.isCurrent()).toBe(false); + expect(() => access.applyVerifiedSnapshot(snapshot(3))).toThrow('Revoked'); + const fresh = { ...a, memberId: 'member-a-new' }; + access.applyVerifiedSnapshot(snapshot(3, [fresh, b])); + expect(ticket.isCurrent()).toBe(false); + expect(access.capture(caller, 'task-1', 'history.read').isCurrent()).toBe(true); + }); + it('changing one guest does not cancel another guest or the owner', () => { + const access = loaded(); + const ticket = access.capture(caller, 'task-1', 'input.send'); + const owner = access.capture({ accountId: 'owner', deviceId: 'owner-phone' }, 'task-1', 'approval.resolve'); + access.applyVerifiedSnapshot(snapshot(2, [a])); + expect(ticket.isCurrent()).toBe(true); + expect(owner.isCurrent()).toBe(true); + }); + it('is isolated between two sharedTasks shared by the same guest device', () => { + const access = loaded(); + const otherIdentity = { ...identity, sharedTaskId: 'sharedTask-2', sessionId: 'task-2' }; + const other = new SharedTaskAccess(otherIdentity); + other.applyVerifiedSnapshot({ ...snapshot(), ...otherIdentity }); + const second = other.capture(caller, 'task-2', 'history.read'); + access.close(); + expect(second.isCurrent()).toBe(true); + }); + it('cannot revive after local close or account logout even with a higher revision', () => { + const access = loaded(); + const ticket = access.capture(caller, 'task-1', 'attachment.read'); + access.close(); + expect(access.applyVerifiedSnapshot(snapshot(50))).toBe(false); + expect(ticket.isCurrent()).toBe(false); + }); + it('refuses reopening a server-closed sharedTask under its original ID', () => { + const access = loaded(); + access.applyVerifiedSnapshot({ ...snapshot(2), status: 'closed' }); + expect(() => access.applyVerifiedSnapshot(snapshot(3))).toThrow('cannot reopen'); + }); + it('does not silently replace a different scope or a conflicting revision', () => { + const access = loaded(); + for (const key of Object.keys(identity)) { + expect(() => access.applyVerifiedSnapshot({ ...snapshot(2), [key]: 'different' })).toThrow('scope'); + } + expect(() => access.applyVerifiedSnapshot(snapshot(1, [b]))).toThrow('Conflicting'); + expect(access.applyVerifiedSnapshot(snapshot(1, [b, a]))).toBe(false); + }); + it('invalidates old delivery when a device grant changes', () => { + const access = loaded(); + const ticket = access.capture(caller, 'task-1', 'history.read'); + expect(() => access.applyVerifiedSnapshot(snapshot(2, [{ ...a, deviceIds: ['device-a', 'device-c'] }, b]))).toThrow('revision'); + access.applyVerifiedSnapshot(snapshot(2, [{ ...a, version: 2 }, b])); + expect(ticket.isCurrent()).toBe(false); + }); + it('rereads pending status and snapshots caller identity across an await', () => { + const access = loaded(); + let item: SharedTaskQueueItem = { sessionId: 'task-1', authorAccountId: 'account-a', state: 'pending' }; + const mutableCaller = { ...caller }; + const ticket = access.capture(mutableCaller, 'task-1', 'input.edit', () => item); + mutableCaller.accountId = 'owner'; + item = { ...item, authorAccountId: 'account-b' }; + expect(ticket.isCurrent()).toBe(false); + item = { ...item, authorAccountId: 'account-a', state: 'accepted' }; + expect(ticket.isCurrent()).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/device-link/__tests__/sharedTaskApi.test.ts b/apps/desktop/src/main/device-link/__tests__/sharedTaskApi.test.ts new file mode 100644 index 00000000000..b5ed5e474b6 --- /dev/null +++ b/apps/desktop/src/main/device-link/__tests__/sharedTaskApi.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const state = vi.hoisted(() => ({ key: 'account:1', endpoint: 'https://relay.example.test', authenticated: true, boundary: false, accountId: 'owner', region: 'global', token: 'test-old-token' })); +const http = vi.hoisted(() => vi.fn()); +vi.mock('../../appSessionState.js', () => ({ activeOwnerScopeKey: () => state.key, isAppSessionBoundaryPending: () => state.boundary })); +vi.mock('../../authManager.js', () => ({ getAuthState: () => ({ isAuthenticated: state.authenticated }), + getCurrentUserId: () => state.accountId, getActiveAuthRealm: () => state.region, getAccessToken: () => state.token })); +vi.mock('../../clientEndpointsService.js', () => ({ getClientEndpoint: () => state.endpoint })); +vi.mock('../../serverApiClient.js', () => ({ serverApiFetch: http })); +import { captureSharedTaskBoundaryClose, sharedTaskApi } from '../sharedTaskApi.js'; + +beforeEach(() => { + state.key = 'account:1'; state.endpoint = 'https://relay.example.test'; state.authenticated = true; state.boundary = false; + state.accountId = 'owner'; state.region = 'global'; state.token = 'test-old-token'; + http.mockReset(); +}); +describe('sharedTask Main HTTP adapter', () => { + it.each(['SHARED_TASK_HOST_LIMIT', 'SHARED_TASK_JOIN_LIMIT', 'SHARED_TASK_GUEST_LIMIT'])( + 'preserves %s through redaction and Electron error serialization', async (code) => { + http.mockImplementation(async (_path, options) => { + expect(options.allowedRedactedErrorCodes).toContain(code); + throw Object.assign(new Error('private server details'), { code }); + }); + await expect(sharedTaskApi.create('session', 'Task')).rejects.toThrow('[' + code + '] Shared task limit reached'); + }); + it('closes across a pending logout with fixed old credentials and no auth side effects', async () => { + state.boundary = true; + const close = captureSharedTaskBoundaryClose('owner', 'global')!; + state.accountId = 'new-owner'; state.region = 'cn'; + state.token = 'test-new-token'; state.endpoint = 'https://new-relay.example.test'; + http.mockResolvedValue({ sharedTaskId: 'sharedTask', status: 'closed' }); + await close('sharedTask'); + expect(http).toHaveBeenCalledExactlyOnceWith('/api/device-link/shared-tasks/sharedTask/close', + expect.objectContaining({ token: 'test-old-token', baseUrl: 'https://relay.example.test', + skipAutoRefresh: true, skipSessionInvalidation: true, timeoutMs: 3_000, redactErrorDetails: true })); + }); + it('cannot capture another account or region, or a cleared credential', () => { + expect(captureSharedTaskBoundaryClose('another', 'global')).toBeNull(); + expect(captureSharedTaskBoundaryClose('owner', 'cn')).toBeNull(); + state.token = ''; + expect(captureSharedTaskBoundaryClose('owner', 'global')).toBeNull(); + expect(http).not.toHaveBeenCalled(); + }); + it('uses a redacted, bounded request through the existing auth client', async () => { + http.mockImplementation(async (_path, options) => { + expect(options.baseUrl()).toBe(state.endpoint); + expect(options).toMatchObject({ timeoutMs: 15_000, cache: 'no-store', redactErrorDetails: true, logLabel: '/api/device-link/shared-tasks' }); + return { sharedTaskId: 'sharedTask', status: 'closed' }; + }); + await expect(sharedTaskApi.close('sharedTask')).resolves.toMatchObject({ status: 'closed' }); + }); + it.each(['account', 'region', 'logout', 'boundary'])('blocks a retry after %s changes', async (change) => { + http.mockImplementation(async (_path, options) => { + expect(options.baseUrl()).toBe(state.endpoint); + if (change === 'account') state.key = 'account:2'; + if (change === 'region') state.endpoint = 'https://other-relay.example.test'; + if (change === 'logout') state.authenticated = false; + if (change === 'boundary') state.boundary = true; + return { endpoint: options.baseUrl() }; + }); + await expect(sharedTaskApi.close('sharedTask')).rejects.toThrow('account or region changed'); + }); + it('rejects late success when logout happens after sending', async () => { + http.mockImplementation(async () => { + state.authenticated = false; + return { sharedTaskId: 'sharedTask', status: 'closed' }; + }); + await expect(sharedTaskApi.close('sharedTask')).rejects.toThrow('account or region changed'); + }); +}); diff --git a/apps/desktop/src/main/device-link/__tests__/sharedTaskCommands.test.ts b/apps/desktop/src/main/device-link/__tests__/sharedTaskCommands.test.ts new file mode 100644 index 00000000000..e0ff6688804 --- /dev/null +++ b/apps/desktop/src/main/device-link/__tests__/sharedTaskCommands.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SharedTaskApi, SharedTaskListItem } from '@cindy/device-link'; +import { executeSharedTaskAccountCommand } from '../sharedTaskCommands.js'; + +function listItem(sharedTaskId: string, overrides: Partial = {}): SharedTaskListItem { + return { + sharedTaskId, sessionId: 'session-' + sharedTaskId, ownerAccountId: 'owner', + hostDeviceId: 'device-a', title: 'task ' + sharedTaskId, revision: 1, ...overrides, + }; +} + +function api(items: SharedTaskListItem[], failing = new Set()): SharedTaskApi { + return { + list: vi.fn(async () => items), + close: vi.fn(async (sharedTaskId: string) => { + if (failing.has(sharedTaskId)) throw new Error('server busy'); + return { sharedTaskId, status: 'closed' as const }; + }), + } as unknown as SharedTaskApi; +} + +describe('sharedTask account commands', () => { + it('lists owned shares for the caller account and flags locally hosted ones', async () => { + const list = [listItem('a'), listItem('b', { ownerAccountId: 'someone-else' }), listItem('c', { hostDeviceId: 'device-b' })]; + const result = await executeSharedTaskAccountCommand({ action: 'owned' }, api(list), 'owner', { hostedIds: () => ['a'] }); + expect(result).toEqual([ + { ...listItem('a'), local: true }, + { ...listItem('c', { hostDeviceId: 'device-b' }), local: false }, + ]); + }); + + it('returns no owned shares without an account', async () => { + const listSpy = api([listItem('a')]); + expect(await executeSharedTaskAccountCommand({ action: 'owned' }, listSpy, undefined)).toEqual([]); + expect(listSpy.list).not.toHaveBeenCalled(); + }); + + it('closes a locally hosted task through the host journal, not the raw api', async () => { + const closeHosted = vi.fn(async () => undefined); + const listSpy = api([listItem('a')]); + const result = await executeSharedTaskAccountCommand({ action: 'close', sharedTaskId: 'a' }, listSpy, 'owner', + { hostedIds: () => ['a'], closeHosted }); + expect(closeHosted).toHaveBeenCalledWith('a'); + expect(listSpy.close).not.toHaveBeenCalled(); + expect(result).toEqual({ closed: ['a'], failed: [] }); + }); + + it('closes remote-hosted tasks directly and keeps failed items for retry', async () => { + const closeHosted = vi.fn(async () => undefined); + const listSpy = api([listItem('a'), listItem('b', { hostDeviceId: 'device-b' }), listItem('c', { hostDeviceId: 'device-b' })], new Set(['c'])); + const result = await executeSharedTaskAccountCommand({ action: 'close', all: true }, listSpy, 'owner', + { hostedIds: () => ['a'], closeHosted }); + expect(closeHosted).toHaveBeenCalledTimes(1); + expect(listSpy.close).toHaveBeenCalledTimes(2); + expect(result).toEqual({ closed: ['a', 'b'], failed: [{ sharedTaskId: 'c' }] }); + }); + + it('still rejects unknown account commands', async () => { + await expect(executeSharedTaskAccountCommand({ action: 'nope' }, api([]), 'owner')) + .rejects.toThrow('INVALID_PARAMS'); + }); +}); diff --git a/apps/desktop/src/main/device-link/__tests__/sharedTaskDispatch.test.ts b/apps/desktop/src/main/device-link/__tests__/sharedTaskDispatch.test.ts new file mode 100644 index 00000000000..93c8734de06 --- /dev/null +++ b/apps/desktop/src/main/device-link/__tests__/sharedTaskDispatch.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { assertSharedTaskInvoke, assertSharedTaskReferences, captureSharedTaskPush, setSharedTaskQueueReader, type SharedTaskPeerCapture } from '../sharedTaskDispatch.js'; + +function capture(): SharedTaskPeerCapture { + return { + author: { sharedTaskId: 'sharedTask', sessionId: 'task', memberId: 'member', accountId: 'guest', displayName: 'Guest' }, + isCurrent: () => true, + authorize: (operation, item) => operation !== 'input.edit' && operation !== 'input.withdraw' || item?.authorAccountId === 'guest', + }; +} +afterEach(() => setSharedTaskQueueReader(null)); +describe('sharedTask dispatch scope', () => { + it('reads subagent context only through the shared parent task', () => { + for (const channel of ['local-db:subagent-runs:list', 'local-db:subagent-runs:detail', 'local-db:subagent-runs:transcript']) { + const request = { sessionId: 'task', provider: 'pi', runIdOrAlias: 'child' }; + expect(() => assertSharedTaskInvoke(capture(), { channel, args: [request] })).not.toThrow(); + for (const args of [[{ ...request, sessionId: 'other' }], [{ ...request, path: '/private' }], [request, 'other']]) { + expect(() => assertSharedTaskInvoke(capture(), { channel, args })).toThrow(); + } + expect(() => assertSharedTaskInvoke({ ...capture(), isCurrent: () => false }, { channel, args: [request] })).toThrow(); + } + }); + it('allows only existing references from the member own pending row when editing', () => { + const payload = { channel: 'maker:input:update-content', args: ['task', 'message', { files: [{ path: '/host/cache/a.png' }] }] }; + setSharedTaskQueueReader((_sid, clientId) => clientId === 'message' ? { sessionId: 'task', authorAccountId: 'guest', state: 'pending', attachments: [{ path: '/host/cache/a.png' }] } : undefined); + expect(() => assertSharedTaskInvoke(capture(), payload)).not.toThrow(); + expect(() => assertSharedTaskInvoke(capture(), { ...payload, args: ['task', 'other', payload.args[2]] })).toThrow(); + expect(() => assertSharedTaskInvoke(capture(), { ...payload, args: ['task', 'message', { files: [{ path: '/host/private.png' }] }] })).toThrow(); + setSharedTaskQueueReader(() => ({ sessionId: 'task', authorAccountId: 'owner', state: 'pending', attachments: [{ path: '/host/cache/a.png' }] })); + expect(() => assertSharedTaskInvoke(capture(), payload)).toThrow(); + }); + it('never inherits the full-device allowlist or wildcard subscriptions', () => { + for (const channel of ['maker:create-session', 'maker:set-permission-mode', 'device-link:voice:credential-sync', 'local-db:sessions:list', 'maker:remote-resources:list']) { + expect(() => assertSharedTaskInvoke(capture(), { channel, args: ['task'] })).toThrow('PERMISSION_DENIED'); + } + for (const topics of [['*'], ['sessions'], ['session:other'], ['session:task', 'session:other']]) { + expect(() => assertSharedTaskInvoke(capture(), { channel: 'device-link:subscribe', args: [{ topics }] })).toThrow(); + } + expect(() => assertSharedTaskInvoke(capture(), { channel: 'device-link:subscribe', args: [{ topics: ['session:task'] }] })).not.toThrow(); + }); + it('allows shared task history and Agent settings, rejecting another task', () => { + for (const channel of ['local-db:messages:list', 'maker:set-model', 'maker:set-effort', 'maker:input:stop']) { + expect(() => assertSharedTaskInvoke(capture(), { channel, args: ['task'] })).not.toThrow(); + expect(() => assertSharedTaskInvoke(capture(), { channel, args: ['other'] })).toThrow(); + } + }); + it('accepts media preparation and OSS fallback without granting the file-peer channel', () => { + for (const prepareOnly of [true, false]) { + expect(() => assertSharedTaskInvoke(capture(), { + channel: 'device-link:media:fetch', args: [{ url: 'xdt-image://task/a.png', prepareOnly }], + })).not.toThrow(); + } + expect(() => assertSharedTaskInvoke(capture(), { + channel: 'device-link:media:fetch', args: [{ url: 'xdt-image://task/a.png', prepareOnly: true, sessionId: 'other' }], + })).toThrow(); + expect(() => assertSharedTaskInvoke(capture(), { + channel: 'device-link:file-peer', args: [{ action: 'caps' }], + })).toThrow(); + }); + it('checks nested references in both structured and persisted content before hydration', () => { + for (const value of [ + { agentReferences: [{ kind: 'message', sessionId: 'other' }] }, + { persistedContent: JSON.stringify({ agentReferences: [{ kind: 'message', sessionId: 'other' }] }) }, + { trustedSessionReferenceContexts: [{ sessionId: 'other' }] }, + { agentReferences: [{ kind: 'bot', botId: 'private-bot' }] }, + { files: [{ path: 'private/other-task.png', pathOrigin: 'desktop-host' }] }, + { persistedContent: JSON.stringify({ images: [{ url: 'cindy-media://blobs/private.png' }] }) }, + ]) expect(() => assertSharedTaskReferences(value, 'task')).toThrow(); + expect(() => assertSharedTaskReferences({ agentReferences: [{ kind: 'message', sessionId: 'task' }] }, 'task')).not.toThrow(); + }); + it('reads queue ownership from the host and allows results after successful withdrawal', () => { + const payload = { channel: 'maker:input:remove', args: ['task', 'message'] }; + expect(() => assertSharedTaskInvoke(capture(), payload)).toThrow(); + setSharedTaskQueueReader(() => ({ sessionId: 'task', authorAccountId: 'owner', state: 'pending' })); + expect(() => assertSharedTaskInvoke(capture(), payload)).toThrow(); + setSharedTaskQueueReader(() => ({ sessionId: 'task', authorAccountId: 'guest', state: 'pending' })); + expect(() => assertSharedTaskInvoke(capture(), payload)).not.toThrow(); + setSharedTaskQueueReader(() => undefined); + expect(() => assertSharedTaskInvoke(capture(), payload, undefined, 'result')).not.toThrow(); + }); + it('rejects expired captured authorization and unbound sharedTask pushes without changing same-account traffic', () => { + expect(() => assertSharedTaskInvoke({ ...capture(), isCurrent: () => false }, { channel: 'local-db:messages:list', args: ['task'] })).toThrow(); + expect(captureSharedTaskPush('shared-task~m~guest~g~d', 'maker:event', { sessionId: 'task' })).toBeNull(); + expect(captureSharedTaskPush('my-phone', 'maker:provider:changed', {})?.()).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/device-link/__tests__/sharedTaskHost.test.ts b/apps/desktop/src/main/device-link/__tests__/sharedTaskHost.test.ts new file mode 100644 index 00000000000..c7e7ca5c5ed --- /dev/null +++ b/apps/desktop/src/main/device-link/__tests__/sharedTaskHost.test.ts @@ -0,0 +1,434 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSharedTaskApi, parseSharedTaskSnapshot, type SharedTaskDetail } from '@cindy/device-link'; +import type { SharedTaskJournalEntry } from '../../localDb/sharedTasks.js'; +import { SharedTaskHost, type SharedTaskHostOptions } from '../sharedTaskHost.js'; + +const detail = (revision = 1): SharedTaskDetail => ({ + sharedTaskId: 'sharedTask', sessionId: 'session', ownerAccountId: 'owner', hostDeviceId: 'desktop', + revision, status: 'active', title: 'Task', + guests: [ + { memberId: 'member-a', accountId: 'guest-a', deviceIds: ['phone-a'], version: 1 }, + { memberId: 'member-b', accountId: 'guest-b', deviceIds: ['phone-b'], version: 1 }, + ], + memberLabels: [], +}); +const api = { + create: vi.fn(), list: vi.fn(), get: vi.fn(), invite: vi.fn(), join: vi.fn(), + remove: vi.fn(), leave: vi.fn(), close: vi.fn(), +}; +let records: Map; +let options: SharedTaskHostOptions; +let host: SharedTaskHost; +let current: boolean; +let serverDetail: SharedTaskDetail; +const canRead = (member = 'a') => host.authorize('sharedTask', { accountId: `guest-${member}`, deviceId: `phone-${member}` }, 'session', 'history.read').allowed; +beforeEach(() => { + for (const fn of Object.values(api)) fn.mockReset(); + current = true; + serverDetail = detail(); + records = new Map(); + api.create.mockResolvedValue({ sharedTaskId: 'sharedTask', revision: 1 }); + api.get.mockImplementation(async () => serverDetail); + api.list.mockImplementation(async () => serverDetail.status === 'active' ? [serverDetail] : []); + options = { + api, ownerAccountId: 'owner', hostDeviceId: 'desktop', isCurrent: () => current, + readSession: vi.fn(async (id) => ({ id, title: 'Task', status: 'active' })), + revoke: vi.fn(), changed: vi.fn(), journal: { + async latest() { return structuredClone([...records.values()]); }, + async recordAuthority(snapshot) { + const previous = records.get(snapshot.sharedTaskId); + if (previous?.terminal || (previous?.snapshot?.revision ?? 0) >= snapshot.revision) return false; + records.set(snapshot.sharedTaskId, { sharedTaskId: snapshot.sharedTaskId, sessionId: snapshot.sessionId, + terminal: snapshot.status === 'closed', snapshot: parseSharedTaskSnapshot(snapshot) }); + return true; + }, + async close(identity) { + records.set(identity.sharedTaskId, { sharedTaskId: identity.sharedTaskId, sessionId: identity.sessionId, terminal: true, snapshot: null }); + }, + }, + }; + host = new SharedTaskHost(options); +}); +describe('task host sharedTask lifecycle', () => { + it('allows first sharing after an unshared task is archived and restored', async () => { + await host.closeLocallyForBoundary('session'); + options.readSession = vi.fn(async (id) => ({ id, title: 'Task', status: 'archived' })); + await expect(host.open('session')).rejects.toThrow('unavailable'); + expect(api.create).not.toHaveBeenCalled(); + options.readSession = vi.fn(async (id) => ({ id, title: 'Task', status: 'active' })); + await expect(host.open('session')).resolves.toBe('sharedTask'); + expect(canRead()).toBe(true); + }); + + it('waits for archive durability and server closure before creating a fresh share', async () => { + await host.open('session'); + const oldCapture = host.capturePeer('shared-task~sharedTask~guest~member-a~phone-a')!; + let finishRefresh!: (value: SharedTaskDetail) => void; + api.get.mockImplementationOnce(() => new Promise((resolve) => { finishRefresh = resolve; })); + const refresh = host.refresh('sharedTask'); + const staleRejected = expect(refresh).rejects.toThrow('closed'); + await vi.waitFor(() => expect(finishRefresh).toBeTypeOf('function')); + const persist = options.journal.close; + let finishWrite!: () => void; + options.journal.close = vi.fn(async (identity) => { + await new Promise((resolve) => { finishWrite = resolve; }); + await persist(identity); + }); + const archive = host.closeLocallyForBoundary('session'); + await vi.waitFor(() => expect(finishWrite).toBeTypeOf('function')); + const reopen = host.open('session'); + await Promise.resolve(); + expect(api.create).toHaveBeenCalledTimes(1); + expect(api.close).not.toHaveBeenCalled(); + api.close.mockImplementation(async () => { + expect(records.get('sharedTask')?.terminal).toBe(true); + serverDetail = { ...detail(), sharedTaskId: 'fresh-sharedTask' }; + }); + api.create.mockImplementation(async () => { + expect(api.close).toHaveBeenCalledWith('sharedTask'); + return { sharedTaskId: 'fresh-sharedTask', revision: 1 }; + }); + finishWrite(); + await archive; + await expect(reopen).resolves.toBe('fresh-sharedTask'); + finishRefresh(detail(2)); + await staleRejected; + expect(oldCapture.isCurrent()).toBe(false); + expect(records.get('sharedTask')?.terminal).toBe(true); + expect(records.get('fresh-sharedTask')?.terminal).toBe(false); + expect(host.capturePeer('shared-task~fresh-sharedTask~guest~member-a~phone-a')?.isCurrent()).toBe(true); + }); + + it('keeps an old open fenced when the restored task is already sharing again', async () => { + let release!: () => void; + vi.mocked(options.readSession).mockImplementationOnce(async (id) => { + await new Promise((resolve) => { release = resolve; }); + return { id, title: 'Task', status: 'active' }; + }); + const oldOpen = host.open('session'); + const rejected = expect(oldOpen).rejects.toThrow('closed'); + await vi.waitFor(() => expect(release).toBeTypeOf('function')); + await host.closeLocallyForBoundary('session'); + await expect(host.open('session')).resolves.toBe('sharedTask'); + release(); + await rejected; + expect(api.create).toHaveBeenCalledOnce(); + }); + + it('preserves an unfinished archive fence across same-profile host replacement', async () => { + options.creationState = { pending: new Map(), identities: new Map() }; + host = new SharedTaskHost(options); + await host.open('session'); + const persist = options.journal.close; + let release!: () => void; + options.journal.close = vi.fn(async (identity) => { + await new Promise((resolve) => { release = resolve; }); + await persist(identity); + }); + const archive = host.closeLocallyForBoundary('session'); + await vi.waitFor(() => expect(release).toBeTypeOf('function')); + const disposing = host.dispose(); + host = new SharedTaskHost(options); + const opening = host.open('session'); + await Promise.resolve(); + expect(api.create).toHaveBeenCalledOnce(); + api.create.mockResolvedValue({ sharedTaskId: 'fresh', revision: 1 }); + serverDetail = { ...detail(), sharedTaskId: 'fresh' }; + release(); + await archive; + await disposing; + await expect(opening).resolves.toBe('fresh'); + expect(records.get('fresh')?.terminal).toBe(false); + }); + + it('retries inherited terminal server closures before re-sharing, without reusing old invitations', async () => { + await host.open('session'); + await host.closeLocallyForBoundary('session'); + await host.dispose(); + host = new SharedTaskHost(options); + api.close.mockRejectedValueOnce(new Error('offline')); + await expect(host.open('session')).rejects.toThrow('offline'); + expect(api.create).toHaveBeenCalledOnce(); + api.create.mockResolvedValue({ sharedTaskId: 'fresh', revision: 1 }); + serverDetail = { ...detail(), sharedTaskId: 'fresh' }; + await expect(host.open('session')).resolves.toBe('fresh'); + expect(api.close).toHaveBeenCalledTimes(2); + expect(host.capturePeer('shared-task~sharedTask~guest~member-a~phone-a')).toBeNull(); + }); + + it.each([undefined, 'session'])('drains creates from a retired same-profile host at boundary (%s)', async (sessionId) => { + let reply!: (value: unknown) => void; + const request = vi.fn(() => new Promise((resolve) => { reply = resolve; })); + options.creationState = { pending: new Map(), identities: new Map() }; + options.api = createSharedTaskApi({ request, captureScope: () => ({ isCurrent: () => current }) }); + host = new SharedTaskHost(options); + const opening = host.open('session'); + const rejected = expect(opening).rejects.toThrow('account or region changed'); + await vi.waitFor(() => expect(reply).toBeTypeOf('function')); + current = false; + await host.dispose(); + const replacement = new SharedTaskHost({ ...options, isCurrent: () => true }); + let finished = false; + const closing = replacement.closeLocallyForBoundary(sessionId).then((ids) => { finished = true; return ids; }); + await Promise.resolve(); + expect(finished).toBe(false); + reply({ sharedTaskId: 'late-sharedTask', revision: 1 }); + expect(await closing).toEqual(['late-sharedTask']); + await rejected; + expect(records.get('late-sharedTask')?.terminal).toBe(true); + expect(replacement.capturePeer('shared-task~late-shared-task~guest~member-a~phone-a')).toBeNull(); + }); + it.each([undefined, 'session'])('drains a late committed create before releasing the outgoing profile (%s)', async (sessionId) => { + let reply!: (value: unknown) => void; + const request = vi.fn(() => new Promise((resolve) => { reply = resolve; })); + options.api = createSharedTaskApi({ request, captureScope: () => ({ isCurrent: () => current }) }); + const opening = host.open('session'); + const rejected = expect(opening).rejects.toThrow(sessionId ? 'Shared task was closed' : 'account or region changed'); + await vi.waitFor(() => expect(reply).toBeTypeOf('function')); + if (!sessionId) current = false; + let databaseReleased = false; + const persist = options.journal.close; + options.journal.close = vi.fn(async (identity) => { + expect(databaseReleased).toBe(false); + await persist(identity); + }); + const boundary = host.closeLocallyForBoundary(sessionId).then((ids) => { databaseReleased = true; return ids; }); + await Promise.resolve(); + expect(databaseReleased).toBe(false); + reply({ sharedTaskId: 'late-sharedTask', revision: 1 }); + expect(await boundary).toEqual(['late-sharedTask']); + await rejected; + expect(records.get('late-sharedTask')?.terminal).toBe(true); + expect(request).toHaveBeenCalledOnce(); + current = true; + options.api = api; + api.list.mockResolvedValue([{ ...detail(), sharedTaskId: 'late-sharedTask' }]); + await new SharedTaskHost(options).restore(); + expect(api.close).toHaveBeenCalledWith('late-sharedTask'); + }); + it('retains a committed identity while the initial authority fetch is pending', async () => { + let finish!: () => void; + api.get.mockImplementationOnce(async () => { + await new Promise((resolve) => { finish = resolve; }); + return detail(); + }); + const opening = host.open('session'); + const rejected = expect(opening).rejects.toThrow('generation'); + await vi.waitFor(() => expect(finish).toBeTypeOf('function')); + current = false; + expect(await host.closeLocallyForBoundary()).toEqual(['sharedTask']); + expect(records.get('sharedTask')?.terminal).toBe(true); + finish(); + await rejected; + }); + it('does not grant an in-flight restore after the task boundary begins', async () => { + let release!: () => void; + options.readSession = vi.fn(async (id) => { + await new Promise((resolve) => { release = resolve; }); + return { id, title: 'Task', status: 'active' }; + }); + const refreshing = host.refresh('sharedTask'); + await vi.waitFor(() => expect(release).toBeTypeOf('function')); + await host.closeLocallyForBoundary('session'); + release(); + await expect(refreshing).rejects.toThrow('closed'); + expect(canRead()).toBe(false); + expect(host.capturePeer('shared-task~sharedTask~guest~member-a~phone-a')).toBeNull(); + }); + it('durably closes the outgoing profile after its network generation is fenced', async () => { + await host.open('session'); + current = false; + await host.dispose(); + await host.closeLocallyForBoundary(); + expect(records.get('sharedTask')?.terminal).toBe(true); + expect(api.close).not.toHaveBeenCalled(); + current = true; + const restored = new SharedTaskHost(options); + await restored.restore(); + expect(api.close).toHaveBeenCalledWith('sharedTask'); + expect(restored.capturePeer('shared-task~sharedTask~guest~member-a~phone-a')).toBeNull(); + }); + + it('closes only the archived task and propagates a journal failure', async () => { + await host.open('session'); + await host.closeLocallyForBoundary('another-task'); + expect(canRead()).toBe(true); + options.journal.close = vi.fn(async () => { throw new Error('disk failed'); }); + await expect(host.closeLocallyForBoundary('session')).rejects.toThrow('disk failed'); + expect(canRead()).toBe(false); + await expect(host.dispose()).rejects.toThrow('disk failed'); + }); + it('invalidates old captures without revoking existing devices when a member adds a device', async () => { + await host.open('session'); + expect(host.capturePeer('shared-task~sharedTask~host')).toBeNull(); + expect(host.capturePeer('shared-task~sharedTask~guest~member-a~phone-b')).toBeNull(); + const a = host.capturePeer('shared-task~sharedTask~guest~member-a~phone-a')!; + const b = host.capturePeer('shared-task~sharedTask~guest~member-b~phone-b')!; + expect(a.author.accountId).toBe('guest-a'); + expect(a.isCurrent()).toBe(true); + serverDetail = { ...detail(2), guests: detail().guests.map((member) => member.memberId === 'member-a' ? { ...member, version: 2, deviceIds: [...member.deviceIds, 'second-phone'] } : member) }; + await host.refresh('sharedTask'); + expect(a.isCurrent()).toBe(false); + expect(b.isCurrent()).toBe(true); + expect(options.revoke).not.toHaveBeenCalled(); + expect(host.capturePeer('shared-task~sharedTask~guest~member-a~phone-a')?.isCurrent()).toBe(true); + expect(host.capturePeer('shared-task~sharedTask~guest~member-a~second-phone')?.isCurrent()).toBe(true); + await host.dispose(); + expect(b.isCurrent()).toBe(false); + }); + it('opens an existing task and requires a server snapshot before granting access', async () => { + expect(canRead()).toBe(false); + expect(await host.open('session')).toBe('sharedTask'); + expect(api.create).toHaveBeenCalledWith('session', 'Task', expect.any(Function)); + expect(canRead()).toBe(true); + expect(records.get('sharedTask')?.snapshot?.revision).toBe(1); + }); + it('does not authorize from a persisted snapshot while offline', async () => { + await options.journal.recordAuthority(detail()); + api.list.mockRejectedValue(new Error('offline')); + await expect(host.restore()).rejects.toThrow('offline'); + expect(canRead()).toBe(false); + }); + it('restores members without a new join request after the host restarts', async () => { + await host.open('session'); + await host.dispose(); + host = new SharedTaskHost(options); + expect(canRead()).toBe(false); + await host.restore(); + expect(canRead()).toBe(true); + expect(api.join).not.toHaveBeenCalled(); + }); + it('rejects another host or a different task returned for the create result', async () => { + serverDetail = { ...detail(), hostDeviceId: 'other-desktop' }; + await expect(host.open('session')).rejects.toThrow('not hosted'); + serverDetail = { ...detail(), sessionId: 'other-session' }; + await expect(host.open('session')).rejects.toThrow('does not match'); + expect(records.size).toBe(0); + expect(canRead()).toBe(false); + }); + it('drops late replies after logout or host disposal', async () => { + api.get.mockImplementation(async () => { current = false; return detail(); }); + await expect(host.open('session')).rejects.toThrow('generation'); + expect(records.size).toBe(0); + expect(canRead()).toBe(false); + }); + it('denies a removed member immediately while other peers continue working', async () => { + await host.open('session'); + let finish!: () => void; + api.remove.mockImplementation(() => new Promise((resolve) => { finish = resolve; })); + const removing = host.remove('sharedTask', 'member-a'); + expect(canRead()).toBe(false); + expect(canRead('b')).toBe(true); + expect(options.revoke).not.toHaveBeenCalled(); + await vi.waitFor(() => expect(api.remove).toHaveBeenCalled()); + serverDetail = { ...detail(2), guests: [detail().guests[1]] }; + finish(); + await removing; + expect(options.revoke).toHaveBeenCalledExactlyOnceWith('sharedTask', 'member-a'); + expect(canRead()).toBe(false); + expect(canRead('b')).toBe(true); + }); + it('retains a removal fence on network failure and recovers it only by reconciliation', async () => { + await host.open('session'); + api.remove.mockRejectedValue(new Error('offline')); + api.get.mockRejectedValueOnce(new Error('offline')); + await expect(host.remove('sharedTask', 'member-a')).rejects.toThrow('offline'); + expect(canRead()).toBe(false); + expect(canRead('b')).toBe(true); + await host.refresh('sharedTask'); + expect(canRead()).toBe(true); + expect(options.revoke).not.toHaveBeenCalled(); + expect(host.capturePeer('shared-task~sharedTask~guest~member-a~phone-a')?.isCurrent()).toBe(true); + }); + it('closes locally before awaiting the server and retries durable closure after restart', async () => { + await host.open('session'); + api.close.mockRejectedValueOnce(new Error('offline')); + const closing = host.close('sharedTask'); + expect(canRead()).toBe(false); + expect(host.detail('sharedTask')?.status).toBe('closed'); + await expect(closing).rejects.toThrow('offline'); + await host.dispose(); + host = new SharedTaskHost(options); + api.close.mockResolvedValue({ sharedTaskId: 'sharedTask', status: 'closed' }); + await host.restore(); + expect(api.close).toHaveBeenCalledTimes(2); + expect(canRead()).toBe(false); + }); + it('observes sharedTasks closed on another owner device without reviving cached members', async () => { + await host.open('session'); + serverDetail = { ...detail(2), status: 'closed' }; + await host.restore(); + expect(canRead()).toBe(false); + expect(records.get('sharedTask')?.terminal).toBe(true); + }); + it.each(['dispose', 'account-switch'] as const)('persists closure despite a pending refresh and %s', async (boundary) => { + await host.open('session'); + let finish!: (value: SharedTaskDetail) => void; + api.get.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const refresh = host.refresh('sharedTask'); + await vi.waitFor(() => expect(finish).toBeDefined()); + const refreshFailed = expect(refresh).rejects.toThrow('generation'); + const closing = host.close('sharedTask'); + const closeFailed = expect(closing).rejects.toThrow('generation'); + if (boundary === 'account-switch') current = false; + else await host.dispose(); + // The close record must not wait for the outstanding HTTP request. + expect(records.get('sharedTask')?.terminal).toBe(true); + finish(detail(2)); + await refreshFailed; + await closeFailed; + expect(api.close).not.toHaveBeenCalled(); + current = true; + host = new SharedTaskHost(options); + await host.restore(); + expect(canRead()).toBe(false); + expect(api.close).toHaveBeenCalledOnce(); + }); + it('waits for the bound journal on dispose without waiting for network requests', async () => { + await host.open('session'); + const persist = options.journal.close; + let finishWrite!: () => void; + options.journal.close = vi.fn((identity) => new Promise((resolve) => { + finishWrite = () => { void persist(identity).then(resolve); }; + })); + const closing = host.close('sharedTask'); + const closeFailed = expect(closing).rejects.toThrow('generation'); + let disposed = false; + const disposing = Promise.resolve(host.dispose()).then(() => { disposed = true; }); + await Promise.resolve(); + expect(disposed).toBe(false); + expect(canRead()).toBe(false); + finishWrite(); + await disposing; + await closeFailed; + expect(records.get('sharedTask')?.terminal).toBe(true); + expect(api.close).not.toHaveBeenCalled(); + }); + it('surfaces journal failure during close and disposal instead of claiming durability', async () => { + await host.open('session'); + options.journal.close = vi.fn().mockRejectedValue(new Error('disk unavailable')); + await expect(host.close('sharedTask')).rejects.toThrow('disk unavailable'); + expect(canRead()).toBe(false); + expect(api.close).not.toHaveBeenCalled(); + await expect(Promise.resolve(host.dispose())).rejects.toThrow('disk unavailable'); + }); + it('does not let late refresh revive an explicitly closed sharedTask', async () => { + await host.open('session'); + let finish!: (value: SharedTaskDetail) => void; + api.get.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + const refresh = host.refresh('sharedTask'); + await vi.waitFor(() => expect(finish).toBeDefined()); + const close = host.close('sharedTask'); + finish(detail(2)); + await expect(refresh).rejects.toThrow('closed'); + await close; + expect(canRead()).toBe(false); + expect(records.get('sharedTask')?.terminal).toBe(true); + }); + it('keeps a live sharedTask usable when a background server refresh fails', async () => { + await host.open('session'); + api.get.mockRejectedValueOnce(new Error('offline')); + await expect(host.refresh('sharedTask')).rejects.toThrow('offline'); + expect(canRead()).toBe(true); + }); +}); diff --git a/apps/desktop/src/main/device-link/__tests__/sharedTaskMediaAccess.test.ts b/apps/desktop/src/main/device-link/__tests__/sharedTaskMediaAccess.test.ts new file mode 100644 index 00000000000..b0a4ae9aad1 --- /dev/null +++ b/apps/desktop/src/main/device-link/__tests__/sharedTaskMediaAccess.test.ts @@ -0,0 +1,64 @@ +import path from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +const deps = vi.hoisted(() => ({ read: vi.fn(), session: vi.fn(), realpath: vi.fn(), query: vi.fn() })); +vi.mock('../../localDb/client/current.js', () => ({ getDbClient: () => ({ query: deps.query }) })); +vi.mock('node:fs/promises', () => ({ realpath: deps.realpath })); +vi.mock('../../cindy-media/blobStore.js', () => ({ parseBlobUrl: (url: string) => url === 'cindy-media://blobs/hash.png' ? { hash: 'hash' } : null })); +vi.mock('../../cindy-media/ledger.js', () => ({ sessionCanRead: deps.read })); +vi.mock('../../localDb/ipc/sessions.js', () => ({ getSessionFsSnapshot: deps.session })); +import { assertSharedTaskMedia } from '../sharedTaskMediaAccess.js'; +import type { SharedTaskPeerCapture } from '../sharedTaskDispatch.js'; +let current = true; +const capture: SharedTaskPeerCapture = { + author: { sharedTaskId: 'shared', sessionId: 'task', memberId: 'm', accountId: 'u', displayName: 'Guest' }, + isCurrent: () => current, authorize: () => current, +}; +beforeEach(() => { + vi.resetAllMocks(); current = true; + deps.realpath.mockImplementation(async (value: string) => path.resolve(value)); + deps.session.mockResolvedValue({ workingDir: path.resolve('workspace'), remoteHostId: null }); + deps.query.mockResolvedValue([]); +}); +describe('shared task media access', () => { + it('allows legacy generated media only when its complete URL occurs in host-authored task history', async () => { + const url = 'xdt-video://art/clip.mp4'; + deps.query.mockResolvedValue([{ content: JSON.stringify({ text: '[video](' + url + ')' }) }]); + await assertSharedTaskMedia(url, capture); + expect(deps.query.mock.calls[0][0]).toContain("role IN ('assistant', 'tool_use', 'tool_result')"); + expect(deps.query.mock.calls[0][1]).toEqual(['task', url]); + deps.query.mockResolvedValue([{ content: url + '.other' }]); + await expect(assertSharedTaskMedia(url, capture)).rejects.toThrow('PERMISSION_DENIED'); + deps.query.mockResolvedValue([]); + await expect(assertSharedTaskMedia(url, capture)).rejects.toThrow('PERMISSION_DENIED'); + }); + it('requires task provenance even for a known managed blob', async () => { + deps.read.mockResolvedValue(false); + await expect(assertSharedTaskMedia('cindy-media://blobs/hash.png', capture)).rejects.toThrow('PERMISSION_DENIED'); + deps.read.mockResolvedValue(true); + await assertSharedTaskMedia('cindy-media://blobs/hash.png', capture); + expect(deps.read).toHaveBeenLastCalledWith('hash', 'task'); + }); + it('rechecks membership after asynchronous ledger reads', async () => { + deps.read.mockImplementation(async () => { current = false; return true; }); + await expect(assertSharedTaskMedia('cindy-media://blobs/hash.png', capture)).rejects.toThrow('PERMISSION_DENIED'); + }); + it('permits task workdir media but rejects siblings and symlink escapes', async () => { + const url = (file: string) => 'xdt-file://local?path=' + encodeURIComponent(path.resolve(file)); + await assertSharedTaskMedia(url('workspace/art.png'), capture); + await expect(assertSharedTaskMedia(url('other/private.png'), capture)).rejects.toThrow(); + deps.realpath.mockImplementation(async (file: string) => file.endsWith('link.png') ? path.resolve('other/private.png') : path.resolve(file)); + await expect(assertSharedTaskMedia(url('workspace/link.png'), capture)).rejects.toThrow(); + }); + it('requires exact SSH task and host provenance before materialization', async () => { + deps.session.mockResolvedValue({ workingDir: '/work', remoteHostId: 'ssh' }); + const url = 'xdt-file://local?path=/work/a.png&sessionId=task&remoteHostId=ssh&workdir=/work'; + await assertSharedTaskMedia(url, capture); + await expect(assertSharedTaskMedia(url.replace('sessionId=task', 'sessionId=other'), capture)).rejects.toThrow(); + await expect(assertSharedTaskMedia('xdt-file://local?path=/work/a.png', capture)).rejects.toThrow(); + expect(deps.realpath).not.toHaveBeenCalled(); + }); + it('keeps legacy task image history scoped to its actual session', async () => { + await assertSharedTaskMedia('xdt-image://task/photo.png', capture); + await expect(assertSharedTaskMedia('xdt-image://other/photo.png', capture)).rejects.toThrow(); + }); +}); diff --git a/apps/desktop/src/main/device-link/__tests__/sharedTaskPush.test.ts b/apps/desktop/src/main/device-link/__tests__/sharedTaskPush.test.ts new file mode 100644 index 00000000000..e77e546e1f9 --- /dev/null +++ b/apps/desktop/src/main/device-link/__tests__/sharedTaskPush.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DL_SUBSCRIBE_CHANNEL } from '@cindy/device-link'; + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/tmp/cindy-test/app', getPath: () => '/tmp/cindy-test', getVersion: () => 'test' }, + powerSaveBlocker: { start: () => 0, stop: () => {}, isStarted: () => false }, + nativeImage: { createFromPath: () => ({ isEmpty: () => true }) }, +})); +vi.mock('../settings-store', () => ({ + readDeviceLinkSettings: () => ({ remoteControlEnabled: true, revokedControllers: [] }), +})); + +import { __testing } from '../dispatch'; +import * as subscriptions from '../subscriptions'; +import { setSharedTaskDispatchHost } from '../sharedTaskDispatch'; +import type { SharedTaskHost } from '../sharedTaskHost'; + +const guestA = 'shared-task~sharedTask-a~guest~member-a~device-a'; +const guestB = 'shared-task~sharedTask-b~guest~member-b~device-b'; +const grants = new Map(); +const metadata = [ + ['local-db:sessions:created', { sessionId: 'task-a' }], + ['local-db:sessions:patched', { sessionId: 'task-a', patch: { title: 'Updated task' } }], + ['local-db:sessions:activity', { sessionId: 'task-a', phase: 'completed', compactDetail: 'Done' }], + ['local-db:session:error-persisted', { sessionId: 'task-a', clientId: 'error-row' }], + ['usage:session-spend-changed', { sessionId: 'task-a', totalCost: 1 }], + ['usage:session-tokens-changed', { sessionId: 'task-a', totalTokens: 100 }], +] as const; + +function client() { + return { + getStatus: vi.fn(() => 'online'), getReliableSendQueueDepth: vi.fn(() => 0), + canSendPush: vi.fn(() => true), sendPush: vi.fn(), + sendInvokeResult: vi.fn(), sendLinkAccept: vi.fn(), closeLink: vi.fn(), + }; +} + +beforeEach(() => { + vi.useFakeTimers(); + __testing.reset(); + grants.clear(); + grants.set(guestA, 'task-a'); grants.set(guestB, 'task-b'); + setSharedTaskDispatchHost({ capturePeer(source: string) { + const sessionId = grants.get(source); + if (!sessionId) return null; + const current = () => grants.get(source) === sessionId; + return { + author: { sharedTaskId: 'sharedTask-a', sessionId, memberId: 'member-a', accountId: 'guest', displayName: 'Guest' }, + isCurrent: current, authorize: current, + }; + } } as unknown as SharedTaskHost); +}); +afterEach(() => { + __testing.reset(); + setSharedTaskDispatchHost(null); + vi.useRealTimers(); +}); + +describe('shared task metadata uses only its authorized task subscription', () => { + it.each(metadata)('delivers %s to the matching guest and ordinary list subscriber only', async (channel, payload) => { + const transport = client(); + __testing.setActiveClient(transport as never); + subscriptions.subscribe(guestA, ['session:task-a']); + subscriptions.subscribe(guestB, ['session:task-b']); + subscriptions.subscribe('own-list', ['sessions']); + subscriptions.subscribe('own-task', ['session:task-a']); + __testing.forwardPush(channel, payload); + await vi.advanceTimersByTimeAsync(300); + expect(transport.sendPush.mock.calls.map((call) => call[0]).sort()).toEqual([guestA, 'own-list'].sort()); + expect(transport.sendPush.mock.calls.every((call) => call[1] === channel && call[2].sessionId === 'task-a')).toBe(true); + }); + + it('keeps offline metadata under the task topic and replays it after that topic is restored', async () => { + const transport = client(); + __testing.setActiveClient(transport as never); + subscriptions.subscribe(guestA, ['session:task-a']); + subscriptions.subscribe(guestB, ['session:task-b']); + subscriptions.clearController(guestA); + for (const [channel, payload] of metadata) __testing.forwardPush(channel, payload); + expect(transport.sendPush).not.toHaveBeenCalled(); + expect(__testing.queuedPushesFor(guestA).map((item) => item.topic)).toEqual(metadata.map(() => 'session:task-a')); + expect(__testing.queuedPushesFor(guestB)).toEqual([]); + const result = __testing.handleSubscriptionFrame(guestA, { + channel: DL_SUBSCRIBE_CHANNEL, args: [{ topics: ['session:task-a'] }], + }); + expect(result.ok).toBe(true); + await vi.advanceTimersByTimeAsync(300); + expect(transport.sendPush.mock.calls.filter((call) => metadata.some(([channel]) => channel === call[1]))).toHaveLength(metadata.length); + expect(__testing.queuedPushesFor(guestA)).toEqual([]); + }); + + it('queues metadata while the relay is offline even if the guest subscription remains active', () => { + const transport = client(); + transport.getStatus.mockReturnValue('reconnecting'); + __testing.setActiveClient(transport as never); + subscriptions.subscribe(guestA, ['session:task-a']); + __testing.forwardPush(...metadata[1]); + expect(transport.sendPush).not.toHaveBeenCalled(); + expect(__testing.queuedPushesFor(guestA)).toEqual([expect.objectContaining({ topic: 'session:task-a', channel: metadata[1][0] })]); + }); + + it.each(['revoke', 'unsubscribe'] as const)('does not deliver deferred patch/activity after %s', async (boundary) => { + const transport = client(); + transport.getReliableSendQueueDepth.mockReturnValue(100); + __testing.setActiveClient(transport as never); + subscriptions.subscribe(guestA, ['session:task-a']); + __testing.forwardPush(...metadata[1]); + __testing.forwardPush(...metadata[2]); + expect(transport.sendPush).not.toHaveBeenCalled(); + if (boundary === 'revoke') grants.delete(guestA); + else subscriptions.unsubscribe(guestA, ['session:task-a']); + transport.getReliableSendQueueDepth.mockReturnValue(0); + await vi.advanceTimersByTimeAsync(600); + expect(transport.sendPush).not.toHaveBeenCalled(); + }); + + it('does not grant global metadata, another task, or a revoked remembered subscriber', () => { + const transport = client(); + __testing.setActiveClient(transport as never); + subscriptions.subscribe(guestA, ['session:task-a']); + __testing.forwardPush('maker:provider:changed', { sessionId: 'task-a' }); + __testing.forwardPush('local-db:sessions:patched', { sessionId: 'other-task', patch: { title: 'Private' } }); + __testing.forwardPush('local-db:sessions:activity', { phase: 'completed' }); + subscriptions.clearController(guestA); + grants.delete(guestA); + for (const [channel, payload] of metadata) __testing.forwardPush(channel, payload); + expect(transport.sendPush).not.toHaveBeenCalled(); + expect(__testing.queuedPushesFor(guestA)).toEqual([]); + }); +}); diff --git a/apps/desktop/src/main/device-link/__tests__/sharedTaskRuntime.test.ts b/apps/desktop/src/main/device-link/__tests__/sharedTaskRuntime.test.ts new file mode 100644 index 00000000000..23acedf77ba --- /dev/null +++ b/apps/desktop/src/main/device-link/__tests__/sharedTaskRuntime.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type HostRecord = { + options: { creationState: unknown; isCurrent(): boolean; revoke(sharedTaskId: string, memberId?: string): void }; + restore: ReturnType; + dispose: ReturnType; + closeLocallyForBoundary: ReturnType; +}; +const state = vi.hoisted(() => ({ + accountId: 'owner', region: 'global', authenticated: true, + session: { mode: 'cloud', dataOwnerId: 'owner', generation: 1 }, boundary: false, + db: { client: { query: vi.fn() }, clientEpoch: 1 }, + hosts: [] as HostRecord[], dispatch: vi.fn(), + captureClose: vi.fn(), close: vi.fn(), +})); +vi.mock('../../localDb/client/current.js', () => ({ getCurrentDbClientSnapshot: () => state.db })); +vi.mock('../../localDb/sharedTasks.js', () => ({ createSharedTaskJournal: () => ({}) })); +vi.mock('../../appSessionState.js', () => ({ + activeOwnerScopeKey: () => [state.session.mode, state.session.dataOwnerId, state.session.generation].join(':'), + getActiveAppSession: () => ({ ...state.session }), isAppSessionBoundaryPending: () => state.boundary, +})); +vi.mock('../../authManager.js', () => ({ + getAuthState: () => ({ isAuthenticated: state.authenticated }), getCurrentUserId: () => state.accountId, + getDeviceId: () => 'host-device', getActiveAuthRealm: () => state.region, +})); +vi.mock('../../logger.js', () => ({ createLogger: () => ({ warn: vi.fn(), debug: vi.fn() }) })); +vi.mock('../sharedTaskApi.js', () => ({ sharedTaskApi: {}, captureSharedTaskBoundaryClose: state.captureClose })); +vi.mock('../sharedTaskDispatch.js', () => ({ setSharedTaskDispatchHost: state.dispatch })); +vi.mock('../sharedTaskHost.js', () => ({ + SharedTaskHost: class { + restore = vi.fn(async () => undefined); + closeLocallyForBoundary = vi.fn(async () => ['sharedTask-a', 'sharedTask-b']); + // The real Host disposes each existing grant through this callback. + dispose = vi.fn(async () => { this.options.revoke('sharedTask-a'); }); + constructor(readonly options: HostRecord['options']) { state.hosts.push(this); } + }, +})); + +import { closeSharedTasksBeforeLogout, requireSharedTaskHost, startSharedTaskRuntime, stopSharedTaskRuntime } from '../sharedTaskRuntime'; + +function start() { + const client = { hasServerCapability: () => true, getStatus: () => 'online', start: vi.fn(), stop: vi.fn(), revoke: vi.fn() }; + startSharedTaskRuntime({ client: client as never, revoke: client.revoke, changed: vi.fn() }); + return client; +} + +beforeEach(async () => { + await stopSharedTaskRuntime(); + vi.useFakeTimers(); + state.accountId = 'owner'; state.region = 'global'; state.authenticated = true; state.boundary = false; + state.session = { mode: 'cloud', dataOwnerId: 'owner', generation: 1 }; + state.db = { client: { query: vi.fn() }, clientEpoch: 1 }; + state.hosts.length = 0; state.dispatch.mockClear(); + state.captureClose.mockReset().mockReturnValue(state.close); + state.close.mockReset().mockResolvedValue({ status: 'closed' }); +}); +afterEach(async () => { + await stopSharedTaskRuntime(); + vi.useRealTimers(); +}); + +describe('shared runtime stable owner recommit', () => { + it('preserves creation cleanup across same-profile rebind but not database replacement', () => { + start(); + const original = state.hosts[0].options.creationState; + state.session.generation++; + requireSharedTaskHost(); + expect(state.hosts[1].options.creationState).toBe(original); + state.db = { client: { query: vi.fn() }, clientEpoch: 2 }; + start(); + expect(state.hosts[2].options.creationState).not.toBe(original); + }); + it('persists the outgoing journal before closing all shares with captured old identity', async () => { + start(); + const outgoing = state.hosts[0]; + let finish!: () => void; + outgoing.closeLocallyForBoundary.mockImplementation(() => new Promise((resolve) => { + finish = () => resolve(['sharedTask-a', 'sharedTask-b']); + })); + state.boundary = true; + const closing = closeSharedTasksBeforeLogout(); + expect(state.captureClose).toHaveBeenCalledExactlyOnceWith('owner', 'global'); + expect(state.close).not.toHaveBeenCalled(); + // Another binding cannot change which host/journal this cleanup stops. + finish(); + await closing; + expect(outgoing.dispose).toHaveBeenCalledOnce(); + expect(state.close.mock.calls).toEqual([['sharedTask-a'], ['sharedTask-b']]); + }); + it('keeps durable cleanup when offline or credentials are already cleared', async () => { + start(); + state.close.mockRejectedValue(new Error('offline')); + await expect(closeSharedTasksBeforeLogout()).resolves.toBeUndefined(); + expect(state.hosts[0].closeLocallyForBoundary).toHaveBeenCalledOnce(); + state.captureClose.mockReturnValue(null); + state.close.mockClear(); + await closeSharedTasksBeforeLogout(); + expect(state.close).not.toHaveBeenCalled(); + }); + it('does not report logout complete or send closure before the journal is durable', async () => { + start(); + state.hosts[0].closeLocallyForBoundary.mockRejectedValue(new Error('disk failed')); + await expect(closeSharedTasksBeforeLogout()).rejects.toThrow('disk failed'); + expect(state.close).not.toHaveBeenCalled(); + expect(state.hosts[0].dispose).not.toHaveBeenCalled(); + }); + it('rebinds on the existing refresh tick without restarting relay or reviving old captures', async () => { + const relay = start(); + const original = state.hosts[0]; + const oldCapture = original.options.isCurrent; + expect(oldCapture()).toBe(true); + state.session.generation++; + expect(oldCapture()).toBe(false); + await vi.advanceTimersByTimeAsync(5_000); + expect(state.hosts).toHaveLength(2); + expect(original.dispose).toHaveBeenCalledOnce(); + expect(state.hosts[1].restore).toHaveBeenCalledOnce(); + expect(requireSharedTaskHost()).toBe(state.hosts[1]); + expect(oldCapture()).toBe(false); + expect(relay.start).not.toHaveBeenCalled(); + expect(relay.stop).not.toHaveBeenCalled(); + expect(relay.revoke).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(5_000); + expect(state.hosts).toHaveLength(2); + }); + + it('rebinds on demand before the next tick and restores authority into a new Host', () => { + start(); + const original = requireSharedTaskHost(); + state.session.generation++; + const replacement = requireSharedTaskHost(); + expect(replacement).not.toBe(original); + expect(replacement).toBe(state.hosts[1]); + expect(state.hosts[1].restore).toHaveBeenCalledOnce(); + }); + + it.each(['boundary', 'account', 'stable-owner', 'region', 'database', 'database-epoch', 'signed-out', 'local'])( + 'never rebinds across an unresolved %s boundary', async (change) => { + start(); state.session.generation++; + if (change === 'boundary') state.boundary = true; + if (change === 'account') state.accountId = 'other'; + if (change === 'stable-owner') state.session.dataOwnerId = 'other'; + if (change === 'region') state.region = 'cn'; + if (change === 'database') state.db = { ...state.db, client: { query: vi.fn() } }; + if (change === 'database-epoch') state.db = { ...state.db, clientEpoch: state.db.clientEpoch + 1 }; + if (change === 'signed-out') state.authenticated = false; + if (change === 'local') state.session.mode = 'local'; + expect(() => requireSharedTaskHost()).toThrow('PRECONDITION_FAILED'); + await vi.advanceTimersByTimeAsync(10_000); + expect(state.hosts).toHaveLength(1); + }, + ); + + it('waits for a stable same-owner boundary to finish, then rebinds', async () => { + start(); state.session.generation++; state.boundary = true; + await vi.advanceTimersByTimeAsync(5_000); + expect(state.hosts).toHaveLength(1); + state.boundary = false; + await vi.advanceTimersByTimeAsync(5_000); + expect(state.hosts).toHaveLength(2); + }); + + it('never reacquires a stopped relay owner on demand or by timer', async () => { + const relay = start(); + await stopSharedTaskRuntime(); + expect(relay.revoke).toHaveBeenCalledWith('sharedTask-a', undefined); + state.session.generation++; + expect(() => requireSharedTaskHost()).toThrow('PRECONDITION_FAILED'); + await vi.advanceTimersByTimeAsync(10_000); + expect(state.hosts).toHaveLength(1); + }); +}); diff --git a/apps/desktop/src/main/device-link/__tests__/sharedTaskTransientAccess.test.ts b/apps/desktop/src/main/device-link/__tests__/sharedTaskTransientAccess.test.ts new file mode 100644 index 00000000000..5c890814424 --- /dev/null +++ b/apps/desktop/src/main/device-link/__tests__/sharedTaskTransientAccess.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PROTOCOL_VERSION, SHARED_TASK_CAPABILITY, type SharedTaskDetail } from '@cindy/device-link'; + +vi.mock('electron', () => ({ + app: { getAppPath: () => '/tmp/cindy-test/app', getPath: () => '/tmp/cindy-test', getVersion: () => 'test' }, + powerSaveBlocker: { start: () => 0, stop: () => {}, isStarted: () => false }, + nativeImage: { createFromPath: () => ({ isEmpty: () => true }) }, +})); +vi.mock('../settings-store', () => ({ + readDeviceLinkSettings: () => ({ remoteControlEnabled: true, revokedControllers: [] }), +})); + +import { __testing, runInvoke, wireInboundDispatch } from '../dispatch'; +import { __testing as registry } from '../invoke-registry'; +import { SharedTaskHost } from '../sharedTaskHost'; +import { setSharedTaskDispatchHost } from '../sharedTaskDispatch'; +import type { SharedTaskJournalEntry } from '../../localDb/sharedTasks'; + +const peer = 'shared-task~sharedTask~guest~member~phone'; +const read = { channel: 'local-db:messages:list', args: ['task'] }; +const subscribe = { channel: 'device-link:subscribe', args: [{ topics: ['session:task'] }] }; +let detail: SharedTaskDetail; +let host: SharedTaskHost; +const api = { create: vi.fn(), list: vi.fn(), get: vi.fn(), invite: vi.fn(), join: vi.fn(), remove: vi.fn(), leave: vi.fn(), close: vi.fn() }; + +function client() { + return { + getStatus: vi.fn(() => 'online'), getConnectionEpoch: vi.fn(() => 1), getPeerLinkGeneration: vi.fn(() => 1), + getReliableSendQueueDepth: vi.fn(() => 0), canSendPush: vi.fn(() => true), sendPush: vi.fn(), + sendInvokeResult: vi.fn(), sendLinkAccept: vi.fn(), closeLink: vi.fn(), onFrame: vi.fn(), + }; +} + +beforeEach(async () => { + __testing.reset(); + registry.reset(); + for (const fn of Object.values(api)) fn.mockReset(); + detail = { + sharedTaskId: 'sharedTask', sessionId: 'task', ownerAccountId: 'owner', hostDeviceId: 'desktop', + revision: 1, status: 'active', title: 'Task', + guests: [{ memberId: 'member', accountId: 'guest', deviceIds: ['phone'], version: 1 }], memberLabels: [], + }; + api.get.mockImplementation(async () => structuredClone(detail)); + let entry: SharedTaskJournalEntry | undefined; + host = new SharedTaskHost({ + api, ownerAccountId: 'owner', hostDeviceId: 'desktop', isCurrent: () => true, + readSession: async (id) => ({ id, title: 'Task', status: 'active' }), revoke: vi.fn(), changed: vi.fn(), + journal: { + latest: async () => entry ? [entry] : [], + recordAuthority: async (snapshot) => { + entry = { sharedTaskId: 'sharedTask', sessionId: 'task', terminal: snapshot.status === 'closed', snapshot }; + return true; + }, + close: async () => { entry = { sharedTaskId: 'sharedTask', sessionId: 'task', terminal: true, snapshot: null }; }, + }, + }); + setSharedTaskDispatchHost(host); + await host.refresh('sharedTask'); +}); +afterEach(() => { + __testing.reset(); registry.reset(); setSharedTaskDispatchHost(null); +}); + +describe('shared task temporary authority fences preserve membership', () => { + it.each(['suspended', 'revoked'] as const)('gates admission, subscription, link-open and final send while %s', async (state) => { + let rejectRemove!: (error: Error) => void; + let removal: Promise | undefined; + if (state === 'suspended') { + api.remove.mockImplementation(() => new Promise((_resolve, reject) => { rejectRemove = reject; })); + removal = host.remove('sharedTask', 'member'); + await vi.waitFor(() => expect(rejectRemove).toBeTypeOf('function')); + } else { + detail = { ...detail, revision: 2, guests: [] }; + await host.refresh('sharedTask'); + } + const code = state === 'suspended' ? 'NOT_CONNECTED' : 'ACCESS_REVOKED'; + expect(host.peerStatus(peer)).toBe(state === 'suspended' ? 'unavailable' : 'revoked'); + expect(host.capturePeer(peer)).toBeNull(); + expect(__testing.handleSubscriptionFrame(peer, subscribe)).toMatchObject({ ok: false, error: { code } }); + const transport = client(); + __testing.setActiveClient(transport as never); + __testing.handleLinkOpen(transport as never, peer, 'open', { + controllerName: 'Guest', protocolVersion: PROTOCOL_VERSION, appVersion: 'test', capabilities: [SHARED_TASK_CAPABILITY], + }, 0, true); + expect(transport.closeLink).toHaveBeenCalledWith(peer, state === 'suspended' ? 'transport-timeout' : 'revoked', 'inbound'); + expect(transport.sendLinkAccept).not.toHaveBeenCalled(); + __testing.sendInvokeResultSafe(transport as never, peer, 'late', { ok: true, result: 'private data' }, read.channel, read.args); + expect(transport.sendInvokeResult).toHaveBeenLastCalledWith(peer, 'late', expect.objectContaining({ ok: false, error: expect.objectContaining({ code }) })); + const handler = vi.fn(() => []); + registry.register(read.channel, handler); + wireInboundDispatch(transport as never); + transport.onFrame.mock.calls[0][0]({ v: PROTOCOL_VERSION, kind: 'invoke', src: peer, id: 'new', payload: read }); + await vi.waitFor(() => expect(transport.sendInvokeResult).toHaveBeenCalledWith(peer, 'new', expect.objectContaining({ ok: false, error: expect.objectContaining({ code }) }))); + expect(handler).not.toHaveBeenCalled(); + if (removal) { + const failed = expect(removal).rejects.toThrow('offline'); + rejectRemove(new Error('offline')); + await failed; + expect(host.peerStatus(peer)).toBe('available'); + expect(__testing.handleSubscriptionFrame(peer, subscribe).ok).toBe(true); + expect((await runInvoke(peer, read)).ok).toBe(true); + } + }); + + it.each(['device-added', 'member-removed'] as const)('fences an in-flight read after %s without confusing stale capture with revocation', async (change) => { + let finish!: (value: unknown) => void; + registry.register(read.channel, () => new Promise((resolve) => { finish = resolve; })); + const result = runInvoke(peer, read); + await vi.waitFor(() => expect(finish).toBeTypeOf('function')); + detail = { ...detail, revision: 2, guests: change === 'device-added' + ? [{ ...detail.guests[0], version: 2, deviceIds: ['phone', 'second-phone'] }] : [] }; + await host.refresh('sharedTask'); + finish([{ content: 'private data' }]); + expect(await result).toMatchObject({ ok: false, error: { code: change === 'device-added' ? 'NOT_CONNECTED' : 'ACCESS_REVOKED' } }); + if (change === 'device-added') { + expect(host.capturePeer(peer)?.isCurrent()).toBe(true); + registry.register(read.channel, () => []); + expect((await runInvoke(peer, read)).ok).toBe(true); + } + }); + + it('denies an out-of-scope request without revoking an otherwise valid member', async () => { + expect(await runInvoke(peer, { ...read, args: ['another-task'] })) + .toMatchObject({ ok: false, error: { code: 'IPC_ERROR', message: expect.stringContaining('PERMISSION_DENIED') } }); + expect(host.peerStatus(peer)).toBe('available'); + expect(__testing.handleSubscriptionFrame(peer, subscribe).ok).toBe(true); + }); + + it('treats unrestored or replaced host authority as unavailable rather than revoked', async () => { + expect(host.peerStatus('shared-task~unknown~guest~member~phone')).toBe('unavailable'); + setSharedTaskDispatchHost(null); + expect(await runInvoke(peer, read)).toMatchObject({ ok: false, error: { code: 'NOT_CONNECTED' } }); + }); +}); diff --git a/apps/desktop/src/main/device-link/dispatch.ts b/apps/desktop/src/main/device-link/dispatch.ts index f4508db79c4..3ed87c2fc1f 100644 --- a/apps/desktop/src/main/device-link/dispatch.ts +++ b/apps/desktop/src/main/device-link/dispatch.ts @@ -95,9 +95,12 @@ import { hasRemoteBotSessionLookup, setRemoteBotSessionLookup, } from './remoteBotSessionBoundary.js'; import { getControllerPlatform } from './controllerPlatform'; -import { runDeviceLinkInvokeContext } from './invoke-context'; +import { getDeviceLinkInvokeContext, runDeviceLinkInvokeContext } from './invoke-context'; +import { isSharedTaskPeer, SHARED_TASK_CAPABILITY } from '@cindy/device-link'; +import { captureSharedTaskPeer, captureSharedTaskPush, assertSharedTaskInvoke, sharedTaskMetadataTopic, sharedTaskAccessFailure } from './sharedTaskDispatch.js'; import { runAsBackgroundDbRpc } from '../localDb/client/rpcAdmission.js'; import { fetchLocalMediaToOss } from './mediaFetch'; +import { refreshSharedTaskPeer } from './sharedTaskDispatch.js'; import { transcribeRemoteVoiceInput } from './voiceTranscribe'; import { readTelegramRemoteStatus, setTelegramRemoteOnline } from './telegramRemoteControl'; import { adviseAndRecordVoiceInputDictionaryLearning } from '../voice-input/index.js'; @@ -184,6 +187,10 @@ function sendBotCheckedPush( dst: string, channel: string, payload: unknown, send: (payload: unknown) => void, failed: (error: unknown) => void, ): void | Promise { + const sharedTaskFence = captureSharedTaskPush(dst, channel, payload); + if (!sharedTaskFence) return; + const deliver = send; + send = (projected) => { if (sharedTaskFence()) deliver(projected); }; if (!hasRemoteBotSessionLookup()) { send(payload); return; } let size: number; try { size = byteLength(JSON.stringify(payload)); } catch (error) { failed(error); return; } @@ -925,7 +932,9 @@ function shouldAcquireRemoteInvokeBusyLease( payload: InvokePayload | undefined, ): boolean { if (!payload || typeof payload.channel !== 'string') return false; - if (!readDeviceLinkSettings().remoteControlEnabled) return false; + if (isSharedTaskPeer(src)) { + if (!captureSharedTaskPeer(src)?.isCurrent()) return false; + } else if (!readDeviceLinkSettings().remoteControlEnabled) return false; if (isControllerRevoked(src)) return false; if (!REMOTE_INVOKE_ALLOWLIST.has(payload.channel)) return false; if ( @@ -1546,6 +1555,7 @@ function stageSessionPatch(dst: string, payload: unknown, ownerStamp?: PushOwner const patch = payload as SessionPatch | null; if (!patch || typeof patch.sessionId !== 'string' || !patch.sessionId || !patch.patch || typeof patch.patch !== 'object' || Array.isArray(patch.patch)) return; + const subscriptionTopic = isSharedTaskPeer(dst) ? sharedTaskMetadataTopic('local-db:sessions:patched', patch)! : 'sessions'; let stage = sessionPatchStages.get(dst); if (stage && !makerEventBatchOwnerStampEquals(stage.ownerStamp, ownerStamp)) { clearSessionPatchStage(dst); @@ -1556,7 +1566,7 @@ function stageSessionPatch(dst: string, payload: unknown, ownerStamp?: PushOwner stage = new SessionPatchStage(ownerStamp, () => { if (!broadcastTap.isDataOwnerBroadcastScopeCurrent(owner) - || !subscriptions.getControllersForTopic('sessions').includes(dst)) { + || !subscriptions.getControllersForTopic(subscriptionTopic).includes(dst)) { clearSessionPatchStage(dst); return false; } @@ -1568,7 +1578,7 @@ function stageSessionPatch(dst: string, payload: unknown, ownerStamp?: PushOwner let failure: unknown; await sendBotCheckedPush(dst, 'local-db:sessions:patched', item, (projected) => { if (!isCurrent() || !broadcastTap.isDataOwnerBroadcastScopeCurrent(owner) - || !subscriptions.getControllersForTopic('sessions').includes(dst)) return; + || !subscriptions.getControllersForTopic(subscriptionTopic).includes(dst)) return; if (!activeClient || activeClient.canSendPush?.(dst) === false) { throw new DeviceLinkError('NOT_CONNECTED', 'peer mirror paused'); } @@ -1636,6 +1646,10 @@ function drainSessionActivityStage(dst: string, stage: SessionActivityStage): vo | undefined; if (!next) return; const [key, item] = next; + if (isSharedTaskPeer(dst) && !subscriptions.getControllersForTopic(`session:${key}`).includes(dst)) { + stage.queue.delete(key); + continue; + } try { let backpressured = false; sendBotCheckedPush(dst, SESSION_ACTIVITY_CHANNEL, item.payload, (projected) => { @@ -1761,7 +1775,16 @@ function forwardPush(channel: string, payload: unknown, ownerStamp?: PushOwnerSt if (channel === MAKER_PUSH.INTERACTION_DISMISSED) { remotePayload = projectInteractionDismissedForRemote(remotePayload); } - const dsts = subscriptions.getControllersForTopic(topic); + const sharedTaskTopic = sharedTaskMetadataTopic(channel, remotePayload); + const targetsFor = (known: boolean): string[] => { + const lookup = known ? subscriptions.getKnownControllersForTopic : subscriptions.getControllersForTopic; + const ordinary = lookup(topic); + if (!sharedTaskTopic) return ordinary; + const shared = lookup(sharedTaskTopic).filter((dst) => isSharedTaskPeer(dst) + && captureSharedTaskPush(dst, channel, remotePayload)?.() === true); + return [...new Set([...ordinary, ...shared])]; + }; + const dsts = targetsFor(false); // The active registry describes peer topic intent, not whether this host can // currently write to the relay. During host-side reconnects sendPush is a // silent no-op, so route queueable pushes through the offline backlog instead. @@ -1800,16 +1823,16 @@ function forwardPush(channel: string, payload: unknown, ownerStamp?: PushOwnerSt const historySessionId = readPushSessionId(remotePayload); const deferred = historySessionId !== null && isDeferredHistoryPush(channel, remotePayload, (id) => readHistoryToolName(historySessionId, id)); - const offlineTargets = subscriptions - .getKnownControllersForTopic(topic) + const offlineTargets = targetsFor(true) .filter((dst) => !liveTargets.includes(dst)); for (const dst of offlineTargets) { if (deferred && historySessionId && subscriptions.hasHistoryView(dst, historySessionId)) continue; - if (OFFLINE_QUEUEABLE_PUSH_CHANNELS.has(channel)) { + const sharedMetadata = isSharedTaskPeer(dst) && sharedTaskTopic !== null; + if (OFFLINE_QUEUEABLE_PUSH_CHANNELS.has(channel) || sharedMetadata) { offlinePushQueue.enqueue(dst, { channel, payload: payloadFor(dst), - topic, + topic: sharedMetadata ? sharedTaskTopic : topic, ...(ownerStamp ? { ownerStamp } : {}), }); } @@ -1912,7 +1935,8 @@ function sendPushBestEffortAuthorized( const sessionId = readPushSessionId(payload); const markForRecovery = () => { if (sessionId && channel !== SESSION_SYNC_CHANNEL - && topicForPush(channel, payload) === `session:${sessionId}`) { + && (topicForPush(channel, payload) === `session:${sessionId}` + || isSharedTaskPeer(dst) && sharedTaskMetadataTopic(channel, payload) === `session:${sessionId}`)) { stageSessionSync(dst, sessionId, channel !== 'maker:event' || !isNonFinalTextPush(payload)); } }; @@ -2293,11 +2317,13 @@ function isControllerRevoked(deviceId: string): boolean { */ const LINK_ACCEPT_RETRY_DELAYS_MS: readonly number[] = [500, 1_000, 2_000]; const linkAcceptRetryTimers = new Map>(); +const pendingSharedTaskOpens = new Map(); /** 已撤权控制端反复 open 时,closeLink 与 warn 的最小间隔。 */ const REVOKED_LINK_OPEN_REJECT_INTERVAL_MS = 30_000; const revokedLinkOpenRejectAt = new Map(); function cancelLinkAcceptRetry(src: string): void { + pendingSharedTaskOpens.delete(src); const timer = linkAcceptRetryTimers.get(src); if (!timer) return; clearTimeout(timer); @@ -2305,6 +2331,7 @@ function cancelLinkAcceptRetry(src: string): void { } function cancelAllLinkAcceptRetries(): void { + pendingSharedTaskOpens.clear(); for (const src of [...linkAcceptRetryTimers.keys()]) cancelLinkAcceptRetry(src); } @@ -2340,9 +2367,57 @@ function handleLinkOpen( requestId: string, payload: LinkOpenPayload | undefined, acceptAttempt = 0, + authorityReady = false, ): void { // 同 src 的新 link-open / 本轮执行顶掉遗留的 accept 重试(requestId 已过时) cancelLinkAcceptRetry(src); + // Cross-account logical peers never enter the same-account legacy wildcard. + if (isSharedTaskPeer(src)) { + if (!authorityReady) { + const attempt = {}; + const epoch = client.getConnectionEpoch(); + pendingSharedTaskOpens.set(src, attempt); + const current = () => pendingSharedTaskOpens.get(src) === attempt && activeClient === client && + client.getConnectionEpoch() === epoch && client.getStatus() === 'online'; + void refreshSharedTaskPeer(src).then(() => { + if (current()) { + handleLinkOpen(client, src, requestId, payload, acceptAttempt, true); + } + }).catch(() => { + // Authority fetch failure is transient, not a permanent user revocation. + if (current()) { + pendingSharedTaskOpens.delete(src); + client.closeLink(src, 'transport-timeout', 'inbound'); + } + }); + return; + } + const sharedTask = captureSharedTaskPeer(src); + if (!sharedTask) { + const failure = sharedTaskAccessFailure(src); + client.closeLink(src, !failure.ok && failure.error.code === 'ACCESS_REVOKED' ? 'revoked' : 'transport-timeout', 'inbound'); + return; + } + if (!sanitizeControllerCapabilities(payload?.capabilities).includes(SHARED_TASK_CAPABILITY)) { + client.closeLink(src, 'revoked', 'inbound'); + return; + } + try { + client.sendLinkAccept(src, requestId, { + appVersion: app.getVersion(), allowlistHash: computeAllowlistHash(), + capabilities: [DEVICE_LINK_CAPABILITY_HISTORY_VIEW_V1, SHARED_TASK_CAPABILITY], + }); + } catch { + scheduleLinkAcceptRetry(client, src, requestId, payload, acceptAttempt + 1); + return; + } + markControllerLinkActive(client, src); + acceptedLinkControllers.add(src); + topicSubscriptionControllers.add(src); + subscriptions.updateControllerMetadata(src, sharedTask.author.displayName, sanitizeControllerCapabilities(payload?.capabilities)); + flushRemoteInvokeResultOutbox(src); + return; + } // 第二道开关校验(server 已是第一道) if (!readDeviceLinkSettings().remoteControlEnabled) { // server 正常不会转发到这里;真到了说明状态不一致,静默不 accept @@ -2688,6 +2763,7 @@ function settleRemoteInvokeWithOrphanDeadline( } function currentRemoteInvokeAdmissionFailure(src: string): InvokeResultPayload | null { + if (isSharedTaskPeer(src)) return captureSharedTaskPeer(src) ? null : sharedTaskAccessFailure(src); if (!readDeviceLinkSettings().remoteControlEnabled) { return { ok: false, error: { code: 'REMOTE_DISABLED', message: 'remote control disabled' } }; } @@ -2954,6 +3030,15 @@ function trySendInvokeResult( args?: unknown[], logFailure = true, ): { sent: true; result: InvokeResultPayload } | { sent: false; result: InvokeResultPayload } { + if (isSharedTaskPeer(src)) { + const sharedTask = captureSharedTaskPeer(src); + try { + if (!sharedTask || !channel) throw new Error('SharedTask unavailable'); + assertSharedTaskInvoke(sharedTask, { channel, args: args ?? [] }, undefined, 'result'); + } catch { + result = sharedTaskAccessFailure(src, sharedTask); + } + } let candidate = result; try { client.sendInvokeResult(src, requestId, candidate); @@ -3470,8 +3555,17 @@ function isRemoteSubscriptionTopic(value: unknown): value is Topic { } function handleSubscriptionFrame(src: string, payload: InvokePayload): InvokeResultPayload { + if (isSharedTaskPeer(src)) { + const sharedTask = captureSharedTaskPeer(src); + try { + if (!sharedTask) throw new Error('SharedTask unavailable'); + assertSharedTaskInvoke(sharedTask, payload); + } catch { + return sharedTaskAccessFailure(src, sharedTask); + } + } // 被控开关(server 已 gate invoke,这里二次兜底) - if (!readDeviceLinkSettings().remoteControlEnabled) { + if (!isSharedTaskPeer(src) && !readDeviceLinkSettings().remoteControlEnabled) { return { ok: false, error: { code: 'REMOTE_DISABLED', message: 'remote control disabled' } }; } // 逐设备黑名单:已撤销 → 拒绝订阅(控制端据此 ACCESS_REVOKED 标记「已撤销」+ 移除该设备)。 @@ -3561,6 +3655,25 @@ export async function runInvoke( src: string, payload: InvokePayload | undefined, timing = new RemoteInvokeTiming(), +): Promise { + if (!isSharedTaskPeer(src)) return runAuthorizedInvoke(src, payload, timing); + const sharedTask = captureSharedTaskPeer(src); + try { + if (!sharedTask || !payload) throw new Error('SharedTask unavailable'); + assertSharedTaskInvoke(sharedTask, payload); + const result = await runDeviceLinkInvokeContext( + { controllerDeviceId: src, channel: payload.channel, sharedTask, sharedTaskSetting: { admitted: false } }, + () => runAuthorizedInvoke(src, payload, timing), + ); + if (!sharedTask.isCurrent()) throw new Error('SharedTask revoked'); + return result; + } catch { + return sharedTaskAccessFailure(src, sharedTask); + } +} + +async function runAuthorizedInvoke( + src: string, payload: InvokePayload | undefined, timing: RemoteInvokeTiming, ): Promise { return withRemoteDbAdmission(payload?.channel, () => executeRemoteInvoke(src, payload, timing)); } @@ -3570,7 +3683,7 @@ async function executeRemoteInvoke(src: string, payload: InvokePayload | undefin return { ok: false, error: { code: 'INTERNAL', message: 'malformed invoke payload' } }; } // 双层校验之一:被控开关 - if (!readDeviceLinkSettings().remoteControlEnabled) { + if (!isSharedTaskPeer(src) && !readDeviceLinkSettings().remoteControlEnabled) { return { ok: false, error: { code: 'REMOTE_DISABLED', message: 'remote control disabled' } }; } // 逐设备黑名单:已撤销访问权限的控制端直接拒绝(早于 allowlist)。 @@ -3772,6 +3885,8 @@ async function executeRemoteInvoke(src: string, payload: InvokePayload | undefin { controllerDeviceId: src, channel: payload.channel, + sharedTask: getDeviceLinkInvokeContext()?.sharedTask, + sharedTaskSetting: getDeviceLinkInvokeContext()?.sharedTaskSetting, // 平台按 server 盖章的 src 查本机 presence 登记表,不采信控制端自报的任何 // 帧内字段(allowlist 只挡 channel 不挡 args,见下方 dispatchLocalInvoke 前的说明)。 controllerPlatform: getControllerPlatform(src), @@ -3789,6 +3904,8 @@ async function executeRemoteInvoke(src: string, payload: InvokePayload | undefin handlerCompleted = true; if (hasRemoteBotSessionLookup()) await timing.measure('authorizeAfter', () => assertRemoteBotInvocationAllowed(args, payload.channel)); if (!broadcastTap.isDataOwnerBroadcastScopeCurrent(invocationOwner)) throw new Error('[NOT_FOUND] Session does not exist'); + if (getDeviceLinkInvokeContext()?.sharedTask?.isCurrent() === false && + !getDeviceLinkInvokeContext()?.sharedTaskSetting?.admitted) throw new Error('[PERMISSION_DENIED] SharedTask task access denied'); // 远程 set-* 回流:被控端 set-* runtime-only,补一次 DB 持久化 + 广播 patched,让控制端 // 镜像收敛到被控端真相(取代控制端乐观覆盖)。本机会话不走这条(走 renderer update)。 await timing.measure('persist', () => persistRemoteSetting(payload.channel, payload.args ?? [], result)); diff --git a/apps/desktop/src/main/device-link/index.ts b/apps/desktop/src/main/device-link/index.ts index 3df7f6360ed..27717982536 100644 --- a/apps/desktop/src/main/device-link/index.ts +++ b/apps/desktop/src/main/device-link/index.ts @@ -18,6 +18,10 @@ import { app, BrowserWindow } from 'electron'; import WebSocket from 'ws'; import { DeviceLinkClient, + parseSharedTaskPeer, + probeSharedTaskHost, + sharedTaskTopics, + SHARED_TASK_CAPABILITY, CONTROLLER_CAPABILITY_MAKER_EVENT_BATCH_V1, CONTROLLER_CAPABILITY_SESSION_TEXT_SNAPSHOT_V1, CONTROLLER_CAPABILITY_PROVIDER_LOGO_KINDS_V2, @@ -44,7 +48,7 @@ import { import { DEVICE_LINK_VOICE_DICTIONARY_SNAPSHOT_CHANNEL } from '@cindy/maker-shared/device-link-contract'; import * as authManager from '../authManager'; import { remoteCredentialHost } from '../remote-desktop/credentialHost'; -import { getActiveDataOwnerPushStamp } from '../appSessionState.js'; +import { activeOwnerScopeKey, getActiveDataOwnerPushStamp, isAppSessionBoundaryPending } from '../appSessionState.js'; import { createLogger } from '../logger'; import { onQuit } from '../lifecycle'; import { getCurrentDbClientUserId } from '../localDb/client/current'; @@ -127,6 +131,9 @@ import { import { onVoiceInputDictionaryChanged } from '../voice-input/VoiceInputDataStore'; import { resetAll as resetSubscriptionRefs, snapshotSubscriptions } from './subscriptionRefcount'; import { getControllersForTopic } from './subscriptions'; +import { getKnownControllerIds } from './subscriptions'; +import { startSharedTaskRuntime, stopSharedTaskRuntime } from './sharedTaskRuntime.js'; +import { sharedTaskApi } from './sharedTaskApi.js'; import { MobileNotifyDeduper, buildSessionNotifyPayload, @@ -349,6 +356,9 @@ function refreshControllerDisplayNamesFromDirectory(generation: number): Promise } let client: DeviceLinkClient | null = null; +// Local IPC metadata, never accepted from a push payload or sent over the wire. +const sharedHostStreams = new Map(); +let sharedHostSourceEpoch = 0; /** * transport-timeout 重开循环(控制端):被控端瞬时重置后 relay/presence 都不会 @@ -499,6 +509,7 @@ const RESPONSIVENESS_PROBE_TICK_MS = 5_000; * 必须用同一份 —— 只在一处声明会让另一条路径静默降级(mobile 侧 review 实测过这个坑)。 */ const CONTROLLER_CAPABILITIES = [ + SHARED_TASK_CAPABILITY, CONTROLLER_CAPABILITY_SESSION_TEXT_SNAPSHOT_V1, CONTROLLER_CAPABILITY_PROVIDER_LOGO_KINDS_V2, CONTROLLER_CAPABILITY_SET_MODEL_EXPLICIT_PROVIDER_NULL_V1, @@ -655,6 +666,7 @@ export function initDeviceLinkService(options: DeviceLinkServiceOptions = {}): v return ok ? authManager.getAccessToken() : null; }, getHello: (): HelloPayload => ({ + capabilities: [SHARED_TASK_CAPABILITY], deviceName: deviceName(), platform: process.platform, appVersion: app.getVersion(), @@ -698,6 +710,17 @@ export function initDeviceLinkService(options: DeviceLinkServiceOptions = {}): v probeInvoke: (deviceId, channel, args) => { if (!client) throw new Error('[DEVICE_LINK_NOT_CONNECTED] device-link client not initialized'); + if (parseSharedTaskPeer(deviceId)) { + const probeClient = client; + const scope = activeOwnerScopeKey(); + return probeSharedTaskHost(deviceId, { + isCurrent: () => client === probeClient && arbiter?.isOwner() === true && + !isAppSessionBoundaryPending() && activeOwnerScopeKey() === scope && !revokedByRemote.has(deviceId), + get: (sharedTaskId) => sharedTaskApi.get(sharedTaskId), + openLink: () => probeClient.isLinkReady(deviceId) ? Promise.resolve() : openRemoteLink(deviceId, { observed: false }), + invoke: (probeChannel, probeArgs) => probeClient.invoke(deviceId, { channel: probeChannel, args: probeArgs }), + }); + } return client.invoke(deviceId, { channel, args }, resolveRemoteInvokeTimeoutMs(channel, args, 'desktop')); }, onUnresponsiveChanged: (deviceId, unresponsive) => { @@ -928,6 +951,11 @@ export function initDeviceLinkService(options: DeviceLinkServiceOptions = {}): v // busy presence:每 5s 探一次本机是否有 turn 在跑,变化才上报(dedupe by value) startBusyReporting(); + client.onPeerStreamAccepted((peer, streamId) => { + if (parseSharedTaskPeer(peer)?.role !== 'host' || sharedHostStreams.get(peer)?.streamId === streamId) return; + sharedHostStreams.set(peer, { streamId, epoch: ++sharedHostSourceEpoch }); + }); + // 控制端:被控端转发回来的 push 帧 → re-broadcast 给 renderer 远程视图, // 带上来源 deviceId(src),renderer 据此把事件路由到对应远程设备的 store client.onFrame((env: Envelope) => { @@ -953,6 +981,7 @@ export function initDeviceLinkService(options: DeviceLinkServiceOptions = {}): v } if (env.kind !== 'push') return; const p = env.payload as PushPayload; + const sourceEpoch = sharedHostStreams.get(env.src)?.epoch; // 词典同步帧在 main 侧消费,不转给 renderer —— 它不是远程视图事件, // renderer 也不该看到别的设备的同步状态。 if (p?.channel === DL_VOICE_DICTIONARY_SYNC_CHANNEL) { @@ -993,6 +1022,7 @@ export function initDeviceLinkService(options: DeviceLinkServiceOptions = {}): v channel: MAKER_PUSH.EVENT, payload: event, ...(p.ownerStamp ? { ownerStamp: p.ownerStamp } : {}), + ...(sourceEpoch !== undefined ? { sourceEpoch } : {}), }); } return; @@ -1002,6 +1032,7 @@ export function initDeviceLinkService(options: DeviceLinkServiceOptions = {}): v channel: p.channel, payload: p.payload, ...(p.ownerStamp ? { ownerStamp: p.ownerStamp } : {}), + ...(sourceEpoch !== undefined ? { sourceEpoch } : {}), }); }); @@ -1077,6 +1108,20 @@ export function initDeviceLinkService(options: DeviceLinkServiceOptions = {}): v // 认领成功但期间已登出:不连(登出路径已 stop 仲裁,这里是 tick 竞态兜底) if (!authManager.getAuthState().isAuthenticated) return; linkTornDown = false; + if (client) startSharedTaskRuntime({ + client, + revoke(sharedTaskId, memberId) { + for (const id of getKnownControllerIds()) { + const peer = parseSharedTaskPeer(id); + if (!peer || peer.role !== 'guest' || peer.sharedTaskId !== sharedTaskId || + memberId && peer.memberId !== memberId) continue; + purgeRevokedController(id); + forgetControllerInvokeState(id); + client?.closeLink(id, 'revoked', 'inbound'); + } + }, + changed(sharedTaskId) { broadcast('shared-task:changed', { sharedTaskId }); }, + }); client?.start(); stopNetworkWatch?.(); stopNetworkWatch = watchNetworkChanges(() => { @@ -1251,6 +1296,7 @@ export function getMobileNotifyGeneration(): number { * 同进程换账号登录还会把上一账号的控制端串到新账号。 */ function teardownActiveLink(): void { + void stopSharedTaskRuntime().catch((error) => log.warn('sharedTask runtime teardown failed', error)); remoteCredentialHost.dispose(); stopNetworkWatch?.(); stopNetworkWatch = null; @@ -1268,6 +1314,7 @@ function teardownActiveLink(): void { subscriptionReplayScheduler.teardown(); presenceAvailableByDevice.clear(); revokedByRemote.clear(); + sharedHostStreams.clear(); // 词典同步驱动是进程级的,**不随单次链路起停**:多实例仲裁的 demote → acquire // 只会 client.start(),不会重跑 initDeviceLinkService,在这里 stop 掉它会让词典 // 同步在降级过一次之后永久失效。清空 presence 就够了 —— 没有对端就不会发送, @@ -1310,6 +1357,11 @@ export function getDeviceLinkStatus(): DeviceLinkStatus { return client?.getStatus() ?? 'stopped'; } +export function isSharedTaskAvailable(): boolean { + return !linkTornDown && !!client?.hasServerCapability(SHARED_TASK_CAPABILITY) && + authManager.getAuthState().isAuthenticated; +} + /** 当前被熔断判定为「无响应」的目标设备(控制端本地判定,供 getState / UI 镜像)。 */ export function getUnresponsiveDeviceIds(): string[] { return responsivenessTracker?.getUnresponsiveDeviceIds() ?? []; @@ -1730,6 +1782,7 @@ export async function remoteSubscribe( deviceId: string, topics: string[], ): Promise { + topics = sharedTaskTopics(deviceId, topics); assertNotStandby(); assertRemoteControlTargetEnabled(deviceId); if (!client) throw new Error('[DEVICE_LINK_NOT_CONNECTED] device-link client not initialized'); @@ -1788,6 +1841,7 @@ export async function remoteUnsubscribe( deviceId: string, topics: string[], ): Promise { + topics = sharedTaskTopics(deviceId, topics); assertNotStandby(); if (!client) throw new Error('[DEVICE_LINK_NOT_CONNECTED] device-link client not initialized'); return client.invoke(deviceId, { channel: DL_UNSUBSCRIBE_CHANNEL, args: [{ topics }] }); diff --git a/apps/desktop/src/main/device-link/invoke-context.ts b/apps/desktop/src/main/device-link/invoke-context.ts index 6bb548c6616..f62a48ab8f8 100644 --- a/apps/desktop/src/main/device-link/invoke-context.ts +++ b/apps/desktop/src/main/device-link/invoke-context.ts @@ -10,10 +10,15 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { isMobilePlatform } from './controllerPlatform'; import * as subscriptions from './subscriptions.js'; +import type { SharedTaskPeerCapture } from './sharedTaskDispatch.js'; export interface DeviceLinkInvokeContext { controllerDeviceId: string; channel: string; + /** Host-verified sharedTask identity and revocation fence; never populated from wire args. */ + sharedTask?: SharedTaskPeerCapture; + /** Shared only within this invoke; an admitted native mutation must finish or roll back. */ + sharedTaskSetting?: { admitted: boolean }; /** * 控制端平台(presence 登记的 `PresenceSnapshot.platform`);未登记时 undefined。 * diff --git a/apps/desktop/src/main/device-link/ipc.ts b/apps/desktop/src/main/device-link/ipc.ts index 2b5bd716637..5f7ce851bec 100644 --- a/apps/desktop/src/main/device-link/ipc.ts +++ b/apps/desktop/src/main/device-link/ipc.ts @@ -53,6 +53,8 @@ import { } from './index'; import { getActiveControllers } from './dispatch'; import { rewriteOutboundMedia } from './outboundMedia'; +import { parseSharedTaskPeer } from '@cindy/device-link'; +import { withSharedTaskMedia } from './sharedTaskMediaContext.js'; import { outboundSessionReferencesRequested, rewriteOutboundSessionReferences, @@ -141,7 +143,7 @@ export interface DeviceLinkIpcDeps { * 出方向附件改写:把消息里的本机附件上传 OSS、替换成引用串(仅 send/steer/enqueue 生效)。 * 可选 —— 测试可不注入(跳过改写,行为同旧版纯透传)。 */ - rewriteOutboundMedia?(channel: string, args: unknown[]): Promise; + rewriteOutboundMedia?(channel: string, args: unknown[], existing?: ReadonlySet): Promise; /** 控制端 main 在越过 device-link 前把相对引用解析为可信、预算化快照。 */ rewriteOutboundSessionReferences?(channel: string, args: unknown[]): Promise; } @@ -648,7 +650,20 @@ export async function handleInvoke( // 上传失败 → MEDIA_TRANSFER_FAILED,整条消息不发(产品决策:不静默丢附件)。 if (deps.rewriteOutboundMedia) { try { - callArgs = await deps.rewriteOutboundMedia(channel, callArgs); + const peer = parseSharedTaskPeer(normalizedDeviceId); + let existing: ReadonlySet | undefined; + if (peer?.role === 'host' && channel === 'maker:input:update-content') { + const projection = await deps.invoke(normalizedDeviceId, 'maker:input:get-projection', [callArgs[0]]); + if (!projection.ok) throw new Error(projection.error.message); + const value = projection.result as { sessionId?: string; pendingQueue?: Array<{ clientId: string; files?: Array<{ path?: string; url?: string }> }> }; + if (value?.sessionId !== callArgs[0] || !Array.isArray(value.pendingQueue)) throw new Error('Invalid shared input projection'); + const item = value.pendingQueue.find((row) => row.clientId === callArgs[1]); + if (!item) throw new Error('Queued message is no longer pending'); + existing = new Set((item.files ?? []).flatMap((file) => [file.path, file.url].filter((ref): ref is string => typeof ref === 'string'))); + assertControlTargetEnabled(deps, normalizedDeviceId); + } + callArgs = await withSharedTaskMedia(peer?.role === 'host' ? peer.sharedTaskId : undefined, + () => existing ? deps.rewriteOutboundMedia!(channel, callArgs, existing) : deps.rewriteOutboundMedia!(channel, callArgs)); } catch (err) { throwIpcError( 'DEVICE_LINK_MEDIA_TRANSFER_FAILED', diff --git a/apps/desktop/src/main/device-link/mediaFetch.ts b/apps/desktop/src/main/device-link/mediaFetch.ts index 875f682b861..9373a007c2c 100644 --- a/apps/desktop/src/main/device-link/mediaFetch.ts +++ b/apps/desktop/src/main/device-link/mediaFetch.ts @@ -36,6 +36,8 @@ import { materializeSshRemoteMedia } from '../file-browser/ssh-media.js'; import { getSessionFsSnapshot } from '../localDb/ipc/sessions.js'; import { mimeOf, uploadLocalFile } from './mediaTransfer.js'; import { createLogger } from '../logger.js'; +import { getDeviceLinkInvokeContext } from './invoke-context.js'; +import { sharedTaskMediaId, withSharedTaskMedia } from './sharedTaskMediaContext.js'; const log = createLogger('device-link:mediaFetch'); @@ -314,6 +316,12 @@ export async function resolveAuthorizedMedia(arg: unknown, maximumBytes?: number : {}; const url = record.url; if (typeof url !== 'string' || !url) throw new Error('media:fetch 缺少 url'); + const sharedTask = getDeviceLinkInvokeContext()?.sharedTask; + let sharedRoot: string | undefined; + if (sharedTask) { + const { assertSharedTaskMedia } = await import('./sharedTaskMediaAccess.js'); + sharedRoot = await assertSharedTaskMedia(url, sharedTask); + } const isPathMedia = url.startsWith('xdt-file://') || url.startsWith('xdt-audio://'); const sshOrigin = isPathMedia ? await parseSshMediaOrigin(url) : null; const constraints: PathMediaConstraints = isPathMedia @@ -367,6 +375,9 @@ export async function resolveAuthorizedMedia(arg: unknown, maximumBytes?: number throw new Error('媒体文件不存在或不可读'); } // realpath 再查:挡字面形式看似无害的 symlink 逃逸。 + if (sharedRoot && !isInsideRealDir(real, sharedRoot)) { + throw new Error('[PERMISSION_DENIED] Media left the shared task workdir'); + } if (!isPathAllowedAgainst(real, getSensitiveMediaBlocklist())) { log.warn(`media:fetch blocked sensitive realpath ${url.slice(0, 60)}`); throw new Error('该路径位于敏感目录,已阻止远程取件'); @@ -413,6 +424,9 @@ export async function fetchLocalMediaToOss(arg: unknown): Promise uploadLocalFile(absPath, { ...(maxBytes !== null ? { maxBytes } : {}), ...(mimeType ? { contentType: mimeType } : {}), ...(uploadExtHint ? { extHint: uploadExtHint } : {}), - }); + })); if (cacheable && st) { - rememberUpload(url, { + rememberUpload(cacheKey, { ossKey: uploaded.key, mimeType: uploaded.contentType, size: uploaded.size, diff --git a/apps/desktop/src/main/device-link/mediaTransfer.ts b/apps/desktop/src/main/device-link/mediaTransfer.ts index eaad3eeb79a..9af61dc61a1 100644 --- a/apps/desktop/src/main/device-link/mediaTransfer.ts +++ b/apps/desktop/src/main/device-link/mediaTransfer.ts @@ -35,6 +35,7 @@ import type { AttachmentIntegrity } from '@cindy/device-link'; import { serverApiFetch } from '../serverApiClient.js'; import { requireAppCapability } from '../appCapabilities.js'; import { deviceLinkApiBase } from './index.js'; +import { sharedTaskMediaId } from './sharedTaskMediaContext.js'; import { describeErrorChain } from '../utils/errorChain.js'; import { createLogger } from '../logger.js'; @@ -119,7 +120,7 @@ async function presignPut( requireAppCapability('canUseDeviceLink', 'Device Link requires a Cindy account.'); return serverApiFetch(PRESIGN_PUT_PATH, { method: 'POST', - body: { size, ext, contentType }, + body: { size, ext, contentType, ...(sharedTaskMediaId() ? { sharedTaskId: sharedTaskMediaId() } : {}) }, baseUrl: deviceLinkApiBase, }); } diff --git a/apps/desktop/src/main/device-link/outboundMedia.ts b/apps/desktop/src/main/device-link/outboundMedia.ts index be6c9e9d76e..e78a3744255 100644 --- a/apps/desktop/src/main/device-link/outboundMedia.ts +++ b/apps/desktop/src/main/device-link/outboundMedia.ts @@ -16,6 +16,7 @@ import { createLogger } from '../logger'; import * as imageCacheStore from '../imageCacheStore'; import * as cindyMediaBlobStore from '../cindy-media/blobStore'; import { uploadLocalFile, uploadBuffer, type UploadResult } from './mediaTransfer'; +import { sharedTaskMediaId } from './sharedTaskMediaContext.js'; import { OUTBOUND_IMAGE_INPUT_MAX_BYTES, compressOutboundImage, @@ -107,6 +108,7 @@ async function uploadAttachment(src: AttachmentSource): Promise { const rawPath = typeof src.path === 'string' && src.path ? src.path : ''; const ref = url || rawPath; if (!ref) throw new Error('附件无可用来源(url/path/base64 皆空)'); + if (parseAttachmentOssRef(ref)) return ref; if (ref.startsWith('clipboard://')) throw new Error('附件为 clipboard 占位,无字节'); // 2a) xdt-image:// 缓存 → 视觉上下文语义(截图/剪贴板/生成图)压缩后 uploadBuffer; @@ -306,9 +308,17 @@ function rewritePersistedContent(json: string, refMap: Map): str * 被控端 reload 历史裂图(PR #166 review)。chatMessage 在被控端不落库/不广播,无需改。 * 去重:每个附件按其 url/path 标识只上传一次 OSS,files[] 与 persistedContent 共用同一引用。 */ -async function rewriteQueued(item: unknown): Promise { +async function rewriteQueued(item: unknown, existing: ReadonlySet = new Set()): Promise { if (!item || typeof item !== 'object') return item; - const it = item as { files?: unknown; persistedContent?: unknown }; + let it = item as { files?: unknown; persistedContent?: unknown; chatMessage?: unknown }; + if (sharedTaskMediaId() && it.chatMessage && typeof it.chatMessage === 'object') { + // The controller already owns its optimistic preview. Do not send its local + // paths or retry caches to the shared host as a second source of authority. + const preview = { ...it.chatMessage } as Record; + for (const key of ['images', 'files', 'retryFiles', 'retryMentions']) delete preview[key]; + it = { ...it, chatMessage: preview }; + item = it; + } if (!Array.isArray(it.files) || it.files.length === 0) return item; // 无 files[] → 无附件 // 原始 ref(url 或 path 字符串)→ OSS 引用;同一附件只传一次,供 files[] + persistedContent 复用。 @@ -316,6 +326,10 @@ async function rewriteQueued(item: unknown): Promise { const files: unknown[] = []; for (const f of it.files) { + if (f && typeof f === 'object' && existing.has(sourceRefKey(f as AttachmentSource))) { + files.push(f); + continue; + } if ( f && typeof f === 'object' && @@ -350,7 +364,12 @@ async function rewriteQueued(item: unknown): Promise { * 出方向附件改写入口:仅对携带附件的 channel 处理,返回新 args(不原地改 caller 数组)。 * 抛错由 handleInvoke 转 MEDIA_TRANSFER_FAILED。 */ -export async function rewriteOutboundMedia(channel: string, args: unknown[]): Promise { +export async function rewriteOutboundMedia(channel: string, args: unknown[], existing: ReadonlySet = new Set()): Promise { + if (channel === 'maker:input:update-content' && sharedTaskMediaId()) { + const next = [...args]; + next[2] = await rewriteQueued(next[2], existing); + return next; + } const isQueued = QUEUED_SHAPE_CHANNELS.has(channel); const isMessage = MESSAGE_SHAPE_CHANNELS.has(channel); if (!isQueued && !isMessage) return args; diff --git a/apps/desktop/src/main/device-link/sharedTaskAccess.ts b/apps/desktop/src/main/device-link/sharedTaskAccess.ts new file mode 100644 index 00000000000..6fcc7986107 --- /dev/null +++ b/apps/desktop/src/main/device-link/sharedTaskAccess.ts @@ -0,0 +1,105 @@ +import { + authorizeSharedTaskOperation, + parseSharedTaskSnapshot, + type SharedTaskCaller, + type SharedTaskDecision, + type SharedTaskIdentity, + type SharedTaskQueueItem, + type SharedTaskSnapshot, +} from '@cindy/device-link'; + +/** + * Per-sharedTask authority mirror owned by the task host. It contains no network or + * persistence: only the authenticated authority adapter may install snapshots. + * A replacement account/sharedTask must get a new instance; queued callbacks keep + * the old instance and therefore cannot regain permission after close(). + */ +export class SharedTaskAccess { + private readonly identity: Readonly; + private snapshot: SharedTaskSnapshot | null = null; + private closed = false; + private readonly retiredMemberIds = new Set(); + private readonly suspendedMembers = new Map(); + + constructor(identity: SharedTaskIdentity) { + this.identity = Object.freeze({ ...identity }); + } + + /** Returns false for a stale revision. Conflicting identities/revisions fail closed. */ + applyVerifiedSnapshot(value: unknown): boolean { + const next = parseSharedTaskSnapshot(value); + for (const key of ['sharedTaskId', 'sessionId', 'ownerAccountId', 'hostDeviceId'] as const) { + if (next[key] !== this.identity[key]) throw new Error('SharedTask authority scope mismatch'); + } + if (this.closed) return false; + if (this.snapshot) { + if (next.revision < this.snapshot.revision) return false; + if (next.revision === this.snapshot.revision) { + if (JSON.stringify(next) !== JSON.stringify(this.snapshot)) throw new Error('Conflicting sharedTask revision'); + return false; + } + if (this.snapshot.status === 'closed') throw new Error('Closed sharedTask cannot reopen'); + for (const guest of next.guests) { + if (this.retiredMemberIds.has(guest.memberId)) throw new Error('Revoked sharedTask member cannot return'); + const previous = this.snapshot.guests.find((item) => item.memberId === guest.memberId); + if (previous && (guest.accountId !== previous.accountId || guest.version < previous.version || + (guest.version === previous.version && JSON.stringify(guest.deviceIds) !== JSON.stringify(previous.deviceIds)))) { + throw new Error('Invalid sharedTask member revision'); + } + } + for (const previous of this.snapshot.guests) { + if (!next.guests.some((item) => item.memberId === previous.memberId)) this.retiredMemberIds.add(previous.memberId); + } + } + this.snapshot = next; + return true; + } + + authorize( + caller: SharedTaskCaller, sessionId: string, operation: string, + queueItem?: SharedTaskQueueItem, + ): SharedTaskDecision { + const decision = authorizeSharedTaskOperation(this.closed ? null : this.snapshot, caller, sessionId, operation, queueItem); + return decision.allowed && decision.role === 'guest' && this.suspendedMembers.has(decision.memberId) + ? { allowed: false, reason: 'not-a-member' } : decision; + } + + /** Fence just this member while its removal is being committed/reconciled. */ + suspendMember(memberId: string): () => void { + this.suspendedMembers.set(memberId, (this.suspendedMembers.get(memberId) ?? 0) + 1); + let released = false; + return () => { + if (released) return; + released = true; + const count = (this.suspendedMembers.get(memberId) ?? 1) - 1; + if (count === 0) this.suspendedMembers.delete(memberId); + else this.suspendedMembers.set(memberId, count); + }; + } + + /** + * Recheck after awaits and immediately before delivery or side effects. The + * queue item must be re-read by the caller too; this is not a queue lock. + */ + capture( + caller: SharedTaskCaller, sessionId: string, operation: string, + readQueueItem?: () => SharedTaskQueueItem | undefined, + ): { decision: SharedTaskDecision; isCurrent: () => boolean } { + const actor = { ...caller }; + const decision = this.authorize(actor, sessionId, operation, readQueueItem?.()); + return { + decision, + isCurrent: () => { + if (!decision.allowed) return false; + const now = this.authorize(actor, sessionId, operation, readQueueItem?.()); + return now.allowed && now.role === decision.role && now.memberId === decision.memberId && now.memberVersion === decision.memberVersion; + }, + }; + } + + /** Local close/logout prevents late authority replies from reviving access. */ + close(): void { + this.closed = true; + this.snapshot = null; + } +} diff --git a/apps/desktop/src/main/device-link/sharedTaskApi.ts b/apps/desktop/src/main/device-link/sharedTaskApi.ts new file mode 100644 index 00000000000..1d7f5d2bb87 --- /dev/null +++ b/apps/desktop/src/main/device-link/sharedTaskApi.ts @@ -0,0 +1,61 @@ +import { createSharedTaskApi, SharedTaskScopeChangedError } from '@cindy/device-link'; +import { activeOwnerScopeKey, isAppSessionBoundaryPending } from '../appSessionState.js'; +import { getAccessToken, getActiveAuthRealm, getAuthState, getCurrentUserId } from '../authManager.js'; +import { getClientEndpoint } from '../clientEndpointsService.js'; +import { serverApiFetch } from '../serverApiClient.js'; +import { throwIpcError } from '../utils/ipcValidate.js'; + +/** Main-owned adapter. Tokens remain inside the existing authenticated HTTP client. */ +export const sharedTaskApi = createSharedTaskApi({ + captureScope() { + const key = activeOwnerScopeKey(); + const endpoint = getClientEndpoint('deviceLinkApiBaseUrl'); + return { isCurrent: () => getAuthState().isAuthenticated && !isAppSessionBoundaryPending() && + activeOwnerScopeKey() === key && getClientEndpoint('deviceLinkApiBaseUrl') === endpoint }; + }, + async request(path, options) { + try { + return await serverApiFetch(path, { + method: options.method, + body: options.body, + // Executed before EACH physical attempt, including automatic token refresh. + // A new account/region cannot submit an old invitation or moderation action. + baseUrl: () => { + if (!options.isCurrent()) throw new SharedTaskScopeChangedError(); + return getClientEndpoint('deviceLinkApiBaseUrl'); + }, + timeoutMs: 15_000, + cache: 'no-store', + logLabel: '/api/device-link/shared-tasks', + redactErrorDetails: true, + allowedRedactedErrorCodes: ['NOT_FOUND', 'CONFLICT', 'PERMISSION_DENIED', 'INVALID_PARAMS', 'RATE_LIMITED', + 'SHARED_TASK_HOST_LIMIT', 'SHARED_TASK_JOIN_LIMIT', 'SHARED_TASK_GUEST_LIMIT'], + }); + } catch (error) { + const code = error && typeof error === 'object' ? (error as { code?: unknown }).code : undefined; + if (code === 'SHARED_TASK_HOST_LIMIT' || code === 'SHARED_TASK_JOIN_LIMIT' || code === 'SHARED_TASK_GUEST_LIMIT') { + throwIpcError(code, 'Shared task limit reached'); + } + throw error; + } + }, +}); + +/** Capture only while the outgoing identity still owns the credentials. Unlike + * ordinary requests, this close-only cleanup may cross the pending boundary: + * its endpoint/token never refresh, and errors cannot log out the next account. */ +export function captureSharedTaskBoundaryClose(ownerAccountId: string, region: ReturnType) { + if (getCurrentUserId() !== ownerAccountId || getActiveAuthRealm() !== region) return null; + const token = getAccessToken(); + if (!token) return null; + const endpoint = getClientEndpoint('deviceLinkApiBaseUrl'); + const api = createSharedTaskApi({ + captureScope: () => ({ isCurrent: () => true }), + request: (path, options) => serverApiFetch(path, { + method: options.method, body: options.body, token, baseUrl: endpoint, + skipAutoRefresh: true, skipSessionInvalidation: true, timeoutMs: 3_000, + cache: 'no-store', redactErrorDetails: true, logLabel: '/api/device-link/shared-tasks', + }), + }); + return (sharedTaskId: string) => api.close(sharedTaskId); +} diff --git a/apps/desktop/src/main/device-link/sharedTaskCommands.ts b/apps/desktop/src/main/device-link/sharedTaskCommands.ts new file mode 100644 index 00000000000..10230f260e5 --- /dev/null +++ b/apps/desktop/src/main/device-link/sharedTaskCommands.ts @@ -0,0 +1,76 @@ +import type { SharedTaskApi, SharedTaskCloseResult, SharedTaskHostState, SharedTaskOwnedItem } from '@cindy/device-link'; +import type { SharedTaskHost } from './sharedTaskHost.js'; +import { requireString, throwIpcError } from '../utils/ipcValidate.js'; + +function command(raw: unknown): Record { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throwIpcError('INVALID_PARAMS', 'SharedTask command is required'); + return raw as Record; +} +function id(value: unknown): string { + const text = requireString(value, 'identifier'); + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(text)) throwIpcError('INVALID_PARAMS', 'Invalid sharedTask identifier'); + return text; +} + +/** Owner management is never exposed through guest task invoke permissions. */ +export async function executeSharedTaskHostCommand(raw: unknown, deps: { + available(): boolean; host(): SharedTaskHost; +}): Promise { + const input = command(raw); + if (input.action === 'state') { + const sessionId = id(input.sessionId); + if (!deps.available()) return { available: false, detail: null } satisfies SharedTaskHostState; + const host = deps.host(); + const sharedTaskId = host.activeSharedTaskIds().find((key) => host.detail(key)?.sessionId === sessionId); + if (!sharedTaskId) return { available: true, detail: null } satisfies SharedTaskHostState; + await host.refresh(sharedTaskId); + const detail = host.detail(sharedTaskId); + return { available: true, detail } satisfies SharedTaskHostState; + } + if (!deps.available()) throwIpcError('UNSUPPORTED_CAPABILITY', 'SharedTask mode requires updated clients and server'); + const host = deps.host(); + if (input.action === 'open') return { sharedTaskId: await host.open(id(input.sessionId)) }; + const sharedTaskId = id(input.sharedTaskId); + if (input.action === 'invite') return host.invite(sharedTaskId); + if (input.action === 'close') { await host.close(sharedTaskId); return { ok: true }; } + if (input.action === 'remove') { await host.remove(sharedTaskId, id(input.memberId)); return { ok: true }; } + throwIpcError('INVALID_PARAMS', 'Unknown sharedTask command'); +} + +/** Account API uses the caller's login, never the host's credentials. */ +export async function executeSharedTaskAccountCommand(raw: unknown, api: SharedTaskApi, accountId?: string, deps?: { + /** Shared tasks hosted by THIS profile; closable through the local host journal. */ + hostedIds?(): string[]; + closeHosted?(sharedTaskId: string): Promise; +}): Promise { + const input = command(raw); + if (input.action === 'list') return (await api.list()).filter((item) => item.ownerAccountId !== accountId); + if (input.action === 'owned') { + if (!accountId) return [] satisfies SharedTaskOwnedItem[]; + const hosted = deps?.hostedIds?.() ?? []; + return (await api.list()) + .filter((item) => item.ownerAccountId === accountId) + .map((item) => ({ ...item, local: hosted.includes(item.sharedTaskId) })) satisfies SharedTaskOwnedItem[]; + } + if (input.action === 'close') { + const result: SharedTaskCloseResult = { closed: [], failed: [] }; + const targets = input.all === true + ? (await api.list()).filter((item) => item.ownerAccountId === accountId).map((item) => item.sharedTaskId) + : [id(input.sharedTaskId)]; + for (const sharedTaskId of targets) { + try { + // Locally hosted tasks must go through the host so the closure is + // journaled and guests are revoked before the server answers. + if (deps?.hostedIds?.().includes(sharedTaskId) && deps.closeHosted) await deps.closeHosted(sharedTaskId); + else await api.close(sharedTaskId); + result.closed.push(sharedTaskId); + } catch { result.failed.push({ sharedTaskId }); } + } + return result; + } + if (input.action === 'join') return api.join(requireString(input.invitation, 'invitation'), requireString(input.displayName, 'displayName')); + const sharedTaskId = id(input.sharedTaskId); + if (input.action === 'get') return api.get(sharedTaskId); + if (input.action === 'leave') return api.leave(sharedTaskId); + throwIpcError('INVALID_PARAMS', 'Unknown sharedTask account command'); +} diff --git a/apps/desktop/src/main/device-link/sharedTaskDispatch.ts b/apps/desktop/src/main/device-link/sharedTaskDispatch.ts new file mode 100644 index 00000000000..02a9b0c6e3b --- /dev/null +++ b/apps/desktop/src/main/device-link/sharedTaskDispatch.ts @@ -0,0 +1,177 @@ +import { isSharedTaskPeer, parseSharedTaskPeer, isSharedTaskAttachment, type InvokePayload, type InvokeResultPayload, type SharedTaskQueueItem } from '@cindy/device-link'; +import type { SharedTaskHost } from './sharedTaskHost.js'; + +export type SharedTaskPeerCapture = NonNullable>; +let host: SharedTaskHost | null = null; +let readQueueItem: ((sessionId: string, clientId: string) => (SharedTaskQueueItem & { attachments?: unknown }) | undefined) | null = null; +export function setSharedTaskQueueReader(value: typeof readQueueItem): void { readQueueItem = value; } +export function setSharedTaskDispatchHost(value: SharedTaskHost | null): void { host = value; } +export function captureSharedTaskPeer(source: string): SharedTaskPeerCapture | null { + return host?.capturePeer(source) ?? null; +} + +/** Only confirmed membership loss may evict a guest's shared task on the client. */ +export function sharedTaskAccessFailure(source: string, capture?: SharedTaskPeerCapture | null): InvokeResultPayload { + const status = host?.peerStatus(source) ?? 'unavailable'; + if (status === 'revoked') return { ok: false, error: { code: 'ACCESS_REVOKED', message: 'Shared task access revoked' } }; + if (status === 'unavailable' || !capture?.isCurrent()) { + return { ok: false, error: { code: 'NOT_CONNECTED', message: 'Shared task authority changed or is temporarily unavailable' } }; + } + return { ok: false, error: { code: 'IPC_ERROR', message: '[PERMISSION_DENIED] Shared task request denied' } }; +} + +// These existing list events also carry single-task state. Shared peers receive +// only this explicit subset through their task subscription, never `sessions`. +const sessionMetadataChannels = new Set([ + 'local-db:sessions:created', 'local-db:sessions:patched', 'local-db:sessions:activity', + 'local-db:session:error-persisted', 'usage:session-spend-changed', 'usage:session-tokens-changed', +]); +export function sharedTaskMetadataTopic(channel: string, payload: unknown): `session:${string}` | null { + const sessionId = record(payload)?.sessionId; + return sessionMetadataChannels.has(channel) && typeof sessionId === 'string' && sessionId.length > 0 + ? `session:${sessionId}` : null; +} + +/** A newly invited device can arrive before the periodic authority refresh. */ +export async function refreshSharedTaskPeer(source: string): Promise { + const peer = parseSharedTaskPeer(source); + const capturedHost = host; + if (!peer || peer.role !== 'guest' || !capturedHost) throw new Error('Shared task host unavailable'); + await capturedHost.refresh(peer.sharedTaskId); + if (host !== capturedHost) throw new Error('Shared task host changed'); +} + +// Deliberately separate from the same-account allowlist: adding a full-device +// channel must never implicitly grant that capability to sharedTask guests. +const sessionReads = new Set([ + 'local-db:sessions:get', 'local-db:messages:list', 'local-db:messages:view', + 'local-db:messages:view-intent', 'local-db:messages:work-details', + 'local-db:messages:around', 'local-db:messages:around-client-id', + 'local-db:messages:estimatedSessionValue', 'maker:input:get-projection', + 'maker:session-in-turn', 'maker:session-background-activity', + 'maker:session-background-tasks:list', 'maker:get-context-usage', + 'maker:get-pending-interactions', 'maker:get-session-agent-switch-intent', +]); +const inputEdits = new Set(['maker:input:update-text', 'maker:input:update-content', 'maker:input:set-edit-lock']); +const agentSettings = new Set(['maker:set-model', 'maker:set-effort', 'maker:set-fast-mode', 'maker:set-thinking-enabled', 'maker:switch-session-agent']); + +function record(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : null; +} +function deny(): never { throw new Error('[PERMISSION_DENIED] SharedTask task access denied'); } + +/** Existing attachments can survive a text edit without being re-uploaded. The + * set comes exclusively from this member's current host-owned pending row. */ +export function sharedTaskOwnedQueueReferences(capture: SharedTaskPeerCapture, clientId: unknown): ReadonlySet { + const result = new Set(); + const item = typeof clientId === 'string' ? readQueueItem?.(capture.author.sessionId, clientId) : undefined; + if (!item || !capture.authorize('input.edit', item)) return result; + if (Array.isArray(item.attachments)) for (const file of item.attachments) { + const row = record(file); + for (const key of ['path', 'url']) if (typeof row?.[key] === 'string') result.add(row[key] as string); + } + return result; +} + +/** Input reference metadata is consumed before Agent execution, under host authority. */ +export function assertSharedTaskReferences(value: unknown, sessionId: string, depth = 0, sharedTaskId?: string, existing: ReadonlySet = new Set()): void { + if (depth > 32) deny(); + if (Array.isArray(value)) { + for (const child of value) assertSharedTaskReferences(child, sessionId, depth + 1, sharedTaskId, existing); + return; + } + const row = record(value); + if (!row) return; + for (const [key, child] of Object.entries(row)) { + if (['sessionId', 'parentSessionId', 'sourceSessionId', 'targetSessionId'].includes(key) && child !== sessionId) deny(); + if (key === 'botId' || key === 'hostSnapshot') deny(); + // Native Agent tools retain normal task permissions; client references are + // direct host reads and must come from this task's authorized upload area. + if ((key === 'path' || key === 'url') && child !== undefined && child !== null && child !== '' && + !(typeof child === 'string' && (existing.has(child) || sharedTaskId && isSharedTaskAttachment(child, sharedTaskId)))) deny(); + // Persisted reference chips are another input to host-side hydration. + if (key === 'persistedContent' && typeof child === 'string') { + let parsed: unknown; + try { parsed = JSON.parse(child); } catch { continue; } + assertSharedTaskReferences(parsed, sessionId, depth + 1, sharedTaskId, existing); + } else if (child && typeof child === 'object') assertSharedTaskReferences(child, sessionId, depth + 1, sharedTaskId, existing); + } +} + +/** Validate the actual channel shape; unknown channels fail closed. */ +export function assertSharedTaskInvoke( + capture: SharedTaskPeerCapture, payload: InvokePayload, queueItem?: SharedTaskQueueItem, + phase: 'invoke' | 'result' = 'invoke', +): void { + if (!capture.isCurrent()) deny(); + const { channel } = payload; + const args = payload.args ?? []; + if (!Array.isArray(args)) deny(); + const sessionId = capture.author.sessionId; + if (['local-db:subagent-runs:list', 'local-db:subagent-runs:detail', 'local-db:subagent-runs:transcript'].includes(channel)) { + const request = record(args[0]); + if (args.length !== 1 || !request || request.sessionId !== sessionId || + Object.keys(request).some((key) => !['sessionId', 'provider', 'runIdOrAlias', 'cursor', 'limit'].includes(key)) || + !capture.authorize('history.read')) deny(); + assertSharedTaskReferences(request, sessionId); + return; + } + if (channel === 'device-link:media:fetch') { + const request = record(args[0]); + if (args.length !== 1 || !request || typeof request.url !== 'string' || + Object.keys(request).some((key) => !['url', 'skipCache', 'thumbnail', 'prepareOnly'].includes(key)) || + !capture.authorize('attachment.read')) deny(); + // The media handler validates ledger/workdir ownership before reading bytes. + return; + } + // Display-only catalogs used by the existing remote composer. The provider + // response goes through dispatch's normal credential-free projection. + if (channel === 'maker:get-capabilities' || channel === 'maker:provider:list') { + if (args.length > 1 || !capture.authorize('history.read')) deny(); + if (channel === 'maker:get-capabilities' && !['claude-code', 'codex', 'pi'].includes(String(args[0]))) deny(); + if (channel === 'maker:provider:list' && args[0] !== undefined) { + const options = record(args[0]); + if (!options || Object.keys(options).some((key) => key !== 'capabilities') || + !Array.isArray(options.capabilities) || options.capabilities.some((item) => typeof item !== 'string')) deny(); + } + return; + } + if (channel === 'device-link:subscribe' || channel === 'device-link:unsubscribe') { + const topics = record(args[0])?.topics; + if (!Array.isArray(topics) || topics.length > 1 || topics.some((topic) => topic !== `session:${sessionId}`)) deny(); + if (!capture.authorize('events.subscribe')) deny(); + return; + } + if (args[0] !== sessionId) deny(); + const operation = sessionReads.has(channel) ? 'history.read' + : ['maker:input:enqueue', 'maker:input:steer', 'maker:input:resume', 'maker:input:set-expanded'].includes(channel) ? 'input.send' + : channel === 'maker:input:stop' ? 'agent.stop' + : channel === 'maker:input:remove' ? 'input.withdraw' + : inputEdits.has(channel) ? 'input.edit' + : agentSettings.has(channel) ? 'agent.configure' : null; + if (!operation) deny(); + if (phase === 'result') { + if (!capture.authorize('history.read')) deny(); + } else { + if (!capture.authorize(operation, queueItem ?? (typeof args[1] === 'string' + ? readQueueItem?.(sessionId, args[1]) : undefined))) deny(); + const existing = sharedTaskOwnedQueueReferences(capture, typeof args[1] === 'string' ? args[1] : record(args[1])?.clientId); + assertSharedTaskReferences(args.slice(1), sessionId, 0, capture.author.sharedTaskId, existing); + } +} + +/** Synchronous last-mile gate, including batches, delayed pushes and offline replay. */ +export function captureSharedTaskPush(source: string, channel: string, payload: unknown): (() => boolean) | null { + if (!isSharedTaskPeer(source)) return () => true; + const capture = captureSharedTaskPeer(source); + if (!capture || !capture.authorize('events.subscribe')) return null; + const sessionId = capture.author.sessionId; + const row = record(payload); + if (row?.sessionId !== sessionId) return null; + // Never forward a device/account projection just because it has a sessionId. + if (!(channel.startsWith('maker:') || channel.startsWith('local-db:messages:') || + sharedTaskMetadataTopic(channel, payload) !== null || + channel.startsWith('usage:message-') || channel === 'usage:session-spend-changed' || channel === 'usage:session-tokens-changed')) return null; + if (channel === 'maker:event:batch' && (!Array.isArray(row.events) || row.events.some((event) => record(event)?.sessionId !== sessionId))) return null; + return () => capture.isCurrent(); +} diff --git a/apps/desktop/src/main/device-link/sharedTaskHost.ts b/apps/desktop/src/main/device-link/sharedTaskHost.ts new file mode 100644 index 00000000000..5f954918659 --- /dev/null +++ b/apps/desktop/src/main/device-link/sharedTaskHost.ts @@ -0,0 +1,395 @@ +import { + parseSharedTaskSnapshot, parseSharedTaskPeer, type SharedTaskApi, type SharedTaskCaller, + type SharedTaskDetail, type SharedTaskIdentity, type SharedTaskQueueItem, +} from '@cindy/device-link'; +import type { SharedTaskJournal } from '../localDb/sharedTasks.js'; +import { SharedTaskAccess } from './sharedTaskAccess.js'; + +interface HostedSharedTask { + identity: SharedTaskIdentity; + access: SharedTaskAccess; + detail: SharedTaskDetail | null; +} +/** Lifecycle bookkeeping survives same-profile runtime replacement; grants do not. */ +export interface SharedTaskCreationState { + pending: Map, string>; + identities: Map; + closedSessions?: Set; + sessionGenerations?: Map; + taskClosures?: Map>; +} +export interface SharedTaskHostOptions { + api: SharedTaskApi; + journal: SharedTaskJournal; + ownerAccountId: string; + hostDeviceId: string; + creationState?: SharedTaskCreationState; + /** Bound at construction to this profile/auth/region generation. */ + isCurrent(): boolean; + readSession(sessionId: string): Promise<{ id: string; title: string; status: string } | null>; + /** Teardown only the affected sharedTask/member, not the shared relay connection. */ + revoke(sharedTaskId: string, memberId?: string): void; + changed(sharedTaskId: string): void; +} + +/** One task-hosting Desktop generation. Never grants access from disk alone. */ +export class SharedTaskHost { + private disposed = false; + private boundaryClosed = false; + private readonly closedSessions: Set; + private readonly sessionGenerations: Map; + private readonly taskClosures: Map>; + private readonly creating: SharedTaskCreationState['pending']; + private readonly created: SharedTaskCreationState['identities']; + private readonly entries = new Map(); + private readonly closed = new Set(); + private readonly chains = new Map>(); + // Keep failed writes too: disposal must not report durability after a disk error. + private readonly closeWrites = new Map>(); + private readonly reconciliation = new Map void>>(); + constructor(private readonly options: SharedTaskHostOptions) { + const lifecycle: SharedTaskCreationState = options.creationState ?? { pending: new Map(), identities: new Map() }; + this.creating = lifecycle.pending; + this.created = lifecycle.identities; + this.closedSessions = lifecycle.closedSessions ??= new Set(); + this.sessionGenerations = lifecycle.sessionGenerations ??= new Map(); + this.taskClosures = lifecycle.taskClosures ??= new Map(); + } + + private assertCurrent(): void { + if (this.disposed || this.boundaryClosed || !this.options.isCurrent()) throw new Error('SharedTask host generation changed'); + } + private assertSessionGeneration(sessionId: string, generation: number): void { + this.assertCurrent(); + if ((this.sessionGenerations.get(sessionId) ?? 0) !== generation) throw new Error('Shared task was closed'); + } + private assertSessionOpen(sessionId: string, generation = this.sessionGenerations.get(sessionId) ?? 0): void { + this.assertSessionGeneration(sessionId, generation); + if (this.closedSessions.has(sessionId)) throw new Error('Shared task was closed'); + } + private requireEntry(sharedTaskId: string): HostedSharedTask { + this.assertCurrent(); + const entry = this.entries.get(sharedTaskId); + if (!entry || this.closed.has(sharedTaskId)) throw new Error('SharedTask is not active here'); + return entry; + } + private serial(sharedTaskId: string, work: () => Promise): Promise { + const result = (this.chains.get(sharedTaskId) ?? Promise.resolve()).catch(() => undefined).then(() => { + this.assertCurrent(); + return work(); + }); + this.chains.set(sharedTaskId, result); + void result.finally(() => { if (this.chains.get(sharedTaskId) === result) this.chains.delete(sharedTaskId); }).catch(() => undefined); + return result; + } + private async accept(detail: SharedTaskDetail, generation: number): Promise { + this.assertCurrent(); + const snapshot = parseSharedTaskSnapshot(detail); + this.assertSessionOpen(snapshot.sessionId, generation); + if (snapshot.ownerAccountId !== this.options.ownerAccountId || snapshot.hostDeviceId !== this.options.hostDeviceId) throw new Error('SharedTask is not hosted by this device'); + if (this.closed.has(snapshot.sharedTaskId)) throw new Error('SharedTask is closed locally'); + const session = await this.options.readSession(snapshot.sessionId); + this.assertSessionOpen(snapshot.sessionId, generation); + if (!session || session.id !== snapshot.sessionId || session.status !== 'active') throw new Error('SharedTask task is unavailable'); + const persisted = (await this.options.journal.latest()).find((item) => item.sharedTaskId === snapshot.sharedTaskId); + this.assertSessionOpen(snapshot.sessionId, generation); + if (persisted?.terminal || this.closed.has(snapshot.sharedTaskId)) throw new Error('SharedTask is closed locally'); + if (persisted?.snapshot) { + const check = new SharedTaskAccess(persisted.snapshot); + check.applyVerifiedSnapshot(persisted.snapshot); + check.applyVerifiedSnapshot(snapshot); + if (persisted.snapshot.revision > snapshot.revision) return; + } + let entry = this.entries.get(snapshot.sharedTaskId); + if (entry) { + // Validate before writing a conflicting identity/revision into the journal. + for (const key of ['sharedTaskId', 'sessionId', 'ownerAccountId', 'hostDeviceId'] as const) { + if (entry.identity[key] !== snapshot[key]) throw new Error('SharedTask scope changed'); + } + } + await this.options.journal.recordAuthority(snapshot); + this.assertSessionOpen(snapshot.sessionId, generation); + if (this.closed.has(snapshot.sharedTaskId)) return; + // A local close could have been persisted by another host callback while + // this write awaited; never treat a rejected insert as permission to grant. + const latest = (await this.options.journal.latest()).find((item) => item.sharedTaskId === snapshot.sharedTaskId); + this.assertSessionOpen(snapshot.sessionId, generation); + if (!latest || latest.terminal && snapshot.status !== 'closed' || this.closed.has(snapshot.sharedTaskId)) return; + if (!latest.snapshot || latest.snapshot.revision !== snapshot.revision) return; + if (!entry) { + entry = { identity: snapshot, access: new SharedTaskAccess(snapshot), detail: null }; + this.entries.set(snapshot.sharedTaskId, entry); + } + if (entry.access.applyVerifiedSnapshot(snapshot) || entry.detail === null) { + for (const previous of entry.detail?.guests ?? []) { + const current = snapshot.guests.find((guest) => guest.memberId === previous.memberId); + // Adding another device invalidates old captures via member version, + // but does not revoke the existing devices or their subscriptions. + if (!current || previous.deviceIds.some((id) => !current.deviceIds.includes(id))) { + this.options.revoke(snapshot.sharedTaskId, previous.memberId); + } + } + entry.detail = detail; + if (snapshot.status === 'closed') { + entry.access.close(); + this.closed.add(snapshot.sharedTaskId); + this.options.revoke(snapshot.sharedTaskId); + } + this.options.changed(snapshot.sharedTaskId); + } + } + private async refreshNow(sharedTaskId: string): Promise { + this.assertCurrent(); + const generations = new Map(this.sessionGenerations); + const detail = await this.options.api.get(sharedTaskId); + await this.accept(detail, generations.get(detail.sessionId) ?? 0); + for (const release of this.reconciliation.get(sharedTaskId)?.values() ?? []) release(); + this.reconciliation.delete(sharedTaskId); + } + refresh(sharedTaskId: string): Promise { return this.serial(sharedTaskId, () => this.refreshNow(sharedTaskId)); } + + async open(sessionId: string): Promise { + this.assertCurrent(); + const generation = this.sessionGenerations.get(sessionId) ?? 0; + // An explicit open may re-share an unarchived task, only after the prior + // boundary has drained its creates and made their closures durable. + await this.taskClosures.get(sessionId); + this.assertSessionGeneration(sessionId, generation); + const session = await this.options.readSession(sessionId); + this.assertSessionGeneration(sessionId, generation); + if (!session || session.id !== sessionId || session.status !== 'active') throw new Error('SharedTask task is unavailable'); + const journal = await this.options.journal.latest(); + this.assertSessionGeneration(sessionId, generation); + // Server create is idempotent for an active task: close the previous IDs + // first, including closures inherited from another runtime or restart. + for (const item of journal) { + if (item.sessionId !== sessionId || !item.terminal) continue; + await this.options.api.close(item.sharedTaskId); + this.assertSessionGeneration(sessionId, generation); + } + this.closedSessions.delete(sessionId); + this.assertSessionOpen(sessionId, generation); + const rememberCreated = (sharedTaskId: string) => { + this.created.set(sharedTaskId, { sharedTaskId, sessionId, ownerAccountId: this.options.ownerAccountId, hostDeviceId: this.options.hostDeviceId }); + }; + const creating = this.options.api.create(sessionId, session.title, rememberCreated); + this.creating.set(creating, sessionId); + let created: Awaited; + try { + created = await creating; + rememberCreated(created.sharedTaskId); + } finally { this.creating.delete(creating); } + if (this.boundaryClosed || this.closedSessions.has(sessionId)) { + // The boundary drains this request, journals the identity and closes it + // using outgoing credentials. Ordinary API scopes are already fenced. + throw new Error('Shared task was closed'); + } + this.assertSessionOpen(sessionId, generation); + await this.serial(created.sharedTaskId, async () => { + const detail = await this.options.api.get(created.sharedTaskId); + if (detail.sessionId !== sessionId) throw new Error('SharedTask task does not match'); + await this.accept(detail, generation); + }); + const detail = this.entries.get(created.sharedTaskId)?.detail; + if (!detail || detail.sessionId !== sessionId || detail.status !== 'active') throw new Error('SharedTask could not be opened'); + return created.sharedTaskId; + } + + async restore(): Promise { + this.assertCurrent(); + const journal = await this.options.journal.latest(); + this.assertCurrent(); + const active = await this.options.api.list(); + this.assertCurrent(); + const owned = active.filter((item) => item.ownerAccountId === this.options.ownerAccountId && item.hostDeviceId === this.options.hostDeviceId); + for (const item of journal) { + if (item.terminal) this.closed.add(item.sharedTaskId); + else if (item.snapshot && !owned.some((sharedTask) => sharedTask.sharedTaskId === item.sharedTaskId)) { + this.closed.add(item.sharedTaskId); + this.entries.get(item.sharedTaskId)?.access.close(); + this.options.revoke(item.sharedTaskId); + await this.options.journal.close(item.snapshot); + this.assertCurrent(); + } + } + for (const item of owned) { + this.assertCurrent(); + if (this.closed.has(item.sharedTaskId)) { + // Retry a previous explicit close that was persisted before connectivity + // failed. A process restart itself never closes an active sharedTask. + await this.options.api.close(item.sharedTaskId); + } else await this.refresh(item.sharedTaskId); + } + } + + detail(sharedTaskId: string): SharedTaskDetail | null { + this.assertCurrent(); + return this.entries.get(sharedTaskId)?.detail ?? null; + } + authorize(sharedTaskId: string, caller: SharedTaskCaller, sessionId: string, operation: string, queueItem?: SharedTaskQueueItem) { + if (this.disposed || !this.options.isCurrent() || this.closed.has(sharedTaskId)) return { allowed: false as const, reason: 'sharedTask-unavailable' as const }; + return this.entries.get(sharedTaskId)?.access.authorize(caller, sessionId, operation, queueItem) ?? { allowed: false as const, reason: 'sharedTask-unavailable' as const }; + } + + invite(sharedTaskId: string) { + return this.serial(sharedTaskId, async () => { + this.requireEntry(sharedTaskId); + const invitation = await this.options.api.invite(sharedTaskId); + this.assertCurrent(); + return invitation; + }); + } + remove(sharedTaskId: string, memberId: string): Promise { + const release = this.requireEntry(sharedTaskId).access.suspendMember(memberId); + // Suspend host reads/writes immediately. A failed request can leave this + // member authorized, so send permanent revocation only from fresh authority. + return this.serial(sharedTaskId, async () => { + try { + this.requireEntry(sharedTaskId); + await this.options.api.remove(sharedTaskId, memberId); + } finally { + // On an ambiguous timeout, regain permission only from a fresh authority + // response. If reconciliation also fails, this member stays suspended. + try { + await this.refreshNow(sharedTaskId); + release(); + } catch (error) { + let pending = this.reconciliation.get(sharedTaskId); + if (!pending) this.reconciliation.set(sharedTaskId, pending = new Map()); + const previous = pending.get(memberId); + pending.set(memberId, () => { previous?.(); release(); }); + throw error; + } + } + }); + } + close(sharedTaskId: string): Promise { + this.assertCurrent(); + const entry = this.entries.get(sharedTaskId); + if (!entry) throw new Error('SharedTask is not hosted here'); + this.closed.add(sharedTaskId); + entry.access.close(); + if (entry.detail) entry.detail = Object.freeze({ ...entry.detail, status: 'closed' }); + // Start the profile-bound write before callbacks can dispose this host. It + // must not queue behind HTTP or be cancelled by an account generation check. + const persisted = this.options.journal.close(entry.identity); + this.closeWrites.set(sharedTaskId, persisted); + const closing = persisted.then(async () => { + this.assertCurrent(); + await this.options.api.close(sharedTaskId); + }); + this.options.revoke(sharedTaskId); + this.options.changed(sharedTaskId); + return closing; + } + /** A temporary authority fence is not evidence that membership was revoked. */ + peerStatus(source: string): 'available' | 'unavailable' | 'revoked' { + const peer = parseSharedTaskPeer(source); + if (!peer || peer.role !== 'guest') return 'revoked'; + if (this.closed.has(peer.sharedTaskId)) return 'revoked'; + if (this.disposed || !this.options.isCurrent()) return 'unavailable'; + const entry = this.entries.get(peer.sharedTaskId); + if (!entry?.detail) return 'unavailable'; + if (this.boundaryClosed || this.closedSessions.has(entry.identity.sessionId)) return 'revoked'; + const member = entry.detail.guests.find((guest) => guest.memberId === peer.memberId); + if (!member || !member.deviceIds.includes(peer.deviceId)) return 'revoked'; + return entry.access.authorize({ accountId: member.accountId, deviceId: peer.deviceId }, + entry.identity.sessionId, 'history.read').allowed ? 'available' : 'unavailable'; + } + + /** Only resolve relay-stamped logical peers against verified host authority. */ + capturePeer(source: string) { + const peer = parseSharedTaskPeer(source); + if (!peer || peer.role !== 'guest' || this.disposed || !this.options.isCurrent()) return null; + const entry = this.entries.get(peer.sharedTaskId); + const member = entry?.detail?.guests.find((guest) => guest.memberId === peer.memberId); + if (!entry || !member || this.closed.has(peer.sharedTaskId)) return null; + const caller = Object.freeze({ accountId: member.accountId, deviceId: peer.deviceId }); + const captured = entry.access.capture(caller, entry.identity.sessionId, 'history.read'); + if (!captured.decision.allowed) return null; + const author = Object.freeze({ + sharedTaskId: peer.sharedTaskId, sessionId: entry.identity.sessionId, + memberId: member.memberId, accountId: member.accountId, + displayName: entry.detail!.memberLabels.find((label) => label.memberId === member.memberId)?.displayName ?? member.accountId, + }); + const isCurrent = () => !this.disposed && this.options.isCurrent() && captured.isCurrent(); + return { + author, isCurrent, + authorize: (operation: string, queueItem?: SharedTaskQueueItem) => + isCurrent() && entry.access.authorize(caller, author.sessionId, operation, queueItem).allowed, + }; + } + + activeSharedTaskIds(): string[] { + this.assertCurrent(); + return [...this.entries].filter(([id, entry]) => !this.closed.has(id) && entry.detail?.status === 'active').map(([id]) => id); + } + + /** Account teardown has already fenced network scopes. Durability must not depend on them. */ + closeLocallyForBoundary(sessionId?: string): Promise { + // Fence in-flight accept/open before the first journal await. + if (sessionId) { + this.closedSessions.add(sessionId); + this.sessionGenerations.set(sessionId, (this.sessionGenerations.get(sessionId) ?? 0) + 1); + } + else this.boundaryClosed = true; + let closing = this.persistBoundaryClosure(sessionId); + if (sessionId) { + const previous = this.taskClosures.get(sessionId); + if (previous) closing = Promise.all([previous, closing]).then((lists) => [...new Set(lists.flat())]); + this.taskClosures.set(sessionId, closing); + } + return closing; + } + + private async persistBoundaryClosure(sessionId?: string): Promise { + const identities = new Map(); + for (const [id, entry] of this.entries) { + if (sessionId && entry.identity.sessionId !== sessionId) continue; + identities.set(id, entry.identity); + this.closed.add(id); + entry.access.close(); + if (entry.detail) entry.detail = Object.freeze({ ...entry.detail, status: 'closed' }); + this.options.revoke(id); + this.options.changed(id); + } + // Keep the outgoing profile alive through the existing bounded create + // requests. Their response observer runs before stale-scope rejection. + await Promise.allSettled([...this.creating].filter(([, sid]) => !sessionId || sid === sessionId).map(([pending]) => pending)); + if (!sessionId) await Promise.all(this.taskClosures.values()); + for (const [id, identity] of this.created) { + if (sessionId && identity.sessionId !== sessionId) continue; + identities.set(id, identity); + this.closed.add(id); + } + // Also close sharedTasks not restored yet (e.g. logout during authority fetch). + for (const item of await this.options.journal.latest()) { + if (item.terminal || !item.snapshot || sessionId && item.sessionId !== sessionId) continue; + if (item.snapshot.ownerAccountId !== this.options.ownerAccountId || item.snapshot.hostDeviceId !== this.options.hostDeviceId) continue; + identities.set(item.sharedTaskId, item.snapshot); + this.closed.add(item.sharedTaskId); + } + for (const [id, identity] of identities) { + const write = this.options.journal.close(identity); + this.closeWrites.set(id, write); + await write; + } + return [...identities.keys()]; + } + + /** + * Revokes synchronously. Await before releasing the old profile database or + * replacing this host; only local close writes drain, never HTTP requests. + * App exit itself does not close active sharedTasks. + */ + async dispose(): Promise { + this.disposed = true; + for (const [sharedTaskId, entry] of this.entries) { + entry.access.close(); + this.options.revoke(sharedTaskId); + } + this.entries.clear(); + const writes = await Promise.allSettled(this.closeWrites.values()); + for (const write of writes) if (write.status === 'rejected') throw write.reason; + } +} diff --git a/apps/desktop/src/main/device-link/sharedTaskIpc.ts b/apps/desktop/src/main/device-link/sharedTaskIpc.ts new file mode 100644 index 00000000000..795367622df --- /dev/null +++ b/apps/desktop/src/main/device-link/sharedTaskIpc.ts @@ -0,0 +1,28 @@ +import { ipcMain } from 'electron'; +import { getCurrentUserId } from '../authManager.js'; +import { SHARED_TASK_ACCOUNT_CHANNEL, SHARED_TASK_HOST_CHANNEL } from '@cindy/device-link'; +import { assertTrustedAppRendererEvent } from '../security/trustedAppRenderer.js'; +import { throwIpcError } from '../utils/ipcValidate.js'; +import { getDeviceLinkInvokeContext } from './invoke-context.js'; +import { requireSharedTaskHost } from './sharedTaskRuntime.js'; +import { sharedTaskApi } from './sharedTaskApi.js'; +import { executeSharedTaskAccountCommand, executeSharedTaskHostCommand } from './sharedTaskCommands.js'; + +/** Narrow Renderer adapter; account operations cannot be tunneled on somebody else's login. */ +export function registerSharedTaskIpc(available: () => boolean): void { + ipcMain.handle(SHARED_TASK_HOST_CHANNEL, async (event, raw: unknown) => { + const context = getDeviceLinkInvokeContext(); + if (context?.sharedTask) throwIpcError('PERMISSION_DENIED', 'Only the sharedTask owner can manage members'); + if (!context) assertTrustedAppRendererEvent(event); + return executeSharedTaskHostCommand(raw, { available, host: requireSharedTaskHost }); + }); + ipcMain.handle(SHARED_TASK_ACCOUNT_CHANNEL, async (event, raw: unknown) => { + if (getDeviceLinkInvokeContext()) throwIpcError('PERMISSION_DENIED', 'SharedTask account operations are local only'); + assertTrustedAppRendererEvent(event); + if (!available()) throwIpcError('UNSUPPORTED_CAPABILITY', 'SharedTask mode requires updated clients and server'); + return executeSharedTaskAccountCommand(raw, sharedTaskApi, getCurrentUserId() ?? undefined, { + hostedIds: () => { try { return requireSharedTaskHost().activeSharedTaskIds(); } catch { return []; } }, + closeHosted: (sharedTaskId) => requireSharedTaskHost().close(sharedTaskId), + }); + }); +} diff --git a/apps/desktop/src/main/device-link/sharedTaskMediaAccess.ts b/apps/desktop/src/main/device-link/sharedTaskMediaAccess.ts new file mode 100644 index 00000000000..dc1ba201286 --- /dev/null +++ b/apps/desktop/src/main/device-link/sharedTaskMediaAccess.ts @@ -0,0 +1,53 @@ +import path from 'node:path'; +import { realpath } from 'node:fs/promises'; +import { parseBlobUrl } from '../cindy-media/blobStore.js'; +import { sessionCanRead } from '../cindy-media/ledger.js'; +import { getSessionFsSnapshot } from '../localDb/ipc/sessions.js'; +import { getDbClient } from '../localDb/client/current.js'; +import type { SharedTaskPeerCapture } from './sharedTaskDispatch.js'; + +function deny(): never { throw new Error('[PERMISSION_DENIED] Media does not belong to this shared task'); } + +/** Run before any local read/SSH transfer, and recheck membership after awaits. */ +export async function assertSharedTaskMedia(url: string, capture: SharedTaskPeerCapture): Promise { + if (!capture.isCurrent() || !capture.authorize('attachment.read')) deny(); + const sessionId = capture.author.sessionId; + const blob = parseBlobUrl(url); + if (blob) { + if (!await sessionCanRead(blob.hash, sessionId) || !capture.isCurrent()) deny(); + return; + } + const parsed = new URL(url); + // Frozen legacy per-task cache, still resolved by the existing safe resolver. + if (parsed.protocol === 'xdt-image:' && decodeURIComponent(parsed.hostname) === sessionId) return; + if (parsed.protocol === 'xdt-video:' || parsed.protocol === 'xdt-image:') { + // Older generated caches predate the media ledger and use global hosts. + // Only a complete URL already emitted into this task's host-authored history + // grants access; guest/user text is not evidence of cache ownership. + const rows = await getDbClient().query<{ content: string }>( + "SELECT content FROM messages WHERE session_id = ? AND role IN ('assistant', 'tool_use', 'tool_result') AND instr(content, ?) > 0", + [sessionId, url], + ); + const present = rows.some((row) => row.content.match(/xdt-(?:image|video):\/\/[^\s"'<>\x60\\)\]}]+/g)?.includes(url) === true); + if (!present || !capture.isCurrent()) deny(); + return; + } + if (!['xdt-file:', 'xdt-audio:'].includes(parsed.protocol)) deny(); + const snapshot = await getSessionFsSnapshot(sessionId); + if (!snapshot?.workingDir || !capture.isCurrent()) deny(); + if (snapshot.remoteHostId) { + if (parsed.searchParams.get('sessionId') !== sessionId || + parsed.searchParams.get('remoteHostId') !== snapshot.remoteHostId || + parsed.searchParams.get('workdir') !== snapshot.workingDir) deny(); + // The remote file-service stat/readFileChunk both check real ancestry under + // workdir (file-browser-core/scanner.ts); no controller-supplied root is used. + return; + } + if (parsed.searchParams.has('remoteHostId')) deny(); + const requested = parsed.searchParams.get('path'); + if (!requested || !path.isAbsolute(requested)) deny(); + const [file, root] = await Promise.all([realpath(requested), realpath(snapshot.workingDir)]); + const relative = path.relative(root, file); + if (relative.startsWith('..') || path.isAbsolute(relative) || !capture.isCurrent()) deny(); + return root; +} diff --git a/apps/desktop/src/main/device-link/sharedTaskMediaContext.ts b/apps/desktop/src/main/device-link/sharedTaskMediaContext.ts new file mode 100644 index 00000000000..1db6a988c8a --- /dev/null +++ b/apps/desktop/src/main/device-link/sharedTaskMediaContext.ts @@ -0,0 +1,8 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; + +const scope = new AsyncLocalStorage(); +/** Scope uploads for one trusted outbound call; never a global mutable flag. */ +export function withSharedTaskMedia(sharedTaskId: string | undefined, work: () => T): T { + return sharedTaskId ? scope.run(sharedTaskId, work) : work(); +} +export function sharedTaskMediaId(): string | undefined { return scope.getStore(); } diff --git a/apps/desktop/src/main/device-link/sharedTaskRuntime.ts b/apps/desktop/src/main/device-link/sharedTaskRuntime.ts new file mode 100644 index 00000000000..bcb9c51ca4f --- /dev/null +++ b/apps/desktop/src/main/device-link/sharedTaskRuntime.ts @@ -0,0 +1,137 @@ +import { SHARED_TASK_CAPABILITY, type DeviceLinkClient } from '@cindy/device-link'; +import { getCurrentDbClientSnapshot } from '../localDb/client/current.js'; +import { createSharedTaskJournal } from '../localDb/sharedTasks.js'; +import { activeOwnerScopeKey, getActiveAppSession, isAppSessionBoundaryPending } from '../appSessionState.js'; +import { getAuthState, getCurrentUserId, getDeviceId, getActiveAuthRealm } from '../authManager.js'; +import { createLogger } from '../logger.js'; +import { throwIpcError } from '../utils/ipcValidate.js'; +import { captureSharedTaskBoundaryClose, sharedTaskApi } from './sharedTaskApi.js'; +import { SharedTaskHost, type SharedTaskCreationState } from './sharedTaskHost.js'; +import { setSharedTaskDispatchHost } from './sharedTaskDispatch.js'; + +const log = createLogger('shared-task'); +interface Binding { + host: SharedTaskHost; + stop(): Promise; + dbEpoch: number; + database: object; + creationState: SharedTaskCreationState; + ownerAccountId: string; + region: ReturnType; + current(): boolean; + rebindIfStable(): void; +} +let binding: Binding | null = null; +let generation = 0; + +/** Binds verified authority to one relay owner, account, region and profile database. */ +export function startSharedTaskRuntime(options: { + client: DeviceLinkClient; + revoke(sharedTaskId: string, memberId?: string): void; + changed(sharedTaskId: string): void; +}): void { + const previous = binding; + void previous?.stop().catch((error) => log.warn('sharedTask runtime disposal failed', error)); + const db = getCurrentDbClientSnapshot(); + const ownerAccountId = getCurrentUserId(); + if (!db || !ownerAccountId) return; + const epoch = ++generation; + const scope = activeOwnerScopeKey(); + const region = getActiveAuthRealm(); + const creationState = previous?.database === db.client && previous.dbEpoch === db.clientEpoch && + previous.ownerAccountId === ownerAccountId && previous.region === region + ? previous.creationState : { pending: new Map(), identities: new Map() }; + let stopped = false; + let preservePeerLinks = false; + const current = () => !stopped && generation === epoch && getAuthState().isAuthenticated && + getCurrentUserId() === ownerAccountId && + !isAppSessionBoundaryPending() && activeOwnerScopeKey() === scope && getActiveAuthRealm() === region && + getCurrentDbClientSnapshot()?.clientEpoch === db.clientEpoch; + const host = new SharedTaskHost({ + api: sharedTaskApi, journal: createSharedTaskJournal(db.client), + ownerAccountId, hostDeviceId: getDeviceId(), creationState, isCurrent: current, + async readSession(sessionId) { + const rows = await db.client.query<{ id: string; title: string; status: string }>( + 'SELECT id, title, status FROM sessions WHERE id = ? LIMIT 1', [sessionId]); + return rows[0] ?? null; + }, + revoke: (sharedTaskId, memberId) => { + // A stable projection recommit replaces authority captures, not members. + // Sending a permanent 'revoked' close here would strand valid guests. + if (!preservePeerLinks) options.revoke(sharedTaskId, memberId); + }, + changed: options.changed, + }); + let refreshing = false; + // A same-account stable projection can advance its generation without + // transferring the relay lease. Retire the old Host rather than relaxing + // its captured scope, so already-admitted callbacks remain invalid forever. + const rebindIfStable = () => { + if (stopped || generation !== epoch || binding?.host !== host || current() || + isAppSessionBoundaryPending() || !getAuthState().isAuthenticated || + getCurrentUserId() !== ownerAccountId || getActiveAuthRealm() !== region) return; + const active = getActiveAppSession(); + const latestDb = getCurrentDbClientSnapshot(); + if (active.mode !== 'cloud' || active.dataOwnerId !== ownerAccountId || + latestDb?.client !== db.client || latestDb.clientEpoch !== db.clientEpoch || + activeOwnerScopeKey() === scope) return; + preservePeerLinks = true; + startSharedTaskRuntime(options); + }; + const refresh = async () => { + if (!current()) { rebindIfStable(); return; } + if (refreshing || !current() || !options.client.hasServerCapability(SHARED_TASK_CAPABILITY) || + options.client.getStatus() !== 'online') return; + refreshing = true; + try { await host.restore(); } + catch { if (current()) log.debug('sharedTask authority refresh unavailable; retrying on next tick'); } + finally { refreshing = false; } + }; + const timer = setInterval(() => { void refresh(); }, 5_000); + timer.unref?.(); + binding = { host, dbEpoch: db.clientEpoch, database: db.client, creationState, ownerAccountId, region, current, rebindIfStable, stop() { + stopped = true; + clearInterval(timer); + if (binding?.host === host) setSharedTaskDispatchHost(null); + return host.dispose(); + } }; + setSharedTaskDispatchHost(host); + void refresh(); +} + +/** Ordinary process/relay ownership loss revokes live access but preserves membership. */ +export function stopSharedTaskRuntime(): Promise { + return binding?.stop() ?? Promise.resolve(); +} + +export function requireSharedTaskHost(): SharedTaskHost { + binding?.rebindIfStable(); + if (!binding?.current()) throwIpcError('PRECONDITION_FAILED', 'SharedTask host is unavailable'); + return binding.host; +} + +/** Must be awaited before disposing the outgoing profile; disk failure aborts handover. */ +export async function closeSharedTasksBeforeLogout(): Promise { + const outgoing = binding; + if (!outgoing || outgoing.dbEpoch !== getCurrentDbClientSnapshot()?.clientEpoch) return; + const close = captureSharedTaskBoundaryClose(outgoing.ownerAccountId, outgoing.region); + const ids = await outgoing.host.closeLocallyForBoundary(); + await outgoing.stop(); + // Journal first; offline/expired credentials leave a durable retry for restore. + // Close concurrently under a bounded old-identity request before logout returns. + if (close) { + const results = await Promise.allSettled(ids.map((id) => close(id))); + if (results.some((result) => result.status === 'rejected')) { + log.debug('sharedTask boundary closure pending; retained journal for retry'); + } + } +} + +/** Terminal task state is durable before this runs; never reopen it on a later restore. */ +export async function closeSharedTaskForTask(sessionId: string, database: unknown): Promise { + if (getCurrentDbClientSnapshot()?.client !== database) return; + if (!binding || binding.dbEpoch !== getCurrentDbClientSnapshot()?.clientEpoch) return; + const ids = await binding.host.closeLocallyForBoundary(sessionId); + // Network failure is retried from the terminal journal; never undo the task archive. + for (const id of ids) void sharedTaskApi.close(id).catch(() => undefined); +} diff --git a/apps/desktop/src/main/file-browser/__tests__/remoteFileCacheOwner.test.ts b/apps/desktop/src/main/file-browser/__tests__/remoteFileCacheOwner.test.ts index 80a5eca2d1a..a574845ac54 100644 --- a/apps/desktop/src/main/file-browser/__tests__/remoteFileCacheOwner.test.ts +++ b/apps/desktop/src/main/file-browser/__tests__/remoteFileCacheOwner.test.ts @@ -65,15 +65,19 @@ it('cancels the shared executor only after the last consumer releases it', async const secondController = new AbortController(); let release!: () => void; const pause = new Promise((resolve) => { release = resolve; }); + let started!: () => void; + const ready = new Promise((resolve) => { started = resolve; }); let transferSignal!: AbortSignal; const executor = vi.fn(async (dest: string, _progress: unknown, signal?: AbortSignal) => { transferSignal = signal!; await fs.writeFile(dest, 'old'); + started(); await pause; if (signal?.aborted) throw new Error('FILE_PEER_CANCELLED'); }); const first = fetchRemoteFileToCache(id, executor, vi.fn(), firstController.signal); - await vi.waitFor(() => expect(executor).toHaveBeenCalledOnce()); + await ready; + expect(executor).toHaveBeenCalledOnce(); const second = fetchRemoteFileToCache(id, executor, vi.fn(), secondController.signal); firstController.abort(); await expect(first).rejects.toThrow('FILE_PEER_CANCELLED'); diff --git a/apps/desktop/src/main/localDb/__tests__/sharedTasks.test.ts b/apps/desktop/src/main/localDb/__tests__/sharedTasks.test.ts new file mode 100644 index 00000000000..a670d7fa8e6 --- /dev/null +++ b/apps/desktop/src/main/localDb/__tests__/sharedTasks.test.ts @@ -0,0 +1,92 @@ +import Database from 'better-sqlite3'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createSharedTaskJournal } from '../sharedTasks.js'; +import type { SharedTaskSnapshot } from '@cindy/device-link'; + +const snapshot = (revision = 1): SharedTaskSnapshot => ({ + sharedTaskId: 'sharedTask', sessionId: 'session', ownerAccountId: 'owner', hostDeviceId: 'desktop', + revision, status: 'active', guests: [{ memberId: 'member', accountId: 'guest', version: 1, deviceIds: ['phone'] }], +}); +let db: Database.Database; +let journal: ReturnType; +beforeEach(() => { + db = new Database(':memory:'); + db.pragma('foreign_keys = ON'); + db.exec("CREATE TABLE sessions (id TEXT PRIMARY KEY); INSERT INTO sessions VALUES ('session'), ('other-session')"); + db.exec(readFileSync(resolve(process.cwd(), 'drizzle/0114_shared_task_events.sql'), 'utf8')); + journal = createSharedTaskJournal({ + async exec(sql, params = []) { return db.prepare(sql).run(...params); }, + async query(sql: string, params: unknown[] = []) { return db.prepare(sql).all(...params) as T[]; }, + }); +}); +afterEach(() => db.close()); + +it('moves development-era records and author metadata without rewriting message content', async () => { + db.exec(` + CREATE TABLE session_meeting_events (id INTEGER PRIMARY KEY, meeting_id TEXT, session_id TEXT, + revision INTEGER, kind TEXT, terminal INTEGER, snapshot TEXT, recorded_at INTEGER); + CREATE TABLE messages (agent_meta TEXT, content TEXT); + CREATE TABLE agent_input_queue_snapshots (payload TEXT); + `); + const legacy = { ...snapshot(), meetingId: 'legacy-share', sharedTaskId: undefined }; + db.prepare("INSERT INTO session_meeting_events VALUES (1, 'legacy-share', 'session', 1, 'authority', 0, ?, 123)") + .run(JSON.stringify(legacy)); + const author = { meetingId: 'legacy-share', sessionId: 'session', memberId: 'member', accountId: 'guest', displayName: 'Guest' }; + const content = 'User text: meetingId and meetingAuthor must stay verbatim'; + db.prepare('INSERT INTO messages VALUES (?, ?)').run(JSON.stringify({ meetingAuthor: author, uuid: 'message' }), content); + db.prepare('INSERT INTO messages VALUES (?, ?)').run('invalid legacy JSON', content); + db.prepare('INSERT INTO agent_input_queue_snapshots VALUES (?)').run(JSON.stringify([{ meetingAuthor: author, text: content }])); + const { run } = createRequire(import.meta.url)(resolve(process.cwd(), 'drizzle/scripts/0114_shared_task_events.ts')); + run(db); + run(db); + expect(await journal.latest()).toMatchObject([{ sharedTaskId: 'legacy-share', snapshot: { sharedTaskId: 'legacy-share' } }]); + expect(db.prepare("SELECT name FROM sqlite_master WHERE name = 'session_meeting_events'").get()).toBeUndefined(); + const message = db.prepare('SELECT * FROM messages LIMIT 1').get() as { agent_meta: string; content: string }; + expect(message.content).toBe(content); + expect(JSON.parse(message.agent_meta)).toEqual({ uuid: 'message', sharedTaskAuthor: { ...author, meetingId: undefined, sharedTaskId: 'legacy-share' } }); + const queue = db.prepare('SELECT payload FROM agent_input_queue_snapshots').get() as { payload: string }; + expect(JSON.parse(queue.payload)[0]).toMatchObject({ text: content, sharedTaskAuthor: { sharedTaskId: 'legacy-share' } }); + expect(db.prepare('SELECT agent_meta FROM messages WHERE rowid = 2').get()).toEqual({ agent_meta: 'invalid legacy JSON' }); +}); +describe('sharedTask authority journal', () => { + it('retains membership changes and reads only the latest authority', async () => { + expect(await journal.recordAuthority(snapshot())).toBe(true); + expect(await journal.recordAuthority({ ...snapshot(2), guests: [] })).toBe(true); + expect(db.prepare('SELECT COUNT(*) AS n FROM shared_task_events').get()).toEqual({ n: 2 }); + expect(await journal.latest()).toMatchObject([{ snapshot: { revision: 2, guests: [] }, terminal: false }]); + }); + it('ignores stale and duplicate revisions without losing audit history', async () => { + await journal.recordAuthority(snapshot(2)); + expect(await journal.recordAuthority(snapshot())).toBe(false); + expect(await journal.recordAuthority(snapshot(2))).toBe(false); + expect(await journal.latest()).toMatchObject([{ snapshot: { revision: 2 } }]); + }); + it('does not let another task reuse a sharedTask identity', async () => { + await journal.recordAuthority(snapshot()); + expect(await journal.recordAuthority({ ...snapshot(2), sessionId: 'other-session' })).toBe(false); + await journal.close({ ...snapshot(), sessionId: 'other-session' }); + expect(await journal.latest()).toMatchObject([{ sessionId: 'session', terminal: false }]); + }); + it.each(['local', 'server'])('keeps a %s closure terminal when late replies arrive', async (source) => { + await journal.recordAuthority(snapshot()); + if (source === 'local') { await journal.close(snapshot()); await journal.close(snapshot()); } + else await journal.recordAuthority({ ...snapshot(2), status: 'closed' }); + expect(await journal.recordAuthority(snapshot(20))).toBe(false); + expect(await journal.latest()).toMatchObject([{ terminal: true }]); + }); + it('allows a fresh sharedTask for the same task after closing the previous one', async () => { + await journal.close(snapshot()); + expect(await journal.recordAuthority({ ...snapshot(), sharedTaskId: 'new-sharedTask' })).toBe(true); + expect(await journal.latest()).toHaveLength(2); + }); + it('cascades journal deletion only with its owning task', async () => { + await journal.recordAuthority(snapshot()); + db.prepare("DELETE FROM sessions WHERE id = 'other-session'").run(); + expect(await journal.latest()).toHaveLength(1); + db.prepare("DELETE FROM sessions WHERE id = 'session'").run(); + expect(await journal.latest()).toEqual([]); + }); +}); 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 a765d9585eb..44c93339342 100644 --- a/apps/desktop/src/main/localDb/ipc/__tests__/sessionsUpdate.test.ts +++ b/apps/desktop/src/main/localDb/ipc/__tests__/sessionsUpdate.test.ts @@ -33,6 +33,7 @@ const h = vi.hoisted(() => ({ persistedSdkSessionId: null, })), closeSession: vi.fn(async (_sessionId: string) => undefined), + closeSharedTask: vi.fn(async (_sessionId: string, _database: unknown): Promise => undefined), tapWindowBroadcast: vi.fn(), windows: [] as Array<{ trusted?: boolean; @@ -109,6 +110,9 @@ vi.mock('../../../git-context/prRefsStore', () => ({ recomputePrRefsForSession: vi.fn(async () => undefined), })); vi.mock('../../../imageCacheStore', () => ({ removeSession: vi.fn(async () => undefined) })); +vi.mock('../../../device-link/sharedTaskRuntime.js', () => ({ + closeSharedTaskForTask: h.closeSharedTask, +})); vi.mock('../recentWorkdirs', () => ({ upsertRecentWorkdir: h.upsertRecentWorkdir })); vi.mock('../../../device-link/broadcast-tap.js', () => ({ captureDataOwnerBroadcastScope: vi.fn(() => @@ -860,6 +864,7 @@ describe('local-db:sessions:update handler wiring', () => { it('cleans runtime state before releasing the local terminal status lock', async () => { const order: string[] = []; h.runtimeCleanup.mockImplementationOnce(() => order.push('runtime-cleanup')); + h.closeSharedTask.mockImplementationOnce(async () => { order.push('sharing-closed'); }); h.routeLock.mockImplementationOnce(async (_sessionId, task) => { const result = await task(); order.push('lock-released'); @@ -869,13 +874,15 @@ describe('local-db:sessions:update handler wiring', () => { await invokeUpdate('codex-local', { status: 'archived' }); - expect(order).toEqual(['runtime-cleanup', 'lock-released']); + expect(order).toEqual(['runtime-cleanup', 'sharing-closed', 'lock-released']); expect(h.runtimeCleanup).toHaveBeenCalledOnce(); + expect(h.closeSharedTask).toHaveBeenCalledWith('codex-local', h.client); }); it('cleans runtime state before releasing the remote terminal status lock', async () => { const order: string[] = []; h.runtimeCleanup.mockImplementationOnce(() => order.push('runtime-cleanup')); + h.closeSharedTask.mockImplementationOnce(async () => { order.push('sharing-closed'); }); h.routeLock.mockImplementationOnce(async (_sessionId, task) => { const result = await task(); order.push('lock-released'); @@ -885,8 +892,9 @@ describe('local-db:sessions:update handler wiring', () => { await patchSessionMetaInDb('codex-local', { status: 'archived' }); - expect(order).toEqual(['runtime-cleanup', 'lock-released']); + expect(order).toEqual(['runtime-cleanup', 'sharing-closed', 'lock-released']); expect(h.runtimeCleanup).toHaveBeenCalledOnce(); + expect(h.closeSharedTask).toHaveBeenCalledWith('codex-local', h.client); }); // 竞态收敛(review on #3225):写入与查询不在同一串行区间,归档写入后、查询前 diff --git a/apps/desktop/src/main/localDb/ipc/sessions.ts b/apps/desktop/src/main/localDb/ipc/sessions.ts index 123790e029a..60a15aee2a0 100644 --- a/apps/desktop/src/main/localDb/ipc/sessions.ts +++ b/apps/desktop/src/main/localDb/ipc/sessions.ts @@ -1921,6 +1921,10 @@ export async function updateSessionInDb( moveGuard?.assertCurrent(); await writeSessionPatch(db, sid, setObj, p.status); cleanupSessionRuntimeForTerminalStatus(sid, p.status); + if (p.status === 'archived' || p.status === 'deleted') { + const { closeSharedTaskForTask } = await import('../../device-link/sharedTaskRuntime.js'); + await closeSharedTaskForTask(sid, dbClient); + } }, p.workingDir !== undefined, ); @@ -2088,6 +2092,10 @@ export async function patchSessionMetaInDb( row.summary = null; } cleanupSessionRuntimeForTerminalStatus(sessionId, patch.status); + if (patch.status === 'archived' || patch.status === 'deleted') { + const { closeSharedTaskForTask } = await import('../../device-link/sharedTaskRuntime.js'); + await closeSharedTaskForTask(sessionId, dbClient); + } return sessionToCamel(row); }); notifyAgentIslandSessionPatch(updated.id, { @@ -2286,6 +2294,10 @@ export async function setSessionsStatusInDb( }); for (const item of rows) { cleanupSessionRuntimeForTerminalStatus(item.sessionId, item.status); + if (item.status === 'archived') { + const { closeSharedTaskForTask } = await import('../../device-link/sharedTaskRuntime.js'); + await closeSharedTaskForTask(item.sessionId, dbClient); + } } for (const resource of physicalResources) notifyWorktreeRecycleOpportunity(resource); return rows; diff --git a/apps/desktop/src/main/localDb/schema.ts b/apps/desktop/src/main/localDb/schema.ts index 8a70d158b4c..b78b8b1c1ce 100644 --- a/apps/desktop/src/main/localDb/schema.ts +++ b/apps/desktop/src/main/localDb/schema.ts @@ -20,6 +20,21 @@ import { import type { SessionSource } from '../../shared/sessionSource.js'; +/** Per-profile authority journal. Snapshots are recovery/audit data, not offline grants. */ +export const sharedTaskEvents = sqliteTable('shared_task_events', { + id: integer('id').primaryKey({ autoIncrement: true }), + sharedTaskId: text('shared_task_id').notNull(), + sessionId: text('session_id').notNull().references((): AnySQLiteColumn => sessions.id, { onDelete: 'cascade' }), + revision: integer('revision').notNull(), + kind: text('kind', { enum: ['authority', 'local-close'] }).notNull(), + terminal: integer('terminal', { mode: 'boolean' }).notNull(), + snapshot: text('snapshot'), + recordedAt: integer('recorded_at').notNull(), +}, (table) => ({ + uniqueRevision: uniqueIndex('shared_task_events_revision_idx').on(table.sharedTaskId, table.kind, table.revision), + bySession: index('shared_task_events_session_idx').on(table.sessionId, table.id), +})); + const SESSION_SOURCES = [ 'desktop', 'feishu', diff --git a/apps/desktop/src/main/localDb/sharedTasks.ts b/apps/desktop/src/main/localDb/sharedTasks.ts new file mode 100644 index 00000000000..8baa2304edc --- /dev/null +++ b/apps/desktop/src/main/localDb/sharedTasks.ts @@ -0,0 +1,55 @@ +import { parseSharedTaskSnapshot, type SharedTaskIdentity, type SharedTaskSnapshot } from '@cindy/device-link'; +import type { DbClient } from './client/DbClient.js'; + +export interface SharedTaskJournalEntry { + sharedTaskId: string; + sessionId: string; + terminal: boolean; + snapshot: SharedTaskSnapshot | null; +} + +/** Bound to one profile's DbClient; never resolves a different account after an await. */ +export function createSharedTaskJournal(db: Pick, now: () => number = Date.now) { + return { + async recordAuthority(value: SharedTaskSnapshot): Promise { + const snapshot = parseSharedTaskSnapshot(value); + // A single atomic statement records both the recovery snapshot and its + // membership audit entry. A terminal record permanently fences late replies. + const result = await db.exec(` + INSERT INTO shared_task_events (shared_task_id, session_id, revision, kind, terminal, snapshot, recorded_at) + SELECT ?, ?, ?, 'authority', ?, ?, ? + WHERE NOT EXISTS ( + SELECT 1 FROM shared_task_events + WHERE shared_task_id = ? AND (terminal = 1 OR session_id <> ? OR revision >= ?) + ) + ON CONFLICT (shared_task_id, kind, revision) DO NOTHING + `, [snapshot.sharedTaskId, snapshot.sessionId, snapshot.revision, snapshot.status === 'closed' ? 1 : 0, + JSON.stringify(snapshot), now(), snapshot.sharedTaskId, snapshot.sessionId, snapshot.revision]); + return result.changes > 0; + }, + async close(identity: SharedTaskIdentity): Promise { + // Revision zero is reserved for a local closure, not a server revision. + // Never manufacture a higher authority revision from the local clock. + const checked = parseSharedTaskSnapshot({ ...identity, revision: 1, status: 'closed', guests: [] }); + await db.exec(` + INSERT INTO shared_task_events (shared_task_id, session_id, revision, kind, terminal, snapshot, recorded_at) + SELECT ?, ?, 0, 'local-close', 1, NULL, ? + WHERE NOT EXISTS (SELECT 1 FROM shared_task_events WHERE shared_task_id = ? AND session_id <> ?) + ON CONFLICT (shared_task_id, kind, revision) DO NOTHING + `, [checked.sharedTaskId, checked.sessionId, now(), checked.sharedTaskId, checked.sessionId]); + }, + async latest(): Promise { + const rows = await db.query<{ shared_task_id: string; session_id: string; terminal: number; snapshot: string | null }>(` + SELECT shared_task_id, session_id, terminal, snapshot FROM shared_task_events + WHERE id IN (SELECT MAX(id) FROM shared_task_events GROUP BY shared_task_id) + `); + return rows.map((row) => { + const snapshot = row.snapshot === null ? null : parseSharedTaskSnapshot(JSON.parse(row.snapshot)); + if (snapshot && (snapshot.sharedTaskId !== row.shared_task_id || snapshot.sessionId !== row.session_id)) throw new Error('SharedTask journal scope mismatch'); + return { sharedTaskId: row.shared_task_id, sessionId: row.session_id, terminal: row.terminal === 1, snapshot }; + }); + }, + }; +} + +export type SharedTaskJournal = ReturnType; diff --git a/apps/desktop/src/main/maker-ipc/__tests__/agent-input-coordinator.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/agent-input-coordinator.test.ts index 1f0c731cbc7..342fa7c9f07 100644 --- a/apps/desktop/src/main/maker-ipc/__tests__/agent-input-coordinator.test.ts +++ b/apps/desktop/src/main/maker-ipc/__tests__/agent-input-coordinator.test.ts @@ -208,6 +208,22 @@ describe('AgentInputCoordinator Orca priority queue transactions', () => { ); }); + it('retains host-stamped sharedTask attribution when the queue drains outside the original invoke', async () => { + const h = createHarness(); + const sid = 'sharedTask-task'; + const author = { sharedTaskId: 'sharedTask', sessionId: sid, memberId: 'member', accountId: 'guest', displayName: 'Guest' }; + h.setRunning(true); + h.coordinator.enqueue(sid, makeItem('sharedTask-input', 'hello', { sharedTaskAuthor: author, userName: author.displayName })); + expect(h.coordinator.getProjection(sid).pendingQueue[0].sharedTaskAuthor).toEqual(author); + h.setRunning(false); + h.coordinator.resume(sid); + await flush(); + expect(h.sendToAgent).toHaveBeenCalledWith( + sid, expect.anything(), expect.anything(), + expect.objectContaining({ userName: 'Guest', persistUserMessage: expect.objectContaining({ sharedTaskAuthor: author }) }), + ); + }); + it('restores first, reserves at the head with a host stamp, deduplicates, and emits once', async () => { const h = createHarness(); const sid = 'priority-worker'; diff --git a/apps/desktop/src/main/maker-ipc/__tests__/sharedTaskContextUsage.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/sharedTaskContextUsage.test.ts new file mode 100644 index 00000000000..9e140f3f368 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/sharedTaskContextUsage.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createSharedTaskContextUsageGuard } from '../sharedTaskContextUsage.js'; +import type { SharedTaskPeerCapture } from '../../device-link/sharedTaskDispatch.js'; + +const capture: SharedTaskPeerCapture = { + author: { sharedTaskId: 'sharedTask', sessionId: 'task', memberId: 'member', accountId: 'guest', displayName: 'Guest' }, + isCurrent: () => true, + authorize: () => true, +}; + +describe('shared task context usage cold-start authority', () => { + it.each(['ask', 'bypassPermissions'])('uses only the host snapshot, preserving host mode %s', async (permissionMode) => { + const guard = createSharedTaskContextUsageGuard(capture, 'task'); + const malicious = { permissionMode: 'bypassPermissions', workingDir: '/private', remoteHostId: 'other-host', + extraDirs: ['/secrets'], writableDirs: ['/'], vendorOptions: { executable: 'untrusted' } }; + const host = { permissionMode, workingDir: '/task', remoteHostId: 'task-host', + extraDirs: ['/allowed'], writableDirs: ['/task'], model: 'saved-model', planMode: false }; + const resolved = await guard.resolveCreateOpts(malicious, async () => host); + expect(resolved).toEqual(host); + expect(resolved).not.toHaveProperty('vendorOptions'); + }); + + it('does not even read guest option getters and permits host-driven cold queries without wire options', async () => { + const guard = createSharedTaskContextUsageGuard(capture, 'task'); + const malicious = Object.defineProperty({}, 'workingDir', { enumerable: true, get() { throw new Error('wire touched'); } }); + const host = { workingDir: '/task' }; + expect(await guard.resolveCreateOpts(malicious, async () => host)).toBe(host); + expect(await guard.resolveCreateOpts(undefined, async () => host)).toBe(host); + }); + + it('fails closed when host state cannot be read', async () => { + const guard = createSharedTaskContextUsageGuard(capture, 'task'); + await expect(guard.resolveCreateOpts({ workingDir: '/untrusted' }, async () => { throw new Error('DB unavailable'); })) + .rejects.toThrow('DB unavailable'); + }); + + it('rejects cross-task and revoked requests before reading the task', async () => { + const read = vi.fn(async () => ({})); + for (const guard of [ + createSharedTaskContextUsageGuard(capture, 'other-task'), + createSharedTaskContextUsageGuard({ ...capture, isCurrent: () => false }, 'task'), + createSharedTaskContextUsageGuard({ ...capture, authorize: () => false }, 'task'), + ]) await expect(guard.resolveCreateOpts({}, read)).rejects.toThrow('PERMISSION_DENIED'); + expect(read).not.toHaveBeenCalled(); + }); + + it('rejects revocation during the DB read and again before a delayed bootstrap side effect', async () => { + let current = true; + const guard = createSharedTaskContextUsageGuard({ ...capture, isCurrent: () => current }, 'task'); + await expect(guard.resolveCreateOpts({}, async () => { current = false; return {}; })).rejects.toThrow('PERMISSION_DENIED'); + current = true; + await guard.resolveCreateOpts({}, async () => ({})); + current = false; + const start = vi.fn(); + expect(() => { guard.assertCurrent(); start(); }).toThrow('PERMISSION_DENIED'); + expect(start).not.toHaveBeenCalled(); + }); + + it('preserves ordinary local and same-account remote options without a DB lookup', async () => { + const guard = createSharedTaskContextUsageGuard(undefined, 'task'); + const read = vi.fn(async () => ({})); + const wire = { workingDir: '/owner-task' }; + expect(await guard.resolveCreateOpts(wire, read)).toBe(wire); + expect(await guard.resolveCreateOpts(undefined, read)).toBeUndefined(); + expect(read).not.toHaveBeenCalled(); + expect(guard.assertCurrent).not.toThrow(); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/sharedTaskInput.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/sharedTaskInput.test.ts new file mode 100644 index 00000000000..5033045df72 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/sharedTaskInput.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { stampSharedTaskInput, assertSharedTaskQueueMutation } from '../sharedTaskInput.js'; +import type { SharedTaskPeerCapture } from '../../device-link/sharedTaskDispatch.js'; +import type { AgentInputQueuedMessage } from '../../../shared/agentInputQueue.js'; + +const author = { sharedTaskId: 'sharedTask', sessionId: 'task', memberId: 'guest-id', accountId: 'guest', displayName: 'Guest' }; +const capture: SharedTaskPeerCapture = { author, isCurrent: () => true, authorize: () => true }; +const item = { clientId: 'message', text: 'hello', persistedContent: 'hello', + permissionMode: 'bypassPermissions', workingDir: 'untrusted', model: 'untrusted', effort: 'untrusted', + createOpts: { agentKind: 'pi', workingDir: 'untrusted', permissionMode: 'bypassPermissions', model: 'untrusted', vendorOptions: { extraDirs: ['private'] } }, + vendorOptions: { extraDirs: ['private'] }, userName: 'Owner', + chatMessage: { clientId: 'message', role: 'user', content: 'hello' }, +} satisfies AgentInputQueuedMessage; + +describe('sharedTask input uses the task Agent authority', () => { + it.each(['ask', 'bypassPermissions'])('keeps the host permission mode %s, without a guest policy', (permissionMode) => { + const task = { agentKind: 'pi' as const, workingDir: 'host-workdir', model: 'host-model', permissionMode }; + const result = stampSharedTaskInput(item, capture, task); + expect(result.createOpts).toEqual(task); + expect(result.permissionMode).toBe(permissionMode); + expect(result.workingDir).toBe('host-workdir'); + expect(result).not.toHaveProperty('vendorOptions'); + expect(result).not.toHaveProperty('turnPermissionPolicy'); + expect(result.sharedTaskAuthor).toEqual(author); + expect(result.userName).toBe('Guest'); + }); + it('strips a forged author from ordinary local input and rejects revoked preparation', () => { + expect(stampSharedTaskInput({ ...item, sharedTaskAuthor: author }, undefined, undefined)).not.toHaveProperty('sharedTaskAuthor'); + expect(() => stampSharedTaskInput(item, { ...capture, isCurrent: () => false }, item.createOpts)).toThrow(); + }); + it('allows editing only the original membership own pending message, rechecking revocation', () => { + const owned = { ...item, sharedTaskAuthor: author }; + expect(() => assertSharedTaskQueueMutation(capture, 'task', 'input.edit', owned)).not.toThrow(); + expect(() => assertSharedTaskQueueMutation(capture, 'task', 'input.withdraw', item)).toThrow(); + expect(() => assertSharedTaskQueueMutation(capture, 'task', 'input.edit', { ...owned, sharedTaskAuthor: { ...author, memberId: 'retired-member' } })).toThrow(); + expect(() => assertSharedTaskQueueMutation({ ...capture, isCurrent: () => false }, 'task', 'input.edit', owned)).toThrow(); + expect(() => assertSharedTaskQueueMutation(undefined, 'task', 'input.edit', owned)).not.toThrow(); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/__tests__/sharedTaskSetting.test.ts b/apps/desktop/src/main/maker-ipc/__tests__/sharedTaskSetting.test.ts new file mode 100644 index 00000000000..b52d01987d9 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/__tests__/sharedTaskSetting.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createSharedTaskSettingGuard } from '../sharedTaskSetting.js'; +import { applyRuntimeSetModelChange } from '../runtimeSetModel.js'; +import { commitRuntimeAxisAfterPersistence } from '../runtimeSelectionAxes.js'; + +function harness() { + let current = true; + const transaction = { admitted: false }; + const guard = createSharedTaskSettingGuard({ + author: { sharedTaskId: 'sharedTask', sessionId: 'task', memberId: 'guest', accountId: 'account', displayName: 'Guest' }, + isCurrent: () => current, authorize: () => current, + }, 'task', transaction); + return { guard, transaction, revoke() { current = false; } }; +} +describe('sharedTask setting admission', () => { + it('rechecks membership after async model preflight, before any runtime change', async () => { + const h = harness(); + const setModel = vi.fn(); + const closeSession = vi.fn(); + let resolve!: (value: boolean) => void; + const session = { agentKind: 'claude-code' as const, model: 'old', setModel, + requiresModelSwitchRebuild: () => new Promise((done) => { resolve = done; }) }; + const run = applyRuntimeSetModelChange({ + maker: { getSession: () => session, listActiveSessions: () => [], closeSession }, + sessionId: 'task', model: 'new', admit: h.guard.admit, + }); + h.revoke(); + resolve(false); + await expect(run).rejects.toThrow('SharedTask task access denied'); + expect(setModel).not.toHaveBeenCalled(); + expect(closeSession).not.toHaveBeenCalled(); + }); + it('finishes persistence of an admitted native operation after removal', async () => { + const h = harness(); + h.guard.admit(); + h.revoke(); + const commit = vi.fn(); + await commitRuntimeAxisAfterPersistence({ + persist: async () => { h.guard(); }, commit, assertCanCommit: h.guard, + }); + expect(commit).toHaveBeenCalledOnce(); + }); + it('does not block recovery when persistence fails after an admitted operation', async () => { + const h = harness(); + h.guard.admit(); + h.revoke(); + const recover = vi.fn(async () => {}); + const commit = vi.fn(); + await expect(commitRuntimeAxisAfterPersistence({ + persist: async () => { throw new Error('disk failed'); }, commit, + assertCanCommit: h.guard, recoverAfterPersistenceFailure: recover, + })).rejects.toThrow('disk failed'); + expect(recover).toHaveBeenCalledOnce(); + expect(commit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts b/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts index 7307917d83b..8f958e2a2e8 100644 --- a/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts +++ b/apps/desktop/src/main/maker-ipc/agent-input-coordinator.ts @@ -1,4 +1,6 @@ import { AUTO_REVIEW_SOURCE_CONTENT, AUTO_REVIEW_USER_INTENT } from '@cindy/maker-core'; +import { getDeviceLinkInvokeContext } from '../device-link/invoke-context.js'; +import { assertSharedTaskQueueMutation } from './sharedTaskInput.js'; /** * AgentInputCoordinator — main 侧排队输入事务协调器。 * @@ -227,6 +229,7 @@ export interface AgentInputSendOpts { /** Session reservation 时回调本轮 vendor generation;必须在 send 返回前绑定 leftover。 */ onVendorTurnReserved?: (generation: number) => void; persistUserMessage?: { + sharedTaskAuthor?: AgentInputQueuedMessage['sharedTaskAuthor']; clientId: string; content: string; /** Overflow 重放用的 agent-facing wire payload(mention / 标注附件等)。 */ @@ -1543,6 +1546,7 @@ export class AgentInputCoordinator { ): AgentInputProjection { const state = this.getState(sessionId); item = captureOriginalSyntheticTrigger(item); + assertSharedTaskQueueMutation(getDeviceLinkInvokeContext()?.sharedTask, sessionId, 'input.send'); // 幂等去重(弱网重发防线,PR #881):同 clientId 重复投递说明是控制端(手机 // 断连自动重试 / 用户对 ack 丢失的消息重发)在补发同一条消息,不是新消息。 // 直接返回当前 projection、不再入队——否则同一条消息双入队、agent 跑两轮。 @@ -1948,6 +1952,8 @@ export class AgentInputCoordinator { (opts?.expectedTurnGeneration === undefined || this.deps.getTurnGeneration?.(sessionId) === opts.expectedTurnGeneration); if (!matchesExpectedTurn()) return false; + const sharedTask = getDeviceLinkInvokeContext()?.sharedTask; + assertSharedTaskQueueMutation(sharedTask, sessionId, 'input.send'); const state = this.getState(sessionId); // Capture the clear boundary before any screening/reference/steer await. The // live state may advance when `/clear` wins the race; this turn must retain @@ -1957,6 +1963,7 @@ export class AgentInputCoordinator { let steersStoredQueueItem = false; if (opts?.removeFromQueue) { const storedItem = state.pendingQueue.find((queued) => queued.clientId === item.clientId); + if (sharedTask) assertSharedTaskQueueMutation(sharedTask, sessionId, 'input.edit', storedItem); if (storedItem) { steersStoredQueueItem = true; // Renderer projections intentionally omit trusted reference bodies. The main-owned @@ -2169,6 +2176,7 @@ export class AgentInputCoordinator { referenceContexts, ); await this.deps.steerToAgent(sessionId, buildMakerUserMessage(item, referenceContexts), { + ...(item.sharedTaskAuthor ? { sharedTaskAuthor: item.sharedTaskAuthor } : {}), [AUTO_REVIEW_SOURCE_CONTENT]: item.autoReviewUserText ?? '', ...(readAutoReviewUserText(item.persistedContent) === null ? { [AUTO_REVIEW_USER_INTENT]: item.autoReviewUserText ?? '' } : {}), @@ -2518,6 +2526,9 @@ export class AgentInputCoordinator { sessionId: string, opts?: { keepQueue?: boolean; pauseQueue?: boolean; resumeOnUserInput?: boolean }, ): AgentInputProjection { + const sharedTask = getDeviceLinkInvokeContext()?.sharedTask; + assertSharedTaskQueueMutation(sharedTask, sessionId, 'agent.stop'); + if (sharedTask) opts = { ...opts, keepQueue: true }; const state = this.getState(sessionId); const preserveQueue = opts?.keepQueue === true; this.supersedePendingAutoResumeRecoveries(sessionId); @@ -2617,6 +2628,7 @@ export class AgentInputCoordinator { } resume(sessionId: string): AgentInputProjection { + assertSharedTaskQueueMutation(getDeviceLinkInvokeContext()?.sharedTask, sessionId, 'input.send'); const state = this.getState(sessionId); const recovery = state.recovery; const pausedQueueHeadRecoveryClientId = @@ -2951,6 +2963,7 @@ export class AgentInputCoordinator { remove(sessionId: string, clientId: string): AgentInputProjection { const state = this.getState(sessionId); + assertSharedTaskQueueMutation(getDeviceLinkInvokeContext()?.sharedTask, sessionId, 'input.withdraw', state.pendingQueue.find((item) => item.clientId === clientId)); if (state.steeringQueueClientIds.includes(clientId)) return this.getProjection(sessionId); const before = state.pendingQueue.length; const removed = state.pendingQueue.find((q) => q.clientId === clientId); @@ -2995,6 +3008,7 @@ export class AgentInputCoordinator { const trimmed = newText.trim(); if (!trimmed) return this.getProjection(sessionId); const state = this.getState(sessionId); + assertSharedTaskQueueMutation(getDeviceLinkInvokeContext()?.sharedTask, sessionId, 'input.edit', state.pendingQueue.find((item) => item.clientId === clientId)); if (state.steeringQueueClientIds.includes(clientId)) return this.getProjection(sessionId); state.pendingQueue = state.pendingQueue.map((entry) => { if (entry.clientId !== clientId) return entry; @@ -3059,6 +3073,7 @@ export class AgentInputCoordinator { if (index < 0) return { projection: this.getProjection(sessionId), updated: false }; const current = state.pendingQueue[index]; const updated = updateQueuedMessageContent(current, next); + assertSharedTaskQueueMutation(getDeviceLinkInvokeContext()?.sharedTask, sessionId, 'input.edit', current); const authorizationContentChanged = updated.text !== current.text || updated.persistedContent !== current.persistedContent || updated.files !== current.files @@ -3216,6 +3231,7 @@ export class AgentInputCoordinator { } setExpanded(sessionId: string, expanded: boolean): AgentInputProjection { + assertSharedTaskQueueMutation(getDeviceLinkInvokeContext()?.sharedTask, sessionId, 'input.send'); const state = this.getState(sessionId); state.queueExpanded = expanded; this.emit(sessionId); @@ -3279,6 +3295,7 @@ export class AgentInputCoordinator { setEditLock(sessionId: string, clientId: string, locked: boolean): AgentInputProjection { const state = this.getState(sessionId); + assertSharedTaskQueueMutation(getDeviceLinkInvokeContext()?.sharedTask, sessionId, 'input.edit', state.pendingQueue.find((item) => item.clientId === clientId)); state.queueEditLocks = toggleList(state.queueEditLocks, clientId, locked); this.emit(sessionId); if (!locked) { @@ -4472,6 +4489,7 @@ export class AgentInputCoordinator { ...(head.fromMobileClient ? { fromMobileClient: true } : {}), ...(head.fromDeviceLinkClient ? { fromDeviceLinkClient: true } : {}), persistUserMessage: { + ...(head.sharedTaskAuthor ? { sharedTaskAuthor: head.sharedTaskAuthor } : {}), clientId: head.clientId, content: head.persistedContent, agentFacingWireContent: makerUserMessage, @@ -6180,6 +6198,7 @@ export class AgentInputCoordinator { content: item.persistedContent, agentMeta: { uuid: active.messageUuid, + ...(item.sharedTaskAuthor ? { sharedTaskAuthor: item.sharedTaskAuthor } : {}), ...(item.autoReviewUserText !== undefined ? { autoReviewUserText: item.autoReviewUserText } : {}), sdkSessionId, delivery: active.delivery, diff --git a/apps/desktop/src/main/maker-ipc/contextOverflowRollover.ts b/apps/desktop/src/main/maker-ipc/contextOverflowRollover.ts index a535a1b3c7a..2a29b85337d 100644 --- a/apps/desktop/src/main/maker-ipc/contextOverflowRollover.ts +++ b/apps/desktop/src/main/maker-ipc/contextOverflowRollover.ts @@ -620,6 +620,7 @@ export function createContextOverflowRollover(deps: ContextOverflowRolloverDeps) return requiresRemoteRebuild ? 'remote-unsupported' : 'not-needed'; } if (!deps.rehydrateColdPiRuntimeForWindowVerification) return 'unknown-context'; + target.assertCanCommit?.(); try { await deps.rehydrateColdPiRuntimeForWindowVerification(sessionId); } catch (error) { diff --git a/apps/desktop/src/main/maker-ipc/makerSendTransaction.ts b/apps/desktop/src/main/maker-ipc/makerSendTransaction.ts index 4d0cf17cc51..f3801183310 100644 --- a/apps/desktop/src/main/maker-ipc/makerSendTransaction.ts +++ b/apps/desktop/src/main/maker-ipc/makerSendTransaction.ts @@ -267,6 +267,7 @@ type MakerSendOptions = { /** Coordinator-transmitted provenance for device-link input.enqueue. */ fromDeviceLinkClient?: boolean; persistUserMessage?: { + sharedTaskAuthor?: AgentInputQueuedMessage['sharedTaskAuthor']; clientId?: unknown; content?: unknown; agentFacingWireContent?: unknown; @@ -519,6 +520,7 @@ type ResolveSessionResult = | { kind: 'failure'; result: DesktopMakerSendResult }; function readPersistUserMessageOption(sendOpts: MakerSendOptions): { + sharedTaskAuthor?: AgentInputQueuedMessage['sharedTaskAuthor']; clientId: string; content: unknown; agentFacingWireContent?: IpcUserMessage; @@ -538,6 +540,7 @@ function readPersistUserMessageOption(sendOpts: MakerSendOptions): { const persist = sendOpts.persistUserMessage; if (!persist || typeof persist.clientId !== 'string') return null; return { + ...(persist.sharedTaskAuthor ? { sharedTaskAuthor: persist.sharedTaskAuthor } : {}), clientId: persist.clientId, content: persist.content, ...(persist.agentFacingWireContent && typeof persist.agentFacingWireContent === 'object' @@ -1520,6 +1523,7 @@ export function createMakerSendTransaction(deps: MakerSendTransactionDeps): Make role: 'user', content: persistUserMessage.content, agentMeta: { + ...(persistUserMessage.sharedTaskAuthor ? { sharedTaskAuthor: persistUserMessage.sharedTaskAuthor } : {}), uuid: so.messageUuid, ...(so.origin?.kind === 'scheduler' ? { autoReviewUserText: { kind: 'scheduled-continuation' } } diff --git a/apps/desktop/src/main/maker-ipc/mobileClientPromptNote.ts b/apps/desktop/src/main/maker-ipc/mobileClientPromptNote.ts index c3afa051e04..d464236b6b9 100644 --- a/apps/desktop/src/main/maker-ipc/mobileClientPromptNote.ts +++ b/apps/desktop/src/main/maker-ipc/mobileClientPromptNote.ts @@ -154,7 +154,12 @@ export function attachMainOwnedInputBoundary( */ export function stripMainOnlySendOpts(sendOpts: unknown): unknown { if (!sendOpts || typeof sendOpts !== 'object' || Array.isArray(sendOpts)) return sendOpts; - const opts = sendOpts as Record; + let opts = sendOpts as Record; + const persisted = opts.persistUserMessage; + if (persisted && typeof persisted === 'object' && !Array.isArray(persisted) && 'sharedTaskAuthor' in persisted) { + const { sharedTaskAuthor: _ignoredAuthor, ...content } = persisted as Record; + opts = { ...opts, persistUserMessage: content }; + } if ( !('fromMobileClient' in opts) && !('fromDeviceLinkClient' in opts) && @@ -167,7 +172,7 @@ export function stripMainOnlySendOpts(sendOpts: unknown): unknown { !('toolsDisabled' in opts) && !('origin' in opts) ) { - return sendOpts; + return opts; } const { fromMobileClient: _ignoredMobile, diff --git a/apps/desktop/src/main/maker-ipc/register.ts b/apps/desktop/src/main/maker-ipc/register.ts index 56713ecb468..0c3b3aa4e6a 100644 --- a/apps/desktop/src/main/maker-ipc/register.ts +++ b/apps/desktop/src/main/maker-ipc/register.ts @@ -1040,9 +1040,19 @@ import { } from '../device-link/dispatch.js'; import { deviceLinkInvokeControllerSupports, + getDeviceLinkInvokeContext, isDeviceLinkInvoke, isMobileControllerInvoke, } from '../device-link/invoke-context.js'; +import { stampSharedTaskInput } from './sharedTaskInput.js'; +import { createSharedTaskContextUsageGuard } from './sharedTaskContextUsage.js'; +import { createSharedTaskSettingGuard } from './sharedTaskSetting.js'; +import { setSharedTaskQueueReader } from '../device-link/sharedTaskDispatch.js'; + +function captureSharedTaskSettingGuard(sessionId: string) { + const context = getDeviceLinkInvokeContext(); + return createSharedTaskSettingGuard(context?.sharedTask, sessionId, context?.sharedTaskSetting ?? { admitted: false }); +} import { attachMainOwnedInputBoundary, buildMobileClientPromptNote, @@ -6560,11 +6570,12 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) return verdict.kind === 'reroute' ? verdict.providerId : undefined; } - async function bootstrapSession(o: CreateOpts): Promise<{ + async function bootstrapSession(o: CreateOpts, assertAccess?: () => void): Promise<{ session: Awaited>; didInjectOrcaInstructions: boolean; didInjectProjectContext: boolean; }> { + assertAccess?.(); if (o.id && o.workingDir && !o.remoteHostId) { o.workingDir = workingDirectoryRecovery.resolve(o.id, o.workingDir); await workingDirectoryRecovery.observe(o.id, o.workingDir).catch((error) => { @@ -6587,6 +6598,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) o.effort = runtimeOverride.effort ?? undefined; o.fastMode = runtimeOverride.fastMode; } + assertAccess?.(); await applyPersistedReviewMode(o); await applyPersistedCindyMakeMarker(o, readSessionSource); const didInjectOrcaInstructions = o.reviewMode === true ? false : applyOrcaInstructions(o); @@ -6595,6 +6607,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) const usingFallback = !!o.id && !!o.workingDir && !o.remoteHostId && workingDirectoryRecovery.isFallback(o.id, o.workingDir); + assertAccess?.(); await prepareDirectoryGrantsForBootstrap(o, { statDirectory: usingFallback ? statWorkingDirectory : undefined, realpathDirectory: usingFallback ? realpathWorkingDirectory : undefined, @@ -6606,11 +6619,13 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) .from(sessions) .where(eq(sessions.id, sessionId)) .limit(1); + assertAccess?.(); if (existing) await persistSessionFields(sessionId, patch); }, }); await hydrateProviderIdBeforeSessionStart(o); + assertAccess?.(); await ensureManagedOllamaReadyForSession({ providerId: o.providerId, remoteHostId: o.remoteHostId ?? null, @@ -6650,6 +6665,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) if (pin) o.providerId = pin; } } + assertAccess?.(); const session = await maker.createSession(o); await markProjectContextIfNeeded(session.id, didInjectProjectContext); wireSessionToIpc(session); @@ -12952,12 +12968,23 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) if (typeof sessionId !== 'string' || sessionId.length === 0) { throwIpcError('INVALID_PARAMS', 'sessionId required'); } + const sharedTaskAccess = createSharedTaskContextUsageGuard( + getDeviceLinkInvokeContext()?.sharedTask, sessionId, + ); + sharedTaskAccess.assertCurrent(); let sess = maker.getSession(sessionId); if (!sess) { - if (!createOpts) { + const trustedCreateOpts = await sharedTaskAccess.resolveCreateOpts( + createOpts, async () => ({ + ...await readSharedTaskTaskCreateOpts(sessionId), + extraDirs: extraDirsForRuntime(await readSessionExtraDirsFromDb(sessionId)), + writableDirs: await readSessionWritableDirsFromDb(sessionId), + }), + ); + if (!trustedCreateOpts) { throwIpcError('NOT_FOUND', `Session ${sessionId} is not running`); } - const co = buildCreateOptsWithStderr({ ...(createOpts as CreateOpts), id: sessionId }); + const co = buildCreateOptsWithStderr({ ...(trustedCreateOpts as CreateOpts), id: sessionId }); // session-agent-switch:先按 DB 行校正再判 claude-only——否则切到 codex 后 // 残留的 claude createOpts 会在这里 spawn 出旧引擎的 live session 并被后续 // send 复用(会话被劫持回旧引擎,2026-07-20 审计实锤)。 @@ -12994,12 +13021,13 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) if (row.length > 0) co.writableDirs = row; } try { + sharedTaskAccess.assertCurrent(); await ensureRemoteReadyForSessionStart({ createOpts: co }); const { session: lazySess, didInjectOrcaInstructions, didInjectProjectContext, - } = await bootstrapSession(co); + } = await bootstrapSession(co, sharedTaskAccess.assertCurrent); await markOrcaRoleIfNeeded(lazySess.id, co.orcaRole); log.info('context-usage: lazy create-session', { sessionId, @@ -13990,6 +14018,10 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) getPersistedClientIds: getPersistedInputClientIds, }); agentInputCoordinatorHolder = inputCoordinator; + setSharedTaskQueueReader((sessionId, clientId) => { + const item = inputCoordinator.getProjection(sessionId).pendingQueue.find((pending) => pending.clientId === clientId); + return item ? { sessionId, authorAccountId: item.sharedTaskAuthor?.accountId ?? '', state: 'pending', attachments: item.files } : undefined; + }); getAgentIslandService()?.setCompletionDeferResolver((sessionId) => inputCoordinator.hasPendingQueuedWork(sessionId), ); @@ -14412,6 +14444,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) throwIpcError('INVALID_PARAMS', 'queued.createOpts.agentKind invalid'); } const normalized: AgentInputQueuedMessage = { ...msg }; + delete normalized.sharedTaskAuthor; delete normalized.autoReviewUserText; // Only Main-created welcomes and restored host snapshots may carry this policy. delete normalized.toolsDisabled; @@ -14443,6 +14476,24 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) ipcMain.handle(DL_SESSION_REFERENCE_CAPABILITY_CHANNEL, () => ({ supported: true, version: 1 })); + async function readSharedTaskTaskCreateOpts(sid: string): Promise { + const [row] = await getDbClient().drizzle.select().from(sessions).where(eq(sessions.id, sid)).limit(1); + if (!row || row.status !== 'active' || !row.workingDir) throwIpcError('NOT_FOUND', 'SharedTask task unavailable'); + return { + agentKind: dbToMakerAgentKind(row.agentKind), workingDir: row.workingDir, + model: row.model, providerId: row.providerId, effort: row.effort, + permissionMode: row.permissionMode, fastMode: row.fastMode, + planMode: row.planModeEnabled, remoteHostId: row.remoteHostId ?? undefined, + resumeSessionId: row.sdkSessionId ?? undefined, orcaRole: row.orcaRole, + }; + } + + const prepareSharedTaskInput = async (sid: string, item: AgentInputQueuedMessage) => { + const sharedTask = getDeviceLinkInvokeContext()?.sharedTask; + if (!sharedTask) return item; + return stampSharedTaskInput(item, sharedTask, await readSharedTaskTaskCreateOpts(sid)); + }; + /** * device-link 远控输入的自动起名(入队 / 插话共用)。 * @@ -14738,7 +14789,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) await assertReviewExternalInputAllowed(sid); const deviceLinkInvoke = isDeviceLinkInvoke(); if (!deviceLinkInvoke) assertTrustedAppRendererEvent(event); - const parsed = requireQueuedMessage(item); + const parsed = await prepareSharedTaskInput(sid, requireQueuedMessage(item)); assertRemoteInputClearNotInFlight(sid, deviceLinkInvoke); const clearBoundaryPrecondition = readRemoteInputClearBoundaryPrecondition(opts); if (!deviceLinkInvoke) await observeLocalInputClearBoundary(sid); @@ -14905,12 +14956,12 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) touchUserSend?: boolean; } & AgentInputClearBoundaryOpts) : undefined; - const parsed = requireQueuedMessage(item, { + const parsed = await prepareSharedTaskInput(sid, requireQueuedMessage(item, { // A device-link projection intentionally omits the trusted snapshot; // Only the explicit remove-from-queue steer path may reattach it from // the main-owned row; all other IPC paths remain fail-closed here. allowMissingTrustedContexts: deviceLinkInvoke && steerOpts?.removeFromQueue === true, - }); + })); assertRemoteInputClearNotInFlight(sid, deviceLinkInvoke); const clearBoundaryPrecondition = readRemoteInputClearBoundaryPrecondition(opts); if (!deviceLinkInvoke) await observeLocalInputClearBoundary(sid); @@ -15678,7 +15729,9 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) let atomicSelection = selection as { effort: SessionRuntimeProfile['effort']; fastMode: boolean } | undefined; const runtimeOwnerEpoch = captureSessionRuntimeControlOwnerEpoch(); + const assertSharedTaskCurrent = captureSharedTaskSettingGuard(sessionId); const assertRuntimeOwnerCurrent = (): void => { + assertSharedTaskCurrent(); internalOptions.assertSelectionCurrent?.(); if (!sessionRuntimeControlOwnerEpochMatches(runtimeOwnerEpoch)) { throwIpcError( @@ -15688,6 +15741,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) } }; const supersededByOwnerBoundary = (): boolean => { + assertSharedTaskCurrent(); if (sessionRuntimeControlOwnerEpochMatches(runtimeOwnerEpoch)) return false; if (internalOptions.source === 'user') { assertRuntimeOwnerCurrent(); @@ -15894,6 +15948,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) if (internalOptions.source === 'user' && !internalOptions.applyingUserSelectionOnSend && !runtimeStatus.remoteHostId && !runtimeStatus.orcaRole) { assertRuntimeOwnerCurrent(); + assertSharedTaskCurrent.admit(); clearPendingCredentialSwitchForSession(sessionId, { wake: false }); const intent = { ...(internalOptions.runtimeSource ? { runtimeSource: internalOptions.runtimeSource } : {}), @@ -15929,6 +15984,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) if (supersededByOwnerBoundary()) { return { deferred: false, superseded: true }; } + assertSharedTaskCurrent.admit(); const generation = routeExplicit ? acceptSessionRuntimeMutation({ sessionId, @@ -16058,6 +16114,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) ); } if (!liveSessionBeforeRouteChange) { + assertRuntimeOwnerCurrent(); + assertSharedTaskCurrent.admit(); try { await rehydrateColdPiRuntimeForWindowVerification(sessionId); } catch { @@ -16234,7 +16292,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) onConfirmationRequired: (contextTokens) => { confirmationContextTokens = contextTokens; }, - assertCanCommit: assertRuntimeOwnerCurrent, + assertCanCommit: () => { assertRuntimeOwnerCurrent(); assertSharedTaskCurrent.admit(); }, beforeClose: () => { clearPendingCredentialSwitchForSession(sessionId, { wake: false }); pendingClearedForWindowRebuild = true; @@ -16411,6 +16469,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) const result = routeExplicit ? await applyRuntimeSetModelChange({ maker, + admit: () => { assertRuntimeOwnerCurrent(); assertSharedTaskCurrent.admit(); }, sessionId, model, providerId: effectiveProviderId, @@ -16562,6 +16621,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) } if (atomicSelection) { const selectionToCommit = atomicSelection; + assertRuntimeOwnerCurrent(); + assertSharedTaskCurrent.admit(); // model/provider/effort/fast 是一次选择快照,必须在同一把 session 锁内收敛。 // applyRuntimeSetModelChange 可能 close + wake;若 effort/fast 留给 renderer // 后续独立调用,queue drain 会用新 model + 旧偏好重建,跨控制端时还会发生 @@ -16625,6 +16686,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) } const routeProjectionOwnerScope = captureDataOwnerBroadcastScope(); try { + assertRuntimeOwnerCurrent(); + assertSharedTaskCurrent.admit(); await persistSessionFields(sessionId, patch); } catch (persistenceError) { // The live route and host stores are applied before SQLite so the @@ -16891,7 +16954,9 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) throwIpcError('INVALID_PARAMS', 'sessionId + effort required'); } const runtimeOwnerEpoch = captureSessionRuntimeControlOwnerEpoch(); + const assertSharedTaskCurrent = captureSharedTaskSettingGuard(sessionId); const assertOwnerCurrent = () => { + assertSharedTaskCurrent(); if (!sessionRuntimeControlOwnerEpochMatches(runtimeOwnerEpoch)) { throwIpcError( 'PRECONDITION_FAILED', @@ -16903,6 +16968,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) const remoteResponse = remoteInvoke ? {} : undefined; const persistEffort = async () => { if (!remoteResponse) return; + assertOwnerCurrent(); + assertSharedTaskCurrent.admit(); await persistSessionFields(sessionId, { effort }); markRemoteSettingPersistedInsideHandler(remoteResponse); }; @@ -16925,6 +16992,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) const userIntent = agentSwitchPending.get(sessionId); if (userIntent?.sameAgentSelection) { assertOwnerCurrent(); + assertSharedTaskCurrent.admit(); const result = await agentSwitchDeps.selectSameAgentModel!(sessionId, { ...userIntent, effort: effort }, false); if (remoteResponse) markRemoteSettingPersistedInsideHandler(remoteResponse); @@ -16969,10 +17037,13 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) ? (await readSessionRuntimeProfiles(sessionId))?.effective : undefined; const result = await applyRuntimeEffortWithRecovery({ - applyRuntime: () => - sess.setEffort( + applyRuntime: () => { + assertOwnerCurrent(); + assertSharedTaskCurrent.admit(); + return sess.setEffort( effort as 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra', - ), + ); + }, terminateSession: () => maker.closeSession(sessionId), }); if (result === 'session-terminated') { @@ -17328,7 +17399,9 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) throwIpcError('INVALID_PARAMS', 'sessionId + enabled required'); } const runtimeOwnerEpoch = captureSessionRuntimeControlOwnerEpoch(); + const assertSharedTaskCurrent = captureSharedTaskSettingGuard(sessionId); const assertOwnerCurrent = () => { + assertSharedTaskCurrent(); if (!sessionRuntimeControlOwnerEpochMatches(runtimeOwnerEpoch)) { throwIpcError( 'PRECONDITION_FAILED', @@ -17340,6 +17413,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) const remoteResponse = remoteInvoke ? {} : undefined; const persistFastMode = async () => { if (!remoteResponse) return; + assertOwnerCurrent(); + assertSharedTaskCurrent.admit(); await persistSessionFields(sessionId, { fastMode: enabled }); markRemoteSettingPersistedInsideHandler(remoteResponse); }; @@ -17362,6 +17437,7 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) const userIntent = agentSwitchPending.get(sessionId); if (userIntent?.sameAgentSelection) { assertOwnerCurrent(); + assertSharedTaskCurrent.admit(); const result = await agentSwitchDeps.selectSameAgentModel!(sessionId, { ...userIntent, fastMode: enabled }, false); if (remoteResponse) markRemoteSettingPersistedInsideHandler(remoteResponse); @@ -17422,6 +17498,8 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) const previousProfile = remoteInvoke ? (await readSessionRuntimeProfiles(sessionId))?.effective : undefined; + assertOwnerCurrent(); + assertSharedTaskCurrent.admit(); await sess.setFastMode(enabled); await commitRuntimeAxisAfterPersistence({ persist: persistFastMode, @@ -17467,9 +17545,12 @@ export function registerMakerIpc(maker: Maker, options: RegisterMakerIpcOptions) if (typeof sessionId !== 'string' || typeof enabled !== 'boolean') { throwIpcError('INVALID_PARAMS', 'sessionId + enabled required'); } + const assertSharedTaskCurrent = captureSharedTaskSettingGuard(sessionId); await assertReviewSettingsUnlocked(sessionId); + assertSharedTaskCurrent(); const sess = maker.getSession(sessionId); if (!sess) return; + assertSharedTaskCurrent.admit(); await sess.setThinkingEnabled(enabled); }, ); diff --git a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts index 279d756e605..30d6a3a05c5 100644 --- a/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts +++ b/apps/desktop/src/main/maker-ipc/runtimeSetModel.ts @@ -65,6 +65,8 @@ export interface ApplyRuntimeSetModelChangeInput { forceSessionRebuild?: boolean; /** Fail closed before an otherwise-required runtime replacement mutates route state. */ assertSessionCloseSupported?: () => void; + /** Called after async preflight, immediately before the first setting side effect. */ + admit?: () => void; isSessionInTurn?: (sessionId: string) => boolean; /** * 会话自己正在跑 turn 时的延迟生效登记(PendingCredentialSwitchService.register)。 @@ -225,6 +227,7 @@ export async function applyRuntimeSetModelChange( } if (!sess && requiresCodexThreadRelink) { + input.admit?.(); await input.relinkCodexThread?.(); if (providerId !== undefined) setSessionProvider(sessionId, nextProviderId); input.wakeSessionInputQueue?.(sessionId); @@ -244,6 +247,7 @@ export async function applyRuntimeSetModelChange( // 把 route/model 的生效边界固定在 turn 结束;pending 收口只关闭本 Session, // 当前账号保持到回合边界,不能让工具续轮命中新账号。 if (input.registerPendingCredentialSwitch) { + input.admit?.(); await input.registerPendingCredentialSwitch(sessionId, { model, providerId: nextProviderId, @@ -265,6 +269,7 @@ export async function applyRuntimeSetModelChange( if (sess && (shouldCloseSession || requiresCodexThreadRelink)) { input.assertSessionCloseSupported?.(); + input.admit?.(); if (isSelfBusy() && input.registerPendingCredentialSwitch) { // A required credential rebuild must also survive close failure: keeping // the old process alive cannot be treated as applying the new account. @@ -369,6 +374,7 @@ export async function applyRuntimeSetModelChange( : { status: 'applied' }; } + input.admit?.(); if (providerId !== undefined) { setSessionProvider(sessionId, nextProviderId); // 显式选源且无需切换 → 取消尚未兑现的 pending(后选覆盖先选)。 diff --git a/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts b/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts index 0f6dfa23d69..6cf2190d759 100644 --- a/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts +++ b/apps/desktop/src/main/maker-ipc/sessionAgentSwitchHandler.ts @@ -27,6 +27,8 @@ */ import type { AgentKind } from '@cindy/maker-core'; +import { getDeviceLinkInvokeContext } from '../device-link/invoke-context.js'; +import { createSharedTaskSettingGuard } from './sharedTaskSetting.js'; import { MAKER_INVOKE } from './channels.js'; import type { IpcHandlerRegistry } from './ipcHandlerRegistry.js'; @@ -869,6 +871,8 @@ export function registerMakerSessionAgentSwitchHandler( effort: unknown, fastMode: unknown, ) => { + const context = getDeviceLinkInvokeContext(); + const guard = createSharedTaskSettingGuard(context?.sharedTask, String(sessionId), context?.sharedTaskSetting ?? { admitted: false }); const run = () => performSessionAgentSwitch(deps, { sessionId, targetAgentKind, @@ -876,6 +880,7 @@ export function registerMakerSessionAgentSwitchHandler( providerId, effort, fastMode, + assertSelectionCurrent: guard.admit, }); return typeof sessionId === 'string' && sessionId && deps.withSessionLock ? deps.withSessionLock(sessionId, run) diff --git a/apps/desktop/src/main/maker-ipc/sharedTaskContextUsage.ts b/apps/desktop/src/main/maker-ipc/sharedTaskContextUsage.ts new file mode 100644 index 00000000000..d07254e170c --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/sharedTaskContextUsage.ts @@ -0,0 +1,26 @@ +import type { SharedTaskPeerCapture } from '../device-link/sharedTaskDispatch.js'; +import { throwIpcError } from '../utils/ipcValidate.js'; + +/** A context query can lazily start an Agent; its configuration is host-owned. */ +export function createSharedTaskContextUsageGuard( + sharedTask: SharedTaskPeerCapture | undefined, + sessionId: string, +) { + const assertCurrent = () => { + if (sharedTask && (sharedTask.author.sessionId !== sessionId || !sharedTask.isCurrent() || + !sharedTask.authorize('history.read'))) { + throwIpcError('PERMISSION_DENIED', 'SharedTask task access denied'); + } + }; + return { + assertCurrent, + async resolveCreateOpts(wireOptions: unknown, readHostOptions: () => Promise): Promise { + assertCurrent(); + if (!sharedTask) return wireOptions; + // Do not inspect or spread even one field of a guest's bootstrap options. + const hostOptions = await readHostOptions(); + assertCurrent(); + return hostOptions; + }, + }; +} diff --git a/apps/desktop/src/main/maker-ipc/sharedTaskInput.ts b/apps/desktop/src/main/maker-ipc/sharedTaskInput.ts new file mode 100644 index 00000000000..fcbd35de175 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/sharedTaskInput.ts @@ -0,0 +1,51 @@ +import type { AgentInputCreateOpts, AgentInputQueuedMessage } from '../../shared/agentInputQueue.js'; +import type { SharedTaskPeerCapture } from '../device-link/sharedTaskDispatch.js'; +import { assertSharedTaskReferences, sharedTaskOwnedQueueReferences } from '../device-link/sharedTaskDispatch.js'; + +/** Build privileged input configuration from host truth, not guest queue snapshots. */ +export function stampSharedTaskInput( + item: AgentInputQueuedMessage, capture: SharedTaskPeerCapture | undefined, + task: AgentInputCreateOpts | undefined, +): AgentInputQueuedMessage { + const stamped = { ...item }; + delete stamped.sharedTaskAuthor; + if (!capture) return stamped; + if (!task || !capture.isCurrent() || !capture.authorize('input.send')) { + throw new Error('[PERMISSION_DENIED] SharedTask task access denied'); + } + assertSharedTaskReferences(item, capture.author.sessionId, 0, capture.author.sharedTaskId, sharedTaskOwnedQueueReferences(capture, item.clientId)); + // Only content comes from the guest. The task owns runtime/bootstrap settings. + stamped.createOpts = { ...task }; + stamped.workingDir = task.workingDir; + stamped.permissionMode = task.permissionMode ?? 'ask'; + stamped.model = task.model; + stamped.effort = task.effort ?? ''; + delete stamped.vendorOptions; + delete stamped.origin; + delete stamped.autoResume; + delete stamped.autoResumeInfo; + delete stamped.recoveryCheckpoint; + delete stamped.bypassGhostHooks; + delete stamped.hostAcceptedAtMs; + stamped.sharedTaskAuthor = { ...capture.author }; + stamped.userName = capture.author.displayName; + return stamped; +} + +/** Run inside the synchronous queue mutation, after all asynchronous preparation. */ +export function assertSharedTaskQueueMutation( + capture: SharedTaskPeerCapture | undefined, sessionId: string, + operation: 'input.send' | 'input.edit' | 'input.withdraw' | 'agent.stop', + item?: AgentInputQueuedMessage, +): void { + if (!capture) return; + const author = item?.sharedTaskAuthor; + if (capture.author.sessionId !== sessionId || !capture.isCurrent() || + (operation === 'input.edit' || operation === 'input.withdraw') && + (!author || author.sharedTaskId !== capture.author.sharedTaskId || author.memberId !== capture.author.memberId) || + !capture.authorize(operation, item ? { + sessionId, authorAccountId: author?.accountId ?? '', state: 'pending', + } : undefined)) { + throw new Error('[PERMISSION_DENIED] SharedTask task access denied'); + } +} diff --git a/apps/desktop/src/main/maker-ipc/sharedTaskSetting.ts b/apps/desktop/src/main/maker-ipc/sharedTaskSetting.ts new file mode 100644 index 00000000000..1ff86670c45 --- /dev/null +++ b/apps/desktop/src/main/maker-ipc/sharedTaskSetting.ts @@ -0,0 +1,19 @@ +import type { SharedTaskPeerCapture } from '../device-link/sharedTaskDispatch.js'; +import { throwIpcError } from '../utils/ipcValidate.js'; + +/** One native setting transaction. Revocation fences admission, not its rollback. */ +export function createSharedTaskSettingGuard( + sharedTask: SharedTaskPeerCapture | undefined, + sessionId: string, + transaction: { admitted: boolean }, +) { + const assertCurrent = () => { + if (!transaction.admitted && sharedTask && (sharedTask.author.sessionId !== sessionId || + !sharedTask.isCurrent() || !sharedTask.authorize('agent.configure'))) { + throwIpcError('PERMISSION_DENIED', 'SharedTask task access denied'); + } + }; + return Object.assign(assertCurrent, { + admit() { assertCurrent(); transaction.admitted = true; }, + }); +} diff --git a/apps/desktop/src/main/serverApiClient.ts b/apps/desktop/src/main/serverApiClient.ts index 059234abb5e..084b1665c3c 100644 --- a/apps/desktop/src/main/serverApiClient.ts +++ b/apps/desktop/src/main/serverApiClient.ts @@ -45,6 +45,8 @@ export interface ApiFetchOptions { token?: string | null; /** 跳过 401 自动 refresh(避免无限循环;refresh 自身调用时禁用)。 */ skipAutoRefresh?: boolean; + /** Fixed-identity teardown requests must not invalidate a newer active account. */ + skipSessionInvalidation?: boolean; /** * 目标服务 base URL(必传;来自 clientEndpoints 的对应字段或注入方)。 * 区域相关服务必须传 resolver:401 refresh 可能切换登录区域,重试前要重新 @@ -150,7 +152,7 @@ export async function serverApiFetch(apiPath: string, opts: ApiFetchOptions): const errCode = readErrorCode(result.data) ?? statusToCode(result.status); const errMsg = readErrorMessage(result.data) ?? `请求失败 (${result.status})`; if ( - result.status === 401 && + !opts.skipSessionInvalidation && result.status === 401 && (errCode === 'ACCOUNT_UNAVAILABLE' || (refreshedAndRetried && isRefreshableUnauthorizedCode(errCode))) ) { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 7cddae746a8..47914955593 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -4381,6 +4381,12 @@ contextBridge.exposeInMainWorld('electronAPI', { windowsSupport: (enabled) => ipcRenderer.invoke(DESKTOP_LOCAL.WINDOWS_SUPPORT, enabled), stop: () => ipcRenderer.invoke(DESKTOP_LOCAL.STOP), } satisfies RemoteDesktopApi, + sharedTask: { + host: (command: import('@cindy/device-link').SharedTaskHostCommand): Promise => + ipcRenderer.invoke('maker:shared-task', command), + account: (command: import('@cindy/device-link').SharedTaskAccountCommand): Promise => + ipcRenderer.invoke('shared-task:account', command), + }, deviceLink: { getState: (): Promise<{ remoteControlEnabled: boolean; diff --git a/apps/desktop/src/renderer/__tests__/cindyMakeInlineEntry.test.ts b/apps/desktop/src/renderer/__tests__/cindyMakeInlineEntry.test.ts index 0c80d4b6051..d1a07f7d755 100644 --- a/apps/desktop/src/renderer/__tests__/cindyMakeInlineEntry.test.ts +++ b/apps/desktop/src/renderer/__tests__/cindyMakeInlineEntry.test.ts @@ -1,6 +1,8 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; +import ts from 'typescript'; +import { isSharedTaskPeer } from '@cindy/device-link'; const composer = readFileSync(resolve(__dirname, '../components/new-chat/ChatInput.tsx'), 'utf8'); const sessionView = readFileSync( @@ -23,14 +25,31 @@ describe('Cindy Make composer presentation', () => { expect(promptHost).toBeGreaterThan(-1); expect(mask).toBeGreaterThan(promptHost); expect(input).toBeGreaterThan(mask); - const interactionGuard = sessionView.slice( - sessionView.indexOf(''), - mask, - ); - expect(interactionGuard).toMatch( - /pendingPlanReview ||[\s\S]*pendingPermission ||[\s\S]*pendingAskUser/, - ); - expect(interactionGuard).toContain('pendingGhostGrantConfirm ? null'); + const promptEnd = sessionView.indexOf(''); + // Parse the real guard so grouping/formatting does not change the contract. + const ast = ts.createSourceFile('session.tsx', sessionView, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + let condition: ts.Expression | undefined; + const visit = (node: ts.Node) => { + if (ts.isConditionalExpression(node) && node.getStart(ast) > promptEnd && node.getStart(ast) < mask + && node.whenTrue.kind === ts.SyntaxKind.NullKeyword + && node.condition.getText(ast).includes('pendingGhostGrantConfirm')) { + condition = node.condition; + } + ts.forEachChild(node, visit); + }; + visit(ast); + expect(condition).toBeDefined(); + const prompts = ['pendingPlanReview', 'pendingPermission', 'pendingAskUser', 'pendingPluginSetup', + 'pendingIssueConfirm', 'pendingRenameSessionsConfirm', 'pendingGhostGrantConfirm']; + const hidesComposer = new Function('isSharedTaskPeer', 'remoteDeviceId', ...prompts, + `return Boolean(${condition!.getText(ast)});`); + for (const deviceId of [undefined, 'own-device', 'shared-task~m~host']) { + expect(hidesComposer(isSharedTaskPeer, deviceId, ...prompts.map(() => false))).toBe(false); + for (const active of prompts) { + expect(hidesComposer(isSharedTaskPeer, deviceId, ...prompts.map((name) => name === active)), + `${deviceId ?? 'local'}: ${active}`).toBe(deviceId !== 'shared-task~m~host'); + } + } expect(sessionView).toContain('if (cindyMakeInputLocked) return false;'); expect(sessionView).not.toContain('CindyMakeResumeCard'); const recovery = sessionView.indexOf(') : cindyMakeRecoveryId && session ? ('); diff --git a/apps/desktop/src/renderer/__tests__/deviceLinkInteractionScenarios.test.ts b/apps/desktop/src/renderer/__tests__/deviceLinkInteractionScenarios.test.ts index 7f18fb67431..ebca9260980 100644 --- a/apps/desktop/src/renderer/__tests__/deviceLinkInteractionScenarios.test.ts +++ b/apps/desktop/src/renderer/__tests__/deviceLinkInteractionScenarios.test.ts @@ -1452,6 +1452,6 @@ describe('远程交互接线不变式', () => { const start = src.indexOf('function handleSubscriptionFrame'); expect(start).toBeGreaterThan(-1); - expect(src.slice(start, start + 900)).toContain('o.topics.filter(isRemoteSubscriptionTopic)'); + expect(src.slice(start, src.indexOf("const name = resolveControllerName", start))).toContain('o.topics.filter(isRemoteSubscriptionTopic)'); }); }); diff --git a/apps/desktop/src/renderer/__tests__/machineSwitcher.test.ts b/apps/desktop/src/renderer/__tests__/machineSwitcher.test.ts index f2479d8834c..f4a1159ecf0 100644 --- a/apps/desktop/src/renderer/__tests__/machineSwitcher.test.ts +++ b/apps/desktop/src/renderer/__tests__/machineSwitcher.test.ts @@ -51,6 +51,15 @@ function mkSession(id: string, deviceLinkDeviceId?: string): Session { return { id, status: 'active', deviceLinkDeviceId } as unknown as Session; } +it('never lists shared-task peers as devices, including disconnected and rejected peers', () => { + const peer = 'shared-task~share-1~host'; + expect(buildSwitcherDevices({ + fullList: [mkDevice('real-pc'), mkDevice(peer)], + syncedDevices: [{ deviceId: peer, deviceName: 'Shared task', sessionCount: 1, connected: false }], + revoked: new Set([peer]), + }).map(device => device.deviceId)).toEqual(['real-pc']); +}); + describe('selectVisibleSessions', () => { afterEach(() => remoteProjectsStore.clear()); const local = [mkSession('l1'), mkSession('l2')]; diff --git a/apps/desktop/src/renderer/__tests__/remoteDataOwnerPushFence.test.ts b/apps/desktop/src/renderer/__tests__/remoteDataOwnerPushFence.test.ts new file mode 100644 index 00000000000..366ac3912b9 --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/remoteDataOwnerPushFence.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { sharedTaskHostPeer } from '@cindy/device-link'; +import { setDataOwnerGeneration } from '../contexts/dataOwnerGeneration'; +import { bindSharedTaskPushOwner, isDeviceLinkRemotePushCurrent, isRemoteDataOwnerPushCurrent, resetRemoteDataOwnerPushFence } from '../lib/remoteDataOwnerPushFence'; + +const peer = sharedTaskHostPeer('shared-task'); +const hostStamp = { dataOwnerId: 'host-account', ownerGeneration: 4 }; +const localStamp = { dataOwnerId: 'guest-account', ownerGeneration: 2 }; + +beforeEach(() => { + resetRemoteDataOwnerPushFence(); + setDataOwnerGeneration('guest-account', 2); +}); + +describe('shared task push owner', () => { + it('accepts a restarted host only after Main advances its verified source epoch', () => { + bindSharedTaskPushOwner(peer, 'host-account'); + const push = (generation: number, sourceEpoch: number) => ({ + deviceId: peer, ownerStamp: { ...hostStamp, ownerGeneration: generation }, sourceEpoch, + }); + expect(isDeviceLinkRemotePushCurrent(push(7, 10), localStamp)).toBe(true); + expect(isDeviceLinkRemotePushCurrent(push(1, 10), localStamp)).toBe(false); + bindSharedTaskPushOwner(peer, 'host-account'); + expect(isDeviceLinkRemotePushCurrent(push(1, 10), localStamp)).toBe(false); + expect(isDeviceLinkRemotePushCurrent(push(1, 11), localStamp)).toBe(true); + expect(isDeviceLinkRemotePushCurrent(push(8, 10), localStamp)).toBe(false); + expect(isDeviceLinkRemotePushCurrent(push(0, 11), localStamp)).toBe(false); + expect(isDeviceLinkRemotePushCurrent({ deviceId: peer, ownerStamp: hostStamp }, localStamp)).toBe(false); + expect(isDeviceLinkRemotePushCurrent(push(2, 11), localStamp)).toBe(true); + }); + + it('never resets an unrelated peer or accepts an epoch from a stale account', () => { + const other = sharedTaskHostPeer('other-task'); + bindSharedTaskPushOwner(peer, 'host-account'); + bindSharedTaskPushOwner(other, 'host-account'); + const push = { deviceId: peer, ownerStamp: hostStamp, sourceEpoch: 1 }; + expect(isDeviceLinkRemotePushCurrent(push, localStamp)).toBe(true); + expect(isDeviceLinkRemotePushCurrent({ ...push, deviceId: other }, localStamp)).toBe(true); + const next = { ...push, sourceEpoch: 2, ownerStamp: { ...hostStamp, ownerGeneration: 1 } }; + expect(isDeviceLinkRemotePushCurrent(next, { ...localStamp, ownerGeneration: 1 })).toBe(false); + expect(isDeviceLinkRemotePushCurrent({ ...next, ownerStamp: { ...hostStamp, dataOwnerId: 'wrong' } }, localStamp)).toBe(false); + expect(isDeviceLinkRemotePushCurrent({ ...next, sourceEpoch: 1 }, localStamp)).toBe(false); + expect(isDeviceLinkRemotePushCurrent(next, localStamp)).toBe(true); + expect(isDeviceLinkRemotePushCurrent({ ...next, deviceId: other, sourceEpoch: 1 }, localStamp)).toBe(false); + expect(isRemoteDataOwnerPushCurrent('ordinary', localStamp, true, 1)).toBe(true); + expect(isRemoteDataOwnerPushCurrent('ordinary', { ...localStamp, ownerGeneration: 1 }, true, 2)).toBe(false); + }); + + it('accepts the authenticated host account with independent source and local generations', () => { + bindSharedTaskPushOwner(peer, 'host-account'); + expect(isDeviceLinkRemotePushCurrent({ deviceId: peer, ownerStamp: hostStamp }, localStamp)).toBe(true); + expect(isDeviceLinkRemotePushCurrent({ deviceId: peer, ownerStamp: hostStamp }, { ...localStamp, ownerGeneration: 1 })).toBe(false); + expect(isRemoteDataOwnerPushCurrent(peer, { ...hostStamp, ownerGeneration: 3 })).toBe(false); + expect(isRemoteDataOwnerPushCurrent(peer, { ...hostStamp, dataOwnerId: 'guest-account' })).toBe(false); + }); + + it('requires an authenticated binding and a valid stamp for shared hosts', () => { + expect(isRemoteDataOwnerPushCurrent(peer, hostStamp)).toBe(false); + bindSharedTaskPushOwner(peer, 'host-account'); + expect(isRemoteDataOwnerPushCurrent(peer, undefined)).toBe(false); + expect(isRemoteDataOwnerPushCurrent(peer, null)).toBe(false); + expect(isRemoteDataOwnerPushCurrent(peer, {})).toBe(false); + expect(isRemoteDataOwnerPushCurrent('shared-task~shared-task~guest~member~device', hostStamp)).toBe(false); + }); + + it('invalidates bindings when the local account or generation changes', () => { + bindSharedTaskPushOwner(peer, 'host-account'); + setDataOwnerGeneration('guest-account', 3); + expect(isRemoteDataOwnerPushCurrent(peer, hostStamp)).toBe(false); + bindSharedTaskPushOwner(peer, 'host-account'); + expect(isRemoteDataOwnerPushCurrent(peer, hostStamp)).toBe(true); + setDataOwnerGeneration('another-account', 4); + expect(isRemoteDataOwnerPushCurrent(peer, hostStamp)).toBe(false); + setDataOwnerGeneration(null, 5); + bindSharedTaskPushOwner(peer, 'host-account'); + expect(isRemoteDataOwnerPushCurrent(peer, hostStamp)).toBe(false); + }); + + it('does not replace a current binding or roll back source generation during polling', () => { + bindSharedTaskPushOwner(peer, 'host-account'); + expect(isRemoteDataOwnerPushCurrent(peer, hostStamp)).toBe(true); + bindSharedTaskPushOwner(peer, 'other-host'); + expect(isRemoteDataOwnerPushCurrent(peer, { ...hostStamp, dataOwnerId: 'other-host' })).toBe(false); + bindSharedTaskPushOwner(peer, 'host-account'); + expect(isRemoteDataOwnerPushCurrent(peer, { ...hostStamp, ownerGeneration: 3 })).toBe(false); + }); + + it('rejects delayed frames after removal, without affecting another shared task', () => { + const other = sharedTaskHostPeer('other-task'); + bindSharedTaskPushOwner(peer, 'host-account'); + bindSharedTaskPushOwner(other, 'host-account'); + resetRemoteDataOwnerPushFence(peer); + expect(isRemoteDataOwnerPushCurrent(peer, hostStamp)).toBe(false); + expect(isRemoteDataOwnerPushCurrent(other, hostStamp)).toBe(true); + resetRemoteDataOwnerPushFence(); + expect(isRemoteDataOwnerPushCurrent(other, hostStamp)).toBe(false); + }); + + it('retains same-account remote control and legacy unstamped compatibility', () => { + expect(isRemoteDataOwnerPushCurrent('ordinary-device', undefined)).toBe(true); + expect(isRemoteDataOwnerPushCurrent('ordinary-device', hostStamp)).toBe(false); + expect(isRemoteDataOwnerPushCurrent('ordinary-device', { ...localStamp, ownerGeneration: 9 })).toBe(true); + expect(isRemoteDataOwnerPushCurrent('ordinary-device', { ...localStamp, ownerGeneration: 8 })).toBe(false); + expect(isRemoteDataOwnerPushCurrent('ordinary-device', undefined)).toBe(false); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/remoteSessionSyncInvariants.test.ts b/apps/desktop/src/renderer/__tests__/remoteSessionSyncInvariants.test.ts index b91c49dd6f9..1a4feef34fe 100644 --- a/apps/desktop/src/renderer/__tests__/remoteSessionSyncInvariants.test.ts +++ b/apps/desktop/src/renderer/__tests__/remoteSessionSyncInvariants.test.ts @@ -130,7 +130,7 @@ describe('CCAgentSessionView 接线不变式', () => { expect(sidebarUpperSrc).toContain('isRemoteSessionWriteBlocked(session)'); expect(sidebarUpperSrc).toContain('selectedSessions.some(isRemoteSessionWriteBlocked)'); expect(sessionItemSrc).toContain( - 'const remoteWritesBlocked = isRemoteSessionWriteBlocked(session)', + "const remoteWritesBlocked = isSharedTaskPeer(session.deviceLinkDeviceId ?? '') || isRemoteSessionWriteBlocked(session)", ); expect(sessionCardSrc).toContain( 'const remoteWritesBlocked = isRemoteSessionWriteBlocked(session)', diff --git a/apps/desktop/src/renderer/__tests__/sessionContentHeaderDragRegion.test.ts b/apps/desktop/src/renderer/__tests__/sessionContentHeaderDragRegion.test.ts index 3349144d99b..babdca16048 100644 --- a/apps/desktop/src/renderer/__tests__/sessionContentHeaderDragRegion.test.ts +++ b/apps/desktop/src/renderer/__tests__/sessionContentHeaderDragRegion.test.ts @@ -10,10 +10,10 @@ const gitContextBadgeSource = readFileSync(resolve(ccAgentDir, 'GitContextBadge. describe('SessionContentHeader window drag region', () => { it('does not mark the whole injected session header as no-drag', () => { expect(sessionHeaderSource).toContain( - '
', + '
', ); expect(sessionHeaderSource).not.toMatch( - / { // 标题 span 留在 drag region 会让 onDoubleClick 被窗口拖拽区吞掉, // 双击改名整体失效(d7dc967df 回归)。 expect(sessionHeaderSource).toMatch( - /onDoubleClick=\{startEdit\}[\s\S]{0,240}style=\{WINDOW_NO_DRAG_STYLE\}/, + /onDoubleClick=\{sharedGuest \? undefined : startEdit\}[\s\S]{0,240}style=\{WINDOW_NO_DRAG_STYLE\}/, ); }); @@ -40,7 +40,7 @@ describe('SessionContentHeader window drag region', () => { // no-drag 之后拖窗习惯必须用 useManualWindowDrag 补回:按住标题移动 // 依旧拖动窗口,双击仍进改名(两者缺一都是回归)。 expect(sessionHeaderSource).toMatch( - /\{\.\.\.titleManualDrag\}[\s\S]{0,80}onDoubleClick=\{startEdit\}/, + /\{\.\.\.titleManualDrag\}[\s\S]{0,80}onDoubleClick=\{sharedGuest \? undefined : startEdit\}/, ); expect(sessionHeaderSource).toContain( 'const titleManualDrag = useManualWindowDrag();', diff --git a/apps/desktop/src/renderer/__tests__/voiceInputEditorEditability.test.ts b/apps/desktop/src/renderer/__tests__/voiceInputEditorEditability.test.ts index 0fecd2cdba7..0f0237f4388 100644 --- a/apps/desktop/src/renderer/__tests__/voiceInputEditorEditability.test.ts +++ b/apps/desktop/src/renderer/__tests__/voiceInputEditorEditability.test.ts @@ -29,7 +29,7 @@ describe('ChatInput voice lifecycle locks', () => { const permissionEnd = chatInputSource.indexOf('/>', permissionStart); expect(permissionEnd).toBeGreaterThan(permissionStart); const permissionBlock = chatInputSource.slice(permissionStart, permissionEnd); - expect(permissionBlock).toContain('disabled={composerEditorLocked || settingsLocked}'); + expect(permissionBlock).toContain('disabled={composerEditorLocked || settingsLocked || sharedGuest}'); expect(permissionBlock).not.toContain('disabled={composerMutationLocked}'); }); diff --git a/apps/desktop/src/renderer/components/chat/AssistantMessage.tsx b/apps/desktop/src/renderer/components/chat/AssistantMessage.tsx index dd506bbe4a4..ea9a238c4bd 100644 --- a/apps/desktop/src/renderer/components/chat/AssistantMessage.tsx +++ b/apps/desktop/src/renderer/components/chat/AssistantMessage.tsx @@ -263,6 +263,7 @@ export const AssistantMessage = memo(function AssistantMessage({ currentSessionId ? originDeviceId(sessionFileOrigin) : undefined, ); const isRemote = Boolean(remoteHostId); + const sharedGuest = isSharedTaskPeer(originDeviceId(sessionFileOrigin) ?? ''); const forkSupported = !isRemote && (!agentKind || (capabilities?.fork?.supported ?? true)); const handleFork = useForkAtMessage({ sessionId: currentSessionId, @@ -409,10 +410,10 @@ export const AssistantMessage = memo(function AssistantMessage({ align="left" hovered={hovered} simplifiedBotConversation={simplifiedBotConversation} - onFork={canFork ? handleFork : undefined} + onFork={!sharedGuest && canFork ? handleFork : undefined} onAddToChat={messageDeepLink ? handleAddToChat : undefined} onShareAsImage={handleShareAsImage} - onDelete={currentSessionId && messageClientId ? handleDelete : undefined} + onDelete={!sharedGuest && currentSessionId && messageClientId ? handleDelete : undefined} turnMoney={turnMoney} turnCostUsd={turnCostUsd} turnCostIsEstimate={turnCostIsEstimate} @@ -425,3 +426,4 @@ export const AssistantMessage = memo(function AssistantMessage({
); }); +import { isSharedTaskPeer } from '@cindy/device-link'; diff --git a/apps/desktop/src/renderer/components/chat/MessageStream.tsx b/apps/desktop/src/renderer/components/chat/MessageStream.tsx index 35614f0dd60..cb14d098de5 100644 --- a/apps/desktop/src/renderer/components/chat/MessageStream.tsx +++ b/apps/desktop/src/renderer/components/chat/MessageStream.tsx @@ -6374,6 +6374,7 @@ const MessageItem = memo(function MessageItem({ case 'user': return ( toRemoteMediaOrigin(sessionFileCtx.origin, sessionFileCtx.workingDir), @@ -1222,6 +1226,7 @@ export function UserMessage({ // 同时按 capabilities.fork.supported gate (Codex 现支持; 未来若 agent 不支持自动隐藏)。 const navigationMode = useSessionNavigationMode(); const canFork = + !sharedGuest && isInteractiveSessionNavigationMode(navigationMode) && Boolean(sessionId && messageClientId) && !isFirstUserMessage && @@ -1274,6 +1279,7 @@ export function UserMessage({ // NO_PRIOR_ASSISTANT。直接藏掉按钮,避免无效点击。 // 同时按 capabilities.rewind.supported gate;Codex 入口还要用户显式开启 Git safety。 const canRewind = + !sharedGuest && Boolean(sessionId && messageClientId) && !isFirstUserMessage && rewindSupported && @@ -1290,9 +1296,9 @@ export function UserMessage({ // 普通重发(onCommitOverride),故可编辑条件与 rewind 无关——只要有 // session/clientId 就能改了重发。 const isBlocked = Boolean(blockedByGhost); - const canEdit = isBlocked + const canEdit = !sharedGuest && (isBlocked ? Boolean(sessionId && messageClientId) - : canRewind && Boolean(isLastUserMessage); + : canRewind && Boolean(isLastUserMessage)); // 中断运行中的 turn——Stop 语义与输入框的 Stop 按钮完全一致 // (useCCAgentChat.stopSession):有排队消息时 keepQueue+pauseQueue(停当前 + @@ -1367,7 +1373,7 @@ export function UserMessage({ onFork={!isBlocked && canFork ? handleFork : undefined} onAddToChat={!isBlocked && messageDeepLink ? handleAddToChat : undefined} onShareAsImage={handleShareAsImage} - onDelete={!isBlocked && sessionId && messageClientId ? handleDelete : undefined} + onDelete={!sharedGuest && !isBlocked && sessionId && messageClientId ? handleDelete : undefined} onEdit={canEdit ? handleEdit : undefined} onRewind={!isBlocked && canRewind ? handleRewind : undefined} rewindInFlight={rewindOpen} @@ -1447,6 +1453,7 @@ export function UserMessage({ : 'max-w-[488px] items-end', )} > + {sharedAuthorName && {sharedAuthorName}} {orcaCommunication ? (
({ })); import { ChromeActions } from '../ChromeActions'; +vi.mock('@/features/device-link/useSharedTaskTasks', () => ({ useSharedTaskTasks: vi.fn() })); +vi.mock('@/features/device-link/JoinSharedTaskDialog', () => ({ JoinSharedTaskDialog: () => null })); +vi.mock('@/features/device-link/SharedTaskEndedNotice', () => ({ SharedTaskEndedNotice: () => null })); afterEach(() => { cleanup(); diff --git a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx index f1728b622ee..99bb71fa842 100644 --- a/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx +++ b/apps/desktop/src/renderer/components/new-chat/ChatInput.tsx @@ -12,6 +12,7 @@ import { type ReactNode, } from 'react'; import { createPortal } from 'react-dom'; +import { isSharedTaskPeer } from '@cindy/device-link'; import { useNavigate } from 'react-router-dom'; import { buildLocalSkillPathRoute } from '@/features/skillhub/lib/localRoutes'; import { Folder, MessageSquarePlus, Mic, Pen, TriangleAlert, X } from 'lucide-react'; @@ -1156,6 +1157,7 @@ export function ChatInput({ // device-link 远程会话:null = 已确认本地会话,undefined = 所有权尚未解析,string = 远程会话。 // 预测守卫用原始值区分 null vs undefined,下游通路继续用 ?? undefined 归一化。 const deviceLinkDeviceId = _deviceLinkDeviceId; + const sharedGuest = isSharedTaskPeer(deviceLinkDeviceId ?? ''); const { t } = useTranslation(); const navigate = useNavigate(); const { preference: composerSendShortcutPreference } = useComposerSendShortcutPreference(); @@ -1229,7 +1231,7 @@ export function ChatInput({ // 会话内「新建目标」对本机与 device-link 远程会话都开放:远程会话的 setGoal / 状态 // 订阅经 goalApiFor / subscribeGoalStatusChanged 隧道到被控端 goal-host(目标随会话 // 在被控端自主续跑)。历史上 device-link 曾被排除(reviewer #354,当时无隧道路由)。 - const inSessionGoalEnabled = !!sessionId; + const inSessionGoalEnabled = !!sessionId && !sharedGuest; // 无参 `/goal` 命令 → 等同点「新建目标」:main 广播 goalAction:'open-dialog', // 这里按 sessionId 过滤后打开本会话的弹窗(命令侧已确保有 session)。 useEffect(() => { @@ -1853,8 +1855,8 @@ export function ChatInput({ // 与下拉菜单看到的顺序一致。伙伴保留这两个入口;任务设置锁定时一起禁用。 const permissionCycleOptions = useMemo( () => - settingsLocked ? [] : (activeAgentCapabilities?.permissionModes ?? []), - [activeAgentCapabilities, settingsLocked], + settingsLocked || sharedGuest ? [] : (activeAgentCapabilities?.permissionModes ?? []), + [activeAgentCapabilities, settingsLocked, sharedGuest], ); const permissionCycleOptionsRef = useRef(permissionCycleOptions); permissionCycleOptionsRef.current = permissionCycleOptions; @@ -1868,7 +1870,7 @@ export function ChatInput({ // 计划模式入口门控:agent capability(device-link 老被控端无此字段 → 隐藏)+ 父组件接线。 const planModeSupported = activeAgentCapabilities?.planMode?.supported === true; const planModeEntry = - !settingsLocked && planModeSupported && onPlanModeChange + !sharedGuest && !settingsLocked && planModeSupported && onPlanModeChange ? { enabled: planModeEnabled, onToggle: (next: boolean) => void onPlanModeChange(next) } : undefined; // 当前 activeModel 归属的 agent runtime —— 用于 send 预检里按 (model, agent) 查 @@ -8739,7 +8741,7 @@ export function ChatInput({ onPermissionModeChange={handlePermissionModeChange} vendorKey={vendorKey} deviceId={deviceLinkDeviceId ?? undefined} - disabled={composerEditorLocked || settingsLocked} + disabled={composerEditorLocked || settingsLocked || sharedGuest} dense={effectiveDenseToolbar} iconOnly={useUltraCompactToolbar} visualVariant={isCreateAgentVariant ? 'create-agent' : 'default'} diff --git a/apps/desktop/src/renderer/components/title-bar/MenuButton.tsx b/apps/desktop/src/renderer/components/title-bar/MenuButton.tsx index cb70eac5c65..0abf1cfbdde 100644 --- a/apps/desktop/src/renderer/components/title-bar/MenuButton.tsx +++ b/apps/desktop/src/renderer/components/title-bar/MenuButton.tsx @@ -15,15 +15,20 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Tip } from '@/components/ui/tooltip'; +import { JoinSharedTaskDialog } from '@/features/device-link/JoinSharedTaskDialog'; +import { useSharedTaskTasks } from '@/features/device-link/useSharedTaskTasks'; +import { SharedTaskEndedNotice } from '@/features/device-link/SharedTaskEndedNotice'; export function MenuButton({ onExitFullscreen }: { onExitFullscreen?: () => void }) { const { t } = useTranslation(); const navigate = useNavigate(); const location = useLocation(); const [menuOpen, setMenuOpen] = useState(false); + const [joinOpen, setJoinOpen] = useState(false); + useSharedTaskTasks(); return ( - + <> {/* 尺寸与 ChromeActions 的折叠按钮同规格(h-7 / 图标 15 / rounded-md), @@ -44,6 +49,9 @@ export function MenuButton({ onExitFullscreen }: { onExitFullscreen?: () => void + setJoinOpen(true)}> + {t('sharedTask.join')} + {onExitFullscreen && ( {t('contentHeader.exitFullscreen')} @@ -113,5 +121,7 @@ export function MenuButton({ onExitFullscreen }: { onExitFullscreen?: () => void + setJoinOpen(true)} /> + {joinOpen && } ); } diff --git a/apps/desktop/src/renderer/components/title-bar/__tests__/MenuButton.test.tsx b/apps/desktop/src/renderer/components/title-bar/__tests__/MenuButton.test.tsx index 9460881f56d..1f1e570f328 100644 --- a/apps/desktop/src/renderer/components/title-bar/__tests__/MenuButton.test.tsx +++ b/apps/desktop/src/renderer/components/title-bar/__tests__/MenuButton.test.tsx @@ -20,6 +20,9 @@ vi.mock('@/lib/checkForUpdateWithToast', () => ({ })); import { MenuButton } from '@/components/title-bar/MenuButton'; +vi.mock('@/features/device-link/useSharedTaskTasks', () => ({ useSharedTaskTasks: vi.fn() })); +vi.mock('@/features/device-link/JoinSharedTaskDialog', () => ({ JoinSharedTaskDialog: () => null })); +vi.mock('@/features/device-link/SharedTaskEndedNotice', () => ({ SharedTaskEndedNotice: () => null })); afterEach(() => { cleanup(); diff --git a/apps/desktop/src/renderer/components/ui/__tests__/confirmDialogA11y.test.tsx b/apps/desktop/src/renderer/components/ui/__tests__/confirmDialogA11y.test.tsx index 2e31d9f1def..bc0b9b103f8 100644 --- a/apps/desktop/src/renderer/components/ui/__tests__/confirmDialogA11y.test.tsx +++ b/apps/desktop/src/renderer/components/ui/__tests__/confirmDialogA11y.test.tsx @@ -99,6 +99,14 @@ describe('ConfirmDialog confirmIcon', () => { }); describe('ConfirmDialog action layout', () => { + it.each([false, true])('preserves cancel focus with cancelFirst=%s', (cancelFirst) => { + render( {}} + title="Confirm" description="Description" confirmText="Confirm action" cancelText="Keep" />); + expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual( + cancelFirst ? ['Keep', 'Confirm action'] : ['Confirm action', 'Keep'], + ); + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Keep' })); + }); it('keeps action labels on one line without allowing buttons to shrink', () => { render( void; title: string; @@ -97,6 +99,7 @@ export interface ConfirmDialogProps { export function ConfirmDialog({ open, presentation, + cancelFirst = false, onOpenChange, title, description, @@ -160,6 +163,14 @@ export function ConfirmDialog({ }); return () => cancelAnimationFrame(raf); }, [open]); + const standardCancel = showCancel && ( + + + + ); return ( @@ -339,6 +350,7 @@ export function ConfirmDialog({ )} {presentation === 'standard' ? (
+ {cancelFirst && standardCancel} - - )} + {!cancelFirst && standardCancel}
) : (
diff --git a/apps/desktop/src/renderer/features/bots/__tests__/botChatSurface.test.ts b/apps/desktop/src/renderer/features/bots/__tests__/botChatSurface.test.ts index 7af0bc52e96..9810184f07d 100644 --- a/apps/desktop/src/renderer/features/bots/__tests__/botChatSurface.test.ts +++ b/apps/desktop/src/renderer/features/bots/__tests__/botChatSurface.test.ts @@ -66,7 +66,7 @@ describe('伙伴输入框只保留对话动作', () => { }); it('伙伴仍可使用权限快捷键,锁定任务不能切换', () => { - expect(chatInput).toContain('settingsLocked ? [] : (activeAgentCapabilities?.permissionModes ?? [])'); + expect(chatInput).toContain('settingsLocked || sharedGuest ? [] : (activeAgentCapabilities?.permissionModes ?? [])'); expect(chatInput).not.toContain('settingsLocked || hideRuntimeControls'); }); diff --git a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx index a7e95c11af9..eb02c74ba83 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CCAgentSessionView.tsx @@ -150,6 +150,7 @@ import { useSessionBinding } from '@/hooks/useSessionBinding'; import { useVendorAuthGate } from '@/hooks/useVendorAuthGate'; import { useProviders } from '@/hooks/useProviders'; import { useAuth } from '@/contexts/AuthContext'; +import { notifySharedTaskEnded } from '@/features/device-link/SharedTaskEndedNotice'; import { getDataOwnerGeneration, isDataOwnerGenerationCurrent, @@ -1215,7 +1216,8 @@ export function CCAgentSessionView({ }); if (!decision.exit) return; wasRemoteSessionRef.current = false; - if (decision.toastOffline) { + const sharedTaskEnded = ownsWindowRoute && notifySharedTaskEnded(dev0); + if (decision.toastOffline && !sharedTaskEnded) { toast.warning(t('settings.devices.toast.remoteSessionEnded')); } if (!ownsWindowRoute) { @@ -5147,7 +5149,15 @@ export function CCAgentSessionView({
} > - {pendingPlanReview ? ( + {isSharedTaskPeer(remoteDeviceId ?? '') ? ( + (pendingPlanReview || pendingPermission || pendingAskUser || pendingPluginSetup || pendingIssueConfirm || pendingRenameSessionsConfirm || pendingGhostGrantConfirm) && +
+

{t('sharedTask.waitingHost')}

+
{pendingPlanReview?.plan ?? (pendingPermission
+                      ? [pendingPermission.title ?? pendingPermission.toolName, pendingPermission.description, JSON.stringify(pendingPermission.input, null, 2)].filter(Boolean).join('\n')
+                      : JSON.stringify(pendingAskUser?.questions ?? pendingPluginSetup ?? pendingIssueConfirm ?? pendingRenameSessionsConfirm ?? pendingGhostGrantConfirm, null, 2))}
+
+ ) : pendingPlanReview ? ( <> {/* 会话内 /goal 进行中状态条(composer 上方);无 goal 时返回 null 不占位。 */} - + {!isSharedTaskPeer(remoteDeviceId ?? '') && } {/* 互斥:控制端能终结的 pending interaction 会接管 composer; Desktop-only 只读确认只能提示等待,必须保留 ChatInput,避免控制端 既处理不了确认又无法继续发送或排队消息。 @@ -5225,13 +5235,13 @@ export function CCAgentSessionView({ 4. 默认 → ChatInput Cindy Make 沿用输入框的背景与边框,准备详情限高滚动; 接管与 worktree 创建继续使用 90px 状态框。 */} - {pendingPlanReview || + {!isSharedTaskPeer(remoteDeviceId ?? '') && (pendingPlanReview || pendingPermission || pendingAskUser || pendingPluginSetup || pendingIssueConfirm || pendingRenameSessionsConfirm || - pendingGhostGrantConfirm ? null : sessionBinding.attached && sessionId ? ( + pendingGhostGrantConfirm) ? null : sessionBinding.attached && sessionId ? ( ); } +import { isSharedTaskPeer } from '@cindy/device-link'; diff --git a/apps/desktop/src/renderer/features/cc-agent/CCAgentSidebarUpper.tsx b/apps/desktop/src/renderer/features/cc-agent/CCAgentSidebarUpper.tsx index 8dd787d6971..d6de7839090 100644 --- a/apps/desktop/src/renderer/features/cc-agent/CCAgentSidebarUpper.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/CCAgentSidebarUpper.tsx @@ -68,6 +68,8 @@ import { useOwnTopNavScrollableRows, useSidebarCollapsedState } from '../feature import { SidebarTopNav } from '@/components/sidebar/SidebarTopNav'; import { SidebarFilterPopover } from './sidebar/SidebarFilterPopover'; import { MainListScopeHeader } from './sidebar/MainListScopeHeader'; +import { SharedTasksSection } from '@/features/device-link/SharedTasksSection'; +import { isSharedTaskPeer } from '@cindy/device-link'; import { stripTrailingPathSeparators } from '../../../shared/pathText'; import { SessionAttentionUrgencyProvider, @@ -570,7 +572,8 @@ export function CCAgentSidebarUpper() { } }, [filter.status, remoteDevices, selectedMachineId]); const sessionsWithRemote = useMemo( - () => selectVisibleSessions(sessionsHook.sessions, remoteProjectSessions, selectedMachineId), + () => selectVisibleSessions(sessionsHook.sessions, remoteProjectSessions, selectedMachineId) + .filter(session => !session.deviceLinkDeviceId || !isSharedTaskPeer(session.deviceLinkDeviceId)), [sessionsHook.sessions, remoteProjectSessions, selectedMachineId], ); const statusFilteredSessionsWithRemote = useMemo( @@ -1391,7 +1394,8 @@ function ExpandedView({ ); const scopedSidebarSessions = useMemo( () => - selectVisibleSessions(sessions, remoteProjectSessions, selectedMachineId).filter( + selectVisibleSessions(sessions, remoteProjectSessions, selectedMachineId) + .filter(session => !session.deviceLinkDeviceId || !isSharedTaskPeer(session.deviceLinkDeviceId)).filter( passesOrcaAndStatus, ), [sessions, remoteProjectSessions, selectedMachineId, passesOrcaAndStatus], @@ -3590,6 +3594,10 @@ function ExpandedView({ ) : null} {/* 搜索时原列表只隐藏、不卸载:置顶段折叠等本地 state 才能保住。 */}