From 5aa4d0b05c4f9ffbc8f86e0e20306733bd582ae1 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Sun, 30 Aug 2026 19:37:37 +0530 Subject: [PATCH 001/306] feat: sim side of the mothership revamp Pairs with mothership feat/mothership-revamp (the TypeScript worker). All mothership surfaces now speak the shared minimal ChatRequest/ExecuteRequest contract (lib/mothership/generated/protocol.ts, synced from the worker and drift-gated there). - Payload rework: no VFS/workspace snapshot build, no server-decided fields on the wire (model/provider/mode), protocolVersion stamp, delegation tokens (rolling 12h PERSONAL api_key per user) minted per request - v2 chat + inbox + execute (agent block) all ride the worker's surfaces; concurrent-leg + sequential resume senders slimmed to contract - lib/copilot -> lib/mothership directory merge (404 files' imports) - Dead code: 59 files removed (VFS snapshot machinery, unreachable server tools/handlers); tool dispatch trimmed to the worker's real surface - UI fixes from the live browser pass: CLI display titles (the partial frame's raw name no longer sticks), snap-to-bottom on send, CLI-truthful upload/archive guidance - use-chat pure-module extraction: send-handoff, stream-protocol, message-reconcile (5,238 -> 4,600 lines; hook split still pending) - Tests realigned to the contract (fat-payload assertions replaced with legacy-field absence checks) 5,577 tests green on touched areas; type-check and check:api-validation clean. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../app/_shell/paste-admission-guard.test.tsx | 2 +- apps/sim/app/_shell/paste-admission-guard.tsx | 2 +- apps/sim/app/api/admin/mothership/route.ts | 2 +- .../app/api/billing/update-cost/route.test.ts | 4 +- apps/sim/app/api/billing/update-cost/route.ts | 12 +- apps/sim/app/api/cli/auth/poll/route.test.ts | 2 +- apps/sim/app/api/cli/auth/poll/route.ts | 2 +- .../api/copilot/api-keys/generate/route.ts | 2 +- .../app/api/copilot/api-keys/route.test.ts | 4 +- apps/sim/app/api/copilot/api-keys/route.ts | 4 +- .../copilot/api-keys/validate/route.test.ts | 8 +- .../api/copilot/api-keys/validate/route.ts | 20 +- apps/sim/app/api/copilot/byok/route.ts | 4 +- .../app/api/copilot/byok/validate/route.ts | 2 +- .../app/api/copilot/chat/abort/route.test.ts | 10 +- apps/sim/app/api/copilot/chat/abort/route.ts | 20 +- .../app/api/copilot/chat/delete/route.test.ts | 4 +- apps/sim/app/api/copilot/chat/delete/route.ts | 4 +- apps/sim/app/api/copilot/chat/queries.ts | 16 +- .../app/api/copilot/chat/resources/route.ts | 10 +- apps/sim/app/api/copilot/chat/route.ts | 2 +- .../app/api/copilot/chat/stop/route.test.ts | 6 +- apps/sim/app/api/copilot/chat/stop/route.ts | 18 +- .../app/api/copilot/chat/stream/route.test.ts | 8 +- apps/sim/app/api/copilot/chat/stream/route.ts | 24 +- apps/sim/app/api/copilot/chats/route.test.ts | 2 +- apps/sim/app/api/copilot/chats/route.ts | 10 +- .../sim/app/api/copilot/confirm/route.test.ts | 6 +- apps/sim/app/api/copilot/confirm/route.ts | 22 +- .../app/api/copilot/feedback/route.test.ts | 2 +- apps/sim/app/api/copilot/feedback/route.ts | 4 +- .../api/copilot/tool-permission/route.test.ts | 8 +- .../app/api/copilot/tool-permission/route.ts | 16 +- .../api/copilot/tools/execute/route.test.ts | 12 +- .../app/api/copilot/tools/execute/route.ts | 26 +- .../api/desktop/tool/authorize/route.test.ts | 4 +- .../app/api/desktop/tool/authorize/route.ts | 10 +- .../public/[token]/content/route.test.ts | 2 +- .../api/files/public/[token]/content/route.ts | 2 +- .../api/files/serve/[...path]/route.test.ts | 2 +- .../app/api/files/serve/[...path]/route.ts | 6 +- .../app/api/internal/file-doc/merge/route.ts | 2 +- .../api/internal/file-doc/persist/route.ts | 2 +- .../api/internal/file-doc/seed/route.test.ts | 2 +- .../app/api/internal/file-doc/seed/route.ts | 2 +- apps/sim/app/api/mothership/chat/route.ts | 2 +- .../chats/[chatId]/fork/route.test.ts | 16 +- .../mothership/chats/[chatId]/fork/route.ts | 29 +- .../chats/[chatId]/restore/route.test.ts | 4 +- .../chats/[chatId]/restore/route.ts | 6 +- .../mothership/chats/[chatId]/route.test.ts | 22 +- .../api/mothership/chats/[chatId]/route.ts | 24 +- .../api/mothership/chats/read/route.test.ts | 2 +- .../app/api/mothership/chats/read/route.ts | 7 +- .../app/api/mothership/chats/route.test.ts | 6 +- apps/sim/app/api/mothership/chats/route.ts | 12 +- apps/sim/app/api/mothership/events/route.ts | 4 +- .../app/api/mothership/execute/route.test.ts | 35 +- apps/sim/app/api/mothership/execute/route.ts | 143 +- .../api/superuser/import-workflow/route.ts | 4 +- apps/sim/app/api/v2/chat/route.test.ts | 33 +- apps/sim/app/api/v2/chat/route.ts | 71 +- .../[id]/execute/route.async.test.ts | 2 +- .../app/api/workflows/[id]/execute/route.ts | 28 +- .../app/api/workspaces/[id]/inbox/route.ts | 2 +- .../message-actions/message-actions.tsx | 2 +- .../rich-markdown-editor.tsx | 2 +- .../components/file-viewer/text-editor.tsx | 2 +- .../use-selection-copy-bridge.test.tsx | 2 +- .../file-viewer/use-selection-copy-bridge.ts | 2 +- .../components/agent-group/tool-call-item.tsx | 10 +- .../components/chat-content/chat-content.tsx | 2 +- .../message-content/message-content.test.ts | 8 +- .../message-content/message-content.tsx | 12 +- .../mothership-chat/mothership-chat.tsx | 23 + .../generic-resource-content.tsx | 2 +- .../resource-content/resource-content.tsx | 7 +- .../resource-tabs/resource-tabs.tsx | 4 +- .../mothership-view/mothership-view.tsx | 2 +- .../prompt-editor/use-prompt-editor.test.tsx | 2 +- .../prompt-editor/use-prompt-editor.ts | 2 +- .../home/components/user-input/user-input.tsx | 4 +- .../home/hooks/message-reconcile.ts | 363 ++ .../preview/apply-file-preview-phase.test.ts | 2 +- .../hooks/preview/apply-file-preview-phase.ts | 4 +- .../preview/use-file-preview-controller.ts | 4 +- .../use-file-preview-sessions.test.tsx | 2 +- .../preview/use-file-preview-sessions.ts | 2 +- .../[workspaceId]/home/hooks/send-handoff.ts | 284 ++ .../home/hooks/stream-protocol.ts | 182 + .../stream/dispatch-stream-event.test.ts | 4 +- .../hooks/stream/dispatch-stream-event.ts | 4 +- .../hooks/stream/handle-complete-event.ts | 2 +- .../home/hooks/stream/handle-error-event.ts | 2 +- .../stream/handle-resource-event.test.ts | 2 +- .../hooks/stream/handle-resource-event.ts | 6 +- .../home/hooks/stream/handle-run-event.ts | 2 +- .../hooks/stream/handle-session-event.test.ts | 4 +- .../home/hooks/stream/handle-session-event.ts | 6 +- .../hooks/stream/handle-span-event.test.ts | 4 +- .../home/hooks/stream/handle-span-event.ts | 4 +- .../home/hooks/stream/handle-text-event.ts | 2 +- .../hooks/stream/handle-tool-event.test.ts | 8 +- .../home/hooks/stream/handle-tool-event.ts | 12 +- .../home/hooks/stream/stream-context.test.ts | 2 +- .../home/hooks/stream/stream-context.ts | 12 +- .../home/hooks/stream/stream-helpers.ts | 6 +- .../home/hooks/stream/stream-test-helpers.ts | 6 +- .../hooks/stream/turn-model-serialize.test.ts | 2 +- .../home/hooks/stream/turn-model-serialize.ts | 2 +- .../home/hooks/stream/turn-model.test.ts | 2 +- .../home/hooks/stream/turn-model.ts | 12 +- .../[workspaceId]/home/hooks/use-chat.test.ts | 11 +- .../[workspaceId]/home/hooks/use-chat.ts | 799 +--- .../home/resolve-resource-ref.test.ts | 2 +- .../home/resolve-resource-ref.ts | 4 +- .../app/workspace/[workspaceId]/home/types.ts | 2 +- .../[workspaceId]/lib/prefetch.test.ts | 2 +- .../app/workspace/[workspaceId]/prefetch.ts | 2 +- .../components/table-grid/table-grid.tsx | 2 +- .../components/table-grid/utils.test.ts | 2 +- .../[tableId]/components/table-grid/utils.ts | 2 +- .../user-input/hooks/use-file-attachments.ts | 2 +- .../utils/workflow-execution-utils.ts | 2 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 2 +- .../components/folder-item/folder-item.tsx | 2 +- .../workflow-item/workflow-item.tsx | 2 +- .../w/components/sidebar/sidebar.tsx | 2 +- .../w/components/sidebar/utils.ts | 2 +- .../agent-stream/agent-stream-chrome.test.tsx | 2 +- .../agent-stream/agent-stream-chrome.tsx | 2 +- .../agent-stream/tool-call-lifecycle.ts | 2 +- .../handlers/function/function-handler.ts | 2 +- .../handlers/mothership/mothership-handler.ts | 2 +- .../workflow/workflow-tool-runner.test.ts | 4 +- .../hooks/queries/mothership-chats.test.ts | 2 +- apps/sim/hooks/queries/mothership-chats.ts | 12 +- .../utils/find-workspace-file-by-src.ts | 2 +- apps/sim/hooks/use-mothership-chat-events.ts | 2 +- apps/sim/instrumentation-node.ts | 2 +- apps/sim/lib/api/contracts/copilot.ts | 6 +- .../api/contracts/secret-mount-policy.test.ts | 2 +- .../lib/api/contracts/secret-mount-policy.ts | 2 +- apps/sim/lib/api/contracts/subscription.ts | 2 +- apps/sim/lib/api/contracts/v1/copilot.ts | 2 +- .../lib/billing/core/billing-attribution.ts | 4 +- .../sim/lib/browser-agent/attachments.test.ts | 2 +- apps/sim/lib/browser-agent/attachments.ts | 2 +- .../sim/lib/catalog/registry-boundary.test.ts | 2 +- apps/sim/lib/cleanup/chat-cleanup.ts | 2 +- .../copilot/chat/workspace-context.test.ts | 298 -- .../sim/lib/copilot/chat/workspace-context.ts | 687 ---- apps/sim/lib/copilot/entitlements.ts | 89 - .../lib/copilot/generated/vfs-snapshot-v1.ts | 139 - .../go/file-preview-append-roundtrip.test.ts | 174 - .../sim/lib/copilot/sim-sandbox-projection.ts | 12 - .../lib/copilot/tool-executor/handler-map.ts | 199 - .../tool-executor/register-handlers.ts | 23 - apps/sim/lib/copilot/tools/handlers/access.ts | 65 - .../tools/handlers/deployment/context.test.ts | 53 - .../tools/handlers/deployment/context.ts | 36 - .../handlers/deployment/custom-block.test.ts | 508 --- .../tools/handlers/deployment/custom-block.ts | 316 -- .../tools/handlers/deployment/deploy.test.ts | 329 -- .../tools/handlers/deployment/deploy.ts | 662 ---- .../tools/handlers/deployment/manage.test.ts | 513 --- .../tools/handlers/deployment/manage.ts | 538 --- .../tools/handlers/deployment/state-refs.ts | 25 - .../tools/handlers/function-execute.test.ts | 1212 ------ .../tools/handlers/function-execute.ts | 732 ---- .../tools/handlers/integration-tools.ts | 48 - .../management/connect-slack-bot.test.ts | 105 - .../handlers/management/connect-slack-bot.ts | 91 - .../manage-application-use-cases.test.ts | 212 -- .../handlers/management/manage-credential.ts | 82 - .../handlers/management/manage-custom-tool.ts | 256 -- .../handlers/management/manage-mcp-tool.ts | 211 -- .../management/manage-sandbox.test.ts | 282 -- .../handlers/management/manage-sandbox.ts | 189 - .../tools/handlers/management/manage-skill.ts | 195 - .../tools/handlers/materialize-file.test.ts | 841 ----- .../tools/handlers/materialize-file.ts | 670 ---- .../lib/copilot/tools/handlers/oauth.test.ts | 174 - apps/sim/lib/copilot/tools/handlers/oauth.ts | 114 - .../copilot/tools/handlers/resources.test.ts | 286 -- .../lib/copilot/tools/handlers/resources.ts | 206 - .../tools/handlers/restore-resource.ts | 38 - .../lib/copilot/tools/handlers/run-code.ts | 31 - .../tools/handlers/upload-file-reader.test.ts | 271 -- .../tools/handlers/upload-file-reader.ts | 276 -- .../copilot/tools/handlers/vfs-mutate.test.ts | 1103 ------ .../lib/copilot/tools/handlers/vfs-mutate.ts | 849 ----- .../lib/copilot/tools/handlers/vfs.test.ts | 953 ----- apps/sim/lib/copilot/tools/handlers/vfs.ts | 622 ---- .../tools/handlers/workflow/queries.test.ts | 72 - .../tools/handlers/workflow/queries.ts | 292 -- .../get-blocks-metadata-projection.test.ts | 103 - .../blocks/get-blocks-metadata-tool.test.ts | 226 -- .../server/blocks/get-blocks-metadata-tool.ts | 851 ----- .../server/blocks/get-trigger-blocks.test.ts | 56 - .../tools/server/blocks/get-trigger-blocks.ts | 68 - .../tools/server/enrichment/enrichment-run.ts | 67 - .../copilot/tools/server/files/create-file.ts | 132 - .../server/files/doc-asset-extract-pdf.ts | 477 --- .../server/files/doc-asset-extract.test.ts | 172 - .../tools/server/files/doc-asset-extract.ts | 424 --- .../files/download-to-workspace-file.ts | 243 -- .../tools/server/files/edit-content.test.ts | 178 - .../tools/server/files/edit-content.ts | 320 -- .../server/files/embedded-image-refs.test.ts | 62 - .../tools/server/files/extract-doc-assets.ts | 221 -- .../tools/server/files/file-folders.ts | 456 --- .../copilot/tools/server/files/rename-file.ts | 115 - .../copilot/tools/server/files/share-file.ts | 179 - .../tools/server/generated-schema.test.ts | 134 - .../server/knowledge/knowledge-base.test.ts | 1053 ------ .../tools/server/knowledge/knowledge-base.ts | 1225 ------ .../knowledge/search-knowledge-base.test.ts | 73 - .../server/knowledge/search-knowledge-base.ts | 39 - .../tools/server/other/search-online.test.ts | 99 - .../tools/server/other/search-online.ts | 145 - apps/sim/lib/copilot/tools/server/router.ts | 300 -- .../server/table/query-user-table.test.ts | 49 - .../tools/server/table/query-user-table.ts | 51 - .../tools/server/table/table-automations.ts | 49 - .../tools/server/table/table-columns.ts | 42 - .../tools/server/table/table-enrichments.ts | 40 - .../tools/server/table/table-manage.ts | 37 - .../copilot/tools/server/table/table-rows.ts | 46 - .../tools/server/table/table-split.test.ts | 60 - .../tools/server/table/table-views.test.ts | 174 - .../copilot/tools/server/table/table-views.ts | 229 -- .../tools/server/table/user-table.test.ts | 1989 ---------- .../copilot/tools/server/table/user-table.ts | 1578 -------- .../user/set-environment-variables.test.ts | 275 -- .../server/user/set-environment-variables.ts | 286 -- .../server/workflow/edit-workflow/index.ts | 152 - .../tools/server/workflow/query-logs.test.ts | 284 -- .../tools/server/workflow/query-logs.ts | 297 -- .../copilot/vfs/custom-block-schema.test.ts | 48 - apps/sim/lib/copilot/vfs/file-reader.test.ts | 251 -- apps/sim/lib/copilot/vfs/file-reader.ts | 639 ---- apps/sim/lib/copilot/vfs/index.ts | 1 - apps/sim/lib/copilot/vfs/serializers.test.ts | 993 ----- apps/sim/lib/copilot/vfs/serializers.ts | 1874 ---------- .../copilot/vfs/service-account-gate.test.ts | 49 - .../sim/lib/copilot/vfs/workspace-vfs.test.ts | 290 -- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 3314 ----------------- apps/sim/lib/core/telemetry.ts | 2 +- apps/sim/lib/desktop/index.ts | 2 +- .../execute-request.test.ts | 4 +- .../lib/function-execution/execute-request.ts | 22 +- .../read-available-by-id-or-title.test.ts | 2 +- .../read-available-by-id-or-title.ts | 10 +- .../lib/internal/daytona/execute-tool.test.ts | 2 +- .../lib/internal/daytona/operations.test.ts | 2 +- apps/sim/lib/internal/file/operations.ts | 2 +- .../internal/guardrails/operations.test.ts | 2 +- .../sim/lib/internal/guardrails/operations.ts | 2 +- apps/sim/lib/internal/llm/operations.test.ts | 2 +- apps/sim/lib/internal/llm/operations.ts | 2 +- .../lib/knowledge/model-input-provenance.ts | 2 +- .../lib/media/ffmpeg-schema-parity.test.ts | 2 +- apps/sim/lib/model-router/resolve.test.ts | 4 +- apps/sim/lib/model-router/resolve.ts | 4 +- .../application/application-adapter.test.ts | 6 +- .../application/application-adapter.ts | 12 +- .../authorize-chat-callback.test.ts | 4 +- .../application/authorize-chat-callback.ts | 8 +- .../application/error.test.ts | 4 +- .../application/error.ts | 0 .../application/execute-api-key-use-case.ts | 4 +- .../execute-credential-use-case.ts | 4 +- .../execute-custom-tool-use-case.test.ts | 2 +- .../execute-custom-tool-use-case.ts | 4 +- .../application/execute-file-use-case.test.ts | 2 +- .../application/execute-file-use-case.ts | 8 +- .../application/execute-knowledge-use-case.ts | 8 +- .../application/execute-log-use-case.ts | 10 +- .../execute-managed-mcp-use-case.ts | 4 +- .../execute-mcp-server-use-case.ts | 4 +- .../execute-sandbox-use-case.test.ts | 2 +- .../application/execute-sandbox-use-case.ts | 4 +- .../application/execute-skill-use-case.ts | 4 +- .../execute-table-use-case.test.ts | 2 +- .../application/execute-table-use-case.ts | 6 +- .../execute-workflow-use-case.test.ts | 6 +- .../application/execute-workflow-use-case.ts | 8 +- .../application/load-connected-accounts.ts | 4 +- .../load-search-integrations.test.ts | 4 +- .../application/load-search-integrations.ts | 4 +- .../application/operations.ts | 0 .../application/table-commands.test.ts | 4 +- .../application/table-commands.ts | 4 +- .../assistant/tool-policy.test.ts | 2 +- .../assistant/tool-policy.ts | 0 .../async-runs/errors.ts | 0 .../async-runs/lifecycle.test.ts | 0 .../async-runs/lifecycle.ts | 2 +- .../async-runs/repository.test.ts | 0 .../async-runs/repository.ts | 8 +- .../async-runs/tool-identity.postgres.test.ts | 14 +- .../auth/application-delegation.test.ts | 2 +- .../auth/application-delegation.ts | 2 +- .../auth/file-delegation.test.ts | 6 +- .../auth/file-delegation.ts | 4 +- .../auth/permissions.test.ts | 2 +- .../auth/permissions.ts | 0 .../auth/table-delegation.test.ts | 2 +- .../auth/table-delegation.ts | 4 +- .../block-visibility.ts | 0 .../chat-status.test.ts | 2 +- .../{copilot => mothership}/chat-status.ts | 0 .../chat/assistant-images.test.ts | 4 +- .../chat/assistant-images.ts | 2 +- .../chat/attachment-preview.test.ts | 0 .../chat/attachment-preview.ts | 0 .../chat/citation-evidence.ts | 0 apps/sim/lib/mothership/chat/delegation.ts | 77 + .../chat/desktop-capabilities.ts | 0 .../chat/display-message.test.ts | 0 .../chat/display-message.ts | 6 +- .../chat/effective-transcript.test.ts | 8 +- .../chat/effective-transcript.ts | 12 +- .../chat/folder-context.ts | 6 +- .../chat/fork-chat-files.test.ts | 2 +- .../chat/fork-chat-files.ts | 0 .../chat/lifecycle.test.ts | 2 +- .../{copilot => mothership}/chat/lifecycle.ts | 4 +- .../chat/list-mothership-chats.ts | 2 +- .../chat/messages-store.test.ts | 5 +- .../chat/messages-store.ts | 8 +- .../chat/organization-chats.test.ts | 6 +- .../chat/organization-chats.ts | 6 +- .../chat/payload.test.ts | 152 +- .../{copilot => mothership}/chat/payload.ts | 92 +- .../chat/persisted-message.test.ts | 4 +- .../chat/persisted-message.ts | 12 +- .../{copilot => mothership}/chat/post.test.ts | 54 +- .../lib/{copilot => mothership}/chat/post.ts | 123 +- .../process-contents-log-projection.test.ts | 2 +- .../chat/process-contents.test.ts | 10 +- .../chat/process-contents.ts | 28 +- .../chat/retrieval-citations.test.ts | 2 +- .../chat/retrieval-citations.ts | 0 .../chat/rewrite-file-references.ts | 4 +- .../chat/selection-clipboard.test.ts | 0 .../chat/selection-clipboard.ts | 0 .../chat/selection-context.test.ts | 0 .../chat/selection-context.ts | 0 .../chat/sim-key-redaction.test.ts | 0 .../chat/sim-key-redaction.ts | 8 +- .../chat/stream-liveness.test.ts | 4 +- .../chat/stream-liveness.ts | 2 +- .../chat/stream-tool-outcome.ts | 2 +- .../chat/terminal-state.test.ts | 2 +- .../chat/terminal-state.ts | 12 +- .../lib/{copilot => mothership}/constants.ts | 2 +- .../docs/docs-corpus.test.ts | 4 +- .../docs/docs-corpus.ts | 8 +- .../docs/docs-path.test.ts | 4 +- .../{copilot => mothership}/docs/docs-path.ts | 0 .../docs/docs-search.test.ts | 2 +- .../docs/docs-search.ts | 4 +- .../environment-context.test.ts | 2 +- .../environment-context.ts | 0 .../generated/billing-protocol-v1.ts | 0 .../generated/docs-manifest.ts | 0 .../generated/metrics-v1.ts | 0 .../generated/mothership-stream-v1-schema.ts | 0 .../generated/mothership-stream-v1.ts | 0 apps/sim/lib/mothership/generated/protocol.ts | 138 + .../generated/request-trace-v1.ts | 0 .../generated/tool-catalog-v1.ts | 0 .../generated/tool-schemas-v1.ts | 0 .../generated/trace-attribute-values-v1.ts | 0 .../generated/trace-attributes-v1.ts | 0 .../generated/trace-events-v1.ts | 0 .../generated/trace-spans-v1.ts | 0 .../sim/lib/mothership/inbox/executor.test.ts | 20 +- apps/sim/lib/mothership/inbox/executor.ts | 49 +- .../integration-tool-projection.test.ts | 4 +- .../integration-tool-projection.ts | 10 +- .../integration-tools-invariants.test.ts | 2 +- .../integration-tools.test.ts | 2 +- .../integration-tools.ts | 0 .../{copilot => mothership}/mcp-tools.test.ts | 2 +- .../lib/{copilot => mothership}/mcp-tools.ts | 6 +- .../persistence/tool-confirm/index.ts | 10 +- .../tool-confirm/tool-confirm.test.ts | 4 +- .../persistence/tool-permission/auto-allow.ts | 0 .../persistence/tool-permission/index.ts | 4 +- .../request/context/request-context.ts | 4 +- .../request/context/result.test.ts | 10 +- .../request/context/result.ts | 4 +- .../request/go/fetch.test.ts | 2 +- .../request/go/fetch.ts | 8 +- .../request/go/file-preview-adapter.test.ts | 18 +- .../request/go/file-preview-adapter.ts | 12 +- .../request/go/parser.ts | 0 .../request/go/propagation.ts | 0 .../request/go/stream.test.ts | 28 +- .../request/go/stream.ts | 35 +- .../request/go/tool-call-identity.test.ts | 6 +- .../request/go/tool-call-identity.ts | 2 +- .../request/handlers/complete.ts | 0 .../request/handlers/error.ts | 0 .../request/handlers/handlers.test.ts | 26 +- .../request/handlers/index.ts | 4 +- .../request/handlers/resource.ts | 0 .../request/handlers/run.ts | 4 +- .../request/handlers/session.ts | 2 +- .../request/handlers/span.ts | 2 +- .../request/handlers/text.ts | 2 +- .../request/handlers/tool.ts | 62 +- .../request/handlers/types.ts | 16 +- .../{copilot => mothership}/request/http.ts | 2 +- .../request/lifecycle/finalize.ts | 16 +- .../request/lifecycle/headless.test.ts | 6 +- .../request/lifecycle/headless.ts | 16 +- .../lifecycle/resume-leg-context.test.ts | 10 +- .../request/lifecycle/run.test.ts | 53 +- .../request/lifecycle/run.ts | 93 +- .../request/lifecycle/start.test.ts | 18 +- .../request/lifecycle/start.ts | 41 +- .../request/metrics.test.ts | 4 +- .../request/metrics.ts | 8 +- .../{copilot => mothership}/request/otel.ts | 14 +- .../request/session/abort-reason.ts | 0 .../request/session/abort.test.ts | 6 +- .../request/session/abort.ts | 8 +- .../request/session/buffer.test.ts | 6 +- .../request/session/buffer.ts | 0 .../request/session/contract.test.ts | 0 .../request/session/contract.ts | 6 +- .../request/session/event.test.ts | 6 +- .../request/session/event.ts | 0 .../request/session/explicit-abort.test.ts | 6 +- .../request/session/explicit-abort.ts | 11 +- .../session/file-preview-session-contract.ts | 0 .../session/file-preview-session.test.ts | 2 +- .../request/session/file-preview-session.ts | 0 .../request/session/index.ts | 0 .../request/session/recovery.test.ts | 0 .../request/session/recovery.ts | 10 +- .../request/session/sse.ts | 0 .../request/session/types.ts | 0 .../request/session/writer.test.ts | 8 +- .../request/session/writer.ts | 2 +- .../request/sse-utils.test.ts | 6 +- .../request/sse-utils.ts | 8 +- .../request/tool-call-state.test.ts | 4 +- .../request/tool-call-state.ts | 6 +- .../request/tools/billing.test.ts | 6 +- .../request/tools/billing.ts | 6 +- .../tools/client-completion-seal.server.ts | 2 +- .../request/tools/client.test.ts | 10 +- .../request/tools/client.ts | 14 +- .../request/tools/executor.test.ts | 44 +- .../request/tools/executor.ts | 42 +- .../request/tools/files.test.ts | 12 +- .../request/tools/files.ts | 22 +- .../request/tools/permission.test.ts | 33 +- .../request/tools/permission.ts | 22 +- .../request/tools/permissions.ts | 2 +- .../tools/resolved-secret-result.test.ts | 4 +- .../request/tools/resolved-secret-result.ts | 4 +- .../request/tools/resources.test.ts | 8 +- .../request/tools/resources.ts | 12 +- .../request/tools/tables.test.ts | 12 +- .../request/tools/tables.ts | 22 +- .../tools/workflow-client-fallback.test.ts | 8 +- .../request/tools/workflow-client-fallback.ts | 10 +- .../request/tools/workflow-context.test.ts | 4 +- .../request/tools/workflow-context.ts | 2 +- .../{copilot => mothership}/request/trace.ts | 2 +- .../{copilot => mothership}/request/types.ts | 17 +- .../{copilot => mothership}/resource-types.ts | 0 .../resources/availability.ts | 2 +- .../client-persistence-queue.test.ts | 4 +- .../resources/client-persistence-queue.ts | 4 +- .../resources/extraction.test.ts | 0 .../resources/extraction.ts | 4 +- .../resources/persistence.test.ts | 2 +- .../resources/persistence.ts | 0 .../resources/types.test.ts | 0 .../resources/types.ts | 0 .../secret-mount-policy.test.ts | 2 +- .../secret-mount-policy.ts | 0 .../server/agent-url.test.ts | 2 +- .../server/agent-url.ts | 3 +- .../server/api-keys.ts | 6 +- .../tool-executor/executor.test.ts | 0 .../tool-executor/executor.ts | 4 +- .../tool-executor/index.ts | 0 .../tool-executor/register-handlers.ts | 62 + .../tool-executor/router.test.ts | 0 .../tool-executor/router.ts | 2 +- .../tool-executor/types.ts | 4 +- .../tools/browser-protocol-contract.test.ts | 2 +- .../lib/mothership/tools/cli-tool-display.ts | 231 ++ .../tools/client/base-tool.ts | 0 .../client/browser-tool-execution.test.ts | 6 +- .../tools/client/browser-tool-execution.ts | 10 +- .../client/browser-tool-replay-ledger.test.ts | 0 .../client/browser-tool-replay-ledger.ts | 0 .../tools/client/browser-tool-result.test.ts | 2 +- .../tools/client/browser-tool-result.ts | 0 .../tools/client/completion.test.ts | 2 +- .../tools/client/completion.ts | 6 +- .../tools/client/hidden-tools.test.ts | 0 .../tools/client/hidden-tools.ts | 0 .../tools/client/local-filesystem.test.ts | 4 +- .../tools/client/local-filesystem.ts | 8 +- .../tools/client/read-block.test.ts | 2 +- .../tools/client/read-block.ts | 0 .../tools/client/run-tool-execution.test.ts | 0 .../tools/client/run-tool-execution.ts | 12 +- .../tools/client/store-utils.test.ts | 2 +- .../tools/client/store-utils.ts | 14 +- .../client/terminal-tool-execution.test.ts | 4 +- .../tools/client/terminal-tool-execution.ts | 6 +- .../tools/client/tool-call-state.ts | 0 .../tools/client/trace-context.ts | 0 .../tools/descriptions.test.ts | 0 .../tools/descriptions.ts | 0 .../tools/handlers/context.ts | 4 +- .../tools/handlers/param-types.ts | 2 +- .../tools/handlers/workflow/mutations.test.ts | 10 +- .../tools/handlers/workflow/mutations.ts | 15 +- .../workflow/withheld-run-result.test.ts | 10 +- .../tools/local-filesystem.ts | 0 .../tools/permissions.test.ts | 2 +- .../tools/permissions.ts | 0 .../registry/server-tool-adapter.test.ts | 6 +- .../tools/registry/server-tool-adapter.ts | 8 +- .../tools/retired-tools.ts | 0 .../secret-mount-materializer.server.test.ts | 2 +- .../tools/secret-mount-materializer.server.ts | 6 +- .../tools/server/base-tool.ts | 0 .../server/docs/search-docs-dispatch.test.ts | 8 +- .../tools/server/docs/search-docs.test.ts | 6 +- .../tools/server/docs/search-docs.ts | 12 +- .../tools/server/env-reference.test.ts | 2 +- .../tools/server/env-reference.ts | 0 .../tools/server/files/doc-compile-error.ts | 0 .../tools/server/files/doc-compile.test.ts | 0 .../tools/server/files/doc-compile.ts | 18 +- .../server/files/doc-compiled-store.test.ts | 2 +- .../tools/server/files/doc-compiled-store.ts | 0 .../tools/server/files/doc-extract.ts | 0 .../tools/server/files/doc-recalc.ts | 2 +- .../tools/server/files/doc-render.ts | 0 .../tools/server/files/doc-servable.test.ts | 2 +- .../tools/server/files/embedded-image-refs.ts | 0 .../server/files/file-folder-application.ts | 4 +- .../server/files/file-intent-store.test.ts | 0 .../tools/server/files/file-intent-store.ts | 0 .../tools/server/files/file-preview.test.ts | 2 +- .../tools/server/files/file-preview.ts | 4 +- .../tools/server/files/pptx-shim.ts | 0 .../tools/server/files/workspace-file.ts | 16 +- .../tools/server/generated-schema.ts | 2 +- .../tools/server/image/generate-image.ts | 14 +- .../server/knowledge/workspace-search.test.ts | 4 +- .../server/knowledge/workspace-search.ts | 4 +- .../tools/server/media/ffmpeg.test.ts | 8 +- .../tools/server/media/ffmpeg.ts | 14 +- .../tools/server/media/generate-audio.ts | 14 +- .../tools/server/media/generate-video.ts | 14 +- .../server/media/model-boundaries.test.ts | 10 +- .../tools/server/model-input.test.ts | 2 +- .../tools/server/model-input.ts | 0 .../sim/lib/mothership/tools/server/router.ts | 137 + .../tools/server/user/get-credentials.test.ts | 2 +- .../tools/server/user/get-credentials.ts | 6 +- .../tools/server/workspace-scope.ts | 0 .../tools/shared/workflow-utils.ts | 0 .../tools/streaming-args.ts | 0 .../tools/tool-activity.test.ts | 6 +- .../tools/tool-activity.ts | 0 .../tools/tool-display.test.ts | 8 +- .../tools/tool-display.ts | 22 +- .../tools/workflow-tools.test.ts | 0 .../tools/workflow-tools.ts | 4 +- .../vfs/document-style.test.ts | 2 +- .../vfs/document-style.ts | 0 .../vfs/normalize-segment.ts | 2 +- .../vfs/operations.test.ts | 4 +- .../{copilot => mothership}/vfs/operations.ts | 10 +- .../vfs/path-utils.test.ts | 2 +- .../{copilot => mothership}/vfs/path-utils.ts | 0 .../vfs/read-placeholders.ts | 0 .../vfs/resource-writer.test.ts | 2 +- .../vfs/resource-writer.ts | 6 +- .../orchestration/restore-resource.ts | 6 +- .../sim/lib/table/application/context.test.ts | 2 +- .../workspace/workspace-file-manager.ts | 8 +- apps/sim/lib/uploads/utils/doc-not-ready.ts | 2 +- .../uploads/utils/file-utils.server.test.ts | 2 +- .../lib/uploads/utils/file-utils.server.ts | 4 +- apps/sim/lib/uploads/utils/file-utils.ts | 2 +- .../lib/workflows/custom-blocks/operations.ts | 2 +- .../read-workspace-file-text.test.ts | 2 +- .../application/style-workspace-file.ts | 2 +- .../workspace-files/workspace-file-path.ts | 2 +- apps/sim/providers/runtime-context.ts | 4 +- apps/sim/sandbox-tasks/pptx-generate.ts | 2 +- apps/sim/tools/index.test.ts | 2 +- apps/sim/tools/types.ts | 2 +- bun.lock | 1 - scripts/check-tool-registry-boundary.test.ts | 2 +- scripts/check-tool-registry-boundary.ts | 2 +- scripts/generate-mship-contracts.ts | 2 +- scripts/sync-billing-protocol-contract.ts | 2 +- scripts/sync-docs-manifest.ts | 7 +- scripts/sync-metrics-contract.ts | 4 +- scripts/sync-mothership-stream-contract.ts | 4 +- scripts/sync-tool-catalog.ts | 4 +- .../sync-trace-attribute-values-contract.ts | 4 +- scripts/sync-trace-attributes-contract.ts | 4 +- scripts/sync-trace-events-contract.ts | 4 +- scripts/sync-trace-spans-contract.ts | 4 +- scripts/sync-vfs-snapshot-contract.ts | 2 +- 624 files changed, 3282 insertions(+), 38118 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/send-handoff.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/hooks/stream-protocol.ts delete mode 100644 apps/sim/lib/copilot/chat/workspace-context.test.ts delete mode 100644 apps/sim/lib/copilot/chat/workspace-context.ts delete mode 100644 apps/sim/lib/copilot/entitlements.ts delete mode 100644 apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts delete mode 100644 apps/sim/lib/copilot/request/go/file-preview-append-roundtrip.test.ts delete mode 100644 apps/sim/lib/copilot/sim-sandbox-projection.ts delete mode 100644 apps/sim/lib/copilot/tool-executor/handler-map.ts delete mode 100644 apps/sim/lib/copilot/tool-executor/register-handlers.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/access.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/deployment/context.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/deployment/manage.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/function-execute.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/function-execute.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/integration-tools.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/materialize-file.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/oauth.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/oauth.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/resources.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/resources.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/restore-resource.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/run-code.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/vfs.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/vfs.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts delete mode 100644 apps/sim/lib/copilot/tools/handlers/workflow/queries.ts delete mode 100644 apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts delete mode 100644 apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts delete mode 100644 apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts delete mode 100644 apps/sim/lib/copilot/tools/server/files/create-file.ts delete mode 100644 apps/sim/lib/copilot/tools/server/files/doc-asset-extract-pdf.ts delete mode 100644 apps/sim/lib/copilot/tools/server/files/doc-asset-extract.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/files/doc-asset-extract.ts delete mode 100644 apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts delete mode 100644 apps/sim/lib/copilot/tools/server/files/edit-content.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/files/edit-content.ts delete mode 100644 apps/sim/lib/copilot/tools/server/files/embedded-image-refs.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/files/extract-doc-assets.ts delete mode 100644 apps/sim/lib/copilot/tools/server/files/file-folders.ts delete mode 100644 apps/sim/lib/copilot/tools/server/files/rename-file.ts delete mode 100644 apps/sim/lib/copilot/tools/server/files/share-file.ts delete mode 100644 apps/sim/lib/copilot/tools/server/generated-schema.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts delete mode 100644 apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts delete mode 100644 apps/sim/lib/copilot/tools/server/other/search-online.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/other/search-online.ts delete mode 100644 apps/sim/lib/copilot/tools/server/router.ts delete mode 100644 apps/sim/lib/copilot/tools/server/table/query-user-table.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/table/query-user-table.ts delete mode 100644 apps/sim/lib/copilot/tools/server/table/table-automations.ts delete mode 100644 apps/sim/lib/copilot/tools/server/table/table-columns.ts delete mode 100644 apps/sim/lib/copilot/tools/server/table/table-enrichments.ts delete mode 100644 apps/sim/lib/copilot/tools/server/table/table-manage.ts delete mode 100644 apps/sim/lib/copilot/tools/server/table/table-rows.ts delete mode 100644 apps/sim/lib/copilot/tools/server/table/table-split.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/table/table-views.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/table/table-views.ts delete mode 100644 apps/sim/lib/copilot/tools/server/table/user-table.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/table/user-table.ts delete mode 100644 apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts delete mode 100644 apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts delete mode 100644 apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts delete mode 100644 apps/sim/lib/copilot/tools/server/workflow/query-logs.ts delete mode 100644 apps/sim/lib/copilot/vfs/custom-block-schema.test.ts delete mode 100644 apps/sim/lib/copilot/vfs/file-reader.test.ts delete mode 100644 apps/sim/lib/copilot/vfs/file-reader.ts delete mode 100644 apps/sim/lib/copilot/vfs/index.ts delete mode 100644 apps/sim/lib/copilot/vfs/serializers.test.ts delete mode 100644 apps/sim/lib/copilot/vfs/serializers.ts delete mode 100644 apps/sim/lib/copilot/vfs/service-account-gate.test.ts delete mode 100644 apps/sim/lib/copilot/vfs/workspace-vfs.test.ts delete mode 100644 apps/sim/lib/copilot/vfs/workspace-vfs.ts rename apps/sim/lib/{copilot => mothership}/application/application-adapter.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/application/application-adapter.ts (99%) rename apps/sim/lib/{copilot => mothership}/application/authorize-chat-callback.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/application/authorize-chat-callback.ts (93%) rename apps/sim/lib/{copilot => mothership}/application/error.test.ts (96%) rename apps/sim/lib/{copilot => mothership}/application/error.ts (100%) rename apps/sim/lib/{copilot => mothership}/application/execute-api-key-use-case.ts (65%) rename apps/sim/lib/{copilot => mothership}/application/execute-credential-use-case.ts (71%) rename apps/sim/lib/{copilot => mothership}/application/execute-custom-tool-use-case.test.ts (94%) rename apps/sim/lib/{copilot => mothership}/application/execute-custom-tool-use-case.ts (71%) rename apps/sim/lib/{copilot => mothership}/application/execute-file-use-case.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/application/execute-file-use-case.ts (87%) rename apps/sim/lib/{copilot => mothership}/application/execute-knowledge-use-case.ts (92%) rename apps/sim/lib/{copilot => mothership}/application/execute-log-use-case.ts (88%) rename apps/sim/lib/{copilot => mothership}/application/execute-managed-mcp-use-case.ts (76%) rename apps/sim/lib/{copilot => mothership}/application/execute-mcp-server-use-case.ts (71%) rename apps/sim/lib/{copilot => mothership}/application/execute-sandbox-use-case.test.ts (95%) rename apps/sim/lib/{copilot => mothership}/application/execute-sandbox-use-case.ts (70%) rename apps/sim/lib/{copilot => mothership}/application/execute-skill-use-case.ts (70%) rename apps/sim/lib/{copilot => mothership}/application/execute-table-use-case.test.ts (96%) rename apps/sim/lib/{copilot => mothership}/application/execute-table-use-case.ts (79%) rename apps/sim/lib/{copilot => mothership}/application/execute-workflow-use-case.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/application/execute-workflow-use-case.ts (90%) rename apps/sim/lib/{copilot => mothership}/application/load-connected-accounts.ts (88%) rename apps/sim/lib/{copilot => mothership}/application/load-search-integrations.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/application/load-search-integrations.ts (94%) rename apps/sim/lib/{copilot => mothership}/application/operations.ts (100%) rename apps/sim/lib/{copilot => mothership}/application/table-commands.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/application/table-commands.ts (95%) rename apps/sim/lib/{copilot => mothership}/assistant/tool-policy.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/assistant/tool-policy.ts (100%) rename apps/sim/lib/{copilot => mothership}/async-runs/errors.ts (100%) rename apps/sim/lib/{copilot => mothership}/async-runs/lifecycle.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/async-runs/lifecycle.ts (98%) rename apps/sim/lib/{copilot => mothership}/async-runs/repository.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/async-runs/repository.ts (98%) rename apps/sim/lib/{copilot => mothership}/async-runs/tool-identity.postgres.test.ts (96%) rename apps/sim/lib/{copilot => mothership}/auth/application-delegation.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/auth/application-delegation.ts (99%) rename apps/sim/lib/{copilot => mothership}/auth/file-delegation.test.ts (96%) rename apps/sim/lib/{copilot => mothership}/auth/file-delegation.ts (94%) rename apps/sim/lib/{copilot => mothership}/auth/permissions.test.ts (99%) rename apps/sim/lib/{copilot => mothership}/auth/permissions.ts (100%) rename apps/sim/lib/{copilot => mothership}/auth/table-delegation.test.ts (92%) rename apps/sim/lib/{copilot => mothership}/auth/table-delegation.ts (83%) rename apps/sim/lib/{copilot => mothership}/block-visibility.ts (100%) rename apps/sim/lib/{copilot => mothership}/chat-status.test.ts (96%) rename apps/sim/lib/{copilot => mothership}/chat-status.ts (100%) rename apps/sim/lib/{copilot => mothership}/chat/assistant-images.test.ts (95%) rename apps/sim/lib/{copilot => mothership}/chat/assistant-images.ts (96%) rename apps/sim/lib/{copilot => mothership}/chat/attachment-preview.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/chat/attachment-preview.ts (100%) rename apps/sim/lib/{copilot => mothership}/chat/citation-evidence.ts (100%) create mode 100644 apps/sim/lib/mothership/chat/delegation.ts rename apps/sim/lib/{copilot => mothership}/chat/desktop-capabilities.ts (100%) rename apps/sim/lib/{copilot => mothership}/chat/display-message.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/chat/display-message.ts (97%) rename apps/sim/lib/{copilot => mothership}/chat/effective-transcript.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/chat/effective-transcript.ts (97%) rename apps/sim/lib/{copilot => mothership}/chat/folder-context.ts (95%) rename apps/sim/lib/{copilot => mothership}/chat/fork-chat-files.test.ts (99%) rename apps/sim/lib/{copilot => mothership}/chat/fork-chat-files.ts (100%) rename apps/sim/lib/{copilot => mothership}/chat/lifecycle.test.ts (99%) rename apps/sim/lib/{copilot => mothership}/chat/lifecycle.ts (99%) rename apps/sim/lib/{copilot => mothership}/chat/list-mothership-chats.ts (96%) rename apps/sim/lib/{copilot => mothership}/chat/messages-store.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/chat/messages-store.ts (95%) rename apps/sim/lib/{copilot => mothership}/chat/organization-chats.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/chat/organization-chats.ts (96%) rename apps/sim/lib/{copilot => mothership}/chat/payload.test.ts (85%) rename apps/sim/lib/{copilot => mothership}/chat/payload.ts (83%) rename apps/sim/lib/{copilot => mothership}/chat/persisted-message.test.ts (99%) rename apps/sim/lib/{copilot => mothership}/chat/persisted-message.ts (98%) rename apps/sim/lib/{copilot => mothership}/chat/post.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/chat/post.ts (94%) rename apps/sim/lib/{copilot => mothership}/chat/process-contents-log-projection.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/chat/process-contents.test.ts (99%) rename apps/sim/lib/{copilot => mothership}/chat/process-contents.ts (96%) rename apps/sim/lib/{copilot => mothership}/chat/retrieval-citations.test.ts (93%) rename apps/sim/lib/{copilot => mothership}/chat/retrieval-citations.ts (100%) rename apps/sim/lib/{copilot => mothership}/chat/rewrite-file-references.ts (96%) rename apps/sim/lib/{copilot => mothership}/chat/selection-clipboard.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/chat/selection-clipboard.ts (100%) rename apps/sim/lib/{copilot => mothership}/chat/selection-context.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/chat/selection-context.ts (100%) rename apps/sim/lib/{copilot => mothership}/chat/sim-key-redaction.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/chat/sim-key-redaction.ts (98%) rename apps/sim/lib/{copilot => mothership}/chat/stream-liveness.test.ts (96%) rename apps/sim/lib/{copilot => mothership}/chat/stream-liveness.ts (98%) rename apps/sim/lib/{copilot => mothership}/chat/stream-tool-outcome.ts (93%) rename apps/sim/lib/{copilot => mothership}/chat/terminal-state.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/chat/terminal-state.ts (91%) rename apps/sim/lib/{copilot => mothership}/constants.ts (98%) rename apps/sim/lib/{copilot => mothership}/docs/docs-corpus.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/docs/docs-corpus.ts (97%) rename apps/sim/lib/{copilot => mothership}/docs/docs-path.test.ts (87%) rename apps/sim/lib/{copilot => mothership}/docs/docs-path.ts (100%) rename apps/sim/lib/{copilot => mothership}/docs/docs-search.test.ts (99%) rename apps/sim/lib/{copilot => mothership}/docs/docs-search.ts (99%) rename apps/sim/lib/{copilot => mothership}/environment-context.test.ts (95%) rename apps/sim/lib/{copilot => mothership}/environment-context.ts (100%) rename apps/sim/lib/{copilot => mothership}/generated/billing-protocol-v1.ts (100%) rename apps/sim/lib/{copilot => mothership}/generated/docs-manifest.ts (100%) rename apps/sim/lib/{copilot => mothership}/generated/metrics-v1.ts (100%) rename apps/sim/lib/{copilot => mothership}/generated/mothership-stream-v1-schema.ts (100%) rename apps/sim/lib/{copilot => mothership}/generated/mothership-stream-v1.ts (100%) create mode 100644 apps/sim/lib/mothership/generated/protocol.ts rename apps/sim/lib/{copilot => mothership}/generated/request-trace-v1.ts (100%) rename apps/sim/lib/{copilot => mothership}/generated/tool-catalog-v1.ts (100%) rename apps/sim/lib/{copilot => mothership}/generated/tool-schemas-v1.ts (100%) rename apps/sim/lib/{copilot => mothership}/generated/trace-attribute-values-v1.ts (100%) rename apps/sim/lib/{copilot => mothership}/generated/trace-attributes-v1.ts (100%) rename apps/sim/lib/{copilot => mothership}/generated/trace-events-v1.ts (100%) rename apps/sim/lib/{copilot => mothership}/generated/trace-spans-v1.ts (100%) rename apps/sim/lib/{copilot => mothership}/integration-tool-projection.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/integration-tool-projection.ts (97%) rename apps/sim/lib/{copilot => mothership}/integration-tools-invariants.test.ts (93%) rename apps/sim/lib/{copilot => mothership}/integration-tools.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/integration-tools.ts (100%) rename apps/sim/lib/{copilot => mothership}/mcp-tools.test.ts (99%) rename apps/sim/lib/{copilot => mothership}/mcp-tools.ts (95%) rename apps/sim/lib/{copilot => mothership}/persistence/tool-confirm/index.ts (96%) rename apps/sim/lib/{copilot => mothership}/persistence/tool-confirm/tool-confirm.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/persistence/tool-permission/auto-allow.ts (100%) rename apps/sim/lib/{copilot => mothership}/persistence/tool-permission/index.ts (96%) rename apps/sim/lib/{copilot => mothership}/request/context/request-context.ts (86%) rename apps/sim/lib/{copilot => mothership}/request/context/result.test.ts (86%) rename apps/sim/lib/{copilot => mothership}/request/context/result.ts (75%) rename apps/sim/lib/{copilot => mothership}/request/go/fetch.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/request/go/fetch.ts (93%) rename apps/sim/lib/{copilot => mothership}/request/go/file-preview-adapter.test.ts (93%) rename apps/sim/lib/{copilot => mothership}/request/go/file-preview-adapter.ts (98%) rename apps/sim/lib/{copilot => mothership}/request/go/parser.ts (100%) rename apps/sim/lib/{copilot => mothership}/request/go/propagation.ts (100%) rename apps/sim/lib/{copilot => mothership}/request/go/stream.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/request/go/stream.ts (96%) rename apps/sim/lib/{copilot => mothership}/request/go/tool-call-identity.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/request/go/tool-call-identity.ts (98%) rename apps/sim/lib/{copilot => mothership}/request/handlers/complete.ts (100%) rename apps/sim/lib/{copilot => mothership}/request/handlers/error.ts (100%) rename apps/sim/lib/{copilot => mothership}/request/handlers/handlers.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/request/handlers/index.ts (92%) rename apps/sim/lib/{copilot => mothership}/request/handlers/resource.ts (100%) rename apps/sim/lib/{copilot => mothership}/request/handlers/run.ts (97%) rename apps/sim/lib/{copilot => mothership}/request/handlers/session.ts (78%) rename apps/sim/lib/{copilot => mothership}/request/handlers/span.ts (98%) rename apps/sim/lib/{copilot => mothership}/request/handlers/text.ts (96%) rename apps/sim/lib/{copilot => mothership}/request/handlers/tool.ts (93%) rename apps/sim/lib/{copilot => mothership}/request/handlers/types.ts (95%) rename apps/sim/lib/{copilot => mothership}/request/http.ts (97%) rename apps/sim/lib/{copilot => mothership}/request/lifecycle/finalize.ts (91%) rename apps/sim/lib/{copilot => mothership}/request/lifecycle/headless.test.ts (95%) rename apps/sim/lib/{copilot => mothership}/request/lifecycle/headless.ts (80%) rename apps/sim/lib/{copilot => mothership}/request/lifecycle/resume-leg-context.test.ts (95%) rename apps/sim/lib/{copilot => mothership}/request/lifecycle/run.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/request/lifecycle/run.ts (95%) rename apps/sim/lib/{copilot => mothership}/request/lifecycle/start.test.ts (96%) rename apps/sim/lib/{copilot => mothership}/request/lifecycle/start.ts (95%) rename apps/sim/lib/{copilot => mothership}/request/metrics.test.ts (95%) rename apps/sim/lib/{copilot => mothership}/request/metrics.ts (94%) rename apps/sim/lib/{copilot => mothership}/request/otel.ts (97%) rename apps/sim/lib/{copilot => mothership}/request/session/abort-reason.ts (100%) rename apps/sim/lib/{copilot => mothership}/request/session/abort.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/request/session/abort.ts (97%) rename apps/sim/lib/{copilot => mothership}/request/session/buffer.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/request/session/buffer.ts (100%) rename apps/sim/lib/{copilot => mothership}/request/session/contract.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/request/session/contract.ts (98%) rename apps/sim/lib/{copilot => mothership}/request/session/event.test.ts (87%) rename apps/sim/lib/{copilot => mothership}/request/session/event.ts (100%) rename apps/sim/lib/{copilot => mothership}/request/session/explicit-abort.test.ts (85%) rename apps/sim/lib/{copilot => mothership}/request/session/explicit-abort.ts (86%) rename apps/sim/lib/{copilot => mothership}/request/session/file-preview-session-contract.ts (100%) rename apps/sim/lib/{copilot => mothership}/request/session/file-preview-session.test.ts (94%) rename apps/sim/lib/{copilot => mothership}/request/session/file-preview-session.ts (100%) rename apps/sim/lib/{copilot => mothership}/request/session/index.ts (100%) rename apps/sim/lib/{copilot => mothership}/request/session/recovery.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/request/session/recovery.ts (91%) rename apps/sim/lib/{copilot => mothership}/request/session/sse.ts (100%) rename apps/sim/lib/{copilot => mothership}/request/session/types.ts (100%) rename apps/sim/lib/{copilot => mothership}/request/session/writer.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/request/session/writer.ts (98%) rename apps/sim/lib/{copilot => mothership}/request/sse-utils.test.ts (92%) rename apps/sim/lib/{copilot => mothership}/request/sse-utils.ts (91%) rename apps/sim/lib/{copilot => mothership}/request/tool-call-state.test.ts (93%) rename apps/sim/lib/{copilot => mothership}/request/tool-call-state.ts (94%) rename apps/sim/lib/{copilot => mothership}/request/tools/billing.test.ts (95%) rename apps/sim/lib/{copilot => mothership}/request/tools/billing.ts (95%) rename apps/sim/lib/{copilot => mothership}/request/tools/client-completion-seal.server.ts (98%) rename apps/sim/lib/{copilot => mothership}/request/tools/client.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/request/tools/client.ts (96%) rename apps/sim/lib/{copilot => mothership}/request/tools/executor.test.ts (95%) rename apps/sim/lib/{copilot => mothership}/request/tools/executor.ts (96%) rename apps/sim/lib/{copilot => mothership}/request/tools/files.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/request/tools/files.ts (95%) rename apps/sim/lib/{copilot => mothership}/request/tools/permission.test.ts (93%) rename apps/sim/lib/{copilot => mothership}/request/tools/permission.ts (93%) rename apps/sim/lib/{copilot => mothership}/request/tools/permissions.ts (93%) rename apps/sim/lib/{copilot => mothership}/request/tools/resolved-secret-result.test.ts (99%) rename apps/sim/lib/{copilot => mothership}/request/tools/resolved-secret-result.ts (98%) rename apps/sim/lib/{copilot => mothership}/request/tools/resources.test.ts (89%) rename apps/sim/lib/{copilot => mothership}/request/tools/resources.ts (93%) rename apps/sim/lib/{copilot => mothership}/request/tools/tables.test.ts (96%) rename apps/sim/lib/{copilot => mothership}/request/tools/tables.ts (92%) rename apps/sim/lib/{copilot => mothership}/request/tools/workflow-client-fallback.test.ts (95%) rename apps/sim/lib/{copilot => mothership}/request/tools/workflow-client-fallback.ts (93%) rename apps/sim/lib/{copilot => mothership}/request/tools/workflow-context.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/request/tools/workflow-context.ts (93%) rename apps/sim/lib/{copilot => mothership}/request/trace.ts (98%) rename apps/sim/lib/{copilot => mothership}/request/types.ts (94%) rename apps/sim/lib/{copilot => mothership}/resource-types.ts (100%) rename apps/sim/lib/{copilot => mothership}/resources/availability.ts (95%) rename apps/sim/lib/{copilot => mothership}/resources/client-persistence-queue.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/resources/client-persistence-queue.ts (98%) rename apps/sim/lib/{copilot => mothership}/resources/extraction.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/resources/extraction.ts (98%) rename apps/sim/lib/{copilot => mothership}/resources/persistence.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/resources/persistence.ts (100%) rename apps/sim/lib/{copilot => mothership}/resources/types.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/resources/types.ts (100%) rename apps/sim/lib/{copilot => mothership}/secret-mount-policy.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/secret-mount-policy.ts (100%) rename apps/sim/lib/{copilot => mothership}/server/agent-url.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/server/agent-url.ts (96%) rename apps/sim/lib/{copilot => mothership}/server/api-keys.ts (94%) rename apps/sim/lib/{copilot => mothership}/tool-executor/executor.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/tool-executor/executor.ts (98%) rename apps/sim/lib/{copilot => mothership}/tool-executor/index.ts (100%) create mode 100644 apps/sim/lib/mothership/tool-executor/register-handlers.ts rename apps/sim/lib/{copilot => mothership}/tool-executor/router.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/tool-executor/router.ts (95%) rename apps/sim/lib/{copilot => mothership}/tool-executor/types.ts (96%) rename apps/sim/lib/{copilot => mothership}/tools/browser-protocol-contract.test.ts (95%) create mode 100644 apps/sim/lib/mothership/tools/cli-tool-display.ts rename apps/sim/lib/{copilot => mothership}/tools/client/base-tool.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/client/browser-tool-execution.test.ts (99%) rename apps/sim/lib/{copilot => mothership}/tools/client/browser-tool-execution.ts (98%) rename apps/sim/lib/{copilot => mothership}/tools/client/browser-tool-replay-ledger.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/client/browser-tool-replay-ledger.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/client/browser-tool-result.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/tools/client/browser-tool-result.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/client/completion.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/tools/client/completion.ts (95%) rename apps/sim/lib/{copilot => mothership}/tools/client/hidden-tools.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/client/hidden-tools.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/client/local-filesystem.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/tools/client/local-filesystem.ts (97%) rename apps/sim/lib/{copilot => mothership}/tools/client/read-block.test.ts (95%) rename apps/sim/lib/{copilot => mothership}/tools/client/read-block.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/client/run-tool-execution.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/client/run-tool-execution.ts (98%) rename apps/sim/lib/{copilot => mothership}/tools/client/store-utils.test.ts (99%) rename apps/sim/lib/{copilot => mothership}/tools/client/store-utils.ts (94%) rename apps/sim/lib/{copilot => mothership}/tools/client/terminal-tool-execution.test.ts (89%) rename apps/sim/lib/{copilot => mothership}/tools/client/terminal-tool-execution.ts (96%) rename apps/sim/lib/{copilot => mothership}/tools/client/tool-call-state.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/client/trace-context.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/descriptions.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/descriptions.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/handlers/context.ts (92%) rename apps/sim/lib/{copilot => mothership}/tools/handlers/param-types.ts (99%) rename apps/sim/lib/{copilot => mothership}/tools/handlers/workflow/mutations.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/tools/handlers/workflow/mutations.ts (97%) rename apps/sim/lib/{copilot => mothership}/tools/handlers/workflow/withheld-run-result.test.ts (95%) rename apps/sim/lib/{copilot => mothership}/tools/local-filesystem.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/permissions.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/tools/permissions.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/registry/server-tool-adapter.test.ts (90%) rename apps/sim/lib/{copilot => mothership}/tools/registry/server-tool-adapter.ts (87%) rename apps/sim/lib/{copilot => mothership}/tools/retired-tools.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/secret-mount-materializer.server.test.ts (99%) rename apps/sim/lib/{copilot => mothership}/tools/secret-mount-materializer.server.ts (99%) rename apps/sim/lib/{copilot => mothership}/tools/server/base-tool.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/server/docs/search-docs-dispatch.test.ts (79%) rename apps/sim/lib/{copilot => mothership}/tools/server/docs/search-docs.test.ts (95%) rename apps/sim/lib/{copilot => mothership}/tools/server/docs/search-docs.ts (87%) rename apps/sim/lib/{copilot => mothership}/tools/server/env-reference.test.ts (96%) rename apps/sim/lib/{copilot => mothership}/tools/server/env-reference.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/server/files/doc-compile-error.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/server/files/doc-compile.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/server/files/doc-compile.ts (99%) rename apps/sim/lib/{copilot => mothership}/tools/server/files/doc-compiled-store.test.ts (99%) rename apps/sim/lib/{copilot => mothership}/tools/server/files/doc-compiled-store.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/server/files/doc-extract.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/server/files/doc-recalc.ts (97%) rename apps/sim/lib/{copilot => mothership}/tools/server/files/doc-render.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/server/files/doc-servable.test.ts (99%) rename apps/sim/lib/{copilot => mothership}/tools/server/files/embedded-image-refs.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/server/files/file-folder-application.ts (85%) rename apps/sim/lib/{copilot => mothership}/tools/server/files/file-intent-store.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/server/files/file-intent-store.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/server/files/file-preview.test.ts (97%) rename apps/sim/lib/{copilot => mothership}/tools/server/files/file-preview.ts (97%) rename apps/sim/lib/{copilot => mothership}/tools/server/files/pptx-shim.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/server/files/workspace-file.ts (98%) rename apps/sim/lib/{copilot => mothership}/tools/server/generated-schema.ts (95%) rename apps/sim/lib/{copilot => mothership}/tools/server/image/generate-image.ts (95%) rename apps/sim/lib/{copilot => mothership}/tools/server/knowledge/workspace-search.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/tools/server/knowledge/workspace-search.ts (98%) rename apps/sim/lib/{copilot => mothership}/tools/server/media/ffmpeg.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/tools/server/media/ffmpeg.ts (97%) rename apps/sim/lib/{copilot => mothership}/tools/server/media/generate-audio.ts (94%) rename apps/sim/lib/{copilot => mothership}/tools/server/media/generate-video.ts (93%) rename apps/sim/lib/{copilot => mothership}/tools/server/media/model-boundaries.test.ts (94%) rename apps/sim/lib/{copilot => mothership}/tools/server/model-input.test.ts (95%) rename apps/sim/lib/{copilot => mothership}/tools/server/model-input.ts (100%) create mode 100644 apps/sim/lib/mothership/tools/server/router.ts rename apps/sim/lib/{copilot => mothership}/tools/server/user/get-credentials.test.ts (99%) rename apps/sim/lib/{copilot => mothership}/tools/server/user/get-credentials.ts (97%) rename apps/sim/lib/{copilot => mothership}/tools/server/workspace-scope.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/shared/workflow-utils.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/streaming-args.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/tool-activity.test.ts (96%) rename apps/sim/lib/{copilot => mothership}/tools/tool-activity.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/tool-display.test.ts (99%) rename apps/sim/lib/{copilot => mothership}/tools/tool-display.ts (98%) rename apps/sim/lib/{copilot => mothership}/tools/workflow-tools.test.ts (100%) rename apps/sim/lib/{copilot => mothership}/tools/workflow-tools.ts (99%) rename apps/sim/lib/{copilot => mothership}/vfs/document-style.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/vfs/document-style.ts (100%) rename apps/sim/lib/{copilot => mothership}/vfs/normalize-segment.ts (84%) rename apps/sim/lib/{copilot => mothership}/vfs/operations.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/vfs/operations.ts (99%) rename apps/sim/lib/{copilot => mothership}/vfs/path-utils.test.ts (98%) rename apps/sim/lib/{copilot => mothership}/vfs/path-utils.ts (100%) rename apps/sim/lib/{copilot => mothership}/vfs/read-placeholders.ts (100%) rename apps/sim/lib/{copilot => mothership}/vfs/resource-writer.test.ts (99%) rename apps/sim/lib/{copilot => mothership}/vfs/resource-writer.ts (98%) diff --git a/apps/sim/app/_shell/paste-admission-guard.test.tsx b/apps/sim/app/_shell/paste-admission-guard.test.tsx index 50b3d9dc0dc..a2d729665ca 100644 --- a/apps/sim/app/_shell/paste-admission-guard.test.tsx +++ b/apps/sim/app/_shell/paste-admission-guard.test.tsx @@ -11,7 +11,7 @@ vi.mock('@sim/emcn', () => ({ useToast: () => ({ toast: { warning } }), })) -import { SIM_SELECTION_MIME } from '@/lib/copilot/chat/selection-clipboard' +import { SIM_SELECTION_MIME } from '@/lib/mothership/chat/selection-clipboard' import { PasteAdmissionGuard } from '@/app/_shell/paste-admission-guard' let host: HTMLDivElement diff --git a/apps/sim/app/_shell/paste-admission-guard.tsx b/apps/sim/app/_shell/paste-admission-guard.tsx index 2ee15ab9c06..2555c1d698c 100644 --- a/apps/sim/app/_shell/paste-admission-guard.tsx +++ b/apps/sim/app/_shell/paste-admission-guard.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react' import { useToast } from '@sim/emcn' import { assessTextPaste, formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste' -import { readSelectionContextFromClipboard } from '@/lib/copilot/chat/selection-clipboard' +import { readSelectionContextFromClipboard } from '@/lib/mothership/chat/selection-clipboard' const EDITABLE_TARGET_SELECTOR = 'input:not([type="file"]):not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="hidden"]), textarea, [contenteditable]:not([contenteditable="false"]), .monaco-editor, .xterm' diff --git a/apps/sim/app/api/admin/mothership/route.ts b/apps/sim/app/api/admin/mothership/route.ts index d15d28a6f92..d4e88b8459d 100644 --- a/apps/sim/app/api/admin/mothership/route.ts +++ b/apps/sim/app/api/admin/mothership/route.ts @@ -7,9 +7,9 @@ import { adminMothershipQuerySchema } from '@/lib/api/contracts/mothership-chats import { mothershipEnvironmentSchema } from '@/lib/api/contracts/user' import { searchParamsToObject, validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getMothershipBaseURL } from '@/lib/mothership/server/agent-url' const ENV_URLS: Record = { dev: env.MOTHERSHIP_DEV_URL, diff --git a/apps/sim/app/api/billing/update-cost/route.test.ts b/apps/sim/app/api/billing/update-cost/route.test.ts index 2b38862261f..d4b402605ec 100644 --- a/apps/sim/app/api/billing/update-cost/route.test.ts +++ b/apps/sim/app/api/billing/update-cost/route.test.ts @@ -39,11 +39,11 @@ const { }, })) -vi.mock('@/lib/copilot/request/http', () => ({ +vi.mock('@/lib/mothership/request/http', () => ({ checkInternalApiKey: mockCheckInternalApiKey, })) -vi.mock('@/lib/copilot/request/otel', () => ({ +vi.mock('@/lib/mothership/request/otel', () => ({ withIncomingGoSpan: ( _headers: unknown, _span: unknown, diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index d9524b4a6f0..0f789f87c99 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -28,15 +28,15 @@ import { checkAndBillPayerOverageThreshold, ThresholdSettlementError, } from '@/lib/billing/threshold-billing' -import { BILLING_CALLBACK_OUTCOME } from '@/lib/copilot/generated/billing-protocol-v1' -import { BillingRouteOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { checkInternalApiKey } from '@/lib/copilot/request/http' -import { withIncomingGoSpan } from '@/lib/copilot/request/otel' import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { BILLING_CALLBACK_OUTCOME } from '@/lib/mothership/generated/billing-protocol-v1' +import { BillingRouteOutcome } from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { checkInternalApiKey } from '@/lib/mothership/request/http' +import { withIncomingGoSpan } from '@/lib/mothership/request/otel' const logger = createLogger('BillingUpdateCostAPI') const RETRYABLE_SETTLEMENT_RESPONSE = { diff --git a/apps/sim/app/api/cli/auth/poll/route.test.ts b/apps/sim/app/api/cli/auth/poll/route.test.ts index 1e3ff967414..c2fd37648c8 100644 --- a/apps/sim/app/api/cli/auth/poll/route.test.ts +++ b/apps/sim/app/api/cli/auth/poll/route.test.ts @@ -28,7 +28,7 @@ vi.mock('@/lib/cli-auth/approval-store', () => ({ releaseMint: mockReleaseMint, })) -vi.mock('@/lib/copilot/server/api-keys', () => ({ +vi.mock('@/lib/mothership/server/api-keys', () => ({ generateCopilotApiKey: mockGenerateCopilotApiKey, CopilotApiKeyError: class extends Error {}, })) diff --git a/apps/sim/app/api/cli/auth/poll/route.ts b/apps/sim/app/api/cli/auth/poll/route.ts index dff2be3a62a..19d9646bbb3 100644 --- a/apps/sim/app/api/cli/auth/poll/route.ts +++ b/apps/sim/app/api/cli/auth/poll/route.ts @@ -8,10 +8,10 @@ import { } from '@/lib/api-key/orchestration' import type { ApprovalGrant } from '@/lib/cli-auth/approval-store' import { completeApproval, pollApproval, releaseMint } from '@/lib/cli-auth/approval-store' -import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/api-keys' import { enforceIpRateLimit } from '@/lib/core/rate-limiter' import type { TokenBucketConfig } from '@/lib/core/rate-limiter/storage' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/mothership/server/api-keys' const logger = createLogger('CliAuthPollAPI') diff --git a/apps/sim/app/api/copilot/api-keys/generate/route.ts b/apps/sim/app/api/copilot/api-keys/generate/route.ts index 3fe5e1d7db8..e19c9ef0451 100644 --- a/apps/sim/app/api/copilot/api-keys/generate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/generate/route.ts @@ -2,8 +2,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { generateCopilotApiKeyContract } from '@/lib/api/contracts' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/api-keys' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/mothership/server/api-keys' export const POST = withRouteHandler(async (req: NextRequest) => { const session = await getSession() diff --git a/apps/sim/app/api/copilot/api-keys/route.test.ts b/apps/sim/app/api/copilot/api-keys/route.test.ts index d5bb8b49a1f..a3add5cbe8b 100644 --- a/apps/sim/app/api/copilot/api-keys/route.test.ts +++ b/apps/sim/app/api/copilot/api-keys/route.test.ts @@ -12,14 +12,14 @@ const { mockFetch, mockGetMothershipBaseURL } = vi.hoisted(() => ({ mockGetMothershipBaseURL: vi.fn(), })) -vi.mock('@/lib/copilot/constants', () => ({ +vi.mock('@/lib/mothership/constants', () => ({ SIM_AGENT_API_URL_DEFAULT: 'https://agent.sim.example.com', SIM_AGENT_API_URL: 'https://agent.sim.example.com', COPILOT_MODES: ['ask', 'build', 'plan'] as const, COPILOT_REQUEST_MODES: ['ask', 'build', 'plan', 'agent'] as const, })) -vi.mock('@/lib/copilot/server/agent-url', () => ({ +vi.mock('@/lib/mothership/server/agent-url', () => ({ getMothershipBaseURL: mockGetMothershipBaseURL, })) diff --git a/apps/sim/app/api/copilot/api-keys/route.ts b/apps/sim/app/api/copilot/api-keys/route.ts index a26b4cad06b..ba7c5915f99 100644 --- a/apps/sim/app/api/copilot/api-keys/route.ts +++ b/apps/sim/app/api/copilot/api-keys/route.ts @@ -1,12 +1,12 @@ import { type NextRequest, NextResponse } from 'next/server' import { deleteCopilotApiKeyQuerySchema } from '@/lib/api/contracts' import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CopilotApiKeyError, deleteCopilotApiKey, listCopilotApiKeys, -} from '@/lib/copilot/server/api-keys' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +} from '@/lib/mothership/server/api-keys' function errorResponse(error: unknown, fallback: string): NextResponse { const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts index 64ced142086..271d5725cda 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.test.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.test.ts @@ -125,20 +125,20 @@ vi.mock('@/lib/billing/core/usage-log', () => ({ deriveBillingContext: mockDeriveBillingContext, })) -vi.mock('@/lib/copilot/application/authorize-chat-callback', () => ({ +vi.mock('@/lib/mothership/application/authorize-chat-callback', () => ({ authorizeCopilotChatCallback: mockAuthorizeCallback, checkCopilotContinuationBilling: mockCheckContinuationBilling, })) -vi.mock('@/lib/copilot/chat/organization-chats', () => ({ +vi.mock('@/lib/mothership/chat/organization-chats', () => ({ authorizeOrganizationChatDelegation: { execute: mockAuthorizeOrganizationChat }, })) -vi.mock('@/lib/copilot/request/http', () => ({ +vi.mock('@/lib/mothership/request/http', () => ({ checkInternalApiKey: mockCheckInternalApiKey, })) -vi.mock('@/lib/copilot/request/otel', () => ({ +vi.mock('@/lib/mothership/request/otel', () => ({ withIncomingGoSpan: ( _headers: unknown, _span: unknown, diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.ts b/apps/sim/app/api/copilot/api-keys/validate/route.ts index 174a22250af..75cd2a498d1 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.ts @@ -23,16 +23,17 @@ import { import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { isEnterprisePlan } from '@/lib/billing/core/subscription' import { deriveBillingContext } from '@/lib/billing/core/usage-log' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { authorizeCopilotChatCallback, type CopilotContinuationBilling, checkCopilotContinuationBilling, -} from '@/lib/copilot/application/authorize-chat-callback' +} from '@/lib/mothership/application/authorize-chat-callback' import { COPILOT_APPLICATION_DELEGATION_TTL_MS, createTrustedOrganizationCopilotPrincipal, -} from '@/lib/copilot/auth/application-delegation' -import { authorizeOrganizationChatDelegation } from '@/lib/copilot/chat/organization-chats' +} from '@/lib/mothership/auth/application-delegation' +import { authorizeOrganizationChatDelegation } from '@/lib/mothership/chat/organization-chats' import { BILLING_ACCOUNT_DECISION_HEADER, BILLING_ATTRIBUTION_HEADER, @@ -41,15 +42,14 @@ import { COPILOT_BILLING_PROTOCOL_HEADER, COPILOT_VALIDATION_PURPOSE, type CopilotBillingProtocol, -} from '@/lib/copilot/generated/billing-protocol-v1' -import { CopilotValidateOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { checkInternalApiKey } from '@/lib/copilot/request/http' -import { withIncomingGoSpan } from '@/lib/copilot/request/otel' +} from '@/lib/mothership/generated/billing-protocol-v1' +import { CopilotValidateOutcome } from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { checkInternalApiKey } from '@/lib/mothership/request/http' +import { withIncomingGoSpan } from '@/lib/mothership/request/otel' import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotApiKeysValidate') diff --git a/apps/sim/app/api/copilot/byok/route.ts b/apps/sim/app/api/copilot/byok/route.ts index 35355fca914..456546ef450 100644 --- a/apps/sim/app/api/copilot/byok/route.ts +++ b/apps/sim/app/api/copilot/byok/route.ts @@ -10,10 +10,10 @@ import { } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' -import { getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { env } from '@/lib/core/config/env' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { SIM_AGENT_API_URL } from '@/lib/mothership/constants' +import { getMothershipSourceEnvHeaders } from '@/lib/mothership/server/agent-url' /** * Enterprise BYOK key management for the current workspace's mothership. diff --git a/apps/sim/app/api/copilot/byok/validate/route.ts b/apps/sim/app/api/copilot/byok/validate/route.ts index 0a3a949f25e..7d88ea56a46 100644 --- a/apps/sim/app/api/copilot/byok/validate/route.ts +++ b/apps/sim/app/api/copilot/byok/validate/route.ts @@ -3,8 +3,8 @@ import { type NextRequest, NextResponse } from 'next/server' import { validateCopilotByokContract } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription' -import { checkInternalApiKey } from '@/lib/copilot/request/http' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkInternalApiKey } from '@/lib/mothership/request/http' import { verifyEffectiveSuperUser } from '@/lib/permissions/super-user' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' diff --git a/apps/sim/app/api/copilot/chat/abort/route.test.ts b/apps/sim/app/api/copilot/chat/abort/route.test.ts index ace30ab46d8..11002e1989a 100644 --- a/apps/sim/app/api/copilot/chat/abort/route.test.ts +++ b/apps/sim/app/api/copilot/chat/abort/route.test.ts @@ -32,22 +32,22 @@ const { } }) -vi.mock('@/lib/copilot/chat/lifecycle', () => ({ +vi.mock('@/lib/mothership/chat/lifecycle', () => ({ getAccessibleCopilotChatForCancellation: mockGetAccessibleChat, })) -vi.mock('@/lib/copilot/request/http', () => ({ +vi.mock('@/lib/mothership/request/http', () => ({ authenticateCopilotRequestSessionOnly: mockAuthenticate, })) -vi.mock('@/lib/copilot/async-runs/repository', () => ({ +vi.mock('@/lib/mothership/async-runs/repository', () => ({ getLatestRunForStream: mockGetLatestRunForStream, })) -vi.mock('@/lib/copilot/request/session', () => ({ +vi.mock('@/lib/mothership/request/session', () => ({ abortActiveStream: mockAbortActiveStream, waitForPendingChatStream: mockWaitForPendingChatStream, releasePendingChatStream: mockReleasePendingChatStream, })) -vi.mock('@/lib/copilot/request/session/explicit-abort', () => ({ +vi.mock('@/lib/mothership/request/session/explicit-abort', () => ({ requestExplicitStreamAbort: mockRequestExplicitStreamAbort, })) diff --git a/apps/sim/app/api/copilot/chat/abort/route.ts b/apps/sim/app/api/copilot/chat/abort/route.ts index 420e5b6eeda..2d3a12856c1 100644 --- a/apps/sim/app/api/copilot/chat/abort/route.ts +++ b/apps/sim/app/api/copilot/chat/abort/route.ts @@ -1,22 +1,22 @@ +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { copilotChatAbortBodySchema } from '@/lib/api/contracts/copilot' import { validationErrorResponse } from '@/lib/api/server' -import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' -import { getAccessibleCopilotChatForCancellation } from '@/lib/copilot/chat/lifecycle' -import { CopilotAbortOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http' -import { withCopilotSpan, withIncomingGoSpan } from '@/lib/copilot/request/otel' +import { getLatestRunForStream } from '@/lib/mothership/async-runs/repository' +import { getAccessibleCopilotChatForCancellation } from '@/lib/mothership/chat/lifecycle' +import { CopilotAbortOutcome } from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { authenticateCopilotRequestSessionOnly } from '@/lib/mothership/request/http' +import { withCopilotSpan, withIncomingGoSpan } from '@/lib/mothership/request/otel' import { abortActiveStream, releasePendingChatStream, waitForPendingChatStream, -} from '@/lib/copilot/request/session' -import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +} from '@/lib/mothership/request/session' +import { requestExplicitStreamAbort } from '@/lib/mothership/request/session/explicit-abort' const logger = createLogger('CopilotChatAbortAPI') const GO_EXPLICIT_ABORT_TIMEOUT_MS = 3000 diff --git a/apps/sim/app/api/copilot/chat/delete/route.test.ts b/apps/sim/app/api/copilot/chat/delete/route.test.ts index 6783fdbd265..a4c95f76711 100644 --- a/apps/sim/app/api/copilot/chat/delete/route.test.ts +++ b/apps/sim/app/api/copilot/chat/delete/route.test.ts @@ -19,12 +19,12 @@ const { mockGetAccessibleCopilotChatAuth: vi.fn(), })) -vi.mock('@/lib/copilot/chat/lifecycle', () => ({ +vi.mock('@/lib/mothership/chat/lifecycle', () => ({ getAccessibleCopilotChat: mockGetAccessibleCopilotChat, getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChatAuth, })) -vi.mock('@/lib/copilot/chat-status', () => ({ +vi.mock('@/lib/mothership/chat-status', () => ({ chatPubSub: { publishStatusChanged: vi.fn() }, })) diff --git a/apps/sim/app/api/copilot/chat/delete/route.ts b/apps/sim/app/api/copilot/chat/delete/route.ts index a0666d2c6eb..6239c4a6ba2 100644 --- a/apps/sim/app/api/copilot/chat/delete/route.ts +++ b/apps/sim/app/api/copilot/chat/delete/route.ts @@ -6,9 +6,9 @@ import { type NextRequest, NextResponse } from 'next/server' import { deleteCopilotChatContract } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' -import { chatPubSub } from '@/lib/copilot/chat-status' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getAccessibleCopilotChatAuth } from '@/lib/mothership/chat/lifecycle' +import { chatPubSub } from '@/lib/mothership/chat-status' const logger = createLogger('DeleteChatAPI') diff --git a/apps/sim/app/api/copilot/chat/queries.ts b/apps/sim/app/api/copilot/chat/queries.ts index e1adee9fac2..105bcac4011 100644 --- a/apps/sim/app/api/copilot/chat/queries.ts +++ b/apps/sim/app/api/copilot/chat/queries.ts @@ -5,20 +5,20 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/work import { toError } from '@sim/utils/errors' import { and, desc, eq, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' -import { buildEffectiveChatTranscript } from '@/lib/copilot/chat/effective-transcript' -import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' -import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' +import { getLatestRunForStream } from '@/lib/mothership/async-runs/repository' +import { buildEffectiveChatTranscript } from '@/lib/mothership/chat/effective-transcript' +import { getAccessibleCopilotChat } from '@/lib/mothership/chat/lifecycle' +import { normalizeMessage } from '@/lib/mothership/chat/persisted-message' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, createForbiddenResponse, createInternalServerErrorResponse, createUnauthorizedResponse, -} from '@/lib/copilot/request/http' -import { readFilePreviewSessions } from '@/lib/copilot/request/session' -import { readEvents } from '@/lib/copilot/request/session/buffer' -import { toStreamBatchEvent } from '@/lib/copilot/request/session/types' +} from '@/lib/mothership/request/http' +import { readFilePreviewSessions } from '@/lib/mothership/request/session' +import { readEvents } from '@/lib/mothership/request/session/buffer' +import { toStreamBatchEvent } from '@/lib/mothership/request/session/types' import { assertActiveWorkspaceAccess, isWorkspaceAccessDeniedError, diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index b367affa0c6..cc5f493dd8c 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -9,25 +9,25 @@ import { reorderCopilotChatResourcesContract, } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, createInternalServerErrorResponse, createNotFoundResponse, createUnauthorizedResponse, -} from '@/lib/copilot/request/http' +} from '@/lib/mothership/request/http' import { type ChatResource, serializeChatResourceWrite, setChatResourceTxTimeouts, -} from '@/lib/copilot/resources/persistence' -import type { MothershipResourceUpdate } from '@/lib/copilot/resources/types' +} from '@/lib/mothership/resources/persistence' +import type { MothershipResourceUpdate } from '@/lib/mothership/resources/types' import { mergeChatResource, reorderStoredChatResources, sanitizeChatResources, -} from '@/lib/copilot/resources/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +} from '@/lib/mothership/resources/types' const logger = createLogger('CopilotChatResourcesAPI') diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 4a5c9ae2e73..2a342a88243 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -1,7 +1,7 @@ import type { NextRequest } from 'next/server' import { copilotChatGetContract } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' -import { handleUnifiedChatPost } from '@/lib/copilot/chat/post' +import { handleUnifiedChatPost } from '@/lib/mothership/chat/post' import { GET as getChat } from '@/app/api/copilot/chat/queries' export const maxDuration = 3600 diff --git a/apps/sim/app/api/copilot/chat/stop/route.test.ts b/apps/sim/app/api/copilot/chat/stop/route.test.ts index 182606b3ab3..381c1f9f5ad 100644 --- a/apps/sim/app/api/copilot/chat/stop/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stop/route.test.ts @@ -12,15 +12,15 @@ const { mockAppendCopilotChatMessages, mockPublishStatusChanged, mockGetAccessib mockPublishStatusChanged: vi.fn(), })) -vi.mock('@/lib/copilot/chat/lifecycle', () => ({ +vi.mock('@/lib/mothership/chat/lifecycle', () => ({ getAccessibleCopilotChatAuth: mockGetAccessibleChat, })) -vi.mock('@/lib/copilot/chat/messages-store', () => ({ +vi.mock('@/lib/mothership/chat/messages-store', () => ({ appendCopilotChatMessages: mockAppendCopilotChatMessages, })) -vi.mock('@/lib/copilot/chat-status', () => ({ +vi.mock('@/lib/mothership/chat-status', () => ({ publishChatStatusChanged: mockPublishStatusChanged, })) diff --git a/apps/sim/app/api/copilot/chat/stop/route.ts b/apps/sim/app/api/copilot/chat/stop/route.ts index 29cf8900229..7dbb5159c31 100644 --- a/apps/sim/app/api/copilot/chat/stop/route.ts +++ b/apps/sim/app/api/copilot/chat/stop/route.ts @@ -4,22 +4,22 @@ import { type NextRequest, NextResponse } from 'next/server' import { copilotChatStopContract } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' +import { getAccessibleCopilotChatAuth } from '@/lib/mothership/chat/lifecycle' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { normalizeMessage, type PersistedMessage, withStoppedContentBlock, -} from '@/lib/copilot/chat/persisted-message' -import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state' -import { publishChatStatusChanged } from '@/lib/copilot/chat-status' +} from '@/lib/mothership/chat/persisted-message' +import { finalizeAssistantTurn } from '@/lib/mothership/chat/terminal-state' +import { publishChatStatusChanged } from '@/lib/mothership/chat-status' import { CopilotChatFinalizeOutcome, CopilotStopOutcome, -} from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { withIncomingGoSpan } from '@/lib/copilot/request/otel' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +} from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { withIncomingGoSpan } from '@/lib/mothership/request/otel' const logger = createLogger('CopilotChatStopAPI') diff --git a/apps/sim/app/api/copilot/chat/stream/route.test.ts b/apps/sim/app/api/copilot/chat/stream/route.test.ts index e93ca3121a1..7d7342ff655 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.test.ts @@ -8,7 +8,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, -} from '@/lib/copilot/generated/mothership-stream-v1' +} from '@/lib/mothership/generated/mothership-stream-v1' const { mockGetAccessibleChat, @@ -28,11 +28,11 @@ vi.mock('@/lib/copilot/chat/lifecycle', () => ({ getAccessibleCopilotChatAuth: mockGetAccessibleChat, })) -vi.mock('@/lib/copilot/async-runs/repository', () => ({ +vi.mock('@/lib/mothership/async-runs/repository', () => ({ getLatestRunForStream, })) -vi.mock('@/lib/copilot/request/session', () => ({ +vi.mock('@/lib/mothership/request/session', () => ({ readEvents, readFilePreviewSessions, checkForReplayGap, @@ -53,7 +53,7 @@ vi.mock('@/lib/copilot/request/session', () => ({ }, })) -vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) +vi.mock('@/lib/mothership/request/http', () => copilotHttpMock) import { GET } from './route' diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index 1a56c7f8435..ec92c750c16 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -6,21 +6,22 @@ import { sleep } from '@sim/utils/helpers' import { type NextRequest, NextResponse } from 'next/server' import { copilotChatStreamContract } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' -import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' -import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getLatestRunForStream } from '@/lib/mothership/async-runs/repository' +import { getAccessibleCopilotChatAuth } from '@/lib/mothership/chat/lifecycle' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, -} from '@/lib/copilot/generated/mothership-stream-v1' +} from '@/lib/mothership/generated/mothership-stream-v1' import { CopilotResumeOutcome, CopilotTransport, -} from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { contextFromRequestHeaders } from '@/lib/copilot/request/go/propagation' -import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http' -import { getCopilotTracer, markSpanForError } from '@/lib/copilot/request/otel' +} from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { contextFromRequestHeaders } from '@/lib/mothership/request/go/propagation' +import { authenticateCopilotRequestSessionOnly } from '@/lib/mothership/request/http' +import { getCopilotTracer, markSpanForError } from '@/lib/mothership/request/otel' import { checkForReplayGap, createEvent, @@ -28,10 +29,9 @@ import { readEvents, readFilePreviewSessions, SSE_RESPONSE_HEADERS, -} from '@/lib/copilot/request/session' -import { toStreamBatchEvent } from '@/lib/copilot/request/session/types' +} from '@/lib/mothership/request/session' +import { toStreamBatchEvent } from '@/lib/mothership/request/session/types' import { encodeSSEComment } from '@/lib/core/utils/sse' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const maxDuration = 3600 diff --git a/apps/sim/app/api/copilot/chats/route.test.ts b/apps/sim/app/api/copilot/chats/route.test.ts index b0d55f718a6..4a6603f763c 100644 --- a/apps/sim/app/api/copilot/chats/route.test.ts +++ b/apps/sim/app/api/copilot/chats/route.test.ts @@ -13,7 +13,7 @@ import { } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) +vi.mock('@/lib/mothership/request/http', () => copilotHttpMock) vi.mock('@/lib/workspaces/utils', () => ({ listAccessibleWorkspaceRowsForUser: vi diff --git a/apps/sim/app/api/copilot/chats/route.ts b/apps/sim/app/api/copilot/chats/route.ts index 1c0c5761388..67af8b2f4b6 100644 --- a/apps/sim/app/api/copilot/chats/route.ts +++ b/apps/sim/app/api/copilot/chats/route.ts @@ -6,17 +6,17 @@ import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { createWorkflowCopilotChatContract } from '@/lib/api/contracts/copilot' import { parseRequest, validationErrorResponse } from '@/lib/api/server' -import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' -import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' -import { chatPubSub } from '@/lib/copilot/chat-status' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveOrCreateChat } from '@/lib/mothership/chat/lifecycle' +import { reconcileChatStreamMarkers } from '@/lib/mothership/chat/stream-liveness' +import { chatPubSub } from '@/lib/mothership/chat-status' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, createForbiddenResponse, createInternalServerErrorResponse, createUnauthorizedResponse, -} from '@/lib/copilot/request/http' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +} from '@/lib/mothership/request/http' import { assertActiveWorkspaceAccess, isWorkspaceAccessDeniedError, diff --git a/apps/sim/app/api/copilot/confirm/route.test.ts b/apps/sim/app/api/copilot/confirm/route.test.ts index 4e6cf0f262e..4e20af3b501 100644 --- a/apps/sim/app/api/copilot/confirm/route.test.ts +++ b/apps/sim/app/api/copilot/confirm/route.test.ts @@ -27,9 +27,9 @@ const { getTrustedWorkflowToolExecution: vi.fn(), })) -vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) +vi.mock('@/lib/mothership/request/http', () => copilotHttpMock) -vi.mock('@/lib/copilot/async-runs/repository', () => ({ +vi.mock('@/lib/mothership/async-runs/repository', () => ({ getAsyncToolCall, getRunSegment, completeAsyncToolCall, @@ -40,7 +40,7 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ claimedBy?.startsWith('workflow:') ? claimedBy.slice('workflow:'.length) : undefined, })) -vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({ +vi.mock('@/lib/mothership/persistence/tool-confirm', () => ({ publishToolConfirmation, })) diff --git a/apps/sim/app/api/copilot/confirm/route.ts b/apps/sim/app/api/copilot/confirm/route.ts index 40001f5392b..d3be653f392 100644 --- a/apps/sim/app/api/copilot/confirm/route.ts +++ b/apps/sim/app/api/copilot/confirm/route.ts @@ -6,6 +6,7 @@ import { isPlainRecord } from '@sim/utils/object' import { type NextRequest, NextResponse } from 'next/server' import { copilotConfirmContract } from '@/lib/api/contracts/copilot' import { parseRequest, validationErrorResponse } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { ASYNC_TOOL_CONFIRMATION_STATUS, ASYNC_TOOL_STATUS, @@ -15,7 +16,7 @@ import { isDeliveredAsyncStatus, isTerminalAsyncStatus, isWorkflowToolExecutionClaimable, -} from '@/lib/copilot/async-runs/lifecycle' +} from '@/lib/mothership/async-runs/lifecycle' import { completeAsyncToolCall, completeClaimedAsyncToolCall, @@ -24,23 +25,23 @@ import { getAsyncToolCall, getClaimedWorkflowExecutionId, getRunSegment, -} from '@/lib/copilot/async-runs/repository' -import { CopilotConfirmOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { publishToolConfirmation } from '@/lib/copilot/persistence/tool-confirm' +} from '@/lib/mothership/async-runs/repository' +import { CopilotConfirmOutcome } from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { publishToolConfirmation } from '@/lib/mothership/persistence/tool-confirm' import { authenticateCopilotRequestSessionOnly, createInternalServerErrorResponse, createNotFoundResponse, createRequestTracker, createUnauthorizedResponse, -} from '@/lib/copilot/request/http' -import { withIncomingGoSpan } from '@/lib/copilot/request/otel' +} from '@/lib/mothership/request/http' +import { withIncomingGoSpan } from '@/lib/mothership/request/otel' import { retainSealedClientToolContext, sealClientToolCompletion, -} from '@/lib/copilot/request/tools/client-completion-seal.server' +} from '@/lib/mothership/request/tools/client-completion-seal.server' import { type AsyncWorkflowDeploymentError, createStructuralWorkflowToolCompletionData, @@ -50,8 +51,7 @@ import { getWorkflowToolConfirmationStatus, isWorkflowToolName, resolveWorkflowToolTargetId, -} from '@/lib/copilot/tools/workflow-tools' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +} from '@/lib/mothership/tools/workflow-tools' import { getTrustedWorkflowToolExecution } from '@/lib/workflows/executor/execution-state' const logger = createLogger('CopilotConfirmAPI') diff --git a/apps/sim/app/api/copilot/feedback/route.test.ts b/apps/sim/app/api/copilot/feedback/route.test.ts index e73cc2c8644..eb122ab0c32 100644 --- a/apps/sim/app/api/copilot/feedback/route.test.ts +++ b/apps/sim/app/api/copilot/feedback/route.test.ts @@ -7,7 +7,7 @@ import { copilotHttpMock, copilotHttpMockFns, dbChainMockFns, resetDbChainMock } import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) +vi.mock('@/lib/mothership/request/http', () => copilotHttpMock) import { GET, POST } from '@/app/api/copilot/feedback/route' diff --git a/apps/sim/app/api/copilot/feedback/route.ts b/apps/sim/app/api/copilot/feedback/route.ts index e14cff30b49..78e2edd0b2f 100644 --- a/apps/sim/app/api/copilot/feedback/route.ts +++ b/apps/sim/app/api/copilot/feedback/route.ts @@ -6,13 +6,13 @@ import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { submitCopilotFeedbackContract } from '@/lib/api/contracts' import { parseRequest, validationErrorResponse } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { authenticateCopilotRequestSessionOnly, createInternalServerErrorResponse, createRequestTracker, createUnauthorizedResponse, -} from '@/lib/copilot/request/http' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +} from '@/lib/mothership/request/http' import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CopilotFeedbackAPI') diff --git a/apps/sim/app/api/copilot/tool-permission/route.test.ts b/apps/sim/app/api/copilot/tool-permission/route.test.ts index b533477f309..b336f55b616 100644 --- a/apps/sim/app/api/copilot/tool-permission/route.test.ts +++ b/apps/sim/app/api/copilot/tool-permission/route.test.ts @@ -24,15 +24,15 @@ const { getUserPermissionConfig: vi.fn(), })) -vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) +vi.mock('@/lib/mothership/request/http', () => copilotHttpMock) -vi.mock('@/lib/copilot/async-runs/repository', () => ({ +vi.mock('@/lib/mothership/async-runs/repository', () => ({ getAsyncToolCall, getRunSegment, recordToolPermissionDecision, })) -vi.mock('@/lib/copilot/persistence/tool-permission', () => ({ +vi.mock('@/lib/mothership/persistence/tool-permission', () => ({ publishToolPermissionDecision, TOOL_PERMISSION_DECISION: { allow: 'allow', @@ -42,7 +42,7 @@ vi.mock('@/lib/copilot/persistence/tool-permission', () => ({ }, })) -vi.mock('@/lib/copilot/persistence/tool-permission/auto-allow', () => ({ +vi.mock('@/lib/mothership/persistence/tool-permission/auto-allow', () => ({ addAutoAllowedTool, addChatAutoAllowedTool, })) diff --git a/apps/sim/app/api/copilot/tool-permission/route.ts b/apps/sim/app/api/copilot/tool-permission/route.ts index 8c18edef37c..565ec8e594f 100644 --- a/apps/sim/app/api/copilot/tool-permission/route.ts +++ b/apps/sim/app/api/copilot/tool-permission/route.ts @@ -3,30 +3,32 @@ import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { copilotToolPermissionContract } from '@/lib/api/contracts/copilot' import { parseRequest, validationErrorResponse } from '@/lib/api/server' +import { isCopilotToolPermissionsEnabled } from '@/lib/core/config/env-flags' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getAsyncToolCall, getRunSegment, recordToolPermissionDecision, -} from '@/lib/copilot/async-runs/repository' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' +} from '@/lib/mothership/async-runs/repository' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' import { publishToolPermissionDecision, TOOL_PERMISSION_DECISION, type ToolPermissionDecision, -} from '@/lib/copilot/persistence/tool-permission' +} from '@/lib/mothership/persistence/tool-permission' import { addAutoAllowedTool, addChatAutoAllowedTool, -} from '@/lib/copilot/persistence/tool-permission/auto-allow' +} from '@/lib/mothership/persistence/tool-permission/auto-allow' import { authenticateCopilotRequestSessionOnly, createInternalServerErrorResponse, createNotFoundResponse, createRequestTracker, createUnauthorizedResponse, -} from '@/lib/copilot/request/http' -import { withIncomingGoSpan } from '@/lib/copilot/request/otel' +} from '@/lib/mothership/request/http' +import { withIncomingGoSpan } from '@/lib/mothership/request/otel' import { isCopilotToolPermissionsEnabled } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' diff --git a/apps/sim/app/api/copilot/tools/execute/route.test.ts b/apps/sim/app/api/copilot/tools/execute/route.test.ts index faacd5934fb..c48afd55239 100644 --- a/apps/sim/app/api/copilot/tools/execute/route.test.ts +++ b/apps/sim/app/api/copilot/tools/execute/route.test.ts @@ -16,20 +16,20 @@ const { mockToolRequiresApprovalLane: vi.fn().mockReturnValue(false), })) -vi.mock('@/lib/copilot/request/http', () => ({ +vi.mock('@/lib/mothership/request/http', () => ({ checkInternalApiKey: mockCheckInternalApiKey, })) -vi.mock('@/lib/copilot/environment-context', () => ({ +vi.mock('@/lib/mothership/environment-context', () => ({ prepareCopilotEnvironmentContext: mockPrepareEnvironmentContext, })) -vi.mock('@/lib/copilot/tool-executor', () => ({ +vi.mock('@/lib/mothership/tool-executor', () => ({ ensureHandlersRegistered: vi.fn(), toolRequiresApprovalLane: mockToolRequiresApprovalLane, })) -vi.mock('@/lib/copilot/tool-executor/executor', () => ({ +vi.mock('@/lib/mothership/tool-executor/executor', () => ({ executeTool: ( _toolName: string, params: Record, @@ -37,11 +37,11 @@ vi.mock('@/lib/copilot/tool-executor/executor', () => ({ ) => mockHandler(params, context), })) -vi.mock('@/lib/copilot/request/tools/resources', () => ({ +vi.mock('@/lib/mothership/request/tools/resources', () => ({ handleResourceSideEffects: vi.fn().mockResolvedValue(undefined), })) -vi.mock('@/lib/copilot/request/otel', () => ({ +vi.mock('@/lib/mothership/request/otel', () => ({ withIncomingGoSpan: ( _headers: Headers, _span: string, diff --git a/apps/sim/app/api/copilot/tools/execute/route.ts b/apps/sim/app/api/copilot/tools/execute/route.ts index d68ac58ba59..1abc63a3f6b 100644 --- a/apps/sim/app/api/copilot/tools/execute/route.ts +++ b/apps/sim/app/api/copilot/tools/execute/route.ts @@ -3,23 +3,23 @@ import { getErrorMessage } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { copilotToolExecuteInternalBodySchema } from '@/lib/api/contracts/copilot' import { validationErrorResponse } from '@/lib/api/server' -import { toolResultForModel } from '@/lib/copilot/chat/sim-key-redaction' -import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' -import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { checkInternalApiKey } from '@/lib/copilot/request/http' -import { withIncomingGoSpan } from '@/lib/copilot/request/otel' +import { toolResultForModel } from '@/lib/mothership/chat/sim-key-redaction' +import { prepareCopilotEnvironmentContext } from '@/lib/mothership/environment-context' +import { MothershipStreamV1ToolOutcome } from '@/lib/mothership/generated/mothership-stream-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { checkInternalApiKey } from '@/lib/mothership/request/http' +import { withIncomingGoSpan } from '@/lib/mothership/request/otel' import { describeWithholdingCause, inspectToolResultForCopilot, projectToolErrorMessageForCopilot, -} from '@/lib/copilot/request/tools/resolved-secret-result' -import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources' -import type { ToolCallResult } from '@/lib/copilot/request/types' -import { ensureHandlersRegistered, toolRequiresApprovalLane } from '@/lib/copilot/tool-executor' -import { executeTool } from '@/lib/copilot/tool-executor/executor' -import { TOOL_EFFECT_PHASE } from '@/lib/copilot/tool-executor/types' +} from '@/lib/mothership/request/tools/resolved-secret-result' +import { handleResourceSideEffects } from '@/lib/mothership/request/tools/resources' +import type { ToolCallResult } from '@/lib/mothership/request/types' +import { ensureHandlersRegistered, toolRequiresApprovalLane } from '@/lib/mothership/tool-executor' +import { executeTool } from '@/lib/mothership/tool-executor/executor' +import { TOOL_EFFECT_PHASE } from '@/lib/mothership/tool-executor/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' diff --git a/apps/sim/app/api/desktop/tool/authorize/route.test.ts b/apps/sim/app/api/desktop/tool/authorize/route.test.ts index 35464baf76f..a66dcf9dcfb 100644 --- a/apps/sim/app/api/desktop/tool/authorize/route.test.ts +++ b/apps/sim/app/api/desktop/tool/authorize/route.test.ts @@ -11,9 +11,9 @@ const { claimPendingAsyncToolCall, getAsyncToolCall, getRunSegment } = vi.hoiste getRunSegment: vi.fn(), })) -vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) +vi.mock('@/lib/mothership/request/http', () => copilotHttpMock) -vi.mock('@/lib/copilot/async-runs/repository', () => ({ +vi.mock('@/lib/mothership/async-runs/repository', () => ({ claimPendingAsyncToolCall, getAsyncToolCall, getRunSegment, diff --git a/apps/sim/app/api/desktop/tool/authorize/route.ts b/apps/sim/app/api/desktop/tool/authorize/route.ts index f7503a9f792..8c1a36153bd 100644 --- a/apps/sim/app/api/desktop/tool/authorize/route.ts +++ b/apps/sim/app/api/desktop/tool/authorize/route.ts @@ -4,19 +4,19 @@ import { isRecordLike } from '@sim/utils/object' import { type NextRequest, NextResponse } from 'next/server' import { authorizeDesktopToolContract } from '@/lib/api/contracts/desktop-tool-authorization' import { parseRequest } from '@/lib/api/server' -import { DESKTOP_TOOL_CLAIM_OWNER } from '@/lib/copilot/async-runs/lifecycle' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { DESKTOP_TOOL_CLAIM_OWNER } from '@/lib/mothership/async-runs/lifecycle' import { claimPendingAsyncToolCall, getAsyncToolCall, getRunSegment, -} from '@/lib/copilot/async-runs/repository' +} from '@/lib/mothership/async-runs/repository' import { authenticateCopilotRequestSessionOnly, createNotFoundResponse, createUnauthorizedResponse, -} from '@/lib/copilot/request/http' -import { isUserLocalVfsToolCall } from '@/lib/copilot/tools/local-filesystem' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +} from '@/lib/mothership/request/http' +import { isUserLocalVfsToolCall } from '@/lib/mothership/tools/local-filesystem' /** * Electron calls this endpoint from the main process before every privileged diff --git a/apps/sim/app/api/files/public/[token]/content/route.test.ts b/apps/sim/app/api/files/public/[token]/content/route.test.ts index 46c666d6d10..5177a2a1060 100644 --- a/apps/sim/app/api/files/public/[token]/content/route.test.ts +++ b/apps/sim/app/api/files/public/[token]/content/route.test.ts @@ -36,7 +36,7 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile, })) -vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({ +vi.mock('@/lib/mothership/tools/server/files/doc-compile', () => ({ resolveServableDoc: mockResolveServableDoc, })) diff --git a/apps/sim/app/api/files/public/[token]/content/route.ts b/apps/sim/app/api/files/public/[token]/content/route.ts index f5ca997c573..d8fc088918e 100644 --- a/apps/sim/app/api/files/public/[token]/content/route.ts +++ b/apps/sim/app/api/files/public/[token]/content/route.ts @@ -4,11 +4,11 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' -import { resolveServableDoc } from '@/lib/copilot/tools/server/files/doc-compile' import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' import { generateRequestId } from '@/lib/core/utils/request' import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveServableDoc } from '@/lib/mothership/tools/server/files/doc-compile' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' import { downloadFile } from '@/lib/uploads/core/storage-service' diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index 38c3cfcf73d..dee93608b0a 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -122,7 +122,7 @@ vi.mock('@/lib/workspace-files/application/read-workspace-file-content-by-key', readWorkspaceFileContentByKey: { execute: mockReadWorkspaceFileContentByKey }, })) -vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({ +vi.mock('@/lib/mothership/tools/server/files/doc-compile', () => ({ resolveServableDocBytes: mockResolveServableDocBytes, })) diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 1bd88eeffd9..3f93ac16e95 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -10,11 +10,13 @@ import { internalSessionAuth, } from '@/lib/api/server/routes' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { resolveServableDocBytes } from '@/lib/copilot/tools/server/files/doc-compile' -import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error' +import { resolveServableDocBytes } from '@/lib/mothership/tools/server/files/doc-compile' +import { DocCompileUserError } from '@/lib/mothership/tools/server/files/doc-compile-error' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveServableDocBytes } from '@/lib/mothership/tools/server/files/doc-compile' +import { DocCompileUserError } from '@/lib/mothership/tools/server/files/doc-compile-error' import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' import { readOrganizationAssistantImage } from '@/lib/uploads/contexts/organization-assistant/application' diff --git a/apps/sim/app/api/internal/file-doc/merge/route.ts b/apps/sim/app/api/internal/file-doc/merge/route.ts index 40f7e5204c0..20ee73256fa 100644 --- a/apps/sim/app/api/internal/file-doc/merge/route.ts +++ b/apps/sim/app/api/internal/file-doc/merge/route.ts @@ -5,8 +5,8 @@ import { NextResponse } from 'next/server' import { mergeFileDocContract } from '@/lib/api/contracts/file-doc' import { parseRequest } from '@/lib/api/server' import { buildFileDocMergeUpdate } from '@/lib/collab-doc/merge' -import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/copilot/request/http' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/mothership/request/http' const logger = createLogger('FileDocMergeAPI') diff --git a/apps/sim/app/api/internal/file-doc/persist/route.ts b/apps/sim/app/api/internal/file-doc/persist/route.ts index a85923deea7..399791a0172 100644 --- a/apps/sim/app/api/internal/file-doc/persist/route.ts +++ b/apps/sim/app/api/internal/file-doc/persist/route.ts @@ -5,8 +5,8 @@ import { NextResponse } from 'next/server' import { persistFileDocContract } from '@/lib/api/contracts/file-doc' import { parseRequest } from '@/lib/api/server' import { persistFileDoc } from '@/lib/collab-doc/persist' -import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/copilot/request/http' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/mothership/request/http' const logger = createLogger('FileDocPersistAPI') diff --git a/apps/sim/app/api/internal/file-doc/seed/route.test.ts b/apps/sim/app/api/internal/file-doc/seed/route.test.ts index 2f071395033..04360a58a81 100644 --- a/apps/sim/app/api/internal/file-doc/seed/route.test.ts +++ b/apps/sim/app/api/internal/file-doc/seed/route.test.ts @@ -10,7 +10,7 @@ const { mockCheckInternalApiKey, mockBuildFileDocSeed } = vi.hoisted(() => ({ mockBuildFileDocSeed: vi.fn(), })) -vi.mock('@/lib/copilot/request/http', () => ({ +vi.mock('@/lib/mothership/request/http', () => ({ checkInternalApiKey: mockCheckInternalApiKey, createUnauthorizedResponse: () => NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), })) diff --git a/apps/sim/app/api/internal/file-doc/seed/route.ts b/apps/sim/app/api/internal/file-doc/seed/route.ts index 75cc13f3864..eacaae68bad 100644 --- a/apps/sim/app/api/internal/file-doc/seed/route.ts +++ b/apps/sim/app/api/internal/file-doc/seed/route.ts @@ -5,8 +5,8 @@ import { NextResponse } from 'next/server' import { buildFileDocSeedContract } from '@/lib/api/contracts/file-doc' import { parseRequest } from '@/lib/api/server' import { buildFileDocSeed } from '@/lib/collab-doc/seed' -import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/copilot/request/http' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { checkInternalApiKey, createUnauthorizedResponse } from '@/lib/mothership/request/http' const logger = createLogger('FileDocSeedAPI') diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index deff41844d0..3a226d94370 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -5,7 +5,7 @@ import { } from '@/lib/api/contracts/mothership-chats' import { validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { handleUnifiedChatPost } from '@/lib/copilot/chat/post' +import { handleUnifiedChatPost, maxDuration } from '@/lib/mothership/chat/post' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { GET as copilotChatGet } from '@/app/api/copilot/chat/queries' diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts index a3e687bdf27..ff46bf1e2c5 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts @@ -43,36 +43,36 @@ const { mockRemoveChatResources: vi.fn(), })) -vi.mock('@/lib/copilot/resources/persistence', () => ({ +vi.mock('@/lib/mothership/resources/persistence', () => ({ removeChatResources: mockRemoveChatResources, })) -vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) +vi.mock('@/lib/mothership/request/http', () => copilotHttpMock) -vi.mock('@/lib/copilot/chat/fork-chat-files', () => ({ +vi.mock('@/lib/mothership/chat/fork-chat-files', () => ({ filterForkableChatFiles: mockFilterForkableChatFiles, listForkableChatFiles: mockListForkableChatFiles, planChatFileCopies: mockPlanChatFileCopies, executeChatFileBlobCopies: mockExecuteChatFileBlobCopies, })) -vi.mock('@/lib/copilot/chat/lifecycle', () => ({ +vi.mock('@/lib/mothership/chat/lifecycle', () => ({ loadCopilotChatMessages: mockLoadCopilotChatMessages, })) -vi.mock('@/lib/copilot/chat/messages-store', () => ({ +vi.mock('@/lib/mothership/chat/messages-store', () => ({ appendCopilotChatMessages: mockAppendCopilotChatMessages, })) -vi.mock('@/lib/copilot/chat-status', () => ({ +vi.mock('@/lib/mothership/chat-status', () => ({ publishChatStatusChanged: mockPublishStatusChanged, })) -vi.mock('@/lib/copilot/request/go/fetch', () => ({ +vi.mock('@/lib/mothership/request/go/fetch', () => ({ fetchGo: mockFetchGo, })) -vi.mock('@/lib/copilot/server/agent-url', () => ({ +vi.mock('@/lib/mothership/server/agent-url', () => ({ getMothershipBaseURL: vi.fn().mockResolvedValue('http://mothership.test'), getMothershipSourceEnvHeaders: vi.fn().mockReturnValue({}), })) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts index 0bd87841838..82216b641b6 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts @@ -6,21 +6,23 @@ import { and, eq, inArray, isNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' +import { env } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { executeChatFileBlobCopies, filterForkableChatFiles, listForkableChatFiles, planChatFileCopies, -} from '@/lib/copilot/chat/fork-chat-files' -import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle' -import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' -import { authorizeOrganizationChat } from '@/lib/copilot/chat/organization-chats' +} from '@/lib/mothership/chat/fork-chat-files' +import { loadCopilotChatMessages } from '@/lib/mothership/chat/lifecycle' +import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store' +import { authorizeOrganizationChat } from '@/lib/mothership/chat/organization-chats' import { rewriteMessageFileRefs, rewriteResourceFileRefs, -} from '@/lib/copilot/chat/rewrite-file-references' -import { publishChatStatusChanged } from '@/lib/copilot/chat-status' -import { fetchGo } from '@/lib/copilot/request/go/fetch' +} from '@/lib/mothership/chat/rewrite-file-references' +import { publishChatStatusChanged } from '@/lib/mothership/chat-status' +import { fetchGo } from '@/lib/mothership/request/go/fetch' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, @@ -28,13 +30,14 @@ import { createInternalServerErrorResponse, createNotFoundResponse, createUnauthorizedResponse, -} from '@/lib/copilot/request/http' -import { removeChatResources } from '@/lib/copilot/resources/persistence' -import { type MothershipResource, sanitizeChatResources } from '@/lib/copilot/resources/types' -import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' -import { env } from '@/lib/core/config/env' +} from '@/lib/mothership/request/http' +import { removeChatResources } from '@/lib/mothership/resources/persistence' +import { type MothershipResource, sanitizeChatResources } from '@/lib/mothership/resources/types' +import { + getMothershipBaseURL, + getMothershipSourceEnvHeaders, +} from '@/lib/mothership/server/agent-url' import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { assertActiveWorkspaceAccess, diff --git a/apps/sim/app/api/mothership/chats/[chatId]/restore/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.test.ts index adfe941a4e0..ceeed4394ad 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/restore/route.test.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.test.ts @@ -10,7 +10,7 @@ const { mockAssertActiveWorkspaceAccess, mockPublishStatusChanged } = vi.hoisted mockPublishStatusChanged: vi.fn(), })) -vi.mock('@/lib/copilot/request/http', () => ({ +vi.mock('@/lib/mothership/request/http', () => ({ ...copilotHttpMock, createForbiddenResponse: vi.fn((message: string) => ({ status: 403, @@ -25,7 +25,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ error instanceof Error && error.message === 'ACCESS_DENIED', })) -vi.mock('@/lib/copilot/chat-status', () => ({ +vi.mock('@/lib/mothership/chat-status', () => ({ publishChatStatusChanged: mockPublishStatusChanged, })) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts index ed1b9d84c24..27e165f8069 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts @@ -5,14 +5,14 @@ import { and, eq, isNotNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { restoreMothershipChatContract } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' -import { authorizeOrganizationChat } from '@/lib/copilot/chat/organization-chats' -import { publishChatStatusChanged } from '@/lib/copilot/chat-status' +import { authorizeOrganizationChat } from '@/lib/mothership/chat/organization-chats' +import { publishChatStatusChanged } from '@/lib/mothership/chat-status' import { authenticateCopilotRequestSessionOnly, createForbiddenResponse, createInternalServerErrorResponse, createUnauthorizedResponse, -} from '@/lib/copilot/request/http' +} from '@/lib/mothership/request/http' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts index ae07dee5be7..6f32455ea68 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.test.ts @@ -23,42 +23,42 @@ const { mockGetLatestRunForStream: vi.fn(), })) -vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) +vi.mock('@/lib/mothership/request/http', () => copilotHttpMock) -vi.mock('@/lib/copilot/chat/lifecycle', () => ({ +vi.mock('@/lib/mothership/chat/lifecycle', () => ({ getAccessibleCopilotChatAuth: mockGetAccessibleCopilotChat, getAccessibleCopilotChatWithMessages: mockGetAccessibleCopilotChat, })) -vi.mock('@/lib/copilot/chat/stream-liveness', () => ({ +vi.mock('@/lib/mothership/chat/stream-liveness', () => ({ reconcileChatStreamMarkers: mockReconcileChatStreamMarkers, })) -vi.mock('@/lib/copilot/request/session/buffer', () => ({ +vi.mock('@/lib/mothership/request/session/buffer', () => ({ readEvents: mockReadEvents, })) -vi.mock('@/lib/copilot/request/session/file-preview-session', () => ({ +vi.mock('@/lib/mothership/request/session/file-preview-session', () => ({ readFilePreviewSessions: mockReadFilePreviewSessions, })) -vi.mock('@/lib/copilot/async-runs/repository', () => ({ +vi.mock('@/lib/mothership/async-runs/repository', () => ({ getLatestRunForStream: mockGetLatestRunForStream, })) -vi.mock('@/lib/copilot/request/session/types', () => ({ +vi.mock('@/lib/mothership/request/session/types', () => ({ toStreamBatchEvent: (e: unknown) => e, })) -vi.mock('@/lib/copilot/chat/effective-transcript', () => ({ +vi.mock('@/lib/mothership/chat/effective-transcript', () => ({ buildEffectiveChatTranscript: ({ messages }: { messages: unknown[] }) => messages, })) -vi.mock('@/lib/copilot/chat/persisted-message', () => ({ +vi.mock('@/lib/mothership/chat/persisted-message', () => ({ normalizeMessage: (m: unknown) => m, })) -vi.mock('@/lib/copilot/chat-status', () => ({ +vi.mock('@/lib/mothership/chat-status', () => ({ publishChatStatusChanged: vi.fn(), })) @@ -71,7 +71,7 @@ vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn(), })) -import { publishChatStatusChanged } from '@/lib/copilot/chat-status' +import { publishChatStatusChanged } from '@/lib/mothership/chat-status' import { DELETE, GET, PATCH } from '@/app/api/mothership/chats/[chatId]/route' function makeContext(chatId: string) { diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index e8e6eccbf8c..94972155e6d 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -10,25 +10,25 @@ import { updateMothershipChatContract, } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' -import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' -import { buildEffectiveChatTranscript } from '@/lib/copilot/chat/effective-transcript' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getLatestRunForStream } from '@/lib/mothership/async-runs/repository' +import { buildEffectiveChatTranscript } from '@/lib/mothership/chat/effective-transcript' import { getAccessibleCopilotChatAuth, getAccessibleCopilotChatWithMessages, -} from '@/lib/copilot/chat/lifecycle' -import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' -import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' -import { publishChatStatusChanged } from '@/lib/copilot/chat-status' +} from '@/lib/mothership/chat/lifecycle' +import { normalizeMessage } from '@/lib/mothership/chat/persisted-message' +import { reconcileChatStreamMarkers } from '@/lib/mothership/chat/stream-liveness' +import { publishChatStatusChanged } from '@/lib/mothership/chat-status' import { authenticateCopilotRequestSessionOnly, createInternalServerErrorResponse, createUnauthorizedResponse, -} from '@/lib/copilot/request/http' -import type { FilePreviewSession } from '@/lib/copilot/request/session' -import { readEvents } from '@/lib/copilot/request/session/buffer' -import { readFilePreviewSessions } from '@/lib/copilot/request/session/file-preview-session' -import { type StreamBatchEvent, toStreamBatchEvent } from '@/lib/copilot/request/session/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +} from '@/lib/mothership/request/http' +import type { FilePreviewSession } from '@/lib/mothership/request/session' +import { readEvents } from '@/lib/mothership/request/session/buffer' +import { readFilePreviewSessions } from '@/lib/mothership/request/session/file-preview-session' +import { type StreamBatchEvent, toStreamBatchEvent } from '@/lib/mothership/request/session/types' import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('MothershipChatAPI') diff --git a/apps/sim/app/api/mothership/chats/read/route.test.ts b/apps/sim/app/api/mothership/chats/read/route.test.ts index b69926d7cf8..6e65890ebbb 100644 --- a/apps/sim/app/api/mothership/chats/read/route.test.ts +++ b/apps/sim/app/api/mothership/chats/read/route.test.ts @@ -10,7 +10,7 @@ const { mockParseRequest, mockGetAccessibleChat } = vi.hoisted(() => ({ mockGetAccessibleChat: vi.fn(), })) -vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) +vi.mock('@/lib/mothership/request/http', () => copilotHttpMock) vi.mock('@/lib/api/server', () => ({ parseRequest: mockParseRequest })) vi.mock('@/lib/api/contracts/mothership-chats', () => ({ markMothershipChatReadContract: {} })) vi.mock('@/lib/copilot/chat/lifecycle', () => ({ diff --git a/apps/sim/app/api/mothership/chats/read/route.ts b/apps/sim/app/api/mothership/chats/read/route.ts index 8eaf98e955c..4b3a365881b 100644 --- a/apps/sim/app/api/mothership/chats/read/route.ts +++ b/apps/sim/app/api/mothership/chats/read/route.ts @@ -5,14 +5,13 @@ import { and, eq, isNull, lt, or, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { markMothershipChatReadContract } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' -import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' -import { publishChatStatusChanged } from '@/lib/copilot/chat-status' +import { getAccessibleCopilotChatAuth } from '@/lib/mothership/chat/lifecycle' +import { publishChatStatusChanged } from '@/lib/mothership/chat-status' import { authenticateCopilotRequestSessionOnly, createInternalServerErrorResponse, createUnauthorizedResponse, -} from '@/lib/copilot/request/http' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +} from '@/lib/mothership/request/http' const logger = createLogger('MarkTaskReadAPI') diff --git a/apps/sim/app/api/mothership/chats/route.test.ts b/apps/sim/app/api/mothership/chats/route.test.ts index 84296f0dd5f..171997000db 100644 --- a/apps/sim/app/api/mothership/chats/route.test.ts +++ b/apps/sim/app/api/mothership/chats/route.test.ts @@ -17,14 +17,14 @@ const { mockReconcileChatStreamMarkers } = vi.hoisted(() => ({ mockReconcileChatStreamMarkers: vi.fn(), })) -vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) +vi.mock('@/lib/mothership/request/http', () => copilotHttpMock) vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) -vi.mock('@/lib/copilot/chat/stream-liveness', () => ({ +vi.mock('@/lib/mothership/chat/stream-liveness', () => ({ reconcileChatStreamMarkers: mockReconcileChatStreamMarkers, })) -vi.mock('@/lib/copilot/chat-status', () => ({ +vi.mock('@/lib/mothership/chat-status', () => ({ chatPubSub: { publishStatusChanged: vi.fn() }, })) diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index bc1828f2579..3eade405082 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -7,21 +7,21 @@ import { listMothershipChatsContract, } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' -import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listMothershipChats } from '@/lib/mothership/chat/list-mothership-chats' import { createOrganizationChat, listOrganizationChats, -} from '@/lib/copilot/chat/organization-chats' -import { chatPubSub } from '@/lib/copilot/chat-status' -import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/copilot/constants' +} from '@/lib/mothership/chat/organization-chats' +import { chatPubSub } from '@/lib/mothership/chat-status' +import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/mothership/constants' import { authenticateCopilotRequestSessionOnly, createForbiddenResponse, createInternalServerErrorResponse, createUnauthorizedResponse, -} from '@/lib/copilot/request/http' +} from '@/lib/mothership/request/http' import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { assertActiveWorkspaceAccess, diff --git a/apps/sim/app/api/mothership/events/route.ts b/apps/sim/app/api/mothership/events/route.ts index c39f4068fc8..62433101ae6 100644 --- a/apps/sim/app/api/mothership/events/route.ts +++ b/apps/sim/app/api/mothership/events/route.ts @@ -15,8 +15,8 @@ import { InternalUnauthenticatedError, internalSessionAuth, } from '@/lib/api/server/routes/internal-json-route' -import { authorizeOrganizationChatEvents } from '@/lib/copilot/chat/organization-chats' -import { chatPubSub } from '@/lib/copilot/chat-status' +import { authorizeOrganizationChatEvents } from '@/lib/mothership/chat/organization-chats' +import { chatPubSub } from '@/lib/mothership/chat-status' import { isChatEnabled } from '@/lib/core/config/env-flags' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' diff --git a/apps/sim/app/api/mothership/execute/route.test.ts b/apps/sim/app/api/mothership/execute/route.test.ts index b243dd31b1d..8823f16c2b5 100644 --- a/apps/sim/app/api/mothership/execute/route.test.ts +++ b/apps/sim/app/api/mothership/execute/route.test.ts @@ -12,7 +12,6 @@ const { mockCheckInternalAuth, mockComputeWorkspaceEntitlements, mockDecryptSecret, - mockGenerateWorkspaceContext, mockGetPersonalAndWorkspaceEnv, mockProcessContextsServer, mockRequestExplicitStreamAbort, @@ -26,7 +25,6 @@ const { mockCheckInternalAuth: vi.fn(), mockComputeWorkspaceEntitlements: vi.fn(), mockDecryptSecret: vi.fn(), - mockGenerateWorkspaceContext: vi.fn(), mockGetPersonalAndWorkspaceEnv: vi.fn(), mockProcessContextsServer: vi.fn(), mockRequestExplicitStreamAbort: vi.fn(), @@ -60,32 +58,28 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ requireBillingAttributionHeader: mockRequireBillingAttributionHeader, })) -vi.mock('@/lib/copilot/chat/payload', () => ({ +vi.mock('@/lib/mothership/chat/payload', () => ({ buildIntegrationToolSchemas: mockBuildIntegrationToolSchemas, })) -vi.mock('@/lib/copilot/chat/process-contents', () => ({ +vi.mock('@/lib/mothership/chat/process-contents', () => ({ processContextsServer: mockProcessContextsServer, })) -vi.mock('@/lib/copilot/chat/workspace-context', () => ({ - generateWorkspaceContext: mockGenerateWorkspaceContext, -})) - -vi.mock('@/lib/copilot/entitlements', () => ({ +vi.mock('@/lib/mothership/entitlements', () => ({ computeWorkspaceEntitlements: mockComputeWorkspaceEntitlements, })) -vi.mock('@/lib/copilot/mcp-tools', () => ({ +vi.mock('@/lib/mothership/mcp-tools', () => ({ buildSelectedMcpToolSchemas: mockBuildSelectedMcpToolSchemas, buildTaggedMcpToolSchemas: mockBuildTaggedMcpToolSchemas, })) -vi.mock('@/lib/copilot/request/lifecycle/headless', () => ({ +vi.mock('@/lib/mothership/request/lifecycle/headless', () => ({ runHeadlessCopilotLifecycle: mockRunHeadlessCopilotLifecycle, })) -vi.mock('@/lib/copilot/request/session/explicit-abort', () => ({ +vi.mock('@/lib/mothership/request/session/explicit-abort', () => ({ requestExplicitStreamAbort: mockRequestExplicitStreamAbort, })) @@ -102,7 +96,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ isWorkspaceAccessDeniedError: vi.fn(() => false), })) -import type { CopilotLifecycleOptions } from '@/lib/copilot/request/lifecycle/run' +import type { CopilotLifecycleOptions } from '@/lib/mothership/request/lifecycle/run' import { buildExecuteResponsePayload, POST } from '@/app/api/mothership/execute/route' type Payload = Parameters[0] @@ -155,7 +149,6 @@ describe('mothership private trace provenance transport', () => { workspaceDecrypted: {}, decryptionFailures: [], }) - mockGenerateWorkspaceContext.mockResolvedValue({}) mockBuildIntegrationToolSchemas.mockResolvedValue([]) mockBuildSelectedMcpToolSchemas.mockResolvedValue([]) mockBuildTaggedMcpToolSchemas.mockResolvedValue([]) @@ -371,13 +364,15 @@ describe('mothership private trace provenance transport', () => { ) expect(response.status).toBe(200) - expect(mockGenerateWorkspaceContext).toHaveBeenCalledWith('workspace-1', 'user-1', { - workspaceAccess: expect.any(Object), - secretMountPolicy: { - secretScope: 'selected', - mountedSecrets: ['API_KEY'], - }, + // The snapshot builder is gone (revamp): the mount policy stays server-only by riding + // the LIFECYCLE OPTIONS (sim-side tool execution), never the wire payload. + const [payload, options] = mockRunHeadlessCopilotLifecycle.mock.calls.at(-1)! + expect(options.secretMountPolicy).toEqual({ + secretScope: 'selected', + mountedSecrets: ['API_KEY'], }) + expect(payload).not.toHaveProperty('secretMountPolicy') + expect(payload).not.toHaveProperty('workspaceContext') }) it('fails model egress closed when catalog setup fails', async () => { diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 9a2331dacfc..c41c4d6b405 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -7,25 +7,6 @@ import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { verifyInternalDelegationToken } from '@/lib/auth/internal' import { requireBillingAttributionHeader } from '@/lib/billing/core/billing-attribution' -import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' -import { processContextsServer } from '@/lib/copilot/chat/process-contents' -import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' -import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' -import { - type CopilotEnvironmentContext, - createCopilotEnvironmentContext, -} from '@/lib/copilot/environment-context' -import { - MothershipStreamV1EventType, - MothershipStreamV1TextChannel, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { buildSelectedMcpToolSchemas, buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools' -import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' -import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' -import type { StreamEvent } from '@/lib/copilot/request/types' -import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' -import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' -import { acceptsMediaType } from '@/lib/core/utils/media-types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { @@ -34,6 +15,25 @@ import { RESOLVED_SECRET_PROVENANCE_METADATA_V1, requestsPrivateToolMetadata, } from '@/lib/execution/private-tool-metadata' +import { mintDelegationToken } from '@/lib/mothership/chat/delegation' +import { buildIntegrationToolSchemas } from '@/lib/mothership/chat/payload' +import { processContextsServer } from '@/lib/mothership/chat/process-contents' +import { + type CopilotEnvironmentContext, + createCopilotEnvironmentContext, +} from '@/lib/mothership/environment-context' +import { + MothershipStreamV1EventType, + MothershipStreamV1TextChannel, +} from '@/lib/mothership/generated/mothership-stream-v1' +import { PROTOCOL_VERSION } from '@/lib/mothership/generated/protocol' +import { buildSelectedMcpToolSchemas, buildTaggedMcpToolSchemas } from '@/lib/mothership/mcp-tools' +import { runHeadlessCopilotLifecycle } from '@/lib/mothership/request/lifecycle/headless' +import { requestExplicitStreamAbort } from '@/lib/mothership/request/session/explicit-abort' +import type { StreamEvent } from '@/lib/mothership/request/types' +import { normalizeSecretMountPolicy } from '@/lib/mothership/secret-mount-policy' +import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { acceptsMediaType } from '@/lib/core/utils/media-types' import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' import { MCP_SERVER_DELEGATION_AUDIENCE } from '@/lib/mcp/application/authorization' import { @@ -257,72 +257,61 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const byName = new Map(groups.flat().map((tool) => [tool.name, tool])) return [...byName.values()] }) - const [workspaceContext, integrationTools, mothershipTools, entitlements, agentContexts] = - await Promise.all([ - generateWorkspaceContext(workspaceId, userId, { - workspaceAccess, - secretMountPolicy, - }), - buildIntegrationToolSchemas(userId, undefined, workspaceId), - mothershipToolsPromise, - computeWorkspaceEntitlements(workspaceId, userId), - processContextsServer( - nonMcpAgentMentions, - userId, - lastUserMessage, - workspaceId, - effectiveChatId, - activeResolvedSecretTraceRegistry - ).catch((error) => { - reqLogger.warn('Failed to resolve agent contexts for execution', { - error: toError(error).message, - }) - return [] - }), - ]) + const [integrationTools, mothershipTools, agentContexts] = await Promise.all([ + buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), + mothershipToolsPromise, + processContextsServer( + nonMcpAgentMentions, + userId, + lastUserMessage, + workspaceId, + effectiveChatId, + activeResolvedSecretTraceRegistry + ).catch((error) => { + reqLogger.warn('Failed to resolve agent contexts for execution', { + error: toError(error).message, + }) + return [] + }), + ]) + /** + * The wire payload IS the shared ExecuteRequest contract. Caller-side context — + * resolved mentions and the MCP-enablement notice — folds into the message array + * itself (the worker adds no persona of its own on this surface), and the run-scoped + * delegation credential is minted here exactly as on the chat path. + */ + const contextBlocks: string[] = [ + ...agentContexts.map((ctx) => { + const c = ctx as { type?: string; content?: string } + return `[Attached ${c.type ?? 'context'}]\n${c.content ?? ''}` + }), + ...(mothershipTools.length > 0 + ? [ + [ + 'The following MCP tools are explicitly enabled for this request and are callable directly by the exact name shown — there is no loading step.', + 'Do not narrate discovery, tool-name selection, or retries. Call the tool first, then respond once with the result. Never claim the server works before a successful tool result. Do not automatically retry a timed-out or abandoned MCP call.', + ...mothershipTools.map((tool) => `- ${tool.name}: ${tool.description || tool.name}`), + ].join('\n'), + ] + : []), + ] + const wireMessages = messages.map((m, i) => + i === messages.length - 1 && contextBlocks.length > 0 + ? { ...m, content: `${contextBlocks.join('\n\n')}\n\n${m.content}` } + : m + ) + const delegationToken = await mintDelegationToken({ workspaceId, userId }) const requestPayload: Record = { - messages, + messages: wireMessages, ...(responseFormat !== undefined ? { responseFormat } : {}), userId, - // Go's auth middleware reads workspaceId off the request body to forward - // to /api/copilot/api-keys/validate (per-member org usage gate). Omitting - // it makes that validation 400 ("API key validation failed"), which kills - // the block. The chat path sends it via buildCopilotRequestPayload; the - // block path must too. + protocolVersion: PROTOCOL_VERSION, workspaceId, chatId: effectiveChatId, - mode: 'agent', messageId, - isHosted: true, - workspaceContext, - ...(isDocSandboxEnabled ? { docCompiler: 'python' } : {}), - ...(userMetadata ? { userMetadata } : {}), - ...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}), - ...(agentContexts.length > 0 || mothershipTools.length > 0 - ? { - contexts: [ - ...agentContexts, - ...(mothershipTools.length > 0 - ? [ - { - type: 'mcp', - content: [ - 'The following MCP tools are explicitly enabled for this request and are callable directly by the exact name shown — there is no loading step.', - 'Do not narrate discovery, tool-name selection, or retries. Call the tool first, then respond once with the result. Never claim the server works before a successful tool result. Do not automatically retry a timed-out or abandoned MCP call.', - ...mothershipTools.map( - (tool) => `- ${tool.name}: ${tool.description || tool.name}` - ), - ].join('\n'), - }, - ] - : []), - ], - } - : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), ...(mothershipTools.length > 0 ? { mothershipTools } : {}), - ...(userPermission ? { userPermission } : {}), - ...(entitlements.length > 0 ? { entitlements } : {}), + ...(delegationToken ? { delegationToken } : {}), } let allowExplicitAbort = true diff --git a/apps/sim/app/api/superuser/import-workflow/route.ts b/apps/sim/app/api/superuser/import-workflow/route.ts index 5bc5b4bf5ee..7ec0ceb17bf 100644 --- a/apps/sim/app/api/superuser/import-workflow/route.ts +++ b/apps/sim/app/api/superuser/import-workflow/route.ts @@ -7,9 +7,9 @@ import { type NextRequest, NextResponse } from 'next/server' import { importWorkflowAsSuperuserContract } from '@/lib/api/contracts/workflows' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle' -import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { loadCopilotChatMessages } from '@/lib/mothership/chat/lifecycle' +import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store' import { verifyEffectiveSuperUser } from '@/lib/permissions/super-user' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts index 0e724a2d61d..9487a166603 100644 --- a/apps/sim/app/api/v2/chat/route.test.ts +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -80,39 +80,39 @@ vi.mock('@/lib/environment/utils', () => ({ getPersonalAndWorkspaceEnv: vi.fn().mockResolvedValue({ personal: {}, workspace: {} }), })) -vi.mock('@/lib/copilot/environment-context', () => ({ +vi.mock('@/lib/mothership/environment-context', () => ({ createCopilotEnvironmentContext: vi.fn().mockResolvedValue({ id: 'env-context' }), })) -vi.mock('@/lib/copilot/chat/workspace-context', () => ({ +vi.mock('@/lib/mothership/chat/workspace-context', () => ({ generateWorkspaceContext: vi.fn().mockResolvedValue('workspace context'), })) -vi.mock('@/lib/copilot/chat/lifecycle', () => ({ +vi.mock('@/lib/mothership/chat/lifecycle', () => ({ resolveOrCreateChat: mockResolveOrCreateChat, })) -vi.mock('@/lib/copilot/chat/messages-store', () => ({ +vi.mock('@/lib/mothership/chat/messages-store', () => ({ persistCopilotChatTurn: mockPersistCopilotChatTurn, })) -vi.mock('@/lib/copilot/chat/payload', () => ({ +vi.mock('@/lib/mothership/chat/payload', () => ({ buildIntegrationToolSchemas: vi.fn().mockResolvedValue([{ name: 'run_workflow' }]), })) -vi.mock('@/lib/copilot/entitlements', () => ({ +vi.mock('@/lib/mothership/entitlements', () => ({ computeWorkspaceEntitlements: vi.fn().mockResolvedValue([]), })) -vi.mock('@/lib/copilot/request/lifecycle/headless', () => ({ +vi.mock('@/lib/mothership/request/lifecycle/headless', () => ({ runHeadlessCopilotLifecycle: mockRunHeadlessCopilotLifecycle, })) -vi.mock('@/lib/copilot/request/session/explicit-abort', () => ({ +vi.mock('@/lib/mothership/request/session/explicit-abort', () => ({ requestExplicitStreamAbort: mockRequestExplicitStreamAbort, })) -vi.mock('@/lib/copilot/secret-mount-policy', () => ({ +vi.mock('@/lib/mothership/secret-mount-policy', () => ({ normalizeSecretMountPolicy: vi.fn(() => ({ secretScope: 'all', mountedSecrets: [] })), })) @@ -445,22 +445,23 @@ describe('POST /api/v2/chat', () => { }) const [payload, options] = mockRunHeadlessCopilotLifecycle.mock.calls[0] + // The wire payload IS the shared ChatRequest contract; this surface rides the full + // CHAT pipeline now (persona + skills + CLI), not the persona-less execute surface. expect(payload).toMatchObject({ - messages: [{ role: 'user', content: 'hi' }], + message: 'hi', userId: 'user-1', workspaceId: 'workspace-1', chatId: SERVER_ISSUED_CHAT_ID, - mode: 'agent', - isHosted: true, - workspaceContext: 'workspace context', integrationTools: [{ name: 'run_workflow' }], - userPermission: 'admin', }) + for (const legacy of ['messages', 'mode', 'isHosted', 'workspaceContext', 'userPermission']) { + expect(payload).not.toHaveProperty(legacy) + } expect(options).toMatchObject({ userId: 'user-1', workspaceId: 'workspace-1', chatId: SERVER_ISSUED_CHAT_ID, - goRoute: '/api/mothership/execute', + goRoute: '/api/mothership', autoExecuteTools: true, interactive: false, // Hosted execution refuses to run without attribution, so the resolved @@ -539,7 +540,7 @@ describe('POST /api/v2/chat', () => { // and the Sim Chat block do. Replaying the transcript here would duplicate // every prior turn. expect(mockRunHeadlessCopilotLifecycle.mock.calls[0][0]).toMatchObject({ - messages: [{ role: 'user', content: 'and then?' }], + message: 'and then?', chatId: OWNED_CONVERSATION_ID, }) }) diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts index 2ea89aebe20..54851f0cf0d 100644 --- a/apps/sim/app/api/v2/chat/route.ts +++ b/apps/sim/app/api/v2/chat/route.ts @@ -18,29 +18,30 @@ import { v2RateLimits, } from '@/lib/api/server/routes' import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' -import { chatOperations } from '@/lib/copilot/application/operations' -import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' -import { persistCopilotChatTurn } from '@/lib/copilot/chat/messages-store' -import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' +import { chatOperations } from '@/lib/mothership/application/operations' +import { mintDelegationToken } from '@/lib/mothership/chat/delegation' +import { resolveOrCreateChat } from '@/lib/mothership/chat/lifecycle' +import { persistCopilotChatTurn } from '@/lib/mothership/chat/messages-store' +import { buildIntegrationToolSchemas } from '@/lib/mothership/chat/payload' import { buildPersistedAssistantMessage, buildPersistedUserMessage, -} from '@/lib/copilot/chat/persisted-message' -import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' -import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/copilot/constants' -import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' +} from '@/lib/mothership/chat/persisted-message' +import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/mothership/constants' import { type CopilotEnvironmentContext, createCopilotEnvironmentContext, -} from '@/lib/copilot/environment-context' +} from '@/lib/mothership/environment-context' import { MothershipStreamV1EventType, MothershipStreamV1TextChannel, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' -import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' -import type { OrchestratorResult, StreamEvent } from '@/lib/copilot/request/types' -import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { runHeadlessCopilotLifecycle } from '@/lib/mothership/request/lifecycle/headless' +import { requestExplicitStreamAbort } from '@/lib/mothership/request/session/explicit-abort' +import type { OrchestratorResult, StreamEvent } from '@/lib/mothership/request/types' +import { normalizeSecretMountPolicy } from '@/lib/mothership/secret-mount-policy' import { ForbiddenOperationError, forbiddenErrorDetails, @@ -50,13 +51,12 @@ import { } from '@/lib/core/application' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { acceptsMediaType } from '@/lib/core/utils/media-types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' import { capabilityRefusal, isWorkspaceCapabilityWithheld, } from '@/lib/permission-groups/capability-assertions' +import { PROTOCOL_VERSION } from '@/lib/mothership/generated/protocol' import { assertActiveWorkspaceAccess, isWorkspaceAccessDeniedError, @@ -332,30 +332,30 @@ export const POST = withRouteHandler( }) } - const [workspaceContext, integrationTools, entitlements, billingAttribution] = - await Promise.all([ - generateWorkspaceContext(workspaceId, userId, { workspaceAccess, secretMountPolicy }), - buildIntegrationToolSchemas(userId, undefined, workspaceId), - computeWorkspaceEntitlements(workspaceId, userId), - // Hosted execution refuses to run without an attribution snapshot; - // the executor path receives it as a header, this path resolves it - // from the authenticated actor and asserted workspace. - resolveBillingAttribution({ actorUserId: userId, workspaceId }), - ]) + const [integrationTools, billingAttribution, delegationToken] = await Promise.all([ + buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), + // Hosted execution refuses to run without an attribution snapshot; + // the executor path receives it as a header, this path resolves it + // from the authenticated actor and asserted workspace. + resolveBillingAttribution({ actorUserId: userId, workspaceId }), + mintDelegationToken({ workspaceId, userId }), + ]) + /** + * The wire payload IS the shared ChatRequest contract, and this surface now rides + * the full CHAT pipeline (persona + skills + CLI under the user's delegation + * token) — "talk to Sim" over the public API is the same agent as the workspace + * chat, not a persona-less one-shot. + */ const requestPayload: Record = { - messages: [{ role: 'user', content: message }], + message, userId, + protocolVersion: PROTOCOL_VERSION, workspaceId, chatId, - mode: 'agent', messageId, - isHosted: true, - workspaceContext, - ...(isDocSandboxEnabled ? { docCompiler: 'python' } : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), - ...(userPermission ? { userPermission } : {}), - ...(entitlements.length > 0 ? { entitlements } : {}), + ...(delegationToken ? { delegationToken } : {}), } let allowExplicitAbort = true @@ -399,10 +399,7 @@ export const POST = withRouteHandler( workspaceId, chatId, simRequestId: requestId, - // The Go copilot route this turn is POSTed to — the same headless - // execute surface the Sim Chat block uses (it also selects the - // mothership sandbox profile for code tools). - goRoute: '/api/mothership/execute', + goRoute: '/api/mothership', autoExecuteTools: true, interactive: false, abortSignal: lifecycleAbortController.signal, diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index 6dcf14f6543..05816e537e8 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -146,7 +146,7 @@ vi.mock('@/lib/workflows/executor/execution-id-claim', () => ({ releaseExecutionIdClaim: mockReleaseExecutionIdClaim, })) -vi.mock('@/lib/copilot/async-runs/repository', () => ({ +vi.mock('@/lib/mothership/async-runs/repository', () => ({ claimWorkflowToolExecution: mockClaimWorkflowToolExecution, getAsyncToolCall: mockGetAsyncToolCall, getRunSegment: mockGetRunSegment, diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 8a8938ad345..5eaf8b69310 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -25,20 +25,6 @@ import { getWorkspaceBilledAccountUserId, requireBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' -import { - claimWorkflowToolExecution, - getAsyncToolCall, - getRunSegment, - releaseWorkflowToolExecutionClaim, -} from '@/lib/copilot/async-runs/repository' -import { COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE } from '@/lib/copilot/constants' -import { CopilotDegradedReason } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { recordDegraded } from '@/lib/copilot/request/metrics' -import { - ASYNC_WORKFLOW_DEPLOYMENT_ERRORS, - type CopilotWorkflowToolBindingResult, - classifyWorkflowToolBinding, -} from '@/lib/copilot/tools/workflow-tools' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' import { createTimeoutAbortController, @@ -100,6 +86,20 @@ import { MCP_TOOL_BRIDGE_ACTOR_HEADER, MCP_TOOL_BRIDGE_HEADER, } from '@/lib/mcp/constants' +import { + claimWorkflowToolExecution, + getAsyncToolCall, + getRunSegment, + releaseWorkflowToolExecutionClaim, +} from '@/lib/mothership/async-runs/repository' +import { COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE } from '@/lib/mothership/constants' +import { CopilotDegradedReason } from '@/lib/mothership/generated/trace-attribute-values-v1' +import { recordDegraded } from '@/lib/mothership/request/metrics' +import { + ASYNC_WORKFLOW_DEPLOYMENT_ERRORS, + type CopilotWorkflowToolBindingResult, + classifyWorkflowToolBinding, +} from '@/lib/mothership/tools/workflow-tools' import { cleanupExecutionBase64Cache, hydrateUserFilesWithBase64, diff --git a/apps/sim/app/api/workspaces/[id]/inbox/route.ts b/apps/sim/app/api/workspaces/[id]/inbox/route.ts index 5b53c629cb1..63a1ddf7ea8 100644 --- a/apps/sim/app/api/workspaces/[id]/inbox/route.ts +++ b/apps/sim/app/api/workspaces/[id]/inbox/route.ts @@ -7,11 +7,11 @@ import { updateInboxConfigContract } from '@/lib/api/contracts/inbox' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { hasWorkspaceInboxAccess } from '@/lib/billing/core/subscription' -import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { disableInbox, enableInbox, updateInboxAddress } from '@/lib/mothership/inbox/lifecycle' import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { capabilityRefusalResponse } from '@/lib/permission-groups/capability-response' +import { normalizeSecretMountPolicy } from '@/lib/mothership/secret-mount-policy' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InboxConfigAPI') diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx index 6207a1e247a..9a2aa8ccbd9 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx @@ -19,7 +19,7 @@ import { useCopyToClipboard, } from '@sim/emcn' import { useParams, useRouter } from 'next/navigation' -import { isLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' +import { isLiveAssistantMessageId } from '@/lib/mothership/chat/effective-transcript' import { organizationRoutes } from '@/lib/navigation/paths' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 66786e97ba2..d36a986ee13 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -22,7 +22,7 @@ import { useSession } from '@/lib/auth/auth-client' import { buildFileSelectionLabel, truncateSelectionText, -} from '@/lib/copilot/chat/selection-context' +} from '@/lib/mothership/chat/selection-context' import type { FileDownloadSource } from '@/lib/uploads/client/download' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { FindBar } from '@/app/workspace/[workspaceId]/components' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx index 4ca5b4c8f72..aee463c5e68 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx @@ -21,7 +21,7 @@ import dynamic from 'next/dynamic' import { buildFileSelectionLabel, truncateSelectionText, -} from '@/lib/copilot/chat/selection-context' +} from '@/lib/mothership/chat/selection-context' import type { FileDownloadSource } from '@/lib/uploads/client/download' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { getFileExtension } from '@/lib/uploads/utils/file-utils' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.test.tsx index f681e8b7cdc..0be54858f72 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.test.tsx @@ -4,7 +4,7 @@ import { act, createRef, type RefObject } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { SIM_SELECTION_MIME } from '@/lib/copilot/chat/selection-clipboard' +import { SIM_SELECTION_MIME } from '@/lib/mothership/chat/selection-clipboard' import type { ChatContext } from '@/stores/panel' import { useSelectionCopyBridge } from './use-selection-copy-bridge' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.ts index 3a386fa6b13..908951e0745 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/use-selection-copy-bridge.ts @@ -1,7 +1,7 @@ 'use client' import { type RefObject, useEffect } from 'react' -import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection-clipboard' +import { attachSelectionContextToClipboard } from '@/lib/mothership/chat/selection-clipboard' import type { ChatContext } from '@/stores/panel' /** diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index 886243b9584..5b47dff7e12 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -7,11 +7,11 @@ import { Read as ReadTool, Terminal as TerminalTool, Wait as WaitTool, -} from '@/lib/copilot/generated/tool-catalog-v1' -import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' -import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' -import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' -import { getToolStatusDisplayTitle, getWaitCountdownTitle } from '@/lib/copilot/tools/tool-display' +} from '@/lib/mothership/generated/tool-catalog-v1' +import { getReadTargetBlock } from '@/lib/mothership/tools/client/read-block' +import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/mothership/tools/retired-tools' +import { extractStreamingStringArgument } from '@/lib/mothership/tools/streaming-args' +import { getToolStatusDisplayTitle, getWaitCountdownTitle } from '@/lib/mothership/tools/tool-display' import { ToolPermissionCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-permission-card' import { BrowserTakeoverQuestion, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index ef9edea0bc2..e7386990231 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -21,8 +21,8 @@ import 'prismjs/components/prism-css' import 'prismjs/components/prism-markup' import '@sim/emcn/components/code/code.css' import { Checkbox, CopyCodeButton, cn, languages, highlight as prismHighlight } from '@sim/emcn' -import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils' import { extractTextContent } from '@/lib/core/utils/react-node-text' +import { decodeVfsSegmentSafe } from '@/lib/mothership/vfs/path-utils' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' import { SourceChip, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts index a32fd27dae7..473d119abea 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.test.ts @@ -14,10 +14,10 @@ vi.mock('@/lib/auth/auth-client', () => ({ useSession: vi.fn(() => ({ data: null, isPending: false })), })) -import { TOOL_CATALOG, type ToolCatalogEntry } from '@/lib/copilot/generated/tool-catalog-v1' -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' -import { getHiddenToolNames } from '@/lib/copilot/tools/client/hidden-tools' -import { getToolDisplayTitle, getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display' +import { TOOL_CATALOG, type ToolCatalogEntry } from '@/lib/mothership/generated/tool-catalog-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' +import { getHiddenToolNames } from '@/lib/mothership/tools/client/hidden-tools' +import { getToolDisplayTitle, getToolStatusDisplayTitle } from '@/lib/mothership/tools/tool-display' import { createTurnModel, reduceEvent, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index fdd9ed5c7ee..18b05195952 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -12,17 +12,17 @@ import { } from 'react' import { cn } from '@sim/emcn' import { CircleStop } from '@sim/emcn/icons' -import { PrepareFileEdit, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' -import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' -import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils' -import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state' -import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' +import { PrepareFileEdit, Read as ReadTool } from '@/lib/mothership/generated/tool-catalog-v1' +import { isToolHiddenInUi } from '@/lib/mothership/tools/client/hidden-tools' +import { resolveToolDisplay } from '@/lib/mothership/tools/client/store-utils' +import { ClientToolCallState } from '@/lib/mothership/tools/client/tool-call-state' +import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/mothership/tools/retired-tools' import { getToolDisplayTitle, getToolStatusDisplayTitle, humanizeToolName, normalizeToolActivityDescription, -} from '@/lib/copilot/tools/tool-display' +} from '@/lib/mothership/tools/tool-display' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import { collectGroupTools, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 07254879b4f..252b318871d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -757,6 +757,29 @@ export function MothershipChat({ virtualizer.scrollToIndex(lastIndex, { align: 'end' }) }, [chatId, hasMessages, initialScrollBlocked, lastIndex, virtualizer]) + /** + * The user's OWN send always snaps the viewport to their message: sending IS the intent + * to watch the reply, and the streaming sticky-scroll only engages when already pinned + * to the bottom — from a scrolled-up position a fresh turn would stream out of view + * (verified live, three-for-three, during the revamp browser pass). + */ + // The send commit appends the user message AND the live-assistant placeholder together, + // so the LAST row is never the user's — track the newest user message wherever it sits. + let lastUserMessageId: string | undefined + for (let i = messages.length - 1; i >= 0; i--) { + const candidate = messages[i] + if (candidate?.role === 'user') { + lastUserMessageId = candidate.id + break + } + } + const scrolledForUserMsgRef = useRef(undefined) + useLayoutEffect(() => { + if (!lastUserMessageId || scrolledForUserMsgRef.current === lastUserMessageId) return + scrolledForUserMsgRef.current = lastUserMessageId + virtualizer.scrollToIndex(lastIndex, { align: 'end' }) + }, [lastUserMessageId, lastIndex, virtualizer]) + const virtualItems = virtualizer.getVirtualItems() return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx index 23d5017c7b8..d763ae269f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react' import { PillsRing } from '@sim/emcn' -import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display' +import { getToolStatusDisplayTitle } from '@/lib/mothership/tools/tool-display' import type { GenericResourceData } from '@/app/workspace/[workspaceId]/home/types' interface GenericResourceContentProps { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 5b6560b4eae..1982487dec7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -17,13 +17,14 @@ import { useRouter } from 'next/navigation' import { isApiClientError } from '@/lib/api/client/errors' import { useSession } from '@/lib/auth/auth-client' import { getWorkspaceUsageLimitAction } from '@/lib/billing/workspace-permissions' -import type { FilePreviewSession } from '@/lib/copilot/request/session' +import { prefersInPlaceNavigation } from '@/lib/desktop' +import type { FilePreviewSession } from '@/lib/mothership/request/session' import { cancelRunToolExecution, markRunToolManuallyStopped, reportManualRunToolStop, -} from '@/lib/copilot/tools/client/run-tool-execution' -import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +} from '@/lib/mothership/tools/client/run-tool-execution' +import { canonicalWorkspaceFilePath } from '@/lib/mothership/vfs/path-utils' import { prefersInPlaceNavigation } from '@/lib/desktop' import { type FileDownloadSource, triggerFileDownload } from '@/lib/uploads/client/download' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index e3f73406f8c..6348b701dd5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -28,8 +28,8 @@ import { reorderBrowserTab, sendBrowserPanelAction, } from '@/lib/browser-agent/transport' -import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' -import { isEphemeralResource } from '@/lib/copilot/resources/types' +import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/mothership/resource-types' +import { isEphemeralResource } from '@/lib/mothership/resources/types' import { requestTerminalFocus } from '@/lib/terminal/focus' import { terminalIdFromResourceId, terminalResourceId } from '@/lib/terminal/resource-id' import { terminalTabTitle, terminalTooltip } from '@/lib/terminal/tab-label' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx index db4483547a1..5bebd1ae51e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx @@ -2,7 +2,7 @@ import { forwardRef, memo, useCallback, useRef, useState } from 'react' import { cn } from '@sim/emcn' -import type { FilePreviewSession } from '@/lib/copilot/request/session' +import type { FilePreviewSession } from '@/lib/mothership/request/session' import type { FileDownloadSource } from '@/lib/uploads/client/download' import { getFileExtension } from '@/lib/uploads/utils/file-utils' import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx index 6a05fd9a395..8828609b6dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.test.tsx @@ -11,7 +11,7 @@ vi.mock('@/blocks/integration-matcher', () => ({ getIntegrationMatcher: () => ({ regex: null, byName: new Map() }), })) -import { SIM_SELECTION_MIME } from '@/lib/copilot/chat/selection-clipboard' +import { SIM_SELECTION_MIME } from '@/lib/mothership/chat/selection-clipboard' import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants' import { type UsePromptEditorProps, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts index 808f6f37902..9defa409d27 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts @@ -5,7 +5,7 @@ import { escapeRegExp } from '@sim/utils/string' import { attachSelectionContextToClipboard, readSelectionContextFromClipboard, -} from '@/lib/copilot/chat/selection-clipboard' +} from '@/lib/mothership/chat/selection-clipboard' import { snapSelectionToChips } from '@/app/workspace/[workspaceId]/home/components/user-input/chip-selection' import { chipDisplayToken, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 036b6ecc228..e54e0baabf8 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -14,9 +14,9 @@ import { Chip, cn, Tooltip, toast } from '@sim/emcn' import { Paperclip, Plus, Slash } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' -import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview' -import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' +import { getMothershipAttachmentPreviewUrl } from '@/lib/mothership/chat/attachment-preview' import { MOTHERSHIP_ADD_CONTEXT_EVENT } from '@/lib/mothership/events' +import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/mothership/resource-types' import { MOTHERSHIP_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.ts new file mode 100644 index 00000000000..f7ce7a7ea67 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.ts @@ -0,0 +1,363 @@ +/** + * Persisted/live message reconciliation (revamp M6 extraction, behavior-preserving): the + * pure helpers that map streamed content blocks onto persisted message shapes, decide + * which persisted assistant row owns a live stream, and select replay state on reconnect — + * moved verbatim out of use-chat.ts. + */ + +import { isBrowserToolName } from '@sim/browser-protocol' +import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message' +import { normalizeMessage, withBlockTiming } from '@/lib/mothership/chat/persisted-message' +import { + MothershipStreamV1CompletionStatus, + MothershipStreamV1EventType, + MothershipStreamV1SpanLifecycleEvent, + MothershipStreamV1SpanPayloadKind, + MothershipStreamV1TextChannel, + MothershipStreamV1ToolPhase, +} from '@/lib/mothership/generated/mothership-stream-v1' +import type { StreamBatchEvent } from '@/lib/mothership/request/session/types' +import { isWorkflowToolName } from '@/lib/mothership/tools/workflow-tools' +import type { MothershipChatHistory } from '@/hooks/queries/mothership-chats' +import type { ContentBlock, MothershipResource } from '@/app/workspace/[workspaceId]/home/types' +import { isZeroStreamCursor } from './stream-protocol' + +export function toRawPersistedContentBlock(block: ContentBlock): Record | null { + const persisted = toRawPersistedContentBlockBody(block) + if (!persisted) return null + if (block.parentToolCallId) persisted.parentToolCallId = block.parentToolCallId + // Carry deterministic span identity onto the live streaming snapshot so the + // rendered live message nests subagents via the span tree. Without this the + // live blocks lose spanId and parseBlocks falls back to legacy flat grouping, + // rendering nested subagents (e.g. deploy) at the top level mid-stream until + // the persisted message (which keeps spanId) replaces it. + if (block.spanId) persisted.spanId = block.spanId + if (block.parentSpanId) persisted.parentSpanId = block.parentSpanId + return withBlockTiming(persisted, block) +} + +function toRawPersistedContentBlockBody(block: ContentBlock): Record | null { + switch (block.type) { + case 'text': + return { + type: MothershipStreamV1EventType.text, + ...(block.subagent ? { lane: 'subagent' } : {}), + channel: MothershipStreamV1TextChannel.assistant, + content: block.content ?? '', + } + case 'thinking': + return { + type: MothershipStreamV1EventType.text, + channel: MothershipStreamV1TextChannel.thinking, + content: block.content ?? '', + } + case 'subagent_thinking': + return { + type: MothershipStreamV1EventType.text, + lane: 'subagent', + channel: MothershipStreamV1TextChannel.thinking, + content: block.content ?? '', + ...(block.subagent ? { agent: block.subagent } : {}), + } + case 'subagent_text': + return { + type: MothershipStreamV1EventType.text, + lane: 'subagent', + channel: MothershipStreamV1TextChannel.assistant, + content: block.content ?? '', + ...(block.subagent ? { agent: block.subagent } : {}), + } + case 'tool_call': + if (!block.toolCall) { + return null + } + return { + type: MothershipStreamV1EventType.tool, + phase: MothershipStreamV1ToolPhase.call, + toolCall: { + id: block.toolCall.id, + name: block.toolCall.name, + state: block.toolCall.status, + ...(block.toolCall.activityDescription + ? { activityDescription: block.toolCall.activityDescription } + : {}), + ...(block.toolCall.params ? { params: block.toolCall.params } : {}), + ...(block.toolCall.result ? { result: block.toolCall.result } : {}), + ...(block.toolCall.calledBy ? { calledBy: block.toolCall.calledBy } : {}), + ...(block.toolCall.displayTitle + ? { + display: { + title: block.toolCall.displayTitle, + }, + } + : {}), + }, + } + case 'subagent': + return { + type: MothershipStreamV1EventType.span, + kind: MothershipStreamV1SpanPayloadKind.subagent, + lifecycle: MothershipStreamV1SpanLifecycleEvent.start, + content: block.content ?? '', + } + case 'subagent_end': + return { + type: MothershipStreamV1EventType.span, + kind: MothershipStreamV1SpanPayloadKind.subagent, + lifecycle: MothershipStreamV1SpanLifecycleEvent.end, + } + case 'stopped': + return { + type: MothershipStreamV1EventType.complete, + status: MothershipStreamV1CompletionStatus.cancelled, + } + default: + return null + } +} + +export function buildAssistantSnapshotMessage(params: { + id: string + content: string + contentBlocks: ContentBlock[] + requestId?: string +}): PersistedMessage { + const rawContentBlocks = params.contentBlocks + .map(toRawPersistedContentBlock) + .filter((block): block is Record => block !== null) + + return normalizeMessage({ + id: params.id, + role: 'assistant', + content: params.content, + timestamp: new Date().toISOString(), + ...(params.requestId ? { requestId: params.requestId } : {}), + ...(rawContentBlocks.length > 0 ? { contentBlocks: rawContentBlocks } : {}), + }) +} + +export function markMessageStopped(message: PersistedMessage): PersistedMessage { + const hasExecutingTool = message.contentBlocks?.some( + (block) => block.toolCall?.state === 'executing' + ) + const hasOpenBlock = message.contentBlocks?.some((block) => block.endedAt === undefined) + if (!hasExecutingTool && !hasOpenBlock) { + return message + } + + const stopTs = Date.now() + const nextBlocks = (message.contentBlocks ?? []).map((block) => { + const stamped = block.endedAt === undefined ? { ...block, endedAt: stopTs } : block + if (stamped.toolCall?.state !== 'executing') { + return stamped + } + return { + ...stamped, + toolCall: { + ...stamped.toolCall, + state: 'cancelled' as const, + display: { + ...(stamped.toolCall.display ?? {}), + title: 'Stopped by user', + }, + }, + } + }) + + if ( + !nextBlocks.some( + (block) => + block.type === MothershipStreamV1EventType.complete && + block.status === MothershipStreamV1CompletionStatus.cancelled + ) + ) { + nextBlocks.push({ + type: MothershipStreamV1EventType.complete, + status: MothershipStreamV1CompletionStatus.cancelled, + }) + } + + return normalizeMessage({ + ...message, + contentBlocks: nextBlocks, + }) +} + +function buildChatResourceHydrationKey(resource: MothershipResource): string { + return JSON.stringify([ + resource.type, + resource.id, + resource.title, + resource.path ?? null, + resource.viewId ?? null, + resource.executionId ?? null, + ]) +} + +export function buildChatHistoryHydrationKey(chatHistory: MothershipChatHistory): string { + const resourceKey = chatHistory.resources.map(buildChatResourceHydrationKey).join('|') + const messageKey = chatHistory.messages.map((message) => message.id).join('|') + const streamSnapshot = chatHistory.streamSnapshot + const snapshotKey = streamSnapshot + ? [ + streamSnapshot.status, + streamSnapshot.events.length, + streamSnapshot.events[streamSnapshot.events.length - 1]?.eventId ?? '', + streamSnapshot.previewSessions + .map( + (session) => + `${session.id}:${session.previewVersion}:${session.status}:${session.updatedAt}` + ) + .join('|'), + ].join('~') + : 'none' + + return [ + chatHistory.id, + chatHistory.activeStreamId ?? '', + messageKey, + resourceKey, + snapshotKey, + ].join('::') +} + +export function isPersistedAssistantMessage( + message: PersistedMessage, + liveAssistantId: string +): boolean { + return ( + message.role === 'assistant' && + message.id !== liveAssistantId && + !message.id.startsWith('live-assistant:') + ) +} + +export function findStreamOwnerIndex(messages: PersistedMessage[], streamId: string): number { + return messages.findIndex((message) => message.role === 'user' && message.id === streamId) +} + +export function findAssistantAfterOwner(messages: PersistedMessage[], ownerIndex: number): number { + for (let index = ownerIndex + 1; index < messages.length; index++) { + const message = messages[index] + if (message.role === 'user') return -1 + if (message.role === 'assistant') return index + } + return -1 +} + +export function hasTerminalPersistedAssistantForStream( + messages: PersistedMessage[], + streamId: string, + liveAssistantId: string +): boolean { + const ownerIndex = findStreamOwnerIndex(messages, streamId) + if (ownerIndex === -1) return false + + const assistantIndex = findAssistantAfterOwner(messages, ownerIndex) + if (assistantIndex === -1) return false + + return isPersistedAssistantMessage(messages[assistantIndex], liveAssistantId) +} + +export function reconcileLiveAssistantTurn(params: { + messages: PersistedMessage[] + streamId: string + liveAssistant: PersistedMessage + activeStreamId: string | null +}): PersistedMessage[] { + const { messages, streamId, liveAssistant, activeStreamId } = params + const ownerIndex = findStreamOwnerIndex(messages, streamId) + if (ownerIndex === -1) { + return [...messages.filter((message) => message.id !== liveAssistant.id), liveAssistant] + } + + const assistantIndex = findAssistantAfterOwner(messages, ownerIndex) + const existingAssistant = assistantIndex >= 0 ? messages[assistantIndex] : undefined + if ( + activeStreamId !== streamId && + existingAssistant && + isPersistedAssistantMessage(existingAssistant, liveAssistant.id) + ) { + const withoutStaleLiveAssistant = messages.filter((message) => message.id !== liveAssistant.id) + return withoutStaleLiveAssistant.length === messages.length + ? messages + : withoutStaleLiveAssistant + } + + const withoutDuplicateLiveAssistant = messages.filter( + (message, index) => index === assistantIndex || message.id !== liveAssistant.id + ) + const adjustedOwnerIndex = withoutDuplicateLiveAssistant.findIndex( + (message) => message.role === 'user' && message.id === streamId + ) + const adjustedAssistantIndex = + adjustedOwnerIndex >= 0 + ? findAssistantAfterOwner(withoutDuplicateLiveAssistant, adjustedOwnerIndex) + : -1 + + if (adjustedAssistantIndex >= 0) { + return withoutDuplicateLiveAssistant.map((message, index) => + index === adjustedAssistantIndex ? liveAssistant : message + ) + } + + if (adjustedOwnerIndex >= 0) { + return [ + ...withoutDuplicateLiveAssistant.slice(0, adjustedOwnerIndex + 1), + liveAssistant, + ...withoutDuplicateLiveAssistant.slice(adjustedOwnerIndex + 1), + ] + } + + return [...withoutDuplicateLiveAssistant, liveAssistant] +} + +export interface ReconnectReplaySelection { + afterCursor: string + preserveExistingState: boolean + source: 'live' | 'reset' +} + +/** + * Decides how a reconnect replay starts. The only state a resumed stream may + * continue from is the live in-memory pair (streaming refs + lastCursorRef) + * maintained together by this mount's stream loop — those are coherent by + * construction. Anything else (fresh mount, cleared refs, cache-derived + * transcripts) replays the Redis buffer from seq 0 into a fresh model: the + * buffer is the source of truth for an in-flight turn and replay is + * idempotent, so a full rebuild is always safe. Seeding the model from a + * cached transcript paired stale content with a newer cursor, which dropped + * replayed events and rendered empty or suffix-only messages. + */ +export function selectReconnectReplayState(params: { + afterCursor: string + currentContent: string + currentBlocks: ContentBlock[] +}): ReconnectReplaySelection { + const { afterCursor, currentContent, currentBlocks } = params + const hasLiveState = currentContent.length > 0 || currentBlocks.length > 0 + if (!isZeroStreamCursor(afterCursor) && hasLiveState) { + return { afterCursor, preserveExistingState: true, source: 'live' } + } + return { afterCursor: '0', preserveExistingState: false, source: 'reset' } +} + +export function getReplayCompletedWorkflowToolCallIds(events: StreamBatchEvent[]): Set { + const completedToolCallIds = new Set() + for (const entry of events) { + const event = entry.event + if (event.type !== MothershipStreamV1EventType.tool) continue + const payload = event.payload + if (!('phase' in payload)) continue + if (payload.phase !== MothershipStreamV1ToolPhase.result) continue + // Client-executed tools (workflow runs, browser actions) must never + // re-fire when their completed call replays after reconnect/reload. + if ( + typeof payload.toolCallId === 'string' && + (isWorkflowToolName(payload.toolName) || isBrowserToolName(payload.toolName)) + ) { + completedToolCallIds.add(payload.toolCallId) + } + } + return completedToolCallIds +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.test.ts index e586d4f9c37..e0413a75650 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import type { FilePreviewSession } from '@/lib/copilot/request/session' +import type { FilePreviewSession } from '@/lib/mothership/request/session' import { deriveFilePreviewSession } from './apply-file-preview-phase' const NOW = '2026-06-08T00:00:00.000Z' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.ts index d4fadb04e86..f9d4c083070 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase.ts @@ -1,8 +1,8 @@ -import type { SyntheticFilePreviewPayload } from '@/lib/copilot/request/session' +import type { SyntheticFilePreviewPayload } from '@/lib/mothership/request/session' import type { FilePreviewSession, FilePreviewTargetKind, -} from '@/lib/copilot/request/session/file-preview-session-contract' +} from '@/lib/mothership/request/session/file-preview-session-contract' function toTargetKind(value: string | undefined): FilePreviewTargetKind | undefined { return value === 'new_file' || value === 'file_id' ? value : undefined diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts index 8f2360d9d92..1fc007be435 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-controller.ts @@ -9,8 +9,8 @@ import { } from 'react' import { isRecordLike } from '@sim/utils/object' import { useQueryClient } from '@tanstack/react-query' -import type { SyntheticFilePreviewPayload } from '@/lib/copilot/request/session' -import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract' +import type { SyntheticFilePreviewPayload } from '@/lib/mothership/request/session' +import type { FilePreviewSession } from '@/lib/mothership/request/session/file-preview-session-contract' import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import { deriveFilePreviewSession } from '@/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase' import { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions.test.tsx index b71d819e8fa..0c2aa75e678 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions.test.tsx @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import type { FilePreviewSession } from '@/lib/copilot/request/session' +import type { FilePreviewSession } from '@/lib/mothership/request/session' import { buildCompletedPreviewSessions, hasRenderableFilePreviewContent, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions.ts index b9a650e4ac0..e673da52361 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/preview/use-file-preview-sessions.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo, useReducer } from 'react' -import type { FilePreviewSession } from '@/lib/copilot/request/session' +import type { FilePreviewSession } from '@/lib/mothership/request/session' export interface FilePreviewSessionsState { activeSessionId: string | null diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/send-handoff.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/send-handoff.ts new file mode 100644 index 00000000000..75eb4ae19f2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/send-handoff.ts @@ -0,0 +1,284 @@ +/** + * Queued-send handoff (revamp M6 extraction, behavior-preserving): the sessionStorage + * claim machinery that carries a queued outgoing message across a chat-scope handoff — + * pure module-scope helpers moved verbatim out of use-chat.ts. State and claim rows are + * TTL-bounded; every reader tolerates malformed or missing storage. + */ +import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { + type WorkspaceSearchFilters, + workspaceSearchFiltersSchema, +} from '@/lib/api/contracts/knowledge/search' +import { STREAM_STORAGE_KEY } from '@/lib/mothership/constants' +import type { ChatContext } from '@/stores/panel' +import type { ChatRequestMode, FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types' + +const QUEUED_SEND_HANDOFF_STORAGE_KEY = `${STREAM_STORAGE_KEY}:queued-send-handoff` +const QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY = `${STREAM_STORAGE_KEY}:queued-send-handoff-claim` +const QUEUED_SEND_HANDOFF_TTL_MS = 5 * 60 * 1000 +const QUEUED_SEND_HANDOFF_CLAIM_TTL_MS = 30_000 +const QUEUED_SEND_HANDOFF_RETRY_BASE_MS = 1000 +const QUEUED_SEND_HANDOFF_RETRY_MAX_MS = 30_000 + +export interface QueuedSendHandoffState { + id: string + chatId?: string + workspaceId?: string + organizationId?: string + supersededStreamId: string | null + userMessageId: string + message: string + fileAttachments?: FileAttachmentForApi[] + contexts?: ChatContext[] + requestMode?: ChatRequestMode + assistantSearch?: WorkspaceSearchFilters + requestedAt: number + resolveAttempts?: number +} + +interface QueuedSendHandoffClaim { + id: string + ownerId: string + claimedAt: number +} + +function isFileAttachmentForApi(value: unknown): value is FileAttachmentForApi { + if (!isRecordLike(value)) return false + return ( + typeof value.id === 'string' && + typeof value.key === 'string' && + typeof value.filename === 'string' && + typeof value.media_type === 'string' && + typeof value.size === 'number' && + Number.isFinite(value.size) && + (value.path === undefined || typeof value.path === 'string') + ) +} + +function isChatContext(value: unknown): value is ChatContext { + if (!isRecordLike(value) || typeof value.kind !== 'string' || typeof value.label !== 'string') { + return false + } + + switch (value.kind) { + case 'past_chat': + return typeof value.chatId === 'string' + case 'workflow': + case 'current_workflow': + return typeof value.workflowId === 'string' + case 'blocks': + return Array.isArray(value.blockIds) && value.blockIds.every((id) => typeof id === 'string') + case 'logs': + return value.executionId === undefined || typeof value.executionId === 'string' + case 'workflow_block': + return typeof value.workflowId === 'string' && typeof value.blockId === 'string' + case 'knowledge': + return value.knowledgeId === undefined || typeof value.knowledgeId === 'string' + case 'table': + return typeof value.tableId === 'string' + case 'table_selection': + return ( + typeof value.tableId === 'string' && + typeof value.tableName === 'string' && + Array.isArray(value.rowIds) && + value.rowIds.every((id) => typeof id === 'string') + ) + case 'file': + return typeof value.fileId === 'string' + case 'file_selection': + return ( + typeof value.fileId === 'string' && + typeof value.fileName === 'string' && + typeof value.text === 'string' + ) + case 'folder': + return typeof value.folderId === 'string' + case 'filefolder': + return typeof value.fileFolderId === 'string' + case 'docs': + return true + case 'slash_command': + return typeof value.command === 'string' + case 'integration': + return typeof value.blockType === 'string' + case 'skill': + return typeof value.skillId === 'string' + case 'mcp': + return typeof value.serverId === 'string' + case 'browser_tab': + return ( + typeof value.tabId === 'string' && + (value.selection === undefined || + (isRecordLike(value.selection) && + typeof value.selection.text === 'string' && + (value.selection.url === undefined || typeof value.selection.url === 'string') && + (value.selection.title === undefined || typeof value.selection.title === 'string'))) + ) + case 'terminal_tab': + return ( + typeof value.terminalId === 'string' && + (value.selection === undefined || + (isRecordLike(value.selection) && + typeof value.selection.text === 'string' && + typeof value.selection.startLine === 'number' && + typeof value.selection.endLine === 'number' && + Number.isInteger(value.selection.startLine) && + Number.isInteger(value.selection.endLine) && + value.selection.startLine > 0 && + value.selection.endLine >= value.selection.startLine)) + ) + default: + return false + } +} + +export function readQueuedSendHandoffState(): QueuedSendHandoffState | null { + if (typeof window === 'undefined') return null + + try { + const raw = window.sessionStorage.getItem(QUEUED_SEND_HANDOFF_STORAGE_KEY) + if (!raw) return null + + const parsed = JSON.parse(raw) as Partial + const chatId = typeof parsed.chatId === 'string' ? parsed.chatId : undefined + const supersededStreamId = + typeof parsed.supersededStreamId === 'string' ? parsed.supersededStreamId : null + if ( + typeof parsed?.id !== 'string' || + (typeof parsed.workspaceId !== 'string' && typeof parsed.organizationId !== 'string') || + (typeof parsed.workspaceId === 'string' && typeof parsed.organizationId === 'string') || + typeof parsed.userMessageId !== 'string' || + typeof parsed.message !== 'string' || + typeof parsed.requestedAt !== 'number' || + (!chatId && !supersededStreamId) + ) { + return null + } + if (Date.now() - parsed.requestedAt > QUEUED_SEND_HANDOFF_TTL_MS) { + window.sessionStorage.removeItem(QUEUED_SEND_HANDOFF_STORAGE_KEY) + if (readQueuedSendHandoffClaim() === parsed.id) { + window.sessionStorage.removeItem(QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY) + } + return null + } + + const assistantSearch = workspaceSearchFiltersSchema.safeParse(parsed.assistantSearch ?? {}) + if (!assistantSearch.success) return null + + return { + id: parsed.id, + ...(chatId ? { chatId } : {}), + workspaceId: parsed.workspaceId, + organizationId: parsed.organizationId, + supersededStreamId, + userMessageId: parsed.userMessageId, + message: parsed.message, + ...(Array.isArray(parsed.fileAttachments) + ? { fileAttachments: parsed.fileAttachments.filter(isFileAttachmentForApi) } + : {}), + ...(Array.isArray(parsed.contexts) + ? { contexts: parsed.contexts.filter(isChatContext) } + : {}), + ...(parsed.requestMode === 'assistant' ? { requestMode: 'assistant' } : {}), + ...(parsed.assistantSearch ? { assistantSearch: assistantSearch.data } : {}), + requestedAt: parsed.requestedAt, + ...(typeof parsed.resolveAttempts === 'number' && + Number.isFinite(parsed.resolveAttempts) && + parsed.resolveAttempts > 0 + ? { resolveAttempts: parsed.resolveAttempts } + : {}), + } + } catch { + return null + } +} + +export function writeQueuedSendHandoffState(state: QueuedSendHandoffState) { + if (typeof window === 'undefined') return + window.sessionStorage.setItem(QUEUED_SEND_HANDOFF_STORAGE_KEY, JSON.stringify(state)) +} + +export function clearQueuedSendHandoffState(expectedId?: string) { + if (typeof window === 'undefined') return + if (expectedId) { + const current = readQueuedSendHandoffState() + if (current && current.id !== expectedId) { + return + } + } + window.sessionStorage.removeItem(QUEUED_SEND_HANDOFF_STORAGE_KEY) +} + +function readQueuedSendHandoffClaimState(): QueuedSendHandoffClaim | null { + if (typeof window === 'undefined') return null + const raw = window.sessionStorage.getItem(QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY) + if (!raw) return null + + try { + const parsed = JSON.parse(raw) as Partial + if ( + typeof parsed?.id !== 'string' || + typeof parsed.ownerId !== 'string' || + typeof parsed.claimedAt !== 'number' + ) { + window.sessionStorage.removeItem(QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY) + return null + } + if (Date.now() - parsed.claimedAt > QUEUED_SEND_HANDOFF_CLAIM_TTL_MS) { + window.sessionStorage.removeItem(QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY) + return null + } + return { id: parsed.id, ownerId: parsed.ownerId, claimedAt: parsed.claimedAt } + } catch { + window.sessionStorage.removeItem(QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY) + return null + } +} + +export function readQueuedSendHandoffClaim(): string | null { + return readQueuedSendHandoffClaimState()?.id ?? null +} + +export function hasQueuedSendHandoffClaimOwner(id: string, ownerId: string): boolean { + const claim = readQueuedSendHandoffClaimState() + return claim?.id === id && claim.ownerId === ownerId +} + +export function queuedSendHandoffClaimRetryDelay(id: string): number | null { + const claim = readQueuedSendHandoffClaimState() + if (!claim || claim.id !== id) return null + const elapsed = Date.now() - claim.claimedAt + return Math.max(0, QUEUED_SEND_HANDOFF_CLAIM_TTL_MS - elapsed + 1) +} + +export function queuedSendHandoffResolveRetryDelay(resolveAttempts: number): number { + return Math.min( + QUEUED_SEND_HANDOFF_RETRY_MAX_MS, + QUEUED_SEND_HANDOFF_RETRY_BASE_MS * 2 ** Math.max(0, resolveAttempts - 1) + ) +} + +export function writeQueuedSendHandoffClaim(id: string): string { + const ownerId = generateId() + if (typeof window === 'undefined') return ownerId + window.sessionStorage.setItem( + QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY, + JSON.stringify({ id, ownerId, claimedAt: Date.now() } satisfies QueuedSendHandoffClaim) + ) + return ownerId +} + +export function clearQueuedSendHandoffClaim(expectedId?: string, expectedOwnerId?: string) { + if (typeof window === 'undefined') return + if (expectedId) { + const current = readQueuedSendHandoffClaimState() + if ( + current && + (current.id !== expectedId || (expectedOwnerId && current.ownerId !== expectedOwnerId)) + ) { + return + } + } + window.sessionStorage.removeItem(QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream-protocol.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream-protocol.ts new file mode 100644 index 00000000000..ba0b839ad55 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream-protocol.ts @@ -0,0 +1,182 @@ +/** + * The client half of the mothership stream wire (revamp M6 extraction, behavior- + * preserving): batch parse + schema enforcement, cursor/terminal-state predicates, the + * stream-gone error class, and the replay-stream builder — pure module-scope helpers + * moved verbatim out of use-chat.ts. + */ +import { isRecordLike } from '@sim/utils/object' +import { + MothershipStreamV1EventType, + MothershipStreamV1SessionKind, +} from '@/lib/mothership/generated/mothership-stream-v1' +import { + type ParseStreamEventEnvelopeFailure, + parsePersistedStreamEventEnvelope, +} from '@/lib/mothership/request/session/contract' +import { + type FilePreviewSession, + isFilePreviewSession, +} from '@/lib/mothership/request/session/file-preview-session-contract' +import type { StreamBatchEvent } from '@/lib/mothership/request/session/types' + +export type StreamBatchResponse = { + success: boolean + events: StreamBatchEvent[] + previewSessions?: FilePreviewSession[] + status: string + chatId?: string +} + +const STREAM_SCHEMA_ENFORCEMENT_PREFIX = 'Client stream schema enforcement failed.' + +class StreamSchemaValidationError extends Error { + constructor(message: string) { + super(message) + this.name = 'StreamSchemaValidationError' + } +} + +export function createStreamSchemaValidationError( + failure: ParseStreamEventEnvelopeFailure, + context?: string +): StreamSchemaValidationError { + const details = failure.errors?.filter(Boolean).join('; ') + return new StreamSchemaValidationError( + [STREAM_SCHEMA_ENFORCEMENT_PREFIX, context, failure.message, details].filter(Boolean).join(' ') + ) +} + +export function createBatchSchemaValidationError(message: string): StreamSchemaValidationError { + return new StreamSchemaValidationError([STREAM_SCHEMA_ENFORCEMENT_PREFIX, message].join(' ')) +} + +export function isStreamSchemaValidationError( + error: unknown +): error is StreamSchemaValidationError { + return error instanceof StreamSchemaValidationError +} + +export function parseStreamBatchResponse(value: unknown): StreamBatchResponse { + if (!isRecordLike(value)) { + throw new Error('Invalid stream batch response') + } + + const rawEvents = Array.isArray(value.events) ? value.events : [] + const events: StreamBatchEvent[] = [] + for (const [index, entry] of rawEvents.entries()) { + if (!isRecordLike(entry)) { + throw createBatchSchemaValidationError(`Reconnect batch event ${index + 1} is not an object.`) + } + if ( + typeof entry.eventId !== 'number' || + !Number.isFinite(entry.eventId) || + typeof entry.streamId !== 'string' + ) { + throw createBatchSchemaValidationError( + `Reconnect batch event ${index + 1} is missing required metadata.` + ) + } + + const parsedEvent = parsePersistedStreamEventEnvelope(entry.event) + if (!parsedEvent.ok) { + throw createStreamSchemaValidationError(parsedEvent, `Reconnect batch event ${index + 1}.`) + } + + events.push({ + eventId: entry.eventId, + streamId: entry.streamId, + event: parsedEvent.event, + }) + } + + const rawPreviewSessions = Array.isArray(value.previewSessions) + ? value.previewSessions + : undefined + const previewSessions = + rawPreviewSessions?.map((session, index) => { + if (!isFilePreviewSession(session)) { + throw createBatchSchemaValidationError( + `Reconnect preview session ${index + 1} failed validation.` + ) + } + return session + }) ?? undefined + + return { + success: value.success === true, + events, + ...(previewSessions ? { previewSessions } : {}), + status: typeof value.status === 'string' ? value.status : 'unknown', + ...(typeof value.chatId === 'string' && value.chatId ? { chatId: value.chatId } : {}), + } +} + +export function resolveChatIdFromStreamBatch(batch: StreamBatchResponse): string | undefined { + if (batch.chatId) return batch.chatId + + for (const { event } of batch.events) { + const streamChatId = typeof event.stream?.chatId === 'string' ? event.stream.chatId : undefined + if (streamChatId) return streamChatId + if ( + event.type === MothershipStreamV1EventType.session && + event.payload.kind === MothershipStreamV1SessionKind.chat + ) { + return event.payload.chatId + } + } + + return undefined +} + +const TERMINAL_STREAM_STATUSES = new Set(['complete', 'error', 'cancelled']) + +export function isTerminalStreamStatus(status: string | null | undefined): boolean { + return TERMINAL_STREAM_STATUSES.has(status ?? '') +} + +export function isAlreadyProcessedStreamCursor( + eventCursor: string | undefined, + currentCursor: string +): boolean { + if (!eventCursor) return false + + const eventSequence = Number(eventCursor) + const currentSequence = Number(currentCursor) + return ( + Number.isFinite(eventSequence) && + Number.isFinite(currentSequence) && + eventSequence <= currentSequence + ) +} + +export function isZeroStreamCursor(cursor: string): boolean { + const sequence = Number(cursor) + return Number.isFinite(sequence) && sequence <= 0 +} + +/** + * The resume endpoint 404s when no run exists for the stream — there is + * nothing left to resume, so reconnect falls back to the persisted DB + * transcript instead of retrying or surfacing an error. + */ +export class StreamGoneError extends Error { + constructor(streamId: string) { + super(`Stream ${streamId} no longer exists`) + this.name = 'StreamGoneError' + } +} + +export function isStreamGoneError(error: unknown): error is StreamGoneError { + return error instanceof Error && error.name === 'StreamGoneError' +} + +const sseEncoder = new TextEncoder() +export function buildReplayStream(events: StreamBatchEvent[]): ReadableStream { + return new ReadableStream({ + start(controller) { + const payload = events.map((entry) => `data: ${JSON.stringify(entry.event)}\n\n`).join('') + controller.enqueue(sseEncoder.encode(payload)) + controller.close() + }, + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.test.ts index b0761dc9180..10ebc251d47 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1' -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import { MothershipStreamV1EventType } from '@/lib/mothership/generated/mothership-stream-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import type { StreamLoopContext } from './stream-context' const handlers = vi.hoisted(() => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.ts index 7d3de4ba00e..dade9df4e45 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/dispatch-stream-event.ts @@ -1,5 +1,5 @@ -import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1' -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import { MothershipStreamV1EventType } from '@/lib/mothership/generated/mothership-stream-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import { handleCompleteEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event' import { handleErrorEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event' import { handleResourceEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts index 6b4fa6f8c39..521f447fc1d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts @@ -1,4 +1,4 @@ -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' type CompleteEvent = Extract diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event.ts index 54b11258412..4ec25620a15 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-error-event.ts @@ -1,4 +1,4 @@ -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' type ErrorEvent = Extract diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts index abb0c6dd488..2f10fe93641 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.test.ts @@ -16,7 +16,7 @@ vi.mock('@/hooks/queries/utils/workflow-cache', () => ({ removeWorkflowFromActiveCache: mocks.removeWorkflowFromActiveCache, })) -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import { handleResourceEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' import { makeStreamLoopDeps } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts index 67ce8a15c72..9731a595588 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts @@ -1,9 +1,9 @@ import { type MothershipStreamV1EventType, MothershipStreamV1ResourceOp, -} from '@/lib/copilot/generated/mothership-stream-v1' -import type { FilePreviewSession } from '@/lib/copilot/request/session' -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +} from '@/lib/mothership/generated/mothership-stream-v1' +import type { FilePreviewSession } from '@/lib/mothership/request/session' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import { hasRenderableFilePreviewContent, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-run-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-run-event.ts index 4182dd92302..7cfbb4b11fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-run-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-run-event.ts @@ -1,4 +1,4 @@ -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' type RunEvent = Extract diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.test.ts index 5d3c26e2d51..efed9b1b351 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' -import { MothershipStreamV1SessionKind } from '@/lib/copilot/generated/mothership-stream-v1' -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import { MothershipStreamV1SessionKind } from '@/lib/mothership/generated/mothership-stream-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import { handleSessionEvent } from '@/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' import { makeStreamLoopDeps } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts index ad01148d02d..14987ecfa5d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-session-event.ts @@ -1,6 +1,6 @@ -import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' -import { MothershipStreamV1SessionKind } from '@/lib/copilot/generated/mothership-stream-v1' -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import { getLiveAssistantMessageId } from '@/lib/mothership/chat/effective-transcript' +import { MothershipStreamV1SessionKind } from '@/lib/mothership/generated/mothership-stream-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import { chatUrl } from '@/app/workspace/[workspaceId]/home/hooks/chat-url' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' import { type MothershipChatHistory, mothershipChatKeys } from '@/hooks/queries/mothership-chats' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.test.ts index e74693687e5..079022b38c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.test.ts @@ -5,8 +5,8 @@ import { describe, expect, it } from 'vitest' import { MothershipStreamV1SpanLifecycleEvent, MothershipStreamV1SpanPayloadKind, -} from '@/lib/copilot/generated/mothership-stream-v1' -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +} from '@/lib/mothership/generated/mothership-stream-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import { handleCompleteEvent } from './handle-complete-event' import { handleSpanEvent } from './handle-span-event' import { createStreamLoopContext } from './stream-context' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.ts index 17ebd4c228f..51811dad903 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.ts @@ -1,8 +1,8 @@ import { MothershipStreamV1SpanLifecycleEvent, MothershipStreamV1SpanPayloadKind, -} from '@/lib/copilot/generated/mothership-stream-v1' -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +} from '@/lib/mothership/generated/mothership-stream-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import type { StreamEventScope, StreamLoopContext, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-text-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-text-event.ts index ba115ca97d6..22fd01c1788 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-text-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-text-event.ts @@ -1,4 +1,4 @@ -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' type TextEvent = Extract diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts index 450f4d5b6b8..5ea1163e33d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts @@ -3,11 +3,11 @@ */ import { describe, expect, it, vi } from 'vitest' -vi.mock('@/lib/copilot/resources/extraction', () => ({ +vi.mock('@/lib/mothership/resources/extraction', () => ({ isResourceToolName: vi.fn(() => false), extractResourcesFromToolResult: vi.fn(() => []), })) -vi.mock('@/lib/copilot/tools/workflow-tools', () => ({ +vi.mock('@/lib/mothership/tools/workflow-tools', () => ({ isWorkflowToolName: vi.fn(() => false), })) vi.mock( @@ -15,8 +15,8 @@ vi.mock( () => ({ invalidateResourceQueries: vi.fn() }) ) -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' -import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' +import type { FilePreviewSession } from '@/lib/mothership/request/session/file-preview-session-contract' import { dispatchStreamEvent } from './dispatch-stream-event' import { createStreamLoopContext, type StreamLoopContext } from './stream-context' import { makeStreamLoopDeps, ref } from './stream-test-helpers' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts index 66b261a966c..89b7b72eb10 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.ts @@ -3,15 +3,15 @@ import { isTerminalToolName } from '@sim/terminal-protocol' import { MothershipStreamV1ToolPhase, MothershipStreamV1ToolStatus, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { ApplyFileEdit, PrepareFileEdit } from '@/lib/copilot/generated/tool-catalog-v1' -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { ApplyFileEdit, PrepareFileEdit } from '@/lib/mothership/generated/tool-catalog-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import { extractResourcesFromToolResult, isResourceToolName, -} from '@/lib/copilot/resources/extraction' -import { isUserLocalVfsToolCall } from '@/lib/copilot/tools/local-filesystem' -import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' +} from '@/lib/mothership/resources/extraction' +import { isUserLocalVfsToolCall } from '@/lib/mothership/tools/local-filesystem' +import { isWorkflowToolName } from '@/lib/mothership/tools/workflow-tools' import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' import type { StreamLoopContext } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-context' import { diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.test.ts index 67eb6ab1b23..da4f8f80f79 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import type { ChatMessage, ContentBlock } from '@/app/workspace/[workspaceId]/home/types' import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' import { createStreamLoopContext } from './stream-context' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts index aeb99e8f257..b9882a6d4a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts @@ -1,11 +1,11 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react' import type { QueryClient } from '@tanstack/react-query' -import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' -import type { RevealedSimKeysByMessage } from '@/lib/copilot/chat/sim-key-redaction' -import { captureRevealedSimKeys } from '@/lib/copilot/chat/sim-key-redaction' -import type { SyntheticFilePreviewPayload } from '@/lib/copilot/request/session' -import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract' -import type { MothershipResourceUpdate } from '@/lib/copilot/resources/types' +import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message' +import type { RevealedSimKeysByMessage } from '@/lib/mothership/chat/sim-key-redaction' +import { captureRevealedSimKeys } from '@/lib/mothership/chat/sim-key-redaction' +import type { SyntheticFilePreviewPayload } from '@/lib/mothership/request/session' +import type { FilePreviewSession } from '@/lib/mothership/request/session/file-preview-session-contract' +import type { MothershipResourceUpdate } from '@/lib/mothership/resources/types' import { createTurnModel, type TurnModel, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts index afeb703e08d..880d90dfb51 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts @@ -26,9 +26,9 @@ import { WebCrawl, WebScrape, WebSearch, -} from '@/lib/copilot/generated/tool-catalog-v1' -import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' -import { getToolDisplayTitle, mvDisplayVerb } from '@/lib/copilot/tools/tool-display' +} from '@/lib/mothership/generated/tool-catalog-v1' +import { extractStreamingStringArgument } from '@/lib/mothership/tools/streaming-args' +import { getToolDisplayTitle, mvDisplayVerb } from '@/lib/mothership/tools/tool-display' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import type { ContentBlock } from '@/app/workspace/[workspaceId]/home/types' import { ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts index f7b1755763b..2e91edfff9e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-test-helpers.ts @@ -1,9 +1,9 @@ import type { MutableRefObject } from 'react' import type { QueryClient } from '@tanstack/react-query' import { vi } from 'vitest' -import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' -import type { RevealedSimKeysByMessage } from '@/lib/copilot/chat/sim-key-redaction' -import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract' +import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message' +import type { RevealedSimKeysByMessage } from '@/lib/mothership/chat/sim-key-redaction' +import type { FilePreviewSession } from '@/lib/mothership/request/session/file-preview-session-contract' import type { ActiveTurn, StreamLoopDeps, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts index 242f236c5ba..3266e1cdcd0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import { resolveStreamingToolDisplayTitle } from '@/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers' import { type AgentNode, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts index 4cc1f5e7233..482bec1289b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model-serialize.ts @@ -1,4 +1,4 @@ -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import { resolveIntegrationToolDisplayTitle, resolveStreamingToolDisplayTitle, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts index 85c0b420e73..318962937dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' import { type AgentNode, applyTurnTerminal, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index 14fee904b18..48f1d551d15 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -1,5 +1,5 @@ import { isRecordLike, toRecord } from '@sim/utils/object' -import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome' +import { resolveStreamToolOutcome } from '@/lib/mothership/chat/stream-tool-outcome' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, @@ -7,14 +7,14 @@ import { MothershipStreamV1SpanLifecycleEvent, MothershipStreamV1SpanPayloadKind, MothershipStreamV1ToolPhase, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { CallIntegrationTool } from '@/lib/copilot/generated/tool-catalog-v1' -import type { PersistedStreamEventEnvelope } from '@/lib/copilot/request/session/contract' -import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { CallIntegrationTool } from '@/lib/mothership/generated/tool-catalog-v1' +import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract' +import { extractStreamingStringArgument } from '@/lib/mothership/tools/streaming-args' import { CONTEXT_COMPACTION_DISPLAY_TITLE, normalizeToolActivityDescription, -} from '@/lib/copilot/tools/tool-display' +} from '@/lib/mothership/tools/tool-display' /** * The single deterministic model of one assistant turn, derived purely from the diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts index b833f38856a..2fe82371beb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.test.ts @@ -2,17 +2,20 @@ * @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' -import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' +import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message' import { MothershipStreamV1EventType, MothershipStreamV1ToolPhase, -} from '@/lib/copilot/generated/mothership-stream-v1' -import type { StreamBatchEvent } from '@/lib/copilot/request/session/types' +} from '@/lib/mothership/generated/mothership-stream-v1' +import type { StreamBatchEvent } from '@/lib/mothership/request/session/types' import { getReplayCompletedWorkflowToolCallIds, reconcileLiveAssistantTurn, - selectDeletedWorkflowResources, selectReconnectReplayState, +} from '@/app/workspace/[workspaceId]/home/hooks/message-reconcile' +import { + panelForExecutingClientTool, + selectDeletedWorkflowResources, shouldActivateResourceEvent, shouldQueueOutgoingMessage, waitForDetachedChatResolution, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index c2ede00f541..694f27daad7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -22,7 +22,6 @@ import { isApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { type WorkspaceSearchFilters, - workspaceSearchFiltersSchema, } from '@/lib/api/contracts/knowledge/search' import { addMothershipChatResourceContract, @@ -31,72 +30,56 @@ import { } from '@/lib/api/contracts/mothership-chats' import { cancelWorkflowExecutionContract } from '@/lib/api/contracts/workflows' import { buildResourceAttachments } from '@/lib/browser-agent/attachments' -import { cancelActiveBrowserTools, initBrowserAgentTransport } from '@/lib/browser-agent/transport' -import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview' -import { toDisplayMessage } from '@/lib/copilot/chat/display-message' -import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' +import { + cancelActiveBrowserTools, + initBrowserAgentTransport, +} from '@/lib/browser-agent/transport' +import { ResourcePersistenceQueue } from '@/lib/mothership/resources/client-persistence-queue' +import { type MothershipResourceUpdate, mergeChatResource } from '@/lib/mothership/resources/types' +import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' +import { readSSELines } from '@/lib/core/utils/sse' +import { getDesktopBridge, getDesktopChatCapabilities } from '@/lib/desktop' +import { + activateDesktopChatScopes, + desktopChatScopeId, + discardDesktopChatScopes, + migrateDesktopChatScopes, + PENDING_CHAT_KEY_PREFIX, +} from '@/lib/desktop/chat-scope' +import { getMothershipAttachmentPreviewUrl } from '@/lib/mothership/chat/attachment-preview' +import { toDisplayMessage } from '@/lib/mothership/chat/display-message' +import { getLiveAssistantMessageId } from '@/lib/mothership/chat/effective-transcript' import type { PersistedFileAttachment, PersistedMessage, -} from '@/lib/copilot/chat/persisted-message' -import { normalizeMessage, withBlockTiming } from '@/lib/copilot/chat/persisted-message' +} from '@/lib/mothership/chat/persisted-message' import { type RevealedSimKeysByMessage, restoreRevealedSimKeysForMessage, -} from '@/lib/copilot/chat/sim-key-redaction' -import { MOTHERSHIP_CHAT_API_PATH, STREAM_STORAGE_KEY } from '@/lib/copilot/constants' -import { - MothershipStreamV1CompletionStatus, - MothershipStreamV1EventType, - MothershipStreamV1SessionKind, - MothershipStreamV1SpanLifecycleEvent, - MothershipStreamV1SpanPayloadKind, - MothershipStreamV1TextChannel, - MothershipStreamV1ToolOutcome, - MothershipStreamV1ToolPhase, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { - type ParseStreamEventEnvelopeFailure, - parsePersistedStreamEventEnvelope, - parsePersistedStreamEventEnvelopeJson, -} from '@/lib/copilot/request/session/contract' -import { - type FilePreviewSession, - isFilePreviewSession, -} from '@/lib/copilot/request/session/file-preview-session-contract' -import type { StreamBatchEvent } from '@/lib/copilot/request/session/types' -import { canDisplayResource } from '@/lib/copilot/resources/availability' -import { ResourcePersistenceQueue } from '@/lib/copilot/resources/client-persistence-queue' +} from '@/lib/mothership/chat/sim-key-redaction' +import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/mothership/constants' +import { sendMothershipMessage } from '@/lib/mothership/events' +import { MothershipStreamV1ToolOutcome } from '@/lib/mothership/generated/mothership-stream-v1' +import { parsePersistedStreamEventEnvelopeJson } from '@/lib/mothership/request/session/contract' +import type { FilePreviewSession } from '@/lib/mothership/request/session/file-preview-session-contract' +import { canDisplayResource } from '@/lib/mothership/resources/availability' import { isAddressableResource, isEphemeralResource, - type MothershipResourceUpdate, - mergeChatResource, sanitizeChatResources, -} from '@/lib/copilot/resources/types' -import { executeBrowserToolOnClient } from '@/lib/copilot/tools/client/browser-tool-execution' +} from '@/lib/mothership/resources/types' +import { executeBrowserToolOnClient } from '@/lib/mothership/tools/client/browser-tool-execution' import { bindRunToolToExecution, cancelRunToolExecution, executeRunToolOnClient, markRunToolManuallyStopped, reportManualRunToolStop, -} from '@/lib/copilot/tools/client/run-tool-execution' -import { executeTerminalToolOnClient } from '@/lib/copilot/tools/client/terminal-tool-execution' -import { setCurrentChatTraceparent } from '@/lib/copilot/tools/client/trace-context' -import { isUserLocalVfsToolCall } from '@/lib/copilot/tools/local-filesystem' -import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' -import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' -import { readSSELines } from '@/lib/core/utils/sse' -import { getDesktopBridge, getDesktopChatCapabilities } from '@/lib/desktop' -import { - activateDesktopChatScopes, - desktopChatScopeId, - discardDesktopChatScopes, - migrateDesktopChatScopes, - PENDING_CHAT_KEY_PREFIX, -} from '@/lib/desktop/chat-scope' -import { sendMothershipMessage } from '@/lib/mothership/events' +} from '@/lib/mothership/tools/client/run-tool-execution' +import { executeTerminalToolOnClient } from '@/lib/mothership/tools/client/terminal-tool-execution' +import { setCurrentChatTraceparent } from '@/lib/mothership/tools/client/trace-context' +import { isUserLocalVfsToolCall } from '@/lib/mothership/tools/local-filesystem' +import { isWorkflowToolName } from '@/lib/mothership/tools/workflow-tools' import { initTerminalTransport } from '@/lib/terminal/transport' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { chatUrl } from '@/app/workspace/[workspaceId]/home/hooks/chat-url' @@ -156,6 +139,39 @@ import type { QueuedMessage, ToolCallInfo, } from '../types' +import { + buildAssistantSnapshotMessage, + buildChatHistoryHydrationKey, + getReplayCompletedWorkflowToolCallIds, + hasTerminalPersistedAssistantForStream, + markMessageStopped, + type ReconnectReplaySelection, + reconcileLiveAssistantTurn, + selectReconnectReplayState, +} from './message-reconcile' +import { + clearQueuedSendHandoffClaim, + clearQueuedSendHandoffState, + hasQueuedSendHandoffClaimOwner, + queuedSendHandoffClaimRetryDelay, + queuedSendHandoffResolveRetryDelay, + readQueuedSendHandoffClaim, + readQueuedSendHandoffState, + writeQueuedSendHandoffClaim, + writeQueuedSendHandoffState, +} from './send-handoff' +import { + buildReplayStream, + createStreamSchemaValidationError, + isAlreadyProcessedStreamCursor, + isStreamGoneError, + isStreamSchemaValidationError, + isTerminalStreamStatus, + parseStreamBatchResponse, + resolveChatIdFromStreamBatch, + type StreamBatchResponse, + StreamGoneError, +} from './stream-protocol' export interface SendMessageOptions { /** @@ -248,12 +264,6 @@ const STREAM_BATCH_FETCH_TIMEOUT_MS = 10_000 const STREAM_CHAT_ID_RESOLVE_TIMEOUT_MS = 10_000 const CHAT_HISTORY_RECOVERY_TIMEOUT_MS = 10_000 const STOP_REQUEST_TIMEOUT_MS = 15_000 -const QUEUED_SEND_HANDOFF_STORAGE_KEY = `${STREAM_STORAGE_KEY}:queued-send-handoff` -const QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY = `${STREAM_STORAGE_KEY}:queued-send-handoff-claim` -const QUEUED_SEND_HANDOFF_TTL_MS = 5 * 60 * 1000 -const QUEUED_SEND_HANDOFF_CLAIM_TTL_MS = 30_000 -const QUEUED_SEND_HANDOFF_RETRY_BASE_MS = 1000 -const QUEUED_SEND_HANDOFF_RETRY_MAX_MS = 30_000 const DETACHED_CHAT_RETRY_BASE_MS = 1000 const DETACHED_CHAT_RETRY_MAX_MS = 30_000 @@ -303,28 +313,6 @@ interface DetachedChatResolution { terminal: boolean } -interface QueuedSendHandoffState { - id: string - chatId?: string - workspaceId?: string - organizationId?: string - supersededStreamId: string | null - userMessageId: string - message: string - fileAttachments?: FileAttachmentForApi[] - contexts?: ChatContext[] - requestMode?: ChatRequestMode - assistantSearch?: WorkspaceSearchFilters - requestedAt: number - resolveAttempts?: number -} - -interface QueuedSendHandoffClaim { - id: string - ownerId: string - claimedAt: number -} - interface ActiveQueuedSendHandoffRecovery { id: string ownerId: string @@ -514,642 +502,6 @@ function isChatContext(value: unknown): value is ChatContext { } } -function readQueuedSendHandoffState(): QueuedSendHandoffState | null { - if (typeof window === 'undefined') return null - - try { - const raw = window.sessionStorage.getItem(QUEUED_SEND_HANDOFF_STORAGE_KEY) - if (!raw) return null - - const parsed = JSON.parse(raw) as Partial - const chatId = typeof parsed.chatId === 'string' ? parsed.chatId : undefined - const supersededStreamId = - typeof parsed.supersededStreamId === 'string' ? parsed.supersededStreamId : null - if ( - typeof parsed?.id !== 'string' || - (typeof parsed.workspaceId !== 'string' && typeof parsed.organizationId !== 'string') || - (typeof parsed.workspaceId === 'string' && typeof parsed.organizationId === 'string') || - typeof parsed.userMessageId !== 'string' || - typeof parsed.message !== 'string' || - typeof parsed.requestedAt !== 'number' || - (!chatId && !supersededStreamId) - ) { - return null - } - if (Date.now() - parsed.requestedAt > QUEUED_SEND_HANDOFF_TTL_MS) { - window.sessionStorage.removeItem(QUEUED_SEND_HANDOFF_STORAGE_KEY) - if (readQueuedSendHandoffClaim() === parsed.id) { - window.sessionStorage.removeItem(QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY) - } - return null - } - - const assistantSearch = workspaceSearchFiltersSchema.safeParse(parsed.assistantSearch ?? {}) - if (!assistantSearch.success) return null - - return { - id: parsed.id, - ...(chatId ? { chatId } : {}), - workspaceId: parsed.workspaceId, - organizationId: parsed.organizationId, - supersededStreamId, - userMessageId: parsed.userMessageId, - message: parsed.message, - ...(Array.isArray(parsed.fileAttachments) - ? { fileAttachments: parsed.fileAttachments.filter(isFileAttachmentForApi) } - : {}), - ...(Array.isArray(parsed.contexts) - ? { contexts: parsed.contexts.filter(isChatContext) } - : {}), - ...(parsed.requestMode === 'assistant' ? { requestMode: 'assistant' } : {}), - ...(parsed.assistantSearch ? { assistantSearch: assistantSearch.data } : {}), - requestedAt: parsed.requestedAt, - ...(typeof parsed.resolveAttempts === 'number' && - Number.isFinite(parsed.resolveAttempts) && - parsed.resolveAttempts > 0 - ? { resolveAttempts: parsed.resolveAttempts } - : {}), - } - } catch { - return null - } -} - -function writeQueuedSendHandoffState(state: QueuedSendHandoffState) { - if (typeof window === 'undefined') return - window.sessionStorage.setItem(QUEUED_SEND_HANDOFF_STORAGE_KEY, JSON.stringify(state)) -} - -function clearQueuedSendHandoffState(expectedId?: string) { - if (typeof window === 'undefined') return - if (expectedId) { - const current = readQueuedSendHandoffState() - if (current && current.id !== expectedId) { - return - } - } - window.sessionStorage.removeItem(QUEUED_SEND_HANDOFF_STORAGE_KEY) -} - -function readQueuedSendHandoffClaimState(): QueuedSendHandoffClaim | null { - if (typeof window === 'undefined') return null - const raw = window.sessionStorage.getItem(QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY) - if (!raw) return null - - try { - const parsed = JSON.parse(raw) as Partial - if ( - typeof parsed?.id !== 'string' || - typeof parsed.ownerId !== 'string' || - typeof parsed.claimedAt !== 'number' - ) { - window.sessionStorage.removeItem(QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY) - return null - } - if (Date.now() - parsed.claimedAt > QUEUED_SEND_HANDOFF_CLAIM_TTL_MS) { - window.sessionStorage.removeItem(QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY) - return null - } - return { id: parsed.id, ownerId: parsed.ownerId, claimedAt: parsed.claimedAt } - } catch { - window.sessionStorage.removeItem(QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY) - return null - } -} - -function readQueuedSendHandoffClaim(): string | null { - return readQueuedSendHandoffClaimState()?.id ?? null -} - -function hasQueuedSendHandoffClaimOwner(id: string, ownerId: string): boolean { - const claim = readQueuedSendHandoffClaimState() - return claim?.id === id && claim.ownerId === ownerId -} - -function queuedSendHandoffClaimRetryDelay(id: string): number | null { - const claim = readQueuedSendHandoffClaimState() - if (!claim || claim.id !== id) return null - const elapsed = Date.now() - claim.claimedAt - return Math.max(0, QUEUED_SEND_HANDOFF_CLAIM_TTL_MS - elapsed + 1) -} - -function queuedSendHandoffResolveRetryDelay(resolveAttempts: number): number { - return Math.min( - QUEUED_SEND_HANDOFF_RETRY_MAX_MS, - QUEUED_SEND_HANDOFF_RETRY_BASE_MS * 2 ** Math.max(0, resolveAttempts - 1) - ) -} - -function writeQueuedSendHandoffClaim(id: string): string { - const ownerId = generateId() - if (typeof window === 'undefined') return ownerId - window.sessionStorage.setItem( - QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY, - JSON.stringify({ id, ownerId, claimedAt: Date.now() } satisfies QueuedSendHandoffClaim) - ) - return ownerId -} - -function clearQueuedSendHandoffClaim(expectedId?: string, expectedOwnerId?: string) { - if (typeof window === 'undefined') return - if (expectedId) { - const current = readQueuedSendHandoffClaimState() - if ( - current && - (current.id !== expectedId || (expectedOwnerId && current.ownerId !== expectedOwnerId)) - ) { - return - } - } - window.sessionStorage.removeItem(QUEUED_SEND_HANDOFF_CLAIM_STORAGE_KEY) -} - -type StreamBatchResponse = { - success: boolean - events: StreamBatchEvent[] - previewSessions?: FilePreviewSession[] - status: string - chatId?: string -} - -const STREAM_SCHEMA_ENFORCEMENT_PREFIX = 'Client stream schema enforcement failed.' - -class StreamSchemaValidationError extends Error { - constructor(message: string) { - super(message) - this.name = 'StreamSchemaValidationError' - } -} - -function createStreamSchemaValidationError( - failure: ParseStreamEventEnvelopeFailure, - context?: string -): StreamSchemaValidationError { - const details = failure.errors?.filter(Boolean).join('; ') - return new StreamSchemaValidationError( - [STREAM_SCHEMA_ENFORCEMENT_PREFIX, context, failure.message, details].filter(Boolean).join(' ') - ) -} - -function createBatchSchemaValidationError(message: string): StreamSchemaValidationError { - return new StreamSchemaValidationError([STREAM_SCHEMA_ENFORCEMENT_PREFIX, message].join(' ')) -} - -function isStreamSchemaValidationError(error: unknown): error is StreamSchemaValidationError { - return error instanceof StreamSchemaValidationError -} - -function parseStreamBatchResponse(value: unknown): StreamBatchResponse { - if (!isRecordLike(value)) { - throw new Error('Invalid stream batch response') - } - - const rawEvents = Array.isArray(value.events) ? value.events : [] - const events: StreamBatchEvent[] = [] - for (const [index, entry] of rawEvents.entries()) { - if (!isRecordLike(entry)) { - throw createBatchSchemaValidationError(`Reconnect batch event ${index + 1} is not an object.`) - } - if ( - typeof entry.eventId !== 'number' || - !Number.isFinite(entry.eventId) || - typeof entry.streamId !== 'string' - ) { - throw createBatchSchemaValidationError( - `Reconnect batch event ${index + 1} is missing required metadata.` - ) - } - - const parsedEvent = parsePersistedStreamEventEnvelope(entry.event) - if (!parsedEvent.ok) { - throw createStreamSchemaValidationError(parsedEvent, `Reconnect batch event ${index + 1}.`) - } - - events.push({ - eventId: entry.eventId, - streamId: entry.streamId, - event: parsedEvent.event, - }) - } - - const rawPreviewSessions = Array.isArray(value.previewSessions) - ? value.previewSessions - : undefined - const previewSessions = - rawPreviewSessions?.map((session, index) => { - if (!isFilePreviewSession(session)) { - throw createBatchSchemaValidationError( - `Reconnect preview session ${index + 1} failed validation.` - ) - } - return session - }) ?? undefined - - return { - success: value.success === true, - events, - ...(previewSessions ? { previewSessions } : {}), - status: typeof value.status === 'string' ? value.status : 'unknown', - ...(typeof value.chatId === 'string' && value.chatId ? { chatId: value.chatId } : {}), - } -} - -function resolveChatIdFromStreamBatch(batch: StreamBatchResponse): string | undefined { - if (batch.chatId) return batch.chatId - - for (const { event } of batch.events) { - const streamChatId = typeof event.stream?.chatId === 'string' ? event.stream.chatId : undefined - if (streamChatId) return streamChatId - if ( - event.type === MothershipStreamV1EventType.session && - event.payload.kind === MothershipStreamV1SessionKind.chat - ) { - return event.payload.chatId - } - } - - return undefined -} - -function toRawPersistedContentBlock(block: ContentBlock): Record | null { - const persisted = toRawPersistedContentBlockBody(block) - if (!persisted) return null - if (block.parentToolCallId) persisted.parentToolCallId = block.parentToolCallId - // Carry deterministic span identity onto the live streaming snapshot so the - // rendered live message nests subagents via the span tree. Without this the - // live blocks lose spanId and parseBlocks falls back to legacy flat grouping, - // rendering nested subagents (e.g. deploy) at the top level mid-stream until - // the persisted message (which keeps spanId) replaces it. - if (block.spanId) persisted.spanId = block.spanId - if (block.parentSpanId) persisted.parentSpanId = block.parentSpanId - return withBlockTiming(persisted, block) -} - -function toRawPersistedContentBlockBody(block: ContentBlock): Record | null { - switch (block.type) { - case 'text': - return { - type: MothershipStreamV1EventType.text, - ...(block.subagent ? { lane: 'subagent' } : {}), - channel: MothershipStreamV1TextChannel.assistant, - content: block.content ?? '', - } - case 'thinking': - return { - type: MothershipStreamV1EventType.text, - channel: MothershipStreamV1TextChannel.thinking, - content: block.content ?? '', - } - case 'subagent_thinking': - return { - type: MothershipStreamV1EventType.text, - lane: 'subagent', - channel: MothershipStreamV1TextChannel.thinking, - content: block.content ?? '', - ...(block.subagent ? { agent: block.subagent } : {}), - } - case 'subagent_text': - return { - type: MothershipStreamV1EventType.text, - lane: 'subagent', - channel: MothershipStreamV1TextChannel.assistant, - content: block.content ?? '', - ...(block.subagent ? { agent: block.subagent } : {}), - } - case 'tool_call': - if (!block.toolCall) { - return null - } - return { - type: MothershipStreamV1EventType.tool, - phase: MothershipStreamV1ToolPhase.call, - toolCall: { - id: block.toolCall.id, - name: block.toolCall.name, - state: block.toolCall.status, - ...(block.toolCall.activityDescription - ? { activityDescription: block.toolCall.activityDescription } - : {}), - ...(block.toolCall.params ? { params: block.toolCall.params } : {}), - ...(block.toolCall.result ? { result: block.toolCall.result } : {}), - ...(block.toolCall.calledBy ? { calledBy: block.toolCall.calledBy } : {}), - ...(block.toolCall.displayTitle - ? { - display: { - title: block.toolCall.displayTitle, - }, - } - : {}), - }, - } - case 'subagent': - return { - type: MothershipStreamV1EventType.span, - kind: MothershipStreamV1SpanPayloadKind.subagent, - lifecycle: MothershipStreamV1SpanLifecycleEvent.start, - content: block.content ?? '', - } - case 'subagent_end': - return { - type: MothershipStreamV1EventType.span, - kind: MothershipStreamV1SpanPayloadKind.subagent, - lifecycle: MothershipStreamV1SpanLifecycleEvent.end, - } - case 'stopped': - return { - type: MothershipStreamV1EventType.complete, - status: MothershipStreamV1CompletionStatus.cancelled, - } - default: - return null - } -} - -function buildAssistantSnapshotMessage(params: { - id: string - content: string - contentBlocks: ContentBlock[] - requestId?: string -}): PersistedMessage { - const rawContentBlocks = params.contentBlocks - .map(toRawPersistedContentBlock) - .filter((block): block is Record => block !== null) - - return normalizeMessage({ - id: params.id, - role: 'assistant', - content: params.content, - timestamp: new Date().toISOString(), - ...(params.requestId ? { requestId: params.requestId } : {}), - ...(rawContentBlocks.length > 0 ? { contentBlocks: rawContentBlocks } : {}), - }) -} - -function markMessageStopped(message: PersistedMessage): PersistedMessage { - const hasExecutingTool = message.contentBlocks?.some( - (block) => block.toolCall?.state === 'executing' - ) - const hasOpenBlock = message.contentBlocks?.some((block) => block.endedAt === undefined) - if (!hasExecutingTool && !hasOpenBlock) { - return message - } - - const stopTs = Date.now() - const nextBlocks = (message.contentBlocks ?? []).map((block) => { - const stamped = block.endedAt === undefined ? { ...block, endedAt: stopTs } : block - if (stamped.toolCall?.state !== 'executing') { - return stamped - } - return { - ...stamped, - toolCall: { - ...stamped.toolCall, - state: 'cancelled' as const, - display: { - ...(stamped.toolCall.display ?? {}), - title: 'Stopped by user', - }, - }, - } - }) - - if ( - !nextBlocks.some( - (block) => - block.type === MothershipStreamV1EventType.complete && - block.status === MothershipStreamV1CompletionStatus.cancelled - ) - ) { - nextBlocks.push({ - type: MothershipStreamV1EventType.complete, - status: MothershipStreamV1CompletionStatus.cancelled, - }) - } - - return normalizeMessage({ - ...message, - contentBlocks: nextBlocks, - }) -} - -function buildChatResourceHydrationKey(resource: MothershipResource): string { - return JSON.stringify([ - resource.type, - resource.id, - resource.title, - resource.path ?? null, - resource.viewId ?? null, - resource.executionId ?? null, - ]) -} - -function buildChatHistoryHydrationKey(chatHistory: MothershipChatHistory): string { - const resourceKey = chatHistory.resources.map(buildChatResourceHydrationKey).join('|') - const messageKey = chatHistory.messages.map((message) => message.id).join('|') - const streamSnapshot = chatHistory.streamSnapshot - const snapshotKey = streamSnapshot - ? [ - streamSnapshot.status, - streamSnapshot.events.length, - streamSnapshot.events[streamSnapshot.events.length - 1]?.eventId ?? '', - streamSnapshot.previewSessions - .map( - (session) => - `${session.id}:${session.previewVersion}:${session.status}:${session.updatedAt}` - ) - .join('|'), - ].join('~') - : 'none' - - return [ - chatHistory.id, - chatHistory.activeStreamId ?? '', - messageKey, - resourceKey, - snapshotKey, - ].join('::') -} - -const TERMINAL_STREAM_STATUSES = new Set(['complete', 'error', 'cancelled']) - -function isTerminalStreamStatus(status: string | null | undefined): boolean { - return TERMINAL_STREAM_STATUSES.has(status ?? '') -} - -function isAlreadyProcessedStreamCursor( - eventCursor: string | undefined, - currentCursor: string -): boolean { - if (!eventCursor) return false - - const eventSequence = Number(eventCursor) - const currentSequence = Number(currentCursor) - return ( - Number.isFinite(eventSequence) && - Number.isFinite(currentSequence) && - eventSequence <= currentSequence - ) -} - -function isZeroStreamCursor(cursor: string): boolean { - const sequence = Number(cursor) - return Number.isFinite(sequence) && sequence <= 0 -} - -/** - * The resume endpoint 404s when no run exists for the stream — there is - * nothing left to resume, so reconnect falls back to the persisted DB - * transcript instead of retrying or surfacing an error. - */ -class StreamGoneError extends Error { - constructor(streamId: string) { - super(`Stream ${streamId} no longer exists`) - this.name = 'StreamGoneError' - } -} - -function isStreamGoneError(error: unknown): error is StreamGoneError { - return error instanceof Error && error.name === 'StreamGoneError' -} - -function isPersistedAssistantMessage(message: PersistedMessage, liveAssistantId: string): boolean { - return ( - message.role === 'assistant' && - message.id !== liveAssistantId && - !message.id.startsWith('live-assistant:') - ) -} - -function findStreamOwnerIndex(messages: PersistedMessage[], streamId: string): number { - return messages.findIndex((message) => message.role === 'user' && message.id === streamId) -} - -function findAssistantAfterOwner(messages: PersistedMessage[], ownerIndex: number): number { - for (let index = ownerIndex + 1; index < messages.length; index++) { - const message = messages[index] - if (message.role === 'user') return -1 - if (message.role === 'assistant') return index - } - return -1 -} - -function hasTerminalPersistedAssistantForStream( - messages: PersistedMessage[], - streamId: string, - liveAssistantId: string -): boolean { - const ownerIndex = findStreamOwnerIndex(messages, streamId) - if (ownerIndex === -1) return false - - const assistantIndex = findAssistantAfterOwner(messages, ownerIndex) - if (assistantIndex === -1) return false - - return isPersistedAssistantMessage(messages[assistantIndex], liveAssistantId) -} - -export function reconcileLiveAssistantTurn(params: { - messages: PersistedMessage[] - streamId: string - liveAssistant: PersistedMessage - activeStreamId: string | null -}): PersistedMessage[] { - const { messages, streamId, liveAssistant, activeStreamId } = params - const ownerIndex = findStreamOwnerIndex(messages, streamId) - if (ownerIndex === -1) { - return [...messages.filter((message) => message.id !== liveAssistant.id), liveAssistant] - } - - const assistantIndex = findAssistantAfterOwner(messages, ownerIndex) - const existingAssistant = assistantIndex >= 0 ? messages[assistantIndex] : undefined - if ( - activeStreamId !== streamId && - existingAssistant && - isPersistedAssistantMessage(existingAssistant, liveAssistant.id) - ) { - const withoutStaleLiveAssistant = messages.filter((message) => message.id !== liveAssistant.id) - return withoutStaleLiveAssistant.length === messages.length - ? messages - : withoutStaleLiveAssistant - } - - const withoutDuplicateLiveAssistant = messages.filter( - (message, index) => index === assistantIndex || message.id !== liveAssistant.id - ) - const adjustedOwnerIndex = withoutDuplicateLiveAssistant.findIndex( - (message) => message.role === 'user' && message.id === streamId - ) - const adjustedAssistantIndex = - adjustedOwnerIndex >= 0 - ? findAssistantAfterOwner(withoutDuplicateLiveAssistant, adjustedOwnerIndex) - : -1 - - if (adjustedAssistantIndex >= 0) { - return withoutDuplicateLiveAssistant.map((message, index) => - index === adjustedAssistantIndex ? liveAssistant : message - ) - } - - if (adjustedOwnerIndex >= 0) { - return [ - ...withoutDuplicateLiveAssistant.slice(0, adjustedOwnerIndex + 1), - liveAssistant, - ...withoutDuplicateLiveAssistant.slice(adjustedOwnerIndex + 1), - ] - } - - return [...withoutDuplicateLiveAssistant, liveAssistant] -} - -export interface ReconnectReplaySelection { - afterCursor: string - preserveExistingState: boolean - source: 'live' | 'reset' -} - -/** - * Decides how a reconnect replay starts. The only state a resumed stream may - * continue from is the live in-memory pair (streaming refs + lastCursorRef) - * maintained together by this mount's stream loop — those are coherent by - * construction. Anything else (fresh mount, cleared refs, cache-derived - * transcripts) replays the Redis buffer from seq 0 into a fresh model: the - * buffer is the source of truth for an in-flight turn and replay is - * idempotent, so a full rebuild is always safe. Seeding the model from a - * cached transcript paired stale content with a newer cursor, which dropped - * replayed events and rendered empty or suffix-only messages. - */ -export function selectReconnectReplayState(params: { - afterCursor: string - currentContent: string - currentBlocks: ContentBlock[] -}): ReconnectReplaySelection { - const { afterCursor, currentContent, currentBlocks } = params - const hasLiveState = currentContent.length > 0 || currentBlocks.length > 0 - if (!isZeroStreamCursor(afterCursor) && hasLiveState) { - return { afterCursor, preserveExistingState: true, source: 'live' } - } - return { afterCursor: '0', preserveExistingState: false, source: 'reset' } -} - -export function getReplayCompletedWorkflowToolCallIds(events: StreamBatchEvent[]): Set { - const completedToolCallIds = new Set() - for (const entry of events) { - const event = entry.event - if (event.type !== MothershipStreamV1EventType.tool) continue - const payload = event.payload - if (!('phase' in payload)) continue - if (payload.phase !== MothershipStreamV1ToolPhase.result) continue - // Client-executed tools (workflow runs, browser actions) must never - // re-fire when their completed call replays after reconnect/reload. - if ( - typeof payload.toolCallId === 'string' && - (isWorkflowToolName(payload.toolName) || isBrowserToolName(payload.toolName)) - ) { - completedToolCallIds.add(payload.toolCallId) - } - } - return completedToolCallIds -} - /** * Runs a browser tool on the desktop client. The agent's tab reaches the * resource strip through the desktop tab list, so nothing is opened here. @@ -1192,17 +544,6 @@ function buildRecoverySubjectKey( return `${chatId ?? ''}:${selectedChatId ?? ''}` } -const sseEncoder = new TextEncoder() -function buildReplayStream(events: StreamBatchEvent[]): ReadableStream { - return new ReadableStream({ - start(controller) { - const payload = events.map((entry) => `data: ${JSON.stringify(entry.event)}\n\n`).join('') - controller.enqueue(sseEncoder.encode(payload)) - controller.close() - }, - }) -} - /** Adds a workflow to the React Query cache with a top-insertion sort order if it doesn't already exist. */ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId: string): boolean { const workflows = getWorkflows(workspaceId) @@ -2113,7 +1454,7 @@ export function useChat( * report an error completion rather than leaving it hanging with the dedupe ref * already marked handled. */ - import('@/lib/copilot/tools/client/local-filesystem').then( + import('@/lib/mothership/tools/client/local-filesystem').then( (m) => m.executeLocalFilesystemTool(toolCallId, toolName, toolArgs, options), async (error) => { logger.error('Failed to load local filesystem tool executor', { error }) @@ -2126,8 +1467,8 @@ export function useChat( try { const [{ reportClientToolCompletion }, { ASYNC_TOOL_CONFIRMATION_STATUS }] = await Promise.all([ - import('@/lib/copilot/tools/client/completion'), - import('@/lib/copilot/async-runs/lifecycle'), + import('@/lib/mothership/tools/client/completion'), + import('@/lib/mothership/async-runs/lifecycle'), ]) await reportClientToolCompletion( toolCallId, diff --git a/apps/sim/app/workspace/[workspaceId]/home/resolve-resource-ref.test.ts b/apps/sim/app/workspace/[workspaceId]/home/resolve-resource-ref.test.ts index 1d65cbd47ae..7568d331a6e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/resolve-resource-ref.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/resolve-resource-ref.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import type { WorkspaceResourceRef } from '@/lib/copilot/resources/types' +import type { WorkspaceResourceRef } from '@/lib/mothership/resources/types' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { resolveWorkspaceResourceRef } from './resolve-resource-ref' diff --git a/apps/sim/app/workspace/[workspaceId]/home/resolve-resource-ref.ts b/apps/sim/app/workspace/[workspaceId]/home/resolve-resource-ref.ts index c09836a0510..d149c745cbf 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/resolve-resource-ref.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/resolve-resource-ref.ts @@ -1,5 +1,5 @@ -import type { MothershipResource, WorkspaceResourceRef } from '@/lib/copilot/resources/types' -import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import type { MothershipResource, WorkspaceResourceRef } from '@/lib/mothership/resources/types' +import { canonicalWorkspaceFilePath } from '@/lib/mothership/vfs/path-utils' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { findWorkspaceFileByPath } from '@/hooks/queries/utils/find-workspace-file-by-src' diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 3844d7beea9..bffb343fd8c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -10,7 +10,7 @@ export type { MothershipResource, MothershipResourceType, WorkspaceResourceRef, -} from '@/lib/copilot/resources/types' +} from '@/lib/mothership/resources/types' /** Union of all valid context kind strings, derived from {@link ChatContext}. */ export type ChatContextKind = ChatContext['kind'] diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts index fada5c1bb1d..e50499a302b 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -71,7 +71,7 @@ vi.mock('@/lib/workspaces/list', () => ({ vi.mock('@/lib/users/queries', () => ({ getUserProfile: mockGetUserProfile, })) -vi.mock('@/lib/copilot/chat/list-mothership-chats', () => ({ +vi.mock('@/lib/mothership/chat/list-mothership-chats', () => ({ listMothershipChats: mockListMothershipChats, })) vi.mock('@/lib/table/application/tables', () => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/prefetch.ts index 45ae8b71bbe..42d5afe4860 100644 --- a/apps/sim/app/workspace/[workspaceId]/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/prefetch.ts @@ -1,6 +1,6 @@ import type { QueryClient } from '@tanstack/react-query' import type { WorkspaceHostContext } from '@/lib/api/contracts/workspaces' -import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' +import { listMothershipChats } from '@/lib/mothership/chat/list-mothership-chats' import { isChatEnabled } from '@/lib/core/config/env-flags' import { prefetchUserProfile } from '@/lib/users/prefetch-user-profile' import { listWorkflowsForUser } from '@/lib/workflows/queries' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index f040e922466..d59861c02d9 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -12,7 +12,7 @@ import { useVirtualizer } from '@tanstack/react-virtual' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import type { RunLimit, RunMode, TableFindMatch } from '@/lib/api/contracts/tables' -import { attachSelectionContextToClipboard } from '@/lib/copilot/chat/selection-clipboard' +import { attachSelectionContextToClipboard } from '@/lib/mothership/chat/selection-clipboard' import { captureEvent } from '@/lib/posthog/client' import type { ColumnDefinition, diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts index 80534939ca4..a9fe8a0b1d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import { MAX_TABLE_SELECTION_COLUMNS, MAX_TABLE_SELECTION_ROWS, -} from '@/lib/copilot/chat/selection-context' +} from '@/lib/mothership/chat/selection-context' import { TABLE_LIMITS } from '@/lib/table/constants' import type { DisplayColumn } from './types' import { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts index 4f3e9282d17..3e9695b7d68 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts @@ -3,7 +3,7 @@ import { buildTableSelectionLabel, MAX_TABLE_SELECTION_COLUMNS, MAX_TABLE_SELECTION_ROWS, -} from '@/lib/copilot/chat/selection-context' +} from '@/lib/mothership/chat/selection-context' import type { ColumnDefinition, RowExecutionMetadata, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts index edf9c7aa24d..38185d3c08a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts @@ -5,7 +5,7 @@ import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview' +import { getMothershipAttachmentPreviewUrl } from '@/lib/mothership/chat/attachment-preview' import { assertMultiFileUploadAdmission } from '@/lib/uploads/client/admission' import { runWithConcurrency, WHOLE_FILE_PARALLEL_UPLOADS } from '@/lib/uploads/client/concurrency' import { uploadInternalFileSession } from '@/lib/uploads/client/session-upload' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts index 3c67bd35c6a..8b50f9ae087 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts @@ -3,9 +3,9 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' import { normalizeWorkflowEdgeSourceHandle } from '@sim/workflow-types/workflow' -import { COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE } from '@/lib/copilot/constants' import type { SecretSafeBlockLog } from '@/lib/logs/execution/display-types' import type { TraceSpan } from '@/lib/logs/types' +import { COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE } from '@/lib/mothership/constants' import type { BlockChildWorkflowStartedData, BlockCompletedData, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 2a6a00d8b32..8dbddd716de 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -43,8 +43,8 @@ import { } from '@sim/workflow-types/workflow' import { useShallow } from 'zustand/react/shallow' import { useSession } from '@/lib/auth/auth-client' -import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/base-tool' import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state' +import type { OAuthConnectEventDetail } from '@/lib/mothership/tools/client/base-tool' import type { OAuthProvider } from '@/lib/oauth' import { usesCredentialConfiguredOAuthClient } from '@/lib/oauth/utils' import { OPERATION_SUBBLOCK_ID } from '@/lib/permission-groups/operation-access' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx index 4203e4df425..9a927fec370 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/folder-item/folder-item.tsx @@ -14,7 +14,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { useRouter } from 'next/navigation' -import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' +import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/mothership/resource-types' import { generateSubfolderName } from '@/lib/workspaces/naming' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx index 546fd8ee00a..84fcd61abfe 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/workflow-item/workflow-item.tsx @@ -4,7 +4,7 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react' import { chipVariants, cn, OverflowText } from '@sim/emcn' import { Lock, MoreHorizontal } from '@sim/emcn/icons' import Link from 'next/link' -import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' +import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/mothership/resource-types' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { SidebarRowAction, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index f79a8ee3582..a8183e6f260 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -42,7 +42,7 @@ import { usePostHog } from 'posthog-js/react' import { useSession } from '@/lib/auth/auth-client' import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions' import { focusVisibleBrowserOmnibox } from '@/lib/browser-agent/renderer-shortcuts' -import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types' +import { SIM_RESOURCES_DRAG_TYPE } from '@/lib/mothership/resource-types' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { isStatusNoticePreviewEnabled } from '@/lib/core/config/env-flags' import { isMacPlatform } from '@/lib/core/utils/platform' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts index 9f358b9a265..234df995058 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts @@ -1,4 +1,4 @@ -import type { MothershipResource } from '@/lib/copilot/resource-types' +import type { MothershipResource } from '@/lib/mothership/resource-types' import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' import type { FolderTreeNode } from '@/stores/folders/types' diff --git a/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx b/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx index 30b5984b96b..79aedd736c4 100644 --- a/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx +++ b/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx @@ -9,7 +9,7 @@ vi.mock('@sim/emcn', () => ({ cn: (...args: unknown[]) => args.filter(Boolean).join(' '), })) -vi.mock('@/lib/copilot/tools/tool-display', () => ({ +vi.mock('@/lib/mothership/tools/tool-display', () => ({ humanizeToolName: (name: string) => name, })) diff --git a/apps/sim/components/agent-stream/agent-stream-chrome.tsx b/apps/sim/components/agent-stream/agent-stream-chrome.tsx index c4a47c62a1e..3e264f716ef 100644 --- a/apps/sim/components/agent-stream/agent-stream-chrome.tsx +++ b/apps/sim/components/agent-stream/agent-stream-chrome.tsx @@ -8,7 +8,7 @@ import type { AgentStreamToolStatus, } from '@/components/agent-stream/tool-call-lifecycle' import { ShimmerText } from '@/components/ui' -import { humanizeToolName } from '@/lib/copilot/tools/tool-display' +import { humanizeToolName } from '@/lib/mothership/tools/tool-display' /** Distance from bottom (px) within which we keep following new thinking text. */ const STICK_TO_BOTTOM_THRESHOLD_PX = 24 diff --git a/apps/sim/components/agent-stream/tool-call-lifecycle.ts b/apps/sim/components/agent-stream/tool-call-lifecycle.ts index 997ce4a4b25..5a78bb1b1c0 100644 --- a/apps/sim/components/agent-stream/tool-call-lifecycle.ts +++ b/apps/sim/components/agent-stream/tool-call-lifecycle.ts @@ -7,7 +7,7 @@ * implementation so the three surfaces cannot drift. */ -import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' +import { getToolDisplayTitle } from '@/lib/mothership/tools/tool-display' export type AgentStreamToolStatus = 'running' | 'success' | 'error' | 'cancelled' diff --git a/apps/sim/executor/handlers/function/function-handler.ts b/apps/sim/executor/handlers/function/function-handler.ts index 22d0b3b9938..2d66a019e1f 100644 --- a/apps/sim/executor/handlers/function/function-handler.ts +++ b/apps/sim/executor/handlers/function/function-handler.ts @@ -1,4 +1,3 @@ -import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { getRemainingExecutionMs } from '@/lib/core/execution-limits' import { normalizeRecord, @@ -9,6 +8,7 @@ import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' import { DEFAULT_CODE_LANGUAGE } from '@/lib/execution/languages' import { NonRetryableExecutionError } from '@/lib/execution/non-retryable-error' import { mergeFileKeys, mergeLargeValueKeys } from '@/lib/execution/payloads/access-keys' +import { normalizeSecretMountPolicy } from '@/lib/mothership/secret-mount-policy' import { BlockType } from '@/executor/constants' import type { BlockHandler, ExecutionContext } from '@/executor/types' import { collectBlockData } from '@/executor/utils/block-data' diff --git a/apps/sim/executor/handlers/mothership/mothership-handler.ts b/apps/sim/executor/handlers/mothership/mothership-handler.ts index f367402ae7d..07f058030ed 100644 --- a/apps/sim/executor/handlers/mothership/mothership-handler.ts +++ b/apps/sim/executor/handlers/mothership/mothership-handler.ts @@ -6,7 +6,6 @@ import { BILLING_ATTRIBUTION_HEADER, serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' -import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { env } from '@/lib/core/config/env' import { projectModelSchemaAnnotations, @@ -25,6 +24,7 @@ import { discoverMcpServerToolsAsExecutor } from '@/lib/internal/mcp/discover-to import { assertValidMcpServerToolBindings, MCP_SERVER_ADVANCED_TOOL_TYPE } from '@/lib/mcp/shared' import { resolveMcpToolBinding } from '@/lib/mcp/tool-binding' import { resolveMothershipConversation } from '@/lib/mothership/conversation-id' +import { normalizeSecretMountPolicy } from '@/lib/mothership/secret-mount-policy' import { areModelSafeWorkspaceFileKeys, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE, diff --git a/apps/sim/executor/handlers/workflow/workflow-tool-runner.test.ts b/apps/sim/executor/handlers/workflow/workflow-tool-runner.test.ts index 4be6f7859c8..0f4712cb9e9 100644 --- a/apps/sim/executor/handlers/workflow/workflow-tool-runner.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-tool-runner.test.ts @@ -20,8 +20,8 @@ vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: mockEncryptSecret, })) -import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' -import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { projectToolResultForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result' +import type { ToolExecutionResult } from '@/lib/mothership/tool-executor/types' import { ExecutionState } from '@/executor/execution/state' import type { PiiBlockOutputRedaction } from '@/executor/execution/types' import { runWorkflowTool } from '@/executor/handlers/workflow/workflow-tool-runner' diff --git a/apps/sim/hooks/queries/mothership-chats.test.ts b/apps/sim/hooks/queries/mothership-chats.test.ts index 1953cc79fbc..96c498e5c77 100644 --- a/apps/sim/hooks/queries/mothership-chats.test.ts +++ b/apps/sim/hooks/queries/mothership-chats.test.ts @@ -4,7 +4,7 @@ import { sleep } from '@sim/utils/helpers' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { MothershipResource } from '@/lib/copilot/resources/types' +import type { MothershipResource } from '@/lib/mothership/resources/types' const { queryClient, suspendBrowserScope, suspendTerminalScope, clearChat } = vi.hoisted(() => ({ clearChat: vi.fn(), diff --git a/apps/sim/hooks/queries/mothership-chats.ts b/apps/sim/hooks/queries/mothership-chats.ts index f076f468bce..7bc009927b8 100644 --- a/apps/sim/hooks/queries/mothership-chats.ts +++ b/apps/sim/hooks/queries/mothership-chats.ts @@ -23,15 +23,15 @@ import { restoreMothershipChatContract, updateMothershipChatContract, } from '@/lib/api/contracts/mothership-chats' -import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' -import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' +import { suspendDesktopChatScopes } from '@/lib/desktop/chat-scope' +import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message' +import { normalizeMessage } from '@/lib/mothership/chat/persisted-message' import { type FilePreviewSession, isFilePreviewSession, -} from '@/lib/copilot/request/session/file-preview-session-contract' -import { isStreamBatchEvent, type StreamBatchEvent } from '@/lib/copilot/request/session/types' -import { type MothershipResource, MothershipResourceType } from '@/lib/copilot/resources/types' -import { suspendDesktopChatScopes } from '@/lib/desktop/chat-scope' +} from '@/lib/mothership/request/session/file-preview-session-contract' +import { isStreamBatchEvent, type StreamBatchEvent } from '@/lib/mothership/request/session/types' +import { type MothershipResource, MothershipResourceType } from '@/lib/mothership/resources/types' import { useMothershipQueueStore } from '@/stores/mothership-queue/store' export interface MothershipChatMetadata { diff --git a/apps/sim/hooks/queries/utils/find-workspace-file-by-src.ts b/apps/sim/hooks/queries/utils/find-workspace-file-by-src.ts index 2a52f7c8259..90811ce8bad 100644 --- a/apps/sim/hooks/queries/utils/find-workspace-file-by-src.ts +++ b/apps/sim/hooks/queries/utils/find-workspace-file-by-src.ts @@ -1,4 +1,4 @@ -import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { canonicalWorkspaceFilePath } from '@/lib/mothership/vfs/path-utils' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' diff --git a/apps/sim/hooks/use-mothership-chat-events.ts b/apps/sim/hooks/use-mothership-chat-events.ts index 6d2a17ecda0..53bf23d6535 100644 --- a/apps/sim/hooks/use-mothership-chat-events.ts +++ b/apps/sim/hooks/use-mothership-chat-events.ts @@ -2,7 +2,7 @@ import { useEffect } from 'react' import { createLogger } from '@sim/logger' import type { QueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query' -import { getLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript' +import { getLiveAssistantMessageId } from '@/lib/mothership/chat/effective-transcript' import { suspendDesktopChatScopes } from '@/lib/desktop/chat-scope' import { createRotatingEventSource } from '@/lib/events/rotating-event-source' import { diff --git a/apps/sim/instrumentation-node.ts b/apps/sim/instrumentation-node.ts index d9a3668d304..b78f809eab6 100644 --- a/apps/sim/instrumentation-node.ts +++ b/apps/sim/instrumentation-node.ts @@ -13,7 +13,7 @@ import type { SpanProcessor, } from '@opentelemetry/sdk-trace-base' import { createLogger } from '@sim/logger' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' import { env } from './lib/core/config/env' import { parseOtlpHeaders } from './lib/monitoring/otlp' diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index 62a6c0c2882..a5901694e6f 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -5,7 +5,7 @@ import { type ContractJsonResponse, defineRouteContract } from '@/lib/api/contra import { ASYNC_TOOL_CONFIRMATION_STATUS, type AsyncConfirmationStatus, -} from '@/lib/copilot/async-runs/lifecycle' +} from '@/lib/mothership/async-runs/lifecycle' import { BILLING_ACCOUNT_DECISION_HEADER, BILLING_ACCOUNT_DECISION_HEADER_MAX_BYTES, @@ -16,8 +16,8 @@ import { COPILOT_BILLING_PROTOCOL_VALUES, COPILOT_VALIDATION_PURPOSE, COPILOT_VALIDATION_PURPOSE_VALUES, -} from '@/lib/copilot/generated/billing-protocol-v1' -import { PERSISTED_RESOURCE_TYPES } from '@/lib/copilot/resources/types' +} from '@/lib/mothership/generated/billing-protocol-v1' +import { PERSISTED_RESOURCE_TYPES } from '@/lib/mothership/resources/types' export const copilotApiKeySchema = z.object({ id: z.string(), diff --git a/apps/sim/lib/api/contracts/secret-mount-policy.test.ts b/apps/sim/lib/api/contracts/secret-mount-policy.test.ts index f1898448ebc..5b519e6dcc2 100644 --- a/apps/sim/lib/api/contracts/secret-mount-policy.test.ts +++ b/apps/sim/lib/api/contracts/secret-mount-policy.test.ts @@ -6,7 +6,7 @@ import { mountedSecretNamesSchema } from '@/lib/api/contracts/secret-mount-polic import { MAX_SECRET_MOUNT_NAME_LENGTH, MAX_SECRET_MOUNT_NAMES, -} from '@/lib/copilot/secret-mount-policy' +} from '@/lib/mothership/secret-mount-policy' describe('mountedSecretNamesSchema', () => { it('accepts the bounded names-only policy shape', () => { diff --git a/apps/sim/lib/api/contracts/secret-mount-policy.ts b/apps/sim/lib/api/contracts/secret-mount-policy.ts index d3306ef7ea9..44183f4fe9d 100644 --- a/apps/sim/lib/api/contracts/secret-mount-policy.ts +++ b/apps/sim/lib/api/contracts/secret-mount-policy.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import { MAX_SECRET_MOUNT_NAME_LENGTH, MAX_SECRET_MOUNT_NAMES, -} from '@/lib/copilot/secret-mount-policy' +} from '@/lib/mothership/secret-mount-policy' export const secretMountScopeSchema = z.enum(['all', 'selected']) diff --git a/apps/sim/lib/api/contracts/subscription.ts b/apps/sim/lib/api/contracts/subscription.ts index a4636e523e9..70c8791a530 100644 --- a/apps/sim/lib/api/contracts/subscription.ts +++ b/apps/sim/lib/api/contracts/subscription.ts @@ -10,7 +10,7 @@ import { BILLING_REQUEST_ID_HEADER, COPILOT_BILLING_PROTOCOL_HEADER, COPILOT_BILLING_PROTOCOL_VALUES, -} from '@/lib/copilot/generated/billing-protocol-v1' +} from '@/lib/mothership/generated/billing-protocol-v1' const booleanQueryParamSchema = z .preprocess((value) => { diff --git a/apps/sim/lib/api/contracts/v1/copilot.ts b/apps/sim/lib/api/contracts/v1/copilot.ts index 4752c61762f..6b2edf9f305 100644 --- a/apps/sim/lib/api/contracts/v1/copilot.ts +++ b/apps/sim/lib/api/contracts/v1/copilot.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants' +import { COPILOT_REQUEST_MODES } from '@/lib/mothership/constants' export const v1CopilotChatBodySchema = z.object({ message: z.string().min(1, 'message is required'), diff --git a/apps/sim/lib/billing/core/billing-attribution.ts b/apps/sim/lib/billing/core/billing-attribution.ts index 2dfdfaa21ce..b21e42b85b3 100644 --- a/apps/sim/lib/billing/core/billing-attribution.ts +++ b/apps/sim/lib/billing/core/billing-attribution.ts @@ -21,6 +21,7 @@ import { import type { BillingContext, BillingEntity } from '@/lib/billing/core/usage-log' import { parseWorkflowExecutionTimeoutSeconds } from '@/lib/billing/execution-timeout-defaults' import { isEnterprise } from '@/lib/billing/plan-helpers' +import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' import { BILLING_ACCOUNT_DECISION_HEADER, BILLING_ACCOUNT_DECISION_HEADER_MAX_BYTES, @@ -30,8 +31,7 @@ import { COPILOT_BILLING_PROTOCOL, COPILOT_BILLING_PROTOCOL_HEADER, type CopilotBillingProtocol, -} from '@/lib/copilot/generated/billing-protocol-v1' -import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags' +} from '@/lib/mothership/generated/billing-protocol-v1' import { type ResourceOwner, resourceScopeFromOwner } from '@/lib/core/resource-scope' export { diff --git a/apps/sim/lib/browser-agent/attachments.test.ts b/apps/sim/lib/browser-agent/attachments.test.ts index 267593cd47f..cbd77439e11 100644 --- a/apps/sim/lib/browser-agent/attachments.test.ts +++ b/apps/sim/lib/browser-agent/attachments.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest' import { buildResourceAttachments } from '@/lib/browser-agent/attachments' -import type { MothershipResource } from '@/lib/copilot/resources/types' +import type { MothershipResource } from '@/lib/mothership/resources/types' import { useBrowserSessionStore } from '@/stores/browser-session/store' const DOCS_TAB: MothershipResource = { type: 'browser', id: '1', title: 'Docs' } diff --git a/apps/sim/lib/browser-agent/attachments.ts b/apps/sim/lib/browser-agent/attachments.ts index ca11bc45ab5..341dde92842 100644 --- a/apps/sim/lib/browser-agent/attachments.ts +++ b/apps/sim/lib/browser-agent/attachments.ts @@ -9,7 +9,7 @@ * nothing to say and is dropped. */ import { browserTabTitle } from '@/lib/browser-agent/tab-label' -import type { MothershipResource } from '@/lib/copilot/resources/types' +import type { MothershipResource } from '@/lib/mothership/resources/types' import { getBrowserSession } from '@/stores/browser-session/store' export interface ResourceAttachment { diff --git a/apps/sim/lib/catalog/registry-boundary.test.ts b/apps/sim/lib/catalog/registry-boundary.test.ts index e13c7741885..0747f904716 100644 --- a/apps/sim/lib/catalog/registry-boundary.test.ts +++ b/apps/sim/lib/catalog/registry-boundary.test.ts @@ -32,7 +32,7 @@ const CATALOG_ROOTS = [ 'app/api/v2/tools', 'app/api/v2/connector-types', /** The Copilot tool the shared projection was extracted for: ~6,756 modules down to ~1,321. */ - 'lib/copilot/tools/server/blocks', + 'lib/mothership/tools/server/blocks', ] as const /** Modules no catalog file may import, with what each would drag in. */ diff --git a/apps/sim/lib/cleanup/chat-cleanup.ts b/apps/sim/lib/cleanup/chat-cleanup.ts index 01ca0777e2d..b3910219bb3 100644 --- a/apps/sim/lib/cleanup/chat-cleanup.ts +++ b/apps/sim/lib/cleanup/chat-cleanup.ts @@ -3,8 +3,8 @@ import { copilotChats, copilotMessages, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { chunkArray } from '@sim/utils/helpers' import { and, inArray, isNull } from 'drizzle-orm' -import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' import { env } from '@/lib/core/config/env' +import { SIM_AGENT_API_URL } from '@/lib/mothership/constants' import type { StorageContext } from '@/lib/uploads' import { isUsingCloudStorage, StorageService } from '@/lib/uploads' diff --git a/apps/sim/lib/copilot/chat/workspace-context.test.ts b/apps/sim/lib/copilot/chat/workspace-context.test.ts deleted file mode 100644 index 3e090f01943..00000000000 --- a/apps/sim/lib/copilot/chat/workspace-context.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * @vitest-environment node - */ - -import { describe, expect, it } from 'vitest' -import { canonicalWorkflowVfsDir } from '@/lib/copilot/vfs/path-utils' -import { buildVfsSnapshot, buildWorkspaceMd, type WorkspaceMdData } from './workspace-context' - -function baseData(overrides: Partial = {}): WorkspaceMdData { - return { - workspace: { id: 'ws-1', name: 'WS', ownerId: 'u-1' }, - members: [], - workflows: [], - knowledgeBases: [], - tables: [], - files: [], - oauthIntegrations: [], - envVariables: [], - ...overrides, - } -} - -describe('buildWorkspaceMd - workflow VFS state paths', () => { - // `workflows[].folderPath` arrives ALREADY per-segment percent-encoded (it is - // the value from buildVfsFolderPathMap / resolveFolderPath that also builds the - // stored VFS keys). The advertised path must not re-encode it. - it('emits a single-encoded state path for a folder name with a space', () => { - const md = buildWorkspaceMd( - baseData({ - workflows: [ - { id: 'wf-1', name: 'The Elder', isDeployed: false, folderPath: 'The%20Elder' }, - ], - }) - ) - - expect(md).toContain('workflows/The%20Elder/The%20Elder/state.json') - // The exact double-encoding regression: `%20` -> `%2520`. - expect(md).not.toContain('The%2520Elder') - }) - - it('matches the canonical VFS dir helper the materializer/pointers use', () => { - const folderPath = 'My%20Folder/Sub%20Folder' - const md = buildWorkspaceMd( - baseData({ - workflows: [{ id: 'wf-1', name: 'My Flow', isDeployed: false, folderPath }], - }) - ) - - const expected = `${canonicalWorkflowVfsDir({ name: 'My Flow', folderPath })}/state.json` - expect(expected).toBe('workflows/My%20Folder/Sub%20Folder/My%20Flow/state.json') - expect(md).toContain(expected) - }) - - it('advertises canonical encoded VFS paths for root-level workflows', () => { - const md = buildWorkspaceMd( - baseData({ - workflows: [{ id: 'wf-1', name: 'Root Flow', isDeployed: false, folderPath: null }], - }) - ) - - expect(md).toContain('VFS dir: `workflows/Root%20Flow`') - expect(md).toContain('VFS state path: `workflows/Root%20Flow/state.json`') - }) - - it('never exposes workflow descriptions in markdown or the typed snapshot', () => { - const workflowWithPrivateDescription = { - id: 'wf-1', - name: 'Private Flow', - description: 'PRIVATE WORKFLOW DESCRIPTION', - isDeployed: false, - folderPath: null, - } - const data = baseData({ workflows: [workflowWithPrivateDescription] }) - - expect(buildWorkspaceMd(data)).not.toContain('PRIVATE WORKFLOW DESCRIPTION') - expect(JSON.stringify(buildVfsSnapshot(data))).not.toContain('PRIVATE WORKFLOW DESCRIPTION') - expect(buildVfsSnapshot(data).workflows?.[0]).not.toHaveProperty('description') - }) -}) - -describe('buildWorkspaceMd - connected integrations / credentials', () => { - it('lists each connected account with its credentialId and never leaks tokens', () => { - const md = buildWorkspaceMd( - baseData({ - oauthIntegrations: [ - { - id: 'cred-abc', - providerId: 'google-email', - displayName: 'alice@example.com', - role: 'admin', - }, - { id: 'cred-def', providerId: 'slack', displayName: 'Workspace Bot', role: 'member' }, - ], - }) - ) - - // credentialId must be present so the superagent can pass it without reading credentials.json. - expect(md).toContain('credentialId: `cred-abc`') - expect(md).toContain('credentialId: `cred-def`') - expect(md).toContain('google-email') - expect(md).toContain('slack') - - // No OAuth secrets/tokens may ever appear in the workspace context. - for (const secret of [ - 'accessToken', - 'refreshToken', - 'idToken', - 'clientSecret', - 'access_token', - 'refresh_token', - ]) { - expect(md).not.toContain(secret) - } - }) - - it('renders (none) when no integrations are connected', () => { - const md = buildWorkspaceMd(baseData({ oauthIntegrations: [] })) - expect(md).toContain('## Connected Integrations\n(none)') - }) - - it('injects available environment credential names into markdown and the typed snapshot', () => { - const data = baseData({ envVariables: ['OPENAI_API_KEY', 'STRIPE_SECRET_KEY'] }) - - const md = buildWorkspaceMd(data) - expect(md).toContain('## Environment Variables (2)') - expect(md).toContain('- OPENAI_API_KEY') - expect(md).toContain('- STRIPE_SECRET_KEY') - expect(buildVfsSnapshot(data).envVars).toEqual(['OPENAI_API_KEY', 'STRIPE_SECRET_KEY']) - }) -}) - -describe('buildWorkspaceMd - Sim sandbox entitlement projection', () => { - it('omits all sandbox knowledge when sandboxes are not projected', () => { - const data = baseData() - - expect(buildWorkspaceMd(data)).not.toContain('Sim Sandboxes') - expect(buildVfsSnapshot(data)).not.toHaveProperty('sandboxes') - }) - - it('publishes entitled sandbox inventory and the typed snapshot fields', () => { - const data = baseData({ - sandboxes: [ - { - id: 'sandbox-1', - name: 'Data Tools', - language: 'python', - dependencies: ['pandas'], - systemPackages: ['graphviz'], - cliTools: ['kubectl@1.36.3-r1'], - }, - ], - }) - - const markdown = buildWorkspaceMd(data) - expect(markdown).toContain('## Sim Sandboxes (1)') - expect(markdown).toContain('agent/sandboxes/Data%20Tools.json') - expect(buildVfsSnapshot(data).sandboxes).toEqual(data.sandboxes) - }) -}) - -describe('buildWorkspaceMd - determinism (prompt-cache stability)', () => { - it('is byte-identical regardless of input row order', () => { - const a = buildWorkspaceMd( - baseData({ - members: [ - { name: 'Bob', email: 'bob@x.com', permissionType: 'admin' }, - { name: 'Amy', email: 'amy@x.com', permissionType: 'write' }, - ], - workflows: [ - { id: 'wf-2', name: 'Zeta', isDeployed: false, folderPath: null }, - { id: 'wf-1', name: 'Alpha', isDeployed: true, folderPath: null }, - ], - tables: [ - { id: 't-2', name: 'Orders', description: null, rowCount: 5 }, - { id: 't-1', name: 'Customers', description: null, rowCount: 9 }, - ], - knowledgeBases: [ - { id: 'kb-2', name: 'Docs', connectorTypes: ['notion', 'github'] }, - { id: 'kb-1', name: 'Articles', connectorTypes: ['github', 'notion'] }, - ], - oauthIntegrations: [ - { id: 'c-2', providerId: 'slack', displayName: null, role: null }, - { id: 'c-1', providerId: 'github', displayName: null, role: null }, - ], - envVariables: ['ZED', 'API_KEY'], - customTools: [ - { id: 'ct-2', name: 'Beta Tool' }, - { id: 'ct-1', name: 'Alpha Tool' }, - ], - mcpServers: [ - { id: 'mcp-2', name: 'Zulu', url: null, enabled: false }, - { id: 'mcp-1', name: 'Mike', url: 'https://x', enabled: true }, - ], - skills: [ - { id: 'sk-2', name: 'Writer', description: 'writes' }, - { id: 'sk-1', name: 'Editor', description: 'edits' }, - ], - }) - ) - const b = buildWorkspaceMd( - baseData({ - members: [ - { name: 'Amy', email: 'amy@x.com', permissionType: 'write' }, - { name: 'Bob', email: 'bob@x.com', permissionType: 'admin' }, - ], - workflows: [ - { id: 'wf-1', name: 'Alpha', isDeployed: true, folderPath: null }, - { id: 'wf-2', name: 'Zeta', isDeployed: false, folderPath: null }, - ], - tables: [ - { id: 't-1', name: 'Customers', description: null, rowCount: 9 }, - { id: 't-2', name: 'Orders', description: null, rowCount: 5 }, - ], - knowledgeBases: [ - { id: 'kb-1', name: 'Articles', connectorTypes: ['notion', 'github'] }, - { id: 'kb-2', name: 'Docs', connectorTypes: ['github', 'notion'] }, - ], - oauthIntegrations: [ - { id: 'c-1', providerId: 'github', displayName: null, role: null }, - { id: 'c-2', providerId: 'slack', displayName: null, role: null }, - ], - envVariables: ['API_KEY', 'ZED'], - customTools: [ - { id: 'ct-1', name: 'Alpha Tool' }, - { id: 'ct-2', name: 'Beta Tool' }, - ], - mcpServers: [ - { id: 'mcp-1', name: 'Mike', url: 'https://x', enabled: true }, - { id: 'mcp-2', name: 'Zulu', url: null, enabled: false }, - ], - skills: [ - { id: 'sk-1', name: 'Editor', description: 'edits' }, - { id: 'sk-2', name: 'Writer', description: 'writes' }, - ], - }) - ) - expect(a).toBe(b) - }) - - it('ignores volatile workflow run timestamps', () => { - const withRun = buildWorkspaceMd( - baseData({ - workflows: [ - { - id: 'wf-1', - name: 'Alpha', - isDeployed: false, - folderPath: null, - lastRunAt: new Date('2026-06-18T12:00:00Z'), - }, - ], - }) - ) - const withoutRun = buildWorkspaceMd( - baseData({ - workflows: [{ id: 'wf-1', name: 'Alpha', isDeployed: false, folderPath: null }], - }) - ) - expect(withRun).toBe(withoutRun) - expect(withRun).not.toContain('last run') - }) - - it('ignores volatile table row counts', () => { - const a = buildWorkspaceMd( - baseData({ tables: [{ id: 't-1', name: 'Customers', description: null, rowCount: 1 }] }) - ) - const b = buildWorkspaceMd( - baseData({ tables: [{ id: 't-1', name: 'Customers', description: null, rowCount: 9999 }] }) - ) - expect(a).toBe(b) - expect(a).not.toContain('rows') - }) -}) - -describe('custom blocks', () => { - const customBlocks = [ - { type: 'custom_block_abc', name: 'Invoice Parser', description: 'Parses invoices' }, - ] - - it('renders a Custom Blocks section in the workspace markdown', () => { - const md = buildWorkspaceMd(baseData({ customBlocks })) - expect(md).toContain('## Custom Blocks (1)') - expect(md).toContain('- **Invoice Parser** (custom_block_abc) — Parses invoices') - }) - - it('omits the section when there are no custom blocks', () => { - expect(buildWorkspaceMd(baseData())).not.toContain('## Custom Blocks') - }) - - it('carries custom blocks in the typed snapshot keyed by type (Go diffs the customBlocks kind)', () => { - const withBlocks = buildVfsSnapshot(baseData({ customBlocks })) - expect(withBlocks.customBlocks).toEqual([ - { type: 'custom_block_abc', name: 'Invoice Parser', description: 'Parses invoices' }, - ]) - const without = buildVfsSnapshot(baseData()) - expect(without.customBlocks).toEqual([]) - }) -}) diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts deleted file mode 100644 index 3652b14547f..00000000000 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ /dev/null @@ -1,687 +0,0 @@ -import { db } from '@sim/db' -import { - folder as folderTable, - knowledgeBase, - knowledgeConnector, - mcpServers, - userTableDefinitions, - workflow, -} from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { and, eq, inArray, isNull } from 'drizzle-orm' -import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' -import { createCopilotWorkspaceContextFilePrincipal } from '@/lib/copilot/auth/file-delegation' -import type { VfsSnapshotV1, VfsSnapshotV1Workflow } from '@/lib/copilot/generated/vfs-snapshot-v1' -import { - filterSecretNamesByMountPolicy, - type SecretMountPolicy, -} from '@/lib/copilot/secret-mount-policy' -import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' -import { canonicalWorkflowVfsDir, canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' -import { - getAccessibleEnvCredentials, - getAccessibleOAuthCredentials, -} from '@/lib/credentials/environment' -import { listWorkspaceSandboxes } from '@/lib/execution/remote-sandbox/workspace-sandboxes' -import { listCustomBlockSummariesForWorkspace } from '@/lib/workflows/custom-blocks/operations' -import { listCustomTools } from '@/lib/workflows/custom-tools/operations' -import { listSkillsForUser } from '@/lib/workflows/skills/operations' -import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' -import { - assertActiveWorkspaceAccess, - getUsersWithPermissions, - type WorkspaceAccess, -} from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('WorkspaceContext') - -const PROVIDER_SERVICES: Record = { - google: ['Gmail', 'Sheets', 'Calendar', 'Drive'], - 'google-service-account': ['Gmail', 'Sheets', 'Calendar', 'Drive'], - slack: ['Slack'], - github: ['GitHub'], - microsoft: ['Outlook', 'OneDrive'], - linear: ['Linear'], - notion: ['Notion'], - stripe: ['Stripe'], - airtable: ['Airtable'], - jira: ['Jira'], - confluence: ['Confluence'], -} - -export interface WorkspaceMdData { - workspace: { id: string; name: string; ownerId: string } | null - members: Array<{ name: string; email: string; permissionType: string }> - workflows: Array<{ - id: string - name: string - isDeployed: boolean - lastRunAt?: Date | null - folderPath?: string | null - }> - knowledgeBases: Array<{ - id: string - name: string - description?: string | null - connectorTypes?: string[] - }> - // rowCount is no longer rendered (it is volatile and would bust the cached - // prompt prefix); kept optional so callers that still have it cheaply (the VFS - // materializer via listTables) need not change, while generateWorkspaceContext - // skips the per-table COUNT query entirely. - tables: Array<{ id: string; name: string; description?: string | null; rowCount?: number }> - files: Array<{ id: string; name: string; type: string; size: number; folderPath?: string | null }> - oauthIntegrations: Array<{ - id: string - providerId: string - displayName?: string | null - role?: string | null - }> - envVariables: string[] - customTools?: Array<{ id: string; name: string }> - customBlocks?: Array<{ type: string; name: string; description?: string }> - mcpServers?: Array<{ id: string; name: string; url?: string | null; enabled: boolean }> - skills?: Array<{ id: string; name: string; description: string }> - sandboxes?: Array<{ - id: string - name: string - language: string - dependencies: string[] - cliTools: string[] - systemPackages: string[] - }> -} - -/** - * Deterministic string ordering. The workspace inventory is placed in the - * prompt-cache prefix (mothership), so its bytes must be identical for identical - * workspace state regardless of DB row order — otherwise the cache silently - * busts every turn. `localeCompare` with a pinned locale gives stable, readable - * ordering across Sim instances (all run the same Node/ICU build). - */ -function stableCompare(a: string, b: string): number { - return a.localeCompare(b, 'en') -} - -/** Stable order by display name, tie-broken by id, for inventory listings. */ -function byNameThenId(a: { name: string; id: string }, b: { name: string; id: string }): number { - return stableCompare(a.name, b.name) || stableCompare(a.id, b.id) -} - -/** - * Pure formatting: build WORKSPACE.md content from pre-fetched data. - * No DB access — callers are responsible for providing the data. - * - * Output is deterministic: every collection is sorted by a stable key and - * volatile fields (run timestamps, mutable row counts) are omitted, so the - * rendered inventory only changes when the workspace structurally changes. This - * is what lets the mothership cache it in the prompt prefix across turns. - */ -export function buildWorkspaceMd(data: WorkspaceMdData): string { - const sections: string[] = [] - - if (data.workspace) { - sections.push( - `## Workspace\n- **Name**: ${data.workspace.name}\n- **ID**: ${data.workspace.id}\n- **Owner**: ${data.workspace.ownerId}` - ) - } - - if (data.members.length > 0) { - const lines = [...data.members] - .sort((a, b) => stableCompare(a.email, b.email)) - .map((m) => { - const display = m.name ? `${m.name} (${m.email})` : m.email - return `- ${display} — ${m.permissionType}` - }) - sections.push(`## Members\n${lines.join('\n')}`) - } - - if (data.workflows.length > 0) { - const rootWorkflows: typeof data.workflows = [] - const folderWorkflows = new Map() - - for (const wf of data.workflows) { - if (wf.folderPath) { - const existing = folderWorkflows.get(wf.folderPath) ?? [] - existing.push(wf) - folderWorkflows.set(wf.folderPath, existing) - } else { - rootWorkflows.push(wf) - } - } - - const formatWf = (wf: (typeof data.workflows)[0], indent: string) => { - const parts = [`${indent}- **${wf.name}** (${wf.id})`] - const workflowDir = canonicalWorkflowVfsDir({ name: wf.name, folderPath: wf.folderPath }) - parts.push(`${indent} VFS dir: \`${workflowDir}\``) - parts.push(`${indent} VFS state path: \`${workflowDir}/state.json\``) - // `deployed` is a structural flag (kept); `lastRunAt` is intentionally - // omitted — it changes on every run and would bust the cached prompt - // prefix that carries this inventory. Current run data lives in - // workflows/{name}/executions.json. - if (wf.isDeployed) parts[0] += ' — deployed' - return parts.join('\n') - } - - const lines: string[] = [] - lines.push( - 'Use the canonical VFS dir/state path shown under each workflow. Paths are percent-encoded per segment; copy them verbatim and do not infer paths from display names.' - ) - for (const wf of [...rootWorkflows].sort(byNameThenId)) { - lines.push(formatWf(wf, '')) - } - const sortedFolders = [...folderWorkflows.entries()].sort((a, b) => stableCompare(a[0], b[0])) - for (const [folder, wfs] of sortedFolders) { - lines.push(`- 📁 **${folder}/**`) - for (const wf of [...wfs].sort(byNameThenId)) { - lines.push(formatWf(wf, ' ')) - } - } - sections.push(`## Workflows (${data.workflows.length})\n${lines.join('\n')}`) - } else { - sections.push('## Workflows (0)\n(none)') - } - - if (data.knowledgeBases.length > 0) { - const lines = [...data.knowledgeBases].sort(byNameThenId).map((kb) => { - let line = `- **${kb.name}** (${kb.id})` - if (kb.description) line += ` — ${kb.description}` - if (kb.connectorTypes && kb.connectorTypes.length > 0) { - line += ` | connectors: ${[...kb.connectorTypes].sort(stableCompare).join(', ')}` - } - return line - }) - sections.push(`## Knowledge Bases (${data.knowledgeBases.length})\n${lines.join('\n')}`) - } else { - sections.push('## Knowledge Bases (0)\n(none)') - } - - if (data.tables.length > 0) { - // rowCount is omitted: it changes on every row write and would bust the - // cached prompt prefix. Live counts are in tables/{name}/meta.json. - const lines = [...data.tables].sort(byNameThenId).map((t) => { - let line = `- **${t.name}** (${t.id})` - if (t.description) line += ` — ${t.description}` - return line - }) - sections.push(`## Tables (${data.tables.length})\n${lines.join('\n')}`) - } else { - sections.push('## Tables (0)\n(none)') - } - - if (data.files.length > 0) { - const rootFiles: typeof data.files = [] - const folderFiles = new Map() - for (const f of data.files) { - if (f.folderPath) { - const existing = folderFiles.get(f.folderPath) ?? [] - existing.push(f) - folderFiles.set(f.folderPath, existing) - } else { - rootFiles.push(f) - } - } - const fileLine = (f: (typeof data.files)[0], indent: string) => { - const vfsPath = canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }) - return `${indent}- **${f.name}** (${f.id}) — ${f.type}, ${formatSize(f.size)} — \`${vfsPath}\`` - } - const lines: string[] = [ - 'Read or edit a file by the exact VFS path shown in backticks below — copy it verbatim (it is already percent-encoded) and append `/content` to read the contents. Do not retype the display name or re-encode the path.', - ] - for (const f of [...rootFiles].sort(byNameThenId)) { - lines.push(fileLine(f, '')) - } - const sortedFolders = [...folderFiles.entries()].sort((a, b) => stableCompare(a[0], b[0])) - for (const [folder, folderFileList] of sortedFolders) { - lines.push(`- 📁 **${folder}/**`) - for (const f of [...folderFileList].sort(byNameThenId)) { - lines.push(fileLine(f, ' ')) - } - } - sections.push(`## Files (${data.files.length})\n${lines.join('\n')}`) - } else { - sections.push('## Files (0)\n(none)') - } - - if (data.oauthIntegrations.length > 0) { - const lines = [...data.oauthIntegrations] - .sort((a, b) => stableCompare(a.providerId, b.providerId) || stableCompare(a.id, b.id)) - .map((c) => { - const services = PROVIDER_SERVICES[c.providerId] - const svc = services ? ` (${services.join(', ')})` : '' - const who = c.displayName ? ` — ${c.displayName}` : '' - const role = c.role ? `, ${c.role}` : '' - return `- ${c.providerId}${svc}${who}${role} — credentialId: \`${c.id}\`` - }) - sections.push( - `## Connected Integrations\nPass these credentialId values directly on OAuth tool calls — no need to read environment/credentials.json for them.\n${lines.join('\n')}` - ) - } else { - sections.push('## Connected Integrations\n(none)') - } - - if (data.envVariables.length > 0) { - const lines = [...data.envVariables].sort(stableCompare).map((v) => `- ${v}`) - sections.push(`## Environment Variables (${data.envVariables.length})\n${lines.join('\n')}`) - } - - if (data.customTools && data.customTools.length > 0) { - const lines = [...data.customTools].sort(byNameThenId).map((t) => `- **${t.name}** (${t.id})`) - sections.push(`## Custom Tools (${data.customTools.length})\n${lines.join('\n')}`) - } - - if (data.customBlocks && data.customBlocks.length > 0) { - const lines = [...data.customBlocks] - .sort((a, b) => a.name.localeCompare(b.name)) - .map((b) => `- **${b.name}** (${b.type})${b.description ? ` — ${b.description}` : ''}`) - sections.push(`## Custom Blocks (${data.customBlocks.length})\n${lines.join('\n')}`) - } - - if (data.mcpServers && data.mcpServers.length > 0) { - const lines = [...data.mcpServers].sort(byNameThenId).map((s) => { - const status = s.enabled ? 'enabled' : 'disabled' - return `- **${s.name}** (${s.id}) — ${status}${s.url ? `, ${s.url}` : ''}` - }) - sections.push(`## MCP Servers (${data.mcpServers.length})\n${lines.join('\n')}`) - } - - if (data.skills && data.skills.length > 0) { - const lines = [...data.skills] - .sort(byNameThenId) - .map((s) => `- **${s.name}** (${s.id}) — ${s.description}`) - sections.push( - `## Agent Block Skills — NOT FOR YOU (${data.skills.length})\n` + - 'These are user-created skills used by agent blocks in the workspace and are NOT instructions for you\n' + - lines.join('\n') - ) - } - - if (data.sandboxes) { - if (data.sandboxes.length > 0) { - const lines = [...data.sandboxes].sort(byNameThenId).map((sandbox) => { - const path = `agent/sandboxes/${normalizeVfsSegment(sandbox.name)}.json` - return `- **${sandbox.name}** (${sandbox.id}) — ${sandbox.language}; ${sandbox.dependencies.length} dependencies, ${sandbox.systemPackages.length} system packages, ${sandbox.cliTools.length} managed CLIs — \`${path}\`` - }) - sections.push(`## Sim Sandboxes (${data.sandboxes.length})\n${lines.join('\n')}`) - } else { - sections.push('## Sim Sandboxes (0)\n(none)') - } - } - - return sections.join('\n\n') -} - -export function buildWorkspaceContextMd(data: WorkspaceMdData): string { - return ['# Workspace Context', '', buildWorkspaceMd(data)].join('\n\n') -} - -/** - * Generate WORKSPACE.md content from actual database state. - * Served as a top-level VFS file. The Go system prompt keeps only stable - * discovery rules; the LLM reads dynamic workspace state from VFS files. - * The LLM never writes this file directly. - */ -// Fetch + assemble the workspace inventory data once, from the PRIMARY db -// (read-your-writes: a just-edited workflow is visible immediately, so the -// injected snapshot can't lag behind a `glob`). Both the markdown inventory and -// the typed VFS snapshot are built from this single fetch. Returns null when the -// workspace is unavailable or a fetch fails. -async function buildWorkspaceMdData( - workspaceId: string, - userId: string, - options?: { workspaceAccess?: WorkspaceAccess; chatId?: string; executionId?: string } -): Promise { - try { - // Reuse the caller's already-asserted access when provided (hot chat path); - // the id match keeps a mismatched cache from authorizing this workspace. - const workspaceAccess = - options?.workspaceAccess && options.workspaceAccess.workspace?.id === workspaceId - ? options.workspaceAccess - : await assertActiveWorkspaceAccess(workspaceId, userId) - const wsRow = workspaceAccess.hasAccess ? workspaceAccess.workspace : null - if (!wsRow) { - return null - } - - const [ - members, - workflows, - folderRows, - kbs, - tables, - files, - credentials, - envCredentials, - customTools, - mcpServerRows, - skillRows, - customBlockSummaries, - sandboxResult, - ] = await Promise.all([ - getUsersWithPermissions(workspaceId), - - db - .select({ - id: workflow.id, - name: workflow.name, - isDeployed: workflow.isDeployed, - lastRunAt: workflow.lastRunAt, - folderId: workflow.folderId, - }) - .from(workflow) - .where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))), - - db - .select({ - id: folderTable.id, - name: folderTable.name, - parentId: folderTable.parentId, - }) - .from(folderTable) - .where( - and( - eq(folderTable.workspaceId, workspaceId), - eq(folderTable.resourceType, 'workflow'), - isNull(folderTable.deletedAt) - ) - ), - - db - .select({ - id: knowledgeBase.id, - name: knowledgeBase.name, - description: knowledgeBase.description, - }) - .from(knowledgeBase) - .where(and(eq(knowledgeBase.workspaceId, workspaceId), isNull(knowledgeBase.deletedAt))), - - db - .select({ - id: userTableDefinitions.id, - name: userTableDefinitions.name, - description: userTableDefinitions.description, - }) - .from(userTableDefinitions) - .where( - and( - eq(userTableDefinitions.workspaceId, workspaceId), - isNull(userTableDefinitions.archivedAt) - ) - ), - - listAllWorkspaceFiles - .execute({ - principal: createCopilotWorkspaceContextFilePrincipal({ - userId, - workspaceId, - chatId: options?.chatId, - executionId: options?.executionId, - }), - input: { workspaceId, scope: 'active' }, - }) - .then(({ files }) => files), - - getAccessibleOAuthCredentials(workspaceId, userId), - - getAccessibleEnvCredentials(workspaceId, userId), - - listCustomTools({ userId, workspaceId }), - - db - .select({ - id: mcpServers.id, - name: mcpServers.name, - url: mcpServers.url, - enabled: mcpServers.enabled, - }) - .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))), - - listSkillsForUser({ workspaceId, userId, includeBuiltins: false, workspaceAccess }), - - listCustomBlockSummariesForWorkspace(workspaceId), - - hasWorkspaceSandboxAccess(workspaceId).then(async (entitled) => ({ - entitled, - rows: entitled ? await listWorkspaceSandboxes(workspaceId) : [], - })), - ]) - - const kbIds = kbs.map((kb) => kb.id) - const connectorRows = - kbIds.length > 0 - ? await db - .select({ - knowledgeBaseId: knowledgeConnector.knowledgeBaseId, - connectorType: knowledgeConnector.connectorType, - }) - .from(knowledgeConnector) - .where( - and( - inArray(knowledgeConnector.knowledgeBaseId, kbIds), - isNull(knowledgeConnector.archivedAt), - isNull(knowledgeConnector.deletedAt) - ) - ) - : [] - const connectorTypesByKb = new Map() - for (const row of connectorRows) { - const types = connectorTypesByKb.get(row.knowledgeBaseId) ?? [] - if (!types.includes(row.connectorType)) { - types.push(row.connectorType) - } - connectorTypesByKb.set(row.knowledgeBaseId, types) - } - - const folderPathMap = new Map() - const folderById = new Map(folderRows.map((f) => [f.id, f])) - function resolveFolderPath(id: string): string { - const cached = folderPathMap.get(id) - if (cached !== undefined) return cached - const folder = folderById.get(id) - if (!folder) return id - const parentPath = folder.parentId ? resolveFolderPath(folder.parentId) : '' - const normalizedName = normalizeVfsSegment(folder.name) - const path = parentPath ? `${parentPath}/${normalizedName}` : normalizedName - folderPathMap.set(id, path) - return path - } - - return { - workspace: wsRow, - members, - workflows: workflows.map((wf) => ({ - ...wf, - folderPath: wf.folderId ? resolveFolderPath(wf.folderId) : null, - })), - knowledgeBases: kbs.map((kb) => ({ - ...kb, - // Sort connector types so the snapshot is order-stable: the DB query has - // no ORDER BY, and the Go delta engine compares item JSON byte-wise, so - // an unsorted (but unchanged) list would emit a spurious "modified" - // delta and needlessly bust the prompt cache. - connectorTypes: connectorTypesByKb.get(kb.id)?.sort(stableCompare), - })), - tables: tables.map((t) => ({ id: t.id, name: t.name, description: t.description })), - files: files.map((f) => ({ - id: f.id, - name: f.name, - type: f.type, - size: f.size, - folderPath: f.folderPath ?? null, - })), - oauthIntegrations: credentials.map((c) => ({ - id: c.id, - providerId: c.providerId, - displayName: c.displayName, - role: c.role, - })), - // Names only: make newly saved personal/workspace secrets visible to the - // next Mothership turn without ever putting their values on the wire. - // De-duplicate conflicts (the same key may exist in both scopes) and sort - // for byte-stable prompt snapshots. - envVariables: [...new Set(envCredentials.map((credential) => credential.envKey))].sort( - stableCompare - ), - customTools: customTools.map((t) => ({ id: t.id, name: t.title })), - customBlocks: customBlockSummaries, - mcpServers: mcpServerRows, - skills: skillRows.map((s) => ({ id: s.id, name: s.name, description: s.description })), - ...(sandboxResult.entitled - ? { - sandboxes: sandboxResult.rows.map((sandbox) => ({ - id: sandbox.id, - name: sandbox.name, - language: sandbox.language, - dependencies: sandbox.dependencies, - cliTools: sandbox.cliTools, - systemPackages: sandbox.systemPackages, - })), - } - : {}), - } - } catch (err) { - logger.error('Failed to build workspace data', { - workspaceId, - error: toError(err).message, - }) - return null - } -} - -const WORKSPACE_CONTEXT_UNAVAILABLE_MD = - '## Workspace\n(unavailable)\n\n## Workflows\n(unavailable)\n\n## Knowledge Bases\n(unavailable)\n\n## Tables\n(unavailable)\n\n## Files\n(unavailable)\n\n## Connected Integrations\n(unavailable)' - -/** - * Generate WORKSPACE.md markdown from current DB state (primary db). The LLM - * reads dynamic workspace state from VFS files; it never writes this file. - */ -export async function generateWorkspaceContext( - workspaceId: string, - userId: string, - options?: { - workspaceAccess?: WorkspaceAccess - secretMountPolicy?: SecretMountPolicy - chatId?: string - executionId?: string - } -): Promise { - const data = await buildWorkspaceMdData(workspaceId, userId, options) - if (!data) return WORKSPACE_CONTEXT_UNAVAILABLE_MD - - return buildWorkspaceMd({ - ...data, - envVariables: filterSecretNamesByMountPolicy(data.envVariables, options?.secretMountPolicy), - }) -} - -/** - * Build BOTH the markdown inventory and the typed VFS snapshot from a single - * primary-db fetch. The snapshot is the structured form Go diffs into - * baseline+delta messages; the markdown is the transition fallback. Returns null - * when the workspace is unavailable. - */ -export async function generateWorkspaceSnapshot( - workspaceId: string, - userId: string -): Promise<{ markdown: string; snapshot: VfsSnapshotV1 } | null> { - const data = await buildWorkspaceMdData(workspaceId, userId) - if (!data) return null - return { markdown: buildWorkspaceMd(data), snapshot: buildVfsSnapshot(data) } -} - -/** - * Map the workspace inventory data to the typed VFS snapshot contract. Pure; - * mirrors buildWorkspaceMd's field selection. Resource order is irrelevant — Go - * diffs by stable id, not position. - */ -export function buildVfsSnapshot(data: WorkspaceMdData): VfsSnapshotV1 { - const workflows: VfsSnapshotV1Workflow[] = data.workflows.map((wf) => ({ - id: wf.id, - name: wf.name, - path: canonicalWorkflowVfsDir({ name: wf.name, folderPath: wf.folderPath }), - ...(wf.isDeployed ? { isDeployed: true } : {}), - ...(wf.folderPath ? { folderPath: wf.folderPath } : {}), - })) - return { - ...(data.workspace - ? { - workspace: { - id: data.workspace.id, - name: data.workspace.name, - ...(data.workspace.ownerId ? { ownerId: data.workspace.ownerId } : {}), - }, - } - : {}), - members: data.members.map((m) => ({ - ...(m.name ? { name: m.name } : {}), - email: m.email, - ...(m.permissionType ? { permissionType: m.permissionType } : {}), - })), - workflows, - knowledgeBases: data.knowledgeBases.map((kb) => ({ - id: kb.id, - name: kb.name, - ...(kb.description ? { description: kb.description } : {}), - ...(kb.connectorTypes && kb.connectorTypes.length > 0 - ? { connectorTypes: kb.connectorTypes } - : {}), - })), - tables: data.tables.map((t) => ({ - id: t.id, - name: t.name, - ...(t.description ? { description: t.description } : {}), - })), - files: data.files.map((f) => ({ - id: f.id, - name: f.name, - path: canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }), - ...(f.type ? { type: f.type } : {}), - ...(f.size ? { size: f.size } : {}), - ...(f.folderPath ? { folderPath: f.folderPath } : {}), - })), - integrations: data.oauthIntegrations.map((c) => ({ - id: c.id, - providerId: c.providerId, - ...(c.displayName ? { displayName: c.displayName } : {}), - ...(c.role ? { role: c.role } : {}), - })), - envVars: data.envVariables, - customTools: (data.customTools ?? []).map((t) => ({ id: t.id, name: t.name })), - customBlocks: (data.customBlocks ?? []).map((b) => ({ - type: b.type, - name: b.name, - ...(b.description ? { description: b.description } : {}), - })), - mcpServers: (data.mcpServers ?? []).map((s) => ({ - id: s.id, - name: s.name, - ...(s.url ? { url: s.url } : {}), - ...(s.enabled ? { enabled: true } : {}), - })), - skills: (data.skills ?? []).map((s) => ({ - id: s.id, - name: s.name, - ...(s.description ? { description: s.description } : {}), - })), - ...(data.sandboxes - ? { - sandboxes: data.sandboxes.map((sandbox) => ({ - id: sandbox.id, - name: sandbox.name, - language: sandbox.language, - dependencies: sandbox.dependencies, - systemPackages: sandbox.systemPackages, - cliTools: sandbox.cliTools, - })), - } - : {}), - } -} - -function formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes}B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` - return `${(bytes / (1024 * 1024)).toFixed(1)}MB` -} diff --git a/apps/sim/lib/copilot/entitlements.ts b/apps/sim/lib/copilot/entitlements.ts deleted file mode 100644 index 402ea9169af..00000000000 --- a/apps/sim/lib/copilot/entitlements.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { LRUCache } from 'lru-cache' -import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' -import { isCustomBlocksEligible } from '@/lib/workflows/custom-blocks/operations' -import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('CopilotEntitlements') - -/** - * Cross-repo contract: the mothership (Go) matches these exact strings against - * its `core.Entitlement*` constants to gate agent surfaces. - */ -export const CUSTOM_BLOCKS_ENTITLEMENT = 'custom-blocks' -export const SIM_SANDBOXES_ENTITLEMENT = 'sim-sandboxes' -export const ORGANIZATION_CONTEXT_ENTITLEMENT = 'organization-context' - -/** - * Workspace entitlements — gated organization capabilities sent to the - * mothership as the chat payload's `entitlements` array. The Go side hides the - * matching tools, skills, and prompt sections when an entitlement is absent, so - * a non-entitled org's agents never hear of the feature. - * - * Adding an entitlement: - * 1. Add the kebab-case name and a fail-closed evaluator here. Every payload - * site (interactive chat, headless execute, inbox) picks it up automatically. - * 2. Go repo: add the matching `Entitlement*` constant in `internal/core` and - * gate surfaces declaratively — `RequiredEntitlement` on tool definitions, - * `entitlement:` frontmatter on skills, a conditional section in - * `BuildAgentEnvelope`, or a variant swap in `Capabilities`. - * 3. Keep enforcement in sim: the Go gating is advertisement-only (the payload - * is forgeable), so the sim-side tool handler must re-check the same - * predicate at execution time. - */ -const ENTITLEMENT_EVALUATORS: Record< - string, - (workspaceId: string, userId?: string) => Promise -> = { - [CUSTOM_BLOCKS_ENTITLEMENT]: isCustomBlocksEligible, - [SIM_SANDBOXES_ENTITLEMENT]: hasWorkspaceSandboxAccess, - [ORGANIZATION_CONTEXT_ENTITLEMENT]: isOrganizationContextAvailable, -} - -/** - * True when this workspace belongs to an organization, which is exactly when - * the copilot's `organization/` VFS namespace has anything in it. Advertising - * it keeps a personal workspace's agents from ever hearing that org standing, - * access-control groups, or fork topology exist. - */ -async function isOrganizationContextAvailable(workspaceId: string): Promise { - const workspace = await getWorkspaceWithOwner(workspaceId) - return Boolean(workspace?.organizationId) -} - -const entitlementsCache = new LRUCache>({ - max: 500, - ttl: 5_000, -}) - -/** - * The entitlements to send to the mothership for a request in this workspace. - * Each evaluator fails closed (an error means the entitlement is absent). - * Cached briefly so the several per-message callers collapse to one evaluation. - */ -export function computeWorkspaceEntitlements( - workspaceId: string, - userId?: string -): Promise { - const cacheKey = `${workspaceId}:${userId ?? ''}` - const cached = entitlementsCache.get(cacheKey) - if (cached) return cached - - const promise = Promise.all( - Object.entries(ENTITLEMENT_EVALUATORS).map(async ([name, evaluate]) => { - try { - return (await evaluate(workspaceId, userId)) ? name : null - } catch (error) { - logger.warn('Entitlement evaluation failed; treating as absent', { - entitlement: name, - workspaceId, - error: getErrorMessage(error), - }) - return null - } - }) - ).then((names) => names.filter((name): name is string => name !== null)) - entitlementsCache.set(cacheKey, promise) - return promise -} diff --git a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts b/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts deleted file mode 100644 index 6559a690ca7..00000000000 --- a/apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts +++ /dev/null @@ -1,139 +0,0 @@ -// AUTO-GENERATED FILE. DO NOT EDIT. -// - -/** - * Structured workspace inventory snapshot Sim sends to Go; Go diffs successive snapshots into baseline+delta messages. - */ -export interface VfsSnapshotV1 { - customBlocks?: VfsSnapshotV1CustomBlock[] - customTools?: VfsSnapshotV1NamedResource[] - envVars?: string[] - files?: VfsSnapshotV1File[] - integrations?: VfsSnapshotV1Integration[] - knowledgeBases?: VfsSnapshotV1KnowledgeBase[] - mcpServers?: VfsSnapshotV1McpServer[] - members?: VfsSnapshotV1Member[] - sandboxes?: VfsSnapshotV1Sandbox[] - skills?: VfsSnapshotV1Skill[] - tables?: VfsSnapshotV1Table[] - workflows?: VfsSnapshotV1Workflow[] - workspace?: VfsSnapshotV1Workspace -} -/** - * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema - * via the `definition` "VfsSnapshotV1CustomBlock". - */ -export interface VfsSnapshotV1CustomBlock { - description?: string - name: string - type: string -} -/** - * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema - * via the `definition` "VfsSnapshotV1NamedResource". - */ -export interface VfsSnapshotV1NamedResource { - id: string - name: string -} -/** - * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema - * via the `definition` "VfsSnapshotV1File". - */ -export interface VfsSnapshotV1File { - folderPath?: string - id: string - name: string - path: string - size?: number - type?: string -} -/** - * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema - * via the `definition` "VfsSnapshotV1Integration". - */ -export interface VfsSnapshotV1Integration { - displayName?: string - id: string - providerId: string - role?: string -} -/** - * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema - * via the `definition` "VfsSnapshotV1KnowledgeBase". - */ -export interface VfsSnapshotV1KnowledgeBase { - connectorTypes?: string[] - description?: string - id: string - name: string -} -/** - * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema - * via the `definition` "VfsSnapshotV1McpServer". - */ -export interface VfsSnapshotV1McpServer { - enabled?: boolean - id: string - name: string - url?: string -} -/** - * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema - * via the `definition` "VfsSnapshotV1Member". - */ -export interface VfsSnapshotV1Member { - email: string - name?: string - permissionType?: string -} -/** - * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema - * via the `definition` "VfsSnapshotV1Sandbox". - */ -export interface VfsSnapshotV1Sandbox { - cliTools?: string[] - dependencies?: string[] - id: string - language: string - name: string - systemPackages?: string[] -} -/** - * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema - * via the `definition` "VfsSnapshotV1Skill". - */ -export interface VfsSnapshotV1Skill { - description?: string - id: string - name: string -} -/** - * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema - * via the `definition` "VfsSnapshotV1Table". - */ -export interface VfsSnapshotV1Table { - description?: string - id: string - name: string -} -/** - * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema - * via the `definition` "VfsSnapshotV1Workflow". - */ -export interface VfsSnapshotV1Workflow { - folderPath?: string - id: string - isDeployed?: boolean - name: string - path: string -} -/** - * This interface was referenced by `VfsSnapshotV1`'s JSON-Schema - * via the `definition` "VfsSnapshotV1Workspace". - */ -export interface VfsSnapshotV1Workspace { - id: string - name: string - ownerId?: string -} diff --git a/apps/sim/lib/copilot/request/go/file-preview-append-roundtrip.test.ts b/apps/sim/lib/copilot/request/go/file-preview-append-roundtrip.test.ts deleted file mode 100644 index 59a36628be7..00000000000 --- a/apps/sim/lib/copilot/request/go/file-preview-append-roundtrip.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { buildPreviewContentUpdate } from '@/lib/copilot/request/go/file-preview-adapter' -import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract' -import { deriveFilePreviewSession } from '@/app/workspace/[workspaceId]/home/hooks/preview/apply-file-preview-phase' - -const CHECKPOINT_MS = 1_000 - -/** - * Producer -> consumer round trip for an `append` preview. - * - * `buildPreviewContentUpdate` decides snapshot vs delta; `deriveFilePreviewSession` is - * the only thing in the app that reads `contentMode`. Emitting deltas instead of a full - * snapshot per chunk is only safe if replaying what the producer emits reconstructs the - * text exactly — this drives the real functions against each other and checks that, - * rather than reasoning about it. - */ -function roundTrip( - chunks: string[], - base: string, - msPerChunk: number -): { rendered: string; expected: string; snapshots: number; deltas: number } { - let lastEmitted = '' - let lastSnapshotAt = 0 - let now = 0 - let streamed = '' - let session: FilePreviewSession | undefined - let version = 0 - let snapshots = 0 - let deltas = 0 - - for (const chunk of chunks) { - streamed += chunk - now += msPerChunk - const nextText = base.length > 0 ? `${base}\n${streamed}` : streamed - const update = buildPreviewContentUpdate(lastEmitted, nextText, lastSnapshotAt, now, 'append') - lastEmitted = nextText - lastSnapshotAt = update.lastSnapshotAt - version += 1 - if (update.contentMode === 'snapshot') snapshots++ - else deltas++ - - session = deriveFilePreviewSession( - session, - { - previewPhase: 'file_preview_content', - content: update.content, - contentMode: update.contentMode, - previewVersion: version, - toolCallId: 'tc_1', - toolName: 'prepare_file_edit', - fileName: 'notes.md', - operation: 'append', - } as never, - 'stream_1', - new Date(now).toISOString() - ) - } - - return { - rendered: session?.previewText ?? '', - expected: base.length > 0 ? `${base}\n${streamed}` : streamed, - snapshots, - deltas, - } -} - -function chunksOf(text: string, size: number): string[] { - const out: string[] = [] - for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size)) - return out -} - -describe('append preview round trip', () => { - it('reconstructs the exact text the user should see, and does it with deltas', () => { - const base = 'Existing file body.\nSecond line.' - const r = roundTrip(chunksOf('The appended paragraph goes here.', 4), base, 20) - - expect(r.rendered).toBe(r.expected) - expect(r.deltas).toBeGreaterThan(0) - }) - - it('holds for realistic token-scale chunking on a large base file', () => { - const base = 'x'.repeat(250 * 1024) - const r = roundTrip(chunksOf('y'.repeat(4096), 10), base, 20) - - expect(r.rendered).toBe(r.expected) - expect(r.rendered.length).toBe(250 * 1024 + 1 + 4096) - }) - - it('still emits a recoverable full snapshot on the checkpoint interval', () => { - // One chunk per 400ms crosses the 1s checkpoint repeatedly. - const r = roundTrip(chunksOf('abcdefghij', 1), 'base', 400) - - expect(r.rendered).toBe(r.expected) - expect(r.snapshots).toBeGreaterThan(1) - expect(r.snapshots * CHECKPOINT_MS).toBeGreaterThan(0) - }) - - it('recovers exactly when the base file changes underneath the stream', () => { - // A divergent base must fall back to a snapshot, not a delta on stale text. - const first = buildPreviewContentUpdate('Old base\nabc', 'New base\nabcd', 100, 200, 'append') - expect(first.contentMode).toBe('snapshot') - expect(first.content).toBe('New base\nabcd') - - const session = deriveFilePreviewSession( - undefined, - { - previewPhase: 'file_preview_content', - content: first.content, - contentMode: first.contentMode, - previewVersion: 1, - toolCallId: 'tc_1', - toolName: 'prepare_file_edit', - fileName: 'notes.md', - operation: 'append', - } as never, - 'stream_1', - new Date().toISOString() - ) - expect(session.previewText).toBe('New base\nabcd') - }) - - it('ignores a replayed event rather than double-appending its delta', () => { - const base = 'Base.' - const chunks = chunksOf('hello world', 3) - let lastEmitted = '' - let lastSnapshotAt = 0 - let now = 0 - let streamed = '' - let session: FilePreviewSession | undefined - let version = 0 - const emitted: Array<{ content: string; contentMode: string; version: number }> = [] - - for (const chunk of chunks) { - streamed += chunk - now += 20 - const u = buildPreviewContentUpdate( - lastEmitted, - `${base}\n${streamed}`, - lastSnapshotAt, - now, - 'append' - ) - lastEmitted = `${base}\n${streamed}` - lastSnapshotAt = u.lastSnapshotAt - version += 1 - emitted.push({ content: u.content, contentMode: u.contentMode, version }) - } - - // Deliver every event twice, out of order for the duplicates. - for (const e of [...emitted, ...emitted]) { - session = deriveFilePreviewSession( - session, - { - previewPhase: 'file_preview_content', - content: e.content, - contentMode: e.contentMode, - previewVersion: e.version, - toolCallId: 'tc_1', - toolName: 'prepare_file_edit', - fileName: 'notes.md', - operation: 'append', - } as never, - 'stream_1', - new Date().toISOString() - ) - } - - expect(session?.previewText).toBe(`${base}\n${streamed}`) - }) -}) diff --git a/apps/sim/lib/copilot/sim-sandbox-projection.ts b/apps/sim/lib/copilot/sim-sandbox-projection.ts deleted file mode 100644 index 822fb3933ce..00000000000 --- a/apps/sim/lib/copilot/sim-sandbox-projection.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { SIM_SANDBOXES_ENTITLEMENT } from '@/lib/copilot/entitlements' - -export const RESTRICTED_SIM_SANDBOX_INPUTS = new Map([ - [ - 'sandboxId', - { - requiredEntitlement: SIM_SANDBOXES_ENTITLEMENT, - reason: - 'Selecting or clearing a Sim sandbox requires an active Max or Enterprise plan. Preserve any existing selection unless the user upgrades.', - }, - ], -]) diff --git a/apps/sim/lib/copilot/tool-executor/handler-map.ts b/apps/sim/lib/copilot/tool-executor/handler-map.ts deleted file mode 100644 index f9b3b583a47..00000000000 --- a/apps/sim/lib/copilot/tool-executor/handler-map.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { - CancelWorkflowRun, - ConnectSlackBot, - Cp as CpTool, - CreateWorkflow, - CreateWorkspaceMcpServer, - DeleteWorkspaceMcpServer, - DeployAsApi, - DeployAsChat, - DeployAsMcp, - DiffWorkflows, - GenerateApiKey, - GetBlockOutputs, - GetBlockUpstreamReferences, - GetDeployedWorkflowState, - GetDeploymentStatus, - GetWorkflowData, - GetWorkflowRunOptions, - Glob as GlobTool, - Grep as GrepTool, - ListDeploymentVersions, - ListIntegrationTools, - ListWorkspaceMcpServers, - LoadDeployment, - ManageCredential, - ManageCustomTool, - ManageMcpConnection, - ManageSandbox, - ManageSkill, - Mkdir as MkdirTool, - Mv as MvTool, - OauthGetAuthLink, - OauthRequestAccess, - OpenResource, - PromoteToLive, - PublishCustomBlock, - Read as ReadTool, - Redeploy, - RestoreResource, - Rm as RmTool, - RunBlock, - RunCode, - RunFromBlock, - RunFunction, - RunWorkflow, - RunWorkflowUntilBlock, - SaveUpload, - SetBlockEnabled, - SetGlobalWorkflowVariables, - UpdateDeploymentVersion, - UpdateWorkspaceMcpServer, -} from '@/lib/copilot/generated/tool-catalog-v1' -import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' -import { getRegisteredServerToolNames } from '@/lib/copilot/tools/server/router' -import { executeDeployCustomBlock } from '../tools/handlers/deployment/custom-block' -import { - executeDeployApi, - executeDeployChat, - executeDeployMcp, - executeRedeploy, -} from '../tools/handlers/deployment/deploy' -import { - executeCheckDeploymentStatus, - executeCreateWorkspaceMcpServer, - executeDeleteWorkspaceMcpServer, - executeDiffWorkflows, - executeGetDeploymentLog, - executeListWorkspaceMcpServers, - executeLoadDeployment, - executePromoteToLive, - executeUpdateDeploymentVersion, - executeUpdateWorkspaceMcpServer, -} from '../tools/handlers/deployment/manage' -import { executeFunctionExecute } from '../tools/handlers/function-execute' -import { executeListIntegrationTools } from '../tools/handlers/integration-tools' -import { executeConnectSlackBot } from '../tools/handlers/management/connect-slack-bot' -import { executeManageCredential } from '../tools/handlers/management/manage-credential' -import { executeManageCustomTool } from '../tools/handlers/management/manage-custom-tool' -import { executeManageMcpTool } from '../tools/handlers/management/manage-mcp-tool' -import { executeManageSandbox } from '../tools/handlers/management/manage-sandbox' -import { executeManageSkill } from '../tools/handlers/management/manage-skill' -import { executeMaterializeFile } from '../tools/handlers/materialize-file' -import { executeOAuthGetAuthLink, executeOAuthRequestAccess } from '../tools/handlers/oauth' -import { executeOpenResource } from '../tools/handlers/resources' -import { executeRestoreResource } from '../tools/handlers/restore-resource' -import { executeRunCode } from '../tools/handlers/run-code' -import { executeVfsGlob, executeVfsGrep, executeVfsRead } from '../tools/handlers/vfs' -import { - executeVfsCp, - executeVfsMkdir, - executeVfsMv, - executeVfsRm, -} from '../tools/handlers/vfs-mutate' -import { - executeCancelWorkflowRun, - executeCreateWorkflow, - executeGenerateApiKey, - executeMoveWorkflow, - executeRenameWorkflow, - executeRunBlock, - executeRunFromBlock, - executeRunWorkflow, - executeRunWorkflowUntilBlock, - executeSetBlockEnabled, - executeSetGlobalWorkflowVariables, -} from '../tools/handlers/workflow/mutations' -import { - executeGetBlockOutputs, - executeGetBlockUpstreamReferences, - executeGetDeployedWorkflowState, - executeGetWorkflowData, - executeGetWorkflowRunOptions, -} from '../tools/handlers/workflow/queries' -import type { ToolHandler } from './types' - -// Bridge: handler implementations accept specific param types (e.g. CreateWorkflowParams) -// while ToolHandler accepts Record. The params are cast internally by -// each implementation. ExecutionContext extends ToolExecutionContext so context is compatible. -function h(fn: (params: any, context: any) => Promise): ToolHandler { - return fn as ToolHandler -} - -export function buildHandlerMap(): Record { - return { - [GetWorkflowData.id]: h(executeGetWorkflowData), - [GetWorkflowRunOptions.id]: h(executeGetWorkflowRunOptions), - [GetBlockOutputs.id]: h(executeGetBlockOutputs), - [GetBlockUpstreamReferences.id]: h(executeGetBlockUpstreamReferences), - [GetDeployedWorkflowState.id]: h(executeGetDeployedWorkflowState), - - [CreateWorkflow.id]: h(executeCreateWorkflow), - // rename_workflow / move_workflow were removed from the mothership catalog - // in favor of mv; the executors stay registered under literal names so - // in-flight checkpoints still resume. Delete after the mv release soaks. - rename_workflow: h(executeRenameWorkflow), - move_workflow: h(executeMoveWorkflow), - [RunWorkflow.id]: h(executeRunWorkflow), - [CancelWorkflowRun.id]: h(executeCancelWorkflowRun), - [RunWorkflowUntilBlock.id]: h(executeRunWorkflowUntilBlock), - [RunFromBlock.id]: h(executeRunFromBlock), - [RunBlock.id]: h(executeRunBlock), - [SetBlockEnabled.id]: h(executeSetBlockEnabled), - [GenerateApiKey.id]: h(executeGenerateApiKey), - [SetGlobalWorkflowVariables.id]: h(executeSetGlobalWorkflowVariables), - - [DeployAsApi.id]: h(executeDeployApi), - [DeployAsChat.id]: h(executeDeployChat), - [DeployAsMcp.id]: h(executeDeployMcp), - [PublishCustomBlock.id]: h(executeDeployCustomBlock), - [Redeploy.id]: h(executeRedeploy), - [GetDeploymentStatus.id]: h(executeCheckDeploymentStatus), - [ListWorkspaceMcpServers.id]: h(executeListWorkspaceMcpServers), - [CreateWorkspaceMcpServer.id]: h(executeCreateWorkspaceMcpServer), - [UpdateWorkspaceMcpServer.id]: h(executeUpdateWorkspaceMcpServer), - [DeleteWorkspaceMcpServer.id]: h(executeDeleteWorkspaceMcpServer), - [ListDeploymentVersions.id]: h(executeGetDeploymentLog), - [DiffWorkflows.id]: h(executeDiffWorkflows), - [LoadDeployment.id]: h(executeLoadDeployment), - [PromoteToLive.id]: h(executePromoteToLive), - [UpdateDeploymentVersion.id]: h(executeUpdateDeploymentVersion), - - [GrepTool.id]: h(executeVfsGrep), - [GlobTool.id]: h(executeVfsGlob), - [ReadTool.id]: h(executeVfsRead), - [MvTool.id]: h(executeVfsMv), - [CpTool.id]: h(executeVfsCp), - [MkdirTool.id]: h(executeVfsMkdir), - [RmTool.id]: h(executeVfsRm), - - [ManageCustomTool.id]: h(executeManageCustomTool), - [ManageMcpConnection.id]: h(executeManageMcpTool), - [ManageSandbox.id]: h(executeManageSandbox), - [ManageSkill.id]: h(executeManageSkill), - [ManageCredential.id]: h(executeManageCredential), - [ConnectSlackBot.id]: h(executeConnectSlackBot), - [OauthGetAuthLink.id]: h(executeOAuthGetAuthLink), - // Rolling-deploy compatibility for calls/checkpoints created before OAuth - // moved into terminal credential cards. New agents no longer receive this - // tool, but old calls must remain resumable until both services have soaked. - [OauthRequestAccess.id]: h(executeOAuthRequestAccess), - [OpenResource.id]: h(executeOpenResource), - [RestoreResource.id]: h(executeRestoreResource), - [ListIntegrationTools.id]: h(executeListIntegrationTools), - [SaveUpload.id]: h(executeMaterializeFile), - [RunFunction.id]: h(executeFunctionExecute), - [RunCode.id]: h(executeRunCode), - - ...buildServerToolHandlers(), - } -} - -function buildServerToolHandlers(): Record { - const toolNames = getRegisteredServerToolNames() - const handlers: Record = {} - for (const toolId of toolNames) { - handlers[toolId] = createServerToolHandler(toolId) - } - return handlers -} diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts deleted file mode 100644 index e77644d08fe..00000000000 --- a/apps/sim/lib/copilot/tool-executor/register-handlers.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { createLogger } from '@sim/logger' -import { registerHandlers } from './executor' - -const logger = createLogger('ToolHandlerRegistration') - -let registration: Promise | null = null - -/** - * Registers every server-side tool handler exactly once. - * - * The handler map statically imports every copilot tool implementation, which - * transitively reaches the block registry, table application layer, and most - * of `lib/` — several thousand modules. Nothing that merely routes or inspects - * tool calls needs any of that, so the map is loaded on first execution rather - * than whenever this module is imported. - */ -export function ensureHandlersRegistered(): Promise { - registration ??= import('./handler-map').then(({ buildHandlerMap }) => { - registerHandlers(buildHandlerMap()) - logger.info('Tool handlers registered') - }) - return registration -} diff --git a/apps/sim/lib/copilot/tools/handlers/access.ts b/apps/sim/lib/copilot/tools/handlers/access.ts deleted file mode 100644 index c01b3a716fc..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/access.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import type { getWorkflowById } from '@/lib/workflows/utils' -import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils' - -type WorkflowRecord = NonNullable>> - -export async function ensureWorkflowAccess( - workflowId: string, - userId: string, - action: 'read' | 'write' | 'admin' = 'read' -): Promise<{ - workflow: WorkflowRecord - workspaceId?: string | null -}> { - const result = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId, - action, - }) - - // Classified, not bare Errors: the copilot error projection passes a - // classified message through to the model verbatim, while an unclassified - // throw collapses into the generic "system error, please retry". - if (!result.workflow) { - throw new OrchestrationError( - 'not_found', - `Workflow not found: ${workflowId}. Pass the workflow's canonical id (copy it from workflows/**/meta.json or the tool result that created it) — a workflow name or @-mention is not an id.` - ) - } - - if (!result.allowed) { - throw new OrchestrationError( - result.status === 404 ? 'not_found' : 'forbidden', - result.message || 'Unauthorized workflow access' - ) - } - - return { workflow: result.workflow, workspaceId: result.workflow.workspaceId } -} - -export async function ensureWorkspaceAccess( - workspaceId: string, - userId: string, - level: 'read' | 'write' | 'admin' = 'read' -): Promise { - const access = await checkWorkspaceAccess(workspaceId, userId) - if (!access.exists || !access.hasAccess) { - throw new Error(`Workspace ${workspaceId} not found`) - } - - if (level === 'read') return access - - if (level === 'admin') { - if (!access.canAdmin) { - throw new Error('Admin access required for this workspace') - } - return access - } - - if (!access.canWrite) { - throw new Error('Write or admin access required for this workspace') - } - return access -} diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts deleted file mode 100644 index 178d403603b..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/deployment/context.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { - getCopilotDeploymentIdempotencyKey, - getHistoricalDeploymentAttemptError, -} from '@/lib/copilot/tools/handlers/deployment/context' - -describe('getCopilotDeploymentIdempotencyKey', () => { - it('is stable for a replay of the same logical tool call', () => { - const context = { executionId: 'execution-1', runId: 'run-1', toolCallId: 'call-1' } - - expect(getCopilotDeploymentIdempotencyKey(context, 'redeploy')).toBe( - getCopilotDeploymentIdempotencyKey(context, 'redeploy') - ) - }) - - it('reuses one operation scope across model retries with new tool-call ids', () => { - expect( - getCopilotDeploymentIdempotencyKey( - { executionId: 'execution-1', toolCallId: 'call-1' }, - 'redeploy' - ) - ).toBe( - getCopilotDeploymentIdempotencyKey( - { executionId: 'execution-1', toolCallId: 'call-2' }, - 'redeploy' - ) - ) - }) - - it('separates deployment intents within the same execution', () => { - const context = { executionId: 'execution-1', toolCallId: 'call-1' } - - expect(getCopilotDeploymentIdempotencyKey(context, 'deploy_as_api')).not.toBe( - getCopilotDeploymentIdempotencyKey(context, 'redeploy') - ) - }) - - it('does not derive a turn-wide key when the tool-call identity is unavailable', () => { - expect(getCopilotDeploymentIdempotencyKey({}, 'redeploy')).toBeUndefined() - }) -}) - -describe('getHistoricalDeploymentAttemptError', () => { - it('requires a new tool call when the persisted attempt is no longer current', () => { - expect(getHistoricalDeploymentAttemptError({ isCurrent: false }, 'redeploy')).toContain( - 'Start a new tool call' - ) - expect(getHistoricalDeploymentAttemptError({ isCurrent: true }, 'redeploy')).toBeNull() - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/context.ts b/apps/sim/lib/copilot/tools/handlers/deployment/context.ts deleted file mode 100644 index 249b96d4956..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/deployment/context.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ToolExecutionContext } from '@/lib/copilot/tool-executor/types' - -type DeploymentToolContext = Pick< - ToolExecutionContext, - 'executionId' | 'messageId' | 'runId' | 'toolCallId' -> - -interface DeploymentAttemptCurrentState { - isCurrent?: boolean -} - -/** - * Builds a retry-stable scope for one deployment intent in a Copilot run. - * The orchestration layer appends the canonical deployment request hash, so - * an unchanged retry reuses the operation while a later edited draft remains - * a distinct deployment. - */ -export function getCopilotDeploymentIdempotencyKey( - context: DeploymentToolContext, - operation: string -): string | undefined { - const executionScope = context.executionId ?? context.runId ?? context.messageId - if (executionScope) return `copilot:${executionScope}:operation:${operation}` - return context.toolCallId - ? `copilot:tool-call:${context.toolCallId}:operation:${operation}` - : undefined -} - -/** Rejects a replay whose persisted operation no longer describes production. */ -export function getHistoricalDeploymentAttemptError( - attempt: DeploymentAttemptCurrentState | null | undefined, - action: string -): string | null { - if (attempt?.isCurrent !== false) return null - return `The ${action} operation associated with this tool call is historical and no longer describes production. Start a new tool call to create a new logical deployment operation.` -} diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts deleted file mode 100644 index d79a2f6c09d..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts +++ /dev/null @@ -1,508 +0,0 @@ -/** - * @vitest-environment node - */ - -import { auditMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ExecutionContext } from '@/lib/copilot/request/types' - -const { - ensureWorkflowAccessMock, - getWorkspaceWithOwnerMock, - isCustomBlocksDeploymentEnabledMock, - isCustomBlocksEligibleForOrganizationMock, - publishCustomBlockMock, - updateCustomBlockMock, - deleteCustomBlockMock, - getCustomBlockWithInputsByWorkflowIdMock, - resolveWorkspaceFileReferenceMock, - readWorkspaceFileContentMock, - uploadFileMock, -} = vi.hoisted(() => ({ - ensureWorkflowAccessMock: vi.fn(), - getWorkspaceWithOwnerMock: vi.fn(), - isCustomBlocksDeploymentEnabledMock: vi.fn(), - isCustomBlocksEligibleForOrganizationMock: vi.fn(), - publishCustomBlockMock: vi.fn(), - updateCustomBlockMock: vi.fn(), - deleteCustomBlockMock: vi.fn(), - getCustomBlockWithInputsByWorkflowIdMock: vi.fn(), - resolveWorkspaceFileReferenceMock: vi.fn(), - readWorkspaceFileContentMock: vi.fn(), - uploadFileMock: vi.fn(), -})) - -vi.mock('@sim/audit', () => auditMock) - -vi.mock('../access', () => ({ - ensureWorkflowAccess: ensureWorkflowAccessMock, - ensureWorkspaceAccess: vi.fn(), -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getWorkspaceWithOwner: getWorkspaceWithOwnerMock, -})) - -vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({ - resolveCopilotWorkspaceFileReference: resolveWorkspaceFileReferenceMock, - executeCopilotFileUseCase: vi.fn( - async (_context, _useCase, input: { fileId: string; maxBytes: number }) => - readWorkspaceFileContentMock(input) - ), -})) -vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ - readWorkspaceFileContent: { - operation: { id: 'files.read_content' }, - execute: readWorkspaceFileContentMock, - }, -})) - -vi.mock('@/lib/uploads/core/storage-service', () => ({ - uploadFile: uploadFileMock, -})) - -vi.mock('@/lib/uploads/utils/file-utils', () => ({ - isImageFileType: (type: string) => type.startsWith('image/'), -})) - -vi.mock('@/lib/workflows/custom-blocks/operations', () => { - class CustomBlockValidationError extends Error {} - return { - CustomBlockValidationError, - publishCustomBlock: publishCustomBlockMock, - updateCustomBlock: updateCustomBlockMock, - deleteCustomBlock: deleteCustomBlockMock, - getCustomBlockWithInputsByWorkflowId: getCustomBlockWithInputsByWorkflowIdMock, - isCustomBlocksDeploymentEnabled: isCustomBlocksDeploymentEnabledMock, - isCustomBlocksEligibleForOrganization: isCustomBlocksEligibleForOrganizationMock, - } -}) - -import { executeDeployCustomBlock } from './custom-block' - -const context = { - userId: 'user-1', - workflowId: 'wf-1', - workspaceId: 'ws-1', - toolCallId: 'tool-1', - copilotToolExecution: true, -} as ExecutionContext - -const publishedBlock = { - id: 'cb-1', - organizationId: 'org-1', - workflowId: 'wf-1', - workflowName: 'Test Workflow', - workspaceId: 'ws-1', - workspaceName: 'Workspace', - type: 'custom_block_abc123', - name: 'Enrich Lead', - description: 'Enrich a lead by email', - iconUrl: null, - enabled: true, - inputFields: [{ id: 'f1', name: 'email', type: 'string' }], - exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }], -} - -describe('executeDeployCustomBlock', () => { - beforeEach(() => { - vi.clearAllMocks() - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow', isDeployed: true }, - }) - getWorkspaceWithOwnerMock.mockResolvedValue({ id: 'ws-1', organizationId: 'org-1' }) - isCustomBlocksDeploymentEnabledMock.mockReturnValue(true) - isCustomBlocksEligibleForOrganizationMock.mockResolvedValue(true) - getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(null) - }) - - it('publishes a new custom block', async () => { - publishCustomBlockMock.mockResolvedValue(publishedBlock) - - const result = await executeDeployCustomBlock( - { - name: 'Enrich Lead', - description: 'Enrich a lead by email', - exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }], - }, - context - ) - - expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'admin') - expect(publishCustomBlockMock).toHaveBeenCalledWith({ - organizationId: 'org-1', - workspaceId: 'ws-1', - workflowId: 'wf-1', - userId: 'user-1', - name: 'Enrich Lead', - description: 'Enrich a lead by email', - iconUrl: undefined, - inputs: undefined, - exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }], - }) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - workflowId: 'wf-1', - blockType: 'custom_block_abc123', - isDeployed: true, - updated: false, - deploymentType: 'custom_block', - deploymentStatus: { customBlock: { isDeployed: true, name: 'Enrich Lead' } }, - }) - }) - - it('rejects a workflowId whose workspace differs from the execution workspace', async () => { - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { id: 'wf-other', workspaceId: 'ws-other', name: 'Other', isDeployed: true }, - }) - - const result = await executeDeployCustomBlock( - { workflowId: 'wf-other', name: 'Enrich Lead' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('does not match the Copilot execution workspace') - expect(publishCustomBlockMock).not.toHaveBeenCalled() - }) - - it('returns a clean admin-permission error when workflow access is denied', async () => { - ensureWorkflowAccessMock.mockRejectedValue(new Error('Unauthorized workflow access')) - - const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('admin permission') - expect(publishCustomBlockMock).not.toHaveBeenCalled() - }) - - it('surfaces workflow-not-found from access resolution', async () => { - ensureWorkflowAccessMock.mockRejectedValue(new Error('Workflow wf-1 not found')) - - const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found') - }) - - it('requires a name on first publish', async () => { - const result = await executeDeployCustomBlock({}, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('name is required') - expect(publishCustomBlockMock).not.toHaveBeenCalled() - }) - - it('requires the workflow to be deployed on first publish', async () => { - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow', isDeployed: false }, - }) - - const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('deploy_as_api') - expect(publishCustomBlockMock).not.toHaveBeenCalled() - }) - - it('updates an existing block in place', async () => { - getCustomBlockWithInputsByWorkflowIdMock - .mockResolvedValueOnce(publishedBlock) - .mockResolvedValueOnce({ ...publishedBlock, name: 'Enrich Lead v2' }) - - const result = await executeDeployCustomBlock({ name: 'Enrich Lead v2' }, context) - - expect(updateCustomBlockMock).toHaveBeenCalledWith('cb-1', { - name: 'Enrich Lead v2', - description: undefined, - iconUrl: undefined, - inputs: undefined, - exposedOutputs: undefined, - }) - expect(publishCustomBlockMock).not.toHaveBeenCalled() - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ updated: true, name: 'Enrich Lead v2' }) - }) - - it('unpublishes the block on undeploy', async () => { - getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(publishedBlock) - - const result = await executeDeployCustomBlock({ action: 'undeploy' }, context) - - expect(deleteCustomBlockMock).toHaveBeenCalledWith('cb-1') - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - isDeployed: false, - removed: true, - action: 'undeploy', - blockType: 'custom_block_abc123', - }) - }) - - it('updates an existing block after organization eligibility lapses', async () => { - isCustomBlocksEligibleForOrganizationMock.mockResolvedValue(false) - getCustomBlockWithInputsByWorkflowIdMock - .mockResolvedValueOnce(publishedBlock) - .mockResolvedValueOnce(publishedBlock) - - const result = await executeDeployCustomBlock({ description: 'refreshed copy' }, context) - - expect(result.success).toBe(true) - expect(updateCustomBlockMock).toHaveBeenCalled() - expect(publishCustomBlockMock).not.toHaveBeenCalled() - }) - - it('undeploys after organization eligibility lapses', async () => { - isCustomBlocksEligibleForOrganizationMock.mockResolvedValue(false) - getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(publishedBlock) - - const result = await executeDeployCustomBlock({ action: 'undeploy' }, context) - - expect(result.success).toBe(true) - expect(deleteCustomBlockMock).toHaveBeenCalledWith('cb-1') - }) - - it('does not clear the stored name when a republish sends whitespace', async () => { - getCustomBlockWithInputsByWorkflowIdMock - .mockResolvedValueOnce(publishedBlock) - .mockResolvedValueOnce(publishedBlock) - - const result = await executeDeployCustomBlock({ name: ' ' }, context) - - expect(updateCustomBlockMock).toHaveBeenCalledWith( - 'cb-1', - expect.objectContaining({ name: undefined }) - ) - expect(result.success).toBe(true) - }) - - it('rejects oversized exposedOutputs and inputs arrays', async () => { - const outputs = Array.from({ length: 51 }, (_, i) => ({ - blockId: `b${i}`, - path: 'content', - name: `out${i}`, - })) - const tooManyOutputs = await executeDeployCustomBlock( - { name: 'Enrich Lead', exposedOutputs: outputs }, - context - ) - expect(tooManyOutputs.success).toBe(false) - expect(tooManyOutputs.error).toContain('50') - - const inputs = Array.from({ length: 51 }, (_, i) => ({ id: `f${i}` })) - const tooManyInputs = await executeDeployCustomBlock({ name: 'Enrich Lead', inputs }, context) - expect(tooManyInputs.success).toBe(false) - expect(tooManyInputs.error).toContain('50') - expect(publishCustomBlockMock).not.toHaveBeenCalled() - }) - - it('rejects oversized per-item fields', async () => { - const longPlaceholder = await executeDeployCustomBlock( - { name: 'Enrich Lead', inputs: [{ id: 'f1', placeholder: 'x'.repeat(201) }] }, - context - ) - expect(longPlaceholder.success).toBe(false) - expect(longPlaceholder.error).toContain('200') - - const longOutputName = await executeDeployCustomBlock( - { - name: 'Enrich Lead', - exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'x'.repeat(61) }], - }, - context - ) - expect(longOutputName.success).toBe(false) - expect(longOutputName.error).toContain('60') - expect(publishCustomBlockMock).not.toHaveBeenCalled() - }) - - it('rejects exposedOutputs entries missing required fields', async () => { - const result = await executeDeployCustomBlock( - { name: 'Enrich Lead', exposedOutputs: [{ blockId: 'b1', path: '', name: 'out' }] }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('blockId, path, and name') - }) - - it('fails undeploy when the workflow is not published as a block', async () => { - const result = await executeDeployCustomBlock({ action: 'undeploy' }, context) - - expect(result.success).toBe(false) - expect(deleteCustomBlockMock).not.toHaveBeenCalled() - }) - - it('fails when custom blocks are not enabled for the organization', async () => { - isCustomBlocksEligibleForOrganizationMock.mockResolvedValue(false) - - const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('not enabled') - }) - - it('blocks existing custom blocks when the deployment entitlement is disabled', async () => { - isCustomBlocksDeploymentEnabledMock.mockReturnValue(false) - getCustomBlockWithInputsByWorkflowIdMock.mockResolvedValue(publishedBlock) - - const result = await executeDeployCustomBlock({ action: 'undeploy' }, context) - - expect(result).toEqual({ - success: false, - error: 'Custom blocks are not enabled for this organization', - }) - expect(deleteCustomBlockMock).not.toHaveBeenCalled() - }) - - it('ingests a workspace-file icon into public icon storage', async () => { - resolveWorkspaceFileReferenceMock.mockResolvedValue({ - id: 'file-1', - name: 'icon.png', - folderPath: null, - type: 'image/png', - size: 1024, - key: 'workspace/ws-1/123-abc-icon.png', - }) - readWorkspaceFileContentMock.mockResolvedValue({ - file: { id: 'file-1', name: 'icon.png' }, - content: Buffer.from('png-bytes'), - }) - uploadFileMock.mockResolvedValue({ path: '/api/files/serve/s3/workspace-logos%2Ficon.png' }) - publishCustomBlockMock.mockResolvedValue(publishedBlock) - - const result = await executeDeployCustomBlock( - { - name: 'Enrich Lead', - exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }], - iconUrl: 'files/icon.png', - }, - context - ) - - expect(uploadFileMock).toHaveBeenCalledWith( - expect.objectContaining({ - context: 'workspace-logos', - contentType: 'image/png', - customKey: expect.stringMatching(/^workspace-logos\/\d+-[A-Za-z0-9_-]+-icon\.png$/), - preserveKey: true, - metadata: { workspaceId: 'ws-1', userId: 'user-1', originalName: 'icon.png' }, - }) - ) - expect(publishCustomBlockMock).toHaveBeenCalledWith( - expect.objectContaining({ iconUrl: '/api/files/serve/s3/workspace-logos%2Ficon.png' }) - ) - expect(result.success).toBe(true) - }) - - it('passes an external icon URL through without ingestion', async () => { - publishCustomBlockMock.mockResolvedValue(publishedBlock) - - const result = await executeDeployCustomBlock( - { - name: 'Enrich Lead', - exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }], - iconUrl: 'https://example.com/icon.png', - }, - context - ) - - expect(uploadFileMock).not.toHaveBeenCalled() - expect(publishCustomBlockMock).toHaveBeenCalledWith( - expect.objectContaining({ iconUrl: 'https://example.com/icon.png' }) - ) - expect(result.success).toBe(true) - }) - - it('fails when the icon workspace file does not exist', async () => { - resolveWorkspaceFileReferenceMock.mockRejectedValue(new Error('File not found')) - - const result = await executeDeployCustomBlock( - { - name: 'Enrich Lead', - exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }], - iconUrl: 'files/missing.png', - }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found') - expect(publishCustomBlockMock).not.toHaveBeenCalled() - }) - - it('rejects non-https icon URL schemes on pass-through', async () => { - const dataUri = await executeDeployCustomBlock( - { - name: 'Enrich Lead', - exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }], - iconUrl: 'data:image/svg+xml;base64,PHN2Zy8+', - }, - context - ) - expect(dataUri.success).toBe(false) - expect(dataUri.error).toContain('https') - - const plainHttp = await executeDeployCustomBlock( - { - name: 'Enrich Lead', - exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }], - iconUrl: 'http://example.com/icon.png', - }, - context - ) - expect(plainHttp.success).toBe(false) - expect(publishCustomBlockMock).not.toHaveBeenCalled() - - publishCustomBlockMock.mockResolvedValue(publishedBlock) - const servePath = await executeDeployCustomBlock( - { - name: 'Enrich Lead', - exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }], - iconUrl: '/api/files/serve/workspace-logos%2Ficon.png', - }, - context - ) - expect(servePath.success).toBe(true) - }) - - it('fails when the icon workspace file is not an image', async () => { - resolveWorkspaceFileReferenceMock.mockResolvedValue({ - id: 'file-2', - name: 'notes.pdf', - folderPath: null, - type: 'application/pdf', - size: 1024, - key: 'k', - }) - - const result = await executeDeployCustomBlock( - { - name: 'Enrich Lead', - exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'answer' }], - iconUrl: 'files/notes.pdf', - }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('image') - expect(uploadFileMock).not.toHaveBeenCalled() - }) - - it('fails when the workspace has no organization', async () => { - getWorkspaceWithOwnerMock.mockResolvedValue({ id: 'ws-1', organizationId: null }) - - const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('organization') - }) - - it('refuses to publish without curated outputs', async () => { - const result = await executeDeployCustomBlock({ name: 'Enrich Lead' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('exposedOutputs is required') - expect(publishCustomBlockMock).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts deleted file mode 100644 index 45158307b63..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { generateShortId } from '@sim/utils/id' -import { isAllowedCustomBlockIconUrl } from '@/lib/api/contracts/custom-blocks' -import { - executeCopilotFileUseCase, - resolveCopilotWorkspaceFileReference, -} from '@/lib/copilot/application/execute-file-use-case' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope' -import { canonicalizeVfsPath } from '@/lib/copilot/vfs/path-utils' -import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' -import { uploadFile } from '@/lib/uploads/core/storage-service' -import { isImageFileType } from '@/lib/uploads/utils/file-utils' -import { - CustomBlockValidationError, - type CustomBlockWithInputs, - deleteCustomBlock, - getCustomBlockWithInputsByWorkflowId, - isCustomBlocksDeploymentEnabled, - isCustomBlocksEligibleForOrganization, - publishCustomBlock, - updateCustomBlock, -} from '@/lib/workflows/custom-blocks/operations' -import { fileOperations } from '@/lib/workspace-files/application/operations' -import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' -import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' -import { ensureWorkflowAccess } from '../access' -import type { DeployCustomBlockParams } from '../param-types' - -const MAX_ICON_BYTES = 5 * 1024 * 1024 -const MAX_INPUT_ENTRIES = 50 -const MAX_OUTPUT_ENTRIES = 50 -const logger = createLogger('CopilotCustomBlockDeployment') - -/** - * Resolve the agent-supplied icon reference to a publicly servable URL. A VFS - * workspace-file path (`files/...`) is ingested: the file is copied into the - * world-readable `workspace-logos` storage context (the same context the icon - * upload UI writes to), because a raw workspace-file URL is membership-gated - * and the block's icon must render for org members in other workspaces. Any - * other non-empty value (an external or already-public URL) passes through. - */ -async function resolveIconUrl( - raw: string | undefined, - context: ExecutionContext, - workspaceId: string -): Promise { - const value = raw?.trim() - if (!value) return undefined - if (!value.startsWith('files/')) { - if (!isAllowedCustomBlockIconUrl(value)) { - throw new CustomBlockValidationError( - 'iconUrl must be an https URL, an internal /api/files/serve/ path, or a workspace file path (files/...)' - ) - } - return value - } - - const canonical = canonicalizeVfsPath(value) - const record = await resolveCopilotWorkspaceFileReference(context, fileOperations.readContent, { - workspaceId, - reference: canonical, - }).catch(() => { - throw new CustomBlockValidationError(`Icon file not found in this workspace: ${value}`) - }) - if (!isImageFileType(record.type)) { - throw new CustomBlockValidationError( - 'Icon file must be an image (PNG, JPEG, GIF, WebP, or SVG)' - ) - } - if (record.size > MAX_ICON_BYTES) { - throw new CustomBlockValidationError('Icon file must be 5MB or smaller') - } - const { content: buffer } = await executeCopilotFileUseCase( - context, - readWorkspaceFileContent, - { fileId: record.id, assertedWorkspaceId: workspaceId, maxBytes: MAX_ICON_BYTES }, - { fileId: record.id } - ) - const uploaded = await uploadFile({ - file: buffer, - fileName: record.name, - contentType: record.type, - context: 'workspace-logos', - customKey: `workspace-logos/${buildStorageKeySegment(`${Date.now()}-${generateShortId()}-`, record.name)}`, - preserveKey: true, - metadata: { workspaceId, userId: context.userId, originalName: record.name }, - }) - return uploaded.path -} - -function customBlockOutput(block: CustomBlockWithInputs, action: 'deploy' | 'undeploy') { - const isDeployed = action === 'deploy' - return { - workflowId: block.workflowId, - blockId: block.id, - blockType: block.type, - name: block.name, - action, - isDeployed, - removed: !isDeployed, - deploymentType: 'custom_block', - deploymentStatus: { - customBlock: { - isDeployed, - blockType: block.type, - name: block.name, - enabled: block.enabled, - }, - }, - deploymentConfig: { - customBlock: { - blockType: block.type, - name: block.name, - description: block.description, - iconUrl: block.iconUrl, - inputFields: block.inputFields, - exposedOutputs: block.exposedOutputs, - organizationId: block.organizationId, - }, - }, - } -} - -export async function executeDeployCustomBlock( - params: DeployCustomBlockParams, - context: ExecutionContext -): Promise { - try { - const workflowId = params.workflowId || context.workflowId - if (!workflowId) { - return { success: false, error: 'workflowId is required' } - } - const action = params.action === 'undeploy' ? 'undeploy' : 'deploy' - - let workflowRecord: Awaited>['workflow'] - try { - workflowRecord = (await ensureWorkflowAccess(workflowId, context.userId, 'admin')).workflow - } catch (error) { - const message = toError(error).message - if (message.includes('not found')) { - return { success: false, error: 'Workflow not found' } - } - return { - success: false, - error: "Managing a custom block requires admin permission on the workflow's workspace", - } - } - if (!workflowRecord.workspaceId) { - return { success: false, error: 'Workflow must belong to a workspace' } - } - let workspaceId: string - try { - workspaceId = requireCopilotWorkspace(context, workflowRecord.workspaceId) - } catch (error) { - return { success: false, error: toError(error).message } - } - - const ws = await getWorkspaceWithOwner(workspaceId) - const organizationId = ws?.organizationId - if (!organizationId) { - return { - success: false, - error: 'Publishing a block requires the workspace to belong to an organization', - } - } - if (!isCustomBlocksDeploymentEnabled()) { - return { success: false, error: 'Custom blocks are not enabled for this organization' } - } - const existing = await getCustomBlockWithInputsByWorkflowId(workflowId) - - if (action === 'undeploy') { - if (!existing) { - return { success: false, error: 'This workflow is not published as a custom block' } - } - await deleteCustomBlock(existing.id) - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.CUSTOM_BLOCK_DELETED, - resourceType: AuditResourceType.CUSTOM_BLOCK, - resourceId: existing.id, - resourceName: existing.name, - description: `Unpublished custom block "${existing.name}"`, - metadata: { organizationId, type: existing.type, workflowId, source: 'copilot' }, - }) - return { success: true, output: customBlockOutput(existing, 'undeploy') } - } - - const name = params.name?.trim() - const description = params.description?.trim() - if (name && name.length > 60) { - return { success: false, error: 'name must be 60 characters or fewer' } - } - if (description && description.length > 280) { - return { success: false, error: 'description must be 280 characters or fewer' } - } - if (params.inputs && params.inputs.length > MAX_INPUT_ENTRIES) { - return { success: false, error: `inputs must be ${MAX_INPUT_ENTRIES} entries or fewer` } - } - if (params.inputs?.some((entry) => !entry?.id?.trim())) { - return { success: false, error: 'each inputs entry requires the trigger field id' } - } - if (params.inputs?.some((entry) => (entry.placeholder?.length ?? 0) > 200)) { - return { success: false, error: 'input placeholders must be 200 characters or fewer' } - } - if (params.exposedOutputs && params.exposedOutputs.length > MAX_OUTPUT_ENTRIES) { - return { - success: false, - error: `exposedOutputs must be ${MAX_OUTPUT_ENTRIES} entries or fewer`, - } - } - if ( - params.exposedOutputs?.some( - (entry) => !entry?.blockId?.trim() || !entry?.path?.trim() || !entry?.name?.trim() - ) - ) { - return { - success: false, - error: 'each exposedOutputs entry requires blockId, path, and name', - } - } - if (params.exposedOutputs?.some((entry) => entry.name.length > 60)) { - return { success: false, error: 'exposed output names must be 60 characters or fewer' } - } - const iconUrl = await resolveIconUrl(params.iconUrl, context, workspaceId) - - if (existing) { - await updateCustomBlock(existing.id, { - name: name || undefined, - description, - iconUrl, - inputs: params.inputs, - exposedOutputs: params.exposedOutputs, - }) - const updated = await getCustomBlockWithInputsByWorkflowId(workflowId) - if (!updated) { - return { success: false, error: 'Custom block not found after update' } - } - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.CUSTOM_BLOCK_UPDATED, - resourceType: AuditResourceType.CUSTOM_BLOCK, - resourceId: updated.id, - resourceName: updated.name, - description: `Updated custom block "${updated.name}"`, - metadata: { organizationId, type: updated.type, workflowId, source: 'copilot' }, - }) - return { success: true, output: { ...customBlockOutput(updated, 'deploy'), updated: true } } - } - - if (!(await isCustomBlocksEligibleForOrganization(organizationId))) { - return { success: false, error: 'Custom blocks are not enabled for this organization' } - } - if (!name) { - return { success: false, error: 'name is required when publishing a new custom block' } - } - if (!workflowRecord.isDeployed) { - return { - success: false, - error: - 'Workflow must be deployed before publishing as a custom block. Use deploy_as_api first.', - } - } - // Curation is required on publish: every consumer-visible field must be one - // the publisher chose, since there is no whole-`result` fallback. - const exposedOutputs = params.exposedOutputs - if (!exposedOutputs || exposedOutputs.length === 0) { - return { - success: false, - error: - 'exposedOutputs is required: select at least one workflow output to expose to consumers', - } - } - - // `traceChildRuns` is deliberately not passed and must never become a - // parameter here: opening a block's runs to every consumer in the org exposes - // the source workflow's internals, and that is a decision for a human - // publisher in the settings UI, not one an agent makes on their behalf. - const block = await publishCustomBlock({ - organizationId, - workspaceId, - workflowId, - userId: context.userId, - name, - description: description ?? '', - iconUrl, - inputs: params.inputs, - exposedOutputs, - }) - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.CUSTOM_BLOCK_PUBLISHED, - resourceType: AuditResourceType.CUSTOM_BLOCK, - resourceId: block.id, - resourceName: block.name, - description: `Published custom block "${block.name}"`, - metadata: { organizationId, type: block.type, workflowId, source: 'copilot' }, - }) - return { success: true, output: { ...customBlockOutput(block, 'deploy'), updated: false } } - } catch (error) { - if (error instanceof CustomBlockValidationError) { - return { success: false, error: error.message } - } - logger.error('Custom block deployment failed', { error }) - return { - success: false, - error: - 'Publishing the custom block failed inside Sim; assume it was NOT published. Call get_deployment_status to confirm, retry once, and report the failure if it repeats instead of retrying further.', - } - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts deleted file mode 100644 index 87d0197e822..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -/** - * @vitest-environment node - */ -import { resetDbChainMock } from '@sim/testing' -import { getErrorMessage } from '@sim/utils/errors' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockCheckChatAccess, - mockEnsureWorkflowAccess, - mockPerformChatUndeploy, - mockPerformDeleteWorkflowMcpTool, - mockPerformFullDeploy, - mockPerformFullUndeploy, - mockExecuteCopilotMcpServerUseCase, - mockExecuteCopilotWorkflowUseCase, -} = vi.hoisted(() => ({ - mockCheckChatAccess: vi.fn(), - mockEnsureWorkflowAccess: vi.fn(), - mockPerformChatUndeploy: vi.fn(), - mockPerformDeleteWorkflowMcpTool: vi.fn(), - mockPerformFullDeploy: vi.fn(), - mockPerformFullUndeploy: vi.fn(), - mockExecuteCopilotMcpServerUseCase: vi.fn(), - mockExecuteCopilotWorkflowUseCase: vi.fn(), -})) - -vi.mock('@/lib/copilot/application/execute-mcp-server-use-case', () => ({ - executeCopilotMcpServerUseCase: mockExecuteCopilotMcpServerUseCase, -})) - -vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ - executeCopilotWorkflowUseCase: mockExecuteCopilotWorkflowUseCase, - messageForCopilotWorkflowError: (error: unknown, fallback: string) => - getErrorMessage(error, fallback), -})) - -vi.mock('@/lib/workflows/orchestration', () => ({ - performChatDeploy: vi.fn(), - performChatUndeploy: mockPerformChatUndeploy, - performFullDeploy: mockPerformFullDeploy, - performFullUndeploy: mockPerformFullUndeploy, -})) - -vi.mock('@/lib/mcp/orchestration', () => ({ - performCreateWorkflowMcpTool: vi.fn(), - performDeleteWorkflowMcpTool: mockPerformDeleteWorkflowMcpTool, - performUpdateWorkflowMcpTool: vi.fn(), -})) - -vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ - getDeployedWorkflowInputFormat: vi.fn(), -})) - -vi.mock('@/lib/mcp/workflow-tool-schema', () => ({ - applyDescriptionOverrides: vi.fn(), - generateToolInputSchema: vi.fn(), - sanitizeToolName: vi.fn(), -})) - -vi.mock('@/app/api/chat/utils', () => ({ - checkChatAccess: mockCheckChatAccess, - checkWorkflowAccessForChatCreation: vi.fn(), -})) - -vi.mock('@/ee/access-control/utils/permission-check', () => ({ - validateChatDeployAuth: vi.fn(), -})) - -vi.mock('@/lib/copilot/tools/handlers/access', () => ({ - ensureWorkflowAccess: mockEnsureWorkflowAccess, -})) - -import { - executeDeployApi, - executeDeployChat, - executeDeployMcp, - executeRedeploy, -} from '@/lib/copilot/tools/handlers/deployment/deploy' - -describe('deployment handlers', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - mockEnsureWorkflowAccess.mockResolvedValue({ - workflow: { id: 'workflow-1', workspaceId: 'workspace-1' }, - }) - }) - - it('undeploys the API without approval context when permission gating is disabled', async () => { - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ success: true }) - - const result = await executeDeployApi( - { workflowId: 'workflow-1', action: 'undeploy' }, - { - userId: 'user-1', - workflowId: 'workflow-1', - toolCallId: 'call-1', - } - ) - - expect(result.success).toBe(true) - expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'user-1' }), - expect.objectContaining({ operation: expect.objectContaining({ id: 'workflows.undeploy' }) }), - expect.objectContaining({ workflowId: 'workflow-1' }) - ) - }) - - it('uses the execution and deployment intent for semantic retry idempotency', async () => { - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ - success: true, - activeDeployment: null, - latestDeploymentAttempt: { status: 'preparing' }, - }) - - await executeDeployApi( - { - workflowId: 'workflow-1', - action: 'deploy', - versionName: 'Safe deploy', - versionDescription: 'Deploy the latest workflow changes', - }, - { - userId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - toolCallId: 'call-1', - } - ) - - expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ operation: expect.objectContaining({ id: 'workflows.deploy' }) }), - expect.objectContaining({ - idempotencyKey: 'copilot:execution-1:operation:deploy_as_api', - }) - ) - }) - - it('does not report an admitted deployment as successful before its version is active', async () => { - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ - success: true, - version: 12, - deploymentVersionId: 'version-12', - activeDeployment: { deploymentVersionId: 'version-11', version: 11 }, - latestDeploymentAttempt: { status: 'preparing', isCurrent: true }, - }) - - const result = await executeRedeploy( - { - workflowId: 'workflow-1', - versionName: 'Safe redeploy', - versionDescription: 'Redeploy the latest workflow changes', - }, - { - userId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - toolCallId: 'call-1', - } - ) - - expect(result).toMatchObject({ - success: false, - error: expect.stringContaining('not active'), - }) - expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ operation: expect.objectContaining({ id: 'workflows.deploy' }) }), - expect.objectContaining({ - idempotencyKey: 'copilot:execution-1:operation:deploy_as_api', - }) - ) - }) - - it('reports success only when the version admitted by this call is active', async () => { - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ - success: true, - version: 12, - deploymentVersionId: 'version-12', - activeDeployment: { deploymentVersionId: 'version-12', version: 12 }, - latestDeploymentAttempt: { status: 'active', isCurrent: true }, - }) - - const result = await executeDeployApi( - { - workflowId: 'workflow-1', - action: 'deploy', - versionName: 'Safe deploy', - versionDescription: 'Deploy the latest workflow changes', - }, - { - userId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - toolCallId: 'call-1', - } - ) - - expect(result).toMatchObject({ - success: true, - output: { - workflowId: 'workflow-1', - isDeployed: true, - version: 12, - lifecycleStatus: 'active', - }, - }) - }) - - it('rejects a replay whose active deployment attempt became historical', async () => { - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ - success: true, - activeDeployment: null, - latestDeploymentAttempt: { status: 'active', isCurrent: false }, - }) - - const result = await executeDeployApi( - { - workflowId: 'workflow-1', - action: 'deploy', - versionName: 'Safe deploy', - versionDescription: 'Deploy the latest workflow changes', - }, - { - userId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - toolCallId: 'call-1', - } - ) - - expect(result).toMatchObject({ - success: false, - error: expect.stringContaining('historical'), - }) - }) - - it('does not report a historical active attempt as a successful redeploy', async () => { - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ - success: true, - activeDeployment: null, - latestDeploymentAttempt: { status: 'active', isCurrent: false }, - }) - - const result = await executeRedeploy( - { - workflowId: 'workflow-1', - versionName: 'Safe redeploy', - versionDescription: 'Redeploy the latest workflow changes', - }, - { - userId: 'user-1', - workflowId: 'workflow-1', - executionId: 'execution-1', - toolCallId: 'call-1', - } - ) - - expect(result).toMatchObject({ - success: false, - error: expect.stringContaining('historical'), - }) - }) - - it('undeploys chat without approval context when permission gating is disabled', async () => { - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ - deployment: { - id: 'chat-1', - identifier: 'production-helper', - title: 'Production Helper', - description: null, - authType: 'public', - allowedEmails: [], - outputConfigs: [], - includeThinking: false, - includeToolCalls: false, - customizations: null, - }, - }) - - const result = await executeDeployChat( - { workflowId: 'workflow-1', action: 'undeploy' }, - { - userId: 'user-1', - workflowId: 'workflow-1', - toolCallId: 'call-1', - } - ) - - expect(result.success).toBe(true) - expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'user-1' }), - expect.objectContaining({ - operation: expect.objectContaining({ id: 'workflows.chat.undeploy' }), - }), - expect.objectContaining({ workflowId: 'workflow-1' }) - ) - }) - - it('undeploys MCP without approval context when permission gating is disabled', async () => { - mockExecuteCopilotMcpServerUseCase.mockResolvedValue({ - server: { id: 'server-1', name: 'Production MCP' }, - tool: { id: 'tool-1', toolName: 'run_workflow' }, - workflow: { id: 'workflow-1' }, - }) - - const result = await executeDeployMcp( - { workflowId: 'workflow-1', serverId: 'server-1', action: 'undeploy' }, - { - userId: 'user-1', - workflowId: 'workflow-1', - toolCallId: 'call-1', - } - ) - - expect(result.success).toBe(true) - expect(mockExecuteCopilotMcpServerUseCase).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'user-1' }), - expect.objectContaining({ - operation: expect.objectContaining({ - id: 'mcp_servers.workflow_deployments.undeploy_tool', - }), - }), - { serverId: 'server-1', workflowId: 'workflow-1' } - ) - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts deleted file mode 100644 index 3413077b490..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ /dev/null @@ -1,662 +0,0 @@ -import { executeCopilotMcpServerUseCase } from '@/lib/copilot/application/execute-mcp-server-use-case' -import { - executeCopilotWorkflowUseCase, - messageForCopilotWorkflowError, -} from '@/lib/copilot/application/execute-workflow-use-case' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { resolveEnvReferenceSecretArg } from '@/lib/copilot/tools/server/env-reference' -import { generateRequestId } from '@/lib/core/utils/request' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { - deployWorkflowMcpTool, - undeployWorkflowMcpTool, -} from '@/lib/mcp/application/workflow-deployments' -import { buildWorkflowMcpApiEndpoint, buildWorkflowMcpServerUrl } from '@/lib/mcp/urls' -import { - deployWorkflowChat, - undeployWorkflowChat, -} from '@/lib/workflows/application/chat-deployments' -import { deployWorkflow, undeployWorkflow } from '@/lib/workflows/application/deployments' -import type { DeployApiParams, DeployChatParams, DeployMcpParams } from '../param-types' -import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context' - -function buildWorkflowRunStatusEndpoint( - baseUrl: string, - apiEndpoint: string, - runId: string -): string { - if ( - !apiEndpoint.startsWith(`${baseUrl}/api/v2/workflows/`) || - !apiEndpoint.endsWith('/execute') - ) { - throw new Error(`Invalid workflow execution endpoint: ${apiEndpoint}`) - } - return `${apiEndpoint.slice(0, -'/execute'.length)}/runs/${runId}` -} - -function buildWorkflowApiConfig(baseUrl: string, apiEndpoint: string) { - return { - endpoint: apiEndpoint, - authentication: { - type: 'api_key', - acceptedHeaders: ['X-API-Key: YOUR_API_KEY', 'Authorization: Bearer YOUR_API_KEY'], - }, - modes: { - sync: { - method: 'POST', - transport: 'json', - stream: false, - body: { input: { key: 'value' } }, - }, - stream: { - method: 'POST', - transport: 'sse', - stream: true, - body: { stream: true, input: { key: 'value' } }, - }, - async: { - method: 'POST', - transport: 'json', - stream: false, - body: { async: true, input: { key: 'value' } }, - runStatusEndpointTemplate: buildWorkflowRunStatusEndpoint(baseUrl, apiEndpoint, '{runId}'), - }, - }, - } -} - -function buildWorkflowApiExamples(baseUrl: string, apiEndpoint: string) { - return { - sync: `curl -X POST "${apiEndpoint}" \\ - -H "Content-Type: application/json" \\ - -H "X-API-Key: YOUR_API_KEY" \\ - -d '{"input":{"key":"value"}}'`, - stream: `curl -N -X POST "${apiEndpoint}" \\ - -H "Content-Type: application/json" \\ - -H "X-API-Key: YOUR_API_KEY" \\ - -d '{"stream":true,"input":{"key":"value"}}'`, - async: `curl -X POST "${apiEndpoint}" \\ - -H "Content-Type: application/json" \\ - -H "X-API-Key: YOUR_API_KEY" \\ - -d '{"async":true,"input":{"key":"value"}}'`, - poll: `curl "${buildWorkflowRunStatusEndpoint(baseUrl, apiEndpoint, 'RUN_ID')}" \\ - -H "X-API-Key: YOUR_API_KEY"`, - } -} - -/** Returns an error until this call's admitted version is the active production version. */ -function getUnconfirmedDeploymentError( - result: Awaited>, - action: string -): string | null { - const attempt = result.latestDeploymentAttempt - const activeVersionId = result.activeDeployment?.deploymentVersionId - if ( - attempt?.status === 'active' && - result.deploymentVersionId !== undefined && - activeVersionId === result.deploymentVersionId - ) { - return null - } - - const versionLabel = result.version === undefined ? '' : ` v${result.version}` - const status = attempt?.status ?? 'unknown' - const detail = result.warnings?.[0] ?? `${action}${versionLabel} is ${status}, not active.` - return `${detail} Do not submit a new deployment; check the existing attempt's status.` -} - -function buildMcpClientExamples(serverName: string, serverUrl: string) { - return { - cursor: { - mcpServers: { - [serverName]: { - url: serverUrl, - headers: { 'X-API-Key': 'YOUR_API_KEY' }, - }, - }, - }, - claudeCode: `claude mcp add ${serverName} --url "${serverUrl}" --header "X-API-Key: YOUR_API_KEY"`, - claudeDesktop: { - mcpServers: { - [serverName]: { - command: 'npx', - args: ['-y', 'mcp-remote', serverUrl, '--header', 'X-API-Key:YOUR_API_KEY'], - }, - }, - }, - vscode: { - mcp: { - servers: { - [serverName]: { - type: 'http', - url: serverUrl, - headers: { 'X-API-Key': 'YOUR_API_KEY' }, - }, - }, - }, - }, - } -} - -export async function executeDeployApi( - params: DeployApiParams, - context: ExecutionContext -): Promise { - try { - const workflowId = params.workflowId || context.workflowId - if (!workflowId) { - return { success: false, error: 'workflowId is required' } - } - const action = params.action === 'undeploy' ? 'undeploy' : 'deploy' - if (action === 'undeploy') { - const result = await executeCopilotWorkflowUseCase(context, undeployWorkflow, { - workflowId, - assertedWorkspaceId: context.workspaceId, - requestId: generateRequestId(), - }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to undeploy workflow' } - } - const baseUrl = getBaseUrl() - const apiEndpoint = buildWorkflowMcpApiEndpoint(workflowId) - return { - success: true, - output: { - workflowId, - isDeployed: false, - apiEndpoint, - baseUrl, - deploymentType: 'api', - deploymentStatus: { - api: { - isDeployed: false, - endpoint: apiEndpoint, - }, - }, - deploymentConfig: { - api: buildWorkflowApiConfig(baseUrl, apiEndpoint), - }, - examples: { - api: { - curl: buildWorkflowApiExamples(baseUrl, apiEndpoint), - }, - }, - }, - } - } - - const versionDescription = params.versionDescription?.trim() - if (!versionDescription) { - return { - success: false, - error: - 'versionDescription is required when deploying. Provide a concise summary of what changed in this deployment version (call diff_workflows with ref1 "live" and ref2 "draft" if unsure what changed).', - } - } - - const versionName = params.versionName?.trim() - if (!versionName) { - return { - success: false, - error: - 'versionName is required when deploying. Provide a short human-readable label for this deployment version.', - } - } - - const result = await executeCopilotWorkflowUseCase(context, deployWorkflow, { - workflowId, - assertedWorkspaceId: context.workspaceId, - description: versionDescription, - name: versionName, - requestId: generateRequestId(), - idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_as_api'), - }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to deploy workflow' } - } - const historicalAttemptError = getHistoricalDeploymentAttemptError( - result.latestDeploymentAttempt, - 'deploy' - ) - if (historicalAttemptError) return { success: false, error: historicalAttemptError } - const unconfirmedDeploymentError = getUnconfirmedDeploymentError(result, 'Deployment') - if (unconfirmedDeploymentError) { - return { success: false, error: unconfirmedDeploymentError } - } - - const baseUrl = getBaseUrl() - const apiEndpoint = buildWorkflowMcpApiEndpoint(workflowId) - const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint) - const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint) - const isDeployed = Boolean(result.activeDeployment) - return { - success: true, - output: { - workflowId, - isDeployed, - deployedAt: result.deployedAt, - version: result.version, - lifecycleStatus: result.latestDeploymentAttempt?.status ?? null, - readiness: result.latestDeploymentAttempt?.readiness ?? null, - error: result.latestDeploymentAttempt?.error ?? null, - warnings: result.warnings ?? [], - apiEndpoint, - baseUrl, - deploymentType: 'api', - deploymentStatus: { - api: { - isDeployed, - endpoint: apiEndpoint, - deployedAt: result.deployedAt, - version: result.version, - }, - }, - deploymentConfig: { - api: apiConfig, - }, - examples: { - api: { - curl: apiExamples, - }, - }, - }, - } - } catch (error) { - return { - success: false, - error: messageForCopilotWorkflowError(error, 'Failed to update API deployment'), - } - } -} - -export async function executeDeployChat( - params: DeployChatParams, - context: ExecutionContext -): Promise { - try { - const workflowId = params.workflowId || context.workflowId - if (!workflowId) { - return { success: false, error: 'workflowId is required' } - } - - const action = params.action === 'undeploy' ? 'undeploy' : 'deploy' - if (action === 'undeploy') { - const { deployment } = await executeCopilotWorkflowUseCase(context, undeployWorkflowChat, { - workflowId, - assertedWorkspaceId: context.workspaceId, - }) - const baseUrl = getBaseUrl() - const apiEndpoint = buildWorkflowMcpApiEndpoint(workflowId) - const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint) - const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint) - return { - success: true, - output: { - workflowId, - success: true, - action: 'undeploy', - isDeployed: true, - isChatDeployed: false, - deploymentType: 'chat', - apiEndpoint, - baseUrl, - deploymentStatus: { - api: { - isDeployed: true, - endpoint: apiEndpoint, - }, - chat: { - isDeployed: false, - identifier: deployment.identifier, - title: deployment.title, - }, - }, - deploymentConfig: { - api: apiConfig, - chat: { - identifier: deployment.identifier, - title: deployment.title, - description: deployment.description || '', - authType: deployment.authType, - allowedEmails: (deployment.allowedEmails as string[]) || [], - outputConfigs: - (deployment.outputConfigs as Array<{ blockId: string; path: string }>) || [], - includeThinking: deployment.includeThinking ?? false, - includeToolCalls: deployment.includeToolCalls ?? false, - welcomeMessage: - (deployment.customizations as { welcomeMessage?: string } | null)?.welcomeMessage || - 'Hi there! How can I help you today?', - }, - }, - examples: { - api: { - curl: apiExamples, - }, - }, - }, - } - } - - const versionDescription = params.versionDescription?.trim() - if (!versionDescription) { - return { - success: false, - error: - 'versionDescription is required when deploying. Provide a concise summary of what changed in this deployment version (distinct from the chat-facing description; call diff_workflows with ref1 "live" and ref2 "draft" if unsure).', - } - } - - const versionName = params.versionName?.trim() - if (!versionName) { - return { - success: false, - error: - 'versionName is required when deploying. Provide a short human-readable label for this deployment version (distinct from the chat title).', - } - } - - // "Use the password in {{CHAT_PW}}" arrives as the literal reference — - // resolve it, or the placeholder string becomes the chat's real password. - const resolvedPassword = await resolveEnvReferenceSecretArg({ - userId: context.userId, - workspaceId: context.workspaceId, - value: params.password ?? undefined, - argName: 'password', - registry: context.resolvedSecretTraceRegistry, - }) - if (resolvedPassword.error) { - return { success: false, error: resolvedPassword.error } - } - - const result = await executeCopilotWorkflowUseCase(context, deployWorkflowChat, { - workflowId, - assertedWorkspaceId: context.workspaceId, - identifier: params.identifier, - title: params.title, - description: params.description, - versionDescription, - versionName, - customizations: { - primaryColor: params.customizations?.primaryColor, - welcomeMessage: params.welcomeMessage ?? params.customizations?.welcomeMessage, - imageUrl: params.customizations?.imageUrl ?? params.customizations?.iconUrl, - }, - authType: params.authType, - password: resolvedPassword.value, - allowedEmails: params.allowedEmails, - outputConfigs: params.outputConfigs, - includeThinking: params.includeThinking, - includeToolCalls: params.includeToolCalls, - requestId: generateRequestId(), - idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_as_chat'), - }) - - const baseUrl = getBaseUrl() - const apiEndpoint = buildWorkflowMcpApiEndpoint(workflowId) - const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint) - const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint) - return { - success: true, - output: { - workflowId, - success: true, - action: 'deploy', - isDeployed: true, - isChatDeployed: true, - identifier: result.identifier, - chatUrl: result.chatUrl, - apiEndpoint, - baseUrl, - deployedAt: result.deployedAt || null, - version: result.version, - deploymentType: 'chat', - deploymentStatus: { - api: { - isDeployed: true, - endpoint: apiEndpoint, - deployedAt: result.deployedAt || null, - version: result.version, - }, - chat: { - isDeployed: true, - identifier: result.identifier, - chatUrl: result.chatUrl, - title: result.title, - description: result.description, - authType: result.authType, - }, - }, - deploymentConfig: { - api: apiConfig, - chat: { - identifier: result.identifier, - chatUrl: result.chatUrl, - title: result.title, - description: result.description, - authType: result.authType, - allowedEmails: result.allowedEmails, - outputConfigs: result.outputConfigs, - includeThinking: result.includeThinking, - includeToolCalls: result.includeToolCalls, - ...result.customizations, - }, - }, - examples: { - chat: { - open: result.chatUrl, - }, - api: { - curl: apiExamples, - }, - }, - }, - } - } catch (error) { - return { - success: false, - error: messageForCopilotWorkflowError(error, 'Failed to update chat deployment'), - } - } -} - -export async function executeDeployMcp( - params: DeployMcpParams, - context: ExecutionContext -): Promise { - try { - const workflowId = params.workflowId || context.workflowId - if (!workflowId) { - return { success: false, error: 'workflowId is required' } - } - - const serverId = params.serverId - if (!serverId) { - return { - success: false, - error: 'serverId is required. Use list_workspace_mcp_servers to get available servers.', - } - } - if (params.action === 'undeploy') { - const result = await executeCopilotMcpServerUseCase(context, undeployWorkflowMcpTool, { - serverId, - workflowId, - }) - return { - success: true, - output: { - workflowId, - serverId, - serverName: result.server.name, - action: 'undeploy', - removed: true, - deploymentType: 'mcp', - deploymentStatus: { - mcp: { - isDeployed: false, - serverId, - serverName: result.server.name, - }, - }, - }, - } - } - - const result = await executeCopilotMcpServerUseCase(context, deployWorkflowMcpTool, { - serverId, - workflowId, - toolName: params.toolName, - toolDescription: params.toolDescription, - parameterDescriptions: params.parameterDescriptions, - }) - const baseUrl = getBaseUrl() - const mcpServerUrl = buildWorkflowMcpServerUrl(serverId) - const apiEndpoint = buildWorkflowMcpApiEndpoint(workflowId) - const clientExamples = buildMcpClientExamples(result.server.name, mcpServerUrl) - const toolId = result.tool.id - const toolName = result.tool.toolName - const toolDescription = result.tool.toolDescription - - return { - success: true, - output: { - toolId, - toolName, - toolDescription, - updated: result.updated, - mcpServerUrl, - baseUrl, - serverId, - serverName: result.server.name, - deploymentType: 'mcp', - apiEndpoint, - deploymentStatus: { - api: { - isDeployed: true, - endpoint: apiEndpoint, - }, - mcp: { - isDeployed: true, - serverId, - serverName: result.server.name, - toolId, - toolName, - updated: result.updated, - }, - }, - deploymentConfig: { - mcp: { - serverId, - serverName: result.server.name, - serverUrl: mcpServerUrl, - toolId, - toolName, - toolDescription, - parameterSchema: result.parameterSchema, - authentication: { - type: 'api_key', - header: 'X-API-Key: YOUR_API_KEY', - }, - }, - }, - examples: { - mcp: clientExamples, - }, - }, - } - } catch (error) { - return { - success: false, - error: messageForCopilotWorkflowError(error, 'Failed to update MCP deployment'), - } - } -} - -export async function executeRedeploy( - params: { workflowId?: string; versionDescription?: string; versionName?: string }, - context: ExecutionContext -): Promise { - try { - const workflowId = params.workflowId || context.workflowId - if (!workflowId) { - return { success: false, error: 'workflowId is required' } - } - const versionDescription = params.versionDescription?.trim() - if (!versionDescription) { - return { - success: false, - error: - 'versionDescription is required. Provide a concise summary of what changed in this deployment version (call diff_workflows with ref1 "live" and ref2 "draft" if unsure what changed).', - } - } - const versionName = params.versionName?.trim() - if (!versionName) { - return { - success: false, - error: - 'versionName is required. Provide a short human-readable label for this deployment version.', - } - } - const result = await executeCopilotWorkflowUseCase(context, deployWorkflow, { - workflowId, - assertedWorkspaceId: context.workspaceId, - description: versionDescription, - name: versionName, - requestId: generateRequestId(), - idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'deploy_as_api'), - }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to redeploy workflow' } - } - const historicalAttemptError = getHistoricalDeploymentAttemptError( - result.latestDeploymentAttempt, - 'redeploy' - ) - if (historicalAttemptError) return { success: false, error: historicalAttemptError } - const unconfirmedDeploymentError = getUnconfirmedDeploymentError(result, 'Redeployment') - if (unconfirmedDeploymentError) { - return { success: false, error: unconfirmedDeploymentError } - } - const baseUrl = getBaseUrl() - const apiEndpoint = buildWorkflowMcpApiEndpoint(workflowId) - const apiConfig = buildWorkflowApiConfig(baseUrl, apiEndpoint) - const apiExamples = buildWorkflowApiExamples(baseUrl, apiEndpoint) - const isDeployed = Boolean(result.activeDeployment) - return { - success: true, - output: { - workflowId, - isDeployed, - deployedAt: result.deployedAt || null, - version: result.version, - lifecycleStatus: result.latestDeploymentAttempt?.status ?? null, - readiness: result.latestDeploymentAttempt?.readiness ?? null, - error: result.latestDeploymentAttempt?.error ?? null, - warnings: result.warnings ?? [], - apiEndpoint, - baseUrl, - deploymentType: 'api', - deploymentStatus: { - api: { - isDeployed, - endpoint: apiEndpoint, - deployedAt: result.deployedAt || null, - version: result.version, - }, - }, - deploymentConfig: { - api: apiConfig, - }, - examples: { - api: { - curl: apiExamples, - }, - }, - }, - } - } catch (error) { - return { - success: false, - error: messageForCopilotWorkflowError(error, 'Failed to redeploy workflow'), - } - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts deleted file mode 100644 index f5e5081d156..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.test.ts +++ /dev/null @@ -1,513 +0,0 @@ -/** - * @vitest-environment node - */ - -import { - auditMock, - resetDbChainMock, - workflowsOrchestrationMock, - workflowsOrchestrationMockFns, -} from '@sim/testing' -import { getErrorMessage } from '@sim/utils/errors' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import type { ExecutionContext } from '@/lib/copilot/request/types' - -const { ensureWorkflowAccessMock, checkNeedsRedeploymentMock, mockExecuteCopilotWorkflowUseCase } = - vi.hoisted(() => ({ - ensureWorkflowAccessMock: vi.fn(), - checkNeedsRedeploymentMock: vi.fn(), - mockExecuteCopilotWorkflowUseCase: vi.fn(), - })) - -vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ - executeCopilotWorkflowUseCase: mockExecuteCopilotWorkflowUseCase, - messageForCopilotWorkflowError: (error: unknown, fallback: string) => - getErrorMessage(error, fallback), -})) - -const performRevertToVersionMock = workflowsOrchestrationMockFns.mockPerformRevertToVersion -const performActivateVersionMock = workflowsOrchestrationMockFns.mockPerformActivateVersion -const getWorkflowDeploymentSummaryMock = - workflowsOrchestrationMockFns.mockGetWorkflowDeploymentSummary - -const { resolveWorkflowStateRefMock, generateWorkflowDiffSummaryMock, listWorkflowVersionsMock } = - vi.hoisted(() => ({ - resolveWorkflowStateRefMock: vi.fn(), - generateWorkflowDiffSummaryMock: vi.fn(), - listWorkflowVersionsMock: vi.fn(), - })) - -vi.mock('@sim/audit', () => auditMock) - -vi.mock('@/lib/mcp/pubsub', () => ({ - mcpPubSub: { - publishWorkflowToolsChanged: vi.fn(), - }, -})) - -vi.mock('@/lib/mcp/workflow-mcp-sync', () => ({ - generateParameterSchemaForWorkflow: vi.fn(), -})) - -vi.mock('@/lib/mcp/workflow-tool-schema', () => ({ - sanitizeToolName: vi.fn((value: string) => value), -})) - -vi.mock('@/lib/workflows/triggers/trigger-utils.server', () => ({ - hasValidStartBlock: vi.fn(), -})) - -vi.mock('../access', () => ({ - ensureWorkflowAccess: ensureWorkflowAccessMock, - ensureWorkspaceAccess: vi.fn(), -})) - -vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock) - -vi.mock('./state-refs', () => ({ - parseWorkflowRef: (value: number | string) => (value === 'live' ? 'active' : value), - resolveWorkflowStateRef: resolveWorkflowStateRefMock, -})) - -vi.mock('@/lib/workflows/comparison', () => ({ - generateWorkflowDiffSummary: generateWorkflowDiffSummaryMock, -})) - -vi.mock('@/lib/workflows/deployment-status', () => ({ - checkNeedsRedeployment: checkNeedsRedeploymentMock, -})) - -vi.mock('@/lib/workflows/persistence/utils', () => ({ - listWorkflowVersions: listWorkflowVersionsMock, - updateDeploymentVersionMetadata: vi.fn(), -})) - -import { - executeCheckDeploymentStatus, - executeDiffWorkflows, - executeGetDeploymentLog, - executeLoadDeployment, - executePromoteToLive, -} from './manage' - -afterAll(() => { - resetDbChainMock() -}) - -describe('executeLoadDeployment', () => { - beforeEach(() => { - vi.clearAllMocks() - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' }, - }) - }) - - it('loads a version into the draft via performRevertToVersion', async () => { - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ lastSaved: 12345 }) - - const result = await executeLoadDeployment({ workflowId: 'wf-1', version: 7 }, { - userId: 'user-1', - workflowId: 'wf-1', - } as ExecutionContext) - - expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'user-1' }), - expect.objectContaining({ - operation: expect.objectContaining({ id: 'workflows.versions.revert' }), - }), - expect.objectContaining({ workflowId: 'wf-1', version: 7 }) - ) - expect(result).toEqual({ - success: true, - output: { - workflowId: 'wf-1', - message: 'Loaded version 7 into the workflow draft', - lastSaved: 12345, - }, - }) - }) - - it('maps "live" to the active version', async () => { - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ lastSaved: 1 }) - - await executeLoadDeployment({ workflowId: 'wf-1', version: 'live' }, { - userId: 'user-1', - workflowId: 'wf-1', - } as ExecutionContext) - - expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( - expect.any(Object), - expect.any(Object), - expect.objectContaining({ version: 'active' }) - ) - }) - - it('rejects "draft"', async () => { - const result = await executeLoadDeployment({ workflowId: 'wf-1', version: 'draft' }, { - userId: 'user-1', - workflowId: 'wf-1', - } as ExecutionContext) - - expect(result.success).toBe(false) - expect(performRevertToVersionMock).not.toHaveBeenCalled() - }) - - it('returns shared helper failures directly', async () => { - mockExecuteCopilotWorkflowUseCase.mockRejectedValue(new Error('Deployment version not found')) - - const result = await executeLoadDeployment({ workflowId: 'wf-1', version: 7 }, { - userId: 'user-1', - workflowId: 'wf-1', - } as ExecutionContext) - - expect(result).toEqual({ success: false, error: 'Deployment version not found' }) - }) -}) - -describe('executePromoteToLive', () => { - beforeEach(() => { - vi.clearAllMocks() - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' }, - }) - }) - - it('promotes a version via performActivateVersion', async () => { - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ - success: true, - deployedAt: new Date('2026-05-30T00:00:00.000Z'), - activeDeployment: { - deploymentVersionId: 'dv-3', - version: 3, - deployedAt: '2026-05-30T00:00:00.000Z', - }, - latestDeploymentAttempt: { - id: 'op-1', - deploymentVersionId: 'dv-3', - version: 3, - action: 'activate', - status: 'active', - isCurrent: true, - readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' }, - requestedAt: '2026-05-30T00:00:00.000Z', - activatedAt: '2026-05-30T00:00:00.000Z', - error: null, - }, - }) - - const result = await executePromoteToLive({ workflowId: 'wf-1', version: 3 }, { - userId: 'user-1', - workflowId: 'wf-1', - executionId: 'execution-1', - toolCallId: 'call-1', - } as ExecutionContext) - - expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'user-1' }), - expect.objectContaining({ - operation: expect.objectContaining({ id: 'workflows.versions.activate' }), - }), - expect.objectContaining({ - workflowId: 'wf-1', - version: 3, - idempotencyKey: 'copilot:execution-1:operation:promote_to_live', - }) - ) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - workflowId: 'wf-1', - version: 3, - message: 'Promoted version 3 to live', - lifecycleStatus: 'active', - error: null, - }) - }) - - it('does not report a historical active operation as a successful promotion', async () => { - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ - success: true, - activeDeployment: null, - latestDeploymentAttempt: { - id: 'op-old', - deploymentVersionId: 'dv-3', - version: 3, - action: 'activate', - status: 'active', - isCurrent: false, - readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' }, - requestedAt: '2026-05-30T00:00:00.000Z', - activatedAt: '2026-05-30T00:00:00.000Z', - error: null, - }, - }) - - const result = await executePromoteToLive({ workflowId: 'wf-1', version: 3 }, { - userId: 'user-1', - workflowId: 'wf-1', - executionId: 'execution-1', - toolCallId: 'call-1', - } as ExecutionContext) - - expect(result).toMatchObject({ - success: false, - error: expect.stringContaining('historical'), - }) - }) - - it('rejects a non-numeric version like "live"', async () => { - const result = await executePromoteToLive({ workflowId: 'wf-1', version: 'live' as never }, { - userId: 'user-1', - workflowId: 'wf-1', - } as ExecutionContext) - - expect(result.success).toBe(false) - expect(performActivateVersionMock).not.toHaveBeenCalled() - }) -}) - -describe('executeGetDeploymentLog', () => { - beforeEach(() => { - vi.clearAllMocks() - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' }, - }) - }) - - it('returns versions from the shared listWorkflowVersions helper', async () => { - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ - versions: [ - { - id: 'v2', - version: 2, - name: null, - description: null, - isActive: true, - createdAt: new Date('2026-05-30T00:00:00.000Z'), - createdBy: 'user-1', - deployedByName: 'Waleed', - }, - { - id: 'v1', - version: 1, - name: 'first', - description: 'initial', - isActive: false, - createdAt: new Date('2026-05-29T00:00:00.000Z'), - createdBy: null, - deployedByName: null, - }, - ], - }) - - const result = await executeGetDeploymentLog({ workflowId: 'wf-1' }, { - userId: 'user-1', - workflowId: 'wf-1', - } as ExecutionContext) - - expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'user-1' }), - expect.objectContaining({ - operation: expect.objectContaining({ id: 'workflows.versions.list' }), - }), - expect.objectContaining({ workflowId: 'wf-1' }) - ) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - workflowId: 'wf-1', - count: 2, - versions: [ - { id: 'v2', version: 2, isActive: true }, - { id: 'v1', version: 1, name: 'first', description: 'initial', isActive: false }, - ], - }) - }) -}) - -describe('executeDiffWorkflows', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('diffs ref2 against ref1 and returns the structured summary', async () => { - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ - references: [ - { state: { base: true }, ref: '1', version: 1, isActive: false }, - { state: { target: true }, ref: 'live', version: 2, isActive: true }, - ], - }) - - const summary = { - addedBlocks: [], - removedBlocks: [], - modifiedBlocks: [], - edgeChanges: { added: 0, removed: 0, addedDetails: [], removedDetails: [] }, - loopChanges: { added: 0, removed: 0, modified: 0 }, - parallelChanges: { added: 0, removed: 0, modified: 0 }, - variableChanges: { - added: 0, - removed: 0, - modified: 0, - addedNames: [], - removedNames: [], - modifiedNames: [], - }, - hasChanges: false, - } - generateWorkflowDiffSummaryMock.mockReturnValue(summary) - - const result = await executeDiffWorkflows({ workflowId: 'wf-1', ref1: 1, ref2: 'live' }, { - userId: 'user-1', - workflowId: 'wf-1', - } as ExecutionContext) - - expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'user-1' }), - expect.objectContaining({ - operation: expect.objectContaining({ id: 'workflows.versions.compare_references' }), - }), - expect.objectContaining({ workflowId: 'wf-1', references: [1, 'active'] }) - ) - // ref1 = base/previous, ref2 = target/current. - expect(generateWorkflowDiffSummaryMock).toHaveBeenCalledWith({ target: true }, { base: true }) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - workflowId: 'wf-1', - ref1: { ref: '1', version: 1 }, - ref2: { ref: 'live', version: 2, isActive: true }, - diff: { hasChanges: false }, - }) - }) -}) - -describe('executeCheckDeploymentStatus', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' }, - }) - checkNeedsRedeploymentMock.mockResolvedValue(false) - getWorkflowDeploymentSummaryMock.mockResolvedValue({ - activeDeployment: null, - latestDeploymentAttempt: null, - warnings: [], - chatDeployment: null, - mcpTools: [], - mcpToolsTruncated: false, - }) - }) - - it('uses the shared redeployment freshness helper for deployed APIs', async () => { - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ - workflow: { id: 'wf-1', workspaceId: 'ws-1', deployedAt: new Date('2026-05-28') }, - workspaceId: 'ws-1', - isDeployed: true, - needsRedeployment: true, - activeDeployment: { - deploymentVersionId: 'dv-1', - version: 1, - deployedAt: '2026-05-28T00:00:00.000Z', - }, - latestDeploymentAttempt: null, - warnings: [], - chatDeployment: null, - mcpTools: [], - mcpToolsTruncated: false, - }) - const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, { - userId: 'user-1', - workflowId: 'wf-1', - } as ExecutionContext) - - expect(mockExecuteCopilotWorkflowUseCase).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'user-1' }), - expect.objectContaining({ - operation: expect.objectContaining({ id: 'workflows.deployment_overview.read' }), - }), - expect.objectContaining({ workflowId: 'wf-1' }) - ) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - isDeployed: true, - api: { - isDeployed: true, - needsRedeployment: true, - }, - }) - }) - - it('does not check redeployment freshness for undeployed APIs', async () => { - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ - workflow: { id: 'wf-1', workspaceId: 'ws-1', deployedAt: null }, - workspaceId: 'ws-1', - isDeployed: false, - needsRedeployment: false, - activeDeployment: null, - latestDeploymentAttempt: null, - warnings: [], - chatDeployment: null, - mcpTools: [], - mcpToolsTruncated: false, - }) - - const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, { - userId: 'user-1', - workflowId: 'wf-1', - } as ExecutionContext) - - expect(checkNeedsRedeploymentMock).not.toHaveBeenCalled() - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - isDeployed: false, - api: { - isDeployed: false, - needsRedeployment: false, - }, - }) - }) - - it('separates a historical active attempt from the current undeployed state', async () => { - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ - workflow: { id: 'wf-1', workspaceId: 'ws-1', deployedAt: null }, - workspaceId: 'ws-1', - isDeployed: false, - needsRedeployment: false, - activeDeployment: null, - latestDeploymentAttempt: { - id: 'op-historical', - deploymentVersionId: 'dv-old', - version: 1, - action: 'deploy', - status: 'active', - isCurrent: false, - readiness: { webhooks: 'ready', schedules: 'ready', mcp: 'ready' }, - requestedAt: '2026-05-28T00:00:00.000Z', - activatedAt: '2026-05-28T00:00:00.000Z', - error: null, - }, - warnings: ['The latest successful deployment attempt is historical.'], - chatDeployment: null, - mcpTools: [], - mcpToolsTruncated: false, - }) - const result = await executeCheckDeploymentStatus({ workflowId: 'wf-1' }, { - userId: 'user-1', - workflowId: 'wf-1', - } as ExecutionContext) - - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - isDeployed: false, - api: { - isDeployed: false, - activeDeployment: null, - latestDeploymentAttempt: { - status: 'active', - isCurrent: false, - }, - currentDeploymentAttempt: null, - warnings: [expect.stringContaining('historical')], - }, - }) - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts deleted file mode 100644 index 156d83d2a8a..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ /dev/null @@ -1,538 +0,0 @@ -import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' -import { executeCopilotMcpServerUseCase } from '@/lib/copilot/application/execute-mcp-server-use-case' -import { - executeCopilotWorkflowUseCase, - messageForCopilotWorkflowError, -} from '@/lib/copilot/application/execute-workflow-use-case' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope' -import { generateRequestId } from '@/lib/core/utils/request' -import { - createWorkflowMcpDeploymentServer, - deleteWorkflowMcpDeploymentServer, - listWorkflowMcpDeployments, - updateWorkflowMcpDeploymentServer, -} from '@/lib/mcp/application/workflow-deployments' -import { - activateWorkflowVersion, - revertWorkflowVersion, - updateWorkflowVersion, -} from '@/lib/workflows/application/deployments' -import { listWorkflowVersions } from '@/lib/workflows/application/list-workflow-versions' -import { readWorkflowDeploymentOverview } from '@/lib/workflows/application/read-workflow-deployment-overview' -import { readWorkflowStateReferences } from '@/lib/workflows/application/read-workflow-state-references' -import { generateWorkflowDiffSummary } from '@/lib/workflows/comparison' -import type { - CheckDeploymentStatusParams, - CreateWorkspaceMcpServerParams, - DeleteWorkspaceMcpServerParams, - DiffWorkflowsParams, - GetDeploymentLogParams, - ListWorkspaceMcpServersParams, - LoadDeploymentParams, - PromoteToLiveParams, - UpdateDeploymentVersionParams, - UpdateWorkspaceMcpServerParams, -} from '../param-types' -import { getCopilotDeploymentIdempotencyKey, getHistoricalDeploymentAttemptError } from './context' -import { parseWorkflowRef } from './state-refs' - -export async function executeCheckDeploymentStatus( - params: CheckDeploymentStatusParams, - context: ExecutionContext -): Promise { - try { - const workflowId = params.workflowId || context.workflowId - if (!workflowId) { - return { success: false, error: 'workflowId is required' } - } - const deployment = await executeCopilotWorkflowUseCase( - context, - readWorkflowDeploymentOverview, - { - workflowId, - assertedWorkspaceId: context.workspaceId, - } - ) - const workflowRecord = deployment.workflow - - /** - * Deployed means an active version snapshot exists; the legacy - * `workflow.isDeployed` flag is not consulted so this can never - * contradict the attached `activeDeployment` summary. - */ - const isApiDeployed = deployment.isDeployed - const currentDeploymentAttempt = deployment.latestDeploymentAttempt?.isCurrent - ? deployment.latestDeploymentAttempt - : null - const apiDetails = { - isDeployed: isApiDeployed, - deployedAt: workflowRecord.deployedAt || null, - endpoint: isApiDeployed ? `/api/workflows/${workflowId}/execute` : null, - apiKey: workflowRecord.workspaceId ? 'Workspace API keys' : 'Personal API keys', - needsRedeployment: deployment.needsRedeployment, - activeDeployment: deployment.activeDeployment, - latestDeploymentAttempt: deployment.latestDeploymentAttempt, - currentDeploymentAttempt, - warnings: deployment.warnings ?? [], - } - - const chatDeploy = deployment.chatDeployment - const isChatDeployed = chatDeploy !== null - const chatCustomizations = - (chatDeploy?.customizations as - | { welcomeMessage?: string; primaryColor?: string } - | undefined) || {} - const chatDetails = { - isDeployed: isChatDeployed, - chatId: chatDeploy?.id || null, - identifier: chatDeploy?.identifier || null, - chatUrl: isChatDeployed ? `/chat/${chatDeploy?.identifier}` : null, - title: chatDeploy?.title || null, - description: chatDeploy?.description || null, - authType: chatDeploy?.authType || null, - allowedEmails: chatDeploy?.allowedEmails || null, - outputConfigs: chatDeploy?.outputConfigs || null, - includeThinking: chatDeploy?.includeThinking ?? false, - includeToolCalls: chatDeploy?.includeToolCalls ?? false, - welcomeMessage: chatCustomizations.welcomeMessage || null, - primaryColor: chatCustomizations.primaryColor || null, - hasPassword: Boolean(chatDeploy?.password), - } - - const mcpDetails: { - isDeployed: boolean - servers: Array<{ - serverId: string - serverName: string - toolName: string - toolDescription: string | null - parameterSchema: unknown - toolId: string - }> - truncated: boolean - } = { - isDeployed: false, - servers: [], - truncated: deployment.mcpToolsTruncated, - } - if (deployment.mcpTools.length > 0) { - mcpDetails.isDeployed = true - mcpDetails.servers = deployment.mcpTools - } - - const isDeployed = apiDetails.isDeployed || chatDetails.isDeployed || mcpDetails.isDeployed - return { - success: true, - output: { isDeployed, api: apiDetails, chat: chatDetails, mcp: mcpDetails }, - } - } catch (error) { - return { - success: false, - error: messageForCopilotWorkflowError(error, 'Failed to check deployment status'), - } - } -} - -export async function executeListWorkspaceMcpServers( - params: ListWorkspaceMcpServersParams, - context: ExecutionContext -): Promise { - try { - const workspaceId = requireCopilotWorkspace(context, params.workspaceId) - const result = await executeCopilotMcpServerUseCase(context, listWorkflowMcpDeployments, { - workspaceId, - }) - return { - success: true, - output: { - servers: result.servers, - count: result.servers.length, - truncated: result.truncated, - }, - } - } catch (error) { - return { success: false, error: messageForCopilotApplicationError(error) } - } -} - -export async function executeCreateWorkspaceMcpServer( - params: CreateWorkspaceMcpServerParams, - context: ExecutionContext -): Promise { - try { - const workspaceId = requireCopilotWorkspace(context, params.workspaceId) - - const name = params.name?.trim() - if (!name) { - return { success: false, error: 'name is required' } - } - - const result = await executeCopilotMcpServerUseCase( - context, - createWorkflowMcpDeploymentServer, - { - workspaceId, - name, - description: params.description, - isPublic: params.isPublic, - workflowIds: params.workflowIds, - } - ) - - return { success: true, output: { server: result.server, addedTools: result.addedTools } } - } catch (error) { - return { success: false, error: messageForCopilotApplicationError(error) } - } -} - -export async function executeUpdateWorkspaceMcpServer( - params: UpdateWorkspaceMcpServerParams, - context: ExecutionContext -): Promise { - try { - const serverId = params.serverId - if (!serverId) { - return { success: false, error: 'serverId is required' } - } - - const updates: { name?: string; description?: string | null; isPublic?: boolean } = {} - if (typeof params.name === 'string') { - const name = params.name.trim() - if (!name) return { success: false, error: 'name cannot be empty' } - updates.name = name - } - if (typeof params.description === 'string') { - updates.description = params.description.trim() || null - } - if (typeof params.isPublic === 'boolean') { - updates.isPublic = params.isPublic - } - - if (Object.keys(updates).length === 0) { - return { success: false, error: 'At least one of name, description, or isPublic is required' } - } - - await executeCopilotMcpServerUseCase(context, updateWorkflowMcpDeploymentServer, { - serverId, - ...updates, - }) - - return { success: true, output: { serverId, ...updates } } - } catch (error) { - return { success: false, error: messageForCopilotApplicationError(error) } - } -} - -export async function executeDeleteWorkspaceMcpServer( - params: DeleteWorkspaceMcpServerParams, - context: ExecutionContext -): Promise { - try { - const serverId = params.serverId - if (!serverId) { - return { success: false, error: 'serverId is required' } - } - - const result = await executeCopilotMcpServerUseCase( - context, - deleteWorkflowMcpDeploymentServer, - { - serverId, - } - ) - - return { success: true, output: { serverId, name: result.server.name, deleted: true } } - } catch (error) { - return { success: false, error: messageForCopilotApplicationError(error) } - } -} - -export async function executeGetDeploymentLog( - params: GetDeploymentLogParams, - context: ExecutionContext -): Promise { - try { - const workflowId = params.workflowId || context.workflowId - if (!workflowId) { - return { success: false, error: 'workflowId is required' } - } - const { versions: rows } = await executeCopilotWorkflowUseCase(context, listWorkflowVersions, { - workflowId, - assertedWorkspaceId: context.workspaceId, - }) - - const versions = rows.map((r) => ({ - id: r.id, - version: r.version, - name: r.name ?? undefined, - description: r.description ?? undefined, - isActive: r.isActive, - latestOperationStatus: r.latestOperationStatus ?? undefined, - createdAt: r.createdAt.toISOString(), - createdBy: r.createdBy ?? undefined, - })) - - return { success: true, output: { workflowId, count: versions.length, versions } } - } catch (error) { - return { - success: false, - error: messageForCopilotWorkflowError(error, 'Failed to list deployment versions'), - } - } -} - -// Cap individual sub-block before/after values so a large diff can't blow the -// tool-result budget. Oversized values are replaced with an elision marker. -const MAX_DIFF_VALUE_BYTES = 2000 - -function guardDiffValue(value: unknown): unknown { - try { - const json = JSON.stringify(value) - if (json && json.length > MAX_DIFF_VALUE_BYTES) { - return { elided: true, bytes: json.length } - } - } catch { - return { elided: true, reason: 'unserializable' } - } - return value -} - -export async function executeDiffWorkflows( - params: DiffWorkflowsParams, - context: ExecutionContext -): Promise { - try { - const workflowId = params.workflowId || context.workflowId - if (!workflowId) { - return { success: false, error: 'workflowId is required' } - } - if (params.ref1 === undefined || params.ref2 === undefined) { - return { success: false, error: 'ref1 and ref2 are required' } - } - - const { references } = await executeCopilotWorkflowUseCase( - context, - readWorkflowStateReferences, - { - workflowId, - assertedWorkspaceId: context.workspaceId, - references: [parseWorkflowRef(params.ref1), parseWorkflowRef(params.ref2)], - } - ) - const [side1, side2] = references - - // ref1 = base/previous, ref2 = target/current: added = present in ref2 only. - const summary = generateWorkflowDiffSummary(side2.state, side1.state) - const diff = { - ...summary, - modifiedBlocks: summary.modifiedBlocks.map((block) => ({ - ...block, - changes: block.changes.map((change) => ({ - field: change.field, - oldValue: guardDiffValue(change.oldValue), - newValue: guardDiffValue(change.newValue), - })), - })), - } - - return { - success: true, - output: { - workflowId, - ref1: { ref: side1.ref, version: side1.version, isActive: side1.isActive }, - ref2: { ref: side2.ref, version: side2.version, isActive: side2.isActive }, - diff, - }, - } - } catch (error) { - return { - success: false, - error: messageForCopilotWorkflowError(error, 'Failed to compare workflow versions'), - } - } -} - -function resolveLoadVersion( - raw: number | string -): { ok: true; version: number | 'active' } | { ok: false; error: string } { - if (typeof raw === 'number' && Number.isFinite(raw)) return { ok: true, version: raw } - if (typeof raw === 'string') { - const t = raw.trim().toLowerCase() - if (t === 'live' || t === 'active') return { ok: true, version: 'active' } - if (t === 'draft' || t === 'current') { - return { - ok: false, - error: 'Cannot load "draft" — load_deployment restores a deployed version into the draft', - } - } - if (/^\d+$/.test(t)) return { ok: true, version: Number.parseInt(t, 10) } - } - return { - ok: false, - error: `Invalid version "${String(raw)}": expected a version number or "live"`, - } -} - -export async function executeLoadDeployment( - params: LoadDeploymentParams, - context: ExecutionContext -): Promise { - try { - const workflowId = params.workflowId || context.workflowId - if (!workflowId) { - return { success: false, error: 'workflowId is required' } - } - if (params.version === undefined || params.version === null) { - return { success: false, error: 'version is required' } - } - const target = resolveLoadVersion(params.version) - if (!target.ok) { - return { success: false, error: target.error } - } - - const result = await executeCopilotWorkflowUseCase(context, revertWorkflowVersion, { - workflowId, - assertedWorkspaceId: context.workspaceId, - version: target.version, - }) - - const label = target.version === 'active' ? 'the live deployment' : `version ${target.version}` - return { - success: true, - output: { - workflowId, - message: `Loaded ${label} into the workflow draft`, - lastSaved: result.lastSaved, - }, - } - } catch (error) { - return { - success: false, - error: messageForCopilotWorkflowError(error, 'Failed to load deployment'), - } - } -} - -function normalizePromoteVersion(raw: number | string): number | null { - if (typeof raw === 'number' && Number.isFinite(raw)) return raw - if (typeof raw === 'string' && /^\d+$/.test(raw.trim())) return Number.parseInt(raw.trim(), 10) - return null -} - -export async function executePromoteToLive( - params: PromoteToLiveParams, - context: ExecutionContext -): Promise { - try { - const workflowId = params.workflowId || context.workflowId - if (!workflowId) { - return { success: false, error: 'workflowId is required' } - } - if (params.version === undefined || params.version === null) { - return { success: false, error: 'version is required' } - } - const version = normalizePromoteVersion(params.version) - if (version === null) { - return { - success: false, - error: - 'version must be a deployment version number (use load_deployment to change the draft; "live" is already live)', - } - } - - const result = await executeCopilotWorkflowUseCase(context, activateWorkflowVersion, { - workflowId, - assertedWorkspaceId: context.workspaceId, - version, - transition: 'activate', - requestId: generateRequestId(), - idempotencyKey: getCopilotDeploymentIdempotencyKey(context, 'promote_to_live'), - }) - - if (!result.success) { - return { success: false, error: result.error || 'Failed to promote version' } - } - const historicalAttemptError = getHistoricalDeploymentAttemptError( - result.latestDeploymentAttempt, - 'promotion' - ) - if (historicalAttemptError) return { success: false, error: historicalAttemptError } - - const isActive = result.activeDeployment?.version === version - if (!isActive) { - const detail = - result.warnings?.[0] ?? - `Promotion of version ${version} is ${result.latestDeploymentAttempt?.status ?? 'unknown'}, not active.` - return { - success: false, - error: `${detail} Do not submit a new promotion; check the existing attempt's status.`, - } - } - return { - success: true, - output: { - workflowId, - version, - message: `Promoted version ${version} to live`, - deployedAt: result.deployedAt ? new Date(result.deployedAt).toISOString() : undefined, - lifecycleStatus: result.latestDeploymentAttempt?.status ?? null, - readiness: result.latestDeploymentAttempt?.readiness ?? null, - error: result.latestDeploymentAttempt?.error ?? null, - warnings: result.warnings, - }, - } - } catch (error) { - return { - success: false, - error: messageForCopilotWorkflowError(error, 'Failed to promote deployment version'), - } - } -} - -export async function executeUpdateDeploymentVersion( - params: UpdateDeploymentVersionParams, - context: ExecutionContext -): Promise { - try { - const workflowId = params.workflowId || context.workflowId - if (!workflowId) { - return { success: false, error: 'workflowId is required' } - } - if (params.version === undefined || params.version === null) { - return { success: false, error: 'version is required' } - } - const version = normalizePromoteVersion(params.version) - if (version === null) { - return { - success: false, - error: - 'version must be a deployment version number (use list_deployment_versions to find it)', - } - } - - const name = typeof params.name === 'string' ? params.name.trim() : undefined - const description = - typeof params.description === 'string' ? params.description.trim() : undefined - if (name === undefined && description === undefined) { - return { success: false, error: 'Provide a name and/or description to update' } - } - - const updated = await executeCopilotWorkflowUseCase(context, updateWorkflowVersion, { - workflowId, - assertedWorkspaceId: context.workspaceId, - version, - ...(name !== undefined ? { name: name || null } : {}), - ...(description !== undefined ? { description: description || null } : {}), - }) - return { - success: true, - output: { workflowId, version, name: updated.name, description: updated.description }, - } - } catch (error) { - return { - success: false, - error: messageForCopilotWorkflowError(error, 'Failed to update deployment version'), - } - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts b/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts deleted file mode 100644 index 84774331fd9..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/deployment/state-refs.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { - ResolvedWorkflowStateReference, - WorkflowStateReference, -} from '@/lib/workflows/application/read-workflow-state-references' - -/** Canonical workflow-state selector: a deployment version number, the live - * (active) deployment, or the current draft. */ -export type WorkflowRef = WorkflowStateReference -export type ResolvedWorkflowRef = ResolvedWorkflowStateReference - -/** - * Parse a raw ref param into a canonical WorkflowRef. - * Accepts a version number, a numeric string, "live"/"active", or "draft"/"current". - * Throws on anything else. - */ -export function parseWorkflowRef(raw: unknown): WorkflowRef { - if (typeof raw === 'number' && Number.isFinite(raw)) return raw - if (typeof raw === 'string') { - const trimmed = raw.trim().toLowerCase() - if (trimmed === 'live' || trimmed === 'active') return 'live' - if (trimmed === 'draft' || trimmed === 'current') return 'draft' - if (/^\d+$/.test(trimmed)) return Number.parseInt(trimmed, 10) - } - throw new Error(`Invalid ref "${String(raw)}": expected a version number, "live", or "draft"`) -} diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts deleted file mode 100644 index 48af183a29e..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ /dev/null @@ -1,1212 +0,0 @@ -/** - * @vitest-environment node - */ - -import { encryptionMock, encryptionMockFns } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import { - MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, - PRIVATE_SECRET_PROVENANCE_FIELD, -} from '@/lib/execution/private-tool-metadata' - -const { - mockGetTableById, - mockListTables, - mockGetOrCreateTableSnapshot, - mockDownloadFile, - mockGeneratePresignedDownloadUrl, - mockHasCloudStorage, - mockExecuteTool, - mockListWorkspaceFiles, - mockFindWorkspaceFileRecord, - mockFetchWorkspaceFileBuffer, - mockFetchServableWorkspaceFileBuffer, - mockGetSandboxWorkspaceFilePath, - mockListWorkspaceFileFolders, - mockListAllWorkspaceFiles, - mockListWorkspaceFileFoldersOperation, - mockDownloadWorkspaceFileRecord, - mockReadWorkspaceFileContent, - mockMaterializeCopilotCodeSecrets, - mockHasWorkspaceSandboxAccess, - mockImportWorkspaceFileSecretProvenanceForRuntime, - mockGetTableSnapshotModelMountSafety, -} = vi.hoisted(() => ({ - mockGetTableById: vi.fn(), - mockListTables: vi.fn(), - mockGetOrCreateTableSnapshot: vi.fn(), - mockDownloadFile: vi.fn(), - mockGeneratePresignedDownloadUrl: vi.fn(), - mockHasCloudStorage: vi.fn(), - mockExecuteTool: vi.fn(), - mockListWorkspaceFiles: vi.fn(), - mockFindWorkspaceFileRecord: vi.fn(), - mockFetchWorkspaceFileBuffer: vi.fn(), - mockFetchServableWorkspaceFileBuffer: vi.fn(), - mockGetSandboxWorkspaceFilePath: vi.fn(), - mockListWorkspaceFileFolders: vi.fn(), - mockListAllWorkspaceFiles: vi.fn(), - mockListWorkspaceFileFoldersOperation: vi.fn(), - mockDownloadWorkspaceFileRecord: vi.fn(), - mockReadWorkspaceFileContent: vi.fn(), - mockMaterializeCopilotCodeSecrets: vi.fn(), - mockHasWorkspaceSandboxAccess: vi.fn(), - mockImportWorkspaceFileSecretProvenanceForRuntime: vi.fn(), - mockGetTableSnapshotModelMountSafety: vi.fn(), -})) - -vi.mock('@/lib/core/security/encryption', () => encryptionMock) -vi.mock('@/lib/table/service', () => ({ - getTableById: mockGetTableById, - listTables: mockListTables, -})) -vi.mock('@/lib/table/rows/secret-provenance', () => ({ - getTableSnapshotModelMountSafety: mockGetTableSnapshotModelMountSafety, -})) -vi.mock('@/lib/table/snapshot-cache', () => ({ - getOrCreateTableSnapshot: mockGetOrCreateTableSnapshot, - SNAPSHOT_MAX_BYTES: 500 * 1024 * 1024, -})) -vi.mock('@/lib/uploads/core/storage-service', () => ({ - downloadFile: mockDownloadFile, - generatePresignedDownloadUrl: mockGeneratePresignedDownloadUrl, - hasCloudStorage: mockHasCloudStorage, -})) -vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer, - findWorkspaceFileRecord: mockFindWorkspaceFileRecord, - getSandboxWorkspaceFilePath: mockGetSandboxWorkspaceFilePath, - listWorkspaceFiles: mockListWorkspaceFiles, -})) -vi.mock('@/lib/workspace-files/application/fetch-servable-workspace-file-buffer', () => ({ - fetchAuthorizedServableWorkspaceFileBuffer: mockFetchServableWorkspaceFileBuffer, -})) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ - listWorkspaceFileFolders: mockListWorkspaceFileFolders, -})) -vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ - listAllWorkspaceFiles: { execute: mockListAllWorkspaceFiles }, -})) -vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ - listWorkspaceFileFoldersOperation: { execute: mockListWorkspaceFileFoldersOperation }, -})) -vi.mock('@/lib/workspace-files/application/read-workspace-file-record', () => ({ - downloadWorkspaceFileRecord: { execute: mockDownloadWorkspaceFileRecord }, -})) -vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ - readWorkspaceFileContent: { execute: mockReadWorkspaceFileContent }, -})) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ - importWorkspaceFileSecretProvenanceForRuntime: mockImportWorkspaceFileSecretProvenanceForRuntime, -})) -vi.mock('@/lib/copilot/vfs/path-utils', () => ({ - decodeVfsPathSegments: (p: string) => p.split('/'), - encodeVfsPathSegments: (s: string[]) => s.join('/'), -})) -vi.mock('@/lib/copilot/tools/secret-mount-materializer.server', () => ({ - CopilotCodeSecretAccessError: class CopilotCodeSecretAccessError extends Error {}, - materializeCopilotCodeSecrets: mockMaterializeCopilotCodeSecrets, -})) -vi.mock('@/lib/billing/core/subscription', () => ({ - hasWorkspaceSandboxAccess: mockHasWorkspaceSandboxAccess, -})) -vi.mock('@/lib/execution/remote-sandbox/entitlement', () => ({ - MAX_PLAN_REQUIRED: 'Sim sandboxes require an active Max or Enterprise plan.', -})) - -import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' -import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute' -import { executeRunCode } from '@/lib/copilot/tools/handlers/run-code' -import { SNAPSHOT_MAX_BYTES } from '@/lib/table/snapshot-cache' -import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' - -const table = { - id: 'tbl_1', - workspaceId: 'ws_1', - rowCount: 1, - schema: { columns: [{ id: 'col_name', name: 'name', type: 'string' }] }, -} - -const context = { - workspaceId: 'ws_1', - userId: 'u1', - copilotToolExecution: true, - toolCallId: 'function-execute-test', -} - -function mountedFiles() { - const params = mockExecuteTool.mock.calls[0][1] as { - _sandboxFiles?: Array<{ path: string; type?: string; content?: string; url?: string }> - } - return params._sandboxFiles ?? [] -} - -function resetExecutionMocks(): void { - vi.clearAllMocks() - mockExecuteTool.mockReset() - mockMaterializeCopilotCodeSecrets.mockReset() - mockGetTableSnapshotModelMountSafety.mockReset() - mockGetTableSnapshotModelMountSafety.mockResolvedValue('safe') - mockListWorkspaceFiles.mockResolvedValue([]) - mockListWorkspaceFileFolders.mockResolvedValue([]) - mockListAllWorkspaceFiles.mockImplementation(async () => { - const files = await mockListWorkspaceFiles() - if (files.length > 0) return { files } - const fallback = mockFindWorkspaceFileRecord() - return { files: fallback ? [fallback] : [] } - }) - mockListWorkspaceFileFoldersOperation.mockImplementation(async () => ({ - folders: await mockListWorkspaceFileFolders(), - })) - mockDownloadWorkspaceFileRecord.mockImplementation( - async ({ input }: { input: { fileId: string } }) => { - const files = await mockListWorkspaceFiles() - const file = - files.find((candidate: { id: string }) => candidate.id === input.fileId) ?? - mockFindWorkspaceFileRecord() - if (!file) throw new Error('File not found') - return { file } - } - ) - mockReadWorkspaceFileContent.mockImplementation( - async ({ input }: { input: { fileId: string } }) => { - const files = await mockListWorkspaceFiles() - const file = - files.find((candidate: { id: string }) => candidate.id === input.fileId) ?? - mockFindWorkspaceFileRecord() - if (!file) throw new Error('File not found') - return { file, content: await mockFetchWorkspaceFileBuffer(file) } - } - ) -} - -describe('executeFunctionExecute trace-secret provenance', () => { - beforeEach(() => { - resetExecutionMocks() - mockExecuteTool.mockResolvedValue({ success: true }) - mockMaterializeCopilotCodeSecrets.mockResolvedValue({ envVars: {}, catalogEntries: [] }) - mockHasWorkspaceSandboxAccess.mockResolvedValue(true) - encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'secret-value' }) - }) - - it('runs Assistant compute without workspace secrets even if a caller supplies a secret actor', async () => { - await executeFunctionExecute( - { code: 'return 6 * 7', envVars: { LEAK: 'forged' } }, - { - userId: 'u1', - workflowId: '', - workspaceId: 'ws_1', - requestMode: 'assistant', - secretActorUserId: 'u1', - } - ) - expect(mockMaterializeCopilotCodeSecrets).not.toHaveBeenCalled() - expect(mockExecuteTool).toHaveBeenCalledWith( - 'function_execute', - expect.objectContaining({ envVars: {}, mountedSecrets: [] }), - expect.anything() - ) - }) - - it.each([ - { secrets: ['API_KEY'] }, - { inputTables: ['table'] }, - { inputs: { tables: ['table'] } }, - { outputTable: { name: 'output' } }, - { code: 'return {{API_KEY}}' }, - ])('refuses Assistant table and secret access before executing code: %j', async (params) => { - await expect( - executeFunctionExecute( - { code: 'return 1', ...params }, - { - userId: 'u1', - workflowId: '', - workspaceId: 'ws_1', - requestMode: 'assistant', - secretActorUserId: 'u1', - } - ) - ).rejects.toThrow() - expect(mockExecuteTool).not.toHaveBeenCalled() - expect(mockMaterializeCopilotCodeSecrets).not.toHaveBeenCalled() - }) - - it('mounts only explicit references and imports active provenance out of band', async () => { - mockMaterializeCopilotCodeSecrets.mockResolvedValue({ - envVars: { API_KEY: 'secret-value' }, - catalogEntries: [ - { - name: 'API_KEY', - plaintext: 'secret-value', - encryptedValue: 'encrypted-secret-value', - }, - ], - }) - mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => { - options.resolvedSecretTraceRegistry.recordResolved('API_KEY', 'secret-value') - return { success: true, output: { result: 'secret-value' } } - }) - const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( - [ - { - name: 'API_KEY', - plaintext: 'secret-value', - encryptedValue: 'encrypted-secret-value', - }, - ], - { userId: 'u1', workspaceId: 'ws_1' } - ) - const runtimeResult = await executeFunctionExecute( - { - code: 'return {{API_KEY}}', - envVars: { ATTACKER_KEY: 'attacker-value' }, - secretScope: 'all', - mountedSecrets: ['ATTACKER_KEY'], - _context: { resolvedSecretTraceRegistry: 'attacker-value' }, - }, - { - userId: 'u1', - workflowId: '', - workspaceId: 'ws_1', - resolvedSecretTraceRegistry, - } - ) - - expect(mockExecuteTool).toHaveBeenCalledWith( - 'function_execute', - expect.objectContaining({ - envVars: { API_KEY: 'secret-value' }, - secretScope: 'selected', - mountedSecrets: ['API_KEY'], - _context: expect.not.objectContaining({ resolvedSecretTraceRegistry: expect.anything() }), - }), - expect.objectContaining({ - resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), - operationContext: expect.objectContaining({ userId: 'u1', workspaceId: 'ws_1' }), - }) - ) - const appParams = mockExecuteTool.mock.calls[0]?.[1] as Record - expect(JSON.stringify(appParams)).not.toContain('resolvedSecretTraceRegistry') - expect(runtimeResult).toEqual({ success: true, output: { result: 'secret-value' } }) - expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([ - { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, - ]) - }) - - it('does not mount direct environment-map or shell-variable access', async () => { - await executeFunctionExecute( - { code: 'return environmentVariables.API_KEY + "$API_KEY"' }, - { userId: 'u1', workflowId: '', workspaceId: 'ws_1' } - ) - - expect(mockMaterializeCopilotCodeSecrets).not.toHaveBeenCalled() - expect(mockExecuteTool).toHaveBeenCalledWith( - 'function_execute', - expect.objectContaining({ envVars: {}, secretScope: 'selected', mountedSecrets: [] }), - expect.objectContaining({ - resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), - operationContext: expect.objectContaining({ userId: 'u1', workspaceId: 'ws_1' }), - }) - ) - }) - - it.each([ - { - language: 'javascript', - code: 'const matcher = /^{{PATTERN}}$/i; return "Bearer {{TOKEN}}" // {{COMMENT}}', - names: ['PATTERN', 'TOKEN'], - }, - { - language: 'python', - code: 'value = "{{TOKEN}}"\n# {{COMMENT}}\n__sim_result__ = value', - names: ['TOKEN'], - }, - { - language: 'shell', - code: "cat <<'PAYLOAD'\nBearer {{TOKEN}}\n$HOME\nPAYLOAD\n# {{COMMENT}}", - names: ['TOKEN'], - }, - ])( - 'uses the shared $language compiler analysis before delegating source to run_function', - async ({ language, code, names }) => { - await executeFunctionExecute({ language, code }, context as never) - - expect(mockMaterializeCopilotCodeSecrets).toHaveBeenCalledWith({ - actorUserId: 'u1', - workspaceId: 'ws_1', - requestedNames: names, - }) - expect(mockExecuteTool).toHaveBeenCalledWith( - 'function_execute', - expect.objectContaining({ code, language, mountedSecrets: names }), - expect.objectContaining({ - resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), - operationContext: expect.objectContaining({ userId: 'u1', workspaceId: 'ws_1' }), - }) - ) - } - ) - - it('routes run_code shell commands through the same run_function boundary', async () => { - const code = 'printf %s "{{CLI_TOKEN}}"' - const abortController = new AbortController() - - await executeRunCode( - { language: 'shell', code }, - { - ...context, - workflowId: '', - sandboxProfile: 'mothership', - abortSignal: abortController.signal, - } - ) - - expect(mockMaterializeCopilotCodeSecrets).toHaveBeenCalledWith({ - actorUserId: 'u1', - workspaceId: 'ws_1', - requestedNames: ['CLI_TOKEN'], - }) - expect(mockExecuteTool).toHaveBeenCalledWith( - 'function_execute', - expect.objectContaining({ code, language: 'shell', mountedSecrets: ['CLI_TOKEN'] }), - expect.objectContaining({ - resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), - operationContext: expect.objectContaining({ userId: 'u1', workspaceId: 'ws_1' }), - internalSandboxProfile: 'mothership', - signal: abortController.signal, - }) - ) - }) - - it('uses the trusted Mothership profile for run_function without accepting a param override', async () => { - await executeFunctionExecute( - { - code: 'return 1', - sandboxProfile: 'attacker', - _context: { sandboxProfile: 'attacker' }, - }, - { ...context, workflowId: '', sandboxProfile: 'mothership' } - ) - - expect(mockExecuteTool).toHaveBeenCalledWith( - 'function_execute', - expect.objectContaining({ - _context: expect.not.objectContaining({ sandboxProfile: expect.anything() }), - }), - expect.objectContaining({ - resolvedSecretTraceRegistry: expect.any(ResolvedSecretTraceRegistry), - operationContext: expect.objectContaining({ userId: 'u1', workspaceId: 'ws_1' }), - internalSandboxProfile: 'mothership', - }) - ) - expect(mockExecuteTool.mock.calls[0]?.[1]).not.toHaveProperty('sandboxProfile') - }) - - it('passes an entitled Sim sandbox selection through to the shared function executor', async () => { - await executeFunctionExecute( - { code: 'import pandas', language: 'python', sandboxId: ' sandbox-1 ' }, - { ...context, workflowId: '', sandboxProfile: 'mothership' } - ) - - expect(mockHasWorkspaceSandboxAccess).toHaveBeenCalledWith('ws_1') - expect(mockExecuteTool).toHaveBeenCalledWith( - 'function_execute', - expect.objectContaining({ sandboxId: 'sandbox-1' }), - expect.objectContaining({ internalSandboxProfile: 'mothership' }) - ) - }) - - it('rejects a Sim sandbox selection when the workspace is not entitled', async () => { - mockHasWorkspaceSandboxAccess.mockResolvedValue(false) - - await expect( - executeFunctionExecute( - { code: 'return 1', sandboxId: 'sandbox-1' }, - { ...context, workflowId: '', sandboxProfile: 'mothership' } - ) - ).rejects.toThrow('Max or Enterprise') - expect(mockExecuteTool).not.toHaveBeenCalled() - }) - - it('returns the raw runtime result when provenance import fails', async () => { - mockMaterializeCopilotCodeSecrets.mockResolvedValue({ - envVars: { API_KEY: 'secret-value' }, - catalogEntries: [ - { - name: 'API_KEY', - plaintext: 'secret-value', - encryptedValue: 'encrypted-secret-value', - }, - ], - }) - const runtimeResult = { success: true, output: { result: 'secret-value' } } - mockExecuteTool.mockResolvedValue(runtimeResult) - const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry([], { - userId: 'u1', - workspaceId: 'ws_1', - }) - vi.spyOn(resolvedSecretTraceRegistry, 'importProvenance').mockRejectedValueOnce( - new Error('provenance import failed') - ) - - await expect( - executeFunctionExecute( - { code: 'return {{API_KEY}}' }, - { - userId: 'u1', - workflowId: '', - workspaceId: 'ws_1', - resolvedSecretTraceRegistry, - } - ) - ).resolves.toBe(runtimeResult) - expect(resolvedSecretTraceRegistry.isComplete()).toBe(false) - }) - - it('does not let pending sibling materialization poison independent model projection', async () => { - let completeMaterialization: ((value: unknown) => void) | undefined - mockMaterializeCopilotCodeSecrets.mockReturnValueOnce( - new Promise((resolve) => { - completeMaterialization = resolve - }) - ) - const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( - [ - { - name: 'API_KEY', - plaintext: 'secret-value', - encryptedValue: 'encrypted-secret-value', - }, - ], - { userId: 'u1', workspaceId: 'ws_1' } - ) - mockExecuteTool.mockImplementationOnce(async (_toolId, _params, options) => { - expect(resolvedSecretTraceRegistry.isComplete()).toBe(false) - options.resolvedSecretTraceRegistry.recordResolved('API_KEY', 'secret-value') - return { success: true, output: { result: 'secret-value' } } - }) - - const execution = executeFunctionExecute( - { code: 'return {{API_KEY}}' }, - { - userId: 'u1', - workflowId: '', - workspaceId: 'ws_1', - resolvedSecretTraceRegistry, - } - ) - - await vi.waitFor(() => expect(mockMaterializeCopilotCodeSecrets).toHaveBeenCalledOnce()) - expect(resolvedSecretTraceRegistry.isComplete()).toBe(false) - expect( - projectToolResultForCopilot( - { success: true, output: { result: 'secret-value' } }, - resolvedSecretTraceRegistry - ) - ).toEqual({ success: true, output: { result: 'secret-value' } }) - - completeMaterialization?.({ - envVars: { API_KEY: 'secret-value' }, - catalogEntries: [ - { - name: 'API_KEY', - plaintext: 'secret-value', - encryptedValue: 'encrypted-secret-value', - }, - ], - }) - await execution - - expect(resolvedSecretTraceRegistry.isComplete()).toBe(true) - expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([ - { plaintext: 'secret-value', replacement: '{{API_KEY}}' }, - ]) - expect( - projectToolResultForCopilot( - { success: true, output: { result: 'secret-value' } }, - resolvedSecretTraceRegistry - ) - ).toEqual({ success: true, output: { result: '{{API_KEY}}' } }) - expect(mockExecuteTool).toHaveBeenCalledOnce() - }) - - it('does not activate a mounted reference when the Function route rejects before resolution', async () => { - mockMaterializeCopilotCodeSecrets.mockResolvedValue({ - envVars: { API_KEY: 'secret-value' }, - catalogEntries: [ - { - name: 'API_KEY', - plaintext: 'secret-value', - encryptedValue: 'encrypted-secret-value', - }, - ], - }) - mockExecuteTool.mockResolvedValueOnce({ - success: false, - error: 'Too many sandbox output files requested', - }) - const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( - [ - { - name: 'API_KEY', - plaintext: 'secret-value', - encryptedValue: 'encrypted-secret-value', - }, - ], - { userId: 'u1', workspaceId: 'ws_1' } - ) - - await expect( - executeFunctionExecute( - { code: 'return {{API_KEY}}' }, - { - userId: 'u1', - workflowId: '', - workspaceId: 'ws_1', - resolvedSecretTraceRegistry, - } - ) - ).resolves.toEqual({ - success: false, - error: 'Too many sandbox output files requested', - }) - - expect(resolvedSecretTraceRegistry.isComplete()).toBe(true) - expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([]) - }) - - it('releases pending provenance without activation when mounting is denied', async () => { - mockMaterializeCopilotCodeSecrets.mockRejectedValueOnce(new Error('mount denied')) - const resolvedSecretTraceRegistry = new ResolvedSecretTraceRegistry( - [ - { - name: 'API_KEY', - plaintext: 'secret-value', - encryptedValue: 'encrypted-secret-value', - }, - ], - { userId: 'u1', workspaceId: 'ws_1' } - ) - - await expect( - executeFunctionExecute( - { code: 'return {{API_KEY}}' }, - { - userId: 'u1', - workflowId: '', - workspaceId: 'ws_1', - resolvedSecretTraceRegistry, - } - ) - ).rejects.toThrow('mount denied') - - expect(resolvedSecretTraceRegistry.isComplete()).toBe(true) - expect(resolvedSecretTraceRegistry.getActiveMatches()).toEqual([]) - expect(mockExecuteTool).not.toHaveBeenCalled() - }) -}) - -describe('executeFunctionExecute table mounts', () => { - beforeEach(() => { - resetExecutionMocks() - mockExecuteTool.mockResolvedValue({ success: true }) - mockGetTableById.mockResolvedValue(table) - mockHasCloudStorage.mockReturnValue(true) - mockGeneratePresignedDownloadUrl.mockResolvedValue('https://s3.example/presigned?sig=abc') - }) - - it('mounts every table by presigned snapshot URL', async () => { - mockGetTableById.mockResolvedValue({ ...table, rowCount: 0 }) - mockGetOrCreateTableSnapshot.mockResolvedValue({ - key: 'table-snapshots/ws_1/tbl_1/v5.csv', - size: 9, - version: 5, - }) - - await executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never) - - expect(mockGetOrCreateTableSnapshot).toHaveBeenCalledTimes(1) - expect(mockDownloadFile).not.toHaveBeenCalled() - expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledWith( - 'table-snapshots/ws_1/tbl_1/v5.csv', - 'execution', - expect.any(Number) - ) - expect(mountedFiles()[0]).toEqual({ - type: 'url', - path: '/home/user/tables/tbl_1.csv', - url: 'https://s3.example/presigned?sig=abc', - // The snapshot's own ceiling, enforced on the bytes the sandbox pulls. - maxBytes: SNAPSHOT_MAX_BYTES, - }) - }) - - it('mounts a complete snapshot through a bounded buffer with local storage', async () => { - mockHasCloudStorage.mockReturnValue(false) - mockGetOrCreateTableSnapshot.mockResolvedValue({ - key: 'table-snapshots/ws_1/tbl_1/v5.csv', - size: 9, - version: 5, - }) - mockDownloadFile.mockResolvedValue(Buffer.from('name\nAda\n')) - - await executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never) - - expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled() - expect(mockDownloadFile).toHaveBeenCalledWith( - expect.objectContaining({ key: 'table-snapshots/ws_1/tbl_1/v5.csv', context: 'execution' }) - ) - const file = mountedFiles()[0] - expect(file.path).toBe('/home/user/tables/tbl_1.csv') - expect(file.content).toBe('name\nAda\n') - expect(file.type).toBeUndefined() - }) - - it('unknown snapshot provenance still mounts and taints model egress', async () => { - mockGetTableSnapshotModelMountSafety.mockResolvedValue('unsafe-provenance') - mockGetOrCreateTableSnapshot.mockResolvedValue({ - key: 'table-snapshots/ws_1/tbl_1/v5.csv', - size: 9, - version: 5, - }) - mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'raw output' } }) - const parentRegistry = new ResolvedSecretTraceRegistry([], { - userId: 'u1', - workspaceId: 'ws_1', - }) - - const result = await executeFunctionExecute( - { inputTables: ['tbl_1'] }, - { ...context, resolvedSecretTraceRegistry: parentRegistry } - ) - - expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalled() - expect(mockExecuteTool.mock.calls[0]?.[1]?.[PRIVATE_SECRET_PROVENANCE_FIELD]).toEqual({ - version: 1, - complete: false, - selections: [], - }) - expect(result).toEqual({ success: true, output: { result: 'raw output' } }) - expect(parentRegistry.isComplete()).toBe(false) - expect(projectToolResultForCopilot(result, parentRegistry)).toEqual({ success: true }) - }) - - it('rejects a snapshot that becomes stale before mounting', async () => { - mockGetTableSnapshotModelMountSafety.mockResolvedValue('stale') - mockGetOrCreateTableSnapshot.mockResolvedValue({ - key: 'table-snapshots/ws_1/tbl_1/v5.csv', - size: 9, - version: 5, - }) - - await expect( - executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never) - ).rejects.toThrow(/changed while preparing its snapshot/) - expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled() - expect(mockExecuteTool).not.toHaveBeenCalled() - }) - - it('throws when a cloud snapshot exceeds the table mount limit', async () => { - mockGetOrCreateTableSnapshot.mockResolvedValue({ - key: 'table-snapshots/ws_1/tbl_1/v5.csv', - size: 600 * 1024 * 1024, - version: 5, - }) - - await expect( - executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never) - ).rejects.toThrow(/table mount limit/) - expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled() - }) - - it('throws when cloud snapshots exceed the aggregate URL mount limit', async () => { - mockGetTableById.mockImplementation(async (tableId: string) => ({ ...table, id: tableId })) - mockGetOrCreateTableSnapshot.mockImplementation(async (mountedTable: typeof table) => ({ - key: `table-snapshots/ws_1/${mountedTable.id}/v5.csv`, - size: 500 * 1024 * 1024, - version: 5, - })) - const tableIds = Array.from({ length: 5 }, (_, index) => `tbl_${index}`) - - await expect( - executeFunctionExecute({ inputTables: tableIds }, context as never) - ).rejects.toThrow(/total mount limit/) - expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledTimes(4) - }) - - it('throws when a local snapshot exceeds the per-file mount limit', async () => { - mockHasCloudStorage.mockReturnValue(false) - mockGetOrCreateTableSnapshot.mockResolvedValue({ - key: 'table-snapshots/ws_1/tbl_1/v5.csv', - size: 20 * 1024 * 1024, - version: 5, - }) - - await expect( - executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never) - ).rejects.toThrow(/per-file mount limit/) - expect(mockDownloadFile).not.toHaveBeenCalled() - }) - - it('rejects a table that belongs to another workspace (tenant isolation)', async () => { - mockGetTableById.mockResolvedValue({ ...table, workspaceId: 'ws_2' }) - - await expect( - executeFunctionExecute({ inputTables: ['tbl_1'] }, context as never) - ).rejects.toThrow(/Input table not found/) - expect(mockGetOrCreateTableSnapshot).not.toHaveBeenCalled() - }) -}) - -const fileRecord = { - id: 'file_1', - workspaceId: 'ws_1', - name: 'data.csv', - key: 'workspace/ws_1/data.csv', - path: '/api/files/serve/workspace%2Fws_1%2Fdata.csv', - size: 100, - type: 'text/csv', - storageContext: 'workspace' as const, -} - -describe('executeFunctionExecute file mounts', () => { - beforeEach(() => { - resetExecutionMocks() - mockExecuteTool.mockResolvedValue({ success: true }) - mockHasCloudStorage.mockReturnValue(true) - mockGeneratePresignedDownloadUrl.mockResolvedValue('https://s3.example/file?sig=abc') - mockListWorkspaceFiles.mockResolvedValue([fileRecord]) - mockFindWorkspaceFileRecord.mockReturnValue(fileRecord) - mockGetSandboxWorkspaceFilePath.mockReturnValue('/home/user/files/data.csv') - mockImportWorkspaceFileSecretProvenanceForRuntime.mockResolvedValue(true) - encryptionMockFns.mockDecryptSecret.mockResolvedValue({ decrypted: 'secret-value' }) - }) - - it('cloud storage: mounts by presigned URL with the record context, no bytes through web', async () => { - await executeFunctionExecute({ inputFiles: ['files/data.csv'] }, context as never) - - expect(mockImportWorkspaceFileSecretProvenanceForRuntime).toHaveBeenCalledWith({ - workspaceId: 'ws_1', - identity: { - fileId: 'file_1', - key: 'workspace/ws_1/data.csv', - context: 'workspace', - }, - registry: expect.any(ResolvedSecretTraceRegistry), - }) - expect( - mockImportWorkspaceFileSecretProvenanceForRuntime.mock.invocationCallOrder[0] - ).toBeLessThan(mockGeneratePresignedDownloadUrl.mock.invocationCallOrder[0]) - expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled() - expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledWith( - 'workspace/ws_1/data.csv', - 'workspace', - expect.any(Number) - ) - expect(mountedFiles()[0]).toEqual({ - type: 'url', - path: '/home/user/files/data.csv', - url: 'https://s3.example/file?sig=abc', - // Copilot's URL mounts share the transport, so each is granted exactly - // the size it was charged against the aggregate. - maxBytes: 100, - }) - }) - - it('local storage: falls back to a buffered inline content mount', async () => { - mockHasCloudStorage.mockReturnValue(false) - mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('name\nAda\n')) - - await executeFunctionExecute({ inputFiles: ['files/data.csv'] }, context as never) - - expect( - mockImportWorkspaceFileSecretProvenanceForRuntime.mock.invocationCallOrder[0] - ).toBeLessThan(mockFetchWorkspaceFileBuffer.mock.invocationCallOrder[0]) - expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled() - const file = mountedFiles()[0] - expect(file.path).toBe('/home/user/files/data.csv') - expect(file.content).toBe('name\nAda\n') - expect(file.type).toBeUndefined() - }) - - it('mounts unavailable file provenance and taints only the model-facing result', async () => { - mockImportWorkspaceFileSecretProvenanceForRuntime.mockResolvedValue(false) - mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'raw output' } }) - const parentRegistry = new ResolvedSecretTraceRegistry([], { - userId: 'u1', - workspaceId: 'ws_1', - }) - - const result = await executeFunctionExecute( - { inputFiles: ['files/data.csv'] }, - { ...context, resolvedSecretTraceRegistry: parentRegistry } - ) - - expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalled() - expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled() - expect(mockExecuteTool.mock.calls[0]?.[1]?.[PRIVATE_SECRET_PROVENANCE_FIELD]).toEqual({ - version: 1, - complete: false, - selections: [], - }) - expect(result).toEqual({ success: true, output: { result: 'raw output' } }) - expect(parentRegistry.isComplete()).toBe(false) - expect(projectToolResultForCopilot(result, parentRegistry)).toEqual({ success: true }) - }) - - it('preserves existing ordinary mounts while sending resolver-owned mount provenance', async () => { - mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'ok' } }) - - await executeFunctionExecute( - { - inputFiles: ['files/data.csv'], - _sandboxFiles: [{ path: '/home/user/preserved.bin', content: 'from another resolver' }], - }, - context - ) - - const call = mockExecuteTool.mock.calls[0]?.[1] - expect(call?._sandboxFiles?.length).toBeGreaterThan(1) - expect(call?.[PRIVATE_SECRET_PROVENANCE_FIELD]).toEqual({ - version: 1, - complete: true, - selections: [ - { - key: MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, - provenance: expect.objectContaining({ version: 1, complete: true, entries: [] }), - }, - ], - }) - }) - - it('projects only mounted-file secrets that cross the settled Function result', async () => { - mockImportWorkspaceFileSecretProvenanceForRuntime.mockImplementation( - async ({ registry }: { registry?: ResolvedSecretTraceRegistry }) => - registry?.importProvenance( - { - version: 1, - complete: true, - entries: [{ name: 'FILE_SECRET', encryptedValue: 'encrypted-file-secret' }], - }, - { trusted: true } - ) ?? false - ) - const parentRegistry = new ResolvedSecretTraceRegistry([], { - userId: 'u1', - workspaceId: 'ws_1', - }) - mockExecuteTool.mockResolvedValue({ - success: true, - output: { result: 'secret-value' }, - }) - - const result = await executeFunctionExecute( - { inputFiles: ['files/data.csv'] }, - { ...context, workflowId: '', resolvedSecretTraceRegistry: parentRegistry } - ) - - const privateBundle = mockExecuteTool.mock.calls[0]?.[1]?.[PRIVATE_SECRET_PROVENANCE_FIELD] - expect(privateBundle).toEqual({ - version: 1, - complete: true, - selections: [ - { - key: MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, - provenance: expect.objectContaining({ - version: 1, - complete: true, - entries: [{ encryptedValue: 'encrypted-file-secret' }], - }), - }, - ], - }) - expect(JSON.stringify(privateBundle)).not.toContain('secret-value') - - expect(projectToolResultForCopilot(result, parentRegistry)).toEqual({ - success: true, - output: { result: '[REDACTED_SECRET]' }, - }) - }) - - it('does not activate mounted-file provenance when no tracked bytes cross the result', async () => { - mockImportWorkspaceFileSecretProvenanceForRuntime.mockImplementation( - async ({ registry }: { registry?: ResolvedSecretTraceRegistry }) => - registry?.importProvenance( - { - version: 1, - complete: true, - entries: [{ name: 'FILE_SECRET', encryptedValue: 'encrypted-file-secret' }], - }, - { trusted: true } - ) ?? false - ) - const parentRegistry = new ResolvedSecretTraceRegistry([], { - userId: 'u1', - workspaceId: 'ws_1', - }) - mockExecuteTool.mockResolvedValue({ success: true, output: { result: 'ordinary' } }) - - const result = await executeFunctionExecute( - { inputFiles: ['files/data.csv'] }, - { ...context, workflowId: '', resolvedSecretTraceRegistry: parentRegistry } - ) - - expect(projectToolResultForCopilot(result, parentRegistry)).toEqual(result) - expect(parentRegistry.getActiveMatches()).toEqual([]) - }) - - describe('generated documents', () => { - const docRecord = { - ...fileRecord, - name: 'report.docx', - key: 'workspace/ws_1/report.docx', - // The stored bytes are the generator source, so the record declares its size. - type: 'text/x-docxjs', - size: 6_242, - } - - beforeEach(() => { - mockFindWorkspaceFileRecord.mockReturnValue(docRecord) - mockListWorkspaceFiles.mockResolvedValue([docRecord]) - mockGetSandboxWorkspaceFilePath.mockReturnValue('/home/user/files/report.docx') - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: Buffer.from('PK\u0003\u0004rendered-docx'), - contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - }) - }) - - it('never presigns the raw key, even on cloud storage', async () => { - mockHasCloudStorage.mockReturnValue(true) - - await executeFunctionExecute({ inputFiles: ['files/report.docx'] }, context as never) - - // Presigning record.key would hand the sandbox the generator source. - expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled() - expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled() - expect(mockFetchServableWorkspaceFileBuffer).toHaveBeenCalledTimes(1) - }) - - it('mounts the rendered bytes as base64, not utf-8', async () => { - mockHasCloudStorage.mockReturnValue(true) - - await executeFunctionExecute({ inputFiles: ['files/report.docx'] }, context as never) - - const file = mountedFiles()[0] - // record.type is text/x-docxjs; keying off it would utf-8 decode a binary. - expect(file.encoding).toBe('base64') - expect(Buffer.from(file.content as string, 'base64').toString()).toContain('rendered-docx') - }) - - it('budgets the mount on rendered length, not the declared source size', async () => { - mockHasCloudStorage.mockReturnValue(true) - // A tiny source that renders past the aggregate mount budget. - mockFetchServableWorkspaceFileBuffer.mockRejectedValue( - new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 }) - ) - - await expect( - executeFunctionExecute({ inputFiles: ['files/report.docx'] }, context as never) - ).rejects.toThrow(/mount limit/) - }) - }) - - it('cloud storage: throws when a file exceeds the per-file URL mount limit', async () => { - const oversized = { ...fileRecord, size: 600 * 1024 * 1024 } - mockFindWorkspaceFileRecord.mockReturnValue(oversized) - mockListWorkspaceFiles.mockResolvedValue([oversized]) - - await expect( - executeFunctionExecute({ inputFiles: ['files/data.csv'] }, context as never) - ).rejects.toThrow(/per-file mount limit/) - expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled() - }) - - it('cloud storage: throws when mounts exceed the aggregate URL mount limit', async () => { - // Each file is at the 500MB per-file cap; the 5th pushes the running total past 2GB. - const oversized = { ...fileRecord, size: 500 * 1024 * 1024 } - mockFindWorkspaceFileRecord.mockReturnValue(oversized) - mockListWorkspaceFiles.mockResolvedValue( - Array.from({ length: 5 }, (_, i) => ({ - ...oversized, - id: `file_${i}`, - name: `big-${i}.csv`, - })) - ) - const paths = Array.from({ length: 5 }, (_, i) => `files/big-${i}.csv`) - - await expect(executeFunctionExecute({ inputFiles: paths }, context as never)).rejects.toThrow( - /total mount limit/ - ) - expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledTimes(4) - }) - - it('throws when the inputFiles list exceeds the mounted-file count cap', async () => { - const paths = Array.from({ length: 501 }, (_, i) => `files/f-${i}.csv`) - - await expect(executeFunctionExecute({ inputFiles: paths }, context as never)).rejects.toThrow( - /Too many input files/ - ) - expect(mockListWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('cloud storage: mounts each directory descendant by presigned URL', async () => { - mockListWorkspaceFileFolders.mockResolvedValue([{ path: 'Reports' }]) - const descendant = { - ...fileRecord, - name: 'q1.csv', - key: 'workspace/ws_1/q1.csv', - folderPath: 'Reports', - } - mockListWorkspaceFiles.mockResolvedValue([descendant]) - - await executeFunctionExecute({ inputs: { directories: ['files/Reports'] } }, context as never) - - expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled() - expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledWith( - 'workspace/ws_1/q1.csv', - 'workspace', - expect.any(Number) - ) - expect(mountedFiles()[0]).toEqual({ - type: 'url', - path: '/home/user/files/Reports/q1.csv', - url: 'https://s3.example/file?sig=abc', - // Copilot's URL mounts share the transport, so each is granted exactly - // the size it was charged against the aggregate. - maxBytes: 100, - }) - }) - - it('mounts a directory descendant with unavailable provenance as incomplete', async () => { - mockListWorkspaceFileFolders.mockResolvedValue([{ path: 'Reports' }]) - mockListWorkspaceFiles.mockResolvedValue([ - { - ...fileRecord, - name: 'q1.csv', - key: 'workspace/ws_1/q1.csv', - folderPath: 'Reports', - }, - ]) - mockImportWorkspaceFileSecretProvenanceForRuntime.mockResolvedValue(false) - - await executeFunctionExecute({ inputs: { directories: ['files/Reports'] } }, context as never) - - expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalled() - expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled() - expect(mockExecuteTool).toHaveBeenCalled() - expect(mockExecuteTool.mock.calls[0]?.[1]?.[PRIVATE_SECRET_PROVENANCE_FIELD]).toEqual({ - version: 1, - complete: false, - selections: [], - }) - }) - - it('local storage: buffers directory descendants via inline content', async () => { - mockHasCloudStorage.mockReturnValue(false) - mockListWorkspaceFileFolders.mockResolvedValue([{ path: 'Reports' }]) - const descendant = { - ...fileRecord, - name: 'q1.csv', - key: 'workspace/ws_1/q1.csv', - folderPath: 'Reports', - } - mockListWorkspaceFiles.mockResolvedValue([descendant]) - mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('a,b\n1,2\n')) - - await executeFunctionExecute({ inputs: { directories: ['files/Reports'] } }, context as never) - - expect(mockGeneratePresignedDownloadUrl).not.toHaveBeenCalled() - const file = mountedFiles()[0] - expect(file.path).toBe('/home/user/files/Reports/q1.csv') - expect(file.content).toBe('a,b\n1,2\n') - expect(file.type).toBeUndefined() - }) -}) - -async function mountError(inputs: Record): Promise { - try { - await executeFunctionExecute(inputs, context as never) - } catch (error) { - return (error as Error).message - } - throw new Error('expected the mount to be rejected') -} - -describe('executeFunctionExecute unmountable namespaces', () => { - beforeEach(() => { - resetExecutionMocks() - mockExecuteTool.mockResolvedValue({ success: true }) - mockHasCloudStorage.mockReturnValue(true) - mockListWorkspaceFiles.mockResolvedValue([]) - mockFindWorkspaceFileRecord.mockReturnValue(null) - mockListWorkspaceFileFolders.mockResolvedValue([]) - }) - - it('tells the agent a tool-result artifact is backend-served, not a wrong path', async () => { - const message = await mountError({ - inputFiles: ['internal/tool-results/user_table-toolu_019Ef.json'], - }) - - expect(message).toContain('Cannot mount "internal/tool-results/user_table-toolu_019Ef.json"') - expect(message).toContain('stored by the copilot backend') - expect(message).toContain('This path is correct') - expect(message).toContain('outputs.files[].path') - expect(message).toContain('user_table: outputPath') - // The old message sent the agent hunting for a canonical path that never existed. - expect(message).not.toContain('Input file not found') - expect(message).not.toContain('canonical VFS path copied from glob/read') - }) - - it('covers the rest of internal/ without the tool-result rerun advice', async () => { - const message = await mountError({ inputFiles: ['internal/memories/SESSION.md'] }) - - expect(message).toContain('served by the copilot backend') - expect(message).toContain('read or grep it') - expect(message).not.toContain('outputPath') - }) - - it('points recently-deleted/ paths at restore_resource', async () => { - const message = await mountError({ inputFiles: ['recently-deleted/files/old.csv'] }) - - expect(message).toContain('restore_resource') - }) - - it('points tables/ paths at inputs.tables', async () => { - const message = await mountError({ inputFiles: ['tables/Leads/meta.json'] }) - - expect(message).toContain('inputs.tables') - }) - - it('names the namespace for VFS metadata views', async () => { - const message = await mountError({ inputFiles: ['workflows/My%20Flow/state.json'] }) - - expect(message).toContain('workflows/ paths are VFS metadata views') - }) - - it('keeps the uploads/ guidance intact', async () => { - const message = await mountError({ inputFiles: ['uploads/report.json'] }) - - expect(message).toContain('save_upload') - }) - - it('still reports a genuine files/ miss as not found', async () => { - const message = await mountError({ inputFiles: ['files/typo.csv'] }) - - expect(message).toContain('Input file not found: "files/typo.csv"') - }) - - it('explains an unmountable namespace passed as a directory', async () => { - const message = await mountError({ inputs: { directories: ['internal/tool-results'] } }) - - expect(message).toContain('Cannot mount "internal/tool-results"') - expect(message).toContain('stored by the copilot backend') - }) - - it('still reports a genuine files/ folder miss as not found', async () => { - const message = await mountError({ inputs: { directories: ['files/Missing'] } }) - - expect(message).toContain('Input directory not found: "files/Missing"') - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts deleted file mode 100644 index a3ff8ee077c..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ /dev/null @@ -1,732 +0,0 @@ -import type { Principal } from '@sim/auth/principal' -import { createLogger } from '@sim/logger' -import { omit } from '@sim/utils/object' -import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' -import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' -import { applySecretMountPolicy } from '@/lib/copilot/secret-mount-policy' -import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' -import { - CopilotCodeSecretAccessError, - type MaterializedCopilotCodeSecrets, - materializeCopilotCodeSecrets, -} from '@/lib/copilot/tools/secret-mount-materializer.server' -import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import type { PrivateSecretProvenanceBundleV1 } from '@/lib/execution/model-input-provenance' -import { - MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, - PRIVATE_SECRET_PROVENANCE_FIELD, -} from '@/lib/execution/private-tool-metadata' -import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/entitlement' -import type { SandboxFile } from '@/lib/execution/remote-sandbox/types' -import { - createSandboxMountBudget, - MAX_INLINE_MOUNT_FILE_BYTES, - MAX_INLINE_MOUNT_TOTAL_BYTES, - MAX_TOTAL_URL_BYTES, - MOUNT_URL_TTL_SECONDS, - pushSandboxFileMount, - type SandboxMountBudget, -} from '@/lib/function-execution/sandbox-mounts' -import { recordSecretUsage } from '@/lib/secrets/usage/record' -import { getTableSnapshotModelMountSafety } from '@/lib/table/rows/secret-provenance' -import { getTableById, listTables } from '@/lib/table/service' -import { getOrCreateTableSnapshot, SNAPSHOT_MAX_BYTES } from '@/lib/table/snapshot-cache' -import { - findWorkspaceFileRecord, - getSandboxWorkspaceFilePath, - type WorkspaceFileRecord, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { importWorkspaceFileSecretProvenanceForRuntime } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { - downloadFile, - generatePresignedDownloadUrl, - hasCloudStorage, -} from '@/lib/uploads/core/storage-service' -import { isGeneratedDocumentSourceType } from '@/lib/uploads/utils/file-utils' -import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer' -import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' -import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' -import { downloadWorkspaceFileRecord } from '@/lib/workspace-files/application/read-workspace-file-record' -import { listWorkspaceFileFoldersOperation } from '@/lib/workspace-files/application/workspace-file-folders' -import { - buildWorkspaceFileFolderDisplayPath, - parseWorkspaceFileFolderDisplayPath, -} from '@/lib/workspace-files/folder-display-path' -import { extractCodeSecretNames } from '@/executor/utils/code-secret-references' -import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' -import { executeTool as executeAppTool } from '@/tools' - -const logger = createLogger('CopilotFunctionExecute') - -const MAX_FILE_SIZE = MAX_INLINE_MOUNT_FILE_BYTES -const MAX_TOTAL_SIZE = MAX_INLINE_MOUNT_TOTAL_BYTES -const MAX_MOUNTED_FILES = 500 - -async function importMountedWorkspaceFileProvenance(args: { - workspaceId: string - record: WorkspaceFileRecord - mountPath: string - registry?: ResolvedSecretTraceRegistry -}): Promise { - if (!args.registry) { - throw new Error( - `Input file "${args.mountPath}" cannot be mounted because its secret provenance is unavailable.` - ) - } - try { - const imported = await importWorkspaceFileSecretProvenanceForRuntime({ - workspaceId: args.workspaceId, - identity: { - fileId: args.record.id, - key: args.record.key, - context: args.record.storageContext ?? 'workspace', - }, - registry: args.registry, - }) - if (!imported) args.registry.markIncomplete('mounted-file-provenance-unavailable') - } catch { - args.registry.markIncomplete('mounted-file-provenance-unavailable') - } -} - -/** - * Mounts a stored workspace file into the sandbox. The transport choice, the byte - * ceilings, and the budget accounting live in {@link pushSandboxFileMount}, which - * the Function block shares; what stays here is workspace-specific — reloading the - * record through its application operation, importing its secret provenance, and - * reading generated documents through the servable reader rather than presigning - * their generator source. - */ -async function pushWorkspaceFileMount( - sandboxFiles: SandboxFile[], - record: WorkspaceFileRecord, - mountPath: string, - mounted: SandboxMountBudget, - workspaceId: string, - principal: Principal, - registry?: ResolvedSecretTraceRegistry -): Promise { - record = ( - await downloadWorkspaceFileRecord.execute({ - principal, - input: { fileId: record.id, assertedWorkspaceId: workspaceId }, - }) - ).file - await importMountedWorkspaceFileProvenance({ workspaceId, record, mountPath, registry }) - - // A generated document stores its generator source, so a presigned URL for - // `record.key` would hand the sandbox source text under a `.docx` name and the - // user's script would fail on a file that looks fine. Those resolve through the - // servable reader instead — they are bounded by the render ceiling, so routing them - // through the web process rather than presigning is affordable. - const rendersFromSource = isGeneratedDocumentSourceType(record.type) - - await pushSandboxFileMount( - sandboxFiles, - { - mountPath, - key: record.key, - storageContext: record.storageContext ?? 'workspace', - declaredSize: record.size, - rendersFromSource, - readInline: async (maxBytes) => { - const { buffer, contentType } = rendersFromSource - ? await fetchAuthorizedServableWorkspaceFileBuffer(record, principal, { - maxBytes, - }).catch((error) => { - if (!isPayloadSizeLimitError(error)) throw error - throw new Error( - `Input file "${mountPath}" renders to more than the ${MAX_FILE_SIZE / 1024 / 1024}MB per-file mount limit, or than the mount budget left. Mount fewer or smaller files.` - ) - }) - : { - buffer: ( - await readWorkspaceFileContent.execute({ - principal, - input: { - fileId: record.id, - assertedWorkspaceId: workspaceId, - maxBytes, - }, - }) - ).content, - contentType: record.type, - } - // Keyed off the resolved type: a rendered document's source MIME is `text/x-…`, and - // decoding the binary as UTF-8 would corrupt it just as surely as shipping the source. - const isText = /^text\/|application\/json|application\/xml|application\/csv/.test( - contentType || '' - ) - return { - content: isText ? buffer.toString('utf-8') : buffer.toString('base64'), - ...(isText ? {} : { encoding: 'base64' as const }), - byteLength: buffer.length, - } - }, - }, - mounted - ) -} - -/** - * Explains why a VFS path the agent legitimately discovered cannot be mounted, and - * what to do instead. Only workspace `files/` are backed by storage the sandbox can - * fetch from — `internal/` is served by the copilot backend and its bytes never reach - * Sim, `uploads/` is chat-scoped, `recently-deleted/` is archived, and the remaining - * namespaces are metadata views rather than stored file bytes. Returns null for - * `files/` references, where "not found" is the honest answer. - * - * These paths are correct and are advertised to the model as read/grep-able, so the - * generic not-found message ("copy the exact canonical path") is actively wrong for - * them: it sends the agent hunting for a path that does not exist. - */ -function unmountableNamespaceReason(filePath: string): string | null { - // Trailing slash so a bare namespace passed as a directory matches the same prefixes - // as a file path inside it. - const path = `${filePath.replace(/^\/+|\/+$/g, '')}/` - - if (path.startsWith('uploads/')) { - return 'uploads/ files are not mountable into the sandbox. Use save_upload to save it to a files/... path first, then mount that canonical path.' - } - if (path.startsWith('internal/tool-results/')) { - return 'tool-result artifacts are stored by the copilot backend, not in workspace storage, so read and grep reach them but the sandbox cannot. This path is correct — searching for a different one will not find anything. Either read or grep the artifact and inline the values you need in code, or re-run the tool that produced it with an output path under files/ (run_function: outputs.files[].path, user_table: outputPath) and mount that files/... path.' - } - if (path.startsWith('internal/')) { - return 'internal/ paths are served by the copilot backend, not from workspace storage, so read and grep reach them but the sandbox cannot. This path is correct — read or grep it and inline the values you need in code instead of mounting it.' - } - if (path.startsWith('recently-deleted/')) { - return 'deleted resources are not mountable into the sandbox. Use restore_resource to restore it first, then mount the restored files/... path.' - } - if (path.startsWith('tables/')) { - return 'tables are not mounted as files. Pass the table in inputs.tables instead and it is mounted as CSV.' - } - const namespace = /^(workflows|knowledgebases|components|environment|agent)\//.exec(path)?.[1] - if (namespace) { - return `${namespace}/ paths are VFS metadata views, not stored file bytes, so the sandbox cannot mount them. This path is correct — read or grep it and inline the values you need in code.` - } - return null -} - -interface CanonicalFileInput { - path: string - sandboxPath?: string -} - -interface CanonicalDirectoryInput { - path: string - sandboxPath?: string -} - -interface CanonicalTableInput { - tableId?: string - path?: string - sandboxPath?: string -} - -function tableNameFromVfsPath(tableRef: string): string | null { - if (!tableRef.startsWith('tables/')) return null - const segments = decodeVfsPathSegments(tableRef) - const metaIndex = segments.lastIndexOf('meta.json') - return segments[metaIndex > 0 ? metaIndex - 1 : segments.length - 1] ?? null -} - -async function resolveTableRef( - tableRef: string, - tablePathLookup?: Map>[number]> -) { - if (!tableRef.startsWith('tables/')) { - return getTableById(tableRef) - } - - const tableName = tableNameFromVfsPath(tableRef) - if (!tableName) return null - return tablePathLookup?.get(tableName) ?? null -} - -export async function resolveInputFiles( - workspaceId: string, - inputFiles?: unknown[], - inputTables?: unknown[], - inputDirectories?: unknown[], - resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry, - filePrincipal?: Principal -): Promise { - const sandboxFiles: SandboxFile[] = [] - const mounted = createSandboxMountBudget() - - if (inputFiles?.length && workspaceId) { - if (!filePrincipal) { - throw new Error('Workspace file mounts require a trusted Copilot principal') - } - if (inputFiles.length > MAX_MOUNTED_FILES) { - throw new Error( - `Too many input files (${inputFiles.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount fewer files.` - ) - } - const { files: allFiles } = await listAllWorkspaceFiles.execute({ - principal: filePrincipal, - input: { workspaceId, scope: 'active' }, - }) - for (const fileRef of inputFiles) { - const filePath = - typeof fileRef === 'string' - ? fileRef - : fileRef && typeof fileRef === 'object' - ? (fileRef as CanonicalFileInput).path - : undefined - if (!filePath) continue - const record = findWorkspaceFileRecord(allFiles, filePath) - if (!record) { - const unmountable = unmountableNamespaceReason(filePath) - if (unmountable) { - throw new Error(`Cannot mount "${filePath}": ${unmountable}`) - } - throw new Error( - `Input file not found: "${filePath}". Pass the exact canonical VFS path copied from glob/read (e.g. "files/Reports/data.csv").` - ) - } - const explicitSandboxPath = - typeof fileRef === 'object' && fileRef !== null - ? (fileRef as CanonicalFileInput).sandboxPath - : undefined - const mountPath = explicitSandboxPath || getSandboxWorkspaceFilePath(record) - await pushWorkspaceFileMount( - sandboxFiles, - record, - mountPath, - mounted, - workspaceId, - filePrincipal, - resolvedSecretTraceRegistry - ) - } - } - - if (inputDirectories?.length && workspaceId) { - if (!filePrincipal) { - throw new Error('Workspace directory mounts require a trusted Copilot principal') - } - const { folders } = await listWorkspaceFileFoldersOperation.execute({ - principal: filePrincipal, - input: { workspaceId }, - }) - const { files: allFiles } = await listAllWorkspaceFiles.execute({ - principal: filePrincipal, - input: { workspaceId, scope: 'active' }, - }) - for (const dirRef of inputDirectories) { - const dirPath = - typeof dirRef === 'string' - ? dirRef - : dirRef && typeof dirRef === 'object' - ? (dirRef as CanonicalDirectoryInput).path - : undefined - if (!dirPath) continue - const folderSegments = decodeVfsPathSegments(dirPath.replace(/^\/?files\/?/, '')) - const folderDisplayPath = buildWorkspaceFileFolderDisplayPath(folderSegments) - const folder = folders.find((candidate) => candidate.path === folderDisplayPath) - if (!folder) { - const unmountable = unmountableNamespaceReason(dirPath) - throw new Error( - unmountable - ? `Cannot mount "${dirPath}": ${unmountable}` - : `Input directory not found: "${dirPath}". Pass a canonical workspace folder path copied from glob/read (e.g. "files/Reports").` - ) - } - const mountRoot = - typeof dirRef === 'object' && - dirRef !== null && - (dirRef as CanonicalDirectoryInput).sandboxPath - ? (dirRef as CanonicalDirectoryInput).sandboxPath! - : `/home/user/files/${encodeVfsPathSegments(parseWorkspaceFileFolderDisplayPath(folder.path))}` - const descendants = allFiles.filter((file) => { - if (!file.folderPath) return false - return file.folderPath === folder.path || file.folderPath.startsWith(`${folder.path}/`) - }) - if (descendants.length > MAX_MOUNTED_FILES) { - throw new Error( - `Input directory contains too many files (${descendants.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount a smaller directory or individual files.` - ) - } - logger.info('Mounting workspace directory for run_function', { - vfsPath: dirPath, - sandboxPath: mountRoot, - fileCount: descendants.length, - }) - const childFolders = folders.filter( - (candidate) => - candidate.path !== folder.path && candidate.path.startsWith(`${folder.path}/`) - ) - if (descendants.length === 0 && childFolders.length === 0) { - sandboxFiles.push({ path: `${mountRoot}/.keep`, content: '' }) - continue - } - for (const childFolder of childFolders) { - const hasFiles = descendants.some((file) => { - if (!file.folderPath) return false - return ( - file.folderPath === childFolder.path || - file.folderPath.startsWith(`${childFolder.path}/`) - ) - }) - if (!hasFiles) { - const relativeFolder = childFolder.path.slice(folder.path.length).replace(/^\/+/, '') - sandboxFiles.push({ path: `${mountRoot}/${relativeFolder}/.keep`, content: '' }) - } - } - for (const record of descendants) { - const relativeFolder = - record.folderPath?.slice(folder.path.length).replace(/^\/+/, '') ?? '' - const relativePath = [relativeFolder, record.name].filter(Boolean).join('/') - await pushWorkspaceFileMount( - sandboxFiles, - record, - `${mountRoot}/${relativePath}`, - mounted, - workspaceId, - filePrincipal, - resolvedSecretTraceRegistry - ) - } - } - } - - if (inputTables?.length) { - const hasTablePathRefs = inputTables.some((tableRef) => { - const tableId = - typeof tableRef === 'string' - ? tableRef - : tableRef && typeof tableRef === 'object' - ? (tableRef as CanonicalTableInput).tableId || (tableRef as CanonicalTableInput).path - : undefined - return typeof tableId === 'string' && tableId.startsWith('tables/') - }) - const tablePathLookup = hasTablePathRefs - ? new Map((await listTables(workspaceId)).map((table) => [table.name, table])) - : undefined - for (const tableRef of inputTables) { - const tableId = - typeof tableRef === 'string' - ? tableRef - : tableRef && typeof tableRef === 'object' - ? (tableRef as CanonicalTableInput).tableId || (tableRef as CanonicalTableInput).path - : undefined - if (!tableId) continue - const table = await resolveTableRef(tableId, tablePathLookup) - if (!table || table.workspaceId !== workspaceId) { - throw new Error( - `Input table not found: "${tableId}". Pass the table id (tbl_...) from tables/{name}/meta.json, or a tables/{name}/meta.json path.` - ) - } - const sandboxPath = - typeof tableRef === 'object' && tableRef !== null - ? (tableRef as CanonicalTableInput).sandboxPath - : undefined - const mountPath = sandboxPath || `/home/user/tables/${table.id}.csv` - - const snapshot = await getOrCreateTableSnapshot(table, 'copilot-fn-exec') - if (!resolvedSecretTraceRegistry) { - throw new Error( - `Input table "${tableId}" cannot be mounted because its secret provenance is unavailable.` - ) - } - const mountSafety = await getTableSnapshotModelMountSafety({ - tableId: table.id, - workspaceId, - rowsVersion: snapshot.version, - }) - if (mountSafety === 'stale') { - throw new Error(`Input table "${tableId}" changed while preparing its snapshot. Retry.`) - } - if (mountSafety === 'unsafe-provenance') { - resolvedSecretTraceRegistry.markIncomplete('table-snapshot-unsafe-for-mount') - } - - if (hasCloudStorage()) { - if (snapshot.size > SNAPSHOT_MAX_BYTES) { - throw new Error( - `Input table "${tableId}" is ${Math.round(snapshot.size / 1024 / 1024)}MB, over the ${SNAPSHOT_MAX_BYTES / 1024 / 1024}MB table mount limit.` - ) - } - if (mounted.url + snapshot.size > MAX_TOTAL_URL_BYTES) { - throw new Error( - `Mounting "${tableId}" would exceed the ${MAX_TOTAL_URL_BYTES / 1024 / 1024 / 1024}GB total mount limit. Mount fewer or smaller files and tables.` - ) - } - const url = await generatePresignedDownloadUrl( - snapshot.key, - 'execution', - MOUNT_URL_TTL_SECONDS - ) - sandboxFiles.push({ type: 'url', path: mountPath, url, maxBytes: SNAPSHOT_MAX_BYTES }) - mounted.url += snapshot.size - continue - } - - // Local storage: a presigned URL is an app-internal serve path a remote sandbox can't - // reach, so fall back to buffering the bytes through the web process (file-mount guards). - if (snapshot.size > MAX_FILE_SIZE) { - throw new Error( - `Input table "${tableId}" is ${Math.round(snapshot.size / 1024 / 1024)}MB, over the ${MAX_FILE_SIZE / 1024 / 1024}MB per-file mount limit.` - ) - } - if (mounted.buffered + snapshot.size > MAX_TOTAL_SIZE) { - throw new Error( - `Mounting "${tableId}" would exceed the ${MAX_TOTAL_SIZE / 1024 / 1024}MB total mount limit. Mount fewer or smaller tables.` - ) - } - const buffer = await downloadFile({ - key: snapshot.key, - context: 'execution', - maxBytes: MAX_FILE_SIZE, - }) - mounted.buffered += buffer.length - sandboxFiles.push({ path: mountPath, content: buffer.toString('utf-8') }) - } - } - - return sandboxFiles -} - -async function importMountedProvenance( - source: ResolvedSecretTraceRegistry, - target: ResolvedSecretTraceRegistry | undefined, - crossingValue: unknown -): Promise { - if (!target) return - - try { - const provenance = source.exportProvenanceForValue(crossingValue) - const imported = await target.importCrossingProvenance(provenance, crossingValue, { - origin: 'copilotFunctionExecute.crossing', - trusted: true, - }) - if (!imported) - target.markIncomplete('value-provenance-import-failed', { - origin: 'copilotFunctionExecute.crossing', - }) - } catch { - target.markIncomplete('value-provenance-import-failed', { - origin: 'copilotFunctionExecute.crossing', - }) - } -} - -export async function executeFunctionExecute( - params: Record, - context: ToolExecutionContext -): Promise { - if (context.requestMode === 'assistant') { - const inputs = params.inputs - if ( - params.secrets !== undefined || - params.inputTables !== undefined || - params.outputTable !== undefined || - (inputs !== null && typeof inputs === 'object' && 'tables' in inputs) - ) { - throw new Error( - 'Assistant code can use files, but cannot mount secrets or access workspace tables' - ) - } - } - const enrichedParams = omit(params, [ - 'sandboxProfile', - 'internalSandboxProfile', - PRIVATE_SECRET_PROVENANCE_FIELD, - ]) - // The copilot tool doc promises `timeout` in SECONDS ("Sim converts to - // milliseconds", default 10, cap 300); the underlying function tool takes - // MILLISECONDS. Nothing converted, so `timeout: 120` armed a 120ms abort. - // Values ≤ 600 are read as seconds; larger values are assumed to already be - // milliseconds (a model habit worth tolerating). Both clamp to the 300s cap. - if (typeof enrichedParams.timeout === 'number' && Number.isFinite(enrichedParams.timeout)) { - const raw = enrichedParams.timeout - const ms = raw <= 600 ? raw * 1000 : raw - enrichedParams.timeout = Math.min(Math.max(ms, 1000), 300_000) - } - if (params.sandboxId !== undefined) { - if (typeof params.sandboxId !== 'string' || !params.sandboxId.trim()) { - throw new Error('sandboxId must be a non-empty Sim sandbox id') - } - if (!context.workspaceId) { - throw new Error('A workspace is required to select a Sim sandbox') - } - if (!(await hasWorkspaceSandboxAccess(context.workspaceId))) { - throw new Error(MAX_PLAN_REQUIRED) - } - enrichedParams.sandboxId = params.sandboxId.trim() - } - const requestedNames = applySecretMountPolicy( - await extractCodeSecretNames(params.code, params.language), - context.secretMountPolicy - ) - const completePendingActivation = - requestedNames.length > 0 - ? context.resolvedSecretTraceRegistry?.beginPendingActivation() - : undefined - let mountedRegistry: ResolvedSecretTraceRegistry | undefined - let crossingValue: unknown - - /** - * Hoisted so the usage trail in `finally` attributes the run to the same identity the mount - * authorized against. Deriving it a second time down there let the two disagree whenever - * `secretActorUserId` was explicitly null. - */ - const secretActorUserId = - context.requestMode === 'assistant' - ? null - : context.secretActorUserId === undefined - ? context.userId - : context.secretActorUserId - - try { - let mounted: MaterializedCopilotCodeSecrets = { envVars: {}, catalogEntries: [] } - if (requestedNames.length > 0) { - if (!secretActorUserId) { - throw new CopilotCodeSecretAccessError('Secret access is unavailable for this Copilot run') - } - if (!context.workspaceId) { - throw new CopilotCodeSecretAccessError( - 'A workspace is required to mount secrets into Copilot code' - ) - } - mounted = await materializeCopilotCodeSecrets({ - actorUserId: secretActorUserId, - workspaceId: context.workspaceId, - requestedNames, - }) - } - mountedRegistry = new ResolvedSecretTraceRegistry(mounted.catalogEntries, { - userId: secretActorUserId ?? context.userId, - ...(context.workspaceId ? { workspaceId: context.workspaceId } : {}), - }) - - enrichedParams.envVars = mounted.envVars - enrichedParams.secretScope = 'selected' - enrichedParams.mountedSecrets = requestedNames - /** - * Certified by the mounted registry rather than read off the raw materializer entries, so - * a mounted secret sharing its plaintext with a protected one is withheld from the route. - */ - const unredactedSecretNames = mountedRegistry.getUnredactedSecretNames() - if (unredactedSecretNames.length > 0) { - enrichedParams.unredactedSecretNames = unredactedSecretNames - } - - if (context.workspaceId) { - const inputs = enrichedParams.inputs as - | { - files?: CanonicalFileInput[] - directories?: CanonicalDirectoryInput[] - tables?: CanonicalTableInput[] - } - | undefined - const inputFiles = [ - ...((enrichedParams.inputFiles as unknown[] | undefined) ?? []), - ...(inputs?.files ?? []), - ] - const inputDirectories = inputs?.directories ?? [] - const inputTables = [ - ...((enrichedParams.inputTables as unknown[] | undefined) ?? []), - ...(inputs?.tables ?? []), - ] - - if (inputFiles?.length || inputTables?.length || inputDirectories.length) { - const resolved = await resolveInputFiles( - context.workspaceId, - inputFiles, - inputTables, - inputDirectories, - mountedRegistry, - inputFiles.length > 0 || inputDirectories.length > 0 - ? resolveCopilotFilePrincipal(context) - : undefined - ) - if (resolved.length > 0) { - const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || [] - enrichedParams._sandboxFiles = [...existing, ...resolved] - - const provenance = mountedRegistry.exportProvenance() - const bundle: PrivateSecretProvenanceBundleV1 = { - version: 1, - complete: provenance.complete, - selections: provenance.complete - ? [{ key: MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, provenance }] - : [], - } - enrichedParams[PRIVATE_SECRET_PROVENANCE_FIELD] = bundle - } - } - } - - enrichedParams._context = { - userId: context.userId, - workflowId: context.workflowId, - workspaceId: context.workspaceId, - chatId: context.chatId, - executionId: context.executionId, - runId: context.runId, - enforceCredentialAccess: true, - } - - try { - /** - * The copilot-facing tool is named `run_function`, but the app-tool - * registry id stays `function_execute` — the validator in tools/index.ts - * only admits `internalSandboxProfile` for that id, and every copilot - * call carries the internal `mothership` profile. Renaming this inner id - * without renaming the registry breaks every copilot sandbox call with - * "An internal sandbox profile may only be used with function_execute". - */ - const result = await executeAppTool('function_execute', enrichedParams, { - resolvedSecretTraceRegistry: mountedRegistry, - operationContext: { - userId: context.userId, - workflowId: context.workflowId, - workspaceId: context.workspaceId, - executionId: context.executionId, - executorDelegationOrigin: { - subjectUserId: context.userId, - workflowId: context.workflowId, - ...(context.executionId ? { executionId: context.executionId } : {}), - }, - copilotToolExecution: context.copilotToolExecution, - billingAttribution: context.billingAttribution, - resolvedSecretTraceRegistry: mountedRegistry, - }, - ...(context.abortSignal ? { signal: context.abortSignal } : {}), - ...(context.sandboxProfile ? { internalSandboxProfile: context.sandboxProfile } : {}), - }) - crossingValue = result - return result - } catch (error) { - crossingValue = error - throw error - } - } finally { - if (mountedRegistry && crossingValue !== undefined) { - await importMountedProvenance( - mountedRegistry, - context.resolvedSecretTraceRegistry, - crossingValue - ) - } - /** - * Copilot-run code is a real read of a workspace secret and has to appear in the trail; - * without this an admin reviewing a secret sees "never used" for one someone read through - * Mothership. Read from the registry rather than `requestedNames` so only names the code - * actually resolved are counted. The headless inbox runner reaches the same handler, so - * it is covered here too. - */ - if (mountedRegistry && context.workspaceId) { - recordSecretUsage(mountedRegistry.getResolvedSecretUsage(), { - workspaceId: context.workspaceId, - source: 'copilot', - actorUserId: secretActorUserId ?? null, - trigger: 'copilot', - }) - } - completePendingActivation?.() - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/integration-tools.ts b/apps/sim/lib/copilot/tools/handlers/integration-tools.ts deleted file mode 100644 index 35a3e66ab23..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/integration-tools.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' -import { projectIntegrationToolsForViewer } from '@/lib/copilot/integration-tool-projection' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' -import { stripVersionSuffix } from '@/tools/utils' - -export async function executeListIntegrationTools( - params: Record, - context: ExecutionContext -): Promise { - const raw = typeof params.integration === 'string' ? params.integration.trim() : '' - if (!raw) { - return { success: false, error: "Missing required parameter 'integration'" } - } - - // The exposed set is the ungated universe — project it for this viewer so - // gated (preview / kill-switched) integrations stay undiscoverable. - const vis = await getBlockVisibilityForCopilot(context.userId, context.workspaceId) - const permissionConfig = context.workspaceId - ? await resolvePermissionGroupConfig(context.userId, context.workspaceId, undefined) - : null - const { tools: all } = projectIntegrationToolsForViewer(vis, permissionConfig) - const service = stripVersionSuffix(raw.toLowerCase()) - const matches = all.filter((tool) => tool.service === service) - - if (matches.length === 0) { - const services = Array.from(new Set(all.map((tool) => tool.service))).sort() - return { - success: false, - error: `Unknown integration "${raw}". Available integrations: ${services.join(', ')}`, - } - } - - return { - success: true, - output: { - integration: service, - note: 'Read the entry\'s "path" verbatim for exact params, then load_integration_tool({tool_ids: [""]}) and call the tool by that exact id.', - tools: matches.map((tool) => ({ - id: tool.toolId, - operation: tool.operation, - path: `components/integrations/${tool.service}/${tool.operation}.json`, - name: tool.config.name, - description: tool.config.description, - })), - }, - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts deleted file mode 100644 index 46dc0b0dc04..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - performCreateCredential: vi.fn(), - getEffectiveDecryptedEnv: vi.fn(), -})) - -vi.mock('@/lib/credentials/orchestration', () => ({ - performCreateCredential: mocks.performCreateCredential, -})) -vi.mock('@/lib/environment/utils', () => ({ - getEffectiveDecryptedEnv: mocks.getEffectiveDecryptedEnv, -})) - -import { executeConnectSlackBot } from './connect-slack-bot' - -const context = { userId: 'user-1', workspaceId: 'ws-1' } as never - -const validParams = { - displayName: 'Elder Bot', - signingSecretEnvVar: 'SLACK_SIGNING_SECRET', - botTokenEnvVar: 'SLACK_BOT_TOKEN', -} - -beforeEach(() => { - vi.clearAllMocks() - mocks.getEffectiveDecryptedEnv.mockResolvedValue({ - SLACK_SIGNING_SECRET: 'shhh', - SLACK_BOT_TOKEN: 'xoxb-123', - }) - mocks.performCreateCredential.mockResolvedValue({ - success: true, - created: true, - credential: { id: 'cred-1', displayName: 'Elder Bot' }, - }) -}) - -describe('executeConnectSlackBot', () => { - it('resolves env vars server-side and mints the credential with the request URL', async () => { - const result = await executeConnectSlackBot(validParams, context) - - expect(mocks.performCreateCredential).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: 'ws-1', - userId: 'user-1', - type: 'service_account', - providerId: 'slack-custom-bot', - displayName: 'Elder Bot', - signingSecret: 'shhh', - botToken: 'xoxb-123', - }) - ) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - credentialId: 'cred-1', - created: true, - requestUrl: expect.stringContaining('/api/webhooks/slack/custom/cred-1'), - }) - }) - - it('names the missing env vars without leaking any values', async () => { - mocks.getEffectiveDecryptedEnv.mockResolvedValue({ SLACK_SIGNING_SECRET: 'shhh' }) - - const result = await executeConnectSlackBot(validParams, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('SLACK_BOT_TOKEN') - expect(result.error).not.toContain('shhh') - expect(mocks.performCreateCredential).not.toHaveBeenCalled() - }) - - it('requires displayName and both env var names', async () => { - const missingName = await executeConnectSlackBot( - { signingSecretEnvVar: 'A', botTokenEnvVar: 'B' }, - context - ) - expect(missingName.success).toBe(false) - expect(missingName.error).toContain('displayName') - - const missingVars = await executeConnectSlackBot({ displayName: 'Bot' }, context) - expect(missingVars.success).toBe(false) - expect(missingVars.error).toContain('signingSecretEnvVar') - }) - - it('surfaces orchestration failures (e.g. auth.test rejection or name conflict)', async () => { - mocks.performCreateCredential.mockResolvedValue({ - success: false, - error: 'Slack rejected the bot token', - }) - - const result = await executeConnectSlackBot(validParams, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('Slack rejected the bot token') - }) - - it('requires workspace scope', async () => { - const result = await executeConnectSlackBot(validParams, { userId: 'user-1' } as never) - expect(result.success).toBe(false) - expect(result.error).toContain('Workspace') - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts b/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts deleted file mode 100644 index f02599e8d8e..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/management/connect-slack-bot.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { toError } from '@sim/utils/errors' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { performCreateCredential } from '@/lib/credentials/orchestration' -import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' -import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' -import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url' - -/** - * Mints a reusable Slack custom-bot credential from secrets ALREADY stored as - * environment variables (a v1 setup being migrated, or values saved via - * set_environment_variables after a browser-agent extraction). The agent - * passes env-var NAMES; the values are resolved here and validated by the - * credential orchestration (Slack auth.test), so no secret ever appears in - * tool args, checkpoints, or transcripts. When the USER holds the secrets, - * the service_account credential card is the right path instead. - */ -export function executeConnectSlackBot( - rawParams: Record, - context: ExecutionContext -): Promise { - const params = rawParams as { - displayName?: string - description?: string - signingSecretEnvVar?: string - botTokenEnvVar?: string - } - return (async () => { - try { - if (!context?.userId) { - return { success: false, error: 'Authentication required' } - } - const workspaceId = context.workspaceId - if (!workspaceId) { - return { success: false, error: 'Workspace scope required' } - } - const { displayName, description, signingSecretEnvVar, botTokenEnvVar } = params - if (!displayName) { - return { success: false, error: 'displayName is required' } - } - if (!signingSecretEnvVar || !botTokenEnvVar) { - return { - success: false, - error: - 'signingSecretEnvVar and botTokenEnvVar are required: the NAMES of the environment variables holding the Slack signing secret and bot token. Save the values with set_environment_variables first if needed.', - } - } - - const env = await getEffectiveDecryptedEnv(context.userId, workspaceId) - const missing = [signingSecretEnvVar, botTokenEnvVar].filter((name) => !env[name]) - if (missing.length > 0) { - return { - success: false, - error: `Environment variable(s) not found: ${missing.join(', ')}. Check environment/ in the VFS, or save the values with set_environment_variables first.`, - } - } - - const result = await performCreateCredential({ - workspaceId, - userId: context.userId, - type: 'service_account', - providerId: SLACK_CUSTOM_BOT_PROVIDER_ID, - displayName, - description, - signingSecret: env[signingSecretEnvVar], - botToken: env[botTokenEnvVar], - }) - if (!result.success || !result.credential) { - return { - success: false, - error: - result.error || - 'Failed to connect the Slack custom bot. If a credential with this display name already exists, reuse it (environment/credentials.json) or pick a different name.', - } - } - return { - success: true, - output: { - credentialId: result.credential.id, - displayName: result.credential.displayName, - created: result.created !== false, - // The Slack app's Event Subscriptions Request URL — one per - // credential, shared by every trigger that selects it; live - // immediately, no deployment needed. - requestUrl: buildSlackCustomBotRequestUrl(result.credential.id), - }, - } - } catch (error) { - return { success: false, error: toError(error).message } - } - })() -} diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts deleted file mode 100644 index 9b9ebb41da5..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mocks, useCases } = vi.hoisted(() => ({ - mocks: { - custom: vi.fn(), - mcp: vi.fn(), - skill: vi.fn(), - credential: vi.fn(), - capture: vi.fn(), - }, - useCases: { - saveCustom: { operation: { id: 'custom_tools.save' } }, - deleteCustom: { operation: { id: 'custom_tools.delete_available' } }, - listCustom: { operation: { id: 'custom_tools.list_available' } }, - updateCustom: { operation: { id: 'custom_tools.update_available' } }, - deleteMcp: { operation: { id: 'mcp_servers.delete' } }, - listMcp: { operation: { id: 'mcp_servers.list' } }, - reconfigureMcp: { operation: { id: 'mcp_servers.reconfigure' } }, - registerMcp: { operation: { id: 'mcp_servers.register' } }, - createSkill: { operation: { id: 'skills.create' } }, - deleteSkill: { operation: { id: 'skills.delete' } }, - listSkill: { operation: { id: 'skills.list_available' } }, - updateSkill: { operation: { id: 'skills.update' } }, - updateCredential: { operation: { id: 'credentials.update' } }, - deleteManyCredentials: { operation: { id: 'credentials.delete_many' } }, - }, -})) - -vi.mock('@/lib/copilot/application/execute-custom-tool-use-case', () => ({ - executeCopilotCustomToolUseCase: mocks.custom, -})) -vi.mock('@/lib/copilot/application/execute-mcp-server-use-case', () => ({ - executeCopilotMcpServerUseCase: mocks.mcp, -})) -vi.mock('@/lib/copilot/application/execute-skill-use-case', () => ({ - executeCopilotSkillUseCase: mocks.skill, -})) -vi.mock('@/lib/copilot/application/execute-credential-use-case', () => ({ - executeCopilotCredentialUseCase: mocks.credential, -})) -vi.mock('@/lib/custom-tools/application/use-cases', () => ({ - deleteAvailableCustomToolUseCase: useCases.deleteCustom, - listAvailableCustomToolsUseCase: useCases.listCustom, - saveWorkspaceCustomToolUseCase: useCases.saveCustom, - updateAvailableCustomToolUseCase: useCases.updateCustom, -})) -vi.mock('@/lib/mcp/application/use-cases', () => ({ - deleteMcpServerUseCase: useCases.deleteMcp, - listMcpServersUseCase: useCases.listMcp, - reconfigureMcpServerUseCase: useCases.reconfigureMcp, - registerMcpServerUseCase: useCases.registerMcp, -})) -vi.mock('@/lib/skills/application/use-cases', () => ({ - createSkillUseCase: useCases.createSkill, - deleteSkillUseCase: useCases.deleteSkill, - listAvailableSkillsUseCase: useCases.listSkill, - updateSkillUseCase: useCases.updateSkill, -})) -vi.mock('@/lib/credentials/application/credential-crud', () => ({ - updateWorkspaceCredentialUseCase: useCases.updateCredential, -})) -vi.mock('@/lib/credentials/application/delete-many-credentials', () => ({ - deleteManyCredentialsUseCase: useCases.deleteManyCredentials, -})) -vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) - -import type { ExecutionContext } from '@/lib/copilot/request/types' -import { executeManageCredential } from '@/lib/copilot/tools/handlers/management/manage-credential' -import { executeManageCustomTool } from '@/lib/copilot/tools/handlers/management/manage-custom-tool' -import { executeManageMcpTool } from '@/lib/copilot/tools/handlers/management/manage-mcp-tool' -import { executeManageSkill } from '@/lib/copilot/tools/handlers/management/manage-skill' - -const context: ExecutionContext = { - userId: 'user-1', - workflowId: '', - workspaceId: 'workspace-1', - chatId: 'chat-1', - toolCallId: 'call-1', - copilotToolExecution: true, - userPermission: 'admin', -} - -describe('Copilot management application boundaries', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('creates custom tools through the shared use case using server workspace context', async () => { - mocks.custom.mockResolvedValue({ - tool: { id: 'tool-1', title: 'lookup_order' }, - }) - - const result = await executeManageCustomTool( - { - operation: 'add', - workspaceId: 'model-workspace', - title: 'lookup_order', - schema: { - type: 'function', - function: { name: 'lookup_order', parameters: {} }, - }, - code: 'return 1', - }, - context - ) - - expect(result).toMatchObject({ success: true, output: { toolId: 'tool-1' } }) - expect(mocks.custom).toHaveBeenCalledWith(context, useCases.saveCustom, { - workspaceId: context.workspaceId, - title: 'lookup_order', - schema: { - type: 'function', - function: { name: 'lookup_order', parameters: {} }, - }, - code: 'return 1', - source: 'tool_input', - }) - }) - - it('keeps Copilot MCP registration compatibility behind its semantic operation', async () => { - mocks.mcp.mockResolvedValue({ - serverId: 'legacy-result-id', - server: { - id: 'mcp-server-1', - name: 'Docs', - transport: 'streamable-http', - }, - updated: true, - }) - - const result = await executeManageMcpTool( - { - operation: 'add', - config: { name: 'Docs', url: 'https://mcp.example.com/sse' }, - }, - context - ) - - expect(result).toMatchObject({ success: true, output: { serverId: 'mcp-server-1' } }) - expect(mocks.mcp).toHaveBeenCalledWith( - context, - useCases.registerMcp, - expect.objectContaining({ workspaceId: context.workspaceId, source: 'tool_input' }) - ) - expect(mocks.capture).not.toHaveBeenCalled() - }) - - it('delegates skill-specific edit authorization to the shared application use case', async () => { - mocks.skill.mockResolvedValue({ - skill: { id: 'skill-1', name: 'refund-policy' }, - }) - - const result = await executeManageSkill( - { operation: 'edit', skillId: 'skill-1', content: '# Updated' }, - { ...context, userPermission: 'read' } - ) - - expect(result).toMatchObject({ success: true, output: { skillId: 'skill-1' } }) - expect(mocks.skill).toHaveBeenCalledWith( - expect.objectContaining({ workspaceId: context.workspaceId }), - useCases.updateSkill, - { - workspaceId: context.workspaceId, - skillId: 'skill-1', - content: '# Updated', - source: 'tool_input', - } - ) - }) - - it('renames credentials through the shared credential use case', async () => { - mocks.credential.mockResolvedValue({ - credential: { id: 'credential-1', displayName: 'Renamed' }, - previousDisplayName: 'Original', - }) - - const result = await executeManageCredential( - { operation: 'rename', credentialId: 'credential-1', displayName: 'Renamed' }, - context - ) - - expect(result).toMatchObject({ - success: true, - output: { previousDisplayName: 'Original', displayName: 'Renamed' }, - }) - expect(mocks.credential).toHaveBeenCalledWith(context, useCases.updateCredential, { - credentialId: 'credential-1', - displayName: 'Renamed', - }) - }) - - it('keeps best-effort batch deletion inside one semantic application command', async () => { - mocks.credential.mockResolvedValue({ deleted: ['credential-1'], failed: ['credential-2'] }) - - const result = await executeManageCredential( - { operation: 'delete', credentialIds: ['credential-1', 'credential-2'] }, - context - ) - - expect(result).toMatchObject({ - success: true, - output: { deleted: ['credential-1'], failed: ['credential-2'] }, - }) - expect(mocks.credential).toHaveBeenCalledWith(context, useCases.deleteManyCredentials, { - workspaceId: 'workspace-1', - credentialIds: ['credential-1', 'credential-2'], - }) - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts deleted file mode 100644 index 07dc5d0bc44..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' -import { executeCopilotCredentialUseCase } from '@/lib/copilot/application/execute-credential-use-case' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { updateWorkspaceCredentialUseCase } from '@/lib/credentials/application/credential-crud' -import { deleteManyCredentialsUseCase } from '@/lib/credentials/application/delete-many-credentials' - -export async function executeManageCredential( - rawParams: Record, - context: ExecutionContext -): Promise { - const operation = typeof rawParams.operation === 'string' ? rawParams.operation : '' - const credentialId = - typeof rawParams.credentialId === 'string' ? rawParams.credentialId : undefined - const displayName = typeof rawParams.displayName === 'string' ? rawParams.displayName : undefined - const rawCredentialIds = rawParams.credentialIds - if ( - rawCredentialIds !== undefined && - (!Array.isArray(rawCredentialIds) || rawCredentialIds.some((id) => typeof id !== 'string')) - ) { - return { success: false, error: 'credentialIds must be an array of strings' } - } - const credentialIds = rawCredentialIds as string[] | undefined - const workspaceId = context.workspaceId - if (!workspaceId) return { success: false, error: 'workspaceId is required' } - - try { - switch (operation) { - case 'rename': { - if (!credentialId) { - return { success: false, error: 'credentialId is required for rename' } - } - if (!displayName) { - return { success: false, error: 'displayName is required for rename' } - } - const result = await executeCopilotCredentialUseCase( - context, - updateWorkspaceCredentialUseCase, - { - credentialId, - displayName, - } - ) - return { - success: true, - output: { - credentialId: result.credential.id, - previousDisplayName: result.previousDisplayName, - displayName: result.credential.displayName, - }, - } - } - case 'delete': { - const ids = credentialIds ?? (credentialId ? [credentialId] : []) - if (ids.length === 0) { - return { - success: false, - error: 'credentialId or credentialIds is required for delete', - } - } - const result = await executeCopilotCredentialUseCase( - context, - deleteManyCredentialsUseCase, - { workspaceId, credentialIds: ids } - ) - return { - success: result.deleted.length > 0, - output: { deleted: result.deleted, failed: result.failed }, - } - } - default: - return { - success: false, - error: `Unknown operation: ${operation}. Use "rename" or "delete".`, - } - } - } catch (error) { - return { - success: false, - error: messageForCopilotApplicationError(error, 'Failed to manage credential'), - } - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts deleted file mode 100644 index adb9e45f3a0..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { executeCopilotCustomToolUseCase } from '@/lib/copilot/application/execute-custom-tool-use-case' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { - deleteAvailableCustomToolUseCase, - listAvailableCustomToolsUseCase, - saveWorkspaceCustomToolUseCase, - updateAvailableCustomToolUseCase, -} from '@/lib/custom-tools/application/use-cases' -import { captureServerEvent } from '@/lib/posthog/server' - -const logger = createLogger('CopilotToolExecutor') - -type ManageCustomToolOperation = 'add' | 'edit' | 'delete' | 'list' - -interface ManageCustomToolSchema { - type: 'function' - function: { - name: string - description?: string - parameters: Record - } -} - -interface ManageCustomToolParams { - operation?: string - toolId?: string - toolIds?: string[] - schema?: ManageCustomToolSchema - code?: string - title?: string -} - -export async function executeManageCustomTool( - rawParams: Record, - context: ExecutionContext -): Promise { - const params = rawParams as ManageCustomToolParams - const operation = String(params.operation || '').toLowerCase() as ManageCustomToolOperation - /** - * Server-set context only. A model-supplied `params.workspaceId` used to win - * here, while the permission gate above is resolved for the CONTEXT - * workspace — so a caller could name another workspace and have it - * authorized against their own. `upsertCustomTools` does no authz of its own - * (it only scopes queries by the id it is handed), so nothing downstream - * caught it. Matches manage_mcp_connection and manage_skill. - */ - const workspaceId = context.workspaceId - - if (!operation) { - return { success: false, error: "Missing required 'operation' argument" } - } - - try { - if (operation === 'list') { - if (!workspaceId) return { success: false, error: 'workspaceId is required' } - const { tools: toolsForUser } = await executeCopilotCustomToolUseCase( - context, - listAvailableCustomToolsUseCase, - { workspaceId } - ) - - return { - success: true, - output: { - success: true, - operation, - tools: toolsForUser, - count: toolsForUser.length, - }, - } - } - - if (operation === 'add') { - if (!workspaceId) { - return { - success: false, - error: "workspaceId is required for operation 'add'", - } - } - if (!params.schema || !params.code) { - return { - success: false, - error: "Both 'schema' and 'code' are required for operation 'add'", - } - } - - const title = params.title || params.schema.function?.name - if (!title) { - return { success: false, error: "Missing tool title or schema.function.name for 'add'" } - } - - const { tool: created } = await executeCopilotCustomToolUseCase( - context, - saveWorkspaceCustomToolUseCase, - { - title, - schema: params.schema, - code: params.code, - source: 'tool_input', - workspaceId, - } - ) - captureServerEvent( - context.userId, - 'custom_tool_saved', - { - tool_id: created.id, - workspace_id: workspaceId, - tool_name: created.title, - source: 'tool_input', - }, - { groups: { workspace: workspaceId } } - ) - - return { - success: true, - output: { - success: true, - operation, - toolId: created.id, - title: created.title, - message: `Created custom tool "${created.title}"`, - }, - } - } - - if (operation === 'edit') { - if (!workspaceId) { - return { - success: false, - error: "workspaceId is required for operation 'edit'", - } - } - if (!params.toolId) { - return { success: false, error: "'toolId' is required for operation 'edit'" } - } - if (!params.schema && !params.code) { - return { - success: false, - error: "At least one of 'schema' or 'code' is required for operation 'edit'", - } - } - - const { tool } = await executeCopilotCustomToolUseCase( - context, - updateAvailableCustomToolUseCase, - { - workspaceId, - toolId: params.toolId, - title: params.title || params.schema?.function?.name, - schema: params.schema, - code: params.code, - source: 'tool_input', - } - ) - captureServerEvent( - context.userId, - 'custom_tool_saved', - { - tool_id: tool.id, - workspace_id: workspaceId, - tool_name: tool.title, - source: 'tool_input', - }, - { groups: { workspace: workspaceId } } - ) - - return { - success: true, - output: { - success: true, - operation, - toolId: tool.id, - title: tool.title, - message: `Updated custom tool "${tool.title}"`, - }, - } - } - - if (operation === 'delete') { - const toolIds: string[] = params.toolIds ?? (params.toolId ? [params.toolId] : []) - if (toolIds.length === 0) { - return { success: false, error: "'toolId' or 'toolIds' is required for operation 'delete'" } - } - if (!workspaceId) return { success: false, error: 'workspaceId is required' } - const deleted: string[] = [] - const notFound: string[] = [] - - for (const toolId of toolIds) { - try { - await executeCopilotCustomToolUseCase(context, deleteAvailableCustomToolUseCase, { - toolId, - workspaceId, - source: 'tool_input', - }) - deleted.push(toolId) - } catch (error) { - const classified = asOrchestrationError(error) - if (classified?.code === 'not_found') { - notFound.push(toolId) - continue - } - throw error - } - } - - for (const toolId of deleted) { - captureServerEvent( - context.userId, - 'custom_tool_deleted', - { tool_id: toolId, workspace_id: workspaceId, source: 'tool_input' }, - { groups: { workspace: workspaceId } } - ) - } - - return { - success: deleted.length > 0, - output: { - success: deleted.length > 0, - operation, - deleted, - notFound, - message: `Deleted ${deleted.length} custom tool(s)`, - }, - } - } - - return { - success: false, - error: `Unsupported operation for manage_custom_tool: ${operation}`, - } - } catch (error) { - logger.error( - context.messageId - ? `manage_custom_tool execution failed [messageId:${context.messageId}]` - : 'manage_custom_tool execution failed', - { - operation, - workspaceId, - userId: context.userId, - error: toError(error).message, - } - ) - const classified = asOrchestrationError(error) - return { - success: false, - error: - classified && classified.code !== 'internal' - ? classified.message - : `The ${operation ?? 'custom tool'} operation failed inside Sim. The write may or may not have landed — run operation "list" to check current state before retrying.`, - } - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts deleted file mode 100644 index 066fb6b8539..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { executeCopilotMcpServerUseCase } from '@/lib/copilot/application/execute-mcp-server-use-case' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { - deleteMcpServerUseCase, - listMcpServersUseCase, - reconfigureMcpServerUseCase, - registerMcpServerUseCase, -} from '@/lib/mcp/application/use-cases' -import { captureServerEvent } from '@/lib/posthog/server' - -const logger = createLogger('CopilotToolExecutor') - -type ManageMcpToolOperation = 'add' | 'edit' | 'delete' | 'list' - -interface ManageMcpToolConfig { - name?: string - transport?: string - url?: string - headers?: Record - timeout?: number - enabled?: boolean -} - -interface ManageMcpToolParams { - operation?: string - serverId?: string - config?: ManageMcpToolConfig -} - -export async function executeManageMcpTool( - rawParams: Record, - context: ExecutionContext -): Promise { - const params = rawParams as ManageMcpToolParams - const operation = String(params.operation || '').toLowerCase() as ManageMcpToolOperation - const workspaceId = context.workspaceId - - if (!operation) { - return { success: false, error: "Missing required 'operation' argument" } - } - - if (!workspaceId) { - return { success: false, error: 'workspaceId is required' } - } - - try { - if (operation === 'list') { - const { servers } = await executeCopilotMcpServerUseCase(context, listMcpServersUseCase, { - workspaceId, - }) - - return { - success: true, - output: { - success: true, - operation, - servers: servers.map((s) => ({ - id: s.id, - name: s.name, - url: s.url, - transport: s.transport, - enabled: s.enabled, - connectionStatus: s.connectionStatus, - })), - count: servers.length, - }, - } - } - - if (operation === 'add') { - const config = params.config - if (!config?.name || !config?.url) { - return { success: false, error: "config.name and config.url are required for 'add'" } - } - - const result = await executeCopilotMcpServerUseCase(context, registerMcpServerUseCase, { - workspaceId, - name: config.name, - description: '', - transport: config.transport || 'streamable-http', - url: config.url, - headers: config.headers, - timeout: config.timeout, - retries: 3, - enabled: config.enabled, - source: 'tool_input', - }) - if (!result.updated) { - captureServerEvent( - context.userId, - 'mcp_server_connected', - { - workspace_id: workspaceId, - server_name: result.server.name, - transport: result.server.transport, - source: 'tool_input', - }, - { - groups: { workspace: workspaceId }, - setOnce: { first_mcp_connected_at: new Date().toISOString() }, - } - ) - } - - return { - success: true, - output: { - success: true, - operation, - serverId: result.server.id, - name: config.name, - message: result.updated - ? `Updated existing MCP server "${config.name}"` - : `Added MCP server "${config.name}"`, - }, - } - } - - if (operation === 'edit') { - if (!params.serverId) { - return { success: false, error: "'serverId' is required for 'edit'" } - } - const config = params.config - if (!config) { - return { success: false, error: "'config' is required for 'edit'" } - } - - const result = await executeCopilotMcpServerUseCase(context, reconfigureMcpServerUseCase, { - workspaceId, - serverId: params.serverId, - name: config.name, - transport: config.transport, - url: config.url, - headers: config.headers, - timeout: config.timeout, - enabled: config.enabled, - source: 'tool_input', - }) - - return { - success: true, - output: { - success: true, - operation, - serverId: params.serverId, - name: result.server.name, - message: `Updated MCP server "${result.server.name}"`, - }, - } - } - - if (operation === 'delete') { - if (!params.serverId) { - return { success: false, error: "'serverId' is required for 'delete'" } - } - - const result = await executeCopilotMcpServerUseCase(context, deleteMcpServerUseCase, { - workspaceId, - serverId: params.serverId, - source: 'tool_input', - }) - captureServerEvent( - context.userId, - 'mcp_server_disconnected', - { - workspace_id: workspaceId, - server_name: result.server.name, - source: 'tool_input', - }, - { groups: { workspace: workspaceId } } - ) - - return { - success: true, - output: { - success: true, - operation, - serverId: params.serverId, - message: `Deleted MCP server "${result.server.name}"`, - }, - } - } - - return { - success: false, - error: `Unsupported operation for manage_mcp_connection: ${operation}`, - } - } catch (error) { - logger.error( - context.messageId - ? `manage_mcp_connection execution failed [messageId:${context.messageId}]` - : 'manage_mcp_connection execution failed', - { - operation, - workspaceId, - error: toError(error).message, - } - ) - const classified = asOrchestrationError(error) - return { - success: false, - error: - classified && classified.code !== 'internal' - ? classified.message - : `The ${operation ?? 'MCP server'} operation failed inside Sim. The write may or may not have landed — run operation "list" to check current state before retrying.`, - } - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts deleted file mode 100644 index c5b491043e9..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -/** @vitest-environment node */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mocks, useCases } = vi.hoisted(() => ({ - mocks: { - sandbox: vi.fn(), - }, - useCases: { - list: { operation: { id: 'sandboxes.list' } }, - create: { operation: { id: 'sandboxes.create' } }, - update: { operation: { id: 'sandboxes.update' } }, - delete: { operation: { id: 'sandboxes.delete' } }, - }, -})) - -vi.mock('@/lib/copilot/application/execute-sandbox-use-case', () => ({ - executeCopilotSandboxUseCase: mocks.sandbox, -})) -vi.mock('@/lib/sandboxes/application/use-cases', () => ({ - listWorkspaceSandboxesUseCase: useCases.list, - createWorkspaceSandboxUseCase: useCases.create, - updateWorkspaceSandboxUseCase: useCases.update, - deleteWorkspaceSandboxUseCase: useCases.delete, -})) -vi.mock('@/lib/core/rate-limiter', () => ({ - RateLimiter: class { - checkRateLimitDirect = vi.fn() - }, -})) -vi.mock('@/lib/execution/remote-sandbox/cli-tools', () => ({ - SANDBOX_CLI_TOOL_IDS: ['kubectl@1.36.3-r1'], - MAX_SANDBOX_CLI_TOOLS: 10, - SANDBOX_CLI_TOOLS: { - 'kubectl@1.36.3-r1': { - id: 'kubectl@1.36.3-r1', - label: 'kubectl', - description: 'Control Kubernetes clusters.', - category: 'Kubernetes', - }, - }, -})) -vi.mock('@/lib/execution/remote-sandbox/workspace-sandboxes', async () => { - const { OrchestrationError } = await import('@/lib/core/orchestration/types') - class SandboxDependencyError extends OrchestrationError { - constructor(readonly issues: { line: number; value: string; reason: string }[]) { - super('validation', issues[0]?.reason ?? 'Invalid dependency list') - } - } - class SandboxSystemPackageError extends OrchestrationError { - constructor(readonly issues: { line: number; value: string; reason: string }[]) { - super('validation', issues[0]?.reason ?? 'Invalid system package list') - } - } - return { - SANDBOX_MUTATION_LIMIT: { maxTokens: 20, refillRate: 10, refillIntervalMs: 60_000 }, - SandboxDependencyError, - SandboxSystemPackageError, - } -}) - -import type { ExecutionContext } from '@/lib/copilot/request/types' -import { ForbiddenOperationError } from '@/lib/core/application' -import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/entitlement' -import { SandboxDependencyError } from '@/lib/execution/remote-sandbox/workspace-sandboxes' -import { SandboxBuildBudgetExceededError } from '@/lib/sandboxes/application/build-budget' -import { executeManageSandbox } from './manage-sandbox' - -const context: ExecutionContext = { - userId: 'user-1', - workflowId: '', - workspaceId: 'workspace-1', - chatId: 'chat-1', - toolCallId: 'call-1', - copilotToolExecution: true, - userPermission: 'admin', -} - -const sandbox = { - id: 'sandbox-1', - name: 'data-tools', - language: 'python' as const, - dependencies: ['pandas'], - cliTools: ['kubectl@1.36.3-r1'], - systemPackages: ['graphviz'], - buildStatus: 'ready' as const, - errorCode: null, - errorMessage: null, - errorDetail: null, - builtAt: '2026-08-04T12:00:00.000Z', - createdAt: '2026-08-04T11:00:00.000Z', - updatedAt: '2026-08-04T12:00:00.000Z', -} - -describe('executeManageSandbox', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.sandbox.mockResolvedValue({ - sandboxes: [sandbox], - nextCursorKeys: null, - strategy: 'prebuilt', - entitled: true, - sortBy: 'name', - sortOrder: 'asc', - }) - }) - - it('lists through the shared use case and returns the authoritative CLI catalog', async () => { - const result = await executeManageSandbox({ operation: 'list' }, context) - - expect(mocks.sandbox).toHaveBeenCalledWith(context, useCases.list, { - workspaceId: 'workspace-1', - sortBy: 'name', - sortOrder: 'asc', - }) - expect(result).toMatchObject({ - success: true, - output: { - strategy: 'prebuilt', - entitled: true, - count: 1, - sandboxes: [sandbox], - availableCliTools: [{ id: 'kubectl@1.36.3-r1' }], - }, - }) - }) - - /** - * The list is a read, so a workspace below the Max tier still sees what it - * built; `entitled: false` is how the model learns that writes will be - * refused, instead of a refusal that hid the list. - */ - it('still lists below the Max tier and reports that writes will be refused', async () => { - mocks.sandbox.mockResolvedValue({ - sandboxes: [sandbox], - nextCursorKeys: null, - strategy: 'prebuilt', - entitled: false, - sortBy: 'name', - sortOrder: 'asc', - }) - - const result = await executeManageSandbox({ operation: 'list' }, context) - - expect(result).toMatchObject({ success: true, output: { entitled: false, count: 1 } }) - }) - - it('ignores a model-supplied workspace in favor of the server context', async () => { - await executeManageSandbox({ operation: 'list', workspaceId: 'model-workspace' }, context) - - expect(mocks.sandbox).toHaveBeenCalledWith( - context, - useCases.list, - expect.objectContaining({ workspaceId: 'workspace-1' }) - ) - }) - - it('validates then creates through the shared use case', async () => { - mocks.sandbox.mockResolvedValue({ sandbox }) - - const result = await executeManageSandbox( - { - operation: 'add', - name: ' data-tools ', - language: 'python', - dependencies: ['pandas'], - cliTools: ['kubectl@1.36.3-r1'], - systemPackages: ['graphviz'], - }, - context - ) - - expect(mocks.sandbox).toHaveBeenCalledWith(context, useCases.create, { - workspaceId: 'workspace-1', - name: 'data-tools', - language: 'python', - dependencies: ['pandas'], - cliTools: ['kubectl@1.36.3-r1'], - systemPackages: ['graphviz'], - source: 'tool_input', - }) - expect(result).toMatchObject({ success: true, output: { sandboxId: 'sandbox-1' } }) - }) - - it('rejects a malformed add before reaching the use case', async () => { - const result = await executeManageSandbox( - { operation: 'add', name: '', language: 'python' }, - context - ) - - expect(result.success).toBe(false) - expect(mocks.sandbox).not.toHaveBeenCalled() - }) - - it('edits and deletes the sandbox the model named', async () => { - mocks.sandbox.mockResolvedValue({ sandbox }) - - await executeManageSandbox( - { operation: 'edit', sandboxId: 'sandbox-1', dependencies: ['pandas', 'numpy'] }, - context - ) - expect(mocks.sandbox).toHaveBeenCalledWith(context, useCases.update, { - workspaceId: 'workspace-1', - sandboxId: 'sandbox-1', - dependencies: ['pandas', 'numpy'], - source: 'tool_input', - }) - - mocks.sandbox.mockResolvedValue({ sandbox }) - const deleted = await executeManageSandbox( - { operation: 'delete', sandboxId: 'sandbox-1' }, - context - ) - expect(mocks.sandbox).toHaveBeenCalledWith(context, useCases.delete, { - workspaceId: 'workspace-1', - sandboxId: 'sandbox-1', - source: 'tool_input', - }) - expect(deleted).toMatchObject({ success: true, output: { sandboxId: 'sandbox-1' } }) - }) - - it('requires a sandbox id for edit and delete', async () => { - const result = await executeManageSandbox({ operation: 'delete' }, context) - - expect(result).toMatchObject({ success: false, error: expect.stringContaining('sandboxId') }) - expect(mocks.sandbox).not.toHaveBeenCalled() - }) - - it('surfaces the plan refusal the use case raised', async () => { - mocks.sandbox.mockRejectedValue( - new ForbiddenOperationError('WORKSPACE_PLAN_CAPABILITY_REQUIRED', MAX_PLAN_REQUIRED) - ) - - const result = await executeManageSandbox( - { operation: 'add', name: 'data-tools', language: 'python' }, - context - ) - - expect(result).toEqual({ success: false, error: MAX_PLAN_REQUIRED }) - }) - - it('tells the model not to retry a spent build budget', async () => { - mocks.sandbox.mockRejectedValue( - new SandboxBuildBudgetExceededError(new Date(Date.now() + 60_000), 60_000) - ) - - const result = await executeManageSandbox( - { operation: 'add', name: 'data-tools', language: 'python' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('do not retry now') - }) - - it('addresses a refused dependency line back to its row', async () => { - mocks.sandbox.mockRejectedValue( - new SandboxDependencyError([{ line: 2, value: 'not a package!', reason: 'invalid name' }]) - ) - - const result = await executeManageSandbox( - { operation: 'add', name: 'data-tools', language: 'python', dependencies: ['a', 'b'] }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('dependencies line 2') - }) - - it('hides an unclassified failure behind the retry guidance', async () => { - mocks.sandbox.mockRejectedValue(new Error('connection refused')) - - const result = await executeManageSandbox( - { operation: 'delete', sandboxId: 'sandbox-1' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).not.toContain('connection refused') - expect(result.error).toContain('run operation "list"') - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts deleted file mode 100644 index 313f44e8be9..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-sandbox.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { createSandboxBodySchema, updateSandboxBodySchema } from '@/lib/api/contracts/sandboxes' -import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' -import { executeCopilotSandboxUseCase } from '@/lib/copilot/application/execute-sandbox-use-case' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { SANDBOX_CLI_TOOLS } from '@/lib/execution/remote-sandbox/cli-tools' -import { - SandboxDependencyError, - SandboxSystemPackageError, -} from '@/lib/execution/remote-sandbox/workspace-sandboxes' -import { SandboxBuildBudgetExceededError } from '@/lib/sandboxes/application/build-budget' -import { - createWorkspaceSandboxUseCase, - deleteWorkspaceSandboxUseCase, - listWorkspaceSandboxesUseCase, - updateWorkspaceSandboxUseCase, -} from '@/lib/sandboxes/application/use-cases' - -const logger = createLogger('CopilotManageSandbox') - -type ManageSandboxOperation = 'add' | 'edit' | 'delete' | 'list' - -interface ManageSandboxParams { - operation?: string - sandboxId?: string - name?: string - language?: string - dependencies?: string[] - cliTools?: string[] - systemPackages?: string[] -} - -function validationMessage(error: SandboxDependencyError | SandboxSystemPackageError): string { - const field = error instanceof SandboxDependencyError ? 'dependencies' : 'systemPackages' - const issues = error.issues - .map((issue) => `${field} line ${issue.line} (${JSON.stringify(issue.value)}): ${issue.reason}`) - .join('; ') - return `${error.message}${issues ? ` — ${issues}` : ''}` -} - -/** - * Maps expected application failures to something the model can act on. A - * spent build budget must not be retried; a refused line names the row; every - * other classified refusal (plan, role, conflict, not found) carries its own - * message; anything else is the generic retry-with-list guidance, with the - * cause kept in server logs. - */ -function sandboxErrorMessage(error: unknown, operation: ManageSandboxOperation): string { - if (error instanceof SandboxBuildBudgetExceededError) { - return `Rate limit exceeded for sandbox ${operation} in this workspace — do not retry now; continue with other work or tell the user the limit was hit.` - } - const classified = asOrchestrationError(error) - if ( - classified instanceof SandboxDependencyError || - classified instanceof SandboxSystemPackageError - ) { - return validationMessage(classified) - } - return messageForCopilotApplicationError( - error, - `The ${operation} operation failed inside Sim. The write may or may not have landed — run operation "list" to check current state before retrying.` - ) -} - -/** Executes the Mothership agent's Sim-sandbox management tool. */ -export async function executeManageSandbox( - rawParams: Record, - context: ExecutionContext -): Promise { - const params = rawParams as ManageSandboxParams - const operation = String(params.operation || '').toLowerCase() as ManageSandboxOperation - /** - * Server-set context only. The use case authorizes against the workspace the - * delegated principal carries, so a model-supplied workspace could never win - * here — but it must not be read at all, or a mismatch would surface as a - * confusing refusal rather than never arising. - */ - const workspaceId = context.workspaceId - - if (!workspaceId) return { success: false, error: 'workspaceId is required' } - if (!['add', 'edit', 'delete', 'list'].includes(operation)) { - return { success: false, error: "operation must be 'add', 'edit', 'delete', or 'list'" } - } - - try { - if (operation === 'list') { - const { sandboxes, strategy, entitled } = await executeCopilotSandboxUseCase( - context, - listWorkspaceSandboxesUseCase, - { workspaceId, sortBy: 'name', sortOrder: 'asc' } - ) - return { - success: true, - output: { - success: true, - operation, - strategy, - /** False below the Max tier: add, edit, and delete will be refused. */ - entitled, - sandboxes, - count: sandboxes.length, - availableCliTools: Object.values(SANDBOX_CLI_TOOLS), - }, - } - } - - if (operation === 'add') { - const parsed = createSandboxBodySchema.safeParse({ - name: params.name, - language: params.language, - dependencies: params.dependencies ?? [], - cliTools: params.cliTools ?? [], - systemPackages: params.systemPackages ?? [], - }) - if (!parsed.success) return { success: false, error: parsed.error.issues[0]?.message } - - const { sandbox } = await executeCopilotSandboxUseCase( - context, - createWorkspaceSandboxUseCase, - { workspaceId, ...parsed.data, source: 'tool_input' } - ) - return { - success: true, - output: { - success: true, - operation, - sandboxId: sandbox.id, - sandbox, - message: `Created Sim sandbox "${sandbox.name}"`, - }, - } - } - - if (!params.sandboxId) { - return { success: false, error: `'sandboxId' is required for operation '${operation}'` } - } - - if (operation === 'edit') { - const parsed = updateSandboxBodySchema.safeParse({ - ...(params.name !== undefined ? { name: params.name } : {}), - ...(params.language !== undefined ? { language: params.language } : {}), - ...(params.dependencies !== undefined ? { dependencies: params.dependencies } : {}), - ...(params.cliTools !== undefined ? { cliTools: params.cliTools } : {}), - ...(params.systemPackages !== undefined ? { systemPackages: params.systemPackages } : {}), - }) - if (!parsed.success) return { success: false, error: parsed.error.issues[0]?.message } - - const { sandbox } = await executeCopilotSandboxUseCase( - context, - updateWorkspaceSandboxUseCase, - { workspaceId, sandboxId: params.sandboxId, ...parsed.data, source: 'tool_input' } - ) - return { - success: true, - output: { - success: true, - operation, - sandboxId: sandbox.id, - sandbox, - message: `Updated Sim sandbox "${sandbox.name}"`, - }, - } - } - - await executeCopilotSandboxUseCase(context, deleteWorkspaceSandboxUseCase, { - workspaceId, - sandboxId: params.sandboxId, - source: 'tool_input', - }) - return { - success: true, - output: { - success: true, - operation, - sandboxId: params.sandboxId, - message: `Deleted Sim sandbox ${params.sandboxId}`, - }, - } - } catch (error) { - logger.error('Failed to manage Sim sandbox', { - workspaceId, - operation, - error: toError(error), - }) - return { success: false, error: sandboxErrorMessage(error, operation) } - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts deleted file mode 100644 index d17bac0bfd6..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { executeCopilotSkillUseCase } from '@/lib/copilot/application/execute-skill-use-case' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { captureServerEvent } from '@/lib/posthog/server' -import { - createSkillUseCase, - deleteSkillUseCase, - listAvailableSkillsUseCase, - updateSkillUseCase, -} from '@/lib/skills/application/use-cases' - -const logger = createLogger('CopilotToolExecutor') - -type ManageSkillOperation = 'add' | 'edit' | 'delete' | 'list' - -interface ManageSkillParams { - operation?: string - skillId?: string - name?: string - description?: string - content?: string -} - -export async function executeManageSkill( - rawParams: Record, - context: ExecutionContext -): Promise { - const params = rawParams as ManageSkillParams - const operation = String(params.operation || '').toLowerCase() as ManageSkillOperation - const workspaceId = context.workspaceId - - if (!operation) { - return { success: false, error: "Missing required 'operation' argument" } - } - - if (!workspaceId) { - return { success: false, error: 'workspaceId is required' } - } - - try { - if (operation === 'list') { - const { skills } = await executeCopilotSkillUseCase(context, listAvailableSkillsUseCase, { - workspaceId, - }) - - return { - success: true, - output: { - success: true, - operation, - skills: skills.map((s) => ({ - id: s.id, - name: s.name, - description: s.description, - createdAt: s.createdAt, - })), - count: skills.length, - }, - } - } - - if (operation === 'add') { - if (!params.name || !params.description || !params.content) { - return { - success: false, - error: "'name', 'description', and 'content' are required for 'add'", - } - } - - const { skill } = await executeCopilotSkillUseCase(context, createSkillUseCase, { - workspaceId, - name: params.name, - description: params.description, - content: params.content, - source: 'tool_input', - }) - captureServerEvent( - context.userId, - 'skill_created', - { - skill_id: skill.id, - skill_name: skill.name, - workspace_id: workspaceId, - source: 'tool_input', - }, - { groups: { workspace: workspaceId } } - ) - - return { - success: true, - output: { - success: true, - operation, - skillId: skill.id, - name: skill.name, - message: `Created skill "${skill.name}"`, - }, - } - } - - if (operation === 'edit') { - if (!params.skillId) { - return { success: false, error: "'skillId' is required for 'edit'" } - } - if (!params.name && !params.description && !params.content) { - return { - success: false, - error: "At least one of 'name', 'description', or 'content' is required for 'edit'", - } - } - - const { skill } = await executeCopilotSkillUseCase(context, updateSkillUseCase, { - workspaceId, - skillId: params.skillId, - ...(params.name ? { name: params.name } : {}), - ...(params.description ? { description: params.description } : {}), - ...(params.content ? { content: params.content } : {}), - source: 'tool_input', - }) - captureServerEvent( - context.userId, - 'skill_updated', - { - skill_id: skill.id, - skill_name: skill.name, - workspace_id: workspaceId, - source: 'tool_input', - }, - { groups: { workspace: workspaceId } } - ) - - return { - success: true, - output: { - success: true, - operation, - skillId: skill.id, - name: skill.name, - message: `Updated skill "${skill.name}"`, - }, - } - } - - if (operation === 'delete') { - if (!params.skillId) { - return { success: false, error: "'skillId' is required for 'delete'" } - } - - const { skill } = await executeCopilotSkillUseCase(context, deleteSkillUseCase, { - workspaceId, - skillId: params.skillId, - source: 'tool_input', - }) - captureServerEvent( - context.userId, - 'skill_deleted', - { skill_id: skill.id, workspace_id: workspaceId, source: 'tool_input' }, - { groups: { workspace: workspaceId } } - ) - - return { - success: true, - output: { - success: true, - operation, - skillId: skill.id, - message: 'Deleted skill', - }, - } - } - - return { success: false, error: `Unsupported operation for manage_skill: ${operation}` } - } catch (error) { - logger.error( - context.messageId - ? `manage_skill execution failed [messageId:${context.messageId}]` - : 'manage_skill execution failed', - { - operation, - workspaceId, - error: toError(error).message, - } - ) - const classified = asOrchestrationError(error) - return { - success: false, - error: - classified && classified.code !== 'internal' - ? classified.message - : 'Failed to manage skill', - } - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts deleted file mode 100644 index b84a7610b6a..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts +++ /dev/null @@ -1,841 +0,0 @@ -/** - * @vitest-environment node - */ -import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockAllocateUniqueWorkspaceFileName, - mockAdmitCreateWorkspaceFile, - mockCheckStorageQuotaForBillingContext, - mockDecompress, - mockFetchBuffer, - mockFindFolder, - mockFindUpload, - mockGetBoundWorkspaceFileSecretProvenance, - mockGetWorkspaceFile, - mockHasCloudStorage, - mockHeadObject, - mockIncrementStorageUsageForBillingContextInTx, - mockMaybeNotifyStorageLimitForBillingContext, - mockReadWorkspaceFileMetadata, - mockResolveStorageBillingContext, -} = vi.hoisted(() => ({ - mockAllocateUniqueWorkspaceFileName: vi.fn(), - mockAdmitCreateWorkspaceFile: vi.fn(), - mockCheckStorageQuotaForBillingContext: vi.fn(), - mockDecompress: vi.fn(), - mockFetchBuffer: vi.fn(), - mockFindFolder: vi.fn(), - mockFindUpload: vi.fn(), - mockGetBoundWorkspaceFileSecretProvenance: vi.fn(), - mockGetWorkspaceFile: vi.fn(), - mockHasCloudStorage: vi.fn(), - mockHeadObject: vi.fn(), - mockIncrementStorageUsageForBillingContextInTx: vi.fn(), - mockMaybeNotifyStorageLimitForBillingContext: vi.fn(), - mockReadWorkspaceFileMetadata: vi.fn(), - mockResolveStorageBillingContext: vi.fn(), -})) - -vi.mock('@/lib/copilot/tools/handlers/access', () => ({ - ensureWorkspaceAccess: vi.fn(), -})) - -vi.mock('@/lib/copilot/tools/handlers/upload-file-reader', () => ({ - findMothershipUploadRowByChatAndName: mockFindUpload, -})) - -vi.mock('@/lib/uploads', () => ({ - getServePathPrefix: () => '/api/files/serve/', -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - allocateUniqueWorkspaceFileName: mockAllocateUniqueWorkspaceFileName, - fetchWorkspaceFileBuffer: mockFetchBuffer, - getWorkspaceFile: mockGetWorkspaceFile, -})) - -vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ - readWorkspaceFileMetadata: { execute: mockReadWorkspaceFileMetadata }, -})) - -vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ - admitCreateWorkspaceFile: mockAdmitCreateWorkspaceFile, -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ - getBoundWorkspaceFileSecretProvenance: mockGetBoundWorkspaceFileSecretProvenance, -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ - findWorkspaceFileFolderIdByPath: mockFindFolder, -})) - -vi.mock('@/lib/uploads/archive', () => ({ - decompressArchiveBufferToWorkspaceFiles: mockDecompress, - ArchiveError: class ArchiveError extends Error { - reason: string - entryName?: string - constructor(reason: string, message: string, entryName?: string) { - super(message) - this.name = 'ArchiveError' - this.reason = reason - this.entryName = entryName - } - }, - MAX_ARCHIVE_BYTES: 100 * 1024 * 1024, -})) - -vi.mock('@/lib/uploads/core/storage-service', () => ({ - hasCloudStorage: mockHasCloudStorage, - headObject: mockHeadObject, -})) - -vi.mock('@/lib/billing/storage', () => ({ - checkStorageQuotaForBillingContext: mockCheckStorageQuotaForBillingContext, - incrementStorageUsageForBillingContextInTx: mockIncrementStorageUsageForBillingContextInTx, - maybeNotifyStorageLimitForBillingContext: mockMaybeNotifyStorageLimitForBillingContext, - resolveStorageBillingContext: mockResolveStorageBillingContext, -})) - -vi.mock('@/lib/copilot/vfs/path-utils', () => ({ - canonicalWorkspaceFilePath: vi.fn( - ({ name }: { name: string }) => `files/${encodeURIComponent(name)}` - ), - encodeVfsPathSegments: (segments: string[]) => - segments.map((s) => encodeURIComponent(s)).join('/'), -})) - -vi.mock('@/lib/workflows/operations/import-export', () => ({ parseWorkflowJson: vi.fn() })) -/** Only the import size cap is read from `import-workflow`; its orchestration dependency is the whole deploy graph. */ -vi.mock('@/lib/workflows/orchestration', () => ({ - performCreateWorkflow: vi.fn(), - performCreateWorkflowTransition: vi.fn(), -})) -vi.mock('@/lib/workflows/persistence/utils', () => ({ saveWorkflowToNormalizedTables: vi.fn() })) -vi.mock('@/lib/workflows/utils', () => ({ deduplicateWorkflowName: vi.fn() })) -vi.mock('@/app/api/v1/admin/types', () => ({ extractWorkflowMetadata: vi.fn() })) - -import type { ExecutionContext } from '@/lib/copilot/request/types' -import { executeMaterializeFile } from '@/lib/copilot/tools/handlers/materialize-file' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' -import { deduplicateWorkflowName } from '@/lib/workflows/utils' -import { extractWorkflowMetadata } from '@/app/api/v1/admin/types' - -const fetchWorkspaceFileBufferMock = vi.mocked(fetchWorkspaceFileBuffer) -const parseWorkflowJsonMock = vi.mocked(parseWorkflowJson) -const saveWorkflowToNormalizedTablesMock = vi.mocked(saveWorkflowToNormalizedTables) -const deduplicateWorkflowNameMock = vi.mocked(deduplicateWorkflowName) -const extractWorkflowMetadataMock = vi.mocked(extractWorkflowMetadata) - -const context = { - chatId: 'chat-1', - workspaceId: 'ws-1', - userId: 'user-1', - workflowId: 'wf-1', - copilotToolExecution: true, - toolCallId: 'materialize-file-test', -} as ExecutionContext - -mockReadWorkspaceFileMetadata.mockImplementation( - async ({ input }: { input: { fileId: string; assertedWorkspaceId?: string } }) => ({ - file: await mockGetWorkspaceFile( - input.assertedWorkspaceId ?? context.workspaceId, - input.fileId, - { - throwOnError: true, - } - ), - }) -) - -const STORAGE_CONTEXT = { - workspaceId: 'ws-1', - billedAccountUserId: 'workspace-owner', - billingEntity: { type: 'organization' as const, id: 'workspace-org' }, - plan: 'team_25000', - customStorageLimitGB: null, -} - -const POSTGRES_INT4_MAX = 2_147_483_647 -const OVERSIZED_BYTES = 3 * 1024 * 1024 * 1024 - -const mothershipRow = { - id: 'file-1', - key: 'mothership/file-1', - userId: 'user-1', - workspaceId: 'ws-1', - folderId: null, - context: 'mothership', - chatId: 'chat-1', - originalName: 'upload.txt', - displayName: 'report.txt', - contentType: 'text/plain', - sizeBytes: 100, - deletedAt: null, - uploadedAt: new Date('2026-01-01'), - updatedAt: new Date('2026-01-01'), -} - -describe('executeMaterializeFile - workspace write gate', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - }) - - it.each(['save', 'import', 'extract'])( - 'refuses %s without workspace write access and touches no upload', - async (operation) => { - const { ensureWorkspaceAccess } = await import('@/lib/copilot/tools/handlers/access') - const denial = new Error('Write access required for this workspace') - if (operation === 'import') { - vi.mocked(ensureWorkspaceAccess).mockRejectedValueOnce(denial) - } else { - mockAdmitCreateWorkspaceFile.mockRejectedValueOnce(denial) - } - - const result = await executeMaterializeFile({ fileNames: ['a.json'], operation }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('Write access required') - expect(mockFindUpload).not.toHaveBeenCalled() - } - ) - - it('requires write, not merely read, access', async () => { - await executeMaterializeFile({ fileNames: ['a.json'], operation: 'save' }, context) - - expect(mockAdmitCreateWorkspaceFile).toHaveBeenCalledWith( - expect.objectContaining({ kind: 'delegated', subjectUserId: context.userId }), - context.workspaceId - ) - }) -}) - -describe('executeMaterializeFile - unsupported operation', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - }) - - it('rejects the table operation and points to the table subagent', async () => { - const result = await executeMaterializeFile( - { fileNames: ['data.csv'], operation: 'table' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('Unsupported save_upload operation "table"') - expect(result.error).toContain('table subagent') - expect(mockFindUpload).not.toHaveBeenCalled() - }) - - it('rejects the manage_knowledge_base operation and points to the knowledge subagent', async () => { - const result = await executeMaterializeFile( - { fileNames: ['data.csv'], operation: 'manage_knowledge_base' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('Unsupported save_upload operation "manage_knowledge_base"') - expect(result.error).toContain('knowledge subagent') - expect(mockFindUpload).not.toHaveBeenCalled() - }) -}) - -describe('executeMaterializeFile - workflow import', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - mockFindUpload.mockResolvedValue({ - ...mothershipRow, - originalName: 'workflow.json', - displayName: 'workflow.json', - contentType: 'application/json', - }) - fetchWorkspaceFileBufferMock.mockResolvedValue(Buffer.from('{"metadata":{}}')) - parseWorkflowJsonMock.mockReturnValue({ - data: { blocks: {}, edges: [], loops: {}, parallels: {}, variables: [] }, - errors: [], - }) - extractWorkflowMetadataMock.mockReturnValue({ - name: 'Imported Workflow', - description: 'PRIVATE WORKFLOW DESCRIPTION', - }) - deduplicateWorkflowNameMock.mockResolvedValue('Imported Workflow') - saveWorkflowToNormalizedTablesMock.mockResolvedValue({ success: true }) - }) - - it('does not persist the uploaded workflow description', async () => { - const result = await executeMaterializeFile( - { fileNames: ['workflow.json'], operation: 'import' }, - context - ) - - expect(result.success).toBe(true) - const insertedWorkflow = dbChainMockFns.values.mock.calls[0]?.[0] as Record - expect(insertedWorkflow).toMatchObject({ name: 'Imported Workflow' }) - expect(insertedWorkflow).not.toHaveProperty('description') - expect(JSON.stringify(dbChainMockFns.values.mock.calls)).not.toContain( - 'PRIVATE WORKFLOW DESCRIPTION' - ) - }) - - /** - * Copilot is a surface adapter, not an exemption. The imported graph comes - * from a file the user uploaded, so it is exactly the caller-supplied - * whole-graph write the integration allowlist judges — and the subject is the - * person chatting. - */ - it('names the chatting user as the subject the permission group governs', async () => { - await executeMaterializeFile({ fileNames: ['workflow.json'], operation: 'import' }, context) - - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledWith( - expect.any(String), - expect.anything(), - { workspaceId: 'ws-1', subjectUserId: 'user-1' } - ) - }) - - it('surfaces the shared write refusal and rolls the shell workflow row back', async () => { - saveWorkflowToNormalizedTablesMock.mockRejectedValue( - new OrchestrationError( - 'forbidden', - 'Block type "gmail" is not allowed by your organization\'s permission group' - ) - ) - - const result = await executeMaterializeFile( - { fileNames: ['workflow.json'], operation: 'import' }, - context - ) - - expect(result.success).toBe(false) - expect( - (result.output as { failed: { fileName: string; error: string }[] }).failed[0].error - ).toContain('gmail') - expect(dbChainMockFns.delete).toHaveBeenCalled() - }) -}) - -describe('executeMaterializeFile - save storage transition', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - mockFindUpload.mockResolvedValue(mothershipRow) - mockAllocateUniqueWorkspaceFileName.mockResolvedValue('report.txt') - mockGetWorkspaceFile.mockResolvedValue({ id: 'file-1', name: 'report.txt' }) - mockHeadObject.mockResolvedValue({ size: 250, contentType: 'text/plain' }) - mockHasCloudStorage.mockReturnValue(true) - mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT) - mockCheckStorageQuotaForBillingContext.mockResolvedValue({ allowed: true }) - mockIncrementStorageUsageForBillingContextInTx.mockResolvedValue(1_250) - mockMaybeNotifyStorageLimitForBillingContext.mockResolvedValue(undefined) - dbChainMockFns.returning.mockResolvedValue([{ id: 'file-1', originalName: 'report.txt' }]) - }) - - it('HEADs before the transaction and accounts the verified object size', async () => { - let transactionOpen = false - mockHeadObject.mockImplementationOnce(async () => { - expect(transactionOpen).toBe(false) - return { size: 250, contentType: 'text/plain' } - }) - dbChainMockFns.transaction.mockImplementationOnce( - async (callback: (tx: typeof dbChainMock.db) => unknown) => { - transactionOpen = true - try { - return await callback(dbChainMock.db) - } finally { - transactionOpen = false - } - } - ) - mockIncrementStorageUsageForBillingContextInTx.mockImplementationOnce( - async (_tx, _billingContext, bytes) => { - expect(transactionOpen).toBe(true) - expect(bytes).toBe(250) - return 1_250 - } - ) - - const result = await executeMaterializeFile( - { fileNames: ['report.txt'], operation: 'save' }, - context - ) - - expect(result.success).toBe(true) - expect(mockHeadObject).toHaveBeenCalledWith('mothership/file-1', 'mothership') - expect(mockCheckStorageQuotaForBillingContext).toHaveBeenCalledWith(STORAGE_CONTEXT, 250) - expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenCalledWith( - context.workspaceId, - 'report.txt', - null - ) - expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ context: 'workspace', chatId: null, sizeBytes: 250 }) - ) - expect(mockMaybeNotifyStorageLimitForBillingContext).toHaveBeenCalledWith( - STORAGE_CONTEXT, - 1_250 - ) - }) - - it('writes the exact byte count above the int4 ceiling without the legacy projection', async () => { - mockHeadObject.mockResolvedValue({ size: OVERSIZED_BYTES, contentType: 'text/plain' }) - - const result = await executeMaterializeFile( - { fileNames: ['report.txt'], operation: 'save' }, - context - ) - - expect(result.success).toBe(true) - const [updateSet] = dbChainMockFns.set.mock.calls.at(-1) as [Record] - expect(updateSet).not.toHaveProperty('size') - expect(updateSet.sizeBytes).toBe(OVERSIZED_BYTES) - expect(mockCheckStorageQuotaForBillingContext).toHaveBeenCalledWith( - STORAGE_CONTEXT, - OVERSIZED_BYTES - ) - expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( - expect.anything(), - STORAGE_CONTEXT, - OVERSIZED_BYTES - ) - }) - - it('uses the exact stored byte count when object metadata is unavailable', async () => { - mockHeadObject.mockResolvedValue(null) - mockHasCloudStorage.mockReturnValue(false) - mockFindUpload.mockResolvedValue({ - ...mothershipRow, - size: POSTGRES_INT4_MAX, - sizeBytes: OVERSIZED_BYTES, - }) - - const result = await executeMaterializeFile( - { fileNames: ['report.txt'], operation: 'save' }, - context - ) - - expect(result.success).toBe(true) - const [updateSet] = dbChainMockFns.set.mock.calls.at(-1) as [Record] - expect(updateSet.sizeBytes).toBe(OVERSIZED_BYTES) - expect(updateSet).not.toHaveProperty('size') - expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( - expect.anything(), - STORAGE_CONTEXT, - OVERSIZED_BYTES - ) - }) - - it('materializes with an available root-level copy name', async () => { - mockFindUpload.mockResolvedValueOnce({ - ...mothershipRow, - originalName: 'image.png', - displayName: 'image.png', - }) - mockAllocateUniqueWorkspaceFileName.mockResolvedValueOnce('image (1).png') - dbChainMockFns.returning.mockResolvedValueOnce([ - { id: 'file-1', originalName: 'image (1).png' }, - ]) - - const result = await executeMaterializeFile( - { fileNames: ['image.png'], operation: 'save' }, - context - ) - - expect(result.success).toBe(true) - expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenCalledWith( - context.workspaceId, - 'image.png', - null - ) - expect(dbChainMockFns.set).toHaveBeenCalledWith( - expect.objectContaining({ - context: 'workspace', - originalName: 'image (1).png', - displayName: 'image (1).png', - }) - ) - expect(result.output).toEqual({ succeeded: ['image (1).png'], failed: [] }) - expect(result.resources).toEqual([{ type: 'file', id: 'file-1', title: 'image (1).png' }]) - }) - - it('reallocates and retries when a concurrent root-level write claims the name', async () => { - const nameCollision = Object.assign(new Error('duplicate workspace file name'), { - code: '23505', - constraint_name: 'workspace_files_workspace_folder_name_active_unique', - }) - mockFindUpload.mockResolvedValueOnce({ - ...mothershipRow, - originalName: 'image.png', - displayName: 'image.png', - }) - mockAllocateUniqueWorkspaceFileName - .mockResolvedValueOnce('image (1).png') - .mockResolvedValueOnce('image (2).png') - dbChainMockFns.returning - .mockRejectedValueOnce(nameCollision) - .mockResolvedValueOnce([{ id: 'file-1', originalName: 'image (2).png' }]) - - const result = await executeMaterializeFile( - { fileNames: ['image.png'], operation: 'save' }, - context - ) - - expect(result.success).toBe(true) - expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenCalledTimes(2) - expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenNthCalledWith( - 1, - context.workspaceId, - 'image.png', - null - ) - expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenNthCalledWith( - 2, - context.workspaceId, - 'image.png', - null - ) - expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(2) - expect(dbChainMockFns.set).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ originalName: 'image (1).png' }) - ) - expect(dbChainMockFns.set).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ originalName: 'image (2).png' }) - ) - expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledTimes(1) - expect(result.output).toEqual({ succeeded: ['image (2).png'], failed: [] }) - expect(result.resources).toEqual([{ type: 'file', id: 'file-1', title: 'image (2).png' }]) - }) - - it('stops after the bounded number of root-level name collisions', async () => { - const nameCollision = Object.assign(new Error('duplicate workspace file name'), { - code: '23505', - constraint_name: 'workspace_files_workspace_folder_name_active_unique', - }) - dbChainMockFns.returning.mockRejectedValue(nameCollision) - - const result = await executeMaterializeFile( - { fileNames: ['report.txt'], operation: 'save' }, - context - ) - - expect(result.success).toBe(false) - expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenCalledTimes(8) - expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(8) - expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() - expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled() - }) - - it('does not retry unique violations from a different constraint', async () => { - const keyCollision = Object.assign(new Error('duplicate workspace file key'), { - code: '23505', - constraint_name: 'workspace_files_key_active_unique', - }) - dbChainMockFns.returning.mockRejectedValueOnce(keyCollision) - - const result = await executeMaterializeFile( - { fileNames: ['report.txt'], operation: 'save' }, - context - ) - - expect(result.success).toBe(false) - expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenCalledTimes(1) - expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) - expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() - }) - - it('treats a lost conditional transition as a replay no-op', async () => { - dbChainMockFns.returning.mockResolvedValueOnce([]) - mockGetWorkspaceFile.mockResolvedValueOnce({ id: 'file-1', name: 'report (1).txt' }) - - const result = await executeMaterializeFile( - { fileNames: ['report.txt'], operation: 'save' }, - context - ) - - expect(result.success).toBe(true) - expect(mockGetWorkspaceFile).toHaveBeenCalledWith(context.workspaceId, 'file-1', { - throwOnError: true, - }) - expect(result.output).toEqual({ succeeded: ['report (1).txt'], failed: [] }) - expect(result.resources).toEqual([{ type: 'file', id: 'file-1', title: 'report (1).txt' }]) - expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() - expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled() - }) - - it('fails a replay when the materialized workspace file no longer exists', async () => { - dbChainMockFns.returning.mockResolvedValueOnce([]) - mockGetWorkspaceFile.mockResolvedValueOnce(null) - - const result = await executeMaterializeFile( - { fileNames: ['report.txt'], operation: 'save' }, - context - ) - - expect(result.success).toBe(false) - expect(result.output).toEqual({ - succeeded: [], - failed: [ - { - fileName: 'report.txt', - error: 'Upload no longer available: "report.txt".', - }, - ], - }) - expect(mockGetWorkspaceFile).toHaveBeenCalledWith(context.workspaceId, 'file-1', { - throwOnError: true, - }) - expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() - expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled() - }) - - it('leaves the mothership row untouched when pre-admission rejects quota', async () => { - mockCheckStorageQuotaForBillingContext.mockResolvedValueOnce({ - allowed: false, - error: 'Storage limit exceeded', - }) - - const result = await executeMaterializeFile( - { fileNames: ['report.txt'], operation: 'save' }, - context - ) - - expect(result.success).toBe(false) - expect(dbChainMockFns.transaction).not.toHaveBeenCalled() - expect(dbChainMockFns.update).not.toHaveBeenCalled() - expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() - }) - - it('fails atomically when the in-transaction quota recheck rejects', async () => { - mockIncrementStorageUsageForBillingContextInTx.mockRejectedValueOnce( - new Error('Storage limit exceeded') - ) - - const result = await executeMaterializeFile( - { fileNames: ['report.txt'], operation: 'save' }, - context - ) - - expect(result.success).toBe(false) - expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) - expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( - expect.anything(), - STORAGE_CONTEXT, - 250 - ) - expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled() - }) - - it('fails on a stale payer instead of charging a new payer', async () => { - mockIncrementStorageUsageForBillingContextInTx.mockRejectedValueOnce( - new Error('Storage payer changed for workspace ws-1') - ) - - const result = await executeMaterializeFile( - { fileNames: ['report.txt'], operation: 'save' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('report.txt') - expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled() - }) -}) - -describe('executeMaterializeFile - extract operation', () => { - beforeEach(() => { - vi.clearAllMocks() - mockFindFolder.mockResolvedValue(null) - mockGetBoundWorkspaceFileSecretProvenance.mockResolvedValue({ - status: 'exact', - entries: [], - }) - }) - - function zipRow(overrides: Record = {}) { - return { - id: 'wf_zip', - key: 'mothership/abc/bundle.zip', - userId: 'user-1', - workspaceId: 'ws-1', - context: 'mothership', - chatId: 'chat-1', - originalName: 'bundle.zip', - displayName: 'bundle.zip', - contentType: 'application/zip', - sizeBytes: 2048, - deletedAt: null, - uploadedAt: new Date(), - updatedAt: new Date(), - ...overrides, - } - } - - it('dispatches to the archive extractor and returns the unpacked files', async () => { - mockFindUpload.mockResolvedValue(zipRow()) - mockFetchBuffer.mockResolvedValue(Buffer.from('zip-bytes')) - mockDecompress.mockResolvedValue({ - extracted: [ - { id: 'f1', name: 'a.txt', url: '/x', size: 1, type: 'text/plain', key: 'k1' }, - { id: 'f2', name: 'b.txt', url: '/y', size: 2, type: 'text/plain', key: 'k2' }, - ], - skipped: 0, - skippedUnsafePaths: [], - }) - - const result = await executeMaterializeFile( - { fileNames: ['bundle.zip'], operation: 'extract' }, - context - ) - - expect(result.success).toBe(true) - expect(mockDecompress).toHaveBeenCalledTimes(1) - expect(mockDecompress).toHaveBeenCalledWith( - expect.any(Buffer), - expect.objectContaining({ - workspaceId: 'ws-1', - principal: expect.objectContaining({ - kind: 'delegated', - subjectUserId: 'user-1', - workspaceId: 'ws-1', - }), - rootFolderSegments: ['bundle'], - skipNoiseEntries: true, - secretProvenance: { status: 'exact', entries: [] }, - }) - ) - expect(result.output).toMatchObject({ succeeded: ['bundle.zip'], failed: [] }) - expect(result.resources).toEqual([ - { type: 'file', id: 'f1', title: 'a.txt' }, - { type: 'file', id: 'f2', title: 'b.txt' }, - ]) - }) - - it('refuses to extract an upload that belongs to a different workspace', async () => { - mockFindUpload.mockResolvedValue(zipRow({ workspaceId: 'other-ws' })) - - const result = await executeMaterializeFile( - { fileNames: ['bundle.zip'], operation: 'extract' }, - context - ) - - expect(result.success).toBe(false) - const output = result.output as { failed: Array<{ fileName: string; error: string }> } - expect(output.failed[0].error).toContain('does not belong to this workspace') - expect(mockDecompress).not.toHaveBeenCalled() - }) - - it('reports an already-extracted archive instead of duplicating the tree', async () => { - mockFindUpload.mockResolvedValue(zipRow()) - mockFindFolder.mockResolvedValue('folder-existing') - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'f-old' }]) - - const result = await executeMaterializeFile( - { fileNames: ['bundle.zip'], operation: 'extract' }, - context - ) - - expect(result.success).toBe(false) - const output = result.output as { failed: Array<{ fileName: string; error: string }> } - expect(output.failed[0].error).toContain('already extracted') - expect(mockDecompress).not.toHaveBeenCalled() - }) - - it('detects a prior nested-only extraction via subfolders, not just direct files', async () => { - // A zip containing only nested entries (src/index.ts) leaves NO direct files - // under the archive root — only subfolders. The guard must still refuse. - mockFindUpload.mockResolvedValue(zipRow()) - mockFindFolder.mockResolvedValue('folder-existing') - dbChainMockFns.limit.mockResolvedValueOnce([]) // no direct files - dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'subfolder-1' }]) // but a subfolder tree - - const result = await executeMaterializeFile( - { fileNames: ['bundle.zip'], operation: 'extract' }, - context - ) - - expect(result.success).toBe(false) - const output = result.output as { failed: Array<{ fileName: string; error: string }> } - expect(output.failed[0].error).toContain('already extracted') - expect(mockDecompress).not.toHaveBeenCalled() - }) - - it('dedupes repeated fileNames so one call cannot double-extract', async () => { - mockFindUpload.mockResolvedValue(zipRow()) - mockFetchBuffer.mockResolvedValue(Buffer.from('zip-bytes')) - mockDecompress.mockResolvedValue({ - extracted: [{ id: 'f1', name: 'a.txt', url: '/x', size: 1, type: 'text/plain', key: 'k1' }], - skipped: 0, - skippedUnsafePaths: [], - }) - - const result = await executeMaterializeFile( - { fileNames: ['bundle.zip', 'bundle.zip'], operation: 'extract' }, - context - ) - - expect(result.success).toBe(true) - expect(mockDecompress).toHaveBeenCalledTimes(1) - }) - - it('folds degenerate archive names into the "archive" fallback folder', async () => { - mockFindUpload.mockResolvedValue(zipRow({ displayName: '..zip', originalName: '..zip' })) - mockFetchBuffer.mockResolvedValue(Buffer.from('zip-bytes')) - mockDecompress.mockResolvedValue({ - extracted: [{ id: 'f1', name: 'a.txt', url: '/x', size: 1, type: 'text/plain', key: 'k1' }], - skipped: 0, - skippedUnsafePaths: [], - }) - - const result = await executeMaterializeFile( - { fileNames: ['..zip'], operation: 'extract' }, - context - ) - - expect(result.success).toBe(true) - expect(mockDecompress).toHaveBeenCalledWith( - expect.any(Buffer), - expect.objectContaining({ rootFolderSegments: ['archive'] }) - ) - }) -}) - -describe('executeMaterializeFile - save operation on archives', () => { - beforeEach(() => { - vi.clearAllMocks() - mockFindFolder.mockResolvedValue(null) - }) - - it('refuses to save a .zip upload and points at extract instead', async () => { - mockFindUpload.mockResolvedValue({ - id: 'wf_zip', - key: 'mothership/abc/bundle.zip', - userId: 'user-1', - workspaceId: 'ws-1', - context: 'mothership', - chatId: 'chat-1', - originalName: 'bundle.zip', - displayName: 'bundle.zip', - contentType: 'application/zip', - sizeBytes: 2048, - deletedAt: null, - uploadedAt: new Date(), - updatedAt: new Date(), - }) - - const result = await executeMaterializeFile({ fileNames: ['bundle.zip'] }, context) - - expect(result.success).toBe(false) - const output = result.output as { failed: Array<{ fileName: string; error: string }> } - expect(output.failed[0].error).toContain('operation: "extract"') - expect(mockDecompress).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts deleted file mode 100644 index aefdec1dabc..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ /dev/null @@ -1,670 +0,0 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import type { Principal } from '@sim/auth/principal' -import { db } from '@sim/db' -import { - folder as folderTable, - type WorkspaceFileRow, - workflow, - workspaceFiles, -} from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { - getErrorMessage, - getPostgresConstraintName, - getPostgresErrorCode, - toError, -} from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { and, eq, isNull, sql } from 'drizzle-orm' -import { - checkStorageQuotaForBillingContext, - incrementStorageUsageForBillingContextInTx, - maybeNotifyStorageLimitForBillingContext, - resolveStorageBillingContext, -} from '@/lib/billing/storage' -import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' -import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader' -import { canonicalWorkspaceFilePath, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { getServePathPrefix } from '@/lib/uploads' -import { - ArchiveError, - type DecompressResult, - decompressArchiveBufferToWorkspaceFiles, - MAX_ARCHIVE_BYTES, -} from '@/lib/uploads/archive' -import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - allocateUniqueWorkspaceFileName, - fetchWorkspaceFileBuffer, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { hasCloudStorage, headObject } from '@/lib/uploads/core/storage-service' -import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' -import { isArchiveFileName } from '@/lib/uploads/utils/file-utils' -import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' -import { MAX_IMPORT_BODY_BYTES } from '@/lib/workflows/operations/import-workflow' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' -import { deduplicateWorkflowName } from '@/lib/workflows/utils' -import { admitCreateWorkspaceFile } from '@/lib/workspace-files/application/create-workspace-file' -import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' -import { extractWorkflowMetadata } from '@/app/api/v1/admin/types' - -const logger = createLogger('SaveUpload') -const MAX_MATERIALIZE_NAME_RETRIES = 8 -const WORKSPACE_FILE_NAME_UNIQUE_INDEX = 'workspace_files_workspace_folder_name_active_unique' - -function toFileRecord(row: WorkspaceFileRow) { - const pathPrefix = getServePathPrefix() - return { - id: row.id, - workspaceId: row.workspaceId || '', - name: row.displayName ?? row.originalName, - key: row.key, - path: `${pathPrefix}${encodeURIComponent(row.key)}?context=mothership`, - size: getWorkspaceFileSize(row), - type: row.contentType, - uploadedBy: row.userId, - deletedAt: row.deletedAt, - uploadedAt: row.uploadedAt, - updatedAt: row.updatedAt, - storageContext: 'mothership' as const, - } -} - -/** - * Cross-workspace ownership guard shared by every operation. The resolver is - * chat-scoped and current write paths always stamp matching workspaceIds, so - * this is defense in depth — but it must hold uniformly: without it, save would - * flip a foreign-workspace row into this workspace and import would read its - * bytes, the exact leak extract blocks. - */ -function uploadBelongsToWorkspace( - row: { workspaceId: string | null }, - workspaceId: string -): boolean { - return row.workspaceId === workspaceId -} - -async function executeSave( - fileName: string, - chatId: string, - workspaceId: string, - principal: Principal -): Promise { - const row = await findMothershipUploadRowByChatAndName(chatId, fileName) - if (!row) { - return { - success: false, - error: `Upload not found: "${fileName}". Use glob("uploads/*") to list available uploads.`, - } - } - if (!uploadBelongsToWorkspace(row, workspaceId)) { - return { success: false, error: `Upload not found: "${fileName}".` } - } - - const displayName = row.displayName ?? row.originalName - if (isArchiveFileName(displayName)) { - return { - success: false, - error: `"${fileName}" is a .zip archive — save it by extracting instead: save_upload(fileNames: ["${fileName}"], operation: "extract") unpacks it into files/ where the contents stay readable. The raw .zip remains in uploads/ for this chat.`, - } - } - - const head = await headObject(row.key, 'mothership') - if (!head && hasCloudStorage()) { - return { success: false, error: `Upload object not found: "${fileName}".` } - } - const verifiedSize = head?.size ?? getWorkspaceFileSize(row) - const billingContext = await resolveStorageBillingContext(workspaceId) - const quotaCheck = await checkStorageQuotaForBillingContext(billingContext, verifiedSize) - if (!quotaCheck.allowed) { - throw new Error(quotaCheck.error || 'Storage limit exceeded') - } - - /** - * The conditional transition makes concurrent replays no-ops. If it wins, - * lock order is workspace -> file row -> payer: the explicit workspace lock - * precedes the conditional file update, then the storage helper reuses that - * workspace lock before locking its payer. Any quota/stale-payer failure - * rolls back the row transition. - */ - let transition: { - updated: { id: string; originalName: string } - updatedUsage: number | undefined - } | null = null - - for (let attempt = 0; attempt < MAX_MATERIALIZE_NAME_RETRIES; attempt++) { - const materializedName = await allocateUniqueWorkspaceFileName(workspaceId, displayName, null) - - try { - transition = await db.transaction(async (tx) => { - /** `FOR NO KEY UPDATE`: see the module header of `lib/billing/storage/tracking.ts`. */ - await tx.execute(sql`SELECT 1 FROM workspace WHERE id = ${workspaceId} FOR NO KEY UPDATE`) - - const [updated] = await tx - .update(workspaceFiles) - .set({ - context: 'workspace', - // A workspace file has no birth chat or message — clear both provenance - // fields so the row reads as workspace-owned, not stale chat-owned. - chatId: null, - messageId: null, - originalName: materializedName, - displayName: materializedName, - sizeBytes: verifiedSize, - }) - .where( - and( - eq(workspaceFiles.id, row.id), - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.chatId, chatId), - eq(workspaceFiles.context, 'mothership'), - isNull(workspaceFiles.deletedAt) - ) - ) - .returning({ id: workspaceFiles.id, originalName: workspaceFiles.originalName }) - - if (!updated) { - return null - } - - const updatedUsage = await incrementStorageUsageForBillingContextInTx( - tx, - billingContext, - verifiedSize - ) - return { updated, updatedUsage } - }) - break - } catch (error) { - const isNameCollision = - getPostgresErrorCode(error) === '23505' && - getPostgresConstraintName(error) === WORKSPACE_FILE_NAME_UNIQUE_INDEX - if (!isNameCollision || attempt === MAX_MATERIALIZE_NAME_RETRIES - 1) { - throw error - } - logger.warn('Workspace file name was claimed during materialization; retrying', { - fileName, - materializedName, - attempt: attempt + 1, - }) - } - } - - const replayedFile = transition - ? null - : ( - await readWorkspaceFileMetadata.execute({ - principal, - input: { fileId: row.id, assertedWorkspaceId: workspaceId }, - }) - ).file - const updated = - transition?.updated ?? - (replayedFile ? { id: replayedFile.id, originalName: replayedFile.name } : null) - if (!updated) { - return { success: false, error: `Upload no longer available: "${fileName}".` } - } - if (transition?.updatedUsage !== undefined) { - void maybeNotifyStorageLimitForBillingContext(billingContext, transition.updatedUsage) - } - - logger.info(transition ? 'Materialized file' : 'Materialize replay was a no-op', { - fileName, - fileId: updated.id, - chatId, - }) - - // Canonical, per-segment-encoded path — matches how the workspace VFS serves - // the file (files/), rather than echoing the raw display name. - const canonicalPath = canonicalWorkspaceFilePath({ - folderPath: null, - name: updated.originalName, - }) - - return { - success: true, - output: { - message: `File "${updated.originalName}" materialized. It is now available at ${canonicalPath} and will persist independently of this chat.`, - fileId: updated.id, - path: canonicalPath, - }, - resources: [{ type: 'file', id: updated.id, title: updated.originalName }], - } -} - -async function executeImport( - fileName: string, - chatId: string, - workspaceId: string, - userId: string -): Promise { - const row = await findMothershipUploadRowByChatAndName(chatId, fileName) - if (!row) { - return { - success: false, - error: `Upload not found: "${fileName}". Use glob("uploads/*") to list available uploads.`, - } - } - if (!uploadBelongsToWorkspace(row, workspaceId)) { - return { - success: false, - error: `Upload "${fileName}" does not belong to this workspace.`, - } - } - if (isArchiveFileName(row.displayName ?? row.originalName)) { - return { - success: false, - error: `"${fileName}" is a .zip archive, not a workflow JSON. Extract it first: save_upload(fileNames: ["${fileName}"], operation: "extract").`, - } - } - - // The bytes are headed straight for `parseWorkflowJson`, so the import body ceiling is - // the real limit here — a larger file could not be imported even if it were read. - const buffer = await fetchWorkspaceFileBuffer(toFileRecord(row), { - maxBytes: MAX_IMPORT_BODY_BYTES, - }) - const content = buffer.toString('utf-8') - - let parsed: unknown - try { - parsed = JSON.parse(content) - } catch { - return { success: false, error: `"${fileName}" is not valid JSON.` } - } - - const { data: workflowData, errors } = parseWorkflowJson(content) - if (!workflowData || errors.length > 0) { - return { - success: false, - error: `Invalid workflow JSON: ${errors.join(', ')}`, - } - } - - const { name: rawName } = extractWorkflowMetadata(parsed) - - const workflowId = generateId() - const now = new Date() - const dedupedName = await deduplicateWorkflowName(rawName, workspaceId, null) - - await db.insert(workflow).values({ - id: workflowId, - userId, - workspaceId, - folderId: null, - name: dedupedName, - lastSynced: now, - createdAt: now, - updatedAt: now, - isDeployed: false, - runCount: 0, - variables: {}, - }) - - let saveResult: Awaited> - try { - /** - * Copilot is a surface adapter, not an exemption. The graph here comes from - * a JSON file the user uploaded, so it names whatever block types the file - * names — exactly the whole-graph write the workspace's integration - * allowlist exists to judge — and the subject is the person chatting, never - * the workflow's billing owner. - * - * The shared write refuses a withheld type by throwing, so the shell row - * inserted above is rolled back here the same way a failed save is; - * otherwise a refusal would leave an empty workflow behind. - */ - saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowData, { - workspaceId, - subjectUserId: userId, - }) - } catch (error) { - await db.delete(workflow).where(eq(workflow.id, workflowId)) - const classified = asOrchestrationError(error) - if (classified) return { success: false, error: classified.message } - throw error - } - if (!saveResult.success) { - await db.delete(workflow).where(eq(workflow.id, workflowId)) - return { success: false, error: `Failed to save workflow state: ${saveResult.error}` } - } - - if (workflowData.variables && Array.isArray(workflowData.variables)) { - const variablesRecord: Record< - string, - { id: string; name: string; type: string; value: unknown } - > = {} - for (const v of workflowData.variables) { - const varId = (v as { id?: string }).id || generateId() - const variable = v as { name: string; type?: string; value: unknown } - variablesRecord[varId] = { - id: varId, - name: variable.name, - type: variable.type || 'string', - value: variable.value, - } - } - - await db - .update(workflow) - .set({ variables: variablesRecord, updatedAt: new Date() }) - .where(eq(workflow.id, workflowId)) - } - - logger.info('Imported workflow from upload', { - fileName, - workflowId, - workflowName: dedupedName, - chatId, - }) - - recordAudit({ - workspaceId, - actorId: userId, - action: AuditAction.WORKFLOW_CREATED, - resourceType: AuditResourceType.WORKFLOW, - resourceId: workflowId, - resourceName: dedupedName, - description: `Imported workflow "${dedupedName}" from file`, - metadata: { fileName, source: 'copilot-import' }, - }) - - return { - success: true, - output: { - message: `Workflow "${dedupedName}" imported successfully. It is now available in the workspace and can be edited or run.`, - workflowId, - workflowName: dedupedName, - }, - resources: [{ type: 'workflow', id: workflowId, title: dedupedName }], - } -} - -/** - * Fold a zip display name into a safe extraction folder name. Mirrors the VFS - * segment normalization (NFC, control-char strip) and rejects the degenerate - * names the folder layer throws plain Errors for (dot segments, separators, - * empty), so a hostile upload name like `..zip` or `\x01.zip` lands in the - * `archive` fallback instead of surfacing a raw internal error — and so the - * VFS-encoded destination path can be computed before anything is extracted. - */ -function archiveFolderBaseName(displayName: string): string { - const stripped = displayName - .replace(/\.zip$/i, '') - .normalize('NFC') - .replace(/[\x00-\x1f\x7f]/g, '') - .replace(/[/\\]/g, '-') - .trim() - if (!stripped || stripped === '.' || stripped === '..') { - return 'archive' - } - return stripped -} - -/** - * Decompress an uploaded `.zip` into the workspace `files//` folder tree - * (reusing the shared, capped, zip-slip/bomb-safe extractor). The raw archive - * stays in uploads/; the extracted files persist in the workspace so the agent - * can read them with the normal files/ tooling. This is the explicit "extract - * before reading a zip" step. - */ -async function executeExtract( - fileName: string, - chatId: string, - workspaceId: string, - userId: string, - principal: Principal -): Promise { - const row = await findMothershipUploadRowByChatAndName(chatId, fileName) - if (!row) { - return { - success: false, - error: `Upload not found: "${fileName}". Use glob("uploads/*") to list available uploads.`, - } - } - - if (!uploadBelongsToWorkspace(row, workspaceId)) { - return { - success: false, - error: `Upload "${fileName}" does not belong to this workspace.`, - } - } - - const displayName = row.displayName ?? row.originalName - if (!isArchiveFileName(displayName)) { - return { - success: false, - error: `"${fileName}" is not a .zip archive — only .zip uploads can be extracted. Read it directly with read("uploads/${fileName}").`, - } - } - - const record = toFileRecord(row) - if (record.size > MAX_ARCHIVE_BYTES) { - return { - success: false, - error: `Archive too large to extract: "${fileName}" (${Math.round( - record.size / 1024 / 1024 - )}MB, limit ${MAX_ARCHIVE_BYTES / 1024 / 1024}MB).`, - } - } - - // Resolve the destination up front (the encoded path is a pure function of the - // hardened base name), so nothing can throw after files have been written. - const baseName = archiveFolderBaseName(displayName) - const folderPath = `files/${encodeVfsPathSegments([baseName])}` - - // Re-running extract must not silently duplicate the tree with " (1)"-suffixed - // copies: when the destination folder already holds content, report it as - // already extracted instead of extracting beside the previous run. Direct - // files AND direct subfolders both count — extraction roots its whole tree - // here, so a prior run of a nested-only zip (e.g. src/index.ts) leaves a - // subfolder even when no file sits at the top level. - const existingFolderId = await findWorkspaceFileFolderIdByPath(workspaceId, [baseName]) - if (existingFolderId) { - const [[existingFile], [existingSubfolder]] = await Promise.all([ - db - .select({ id: workspaceFiles.id }) - .from(workspaceFiles) - .where( - and( - eq(workspaceFiles.folderId, existingFolderId), - eq(workspaceFiles.context, 'workspace'), - isNull(workspaceFiles.deletedAt) - ) - ) - .limit(1), - db - .select({ id: folderTable.id }) - .from(folderTable) - .where( - and( - eq(folderTable.parentId, existingFolderId), - eq(folderTable.resourceType, 'file'), - isNull(folderTable.deletedAt) - ) - ) - .limit(1), - ]) - if (existingFile || existingSubfolder) { - return { - success: false, - error: `"${fileName}" appears to be already extracted — ${folderPath}/ exists and contains content. List it with glob("${folderPath}/**"). To re-extract, delete that folder first.`, - } - } - } - - let result: DecompressResult - try { - const buffer = await fetchWorkspaceFileBuffer(record, { maxBytes: MAX_ARCHIVE_BYTES }) - const secretProvenance = await getBoundWorkspaceFileSecretProvenance(workspaceId, { - fileId: row.id, - key: row.key, - context: 'mothership', - }) - result = await decompressArchiveBufferToWorkspaceFiles(buffer, { - workspaceId, - principal, - rootFolderSegments: [baseName], - // The agent-facing extract drops macOS/Windows filesystem cruft so the - // unpacked files/ tree only contains meaningful entries. - skipNoiseEntries: true, - secretProvenance, - }) - } catch (err) { - if (err instanceof ArchiveError) { - // Reads sniff small uploads' magic bytes, so a mislabeled ".zip" that - // fails to parse here is genuinely readable via read() — say so instead - // of bouncing the model between extract and read forever. - const mislabeledHint = - err.reason === 'invalid' - ? ` If the file is not actually a zip archive, read it directly with read("uploads/${fileName}").` - : '' - return { - success: false, - error: `Cannot extract "${fileName}": ${err.message}${mislabeledHint}`, - } - } - throw err - } - - if (result.extracted.length === 0) { - return { success: false, error: `No files could be extracted from "${fileName}".` } - } - - const count = result.extracted.length - - if (result.skippedUnsafePaths.length > 0) { - logger.warn('Skipped unsafe archive entries during extract', { - fileName, - chatId, - entryNames: result.skippedUnsafePaths, - }) - } - - logger.info('Extracted archive into workspace files', { - fileName, - chatId, - folder: baseName, - extractedCount: count, - skipped: result.skipped, - }) - - return { - success: true, - output: { - message: `Extracted ${count} file${count === 1 ? '' : 's'} from "${fileName}" into ${folderPath}/. They now persist in the workspace — list them with glob("${folderPath}/**") and read one with read("${folderPath}//content").`, - fileCount: count, - path: folderPath, - }, - resources: result.extracted.map((f) => ({ type: 'file' as const, id: f.id, title: f.name })), - } -} - -export async function executeMaterializeFile( - params: Record, - context: ExecutionContext -): Promise { - // Dedupe: a repeated name in one call would re-run the operation against the - // same upload (for extract, duplicating the unpacked tree with " (1)" copies). - const fileNames: string[] = Array.from( - new Set( - (params.fileNames as string[] | undefined) ?? - ([params.fileName as string | undefined].filter(Boolean) as string[]) - ) - ) - - if (fileNames.length === 0) { - return { success: false, error: "Missing required parameter 'fileNames'" } - } - - if (!context.chatId) { - return { success: false, error: 'No chat context available for save_upload' } - } - - if (!context.workspaceId) { - return { success: false, error: 'No workspace context available for save_upload' } - } - - const principal = resolveCopilotFilePrincipal(context) - - const operation = (params.operation as string | undefined) || 'save' - // save (promote upload → workspace file), import (JSON → workflow), and extract - // (decompress a .zip upload → workspace files/) are implemented. Reject anything - // else with guidance instead of silently falling back to save. - if (operation !== 'save' && operation !== 'import' && operation !== 'extract') { - return { - success: false, - error: `Unsupported save_upload operation "${operation}". Use "save", "import", or "extract". For CSV/TSV/JSON → use the table subagent; for documents → use the knowledge subagent.`, - } - } - - try { - if (operation === 'import') { - await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') - } else { - await admitCreateWorkspaceFile(principal, context.workspaceId) - } - } catch (error) { - return { success: false, error: getErrorMessage(error, 'Workspace write access required') } - } - - const succeeded: string[] = [] - const failed: Array<{ fileName: string; error: string }> = [] - const resources: NonNullable = [] - - for (const fileName of fileNames) { - try { - let result: ToolCallResult - if (operation === 'import') { - result = await executeImport(fileName, context.chatId, context.workspaceId, context.userId) - } else if (operation === 'extract') { - result = await executeExtract( - fileName, - context.chatId, - context.workspaceId, - context.userId, - principal - ) - } else { - result = await executeSave(fileName, context.chatId, context.workspaceId, principal) - } - - if (result.success) { - const materializedName = - operation === 'save' - ? result.resources?.find((resource) => resource.type === 'file')?.title - : undefined - succeeded.push(materializedName ?? fileName) - if (result.resources) resources.push(...result.resources) - } else { - failed.push({ fileName, error: result.error ?? 'Failed to materialize file' }) - } - } catch (err) { - logger.error('save_upload failed', { - fileName, - operation, - chatId: context.chatId, - error: toError(err).message, - postgresCode: getPostgresErrorCode(err), - postgresConstraint: getPostgresConstraintName(err), - }) - failed.push({ - fileName, - error: getErrorMessage(err, 'Failed to materialize file'), - }) - } - } - - return { - success: succeeded.length > 0, - output: { succeeded, failed }, - error: - failed.length > 0 - ? `Failed to materialize: ${failed.map((f) => f.fileName).join(', ')}` - : undefined, - resources: resources.length > 0 ? resources : undefined, - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts deleted file mode 100644 index 1d0c6f5ba19..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { OrchestrationError } from '@/lib/core/orchestration/types' - -const mocks = vi.hoisted(() => ({ - execute: vi.fn(), - getBaseUrl: vi.fn(), -})) - -const useCases = vi.hoisted(() => ({ - prepare: { operation: { id: 'credentials.connections.prepare' } }, -})) - -vi.mock('@/lib/copilot/application/execute-credential-use-case', () => ({ - executeCopilotCredentialUseCase: mocks.execute, -})) -vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: mocks.getBaseUrl })) -vi.mock('@/lib/credentials/application/prepare-credential-connection', () => ({ - prepareCredentialConnection: useCases.prepare, -})) - -import type { ExecutionContext } from '@/lib/copilot/request/types' -import { executeOAuthGetAuthLink } from '@/lib/copilot/tools/handlers/oauth' - -const context: ExecutionContext = { - userId: 'user-1', - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - chatId: 'chat-1', - toolCallId: 'call-1', - copilotToolExecution: true, - userPermission: 'write', -} - -describe('executeOAuthGetAuthLink', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.getBaseUrl.mockReturnValue('https://sim.test') - mocks.execute.mockResolvedValue({ - serviceName: 'Gmail', - providerId: 'google-email', - workspaceId: 'workspace-1', - }) - }) - - it('uses the credential application adapter for a new connection', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'gmail' }, context) - - expect(result.success).toBe(true) - expect(mocks.execute).toHaveBeenCalledWith(context, useCases.prepare, { - workspaceId: 'workspace-1', - providerName: 'gmail', - credentialId: undefined, - }) - const url = new URL((result.output as { oauth_url: string }).oauth_url) - expect(url.pathname).toBe('/api/auth/oauth2/authorize') - expect(url.searchParams.get('providerId')).toBe('google-email') - expect(url.searchParams.get('workspaceId')).toBe('workspace-1') - expect(url.searchParams.has('credentialId')).toBe(false) - }) - - it('preserves the canonical credential ID for reconnect', async () => { - mocks.execute.mockResolvedValue({ - serviceName: 'Gmail', - providerId: 'google-email', - workspaceId: 'workspace-1', - credentialId: 'credential-1', - }) - - const result = await executeOAuthGetAuthLink( - { providerName: 'gmail', credentialId: 'credential-1' }, - context - ) - - const output = result.output as { oauth_url: string; message: string } - expect(new URL(output.oauth_url).searchParams.get('credentialId')).toBe('credential-1') - expect(output.message).toContain('re-authorizes credential credential-1 in place') - }) - - it('returns application validation errors without exposing infrastructure failures', async () => { - mocks.execute.mockRejectedValue(new OrchestrationError('not_found', 'Provider not found')) - - const result = await executeOAuthGetAuthLink({ providerName: 'missing' }, context) - - expect(result.success).toBe(false) - expect(result.error).toBe('Provider not found') - }) - - it('fails fast without trusted workspace context', async () => { - const result = await executeOAuthGetAuthLink( - { providerName: 'gmail' }, - { ...context, workspaceId: undefined } - ) - - expect(result).toEqual({ success: false, error: 'workspaceId is required' }) - expect(mocks.execute).not.toHaveBeenCalled() - }) - - it('rejects service-account providers before OAuth resolution', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'slack custom bot' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('service account, not an OAuth provider') - expect(mocks.execute).not.toHaveBeenCalled() - }) - - it('does not confuse integrations that also offer service accounts', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'slack' }, context) - - expect(result.success).toBe(true) - expect(mocks.execute).toHaveBeenCalledOnce() - }) - - it('requires personal credentials from trusted mode, ignoring model-supplied policy', async () => { - const assistant = { ...context, requestMode: 'assistant' } - const result = await executeOAuthGetAuthLink( - { providerName: 'gmail', personalOnly: false }, - assistant - ) - expect(result.success).toBe(true) - expect(mocks.execute).toHaveBeenCalledWith(assistant, useCases.prepare, { - workspaceId: 'workspace-1', - providerName: 'gmail', - credentialId: undefined, - personalOnly: true, - }) - }) - - it.each([ - { kind: 'personal_token', providerId: 'gitlab', serviceName: 'GitLab' }, - { kind: 'managed_oauth', providerId: 'slack', serviceName: 'Slack' }, - { kind: 'oauth', providerId: 'confluence', serviceName: 'Confluence' }, - ])('offers a provider-only personal connection card for $serviceName', async (provider) => { - mocks.execute.mockResolvedValue(provider) - const result = await executeOAuthGetAuthLink( - { providerName: provider.providerId }, - { ...context, requestMode: 'assistant' } - ) - expect(result.success).toBe(true) - expect(result.output).not.toHaveProperty('oauth_url') - expect(result.output).toMatchObject({ - providerId: provider.providerId, - instructions: expect.stringContaining( - `{"type":"link","provider":"${provider.providerId}"}` - ), - }) - }) - - it('does not offer a service-account card or fallback link in Assistant', async () => { - const result = await executeOAuthGetAuthLink( - { providerName: 'slack custom bot' }, - { ...context, requestMode: 'assistant' } - ) - expect(result.success).toBe(false) - expect(result.error).toContain('your own connected accounts') - expect(result.output).not.toHaveProperty('oauth_url') - expect(mocks.execute).not.toHaveBeenCalled() - }) - - it('does not attach a reconnect URL after rejecting another person’s credential', async () => { - mocks.execute.mockRejectedValue( - new OrchestrationError('forbidden', 'Assistant can only reconnect your own account.') - ) - const result = await executeOAuthGetAuthLink( - { providerName: 'gmail', credentialId: 'other-account' }, - { ...context, requestMode: 'assistant' } - ) - expect(result.success).toBe(false) - expect(result.output).not.toHaveProperty('oauth_url') - expect(result.error).toContain('own account') - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts deleted file mode 100644 index 9fca7b56e02..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' -import { executeCopilotCredentialUseCase } from '@/lib/copilot/application/execute-credential-use-case' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { prepareCredentialConnection } from '@/lib/credentials/application/prepare-credential-connection' -import { isServiceAccountProviderId } from '@/lib/credentials/service-account-provider-ids' -import { APP_ENTRY_PATH } from '@/lib/navigation/paths' - -export async function executeOAuthGetAuthLink( - rawParams: Record, - context: ExecutionContext -): Promise { - const providerName = String(rawParams.providerName || rawParams.provider_name || '') - const rawCredentialId = rawParams.credentialId || rawParams.credential_id - const credentialId = rawCredentialId ? String(rawCredentialId) : undefined - const baseUrl = getBaseUrl() - - /** Reject service-account aliases before the provider resolver's fuzzy OAuth match. */ - const serviceAccountId = providerName - .toLowerCase() - .trim() - .replace(/[\s_]+/g, '-') - if (isServiceAccountProviderId(serviceAccountId)) { - if (context.requestMode === 'assistant') { - const message = - 'Assistant uses your own connected accounts. A service account cannot be used here.' - return { success: false, error: message, output: { message } } - } - const message = - `"${providerName}" is a service account, not an OAuth provider. ` + - `Emit a service_account credential tag with the service's OAuth provider ` + - `value instead (e.g. "slack") — it opens the service account setup form in chat.` - return { success: false, error: message, output: { message } } - } - const workspaceId = context.workspaceId - if (!workspaceId) return { success: false, error: 'workspaceId is required' } - - try { - const result = await executeCopilotCredentialUseCase(context, prepareCredentialConnection, { - workspaceId, - providerName, - credentialId, - ...(context.requestMode === 'assistant' ? { personalOnly: true } : {}), - }) - if (context.requestMode === 'assistant') { - return { - success: true, - output: { - message: `Connect your ${result.serviceName} account using the in-chat connection card.`, - provider: result.serviceName, - providerId: result.providerId, - instructions: `End your response with ${JSON.stringify({ type: 'link', provider: result.providerId })}. The card connects the signed-in person's account to Connected accounts. Wait for the connection status before continuing.`, - }, - } - } - const callbackURL = context.workflowId - ? `${baseUrl}/workspace/${workspaceId}/w/${context.workflowId}` - : context.chatId - ? `${baseUrl}/workspace/${workspaceId}/chat/${context.chatId}` - : `${baseUrl}/workspace/${workspaceId}` - const authorizeUrl = new URL(`${baseUrl}/api/auth/oauth2/authorize`) - authorizeUrl.searchParams.set('providerId', result.providerId) - authorizeUrl.searchParams.set('workspaceId', workspaceId) - authorizeUrl.searchParams.set('callbackURL', callbackURL) - if (result.credentialId) authorizeUrl.searchParams.set('credentialId', result.credentialId) - - const action = credentialId ? 'reconnect' : 'connect' - return { - success: true, - output: { - message: credentialId - ? `Reconnect authorization URL generated for ${result.serviceName}. Completing it re-authorizes credential ${credentialId} in place — its id stays the same.` - : `Authorization URL generated for ${result.serviceName}.`, - oauth_url: authorizeUrl.toString(), - instructions: `Open this URL in your browser to ${action} ${result.serviceName}: ${authorizeUrl.toString()}`, - provider: result.serviceName, - providerId: result.providerId, - }, - } - } catch (err) { - const message = messageForCopilotApplicationError(err) - if (context.requestMode === 'assistant') { - return { success: false, error: message, output: { message } } - } - const workspaceUrl = context.workspaceId - ? `${baseUrl}/workspace/${context.workspaceId}` - : `${baseUrl}${APP_ENTRY_PATH}` - return { - success: false, - error: message, - output: { - message: `Could not generate a direct OAuth link for ${providerName}. Connect manually from the workspace.`, - oauth_url: workspaceUrl, - error: message, - }, - } - } -} - -/** Compatibility executor for older Mothership calls and persisted checkpoints. */ -export async function executeOAuthRequestAccess( - rawParams: Record, - _context: ExecutionContext -): Promise { - const providerName = String(rawParams.providerName || rawParams.provider_name || 'the provider') - return { - success: true, - output: { - status: 'requested', - providerName, - message: `Requested ${providerName} OAuth connection.`, - }, - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/resources.test.ts b/apps/sim/lib/copilot/tools/handlers/resources.test.ts deleted file mode 100644 index 64fa0d7610e..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/resources.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * @vitest-environment node - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { listAllWorkspaceFilesMock, readKnowledgeBaseMock, readWorkspaceFileMetadataMock } = - vi.hoisted(() => ({ - listAllWorkspaceFilesMock: vi.fn(), - readKnowledgeBaseMock: vi.fn(), - readWorkspaceFileMetadataMock: vi.fn(), - })) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - findWorkspaceFileRecord: ( - files: Array<{ id: string; name: string; folderPath: string | null }> - ) => files[0] ?? null, -})) - -vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ - listAllWorkspaceFiles: { - operation: { id: 'files.list' }, - execute: listAllWorkspaceFilesMock, - }, -})) - -vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ - readWorkspaceFileMetadata: { - operation: { id: 'files.read_metadata' }, - execute: readWorkspaceFileMetadataMock, - }, -})) - -vi.mock('@/lib/workflows/utils', () => ({ - getWorkflowById: vi.fn(), -})) - -vi.mock('@/lib/table/service', () => ({ - getTableById: vi.fn(), -})) - -vi.mock('@/lib/table/views/service', () => ({ - getTableView: vi.fn(), -})) - -vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ - readKnowledgeBase: { - operation: { id: 'knowledge.read' }, - execute: readKnowledgeBaseMock, - }, -})) - -vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({ - executeCopilotFileUseCase: ( - context: { userId: string; workspaceId: string; toolCallId: string }, - useCase: { execute: (args: unknown) => unknown }, - input: unknown - ) => - useCase.execute({ - principal: { - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: context.userId, - workspaceId: context.workspaceId, - delegationId: context.toolCallId, - }, - input, - }), -})) - -vi.mock('@/lib/copilot/application/execute-knowledge-use-case', () => ({ - executeCopilotKnowledgeUseCase: ( - context: { userId: string; workspaceId: string; toolCallId: string }, - useCase: { execute: (args: unknown) => unknown }, - input: unknown - ) => - useCase.execute({ - principal: { - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: context.userId, - workspaceId: context.workspaceId, - delegationId: context.toolCallId, - }, - input, - }), -})) - -vi.mock('@/lib/logs/service', () => ({ - getLogById: vi.fn(), -})) - -import { getTableById } from '@/lib/table/service' -import { getTableView } from '@/lib/table/views/service' -import { executeOpenResource } from './resources' - -describe('executeOpenResource', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('opens workspace files with canonical non-UUID file ids', async () => { - readWorkspaceFileMetadataMock.mockResolvedValue({ - file: { - id: 'wf_qL_cfff-FskMsXtOdm599', - name: 'MAC_Brand_Guidelines_May_2021 (1).docx', - folderPath: null, - }, - }) - - const result = await executeOpenResource( - { - resources: [{ type: 'file', id: 'wf_qL_cfff-FskMsXtOdm599' }], - }, - { - userId: 'user-1', - workflowId: 'workflow-1', - workspaceId: 'workspace-1', - toolCallId: 'tool-1', - copilotToolExecution: true, - } - ) - - expect(readWorkspaceFileMetadataMock).toHaveBeenCalledWith( - expect.objectContaining({ - input: { - fileId: 'wf_qL_cfff-FskMsXtOdm599', - assertedWorkspaceId: 'workspace-1', - }, - }) - ) - expect(result).toMatchObject({ - success: true, - output: { opened: 1, errors: [] }, - resources: [ - { - type: 'file', - id: 'wf_qL_cfff-FskMsXtOdm599', - title: 'MAC_Brand_Guidelines_May_2021 (1).docx', - path: 'files/MAC_Brand_Guidelines_May_2021%20(1).docx', - }, - ], - }) - }) - - it('opens workspace files by canonical VFS path', async () => { - listAllWorkspaceFilesMock.mockResolvedValue({ - files: [ - { - id: 'wf_qL_cfff-FskMsXtOdm599', - name: 'MAC_Brand_Guidelines_May_2021 (1).docx', - folderPath: 'Docs', - }, - ], - }) - - const result = await executeOpenResource( - { - resources: [{ type: 'file', path: 'files/Docs/MAC_Brand_Guidelines.docx' }], - }, - { - userId: 'user-1', - workflowId: 'workflow-1', - workspaceId: 'workspace-1', - toolCallId: 'tool-1', - copilotToolExecution: true, - } - ) - - expect(listAllWorkspaceFilesMock).toHaveBeenCalledWith( - expect.objectContaining({ input: { workspaceId: 'workspace-1', scope: 'active' } }) - ) - expect(result).toMatchObject({ - success: true, - output: { opened: 1, errors: [] }, - resources: [ - { - type: 'file', - id: 'wf_qL_cfff-FskMsXtOdm599', - title: 'MAC_Brand_Guidelines_May_2021 (1).docx', - path: 'files/Docs/MAC_Brand_Guidelines_May_2021%20(1).docx', - }, - ], - }) - }) - - it('opens a knowledge base through trusted application delegation', async () => { - readKnowledgeBaseMock.mockResolvedValue({ - knowledgeBase: { id: 'kb-1', name: 'Product Docs', workspaceId: 'workspace-1' }, - folderPath: '/', - }) - - const result = await executeOpenResource( - { resources: [{ type: 'knowledgebase', id: 'kb-1' }] }, - { - userId: 'user-1', - workflowId: 'workflow-1', - workspaceId: 'workspace-1', - toolCallId: 'tool-1', - copilotToolExecution: true, - } - ) - - expect(readKnowledgeBaseMock).toHaveBeenCalledWith( - expect.objectContaining({ - principal: expect.objectContaining({ - kind: 'delegated', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'tool-1', - }), - input: { knowledgeBaseId: 'kb-1', assertedWorkspaceId: 'workspace-1' }, - }) - ) - expect(result).toMatchObject({ - success: true, - resources: [{ type: 'knowledgebase', id: 'kb-1', title: 'Product Docs' }], - }) - }) - - it('propagates knowledge application infrastructure failures', async () => { - readKnowledgeBaseMock.mockRejectedValueOnce(new Error('knowledge database unavailable')) - - await expect( - executeOpenResource( - { resources: [{ type: 'knowledgebase', id: 'kb-1' }] }, - { - userId: 'user-1', - workflowId: 'workflow-1', - workspaceId: 'workspace-1', - toolCallId: 'tool-1', - copilotToolExecution: true, - } - ) - ).rejects.toThrow('knowledge database unavailable') - }) -}) - -describe('open_resource table views', () => { - const executionContext = { userId: 'user-1', workspaceId: 'ws-1' } as never - - it('opens a table pinned to a saved view by id, stamping viewId and a pinned title', async () => { - vi.mocked(getTableById).mockResolvedValue({ - id: 'tbl-1', - name: 'Leads', - workspaceId: 'ws-1', - schema: { columns: [{ id: 'col_a', name: 'status', type: 'string' }] }, - } as never) - vi.mocked(getTableView).mockResolvedValue({ - id: 'view-1', - name: 'Overdue', - isDefault: false, - config: {}, - } as never) - - const result = await executeOpenResource( - { resources: [{ type: 'table', id: 'tbl-1', view: 'view-1' }] }, - executionContext - ) - - expect(result.success).toBe(true) - expect(result.resources?.[0]).toMatchObject({ - type: 'table', - id: 'tbl-1', - title: 'Leads — Overdue', - viewId: 'view-1', - }) - }) - - it('rejects an unknown view id and points at views.json', async () => { - vi.mocked(getTableById).mockResolvedValue({ - id: 'tbl-1', - name: 'Leads', - workspaceId: 'ws-1', - schema: { columns: [] }, - } as never) - vi.mocked(getTableView).mockResolvedValue(null as never) - - const missing = await executeOpenResource( - { resources: [{ type: 'table', id: 'tbl-1', view: 'view-nope' }] }, - executionContext - ) - expect(missing.success).toBe(false) - expect((missing.output as { errors: string[] }).errors[0]).toContain('views.json') - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts deleted file mode 100644 index 1dbfbeb00cd..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/resources.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' -import { executeCopilotKnowledgeUseCase } from '@/lib/copilot/application/execute-knowledge-use-case' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { type MothershipResource, MothershipResourceType } from '@/lib/copilot/resources/types' -import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { readKnowledgeBase } from '@/lib/knowledge/application/knowledge-bases' -import { getLogById } from '@/lib/logs/service' -import type { TableSchema } from '@/lib/table' -import { getTableById } from '@/lib/table/service' -import { getTableView } from '@/lib/table/views/service' -import { - findWorkspaceFileRecord, - type WorkspaceFileRecord, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { getWorkflowById } from '@/lib/workflows/utils' -import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' -import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' -import type { OpenResourceItem, OpenResourceParams, ValidOpenResourceParams } from './param-types' - -const VALID_OPEN_RESOURCE_TYPES = new Set(Object.values(MothershipResourceType)) - -async function resolveResource( - item: ValidOpenResourceParams, - context: ExecutionContext -): Promise { - const resourceType = item.type - let resourceId = item.id ?? '' - let title: string = resourceType - - if (resourceType === 'file') { - if (!context.workspaceId) - return { error: 'Opening a workspace file requires workspace context.' } - const fileRef = item.path || item.id || '' - let record: WorkspaceFileRecord | null - if (item.path) { - const { files } = await executeCopilotFileUseCase(context, listAllWorkspaceFiles, { - workspaceId: context.workspaceId, - scope: 'active', - }) - record = findWorkspaceFileRecord(files, item.path) - } else if (item.id) { - record = ( - await executeCopilotFileUseCase( - context, - readWorkspaceFileMetadata, - { fileId: item.id, assertedWorkspaceId: context.workspaceId }, - { fileId: item.id } - ) - ).file - } else { - record = null - } - if (!record) return { error: `No workspace file found for "${fileRef}".` } - resourceId = record.id - title = record.name - return { - type: resourceType, - id: resourceId, - title, - path: canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name }), - } - } - if (resourceType === 'workflow') { - if (!item.id) return { error: 'workflow resources require `id`.' } - const wf = await getWorkflowById(item.id) - if (!wf) return { error: `No workflow with id "${item.id}".` } - if (context.workspaceId && wf.workspaceId !== context.workspaceId) - return { - error: `Workflow "${item.id}" is not in the current workspace — run glob("workflows/*/meta.json") for workflows you can reference.`, - } - resourceId = wf.id - title = wf.name - } - if (resourceType === 'table') { - if (!item.id) return { error: 'table resources require `id`.' } - const tbl = await getTableById(item.id) - if (!tbl) return { error: `No table with id "${item.id}".` } - if (context.workspaceId && tbl.workspaceId !== context.workspaceId) - return { - error: `Table "${item.id}" is not in the current workspace — run glob("tables/*") for tables you can reference.`, - } - resourceId = tbl.id - title = tbl.name - if (item.view) { - const view = await getTableView( - item.view.trim(), - tbl.id, - (tbl.schema as TableSchema).columns, - context.workspaceId ?? undefined - ) - if (!view) { - return { - error: `No view with id "${item.view.trim()}" on table "${tbl.name}". View ids are listed in the table's views.json.`, - } - } - title = `${tbl.name} — ${view.name}` - return { type: resourceType, id: resourceId, title, viewId: view.id } - } - } - if (resourceType === 'knowledgebase') { - if (!item.id) return { error: 'knowledgebase resources require `id`.' } - if (!context.workspaceId) { - return { error: 'Opening a knowledge base requires workspace context.' } - } - let kb: Awaited>['knowledgeBase'] - try { - const result = await executeCopilotKnowledgeUseCase(context, readKnowledgeBase, { - knowledgeBaseId: item.id, - assertedWorkspaceId: context.workspaceId, - }) - kb = result.knowledgeBase - } catch (error) { - const classified = asOrchestrationError(error) - if ( - classified?.code === 'not_found' || - classified?.code === 'forbidden' || - classified?.code === 'unauthorized' - ) { - return { - error: `Knowledge base "${item.id}" is not readable in the current workspace — it does not exist here or you lack access. Run glob("knowledgebases/*") for ids you can open.`, - } - } - throw error - } - resourceId = kb.id - title = kb.name - } - if (resourceType === 'log') { - if (!item.id) return { error: 'log resources require `id`.' } - const logRecord = await getLogById(item.id) - if (!logRecord) return { error: `No log with id "${item.id}".` } - if (context.workspaceId && logRecord.workspaceId !== context.workspaceId) - return { - error: `Log "${item.id}" is not in the current workspace — use query_logs to find valid execution ids.`, - } - resourceId = logRecord.id - const workflowName = logRecord.workflowName ?? 'Unknown Workflow' - const timestamp = logRecord.startedAt.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }) - title = `${workflowName} — ${timestamp}` - } - return { type: resourceType, id: resourceId, title } -} - -export async function executeOpenResource( - rawParams: Record, - context: ExecutionContext -): Promise { - const params = rawParams as OpenResourceParams - - const items: OpenResourceItem[] = - params.resources ?? - (params.type && (params.id || params.path) - ? [{ type: params.type, id: params.id, path: params.path }] - : []) - - if (items.length === 0) { - return { success: false, error: 'resources array is required' } - } - - const resources: MothershipResource[] = [] - const errors: string[] = [] - - for (const item of items) { - const validated = validateOpenResourceItem(item) - if (!validated.success) { - errors.push(validated.error) - continue - } - const result = await resolveResource(validated.params, context) - if ('error' in result) { - errors.push(result.error) - } else { - resources.push(result) - } - } - - return { - success: resources.length > 0, - output: { opened: resources.length, errors }, - resources, - } -} - -function validateOpenResourceItem( - item: OpenResourceItem -): { success: true; params: ValidOpenResourceParams } | { success: false; error: string } { - if (!item.type) { - return { success: false, error: 'type is required' } - } - if (!VALID_OPEN_RESOURCE_TYPES.has(item.type)) { - return { success: false, error: `Invalid resource type: ${item.type}` } - } - if (!item.id && !(item.type === 'file' && item.path)) { - return { success: false, error: `${item.type} resources require \`id\`` } - } - return { - success: true, - params: { type: item.type, id: item.id, path: item.path, view: item.view }, - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/restore-resource.ts b/apps/sim/lib/copilot/tools/handlers/restore-resource.ts deleted file mode 100644 index fa57b17b1b6..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/restore-resource.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { performRestoreResource, type RestorableResourceType } from '@/lib/resources/orchestration' - -const VALID_TYPES = new Set([ - 'workflow', - 'table', - 'file', - 'knowledgebase', - 'folder', - 'file_folder', - 'knowledge_folder', - 'table_folder', -]) - -export async function executeRestoreResource( - rawParams: Record, - context: ExecutionContext -): Promise { - const type = rawParams.type as string | undefined - const id = rawParams.id as string | undefined - - if (!type || !VALID_TYPES.has(type)) { - return { success: false, error: `Invalid type. Must be one of: ${[...VALID_TYPES].join(', ')}` } - } - if (!id) { - return { success: false, error: 'id is required' } - } - if (!context.workspaceId) { - return { success: false, error: 'Workspace context required' } - } - - return performRestoreResource({ - type: type as RestorableResourceType, - id, - userId: context.userId, - workspaceId: context.workspaceId, - }) as Promise -} diff --git a/apps/sim/lib/copilot/tools/handlers/run-code.ts b/apps/sim/lib/copilot/tools/handlers/run-code.ts deleted file mode 100644 index 68345ea7527..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/run-code.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' -import { executeFunctionExecute } from '@/lib/copilot/tools/handlers/function-execute' - -/** - * Compute-only variant of run_function for info-gathering agents: same - * sandbox and inputs, but it must never create or overwrite workspace - * resources. The write vectors (outputs.files, outputTable) are rejected here - * on top of the Go executor's fail-fast guard; run_code is also absent from - * the name-gated output post-processors (OUTPUT_PATH_TOOLS etc.), so even a - * leaked arg could not write anything. - */ -export async function executeRunCode( - params: Record, - context: ToolExecutionContext -): Promise { - if ('outputs' in params) { - return { - success: false, - error: - 'run_code is compute-only: outputs (workspace file writes) is not available; return the data and report it instead', - } - } - if ('outputTable' in params) { - return { - success: false, - error: - 'run_code is compute-only: outputTable (workspace table overwrite) is not available; return the data and report it instead', - } - } - return executeFunctionExecute(params, context) -} diff --git a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts deleted file mode 100644 index 14e93a3ba3c..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -/** - * @vitest-environment node - */ - -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockReadFileRecord, mockFetchBuffer } = vi.hoisted(() => ({ - mockReadFileRecord: vi.fn(), - mockFetchBuffer: vi.fn(), -})) - -vi.mock('@/lib/copilot/vfs/file-reader', () => ({ - isReadableFileType: (contentType: string) => contentType.startsWith('text/'), - readFileRecord: mockReadFileRecord, - MAX_TEXT_READ_BYTES: 5 * 1024 * 1024, -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - fetchWorkspaceFileBuffer: mockFetchBuffer, -})) - -/** A buffer beginning with the ZIP local-file-header magic (PK\x03\x04). */ -const ZIP_SHAPED = Buffer.from([0x50, 0x4b, 0x03, 0x04, 0x14, 0x00, 0x00, 0x00]) - -import { - findMothershipUploadRowByChatAndName, - grepChatUpload, - grepChatUploadWithProvenance, - listChatUploads, - readChatUpload, - readChatUploadWithProvenance, -} from '@/lib/copilot/tools/handlers/upload-file-reader' -import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' - -const CHAT_ID = '11111111-1111-1111-1111-111111111111' -const NOW = new Date('2026-05-05T00:00:00.000Z') - -function makeRow(overrides: Partial> = {}) { - return { - id: 'wf_1', - key: 'mothership/abc/123-image.png', - userId: 'user_1', - workspaceId: 'ws_1', - context: 'mothership', - chatId: CHAT_ID, - originalName: 'image.png', - displayName: 'image.png', - contentType: 'image/png', - sizeBytes: 1024, - deletedAt: null, - uploadedAt: NOW, - updatedAt: NOW, - contentUpdatedAt: NOW, - ...overrides, - } -} - -/** - * Resolver chain is `.where().orderBy(...).limit(1)`. The default chain mock makes - * `orderBy` a terminal, so we wire a chainable `{limit}` for each call manually. - */ -function mockOrderByThenLimit(rows: unknown) { - dbChainMockFns.orderBy.mockReturnValueOnce({ limit: dbChainMockFns.limit } as never) - dbChainMockFns.limit.mockResolvedValueOnce(rows as never) -} - -describe('findMothershipUploadRowByChatAndName', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - }) - - it('matches by displayName for the first occurrence', async () => { - const row = makeRow({ id: 'wf_1', displayName: 'image.png' }) - mockOrderByThenLimit([row]) - - const result = await findMothershipUploadRowByChatAndName(CHAT_ID, 'image.png') - - expect(result).toEqual(row) - }) - - it('matches by suffixed displayName for collision-disambiguated rows', async () => { - const row = makeRow({ id: 'wf_2', displayName: 'image (2).png' }) - mockOrderByThenLimit([row]) - - const result = await findMothershipUploadRowByChatAndName(CHAT_ID, 'image (2).png') - - expect(result?.id).toBe('wf_2') - expect(result?.displayName).toBe('image (2).png') - }) - - it('prefers the most recent row when legacy rows share the same originalName', async () => { - // Pre-displayName legacy rows have displayName=null. Resolver's ORDER BY uploaded_at - // DESC ensures the newest upload wins, fixing read("uploads/") for legacy data. - const newer = makeRow({ - id: 'wf_new', - displayName: null, - originalName: 'image.png', - uploadedAt: new Date('2026-05-05T12:00:00.000Z'), - }) - mockOrderByThenLimit([newer]) - - const result = await findMothershipUploadRowByChatAndName(CHAT_ID, 'image.png') - - expect(result?.id).toBe('wf_new') - }) - - it('returns null when no row matches and the fallback scan is empty', async () => { - // First query: .where().orderBy().limit() returns []. - mockOrderByThenLimit([]) - // Second query: .where().orderBy(...) (no .limit) — orderBy is the terminal. - dbChainMockFns.orderBy.mockResolvedValueOnce([] as never) - - const result = await findMothershipUploadRowByChatAndName(CHAT_ID, 'missing.png') - - expect(result).toBeNull() - }) - - it('falls back to normalized segment match when exact lookup misses (macOS U+202F)', async () => { - // Model passes ASCII space; DB row was saved with U+202F (narrow no-break space). - const macosName = 'Screenshot 2026-05-05 at 9.41.00 AM.png' - const asciiName = 'Screenshot 2026-05-05 at 9.41.00 AM.png' - const row = makeRow({ id: 'wf_3', displayName: macosName }) - - mockOrderByThenLimit([]) - dbChainMockFns.orderBy.mockResolvedValueOnce([row] as never) - - const result = await findMothershipUploadRowByChatAndName(CHAT_ID, asciiName) - - expect(result?.id).toBe('wf_3') - }) -}) - -describe('listChatUploads', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - }) - - it('returns rows in upload order with name set to displayName', async () => { - const rows = [ - makeRow({ id: 'a', displayName: 'image.png' }), - makeRow({ id: 'b', displayName: 'image (2).png' }), - makeRow({ id: 'c', displayName: 'image (3).png' }), - ] - dbChainMockFns.orderBy.mockResolvedValueOnce(rows) - - const result = await listChatUploads(CHAT_ID) - - expect(result.map((r) => r.id)).toEqual(['a', 'b', 'c']) - expect(result.map((r) => r.name)).toEqual(['image.png', 'image (2).png', 'image (3).png']) - expect(result.every((r) => r.storageContext === 'mothership')).toBe(true) - }) - - it('returns [] and does not throw when the DB query fails', async () => { - dbChainMockFns.orderBy.mockRejectedValueOnce(new Error('boom')) - const result = await listChatUploads(CHAT_ID) - expect(result).toEqual([]) - }) -}) - -describe('readChatUpload', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - mockReadFileRecord.mockReset() - }) - - it('captures the content revision before read and grep can race with upload promotion', async () => { - const row = makeRow({ displayName: 'note.txt', contentType: 'text/plain' }) - const result = { content: 'note', totalLines: 1 } - for (const read of [ - () => readChatUploadWithProvenance('note.txt', CHAT_ID), - () => grepChatUploadWithProvenance('note.txt', CHAT_ID, 'note'), - ]) { - mockOrderByThenLimit([row]) - mockReadFileRecord.mockResolvedValueOnce(result) - expect((await read())?.file).toEqual({ - fileId: row.id, - key: row.key, - context: 'mothership', - contentUpdatedAt: NOW, - }) - } - }) - - it('reads the row resolved by the suffixed displayName', async () => { - const row = makeRow({ id: 'wf_2', displayName: 'image (2).png' }) - mockOrderByThenLimit([row]) - mockReadFileRecord.mockResolvedValueOnce({ content: 'PNGDATA', totalLines: 1 }) - - const result = await readChatUpload('image (2).png', CHAT_ID) - - expect(result).toEqual({ content: 'PNGDATA', totalLines: 1 }) - expect(mockReadFileRecord).toHaveBeenCalledWith( - expect.objectContaining({ id: 'wf_2', name: 'image (2).png', storageContext: 'mothership' }) - ) - }) - - it('returns extract-first guidance for a .zip upload instead of reading bytes', async () => { - const row = makeRow({ id: 'wf_z', displayName: 'bundle.zip', contentType: 'application/zip' }) - mockOrderByThenLimit([row]) - mockFetchBuffer.mockResolvedValueOnce(ZIP_SHAPED) - - const result = await readChatUpload('bundle.zip', CHAT_ID) - - expect(result?.content).toContain('save_upload') - expect(result?.content).toContain('extract') - expect(mockReadFileRecord).not.toHaveBeenCalled() - }) - - it('returns extract-first guidance for a large .zip without downloading it', async () => { - const row = makeRow({ - id: 'wf_z', - displayName: 'huge.zip', - contentType: 'application/zip', - sizeBytes: 50 * 1024 * 1024, - }) - mockOrderByThenLimit([row]) - - const result = await readChatUpload('huge.zip', CHAT_ID) - - expect(result?.content).toContain('save_upload') - expect(mockFetchBuffer).not.toHaveBeenCalled() - expect(mockReadFileRecord).not.toHaveBeenCalled() - }) - - it('reads a small mislabeled ".zip" (non-zip bytes) normally instead of dead-ending it', async () => { - const row = makeRow({ id: 'wf_m', displayName: 'data.zip', contentType: 'text/csv' }) - mockOrderByThenLimit([row]) - mockFetchBuffer.mockResolvedValueOnce(Buffer.from('a,b,c\n1,2,3\n')) - mockReadFileRecord.mockResolvedValueOnce({ content: 'a,b,c\n1,2,3', totalLines: 2 }) - - const result = await readChatUpload('data.zip', CHAT_ID) - - expect(result).toEqual({ content: 'a,b,c\n1,2,3', totalLines: 2 }) - expect(mockReadFileRecord).toHaveBeenCalledTimes(1) - }) - - it('returns null when no row matches', async () => { - mockOrderByThenLimit([]) - dbChainMockFns.orderBy.mockResolvedValueOnce([] as never) - - const result = await readChatUpload('nope.png', CHAT_ID) - - expect(result).toBeNull() - expect(mockReadFileRecord).not.toHaveBeenCalled() - }) -}) - -describe('grepChatUpload', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - mockReadFileRecord.mockReset() - }) - - it('throws WorkspaceFileGrepError with extract-first guidance for a .zip upload', async () => { - const row = makeRow({ id: 'wf_z', displayName: 'bundle.zip', contentType: 'application/zip' }) - mockOrderByThenLimit([row]) - mockFetchBuffer.mockResolvedValueOnce(ZIP_SHAPED) - - const error = await grepChatUpload('bundle.zip', CHAT_ID, 'foo').catch((e) => e) - - expect(error).toBeInstanceOf(WorkspaceFileGrepError) - expect(error.message).toContain('save_upload') - expect(error.message).toContain('extract') - expect(mockReadFileRecord).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts deleted file mode 100644 index 378ff6ebd65..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { db } from '@sim/db' -import { type WorkspaceFileRow, workspaceFiles } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { and, asc, desc, eq, isNull, or } from 'drizzle-orm' -import { - type FileReadResult, - isReadableFileType, - MAX_TEXT_READ_BYTES, - readFileRecord, -} from '@/lib/copilot/vfs/file-reader' -import { - type GrepCountEntry, - type GrepMatch, - type GrepOptions, - grepReadResult, - WorkspaceFileGrepError, -} from '@/lib/copilot/vfs/operations' -import { decodeVfsSegment, encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' -import { isZipShaped } from '@/lib/file-parsers/zip-guard' -import { getServePathPrefix } from '@/lib/uploads' -import { - fetchWorkspaceFileBuffer, - type WorkspaceFileRecord, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import type { WorkspaceFileSecretProvenanceEnvelope } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' -import { buildArchiveExtractGuidance, isArchiveFileName } from '@/lib/uploads/utils/file-utils' - -const logger = createLogger('UploadFileReader') - -/** - * Sniff budget for uploads whose NAME says archive: below this size the actual - * bytes decide (a mislabeled text file named `data.zip` stays readable instead - * of being trapped between read-says-extract and extract-says-invalid); above - * it the extension is trusted so a real 100MB zip is never downloaded just to - * refuse it. Aligned with the read path's inline text cap — any mislabeled - * file too big to sniff would be rejected by read() as too large anyway, so - * nothing readable is ever dead-ended. - */ -const ARCHIVE_SNIFF_MAX_BYTES = MAX_TEXT_READ_BYTES - -/** - * True when the upload should get extract-first guidance: named like an archive - * and — for small files — actually shaped like one. - */ -async function isActualArchiveUpload(record: WorkspaceFileRecord): Promise { - if (!isArchiveFileName(record.name)) return false - if (record.size > ARCHIVE_SNIFF_MAX_BYTES) return true - try { - const buffer = await fetchWorkspaceFileBuffer(record, { maxBytes: ARCHIVE_SNIFF_MAX_BYTES }) - return isZipShaped(buffer) - } catch { - return true - } -} - -/** - * Canonical comparison key for an upload's VFS name. Accepts both the raw display - * name and a percent-encoded segment (decode first — a no-op for raw names — - * then re-encode to the canonical `files/`-style form) so either spelling - * resolves the same row. Raw names containing a literal `%` cannot be decoded; - * fall back to encoding the raw name. - */ -function canonicalUploadKey(name: string): string { - let decoded = name - try { - decoded = decodeVfsSegment(name) - } catch { - decoded = name - } - try { - return encodeVfsSegment(decoded) - } catch { - return name.trim() - } -} - -/** VFS-visible name. Coalesces to originalName for legacy rows that predate displayName. */ -function vfsName(row: WorkspaceFileRow): string { - return row.displayName ?? row.originalName -} - -function toWorkspaceFileRecord(row: WorkspaceFileRow): WorkspaceFileRecord { - const pathPrefix = getServePathPrefix() - return { - id: row.id, - workspaceId: row.workspaceId || '', - name: vfsName(row), - key: row.key, - path: `${pathPrefix}${encodeURIComponent(row.key)}?context=mothership`, - size: getWorkspaceFileSize(row), - type: row.contentType, - uploadedBy: row.userId, - deletedAt: row.deletedAt, - uploadedAt: row.uploadedAt, - updatedAt: row.updatedAt, - contentUpdatedAt: row.contentUpdatedAt, - storageContext: 'mothership', - } -} - -/** - * Resolve a mothership upload row by VFS name (the collision-disambiguated `displayName` - * for new rows, or `originalName` for legacy rows that predate the column). Prefers an - * exact DB match; falls back to a normalized scan when the model passes a visually - * equivalent name (e.g. macOS U+202F vs ASCII space in screenshot filenames). - * - * On ambiguity (multiple legacy rows sharing the same originalName in one chat — the - * pre-displayName collision case), returns the most recent upload. New rows are unique - * by index so this only affects pre-fix data. - */ -export async function findMothershipUploadRowByChatAndName( - chatId: string, - fileName: string -): Promise { - const exactRows = await db - .select() - .from(workspaceFiles) - .where( - and( - eq(workspaceFiles.chatId, chatId), - eq(workspaceFiles.context, 'mothership'), - or( - eq(workspaceFiles.displayName, fileName), - and(isNull(workspaceFiles.displayName), eq(workspaceFiles.originalName, fileName)) - ), - isNull(workspaceFiles.deletedAt) - ) - ) - .orderBy(desc(workspaceFiles.uploadedAt), desc(workspaceFiles.id)) - .limit(1) - - if (exactRows[0]) { - return exactRows[0] - } - - const allRows = await db - .select() - .from(workspaceFiles) - .where( - and( - eq(workspaceFiles.chatId, chatId), - eq(workspaceFiles.context, 'mothership'), - isNull(workspaceFiles.deletedAt) - ) - ) - .orderBy(desc(workspaceFiles.uploadedAt), desc(workspaceFiles.id)) - - const segmentKey = canonicalUploadKey(fileName) - return allRows.find((r) => canonicalUploadKey(vfsName(r)) === segmentKey) ?? null -} - -/** - * List all chat-scoped uploads for a given chat in upload order. - */ -export async function listChatUploads(chatId: string): Promise { - try { - const rows = await db - .select() - .from(workspaceFiles) - .where( - and( - eq(workspaceFiles.chatId, chatId), - eq(workspaceFiles.context, 'mothership'), - isNull(workspaceFiles.deletedAt) - ) - ) - .orderBy(asc(workspaceFiles.uploadedAt), asc(workspaceFiles.id)) - - return rows.map(toWorkspaceFileRecord) - } catch (err) { - logger.warn('Failed to list chat uploads', { - chatId, - error: toError(err).message, - }) - return [] - } -} - -/** - * Read a specific uploaded file by display name within a chat session. - * Resolves names with `normalizeVfsSegment` so macOS screenshot spacing (e.g. U+202F) - * matches when the model passes a visually equivalent path. A `.zip` upload is not - * read directly — it returns extract-first guidance instead of binary bytes. - */ -export async function readChatUpload( - filename: string, - chatId: string -): Promise { - return (await readChatUploadWithProvenance(filename, chatId))?.value ?? null -} - -export async function readChatUploadWithProvenance( - filename: string, - chatId: string -): Promise | null> { - try { - const row = await findMothershipUploadRowByChatAndName(chatId, filename) - if (!row) return null - const record = toWorkspaceFileRecord(row) - if (await isActualArchiveUpload(record)) { - return { - value: { content: `[${buildArchiveExtractGuidance(record.name)}]`, totalLines: 1 }, - } - } - const result = await readFileRecord(record) - if (!result) return null - return { - value: result, - file: { - fileId: record.id, - key: record.key, - context: 'mothership', - contentUpdatedAt: row.contentUpdatedAt, - }, - view: isReadableFileType(record.type) ? 'complete' : 'derived', - } - } catch (err) { - logger.warn('Failed to read chat upload', { - filename, - chatId, - error: toError(err).message, - }) - return null - } -} - -/** - * Grep the content of a single chat upload (`uploads/`), mirroring - * {@link WorkspaceVFS.grepFile} for the chat-scoped uploads namespace. Resolves - * the upload by name (raw or percent-encoded), reads its text per file type, and - * greps it. Throws {@link WorkspaceFileGrepError} when the upload is missing or - * has no searchable text (image/binary/too-large) so the caller surfaces the - * message verbatim. - */ -export async function grepChatUpload( - filename: string, - chatId: string, - pattern: string, - options?: GrepOptions -): Promise { - return (await grepChatUploadWithProvenance(filename, chatId, pattern, options)).value -} - -export async function grepChatUploadWithProvenance( - filename: string, - chatId: string, - pattern: string, - options?: GrepOptions -): Promise> { - const row = await findMothershipUploadRowByChatAndName(chatId, filename) - if (!row) { - throw new WorkspaceFileGrepError( - `Upload not found: "${filename}". Use glob("uploads/*") to list available uploads.` - ) - } - const record = toWorkspaceFileRecord(row) - if (await isActualArchiveUpload(record)) { - throw new WorkspaceFileGrepError(buildArchiveExtractGuidance(record.name)) - } - const result = await readFileRecord(record) - if (!result) { - throw new WorkspaceFileGrepError(`Upload content not found for "${filename}".`) - } - const uploadsPath = `uploads/${canonicalUploadKey(record.name)}` - return { - value: grepReadResult(uploadsPath, result, pattern, uploadsPath, options), - file: { - fileId: record.id, - key: record.key, - context: 'mothership', - contentUpdatedAt: row.contentUpdatedAt, - }, - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts deleted file mode 100644 index a846d81b736..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ /dev/null @@ -1,1103 +0,0 @@ -/** - * @vitest-environment node - */ -import { dbChainMock, resetDbChainMock, schemaMock, workflowAuthzMockFns } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { knowledgeOperations } from '@/lib/knowledge/application/operations' -import { tableOperations } from '@/lib/table/application/operations' -import { workflowOperations } from '@/lib/workflows/application/operations' -import { fileOperations } from '@/lib/workspace-files/application/operations' - -const mocks = vi.hoisted(() => ({ - ensureWorkspaceAccess: vi.fn(), - ensureWorkflowAccess: vi.fn(), - getWorkspaceFileByName: vi.fn(), - resolveWorkspaceFileReference: vi.fn(), - findWorkspaceFileFolderIdByPath: vi.fn(), - ensureWorkspaceFileFolderPath: vi.fn(), - ensureCopilotFileFolderPath: vi.fn(), - moveWorkspaceFileItems: vi.fn(), - updateWorkspaceFileFolder: vi.fn(), - deleteWorkspaceFile: vi.fn(), - renameWorkspaceFile: vi.fn(), - performUpdateWorkspaceFileFolder: vi.fn(), - performCreateFolder: vi.fn(), - performUpdateFolder: vi.fn(), - moveWorkflowVfs: vi.fn(), - copyWorkflowVfs: vi.fn(), - createWorkflowVfsFolders: vi.fn(), - deleteWorkflowVfs: vi.fn(), - listFolders: vi.fn(), - verifyFolderWorkspace: vi.fn(), - listTables: vi.fn(), - renameTable: vi.fn(), - listKnowledgeBases: vi.fn(), - updateKnowledgeBase: vi.fn(), - deleteKnowledgeBase: vi.fn(), - knowledgeBaseDeleted: vi.fn(), - createFileVfsFolders: vi.fn(), - relocateFileVfsItems: vi.fn(), - deleteFileVfsItems: vi.fn(), - renameTableVfs: vi.fn(), - deleteTableVfs: vi.fn(), - transferTableVfs: vi.fn(), - createTableFolders: vi.fn(), - deleteTableFolders: vi.fn(), - renameKnowledgeVfs: vi.fn(), - deleteKnowledgeVfs: vi.fn(), - transferKnowledgeVfs: vi.fn(), - createKnowledgeFolders: vi.fn(), - deleteKnowledgeFolders: vi.fn(), -})) - -vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) - -vi.mock('@/lib/copilot/tools/handlers/access', () => ({ - ensureWorkspaceAccess: mocks.ensureWorkspaceAccess, - ensureWorkflowAccess: mocks.ensureWorkflowAccess, -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - getWorkspaceFileByName: mocks.getWorkspaceFileByName, -})) - -vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ - resolveWorkspaceFileReference: mocks.resolveWorkspaceFileReference, -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ - findWorkspaceFileFolderIdByPath: mocks.findWorkspaceFileFolderIdByPath, - normalizeWorkspaceFileItemName: vi.fn((name: string) => name.trim()), -})) - -vi.mock('@/lib/copilot/tools/server/files/file-folder-application', () => ({ - resolveCopilotFilePrincipal: vi.fn((context, workspaceId, fileId) => ({ - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: context.userId, - workspaceId, - delegationId: `copilot-tool:${context.toolCallId}`, - audience: 'sim:workspace-files', - issuedAt: new Date(), - expiresAt: new Date(Date.now() + 300_000), - ...(fileId ? { resourceScope: { fileId } } : {}), - })), - ensureCopilotFileFolderPath: mocks.ensureCopilotFileFolderPath, -})) - -vi.mock('@/lib/copilot/tools/server/workspace-scope', () => ({ - requireCopilotWorkspace: vi.fn((context) => context.workspaceId), -})) - -vi.mock('@/lib/workspace-files/application/move-workspace-file-items', () => ({ - moveWorkspaceFileItemsOperation: { - operation: fileOperations.move, - execute: mocks.moveWorkspaceFileItems, - }, -})) - -vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ - updateWorkspaceFileFolderOperation: { - operation: fileOperations.updateFolder, - execute: mocks.updateWorkspaceFileFolder, - }, -})) - -vi.mock('@/lib/workspace-files/application/delete-workspace-file', () => ({ - deleteWorkspaceFileOperation: { - operation: fileOperations.delete, - execute: mocks.deleteWorkspaceFile, - }, -})) - -vi.mock('@/lib/workspace-files/application/archive-workspace-file-items', () => ({ - archiveWorkspaceFileItemsOperation: { - operation: fileOperations.delete, - execute: mocks.deleteWorkspaceFile, - }, -})) - -vi.mock('@/lib/workspace-files/orchestration', () => ({})) - -vi.mock('@/lib/workspace-files/application/rename-workspace-file', () => ({ - renameWorkspaceFile: { - operation: fileOperations.rename, - execute: mocks.renameWorkspaceFile, - }, -})) - -vi.mock('@/lib/workflows/application/workflow-vfs', () => ({ - moveWorkflowVfsItems: { - operation: workflowOperations.moveVfsItems, - execute: mocks.moveWorkflowVfs, - }, - copyWorkflowVfsItems: { - operation: workflowOperations.copyVfsItems, - execute: mocks.copyWorkflowVfs, - }, - createWorkflowVfsFolders: { - operation: workflowOperations.createVfsFolders, - execute: mocks.createWorkflowVfsFolders, - }, - deleteWorkflowVfsItems: { - operation: workflowOperations.deleteVfsItems, - execute: mocks.deleteWorkflowVfs, - }, -})) - -vi.mock('@/lib/workspace-files/application/workspace-file-vfs', () => ({ - createWorkspaceFileVfsFolders: { - operation: fileOperations.createVfsFolders, - execute: mocks.createFileVfsFolders, - }, - relocateWorkspaceFileVfsItems: { - operation: fileOperations.relocateVfsItems, - execute: mocks.relocateFileVfsItems, - }, - deleteWorkspaceFileVfsItems: { - operation: fileOperations.deleteVfsItems, - execute: mocks.deleteFileVfsItems, - }, -})) - -vi.mock('@/lib/table/application/table-vfs', () => ({ - renameTableByVfsPath: { - operation: tableOperations.renameByVfsPath, - execute: mocks.renameTableVfs, - }, - deleteTableByVfsPath: { - operation: tableOperations.deleteByVfsPath, - execute: mocks.deleteTableVfs, - }, - transferTableVfsItems: { - operation: tableOperations.moveByVfsPath, - execute: mocks.transferTableVfs, - }, - createTableVfsFolders: { - operation: tableOperations.createFolder, - execute: mocks.createTableFolders, - }, - deleteTableVfsFolders: { - operation: tableOperations.deleteFolder, - execute: mocks.deleteTableFolders, - }, -})) - -vi.mock('@/lib/knowledge/application/knowledge-vfs', () => ({ - renameKnowledgeBaseByVfsPath: { - operation: knowledgeOperations.renameByVfsPath, - execute: mocks.renameKnowledgeVfs, - }, - deleteKnowledgeBaseByVfsPath: { - operation: knowledgeOperations.deleteByVfsPath, - execute: mocks.deleteKnowledgeVfs, - }, - transferKnowledgeVfsItems: { - operation: knowledgeOperations.moveByVfsPath, - execute: mocks.transferKnowledgeVfs, - }, - createKnowledgeVfsFolders: { - operation: knowledgeOperations.manageVfsFolders, - execute: mocks.createKnowledgeFolders, - }, - deleteKnowledgeVfsFolders: { - operation: knowledgeOperations.manageVfsFolders, - execute: mocks.deleteKnowledgeFolders, - }, -})) - -vi.mock('@/lib/table/service', () => ({ - listTables: mocks.listTables, - renameTable: mocks.renameTable, -})) - -vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ - listKnowledgeBases: { - operation: knowledgeOperations.list, - execute: mocks.listKnowledgeBases, - }, - updateKnowledgeBaseOperation: { - operation: knowledgeOperations.update, - execute: mocks.updateKnowledgeBase, - }, - deleteKnowledgeBaseOperation: { - operation: knowledgeOperations.delete, - execute: mocks.deleteKnowledgeBase, - }, -})) - -vi.mock('@/lib/core/telemetry', () => ({ - PlatformEvents: { knowledgeBaseDeleted: mocks.knowledgeBaseDeleted }, -})) - -import type { ExecutionContext } from '@/lib/copilot/request/types' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { executeVfsCp, executeVfsMkdir, executeVfsMv, executeVfsRm } from './vfs-mutate' - -const context = { - userId: 'user-1', - workspaceId: 'ws-1', - toolCallId: 'tool-call-1', - copilotToolExecution: true, -} as ExecutionContext - -describe('vfs mv/cp', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - mocks.ensureWorkspaceAccess.mockResolvedValue(undefined) - mocks.ensureWorkflowAccess.mockResolvedValue({ workspaceId: 'ws-1', workflow: {} }) - workflowAuthzMockFns.mockAssertFolderMutable.mockResolvedValue(undefined) - workflowAuthzMockFns.mockAssertWorkflowMutable.mockResolvedValue(undefined) - mocks.verifyFolderWorkspace.mockResolvedValue(true) - mocks.listFolders.mockResolvedValue([]) - mocks.moveWorkflowVfs.mockResolvedValue({ outcomes: [] }) - mocks.copyWorkflowVfs.mockResolvedValue({ outcomes: [] }) - mocks.createWorkflowVfsFolders.mockResolvedValue({ outcomes: [] }) - mocks.deleteWorkflowVfs.mockResolvedValue({ outcomes: [] }) - mocks.getWorkspaceFileByName.mockResolvedValue(null) - mocks.resolveWorkspaceFileReference.mockImplementation(async ({ reference }) => { - const segments = reference.split('/').slice(1) - const folderSegments = segments.slice(0, -1) - if (folderSegments.length > 0) { - const folderId = await mocks.findWorkspaceFileFolderIdByPath('ws-1', folderSegments) - if (!folderId) return null - return mocks.getWorkspaceFileByName('ws-1', segments.at(-1), { folderId }) - } - return mocks.getWorkspaceFileByName('ws-1', segments.at(-1), { folderId: null }) - }) - mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) - mocks.ensureWorkspaceFileFolderPath.mockResolvedValue({ - folderId: 'ensured-folder', - createdFolderIds: [], - }) - mocks.ensureCopilotFileFolderPath.mockResolvedValue('ensured-folder') - mocks.moveWorkspaceFileItems.mockResolvedValue({ movedItems: { files: 1, folders: 0 } }) - mocks.updateWorkspaceFileFolder.mockResolvedValue({ folder: { name: 'Reports 2025' } }) - mocks.deleteWorkspaceFile.mockResolvedValue({ - id: 'file-1', - workspaceId: 'ws-1', - deleted: true, - }) - mocks.renameWorkspaceFile.mockResolvedValue({ - file: { id: 'file-1', name: 'renamed.md' }, - }) - mocks.createFileVfsFolders.mockResolvedValue({ outcomes: [] }) - mocks.relocateFileVfsItems.mockResolvedValue({ outcomes: [] }) - mocks.deleteFileVfsItems.mockResolvedValue({ outcomes: [] }) - mocks.renameTableVfs.mockResolvedValue({ - id: 'tbl-1', - name: 'Customers', - previousName: 'Leads', - workspaceId: 'ws-1', - }) - mocks.renameKnowledgeVfs.mockResolvedValue({ - id: 'kb-1', - name: 'Product Docs', - previousName: 'Docs', - workspaceId: 'ws-1', - }) - mocks.deleteKnowledgeVfs.mockResolvedValue({ - id: 'kb-1', - name: 'Docs', - workspaceId: 'ws-1', - deleted: true, - }) - }) - - afterAll(() => { - resetDbChainMock() - workflowAuthzMockFns.mockAssertFolderMutable.mockReset().mockResolvedValue(undefined) - workflowAuthzMockFns.mockAssertWorkflowMutable.mockReset().mockResolvedValue(undefined) - }) - - describe('category rules', () => { - it('rejects cross-category moves', async () => { - const result = await executeVfsMv( - { sources: ['files/report.pdf'], destination: 'workflows/report' }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('across categories') - }) - - it('rejects uploads with a save_upload pointer', async () => { - const result = await executeVfsMv( - { sources: ['uploads/data.csv'], destination: 'files/data.csv' }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('save_upload') - }) - - it('rejects read-only categories', async () => { - const result = await executeVfsMv( - { sources: ['components/blocks/gmail.json'], destination: 'components/blocks/g.json' }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('not a movable resource') - }) - - it('aborts before mutating when the request was cancelled', async () => { - const abortedContext = { - userId: 'user-1', - workspaceId: 'ws-1', - abortSignal: { aborted: true }, - } as unknown as ExecutionContext - const result = await executeVfsMv( - { sources: ['files/a.md'], destination: 'files/b.md' }, - abortedContext - ) - expect(result.success).toBe(false) - expect(result.error).toContain('aborted') - expect(mocks.moveWorkspaceFileItems).not.toHaveBeenCalled() - }) - }) - - describe('files', () => { - it('routes a same-folder rename through the delegated file use case', async () => { - mocks.relocateFileVfsItems.mockResolvedValue({ - outcomes: [ - { - source: 'files/draft.md', - targetSegments: ['final.md'], - resourceType: 'file', - resourceId: 'file-1', - }, - ], - }) - - const result = await executeVfsMv( - { sources: ['files/draft.md'], destination: 'files/final.md' }, - context - ) - - expect(mocks.relocateFileVfsItems).toHaveBeenCalledWith({ - principal: expect.objectContaining({ - kind: 'delegated', - subjectUserId: 'user-1', - workspaceId: 'ws-1', - delegationId: 'copilot-tool:tool-call-1', - }), - input: { - workspaceId: 'ws-1', - sources: [{ source: 'files/draft.md', segments: ['draft.md'] }], - destination: { segments: ['final.md'], trailingSlash: false }, - }, - }) - expect(mocks.moveWorkspaceFileItems).not.toHaveBeenCalled() - expect(result).toMatchObject({ - success: true, - output: { results: [{ to: 'files/final.md', id: 'file-1' }] }, - }) - }) - - it('moves and renames a file in one call, auto-creating destination folders', async () => { - mocks.relocateFileVfsItems.mockResolvedValue({ - outcomes: [ - { - source: 'files/draft.md', - targetSegments: ['Reports', '2026', 'final.md'], - resourceType: 'file', - resourceId: 'file-1', - }, - ], - }) - - const result = await executeVfsMv( - { sources: ['files/draft.md'], destination: 'files/Reports/2026/final.md' }, - context - ) - - expect(mocks.relocateFileVfsItems).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - destination: { segments: ['Reports', '2026', 'final.md'], trailingSlash: false }, - }), - }) - ) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - results: [{ from: 'files/draft.md', to: 'files/Reports/2026/final.md', kind: 'file' }], - }) - }) - - it('moves into an existing folder keeping the name without creating anything', async () => { - mocks.relocateFileVfsItems.mockResolvedValue({ - outcomes: [ - { - source: 'files/a.png', - targetSegments: ['Images', 'a.png'], - resourceType: 'file', - resourceId: 'file-1', - }, - ], - }) - - const result = await executeVfsMv( - { sources: ['files/a.png'], destination: 'files/Images' }, - context - ) - - expect(mocks.relocateFileVfsItems).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - destination: { segments: ['Images'], trailingSlash: false }, - }), - }) - ) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ results: [{ to: 'files/Images/a.png' }] }) - }) - - it('requires a folder destination for multiple sources', async () => { - mocks.relocateFileVfsItems.mockResolvedValue({ - outcomes: [ - { - source: 'files/a.png', - resourceType: 'file', - error: 'Destination must be a folder when moving multiple sources', - }, - { - source: 'files/b.png', - resourceType: 'file', - error: 'Destination must be a folder when moving multiple sources', - }, - ], - }) - const result = await executeVfsMv( - { sources: ['files/a.png', 'files/b.png'], destination: 'files/Images/c.png' }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('must be a folder') - }) - - it('resolves sources at their exact path only — no cross-folder name fallback', async () => { - mocks.relocateFileVfsItems.mockResolvedValue({ - outcomes: [ - { - source: 'files/report.pdf', - resourceType: 'file', - error: 'Not found at files/report.pdf', - }, - ], - }) - - const result = await executeVfsMv( - { sources: ['files/report.pdf'], destination: 'files/Archive/' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('Not found') - expect(mocks.relocateFileVfsItems).toHaveBeenCalledOnce() - }) - - it('rejects copying workspace files — cp is workflows-only', async () => { - mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'template.md' }) - - const result = await executeVfsCp( - { sources: ['files/template.md'], destination: 'files/Reports/january.md' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('cp only duplicates workflows') - expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() - }) - - it('moves and renames a file folder via the shared folder operation', async () => { - mocks.relocateFileVfsItems.mockResolvedValue({ - outcomes: [ - { - source: 'files/Reports', - targetSegments: ['Archive', 'Reports 2025'], - resourceType: 'folder', - resourceId: 'folder-src', - }, - ], - }) - - const result = await executeVfsMv( - { sources: ['files/Reports'], destination: 'files/Archive/Reports 2025' }, - context - ) - - expect(mocks.relocateFileVfsItems).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - destination: { segments: ['Archive', 'Reports 2025'], trailingSlash: false }, - }), - }) - ) - expect(result.success).toBe(true) - }) - }) - - describe('workflows', () => { - it('routes an encoded rename through one bounded workflow VFS command', async () => { - mocks.moveWorkflowVfs.mockResolvedValue({ - outcomes: [ - { - source: 'workflows/Old%20Name', - targetSegments: ['New Name'], - resourceType: 'workflow', - resourceId: 'wf-1', - }, - ], - }) - - const result = await executeVfsMv( - { sources: ['workflows/Old%20Name'], destination: 'workflows/New Name' }, - context - ) - - expect(mocks.moveWorkflowVfs).toHaveBeenCalledWith( - expect.objectContaining({ - principal: expect.objectContaining({ - serviceId: 'copilot', - workspaceId: 'ws-1', - }), - input: { - workspaceId: 'ws-1', - sources: [{ source: 'workflows/Old%20Name', segments: ['Old Name'] }], - destination: { segments: ['New Name'], trailingSlash: false }, - }, - }) - ) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ results: [{ to: 'workflows/New%20Name' }] }) - }) - - it('passes a multi-source move to the application once', async () => { - mocks.moveWorkflowVfs.mockResolvedValue({ - outcomes: [ - { - source: 'workflows/One', - targetSegments: ['Archive', 'One'], - resourceType: 'workflow', - resourceId: 'wf-1', - }, - { - source: 'workflows/Two', - targetSegments: ['Archive', 'Two'], - resourceType: 'workflow', - resourceId: 'wf-2', - }, - ], - }) - - const result = await executeVfsMv( - { sources: ['workflows/One', 'workflows/Two'], destination: 'workflows/Archive/' }, - context - ) - - expect(mocks.moveWorkflowVfs).toHaveBeenCalledOnce() - expect(result.success).toBe(true) - }) - - it('preserves safe workflow application validation errors', async () => { - mocks.moveWorkflowVfs.mockRejectedValueOnce( - new OrchestrationError( - 'validation', - 'With multiple sources the destination must be a folder' - ) - ) - - const result = await executeVfsMv( - { sources: ['workflows/One', 'workflows/Two'], destination: 'workflows/Renamed' }, - context - ) - - expect(result).toEqual({ - success: false, - error: 'With multiple sources the destination must be a folder', - }) - }) - - it('surfaces locked-workflow rejections per item', async () => { - mocks.moveWorkflowVfs.mockResolvedValue({ - outcomes: [ - { - source: 'workflows/Locked%20One', - resourceType: 'workflow', - error: 'Workflow is locked', - }, - ], - }) - - const result = await executeVfsMv( - { sources: ['workflows/Locked%20One'], destination: 'workflows/Renamed' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('locked') - }) - - it('duplicates a workflow with cp (locked source allowed)', async () => { - mocks.copyWorkflowVfs.mockResolvedValue({ - outcomes: [ - { - source: 'workflows/Template', - targetSegments: ['My Copy'], - resourceType: 'workflow', - resourceId: 'wf-2', - }, - ], - }) - - const result = await executeVfsCp( - { sources: ['workflows/Template'], destination: 'workflows/My Copy' }, - context - ) - - expect(workflowAuthzMockFns.mockAssertWorkflowMutable).not.toHaveBeenCalled() - expect(mocks.copyWorkflowVfs).toHaveBeenCalledOnce() - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ results: [{ to: 'workflows/My%20Copy', id: 'wf-2' }] }) - }) - - it('rejects copying workflow folders', async () => { - mocks.copyWorkflowVfs.mockResolvedValue({ - outcomes: [ - { - source: 'workflows/Projects', - resourceType: 'folder', - error: 'Workflow folders cannot be copied.', - }, - ], - }) - const result = await executeVfsCp( - { sources: ['workflows/Projects'], destination: 'workflows/Projects Copy' }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('cannot be copied') - }) - - it('moves and renames a workflow folder', async () => { - mocks.moveWorkflowVfs.mockResolvedValue({ - outcomes: [ - { - source: 'workflows/Q1', - targetSegments: ['Archive', 'Q1 2026'], - resourceType: 'folder', - resourceId: 'fold-1', - }, - ], - }) - - const result = await executeVfsMv( - { sources: ['workflows/Q1'], destination: 'workflows/Archive/Q1 2026' }, - context - ) - - expect(mocks.moveWorkflowVfs).toHaveBeenCalledOnce() - expect(result.success).toBe(true) - }) - - it('does not expose workflow application infrastructure errors', async () => { - mocks.moveWorkflowVfs.mockResolvedValue({ - outcomes: [ - { - source: 'workflows/Old%20Name', - resourceType: 'workflow', - error: 'Workflow mutation failed', - }, - ], - }) - - const result = await executeVfsMv( - { sources: ['workflows/Old%20Name'], destination: 'workflows/New Name' }, - context - ) - - expect(result).toMatchObject({ - success: false, - error: 'Workflow mutation failed', - output: { results: [expect.objectContaining({ error: 'Workflow mutation failed' })] }, - }) - }) - - it('deletes an encoded workflow alias through the workflow application operation', async () => { - mocks.deleteWorkflowVfs.mockResolvedValue({ - outcomes: [ - { - source: 'workflows/Old%20Name', - resourceType: 'workflow', - resourceId: 'wf-1', - }, - ], - }) - - const result = await executeVfsRm({ paths: ['workflows/Old%20Name'] }, context) - - expect(result.success).toBe(true) - expect(mocks.deleteWorkflowVfs).toHaveBeenCalledWith( - expect.objectContaining({ - principal: expect.objectContaining({ - serviceId: 'copilot', - workspaceId: 'ws-1', - }), - input: { - workspaceId: 'ws-1', - paths: [{ source: 'workflows/Old%20Name', segments: ['Old Name'] }], - }, - }) - ) - }) - }) - - describe('mkdir', () => { - it('creates a nested file folder chain', async () => { - mocks.createFileVfsFolders.mockResolvedValue({ - outcomes: [ - { - source: 'files/Reports/2026', - targetSegments: ['Reports', '2026'], - resourceType: 'folder', - resourceId: 'folder-2026', - }, - ], - }) - const result = await executeVfsMkdir({ paths: ['files/Reports/2026'] }, context) - - expect(mocks.createFileVfsFolders).toHaveBeenCalledWith( - expect.objectContaining({ - input: { - workspaceId: 'ws-1', - paths: [{ source: 'files/Reports/2026', segments: ['Reports', '2026'] }], - }, - }) - ) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - results: [{ from: 'files/Reports/2026', to: 'files/Reports/2026', kind: 'file_folder' }], - }) - }) - - it('creates a workflow folder through the workflow application operation', async () => { - mocks.createWorkflowVfsFolders.mockResolvedValue({ - outcomes: [ - { - source: 'workflows/Project Plans', - targetSegments: ['Project Plans'], - resourceType: 'folder', - resourceId: 'fold-new', - }, - ], - }) - const result = await executeVfsMkdir({ paths: ['workflows/Project Plans'] }, context) - - expect(mocks.createWorkflowVfsFolders).toHaveBeenCalledWith( - expect.objectContaining({ - input: { - workspaceId: 'ws-1', - paths: [{ source: 'workflows/Project Plans', segments: ['Project Plans'] }], - }, - }) - ) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - results: [{ to: 'workflows/Project%20Plans', kind: 'workflow_folder', id: 'fold-new' }], - }) - }) - - it('creates table folders through the table application operation', async () => { - mocks.createTableFolders.mockResolvedValue({ - outcomes: [ - { source: 'tables/CRM', kind: 'folder', resourceId: 'fld-1', targetSegments: ['CRM'] }, - ], - }) - - const result = await executeVfsMkdir({ paths: ['tables/CRM'] }, context) - - expect(mocks.createTableFolders).toHaveBeenCalledWith( - expect.objectContaining({ - input: { - workspaceId: 'ws-1', - paths: [{ source: 'tables/CRM', segments: ['CRM'] }], - }, - }) - ) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - results: [{ from: 'tables/CRM', to: 'tables/CRM', kind: 'table_folder', id: 'fld-1' }], - }) - expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() - }) - - it('rejects the reserved knowledgebases/connectors folder path', async () => { - const result = await executeVfsMkdir({ paths: ['knowledgebases/connectors/sub'] }, context) - expect(result.success).toBe(false) - expect(result.output).toMatchObject({ - results: [ - { from: 'knowledgebases/connectors/sub', error: expect.stringContaining('reserved') }, - ], - }) - expect(mocks.createKnowledgeFolders).not.toHaveBeenCalled() - }) - - it('rejects creation inside a locked workflow folder', async () => { - mocks.createWorkflowVfsFolders.mockResolvedValue({ - outcomes: [ - { - source: 'workflows/Locked/Sub', - resourceType: 'folder', - error: 'Folder is locked', - }, - ], - }) - - const result = await executeVfsMkdir({ paths: ['workflows/Locked/Sub'] }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('locked') - expect(mocks.createWorkflowVfsFolders).toHaveBeenCalledOnce() - }) - }) - - describe('tables and knowledge bases (foldered)', () => { - it('renames a table through the transfer application operation', async () => { - mocks.transferTableVfs.mockResolvedValue({ - outcomes: [ - { - source: 'tables/Leads', - kind: 'resource', - resourceId: 'tbl-1', - targetSegments: ['Customers'], - }, - ], - }) - - const result = await executeVfsMv( - { sources: ['tables/Leads'], destination: 'tables/Customers' }, - context - ) - - expect(mocks.transferTableVfs).toHaveBeenCalledWith( - expect.objectContaining({ - input: { - workspaceId: 'ws-1', - sources: [{ source: 'tables/Leads', segments: ['Leads'] }], - destination: { segments: ['Customers'], trailingSlash: false }, - }, - }) - ) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ results: [{ to: 'tables/Customers', kind: 'table' }] }) - }) - - it('moves a table into a folder, folders auto-created server-side', async () => { - mocks.transferTableVfs.mockResolvedValue({ - outcomes: [ - { - source: 'tables/Leads', - kind: 'resource', - resourceId: 'tbl-1', - targetSegments: ['CRM', 'Leads'], - }, - ], - }) - - const result = await executeVfsMv( - { sources: ['tables/Leads'], destination: 'tables/CRM/' }, - context - ) - - expect(mocks.transferTableVfs).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - destination: { segments: ['CRM'], trailingSlash: true }, - }), - }) - ) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - results: [{ to: 'tables/CRM/Leads', kind: 'table' }], - }) - }) - - it('rejects copying tables', async () => { - const result = await executeVfsCp( - { sources: ['tables/Leads'], destination: 'tables/Leads Copy' }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('cannot be copied') - }) - - it('renames a knowledge base through trusted application operations', async () => { - mocks.transferKnowledgeVfs.mockResolvedValue({ - outcomes: [ - { - source: 'knowledgebases/Docs', - kind: 'resource', - resourceId: 'kb-1', - targetSegments: ['Product Docs'], - }, - ], - }) - - const result = await executeVfsMv( - { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/Product Docs' }, - context - ) - - expect(mocks.transferKnowledgeVfs).toHaveBeenCalledWith( - expect.objectContaining({ - principal: expect.objectContaining({ - kind: 'delegated', - subjectUserId: 'user-1', - workspaceId: 'ws-1', - delegationId: 'tool-call-1', - }), - input: { - workspaceId: 'ws-1', - sources: [{ source: 'knowledgebases/Docs', segments: ['Docs'] }], - destination: { segments: ['Product Docs'], trailingSlash: false }, - }, - }) - ) - expect(result.success).toBe(true) - }) - - it('propagates knowledge application infrastructure failures', async () => { - mocks.transferKnowledgeVfs.mockRejectedValueOnce(new Error('knowledge database unavailable')) - - await expect( - executeVfsMv( - { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/Product Docs' }, - context - ) - ).rejects.toThrow('knowledge database unavailable') - }) - - it('preserves an actionable knowledge rename conflict', async () => { - mocks.transferKnowledgeVfs.mockRejectedValue( - new OrchestrationError('conflict', 'A knowledge base named Product Docs already exists') - ) - - const result = await executeVfsMv( - { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/Product Docs' }, - context - ) - - expect(result).toMatchObject({ - success: false, - error: 'A knowledge base named Product Docs already exists', - }) - }) - - it('rejects the reserved knowledgebases/connectors name', async () => { - const result = await executeVfsMv( - { sources: ['knowledgebases/Docs'], destination: 'knowledgebases/connectors' }, - context - ) - expect(result.success).toBe(false) - expect(result.error).toContain('reserved') - }) - - it('deletes a knowledge base through the trusted application operation', async () => { - const result = await executeVfsRm({ paths: ['knowledgebases/Docs'] }, context) - - expect(result).toMatchObject({ - success: true, - output: { results: [{ from: 'knowledgebases/Docs', id: 'kb-1' }] }, - }) - expect(mocks.deleteKnowledgeVfs).toHaveBeenCalledWith( - expect.objectContaining({ - principal: expect.objectContaining({ delegationId: 'tool-call-1' }), - input: { - workspaceId: 'ws-1', - sourceName: 'Docs', - sourceSegments: ['Docs'], - }, - }) - ) - }) - - it('moves a whole table folder through the transfer operation', async () => { - mocks.transferTableVfs.mockResolvedValue({ - outcomes: [ - { - source: 'tables/CRM', - kind: 'folder', - resourceId: 'fld-1', - targetSegments: ['Archive', 'CRM'], - }, - ], - }) - - const result = await executeVfsMv( - { sources: ['tables/CRM'], destination: 'tables/Archive/' }, - context - ) - - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - results: [{ to: 'tables/Archive/CRM', kind: 'table_folder' }], - }) - }) - - it('rm retargets to the folder cascade when the path is a folder', async () => { - mocks.deleteTableVfs.mockRejectedValue( - new OrchestrationError('invalid', 'tables/CRM is a folder; this operation takes a table.') - ) - mocks.deleteTableFolders.mockResolvedValue({ - outcomes: [{ source: 'tables/CRM', kind: 'folder', resourceId: 'fld-1' }], - }) - - const result = await executeVfsRm({ paths: ['tables/CRM'] }, context) - - expect(mocks.deleteTableFolders).toHaveBeenCalledWith( - expect.objectContaining({ - input: { workspaceId: 'ws-1', paths: [{ source: 'tables/CRM', segments: ['CRM'] }] }, - }) - ) - expect(result.success).toBe(true) - expect(result.output).toMatchObject({ - results: [{ from: 'tables/CRM', kind: 'table_folder', id: 'fld-1' }], - }) - }) - - it('deletes a nested knowledge base by its folder path', async () => { - const result = await executeVfsRm({ paths: ['knowledgebases/Legal/Contracts'] }, context) - - expect(mocks.deleteKnowledgeVfs).toHaveBeenCalledWith( - expect.objectContaining({ - input: { - workspaceId: 'ws-1', - sourceName: 'Contracts', - sourceSegments: ['Legal', 'Contracts'], - }, - }) - ) - expect(result.success).toBe(true) - }) - - it('preserves an actionable knowledge delete failure', async () => { - mocks.deleteKnowledgeVfs.mockRejectedValue( - new OrchestrationError('not_found', 'Knowledge base no longer exists') - ) - - const result = await executeVfsRm({ paths: ['knowledgebases/Docs'] }, context) - - expect(result).toMatchObject({ - success: false, - error: 'Knowledge base no longer exists', - output: { - results: [ - expect.objectContaining({ - from: 'knowledgebases/Docs', - error: 'Knowledge base no longer exists', - }), - ], - }, - }) - }) - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts deleted file mode 100644 index 0a65249ebef..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ /dev/null @@ -1,849 +0,0 @@ -import { createLogger } from '@sim/logger' -import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' -import { - executeCopilotKnowledgeUseCase, - messageForCopilotKnowledgeError, -} from '@/lib/copilot/application/execute-knowledge-use-case' -import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' -import { - executeCopilotWorkflowUseCase, - messageForCopilotWorkflowError, -} from '@/lib/copilot/application/execute-workflow-use-case' -import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope' -import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { PlatformEvents } from '@/lib/core/telemetry' -import type { ResourceVfsOutcome } from '@/lib/folders/application/resource-vfs' -import { - createKnowledgeVfsFolders, - deleteKnowledgeBaseByVfsPath, - deleteKnowledgeVfsFolders, - transferKnowledgeVfsItems, -} from '@/lib/knowledge/application/knowledge-vfs' -import { captureServerEvent } from '@/lib/posthog/server' -import { - createTableVfsFolders, - deleteTableByVfsPath, - deleteTableVfsFolders, - transferTableVfsItems, -} from '@/lib/table/application/table-vfs' -import { VfsPathLimitError, validateVfsPathBatch } from '@/lib/vfs/limits' -import { - copyWorkflowVfsItems, - createWorkflowVfsFolders, - deleteWorkflowVfsItems, - moveWorkflowVfsItems, - type WorkflowVfsOutcome, -} from '@/lib/workflows/application/workflow-vfs' -import { - createWorkspaceFileVfsFolders, - deleteWorkspaceFileVfsItems, - relocateWorkspaceFileVfsItems, - type WorkspaceFileVfsOutcome, -} from '@/lib/workspace-files/application/workspace-file-vfs' - -const logger = createLogger('VfsMutateTools') - -type MutateVerb = 'mv' | 'cp' - -type MutateCategory = 'files' | 'workflows' | 'tables' | 'knowledgebases' - -const MUTATE_CATEGORIES = new Set(['files', 'workflows', 'tables', 'knowledgebases']) - -const CATEGORY_REJECTIONS: Record = { - uploads: - 'uploads/ files are chat-scoped and immutable. Use save_upload to promote one into files/ first.', - 'recently-deleted': - 'recently-deleted/ items cannot be moved or copied. Restore them with restore_resource first.', -} - -/** - * Same categories as CATEGORY_REJECTIONS, but the advice differs for a delete: - * an upload needs no cleanup and a recently-deleted item is already gone. - */ -const RM_CATEGORY_REJECTIONS: Record = { - uploads: - 'uploads/ files are chat-scoped and disappear with the chat — there is nothing to delete.', - 'recently-deleted': - 'recently-deleted/ items are already deleted. Use restore_resource to bring one back.', -} - -interface VfsMutateOutcome { - from: string - to?: string - kind: - | 'file' - | 'file_folder' - | 'workflow' - | 'workflow_folder' - | 'table' - | 'table_folder' - | 'knowledge_base' - | 'knowledge_base_folder' - id?: string - error?: string -} - -class KnowledgeVfsInfrastructureError extends Error { - constructor(readonly infrastructureCause: unknown) { - super('Knowledge VFS infrastructure failure') - this.name = 'KnowledgeVfsInfrastructureError' - } -} - -function messageForKnowledgeVfsError(error: unknown, forbiddenMessage: string): string { - const classified = asOrchestrationError(error) - if (!classified || classified.code === 'internal') { - throw new KnowledgeVfsInfrastructureError(error) - } - return classified.code === 'forbidden' ? forbiddenMessage : messageForCopilotKnowledgeError(error) -} - -function messageForExpectedWorkflowVfsError(error: unknown, fallback: string): string { - const classified = asOrchestrationError(error) - if (!classified || classified.code === 'internal') throw error - return messageForCopilotWorkflowError(error, fallback) -} - -function messageForExpectedTableVfsError(error: unknown): string { - const classified = asOrchestrationError(error) - if (!classified || classified.code === 'internal') throw error - return messageForCopilotTableError(error) -} - -/** Top-level VFS segment of a raw (possibly encoded) path. */ -function topLevelSegment(path: string): string { - return path.trim().replace(/^\/+/, '').split('/')[0] ?? '' -} - -function classifyCategory( - path: string, - rejections: Record = CATEGORY_REJECTIONS, - verbNoun = 'movable' -): { category: MutateCategory } | { error: string } { - const top = topLevelSegment(path) - if (MUTATE_CATEGORIES.has(top)) return { category: top as MutateCategory } - const rejection = rejections[top] - if (rejection) return { error: rejection } - return { - error: `"${path}" is not a ${verbNoun} resource. Only files/, workflows/, tables/, and knowledgebases/ paths are supported.`, - } -} - -function normalizeSources(raw: unknown): string[] { - if (typeof raw === 'string') return raw.trim() ? [raw.trim()] : [] - if (!Array.isArray(raw)) return [] - return raw.filter((s): s is string => typeof s === 'string' && s.trim().length > 0) -} - -function hasTrailingSlash(path: string): boolean { - return /\/\s*$/.test(path) -} - -function assertMutationNotAborted(context: ExecutionContext): void { - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before the mutation could be applied.') - } -} - -function buildResult( - verb: MutateVerb | 'mkdir' | 'rm', - outcomes: VfsMutateOutcome[] -): ToolCallResult { - const failed = outcomes.filter((o) => o.error) - if (failed.length === outcomes.length) { - return { - success: false, - error: failed[0]?.error || `${verb} failed`, - output: { results: outcomes }, - } - } - return { success: true, output: { results: outcomes } } -} - -export async function executeVfsMv( - params: Record, - context: ExecutionContext -): Promise { - return executeVfsMutate('mv', params, context) -} - -export async function executeVfsCp( - params: Record, - context: ExecutionContext -): Promise { - return executeVfsMutate('cp', params, context) -} - -/** - * mkdir -p over the VFS: creates each folder path (missing parents included) - * under files/ or workflows/. Existing folders are not an error. - */ -export async function executeVfsMkdir( - params: Record, - context: ExecutionContext -): Promise { - try { - const paths = normalizeSources(params.paths) - if (paths.length === 0) { - return { success: false, error: 'paths is required (an array of folder VFS paths)' } - } - validateVfsPathBatch(paths) - - const workspaceId = requireCopilotWorkspace(context) - assertMutationNotAborted(context) - - const filePaths = paths.filter((path) => topLevelSegment(path) === 'files') - const fileOutcomes = new Map() - if (filePaths.length > 0) { - const result = await executeCopilotFileUseCase(context, createWorkspaceFileVfsFolders, { - workspaceId, - paths: filePaths.map((path) => ({ - source: path, - segments: decodeVfsPathSegments(path).slice(1), - })), - }) - for (const outcome of result.outcomes) { - fileOutcomes.set(outcome.source, presentFileVfsOutcome(outcome)) - } - } - - const workflowPaths = paths.filter((path) => topLevelSegment(path) === 'workflows') - const workflowOutcomes = new Map() - if (workflowPaths.length > 0) { - try { - const result = await executeCopilotWorkflowUseCase(context, createWorkflowVfsFolders, { - workspaceId, - paths: workflowPaths.map((path) => ({ - source: path, - segments: decodeVfsPathSegments(path).slice(1), - })), - }) - for (const outcome of result.outcomes) { - workflowOutcomes.set(outcome.source, presentWorkflowVfsOutcome(outcome)) - } - } catch (error) { - const message = messageForExpectedWorkflowVfsError(error, 'Workflow folder creation failed') - for (const path of workflowPaths) { - workflowOutcomes.set(path, { from: path, kind: 'workflow_folder', error: message }) - } - } - } - - const folderedOutcomes = new Map() - for (const category of ['tables', 'knowledgebases'] as const) { - const categoryPaths = paths.filter((path) => topLevelSegment(path) === category) - if (categoryPaths.length === 0) continue - const folderKind = category === 'tables' ? 'table_folder' : 'knowledge_base_folder' - const reserved = categoryPaths.filter((path) => - isReservedKnowledgePath(category, decodeVfsPathSegments(path).slice(1)) - ) - for (const path of reserved) { - folderedOutcomes.set(path, { - from: path, - kind: folderKind, - error: '"knowledgebases/connectors" is a reserved path.', - }) - } - const eligible = categoryPaths.filter((path) => !folderedOutcomes.has(path)) - if (eligible.length === 0) continue - const input = { - workspaceId, - paths: eligible.map((path) => ({ - source: path, - segments: decodeVfsPathSegments(path).slice(1), - })), - } - try { - const result = - category === 'tables' - ? await executeCopilotTableUseCase(context, createTableVfsFolders, input, {}) - : await executeCopilotKnowledgeUseCase(context, createKnowledgeVfsFolders, input) - for (const outcome of result.outcomes) { - folderedOutcomes.set(outcome.source, presentResourceVfsOutcome(category, outcome)) - } - } catch (error) { - const message = - category === 'tables' - ? messageForExpectedTableVfsError(error) - : messageForKnowledgeVfsError(error, 'Write access required to create folders') - for (const path of eligible) { - folderedOutcomes.set(path, { from: path, kind: folderKind, error: message }) - } - } - } - - const outcomes: VfsMutateOutcome[] = [] - for (const path of paths) { - const top = topLevelSegment(path) - const segments = decodeVfsPathSegments(path).slice(1) - const kind = - top === 'workflows' - ? 'workflow_folder' - : top === 'tables' - ? 'table_folder' - : top === 'knowledgebases' - ? 'knowledge_base_folder' - : 'file_folder' - - if (top === 'tables' || top === 'knowledgebases') { - outcomes.push( - folderedOutcomes.get(path) ?? { - from: path, - kind, - error: `No result came back for "${path}" — the parent path may not exist or the name may collide. Run glob on the parent to confirm, and do not repeat the identical call.`, - } - ) - continue - } - if (top !== 'files' && top !== 'workflows') { - const rejection = - CATEGORY_REJECTIONS[top] ?? - `"${path}" is not a folder target. mkdir supports files/, workflows/, tables/, and knowledgebases/ paths.` - outcomes.push({ from: path, kind, error: rejection }) - continue - } - if (segments.length === 0) { - outcomes.push({ from: path, kind, error: 'Path must include at least one folder segment' }) - continue - } - try { - assertMutationNotAborted(context) - if (top === 'files') { - outcomes.push( - fileOutcomes.get(path) ?? { - from: path, - kind: 'file_folder', - error: `No result came back for "${path}" — the parent path may not exist or the name may collide. Run glob on the parent to confirm, and do not repeat the identical call.`, - } - ) - } else { - outcomes.push( - workflowOutcomes.get(path) ?? { - from: path, - kind: 'workflow_folder', - error: `No result came back for "${path}" — the parent path may not exist or the name may collide. Run glob on the parent to confirm, and do not repeat the identical call.`, - } - ) - } - } catch (error) { - const classified = asOrchestrationError(error) - if (!classified || classified.code === 'internal') throw error - outcomes.push({ from: path, kind, error: classified.message }) - } - } - - return buildResult('mkdir', outcomes) - } catch (error) { - if (context.abortSignal?.aborted) { - return { success: false, error: 'Request aborted before the mutation could be applied.' } - } - if (error instanceof VfsPathLimitError) return { success: false, error: error.message } - throw error - } -} - -async function executeVfsMutate( - verb: MutateVerb, - params: Record, - context: ExecutionContext -): Promise { - try { - const sources = normalizeSources(params.sources) - const destination = typeof params.destination === 'string' ? params.destination.trim() : '' - if (sources.length === 0) { - return { success: false, error: 'sources is required (an array of canonical VFS paths)' } - } - if (!destination) { - return { success: false, error: 'destination is required' } - } - validateVfsPathBatch([...sources, destination]) - - const workspaceId = requireCopilotWorkspace(context) - assertMutationNotAborted(context) - - const classified = classifyCategory(sources[0]) - if ('error' in classified) return { success: false, error: classified.error } - const { category } = classified - for (const source of sources.slice(1)) { - const other = classifyCategory(source) - if ('error' in other) return { success: false, error: other.error } - if (other.category !== category) { - return { - success: false, - error: `All sources must share one category; got ${category}/ and ${other.category}/.`, - } - } - } - - const destTop = topLevelSegment(destination) - if (destTop !== category) { - return { - success: false, - error: `Cannot ${verb} across categories: ${category}/ sources cannot target "${destination}". Resources stay within their category.`, - } - } - - switch (category) { - case 'files': - return await mutateWorkspaceFiles(verb, sources, destination, context, workspaceId) - case 'workflows': - return await mutateWorkflows(verb, sources, destination, context, workspaceId) - default: - return await transferFolderedResource( - verb, - category, - sources, - destination, - context, - workspaceId - ) - } - } catch (error) { - if (error instanceof KnowledgeVfsInfrastructureError) { - throw error.infrastructureCause - } - if (context.abortSignal?.aborted) { - return { success: false, error: 'Request aborted before the mutation could be applied.' } - } - if (error instanceof VfsPathLimitError) return { success: false, error: error.message } - throw error - } -} - -function presentResourceVfsOutcome( - category: 'tables' | 'knowledgebases', - outcome: ResourceVfsOutcome -): VfsMutateOutcome { - const resourceKind = category === 'tables' ? 'table' : 'knowledge_base' - const folderKind = category === 'tables' ? 'table_folder' : 'knowledge_base_folder' - return { - from: outcome.source, - ...(outcome.targetSegments - ? { to: `${category}/${encodeVfsPathSegments(outcome.targetSegments)}` } - : {}), - kind: outcome.kind === 'folder' ? folderKind : resourceKind, - id: outcome.resourceId, - error: outcome.error, - } -} - -/** knowledgebases/connectors is a virtual tree, not a knowledge base or folder. */ -function isReservedKnowledgePath(category: string, segments: readonly string[]): boolean { - return category === 'knowledgebases' && segments[0]?.toLowerCase() === 'connectors' -} - -async function mutateWorkspaceFiles( - verb: MutateVerb, - sources: string[], - destination: string, - context: ExecutionContext, - workspaceId: string -): Promise { - if (verb === 'cp') { - return { - success: false, - error: 'Workspace files cannot be copied — cp only duplicates workflows.', - } - } - assertMutationNotAborted(context) - const result = await executeCopilotFileUseCase(context, relocateWorkspaceFileVfsItems, { - workspaceId, - sources: sources.map((source) => ({ - source, - segments: decodeVfsPathSegments(source).slice(1), - })), - destination: { - segments: decodeVfsPathSegments(destination).slice(1), - trailingSlash: hasTrailingSlash(destination), - }, - }) - return buildResult(verb, result.outcomes.map(presentFileVfsOutcome)) -} - -function presentFileVfsOutcome(outcome: WorkspaceFileVfsOutcome): VfsMutateOutcome { - return { - from: outcome.source, - ...(outcome.targetSegments - ? { to: `files/${encodeVfsPathSegments(outcome.targetSegments)}` } - : {}), - kind: outcome.resourceType === 'file' ? 'file' : 'file_folder', - id: outcome.resourceId, - error: outcome.error, - } -} - -function presentWorkflowVfsOutcome(outcome: WorkflowVfsOutcome): VfsMutateOutcome { - return { - from: outcome.source, - ...(outcome.targetSegments - ? { to: `workflows/${encodeVfsPathSegments(outcome.targetSegments)}` } - : {}), - kind: outcome.resourceType === 'workflow' ? 'workflow' : 'workflow_folder', - id: outcome.resourceId, - error: outcome.error, - } -} - -async function mutateWorkflows( - verb: MutateVerb, - sources: string[], - destination: string, - context: ExecutionContext, - workspaceId: string -): Promise { - assertMutationNotAborted(context) - const input = { - workspaceId, - sources: sources.map((source) => ({ - source, - segments: decodeVfsPathSegments(source).slice(1), - })), - destination: { - segments: decodeVfsPathSegments(destination).slice(1), - trailingSlash: hasTrailingSlash(destination), - }, - } - try { - const result = - verb === 'cp' - ? await executeCopilotWorkflowUseCase(context, copyWorkflowVfsItems, input) - : await executeCopilotWorkflowUseCase(context, moveWorkflowVfsItems, input) - return buildResult(verb, result.outcomes.map(presentWorkflowVfsOutcome)) - } catch (error) { - if (context.abortSignal?.aborted) throw error - return { - success: false, - error: messageForExpectedWorkflowVfsError(error, 'Workflow mutation failed'), - } - } -} - -async function transferFolderedResource( - verb: MutateVerb, - category: 'tables' | 'knowledgebases', - sources: string[], - destination: string, - context: ExecutionContext, - workspaceId: string -): Promise { - const label = category === 'tables' ? 'Tables' : 'Knowledge bases' - if (verb === 'cp') { - return { success: false, error: `${label} cannot be copied — duplication is not supported.` } - } - - const sourceRefs = sources.map((source) => ({ - source, - segments: decodeVfsPathSegments(source).slice(1), - })) - const destinationSegments = decodeVfsPathSegments(destination).slice(1) - for (const ref of sourceRefs) { - if (isReservedKnowledgePath(category, ref.segments)) { - return { success: false, error: '"knowledgebases/connectors" is a reserved path.' } - } - } - if (isReservedKnowledgePath(category, destinationSegments)) { - return { success: false, error: '"knowledgebases/connectors" is a reserved path.' } - } - - const input = { - workspaceId, - sources: sourceRefs, - destination: { - segments: destinationSegments, - trailingSlash: hasTrailingSlash(destination), - }, - } - assertMutationNotAborted(context) - try { - const result = - category === 'tables' - ? await executeCopilotTableUseCase(context, transferTableVfsItems, input, {}) - : await executeCopilotKnowledgeUseCase(context, transferKnowledgeVfsItems, input) - return buildResult( - verb, - result.outcomes.map((outcome) => presentResourceVfsOutcome(category, outcome)) - ) - } catch (error) { - if (context.abortSignal?.aborted) throw error - const message = - category === 'tables' - ? messageForExpectedTableVfsError(error) - : messageForKnowledgeVfsError(error, `Write access required to move ${label.toLowerCase()}`) - return { success: false, error: message } - } -} - -/** - * rm over the VFS: deletes the resource each path names. Every delete here is - * SOFT — the resource lands in recently-deleted/ and restore_resource brings it - * back — so this is the product's delete, not a purge. - * - * Scope is deliberately "things with a path". Removing something INSIDE a - * resource (a table row, a KB document, a workflow block) is an edit to that - * resource and stays with its owning tool. - */ -export async function executeVfsRm( - params: Record, - context: ExecutionContext -): Promise { - try { - const paths = normalizeSources(params.paths) - if (paths.length === 0) { - return { success: false, error: 'paths is required (an array of VFS paths to delete)' } - } - validateVfsPathBatch(paths) - - const workspaceId = requireCopilotWorkspace(context) - assertMutationNotAborted(context) - - const filePaths = paths.filter((path) => topLevelSegment(path) === 'files') - const fileOutcomes = new Map() - if (filePaths.length > 0) { - const result = await executeCopilotFileUseCase(context, deleteWorkspaceFileVfsItems, { - workspaceId, - paths: filePaths.map((path) => ({ - source: path, - segments: decodeVfsPathSegments(path).slice(1), - })), - }) - for (const outcome of result.outcomes) { - fileOutcomes.set(outcome.source, presentFileVfsOutcome(outcome)) - } - } - - const workflowPaths = paths.filter((path) => topLevelSegment(path) === 'workflows') - const workflowOutcomes = new Map() - if (workflowPaths.length > 0) { - try { - const result = await executeCopilotWorkflowUseCase(context, deleteWorkflowVfsItems, { - workspaceId, - paths: workflowPaths.map((path) => ({ - source: path, - segments: decodeVfsPathSegments(path).slice(1), - })), - }) - for (const outcome of result.outcomes) { - workflowOutcomes.set(outcome.source, presentWorkflowVfsOutcome(outcome)) - } - } catch (error) { - const message = messageForExpectedWorkflowVfsError(error, 'Workflow deletion failed') - for (const path of workflowPaths) { - workflowOutcomes.set(path, { from: path, kind: 'workflow', error: message }) - } - } - } - - const outcomes: VfsMutateOutcome[] = [] - for (const path of paths) { - const classified = classifyCategory(path, RM_CATEGORY_REJECTIONS, 'deletable') - if ('error' in classified) { - outcomes.push({ from: path, kind: defaultKindFor(path), error: classified.error }) - continue - } - try { - assertMutationNotAborted(context) - if (classified.category === 'workflows') { - outcomes.push( - workflowOutcomes.get(path) ?? { - from: path, - kind: 'workflow', - error: `No result came back for deleting "${path}" — it may not exist or may already be deleted. Run glob("workflows/*") to confirm before retrying.`, - } - ) - } else if (classified.category === 'files') { - outcomes.push( - fileOutcomes.get(path) ?? { - from: path, - kind: 'file', - error: `No result came back for deleting "${path}" — it may not exist or may already be deleted. Run glob("files/**") to confirm before retrying.`, - } - ) - } else { - outcomes.push(await removeOne(classified.category, path, context, workspaceId)) - } - } catch (error) { - if (error instanceof KnowledgeVfsInfrastructureError) throw error - if (classified.category === 'workflows') { - outcomes.push({ - from: path, - kind: defaultKindFor(path), - error: messageForExpectedWorkflowVfsError(error, 'Workflow deletion failed'), - }) - continue - } - throw error - } - } - - return buildResult('rm', outcomes) - } catch (error) { - if (error instanceof KnowledgeVfsInfrastructureError) { - throw error.infrastructureCause - } - if (context.abortSignal?.aborted) { - return { success: false, error: 'Request aborted before the mutation could be applied.' } - } - if (error instanceof VfsPathLimitError) return { success: false, error: error.message } - throw error - } -} - -/** Best-effort kind for an outcome that failed before the resource was identified. */ -function defaultKindFor(path: string): VfsMutateOutcome['kind'] { - switch (topLevelSegment(path)) { - case 'workflows': - return 'workflow' - case 'tables': - return 'table' - case 'knowledgebases': - return 'knowledge_base' - default: - return 'file' - } -} - -function removeOne( - category: Exclude, - path: string, - context: ExecutionContext, - workspaceId: string -): Promise { - switch (category) { - case 'tables': - return removeTablePath(path, context, workspaceId) - case 'knowledgebases': - return removeKnowledgeBasePath(path, context, workspaceId) - } -} - -async function removeTablePath( - path: string, - context: ExecutionContext, - workspaceId: string -): Promise { - const segments = decodeVfsPathSegments(path).slice(1) - if (segments.length === 0) { - return { - from: path, - kind: 'table', - error: 'rm takes a table or folder path, e.g. rm(["tables/Leads"]) or rm(["tables/CRM"]).', - } - } - const sourceName = segments[segments.length - 1] - try { - const deleted = await executeCopilotTableUseCase( - context, - deleteTableByVfsPath, - { workspaceId, sourceName, sourceSegments: segments }, - {} - ) - captureServerEvent( - context.userId, - 'table_deleted', - { table_id: deleted.id, workspace_id: deleted.workspaceId }, - { groups: { workspace: deleted.workspaceId } } - ) - logger.info('Archived table via rm', { tableId: deleted.id, workspaceId }) - return { from: path, kind: 'table', id: deleted.id } - } catch (error) { - const message = messageForExpectedTableVfsError(error) - const folderOutcome = await removeResourceFolderFallback( - 'tables', - path, - segments, - message, - context, - workspaceId - ) - if (folderOutcome) return folderOutcome - return { from: path, kind: 'table', error: message } - } -} - -/** - * rm resolution is resource-first (matching mv); when the resource resolver - * reports the path IS a folder, the delete retargets to the folder cascade. - */ -async function removeResourceFolderFallback( - category: 'tables' | 'knowledgebases', - path: string, - segments: string[], - resourceError: string, - context: ExecutionContext, - workspaceId: string -): Promise { - if (!resourceError.includes('is a folder')) return null - const input = { workspaceId, paths: [{ source: path, segments }] } - try { - const result = - category === 'tables' - ? await executeCopilotTableUseCase(context, deleteTableVfsFolders, input, {}) - : await executeCopilotKnowledgeUseCase(context, deleteKnowledgeVfsFolders, input) - const outcome = result.outcomes[0] - return outcome ? presentResourceVfsOutcome(category, outcome) : null - } catch (error) { - const message = - category === 'tables' - ? messageForExpectedTableVfsError(error) - : messageForKnowledgeVfsError(error, 'Write access required to delete folders') - return { - from: path, - kind: category === 'tables' ? 'table_folder' : 'knowledge_base_folder', - error: message, - } - } -} - -async function removeKnowledgeBasePath( - path: string, - context: ExecutionContext, - workspaceId: string -): Promise { - const segments = decodeVfsPathSegments(path).slice(1) - if (segments.length === 0) { - return { - from: path, - kind: 'knowledge_base', - error: 'rm takes a knowledge base or folder path, e.g. rm(["knowledgebases/support-docs"]).', - } - } - const sourceName = segments[segments.length - 1] - if (isReservedKnowledgePath('knowledgebases', segments)) { - return { - from: path, - kind: 'knowledge_base', - error: '"knowledgebases/connectors" is a reserved path, not a knowledge base.', - } - } - try { - const deleted = await executeCopilotKnowledgeUseCase(context, deleteKnowledgeBaseByVfsPath, { - workspaceId, - sourceName, - sourceSegments: segments, - }) - PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: deleted.id }) - logger.info('Deleted knowledge base via rm', { - knowledgeBaseId: deleted.id, - workspaceId, - }) - return { from: path, kind: 'knowledge_base', id: deleted.id } - } catch (error) { - const message = messageForKnowledgeVfsError( - error, - `Write access required to delete knowledge base "${sourceName}"` - ) - const folderOutcome = await removeResourceFolderFallback( - 'knowledgebases', - path, - segments, - message, - context, - workspaceId - ) - if (folderOutcome) return folderOutcome - return { from: path, kind: 'knowledge_base', error: message } - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs.test.ts deleted file mode 100644 index c1f2b378fc8..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/vfs.test.ts +++ /dev/null @@ -1,953 +0,0 @@ -/** - * @vitest-environment node - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants' - -const { getOrMaterializeVFS } = vi.hoisted(() => ({ - getOrMaterializeVFS: vi.fn(), -})) - -const { importWorkspaceFileSecretProvenanceForModelView } = vi.hoisted(() => ({ - importWorkspaceFileSecretProvenanceForModelView: vi.fn().mockResolvedValue(true), -})) - -const { - readChatUpload, - readChatUploadWithProvenance, - listChatUploads, - grepChatUpload, - grepChatUploadWithProvenance, -} = vi.hoisted(() => { - const readChatUpload = vi.fn() - const grepChatUpload = vi.fn() - return { - readChatUpload, - readChatUploadWithProvenance: vi.fn(async (...args: unknown[]) => { - const value = await readChatUpload(...args) - return value ? { value } : null - }), - listChatUploads: vi.fn(), - grepChatUpload, - grepChatUploadWithProvenance: vi.fn(async (...args: unknown[]) => ({ - value: await grepChatUpload(...args), - })), - } -}) - -vi.mock('@/lib/copilot/vfs', () => ({ - getOrMaterializeVFS, -})) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ - importWorkspaceFileSecretProvenanceForModelView, -})) -vi.mock('./upload-file-reader', () => ({ - readChatUpload, - readChatUploadWithProvenance, - listChatUploads, - grepChatUpload, - grepChatUploadWithProvenance, -})) - -import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' -import { readPlaceholder } from '@/lib/copilot/vfs/read-placeholders' -import { executeVfsGlob, executeVfsGrep, executeVfsRead } from './vfs' - -const OVERSIZED_INLINE_CONTENT = 'x'.repeat(TOOL_RESULT_MAX_INLINE_CHARS + 1) - -function makeVfs() { - const grepFile = vi.fn() - const readFileContent = vi.fn() - return { - grep: vi.fn(), - grepFile, - grepFileWithProvenance: vi.fn(async (...args: unknown[]) => ({ - value: await grepFile(...args), - })), - glob: vi.fn().mockReturnValue([]), - read: vi.fn(), - readFileContent, - readFileContentWithProvenance: vi.fn(async (...args: unknown[]) => { - const value = await readFileContent(...args) - return value ? { value } : null - }), - suggestSimilar: vi.fn().mockReturnValue([]), - } -} - -const GREP_CTX = { - userId: 'user-1', - workflowId: 'wf-1', - workspaceId: 'ws-1', - toolCallId: 'tool-1', - copilotToolExecution: true, -} -const GREP_CTX_CHAT = { ...GREP_CTX, chatId: 'chat-1' } - -describe('vfs handlers oversize policy', () => { - beforeEach(() => { - vi.clearAllMocks() - importWorkspaceFileSecretProvenanceForModelView.mockResolvedValue(true) - }) - - it('fails oversized grep results with narrowing guidance', async () => { - const vfs = makeVfs() - vfs.grep.mockReturnValue([{ path: 'files/a.txt', line: 1, content: OVERSIZED_INLINE_CONTENT }]) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsGrep({ pattern: 'foo', output_mode: 'content' }, GREP_CTX) - - expect(result.success).toBe(false) - expect(result.error).toContain('more specific pattern') - expect(result.error).toContain('context window') - }) - - it('fails oversized read results from VFS with paging guidance', async () => { - const vfs = makeVfs() - vfs.readFileContent.mockResolvedValue(null) - vfs.read.mockReturnValue({ content: OVERSIZED_INLINE_CONTENT, totalLines: 1 }) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead({ path: 'workflows/My Workflow/state.json' }, GREP_CTX) - - expect(result.success).toBe(false) - expect(result.error).toContain('Page it') - expect(result.error).toContain('grep') - expect(result.error).toContain('context window') - }) - - it('pages an oversized workspace file when offset/limit are passed', async () => { - const vfs = makeVfs() - const lines = Array.from({ length: 5000 }, (_, i) => `line ${i} ${'y'.repeat(50)}`) - vfs.readFileContent.mockResolvedValue({ - content: lines.join('\n'), - totalLines: lines.length, - }) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const whole = await executeVfsRead({ path: 'files/big.log/content' }, GREP_CTX) - expect(whole.success).toBe(false) - expect(whole.error).toContain('Page it') - - const paged = await executeVfsRead( - { path: 'files/big.log/content', offset: 10, limit: 5 }, - GREP_CTX - ) - expect(paged.success).toBe(true) - expect((paged.output as { content: string }).content).toBe(lines.slice(10, 15).join('\n')) - }) - - it('tells the model to reduce limit when the requested window is still oversized', async () => { - const vfs = makeVfs() - vfs.readFileContent.mockResolvedValue({ - content: Array.from({ length: 100 }, () => OVERSIZED_INLINE_CONTENT).join('\n'), - totalLines: 100, - }) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead( - { path: 'files/big.log/content', offset: 0, limit: 50 }, - GREP_CTX - ) - expect(result.success).toBe(false) - expect(result.error).toContain('Reduce limit') - }) - - it('notes an empty file instead of returning bare empty content', async () => { - const vfs = makeVfs() - vfs.readFileContent.mockResolvedValue({ content: '', totalLines: 0 }) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead({ path: 'files/hi.txt/content' }, GREP_CTX) - expect(result.success).toBe(true) - expect((result.output as { note?: string }).note).toContain('empty') - }) - - it('fails file-backed oversized read placeholders with original message', async () => { - const vfs = makeVfs() - vfs.readFileContent.mockResolvedValue( - readPlaceholder.fileTooLarge('big.txt', 6_000_000, 5_242_880) - ) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead({ path: 'files/big.txt/content' }, GREP_CTX) - - expect(result.success).toBe(false) - expect(result.error).toContain('File too large to display inline') - expect(result.error).toContain('big.txt') - }) - - it('passes through image reads with attachment even when oversized', async () => { - const vfs = makeVfs() - const largeBase64 = 'A'.repeat(TOOL_RESULT_MAX_INLINE_CHARS + 1) - vfs.readFileContent.mockResolvedValue({ - content: 'Image: chess.png (500.0KB, image/png)', - totalLines: 1, - attachment: { - type: 'image', - source: { type: 'base64', media_type: 'image/png', data: largeBase64 }, - }, - }) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead({ path: 'files/chess.png/content' }, GREP_CTX) - - expect(result.success).toBe(true) - expect((result.output as { attachment?: { type: string } })?.attachment?.type).toBe('image') - }) - - it('passes through compiled file attachments even when oversized', async () => { - const vfs = makeVfs() - const largeBase64 = 'A'.repeat(TOOL_RESULT_MAX_INLINE_CHARS + 1) - vfs.readFileContent.mockResolvedValue({ - content: 'Compiled file: report.pdf (500000 bytes, application/pdf)', - totalLines: 1, - attachment: { - type: 'file', - name: 'report.pdf', - source: { type: 'base64', media_type: 'application/pdf', data: largeBase64 }, - }, - }) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead({ path: 'files/reports/report.pdf/compiled' }, GREP_CTX) - - expect(result.success).toBe(true) - expect((result.output as { attachment?: { type: string } })?.attachment?.type).toBe('file') - }) - - /** - * Every size refusal is a failed read, whichever path produced it. Built from the - * producers so one that stops tagging itself `oversized` fails here rather than - * silently downgrading a refusal to a one-line "successful" read. - */ - it.each([ - ['image', readPlaceholder.imageTooLarge('huge.png', 99, 5)], - ['file', readPlaceholder.fileTooLarge('huge.txt', 99, 5)], - ['document', readPlaceholder.documentTooLarge('huge.pdf', 99, 5)], - ['compiled artifact', readPlaceholder.compiledArtifactTooLarge('app.js', 99, 5)], - ])('fails the read when a %s exceeds its size limit', async (_kind, placeholder) => { - const vfs = makeVfs() - vfs.readFileContent.mockResolvedValue(placeholder) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead({ path: 'files/huge.png/content' }, GREP_CTX) - - expect(result.success).toBe(false) - // The placeholder verbatim, not the generic "grep this instead" fallback. - expect(result.error).toBe(placeholder.content) - }) - - it('still fails the read when the stored name contains a newline', async () => { - // Nothing about the message text decides this, so a name that would break a - // text-shape match cannot hide a refusal. - const vfs = makeVfs() - const placeholder = readPlaceholder.fileTooLarge('we\nird.txt', 99, 5) - vfs.readFileContent.mockResolvedValue(placeholder) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead({ path: 'files/weird/content' }, GREP_CTX) - - expect(result.success).toBe(false) - expect(result.error).toBe(placeholder.content) - }) - - it('returns a real file whose content is exactly a size-refusal message', async () => { - // Untagged, so it is content. Recognising refusals by their text would turn this - // user's file into a tool error instead of returning it. - const vfs = makeVfs() - const { content } = readPlaceholder.documentTooLarge('huge.pdf', 99, 5) - vfs.readFileContent.mockResolvedValue({ content, totalLines: 1 }) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead({ path: 'files/notes.md/content' }, GREP_CTX) - - expect(result.success).toBe(true) - expect((result.output as { content?: string })?.content).toBe(content) - }) - - it('returns an undecodable image placeholder as content, not as a size failure', async () => { - const vfs = makeVfs() - // Not a size problem — the bytes were read fine and the reason is already in the - // message, so the model should see it rather than a "too large, use grep" error. - const placeholder = readPlaceholder.imageUnavailable( - 'bomb.png', - 90, - 'It is too large to decode safely.' - ) - const content = placeholder.content - vfs.readFileContent.mockResolvedValue(placeholder) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead({ path: 'files/bomb.png/content' }, GREP_CTX) - - expect(result.success).toBe(true) - expect((result.output as { content?: string })?.content).toBe(content) - }) - - it('reads canonical file leaf metadata without fetching dynamic content', async () => { - const vfs = makeVfs() - vfs.read.mockReturnValue({ - content: '{"id":"wf_123","vfsPath":"files/report.csv"}', - totalLines: 1, - }) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead({ path: 'files/report.csv' }, GREP_CTX) - - expect(result.success).toBe(true) - expect(vfs.readFileContent).not.toHaveBeenCalled() - expect(vfs.read).toHaveBeenCalledWith('files/report.csv', undefined, undefined) - }) - - it('materializes VFS reads with the request secret policy', async () => { - const vfs = makeVfs() - vfs.read.mockReturnValue({ content: '{}', totalLines: 1 }) - getOrMaterializeVFS.mockResolvedValue(vfs) - const secretMountPolicy = { - secretScope: 'selected' as const, - mountedSecrets: ['VISIBLE_KEY'], - } - - const result = await executeVfsRead( - { path: 'environment/variables.json' }, - { ...GREP_CTX, secretMountPolicy } - ) - - expect(result.success).toBe(true) - expect(getOrMaterializeVFS).toHaveBeenCalledWith( - 'ws-1', - 'user-1', - expect.objectContaining({ - secretMountPolicy, - knowledgePrincipal: expect.objectContaining({ - kind: 'delegated', - delegationId: 'tool-1', - workspaceId: 'ws-1', - }), - }) - ) - }) - - it('uses dynamic file reads for canonical style paths', async () => { - const vfs = makeVfs() - vfs.readFileContent.mockResolvedValue({ - content: '{"format":"docx"}', - totalLines: 1, - }) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead({ path: 'files/reports/brief.docx/style' }, GREP_CTX) - - expect(result.success).toBe(true) - expect(vfs.readFileContent).toHaveBeenCalledWith('files/reports/brief.docx/style') - expect(vfs.read).not.toHaveBeenCalled() - }) - - it('uses dynamic file reads for canonical compiled paths', async () => { - const vfs = makeVfs() - vfs.readFileContent.mockResolvedValue({ - content: 'Compiled file: brief.pdf (1000 bytes, application/pdf)', - totalLines: 1, - }) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead({ path: 'files/reports/brief.pdf/compiled' }, GREP_CTX) - - expect(result.success).toBe(true) - expect(vfs.readFileContent).toHaveBeenCalledWith('files/reports/brief.pdf/compiled') - expect(vfs.read).not.toHaveBeenCalled() - }) - - it('surfaces dynamic file read errors as failed tool calls', async () => { - const vfs = makeVfs() - const error = 'Document compiler not configured (MOTHERSHIP_E2B_DOC_TEMPLATE_ID is unset)' - vfs.readFileContent.mockResolvedValue({ - content: JSON.stringify({ ok: false, error }), - totalLines: 1, - error, - }) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead({ path: 'files/reports/brief.pdf/render' }, GREP_CTX) - - expect(result).toEqual({ success: false, error }) - }) - - it('does not expose dynamic file read errors when provenance cannot be verified', async () => { - const vfs = makeVfs() - const error = 'Document compiler not configured (MOTHERSHIP_E2B_DOC_TEMPLATE_ID is unset)' - vfs.readFileContentWithProvenance.mockResolvedValue({ - value: { - content: JSON.stringify({ ok: false, error }), - totalLines: 1, - error, - }, - file: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' }, - }) - getOrMaterializeVFS.mockResolvedValue(vfs) - importWorkspaceFileSecretProvenanceForModelView.mockResolvedValueOnce(false) - - const result = await executeVfsRead({ path: 'files/reports/brief.pdf/render' }, GREP_CTX) - - expect(result).toEqual({ - success: false, - error: - 'This file result cannot be shared safely because its secret provenance is unavailable.', - }) - expect(importWorkspaceFileSecretProvenanceForModelView).toHaveBeenCalledWith( - expect.objectContaining({ - identity: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' }, - view: 'derived', - }) - ) - }) - - it('marks a windowed read as a derived provenance view', async () => { - const vfs = makeVfs() - vfs.readFileContentWithProvenance.mockResolvedValue({ - value: { content: 'hidden-secret\nvisible line', totalLines: 2 }, - file: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' }, - }) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead( - { path: 'files/report.txt/content', offset: 1, limit: 1 }, - GREP_CTX - ) - - expect(result.success).toBe(true) - expect(importWorkspaceFileSecretProvenanceForModelView).toHaveBeenCalledWith( - expect.objectContaining({ - identity: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' }, - view: 'derived', - }) - ) - }) - - it('windows against the real line count when a read under-reports totalLines', async () => { - const vfs = makeVfs() - vfs.readFileContentWithProvenance.mockResolvedValue({ - // `/extract` synthesizes a whole extracted document but reports totalLines: 1. - value: { content: 'page one\npage two\npage three', totalLines: 1 }, - file: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' }, - }) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead( - { path: 'files/report.pdf/extract', offset: 1, limit: 2 }, - GREP_CTX - ) - - expect(result.success).toBe(true) - expect(result.output).toEqual({ content: 'page two\npage three', totalLines: 1 }) - }) - - it('leaves an attachment read unwindowed so its label is never blanked', async () => { - const vfs = makeVfs() - const imageResult = { - content: 'Image: photo.jpeg (157.0KB, image/jpeg, resized for vision)', - totalLines: 1, - attachment: { - type: 'image', - name: 'photo.jpeg', - source: { type: 'base64', media_type: 'image/jpeg', data: 'AAAA' }, - }, - } - vfs.readFileContentWithProvenance.mockResolvedValue({ - value: imageResult, - file: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' }, - }) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead( - { path: 'files/photo.jpeg/content', offset: 1, limit: 100 }, - GREP_CTX - ) - - expect(result.success).toBe(true) - expect(result.output).toEqual(imageResult) - }) - - it('checks every compiled-document contributor at the opaque model boundary', async () => { - const vfs = makeVfs() - const contentUpdatedAt = new Date('2026-08-06T00:00:00.000Z') - vfs.readFileContentWithProvenance.mockResolvedValue({ - value: { - content: 'Compiled file: report.pdf', - totalLines: 1, - attachment: { - type: 'file', - name: 'report.pdf', - source: { type: 'base64', media_type: 'application/pdf', data: 'AAAA' }, - }, - }, - file: { fileId: 'source-1', key: 'workspace/source-1', context: 'workspace' }, - contributingFiles: [ - { - fileId: 'image-1', - key: 'workspace/image-1', - context: 'workspace', - contentUpdatedAt, - }, - ], - }) - getOrMaterializeVFS.mockResolvedValue(vfs) - importWorkspaceFileSecretProvenanceForModelView - .mockResolvedValueOnce(true) - .mockResolvedValueOnce(false) - - const result = await executeVfsRead({ path: 'files/report.pdf/compiled' }, GREP_CTX) - - expect(result.success).toBe(false) - expect(result.error).toContain('cannot be shared safely') - expect(importWorkspaceFileSecretProvenanceForModelView).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - identity: { fileId: 'source-1', key: 'workspace/source-1', context: 'workspace' }, - view: 'opaque', - }) - ) - expect(importWorkspaceFileSecretProvenanceForModelView).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - identity: { - fileId: 'image-1', - key: 'workspace/image-1', - context: 'workspace', - contentUpdatedAt, - }, - view: 'opaque', - }) - ) - }) - - it('uses the source-declared view for an unwindowed read', async () => { - const vfs = makeVfs() - vfs.readFileContentWithProvenance.mockResolvedValue({ - value: { content: 'complete content', totalLines: 1 }, - file: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' }, - view: 'complete', - }) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsRead({ path: 'files/report.txt/content' }, GREP_CTX) - - expect(result.success).toBe(true) - expect(importWorkspaceFileSecretProvenanceForModelView).toHaveBeenCalledWith( - expect.objectContaining({ view: 'complete' }) - ) - }) - - it('rejects only the file read when durable provenance cannot be verified', async () => { - const vfs = makeVfs() - vfs.readFileContentWithProvenance.mockResolvedValue({ - value: { content: 'content', totalLines: 1 }, - file: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' }, - }) - getOrMaterializeVFS.mockResolvedValue(vfs) - importWorkspaceFileSecretProvenanceForModelView.mockResolvedValueOnce(false) - - const result = await executeVfsRead({ path: 'files/report.txt/content' }, GREP_CTX) - - expect(result.success).toBe(false) - expect(result.error).toContain('cannot be shared safely') - }) -}) - -describe('vfs grep workspace-file routing', () => { - beforeEach(() => { - vi.clearAllMocks() - importWorkspaceFileSecretProvenanceForModelView.mockResolvedValue(true) - }) - - it('routes a single workspace file leaf to grepFile (content search)', async () => { - const vfs = makeVfs() - vfs.grepFile.mockResolvedValue([{ path: 'files/report.csv', line: 2, content: 'revenue,100' }]) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsGrep( - { pattern: 'revenue', path: 'files/report.csv', output_mode: 'content' }, - GREP_CTX - ) - - expect(result.success).toBe(true) - expect(vfs.grepFile).toHaveBeenCalledWith( - 'files/report.csv', - 'revenue', - expect.objectContaining({ outputMode: 'content', maxResults: 50 }) - ) - expect(vfs.grep).not.toHaveBeenCalled() - expect((result.output as { matches: unknown[] }).matches).toHaveLength(1) - }) - - it('routes a files//content path to grepFile', async () => { - const vfs = makeVfs() - vfs.grepFile.mockResolvedValue([]) - getOrMaterializeVFS.mockResolvedValue(vfs) - - await executeVfsGrep({ pattern: 'x', path: 'files/reports/brief.pdf/content' }, GREP_CTX) - - expect(vfs.grepFile).toHaveBeenCalledWith( - 'files/reports/brief.pdf/content', - 'x', - expect.any(Object) - ) - expect(vfs.grep).not.toHaveBeenCalled() - }) - - it('uses the VFS map grep for non-file paths', async () => { - const vfs = makeVfs() - vfs.grep.mockReturnValue([]) - getOrMaterializeVFS.mockResolvedValue(vfs) - - await executeVfsGrep({ pattern: 'slack', path: 'workflows/' }, GREP_CTX) - - expect(vfs.grep).toHaveBeenCalledWith('slack', 'workflows/', expect.any(Object)) - expect(vfs.grepFile).not.toHaveBeenCalled() - }) - - it('uses the VFS map grep when no path is given', async () => { - const vfs = makeVfs() - vfs.grep.mockReturnValue([]) - getOrMaterializeVFS.mockResolvedValue(vfs) - - await executeVfsGrep({ pattern: 'slack' }, GREP_CTX) - - expect(vfs.grep).toHaveBeenCalledWith('slack', undefined, expect.any(Object)) - expect(vfs.grepFile).not.toHaveBeenCalled() - }) - - it('surfaces a workspace-file grep scope error verbatim', async () => { - const vfs = makeVfs() - vfs.grepFile.mockRejectedValue( - new WorkspaceFileGrepError( - 'Grep over workspace file content must target a single workspace file (e.g. path: "files/report.csv"). "files/" is not a single workspace file.' - ) - ) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsGrep({ pattern: 'x', path: 'files/' }, GREP_CTX) - - expect(result.success).toBe(false) - expect(result.error).toContain('single workspace file') - }) - - it('marks content grep as a derived provenance view', async () => { - const vfs = makeVfs() - vfs.grepFileWithProvenance.mockResolvedValue({ - value: [{ path: 'files/report.csv', line: 2, content: 'visible hit' }], - file: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' }, - }) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsGrep( - { pattern: 'visible', path: 'files/report.csv', output_mode: 'content' }, - GREP_CTX - ) - - expect(result.success).toBe(true) - expect(importWorkspaceFileSecretProvenanceForModelView).toHaveBeenCalledWith( - expect.objectContaining({ - view: 'derived', - }) - ) - }) - - it('treats count grep as derived from file content', async () => { - importWorkspaceFileSecretProvenanceForModelView.mockResolvedValueOnce(false) - const vfs = makeVfs() - vfs.grepFileWithProvenance.mockResolvedValue({ - value: [{ path: 'files/report.csv', count: 1 }], - file: { fileId: 'file-1', key: 'workspace/key-1', context: 'workspace' }, - }) - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsGrep( - { pattern: 'visible', path: 'files/report.csv', output_mode: 'count' }, - GREP_CTX - ) - - expect(result).toEqual({ - success: false, - error: - 'This file result cannot be shared safely because its secret provenance is unavailable.', - }) - expect(importWorkspaceFileSecretProvenanceForModelView).toHaveBeenCalledWith( - expect.objectContaining({ view: 'derived' }) - ) - }) -}) - -describe('vfs uploads are opt-in (like recently-deleted/)', () => { - beforeEach(() => { - vi.clearAllMocks() - importWorkspaceFileSecretProvenanceForModelView.mockResolvedValue(true) - }) - - it('does not search uploads for an unscoped grep', async () => { - const vfs = makeVfs() - vfs.grep.mockReturnValue([]) - getOrMaterializeVFS.mockResolvedValue(vfs) - - await executeVfsGrep({ pattern: 'secret' }, GREP_CTX_CHAT) - - expect(grepChatUpload).not.toHaveBeenCalled() - expect(vfs.grep).toHaveBeenCalledWith('secret', undefined, expect.any(Object)) - }) - - it('does not search uploads for a files/ grep', async () => { - const vfs = makeVfs() - vfs.grepFile.mockResolvedValue([]) - getOrMaterializeVFS.mockResolvedValue(vfs) - - await executeVfsGrep({ pattern: 'secret', path: 'files/report.csv' }, GREP_CTX_CHAT) - - expect(grepChatUpload).not.toHaveBeenCalled() - }) - - it('routes an explicit uploads/ path to grepChatUpload', async () => { - grepChatUpload.mockResolvedValue([{ path: 'uploads/report.json', line: 1, content: 'hit' }]) - - const result = await executeVfsGrep( - { pattern: 'hit', path: 'uploads/report.json' }, - GREP_CTX_CHAT - ) - - expect(result.success).toBe(true) - expect(grepChatUpload).toHaveBeenCalledWith( - 'report.json', - 'chat-1', - 'hit', - expect.objectContaining({ maxResults: 50 }) - ) - expect(getOrMaterializeVFS).not.toHaveBeenCalled() - }) - - it('rejects a bare uploads/ folder grep (no cross-folder search)', async () => { - const result = await executeVfsGrep({ pattern: 'x', path: 'uploads/' }, GREP_CTX_CHAT) - - expect(result.success).toBe(false) - expect(result.error).toContain('single upload') - expect(grepChatUpload).not.toHaveBeenCalled() - }) - - it('errors when grepping uploads without chat context', async () => { - const result = await executeVfsGrep({ pattern: 'x', path: 'uploads/report.json' }, GREP_CTX) - - expect(result.success).toBe(false) - expect(result.error).toContain('No chat context') - expect(grepChatUpload).not.toHaveBeenCalled() - }) - - it('surfaces an upload-not-found grep error verbatim', async () => { - grepChatUpload.mockRejectedValue( - new WorkspaceFileGrepError( - 'Upload not found: "ghost.json". Use glob("uploads/*") to list available uploads.' - ) - ) - - const result = await executeVfsGrep({ pattern: 'x', path: 'uploads/ghost.json' }, GREP_CTX_CHAT) - - expect(result.success).toBe(false) - expect(result.error).toContain('Upload not found') - }) - - it('lists uploads only when scoped, with percent-encoded paths', async () => { - const vfs = makeVfs() - getOrMaterializeVFS.mockResolvedValue(vfs) - listChatUploads.mockResolvedValue([{ name: 'My Report.json' }, { name: 'data.csv' }]) - - const scoped = await executeVfsGlob({ pattern: 'uploads/*' }, GREP_CTX_CHAT) - expect((scoped.output as { files: string[] }).files).toEqual( - expect.arrayContaining(['uploads/My%20Report.json', 'uploads/data.csv']) - ) - - listChatUploads.mockClear() - const broad = await executeVfsGlob({ pattern: '**' }, GREP_CTX_CHAT) - expect(listChatUploads).not.toHaveBeenCalled() - expect((broad.output as { files: string[] }).files).not.toContain('uploads/My%20Report.json') - }) - - it('explains an empty uploads glob instead of returning a bare []', async () => { - const vfs = makeVfs() - getOrMaterializeVFS.mockResolvedValue(vfs) - listChatUploads.mockResolvedValue([]) - - const result = await executeVfsGlob({ pattern: 'uploads/*' }, GREP_CTX_CHAT) - expect(result.success).toBe(true) - expect((result.output as { files: string[]; note?: string }).files).toEqual([]) - expect((result.output as { note?: string }).note).toContain('no uploads') - }) - - it('explains an empty user-local glob instead of returning a bare []', async () => { - const vfs = makeVfs() - getOrMaterializeVFS.mockResolvedValue(vfs) - - const result = await executeVfsGlob({ pattern: 'user-local/**' }, GREP_CTX_CHAT) - expect(result.success).toBe(true) - expect((result.output as { note?: string }).note).toContain('user-local') - }) - - it('reads an upload directly, tolerating a spurious /content suffix', async () => { - const vfs = makeVfs() - getOrMaterializeVFS.mockResolvedValue(vfs) - readChatUpload.mockResolvedValue({ content: 'hello upload', totalLines: 1 }) - - const bare = await executeVfsRead({ path: 'uploads/report.csv' }, GREP_CTX_CHAT) - expect(bare.success).toBe(true) - expect(readChatUpload).toHaveBeenLastCalledWith('report.csv', 'chat-1') - - // The model adds /content out of habit (from files/) — it must still resolve. - const withContent = await executeVfsRead({ path: 'uploads/report.csv/content' }, GREP_CTX_CHAT) - expect(withContent.success).toBe(true) - expect(readChatUpload).toHaveBeenLastCalledWith('report.csv', 'chat-1') - }) - - it('tolerates a trailing /content on an uploads grep path', async () => { - grepChatUpload.mockResolvedValue([]) - - await executeVfsGrep({ pattern: 'x', path: 'uploads/report.json/content' }, GREP_CTX_CHAT) - - expect(grepChatUpload).toHaveBeenCalledWith('report.json', 'chat-1', 'x', expect.any(Object)) - }) -}) - -describe('vfs handlers docs corpus routing', () => { - const fetchMock = vi.fn() - const DOCS_PAGE = 'docs/workflows/blocks/agent.mdx' - - beforeEach(() => { - vi.clearAllMocks() - fetchMock.mockReset() - vi.stubGlobal('fetch', fetchMock) - }) - - afterEach(() => { - vi.unstubAllGlobals() - }) - - it('globs the docs corpus without materializing the workspace VFS', async () => { - const result = await executeVfsGlob({ pattern: 'docs/**' }, GREP_CTX) - - expect(result.success).toBe(true) - expect((result.output as { files: string[] }).files).toContain(DOCS_PAGE) - expect(getOrMaterializeVFS).not.toHaveBeenCalled() - }) - - it('reads a docs page via the live-site fetch, not the workspace VFS', async () => { - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - headers: new Headers(), - text: async () => 'line one\nline two', - }) - - const result = await executeVfsRead({ path: DOCS_PAGE }, GREP_CTX) - - expect(result.success).toBe(true) - expect(result.output).toEqual({ content: 'line one\nline two', totalLines: 2 }) - expect(getOrMaterializeVFS).not.toHaveBeenCalled() - }) - - it('surfaces DocsCorpusError messages verbatim from read, without fetching', async () => { - const unknown = await executeVfsRead({ path: 'docs/not-a-real-page.mdx' }, GREP_CTX) - expect(unknown.success).toBe(false) - expect(unknown.error).toContain('Docs page not found') - - const dir = await executeVfsRead({ path: 'docs/workflows/blocks' }, GREP_CTX) - expect(dir.success).toBe(false) - expect(dir.error).toContain('is a directory') - expect(fetchMock).not.toHaveBeenCalled() - }) - - it('greps one docs page and rejects directory scope without touching the workspace VFS', async () => { - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - headers: new Headers(), - text: async () => 'alpha\ncron beta\ngamma', - }) - - const single = await executeVfsGrep({ pattern: 'cron', path: DOCS_PAGE }, GREP_CTX) - expect(single.success).toBe(true) - - const directory = await executeVfsGrep( - { pattern: 'cron', path: 'docs/workflows', maxResults: 10_000 }, - GREP_CTX - ) - expect(directory.success).toBe(false) - expect(directory.error).toContain('grep must target one docs page') - expect(fetchMock).toHaveBeenCalledOnce() - - const invalid = await executeVfsGrep({ pattern: 'cron', path: 'docs/not-a-page.mdx' }, GREP_CTX) - expect(invalid.success).toBe(false) - expect(invalid.error).toContain('not a docs page') - expect(getOrMaterializeVFS).not.toHaveBeenCalled() - }) - - it('truncates an oversized multi-line docs page to fit the inline cap', async () => { - const line = 'y'.repeat(200) - const totalLines = Math.ceil((TOOL_RESULT_MAX_INLINE_CHARS * 2) / (line.length + 1)) - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - headers: new Headers(), - text: async () => Array.from({ length: totalLines }, () => line).join('\n'), - }) - - const result = await executeVfsRead({ path: DOCS_PAGE }, GREP_CTX) - - expect(result.success).toBe(true) - const output = result.output as { content: string; totalLines: number } - expect(output.totalLines).toBe(totalLines) - expect(output.content).toContain('[Page truncated: returned lines 1-') - expect(output.content).toMatch(/offset: \d+ and limit: \d+/) - expect(output.content).toContain('reduce the limit if that window is still too large') - expect(JSON.stringify(output).length).toBeLessThanOrEqual(TOOL_RESULT_MAX_INLINE_CHARS) - }) - - it('fails a docs page whose single line cannot fit inline instead of returning it oversized', async () => { - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - headers: new Headers(), - text: async () => 'z'.repeat(TOOL_RESULT_MAX_INLINE_CHARS + 1000), - }) - - const result = await executeVfsRead({ path: DOCS_PAGE }, GREP_CTX) - - expect(result.success).toBe(false) - expect(result.error).toContain('Grep this page') - }) - - it('rejects an explicit window that still overflows instead of truncating it', async () => { - const line = 'y'.repeat(200) - const totalLines = Math.ceil((TOOL_RESULT_MAX_INLINE_CHARS * 2) / (line.length + 1)) - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - headers: new Headers(), - text: async () => Array.from({ length: totalLines }, () => line).join('\n'), - }) - - const result = await executeVfsRead({ path: DOCS_PAGE, offset: 0, limit: totalLines }, GREP_CTX) - - expect(result.success).toBe(false) - expect(result.error).toContain('still too large over the requested window') - }) - - it('forwards caller cancellation to docs read and grep without fetching', async () => { - const controller = new AbortController() - controller.abort(new Error('user stopped docs tool')) - const context = { ...GREP_CTX, abortSignal: controller.signal } - - const read = await executeVfsRead({ path: DOCS_PAGE }, context) - const grep = await executeVfsGrep({ pattern: 'agent', path: DOCS_PAGE }, context) - - expect(read).toEqual({ success: false, error: 'user stopped docs tool' }) - expect(grep).toEqual({ success: false, error: 'user stopped docs tool' }) - expect(fetchMock).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts deleted file mode 100644 index 2f71288ebba..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ /dev/null @@ -1,622 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' -import { resolveCopilotKnowledgePrincipal } from '@/lib/copilot/application/execute-knowledge-use-case' -import { loadCopilotConnectedAccounts } from '@/lib/copilot/application/load-connected-accounts' -import { requireTrustedCopilotExecutionContext } from '@/lib/copilot/auth/application-delegation' -import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' -import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' -import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants' -import { - couldMatchDocsScope, - DocsCorpusError, - globDocs, - grepDocs, - isDocsPath, - readDocsPage, -} from '@/lib/copilot/docs/docs-corpus' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { getOrMaterializeVFS } from '@/lib/copilot/vfs' -import type { GrepCountEntry, GrepMatch } from '@/lib/copilot/vfs/operations' -import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' -import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' -import { isOversizedReadPlaceholder } from '@/lib/copilot/vfs/read-placeholders' -import { - importWorkspaceFileSecretProvenanceForModelView, - type WorkspaceFileSecretProvenanceIdentity, -} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { withBlockVisibility } from '@/blocks/visibility/server-context' -import { - grepChatUploadWithProvenance, - listChatUploads, - readChatUploadWithProvenance, -} from './upload-file-reader' - -const logger = createLogger('VfsTools') - -/** - * Materialize the workspace VFS inside the viewer's block-visibility context so - * the static component files stamped into it exclude blocks gated for this - * viewer (unrevealed previews, kill-switched types). Visibility is memoized per - * (userId, workspaceId), so repeated tool calls in one turn resolve once. - */ -async function getGatedVFS(context: ExecutionContext) { - const workspaceId = context.workspaceId - if (!workspaceId) throw new Error('No workspace context available') - const knowledgePrincipal = resolveCopilotKnowledgePrincipal(context) - const connectedAccountsContext = requireTrustedCopilotExecutionContext(context) - const vis = await getBlockVisibilityForCopilot(context.userId, workspaceId) - const filePrincipal = - context.copilotToolExecution && context.toolCallId - ? resolveCopilotFilePrincipal(context) - : undefined - return withBlockVisibility(vis, () => - getOrMaterializeVFS(workspaceId, context.userId, { - secretMountPolicy: context.secretMountPolicy, - filePrincipal, - knowledgePrincipal, - loadConnectedAccounts: () => loadCopilotConnectedAccounts(connectedAccountsContext), - }) - ) -} - -/** - * Encode a chat-upload display name as a single canonical VFS path segment so - * `uploads/` paths follow the same percent-encoded convention as `files/`. - * Falls back to the raw name if the segment cannot be encoded (so a listing - * never fails wholesale over one odd name). - */ -function encodeUploadSegment(name: string): string { - try { - return encodeVfsSegment(name) - } catch { - return name - } -} - -/** - * True when a grep `path` targets the workspace files tree (`files/` or - * `recently-deleted/files/`). Such greps search a single file's content via - * {@link WorkspaceVFS.grepFile}; every other path searches the VFS map. - */ -function isWorkspaceFileGrepPath(path: string | undefined): path is string { - if (!path) return false - return /^(recently-deleted\/)?files(\/|$)/.test(path.replace(/^\/+/, '')) -} - -/** True when a grep `path` targets the chat-scoped uploads namespace. */ -function isChatUploadGrepPath(path: string | undefined): path is string { - if (!path) return false - return /^uploads(\/|$)/.test(path.replace(/^\/+/, '')) -} - -function serializedResultSize(value: unknown): number { - try { - return JSON.stringify(value).length - } catch { - return String(value).length - } -} - -function hasModelAttachment(result: unknown): boolean { - if (!result || typeof result !== 'object') { - return false - } - const attachment = (result as { attachment?: { type?: string } }).attachment - return ( - attachment?.type === 'image' || attachment?.type === 'file' || attachment?.type === 'document' - ) -} - -async function canReturnWorkspaceFileValue( - file: WorkspaceFileSecretProvenanceIdentity | undefined, - value: unknown, - context: ExecutionContext, - view: 'complete' | 'derived', - contributingFiles: readonly WorkspaceFileSecretProvenanceIdentity[] = [] -): Promise { - if (!context.workspaceId) return true - const files = new Map() - for (const identity of file ? [file, ...contributingFiles] : contributingFiles) { - files.set(`${identity.context}:${identity.fileId}:${identity.key}`, identity) - } - if (files.size === 0) return true - - const provenanceView = hasModelAttachment(value) ? 'opaque' : view - for (const identity of files.values()) { - if ( - !(await importWorkspaceFileSecretProvenanceForModelView({ - workspaceId: context.workspaceId, - identity, - registry: context.resolvedSecretTraceRegistry, - view: provenanceView, - value, - })) - ) { - return false - } - } - return true -} - -/** - * Trim an oversized docs page to a whole-line prefix that fits the - * inline budget, preserving the true `totalLines` so the model can page through - * the rest with offset/limit. Returns null when not even one line fits — a - * single line longer than the cap — so the caller can fail instead of returning - * an over-cap payload as success. The notice offers grep as an alternative to - * another read because either operation fetches the page once. - */ -function truncateDocsPageToInlineCap(page: { content: string; totalLines: number }): { - output: { content: string; totalLines: number } - returnedLines: number -} | null { - const lines = page.content.split('\n') - const notice = (shown: number) => - `\n\n[Page truncated: returned lines 1-${shown} of ${page.totalLines}. To continue, read this path with offset: ${shown} and limit: ${shown}; reduce the limit if that window is still too large. To jump straight to a section, grep this path INSTEAD of reading it — grep is the same single fetch and returns only matching lines with their numbers.]` - - let kept = lines.length - while (kept > 0) { - const content = `${lines.slice(0, kept).join('\n')}${notice(kept)}` - if ( - serializedResultSize({ content, totalLines: page.totalLines }) <= TOOL_RESULT_MAX_INLINE_CHARS - ) { - return { output: { content, totalLines: page.totalLines }, returnedLines: kept } - } - kept = Math.floor(kept / 2) - } - return null -} - -/** - * Routes grep by content source. `docs/` uses one network-backed docs - * page; `uploads/` uses one chat-scoped upload; workspace file paths use - * one authorized file; all remaining paths use the materialized in-memory VFS. - * External and dynamic file contents are therefore opt-in and single-target, - * while an unscoped grep searches only static VFS resources and metadata. - */ -export async function executeVfsGrep( - params: Record, - context: ExecutionContext -): Promise { - const pattern = params.pattern as string | undefined - if (!pattern) { - return { success: false, error: "Missing required parameter 'pattern'" } - } - const outputMode = (params.output_mode as string) ?? 'content' - - const workspaceId = context.workspaceId - if (!workspaceId) { - return { success: false, error: 'No workspace context available' } - } - - const rawPath = typeof params.path === 'string' ? params.path : undefined - - try { - const grepOptions = { - maxResults: (params.maxResults as number) ?? 50, - outputMode: outputMode as 'content' | 'files_with_matches' | 'count', - ignoreCase: (params.ignoreCase as boolean) ?? false, - lineNumbers: (params.lineNumbers as boolean) ?? true, - context: (params.context as number) ?? 0, - } - - let result: GrepMatch[] | string[] | GrepCountEntry[] - let provenanceFile: WorkspaceFileSecretProvenanceIdentity | undefined - if (rawPath !== undefined && isDocsPath(rawPath)) { - result = await grepDocs(rawPath, pattern, grepOptions, context.abortSignal) - } else if (isChatUploadGrepPath(rawPath)) { - if (!context.chatId) { - return { success: false, error: 'No chat context available for uploads/' } - } - // The upload is the first segment after uploads/; any trailing segment - // (e.g. a /content suffix) is ignored, mirroring the uploads read path. - const filename = rawPath - .replace(/^\/+/, '') - .replace(/^uploads\/?/, '') - .split('/')[0] - if (!filename) { - return { - success: false, - error: - 'Grep over chat uploads must target a single upload (e.g. path: "uploads/report.json"). Use glob("uploads/*") to list uploads.', - } - } - const envelope = await grepChatUploadWithProvenance( - filename, - context.chatId, - pattern, - grepOptions - ) - result = envelope.value - provenanceFile = envelope.file - } else { - const vfs = await getGatedVFS(context) - if (isWorkspaceFileGrepPath(rawPath)) { - const envelope = await vfs.grepFileWithProvenance(rawPath, pattern, grepOptions) - result = envelope.value - provenanceFile = envelope.file - } else { - result = await vfs.grep(pattern, rawPath, grepOptions) - } - } - const key = - outputMode === 'files_with_matches' ? 'files' : outputMode === 'count' ? 'counts' : 'matches' - const matchCount = Array.isArray(result) - ? result.length - : typeof result === 'object' - ? Object.keys(result).length - : 0 - const output = { [key]: result } - if (!(await canReturnWorkspaceFileValue(provenanceFile, output, context, 'derived'))) { - return { - success: false, - error: - 'This file result cannot be shared safely because its secret provenance is unavailable.', - } - } - if (serializedResultSize(output) > TOOL_RESULT_MAX_INLINE_CHARS) { - return { - success: false, - error: - 'Grep result too large to return inline. Retry grep with a more specific pattern or narrower path, and reduce context or maxResults. Avoid catch-all greps because smaller searches save context window and make follow-up reads cheaper.', - } - } - logger.debug('vfs_grep result', { pattern, path: rawPath, outputMode, matchCount }) - return { success: true, output } - } catch (err) { - // Expected single-file scoping / no-text / too-large conditions: surface the - // message verbatim instead of logging an internal failure. - if (err instanceof WorkspaceFileGrepError || err instanceof DocsCorpusError) { - logger.debug('vfs_grep single-file scope rejected', { - pattern, - path: rawPath, - error: err.message, - }) - return { success: false, error: err.message } - } - logger.error('vfs_grep failed', { - pattern, - path: rawPath, - error: toError(err).message, - }) - return { success: false, error: getErrorMessage(err, 'vfs_grep failed') } - } -} - -export async function executeVfsGlob( - params: Record, - context: ExecutionContext -): Promise { - const pattern = params.pattern as string | undefined - if (!pattern) { - return { success: false, error: "Missing required parameter 'pattern'" } - } - - const workspaceId = context.workspaceId - if (!workspaceId) { - return { success: false, error: 'No workspace context available' } - } - - try { - if (couldMatchDocsScope(pattern)) { - const files = globDocs(pattern) - logger.debug('vfs_glob docs result', { pattern, fileCount: files.length }) - return { success: true, output: { files } } - } - - const vfs = await getGatedVFS(context) - let files = vfs.glob(pattern) - - if (context.chatId && (pattern === 'uploads/*' || pattern.startsWith('uploads/'))) { - const uploads = await listChatUploads(context.chatId) - // Encode per segment so uploads/ paths match the files/ convention; the - // upload resolver accepts both the encoded path and the raw display name. - const uploadPaths = uploads.map((f) => `uploads/${encodeUploadSegment(f.name)}`) - files = [...files, ...uploadPaths] - } - - logger.debug('vfs_glob result', { pattern, fileCount: files.length }) - // A bare [] on a namespace that is legitimately absent reads as "my glob is - // wrong". Say why it's empty so the model doesn't retry pattern variants. - if (files.length === 0) { - if (pattern.startsWith('uploads')) { - return { - success: true, - output: { files, note: 'This chat has no uploads.' }, - } - } - if (pattern.startsWith('user-local')) { - return { - success: true, - output: { - files, - note: 'No user-local folder is granted in this chat, so user-local/ is empty.', - }, - } - } - } - return { success: true, output: { files } } - } catch (err) { - logger.error('vfs_glob failed', { - pattern, - error: toError(err).message, - }) - return { success: false, error: getErrorMessage(err, 'vfs_glob failed') } - } -} - -export async function executeVfsRead( - params: Record, - context: ExecutionContext -): Promise { - const path = params.path as string | undefined - if (!path) { - return { success: false, error: "Missing required parameter 'path'" } - } - - const workspaceId = context.workspaceId - if (!workspaceId) { - return { success: false, error: 'No workspace context available' } - } - - try { - const parseOptionalNumber = (value: unknown): number | undefined => { - if (typeof value === 'number' && Number.isFinite(value)) return value - if (typeof value === 'string' && value.trim() !== '') { - const parsed = Number.parseInt(value, 10) - return Number.isFinite(parsed) ? parsed : undefined - } - return undefined - } - const offset = parseOptionalNumber(params.offset) - const limit = parseOptionalNumber(params.limit) - /** - * Applies the caller's line window, clamped against the content's ACTUAL line count rather than - * the self-reported `totalLines`. Synthesized results report `totalLines: 1` for content that is - * not one line (e.g. `files/x.pdf/extract` returns a whole extracted document), and clamping to - * that collapses the read to its first line — or to nothing for any nonzero offset. An - * attachment result is skipped outright: its `content` is a one-line label beside the bytes, so - * a window can only blank the label while the model still receives the attachment. - */ - const applyWindow = (result: T): T => { - if (offset === undefined && limit === undefined) return result - if (hasModelAttachment(result)) return result - const lines = result.content.split('\n') - const start = Math.max(0, Math.min(lines.length, offset ?? 0)) - const endRaw = limit !== undefined ? start + Math.max(0, limit) : lines.length - const end = Math.max(start, Math.min(lines.length, endRaw)) - return { - ...result, - content: lines.slice(start, end).join('\n'), - } - } - - if (isDocsPath(path)) { - const page = await readDocsPage(path, context.abortSignal) - const windowed = applyWindow(page) - if (serializedResultSize(windowed) > TOOL_RESULT_MAX_INLINE_CHARS) { - if (offset !== undefined || limit !== undefined) { - return { - success: false, - error: `${path} is still too large over the requested window. Narrow offset/limit, or grep this page for the section you need.`, - } - } - const truncated = truncateDocsPageToInlineCap(page) - if (!truncated) { - return { - success: false, - error: `${path} is too large to return inline even truncated. Grep this page for the section you need.`, - } - } - logger.debug('vfs_read truncated oversized docs page', { - path, - totalLines: page.totalLines, - returnedLines: truncated.returnedLines, - }) - return { success: true, output: truncated.output } - } - logger.debug('vfs_read resolved docs page', { path, totalLines: page.totalLines }) - return { success: true, output: windowed } - } - - // Handle chat-scoped uploads via the uploads/ virtual prefix. - // Uploads are flat and have no metadata/content split like files/ — the upload - // IS the first path segment after uploads/. Any trailing segment (e.g. a - // /content suffix added out of habit) is ignored so the read resolves either way. - if (path.startsWith('uploads/')) { - if (!context.chatId) { - return { success: false, error: 'No chat context available for uploads/' } - } - const filename = path.slice('uploads/'.length).split('/')[0] - const uploadEnvelope = await readChatUploadWithProvenance(filename, context.chatId) - const uploadResult = uploadEnvelope?.value - if (uploadResult) { - const isAttachment = hasModelAttachment(uploadResult) - if (!isAttachment && isOversizedReadPlaceholder(uploadResult)) { - // The loader refused to materialize the bytes at all; a window can't help. - return { success: false, error: uploadResult.content } - } - // Window BEFORE the inline-size gate, so offset/limit genuinely page a - // large upload instead of the gate rejecting the whole file first. - const windowedUpload = applyWindow(uploadResult) - if (!isAttachment && serializedResultSize(windowedUpload) > TOOL_RESULT_MAX_INLINE_CHARS) { - logger.warn('Upload read result too large', { - path, - hasAttachment: isAttachment, - contentLength: uploadResult.content.length, - serializedSize: serializedResultSize(windowedUpload), - windowed: offset !== undefined || limit !== undefined, - }) - return { - success: false, - error: - offset !== undefined || limit !== undefined - ? `The requested window is still too large to return inline. Reduce limit (fewer lines per page) — e.g. read({path: "${path}", offset: ${offset ?? 0}, limit: 200}).` - : `Read result too large to return inline. Page it — read({path: "${path}", offset: 0, limit: 500}) — or locate the relevant section first with grep({pattern: "...", path: "${path}"}).`, - } - } - const provenanceView = - offset === undefined && limit === undefined - ? (uploadEnvelope?.view ?? 'derived') - : 'derived' - if ( - !(await canReturnWorkspaceFileValue( - uploadEnvelope?.file, - windowedUpload, - context, - provenanceView, - uploadEnvelope?.contributingFiles - )) - ) { - return { - success: false, - error: - 'This file result cannot be shared safely because its secret provenance is unavailable.', - } - } - logger.debug('vfs_read resolved chat upload', { - path, - totalLines: uploadResult.totalLines, - hasAttachment: isAttachment, - offset, - limit, - }) - return { success: true, output: windowedUpload } - } - return { - success: false, - error: `Upload not found: ${path}. Use glob("uploads/*") to list available uploads.`, - } - } - - const vfs = await getGatedVFS(context) - - // Plain canonical file leaves are metadata resources. Dynamic file content - // and inspection paths use explicit suffixes like /content, /style, - // /compiled-check, or /compiled. - const shouldReadDynamicFileContent = - /^recently-deleted\/files\/.+\/content$/.test(path) || - /^files\/.+\/(?:content|style|compiled-check|compiled|render|extract)$/.test(path) - const fileEnvelope = shouldReadDynamicFileContent - ? await vfs.readFileContentWithProvenance(path) - : null - const fileContent = fileEnvelope?.value - if (fileContent) { - const isAttachment = hasModelAttachment(fileContent) - if (!isAttachment && isOversizedReadPlaceholder(fileContent)) { - // The loader refused to materialize the bytes at all; a window can't help. - return { success: false, error: fileContent.content } - } - // Window BEFORE the inline-size gate, so offset/limit genuinely page a - // large file instead of the gate rejecting the whole file first — the - // paging advice in the error below has to actually work. - const windowedFileContent = applyWindow(fileContent) - if ( - !isAttachment && - serializedResultSize(windowedFileContent) > TOOL_RESULT_MAX_INLINE_CHARS - ) { - logger.warn('File read result too large', { - path, - hasAttachment: isAttachment, - contentLength: fileContent.content.length, - serializedSize: serializedResultSize(windowedFileContent), - windowed: offset !== undefined || limit !== undefined, - }) - return { - success: false, - error: - offset !== undefined || limit !== undefined - ? `The requested window is still too large to return inline. Reduce limit (fewer lines per page) — e.g. read({path: "${path}", offset: ${offset ?? 0}, limit: 200}).` - : `Read result too large to return inline. Page it — read({path: "${path}", offset: 0, limit: 500}) — or locate the relevant section first with grep({pattern: "...", path: "${path}"}), then read({path: "${path}", offset: , limit: }). Avoid catch-all greps or full-file reads because they waste context window.`, - } - } - const provenanceView = - offset === undefined && limit === undefined ? (fileEnvelope?.view ?? 'derived') : 'derived' - if ( - !(await canReturnWorkspaceFileValue( - fileEnvelope?.file, - windowedFileContent, - context, - provenanceView, - fileEnvelope?.contributingFiles - )) - ) { - return { - success: false, - error: - 'This file result cannot be shared safely because its secret provenance is unavailable.', - } - } - if (fileContent.error !== undefined) { - return { success: false, error: fileContent.error } - } - logger.debug('vfs_read resolved workspace file', { - path, - totalLines: fileContent.totalLines, - hasAttachment: isAttachment, - offset, - limit, - }) - return { - success: true, - output: - fileContent.content === '' && !isAttachment - ? // An empty string with no explanation reads as a failed read. - { ...windowedFileContent, note: 'File is empty (0 bytes).' } - : windowedFileContent, - } - } - - let result = await vfs.read(path, offset, limit) - if (!result) { - // Same name, wrong encoding (spaces instead of %20) is the most common - // path mistake and carries zero ambiguity — resolve it instead of - // bouncing the model through a not-found round-trip. - const decodedEquivalent = vfs.resolveDecodedEquivalent(path) - if (decodedEquivalent) { - logger.info('vfs_read resolved decoded-equivalent path', { - requested: path, - resolved: decodedEquivalent, - }) - result = await vfs.read(decodedEquivalent, offset, limit) - } - } - if (!result) { - const suggestions = vfs.suggestSimilar(path) - logger.warn('vfs_read file not found', { path, suggestions }) - const hint = - suggestions.length > 0 - ? ` Did you mean: ${suggestions.join(', ')}?` - : ' Use glob to discover available paths.' - return { success: false, error: `File not found: ${path}.${hint}` } - } - if ( - !hasModelAttachment(result) && - (isOversizedReadPlaceholder(result) || - serializedResultSize(result) > TOOL_RESULT_MAX_INLINE_CHARS) - ) { - return { - success: false, - error: - offset !== undefined || limit !== undefined - ? `The requested window is still too large to return inline. Reduce limit (fewer lines per page) — e.g. read({path: "${path}", offset: ${offset ?? 0}, limit: 200}).` - : 'Read result too large to return inline. Page it with read({path, offset, limit}), or use grep with a more specific pattern to locate the relevant section first. Avoid catch-all greps or full-file reads because they waste context window.', - } - } - logger.debug('vfs_read result', { path, totalLines: result.totalLines, offset, limit }) - return { - success: true, - output: result, - } - } catch (err) { - if (err instanceof DocsCorpusError) { - logger.debug('vfs_read docs page rejected', { path, error: err.message }) - return { success: false, error: err.message } - } - logger.error('vfs_read failed', { - path, - error: toError(err).message, - }) - return { success: false, error: getErrorMessage(err, 'vfs_read failed') } - } -} diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts deleted file mode 100644 index 54d47e30f24..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { getErrorMessage } from '@sim/utils/errors' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ExecutionContext } from '@/lib/copilot/request/types' - -const { executeWorkflowUseCaseMock } = vi.hoisted(() => ({ - executeWorkflowUseCaseMock: vi.fn(), -})) - -vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ - executeCopilotWorkflowUseCase: executeWorkflowUseCaseMock, - messageForCopilotWorkflowError: (error: unknown) => - getErrorMessage(error, 'Workflow operation failed'), -})) - -import { executeGetBlockOutputs } from '@/lib/copilot/tools/handlers/workflow/queries' - -describe('executeGetBlockOutputs', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('returns display outputs and block-relative outputs for chat deployment', async () => { - const applicationResult = { - blocks: [ - { - blockId: 'agent-1', - blockName: 'Support Agent', - blockType: 'agent', - outputs: ['supportagent.content'], - relativeOutputs: ['content'], - triggerMode: undefined, - }, - { - blockId: 'loop-1', - blockName: 'Items Loop', - blockType: 'loop', - outputs: [], - relativeOutputs: [], - insideSubflowOutputs: ['itemsloop.index', 'itemsloop.currentItem', 'itemsloop.items'], - outsideSubflowOutputs: ['itemsloop.results'], - relativeInsideSubflowOutputs: ['index', 'currentItem', 'items'], - relativeOutsideSubflowOutputs: ['results'], - triggerMode: undefined, - }, - ], - variables: [], - } - executeWorkflowUseCaseMock.mockResolvedValue(applicationResult) - - const result = await executeGetBlockOutputs({ blockIds: ['agent-1', 'loop-1'] }, { - workflowId: 'wf-1', - workspaceId: 'ws-1', - userId: 'user-1', - toolCallId: 'tool-1', - copilotToolExecution: true, - } as ExecutionContext) - - expect(result.success).toBe(true) - expect(result.output).toEqual(applicationResult) - expect(executeWorkflowUseCaseMock).toHaveBeenCalledWith( - expect.objectContaining({ workflowId: 'wf-1', workspaceId: 'ws-1' }), - expect.objectContaining({ - operation: expect.objectContaining({ id: 'workflows.copilot.block_outputs.read' }), - }), - { - workflowId: 'wf-1', - assertedWorkspaceId: 'ws-1', - blockIds: ['agent-1', 'loop-1'], - } - ) - }) -}) diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts deleted file mode 100644 index 88a1ca0a5a8..00000000000 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { executeCopilotCustomToolUseCase } from '@/lib/copilot/application/execute-custom-tool-use-case' -import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' -import { executeCopilotMcpServerUseCase } from '@/lib/copilot/application/execute-mcp-server-use-case' -import { - executeCopilotWorkflowUseCase, - messageForCopilotWorkflowError, -} from '@/lib/copilot/application/execute-workflow-use-case' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { formatNormalizedWorkflowForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' -import { listAvailableCustomToolsUseCase } from '@/lib/custom-tools/application/use-cases' -import { discoverMcpToolsUseCase } from '@/lib/mcp/application/use-cases' -import { - readCopilotWorkflowBlockOutputs, - readCopilotWorkflowRunOptions, - readCopilotWorkflowUpstreamReferences, -} from '@/lib/workflows/application/read-workflow-copilot-metadata' -import { readWorkflowDefinition } from '@/lib/workflows/application/read-workflow-definition' -import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' -import type { Loop, Parallel } from '@/stores/workflows/workflow/types' -import type { - GetBlockOutputsParams, - GetBlockUpstreamReferencesParams, - GetDeployedWorkflowStateParams, - GetWorkflowDataParams, - GetWorkflowRunOptionsParams, -} from '../param-types' - -export async function executeGetWorkflowRunOptions( - params: GetWorkflowRunOptionsParams, - context: ExecutionContext -): Promise { - try { - const workflowId = params.workflowId || context.workflowId - if (!workflowId) { - return { success: false, error: 'workflowId is required' } - } - - const { options } = await executeCopilotWorkflowUseCase( - context, - readCopilotWorkflowRunOptions, - { workflowId, assertedWorkspaceId: context.workspaceId } - ) - - if (options.length === 0) { - return { - success: true, - output: { - workflowId, - default: null, - triggers: [], - message: - 'No runnable trigger blocks found. Add a Start/API/Input/Chat trigger or an external (webhook/integration) trigger before running.', - }, - } - } - - const guidanceFor = (kind: string): string => { - switch (kind) { - case 'fields': - return 'Build workflow_input matching inputSchema. Copy mockPayload only if you have no better values.' - case 'event_payload': - return 'Construct an event payload matching inputSchema, or run with useMockPayload: true if you cannot build one.' - case 'chat': - return 'Provide workflow_input shaped like { "input": "" }.' - default: - return 'No input required.' - } - } - - const triggers = options.map((option) => { - const callExample = - option.inputKind === 'none' - ? { triggerBlockId: option.triggerBlockId } - : { triggerBlockId: option.triggerBlockId, workflow_input: option.mockPayload } - return { ...option, guidance: guidanceFor(option.inputKind), callExample } - }) - - const defaultOption = options.find((option) => option.isDefault) - - return { - success: true, - output: { - workflowId, - default: defaultOption - ? { - triggerBlockId: defaultOption.triggerBlockId, - reason: `Highest-priority trigger (${defaultOption.blockName})`, - } - : null, - triggers, - }, - } - } catch (error) { - return { success: false, error: messageForCopilotWorkflowError(error) } - } -} - -export async function executeGetWorkflowData( - params: GetWorkflowDataParams, - context: ExecutionContext -): Promise { - try { - const workflowId = params.workflowId || context.workflowId - const dataType = params.data_type || params.dataType || '' - if (!workflowId) { - return { success: false, error: 'workflowId is required' } - } - if (!dataType) { - return { success: false, error: 'data_type is required' } - } - - if (dataType === 'global_variables') { - const { workflow: workflowRecord } = await executeCopilotWorkflowUseCase( - context, - readWorkflowDefinition, - { workflowId, assertedWorkspaceId: context.workspaceId, state: 'draft' } - ) - const variablesRecord = (workflowRecord.variables as Record) || {} - const variables = Object.values(variablesRecord).map((v) => { - const variable = v as Record | null - return { - id: String(variable?.id || ''), - name: String(variable?.name || ''), - value: variable?.value, - } - }) - return { success: true, output: { variables } } - } - - const workspaceId = context.workspaceId - if (dataType === 'custom_tools') { - if (!workspaceId) { - return { success: false, error: 'workspaceId is required' } - } - const { tools: toolsRows } = await executeCopilotCustomToolUseCase( - context, - listAvailableCustomToolsUseCase, - { - workspaceId, - } - ) - - const customToolsData = toolsRows.map((tool) => { - const schema = tool.schema as Record | null - const fn = (schema?.function ?? {}) as Record - return { - id: String(tool.id || ''), - title: String(tool.title || ''), - functionName: String(fn.name || ''), - description: String(fn.description || ''), - parameters: fn.parameters, - } - }) - - return { success: true, output: { customTools: customToolsData } } - } - - if (dataType === 'mcp_tools') { - if (!workspaceId) { - return { success: false, error: 'workspaceId is required' } - } - const { tools } = await executeCopilotMcpServerUseCase(context, discoverMcpToolsUseCase, { - workspaceId, - refresh: false, - }) - const mcpTools = tools.map((tool) => ({ - name: String(tool.name || ''), - serverId: String(tool.serverId || ''), - serverName: String(tool.serverName || ''), - description: String(tool.description || ''), - inputSchema: tool.inputSchema, - })) - return { success: true, output: { mcpTools } } - } - - if (dataType === 'files') { - if (!workspaceId) { - return { success: false, error: 'workspaceId is required' } - } - const { files } = await executeCopilotFileUseCase(context, listAllWorkspaceFiles, { - workspaceId, - scope: 'active', - }) - const fileResults = files.map((file) => ({ - id: String(file.id || ''), - name: String(file.name || ''), - key: String(file.key || ''), - path: String(file.path || ''), - size: Number(file.size || 0), - type: String(file.type || ''), - uploadedAt: String(file.uploadedAt || ''), - })) - return { success: true, output: { files: fileResults } } - } - - return { success: false, error: `Unknown data_type: ${dataType}` } - } catch (error) { - return { success: false, error: messageForCopilotWorkflowError(error) } - } -} - -export async function executeGetBlockOutputs( - params: GetBlockOutputsParams, - context: ExecutionContext -): Promise { - try { - const workflowId = params.workflowId || context.workflowId - if (!workflowId) { - return { success: false, error: 'workflowId is required' } - } - const payload = await executeCopilotWorkflowUseCase(context, readCopilotWorkflowBlockOutputs, { - workflowId, - assertedWorkspaceId: context.workspaceId, - blockIds: params.blockIds, - }) - return { success: true, output: payload } - } catch (error) { - return { success: false, error: messageForCopilotWorkflowError(error) } - } -} - -export async function executeGetBlockUpstreamReferences( - params: GetBlockUpstreamReferencesParams, - context: ExecutionContext -): Promise { - try { - const workflowId = params.workflowId || context.workflowId - if (!workflowId) { - return { success: false, error: 'workflowId is required' } - } - if (!Array.isArray(params.blockIds) || params.blockIds.length === 0) { - return { success: false, error: 'blockIds array is required' } - } - const payload = await executeCopilotWorkflowUseCase( - context, - readCopilotWorkflowUpstreamReferences, - { workflowId, assertedWorkspaceId: context.workspaceId, blockIds: params.blockIds } - ) - return { success: true, output: payload } - } catch (error) { - return { success: false, error: messageForCopilotWorkflowError(error) } - } -} - -export async function executeGetDeployedWorkflowState( - params: GetDeployedWorkflowStateParams, - context: ExecutionContext -): Promise { - try { - const workflowId = params.workflowId || context.workflowId - if (!workflowId) { - return { success: false, error: 'workflowId is required' } - } - - const { workflow: workflowRecord, state: deployedState } = await executeCopilotWorkflowUseCase( - context, - readWorkflowDefinition, - { workflowId, assertedWorkspaceId: context.workspaceId, state: 'deployed' } - ) - if (deployedState) { - const formatted = formatNormalizedWorkflowForCopilot({ - blocks: deployedState.blocks, - edges: deployedState.edges, - loops: deployedState.loops as Record, - parallels: deployedState.parallels as Record, - }) - - return { - success: true, - output: { - workflowId, - workflowName: workflowRecord.name || '', - isDeployed: true, - deploymentVersionId: - 'deploymentVersionId' in deployedState ? deployedState.deploymentVersionId : undefined, - deployedState: formatted, - }, - } - } - return { - success: true, - output: { - workflowId, - workflowName: workflowRecord.name || '', - isDeployed: false, - message: 'Workflow has not been deployed yet.', - }, - } - } catch (error) { - return { success: false, error: messageForCopilotWorkflowError(error) } - } -} diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts deleted file mode 100644 index a4e5ecc4297..00000000000 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-projection.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -/** - * Pins that the agent's block metadata still resolves tool params, outputs, and - * the hosted-key note after the projection moved to the shared catalog layer and - * off `@/tools/registry`. - * - * The sibling suite exercises this tool's gating against a mocked registry; this - * one runs it against the real Slack block config and the real generated tool - * metadata, because the thing worth proving is exactly that the metadata - * artifacts can answer everything the executable registry used to. Only the - * Slack block is read, so only it is registered. - */ -vi.unmock('@/blocks/registry') -vi.mock('@/blocks/registry-maps', async () => { - const { partialBlockRegistry } = await import('@sim/testing/mocks/block-registry.mock') - return partialBlockRegistry(await import('@/blocks/blocks/slack')) -}) - -const mocks = vi.hoisted(() => ({ - getUserPermissionConfig: vi.fn(), - isDeploymentAvailable: vi.fn(() => true), -})) - -vi.mock('@/lib/permission-groups/resolve.server', () => ({ - getUserPermissionConfig: mocks.getUserPermissionConfig, -})) - -vi.mock('@/lib/integrations/availability.server', () => ({ - isIntegrationDeploymentAvailableForVisibility: mocks.isDeploymentAvailable, -})) - -import { getBlocksMetadataServerTool } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' - -/** - * The projection under test reads real tool params and outputs, which the global - * `@/tools/metadata` and `@/tools/metadata-outputs` mocks in vitest.setup.ts empty. - */ -vi.unmock('@/tools/metadata') -vi.unmock('@/tools/metadata-outputs') - -interface AgentBlockMetadata { - blockType: string - name: string - description: string - operations?: Record< - string, - { name: string; description?: string; inputs: { required: unknown[]; optional: unknown[] } } - > - inputs?: { required: unknown[]; optional: unknown[] } -} - -describe('get_blocks_metadata against the real registries', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: null }) - mocks.isDeploymentAvailable.mockReturnValue(true) - }) - - it('resolves an integration block’s operations and their tool-derived inputs', async () => { - const result = await getBlocksMetadataServerTool.execute( - { blockIds: ['slack_v2'] }, - { userId: 'user-1', workspaceId: 'workspace-1' } - ) - - const slack = result.metadata.slack_v2 as AgentBlockMetadata - expect(slack.blockType).toBe('slack_v2') - expect(slack.name).toBe('Slack') - - const operations = slack.operations ?? {} - expect(Object.keys(operations).length).toBeGreaterThan(0) - for (const [operationId, operation] of Object.entries(operations)) { - expect(operation.name, operationId).toBeTruthy() - expect(operation.inputs, operationId).toBeDefined() - } - - /** - * The point of the rewrite: these inputs come from the generated tool - * metadata. An empty set everywhere means the tool params stopped being - * resolved, which is what reaching for the executable registry used to buy. - */ - const parameterCount = Object.values(operations).reduce( - (total, operation) => - total + operation.inputs.required.length + operation.inputs.optional.length, - 0 - ) - expect(parameterCount).toBeGreaterThan(0) - }) - - it('still describes the control-flow blocks it defines itself', async () => { - const result = await getBlocksMetadataServerTool.execute( - { blockIds: ['loop'] }, - { userId: 'user-1', workspaceId: 'workspace-1' } - ) - - const loop = result.metadata.loop as AgentBlockMetadata - expect(loop.blockType).toBe('loop') - expect(loop.inputs?.required.length).toBeGreaterThan(0) - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts deleted file mode 100644 index 3fb868b0731..00000000000 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * @vitest-environment node - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockGetUserPermissionConfig, mockIsIntegrationDeploymentAvailable } = vi.hoisted(() => ({ - mockGetUserPermissionConfig: vi.fn(), - mockIsIntegrationDeploymentAvailable: vi.fn(() => true), -})) - -vi.mock('@/lib/permission-groups/resolve.server', () => ({ - getUserPermissionConfig: mockGetUserPermissionConfig, -})) - -vi.mock('@/lib/integrations/availability.server', () => ({ - isIntegrationDeploymentAvailableForVisibility: mockIsIntegrationDeploymentAvailable, -})) - -import { computeBlockLevelInputs } from '@/lib/catalog/projection/block-detail' -import { getBlocksMetadataServerTool } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' -import { MothershipBlock } from '@/blocks/blocks/mothership' -import { getBlock } from '@/blocks/registry' -import type { BlockConfig } from '@/blocks/types' - -describe('get blocks metadata', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) - mockIsIntegrationDeploymentAvailable.mockReturnValue(true) - }) - - it('omits server-only Mothership policy inputs from block metadata definitions', () => { - const definitions = computeBlockLevelInputs(MothershipBlock) - - expect(definitions).not.toHaveProperty('secretScope') - expect(definitions).not.toHaveProperty('mountedSecrets') - }) - - /** - * A sub-block `condition` declared as a function is invoked during projection. - * A throwing one is an authoring defect worth surfacing, but it must cost the - * agent one block rather than every block it asked for — the projection call - * used to sit outside the per-block guard, so one bad condition emptied the - * whole response. - */ - it('drops only the block whose projection throws', async () => { - const healthy = { - type: 'slack', - name: 'Slack', - description: 'Send messages.', - category: 'tools', - bgColor: '#000000', - icon: () => null, - subBlocks: [], - tools: { access: [] }, - inputs: {}, - outputs: {}, - } as unknown as BlockConfig - const poisoned = { - ...healthy, - type: 'slack_broken', - name: 'Broken', - subBlocks: [ - { - id: 'text', - type: 'long-input', - condition: () => { - throw new Error('condition dereferences values') - }, - }, - ], - } as unknown as BlockConfig - - mockGetUserPermissionConfig.mockResolvedValue({ allowedIntegrations: null }) - vi.mocked(getBlock).mockImplementation((type: string) => - type === 'slack_broken' ? poisoned : healthy - ) - - const result = await getBlocksMetadataServerTool.execute( - { blockIds: ['slack_broken', 'slack'] }, - { userId: 'user-1', workspaceId: 'workspace-1' } - ) - - expect(result.metadata).not.toHaveProperty('slack_broken') - expect(result.metadata).toHaveProperty('slack') - }) - - /** - * A two-operation block standing in for a real integration: the projection - * resolves each operation to a tool id through `tools.config.tool`, which is - * what the group's denylist is written against. - */ - const gatedBlock = { - type: 'slack', - name: 'Slack', - description: 'Send messages.', - category: 'tools', - bgColor: '#000000', - icon: () => null, - subBlocks: [ - { - id: 'operation', - title: 'Operation', - type: 'dropdown', - options: [ - { label: 'Send Message', id: 'send' }, - { label: 'Create Canvas', id: 'canvas' }, - ], - }, - ], - tools: { - access: ['slack_message', 'slack_canvas'], - config: { - tool: ({ operation }: { operation?: string }) => - operation === 'canvas' ? 'slack_canvas' : 'slack_message', - }, - }, - inputs: {}, - outputs: {}, - } as unknown as BlockConfig - - it('withholds an operation whose tool the group denies', async () => { - mockGetUserPermissionConfig.mockResolvedValue({ - allowedIntegrations: ['slack'], - deniedTools: ['slack_canvas'], - }) - vi.mocked(getBlock).mockReturnValue(gatedBlock) - - const result = await getBlocksMetadataServerTool.execute( - { blockIds: ['slack'] }, - { userId: 'user-1', workspaceId: 'workspace-1' } - ) - - const slack = result.metadata.slack as { operations: Record } - - expect(Object.keys(slack.operations)).toEqual(['send']) - }) - - it('leaves the projection untouched when the group denies nothing', async () => { - mockGetUserPermissionConfig.mockResolvedValue({ - allowedIntegrations: ['slack'], - deniedTools: [], - }) - vi.mocked(getBlock).mockReturnValue(gatedBlock) - - const result = await getBlocksMetadataServerTool.execute( - { blockIds: ['slack'] }, - { userId: 'user-1', workspaceId: 'workspace-1' } - ) - - const slack = result.metadata.slack as { operations: Record } - expect(Object.keys(slack.operations).sort()).toEqual(['canvas', 'send']) - }) - - /** - * A block whose operation ids ARE its tool ids, declaring no - * `tools.config.tool`. The catalog projection cannot fill `operation.toolId` - * for it, so gating on that field alone would publish every denied operation. - */ - const selectorlessBlock = { - type: 'sqs', - name: 'SQS', - description: 'Queue.', - category: 'tools', - bgColor: '#000000', - icon: () => null, - subBlocks: [ - { - id: 'operation', - title: 'Operation', - type: 'dropdown', - options: [ - { label: 'Send', id: 'sqs_send' }, - { label: 'Receive', id: 'sqs_receive' }, - ], - }, - ], - tools: { access: ['sqs_send', 'sqs_receive'] }, - inputs: {}, - outputs: {}, - } as unknown as BlockConfig - - it('withholds a denied operation on a block that declares no tool selector', async () => { - mockGetUserPermissionConfig.mockResolvedValue({ - allowedIntegrations: ['sqs'], - deniedTools: ['sqs_receive'], - }) - vi.mocked(getBlock).mockReturnValue(selectorlessBlock) - - const result = await getBlocksMetadataServerTool.execute( - { blockIds: ['sqs'] }, - { userId: 'user-1', workspaceId: 'workspace-1' } - ) - - const sqs = result.metadata.sqs as { operations: Record } - expect(Object.keys(sqs.operations)).toEqual(['sqs_send']) - }) - - it('withholds a block whose every operation the group denies', async () => { - mockGetUserPermissionConfig.mockResolvedValue({ - allowedIntegrations: ['slack'], - deniedTools: ['slack_message', 'slack_canvas'], - }) - vi.mocked(getBlock).mockReturnValue(gatedBlock) - - const result = await getBlocksMetadataServerTool.execute( - { blockIds: ['slack'] }, - { userId: 'user-1', workspaceId: 'workspace-1' } - ) - - expect(result.metadata).not.toHaveProperty('slack') - }) - - it('keeps access-control-exempt and special blocks under a restrictive allowlist', async () => { - const result = await getBlocksMetadataServerTool.execute( - { blockIds: ['start_trigger', 'loop', 'slack', 'notion'] }, - { userId: 'user-1', workspaceId: 'workspace-1' } - ) - - expect(result.metadata).toHaveProperty('start_trigger') - expect(result.metadata).toHaveProperty('loop') - expect(result.metadata).toHaveProperty('slack') - expect(result.metadata).not.toHaveProperty('notion') - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts deleted file mode 100644 index 0c0796bc55e..00000000000 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ /dev/null @@ -1,851 +0,0 @@ -import { existsSync, readFileSync } from 'fs' -import { join } from 'path' -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { omit } from '@sim/utils/object' -import { z } from 'zod' -import { - type CatalogBlockDetail, - type CatalogInputDefinition, - projectBlockDetail, - splitFieldsByOperation, -} from '@/lib/catalog/projection/block-detail' -import type { CatalogSubBlock } from '@/lib/catalog/projection/subblock' -import type { CatalogToolSummary } from '@/lib/catalog/projection/tool' -import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { getAllowedIntegrationsFromEnv, isHosted } from '@/lib/core/config/env-flags' -import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' -import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' -import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' -import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' -import { - intersectIntegrationAllowlists, - resolveAccessControlBlockType, -} from '@/lib/permission-groups/integration-allowlist' -import { - collectDeniedOperationIds, - createToolAccessGate, - type IsToolAllowed, - OPERATION_SUBBLOCK_ID, - type OperationGateBlock, -} from '@/lib/permission-groups/operation-access' -import { getBlock } from '@/blocks/registry' -import { AuthMode, type BlockConfig, type SubBlockConfig } from '@/blocks/types' -import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' - -/** - * The block shape this tool reports, projected by the shared catalog projection - * (`@/lib/catalog/projection`) and then reshaped for the agent below. - * - * The projection reads tool params and outputs from `@/tools/metadata` and - * `@/tools/metadata-outputs` rather than the executable registry, which is what - * keeps this module's graph off the ~4,700 modules `@/tools/registry` costs. - */ -type CopilotSubblockMetadata = CatalogSubBlock - -interface CopilotToolMetadata { - id: string - name: string - description?: string - inputs?: Record - outputs?: Record -} - -interface CopilotTriggerMetadata { - id: string - outputs?: Record - configFields?: Record -} - -interface CopilotBlockMetadata { - id: string - name: string - description: string - bestPractices?: string - inputSchema: CopilotSubblockMetadata[] - inputDefinitions?: Record - triggerAllowed?: boolean - authType?: 'OAuth' | 'API Key' | 'Bot Token' - tools: CopilotToolMetadata[] - triggers: CopilotTriggerMetadata[] - operationInputSchema: Record - operations?: Record< - string, - { - toolId?: string - toolName?: string - description?: string - inputs?: Record - outputs?: Record - inputSchema?: CopilotSubblockMetadata[] - } - > - outputs?: Record - yamlDocumentation?: string -} - -const GetBlocksMetadataInputSchema = z.object({ blockIds: z.array(z.string()).min(1) }) -const GetBlocksMetadataResultSchema = z.object({ metadata: z.record(z.string(), z.any()) }) - -/** - * Prompt-shaped tool description: the raw text plus the hosted-key note the - * agent needs. The public catalog publishes the raw description and a structured - * `hostedApiKey` instead, which is why this stays a Copilot concern rather than - * moving into the shared projection. - */ -function describeToolForAgent(tool: CatalogToolSummary): string { - return getCopilotToolDescription(tool, { - isHosted, - hostedApiKey: tool.hostedApiKey, - fallbackName: tool.id, - }) -} - -/** Reshapes the shared block projection into the agent-facing metadata above. */ -function toCopilotBlockMetadata(detail: CatalogBlockDetail): CopilotBlockMetadata { - return removeNullish({ - id: detail.id, - name: detail.name, - description: detail.longDescription || detail.description || '', - bestPractices: detail.bestPractices, - inputSchema: detail.inputSchema, - inputDefinitions: detail.inputDefinitions, - triggerAllowed: detail.triggerAllowed, - authType: resolveAuthType(detail.authMode as AuthMode | undefined), - tools: detail.tools.map((tool) => ({ - id: tool.id, - name: tool.name, - description: tool.description, - inputs: tool.params, - outputs: tool.outputs, - })), - triggers: detail.triggers, - operationInputSchema: detail.operationInputSchema, - operations: detail.operations, - outputs: detail.outputs, - }) as CopilotBlockMetadata -} - -/** - * Strips everything a denied tool id reaches in one block's metadata: the tool - * entry, every operation that runs it, that operation's input schema, and the - * selector option that would choose it. - * - * Returns the projection untouched when the group denies nothing this block - * owns, so an unrestricted viewer pays one pass over `operations` and nothing - * else. `null` means the block has no usable operation left and should be - * withheld entirely, matching the VFS projection. - */ -function withDeniedToolsRemoved( - metadata: CopilotBlockMetadata, - block: OperationGateBlock, - isToolAllowed: IsToolAllowed -): CopilotBlockMetadata | null { - const operations = metadata.operations ?? {} - /* Resolved through the shared operation gate rather than `operation.toolId`: - the catalog projection fills that field only from `tools.config.tool`, so a - block whose operation ids ARE its tool ids leaves it undefined and every one - of its operations would read as permitted. */ - const deniedOperations = collectDeniedOperationIds(block, Object.keys(operations), isToolAllowed) - const tools = metadata.tools.filter((tool) => isToolAllowed(tool.id)) - if (deniedOperations.size === 0 && tools.length === metadata.tools.length) return metadata - - const allToolsDenied = metadata.tools.length > 0 && tools.length === 0 - const allOperationsDenied = - Object.keys(operations).length > 0 && deniedOperations.size === Object.keys(operations).length - if (allToolsDenied || allOperationsDenied) return null - - return { - ...metadata, - tools, - /* `removeNullish` drops an empty projection, so neither schema is - guaranteed present. */ - ...(metadata.inputSchema - ? { - inputSchema: metadata.inputSchema.map((field) => - field.id === OPERATION_SUBBLOCK_ID && Array.isArray(field.options) - ? { - ...field, - options: field.options.filter((option) => !deniedOperations.has(option.id)), - } - : field - ), - } - : {}), - operations: omit(operations, [...deniedOperations]), - ...(metadata.operationInputSchema - ? { operationInputSchema: omit(metadata.operationInputSchema, [...deniedOperations]) } - : {}), - } -} - -export const getBlocksMetadataServerTool: BaseServerTool< - z.infer, - z.infer -> = { - name: 'get_blocks_metadata', - inputSchema: GetBlocksMetadataInputSchema, - outputSchema: GetBlocksMetadataResultSchema, - async execute( - { blockIds }: z.infer, - context?: { userId: string; workspaceId?: string } - ): Promise> { - const logger = createLogger('GetBlocksMetadataServerTool') - logger.debug('Executing get_blocks_metadata', { count: blockIds?.length }) - - const permissionConfig = - context?.userId && context?.workspaceId - ? await resolvePermissionGroupConfig(context.userId, context.workspaceId, undefined) - : null - const allowedIntegrations = intersectIntegrationAllowlists( - permissionConfig?.allowedIntegrations ?? null, - getAllowedIntegrationsFromEnv() - ) - const isToolAllowed = createToolAccessGate(permissionConfig?.deniedTools) - const visibility = overlayVisibility() - - const result: Record = {} - for (const blockId of blockIds || []) { - const specialBlock = SPECIAL_BLOCKS_METADATA[blockId] - if (!isIntegrationDeploymentAvailableForVisibility(blockId, visibility)) { - logger.debug('Block unavailable for this deployment', { blockId }) - continue - } - if ( - allowedIntegrations != null && - !specialBlock && - !isBlockTypeAccessControlExempt(blockId) && - !allowedIntegrations.includes(resolveAccessControlBlockType(blockId.toLowerCase())) - ) { - logger.debug('Block not allowed by permission group', { blockId }) - continue - } - - let metadata: CopilotBlockMetadata - - if (specialBlock) { - const inputDefinitions: Record = specialBlock.inputs || {} - const { commonFields, operationFields } = splitFieldsByOperation( - (specialBlock.subBlocks || []) as SubBlockConfig[], - inputDefinitions - ) - metadata = { - id: specialBlock.id, - name: specialBlock.name, - description: specialBlock.description || '', - inputSchema: commonFields, - inputDefinitions, - tools: [], - triggers: [], - operationInputSchema: operationFields, - outputs: specialBlock.outputs, - } - } else { - const blockConfig: BlockConfig | undefined = getBlock(blockId) - if (!blockConfig) { - logger.debug('Block not found in registry', { blockId }) - continue - } - - if (blockConfig.hideFromToolbar) { - logger.debug('Skipping block hidden from toolbar', { blockId }) - continue - } - - // getBlock is pure, so the viewer's visibility must be checked - // explicitly: unrevealed preview blocks and kill-switched types stay - // out of the agent's metadata (the router wraps this tool in - // withBlockVisibility). - if (isHiddenUnder(visibility, blockConfig)) { - logger.debug('Skipping block gated by visibility', { blockId }) - continue - } - - /** - * One block's projection must not fail the whole call. A sub-block - * `condition` declared as a function is invoked during projection, and a - * throwing one propagates — the `catalog-sweep` test proves no - * registered block has one, but a runtime-built custom (deploy-as-block) - * config is not swept, so this keeps the blast radius to the block that - * carries the defect rather than every block the agent asked for. - */ - try { - metadata = toCopilotBlockMetadata( - projectBlockDetail(blockConfig, { - deployment: { hostedKeys: isHosted }, - describeTool: describeToolForAgent, - }) - ) - } catch (error) { - logger.error('Failed to project block metadata', { - blockId, - error: toError(error).message, - }) - continue - } - - const permitted = withDeniedToolsRemoved(metadata, blockConfig, isToolAllowed) - if (!permitted) { - logger.debug('Block has no operation this permission group allows', { blockId }) - continue - } - metadata = permitted - } - - try { - const workingDir = process.cwd() - const isInAppsSim = workingDir.endsWith('/apps/sim') || workingDir.endsWith('\\apps\\sim') - const basePath = isInAppsSim ? join(workingDir, '..', '..') : workingDir - const docPath = join( - basePath, - 'apps', - 'docs', - 'content', - 'docs', - 'yaml', - 'blocks', - `${DOCS_FILE_MAPPING[blockId] || blockId}.mdx` - ) - if (existsSync(docPath)) { - metadata.yamlDocumentation = readFileSync(docPath, 'utf-8') - } - } catch (error) { - logger.warn('Failed to read YAML documentation file', { - error: toError(error).message, - }) - } - - result[blockId] = metadata - } - - const transformedResult: Record = {} - for (const [blockId, metadata] of Object.entries(result)) { - transformedResult[blockId] = transformBlockMetadata(metadata) - } - - return GetBlocksMetadataResultSchema.parse({ metadata: transformedResult }) - }, -} - -function transformBlockMetadata(metadata: CopilotBlockMetadata): any { - const transformed: any = { - blockType: metadata.id, - name: metadata.name, - description: metadata.description, - } - - if (metadata.bestPractices) { - transformed.bestPractices = metadata.bestPractices - } - - if (metadata.authType) { - transformed.authType = metadata.authType - - if (metadata.authType === 'OAuth') { - transformed.requiredCredentials = { - type: 'oauth', - service: metadata.id, // e.g., 'gmail', 'slack', etc. - description: `OAuth authentication required for ${metadata.name}`, - } - - // Check if this service also supports service account credentials - const oauthSubBlock = metadata.inputSchema?.find( - (sb: CopilotSubblockMetadata) => sb.type === 'oauth-input' && sb.serviceId - ) - if (oauthSubBlock?.serviceId) { - const serviceAccountProviderId = getServiceAccountProviderForProviderId( - oauthSubBlock.serviceId - ) - if (serviceAccountProviderId) { - transformed.requiredCredentials.serviceAccountType = serviceAccountProviderId - transformed.requiredCredentials.description = `OAuth or service account authentication supported for ${metadata.name}` - } - } - } else if (metadata.authType === 'API Key') { - transformed.requiredCredentials = { - type: 'api_key', - description: `API key required for ${metadata.name}`, - } - } else if (metadata.authType === 'Bot Token') { - transformed.requiredCredentials = { - type: 'bot_token', - description: `Bot token required for ${metadata.name}`, - } - } - } - - const inputs = extractInputs(metadata) - if (inputs.required.length > 0 || inputs.optional.length > 0) { - transformed.inputs = inputs - } - - const hasOperations = metadata.operations && Object.keys(metadata.operations).length > 0 - if (hasOperations && metadata.operations) { - const blockLevelInputs = new Set(Object.keys(metadata.inputDefinitions || {})) - transformed.operations = Object.entries(metadata.operations).reduce( - (acc, [opId, opData]) => { - acc[opId] = { - name: opData.toolName || opId, - description: opData.description, - inputs: extractOperationInputs(opData, blockLevelInputs), - outputs: formatOutputsFromDefinition(opData.outputs || {}), - } - return acc - }, - {} as Record - ) - } - - if (!hasOperations) { - const outputs = extractOutputs(metadata) - if (outputs.length > 0) { - transformed.outputs = outputs - } - } - - if (metadata.triggers && metadata.triggers.length > 0) { - transformed.triggers = metadata.triggers.map((t) => ({ - id: t.id, - outputs: formatOutputsFromDefinition(t.outputs || {}), - configFields: t.configFields || {}, - })) - } - - if (metadata.yamlDocumentation) { - transformed.yamlDocumentation = metadata.yamlDocumentation - } - - return transformed -} - -function extractInputs(metadata: CopilotBlockMetadata): { - required: any[] - optional: any[] -} { - const required: any[] = [] - const optional: any[] = [] - const inputDefs = metadata.inputDefinitions || {} - - for (const schema of metadata.inputSchema || []) { - // Skip trigger subBlocks - they're handled separately in triggers.configFields - if (schema.mode === 'trigger' || schema.mode === 'trigger-advanced') { - continue - } - - if (schema.id === 'triggerConfig' || schema.type === 'trigger-config') { - continue - } - - const inputDef = inputDefs[schema.id] || inputDefs[schema.canonicalParamId || ''] - - let description = schema.description || inputDef?.description || schema.title - if (schema.id === 'operation') { - description = 'Operation to perform' - } - - const input: any = { - name: schema.id, - type: mapSchemaTypeToSimpleType(schema.type, schema), - description, - } - - if (schema.options && schema.options.length > 0) { - input.options = schema.options.map((opt) => opt.id || opt.label) - } - - if (inputDef?.enum && Array.isArray(inputDef.enum)) { - input.options = inputDef.enum - } - - if (schema.defaultValue !== undefined) { - input.default = schema.defaultValue - } else if (inputDef?.default !== undefined) { - input.default = inputDef.default - } - - if (schema.type === 'slider' || schema.type === 'number-input') { - if (schema.min !== undefined) input.min = schema.min - if (schema.max !== undefined) input.max = schema.max - } else if (inputDef?.minimum !== undefined || inputDef?.maximum !== undefined) { - if (inputDef.minimum !== undefined) input.min = inputDef.minimum - if (inputDef.maximum !== undefined) input.max = inputDef.maximum - } - - const example = generateInputExample(schema, inputDef) - if (example !== undefined) { - input.example = example - } - - const isOperationField = - schema.id === 'operation' && - metadata.operations && - Object.keys(metadata.operations).length > 0 - const isRequired = schema.required || inputDef?.required || isOperationField - - if (isRequired) { - required.push(input) - } else { - optional.push(input) - } - } - - return { required, optional } -} - -function extractOperationInputs( - opData: any, - blockLevelInputs: Set -): { - required: any[] - optional: any[] -} { - const required: any[] = [] - const optional: any[] = [] - const inputs = opData.inputs || {} - - for (const [key, inputDef] of Object.entries(inputs)) { - if (blockLevelInputs.has(key)) { - continue - } - - const input: any = { - name: key, - type: (inputDef as any)?.type || 'string', - description: (inputDef as any)?.description, - } - - if ((inputDef as any)?.enum) { - input.options = (inputDef as any).enum - } - - if ((inputDef as any)?.default !== undefined) { - input.default = (inputDef as any).default - } - - if ((inputDef as any)?.example !== undefined) { - input.example = (inputDef as any).example - } - - if ((inputDef as any)?.required) { - required.push(input) - } else { - optional.push(input) - } - } - - return { required, optional } -} - -function extractOutputs(metadata: CopilotBlockMetadata): any[] { - const outputs: any[] = [] - - if (metadata.outputs && Object.keys(metadata.outputs).length > 0) { - return formatOutputsFromDefinition(metadata.outputs) - } - - if (metadata.operations && Object.keys(metadata.operations).length > 0) { - const firstOp = Object.values(metadata.operations)[0] - return formatOutputsFromDefinition(firstOp.outputs || {}) - } - - return outputs -} - -function formatOutputsFromDefinition(outputDefs: Record): any[] { - const outputs: any[] = [] - - for (const [key, def] of Object.entries(outputDefs)) { - const output: any = { - name: key, - type: typeof def === 'string' ? def : def?.type || 'any', - } - - if (typeof def === 'object') { - if (def.description) output.description = def.description - if (def.example) output.example = def.example - } - - outputs.push(output) - } - - return outputs -} - -function mapSchemaTypeToSimpleType(schemaType: string, schema: CopilotSubblockMetadata): string { - const typeMap: Record = { - 'short-input': 'string', - 'long-input': 'string', - 'code-input': 'string', - 'number-input': 'number', - slider: 'number', - dropdown: 'string', - combobox: 'string', - toggle: 'boolean', - 'json-input': 'json', - 'file-upload': 'file', - 'multi-select': 'array', - 'credential-input': 'credential', - 'oauth-credential': 'credential', - 'oauth-input': 'credential', - } - - const mappedType = typeMap[schemaType] || schemaType - - if (schema.multiSelect) return 'array' - - return mappedType -} - -function generateInputExample(schema: CopilotSubblockMetadata, inputDef?: any): any { - if (inputDef?.example !== undefined) return inputDef.example - - switch (schema.type) { - case 'short-input': - case 'long-input': - if (schema.id === 'systemPrompt') return 'You are a helpful assistant...' - if (schema.id === 'userPrompt') return 'What is the weather today?' - if (schema.placeholder) return schema.placeholder - return undefined - case 'number-input': - case 'slider': - return schema.defaultValue ?? schema.min ?? 0 - case 'toggle': - return schema.defaultValue ?? false - case 'json-input': - return schema.defaultValue ?? {} - case 'dropdown': - case 'combobox': - if (schema.options && schema.options.length > 0) { - return schema.options[0].id - } - return undefined - default: - return undefined - } -} -function resolveAuthType( - authMode: AuthMode | undefined -): 'OAuth' | 'API Key' | 'Bot Token' | undefined { - if (!authMode) return undefined - if (authMode === AuthMode.OAuth) return 'OAuth' - if (authMode === AuthMode.ApiKey) return 'API Key' - if (authMode === AuthMode.BotToken) return 'Bot Token' - return undefined -} -function removeNullish(obj: any): any { - if (!obj || typeof obj !== 'object') return obj - - const cleaned: any = Array.isArray(obj) ? [] : {} - - for (const [key, value] of Object.entries(obj)) { - if (value !== null && value !== undefined) { - cleaned[key] = value - } - } - - return cleaned -} -const DOCS_FILE_MAPPING: Record = {} - -const SPECIAL_BLOCKS_METADATA: Record = { - loop: { - id: 'loop', - name: 'Loop', - description: 'Control flow block for iterating over collections or repeating actions', - longDescription: - 'Control flow block for iterating over collections or repeating actions serially', - bestPractices: ` - - Set reasonable limits for iterations. - - Use forEach for collection processing, for loops for fixed iterations. - - Cannot have loops/parallels inside a loop block. - - For yaml it needs to connect blocks inside to the start field of the block. - - IMPORTANT for while/doWhile: The condition is evaluated BEFORE each iteration starts, so blocks INSIDE the loop cannot be referenced in the condition (their outputs don't exist yet when the condition runs). - - For while/doWhile conditions, use: for iteration count, workflow variables (set by blocks OUTSIDE the loop), or references to blocks OUTSIDE the loop. - - To break a while/doWhile loop based on internal block results, use a variables block OUTSIDE the loop and update it from inside, then reference that variable in the condition. - `, - inputs: { - loopType: { - type: 'string', - required: true, - enum: ['for', 'forEach', 'while', 'doWhile'], - description: - "Loop Type - 'for' runs N times, 'forEach' iterates over collection, 'while' runs while condition is true, 'doWhile' runs at least once then checks condition", - }, - iterations: { - type: 'number', - required: false, - minimum: 1, - maximum: 1000, - description: "Number of iterations (for 'for' loopType)", - example: 5, - }, - collection: { - type: 'string', - required: false, - description: "Collection to iterate over (for 'forEach' loopType)", - example: '', - }, - condition: { - type: 'string', - required: false, - description: - "Condition to evaluate (for 'while' and 'doWhile' loopType). IMPORTANT: Cannot reference blocks INSIDE the loop - use , workflow variables, or blocks OUTSIDE the loop instead.", - example: ' < 10', - }, - maxConcurrency: { - type: 'number', - required: false, - default: 1, - minimum: 1, - maximum: 10, - description: 'Max parallel executions (1 = sequential)', - example: 1, - }, - }, - outputs: { - results: { type: 'array', description: 'Array of results from each iteration' }, - currentIndex: { type: 'number', description: 'Current iteration index (0-based)' }, - currentItem: { type: 'any', description: 'Current item being iterated (for forEach loops)' }, - totalIterations: { type: 'number', description: 'Total number of iterations' }, - }, - subBlocks: [ - { - id: 'loopType', - title: 'Loop Type', - type: 'dropdown', - required: true, - options: [ - { label: 'For Loop (count)', id: 'for' }, - { label: 'For Each (collection)', id: 'forEach' }, - { label: 'While (condition)', id: 'while' }, - { label: 'Do While (condition)', id: 'doWhile' }, - ], - }, - { - id: 'iterations', - title: 'Iterations', - type: 'slider', - min: 1, - max: 1000, - integer: true, - condition: { field: 'loopType', value: 'for' }, - }, - { - id: 'collection', - title: 'Collection', - type: 'short-input', - placeholder: 'Array or object to iterate over...', - condition: { field: 'loopType', value: 'forEach' }, - }, - { - id: 'condition', - title: 'Condition', - type: 'code', - language: 'javascript', - placeholder: ' < 10 or ', - description: - 'Cannot reference blocks inside the loop. Use , workflow variables, or blocks outside the loop.', - condition: { field: 'loopType', value: ['while', 'doWhile'] }, - }, - { - id: 'maxConcurrency', - title: 'Max Concurrency', - type: 'slider', - min: 1, - max: 10, - integer: true, - default: 1, - }, - ], - }, - parallel: { - id: 'parallel', - name: 'Parallel', - description: 'Control flow block for executing multiple branches simultaneously', - longDescription: 'Control flow block for executing multiple branches simultaneously', - bestPractices: ` - - Keep structures inside simple. Cannot have multiple blocks within a parallel block. - - Cannot have loops/parallels inside a parallel block. - - Agent block combobox can be if the user wants to query multiple models in parallel. The collection has to be an array of correct model strings available for the agent block. - - For yaml it needs to connect blocks inside to the start field of the block. - `, - inputs: { - parallelType: { - type: 'string', - required: true, - enum: ['count', 'collection'], - description: "Parallel Type - 'count' runs N branches, 'collection' runs one per item", - }, - count: { - type: 'number', - required: false, - minimum: 1, - maximum: 100, - description: "Number of parallel branches (for 'count' type)", - example: 3, - }, - collection: { - type: 'string', - required: false, - description: "Collection to process in parallel (for 'collection' type)", - example: '', - }, - maxConcurrency: { - type: 'number', - required: false, - default: 10, - minimum: 1, - maximum: 50, - description: 'Max concurrent executions at once', - example: 10, - }, - }, - outputs: { - results: { type: 'array', description: 'Array of results from all parallel branches' }, - index: { type: 'number', description: 'Current branch index (0-based)' }, - currentItem: { - type: 'any', - description: 'Current item for this branch (for collection type)', - }, - items: { type: 'array', description: 'All distribution items' }, - }, - subBlocks: [ - { - id: 'parallelType', - title: 'Parallel Type', - type: 'dropdown', - required: true, - options: [ - { label: 'Count (number)', id: 'count' }, - { label: 'Collection (array)', id: 'collection' }, - ], - }, - { - id: 'count', - title: 'Count', - type: 'slider', - min: 1, - max: 100, - integer: true, - condition: { field: 'parallelType', value: 'count' }, - }, - { - id: 'collection', - title: 'Collection', - type: 'short-input', - placeholder: 'Array to process in parallel...', - condition: { field: 'parallelType', value: 'collection' }, - }, - { - id: 'maxConcurrency', - title: 'Max Concurrency', - type: 'slider', - min: 1, - max: 50, - integer: true, - default: 10, - }, - ], - }, -} diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.test.ts b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.test.ts deleted file mode 100644 index f93858ab953..00000000000 --- a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * @vitest-environment node - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockGetAllBlocks, - mockGetBlock, - mockGetUserPermissionConfig, - mockIsIntegrationDeploymentAvailable, -} = vi.hoisted(() => ({ - mockGetAllBlocks: vi.fn(), - mockGetBlock: vi.fn(), - mockGetUserPermissionConfig: vi.fn(), - mockIsIntegrationDeploymentAvailable: vi.fn(() => true), -})) - -vi.mock('@/blocks/registry', () => ({ - getAllBlocks: mockGetAllBlocks, - getBlock: mockGetBlock, -})) - -vi.mock('@/lib/permission-groups/resolve.server', () => ({ - getUserPermissionConfig: mockGetUserPermissionConfig, -})) - -vi.mock('@/lib/integrations/availability.server', () => ({ - isIntegrationDeploymentAvailableForVisibility: mockIsIntegrationDeploymentAvailable, -})) - -import { getTriggerBlocksServerTool } from '@/lib/copilot/tools/server/blocks/get-trigger-blocks' - -describe('get trigger blocks', () => { - beforeEach(() => { - vi.clearAllMocks() - const blocks = [ - { type: 'start_trigger', category: 'triggers', subBlocks: [] }, - { type: 'slack', category: 'tools', triggerAllowed: true, subBlocks: [] }, - { type: 'notion', category: 'tools', triggerAllowed: true, subBlocks: [] }, - ] - mockGetAllBlocks.mockReturnValue(blocks) - mockGetBlock.mockImplementation((type: string) => blocks.find((block) => block.type === type)) - mockGetUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) - mockIsIntegrationDeploymentAvailable.mockReturnValue(true) - }) - - it('keeps the start trigger while filtering non-exempt integrations', async () => { - const result = await getTriggerBlocksServerTool.execute( - {}, - { userId: 'user-1', workspaceId: 'workspace-1' } - ) - - expect(result.triggerBlockIds).toEqual(['slack', 'start_trigger']) - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts b/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts deleted file mode 100644 index 389dbc334d2..00000000000 --- a/apps/sim/lib/copilot/tools/server/blocks/get-trigger-blocks.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { createLogger } from '@sim/logger' -import { z } from 'zod' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' -import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' -import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' -import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' -import { - intersectIntegrationAllowlists, - resolveAccessControlBlockType, -} from '@/lib/permission-groups/integration-allowlist' -import { getAllBlocks } from '@/blocks/registry' -import { overlayVisibility } from '@/blocks/visibility/context' - -export const GetTriggerBlocksInput = z.object({}) -export const GetTriggerBlocksResult = z.object({ - triggerBlockIds: z.array(z.string()), -}) - -export const getTriggerBlocksServerTool: BaseServerTool< - ReturnType, - ReturnType -> = { - name: 'get_trigger_blocks', - inputSchema: GetTriggerBlocksInput, - outputSchema: GetTriggerBlocksResult, - async execute(_args: unknown, context?: { userId: string; workspaceId?: string }) { - const logger = createLogger('GetTriggerBlocksServerTool') - logger.debug('Executing get_trigger_blocks') - - const permissionConfig = - context?.userId && context?.workspaceId - ? await resolvePermissionGroupConfig(context.userId, context.workspaceId, undefined) - : null - const allowedIntegrations = intersectIntegrationAllowlists( - permissionConfig?.allowedIntegrations ?? null, - getAllowedIntegrationsFromEnv() - ) - const visibility = overlayVisibility() - - const triggerBlockIds: string[] = [] - - for (const blockConfig of getAllBlocks()) { - const blockType = blockConfig.type - if (blockConfig.hideFromToolbar) continue - if (!isIntegrationDeploymentAvailableForVisibility(blockType, visibility)) continue - if ( - allowedIntegrations != null && - !isBlockTypeAccessControlExempt(blockType) && - !allowedIntegrations.includes(resolveAccessControlBlockType(blockType.toLowerCase())) - ) - continue - - if (blockConfig.category === 'triggers') { - triggerBlockIds.push(blockType) - } else if ('triggerAllowed' in blockConfig && blockConfig.triggerAllowed === true) { - triggerBlockIds.push(blockType) - } else if (blockConfig.subBlocks?.some((subBlock) => subBlock.mode === 'trigger')) { - triggerBlockIds.push(blockType) - } - } - - triggerBlockIds.sort() - - logger.debug(`Found ${triggerBlockIds.length} trigger blocks`) - return GetTriggerBlocksResult.parse({ triggerBlockIds }) - }, -} diff --git a/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts b/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts deleted file mode 100644 index 822059863c5..00000000000 --- a/apps/sim/lib/copilot/tools/server/enrichment/enrichment-run.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { createLogger } from '@sim/logger' -import { RunEnrichment } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { getEnrichment } from '@/enrichments/registry' -import { runEnrichment } from '@/enrichments/run' - -interface EnrichmentRunParams { - enrichmentId: string - inputs: Record -} - -interface EnrichmentRunResult { - matched: boolean - result: Record - provider: string | null - /** Hosted-key cost surfaced for per-round billing (omitted for BYOK / free). */ - _serviceCost?: { service: string; cost: number } -} - -/** - * Direct one-off enrichment lookup. Runs the same provider cascade as table - * enrichments (`runEnrichment`) for a single entity and returns the result - * inline — no table required. The hosted-key cost is surfaced as `_serviceCost` - * so copilot's per-round billing charges for it, matching how the media tools - * bill (see image/generate-image.ts). - */ -export const enrichmentRunServerTool: BaseServerTool = { - name: RunEnrichment.id, - async execute(params: EnrichmentRunParams, context): Promise { - const logger = createLogger('EnrichmentRunServerTool') - const { enrichmentId, inputs } = params - - if (!enrichmentId || typeof enrichmentId !== 'string') { - throw new Error('enrichmentId is required') - } - const workspaceId = context?.workspaceId - if (!workspaceId) { - throw new Error('workspaceId is required to run an enrichment') - } - const enrichment = getEnrichment(enrichmentId) - if (!enrichment) { - throw new Error(`Unknown enrichment "${enrichmentId}"`) - } - - const { result, cost, error, provider } = await runEnrichment(enrichment, inputs ?? {}, { - workspaceId, - userId: context?.userId ?? null, - signal: context?.abortSignal, - }) - - const matched = Object.keys(result).length > 0 - logger.info('Enrichment run', { enrichmentId, matched, provider, cost }) - - // A genuine "no match" returns normally (matched: false). Only surface an - // error when every provider that ran failed (infra/auth/rate-limit). - if (error && !matched) { - throw new Error(error) - } - - return { - matched, - result, - provider, - ...(cost > 0 ? { _serviceCost: { service: provider ?? enrichmentId, cost } } : {}), - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/files/create-file.ts b/apps/sim/lib/copilot/tools/server/files/create-file.ts deleted file mode 100644 index a09bdccf572..00000000000 --- a/apps/sim/lib/copilot/tools/server/files/create-file.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { createLogger } from '@sim/logger' -import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' -import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' -import { - assertServerToolNotAborted, - type BaseServerTool, - type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' -import { inferContentType } from '@/lib/copilot/tools/server/files/workspace-file' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { - createWorkspaceFileByPath, - updateWorkspaceFileContentByPath, -} from '@/lib/workspace-files/application/write-workspace-file-by-path' -import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' - -const logger = createLogger('CreateFileServerTool') -const CREATE_FILE_TOOL_ID = 'create_empty_file' - -interface CreateFileArgs { - fileName: string - contentType?: string - outputs?: { files?: Array<{ path: string; mode?: 'create' | 'overwrite'; mimeType?: string }> } - args?: Record -} - -interface CreateFileResult { - success: boolean - message: string - data?: { - id: string - name: string - contentType: string - vfsPath: string - } -} - -export const createFileServerTool: BaseServerTool = { - name: CREATE_FILE_TOOL_ID, - async execute(params: CreateFileArgs, context?: ServerToolContext): Promise { - if (!context?.userId) { - throw new Error('Authentication required') - } - const workspaceId = context.workspaceId - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - const nested = params.args - const fileName = params.fileName || (nested?.fileName as string) || '' - const explicitType = params.contentType || (nested?.contentType as string) || undefined - const outputFile = params.outputs?.files?.[0] - if (!outputFile?.path && !fileName) { - return { - success: false, - message: 'create_empty_file requires outputs.files[0].path or fileName', - } - } - const outputPath = - outputFile?.path ?? (fileName.startsWith('files/') ? fileName : `files/${fileName}`) - // .html defaults to plain text/html; a file is a Sim page only when the - // model DECLARES it (the skill passes contentType text/x-sim-page at - // creation) — or when the first apply_file_edit finds actual page - // source, which re-stamps the record from reality either way. - const contentType = outputFile?.mimeType ?? inferContentType(outputPath, explicitType) - // A Sim page's stored name drops the .html the agent signals format with: - // the record type carries the format, every surface then shows the bare - // name with the plain file icon, and downloads re-append the extension. - const storedPath = - contentType === SIM_PAGE_CONTENT_TYPE ? outputPath.replace(/\.html?$/i, '') : outputPath - assertServerToolNotAborted(context) - const mode = outputFile?.mode ?? 'create' - // An empty shell provably contains no secrets; recording that keeps the - // file model-readable (an absent sidecar reads as "unknown" and gates - // every later content view of the file). - const emptyProvenance = EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE - const createShell = () => - executeCopilotFileUseCase(context, createWorkspaceFileByPath, { - workspaceId, - path: storedPath, - mode: 'create', - content: '', - encoding: 'utf-8', - contentType, - exactName: true, - secretProvenance: emptyProvenance, - }) - try { - let result - if (mode === 'overwrite') { - try { - result = await executeCopilotFileUseCase(context, updateWorkspaceFileContentByPath, { - workspaceId, - path: storedPath, - mode, - content: '', - encoding: 'utf-8', - contentType, - syncLiveDoc: false, - secretProvenance: emptyProvenance, - }) - } catch (overwriteError) { - // Upsert: overwrite of a missing path falls through to create. - if (asOrchestrationError(overwriteError)?.code !== 'not_found') throw overwriteError - result = await createShell() - } - } else { - result = await createShell() - } - - logger.info('File created via create_empty_file', { - fileId: result.id, - name: result.vfsPath, - contentType, - userId: context.userId, - }) - - return { - success: true, - message: `File "${result.vfsPath}" created successfully`, - data: { - id: result.id, - name: result.name, - contentType, - vfsPath: result.vfsPath, - }, - } - } catch (error) { - return { success: false, message: messageForCopilotFileError(error, 'Failed to create file') } - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/files/doc-asset-extract-pdf.ts b/apps/sim/lib/copilot/tools/server/files/doc-asset-extract-pdf.ts deleted file mode 100644 index dc2000a2fd1..00000000000 --- a/apps/sim/lib/copilot/tools/server/files/doc-asset-extract-pdf.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { CodeLanguage } from '@/lib/execution/languages' -import { executeInSandbox } from '@/lib/execution/remote-sandbox' - -const EXTRACT_TIMEOUT_MS = 180_000 - -/** - * PDF asset extraction runs in the doc sandbox — the same vetted image that - * compiles and renders documents — because the PDF toolchain lives there: - * poppler's `pdfimages` dumps every embedded image in its native format, - * pdfplumber supplies each image's placement rects in page points plus the - * document's font families, and pdftoppm+Pillow sample a dominant-color - * palette from rendered pages. - * - * A transparent image in a PDF is stored as an opaque base image plus a - * separate alpha mask (`/SMask`), so the base alone carries a baked-in solid - * background. The script pairs each mask with its base via `pdfimages -list` - * row adjacency and recomposites them into an RGBA PNG; masks are never - * shipped as standalone assets. - * - * Assets are shipped at presentation resolution: print-dpi originals are - * downscaled to a 2560px long edge (alpha preserved) and exotic formats - * (TIFF/JP2) are normalized to PNG/JPEG, so the extracted set stays inside - * the doc-compile staging budget and re-stages fast on every slide edit. - * Images already within range keep their original bytes. - * - * Unlike OOXML there is no declared theme in a PDF, so the palette is - * explicitly labeled inferred; fonts are names only (embedded font files are - * subsetted and license-restricted). - */ - -export interface PdfImagePlacement { - page: number - xPt: number - yPt: number - wPt: number - hPt: number -} - -export interface ExtractedPdfTheme { - format: 'pdf' - /** Font family names used in the document (style suffixes split off). */ - fonts: string[] - pageSize?: { widthPt: number; heightPt: number } - pageCount: number - /** Dominant colors sampled from rendered pages — inferred, not declared. */ - inferredPalette: string[] - /** Per-asset intrinsic size and where each instance sits on its pages. */ - images: Record -} - -export interface ExtractedPdfMedia { - name: string - bytes: Buffer -} - -export interface PdfTextBlock { - text: string - xPt: number - yPt: number - wPt: number - hPt: number - /** Font family name, with the PostScript style suffix split off. */ - font: string - sizePt: number - colorHex: string | null - bold?: boolean - italic?: boolean -} - -export interface PdfFilledRect { - xPt: number - yPt: number - wPt: number - hPt: number - colorHex: string | null -} - -export interface PdfOverlay { - imageAt: { xPt: number; yPt: number } - colorHex: string | null - coverage: number -} - -/** An extracted asset's placement on a page, by its written filename. */ -export interface PdfPlacedImage { - name: string - xPt: number - yPt: number - wPt: number - hPt: number -} - -/** One page's rebuild recipe: what sits where, in which font and color. */ -export interface PdfPageLayout { - page: number - texts: PdfTextBlock[] - rects: PdfFilledRect[] - overlays: PdfOverlay[] - images: PdfPlacedImage[] -} - -export interface ExtractedPdfAssets { - theme: ExtractedPdfTheme - media: ExtractedPdfMedia[] - layout: PdfPageLayout[] -} - -const SCRIPT = ` -import subprocess, glob, json, base64, os, re -import pdfplumber -from PIL import Image - -inp = "/home/user/input.pdf" -outdir = "/home/user/assets" -os.makedirs(outdir, exist_ok=True) - -# Embedded images, native formats. No -p flag so filenames are asset-NNN.ext -# with NNN matching the -list "num" column exactly. -subprocess.run(["pdfimages", "-all", inp, outdir + "/asset"], - check=True, timeout=120, capture_output=True) -listing = subprocess.run(["pdfimages", "-list", inp], - check=True, timeout=60, capture_output=True, text=True).stdout -rows = {} -alpha_of = {} -prev_image = None -for line in listing.splitlines()[2:]: - parts = line.split() - if len(parts) < 5: - continue - try: - page, num, typ, w, h = int(parts[0]), int(parts[1]), parts[2], int(parts[3]), int(parts[4]) - except ValueError: - continue - if typ == "image": - rows[num] = {"page": page, "width": w, "height": h} - prev_image = num - elif typ in ("smask", "mask") and prev_image is not None: - # poppler lists an image's alpha mask on the row directly after it. - # An smask's luminance IS the alpha; an explicit /Mask paints only - # where the sample is 0, hence the inversion downstream. - alpha_of[prev_image] = {"num": num, "invert": typ == "mask"} - prev_image = None - else: - prev_image = None - -paths = {} -for path in sorted(glob.glob(outdir + "/asset-*")): - m = re.search(r"asset-(\\d+)\\.(\\w+)$", path) - if m: - paths[int(m.group(1))] = path -files = {num: path for num, path in paths.items() if num in rows} - -# A transparent source image arrives as an opaque base plus a separate mask; -# shipping the base alone would bake in a solid background. Recomposite the -# pair into an RGBA PNG (the mask may be stored at a different resolution). -for num, ref in alpha_of.items(): - base_path, mask_path = files.get(num), paths.get(ref["num"]) - if not base_path or not mask_path: - continue - try: - base = Image.open(base_path).convert("RGB") - mask = Image.open(mask_path).convert("L") - if ref["invert"]: - mask = Image.eval(mask, lambda v: 255 - v) - if mask.size != base.size: - mask = mask.resize(base.size, Image.BILINEAR) - base.putalpha(mask) - out = base_path.rsplit(".", 1)[0] + ".png" - base.save(out, "PNG") - if out != base_path: - os.remove(base_path) - files[num] = out - except Exception: - pass # undecodable mask: the opaque base still beats losing the asset - -# Downscale print-resolution assets and normalize exotic formats. Embedded PDF -# images are often 300-dpi originals several MB each, a slide never shows more -# than ~2560px on the long edge, and every deck compile re-stages the whole -# referenced set — so oversized extractions slow every edit and blow the byte -# staging budget. Images already within range keep their original bytes. -MAX_ASSET_EDGE = 2560 -shipped_dims = {} -for num, path in list(files.items()): - try: - im = Image.open(path) - im.load() - except Exception: - continue # undecodable (jbig2/ccitt params etc.): ship as-is - ext = path.rsplit(".", 1)[1].lower() - exotic = ext not in ("png", "jpg", "jpeg") - w, h = im.size - scale = MAX_ASSET_EDGE / float(max(w, h)) - if scale >= 1 and not exotic: - continue - if scale < 1: - im = im.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS) - has_alpha = im.mode in ("RGBA", "LA", "PA") or (im.mode == "P" and "transparency" in im.info) - try: - if ext in ("jpg", "jpeg") or (exotic and not has_alpha): - out = path.rsplit(".", 1)[0] + ".jpg" - im.convert("RGB").save(out, "JPEG", quality=85, optimize=True) - else: - out = path.rsplit(".", 1)[0] + ".png" - im.convert("RGBA" if has_alpha else "RGB").save(out, "PNG", optimize=True) - except Exception: - continue # unencodable: keep the original bytes - if out != path: - os.remove(path) - files[num] = out - shipped_dims[num] = im.size - -def to_hex(color): - if color is None: - return None - vals = list(color) if isinstance(color, (tuple, list)) else [color] - try: - if len(vals) == 1: - r = g = b = float(vals[0]) - elif len(vals) == 3: - r, g, b = (float(v) for v in vals) - elif len(vals) == 4: - c, m, y, k = (float(v) for v in vals) - r, g, b = (1 - c) * (1 - k), (1 - m) * (1 - k), (1 - y) * (1 - k) - else: - return None - except (TypeError, ValueError): - return None - f = lambda v: max(0, min(255, int(round(v * 255)))) - return "%02X%02X%02X" % (f(r), f(g), f(b)) - -STYLE_TOKENS = {"bold", "black", "heavy", "light", "medium", "thin", "italic", - "oblique", "semibold", "demibold", "extrabold", "ultrabold", - "semi", "demi", "extra", "ultra", "condensed", "cond"} - -def parse_font_name(name): - # PostScript names pack family and style together ("Arial-BoldMT", - # "MyriadPro-Semibold"). Split them so the rebuild can set a real family - # plus bold/italic flags instead of asking PowerPoint for a face that - # does not exist (which silently falls back to a regular weight). - base = re.sub(r"^[A-Z]{6}\\+", "", name or "") - parts = re.split(r"[-,_]", base, maxsplit=1) - probe = parts[1] if len(parts) > 1 else base - bold = re.search(r"(?i)bold|black|heavy|demi", probe) is not None - italic = re.search(r"(?i)italic|oblique", probe) is not None - family = re.sub(r"(?:PS|MT|PSMT)$", "", parts[0]) - family = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", family) - words = family.split() - while len(words) > 1 and words[-1].lower() in STYLE_TOKENS: - words.pop() - return " ".join(words), bold, italic - -fonts = set() -placements = [] -page_size = None -layout = [] -with pdfplumber.open(inp) as pdf: - page_count = len(pdf.pages) - if pdf.pages: - p0 = pdf.pages[0] - page_size = {"widthPt": round(float(p0.width), 2), "heightPt": round(float(p0.height), 2)} - for pi, page in enumerate(pdf.pages[:100], start=1): - char_color = {} - for ch in page.chars[:8000]: - name = ch.get("fontname") or "" - if name: - family = parse_font_name(name)[0] - if family: - fonts.add(family) - char_color[(round(ch["x0"], 1), round(ch["top"], 1))] = ch.get("non_stroking_color") - page_images = [] - for im in page.images: - src = im.get("srcsize") or (0, 0) - entry = { - "page": pi, - "xPt": round(float(im["x0"]), 2), - "yPt": round(float(im["top"]), 2), - "wPt": round(float(im["x1"] - im["x0"]), 2), - "hPt": round(float(im["bottom"] - im["top"]), 2), - "srcW": int(src[0] or 0), - "srcH": int(src[1] or 0), - } - placements.append(entry) - page_images.append(entry) - - # Text blocks: words grouped into lines, each line carrying its font, - # size, and fill color — the recipe for what text sits where. - words = page.extract_words(extra_attrs=["fontname", "size"])[:800] - lines = {} - for w in words: - lines.setdefault(round(w["top"] / 2), []).append(w) - texts = [] - for key in sorted(lines): - ws = sorted(lines[key], key=lambda w: w["x0"]) - # Side-by-side text boxes share a baseline; a gap much wider than - # a space means a new box, not a continuation of the same line. - runs = [[ws[0]]] - for w in ws[1:]: - prev = runs[-1][-1] - gap = float(w["x0"]) - float(prev["x1"]) - if gap > max(2.5 * float(prev.get("size") or 10), 18): - runs.append([w]) - else: - runs[-1].append(w) - for run in runs: - first = run[0] - color = to_hex(char_color.get((round(first["x0"], 1), round(first["top"], 1)))) - family, bold, italic = parse_font_name(first.get("fontname") or "") - entry = { - "text": " ".join(w["text"] for w in run)[:400], - "xPt": round(float(first["x0"]), 2), - "yPt": round(float(first["top"]), 2), - "wPt": round(float(max(w["x1"] for w in run) - first["x0"]), 2), - "hPt": round(float(max(w["bottom"] for w in run) - first["top"]), 2), - "font": family, - "sizePt": round(float(first.get("size") or 0), 1), - "colorHex": color, - } - if bold: - entry["bold"] = True - if italic: - entry["italic"] = True - texts.append(entry) - texts = texts[:80] - - # Filled rects: backgrounds and the scrims decks lay over photos. - rects = [] - for r in page.rects[:80]: - if not r.get("fill"): - continue - rects.append({ - "xPt": round(float(r["x0"]), 2), - "yPt": round(float(r["top"]), 2), - "wPt": round(float(r["x1"] - r["x0"]), 2), - "hPt": round(float(r["bottom"] - r["top"]), 2), - "colorHex": to_hex(r.get("non_stroking_color")), - }) - rects = rects[:40] - - # A rect covering most of an image is an overlay scrim — the "image - # opacity" effect. Alpha is not recoverable from the stream, so the - # renderer's page image is the reference for how strong it looks. - overlays = [] - for r in rects: - for im in page_images: - ix0, iy0 = im["xPt"], im["yPt"] - ix1, iy1 = ix0 + im["wPt"], iy0 + im["hPt"] - rx0, ry0 = r["xPt"], r["yPt"] - rx1, ry1 = rx0 + r["wPt"], ry0 + r["hPt"] - inter = max(0, min(ix1, rx1) - max(ix0, rx0)) * max(0, min(iy1, ry1) - max(iy0, ry0)) - area = im["wPt"] * im["hPt"] - if area > 0 and inter / area >= 0.5: - overlays.append({ - "imageAt": {"xPt": ix0, "yPt": iy0}, - "colorHex": r["colorHex"], - "coverage": round(inter / area, 2), - }) - layout.append({"page": pi, "texts": texts, "rects": rects, "overlays": overlays}) - -# Inferred palette: quantized dominant colors over up to 3 rendered pages. -palette = [] -try: - subprocess.run(["pdftoppm", "-jpeg", "-r", "50", "-f", "1", "-l", "3", inp, "/home/user/pal"], - check=True, timeout=60, capture_output=True) - counts = {} - for p in glob.glob("/home/user/pal*.jpg"): - im = Image.open(p).convert("RGB").resize((120, 120)) - for c, rgb in im.getcolors(120 * 120) or []: - q = tuple(v // 32 * 32 for v in rgb) - counts[q] = counts.get(q, 0) + c - top = sorted(counts.items(), key=lambda kv: -kv[1])[:10] - palette = ["%02X%02X%02X" % k for k, _ in top] -except Exception: - palette = [] - -MAX_FILE = 15 * 1024 * 1024 -MAX_TOTAL = 60 * 1024 * 1024 -total = 0 -images = [] -for num, path in sorted(files.items()): - size = os.path.getsize(path) - if size == 0 or size > MAX_FILE or total + size > MAX_TOTAL: - continue - total += size - row = rows[num] - ext = path.rsplit(".", 1)[1] - pls = [ - {k: p[k] for k in ("page", "xPt", "yPt", "wPt", "hPt")} - for p in placements - if p["page"] == row["page"] and ( - (p["srcW"] == row["width"] and p["srcH"] == row["height"]) or not p["srcW"] - ) - ] - with open(path, "rb") as f: - data = base64.b64encode(f.read()).decode() - dims = shipped_dims.get(num) - images.append({ - "name": "image%d.%s" % (num, ext), - "widthPx": dims[0] if dims else row["width"], - "heightPx": dims[1] if dims else row["height"], - "placements": pls, - "base64": data, - }) - -print("__SIM_RESULT__=" + json.dumps({ - "fonts": sorted(f for f in fonts if f), - "pageSize": page_size, - "pageCount": page_count, - "inferredPalette": palette, - "images": images, - "layout": layout, -})) -`.trim() - -interface SandboxPdfImage { - name: string - widthPx: number - heightPx: number - placements: PdfImagePlacement[] - base64: string -} - -interface SandboxPdfResult { - fonts?: string[] - pageSize?: { widthPt: number; heightPt: number } | null - pageCount?: number - inferredPalette?: string[] - images?: SandboxPdfImage[] - layout?: Array> -} - -export async function extractPdfAssets(binary: Buffer): Promise { - const result = await executeInSandbox({ - code: SCRIPT, - language: CodeLanguage.Python, - timeoutMs: EXTRACT_TIMEOUT_MS, - sandboxKind: 'doc', - sandboxFiles: [ - { path: '/home/user/input.pdf', content: binary.toString('base64'), encoding: 'base64' }, - ], - }) - if (result.error) { - throw new Error(`PDF asset extraction failed: ${result.error}`) - } - const payload = (result.result ?? {}) as SandboxPdfResult - const images = payload.images ?? [] - const theme: ExtractedPdfTheme = { - format: 'pdf', - fonts: payload.fonts ?? [], - pageSize: payload.pageSize ?? undefined, - pageCount: payload.pageCount ?? 0, - inferredPalette: payload.inferredPalette ?? [], - images: Object.fromEntries( - images.map((image) => [ - image.name, - { widthPx: image.widthPx, heightPx: image.heightPx, placements: image.placements }, - ]) - ), - } - // The sandbox reports placements per asset (asset → pages); the rebuild - // recipe reads per page, so join each asset's rects onto its page entries. - const layout = (payload.layout ?? []).map((page) => ({ - ...page, - images: images.flatMap((image) => - image.placements - .filter((placement) => placement.page === page.page) - .map(({ xPt, yPt, wPt, hPt }) => ({ name: image.name, xPt, yPt, wPt, hPt })) - ), - })) - return { - theme, - media: images.map((image) => ({ - name: image.name, - bytes: Buffer.from(image.base64, 'base64'), - })), - layout, - } -} diff --git a/apps/sim/lib/copilot/tools/server/files/doc-asset-extract.test.ts b/apps/sim/lib/copilot/tools/server/files/doc-asset-extract.test.ts deleted file mode 100644 index 2ee3c23581c..00000000000 --- a/apps/sim/lib/copilot/tools/server/files/doc-asset-extract.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * @vitest-environment node - */ -import JSZip from 'jszip' -import { describe, expect, it } from 'vitest' -import { extractDocAssets } from '@/lib/copilot/tools/server/files/doc-asset-extract' -import { MAX_OOXML_CENTRAL_DIRECTORY_RECORDS, ZipBombError } from '@/lib/file-parsers/ooxml-limits' - -const THEME_XML = ` - - - - - - - - - - - - - - - - - - - - - -` - -const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]) -const JPG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 9, 8, 7]) - -async function buildPptx(): Promise { - const zip = new JSZip() - zip.file('ppt/theme/theme1.xml', THEME_XML) - zip.file( - 'ppt/presentation.xml', - '' - ) - zip.file('ppt/media/image1.png', PNG_BYTES) - zip.file('ppt/media/image2.jpeg', JPG_BYTES) - zip.file('ppt/slides/slide1.xml', '') - return zip.generateAsync({ type: 'nodebuffer' }) -} - -describe('extractDocAssets', () => { - it('extracts theme colors, fonts, slide size, and media from a pptx', async () => { - const { theme, media } = await extractDocAssets(await buildPptx(), 'pptx') - expect(theme.format).toBe('pptx') - expect(theme.colors).toMatchObject({ - dk1: '000000', - lt1: 'FFFFFF', - dk2: '1F2937', - accent1: '4F81BD', - accent6: 'F79646', - hlink: '0000FF', - }) - expect(theme.fonts).toEqual({ major: 'Calibri Light', minor: 'Calibri' }) - expect(theme.slideSize).toEqual({ widthIn: 13.33, heightIn: 7.5 }) - expect(media.map((m) => m.name)).toEqual(['image1.png', 'image2.jpeg']) - expect(media[0]?.bytes.equals(PNG_BYTES)).toBe(true) - expect(media[1]?.bytes.equals(JPG_BYTES)).toBe(true) - }) - - it('extracts from a docx under the word/ prefix without a slide size', async () => { - const zip = new JSZip() - zip.file('word/theme/theme1.xml', THEME_XML) - zip.file('word/media/image1.png', PNG_BYTES) - zip.file('word/document.xml', '') - const { theme, media } = await extractDocAssets( - await zip.generateAsync({ type: 'nodebuffer' }), - 'docx' - ) - expect(theme.format).toBe('docx') - expect(theme.colors.accent1).toBe('4F81BD') - expect(theme.slideSize).toBeUndefined() - expect(media.map((m) => m.name)).toEqual(['image1.png']) - }) - - it('extracts slide text/image layout, inheriting placeholder frames from the slide layout', async () => { - const relNs = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships' - const zip = new JSZip() - zip.file('ppt/theme/theme1.xml', THEME_XML) - zip.file('ppt/media/image1.png', PNG_BYTES) - zip.file( - 'ppt/slides/slide1.xml', - ` - -Q3 & Q4 Results - -First lineSecond line -grouped - -` - ) - zip.file( - 'ppt/slides/_rels/slide1.xml.rels', - `` - ) - zip.file( - 'ppt/slideLayouts/slideLayout1.xml', - ` -Click to edit -` - ) - const { theme, layout } = await extractDocAssets( - await zip.generateAsync({ type: 'nodebuffer' }), - 'pptx' - ) - expect(layout).toHaveLength(1) - const slide = layout[0] - expect(slide.slide).toBe(1) - expect(slide.images).toEqual([{ name: 'image1.png', xIn: 2, yIn: 1, wIn: 2, hIn: 2 }]) - expect(theme.images?.['image1.png']?.placements).toEqual([ - { slide: 1, xIn: 2, yIn: 1, wIn: 2, hIn: 2 }, - ]) - expect(slide.texts).toHaveLength(2) - expect(slide.texts[0]).toMatchObject({ - text: 'Q3 & Q4 Results', - xIn: 1, - yIn: 0.5, - wIn: 12, - hIn: 1.25, - font: 'major', - sizePt: 36, - bold: true, - schemeColor: 'accent1', - }) - expect(slide.texts[0].colorHex).toBeUndefined() - expect(slide.texts[1]).toMatchObject({ - text: 'First line\nSecond line', - xIn: 1, - yIn: 2, - wIn: 5, - hIn: 1, - font: 'Georgia', - sizePt: 14, - colorHex: '112233', - }) - expect(slide.texts[1].bold).toBeUndefined() - expect(slide.texts.some((t) => t.text.includes('grouped'))).toBe(false) - }) - - it('refuses an archive the OOXML guard rejects', async () => { - // Every media entry is inflated into a retained Buffer with no cap of its - // own, so the guard is the only thing bounding this. Tripping its - // record-count ceiling asserts the call site is guarded without building a - // multi-megabyte fixture; the size ceilings are covered in zip-guard.test.ts. - const zip = new JSZip() - for (let index = 0; index <= MAX_OOXML_CENTRAL_DIRECTORY_RECORDS; index++) { - zip.file(`ppt/media/image${index}.png`, PNG_BYTES) - } - - await expect( - extractDocAssets(await zip.generateAsync({ type: 'nodebuffer' }), 'pptx') - ).rejects.toThrow(ZipBombError) - }) - - it('tolerates a package with no theme or media', async () => { - const zip = new JSZip() - zip.file('ppt/slides/slide1.xml', '') - const { theme, media } = await extractDocAssets( - await zip.generateAsync({ type: 'nodebuffer' }), - 'pptx' - ) - expect(theme.colors).toEqual({}) - expect(media).toEqual([]) - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/files/doc-asset-extract.ts b/apps/sim/lib/copilot/tools/server/files/doc-asset-extract.ts deleted file mode 100644 index d3564c30aa9..00000000000 --- a/apps/sim/lib/copilot/tools/server/files/doc-asset-extract.ts +++ /dev/null @@ -1,424 +0,0 @@ -import JSZip from 'jszip' -import { assertOoxmlArchiveWithinLimits } from '@/lib/file-parsers/zip-guard' - -/** - * Pulls the reusable design material out of an OOXML document (.pptx/.docx): - * the theme (color scheme, font scheme, slide size), every embedded media - * file byte-identical, and — for pptx — a slide-by-slide layout of text - * blocks (content, frame, font, size, color) and placed images. OOXML - * packages are ZIP archives with fixed part names, so extraction is direct: - * `ppt|word/theme/theme1.xml` for the theme, `ppt|word/media/*` for assets, - * `ppt/presentation.xml` for slide size, and `ppt/slides/slideN.xml` (plus - * each slide's layout/master chain for inherited placeholder frames) for the - * layout. Read-only over the source bytes. - */ - -/** OOXML theme color slots, in scheme order. */ -const THEME_COLOR_SLOTS = [ - 'dk1', - 'lt1', - 'dk2', - 'lt2', - 'accent1', - 'accent2', - 'accent3', - 'accent4', - 'accent5', - 'accent6', - 'hlink', - 'folHlink', -] as const - -const EMU_PER_INCH = 914400 -const MAX_TEXTS_PER_SLIDE = 80 -const MAX_TEXT_CHARS = 400 - -export interface DocImagePlacement { - slide: number - xIn: number - yIn: number - wIn: number - hIn: number -} - -/** An asset's placement on one slide, by its media basename. */ -export interface PptxPlacedImage { - name: string - xIn: number - yIn: number - wIn: number - hIn: number -} - -export interface PptxTextBlock { - text: string - xIn: number - yIn: number - wIn: number - hIn: number - /** Typeface name, or "major"/"minor" when the run uses a theme font slot. */ - font?: string - sizePt?: number - bold?: boolean - italic?: boolean - /** Literal run color. Absent when the run uses a theme slot instead. */ - colorHex?: string - /** Theme color slot name (e.g. "accent1") — resolve via theme.json colors. */ - schemeColor?: string -} - -/** One slide's rebuild recipe: what sits where, in which font and color. */ -export interface PptxSlideLayout { - slide: number - texts: PptxTextBlock[] - images: PptxPlacedImage[] -} - -export interface ExtractedDocTheme { - format: 'pptx' | 'docx' - /** Slot → 6-digit uppercase hex, no leading '#'. Only slots the theme defines. */ - colors: Record - fonts: { major?: string; minor?: string } - /** Slide dimensions in inches (pptx only). */ - slideSize?: { widthIn: number; heightIn: number } - slideCount?: number - /** Per-asset slide-by-slide placements in inches (pptx only). */ - images?: Record -} - -export interface ExtractedDocMedia { - /** Basename inside the package, e.g. "image1.png". */ - name: string - bytes: Buffer -} - -export interface ExtractedDocAssets { - theme: ExtractedDocTheme - media: ExtractedDocMedia[] - /** Slide-by-slide rebuild recipe (pptx only; empty for docx). */ - layout: PptxSlideLayout[] -} - -/** - * A slot's color is either a literal `` or a system - * color carrying its resolved value in `lastClr`. - */ -function parseSlotColor(themeXml: string, slot: string): string | undefined { - const block = themeXml.match(new RegExp(`([\\s\\S]*?)`))?.[1] - if (!block) return undefined - const hex = - block.match(/([\\s\\S]*?)`))?.[1] - const typeface = block?.match(/ Number((Number(emu) / EMU_PER_INCH).toFixed(2)) - -interface ShapeFrame { - xIn: number - yIn: number - wIn: number - hIn: number -} - -/** - * Placeholder frame lookup falls back across interchangeable ph types: a - * slide's "title" inherits from a layout's centered title and vice versa. - */ -const PH_TYPE_ALIASES: Record = { - title: ['title', 'ctrTitle'], - ctrTitle: ['ctrTitle', 'title'], - body: ['body', 'subTitle'], - subTitle: ['subTitle', 'body'], -} - -const XML_ENTITIES: Record = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - ''': "'", -} - -function decodeXml(value: string): string { - return value.replace(/&(?:amp|lt|gt|quot|apos|#x[0-9A-Fa-f]+|#\d+);/g, (entity) => { - const named = XML_ENTITIES[entity] - if (named) return named - const code = entity.startsWith('&#x') - ? Number.parseInt(entity.slice(3, -1), 16) - : Number.parseInt(entity.slice(2, -1), 10) - return code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : entity - }) -} - -function parseFrame(block: string): ShapeFrame | null { - const off = block.match(/]*)>/)?.[1] - if (attrs === undefined) return null - return { idx: attrs.match(/ idx="(\d+)"/)?.[1], type: attrs.match(/ type="([^"]+)"/)?.[1] } -} - -/** - * Drops grouped shapes from a slide/layout XML. Group members carry - * group-relative coordinates that need the full chOff/chExt transform to - * place absolutely; omitting them beats emitting frames at wrong positions. - * Balanced scan rather than a regex so nested groups drop cleanly. - */ -function stripGroups(xml: string): string { - let out = '' - let depth = 0 - let last = 0 - for (const tag of xml.matchAll(/<\/?p:grpSp>/g)) { - const at = tag.index ?? 0 - if (depth === 0 && tag[0] === '') out += xml.slice(last, at) - depth = tag[0] === '' ? depth + 1 : Math.max(0, depth - 1) - if (depth === 0) last = at + tag[0].length - } - return depth === 0 ? out + xml.slice(last) : out -} - -/** Frames of a layout/master's placeholder shapes, keyed by idx and type. */ -function collectPlaceholderFrames(xml: string): Map { - const frames = new Map() - for (const sp of stripGroups(xml).matchAll(/[\s\S]*?<\/p:sp>/g)) { - const ph = parsePlaceholder(sp[0]) - if (!ph) continue - const frame = parseFrame(sp[0]) - if (!frame) continue - if (ph.idx !== undefined && !frames.has(`idx:${ph.idx}`)) frames.set(`idx:${ph.idx}`, frame) - if (ph.type !== undefined && !frames.has(`type:${ph.type}`)) - frames.set(`type:${ph.type}`, frame) - } - return frames -} - -/** Resolves a rels Target like "../slideLayouts/slideLayout1.xml" to a zip path. */ -function resolveZipPath(baseDir: string, target: string): string { - const resolved: string[] = [] - for (const part of `${baseDir}/${target}`.split('/')) { - if (part === '..') resolved.pop() - else if (part !== '.' && part !== '') resolved.push(part) - } - return resolved.join('/') -} - -function matchRelTarget(relsXml: string, typeSuffix: string, baseDir: string): string | undefined { - for (const rel of relsXml.matchAll(/]*>/g)) { - const type = rel[0].match(/ Type="([^"]+)"/)?.[1] - const target = rel[0].match(/ Target="([^"]+)"/)?.[1] - if (target && type?.endsWith(`/${typeSuffix}`)) return resolveZipPath(baseDir, target) - } - return undefined -} - -function relsPathFor(partPath: string): string { - const cut = partPath.lastIndexOf('/') - return `${partPath.slice(0, cut)}/_rels/${partPath.slice(cut + 1)}.rels` -} - -/** - * A slide shape without its own xfrm inherits its frame from the matching - * placeholder in the slide's layout, then the layout's master. Parsed frames - * are cached per layout part since slides share layouts. - */ -async function inheritedFramesForSlide( - zip: JSZip, - slideRelsXml: string, - cache: Map> -): Promise> { - const layoutPath = matchRelTarget(slideRelsXml, 'slideLayout', 'ppt/slides') - if (!layoutPath) return new Map() - const cached = cache.get(layoutPath) - if (cached) return cached - const layoutXml = (await zip.file(layoutPath)?.async('string')) ?? '' - const frames = collectPlaceholderFrames(layoutXml) - const layoutRelsXml = await zip.file(relsPathFor(layoutPath))?.async('string') - const layoutDir = layoutPath.slice(0, layoutPath.lastIndexOf('/')) - const masterPath = layoutRelsXml - ? matchRelTarget(layoutRelsXml, 'slideMaster', layoutDir) - : undefined - if (masterPath) { - const masterXml = (await zip.file(masterPath)?.async('string')) ?? '' - for (const [key, frame] of collectPlaceholderFrames(masterXml)) { - if (!frames.has(key)) frames.set(key, frame) - } - } - cache.set(layoutPath, frames) - return frames -} - -function lookupPlaceholderFrame( - ph: { idx?: string; type?: string }, - frames: Map -): ShapeFrame | null { - if (ph.idx !== undefined) { - const byIdx = frames.get(`idx:${ph.idx}`) - if (byIdx) return byIdx - } - for (const type of ph.type ? (PH_TYPE_ALIASES[ph.type] ?? [ph.type]) : []) { - const byType = frames.get(`type:${type}`) - if (byType) return byType - } - return null -} - -function collectSlideTexts( - slideXml: string, - inheritedFrames: Map -): PptxTextBlock[] { - const texts: PptxTextBlock[] = [] - for (const sp of stripGroups(slideXml).matchAll(/[\s\S]*?<\/p:sp>/g)) { - if (texts.length >= MAX_TEXTS_PER_SLIDE) break - const txBody = sp[0].match(/([\s\S]*?)<\/p:txBody>/)?.[1] - if (!txBody) continue - const text = [...txBody.matchAll(/([\s\S]*?)<\/a:p>/g)] - .map((para) => - [...para[1].matchAll(/([^<]*)<\/a:t>/g)].map((t) => decodeXml(t[1])).join('') - ) - .filter((line) => line.trim().length > 0) - .join('\n') - .slice(0, MAX_TEXT_CHARS) - if (!text) continue - const ph = parsePlaceholder(sp[0]) - const frame = parseFrame(sp[0]) ?? (ph ? lookupPlaceholderFrame(ph, inheritedFrames) : null) - if (!frame) continue - // Style is read from the shape's first styled run — the way decks are - // authored (one style per box) — without resolving list/master styles. - const rPrAttrs = txBody.match(/]*)>/)?.[1] ?? '' - const sz = rPrAttrs.match(/ sz="(\d+)"/)?.[1] - const typeface = txBody.match(// - * frame carries its offset/extent in EMU (or inherits it via placeholder). - */ -async function collectSlideLayouts(zip: JSZip): Promise<{ - slideCount: number - images: Record - slides: PptxSlideLayout[] -}> { - const images: Record = {} - const slides: PptxSlideLayout[] = [] - const placeholderFrameCache = new Map>() - const slideNames = Object.keys(zip.files) - .map((name) => name.match(/^ppt\/slides\/slide(\d+)\.xml$/)) - .filter((m): m is RegExpMatchArray => m !== null) - .sort((a, b) => Number(a[1]) - Number(b[1])) - for (const match of slideNames) { - const slide = Number(match[1]) - const slideXml = (await zip.file(match[0])?.async('string')) ?? '' - const relsXml = await zip.file(`ppt/slides/_rels/slide${slide}.xml.rels`)?.async('string') - const relToMedia = new Map() - if (relsXml) { - for (const rel of relsXml.matchAll(/]*>/g)) { - const id = rel[0].match(/ Id="([^"]+)"/)?.[1] - const target = rel[0].match(/ Target="([^"]+)"/)?.[1] - if (id && target?.includes('/media/')) { - relToMedia.set(id, target.slice(target.lastIndexOf('/') + 1)) - } - } - } - const placed: PptxPlacedImage[] = [] - for (const pic of slideXml.matchAll(/[\s\S]*?<\/p:pic>/g)) { - const embed = pic[0].match(/r:embed="([^"]+)"/)?.[1] - const name = embed ? relToMedia.get(embed) : undefined - if (!name) continue - const frame = parseFrame(pic[0]) - if (!frame) continue - images[name] ??= { placements: [] } - images[name].placements.push({ slide, ...frame }) - placed.push({ name, ...frame }) - } - const inheritedFrames = relsXml - ? await inheritedFramesForSlide(zip, relsXml, placeholderFrameCache) - : new Map() - slides.push({ slide, texts: collectSlideTexts(slideXml, inheritedFrames), images: placed }) - } - return { slideCount: slideNames.length, images, slides } -} - -export async function extractDocAssets( - binary: Buffer, - format: 'pptx' | 'docx' -): Promise { - // The media loop below inflates every entry into a retained Buffer, so an - // attacker-supplied archive has to be bounded from its central directory first — - // the same guard `extractDocumentStyle` and the document parsers already apply. - assertOoxmlArchiveWithinLimits(binary) - - const zip = await JSZip.loadAsync(binary) - const prefix = format === 'pptx' ? 'ppt' : 'word' - - const themeXml = await zip.file(`${prefix}/theme/theme1.xml`)?.async('string') - const colors: Record = {} - for (const slot of THEME_COLOR_SLOTS) { - const hex = themeXml ? parseSlotColor(themeXml, slot) : undefined - if (hex) colors[slot] = hex - } - const theme: ExtractedDocTheme = { - format, - colors, - fonts: { - major: themeXml ? parseFont(themeXml, 'majorFont') : undefined, - minor: themeXml ? parseFont(themeXml, 'minorFont') : undefined, - }, - } - - let layout: PptxSlideLayout[] = [] - if (format === 'pptx') { - const presentation = await zip.file('ppt/presentation.xml')?.async('string') - const size = presentation?.match(/ 0) theme.images = images - layout = slides - } - - const mediaPrefix = `${prefix}/media/` - const media: ExtractedDocMedia[] = [] - for (const [entryName, entry] of Object.entries(zip.files)) { - if (entry.dir || !entryName.startsWith(mediaPrefix)) continue - const name = entryName.slice(mediaPrefix.length) - if (!name || name.includes('/')) continue - media.push({ name, bytes: await entry.async('nodebuffer') }) - } - media.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })) - - return { theme, media, layout } -} diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts deleted file mode 100644 index 5f949e3e978..00000000000 --- a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { z } from 'zod' -import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' -import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' -import { DownloadFile } from '@/lib/copilot/generated/tool-catalog-v1' -import { - assertServerToolNotAborted, - type BaseServerTool, - type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' -import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' -import { - getExtensionFromMimeType, - getFileExtension, - getMimeTypeFromExtension, -} from '@/lib/uploads/utils/file-utils' -import { - createWorkspaceFileByPath, - updateWorkspaceFileContentByPath, -} from '@/lib/workspace-files/application/write-workspace-file-by-path' - -const logger = createLogger('DownloadToWorkspaceFileTool') - -const MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 // 50 MB -const DownloadToWorkspaceFileArgsSchema = z.object({ - url: z.string().url(), - fileName: z.string().min(1).optional(), - outputs: z - .object({ - files: z - .array( - z.object({ - path: z.string().min(1), - mode: z.enum(['create', 'overwrite']).optional(), - mimeType: z.string().optional(), - }) - ) - .optional(), - }) - .optional(), -}) - -const DownloadToWorkspaceFileResultSchema = z.object({ - success: z.boolean(), - message: z.string(), - fileId: z.string().optional(), - fileName: z.string().optional(), - vfsPath: z.string().optional(), - downloadUrl: z.string().optional(), -}) - -type DownloadToWorkspaceFileArgs = z.infer -type DownloadToWorkspaceFileResult = z.infer - -function sanitizeFileName(fileName: string): string { - return fileName.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '_').trim() -} - -function stripQueryAndHash(input: string): string { - return input.split('#')[0]?.split('?')[0] ?? input -} - -function extractFileNameFromUrl(url: string): string | undefined { - try { - const pathname = new URL(url).pathname - const lastSegment = pathname.split('/').pop() - if (!lastSegment) return undefined - const decoded = decodeURIComponent(lastSegment) - return decoded && decoded !== '/' ? decoded : undefined - } catch { - return undefined - } -} - -function extractFileNameFromContentDisposition(header: string | null): string | undefined { - if (!header) return undefined - - const utf8Match = header.match(/filename\*\s*=\s*UTF-8''([^;]+)/i) - if (utf8Match?.[1]) { - try { - return decodeURIComponent(utf8Match[1].trim()) - } catch { - return utf8Match[1].trim() - } - } - - const quotedMatch = header.match(/filename\s*=\s*"([^"]+)"/i) - if (quotedMatch?.[1]) return quotedMatch[1].trim() - - const bareMatch = header.match(/filename\s*=\s*([^;]+)/i) - if (bareMatch?.[1]) return bareMatch[1].trim() - - return undefined -} - -function resolveMimeType( - responseContentType: string | null, - candidateFileName?: string, - sourceUrl?: string -): string { - const headerMime = responseContentType?.split(';')[0]?.trim().toLowerCase() - if (headerMime && headerMime !== 'application/octet-stream') { - return headerMime - } - - const fileName = candidateFileName || extractFileNameFromUrl(sourceUrl || '') - const ext = fileName ? getFileExtension(stripQueryAndHash(fileName)) : '' - return ext ? getMimeTypeFromExtension(ext) : 'application/octet-stream' -} - -function ensureFileExtension(fileName: string, mimeType: string): string { - const ext = getFileExtension(stripQueryAndHash(fileName)) - if (ext) return fileName - - const inferredExt = getExtensionFromMimeType(mimeType) - return inferredExt ? `${fileName}.${inferredExt}` : fileName -} - -function inferOutputFileName( - requestedFileName: string | undefined, - headers: { get(name: string): string | null }, - url: string, - mimeType: string -): string { - const preferredName = - requestedFileName || - extractFileNameFromContentDisposition(headers.get('content-disposition')) || - extractFileNameFromUrl(url) || - 'downloaded-file' - - const sanitized = sanitizeFileName(stripQueryAndHash(preferredName)) || 'downloaded-file' - return ensureFileExtension(sanitized, mimeType) -} - -export const downloadToWorkspaceFileServerTool: BaseServerTool< - DownloadToWorkspaceFileArgs, - DownloadToWorkspaceFileResult -> = { - name: DownloadFile.id, - inputSchema: DownloadToWorkspaceFileArgsSchema, - outputSchema: DownloadToWorkspaceFileResultSchema, - - async execute( - params: DownloadToWorkspaceFileArgs, - context?: ServerToolContext - ): Promise { - if (!context?.userId) { - throw new Error('Authentication required') - } - - const workspaceId = context.workspaceId - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - try { - assertServerToolNotAborted(context) - - // secureFetchWithValidation handles: DNS resolution, private IP blocking (via ipaddr.js), - // SSRF-safe redirect following, and streaming size enforcement - const response = await secureFetchWithValidation(params.url, { - profile: 'contentFetch', - maxResponseBytes: MAX_DOWNLOAD_BYTES, - }) - - if (!response.ok) { - const hint = - response.status === 401 || response.status === 403 - ? ' — the URL requires authentication this tool cannot supply; ask for a public or pre-signed link instead' - : response.status === 404 - ? ' — the URL does not exist; verify it before retrying' - : response.status === 429 - ? ' — the host is rate-limiting; do not retry immediately' - : ' — the host rejected the request; retrying the same URL will fail again' - return { - success: false, - message: `Download failed with status ${response.status} ${response.statusText}${hint}`, - } - } - - const mimeType = resolveMimeType( - response.headers.get('content-type'), - params.fileName, - params.url - ) - const outputFile = params.outputs?.files?.[0] - const fileName = inferOutputFileName(params.fileName, response.headers, params.url, mimeType) - const outputPath = outputFile?.path ?? `files/${fileName}` - - assertServerToolNotAborted(context) - - const arrayBuffer = await response.arrayBuffer() - const fileBuffer = Buffer.from(arrayBuffer) - - if (fileBuffer.length === 0) { - return { success: false, message: 'Downloaded file is empty' } - } - - assertServerToolNotAborted(context) - const mode = outputFile?.mode ?? 'create' - const writeInput = { - workspaceId, - path: outputPath, - mode, - content: fileBuffer.toString('base64'), - encoding: 'base64' as const, - contentType: outputFile?.mimeType ?? mimeType, - } - const written = - mode === 'overwrite' - ? await executeCopilotFileUseCase(context, updateWorkspaceFileContentByPath, writeInput) - : await executeCopilotFileUseCase(context, createWorkspaceFileByPath, writeInput) - - logger.info('Downloaded remote file to workspace', { - sourceUrl: params.url, - fileId: written.id, - fileName: written.name, - vfsPath: written.vfsPath, - mimeType, - size: fileBuffer.length, - }) - - return { - success: true, - message: `Downloaded "${written.name}" to ${written.vfsPath} (${fileBuffer.length} bytes)`, - fileId: written.id, - fileName: written.name, - vfsPath: written.vfsPath, - downloadUrl: written.downloadUrl, - } - } catch (error) { - const msg = getErrorMessage(error, 'Unknown error') - logger.error('Failed to download file to workspace', { - url: params.url, - error: msg, - }) - return { - success: false, - message: `Failed to download file: ${messageForCopilotFileError(error, 'Unable to write downloaded file')}`, - } - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/files/edit-content.test.ts b/apps/sim/lib/copilot/tools/server/files/edit-content.test.ts deleted file mode 100644 index 1038bb2791b..00000000000 --- a/apps/sim/lib/copilot/tools/server/files/edit-content.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - buildEmbeddedImageRefWarningMock, - compileDocForWriteMock, - waitForLatestFileIntentMock, - executeCopilotFileUseCaseMock, - getDocumentFormatInfoMock, - inferContentTypeMock, - resolveCopilotFilePrincipalMock, -} = vi.hoisted(() => ({ - buildEmbeddedImageRefWarningMock: vi.fn(), - compileDocForWriteMock: vi.fn(), - waitForLatestFileIntentMock: vi.fn(), - executeCopilotFileUseCaseMock: vi.fn(), - getDocumentFormatInfoMock: vi.fn(), - inferContentTypeMock: vi.fn(), - resolveCopilotFilePrincipalMock: vi.fn(), -})) - -vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({ - executeCopilotFileUseCase: executeCopilotFileUseCaseMock, -})) -vi.mock('@/lib/copilot/auth/file-delegation', () => ({ - messageForCopilotFileError: vi.fn((_error: unknown, fallback: string) => fallback), - resolveCopilotFilePrincipal: resolveCopilotFilePrincipalMock, -})) -vi.mock('@/lib/core/config/env-flags', () => ({ - isDocSandboxEnabled: false, -})) -vi.mock('@/lib/workspace-files/application/update-workspace-file-content', () => ({ - updateWorkspaceFileContent: { operation: { id: 'files.update_content' } }, -})) -vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({ - getE2BDocFormat: vi.fn(), -})) -vi.mock('@/lib/copilot/tools/server/files/embedded-image-refs', () => ({ - buildEmbeddedImageRefWarning: buildEmbeddedImageRefWarningMock, -})) -vi.mock('@/lib/copilot/tools/server/files/file-intent-store', () => ({ - waitForLatestFileIntent: waitForLatestFileIntentMock, -})) -vi.mock('@/lib/copilot/tools/server/files/workspace-file', () => ({ - compileDocForWrite: compileDocForWriteMock, - getDocumentFormatInfo: getDocumentFormatInfoMock, - inferContentType: inferContentTypeMock, -})) - -import { editContentServerTool } from '@/lib/copilot/tools/server/files/edit-content' -import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/update-workspace-file-content' - -const context = { - userId: 'user-1', - workspaceId: 'workspace-1', - chatId: 'chat-1', - messageId: 'message-1', - toolCallId: 'tool-call-1', - copilotToolExecution: true, -} as const - -const workspacePrincipal = { - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'copilot-tool:tool-call-1', - audience: 'sim:workspace-files', - issuedAt: new Date('2026-08-12T00:00:00.000Z'), - expiresAt: new Date('2026-08-12T00:05:00.000Z'), - resourceScope: { chatId: 'chat-1' }, -} as const - -describe('edit_content', () => { - beforeEach(() => { - vi.clearAllMocks() - resolveCopilotFilePrincipalMock.mockReturnValue(workspacePrincipal) - getDocumentFormatInfoMock.mockReturnValue({ isDoc: true }) - inferContentTypeMock.mockReturnValue('text/x-python-pdf') - compileDocForWriteMock.mockResolvedValue({ ok: true, sourceMime: 'text/x-python-pdf' }) - executeCopilotFileUseCaseMock.mockResolvedValue({}) - buildEmbeddedImageRefWarningMock.mockResolvedValue('') - waitForLatestFileIntentMock.mockResolvedValue({ - operation: 'update', - fileId: 'pdf-1', - workspaceId: 'workspace-1', - userId: 'user-1', - chatId: 'chat-1', - messageId: 'message-1', - fileRecord: { - id: 'pdf-1', - name: 'report.pdf', - }, - contentType: 'text/x-python-pdf', - createdAt: Date.now(), - }) - }) - - it('compiles with workspace scope while keeping the destination write file-scoped', async () => { - const content = "image = ImageReader('/home/user/inputs/image-1')" - - await expect(editContentServerTool.execute({ content }, context)).resolves.toMatchObject({ - success: true, - }) - - expect(resolveCopilotFilePrincipalMock).toHaveBeenCalledWith(context) - expect(compileDocForWriteMock).toHaveBeenCalledWith( - expect.objectContaining({ - source: content, - fileName: 'report.pdf', - workspaceId: 'workspace-1', - principal: workspacePrincipal, - }) - ) - expect(executeCopilotFileUseCaseMock).toHaveBeenCalledWith( - context, - updateWorkspaceFileContent, - expect.objectContaining({ - fileId: 'pdf-1', - assertedWorkspaceId: 'workspace-1', - }), - { fileId: 'pdf-1' } - ) - }) - - it('applies anchored patch intent through the shared edit engine', async () => { - waitForLatestFileIntentMock.mockResolvedValue({ - operation: 'patch', - fileId: 'text-1', - workspaceId: 'workspace-1', - userId: 'user-1', - chatId: 'chat-1', - messageId: 'message-1', - fileRecord: { id: 'text-1', name: 'notes.md' }, - existingContent: 'before\nold\nafter\n', - edit: { - strategy: 'anchored', - mode: 'replace_between', - before_anchor: 'before', - after_anchor: 'after', - }, - createdAt: Date.now(), - }) - - await expect(editContentServerTool.execute({ content: 'new' }, context)).resolves.toMatchObject( - { - success: true, - } - ) - expect(compileDocForWriteMock).toHaveBeenCalledWith( - expect.objectContaining({ source: 'before\nnew\nafter\n' }) - ) - }) - - it('honors replaceAll with literal replacement content for exact patch intent', async () => { - waitForLatestFileIntentMock.mockResolvedValue({ - operation: 'patch', - fileId: 'text-1', - workspaceId: 'workspace-1', - userId: 'user-1', - chatId: 'chat-1', - messageId: 'message-1', - fileRecord: { id: 'text-1', name: 'notes.md' }, - existingContent: 'old old', - edit: { strategy: 'search_replace', search: 'old', replaceAll: true }, - createdAt: Date.now(), - }) - - await expect(editContentServerTool.execute({ content: '$&' }, context)).resolves.toMatchObject({ - success: true, - }) - expect(compileDocForWriteMock).toHaveBeenCalledWith( - expect.objectContaining({ source: '$& $&' }) - ) - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/files/edit-content.ts b/apps/sim/lib/copilot/tools/server/files/edit-content.ts deleted file mode 100644 index 7b8b4ed4018..00000000000 --- a/apps/sim/lib/copilot/tools/server/files/edit-content.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' -import { - messageForCopilotFileError, - resolveCopilotFilePrincipal, -} from '@/lib/copilot/auth/file-delegation' -import { - assertServerToolNotAborted, - type BaseServerTool, - type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' -import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' -import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/update-workspace-file-content' -import { - applyWorkspaceFileContentEdit, - EditContentError, - type WorkspaceFileContentEdit, -} from '@/lib/workspace-files/edit-content' -import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration' -import { - collectSimPageDiagnostics, - HAND_WRITTEN_PAGE_MESSAGE, - isHandWrittenCompiledPage, - isSimPageSource, - SIM_PAGE_CONTENT_TYPE, -} from '@/lib/workspace-files/page-compile' -import { getE2BDocFormat } from './doc-compile' -import { buildEmbeddedImageRefWarning } from './embedded-image-refs' -import { waitForLatestFileIntent } from './file-intent-store' -import { compileDocForWrite, getDocumentFormatInfo, inferContentType } from './workspace-file' - -const logger = createLogger('EditContentServerTool') - -type EditContentArgs = { - content: string -} - -type EditContentResult = { - success: boolean - message: string - data?: Record -} - -export const editContentServerTool: BaseServerTool = { - name: 'apply_file_edit', - async execute(params: EditContentArgs, context?: ServerToolContext): Promise { - if (!context?.userId) { - logger.error('Unauthorized attempt to use apply_file_edit') - throw new Error('Authentication required') - } - - const workspaceId = context.workspaceId - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - const raw = params as Record - const nested = raw.args as Record | undefined - const content = - typeof params.content === 'string' - ? params.content - : typeof nested?.content === 'string' - ? (nested.content as string) - : undefined - - if (content === undefined) { - return { success: false, message: 'content is required for apply_file_edit' } - } - - // Consume the intent from THIS file subagent's channel (its outer tool_use - // id), not just the latest in the message — otherwise two file agents - // writing concurrently would each grab whichever prepare_file_edit landed last - // and write their content into the wrong file. Falls back to latest-in- - // message when no channel id is present (main-agent / legacy calls). - // Waits briefly: a prepare batched into the same round may still be running. - const intent = await waitForLatestFileIntent(workspaceId, { - chatId: context.chatId, - messageId: context.messageId, - channelId: context.parentToolCallId, - }) - if (!intent) { - return { - success: false, - message: - 'No prepare_file_edit context found. Call prepare_file_edit first, wait for it to succeed, then call apply_file_edit in the next step. Do not emit apply_file_edit in parallel or in the same batch as prepare_file_edit.', - } - } - - try { - const { operation, fileRecord } = intent - const docInfo = getDocumentFormatInfo(fileRecord.name) - const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(fileRecord.name) : null - // Agent-authored pages are stored as SOURCE (frontmatter + markdown + - // sim: fences) and compiled to the docs-styled document at render time - // — the pdf model: the file holds the source, the preview/share/ - // download surfaces serve the rendered version. Bespoke raw HTML - // passes through, but a hand-written copy of compiled output defeats - // the source format, so it is rejected with the steer back to source. - // Patches are exempt: small in-place fixes on a legacy stored-compiled - // page legitimately contain compiled fragments. - // Sim pages store an extensionless name; the record type marks them. - const isHtmlTarget = - fileRecord.name.toLowerCase().endsWith('.html') || fileRecord.type === SIM_PAGE_CONTENT_TYPE - if ( - isHtmlTarget && - (operation === 'append' || operation === 'update') && - isHandWrittenCompiledPage(content) - ) { - return { success: false, message: HAND_WRITTEN_PAGE_MESSAGE } - } - - let finalContent: string - switch (operation) { - case 'append': { - const existing = intent.existingContent ?? '' - if (isHtmlTarget) { - finalContent = existing ? `${existing}\n${content}` : content - break - } - // The JS engines (isolated-vm and E2B-node pptx/docx) use the `{ ... }` - // block-append convention — block statements scope cleanly inside the - // compile wrapper. Python docs (pdf/xlsx) are a single cohesive script, - // so brace-wrapping would produce invalid Python; plain-concatenate. - // Brace-wrap appended content for the JS engines (isolated-vm and - // E2B-node pptx/docx); Python docs (pdf/xlsx) are one cohesive script. - const braceWrap = e2bFmt ? e2bFmt.engine === 'node' : docInfo.isDoc - finalContent = braceWrap - ? existing - ? `${existing}\n{\n${content}\n}` - : content - : existing - ? `${existing}\n${content}` - : content - break - } - case 'update': { - finalContent = content - break - } - case 'patch': { - const existing = intent.existingContent ?? '' - if (!intent.edit) { - return { success: false, message: 'Patch intent missing edit metadata' } - } - - let edit: WorkspaceFileContentEdit - if (intent.edit.strategy === 'search_replace') { - if (!intent.edit.search) { - return { - success: false, - message: 'search_replace requires search', - } - } - edit = { - mode: 'search_replace', - search: intent.edit.search, - content, - replaceAll: intent.edit.replaceAll, - } - } else if (intent.edit.strategy === 'anchored') { - if (intent.edit.mode === 'replace_between') { - if (!intent.edit.before_anchor || !intent.edit.after_anchor) { - return { - success: false, - message: 'replace_between requires before_anchor and after_anchor', - } - } - edit = { - mode: 'replace_between', - beforeAnchor: intent.edit.before_anchor, - afterAnchor: intent.edit.after_anchor, - content, - occurrence: intent.edit.occurrence, - } - } else if (intent.edit.mode === 'insert_after') { - if (!intent.edit.anchor) { - return { success: false, message: 'insert_after requires anchor' } - } - edit = { - mode: 'insert_after', - anchor: intent.edit.anchor, - content, - occurrence: intent.edit.occurrence, - } - } else if (intent.edit.mode === 'delete_between') { - if (!intent.edit.start_anchor || !intent.edit.end_anchor) { - return { - success: false, - message: 'delete_between requires start_anchor and end_anchor', - } - } - edit = { - mode: 'delete_between', - startAnchor: intent.edit.start_anchor, - endAnchor: intent.edit.end_anchor, - occurrence: intent.edit.occurrence, - } - } else { - return { - success: false, - message: `Unknown anchored patch mode: "${intent.edit.mode}"`, - } - } - } else { - return { success: false, message: `Unknown patch strategy: "${intent.edit.strategy}"` } - } - try { - finalContent = applyWorkspaceFileContentEdit(existing, edit, { - maxOutputBytes: MAX_WORKSPACE_FILE_CONTENT_BYTES, - }) - } catch (error) { - if (error instanceof EditContentError) { - return { - success: false, - message: `Patch failed for "${fileRecord.name}": ${error.message}`, - } - } - throw error - } - break - } - default: - return { success: false, message: `Unsupported operation in intent: ${operation}` } - } - - // Compile once via the right engine (or isolated-vm fallback) and resolve - // the source MIME to store. Shared with the create path. - const principal = resolveCopilotFilePrincipal(context) - const compiled = await compileDocForWrite({ - source: finalContent, - fileName: fileRecord.name, - workspaceId, - principal, - ownerKey: `user:${context.userId}`, - signal: context.abortSignal, - fallbackMime: inferContentType(fileRecord.name, intent.contentType), - }) - if (!compiled.ok) { - return { success: false, message: compiled.message } - } - - // The internal page type: the record advertises what the .html holds so - // surfaces can force the rendered view before content loads. The file - // itself stays .html (serve/download emit text/html). - // create_empty_file stamps copilot .html as a page by default; the - // first real content confirms or corrects that from what was written. - const storedContentType = - isHtmlTarget && isSimPageSource(finalContent) ? SIM_PAGE_CONTENT_TYPE : compiled.sourceMime - - const fileBuffer = Buffer.from(finalContent, 'utf-8') - assertServerToolNotAborted(context) - // `updateWorkspaceFileContent` also streams this edit into any open collaborative editor as a live - // CRDT merge (gated to markdown, best-effort) — the shared chokepoint every external write path - // goes through — so a copilot edit shows up live instead of the file changing under the reader. - await executeCopilotFileUseCase( - context, - updateWorkspaceFileContent, - { - fileId: intent.fileId, - assertedWorkspaceId: workspaceId, - content: finalContent, - encoding: 'utf-8', - contentType: storedContentType, - provenanceMode: operation === 'update' ? 'replace_empty' : 'preserve', - }, - { fileId: intent.fileId } - ) - - const verb = - operation === 'append' ? 'appended to' : operation === 'update' ? 'updated' : 'patched' - logger.info(`Workspace file ${verb} via copilot (apply_file_edit)`, { - fileId: intent.fileId, - name: fileRecord.name, - operation, - size: fileBuffer.length, - userId: context.userId, - }) - - // Flag any `/api/files/view/` embeds the model just authored that won't render/export - // (non-workspace or missing), so it can self-correct on the next step. - const embedWarning = await buildEmbeddedImageRefWarning(content, workspaceId) - - // Page-source lint: a malformed sim: block renders as NOTHING for the - // reader — the only place the failure surfaces is right here, so the - // agent can fix the fence instead of shipping a silent hole. - let pageLint = '' - if (storedContentType === SIM_PAGE_CONTENT_TYPE) { - const diagnostics = collectSimPageDiagnostics(finalContent) - if (diagnostics.length > 0) { - pageLint = ` WARNING — ${diagnostics.length} block(s) failed to compile and are OMITTED from the rendered page; fix them: ${diagnostics.join('; ')}` - } - } - - return { - success: true, - message: `File "${fileRecord.name}" ${verb} successfully (${fileBuffer.length} bytes)${embedWarning}${pageLint}`, - data: { - id: intent.fileId, - name: fileRecord.name, - size: fileBuffer.length, - contentType: storedContentType, - }, - } - } catch (error) { - const safeMessage = messageForCopilotFileError(error, 'Failed to edit file content') - const errorMessage = getErrorMessage(error, 'Unknown error occurred') - logger.error('Error in apply_file_edit tool', { - operation: intent.operation, - fileId: intent.fileId, - error: errorMessage, - userId: context.userId, - }) - return { - success: false, - message: safeMessage, - } - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/files/embedded-image-refs.test.ts b/apps/sim/lib/copilot/tools/server/files/embedded-image-refs.test.ts deleted file mode 100644 index c1c0c0c64ea..00000000000 --- a/apps/sim/lib/copilot/tools/server/files/embedded-image-refs.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockGetFileMetadataById } = vi.hoisted(() => ({ - mockGetFileMetadataById: vi.fn(), -})) - -vi.mock('@/lib/uploads/server/metadata', () => ({ - getFileMetadataById: mockGetFileMetadataById, -})) - -import { findUnembeddableImageRefs } from '@/lib/copilot/tools/server/files/embedded-image-refs' - -const WORKSPACE_ID = 'W1' - -describe('findUnembeddableImageRefs', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('flags embeds that are not workspace files in this workspace', async () => { - mockGetFileMetadataById.mockImplementation(async (id: string) => { - if (id === 'wf_here') return { context: 'workspace', workspaceId: WORKSPACE_ID } - if (id === 'wf_elsewhere') return { context: 'workspace', workspaceId: 'W2' } - if (id === 'wf_chat') return { context: 'mothership', workspaceId: WORKSPACE_ID } - return null - }) - - const content = `![a](/api/files/view/wf_here) ![b](/api/files/view/wf_elsewhere) - ![c](/api/files/view/wf_chat) ![d](/api/files/view/wf_missing)` - - expect((await findUnembeddableImageRefs(content, WORKSPACE_ID)).sort()).toEqual([ - 'wf_chat', - 'wf_elsewhere', - 'wf_missing', - ]) - }) - - it('never warns about a url the document only mentions', async () => { - const content = 'Call `/api/files/view/{id}`; see [the docs](/api/files/view/wf_linked).' - - expect(await findUnembeddableImageRefs(content, WORKSPACE_ID)).toEqual([]) - expect(mockGetFileMetadataById).not.toHaveBeenCalled() - }) - - /** - * The export bundler resolves an embed by its stored id, so reporting the same embed as one that - * will not survive an export would contradict what the export actually does with it. - */ - it('resolves a percent-encoded embed by its stored id, like the export does', async () => { - mockGetFileMetadataById.mockImplementation(async (id: string) => - id === 'wf_abc' ? { context: 'workspace', workspaceId: WORKSPACE_ID } : null - ) - - expect(await findUnembeddableImageRefs('![a](/api/files/view/wf%5Fabc)', WORKSPACE_ID)).toEqual( - [] - ) - expect(mockGetFileMetadataById).toHaveBeenCalledWith('wf_abc') - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/files/extract-doc-assets.ts b/apps/sim/lib/copilot/tools/server/files/extract-doc-assets.ts deleted file mode 100644 index 5f8019b38c4..00000000000 --- a/apps/sim/lib/copilot/tools/server/files/extract-doc-assets.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { z } from 'zod' -import { - executeCopilotFileUseCase, - resolveCopilotWorkspaceFileReference, -} from '@/lib/copilot/application/execute-file-use-case' -import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' -import { ExtractDocAssets } from '@/lib/copilot/generated/tool-catalog-v1' -import { - assertServerToolNotAborted, - type BaseServerTool, - type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' -import { extractDocAssets } from '@/lib/copilot/tools/server/files/doc-asset-extract' -import { extractPdfAssets } from '@/lib/copilot/tools/server/files/doc-asset-extract-pdf' -import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' -import { fileOperations } from '@/lib/workspace-files/application/operations' -import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' -import { - createWorkspaceFileByPath, - updateWorkspaceFileContentByPath, -} from '@/lib/workspace-files/application/write-workspace-file-by-path' - -const logger = createLogger('ExtractDocAssetsTool') - -const MAX_SOURCE_BYTES = 100 * 1024 * 1024 // 100 MB -const MAX_MEDIA_FILES = 200 - -const ExtractDocAssetsArgsSchema = z.object({ - path: z.string().min(1), - destination: z.string().min(1).optional(), -}) - -const ExtractDocAssetsResultSchema = z.object({ - success: z.boolean(), - message: z.string(), - themePath: z.string().optional(), - theme: z.unknown().optional(), - files: z - .array(z.object({ fileId: z.string(), fileName: z.string(), vfsPath: z.string() })) - .optional(), -}) - -type ExtractDocAssetsArgs = z.infer -type ExtractDocAssetsResult = z.infer - -/** - * Materializes a reference document's design into workspace files with a - * fixed, predictable structure: `/theme.json` (color scheme, - * fonts, slide size), `/layout.json` (the slide/page-by-page - * text and asset layout, pptx and pdf), plus one file per embedded image — - * original bytes for OOXML media; for PDF, an image stored as base + - * separate alpha mask is recombined into a transparent PNG. - * Re-running against the same destination overwrites the previous set - * instead of duplicating it. The source document is never modified. - */ -export const extractDocAssetsServerTool: BaseServerTool< - ExtractDocAssetsArgs, - ExtractDocAssetsResult -> = { - name: ExtractDocAssets.id, - inputSchema: ExtractDocAssetsArgsSchema, - outputSchema: ExtractDocAssetsResultSchema, - - async execute( - params: ExtractDocAssetsArgs, - context?: ServerToolContext - ): Promise { - if (!context?.userId) { - throw new Error('Authentication required') - } - const workspaceId = context.workspaceId - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - try { - assertServerToolNotAborted(context) - - const record = await resolveCopilotWorkspaceFileReference( - context, - fileOperations.readContent, - { workspaceId, reference: params.path } - ) - const sourceName = record.name - const ext = getFileExtension(sourceName).toLowerCase() - if (ext !== 'pptx' && ext !== 'docx' && ext !== 'pdf') { - return { - success: false, - message: `"${sourceName}" is a .${ext || '?'} file — assets can only be extracted from .pptx, .docx, or .pdf documents`, - } - } - - const { content } = await executeCopilotFileUseCase( - context, - readWorkspaceFileContent, - { fileId: record.id, assertedWorkspaceId: workspaceId, maxBytes: MAX_SOURCE_BYTES }, - { fileId: record.id } - ) - - assertServerToolNotAborted(context) - // OOXML is a zip and extracts in-process; PDF needs the doc sandbox's - // poppler/pdfplumber toolchain (same environment that compiles docs). - const extracted = - ext === 'pdf' ? await extractPdfAssets(content) : await extractDocAssets(content, ext) - const totalMediaCount = extracted.media.length - if (totalMediaCount > MAX_MEDIA_FILES) { - extracted.media = extracted.media.slice(0, MAX_MEDIA_FILES) - } - - const baseName = sourceName.replace(/\.[^.]+$/, '') - const destination = (params.destination ?? `files/${baseName} assets`).replace(/\/+$/, '') - - // Overwrite-or-create per file: a re-run refreshes the set in place - // rather than erroring on the existing files or duplicating them. - const writeAsset = async (name: string, bytes: Buffer, contentType: string) => { - const writeInput = { - workspaceId, - path: `${destination}/${name}`, - content: bytes.toString('base64'), - encoding: 'base64' as const, - contentType, - } - try { - return await executeCopilotFileUseCase(context, createWorkspaceFileByPath, { - ...writeInput, - mode: 'create' as const, - }) - } catch { - return await executeCopilotFileUseCase(context, updateWorkspaceFileContentByPath, { - ...writeInput, - mode: 'overwrite' as const, - }) - } - } - - const written: Array<{ fileId: string; fileName: string; vfsPath: string }> = [] - const themeFile = await writeAsset( - 'theme.json', - Buffer.from(JSON.stringify(extracted.theme, null, 2), 'utf8'), - 'application/json' - ) - written.push({ fileId: themeFile.id, fileName: themeFile.name, vfsPath: themeFile.vfsPath }) - - // The slide/page-by-page rebuild recipe — text blocks with their frame, - // font, size, and color, plus each slide's asset placements by extracted - // filename (pdf pages also carry filled rects and overlay scrims). - let wroteLayout = false - if (extracted.layout.length > 0) { - const layoutFile = await writeAsset( - 'layout.json', - Buffer.from(JSON.stringify(extracted.layout, null, 2), 'utf8'), - 'application/json' - ) - written.push({ - fileId: layoutFile.id, - fileName: layoutFile.name, - vfsPath: layoutFile.vfsPath, - }) - wroteLayout = true - } - - for (const media of extracted.media) { - assertServerToolNotAborted(context) - const mediaExt = getFileExtension(media.name) - const mime = mediaExt ? getMimeTypeFromExtension(mediaExt) : 'application/octet-stream' - const file = await writeAsset(media.name, media.bytes, mime) - written.push({ fileId: file.id, fileName: file.name, vfsPath: file.vfsPath }) - } - - logger.info('Extracted document assets to workspace', { - source: params.path, - destination, - mediaCount: extracted.media.length, - droppedMediaCount: totalMediaCount - extracted.media.length, - format: extracted.theme.format, - }) - - const themeSummary = ( - extracted.theme.format === 'pdf' - ? [ - extracted.theme.fonts.length > 0 ? 'font names' : null, - extracted.theme.inferredPalette.length > 0 ? 'inferred palette' : null, - 'page size and image placements', - ] - : [ - Object.keys(extracted.theme.colors).length > 0 ? 'theme colors' : null, - extracted.theme.fonts.major || extracted.theme.fonts.minor ? 'fonts' : null, - extracted.theme.slideSize ? 'slide size' : null, - ] - ) - .filter(Boolean) - .join(', ') - const unit = extracted.theme.format === 'pdf' ? 'page' : 'slide' - const layoutNote = wroteLayout - ? `, plus layout.json (${unit}-by-${unit} text and asset layout)` - : '' - // Never report a truncated set as complete: theme/layout may reference - // media that was not written. - const truncationNote = - totalMediaCount > extracted.media.length - ? ` NOTE: the document holds ${totalMediaCount} media files; only the first ${extracted.media.length} were extracted — theme/layout references beyond that were not written.` - : '' - return { - success: true, - message: `Extracted ${extracted.media.length} asset file(s) and theme.json (${themeSummary || 'no theme data found'})${layoutNote} from "${sourceName}" into ${destination}/${truncationNote}`, - themePath: written[0]?.vfsPath, - theme: extracted.theme, - files: written, - } - } catch (error) { - const msg = getErrorMessage(error, 'Unknown error') - logger.error('Failed to extract document assets', { path: params.path, error: msg }) - return { - success: false, - message: `Failed to extract assets: ${messageForCopilotFileError(error, 'Unable to extract document assets')}`, - } - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/files/file-folders.ts b/apps/sim/lib/copilot/tools/server/files/file-folders.ts deleted file mode 100644 index 071b46c5713..00000000000 --- a/apps/sim/lib/copilot/tools/server/files/file-folders.ts +++ /dev/null @@ -1,456 +0,0 @@ -import { createLogger } from '@sim/logger' -import { - executeCopilotFileUseCase, - resolveCopilotWorkspaceFileReference, -} from '@/lib/copilot/application/execute-file-use-case' -import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' -import { - assertServerToolNotAborted, - type BaseServerTool, - type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' -import { ensureCopilotFileFolderPath } from '@/lib/copilot/tools/server/files/file-folder-application' -import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope' -import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' -import { - findWorkspaceFileFolderIdByPath, - getWorkspaceFileFolder, - type WorkspaceFileFolderRecord, -} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' -import { fileOperations } from '@/lib/workspace-files/application/operations' -import { - createWorkspaceFileFolderOperation, - listWorkspaceFileFoldersOperation, - updateWorkspaceFileFolderOperation, -} from '@/lib/workspace-files/application/workspace-file-folders' - -const logger = createLogger('FileFolderServerTools') - -interface WorkspaceScopedArgs { - workspaceId?: string - args?: Record -} - -type ListFileFoldersArgs = WorkspaceScopedArgs - -interface CreateFileFolderArgs extends WorkspaceScopedArgs { - path?: string - name?: string - parentId?: string | null - parentPath?: string | null -} - -interface RenameFileFolderArgs extends WorkspaceScopedArgs { - path?: string - folderId?: string - name?: string -} - -interface MoveFileFolderArgs extends WorkspaceScopedArgs { - path?: string - folderId?: string - destinationPath?: string | null - parentId?: string | null -} - -interface MoveFileArgs extends WorkspaceScopedArgs { - paths?: string[] - path?: string - destinationPath?: string | null - fileIds?: string[] - fileId?: string - folderId?: string | null -} - -interface FileFolderResult { - success: boolean - message: string - data?: unknown -} - -function nested(params: WorkspaceScopedArgs): Record | undefined { - return params.args && typeof params.args === 'object' ? params.args : undefined -} - -function stringValue(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined -} - -function stringArrayValue(value: unknown): string[] | undefined { - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === 'string') - : undefined -} - -function nullableStringValue(value: unknown): string | null | undefined { - if (value === null) return null - if (typeof value !== 'string') return undefined - return value.trim() ? value : null -} - -function stringListFromValues(...values: unknown[]): string[] { - for (const value of values) { - const arr = stringArrayValue(value) - if (arr && arr.length > 0) return arr - } - return values - .map((value) => stringValue(value)) - .filter((value): value is string => Boolean(value)) -} - -function decodeFileFolderPath(path: string): string[] | null { - const trimmed = path.trim().replace(/\/+$/, '') - if (!trimmed || trimmed === 'files') return null - const withoutPrefix = trimmed.startsWith('files/') ? trimmed.slice('files/'.length) : trimmed - const withoutMarker = withoutPrefix.endsWith('/.folder') - ? withoutPrefix.slice(0, -'/.folder'.length) - : withoutPrefix - const segments = decodeVfsPathSegments(withoutMarker).filter(Boolean) - return segments.length > 0 ? segments : null -} - -async function resolveFolderIdFromPath( - workspaceId: string, - path: string, - label = 'Folder' -): Promise { - const segments = decodeFileFolderPath(path) - if (!segments) - throw new OrchestrationError('validation', `${label} path must identify a folder under files/`) - const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) - if (!folderId) - throw new OrchestrationError('not_found', `${label} not found at files/${segments.join('/')}`) - return folderId -} - -async function resolveOptionalFolderId( - workspaceId: string, - value: unknown -): Promise { - const raw = nullableStringValue(value) - if (raw === undefined) return undefined - if (raw === null) return null - const segments = decodeFileFolderPath(raw) - if (!segments) return null - const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) - if (!folderId) - throw new OrchestrationError( - 'not_found', - `Target folder not found at files/${segments.join('/')}` - ) - return folderId -} - -async function resolveFileIdsFromPaths( - workspaceId: string, - paths: string[], - context: ServerToolContext -): Promise<{ - fileIds: string[] - failed: string[] -}> { - const fileIds: string[] = [] - const failed: string[] = [] - for (const path of paths) { - try { - const file = await resolveCopilotWorkspaceFileReference(context, fileOperations.move, { - workspaceId, - reference: path, - }) - fileIds.push(file.id) - } catch (error) { - const classified = asOrchestrationError(error) - if (classified?.code !== 'not_found') throw error - failed.push(path) - } - } - return { fileIds, failed } -} - -async function resolveWorkspaceId( - params: WorkspaceScopedArgs, - context: ServerToolContext | undefined -): Promise { - if (!context?.userId) { - throw new Error('Authentication required') - } - - const payload = nested(params) - const assertedWorkspaceId = - stringValue(params.workspaceId) || stringValue(payload?.workspaceId) || undefined - const workspaceId = requireCopilotWorkspace(context, assertedWorkspaceId) - - return workspaceId -} - -function folderLabel(folder: WorkspaceFileFolderRecord): string { - return folder.path || folder.name -} - -export const listFileFoldersServerTool: BaseServerTool = { - name: 'list_file_folders', - async execute( - params: ListFileFoldersArgs, - context?: ServerToolContext - ): Promise { - try { - const workspaceId = await resolveWorkspaceId(params, context) - if (typeof workspaceId !== 'string') return workspaceId - - const result = await executeCopilotFileUseCase(context, listWorkspaceFileFoldersOperation, { - workspaceId, - }) - const folders = result.folders - return { - success: true, - message: - folders.length === 1 ? 'Found 1 file folder' : `Found ${folders.length} file folders`, - data: { workspaceId, folders }, - } - } catch (error) { - return { - success: false, - message: messageForCopilotFileError(error, 'Failed to list file folders'), - } - } - }, -} - -export const createFileFolderServerTool: BaseServerTool = { - name: 'create_file_folder', - async execute( - params: CreateFileFolderArgs, - context?: ServerToolContext - ): Promise { - try { - const workspaceId = await resolveWorkspaceId(params, context) - if (typeof workspaceId !== 'string') return workspaceId - if (!context?.userId) throw new Error('Authentication required') - - const payload = nested(params) - const rawPath = stringValue(params.path) || stringValue(payload?.path) - const pathSegments = rawPath ? decodeFileFolderPath(rawPath) : undefined - const name = ( - pathSegments?.at(-1) || - stringValue(params.name) || - stringValue(payload?.name) || - '' - ).trim() - if (!name) return { success: false, message: 'name is required' } - - let parentId = - (await resolveOptionalFolderId(workspaceId, params.parentPath ?? payload?.parentPath)) ?? - nullableStringValue(params.parentId ?? payload?.parentId) ?? - null - if (pathSegments && pathSegments.length > 1) { - parentId = await ensureCopilotFileFolderPath( - context, - workspaceId, - pathSegments.slice(0, -1) - ) - } - - assertServerToolNotAborted(context) - const result = await executeCopilotFileUseCase(context, createWorkspaceFileFolderOperation, { - workspaceId, - name, - parentId, - }) - const { folder } = result - - logger.info('File folder created via create_file_folder', { - workspaceId, - folderId: folder.id, - parentId, - userId: context.userId, - }) - - return { - success: true, - message: `Created file folder "${folderLabel(folder)}"`, - data: { folder }, - } - } catch (error) { - return { - success: false, - message: messageForCopilotFileError(error, 'Failed to create file folder'), - } - } - }, -} - -export const renameFileFolderServerTool: BaseServerTool = { - name: 'rename_file_folder', - async execute( - params: RenameFileFolderArgs, - context?: ServerToolContext - ): Promise { - try { - const workspaceId = await resolveWorkspaceId(params, context) - if (typeof workspaceId !== 'string') return workspaceId - if (!context?.userId) throw new Error('Authentication required') - - const payload = nested(params) - const folderPath = stringValue(params.path) || stringValue(payload?.path) - const folderId = - (folderPath ? await resolveFolderIdFromPath(workspaceId, folderPath) : undefined) || - stringValue(params.folderId) || - stringValue(payload?.folderId) || - '' - const name = (stringValue(params.name) || stringValue(payload?.name) || '').trim() - if (!folderId) return { success: false, message: 'path is required' } - if (!name) return { success: false, message: 'name is required' } - - const existing = await getWorkspaceFileFolder(workspaceId, folderId) - if (!existing) return { success: false, message: 'Folder not found' } - - assertServerToolNotAborted(context) - const result = await executeCopilotFileUseCase(context, updateWorkspaceFileFolderOperation, { - workspaceId, - folderId, - name, - }) - const { folder } = result - - logger.info('File folder renamed via rename_file_folder', { - workspaceId, - folderId, - oldName: existing.name, - name, - userId: context.userId, - }) - - return { - success: true, - message: `Renamed file folder "${folderLabel(existing)}" to "${folderLabel(folder)}"`, - data: { folder }, - } - } catch (error) { - return { - success: false, - message: messageForCopilotFileError(error, 'Failed to rename file folder'), - } - } - }, -} - -export const moveFileFolderServerTool: BaseServerTool = { - name: 'move_file_folder', - async execute( - params: MoveFileFolderArgs, - context?: ServerToolContext - ): Promise { - try { - const workspaceId = await resolveWorkspaceId(params, context) - if (typeof workspaceId !== 'string') return workspaceId - if (!context?.userId) throw new Error('Authentication required') - - const payload = nested(params) - const folderPath = stringValue(params.path) || stringValue(payload?.path) - const folderId = - (folderPath ? await resolveFolderIdFromPath(workspaceId, folderPath) : undefined) || - stringValue(params.folderId) || - stringValue(payload?.folderId) || - '' - if (!folderId) return { success: false, message: 'path is required' } - const parentId = - (await resolveOptionalFolderId( - workspaceId, - params.destinationPath ?? payload?.destinationPath - )) ?? - nullableStringValue(params.parentId ?? payload?.parentId) ?? - null - - assertServerToolNotAborted(context) - const result = await executeCopilotFileUseCase(context, updateWorkspaceFileFolderOperation, { - workspaceId, - folderId, - parentId, - }) - const { folder } = result - - logger.info('File folder moved via move_file_folder', { - workspaceId, - folderId, - parentId, - userId: context.userId, - }) - - return { - success: true, - message: parentId - ? `Moved file folder "${folderLabel(folder)}"` - : `Moved file folder "${folderLabel(folder)}" to root`, - data: { folder }, - } - } catch (error) { - return { - success: false, - message: messageForCopilotFileError(error, 'Failed to move file folder'), - } - } - }, -} - -export const moveFileServerTool: BaseServerTool = { - name: 'move_file', - async execute(params: MoveFileArgs, context?: ServerToolContext): Promise { - try { - const workspaceId = await resolveWorkspaceId(params, context) - if (typeof workspaceId !== 'string') return workspaceId - if (!context?.userId) throw new Error('Authentication required') - - const payload = nested(params) - const paths = stringListFromValues(params.paths, payload?.paths, params.path, payload?.path) - const resolvedByPath = - paths.length > 0 ? await resolveFileIdsFromPaths(workspaceId, paths, context) : undefined - if (resolvedByPath?.failed.length) { - return { - success: false, - message: `Files not found: ${resolvedByPath.failed.join(', ')}`, - } - } - const fileIds = - resolvedByPath?.fileIds ?? - params.fileIds ?? - stringArrayValue(payload?.fileIds) ?? - [stringValue(params.fileId) || stringValue(payload?.fileId) || ''].filter(Boolean) - if (fileIds.length === 0) return { success: false, message: 'paths is required' } - - const folderId = - (await resolveOptionalFolderId( - workspaceId, - params.destinationPath ?? payload?.destinationPath - )) ?? - nullableStringValue(params.folderId ?? payload?.folderId) ?? - null - - assertServerToolNotAborted(context) - const result = await executeCopilotFileUseCase(context, moveWorkspaceFileItemsOperation, { - workspaceId, - fileIds, - targetFolderId: folderId, - }) - - logger.info('Files moved via move_file', { - workspaceId, - fileIds, - folderId, - movedFiles: result.movedItems.files, - userId: context.userId, - }) - - return { - success: result.movedItems.files > 0, - message: folderId - ? `Moved ${result.movedItems.files} file${result.movedItems.files === 1 ? '' : 's'}` - : `Moved ${result.movedItems.files} file${result.movedItems.files === 1 ? '' : 's'} to root`, - data: result.movedItems, - } - } catch (error) { - return { success: false, message: messageForCopilotFileError(error, 'Failed to move files') } - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/files/rename-file.ts b/apps/sim/lib/copilot/tools/server/files/rename-file.ts deleted file mode 100644 index 807e42e1a39..00000000000 --- a/apps/sim/lib/copilot/tools/server/files/rename-file.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { createLogger } from '@sim/logger' -import { - executeCopilotFileUseCase, - resolveCopilotWorkspaceFileReference, -} from '@/lib/copilot/application/execute-file-use-case' -import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' -import { - assertServerToolNotAborted, - type BaseServerTool, - type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { fileOperations } from '@/lib/workspace-files/application/operations' -import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' -import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' -import { validateFlatWorkspaceFileName } from './workspace-file' - -const logger = createLogger('RenameFileServerTool') - -interface RenameFileArgs { - path?: string - fileId?: string - newName: string - args?: Record -} - -interface RenameFileResult { - success: boolean - message: string - data?: { - id: string - name: string - } -} - -/** - * Removed from the mothership catalog in favor of mv; the executor stays - * registered under its literal name so in-flight checkpoints paused on - * rename_file still resume. Delete after the mv release soaks. - */ -export const renameFileServerTool: BaseServerTool = { - name: 'rename_file', - async execute(params: RenameFileArgs, context?: ServerToolContext): Promise { - if (!context?.userId) { - throw new Error('Authentication required') - } - const workspaceId = context.workspaceId - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - const nested = params.args - const path = params.path || (nested?.path as string) || '' - const legacyFileId = params.fileId || (nested?.fileId as string) || '' - const newName = params.newName || (nested?.newName as string) || '' - - const targetRef = path || legacyFileId - if (!targetRef) return { success: false, message: 'path is required' } - - const nameError = validateFlatWorkspaceFileName(newName) - if (nameError) return { success: false, message: nameError } - - let existingFile - try { - existingFile = path - ? await resolveCopilotWorkspaceFileReference(context, fileOperations.rename, { - workspaceId, - reference: path, - }) - : ( - await executeCopilotFileUseCase( - context, - readWorkspaceFileMetadata, - { fileId: legacyFileId, assertedWorkspaceId: workspaceId }, - { fileId: legacyFileId } - ) - ).file - } catch (error) { - const classified = asOrchestrationError(error) - if (classified?.code !== 'not_found') throw error - return { success: false, message: `File not found: ${targetRef}` } - } - if (!existingFile) { - return { success: false, message: `File not found: ${targetRef}` } - } - const fileId = existingFile.id - - assertServerToolNotAborted(context) - try { - await executeCopilotFileUseCase( - context, - renameWorkspaceFile, - { fileId, assertedWorkspaceId: workspaceId, name: newName }, - { fileId } - ) - } catch (error) { - return { success: false, message: messageForCopilotFileError(error, 'Failed to rename file') } - } - - logger.info('File renamed via rename_file', { - fileId, - oldName: existingFile.name, - newName, - userId: context.userId, - }) - - return { - success: true, - message: `File renamed from "${existingFile.name}" to "${newName}"`, - data: { - id: fileId, - name: newName, - }, - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/files/share-file.ts b/apps/sim/lib/copilot/tools/server/files/share-file.ts deleted file mode 100644 index e59ae20c81c..00000000000 --- a/apps/sim/lib/copilot/tools/server/files/share-file.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { createLogger } from '@sim/logger' -import type { ShareAuthType } from '@/lib/api/contracts/public-shares' -import { - executeCopilotFileUseCase, - resolveCopilotWorkspaceFileReference, -} from '@/lib/copilot/application/execute-file-use-case' -import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' -import { ShareFile } from '@/lib/copilot/generated/tool-catalog-v1' -import { - assertServerToolNotAborted, - type BaseServerTool, - type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' -import { resolveEnvReferenceSecretArg } from '@/lib/copilot/tools/server/env-reference' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { fileOperations } from '@/lib/workspace-files/application/operations' -import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' -import { - updateWorkspaceFileShare, - WorkspaceFileShareNoopError, -} from '@/lib/workspace-files/application/share-workspace-file' - -const logger = createLogger('ShareFileServerTool') - -interface ShareFileArgs { - path?: string - fileId?: string - action?: 'share' | 'unshare' - authType?: ShareAuthType - password?: string - allowedEmails?: string[] - args?: Record -} - -interface ShareFileResult { - success: boolean - message: string - data?: { - url: string - token: string - authType: ShareAuthType - hasPassword: boolean - isActive: boolean - } -} - -export const shareFileServerTool: BaseServerTool = { - name: ShareFile.id, - async execute(params: ShareFileArgs, context?: ServerToolContext): Promise { - if (!context?.userId) { - throw new Error('Authentication required') - } - const workspaceId = context.workspaceId - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - const nested = params.args - const path = params.path || (nested?.path as string) || '' - const legacyFileId = params.fileId || (nested?.fileId as string) || '' - const action = (params.action || (nested?.action as string) || 'share') as 'share' | 'unshare' - const authType = (params.authType || (nested?.authType as ShareAuthType | undefined)) as - | ShareAuthType - | undefined - const rawPassword = params.password || (nested?.password as string) || undefined - // "Protect it with the password in {{SHARE_PW}}" arrives as the literal - // reference — resolve it, or the placeholder becomes the real password. - const resolvedPassword = await resolveEnvReferenceSecretArg({ - userId: context.userId, - workspaceId: context.workspaceId, - value: rawPassword, - argName: 'password', - registry: context.resolvedSecretTraceRegistry, - }) - if (resolvedPassword.error) { - return { success: false, message: resolvedPassword.error } - } - const password = resolvedPassword.value - const allowedEmails = - params.allowedEmails || (nested?.allowedEmails as string[] | undefined) || undefined - - const targetRef = path || legacyFileId - if (!targetRef) return { success: false, message: 'path is required' } - - let existingFile - try { - existingFile = path - ? await resolveCopilotWorkspaceFileReference(context, fileOperations.updateShare, { - workspaceId, - reference: path, - }) - : ( - await executeCopilotFileUseCase( - context, - readWorkspaceFileMetadata, - { fileId: legacyFileId, assertedWorkspaceId: workspaceId }, - { fileId: legacyFileId } - ) - ).file - } catch (error) { - const classified = asOrchestrationError(error) - if (classified?.code !== 'not_found') throw error - return { success: false, message: `File not found: ${targetRef}` } - } - if (!existingFile) { - return { success: false, message: `File not found: ${targetRef}` } - } - assertServerToolNotAborted(context) - const isActive = action !== 'unshare' - try { - const result = await executeCopilotFileUseCase( - context, - updateWorkspaceFileShare, - { - fileId: existingFile.id, - assertedWorkspaceId: workspaceId, - isActive, - authType, - password, - allowedEmails, - noOpIfInactive: !isActive, - }, - { fileId: existingFile.id } - ) - const share = result.share - logger.info(`${isActive ? 'Enabled' : 'Disabled'} share for file via share_file`, { - fileId: existingFile.id, - workspaceId, - authType: share.authType, - userId: context.userId, - }) - - if (!isActive) { - return { - success: true, - message: `Stopped sharing "${existingFile.name}". The previous link no longer works.`, - data: { - url: share.url, - token: share.token, - authType: share.authType, - hasPassword: share.hasPassword, - isActive: share.isActive, - }, - } - } - - const authNote = - share.authType === 'password' - ? ' (password-protected — share the password separately)' - : share.authType === 'email' - ? ' (restricted to allowed emails via one-time code)' - : share.authType === 'sso' - ? ' (restricted to allowed emails via SSO)' - : '' - - return { - success: true, - message: `Shared "${existingFile.name}"${authNote}: ${share.url}`, - data: { - url: share.url, - token: share.token, - authType: share.authType, - hasPassword: share.hasPassword, - isActive: share.isActive, - }, - } - } catch (error) { - if (error instanceof WorkspaceFileShareNoopError) { - return { - success: true, - message: `"${existingFile.name}" isn't shared — nothing to unshare.`, - } - } - return { - success: false, - message: messageForCopilotFileError(error, 'Unable to update file sharing'), - } - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/generated-schema.test.ts b/apps/sim/lib/copilot/tools/server/generated-schema.test.ts deleted file mode 100644 index 3dc3af8aabb..00000000000 --- a/apps/sim/lib/copilot/tools/server/generated-schema.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generated-schema' -import { OrchestrationError } from '@/lib/core/orchestration/types' - -describe('validateGeneratedToolPayload browser_select_option parameters', () => { - it.each([ - { elementId: 0, value: 'a' }, - { elementId: 0, values: ['a', 'b'] }, - { elementId: 0, values: [] }, - ])('accepts a single selection mode %#', (payload) => { - expect(validateGeneratedToolPayload('browser_select_option', 'parameters', payload)).toBe( - payload - ) - }) - - it.each([ - { elementId: 0 }, - { elementId: 0, value: 'a', values: ['b'] }, - { elementId: 0, value: 'a', values: [] }, - { elementId: 0, values: [1] }, - { elementId: 0, values: Array.from({ length: 101 }, () => 'a') }, - ])('rejects missing, conflicting or malformed selection arguments %#', (payload) => { - expect(() => - validateGeneratedToolPayload('browser_select_option', 'parameters', payload) - ).toThrow(OrchestrationError) - }) -}) - -describe('validateGeneratedToolPayload browser_fill_form parameters', () => { - it('accepts mixed fields, including empty text and false checked state', () => { - const payload = { - fields: [ - { elementId: 0, kind: 'text', text: '' }, - { elementId: 1, kind: 'select', value: 'pro' }, - { elementId: 2, kind: 'checked', checked: false }, - ], - } - expect(validateGeneratedToolPayload('browser_fill_form', 'parameters', payload)).toBe(payload) - }) - - it.each([ - { fields: [] }, - { - fields: Array.from({ length: 9 }, (_, elementId) => ({ elementId, kind: 'text', text: '' })), - }, - { fields: [{ elementId: 1, kind: 'text' }] }, - { fields: [{ elementId: 1, kind: 'select' }] }, - { fields: [{ elementId: 1, kind: 'checked' }] }, - { fields: [{ elementId: 1, kind: 'text', text: 'a', value: 'a' }] }, - { fields: [{ elementId: 1, kind: 'select', value: 'a', checked: false }] }, - { fields: [{ elementId: 1, kind: 'checked', checked: false, text: '' }] }, - { fields: [{ elementId: 1, kind: 'checked', checked: 'false' }] }, - { fields: [{ elementId: 1, kind: 'text', text: null }] }, - { fields: [{ elementId: 1, kind: 'text', text: '', submit: true }] }, - { fields: [{ elementId: -1, kind: 'text', text: '' }] }, - { fields: [{ elementId: 1.5, kind: 'text', text: '' }] }, - { fields: [{ elementId: Number.MAX_SAFE_INTEGER + 1, kind: 'text', text: '' }] }, - { fields: [{ elementId: 1, kind: 'text', text: 'a'.repeat(4097) }] }, - { fields: [{ elementId: 1, kind: 'select', value: 'a'.repeat(4097) }] }, - { fields: [{ elementId: 1, kind: 'text', text: '' }], submit: true }, - ])('rejects malformed form payload %# through the generated contract', (payload) => { - expect(() => validateGeneratedToolPayload('browser_fill_form', 'parameters', payload)).toThrow( - OrchestrationError - ) - }) -}) - -/** - * The shapes below are what an agent actually sent when the catalog advertised - * `updates` as a bare array: the provider-path sanitizer filled the missing - * `items` with {type: "string"}, so the model produced `["rowId", "data"]` and - * `[]`, and the executor crashed on the strings. With the item schema synced - * from the catalog, the router refuses them as a classified input error the - * model can correct. - */ -describe('validateGeneratedToolPayload table_rows parameters', () => { - it('rejects string elements in updates as the caller error they are', () => { - expect(() => - validateGeneratedToolPayload('table_rows', 'parameters', { - operation: 'batch_update_rows', - args: { tableId: 'tbl_1', updates: ['rowId', 'data'] }, - }) - ).toThrow(OrchestrationError) - expect(() => - validateGeneratedToolPayload('table_rows', 'parameters', { - operation: 'batch_update_rows', - args: { tableId: 'tbl_1', updates: ['rowId', 'data'] }, - }) - ).toThrow(/\/args\/updates\/0 must be object/) - }) - - it('rejects an update patch that omits its data object', () => { - expect(() => - validateGeneratedToolPayload('table_rows', 'parameters', { - operation: 'batch_update_rows', - args: { tableId: 'tbl_1', updates: [{ rowId: 'row-1' }] }, - }) - ).toThrow(/\/args\/updates\/0 must have required property 'data'/) - }) - - it('rejects a non-object row in batch_insert_rows', () => { - expect(() => - validateGeneratedToolPayload('table_rows', 'parameters', { - operation: 'batch_insert_rows', - args: { tableId: 'tbl_1', rows: [{ name: 'Ada' }, 'Bob'] }, - }) - ).toThrow(/\/args\/rows\/1 must be object/) - }) - - it('accepts the documented per-row patch shape', () => { - const payload = { - operation: 'batch_update_rows', - args: { tableId: 'tbl_1', updates: [{ rowId: 'row-1', data: { status: 'active' } }] }, - } - expect(validateGeneratedToolPayload('table_rows', 'parameters', payload)).toBe(payload) - }) - - it('accepts a sort spec on query_user_table order', () => { - const payload = { - operation: 'query_rows', - args: { tableId: 'tbl_1', order: [{ field: 'age', direction: 'desc' }] }, - } - expect(validateGeneratedToolPayload('query_user_table', 'parameters', payload)).toBe(payload) - expect(() => - validateGeneratedToolPayload('query_user_table', 'parameters', { - operation: 'query_rows', - args: { tableId: 'tbl_1', order: ['age'] }, - }) - ).toThrow(/\/args\/order\/0 must be object/) - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts deleted file mode 100644 index 4e0977f410a..00000000000 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.test.ts +++ /dev/null @@ -1,1053 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockAddWorkspaceFiles, - mockBulkDeleteKnowledgeBases, - mockBulkDeleteKnowledgeDocuments, - mockCaptureServerEvent, - mockCreateKnowledgeBase, - mockDeleteKnowledgeConnector, - mockDeleteKnowledgeTag, - mockKnowledgeBaseCreated, - mockKnowledgeBaseDeleted, - mockKnowledgeBaseDocumentsUploaded, - mockReadKnowledgeBase, - mockReadKnowledgeTagUsage, - mockSearchKnowledge, - mockSyncKnowledgeConnector, - mockUpdateKnowledgeBase, - mockUpdateKnowledgeConnector, - mockUpdateKnowledgeDocument, - mockUpdateKnowledgeTag, - mockCreateKnowledgeConnector, - mockCreateKnowledgeTag, - mockListKnowledgeTags, - mockIsKnowledgeMemberAccessAvailable, - knowledgeOperations, -} = vi.hoisted(() => { - const defineOperation = (id: string, minimumRole: 'read' | 'write') => - Object.freeze({ - id, - minimumRole, - workspaceApiKey: 'deny' as const, - principalKinds: ['session', 'personal_api_key', 'delegated'] as const, - delegatedServices: ['copilot'] as const, - }) - - return { - mockAddWorkspaceFiles: vi.fn(), - mockBulkDeleteKnowledgeBases: vi.fn(), - mockBulkDeleteKnowledgeDocuments: vi.fn(), - mockCaptureServerEvent: vi.fn(), - mockCreateKnowledgeBase: vi.fn(), - mockDeleteKnowledgeConnector: vi.fn(), - mockDeleteKnowledgeTag: vi.fn(), - mockKnowledgeBaseCreated: vi.fn(), - mockKnowledgeBaseDeleted: vi.fn(), - mockKnowledgeBaseDocumentsUploaded: vi.fn(), - mockReadKnowledgeBase: vi.fn(), - mockReadKnowledgeTagUsage: vi.fn(), - mockSearchKnowledge: vi.fn(), - mockSyncKnowledgeConnector: vi.fn(), - mockUpdateKnowledgeBase: vi.fn(), - mockUpdateKnowledgeConnector: vi.fn(), - mockUpdateKnowledgeDocument: vi.fn(), - mockUpdateKnowledgeTag: vi.fn(), - mockCreateKnowledgeConnector: vi.fn(), - mockCreateKnowledgeTag: vi.fn(), - mockListKnowledgeTags: vi.fn(), - mockIsKnowledgeMemberAccessAvailable: vi.fn(), - knowledgeOperations: { - addWorkspaceFiles: defineOperation('knowledge.documents.add_workspace_files', 'write'), - bulkDelete: defineOperation('knowledge.bulk_delete', 'write'), - bulkDeleteDocuments: defineOperation('knowledge.documents.bulk_delete', 'write'), - create: defineOperation('knowledge.create', 'write'), - createConnector: defineOperation('knowledge.connectors.create', 'write'), - createTag: defineOperation('knowledge.tags.create', 'write'), - deleteConnector: defineOperation('knowledge.connectors.delete', 'write'), - deleteTag: defineOperation('knowledge.tags.delete', 'write'), - listTags: defineOperation('knowledge.tags.list', 'read'), - read: defineOperation('knowledge.read', 'read'), - readTagUsage: defineOperation('knowledge.tags.read_usage', 'read'), - search: defineOperation('knowledge.search', 'read'), - syncConnector: defineOperation('knowledge.connectors.sync', 'write'), - update: defineOperation('knowledge.update', 'write'), - updateConnector: defineOperation('knowledge.connectors.update', 'write'), - updateDocument: defineOperation('knowledge.documents.update', 'write'), - updateTag: defineOperation('knowledge.tags.update', 'write'), - }, - } -}) - -vi.mock('@/lib/copilot/chat/organization-chats', () => ({ - authorizeOrganizationChatDelegation: { execute: vi.fn() }, -})) - -vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ - ManageKnowledgeBase: { id: 'manage_knowledge_base' }, -})) -const { mockGetEffectiveEnvironmentSnapshot } = vi.hoisted(() => ({ - mockGetEffectiveEnvironmentSnapshot: vi.fn(), -})) -vi.mock('@/lib/environment/utils', () => ({ - getEffectiveEnvironmentSnapshot: mockGetEffectiveEnvironmentSnapshot, -})) -vi.mock('@/lib/core/telemetry', () => ({ - PlatformEvents: { - knowledgeBaseCreated: mockKnowledgeBaseCreated, - knowledgeBaseDeleted: mockKnowledgeBaseDeleted, - knowledgeBaseDocumentsUploaded: mockKnowledgeBaseDocumentsUploaded, - }, -})) -vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) -vi.mock('@/lib/knowledge/application/operations', () => ({ knowledgeOperations })) -vi.mock('@/lib/knowledge/access/availability', () => ({ - isKnowledgeMemberAccessAvailable: mockIsKnowledgeMemberAccessAvailable, -})) -vi.mock('@/lib/knowledge/application/add-workspace-files', () => ({ - addWorkspaceFilesToKnowledgeBase: { - operation: knowledgeOperations.addWorkspaceFiles, - execute: mockAddWorkspaceFiles, - }, -})) -vi.mock('@/lib/knowledge/application/knowledge-bases', () => ({ - bulkDeleteKnowledgeBases: { - operation: knowledgeOperations.bulkDelete, - execute: mockBulkDeleteKnowledgeBases, - }, - createKnowledgeBase: { - operation: knowledgeOperations.create, - execute: mockCreateKnowledgeBase, - }, - readKnowledgeBase: { operation: knowledgeOperations.read, execute: mockReadKnowledgeBase }, - updateKnowledgeBaseOperation: { - operation: knowledgeOperations.update, - execute: mockUpdateKnowledgeBase, - }, -})) -vi.mock('@/lib/knowledge/application/documents', () => ({ - bulkDeleteKnowledgeDocuments: { - operation: knowledgeOperations.bulkDeleteDocuments, - execute: mockBulkDeleteKnowledgeDocuments, - }, - updateKnowledgeDocument: { - operation: knowledgeOperations.updateDocument, - execute: mockUpdateKnowledgeDocument, - }, -})) -vi.mock('@/lib/knowledge/application/search', () => ({ - searchKnowledge: { operation: knowledgeOperations.search, execute: mockSearchKnowledge }, -})) -vi.mock('@/lib/knowledge/application/connectors', () => ({ - createKnowledgeConnector: { - operation: knowledgeOperations.createConnector, - execute: mockCreateKnowledgeConnector, - }, - updateKnowledgeConnector: { - operation: knowledgeOperations.updateConnector, - execute: mockUpdateKnowledgeConnector, - }, - deleteKnowledgeConnector: { - operation: knowledgeOperations.deleteConnector, - execute: mockDeleteKnowledgeConnector, - }, - syncKnowledgeConnector: { - operation: knowledgeOperations.syncConnector, - execute: mockSyncKnowledgeConnector, - }, -})) -vi.mock('@/lib/knowledge/application/tags', () => ({ - createKnowledgeTag: { - operation: knowledgeOperations.createTag, - execute: mockCreateKnowledgeTag, - }, - deleteKnowledgeTag: { - operation: knowledgeOperations.deleteTag, - execute: mockDeleteKnowledgeTag, - }, - listKnowledgeTags: { - operation: knowledgeOperations.listTags, - execute: mockListKnowledgeTags, - }, - readKnowledgeTagUsage: { - operation: knowledgeOperations.readTagUsage, - execute: mockReadKnowledgeTagUsage, - }, - updateKnowledgeTag: { - operation: knowledgeOperations.updateTag, - execute: mockUpdateKnowledgeTag, - }, -})) - -import type { ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' - -const KNOWLEDGE_BASE = { - id: 'knowledge-base-1', - name: 'Private KB', - description: 'Private documentation', - workspaceId: 'workspace-paid', - docCount: 2, - tokenCount: 42, - embeddingModel: 'text-embedding-3-small', - chunkingConfig: { maxSize: 1024, minSize: 100, overlap: 200 }, - createdAt: new Date('2026-08-01T00:00:00.000Z'), - updatedAt: new Date('2026-08-02T00:00:00.000Z'), -} - -const CONTEXT = { - userId: 'external-admin', - workspaceId: 'workspace-paid', - chatId: 'chat-1', - executionId: 'execution-1', - toolCallId: 'tool-call-1', - copilotToolExecution: true, -} satisfies ServerToolContext - -const BILLED_CONTEXT = { - ...CONTEXT, - billingAttribution: { - actorUserId: 'external-admin', - workspaceId: 'workspace-paid', - billedAccountUserId: 'workspace-owner', - organizationId: null, - billingEntity: { type: 'user' as const, id: 'workspace-owner' }, - billingPeriod: { - start: '2026-08-01T00:00:00.000Z', - end: '2026-09-01T00:00:00.000Z', - }, - payerSubscription: null, - }, -} satisfies ServerToolContext - -function expectDelegatedPrincipal(call: unknown): void { - expect(call).toMatchObject({ - principal: { - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: 'external-admin', - workspaceId: 'workspace-paid', - delegationId: 'tool-call-1', - audience: 'sim:knowledge', - resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, - }, - }) -} - -describe('manage_knowledge_base trusted application delegation', () => { - beforeEach(() => { - vi.clearAllMocks() - mockReadKnowledgeBase.mockResolvedValue({ knowledgeBase: KNOWLEDGE_BASE, folderPath: '/' }) - mockCreateKnowledgeBase.mockResolvedValue({ knowledgeBase: KNOWLEDGE_BASE, folderPath: '/' }) - mockUpdateKnowledgeBase.mockResolvedValue({ knowledgeBase: KNOWLEDGE_BASE, folderPath: '/' }) - mockBulkDeleteKnowledgeBases.mockResolvedValue({ - deleted: [{ id: KNOWLEDGE_BASE.id, name: KNOWLEDGE_BASE.name }], - notFound: [], - failed: [], - }) - mockBulkDeleteKnowledgeDocuments.mockResolvedValue({ - knowledgeBaseId: KNOWLEDGE_BASE.id, - deleted: ['document-1'], - failed: [], - deletedDocuments: [], - }) - mockAddWorkspaceFiles.mockResolvedValue({ - knowledgeBaseId: KNOWLEDGE_BASE.id, - knowledgeBaseName: KNOWLEDGE_BASE.name, - added: [ - { - documentId: 'document-1', - filename: 'report.pdf', - fileSize: 100, - mimeType: 'application/pdf', - }, - ], - failed: [], - }) - mockIsKnowledgeMemberAccessAvailable.mockResolvedValue(true) - mockSearchKnowledge.mockResolvedValue({ - results: [], - query: 'query', - knowledgeBaseIds: [KNOWLEDGE_BASE.id], - knowledgeBases: [{ id: KNOWLEDGE_BASE.id, name: KNOWLEDGE_BASE.name }], - topK: 5, - totalResults: 0, - }) - mockCreateKnowledgeConnector.mockResolvedValue({ - connector: { - id: 'connector-1', - knowledgeBaseId: KNOWLEDGE_BASE.id, - connectorType: 'notion', - status: 'active', - syncIntervalMinutes: 1440, - }, - workspaceId: 'workspace-paid', - }) - mockDeleteKnowledgeConnector.mockResolvedValue({ - knowledgeBaseId: KNOWLEDGE_BASE.id, - workspaceId: 'workspace-paid', - connectorId: 'connector-1', - connectorType: 'notion', - documentsDeleted: 0, - documentsKept: 2, - }) - mockSyncKnowledgeConnector.mockResolvedValue({ - knowledgeBaseId: KNOWLEDGE_BASE.id, - workspaceId: 'workspace-paid', - connectorId: 'connector-1', - connectorType: 'notion', - }) - }) - - it.each([ - [{ ...CONTEXT, copilotToolExecution: false }, 'trusted Copilot execution context'], - [{ ...CONTEXT, workspaceId: undefined }, 'workspace ID'], - [{ ...CONTEXT, toolCallId: undefined }, 'tool call ID'], - [{ ...CONTEXT, userId: '' }, 'authenticated user ID'], - ])('rejects incomplete server-authored context', async (context, message) => { - await expect( - knowledgeBaseServerTool.execute( - { operation: 'get', args: { knowledgeBaseId: KNOWLEDGE_BASE.id } }, - context - ) - ).rejects.toThrow(message) - expect(mockReadKnowledgeBase).not.toHaveBeenCalled() - }) - - it('creates in the trusted workspace and ignores a model workspace field', async () => { - const result = await knowledgeBaseServerTool.execute( - { - operation: 'create', - args: { name: 'Private KB', workspaceId: 'model-controlled-workspace' }, - }, - CONTEXT - ) - - expect(result.success).toBe(true) - const call = mockCreateKnowledgeBase.mock.calls[0][0] - expectDelegatedPrincipal(call) - expect(call.input).toMatchObject({ - workspaceId: 'workspace-paid', - name: 'Private KB', - source: 'agent', - }) - expect(mockKnowledgeBaseCreated).toHaveBeenCalledWith({ - knowledgeBaseId: KNOWLEDGE_BASE.id, - name: KNOWLEDGE_BASE.name, - workspaceId: 'workspace-paid', - }) - expect(mockCaptureServerEvent).toHaveBeenCalledWith( - 'external-admin', - 'knowledge_base_created', - expect.objectContaining({ workspace_id: 'workspace-paid' }), - expect.any(Object) - ) - }) - - it('reads through the canonical application operation', async () => { - const result = await knowledgeBaseServerTool.execute( - { operation: 'get', args: { knowledgeBaseId: KNOWLEDGE_BASE.id } }, - CONTEXT - ) - - expect(result.success).toBe(true) - const call = mockReadKnowledgeBase.mock.calls[0][0] - expectDelegatedPrincipal(call) - expect(call.input).toEqual({ - knowledgeBaseId: KNOWLEDGE_BASE.id, - assertedWorkspaceId: 'workspace-paid', - }) - }) - - it('projects query secrets before delegating search and passes only the trusted registry', async () => { - const controller = new AbortController() - const registry = new ResolvedSecretTraceRegistry([ - { - name: 'KB_QUERY', - plaintext: 'private query', - encryptedValue: 'encrypted-query', - }, - ]) - registry.recordResolved('KB_QUERY', 'private query') - mockSearchKnowledge.mockResolvedValueOnce({ - results: [ - { - embeddingId: 'embedding-1', - documentId: 'document-1', - documentName: 'doc.pdf', - sourceUrl: null, - content: 'result', - chunkIndex: 0, - metadata: {}, - similarity: 0.9, - }, - ], - query: '{{KB_QUERY}}', - knowledgeBaseIds: [KNOWLEDGE_BASE.id], - knowledgeBases: [{ id: KNOWLEDGE_BASE.id, name: KNOWLEDGE_BASE.name }], - topK: 5, - totalResults: 1, - }) - - const result = await knowledgeBaseServerTool.execute( - { - operation: 'query', - args: { knowledgeBaseId: KNOWLEDGE_BASE.id, query: 'private query' }, - }, - { ...CONTEXT, resolvedSecretTraceRegistry: registry, abortSignal: controller.signal } - ) - - expect(result).toMatchObject({ - success: true, - data: { query: 'private query', results: [{ similarity: 0.9 }] }, - }) - const call = mockSearchKnowledge.mock.calls[0][0] - expectDelegatedPrincipal(call) - expect(call.input).toEqual({ - workspaceId: 'workspace-paid', - knowledgeBaseIds: [KNOWLEDGE_BASE.id], - query: '{{KB_QUERY}}', - topK: 5, - surface: 'copilot', - resultSecretRegistry: registry, - signal: controller.signal, - }) - expect(mockReadKnowledgeBase).not.toHaveBeenCalled() - }) - - it('attributes knowledge queries to the trusted Slack context', async () => { - const result = await knowledgeBaseServerTool.execute( - { operation: 'query', args: { knowledgeBaseId: KNOWLEDGE_BASE.id, query: 'query' } }, - { - ...CONTEXT, - resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), - searchSurface: 'slack', - } - ) - - expect(result.success).toBe(true) - expect(mockSearchKnowledge).toHaveBeenCalledWith( - expect.objectContaining({ input: expect.objectContaining({ surface: 'slack' }) }) - ) - }) - - it('asks for citations where per-member access is on', async () => { - const result = await knowledgeBaseServerTool.execute( - { operation: 'query', args: { knowledgeBaseId: KNOWLEDGE_BASE.id, query: 'query' } }, - { ...CONTEXT, resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry() } - ) - - expect(result.message).toContain('') - expect(mockIsKnowledgeMemberAccessAvailable).toHaveBeenCalledWith({ - workspaceId: 'workspace-paid', - }) - }) - - it('asks for no citation where the workspace cannot render one', async () => { - mockIsKnowledgeMemberAccessAvailable.mockResolvedValue(false) - - const result = await knowledgeBaseServerTool.execute( - { operation: 'query', args: { knowledgeBaseId: KNOWLEDGE_BASE.id, query: 'query' } }, - { ...CONTEXT, resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry() } - ) - - expect(result).toMatchObject({ success: true }) - expect(result.message).toBe('Found 0 result(s) for query "query".') - }) - - it('answers without citations when the eligibility lookup fails, rather than failing the query', async () => { - mockIsKnowledgeMemberAccessAvailable.mockRejectedValueOnce(new Error('billing unavailable')) - - const result = await knowledgeBaseServerTool.execute( - { operation: 'query', args: { knowledgeBaseId: KNOWLEDGE_BASE.id, query: 'query' } }, - { ...CONTEXT, resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry() } - ) - - expect(result).toMatchObject({ success: true }) - expect(result.message).toBe('Found 0 result(s) for query "query".') - }) - - it('returns a safe model result for search infrastructure failures', async () => { - mockSearchKnowledge.mockRejectedValueOnce(new Error('database unavailable')) - - const result = await knowledgeBaseServerTool.execute( - { operation: 'query', args: { knowledgeBaseId: KNOWLEDGE_BASE.id, query: 'query' } }, - { ...CONTEXT, resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry() } - ) - - expect(result).toEqual({ success: false, message: 'Failed to query knowledge base' }) - expect(result.message).not.toContain('database unavailable') - }) - - it('updates through the semantic operation', async () => { - const result = await knowledgeBaseServerTool.execute( - { operation: 'update', args: { knowledgeBaseId: KNOWLEDGE_BASE.id, name: 'Renamed' } }, - CONTEXT - ) - - expect(result.success).toBe(true) - const call = mockUpdateKnowledgeBase.mock.calls[0][0] - expectDelegatedPrincipal(call) - expect(call.input).toMatchObject({ - knowledgeBaseId: KNOWLEDGE_BASE.id, - assertedWorkspaceId: 'workspace-paid', - name: 'Renamed', - source: 'agent', - }) - }) - - it('delegates the unexposed delete compatibility path once to the bulk command', async () => { - const result = await knowledgeBaseServerTool.execute( - { operation: 'delete', args: { knowledgeBaseId: KNOWLEDGE_BASE.id } }, - CONTEXT - ) - - expect(result).toMatchObject({ - success: true, - data: { deleted: [{ id: KNOWLEDGE_BASE.id, name: KNOWLEDGE_BASE.name }] }, - }) - const call = mockBulkDeleteKnowledgeBases.mock.calls[0][0] - expectDelegatedPrincipal(call) - expect(call.input).toMatchObject({ - assertedWorkspaceId: 'workspace-paid', - knowledgeBaseIds: [KNOWLEDGE_BASE.id], - source: 'agent', - }) - }) - - it('keeps classified delete failures in the batch result', async () => { - mockBulkDeleteKnowledgeBases.mockResolvedValueOnce({ - deleted: [], - notFound: [], - failed: [ - { id: KNOWLEDGE_BASE.id, name: KNOWLEDGE_BASE.name, reason: 'Knowledge base is locked' }, - ], - }) - - const result = await knowledgeBaseServerTool.execute( - { operation: 'delete', args: { knowledgeBaseId: KNOWLEDGE_BASE.id } }, - CONTEXT - ) - - expect(result).toMatchObject({ - success: false, - data: { - notFound: [], - failed: [ - { id: KNOWLEDGE_BASE.id, name: KNOWLEDGE_BASE.name, reason: 'Knowledge base is locked' }, - ], - }, - }) - }) - - it('delegates document deletion and retains partial batch results', async () => { - mockBulkDeleteKnowledgeDocuments.mockResolvedValueOnce({ - knowledgeBaseId: KNOWLEDGE_BASE.id, - deleted: ['document-1'], - failed: ['missing'], - deletedDocuments: [ - { - id: 'document-1', - filename: 'guide.pdf', - fileSize: 42, - mimeType: 'application/pdf', - }, - ], - }) - - const result = await knowledgeBaseServerTool.execute( - { - operation: 'delete_document', - args: { knowledgeBaseId: KNOWLEDGE_BASE.id, documentIds: ['missing', 'document-1'] }, - }, - CONTEXT - ) - - expect(result).toMatchObject({ - success: true, - data: { deleted: ['document-1'], failed: ['missing'] }, - }) - expectDelegatedPrincipal(mockBulkDeleteKnowledgeDocuments.mock.calls[0][0]) - expect(mockBulkDeleteKnowledgeDocuments).toHaveBeenCalledOnce() - expect(mockCaptureServerEvent).toHaveBeenCalledWith( - 'external-admin', - 'knowledge_base_document_deleted', - expect.objectContaining({ knowledge_base_id: KNOWLEDGE_BASE.id }), - expect.any(Object) - ) - }) - - it.each([ - [ - 'add_connector', - { - knowledgeBaseId: KNOWLEDGE_BASE.id, - connectorType: 'notion', - credentialId: 'credential-1', - }, - 'knowledge_base_connector_added', - ], - ['delete_connector', { connectorId: 'connector-1' }, 'knowledge_base_connector_removed'], - ['sync_connector', { connectorId: 'connector-1' }, 'knowledge_base_connector_synced'], - ])( - 'records %s product analytics only after application success', - async (operation, args, event) => { - const result = await knowledgeBaseServerTool.execute({ operation, args }, BILLED_CONTEXT) - - expect(result.success).toBe(true) - expect(mockCaptureServerEvent).toHaveBeenCalledWith( - 'external-admin', - event, - expect.objectContaining({ workspace_id: 'workspace-paid' }), - expect.any(Object) - ) - } - ) - - it('delegates document updates with only the trusted workspace assertion', async () => { - mockUpdateKnowledgeDocument.mockResolvedValueOnce({ document: {}, updatedFields: ['filename'] }) - - const result = await knowledgeBaseServerTool.execute( - { - operation: 'update_document', - args: { - knowledgeBaseId: KNOWLEDGE_BASE.id, - documentId: 'document-1', - filename: 'renamed.pdf', - }, - }, - CONTEXT - ) - - expect(result).toMatchObject({ success: true, data: { documentId: 'document-1' } }) - const call = mockUpdateKnowledgeDocument.mock.calls[0][0] - expectDelegatedPrincipal(call) - expect(call.input).toEqual({ - knowledgeBaseId: KNOWLEDGE_BASE.id, - documentId: 'document-1', - assertedWorkspaceId: 'workspace-paid', - filename: 'renamed.pdf', - source: 'agent', - }) - }) - - it('delegates per-document typed tag values by tag definition ID', async () => { - mockUpdateKnowledgeDocument.mockResolvedValueOnce({ - document: {}, - updatedFields: ['tag1', 'number1'], - }) - - const result = await knowledgeBaseServerTool.execute( - { - operation: 'update_document', - args: { - knowledgeBaseId: KNOWLEDGE_BASE.id, - documentId: 'document-1', - tagValues: [ - { tagDefinitionId: 'category-tag', value: 'support' }, - { tagDefinitionId: 'priority-tag', value: 2 }, - ], - }, - }, - CONTEXT - ) - - expect(result).toMatchObject({ - success: true, - data: { - documentId: 'document-1', - tagDefinitionIds: ['category-tag', 'priority-tag'], - }, - }) - const call = mockUpdateKnowledgeDocument.mock.calls[0][0] - expectDelegatedPrincipal(call) - expect(call.input).toEqual({ - knowledgeBaseId: KNOWLEDGE_BASE.id, - documentId: 'document-1', - assertedWorkspaceId: 'workspace-paid', - tagValues: [ - { tagDefinitionId: 'category-tag', value: 'support' }, - { tagDefinitionId: 'priority-tag', value: 2 }, - ], - source: 'agent', - }) - }) - - it('does not expose connector infrastructure errors to the model', async () => { - mockUpdateKnowledgeConnector.mockRejectedValueOnce(new Error('sql host=private-db')) - - const result = await knowledgeBaseServerTool.execute( - { - operation: 'update_connector', - args: { connectorId: 'connector-1', connectorStatus: 'paused' }, - }, - CONTEXT - ) - - expect(result).toEqual({ - success: false, - message: 'Failed to update connector', - }) - expect(result.message).not.toContain('private-db') - }) - - it('passes trusted billing attribution for a source-change sync', async () => { - mockUpdateKnowledgeConnector.mockResolvedValueOnce({ - connector: { - id: 'connector-1', - knowledgeBaseId: KNOWLEDGE_BASE.id, - connectorType: 'notion', - sourceConfig: { pageIds: ['page-2'] }, - status: 'active', - }, - }) - - const result = await knowledgeBaseServerTool.execute( - { - operation: 'update_connector', - args: { connectorId: 'connector-1', sourceConfig: { pageIds: ['page-2'] } }, - }, - BILLED_CONTEXT - ) - - expect(result).toMatchObject({ - success: true, - message: 'Connector updated successfully. Synchronization was queued for the source change.', - }) - const call = mockUpdateKnowledgeConnector.mock.calls[0]?.[0] as { - input?: { resolveBillingAttribution?: (workspaceId: string) => Promise } - } - expectDelegatedPrincipal(call) - if (!call.input?.resolveBillingAttribution) { - throw new Error('Copilot connector update did not provide billing attribution') - } - await expect(call.input.resolveBillingAttribution('workspace-paid')).resolves.toEqual( - BILLED_CONTEXT.billingAttribution - ) - }) - - it.each([ - [ - 'update_document', - { - knowledgeBaseId: KNOWLEDGE_BASE.id, - documentId: 'document-1', - filename: 'renamed.pdf', - }, - mockUpdateKnowledgeDocument, - ], - [ - 'update_tag', - { - knowledgeBaseId: KNOWLEDGE_BASE.id, - tagDefinitionId: 'tag-1', - displayName: 'Renamed', - }, - mockUpdateKnowledgeTag, - ], - ])('does not expose %s infrastructure details to the model', async (operation, args, useCase) => { - useCase.mockRejectedValueOnce(new Error('database password=private')) - - const result = await knowledgeBaseServerTool.execute({ operation, args }, CONTEXT) - - expect(result.success).toBe(false) - expect(result.message).not.toContain('database') - expect(result.message).not.toContain('private') - }) - - it('preserves caller-actionable connector failure messages', async () => { - mockUpdateKnowledgeConnector.mockRejectedValueOnce( - new OrchestrationError('validation', 'At least one connector update is required') - ) - - const result = await knowledgeBaseServerTool.execute( - { - operation: 'update_connector', - args: { connectorId: 'connector-1', connectorStatus: 'paused' }, - }, - CONTEXT - ) - - expect(result).toEqual({ - success: false, - message: 'At least one connector update is required', - }) - }) - - it('preserves credential access guidance for connector creation', async () => { - mockCreateKnowledgeConnector.mockRejectedValueOnce( - new OrchestrationError( - 'validation', - 'Credential is not available to you in this workspace. Ask a credential administrator to grant access or select another credential.' - ) - ) - - const result = await knowledgeBaseServerTool.execute( - { - operation: 'add_connector', - args: { - knowledgeBaseId: KNOWLEDGE_BASE.id, - connectorType: 'notion', - credentialId: 'credential-1', - }, - }, - CONTEXT - ) - - expect(result).toEqual({ - success: false, - message: - 'Credential is not available to you in this workspace. Ask a credential administrator to grant access or select another credential.', - }) - }) - - it.each(['{{SIM_GITHUB_PAT}}', '$SIM_GITHUB_PAT', 'SIM_GITHUB_PAT'])( - 'resolves the %s environment reference into the connector API key', - async (ref) => { - mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({ - personalEncrypted: {}, - personalDecrypted: {}, - workspaceEncrypted: { SIM_GITHUB_PAT: 'encrypted-token' }, - workspaceDecrypted: { SIM_GITHUB_PAT: 'ghp_realtoken' }, - }) - - const result = await knowledgeBaseServerTool.execute( - { - operation: 'add_connector', - args: { knowledgeBaseId: KNOWLEDGE_BASE.id, connectorType: 'github', apiKey: ref }, - }, - BILLED_CONTEXT - ) - - expect(result.success).toBe(true) - const call = mockCreateKnowledgeConnector.mock.calls.at(-1)?.[0] as { - input: { apiKey?: string } - } - expect(call.input.apiKey).toBe('ghp_realtoken') - } - ) - - it('names the missing variable instead of sending a placeholder upstream', async () => { - mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({ - personalEncrypted: {}, - personalDecrypted: {}, - workspaceEncrypted: {}, - workspaceDecrypted: {}, - }) - - const result = await knowledgeBaseServerTool.execute( - { - operation: 'add_connector', - args: { - knowledgeBaseId: KNOWLEDGE_BASE.id, - connectorType: 'github', - apiKey: '{{SIM_GITHUB_PAT}}', - }, - }, - BILLED_CONTEXT - ) - - expect(result.success).toBe(false) - expect(result.message).toContain('SIM_GITHUB_PAT') - expect(result.message).toContain('not set') - expect(mockCreateKnowledgeConnector).not.toHaveBeenCalled() - }) - - it('passes a raw API key through untouched', async () => { - mockGetEffectiveEnvironmentSnapshot.mockResolvedValue({ - personalEncrypted: {}, - personalDecrypted: {}, - workspaceEncrypted: { SIM_GITHUB_PAT: 'encrypted-token' }, - workspaceDecrypted: { SIM_GITHUB_PAT: 'ghp_realtoken' }, - }) - - const result = await knowledgeBaseServerTool.execute( - { - operation: 'add_connector', - args: { - knowledgeBaseId: KNOWLEDGE_BASE.id, - connectorType: 'github', - apiKey: 'ghp_literal_key', - }, - }, - BILLED_CONTEXT - ) - - expect(result.success).toBe(true) - const call = mockCreateKnowledgeConnector.mock.calls.at(-1)?.[0] as { - input: { apiKey?: string } - } - expect(call.input.apiKey).toBe('ghp_literal_key') - }) - - it('preserves caller-actionable tag provenance conflicts', async () => { - mockDeleteKnowledgeTag.mockRejectedValueOnce( - new OrchestrationError( - 'conflict', - 'Tag definitions cannot be deleted while resolved-secret document provenance is present' - ) - ) - - const result = await knowledgeBaseServerTool.execute( - { - operation: 'delete_tag', - args: { knowledgeBaseId: KNOWLEDGE_BASE.id, tagDefinitionId: 'tag-1' }, - }, - CONTEXT - ) - - expect(result).toEqual({ - success: false, - message: - 'Failed to delete_tag knowledge base: Tag definitions cannot be deleted while resolved-secret document provenance is present', - }) - }) - - it.each([ - { - operation: 'add_file', - args: { knowledgeBaseId: KNOWLEDGE_BASE.id, filePaths: Array(101).fill('files/doc.pdf') }, - }, - { - operation: 'delete', - args: { knowledgeBaseIds: Array.from({ length: 101 }, (_, index) => `kb-${index}`) }, - }, - { - operation: 'delete_document', - args: { - knowledgeBaseId: KNOWLEDGE_BASE.id, - documentIds: Array.from({ length: 101 }, (_, index) => `document-${index}`), - }, - }, - ])( - 'rejects oversized $operation batches before application work', - async ({ operation, args }) => { - const result = await knowledgeBaseServerTool.execute({ operation, args }, CONTEXT) - - expect(result.success).toBe(false) - expect(result.message).toContain('Maximum is 100') - expect(mockReadKnowledgeBase).not.toHaveBeenCalled() - expect(mockBulkDeleteKnowledgeBases).not.toHaveBeenCalled() - expect(mockBulkDeleteKnowledgeDocuments).not.toHaveBeenCalled() - expect(mockAddWorkspaceFiles).not.toHaveBeenCalled() - } - ) -}) - -describe('manage_knowledge_base add_file delegation', () => { - beforeEach(() => { - vi.clearAllMocks() - mockAddWorkspaceFiles.mockResolvedValue({ - knowledgeBaseId: KNOWLEDGE_BASE.id, - knowledgeBaseName: KNOWLEDGE_BASE.name, - added: [ - { - documentId: 'document-1', - filename: 'report.pdf', - fileSize: 100, - mimeType: 'application/pdf', - }, - ], - failed: [], - }) - }) - - it('maps aliases and delegates the complete batch once to the application command', async () => { - const result = await knowledgeBaseServerTool.execute( - { - operation: 'add_file', - args: { knowledgeBaseId: KNOWLEDGE_BASE.id, filePaths: ['files/report.pdf'] }, - }, - CONTEXT - ) - - expect(result).toMatchObject({ - success: true, - data: { added: [{ documentId: 'document-1', filename: 'report.pdf' }] }, - }) - const call = mockAddWorkspaceFiles.mock.calls[0][0] - expectDelegatedPrincipal(call) - expect(call.input).toMatchObject({ - knowledgeBaseId: KNOWLEDGE_BASE.id, - assertedWorkspaceId: 'workspace-paid', - fileReferences: ['files/report.pdf'], - source: 'agent', - }) - expect(mockAddWorkspaceFiles).toHaveBeenCalledOnce() - expect(mockKnowledgeBaseDocumentsUploaded).toHaveBeenCalledWith( - expect.objectContaining({ knowledgeBaseId: KNOWLEDGE_BASE.id, documentsCount: 1 }) - ) - expect(mockCaptureServerEvent).toHaveBeenCalledWith( - 'external-admin', - 'knowledge_base_document_uploaded', - expect.objectContaining({ knowledge_base_id: KNOWLEDGE_BASE.id }), - expect.any(Object) - ) - }) - - it('preserves explicit partial failures returned by the application command', async () => { - mockAddWorkspaceFiles.mockResolvedValueOnce({ - knowledgeBaseId: KNOWLEDGE_BASE.id, - knowledgeBaseName: KNOWLEDGE_BASE.name, - added: [], - failed: ['files/report.pdf'], - }) - - const result = await knowledgeBaseServerTool.execute( - { - operation: 'add_file', - args: { knowledgeBaseId: KNOWLEDGE_BASE.id, filePaths: ['files/report.pdf'] }, - }, - CONTEXT - ) - - expect(result.success).toBe(false) - expect(result).toMatchObject({ data: { added: [], failed: ['files/report.pdf'] } }) - }) - - it('does not expose add-file infrastructure failures to the model', async () => { - mockAddWorkspaceFiles.mockRejectedValueOnce(new Error('storage host=private-bucket')) - - const result = await knowledgeBaseServerTool.execute( - { - operation: 'add_file', - args: { knowledgeBaseId: KNOWLEDGE_BASE.id, filePaths: ['files/report.pdf'] }, - }, - CONTEXT - ) - - expect(result).toEqual({ success: false, message: 'Failed to add_file knowledge base' }) - expect(result.message).not.toContain('private-bucket') - }) - - it('rechecks cancellation after application composition before presenting a partial result', async () => { - const controller = new AbortController() - mockAddWorkspaceFiles.mockImplementationOnce(async () => { - controller.abort('user stopped') - return { - knowledgeBaseId: KNOWLEDGE_BASE.id, - knowledgeBaseName: KNOWLEDGE_BASE.name, - added: [{ documentId: 'document-1', filename: 'report.pdf' }], - failed: [], - cancelled: true, - } - }) - - await expect( - knowledgeBaseServerTool.execute( - { - operation: 'add_file', - args: { knowledgeBaseId: KNOWLEDGE_BASE.id, filePaths: ['files/report.pdf'] }, - }, - { ...CONTEXT, userStopSignal: controller.signal } - ) - ).rejects.toThrow('Request aborted before knowledge mutation could be applied') - - expect(mockAddWorkspaceFiles).toHaveBeenCalledOnce() - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts deleted file mode 100644 index bcd994f9402..00000000000 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ /dev/null @@ -1,1225 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { truncate } from '@sim/utils/string' -import { - assertBillingAttributionSnapshot, - type BillingAttributionSnapshot, -} from '@/lib/billing/core/billing-attribution' -import { - executeCopilotKnowledgeUseCase, - messageForCopilotKnowledgeError, - requireCopilotKnowledgeWorkspaceId, -} from '@/lib/copilot/application/execute-knowledge-use-case' -import { ManageKnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' -import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' -import { - assertServerToolNotAborted, - type BaseServerTool, - type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { PlatformEvents } from '@/lib/core/telemetry' -import { getEffectiveEnvironmentSnapshot } from '@/lib/environment/utils' -import { isKnowledgeMemberAccessAvailable } from '@/lib/knowledge/access/availability' -import { addWorkspaceFilesToKnowledgeBase } from '@/lib/knowledge/application/add-workspace-files' -import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' -import { - createKnowledgeConnector, - deleteKnowledgeConnector, - syncKnowledgeConnector, - updateKnowledgeConnector, -} from '@/lib/knowledge/application/connectors' -import { - bulkDeleteKnowledgeDocuments, - type KnowledgeDocumentTagValueAssignment, - updateKnowledgeDocument, -} from '@/lib/knowledge/application/documents' -import { - bulkDeleteKnowledgeBases, - createKnowledgeBase, - readKnowledgeBase, - updateKnowledgeBaseOperation, -} from '@/lib/knowledge/application/knowledge-bases' -import { searchKnowledge } from '@/lib/knowledge/application/search' -import { - createKnowledgeTag, - deleteKnowledgeTag, - listKnowledgeTags, - readKnowledgeTagUsage, - updateKnowledgeTag, -} from '@/lib/knowledge/application/tags' -import { - ALL_TAG_SLOTS, - KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH, - MAX_KNOWLEDGE_BATCH_ITEMS, -} from '@/lib/knowledge/constants' -import { sourceAuthor } from '@/lib/knowledge/search/author' -import { captureServerEvent } from '@/lib/posthog/server' -import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' - -const logger = createLogger('KnowledgeBaseServerTool') - -/** Results a query returns unless the caller asks for a number. */ -const DEFAULT_QUERY_TOP_K = 5 -/** - * How the model cites a knowledge result in its reply. The `` tag is - * what the chat renders as a link back to the document, so a result without - * a source URL is quoted by name instead. - * - * Asked for only where per-member access is on. The chip and the sources strip - * that render the tag arrived with Sim Search, so a workspace without the - * feature must not be told to emit one: the gate belongs here, at the emission, - * because a client that merely declined to render the tag would leave the raw - * `{...}` JSON sitting in the visible reply. - */ -const KNOWLEDGE_CITATION_INSTRUCTION = - 'Cite each result you use inline, right after the sentence it supports, as {"url":"","title":"","siteName":"","connectorType":"","snippet":"","updatedAt":"","author":""} with every value JSON-escaped; leave out any optional field whose value is null or unknown, and omit the tag for a result whose sourceUrl is null and name the document instead.' - -/** - * Resolves an environment-variable reference passed as a connector API key. - * - * Models reference workspace secrets the way workflows do — `{{SIM_GITHUB_PAT}}` - * (and, when improvising, `$SIM_GITHUB_PAT` or the bare name). Before this, - * the literal placeholder string was sent upstream as the bearer token and the - * provider answered 401 — an error that never named the real problem. A raw - * key that matches no reference form passes through untouched. - * - * Returns an error string when a reference names a variable that is not set, - * so the model learns the actual fix instead of retrying reference syntaxes. - */ -async function resolveConnectorApiKey( - context: ServerToolContext, - workspaceId: string, - apiKey: string | undefined -): Promise<{ apiKey?: string; error?: string }> { - if (!apiKey) return { apiKey } - const braced = apiKey.match(/^\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$/) - const dollar = apiKey.match(/^\$([A-Za-z_][A-Za-z0-9_]*)$/) - const referencedName = braced?.[1] ?? dollar?.[1] - const environment = await getEffectiveEnvironmentSnapshot(context.userId, workspaceId) - const env = { ...environment.personalDecrypted, ...environment.workspaceDecrypted } - const name = referencedName ?? (Object.hasOwn(env, apiKey) ? apiKey : undefined) - if (!name) return { apiKey } - const value = env[name] - if (value === undefined || value === '') { - return { - error: `Environment variable "${name}" is not set for this workspace or user, so it cannot be used as the connector API key. Set it first, pass a different {{ENV_VAR}} reference, or pass the raw key.`, - } - } - context.resolvedSecretTraceRegistry?.recordResolvedFromEnvironment(name, value, { - ...environment, - scope: { userId: context.userId, workspaceId }, - }) - return { apiKey: value } -} - -function requireKnowledgeBillingAttribution( - context: ServerToolContext, - workspaceId: string -): BillingAttributionSnapshot { - if (!context.billingAttribution) { - throw new Error('Billing attribution is required for knowledge operations') - } - const attribution = assertBillingAttributionSnapshot(context.billingAttribution) - if (attribution.actorUserId !== context.userId || attribution.workspaceId !== workspaceId) { - throw new Error('Knowledge billing attribution does not match its actor and workspace') - } - return attribution -} - -/** Records the existing Copilot product analytics after application success. */ -function captureKnowledgeBaseCreated( - userId: string, - workspaceId: string, - knowledgeBase: { id: string; name: string } -): void { - PlatformEvents.knowledgeBaseCreated({ - knowledgeBaseId: knowledgeBase.id, - name: knowledgeBase.name, - workspaceId, - }) - captureServerEvent( - userId, - 'knowledge_base_created', - { - knowledge_base_id: knowledgeBase.id, - workspace_id: workspaceId, - name: knowledgeBase.name, - }, - { - groups: { workspace: workspaceId }, - setOnce: { first_kb_created_at: new Date().toISOString() }, - } - ) -} - -function captureKnowledgeDocumentsUploaded( - userId: string, - workspaceId: string, - knowledgeBaseId: string, - documents: readonly { mimeType: string; fileSize: number }[] -): void { - for (const document of documents) { - PlatformEvents.knowledgeBaseDocumentsUploaded({ - knowledgeBaseId, - documentsCount: 1, - uploadType: 'single', - mimeType: document.mimeType, - fileSize: document.fileSize, - }) - captureServerEvent( - userId, - 'knowledge_base_document_uploaded', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: workspaceId, - document_count: 1, - upload_type: 'single', - }, - { - groups: { workspace: workspaceId }, - setOnce: { first_document_uploaded_at: new Date().toISOString() }, - } - ) - } -} - -function captureKnowledgeDocumentsDeleted( - userId: string, - workspaceId: string, - knowledgeBaseId: string, - count: number -): void { - for (let index = 0; index < count; index += 1) { - captureServerEvent( - userId, - 'knowledge_base_document_deleted', - { knowledge_base_id: knowledgeBaseId, workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - } -} - -function captureKnowledgeConnectorAdded( - userId: string, - workspaceId: string, - knowledgeBaseId: string, - connectorType: string, - syncIntervalMinutes: number -): void { - captureServerEvent( - userId, - 'knowledge_base_connector_added', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: workspaceId, - connector_type: connectorType, - sync_interval_minutes: syncIntervalMinutes, - }, - { - groups: { workspace: workspaceId }, - setOnce: { first_connector_added_at: new Date().toISOString() }, - } - ) -} - -function captureKnowledgeConnectorRemoved( - userId: string, - workspaceId: string, - knowledgeBaseId: string, - connectorType: string, - documentsDeleted: number -): void { - captureServerEvent( - userId, - 'knowledge_base_connector_removed', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: workspaceId, - connector_type: connectorType, - documents_deleted: documentsDeleted, - }, - { groups: { workspace: workspaceId } } - ) -} - -function captureKnowledgeConnectorSynced( - userId: string, - workspaceId: string, - knowledgeBaseId: string, - connectorType: string -): void { - captureServerEvent( - userId, - 'knowledge_base_connector_synced', - { - knowledge_base_id: knowledgeBaseId, - workspace_id: workspaceId, - connector_type: connectorType, - }, - { groups: { workspace: workspaceId } } - ) -} - -function applicationFailureFallback(operation: string): string | null { - switch (operation) { - case 'update_document': - return 'Failed to update document' - case 'create_tag': - return 'Failed to create tag' - case 'add_connector': - return 'Failed to add connector' - case 'update_connector': - return 'Failed to update connector' - case 'delete_connector': - return 'Failed to delete connector' - case 'sync_connector': - return 'Failed to sync connector' - default: - return null - } -} - -type KnowledgeBaseArgs = { - operation: string - args?: Record -} - -type KnowledgeBaseResult = { - success: boolean - message: string - data?: any -} - -function isKnowledgeDocumentTagValueAssignment( - value: unknown -): value is KnowledgeDocumentTagValueAssignment { - if (typeof value !== 'object' || value === null) return false - const assignment = value as Record - if (typeof assignment.tagDefinitionId !== 'string' || !assignment.tagDefinitionId.trim()) { - return false - } - if (!Object.hasOwn(assignment, 'value')) return false - return ( - assignment.value === null || - typeof assignment.value === 'string' || - typeof assignment.value === 'number' || - typeof assignment.value === 'boolean' - ) -} - -/** - * Knowledge base tool for copilot to create, list, and get knowledge bases - */ -export const knowledgeBaseServerTool: BaseServerTool = { - name: ManageKnowledgeBase.id, - async execute( - params: KnowledgeBaseArgs, - context?: ServerToolContext - ): Promise { - if (!context) throw new Error('Knowledge delegation requires a Copilot execution context') - const { operation, args = {} } = params - const workspaceId = requireCopilotKnowledgeWorkspaceId(context) - const assertNotAborted = () => - assertServerToolNotAborted( - context, - 'Request aborted before knowledge mutation could be applied.' - ) - try { - switch (operation) { - case 'create': { - if (!args.name) { - return { - success: false, - message: 'Name is required for creating a knowledge base', - } - } - - if (!workspaceId) { - return { - success: false, - message: 'Workspace ID is required for creating a knowledge base', - } - } - - assertNotAborted() - const { knowledgeBase: newKnowledgeBase } = await executeCopilotKnowledgeUseCase( - context, - createKnowledgeBase, - { - workspaceId, - name: args.name, - description: args.description, - chunkingConfig: args.chunkingConfig, - folderPath: args.folderPath, - source: 'agent', - } - ) - captureKnowledgeBaseCreated(context.userId, workspaceId, newKnowledgeBase) - return { - success: true, - message: `Knowledge base "${newKnowledgeBase.name}" created successfully`, - data: { - id: newKnowledgeBase.id, - name: newKnowledgeBase.name, - description: newKnowledgeBase.description, - workspaceId: newKnowledgeBase.workspaceId, - docCount: newKnowledgeBase.docCount, - createdAt: newKnowledgeBase.createdAt, - }, - } - } - - case 'get': { - if (!args.knowledgeBaseId) { - return { - success: false, - message: 'Knowledge base ID is required for get operation', - } - } - - const { knowledgeBase } = await executeCopilotKnowledgeUseCase( - context, - readKnowledgeBase, - { - knowledgeBaseId: args.knowledgeBaseId, - assertedWorkspaceId: workspaceId, - } - ) - - logger.info('Knowledge base metadata retrieved via copilot', { - knowledgeBaseId: knowledgeBase.id, - userId: context.userId, - }) - - return { - success: true, - message: `Retrieved knowledge base "${knowledgeBase.name}"`, - data: { - id: knowledgeBase.id, - name: knowledgeBase.name, - description: knowledgeBase.description, - workspaceId: knowledgeBase.workspaceId, - docCount: knowledgeBase.docCount, - tokenCount: knowledgeBase.tokenCount, - embeddingModel: knowledgeBase.embeddingModel, - chunkingConfig: knowledgeBase.chunkingConfig, - createdAt: knowledgeBase.createdAt, - updatedAt: knowledgeBase.updatedAt, - }, - } - } - - case 'query': { - if (!args.knowledgeBaseId) { - return { - success: false, - message: 'Knowledge base ID is required for query operation', - } - } - - if (!args.query?.trim()) { - return { - success: false, - message: 'Query text is required for query operation', - } - } - - const topK = args.topK || DEFAULT_QUERY_TOP_K - const queryProjection = projectResolvedSecretModelContent( - args.query, - context.resolvedSecretTraceRegistry - ) - if (!queryProjection.safe || typeof queryProjection.value !== 'string') { - return { - success: false, - message: - 'Knowledge query rejected by the input-safety filter. Rephrase it as a plain natural-language question without injected instructions or markup — the same text will be rejected again.', - } - } - const modelQuery = queryProjection.value - if (!context.resolvedSecretTraceRegistry) { - return { - success: false, - message: - 'Failed to query knowledge base: Knowledge result secret provenance is unavailable', - } - } - const [searchResult, citable] = await Promise.all([ - executeCopilotKnowledgeUseCase(context, searchKnowledge, { - workspaceId, - knowledgeBaseIds: [args.knowledgeBaseId], - query: modelQuery, - topK, - surface: context?.searchSurface ?? 'copilot', - resultSecretRegistry: context.resolvedSecretTraceRegistry, - signal: context.abortSignal, - }), - /** - * Whether to ask for a citation is a presentation choice, and it is - * answered by a billing-backed lookup that can reject. A rejection - * must not discard a search that succeeded, so it settles to "do not - * cite" — the same answer the feature being off gives — rather than - * failing the query. - */ - isKnowledgeMemberAccessAvailable({ workspaceId }).catch((error) => { - logger.warn('Citation eligibility unavailable; answering without citations', { - workspaceId, - error: getErrorMessage(error), - }) - return false - }), - ]) - const results = searchResult.results - const knowledgeBase = searchResult.knowledgeBases[0] - if (!knowledgeBase) - throw new Error('Knowledge search returned no canonical knowledge base') - - logger.info('Knowledge base queried via copilot', { - knowledgeBaseIds: [args.knowledgeBaseId], - queryLength: args.query.length, - resultCount: results.length, - userId: context.userId, - }) - - const foundMessage = `Found ${results.length} result(s) for query "${truncate(args.query, 50)}".` - - return { - success: true, - message: citable ? `${foundMessage} ${KNOWLEDGE_CITATION_INSTRUCTION}` : foundMessage, - data: { - knowledgeBaseId: args.knowledgeBaseId, - knowledgeBaseName: knowledgeBase.name, - query: args.query, - topK, - totalResults: results.length, - results: results.map((result) => ({ - documentId: result.documentId, - documentName: result.documentName, - sourceUrl: result.sourceUrl, - sourceModifiedAt: result.sourceModifiedAt?.toISOString() ?? null, - author: sourceAuthor(result.metadata), - connectorType: result.connectorType, - content: result.content, - chunkIndex: result.chunkIndex, - similarity: result.similarity, - })), - }, - } - } - - case 'add_file': { - if (!args.knowledgeBaseId) { - return { - success: false, - message: 'Knowledge base ID is required for add_file operation', - } - } - - const fileRefs: string[] = - args.filePaths ?? - args.fileIds ?? - (args.fileId ? [args.fileId] : args.filePath ? [args.filePath] : []) - if (fileRefs.length === 0) { - return { - success: false, - message: - 'filePaths is required for add_file. Use canonical VFS file paths from glob("files/**").', - } - } - if (fileRefs.length > MAX_KNOWLEDGE_BATCH_ITEMS) { - return { - success: false, - message: `Too many files (${fileRefs.length}). Maximum is ${MAX_KNOWLEDGE_BATCH_ITEMS}.`, - } - } - - assertNotAborted() - const outcome = await executeCopilotKnowledgeUseCase( - context, - addWorkspaceFilesToKnowledgeBase, - { - knowledgeBaseId: args.knowledgeBaseId, - assertedWorkspaceId: workspaceId, - fileReferences: fileRefs, - cancellationSignal: context.userStopSignal, - source: 'agent', - } - ) - captureKnowledgeDocumentsUploaded( - context.userId, - workspaceId, - outcome.knowledgeBaseId, - outcome.added - ) - assertNotAborted() - - const added = outcome.added.map(({ documentId, filename }) => ({ - documentId, - filename, - })) - const addedNames = added.map((item) => item.filename).join(', ') - return { - success: added.length > 0, - message: - added.length > 0 - ? `Added ${added.length} file(s) to "${outcome.knowledgeBaseName}": ${addedNames}. Processing started.` - : `No files could be added.`, - data: { - knowledgeBaseId: args.knowledgeBaseId, - knowledgeBaseName: outcome.knowledgeBaseName, - added, - failed: outcome.failed, - }, - } - } - - case 'update': { - if (!args.knowledgeBaseId) { - return { - success: false, - message: 'Knowledge base ID is required for update operation', - } - } - - const updates: { - name?: string - description?: string - chunkingConfig?: { maxSize: number; minSize: number; overlap: number } - } = {} - if (args.name) updates.name = args.name - if (args.description !== undefined) updates.description = args.description - if (args.chunkingConfig) updates.chunkingConfig = args.chunkingConfig - - if (!updates.name && updates.description === undefined && !updates.chunkingConfig) { - return { - success: false, - message: - 'At least one of name, description, or chunkingConfig is required for update', - } - } - - assertNotAborted() - const { knowledgeBase: updatedKb } = await executeCopilotKnowledgeUseCase( - context, - updateKnowledgeBaseOperation, - { - knowledgeBaseId: args.knowledgeBaseId, - assertedWorkspaceId: workspaceId, - ...updates, - source: 'agent', - } - ) - return { - success: true, - message: `Knowledge base "${updatedKb.name}" updated successfully`, - data: { - id: updatedKb.id, - name: updatedKb.name, - description: updatedKb.description, - workspaceId: updatedKb.workspaceId, - docCount: updatedKb.docCount, - updatedAt: updatedKb.updatedAt, - }, - } - } - - case 'delete': { - const kbIds: string[] = - args.knowledgeBaseIds ?? (args.knowledgeBaseId ? [args.knowledgeBaseId] : []) - if (kbIds.length === 0) { - return { - success: false, - message: 'knowledgeBaseId or knowledgeBaseIds is required for delete operation', - } - } - if (kbIds.length > MAX_KNOWLEDGE_BATCH_ITEMS) { - return { - success: false, - message: `Too many knowledge base IDs (${kbIds.length}). Maximum is ${MAX_KNOWLEDGE_BATCH_ITEMS}.`, - } - } - - assertNotAborted() - const { deleted, notFound, failed } = await executeCopilotKnowledgeUseCase( - context, - bulkDeleteKnowledgeBases, - { - assertedWorkspaceId: workspaceId, - knowledgeBaseIds: kbIds, - cancellationSignal: context.userStopSignal, - source: 'agent', - } - ) - assertNotAborted() - - const deleteSummary = [ - deleted.length > 0 ? `Deleted: ${deleted.map((d) => d.name).join(', ')}` : null, - failed.length > 0 - ? `Failed: ${failed.map((f) => `${f.name} (${f.reason})`).join(', ')}` - : null, - ] - .filter(Boolean) - .join('. ') - - return { - success: deleted.length > 0, - message: deleteSummary || 'No knowledge bases found', - data: { deleted, notFound, failed }, - } - } - - case 'delete_document': { - if (!args.knowledgeBaseId) { - return { success: false, message: 'knowledgeBaseId is required for delete_document' } - } - const docIds: string[] = args.documentIds ?? (args.documentId ? [args.documentId] : []) - if (docIds.length === 0) { - return { - success: false, - message: 'documentId or documentIds is required for delete_document', - } - } - if (docIds.length > MAX_KNOWLEDGE_BATCH_ITEMS) { - return { - success: false, - message: `Too many document IDs (${docIds.length}). Maximum is ${MAX_KNOWLEDGE_BATCH_ITEMS}.`, - } - } - - assertNotAborted() - const { knowledgeBaseId, deleted, deletedDocuments, failed } = - await executeCopilotKnowledgeUseCase(context, bulkDeleteKnowledgeDocuments, { - knowledgeBaseId: args.knowledgeBaseId, - documentIds: docIds, - assertedWorkspaceId: workspaceId, - cancellationSignal: context.userStopSignal, - source: 'agent', - }) - captureKnowledgeDocumentsDeleted( - context.userId, - workspaceId, - knowledgeBaseId, - deletedDocuments.length - ) - assertNotAborted() - - return { - success: deleted.length > 0, - message: `Deleted ${deleted.length} document(s)${failed.length > 0 ? `, ${failed.length} failed` : ''}`, - data: { knowledgeBaseId, deleted, failed }, - } - } - - case 'update_document': { - if (!args.knowledgeBaseId) { - return { success: false, message: 'knowledgeBaseId is required for update_document' } - } - if (!args.documentId) { - return { success: false, message: 'documentId is required for update_document' } - } - const updateData: { - filename?: string - enabled?: boolean - tagValues?: KnowledgeDocumentTagValueAssignment[] - } = {} - if (args.filename !== undefined) { - updateData.filename = args.filename - } - if (args.enabled !== undefined) { - updateData.enabled = args.enabled - } - if (args.tagValues !== undefined) { - if ( - !Array.isArray(args.tagValues) || - args.tagValues.length === 0 || - !args.tagValues.every(isKnowledgeDocumentTagValueAssignment) - ) { - return { - success: false, - message: - 'tagValues must be a non-empty array of { tagDefinitionId, value } assignments', - } - } - if (args.tagValues.length > ALL_TAG_SLOTS.length) { - return { - success: false, - message: `Too many tag values (${args.tagValues.length}). Maximum is ${ALL_TAG_SLOTS.length}.`, - } - } - updateData.tagValues = args.tagValues - } - if (Object.keys(updateData).length === 0) { - return { - success: false, - message: - 'At least one of filename, enabled, or tagValues is required for update_document', - } - } - assertNotAborted() - await executeCopilotKnowledgeUseCase(context, updateKnowledgeDocument, { - knowledgeBaseId: args.knowledgeBaseId, - documentId: args.documentId, - assertedWorkspaceId: workspaceId, - ...updateData, - source: 'agent', - }) - - return { - success: true, - message: `Document updated successfully`, - data: { - documentId: args.documentId, - knowledgeBaseId: args.knowledgeBaseId, - ...(updateData.filename !== undefined && { - filename: updateData.filename, - }), - ...(updateData.enabled !== undefined && { - enabled: updateData.enabled, - }), - ...(updateData.tagValues !== undefined && { - tagDefinitionIds: updateData.tagValues.map( - (assignment) => assignment.tagDefinitionId - ), - }), - }, - } - } - - case 'list_tags': { - if (!args.knowledgeBaseId) { - return { - success: false, - message: 'Knowledge base ID is required for list_tags operation', - } - } - - const { tagDefinitions } = await executeCopilotKnowledgeUseCase( - context, - listKnowledgeTags, - { - knowledgeBaseId: args.knowledgeBaseId, - assertedWorkspaceId: workspaceId, - } - ) - - logger.info('Tag definitions listed via copilot', { - knowledgeBaseId: args.knowledgeBaseId, - count: tagDefinitions.length, - userId: context.userId, - }) - - return { - success: true, - message: `Found ${tagDefinitions.length} tag definition(s)`, - data: tagDefinitions.map((td) => ({ - id: td.id, - tagSlot: td.tagSlot, - displayName: td.displayName, - fieldType: td.fieldType, - createdAt: td.createdAt, - })), - } - } - - case 'create_tag': { - if (!args.knowledgeBaseId) { - return { - success: false, - message: 'Knowledge base ID is required for create_tag operation', - } - } - if (!args.tagDisplayName) { - return { - success: false, - message: 'tagDisplayName is required for create_tag operation', - } - } - if (args.tagDisplayName.length > KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH) { - return { - success: false, - message: `tagDisplayName must be ${KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH} characters or less`, - } - } - - const fieldType = args.tagFieldType || 'text' - assertNotAborted() - const { tagDefinition: newTag } = await executeCopilotKnowledgeUseCase( - context, - createKnowledgeTag, - { - knowledgeBaseId: args.knowledgeBaseId, - displayName: args.tagDisplayName, - fieldType, - assertedWorkspaceId: workspaceId, - source: 'agent', - } - ) - - logger.info('Tag definition created via copilot', { - knowledgeBaseId: args.knowledgeBaseId, - tagId: newTag.id, - displayName: newTag.displayName, - userId: context.userId, - }) - - return { - success: true, - message: `Tag "${newTag.displayName}" created successfully`, - data: { - id: newTag.id, - knowledgeBaseId: args.knowledgeBaseId, - tagSlot: newTag.tagSlot, - displayName: newTag.displayName, - fieldType: newTag.fieldType, - }, - } - } - - case 'update_tag': { - if (!args.tagDefinitionId) { - return { - success: false, - message: 'tagDefinitionId is required for update_tag operation', - } - } - - const updateData: { displayName?: string; fieldType?: string } = {} - if (args.tagDisplayName) updateData.displayName = args.tagDisplayName - if (args.tagFieldType) updateData.fieldType = args.tagFieldType - - if (!updateData.displayName && !updateData.fieldType) { - return { - success: false, - message: 'At least one of tagDisplayName or tagFieldType is required for update_tag', - } - } - if ( - updateData.displayName && - updateData.displayName.length > KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH - ) { - return { - success: false, - message: `tagDisplayName must be ${KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH} characters or less`, - } - } - - assertNotAborted() - const { tagDefinition: updatedTag, knowledgeBaseId } = - await executeCopilotKnowledgeUseCase(context, updateKnowledgeTag, { - tagDefinitionId: args.tagDefinitionId, - assertedWorkspaceId: workspaceId, - updates: updateData, - source: 'agent', - }) - - logger.info('Tag definition updated via copilot', { - tagId: args.tagDefinitionId, - knowledgeBaseId, - userId: context.userId, - }) - - return { - success: true, - message: `Tag "${updatedTag.displayName}" updated successfully`, - data: { - id: updatedTag.id, - knowledgeBaseId, - tagSlot: updatedTag.tagSlot, - displayName: updatedTag.displayName, - fieldType: updatedTag.fieldType, - }, - } - } - - case 'delete_tag': { - if (!args.knowledgeBaseId) { - return { - success: false, - message: 'knowledgeBaseId is required for delete_tag operation', - } - } - if (!args.tagDefinitionId) { - return { - success: false, - message: 'tagDefinitionId is required for delete_tag operation', - } - } - - assertNotAborted() - const deleted = await executeCopilotKnowledgeUseCase(context, deleteKnowledgeTag, { - knowledgeBaseId: args.knowledgeBaseId, - tagDefinitionId: args.tagDefinitionId, - assertedWorkspaceId: workspaceId, - source: 'agent', - }) - - logger.info('Tag definition deleted via copilot', { - tagId: args.tagDefinitionId, - tagSlot: deleted.tagSlot, - displayName: deleted.displayName, - userId: context.userId, - }) - - return { - success: true, - message: `Tag "${deleted.displayName}" deleted successfully. All document/chunk references cleared.`, - data: { - knowledgeBaseId: args.knowledgeBaseId, - tagSlot: deleted.tagSlot, - displayName: deleted.displayName, - }, - } - } - - case 'get_tag_usage': { - if (!args.knowledgeBaseId) { - return { - success: false, - message: 'Knowledge base ID is required for get_tag_usage operation', - } - } - - const { usage: stats } = await executeCopilotKnowledgeUseCase( - context, - readKnowledgeTagUsage, - { - knowledgeBaseId: args.knowledgeBaseId, - assertedWorkspaceId: workspaceId, - } - ) - - return { - success: true, - message: `Retrieved usage stats for ${stats.length} tag(s)`, - data: stats, - } - } - - case 'add_connector': { - if (!args.knowledgeBaseId) { - return { success: false, message: 'Knowledge base ID is required for add_connector' } - } - if (!args.connectorType) { - return { success: false, message: 'connectorType is required for add_connector' } - } - const sourceConfig: Record = { ...(args.sourceConfig ?? {}) } - if (args.disabledTagIds?.length) { - sourceConfig.disabledTagIds = args.disabledTagIds - } - - const resolvedKey = await resolveConnectorApiKey(context, workspaceId, args.apiKey) - if (resolvedKey.error) { - return { success: false, message: resolvedKey.error } - } - - assertNotAborted() - const { connector, workspaceId: canonicalWorkspaceId } = - await executeCopilotKnowledgeUseCase(context, createKnowledgeConnector, { - knowledgeBaseId: args.knowledgeBaseId, - assertedWorkspaceId: workspaceId, - connectorType: args.connectorType, - credentialId: args.credentialId, - apiKey: resolvedKey.apiKey, - sourceConfig, - syncIntervalMinutes: args.syncIntervalMinutes ?? 1440, - resolveBillingAttribution: async (billingWorkspaceId) => - requireKnowledgeBillingAttribution(context, billingWorkspaceId), - source: 'agent', - }) - if (canonicalWorkspaceId !== workspaceId) { - throw new Error('Knowledge connector workspace does not match the authorized workspace') - } - captureKnowledgeConnectorAdded( - context.userId, - canonicalWorkspaceId, - connector.knowledgeBaseId, - connector.connectorType, - connector.syncIntervalMinutes - ) - return { - success: true, - message: `Connector "${args.connectorType}" added to knowledge base. Initial sync started.`, - data: { - id: connector.id, - connectorType: connector.connectorType, - status: connector.status, - knowledgeBaseId: connector.knowledgeBaseId, - }, - } - } - - case 'update_connector': { - if (!args.connectorId) { - return { success: false, message: 'connectorId is required for update_connector' } - } - - const updates = { - sourceConfig: args.sourceConfig, - syncIntervalMinutes: args.syncIntervalMinutes, - status: args.connectorStatus, - } - - assertNotAborted() - const { connector } = await executeCopilotKnowledgeUseCase( - context, - updateKnowledgeConnector, - { - connectorId: args.connectorId, - assertedWorkspaceId: workspaceId, - updates, - resolveBillingAttribution: async (canonicalWorkspaceId) => - requireKnowledgeBillingAttribution(context, canonicalWorkspaceId), - source: 'agent', - } - ) - - return { - success: true, - message: - updates.sourceConfig === undefined - ? 'Connector updated successfully' - : connector.status === 'paused' || connector.status === 'disabled' - ? 'Connector updated successfully. The source change will synchronize when the connector is resumed.' - : 'Connector updated successfully. Synchronization was queued for the source change.', - data: { - id: args.connectorId, - ...(updates.sourceConfig !== undefined && { sourceConfig: updates.sourceConfig }), - ...(updates.syncIntervalMinutes !== undefined && { - syncIntervalMinutes: updates.syncIntervalMinutes, - }), - ...(updates.status !== undefined && { status: updates.status }), - }, - } - } - - case 'delete_connector': { - if (!args.connectorId) { - return { success: false, message: 'connectorId is required for delete_connector' } - } - - assertNotAborted() - const outcome = await executeCopilotKnowledgeUseCase(context, deleteKnowledgeConnector, { - connectorId: args.connectorId, - assertedWorkspaceId: workspaceId, - source: 'agent', - }) - if (!outcome.workspaceId) { - throw new Error('Knowledge connector deletion is missing its workspace scope') - } - captureKnowledgeConnectorRemoved( - context.userId, - outcome.workspaceId, - outcome.knowledgeBaseId, - outcome.connectorType, - outcome.documentsDeleted - ) - - // Report what the delete actually did. The documents are kept — this - // used to claim they had been removed, which was never true on this - // path: it reached the route over HTTP with no query string, so the - // route's keep-documents default always applied. - return { - success: true, - message: - outcome.documentsKept > 0 - ? `Connector deleted successfully. Its ${outcome.documentsKept} document(s) were kept in the knowledge base.` - : 'Connector deleted successfully.', - data: { - id: args.connectorId, - documentsKept: outcome.documentsKept, - documentsDeleted: outcome.documentsDeleted, - }, - } - } - - case 'sync_connector': { - if (!args.connectorId) { - return { success: false, message: 'connectorId is required for sync_connector' } - } - - assertNotAborted() - const outcome = await executeCopilotKnowledgeUseCase(context, syncKnowledgeConnector, { - connectorId: args.connectorId, - assertedWorkspaceId: workspaceId, - resolveBillingAttribution: async (canonicalWorkspaceId) => - requireKnowledgeBillingAttribution(context, canonicalWorkspaceId), - source: 'agent', - }) - if (outcome.workspaceId !== workspaceId) { - throw new Error('Knowledge connector workspace does not match the authorized workspace') - } - captureKnowledgeConnectorSynced( - context.userId, - outcome.workspaceId, - outcome.knowledgeBaseId, - outcome.connectorType - ) - - return { - success: true, - message: 'Sync triggered. Documents will be updated in the background.', - data: { id: args.connectorId }, - } - } - - default: - return { - success: false, - message: `Unknown operation: ${operation}. Supported operations: create, get, query, add_file, update, delete, list_tags, create_tag, update_tag, delete_tag, get_tag_usage, add_connector, update_connector, delete_connector, sync_connector`, - } - } - } catch (error) { - const errorMessage = getErrorMessage(error, 'Unknown error occurred') - logger.error('Error in manage_knowledge_base tool', { - operation, - error: projectToolErrorMessageForCopilot(errorMessage, context.resolvedSecretTraceRegistry), - userId: context.userId, - }) - - if (operation === 'query' && context.resolvedSecretTraceRegistry?.isPermanentlyIncomplete()) { - return { - success: false, - message: - 'Failed to query knowledge base: Knowledge result secret provenance is unavailable', - } - } - if (error instanceof KnowledgeUsageLimitExceededError) { - return { success: false, message: error.message } - } - if (context.userStopSignal?.aborted) throw error - const classified = asOrchestrationError(error) - if (classified?.code === 'not_found' || classified?.code === 'forbidden') { - if (args.connectorId) { - return { success: false, message: `Connector "${args.connectorId}" not found` } - } - if (args.tagDefinitionId) { - return { - success: false, - message: `Tag definition with ID "${args.tagDefinitionId}" not found`, - } - } - if (args.documentId) { - return { - success: false, - message: `Document with ID "${args.documentId}" not found`, - } - } - if (args.knowledgeBaseId) { - return { - success: false, - message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`, - } - } - } - const directFallback = applicationFailureFallback(operation) - const fallback = directFallback ?? `Failed to ${operation} knowledge base` - const safeMessage = messageForCopilotKnowledgeError(error, fallback) - return { - success: false, - message: - directFallback || safeMessage === fallback ? safeMessage : `${fallback}: ${safeMessage}`, - } - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.test.ts b/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.test.ts deleted file mode 100644 index 0478d73eafe..00000000000 --- a/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { executeKnowledgeBase } = vi.hoisted(() => ({ executeKnowledgeBase: vi.fn() })) - -vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ - SearchKnowledgeBase: { id: 'search_knowledge_base' }, -})) -vi.mock('@/lib/copilot/tools/server/knowledge/knowledge-base', () => ({ - knowledgeBaseServerTool: { execute: executeKnowledgeBase }, -})) - -import { searchKnowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/search-knowledge-base' - -describe('search_knowledge_base delegation', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it.each(['get', 'query', 'list_tags'])( - 'forwards %s with the immutable trusted context', - async (operation) => { - const params = { operation, args: { knowledgeBaseId: 'kb-1' } } - const context = { - userId: 'user-1', - workspaceId: 'workspace-1', - chatId: 'chat-1', - toolCallId: 'tool-1', - copilotToolExecution: true, - } - executeKnowledgeBase.mockResolvedValueOnce({ success: true, message: 'ok' }) - - await expect(searchKnowledgeBaseServerTool.execute(params, context)).resolves.toEqual({ - success: true, - message: 'ok', - }) - expect(executeKnowledgeBase).toHaveBeenCalledWith(params, context) - } - ) - - it.each([ - 'create', - 'add_file', - 'update', - 'delete', - 'delete_document', - 'update_document', - 'create_tag', - 'update_tag', - 'delete_tag', - 'add_connector', - 'update_connector', - 'delete_connector', - 'sync_connector', - 'unknown', - ])('refuses %s before entering any knowledge application operation', async (operation) => { - const result = await searchKnowledgeBaseServerTool.execute( - { operation, args: { knowledgeBaseId: 'kb-1' } }, - { - userId: 'user-1', - workspaceId: 'workspace-1', - toolCallId: 'tool-1', - copilotToolExecution: true, - } - ) - - expect(result.success).toBe(false) - expect(result.message).toContain('read-only') - expect(executeKnowledgeBase).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts deleted file mode 100644 index 3f9ee829d5b..00000000000 --- a/apps/sim/lib/copilot/tools/server/knowledge/search-knowledge-base.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { SearchKnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' - -type SearchKnowledgeBaseArgs = { - operation: string - args?: Record -} - -type SearchKnowledgeBaseResult = { - success: boolean - message: string - data?: any -} - -const READ_OPERATIONS = new Set(['get', 'query', 'list_tags']) - -/** - * Read-only variant of manage_knowledge_base for info-gathering agents. Copilot - * access control is a per-agent tool allowlist, so read-only access gets its - * own tool name with its own operation contract — enforced here (where - * execution happens) on top of the fail-fast guard in the Go executor. - */ -export const searchKnowledgeBaseServerTool: BaseServerTool< - SearchKnowledgeBaseArgs, - SearchKnowledgeBaseResult -> = { - name: SearchKnowledgeBase.id, - async execute(params: SearchKnowledgeBaseArgs, context?: ServerToolContext) { - const operation = params?.operation - if (!READ_OPERATIONS.has(operation)) { - return { - success: false, - message: `search_knowledge_base is read-only: operation '${operation}' is not available (allowed: get, list_tags, query); mutations go through the knowledge agent's manage_knowledge_base tool`, - } - } - return knowledgeBaseServerTool.execute(params, context) - }, -} diff --git a/apps/sim/lib/copilot/tools/server/other/search-online.test.ts b/apps/sim/lib/copilot/tools/server/other/search-online.test.ts deleted file mode 100644 index 569791042b9..00000000000 --- a/apps/sim/lib/copilot/tools/server/other/search-online.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * @vitest-environment node - */ -import { loggerMock, resetEnvMock, setEnv } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockExecuteTool } = vi.hoisted(() => ({ - mockExecuteTool: vi.fn(), -})) - -vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ - WebSearch: { id: 'web_search' }, -})) -vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) - -import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online' -import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' - -function activeQueryRegistry(): ResolvedSecretTraceRegistry { - const registry = new ResolvedSecretTraceRegistry([ - { - name: 'SEARCH_QUERY', - plaintext: 'private search query', - encryptedValue: 'encrypted-query', - }, - ]) - registry.recordResolved('SEARCH_QUERY', 'private search query', { propagated: true }) - return registry -} - -describe('online search model boundary', () => { - beforeEach(() => { - vi.clearAllMocks() - setEnv({ EXA_API_KEY: 'exa-key', SERPER_API_KEY: 'serper-key' }) - }) - - afterAll(resetEnvMock) - - it('passes the request registry through the Exa execution boundary', async () => { - const registry = activeQueryRegistry() - mockExecuteTool.mockResolvedValue({ - success: true, - output: { - results: [{ title: 'Result', url: 'https://example.com', highlights: ['Snippet'] }], - }, - }) - - await searchOnlineServerTool.execute( - { query: 'private search query' }, - { userId: 'user-1', resolvedSecretTraceRegistry: registry } - ) - - expect(mockExecuteTool).toHaveBeenCalledWith( - 'exa_search', - expect.objectContaining({ query: 'private search query' }), - { resolvedSecretTraceRegistry: registry } - ) - }) - - it('passes the request registry through the Serper fallback boundary', async () => { - const registry = activeQueryRegistry() - mockExecuteTool - .mockResolvedValueOnce({ success: true, output: { results: [] } }) - .mockResolvedValueOnce({ - success: true, - output: { - searchResults: [{ title: 'Result', link: 'https://example.com', snippet: 'Snippet' }], - }, - }) - - await searchOnlineServerTool.execute( - { query: 'private search query' }, - { userId: 'user-1', resolvedSecretTraceRegistry: registry } - ) - - expect(mockExecuteTool).toHaveBeenNthCalledWith( - 2, - 'serper_search', - expect.objectContaining({ query: 'private search query' }), - { resolvedSecretTraceRegistry: registry } - ) - }) - - it('projects a provider error before logging the fallback', async () => { - const registry = activeQueryRegistry() - mockExecuteTool - .mockRejectedValueOnce(new Error('Provider echoed private search query')) - .mockResolvedValueOnce({ success: true, output: { searchResults: [] } }) - - await searchOnlineServerTool.execute( - { query: 'private search query' }, - { userId: 'user-1', resolvedSecretTraceRegistry: registry } - ) - - const logger = loggerMock.createLogger.mock.results.at(-1)?.value - expect(JSON.stringify(logger?.warn.mock.calls)).toContain('{{SEARCH_QUERY}}') - expect(JSON.stringify(logger?.warn.mock.calls)).not.toContain('private search query') - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/other/search-online.ts b/apps/sim/lib/copilot/tools/server/other/search-online.ts deleted file mode 100644 index 3000aab9c30..00000000000 --- a/apps/sim/lib/copilot/tools/server/other/search-online.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { WebSearch } from '@/lib/copilot/generated/tool-catalog-v1' -import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' -import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { env } from '@/lib/core/config/env' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { executeTool } from '@/tools' - -interface OnlineSearchParams { - query: string - num?: number - type?: string - gl?: string - hl?: string -} - -interface SearchResult { - title: string - link: string - snippet: string - date?: string - position?: number -} - -interface SearchResponse { - results: SearchResult[] - query: string - type: string - totalResults: number - source: 'exa' | 'serper' -} - -export const searchOnlineServerTool: BaseServerTool = { - name: WebSearch.id, - async execute(params: OnlineSearchParams, context?: ServerToolContext): Promise { - const logger = createLogger('SearchOnlineServerTool') - const { query, num = 10, type = 'search', gl, hl } = params - if (!query || typeof query !== 'string') - throw new OrchestrationError('validation', 'query is required') - - const hasExaApiKey = Boolean(env.EXA_API_KEY && String(env.EXA_API_KEY).length > 0) - const hasSerperApiKey = Boolean(env.SERPER_API_KEY && String(env.SERPER_API_KEY).length > 0) - - logger.debug('Performing online search', { queryLength: query.length, num, type }) - - // Try Exa first if available - if (hasExaApiKey) { - try { - const exaResult = await executeTool( - 'exa_search', - { - query, - numResults: num, - type: 'auto', - // Exa omits page content unless it is requested, which would leave - // every snippet empty. Highlights keep the payload small. - highlights: true, - apiKey: env.EXA_API_KEY ?? '', - }, - { resolvedSecretTraceRegistry: context?.resolvedSecretTraceRegistry } - ) - - const output = exaResult.output as - | { - results?: Array<{ - title?: string - url?: string - text?: string - summary?: string - highlights?: string[] - publishedDate?: string - }> - } - | undefined - const exaResults = output?.results ?? [] - - if (exaResult.success && exaResults.length > 0) { - const transformedResults: SearchResult[] = exaResults.map((result, index) => ({ - title: result.title ?? '', - link: result.url ?? '', - snippet: result.highlights?.join(' ') || result.text || result.summary || '', - date: result.publishedDate, - position: index + 1, - })) - - return { - results: transformedResults, - query, - type, - totalResults: transformedResults.length, - source: 'exa', - } - } - - logger.debug('exa_search returned no results, falling back to Serper') - } catch (exaError) { - const errorMessage = toError(exaError).message - logger.warn('exa_search failed, falling back to Serper', { - error: projectToolErrorMessageForCopilot( - errorMessage, - context?.resolvedSecretTraceRegistry - ), - }) - } - } - - if (!hasSerperApiKey) { - throw new OrchestrationError( - 'forbidden', - 'Web search is not configured on this Sim deployment and cannot be enabled from a tool. Answer from the workspace instead (grep/glob/read, search_sim_docs) or tell the user web search is unavailable.' - ) - } - - const toolParams = { - query, - num, - type, - gl, - hl, - apiKey: env.SERPER_API_KEY ?? '', - } - - const result = await executeTool('serper_search', toolParams, { - resolvedSecretTraceRegistry: context?.resolvedSecretTraceRegistry, - }) - const output = result.output as { searchResults?: SearchResult[] } | undefined - const results = output?.searchResults ?? [] - - if (!result.success) { - const errorMsg = (result as { error?: string }).error ?? 'Search failed' - // Classified so the provider's actual failure (rate limit, bad query) - // reaches the model instead of the generic system-error mask. - throw new OrchestrationError('conflict', errorMsg) - } - - return { - results, - query, - type, - totalResults: results.length, - source: 'serper', - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts deleted file mode 100644 index 0c499a66e9e..00000000000 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { createLogger } from '@sim/logger' -import { isRecordLike } from '@sim/utils/object' -import { z } from 'zod' -import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' -import { - CreateEmptyFile, - DownloadFile, - Ffmpeg, - GenerateAudio, - GenerateImage, - GenerateVideo, - ManageCredential, - ManageCustomTool, - ManageKnowledgeBase, - ManageMcpConnection, - ManageSkill, - PrepareFileEdit, - UserTable, -} from '@/lib/copilot/generated/tool-catalog-v1' -import { copilotToolCanWrite } from '@/lib/copilot/tools/permissions' -import { - assertServerToolNotAborted, - type BaseServerTool, - type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' -import { getBlocksMetadataServerTool } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool' -import { getTriggerBlocksServerTool } from '@/lib/copilot/tools/server/blocks/get-trigger-blocks' -import { searchDocsServerTool } from '@/lib/copilot/tools/server/docs/search-docs' -import { enrichmentRunServerTool } from '@/lib/copilot/tools/server/enrichment/enrichment-run' -import { createFileServerTool } from '@/lib/copilot/tools/server/files/create-file' -import { downloadToWorkspaceFileServerTool } from '@/lib/copilot/tools/server/files/download-to-workspace-file' -import { editContentServerTool } from '@/lib/copilot/tools/server/files/edit-content' -import { extractDocAssetsServerTool } from '@/lib/copilot/tools/server/files/extract-doc-assets' -import { - createFileFolderServerTool, - listFileFoldersServerTool, - moveFileFolderServerTool, - moveFileServerTool, - renameFileFolderServerTool, -} from '@/lib/copilot/tools/server/files/file-folders' -import { renameFileServerTool } from '@/lib/copilot/tools/server/files/rename-file' -import { shareFileServerTool } from '@/lib/copilot/tools/server/files/share-file' -import { workspaceFileServerTool } from '@/lib/copilot/tools/server/files/workspace-file' -import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generated-schema' -import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image' -import { knowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/knowledge-base' -import { searchKnowledgeBaseServerTool } from '@/lib/copilot/tools/server/knowledge/search-knowledge-base' -import { - readDocumentServerTool, - searchWorkspaceServerTool, -} from '@/lib/copilot/tools/server/knowledge/workspace-search' -import { ffmpegServerTool } from '@/lib/copilot/tools/server/media/ffmpeg' -import { generateAudioServerTool } from '@/lib/copilot/tools/server/media/generate-audio' -import { generateVideoServerTool } from '@/lib/copilot/tools/server/media/generate-video' -import { searchOnlineServerTool } from '@/lib/copilot/tools/server/other/search-online' -import { queryUserTableServerTool } from '@/lib/copilot/tools/server/table/query-user-table' -import { tableAutomationsServerTool } from '@/lib/copilot/tools/server/table/table-automations' -import { tableColumnsServerTool } from '@/lib/copilot/tools/server/table/table-columns' -import { tableEnrichmentsServerTool } from '@/lib/copilot/tools/server/table/table-enrichments' -import { tableManageServerTool } from '@/lib/copilot/tools/server/table/table-manage' -import { tableRowsServerTool } from '@/lib/copilot/tools/server/table/table-rows' -import { tableViewsServerTool } from '@/lib/copilot/tools/server/table/table-views' -import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' -import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-credentials' -import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables' -import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow' -import { queryLogsServerTool } from '@/lib/copilot/tools/server/workflow/query-logs' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' -import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' -import { withBlockVisibility } from '@/blocks/visibility/server-context' - -export type ExecuteResponseSuccess = z.output - -const ExecuteResponseSuccessSchema = z.object({ - success: z.literal(true), - result: z.unknown(), -}) - -const logger = createLogger('ServerToolRouter') - -/** - * Tools that resolve blocks through the registry (`getBlock`/`getAllBlocks`) and - * must run inside the custom-block overlay so `custom_block_*` types resolve. - */ -const CUSTOM_BLOCK_OVERLAY_TOOLS = new Set(['edit_workflow', 'get_blocks_metadata']) - -/** - * Discovery tools that consume the viewer's block-visibility context to hide - * gated blocks and credentials. `edit_workflow` establishes a narrower scope - * around operation validation after it resolves the workflow's actual - * workspace. - */ -const VISIBILITY_GATED_TOOLS = new Set([ - 'get_blocks_metadata', - 'get_credentials', - 'get_trigger_blocks', -]) - -const WRITE_ACTIONS: Record = { - [ManageKnowledgeBase.id]: [ - 'create', - 'add_file', - 'update', - 'delete', - 'delete_document', - 'update_document', - 'create_tag', - 'update_tag', - 'delete_tag', - 'add_connector', - 'update_connector', - 'delete_connector', - 'sync_connector', - ], - [UserTable.id]: [ - 'create', - 'create_from_file', - 'import_file', - 'delete', - 'rename', - 'insert_row', - 'batch_insert_rows', - 'update_row', - 'batch_update_rows', - 'delete_row', - 'batch_delete_rows', - 'update_rows_by_filter', - 'delete_rows_by_filter', - 'add_column', - 'rename_column', - 'delete_column', - 'update_column', - 'add_workflow_group', - 'update_workflow_group', - 'delete_workflow_group', - 'add_workflow_group_output', - 'delete_workflow_group_output', - 'run_column', - 'cancel_table_runs', - 'add_enrichment', - ], - [ManageCustomTool.id]: ['add', 'edit', 'delete'], - [ManageMcpConnection.id]: ['add', 'edit', 'delete'], - [ManageSkill.id]: ['add', 'edit', 'delete'], - [ManageCredential.id]: ['rename', 'delete'], - [PrepareFileEdit.id]: ['create', 'append', 'update', 'delete', 'rename', 'patch'], - [editContentServerTool.name]: ['*'], - [CreateEmptyFile.id]: ['*'], - rename_file: ['*'], - [shareFileServerTool.name]: ['*'], - move_file: ['*'], - create_file_folder: ['*'], - rename_file_folder: ['*'], - move_file_folder: ['*'], - [DownloadFile.id]: ['*'], - [GenerateImage.id]: ['generate'], - [GenerateVideo.id]: ['generate'], - [GenerateAudio.id]: ['generate'], - [Ffmpeg.id]: ['*'], - // Paid external-provider lookups (hosted-key cost), like the media tools. - [enrichmentRunServerTool.name]: ['*'], -} - -function isWriteAction(toolName: string, action: string | undefined): boolean { - const writeActions = WRITE_ACTIONS[toolName] - if (!writeActions) return false - // '*' means the tool is always a write operation regardless of action field - if (writeActions.includes('*')) return true - return Boolean(action && writeActions.includes(action)) -} - -/** Registry of all server tools. Tools self-declare their validation schemas. */ -const baseServerToolRegistry: Record = { - [getBlocksMetadataServerTool.name]: getBlocksMetadataServerTool, - [getTriggerBlocksServerTool.name]: getTriggerBlocksServerTool, - [editWorkflowServerTool.name]: editWorkflowServerTool, - [queryLogsServerTool.name]: queryLogsServerTool, - [searchDocsServerTool.name]: searchDocsServerTool, - [searchOnlineServerTool.name]: searchOnlineServerTool, - [setEnvironmentVariablesServerTool.name]: setEnvironmentVariablesServerTool, - [getCredentialsServerTool.name]: getCredentialsServerTool, - [knowledgeBaseServerTool.name]: knowledgeBaseServerTool, - [searchKnowledgeBaseServerTool.name]: searchKnowledgeBaseServerTool, - [searchWorkspaceServerTool.name]: searchWorkspaceServerTool, - [readDocumentServerTool.name]: readDocumentServerTool, - [enrichmentRunServerTool.name]: enrichmentRunServerTool, - [userTableServerTool.name]: userTableServerTool, - [queryUserTableServerTool.name]: queryUserTableServerTool, - [tableManageServerTool.name]: tableManageServerTool, - [tableRowsServerTool.name]: tableRowsServerTool, - [tableColumnsServerTool.name]: tableColumnsServerTool, - [tableAutomationsServerTool.name]: tableAutomationsServerTool, - [tableEnrichmentsServerTool.name]: tableEnrichmentsServerTool, - [tableViewsServerTool.name]: tableViewsServerTool, - [workspaceFileServerTool.name]: workspaceFileServerTool, - [editContentServerTool.name]: editContentServerTool, - [createFileServerTool.name]: createFileServerTool, - [renameFileServerTool.name]: renameFileServerTool, - [shareFileServerTool.name]: shareFileServerTool, - [moveFileServerTool.name]: moveFileServerTool, - [listFileFoldersServerTool.name]: listFileFoldersServerTool, - [createFileFolderServerTool.name]: createFileFolderServerTool, - [renameFileFolderServerTool.name]: renameFileFolderServerTool, - [moveFileFolderServerTool.name]: moveFileFolderServerTool, - [downloadToWorkspaceFileServerTool.name]: downloadToWorkspaceFileServerTool, - [extractDocAssetsServerTool.name]: extractDocAssetsServerTool, - [generateImageServerTool.name]: generateImageServerTool, - [generateVideoServerTool.name]: generateVideoServerTool, - [generateAudioServerTool.name]: generateAudioServerTool, - [ffmpegServerTool.name]: ffmpegServerTool, -} - -function getServerToolRegistry(): Record { - return baseServerToolRegistry -} - -export function getRegisteredServerToolNames(): string[] { - return Object.keys(getServerToolRegistry()) -} - -export async function routeExecution( - toolName: string, - payload: unknown, - context?: ServerToolContext -): Promise { - const tool = getServerToolRegistry()[toolName] - if (!tool) { - throw new OrchestrationError('validation', `Unknown server tool: ${toolName}`) - } - - logger.debug( - context?.messageId ? `Routing to tool [messageId:${context.messageId}]` : 'Routing to tool', - { toolName } - ) - - // Action-level permission enforcement for mixed read/write tools - if (WRITE_ACTIONS[toolName]) { - const p = payload as Record - const action = (p?.operation ?? p?.action) as string | undefined - if (isWriteAction(toolName, action) && !copilotToolCanWrite(context?.userPermission)) { - const actionLabel = action ? `'${action}' on ` : '' - // Classified so the projection surfaces it: a permission denial is - // caller-actionable (stop retrying, tell the user), not a system error. - throw new OrchestrationError( - 'forbidden', - `Permission denied: ${actionLabel}${toolName} requires write access. You have '${context?.userPermission ?? 'none'}' permission.` - ) - } - } - - assertServerToolNotAborted( - context, - `User stop signal aborted ${toolName} before payload normalization` - ) - - // Go injects chatId/workspaceId and may wrap the model's args inside a - // nested "args" object. Unwrap that before validation so the generated - // JSON Schema sees the flat tool contract shape. - let normalizedPayload = payload ?? {} - if (isRecordLike(normalizedPayload)) { - const raw = normalizedPayload as Record - if (raw.args && typeof raw.args === 'object' && !raw.operation) { - const nested = raw.args as Record - normalizedPayload = { ...nested, ...raw, args: undefined } - } - } - - const args = tool.inputSchema - ? tool.inputSchema.parse(normalizedPayload) - : validateGeneratedToolPayload(toolName, 'parameters', normalizedPayload) - - assertServerToolNotAborted(context, `User stop signal aborted ${toolName} after validation`) - - // Execute. The registry-dependent tools resolve blocks via getBlock/getAllBlocks; - // wrap them in the custom-block overlay for the workspace's org so `custom_block_*` - // types resolve (metadata lookup + edit-workflow validation) instead of being - // rejected as unknown, and wrap discovery tools in the viewer's block-visibility - // context so gated blocks stay hidden. The two ALS scopes are independent and - // nest in either order. Other tools skip the extra queries. - let run = () => tool.execute(args, context) - if (VISIBILITY_GATED_TOOLS.has(toolName) && context?.userId) { - // Memoized per (userId, workspaceId) ~30s — a multi-tool turn resolves once. - const vis = await getBlockVisibilityForCopilot(context.userId, context.workspaceId) - const inner = run - run = () => withBlockVisibility(vis, inner) - } - if (CUSTOM_BLOCK_OVERLAY_TOOLS.has(toolName) && context?.workspaceId) { - const rows = await listCustomBlocksWithInputsForWorkspace(context.workspaceId) - const inner = run - run = () => withCustomBlockOverlay(rows, inner) - } - const result = await run() - - // Validate output if tool declares a schema; otherwise fall back to the - // generated JSON schema contract emitted from Go. - return tool.outputSchema - ? tool.outputSchema.parse(result) - : validateGeneratedToolPayload(toolName, 'resultSchema', result) -} diff --git a/apps/sim/lib/copilot/tools/server/table/query-user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/query-user-table.test.ts deleted file mode 100644 index f6f3e35145f..00000000000 --- a/apps/sim/lib/copilot/tools/server/table/query-user-table.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @vitest-environment node - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const executeUserTable = vi.hoisted(() => vi.fn()) - -vi.mock('@/lib/copilot/tools/server/table/user-table', () => ({ - userTableServerTool: { execute: executeUserTable }, -})) - -import { queryUserTableServerTool } from '@/lib/copilot/tools/server/table/query-user-table' - -describe('query_user_table alias', () => { - beforeEach(() => { - vi.clearAllMocks() - executeUserTable.mockResolvedValue({ success: true, message: 'ok' }) - }) - - it('delegates read operations with the original trusted context', async () => { - const context = { - userId: 'user-1', - workspaceId: 'workspace-1', - toolCallId: 'tool-call-1', - copilotToolExecution: true, - } - const params = { operation: 'query_rows', args: { tableId: 'table-1', limit: 10 } } - - await expect(queryUserTableServerTool.execute(params, context)).resolves.toEqual({ - success: true, - message: 'ok', - }) - expect(executeUserTable).toHaveBeenCalledWith(params, context) - }) - - it('rejects mutations and outputPath without invoking user_table', async () => { - await expect( - queryUserTableServerTool.execute({ operation: 'delete', args: { tableId: 'table-1' } }) - ).resolves.toMatchObject({ success: false, message: expect.stringContaining('read-only') }) - await expect( - queryUserTableServerTool.execute({ - operation: 'query_rows', - args: { tableId: 'table-1', outputPath: 'files/result.csv' }, - }) - ).resolves.toMatchObject({ success: false, message: expect.stringContaining('outputPath') }) - expect(executeUserTable).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/table/query-user-table.ts b/apps/sim/lib/copilot/tools/server/table/query-user-table.ts deleted file mode 100644 index e2b07176da5..00000000000 --- a/apps/sim/lib/copilot/tools/server/table/query-user-table.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { QueryUserTable } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' - -type QueryUserTableArgs = { - operation: string - args?: Record -} - -type QueryUserTableResult = { - success: boolean - message: string - data?: any -} - -const READ_OPERATIONS = new Set(['get', 'get_schema', 'get_row', 'query_rows']) - -/** - * Read-only variant of user_table for info-gathering agents. Copilot access - * control is a per-agent tool allowlist, so read-only access gets its own tool - * name with its own operation contract — enforced here (where execution - * happens) on top of the fail-fast guard in the Go executor. outputPath is - * rejected because query_rows exports rows to a workspace file through it. - */ -export const queryUserTableServerTool: BaseServerTool = { - name: QueryUserTable.id, - async execute(params: QueryUserTableArgs, context?: ServerToolContext) { - const operation = params?.operation - if (!READ_OPERATIONS.has(operation)) { - return { - success: false, - message: `query_user_table is read-only: operation '${operation}' is not available (allowed: get, get_row, get_schema, query_rows); mutations go through the table agent's user_table tool`, - } - } - if (params?.args && 'outputPath' in params.args) { - return { - success: false, - message: - 'query_user_table is read-only: outputPath (file export) is not available; digest the rows directly or route exports through the table agent', - } - } - if (params && 'outputPath' in (params as Record)) { - return { - success: false, - message: - 'query_user_table is read-only: outputPath (file export) is not available; digest the rows directly or route exports through the table agent', - } - } - return userTableServerTool.execute(params, context) - }, -} diff --git a/apps/sim/lib/copilot/tools/server/table/table-automations.ts b/apps/sim/lib/copilot/tools/server/table/table-automations.ts deleted file mode 100644 index e2b94929404..00000000000 --- a/apps/sim/lib/copilot/tools/server/table/table-automations.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { TableAutomations } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' - -type TableAutomationsArgs = { - operation: string - args?: Record -} - -type TableAutomationsResult = { - success: boolean - message: string - data?: any -} - -const ALLOWED_OPERATIONS = new Set([ - 'list_workflow_outputs', - 'add_workflow_group', - 'update_workflow_group', - 'delete_workflow_group', - 'add_workflow_group_output', - 'delete_workflow_group_output', - 'run_column', - 'cancel_table_runs', -]) - -/** - * per-row workflow automations slice of the split user_table surface. Copilot access control is a - * per-agent tool allowlist, so each slice gets its own tool name with its own - * operation contract — enforced here (where execution happens) on top of the - * schema enum in the Go catalog. Delegates to the shared user_table executor, - * so argument semantics stay identical by construction. - */ -export const tableAutomationsServerTool: BaseServerTool< - TableAutomationsArgs, - TableAutomationsResult -> = { - name: TableAutomations.id, - async execute(params: TableAutomationsArgs, context?: ServerToolContext) { - const operation = params?.operation - if (!ALLOWED_OPERATIONS.has(operation)) { - return { - success: false, - message: `table_automations does not support operation '${operation}' (allowed: list_workflow_outputs, add_workflow_group, update_workflow_group, delete_workflow_group, add_workflow_group_output, delete_workflow_group_output, run_column, cancel_table_runs); other table operations live on their own table_* tools`, - } - } - return userTableServerTool.execute(params, context) - }, -} diff --git a/apps/sim/lib/copilot/tools/server/table/table-columns.ts b/apps/sim/lib/copilot/tools/server/table/table-columns.ts deleted file mode 100644 index 9e4ebc2f499..00000000000 --- a/apps/sim/lib/copilot/tools/server/table/table-columns.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { TableColumns } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' - -type TableColumnsArgs = { - operation: string - args?: Record -} - -type TableColumnsResult = { - success: boolean - message: string - data?: any -} - -const ALLOWED_OPERATIONS = new Set([ - 'add_column', - 'rename_column', - 'delete_column', - 'update_column', -]) - -/** - * column DDL (add/rename/retype/delete) slice of the split user_table surface. Copilot access control is a - * per-agent tool allowlist, so each slice gets its own tool name with its own - * operation contract — enforced here (where execution happens) on top of the - * schema enum in the Go catalog. Delegates to the shared user_table executor, - * so argument semantics stay identical by construction. - */ -export const tableColumnsServerTool: BaseServerTool = { - name: TableColumns.id, - async execute(params: TableColumnsArgs, context?: ServerToolContext) { - const operation = params?.operation - if (!ALLOWED_OPERATIONS.has(operation)) { - return { - success: false, - message: `table_columns does not support operation '${operation}' (allowed: add_column, rename_column, delete_column, update_column); other table operations live on their own table_* tools`, - } - } - return userTableServerTool.execute(params, context) - }, -} diff --git a/apps/sim/lib/copilot/tools/server/table/table-enrichments.ts b/apps/sim/lib/copilot/tools/server/table/table-enrichments.ts deleted file mode 100644 index caa317ebdfa..00000000000 --- a/apps/sim/lib/copilot/tools/server/table/table-enrichments.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { TableEnrichments } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' - -type TableEnrichmentsArgs = { - operation: string - args?: Record -} - -type TableEnrichmentsResult = { - success: boolean - message: string - data?: any -} - -const ALLOWED_OPERATIONS = new Set(['list_enrichments', 'add_enrichment']) - -/** - * prebuilt per-row enrichments slice of the split user_table surface. Copilot access control is a - * per-agent tool allowlist, so each slice gets its own tool name with its own - * operation contract — enforced here (where execution happens) on top of the - * schema enum in the Go catalog. Delegates to the shared user_table executor, - * so argument semantics stay identical by construction. - */ -export const tableEnrichmentsServerTool: BaseServerTool< - TableEnrichmentsArgs, - TableEnrichmentsResult -> = { - name: TableEnrichments.id, - async execute(params: TableEnrichmentsArgs, context?: ServerToolContext) { - const operation = params?.operation - if (!ALLOWED_OPERATIONS.has(operation)) { - return { - success: false, - message: `table_enrichments does not support operation '${operation}' (allowed: list_enrichments, add_enrichment); other table operations live on their own table_* tools`, - } - } - return userTableServerTool.execute(params, context) - }, -} diff --git a/apps/sim/lib/copilot/tools/server/table/table-manage.ts b/apps/sim/lib/copilot/tools/server/table/table-manage.ts deleted file mode 100644 index b8030fc5f43..00000000000 --- a/apps/sim/lib/copilot/tools/server/table/table-manage.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { TableManage } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' - -type TableManageArgs = { - operation: string - args?: Record -} - -type TableManageResult = { - success: boolean - message: string - data?: any -} - -const ALLOWED_OPERATIONS = new Set(['create', 'create_from_file', 'import_file', 'rename']) - -/** - * table lifecycle (create, create_from_file, import_file, rename) slice of the split user_table surface. Copilot access control is a - * per-agent tool allowlist, so each slice gets its own tool name with its own - * operation contract — enforced here (where execution happens) on top of the - * schema enum in the Go catalog. Delegates to the shared user_table executor, - * so argument semantics stay identical by construction. - */ -export const tableManageServerTool: BaseServerTool = { - name: TableManage.id, - async execute(params: TableManageArgs, context?: ServerToolContext) { - const operation = params?.operation - if (!ALLOWED_OPERATIONS.has(operation)) { - return { - success: false, - message: `table_manage does not support operation '${operation}' (allowed: create, create_from_file, import_file, rename); other table operations live on their own table_* tools`, - } - } - return userTableServerTool.execute(params, context) - }, -} diff --git a/apps/sim/lib/copilot/tools/server/table/table-rows.ts b/apps/sim/lib/copilot/tools/server/table/table-rows.ts deleted file mode 100644 index aec5ee08211..00000000000 --- a/apps/sim/lib/copilot/tools/server/table/table-rows.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { TableRows } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' - -type TableRowsArgs = { - operation: string - args?: Record -} - -type TableRowsResult = { - success: boolean - message: string - data?: any -} - -const ALLOWED_OPERATIONS = new Set([ - 'insert_row', - 'batch_insert_rows', - 'update_row', - 'batch_update_rows', - 'delete_row', - 'batch_delete_rows', - 'update_rows_by_filter', - 'delete_rows_by_filter', -]) - -/** - * row data (insert/update/delete, batch and by-filter) slice of the split user_table surface. Copilot access control is a - * per-agent tool allowlist, so each slice gets its own tool name with its own - * operation contract — enforced here (where execution happens) on top of the - * schema enum in the Go catalog. Delegates to the shared user_table executor, - * so argument semantics stay identical by construction. - */ -export const tableRowsServerTool: BaseServerTool = { - name: TableRows.id, - async execute(params: TableRowsArgs, context?: ServerToolContext) { - const operation = params?.operation - if (!ALLOWED_OPERATIONS.has(operation)) { - return { - success: false, - message: `table_rows does not support operation '${operation}' (allowed: insert_row, batch_insert_rows, update_row, batch_update_rows, delete_row, batch_delete_rows, update_rows_by_filter, delete_rows_by_filter); other table operations live on their own table_* tools`, - } - } - return userTableServerTool.execute(params, context) - }, -} diff --git a/apps/sim/lib/copilot/tools/server/table/table-split.test.ts b/apps/sim/lib/copilot/tools/server/table/table-split.test.ts deleted file mode 100644 index 00233836d39..00000000000 --- a/apps/sim/lib/copilot/tools/server/table/table-split.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @vitest-environment node - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const executeUserTable = vi.hoisted(() => vi.fn()) - -vi.mock('@/lib/copilot/tools/server/table/user-table', () => ({ - userTableServerTool: { execute: executeUserTable }, -})) - -import { tableAutomationsServerTool } from '@/lib/copilot/tools/server/table/table-automations' -import { tableColumnsServerTool } from '@/lib/copilot/tools/server/table/table-columns' -import { tableEnrichmentsServerTool } from '@/lib/copilot/tools/server/table/table-enrichments' -import { tableManageServerTool } from '@/lib/copilot/tools/server/table/table-manage' -import { tableRowsServerTool } from '@/lib/copilot/tools/server/table/table-rows' - -/** - * Every split tool delegates its own operations to the shared user_table - * executor untouched, and rejects operations that belong to a sibling slice - * without ever invoking it — the per-slice allowlist is the access contract. - */ -describe('split table tools', () => { - beforeEach(() => { - vi.clearAllMocks() - executeUserTable.mockResolvedValue({ success: true, message: 'ok' }) - }) - - const cases = [ - { tool: tableManageServerTool, own: 'create', foreign: 'insert_row' }, - { tool: tableRowsServerTool, own: 'batch_update_rows', foreign: 'add_column' }, - { tool: tableColumnsServerTool, own: 'update_column', foreign: 'create' }, - { tool: tableAutomationsServerTool, own: 'run_column', foreign: 'add_enrichment' }, - { tool: tableEnrichmentsServerTool, own: 'add_enrichment', foreign: 'run_column' }, - ] as const - - it.each(cases)( - '$tool.name delegates $own and rejects $foreign', - async ({ tool, own, foreign }) => { - const context = { userId: 'user-1', workspaceId: 'workspace-1', copilotToolExecution: true } - const params = { operation: own, args: { tableId: 'table-1' } } - - await expect(tool.execute(params as never, context as never)).resolves.toEqual({ - success: true, - message: 'ok', - }) - expect(executeUserTable).toHaveBeenCalledWith(params, context) - - executeUserTable.mockClear() - await expect( - tool.execute({ operation: foreign, args: { tableId: 'table-1' } } as never) - ).resolves.toMatchObject({ - success: false, - message: expect.stringContaining(foreign), - }) - expect(executeUserTable).not.toHaveBeenCalled() - } - ) -}) diff --git a/apps/sim/lib/copilot/tools/server/table/table-views.test.ts b/apps/sim/lib/copilot/tools/server/table/table-views.test.ts deleted file mode 100644 index 89579a4cc0d..00000000000 --- a/apps/sim/lib/copilot/tools/server/table/table-views.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * @vitest-environment node - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const useCases = vi.hoisted(() => ({ - list: vi.fn(), - read: vi.fn(), - create: vi.fn(), - update: vi.fn(), - del: vi.fn(), -})) - -vi.mock('@/lib/table/application/views', () => ({ - listTableViewsUseCase: { operation: { id: 'tables.views.list' }, execute: useCases.list }, - readTableViewUseCase: { operation: { id: 'tables.views.read' }, execute: useCases.read }, - createTableViewUseCase: { operation: { id: 'tables.views.create' }, execute: useCases.create }, - updateTableViewUseCase: { operation: { id: 'tables.views.update' }, execute: useCases.update }, - deleteTableViewUseCase: { operation: { id: 'tables.views.delete' }, execute: useCases.del }, -})) - -const executeUseCase = vi.hoisted(() => vi.fn()) -vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ - executeCopilotTableUseCase: executeUseCase, -})) - -import { tableViewsServerTool } from '@/lib/copilot/tools/server/table/table-views' -import { asOrchestrationError } from '@/lib/core/orchestration/types' - -const context = { userId: 'user-1', workspaceId: 'ws-1', copilotToolExecution: true } as never - -const columns = [ - { id: 'col_a', name: 'status', type: 'string' }, - { id: 'col_b', name: 'due', type: 'date' }, -] -const table = { id: 'tbl-1', name: 'Invoices', schema: { columns } } - -describe('table_views adapter', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('translates stored id-domain configs to column names on list', async () => { - executeUseCase.mockResolvedValueOnce({ - table, - views: [ - { - id: 'view-1', - name: 'Overdue', - isDefault: true, - config: { - filter: { all: [{ field: 'col_a', op: 'ne', value: 'Done' }] }, - sort: [{ field: 'col_b', direction: 'asc' }], - }, - }, - ], - }) - - const result = await tableViewsServerTool.execute( - { operation: 'list_views', args: { tableId: 'tbl-1' } }, - context - ) - - expect(result.success).toBe(true) - expect(result.data.views[0].filter).toEqual({ - all: [{ field: 'status', op: 'ne', value: 'Done' }], - }) - expect(result.data.views[0].sort).toEqual([{ field: 'due', direction: 'asc' }]) - }) - - it('translates agent column names to stable ids on create', async () => { - executeUseCase.mockResolvedValueOnce({ table, views: [] }).mockResolvedValueOnce({ - view: { id: 'view-2', name: 'Mine', isDefault: false, config: {} }, - table, - }) - - const result = await tableViewsServerTool.execute( - { - operation: 'create_view', - args: { - tableId: 'tbl-1', - name: 'Mine', - filter: { all: [{ field: 'status', op: 'eq', value: 'Open' }] }, - }, - }, - context - ) - - expect(result.success).toBe(true) - const createInput = executeUseCase.mock.calls[1][2] - expect(createInput.config.filter).toEqual({ - all: [{ field: 'col_a', op: 'eq', value: 'Open' }], - }) - expect(createInput).not.toHaveProperty('isDefault') - // What resource extraction reads to open the panel on the new view. - expect(result.data).toMatchObject({ tableId: 'tbl-1', tableName: 'Invoices', viewId: 'view-2' }) - }) - - it('makes the view default inside the same create, with no follow-up write', async () => { - executeUseCase.mockResolvedValueOnce({ table, views: [] }).mockResolvedValueOnce({ - view: { id: 'view-2', name: 'Mine', isDefault: true, config: {} }, - table, - }) - - const result = await tableViewsServerTool.execute( - { operation: 'create_view', args: { tableId: 'tbl-1', name: 'Mine', isDefault: true } }, - context - ) - - expect(executeUseCase).toHaveBeenCalledTimes(2) - expect(executeUseCase.mock.calls[1][2]).toMatchObject({ isDefault: true }) - expect(result.message).toContain('as default') - expect(result.data.view.isDefault).toBe(true) - }) - - it('names the table and view on update, and only the table on delete', async () => { - const stored = { id: 'view-1', name: 'Overdue', isDefault: false, config: {} } - executeUseCase.mockResolvedValueOnce({ table, views: [stored] }).mockResolvedValueOnce({ - view: { ...stored, name: 'Late' }, - table, - }) - const updated = await tableViewsServerTool.execute( - { operation: 'update_view', args: { tableId: 'tbl-1', viewId: 'view-1', name: 'Late' } }, - context - ) - expect(updated.data).toMatchObject({ - tableId: 'tbl-1', - tableName: 'Invoices', - viewId: 'view-1', - }) - - executeUseCase.mockResolvedValueOnce({ viewId: 'view-1', viewName: 'Late', table }) - const deleted = await tableViewsServerTool.execute( - { operation: 'delete_view', args: { tableId: 'tbl-1', viewId: 'view-1' } }, - context - ) - expect(deleted.data).toEqual({ tableId: 'tbl-1', tableName: 'Invoices' }) - }) - - it('rejects unknown column names with the columns spelled out', async () => { - executeUseCase.mockResolvedValueOnce({ table, views: [] }) - - const failure = await tableViewsServerTool - .execute( - { - operation: 'create_view', - args: { - tableId: 'tbl-1', - name: 'Broken', - filter: { all: [{ field: 'nope', op: 'eq', value: 1 }] }, - }, - }, - context - ) - .catch((error: unknown) => error) - - // Classified as the caller's mistake, so the model sees the column name - // instead of a masked system error. - expect(asOrchestrationError(failure)?.code).toBe('validation') - expect(asOrchestrationError(failure)?.message).toMatch(/Unknown column/) - expect(executeUseCase).toHaveBeenCalledTimes(1) - }) - - it('rejects unsupported operations without invoking anything', async () => { - const result = await tableViewsServerTool.execute( - { operation: 'insert_row', args: { tableId: 'tbl-1' } }, - context - ) - expect(result.success).toBe(false) - expect(result.message).toContain('insert_row') - expect(executeUseCase).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/table/table-views.ts b/apps/sim/lib/copilot/tools/server/table/table-views.ts deleted file mode 100644 index 6872f55d4b3..00000000000 --- a/apps/sim/lib/copilot/tools/server/table/table-views.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' -import { TableViews } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import type { SortSpec, TablePredicateInput, TableSchema, TableViewConfig } from '@/lib/table' -import { - createTableViewUseCase, - deleteTableViewUseCase, - listTableViewsUseCase, - readTableViewUseCase, - updateTableViewUseCase, -} from '@/lib/table/application/views' -import { - TableViewValidationError, - viewConfigIdsToNames, - viewConfigNamesToIds, -} from '@/lib/table/views/service' - -type TableViewsArgs = { - operation: string - args?: Record -} - -type TableViewsResult = { - success: boolean - message: string - data?: any -} - -type StoredView = { id: string; name: string; isDefault: boolean; config: TableViewConfig } - -/** - * Saved-view slice of the split table surface. Unlike the other slices this is - * NOT a user_table passthrough — it adapts the dedicated view use cases. - * Agents speak column NAMES; stored configs are keyed by stable column id, so - * inputs translate names→ids on the way in and every returned view translates - * ids→names on the way out. Every write also names the table and the view it - * touched in `data`; resource extraction reads that to open the panel on the - * view that was just written. - */ -export const tableViewsServerTool: BaseServerTool = { - name: TableViews.id, - async execute(params: TableViewsArgs, context?: ServerToolContext) { - const operation = params?.operation - const args = params?.args ?? {} - const tableId = args.tableId as string | undefined - const workspaceId = context?.workspaceId - if (!tableId) return { success: false, message: 'Table ID is required' } - if (!workspaceId) return { success: false, message: 'Workspace ID is required' } - - const presentView = (view: StoredView, columns: TableSchema['columns']) => { - const named = viewConfigIdsToNames(view.config, columns) - return { - id: view.id, - name: view.name, - isDefault: view.isDefault, - filter: named.filter ?? null, - sort: named.sort ?? null, - hiddenColumns: named.hiddenColumns?.length ? named.hiddenColumns : undefined, - } - } - - // What a write hands back: the view, plus the ids the resource panel opens on. - const presentWrite = ( - table: { id: string; name: string }, - view: StoredView, - columns: TableSchema['columns'] - ) => ({ - tableId: table.id, - tableName: table.name, - viewId: view.id, - view: presentView(view, columns), - }) - - // Build the patch from only the keys the caller actually sent: the update - // path shallow-merges this into the stored config, so including an absent - // part as `null` silently wiped a view's saved sort when only the filter - // changed (and vice versa) — the doc promises "omit to keep, null to clear". - // The name→id translation runs here, outside the use case that would - // classify a bad column name, so it is classified here: unclassified, the - // model gets a masked "system error" instead of the column it got wrong. - const namedConfigFromArgs = (columns: TableSchema['columns']): TableViewConfig => { - const patch: Record = {} - if (args.filter !== undefined) patch.filter = args.filter as TablePredicateInput | null - if (args.sort !== undefined) patch.sort = args.sort as SortSpec | null - if (args.hiddenColumns !== undefined) patch.hiddenColumns = args.hiddenColumns as string[] - try { - return viewConfigNamesToIds(patch as TableViewConfig, columns) - } catch (error) { - if (error instanceof TableViewValidationError) { - throw new OrchestrationError('validation', error.message) - } - throw error - } - } - - switch (operation) { - case 'list_views': { - const result = await executeCopilotTableUseCase( - context, - listTableViewsUseCase, - { tableId, workspaceId }, - { tableId } - ) - const columns = (result.table.schema as TableSchema).columns - const views = result.views.map((view) => presentView(view, columns)) - return { - success: true, - message: `Table has ${views.length} view(s)`, - data: { views }, - } - } - case 'get_view': { - if (!args.viewId) return { success: false, message: 'viewId is required' } - const result = await executeCopilotTableUseCase( - context, - readTableViewUseCase, - { tableId, workspaceId, viewId: args.viewId }, - { tableId } - ) - const columns = (result.table.schema as TableSchema).columns - return { - success: true, - message: 'View loaded', - data: { view: presentView(result.view, columns) }, - } - } - case 'create_view': { - if (!args.name) return { success: false, message: 'name is required' } - const listed = await executeCopilotTableUseCase( - context, - listTableViewsUseCase, - { tableId, workspaceId }, - { tableId } - ) - const columns = (listed.table.schema as TableSchema).columns - // The default flag lands in the same locked transaction as the insert - // (demoting the previous default), so no follow-up write can race it. - const created = await executeCopilotTableUseCase( - context, - createTableViewUseCase, - { - tableId, - workspaceId, - name: args.name, - config: namedConfigFromArgs(columns), - ...(args.isDefault === true ? { isDefault: true } : {}), - }, - { tableId } - ) - return { - success: true, - message: `Created view "${created.view.name}" (${created.view.id})${created.view.isDefault ? ' as default' : ''}`, - data: presentWrite(created.table, created.view, columns), - } - } - case 'update_view': { - if (!args.viewId) return { success: false, message: 'viewId is required' } - const listed = await executeCopilotTableUseCase( - context, - listTableViewsUseCase, - { tableId, workspaceId }, - { tableId } - ) - const columns = (listed.table.schema as TableSchema).columns - const hasConfigChange = - args.filter !== undefined || args.sort !== undefined || args.hiddenColumns !== undefined - const updated = await executeCopilotTableUseCase( - context, - updateTableViewUseCase, - { - tableId, - workspaceId, - viewId: args.viewId, - name: args.name as string | undefined, - ...(hasConfigChange ? { configPatch: namedConfigFromArgs(columns) } : {}), - isDefault: args.isDefault as boolean | undefined, - }, - { tableId } - ) - return { - success: true, - message: `Updated view "${updated.view.name}"`, - data: presentWrite(updated.table, updated.view, columns), - } - } - case 'delete_view': { - if (!args.viewId) return { success: false, message: 'viewId is required' } - const result = await executeCopilotTableUseCase( - context, - deleteTableViewUseCase, - { tableId, workspaceId, viewId: args.viewId }, - { tableId } - ) - return { - success: true, - message: `Deleted view "${result.viewName}"`, - data: { tableId: result.table.id, tableName: result.table.name }, - } - } - case 'set_default_view': { - if (!args.viewId) return { success: false, message: 'viewId is required' } - const listed = await executeCopilotTableUseCase( - context, - listTableViewsUseCase, - { tableId, workspaceId }, - { tableId } - ) - const columns = (listed.table.schema as TableSchema).columns - const updated = await executeCopilotTableUseCase( - context, - updateTableViewUseCase, - { tableId, workspaceId, viewId: args.viewId, isDefault: true }, - { tableId } - ) - return { - success: true, - message: `"${updated.view.name}" is now the default view`, - data: presentWrite(updated.table, updated.view, columns), - } - } - default: - return { - success: false, - message: `table_views does not support operation '${operation}' (allowed: list_views, get_view, create_view, update_view, delete_view, set_default_view)`, - } - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts deleted file mode 100644 index 2d247f6847d..00000000000 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ /dev/null @@ -1,1989 +0,0 @@ -/** - * @vitest-environment node - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import type { TableDefinition } from '@/lib/table' - -const { - mockUpdateColumnType, - mockUpdateColumnOptions, - mockResolveWorkspaceFileReference, - mockGetBoundWorkspaceFileSecretProvenance, - mockDownloadWorkspaceFile, - mockGetTableById, - mockBatchInsertRows, - mockBatchUpdateRows, - mockInsertRow, - mockUpdateRow, - mockReplaceTableRows, - mockAddWorkflowGroup, - mockCreateTable, - mockDeleteTable, - mockGetWorkspaceTableLimits, - mockMarkTableJobRunning, - mockReleaseJobClaim, - mockQueryRows, - mockDeleteRowsByFilter, - mockDeleteColumns, - mockUpdateRowsByFilter, - mockRunTableImport, - mockRunTableDelete, - mockRunTableUpdate, - mockExecuteCopilotFileUseCase, - mockExecuteCopilotWorkflowUseCase, - mockLoadWorkspaceFileContext, - mockCreateTableRowProvenanceReader, - mockExportTableRowProvenance, - mockResolveWorkflowContext, - fakeEnrichment, -} = vi.hoisted(() => ({ - mockUpdateColumnType: vi.fn(), - mockUpdateColumnOptions: vi.fn(), - mockResolveWorkspaceFileReference: vi.fn(), - mockGetBoundWorkspaceFileSecretProvenance: vi.fn(), - mockDownloadWorkspaceFile: vi.fn(), - mockGetTableById: vi.fn(), - mockBatchInsertRows: vi.fn(), - mockBatchUpdateRows: vi.fn(), - mockInsertRow: vi.fn(), - mockUpdateRow: vi.fn(), - mockReplaceTableRows: vi.fn(), - mockAddWorkflowGroup: vi.fn(), - mockCreateTable: vi.fn(), - mockDeleteTable: vi.fn(), - mockGetWorkspaceTableLimits: vi.fn(), - mockMarkTableJobRunning: vi.fn(), - mockReleaseJobClaim: vi.fn(), - mockQueryRows: vi.fn(), - mockDeleteRowsByFilter: vi.fn(), - mockDeleteColumns: vi.fn(), - mockUpdateRowsByFilter: vi.fn(), - mockRunTableImport: vi.fn(), - mockRunTableDelete: vi.fn(), - mockRunTableUpdate: vi.fn(), - mockExecuteCopilotFileUseCase: vi.fn(), - mockExecuteCopilotWorkflowUseCase: vi.fn(), - mockLoadWorkspaceFileContext: vi.fn(), - mockCreateTableRowProvenanceReader: vi.fn(), - mockExportTableRowProvenance: vi.fn(), - mockResolveWorkflowContext: vi.fn(), - fakeEnrichment: { - id: 'work-email', - name: 'Work Email', - description: 'Find work email', - icon: () => null, - inputs: [ - { id: 'fullName', name: 'Full name', type: 'string', required: true }, - { id: 'companyDomain', name: 'Company domain', type: 'string', required: true }, - ], - outputs: [{ id: 'email', name: 'email', type: 'string' }], - providers: [], - }, -})) - -vi.mock('@sim/utils/id', () => ({ - generateId: vi.fn().mockReturnValue('deadbeefcafef00d'), - generateShortId: vi.fn().mockReturnValue('short-id'), -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - resolveWorkspaceFileReference: async (workspaceId: string, reference: string) => { - const file = await mockResolveWorkspaceFileReference(workspaceId, reference) - return file ? { ...file, workspaceId: file.workspaceId ?? workspaceId } : null - }, - fetchWorkspaceFileBuffer: mockDownloadWorkspaceFile, - loadActiveWorkspaceFileContext: mockLoadWorkspaceFileContext, -})) -vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ - readWorkspaceFileContent: { - execute: async () => ({ content: await mockDownloadWorkspaceFile() }), - }, -})) -vi.mock('@/lib/copilot/auth/file-delegation', () => ({ - resolveCopilotFilePrincipal: vi.fn(() => ({ - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'test-tool', - audience: 'sim:workspace-files', - issuedAt: new Date(0), - expiresAt: new Date(Date.now() + 60_000), - })), -})) - -vi.mock('@/lib/copilot/auth/table-delegation', () => ({ - messageForCopilotTableError: (error: unknown) => { - const classified = error as { code?: string; message?: string } - return classified.code && classified.code !== 'internal' - ? (classified.message ?? 'Table operation failed') - : 'Table operation failed' - }, -})) - -vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({ - executeCopilotFileUseCase: mockExecuteCopilotFileUseCase, -})) - -vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ - executeCopilotResolveWorkflowOutputs: mockExecuteCopilotWorkflowUseCase, -})) - -vi.mock('@sim/platform-authz/workspace', () => ({ - permissionSatisfies: (actual: string | null, required: string) => { - const rank = { read: 1, write: 2, admin: 3 } as const - return ( - actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] - ) - }, - resolveEffectiveWorkspacePermission: vi.fn().mockResolvedValue('write'), -})) - -vi.mock('@/lib/table/application/context', () => ({ - resolveActiveTableContext: async (input: { tableId: string; assertedWorkspaceId?: string }) => { - const table = await mockGetTableById(input.tableId) - if (!table || (input.assertedWorkspaceId && table.workspaceId !== input.assertedWorkspaceId)) { - throw Object.assign(new Error('Table not found'), { code: 'not_found' }) - } - if (table.archivedAt) { - throw Object.assign(new Error('Table is archived'), { code: 'conflict' }) - } - return { - tableId: table.id, - table, - workspaceId: table.workspaceId, - workspaceOrganizationId: null, - allowPersonalApiKeys: true, - billedAccountUserId: 'user-1', - } - }, - resolveTableWorkspaceContext: async (workspaceId: string) => ({ - workspaceId, - workspaceOrganizationId: null, - allowPersonalApiKeys: true, - billedAccountUserId: 'user-1', - }), -})) - -vi.mock('@/lib/table/application/folder-paths', () => ({ - resolveTableFolderPath: async () => ({ - folderId: null, - index: { idByPath: new Map(), pathById: new Map() }, - }), - tableFolderPathForId: () => '/', - archivableTableFolderPath: () => '/', -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ - getBoundWorkspaceFileSecretProvenance: mockGetBoundWorkspaceFileSecretProvenance, -})) - -vi.mock('@/enrichments/registry', () => ({ - ALL_ENRICHMENTS: [fakeEnrichment], - getEnrichment: (id: string) => (id === fakeEnrichment.id ? fakeEnrichment : undefined), -})) - -vi.mock('@/lib/table/service', () => ({ - createTable: mockCreateTable, - deleteTable: mockDeleteTable, - getTableById: mockGetTableById, - renameTable: vi.fn(), -})) - -vi.mock('@/lib/table/workflow-groups/service', () => ({ - addWorkflowGroup: mockAddWorkflowGroup, - addWorkflowGroupOutput: vi.fn(), - deleteWorkflowGroup: vi.fn(), - deleteWorkflowGroupOutput: vi.fn(), - updateWorkflowGroup: vi.fn(), -})) - -vi.mock('@/lib/table/columns/service', () => ({ - addTableColumn: vi.fn(), - deleteColumn: vi.fn(), - deleteColumns: mockDeleteColumns, - renameColumn: vi.fn(), - updateColumnConstraints: vi.fn(), - updateColumnType: mockUpdateColumnType, - updateColumnOptions: mockUpdateColumnOptions, -})) - -vi.mock('@/lib/table/rows/service', () => ({ - batchInsertRows: mockBatchInsertRows, - batchUpdateRows: mockBatchUpdateRows, - deleteRow: vi.fn(), - deleteRowsByFilter: mockDeleteRowsByFilter, - deleteRowsByIds: vi.fn(), - getRowById: vi.fn(), - insertRow: mockInsertRow, - queryRows: mockQueryRows, - replaceTableRows: mockReplaceTableRows, - updateRow: mockUpdateRow, - updateRowsByFilter: mockUpdateRowsByFilter, -})) - -vi.mock('@/lib/table/rows/secret-provenance', () => ({ - createExactEmptyTableRowSecretProvenance: (data: Record) => ({ - complete: true, - columns: Object.fromEntries( - Object.keys(data).map((columnId) => [columnId, { version: 1, complete: true, entries: [] }]) - ), - }), - TableRowProvenanceReader: class { - constructor(scope: unknown) { - mockCreateTableRowProvenanceReader(scope) - } - exportProvenance = mockExportTableRowProvenance - }, -})) - -vi.mock('@/lib/table/jobs/service', () => ({ - markTableJobRunningInWorkspace: mockMarkTableJobRunning, - releaseJobClaimInWorkspace: mockReleaseJobClaim, -})) - -vi.mock('@/lib/table/import-runner', () => ({ - runTableImport: mockRunTableImport, -})) - -vi.mock('@/lib/table/delete-runner', () => ({ - markTableDeleteFailed: vi.fn(), - runTableDelete: mockRunTableDelete, -})) - -vi.mock('@/lib/table/update-runner', () => ({ - markTableUpdateFailed: vi.fn(), - runTableUpdate: mockRunTableUpdate, -})) - -vi.mock('@/lib/table/billing', () => ({ - getWorkspaceTableLimits: mockGetWorkspaceTableLimits, -})) - -vi.mock('@/lib/workflows/application/context', () => ({ - resolveActiveWorkflowApplicationContext: mockResolveWorkflowContext, -})) - -vi.mock('@/lib/workflows/application/resolve-workflow-outputs', () => ({ - loadResolvedDeployedWorkflowOutputs: async () => mockExecuteCopilotWorkflowUseCase(), - loadResolvedWorkflowOutputs: async () => mockExecuteCopilotWorkflowUseCase(), - resolveWorkflowOutputs: { operation: { id: 'workflows.read' } }, -})) - -import { userTableServerTool } from '@/lib/copilot/tools/server/table/user-table' -import { encodeCursor } from '@/lib/table/rows/cursor' -import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' - -beforeEach(() => { - mockLoadWorkspaceFileContext.mockResolvedValue({ workspaceId: 'workspace-1' }) - mockExportTableRowProvenance.mockReturnValue({ - version: 1, - complete: true, - entries: [], - scope: { userId: 'user-1', workspaceId: 'workspace-1' }, - }) - mockResolveWorkflowContext.mockImplementation( - async ({ - workflowId, - assertedWorkspaceId, - }: { - workflowId: string - assertedWorkspaceId: string - }) => ({ - workflowId, - workspaceId: assertedWorkspaceId, - }) - ) - mockExecuteCopilotWorkflowUseCase.mockResolvedValue({ - workflowId: 'workflow-1', - outputs: [ - { - blockId: 'block-1', - blockName: 'Agent', - blockType: 'agent', - path: 'content', - leafType: 'string', - }, - ], - executionOrderByBlockId: { 'block-1': 1 }, - }) - mockExecuteCopilotFileUseCase.mockImplementation( - async (_context: unknown, _useCase: unknown, input: Record) => { - const workspaceId = String(input.workspaceId) - const reference = String(input.reference) - const file = await mockResolveWorkspaceFileReference(workspaceId, reference) - if (!file) throw new OrchestrationError('not_found', 'File not found') - const provenance = await mockGetBoundWorkspaceFileSecretProvenance(workspaceId, { - fileId: file.id, - key: file.key, - context: 'workspace', - }) - if (provenance.status !== 'exact' || provenance.entries.length > 0) { - throw new OrchestrationError( - 'validation', - `Cannot import "${reference}": the file cannot be verified as free of resolved secrets.` - ) - } - return { - file: { ...file, workspaceId }, - ...(input.maxBytes === undefined - ? {} - : { content: await mockDownloadWorkspaceFile(file, { maxBytes: input.maxBytes }) }), - } - } - ) -}) - -function buildTable(overrides: Partial = {}): TableDefinition { - return { - id: 'tbl_1', - name: 'People', - description: null, - schema: { - columns: [ - { name: 'name', type: 'string', required: true }, - { name: 'age', type: 'number' }, - ], - }, - metadata: null, - rowCount: 0, - maxRows: 100, - workspaceId: 'workspace-1', - createdBy: 'user-1', - archivedAt: null, - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - ...overrides, - } -} - -function buildToolContext() { - return { - userId: 'user-1', - workspaceId: 'workspace-1', - toolCallId: 'tool-call-1', - copilotToolExecution: true, - } as const -} - -/** Lets a runDetached microtask chain run before asserting on the work it dispatched. */ -async function flushDetached(): Promise { - await Promise.resolve() - await Promise.resolve() -} - -describe('userTableServerTool.import_file', () => { - beforeEach(() => { - vi.clearAllMocks() - mockResolveWorkspaceFileReference.mockResolvedValue({ - id: 'file-1', - name: 'people.csv', - type: 'text/csv', - key: 'workspace/workspace-1/people.csv', - size: 100, - }) - mockGetBoundWorkspaceFileSecretProvenance.mockResolvedValue({ status: 'exact', entries: [] }) - mockDownloadWorkspaceFile.mockResolvedValue(Buffer.from('name,age\nAlice,30\nBob,40')) - mockGetTableById.mockResolvedValue(buildTable()) - mockMarkTableJobRunning.mockResolvedValue(true) - mockReleaseJobClaim.mockResolvedValue(true) - mockBatchInsertRows.mockImplementation(async (data: { rows: unknown[] }) => - data.rows.map((_, i) => ({ id: `row_${i}` })) - ) - mockReplaceTableRows.mockResolvedValue({ deletedCount: 0, insertedCount: 0 }) - }) - - it('appends rows using auto-mapping by default', async () => { - const result = await userTableServerTool.execute( - { - operation: 'import_file', - args: { tableId: 'tbl_1', fileId: 'file-1' }, - }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(result.data?.mode).toBe('append') - expect(result.data?.rowCount).toBe(2) - expect(mockBatchInsertRows).toHaveBeenCalledTimes(1) - expect(mockReplaceTableRows).not.toHaveBeenCalled() - const call = mockBatchInsertRows.mock.calls[0][0] as { rows: unknown[] } - expect(call.rows).toEqual([ - { name: 'Alice', age: 30 }, - { name: 'Bob', age: 40 }, - ]) - }) - - it('replaces rows in replace mode', async () => { - mockReplaceTableRows.mockResolvedValueOnce({ deletedCount: 3, insertedCount: 2 }) - const result = await userTableServerTool.execute( - { - operation: 'import_file', - args: { tableId: 'tbl_1', fileId: 'file-1', mode: 'replace' }, - }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(result.data?.mode).toBe('replace') - expect(result.data?.deletedCount).toBe(3) - expect(result.data?.insertedCount).toBe(2) - expect(mockReplaceTableRows).toHaveBeenCalledTimes(1) - expect(mockBatchInsertRows).not.toHaveBeenCalled() - const call = mockReplaceTableRows.mock.calls[0][0] as { - secretProvenance: Array<{ complete: boolean; columns: Record }> - } - expect(call.secretProvenance).toEqual([ - { - complete: true, - columns: { - name: { version: 1, complete: true, entries: [] }, - age: { version: 1, complete: true, entries: [] }, - }, - }, - { - complete: true, - columns: { - name: { version: 1, complete: true, entries: [] }, - age: { version: 1, complete: true, entries: [] }, - }, - }, - ]) - }) - - it('uses the caller-provided mapping', async () => { - mockDownloadWorkspaceFile.mockResolvedValueOnce( - Buffer.from('Full Name,Years\nAlice,30\nBob,40') - ) - const result = await userTableServerTool.execute( - { - operation: 'import_file', - args: { - tableId: 'tbl_1', - fileId: 'file-1', - mapping: { 'Full Name': 'name', Years: 'age' }, - }, - }, - buildToolContext() - ) - - expect(result.success).toBe(true) - const call = mockBatchInsertRows.mock.calls[0][0] as { rows: unknown[] } - expect(call.rows).toEqual([ - { name: 'Alice', age: 30 }, - { name: 'Bob', age: 40 }, - ]) - }) - - it('rejects unknown modes', async () => { - const result = await userTableServerTool.execute( - { - operation: 'import_file', - args: { tableId: 'tbl_1', fileId: 'file-1', mode: 'merge' }, - }, - buildToolContext() - ) - expect(result.success).toBe(false) - expect(result.message).toMatch(/Invalid mode/) - expect(mockBatchInsertRows).not.toHaveBeenCalled() - }) - - it('refuses to import into an archived table', async () => { - mockGetTableById.mockResolvedValueOnce(buildTable({ archivedAt: new Date('2024-02-01') })) - const result = await userTableServerTool.execute( - { - operation: 'import_file', - args: { tableId: 'tbl_1', fileId: 'file-1' }, - }, - buildToolContext() - ) - expect(result.success).toBe(false) - expect(result.message).toMatch(/archived/i) - }) - - it('refuses to import when the table belongs to a different workspace', async () => { - mockGetTableById.mockResolvedValueOnce(buildTable({ workspaceId: 'workspace-other' })) - const result = await userTableServerTool.execute( - { - operation: 'import_file', - args: { tableId: 'tbl_1', fileId: 'file-1' }, - }, - buildToolContext() - ) - expect(result.success).toBe(false) - expect(result.message).toMatch(/not found/i) - expect(mockBatchInsertRows).not.toHaveBeenCalled() - }) - - it('reports missing required columns instead of inserting', async () => { - mockDownloadWorkspaceFile.mockResolvedValueOnce(Buffer.from('age\n30')) - const result = await userTableServerTool.execute( - { - operation: 'import_file', - args: { tableId: 'tbl_1', fileId: 'file-1' }, - }, - buildToolContext() - ) - expect(result.success).toBe(false) - expect(result.message).toMatch(/missing required columns/i) - expect(mockBatchInsertRows).not.toHaveBeenCalled() - }) - - it('claims and releases the table job slot around an inline import', async () => { - const result = await userTableServerTool.execute( - { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(mockMarkTableJobRunning).toHaveBeenCalledWith( - 'tbl_1', - 'workspace-1', - expect.any(String), - 'import' - ) - expect(mockReleaseJobClaim).toHaveBeenCalledWith( - 'tbl_1', - 'workspace-1', - mockMarkTableJobRunning.mock.calls[0][2] - ) - }) - - it('rejects an inline import while another job holds the table slot', async () => { - mockMarkTableJobRunning.mockResolvedValueOnce(false) - const result = await userTableServerTool.execute( - { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(result.message).toMatch(/job is already in progress/i) - expect(mockBatchInsertRows).not.toHaveBeenCalled() - expect(mockReleaseJobClaim).not.toHaveBeenCalled() - }) - - it('dispatches a background import for large CSV files', async () => { - mockResolveWorkspaceFileReference.mockResolvedValueOnce({ - id: 'file-1', - name: 'big.csv', - type: 'text/csv', - key: 'workspace/workspace-1/big.csv', - size: 9 * 1024 * 1024, - }) - - const result = await userTableServerTool.execute( - { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1', mode: 'replace' } }, - buildToolContext() - ) - await flushDetached() - - expect(result.success).toBe(true) - expect(result.data?.jobId).toBeDefined() - expect(result.message).toMatch(/background/i) - expect(mockMarkTableJobRunning).toHaveBeenCalledWith( - 'tbl_1', - 'workspace-1', - expect.any(String), - 'import' - ) - expect(mockBatchInsertRows).not.toHaveBeenCalled() - expect(mockReplaceTableRows).not.toHaveBeenCalled() - expect(mockDownloadWorkspaceFile).not.toHaveBeenCalled() - expect(mockRunTableImport).toHaveBeenCalledTimes(1) - expect(mockRunTableImport.mock.calls[0][0]).toMatchObject({ - tableId: 'tbl_1', - workspaceId: 'workspace-1', - fileKey: 'workspace/workspace-1/big.csv', - mode: 'replace', - deleteSourceFile: false, - }) - }) - - it('rejects a workspace file with resolved-secret provenance before importing rows', async () => { - mockGetBoundWorkspaceFileSecretProvenance.mockResolvedValueOnce({ - status: 'exact', - entries: [{ name: 'API_KEY', encryptedValue: 'encrypted' }], - }) - - const result = await userTableServerTool.execute( - { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(result.message).toMatch(/cannot be verified as free of resolved secrets/i) - expect(mockDownloadWorkspaceFile).not.toHaveBeenCalled() - expect(mockMarkTableJobRunning).not.toHaveBeenCalled() - expect(mockBatchInsertRows).not.toHaveBeenCalled() - }) - - it('points a chat-upload path at save_upload instead of globbing files/', async () => { - mockResolveWorkspaceFileReference.mockResolvedValueOnce(null) - - const result = await userTableServerTool.execute( - { - operation: 'import_file', - args: { tableId: 'tbl_1', fileId: 'uploads/people.csv' }, - }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(result.message).toMatch(/save_upload/) - expect(result.message).not.toMatch(/glob\("files/) - }) - - it('still tells the agent to glob files\\/ for a genuine workspace-file miss', async () => { - mockResolveWorkspaceFileReference.mockResolvedValueOnce(null) - - const result = await userTableServerTool.execute( - { - operation: 'import_file', - args: { tableId: 'tbl_1', fileId: 'files/typo.csv' }, - }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(result.message).toMatch(/File not found: "files\/typo\.csv"/) - }) - - /** - * A malformed CSV silently loses records; the tool used to report the survivors - * as a clean import, so the model had no signal that the file did not land whole. - */ - it('surfaces rejected records in the message and payload of an append import', async () => { - mockDownloadWorkspaceFile.mockResolvedValueOnce( - Buffer.from('name,age\nAlice,30\nBroken,"unterminated\nBob,40\n') - ) - - const result = await userTableServerTool.execute( - { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(result.message).toMatch(/dropped at least 1 unreadable row/i) - expect(result.message).toMatch(/CSV_QUOTE_NOT_CLOSED/) - expect(result.data?.rejections).toMatchObject({ rowsRejected: 1 }) - expect(result.data?.rejections.rejectedSamples[0]).toMatchObject({ - code: 'CSV_QUOTE_NOT_CLOSED', - }) - }) - - it('surfaces rejected records in the message and payload of a replace import', async () => { - mockDownloadWorkspaceFile.mockResolvedValueOnce( - Buffer.from('name,age\nAlice,30\nBroken,"unterminated\nBob,40\n') - ) - mockReplaceTableRows.mockResolvedValueOnce({ deletedCount: 3, insertedCount: 1 }) - - const result = await userTableServerTool.execute( - { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1', mode: 'replace' } }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(result.message).toMatch(/dropped at least 1 unreadable row/i) - expect(result.data?.rejections).toMatchObject({ rowsRejected: 1 }) - }) - - it("leaves a clean import's message and payload untouched", async () => { - const result = await userTableServerTool.execute( - { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } }, - buildToolContext() - ) - - expect(result.message).toBe( - 'Imported 2 rows into "People" from "people.csv" (2 columns matched)' - ) - expect(result.data).not.toHaveProperty('rejections') - }) - - it('rejects a background import while another job holds the table slot', async () => { - mockResolveWorkspaceFileReference.mockResolvedValueOnce({ - id: 'file-1', - name: 'big.csv', - type: 'text/csv', - key: 'workspace/workspace-1/big.csv', - size: 9 * 1024 * 1024, - }) - mockMarkTableJobRunning.mockResolvedValueOnce(false) - - const result = await userTableServerTool.execute( - { operation: 'import_file', args: { tableId: 'tbl_1', fileId: 'file-1' } }, - buildToolContext() - ) - await flushDetached() - - expect(result.success).toBe(false) - expect(result.message).toMatch(/job is already in progress/i) - expect(mockRunTableImport).not.toHaveBeenCalled() - }) -}) - -describe('userTableServerTool.create_from_file', () => { - beforeEach(() => { - vi.clearAllMocks() - mockResolveWorkspaceFileReference.mockResolvedValue({ - id: 'file-1', - name: 'people.csv', - type: 'text/csv', - key: 'workspace/workspace-1/people.csv', - size: 100, - }) - mockGetBoundWorkspaceFileSecretProvenance.mockResolvedValue({ status: 'exact', entries: [] }) - mockDownloadWorkspaceFile.mockResolvedValue(Buffer.from('name,age\nAlice,30\nBob,40')) - mockGetWorkspaceTableLimits.mockResolvedValue({ maxRowsPerTable: 1000, maxTables: 3 }) - mockCreateTable.mockResolvedValue(buildTable({ id: 'tbl_new', name: 'people' })) - mockDeleteTable.mockResolvedValue(undefined) - mockBatchInsertRows.mockImplementation(async (data: { rows: unknown[] }) => - data.rows.map((_, i) => ({ id: `row_${i}` })) - ) - }) - - it('stamps the workspace plan limits on the created table', async () => { - const result = await userTableServerTool.execute( - { operation: 'create_from_file', args: { fileId: 'file-1' } }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(mockGetWorkspaceTableLimits).toHaveBeenCalledWith('workspace-1') - expect(mockCreateTable).toHaveBeenCalledTimes(1) - const createArgs = mockCreateTable.mock.calls[0][0] as { maxTables: number } - expect(createArgs.maxTables).toBe(3) - }) - - it('truncates to the plan row limit and reports dropped rows', async () => { - // File has 2 data rows (Alice, Bob); plan cap is 1. - mockGetWorkspaceTableLimits.mockResolvedValueOnce({ maxRowsPerTable: 1, maxTables: 3 }) - - const result = await userTableServerTool.execute( - { operation: 'create_from_file', args: { fileId: 'file-1' } }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(mockCreateTable).toHaveBeenCalledTimes(1) - const insertCall = mockBatchInsertRows.mock.calls[0][0] as { rows: unknown[] } - expect(insertCall.rows).toHaveLength(1) - expect(result.data?.rowCount).toBe(1) - expect(result.message).toMatch(/dropped 1 row/i) - expect(mockDeleteTable).not.toHaveBeenCalled() - }) - - it('rolls back the created table and safely conceals unknown insertion failures', async () => { - mockBatchInsertRows.mockRejectedValueOnce(new Error('Row 2: Column "email" must be unique')) - - const result = await userTableServerTool.execute( - { operation: 'create_from_file', args: { fileId: 'file-1' } }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(mockDeleteTable).toHaveBeenCalledWith('tbl_new', expect.any(String)) - expect(result.message).toBe('Operation failed: Table operation failed') - expect(result.message).not.toMatch(/must be unique/i) - }) - - it('creates a placeholder table and dispatches a background import for large CSV files', async () => { - mockResolveWorkspaceFileReference.mockResolvedValueOnce({ - id: 'file-1', - name: 'big.csv', - type: 'text/csv', - key: 'workspace/workspace-1/big.csv', - size: 9 * 1024 * 1024, - }) - - const result = await userTableServerTool.execute( - { operation: 'create_from_file', args: { fileId: 'file-1' } }, - buildToolContext() - ) - await flushDetached() - - expect(result.success).toBe(true) - expect(result.data?.tableId).toBe('tbl_new') - expect(result.data?.jobId).toBeDefined() - expect(mockDownloadWorkspaceFile).not.toHaveBeenCalled() - expect(mockBatchInsertRows).not.toHaveBeenCalled() - const createArgs = mockCreateTable.mock.calls[0][0] as Record - expect(createArgs).toMatchObject({ - jobStatus: 'running', - jobType: 'import', - jobId: result.data?.jobId, - }) - expect(mockRunTableImport).toHaveBeenCalledTimes(1) - expect(mockRunTableImport.mock.calls[0][0]).toMatchObject({ - tableId: 'tbl_new', - mode: 'create', - fileKey: 'workspace/workspace-1/big.csv', - deleteSourceFile: false, - }) - }) - - it('surfaces rejected records alongside the created table', async () => { - mockDownloadWorkspaceFile.mockResolvedValueOnce( - Buffer.from('name,age\nAlice,30\nBroken,"unterminated\nBob,40\n') - ) - - const result = await userTableServerTool.execute( - { operation: 'create_from_file', args: { fileId: 'file-1' } }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(result.message).toMatch(/dropped at least 1 unreadable row/i) - expect(result.message).toMatch(/CSV_QUOTE_NOT_CLOSED/) - expect(result.data?.rejections).toMatchObject({ rowsRejected: 1 }) - }) - - it('leaves a clean create_from_file message and payload untouched', async () => { - const result = await userTableServerTool.execute( - { operation: 'create_from_file', args: { fileId: 'file-1' } }, - buildToolContext() - ) - - expect(result.message).toBe( - 'Created table "people" with 2 columns and 2 rows from "people.csv"' - ) - expect(result.data).not.toHaveProperty('rejections') - }) - - it('rejects unknown workspace-file provenance before creating a table', async () => { - mockGetBoundWorkspaceFileSecretProvenance.mockResolvedValueOnce({ status: 'unknown' }) - - const result = await userTableServerTool.execute( - { operation: 'create_from_file', args: { fileId: 'file-1' } }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(result.message).toMatch(/cannot be verified as free of resolved secrets/i) - expect(mockDownloadWorkspaceFile).not.toHaveBeenCalled() - expect(mockCreateTable).not.toHaveBeenCalled() - }) -}) - -describe('userTableServerTool.create', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetWorkspaceTableLimits.mockResolvedValue({ maxRowsPerTable: 1000, maxTables: 3 }) - mockCreateTable.mockResolvedValue(buildTable({ id: 'tbl_new', name: 'People' })) - }) - - it('stamps the workspace plan limits on the created table', async () => { - const result = await userTableServerTool.execute( - { - operation: 'create', - args: { - name: 'People', - schema: { columns: [{ name: 'name', type: 'string', required: true }] }, - }, - }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(mockGetWorkspaceTableLimits).toHaveBeenCalledWith('workspace-1') - const createArgs = mockCreateTable.mock.calls[0][0] as { maxTables: number } - expect(createArgs.maxTables).toBe(3) - }) -}) - -describe('userTableServerTool.delete_column', () => { - it('presents the authoritative canonical deletion for aliases and duplicates', async () => { - const current = buildTable({ - schema: { - columns: [ - { id: 'column-name', name: 'name', type: 'string' }, - { id: 'column-age', name: 'age', type: 'number' }, - ], - }, - }) - mockGetTableById.mockResolvedValue(current) - mockDeleteColumns.mockResolvedValue({ - ...current, - schema: { columns: [{ id: 'column-name', name: 'name', type: 'string' }] }, - }) - - const result = await userTableServerTool.execute( - { - operation: 'delete_column', - args: { - tableId: 'tbl_1', - columnNames: ['age', 'column-age', 'AGE'], - }, - }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(result.message).toBe('Deleted 1 column: age') - expect(mockDeleteColumns).toHaveBeenCalledWith( - { tableId: 'tbl_1', columnNames: ['age', 'column-age', 'AGE'] }, - expect.any(String), - { expectedWorkspaceId: 'workspace-1' } - ) - }) -}) - -describe('userTableServerTool workflow scope', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetTableById.mockResolvedValue(buildTable()) - mockAddWorkflowGroup.mockImplementation( - async ({ group, outputColumns }: { group: unknown; outputColumns: unknown[] }) => - buildTable({ - schema: { - columns: outputColumns, - workflowGroups: [group], - } as never, - }) - ) - }) - - it('conceals a cross-workspace workflow id before persisting a group', async () => { - mockResolveWorkflowContext.mockRejectedValueOnce( - new OrchestrationError('not_found', 'Workflow not found') - ) - - const result = await userTableServerTool.execute( - { - operation: 'add_workflow_group', - args: { - tableId: 'tbl_1', - workflowId: 'workflow-cross-workspace', - outputs: [{ blockId: 'block-1', path: 'content' }], - }, - }, - buildToolContext() - ) - - expect(result).toEqual({ success: false, message: 'Operation failed: Workflow not found' }) - expect(mockResolveWorkflowContext).toHaveBeenCalledWith({ - workflowId: 'workflow-cross-workspace', - assertedWorkspaceId: 'workspace-1', - }) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('does not pass a legacy deployment mode into workflow group creation', async () => { - const result = await userTableServerTool.execute( - { - operation: 'add_workflow_group', - args: { - tableId: 'tbl_1', - workflowId: 'workflow-1', - outputs: [{ blockId: 'block-1', path: 'content' }], - deploymentMode: 'live', - }, - }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(mockAddWorkflowGroup).toHaveBeenCalledTimes(1) - expect(mockAddWorkflowGroup.mock.calls[0][0].group).not.toHaveProperty('deploymentMode') - }) - - it('conceals unknown application failures from tool output', async () => { - mockQueryRows.mockRejectedValueOnce(new Error('database host unavailable')) - - const result = await userTableServerTool.execute( - { operation: 'query_rows', args: { tableId: 'tbl_1' } }, - buildToolContext() - ) - - expect(result).toEqual({ success: false, message: 'Operation failed: Table operation failed' }) - expect(result.message).not.toContain('database host unavailable') - }) -}) - -describe('userTableServerTool.list_enrichments', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('returns the enrichment catalog metadata', async () => { - const result = await userTableServerTool.execute( - { operation: 'list_enrichments', args: {} }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(result.data?.enrichments).toEqual([ - { - id: 'work-email', - name: 'Work Email', - description: 'Find work email', - inputs: [ - { id: 'fullName', name: 'Full name', type: 'string', required: true }, - { id: 'companyDomain', name: 'Company domain', type: 'string', required: true }, - ], - outputs: [{ id: 'email', name: 'email', type: 'string' }], - }, - ]) - }) -}) - -describe('userTableServerTool.add_enrichment', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetTableById.mockResolvedValue( - buildTable({ - schema: { - columns: [ - { name: 'name', type: 'string' }, - { name: 'company', type: 'string' }, - ], - }, - }) - ) - mockAddWorkflowGroup.mockImplementation( - async ({ group, outputColumns }: { group: unknown; outputColumns: unknown[] }) => - buildTable({ - schema: { - columns: outputColumns, - workflowGroups: [group], - } as never, - }) - ) - }) - - it('creates an enrichment group with mapped inputs and derived output columns', async () => { - const result = await userTableServerTool.execute( - { - operation: 'add_enrichment', - args: { - tableId: 'tbl_1', - enrichmentId: 'work-email', - inputMappings: [ - { inputName: 'fullName', columnName: 'name' }, - { inputName: 'companyDomain', columnName: 'company' }, - ], - }, - }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(result.data?.groupId).toBe('deadbeefcafef00d') - expect(mockAddWorkflowGroup).toHaveBeenCalledTimes(1) - const call = mockAddWorkflowGroup.mock.calls[0][0] - expect(call.autoRun).toBe(false) - expect(call.group).toMatchObject({ - type: 'enrichment', - enrichmentId: 'work-email', - workflowId: '', - autoRun: false, - dependencies: { columns: ['name', 'company'] }, - inputMappings: [ - { inputName: 'fullName', columnName: 'name' }, - { inputName: 'companyDomain', columnName: 'company' }, - ], - outputs: [{ blockId: '', path: '', outputId: 'email', columnName: 'email' }], - }) - expect(call.outputColumns).toEqual([ - { - name: 'email', - type: 'string', - required: false, - unique: false, - workflowGroupId: 'deadbeefcafef00d', - }, - ]) - }) - - it('enables auto-run when explicitly requested', async () => { - const result = await userTableServerTool.execute( - { - operation: 'add_enrichment', - args: { - tableId: 'tbl_1', - enrichmentId: 'work-email', - inputMappings: [ - { inputName: 'fullName', columnName: 'name' }, - { inputName: 'companyDomain', columnName: 'company' }, - ], - autoRun: true, - }, - }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(result.message).toMatch(/auto-run enabled/) - const call = mockAddWorkflowGroup.mock.calls[0][0] - expect(call.autoRun).toBe(true) - expect(call.group.autoRun).toBe(true) - }) - - it('rejects an unknown enrichment id', async () => { - const result = await userTableServerTool.execute( - { - operation: 'add_enrichment', - args: { tableId: 'tbl_1', enrichmentId: 'nope', inputMappings: [] }, - }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(result.message).toMatch(/Unknown enrichment/) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('rejects when a required input is unmapped', async () => { - const result = await userTableServerTool.execute( - { - operation: 'add_enrichment', - args: { - tableId: 'tbl_1', - enrichmentId: 'work-email', - inputMappings: [{ inputName: 'fullName', columnName: 'name' }], - }, - }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(result.message).toMatch(/requires input "companyDomain"/) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) - - it('rejects when a mapped column does not exist on the table', async () => { - const result = await userTableServerTool.execute( - { - operation: 'add_enrichment', - args: { - tableId: 'tbl_1', - enrichmentId: 'work-email', - inputMappings: [ - { inputName: 'fullName', columnName: 'name' }, - { inputName: 'companyDomain', columnName: 'missing_col' }, - ], - }, - }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(result.message).toMatch(/does not exist/) - expect(mockAddWorkflowGroup).not.toHaveBeenCalled() - }) -}) - -describe('userTableServerTool.query_rows', () => { - const queryRow = (i: number) => ({ - id: `row_${i}`, - data: { name: `r${i}` }, - executions: {}, - position: i, - orderKey: `a${i}`, - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - }) - - beforeEach(() => { - vi.clearAllMocks() - mockGetTableById.mockResolvedValue(buildTable()) - mockQueryRows.mockResolvedValue({ - rows: [queryRow(1), queryRow(2)], - rowCount: 2, - totalCount: 10, - limit: 2, - offset: 0, - }) - }) - - it('rejects an explicit limit above the Copilot page cap before any DB call', async () => { - const result = await userTableServerTool.execute( - { operation: 'query_rows', args: { tableId: 'tbl_1', limit: 100000 } }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(result.message).toBe('Limit cannot exceed 1000') - expect(mockGetTableById).not.toHaveBeenCalled() - expect(mockQueryRows).not.toHaveBeenCalled() - }) - - it('defaults an omitted Copilot page limit to the surface maximum', async () => { - const result = await userTableServerTool.execute( - { operation: 'query_rows', args: { tableId: 'tbl_1' } }, - buildToolContext() - ) - - expect(result.success).toBe(true) - const options = mockQueryRows.mock.calls[0][1] as Record - expect(options.limit).toBe(1000) - }) - - it('imports application-owned persisted provenance into the tool trace registry', async () => { - const registry = new ResolvedSecretTraceRegistry() - const importProvenance = vi.spyOn(registry, 'importCrossingProvenance') - - const result = await userTableServerTool.execute( - { operation: 'query_rows', args: { tableId: 'tbl_1', limit: 2 } }, - { ...buildToolContext(), resolvedSecretTraceRegistry: registry } - ) - - expect(result.success).toBe(true) - expect(mockCreateTableRowProvenanceReader).toHaveBeenCalledWith({ - userId: 'user-1', - workspaceId: 'workspace-1', - }) - expect(mockQueryRows.mock.calls[0][3]).toEqual( - expect.objectContaining({ exportProvenance: mockExportTableRowProvenance }) - ) - expect(importProvenance).toHaveBeenCalledWith( - expect.objectContaining({ - version: 1, - complete: true, - scope: { userId: 'user-1', workspaceId: 'workspace-1' }, - }), - expect.arrayContaining([{ name: 'r1' }]), - { trusted: true } - ) - }) - - it('normalizes a root condition before querying', async () => { - const result = await userTableServerTool.execute( - { - operation: 'query_rows', - args: { tableId: 'tbl_1', filter: { field: 'name', op: 'eq', value: 'r1' } }, - }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(mockQueryRows.mock.calls[0][1].predicate).toEqual({ - all: [{ field: 'name', op: 'eq', value: 'r1' }], - }) - }) - - it('decodes an opaque cursor into after/offset and skips the count', async () => { - const cursor = encodeCursor({ - lastRow: { id: 'row_9', orderKey: 'a9' }, - keysetValid: true, - nextOffset: 20, - }) - const result = await userTableServerTool.execute( - { operation: 'query_rows', args: { tableId: 'tbl_1', limit: 2, cursor } }, - buildToolContext() - ) - - expect(result.success).toBe(true) - const options = mockQueryRows.mock.calls[0][1] as Record - expect(options.withExecutions).toBe(false) - expect(options.after).toEqual({ orderKey: 'a9', id: 'row_9' }) - // A cursor page never re-counts. - expect(options.includeTotal).toBe(false) - }) - - it('surfaces the opaque nextCursor (not an offset) in the "more available" message', async () => { - mockQueryRows.mockResolvedValueOnce({ - rows: [queryRow(1), queryRow(2)], - rowCount: 2, - totalCount: 10, - limit: 2, - offset: 0, - nextCursor: 'CURSOR_TOKEN_ABC', - }) - const result = await userTableServerTool.execute( - { operation: 'query_rows', args: { tableId: 'tbl_1', limit: 2 } }, - buildToolContext() - ) - - expect(result.message).toContain('more available') - expect(result.message).toContain('cursor=CURSOR_TOKEN_ABC') - expect(result.message).not.toContain('offset=') - }) - - it('rejects a keyset cursor combined with a custom sort', async () => { - const cursor = encodeCursor({ - lastRow: { id: 'row_9', orderKey: 'a9' }, - keysetValid: true, - nextOffset: 20, - }) - const result = await userTableServerTool.execute( - { - operation: 'query_rows', - args: { tableId: 'tbl_1', cursor, order: [{ field: 'name', direction: 'desc' }] }, - }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(result.message).toMatch(/not valid for a sorted query/i) - expect(mockQueryRows).not.toHaveBeenCalled() - }) -}) - -describe('userTableServerTool.delete_rows_by_filter', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetTableById.mockResolvedValue(buildTable({ rowCount: 50000, maxRows: 100000 })) - mockMarkTableJobRunning.mockResolvedValue(true) - mockDeleteRowsByFilter.mockResolvedValue({ affectedCount: 5, affectedRowIds: ['r1'] }) - mockQueryRows.mockResolvedValue({ - rows: [], - rowCount: 0, - totalCount: 5, - limit: 1, - offset: 0, - }) - }) - - it('escalates an explicit limit above the cap to a background delete with maxRows (unmasked)', async () => { - mockQueryRows.mockResolvedValueOnce({ - rows: [], - rowCount: 0, - totalCount: 20000, - limit: 1, - offset: 0, - }) - - const result = await userTableServerTool.execute( - { - operation: 'delete_rows_by_filter', - args: { - tableId: 'tbl_1', - filter: { all: [{ field: 'name', op: 'eq', value: 'x' }] }, - limit: 5000, - }, - }, - buildToolContext() - ) - await flushDetached() - - expect(result.success).toBe(true) - // target = min(limit 5000, matchCount 20000) = 5000, above the inline cap → background. - expect(result.data?.doomedCount).toBe(5000) - expect(mockDeleteRowsByFilter).not.toHaveBeenCalled() - const [, , , type, payload] = mockMarkTableJobRunning.mock.calls[0] - expect(type).toBe('delete') - // Bounded delete carries maxRows and omits doomedCount so the mask is skipped and the count - // isn't double-subtracted. - expect(payload).toMatchObject({ maxRows: 5000 }) - expect((payload as { doomedCount?: number }).doomedCount).toBeUndefined() - expect(mockRunTableDelete.mock.calls[0][0]).toMatchObject({ maxRows: 5000 }) - }) - - it('deletes inline when the unbounded match count is within the cap', async () => { - const result = await userTableServerTool.execute( - { - operation: 'delete_rows_by_filter', - args: { tableId: 'tbl_1', filter: { all: [{ field: 'name', op: 'eq', value: 'x' }] } }, - }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(result.data?.affectedCount).toBe(5) - expect(mockDeleteRowsByFilter).toHaveBeenCalledTimes(1) - // Inline delete still claims (and releases) the table's write-job slot. - expect(mockMarkTableJobRunning).toHaveBeenCalledWith( - 'tbl_1', - 'workspace-1', - expect.any(String), - 'delete' - ) - expect(mockReleaseJobClaim).toHaveBeenCalled() - }) - - it('normalizes a root condition before deleting', async () => { - const result = await userTableServerTool.execute( - { - operation: 'delete_rows_by_filter', - args: { tableId: 'tbl_1', filter: { field: 'name', op: 'eq', value: 'x' } }, - }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(mockDeleteRowsByFilter.mock.calls[0][1].filter).toEqual({ $and: [{ name: 'x' }] }) - }) - - it('rejects an inline delete while another job holds the table slot', async () => { - mockMarkTableJobRunning.mockResolvedValueOnce(false) - - const result = await userTableServerTool.execute( - { - operation: 'delete_rows_by_filter', - args: { - tableId: 'tbl_1', - filter: { all: [{ field: 'name', op: 'eq', value: 'x' }] }, - limit: 100, - }, - }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(result.message).toMatch(/job is already in progress/i) - expect(mockDeleteRowsByFilter).not.toHaveBeenCalled() - }) - - it('dispatches a background delete when the unbounded match count exceeds the cap', async () => { - mockQueryRows.mockResolvedValueOnce({ - rows: [], - rowCount: 0, - totalCount: 20000, - limit: 1, - offset: 0, - }) - - const result = await userTableServerTool.execute( - { - operation: 'delete_rows_by_filter', - args: { tableId: 'tbl_1', filter: { all: [{ field: 'name', op: 'eq', value: 'x' }] } }, - }, - buildToolContext() - ) - await flushDetached() - - expect(result.success).toBe(true) - expect(result.data?.jobId).toBeDefined() - expect(result.data?.doomedCount).toBe(20000) - expect(mockDeleteRowsByFilter).not.toHaveBeenCalled() - const [tableId, workspaceId, jobId, type, payload] = mockMarkTableJobRunning.mock.calls[0] - expect(tableId).toBe('tbl_1') - expect(workspaceId).toBe('workspace-1') - expect(type).toBe('delete') - expect(payload).toMatchObject({ doomedCount: 20000, cutoff: expect.any(String) }) - // Unbounded delete masks the whole set — no maxRows cap. - expect((payload as { maxRows?: number }).maxRows).toBeUndefined() - expect(mockRunTableDelete).toHaveBeenCalledTimes(1) - expect(mockRunTableDelete.mock.calls[0][0]).toMatchObject({ - jobId, - tableId: 'tbl_1', - workspaceId: 'workspace-1', - cutoff: expect.any(Date), - }) - }) - - it('rejects a background delete while another job holds the table slot', async () => { - mockQueryRows.mockResolvedValueOnce({ - rows: [], - rowCount: 0, - totalCount: 20000, - limit: 1, - offset: 0, - }) - mockMarkTableJobRunning.mockResolvedValueOnce(false) - - const result = await userTableServerTool.execute( - { - operation: 'delete_rows_by_filter', - args: { tableId: 'tbl_1', filter: { all: [{ field: 'name', op: 'eq', value: 'x' }] } }, - }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(result.message).toMatch(/job is already in progress/i) - expect(mockDeleteRowsByFilter).not.toHaveBeenCalled() - expect(mockRunTableDelete).not.toHaveBeenCalled() - }) - - it('deletes inline with an explicit limit without counting first', async () => { - const result = await userTableServerTool.execute( - { - operation: 'delete_rows_by_filter', - args: { - tableId: 'tbl_1', - filter: { all: [{ field: 'name', op: 'eq', value: 'x' }] }, - limit: 100, - }, - }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(mockQueryRows).not.toHaveBeenCalled() - expect(mockDeleteRowsByFilter).toHaveBeenCalledTimes(1) - }) -}) - -describe('userTableServerTool.update_rows_by_filter', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetTableById.mockResolvedValue(buildTable()) - mockMarkTableJobRunning.mockResolvedValue(true) - mockUpdateRowsByFilter.mockResolvedValue({ affectedCount: 5, affectedRowIds: ['r1'] }) - mockQueryRows.mockResolvedValue({ rows: [], rowCount: 0, totalCount: 5, limit: 1, offset: 0 }) - }) - - it('escalates an explicit limit above the cap to a background update with maxRows', async () => { - mockQueryRows.mockResolvedValueOnce({ - rows: [], - rowCount: 0, - totalCount: 20000, - limit: 1, - offset: 0, - }) - const result = await userTableServerTool.execute( - { - operation: 'update_rows_by_filter', - args: { - tableId: 'tbl_1', - filter: { all: [{ field: 'name', op: 'eq', value: 'x' }] }, - data: { age: 1 }, - limit: 5000, - }, - }, - buildToolContext() - ) - await flushDetached() - - expect(result.success).toBe(true) - // target = min(limit 5000, matchCount 20000) = 5000, above the inline cap → background. - expect(result.data?.affectedCount).toBe(5000) - expect(mockUpdateRowsByFilter).not.toHaveBeenCalled() - const [, , , type, payload] = mockMarkTableJobRunning.mock.calls[0] - expect(type).toBe('update') - expect(payload).toMatchObject({ affectedCount: 5000, maxRows: 5000 }) - expect(mockRunTableUpdate.mock.calls[0][0]).toMatchObject({ maxRows: 5000 }) - }) - - it('updates inline when the unbounded match count is within the cap', async () => { - const result = await userTableServerTool.execute( - { - operation: 'update_rows_by_filter', - args: { - tableId: 'tbl_1', - filter: { all: [{ field: 'name', op: 'eq', value: 'x' }] }, - data: { age: 1 }, - }, - }, - buildToolContext() - ) - expect(result.success).toBe(true) - expect(result.data?.affectedCount).toBe(5) - expect(mockUpdateRowsByFilter).toHaveBeenCalledTimes(1) - expect(mockMarkTableJobRunning).not.toHaveBeenCalled() - }) - - it('normalizes a root condition before updating', async () => { - const result = await userTableServerTool.execute( - { - operation: 'update_rows_by_filter', - args: { - tableId: 'tbl_1', - filter: { field: 'name', op: 'eq', value: 'x' }, - data: { age: 1 }, - }, - }, - buildToolContext() - ) - - expect(result.success).toBe(true) - expect(mockUpdateRowsByFilter.mock.calls[0][1].filter).toEqual({ $and: [{ name: 'x' }] }) - }) - - it('dispatches a background update when the unbounded match count exceeds the cap', async () => { - mockQueryRows.mockResolvedValueOnce({ - rows: [], - rowCount: 0, - totalCount: 20000, - limit: 1, - offset: 0, - }) - const result = await userTableServerTool.execute( - { - operation: 'update_rows_by_filter', - args: { - tableId: 'tbl_1', - filter: { all: [{ field: 'name', op: 'eq', value: 'x' }] }, - data: { age: 1 }, - }, - }, - buildToolContext() - ) - await flushDetached() - - expect(result.success).toBe(true) - expect(result.data?.jobId).toBeDefined() - expect(result.data?.affectedCount).toBe(20000) - expect(mockUpdateRowsByFilter).not.toHaveBeenCalled() - const [tableId, workspaceId, jobId, type, payload] = mockMarkTableJobRunning.mock.calls[0] - expect(tableId).toBe('tbl_1') - expect(workspaceId).toBe('workspace-1') - expect(type).toBe('update') - expect(payload).toMatchObject({ - affectedCount: 20000, - cutoff: expect.any(String), - data: { age: 1 }, - }) - // Unbounded match (no explicit limit) → the worker patches every match, no cap. - expect((payload as { maxRows?: number }).maxRows).toBeUndefined() - expect(mockRunTableUpdate).toHaveBeenCalledTimes(1) - expect(mockRunTableUpdate.mock.calls[0][0]).toMatchObject({ - jobId, - tableId: 'tbl_1', - workspaceId: 'workspace-1', - cutoff: expect.any(Date), - }) - }) - - it('keeps a unique-column patch inline even when many rows match', async () => { - mockGetTableById.mockResolvedValue( - buildTable({ schema: { columns: [{ name: 'email', type: 'string', unique: true }] } }) - ) - const result = await userTableServerTool.execute( - { - operation: 'update_rows_by_filter', - args: { - tableId: 'tbl_1', - filter: { all: [{ field: 'email', op: 'eq', value: 'x' }] }, - data: { email: 'y' }, - }, - }, - buildToolContext() - ) - expect(result.success).toBe(true) - expect(mockQueryRows).not.toHaveBeenCalled() - expect(mockMarkTableJobRunning).not.toHaveBeenCalled() - expect(mockUpdateRowsByFilter).toHaveBeenCalledTimes(1) - }) - - it('rejects a background update while another job holds the table slot', async () => { - mockQueryRows.mockResolvedValueOnce({ - rows: [], - rowCount: 0, - totalCount: 20000, - limit: 1, - offset: 0, - }) - mockMarkTableJobRunning.mockResolvedValueOnce(false) - const result = await userTableServerTool.execute( - { - operation: 'update_rows_by_filter', - args: { - tableId: 'tbl_1', - filter: { all: [{ field: 'name', op: 'eq', value: 'x' }] }, - data: { age: 1 }, - }, - }, - buildToolContext() - ) - expect(result.success).toBe(false) - expect(result.message).toMatch(/job is already in progress/i) - expect(mockUpdateRowsByFilter).not.toHaveBeenCalled() - expect(mockRunTableUpdate).not.toHaveBeenCalled() - }) - - it('updates inline with an explicit limit without counting first', async () => { - const result = await userTableServerTool.execute( - { - operation: 'update_rows_by_filter', - args: { - tableId: 'tbl_1', - filter: { all: [{ field: 'name', op: 'eq', value: 'x' }] }, - data: { age: 1 }, - limit: 100, - }, - }, - buildToolContext() - ) - expect(result.success).toBe(true) - expect(mockQueryRows).not.toHaveBeenCalled() - expect(mockUpdateRowsByFilter).toHaveBeenCalledTimes(1) - }) -}) - -describe('userTableServerTool.update_column — select routing', () => { - const selectTable = buildTable({ - schema: { - columns: [ - { - id: 'col_status', - name: 'status', - type: 'select', - options: [{ id: 'opt_open', name: 'Open' }], - }, - ], - }, - }) - - beforeEach(() => { - vi.clearAllMocks() - mockGetTableById.mockResolvedValue(selectTable) - mockUpdateColumnOptions.mockResolvedValue(selectTable) - mockUpdateColumnType.mockResolvedValue(selectTable) - }) - - it('routes an unchanged type with options to updateColumnOptions', async () => { - // `updateColumnType` early-returns when the type is unchanged, so routing - // there would silently drop the new option set and still report success. - await userTableServerTool.execute( - { - operation: 'update_column', - args: { - tableId: 'tbl_1', - columnName: 'status', - newType: 'select', - options: ['Open', 'Closed'], - }, - }, - buildToolContext() - ) - - expect(mockUpdateColumnType).not.toHaveBeenCalled() - expect(mockUpdateColumnOptions).toHaveBeenCalledTimes(1) - expect( - mockUpdateColumnOptions.mock.calls[0][0].options.map((o: { name: string }) => o.name) - ).toEqual(['Open', 'Closed']) - }) - - it('routes a genuine type change to updateColumnType', async () => { - await userTableServerTool.execute( - { - operation: 'update_column', - args: { tableId: 'tbl_1', columnName: 'status', newType: 'string' }, - }, - buildToolContext() - ) - - expect(mockUpdateColumnType).toHaveBeenCalledTimes(1) - expect(mockUpdateColumnOptions).not.toHaveBeenCalled() - }) - - it('accepts a multiple-only toggle by reusing the current options', async () => { - await userTableServerTool.execute( - { - operation: 'update_column', - args: { tableId: 'tbl_1', columnName: 'status', multiple: true }, - }, - buildToolContext() - ) - - expect(mockUpdateColumnOptions).toHaveBeenCalledTimes(1) - const arg = mockUpdateColumnOptions.mock.calls[0][0] - expect(arg.multiple).toBe(true) - expect(arg.options).toEqual([{ id: 'opt_open', name: 'Open' }]) - }) -}) - -describe('userTableServerTool.delete bounds', () => { - it('rejects an unbounded multi-table delete before invoking an application use case', async () => { - vi.clearAllMocks() - const result = await userTableServerTool.execute( - { - operation: 'delete', - args: { tableIds: Array.from({ length: 101 }, (_, index) => `table-${index}`) }, - }, - buildToolContext() - ) - - expect(result).toEqual({ - success: false, - message: 'Cannot delete more than 100 tables at once', - }) - expect(mockGetTableById).not.toHaveBeenCalled() - }) -}) - -/** - * Copilot is the one row-write surface whose column keys come from a model rather - * than from the schema, so `dataKeying: 'names'` is what stands between an - * LLM-authored key and the storage column it means. - * - * These pin the translated outcome rather than the literal: the shared `buildTable` - * fixture uses legacy columns with no `id`, where name-to-id mapping is the identity - * and flipping the keying is unobservable. Columns whose `id` differs from `name` are - * what make the wrong keying fail — under `'ids'` the lax write path stores the - * model's key verbatim and reports success, corrupting the row silently. - */ -describe('userTableServerTool row writes key model-supplied columns by name', () => { - const KEYED_TABLE = buildTable({ - schema: { - columns: [ - { id: 'col_name', name: 'name', type: 'string', required: true }, - { id: 'col_age', name: 'age', type: 'number' }, - ], - }, - }) - - beforeEach(() => { - vi.clearAllMocks() - mockGetTableById.mockResolvedValue(KEYED_TABLE) - }) - - it('translates an inserted row to storage column ids', async () => { - mockInsertRow.mockResolvedValue({ - id: 'row-1', - data: { col_name: 'Ada', col_age: 36 }, - position: 0, - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - }) - - await userTableServerTool.execute( - { - operation: 'insert_row', - args: { tableId: 'tbl_1', data: { name: 'Ada', age: 36 } }, - }, - buildToolContext() - ) - - expect(mockInsertRow).toHaveBeenCalledTimes(1) - expect(mockInsertRow.mock.calls[0][0].data).toEqual({ col_name: 'Ada', col_age: 36 }) - }) - - it('translates an updated row to storage column ids', async () => { - mockUpdateRow.mockResolvedValue({ - id: 'row-1', - data: { col_name: 'Grace' }, - position: 0, - executions: {}, - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - }) - - await userTableServerTool.execute( - { - operation: 'update_row', - args: { tableId: 'tbl_1', rowId: 'row-1', data: { name: 'Grace' } }, - }, - buildToolContext() - ) - - expect(mockUpdateRow).toHaveBeenCalledTimes(1) - expect(mockUpdateRow.mock.calls[0][0].data).toEqual({ col_name: 'Grace' }) - }) -}) - -describe('userTableServerTool.batch_update_rows', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetTableById.mockResolvedValue(buildTable()) - }) - - it('rejects string elements with the expected shape instead of crashing in the executor', async () => { - const result = await userTableServerTool.execute( - { - operation: 'batch_update_rows', - args: { tableId: 'tbl_1', updates: ['rowId', 'data'] }, - }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(result.message).toBe( - 'updates[0] is a string, not a { rowId, data } object. Expected updates: [{ rowId, data: { col: val } }]' - ) - expect(mockBatchUpdateRows).not.toHaveBeenCalled() - }) - - it('names the element that is missing its data object', async () => { - const result = await userTableServerTool.execute( - { - operation: 'batch_update_rows', - args: { - tableId: 'tbl_1', - updates: [{ rowId: 'row-1', data: { name: 'Ada' } }, { rowId: 'row-2' }], - }, - }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(result.message).toBe( - 'updates[1] is missing a data object of column → value pairs. Expected updates: [{ rowId, data: { col: val } }]' - ) - expect(mockBatchUpdateRows).not.toHaveBeenCalled() - }) - - it('spells out both accepted formats when updates is empty and no values map is given', async () => { - const result = await userTableServerTool.execute( - { operation: 'batch_update_rows', args: { tableId: 'tbl_1', updates: [] } }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(result.message).toBe( - 'Provide either a non-empty "updates" array of { rowId, data } objects or "columnName" + "values" map' - ) - expect(mockBatchUpdateRows).not.toHaveBeenCalled() - }) - - it('forwards well-formed per-row patches to the batch service', async () => { - mockBatchUpdateRows.mockResolvedValue({ affectedCount: 1, affectedRowIds: ['row-1'] }) - - const result = await userTableServerTool.execute( - { - operation: 'batch_update_rows', - args: { tableId: 'tbl_1', updates: [{ rowId: 'row-1', data: { name: 'Ada' } }] }, - }, - buildToolContext() - ) - - expect(result).toEqual({ - success: true, - message: 'Updated 1 rows', - data: { affectedCount: 1, affectedRowIds: ['row-1'] }, - }) - expect(mockBatchUpdateRows).toHaveBeenCalledTimes(1) - const call = mockBatchUpdateRows.mock.calls[0][0] as { - updates: Array<{ rowId: string; data: Record }> - } - expect(call.updates).toHaveLength(1) - expect(call.updates[0].rowId).toBe('row-1') - expect(Object.values(call.updates[0].data)).toEqual(['Ada']) - }) - - it('expands the columnName + values map into per-row patches', async () => { - mockBatchUpdateRows.mockResolvedValue({ affectedCount: 2, affectedRowIds: ['row-1', 'row-2'] }) - - const result = await userTableServerTool.execute( - { - operation: 'batch_update_rows', - args: { tableId: 'tbl_1', columnName: 'name', values: { 'row-1': 'Ada', 'row-2': 'Bob' } }, - }, - buildToolContext() - ) - - expect(result.success).toBe(true) - const call = mockBatchUpdateRows.mock.calls[0][0] as { - updates: Array<{ rowId: string; data: Record }> - } - expect(call.updates.map((update) => update.rowId)).toEqual(['row-1', 'row-2']) - }) -}) - -describe('userTableServerTool.batch_insert_rows', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetTableById.mockResolvedValue(buildTable()) - }) - - it('rejects a row that is not a column → value object', async () => { - const result = await userTableServerTool.execute( - { - operation: 'batch_insert_rows', - args: { tableId: 'tbl_1', rows: [{ name: 'Ada' }, 'Bob'] }, - }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(result.message).toBe( - 'rows[1] is a string, not a row data object. Expected rows: [{ col: val }]' - ) - expect(mockBatchInsertRows).not.toHaveBeenCalled() - }) - - it('rejects rows that is not an array', async () => { - const result = await userTableServerTool.execute( - { operation: 'batch_insert_rows', args: { tableId: 'tbl_1', rows: { name: 'Ada' } } }, - buildToolContext() - ) - - expect(result.success).toBe(false) - expect(result.message).toBe('Rows array is required and must not be empty') - expect(mockBatchInsertRows).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts deleted file mode 100644 index 74978f50663..00000000000 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ /dev/null @@ -1,1578 +0,0 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { isRecordLike } from '@sim/utils/object' -import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' -import { executeCopilotResolveWorkflowOutputs } from '@/lib/copilot/application/execute-workflow-use-case' -import { - executeCopilotAddWorkflowTableGroupOutput, - executeCopilotCreateTableEnrichmentGroup, - executeCopilotCreateTableFromWorkspaceFile, - executeCopilotCreateWorkflowTableGroup, - executeCopilotDeleteTables, - executeCopilotImportWorkspaceFileIntoTable, - executeCopilotUpdateWorkflowTableGroup, -} from '@/lib/copilot/application/table-commands' -import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation' -import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' -import { - assertServerToolNotAborted, - type BaseServerTool, - type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' -import { COLUMN_TYPES, CSV_MAX_BATCH_SIZE, type CsvHeaderMapping, TABLE_LIMITS } from '@/lib/table' -import { - addTableColumnUseCase, - deleteTableColumnsUseCase, - deleteTableColumnUseCase, - updateTableColumnUseCase, -} from '@/lib/table/application/columns' -import { - copilotDeleteRowsByFilter, - copilotUpdateRowsByFilter, -} from '@/lib/table/application/copilot-bulk-rows' -import { - deleteTableGroupOutputUseCase, - deleteTableGroupUseCase, -} from '@/lib/table/application/groups' -import { - batchUpdateTableRows, - createTableRows, - deleteTableRow, - deleteTableRows, - queryTableRows, - readTableRow, - updateTableRow, -} from '@/lib/table/application/rows' -import { cancelTableRuns, startTableRun } from '@/lib/table/application/runs' -import { - createTableUseCase, - readTableUseCase, - updateTableUseCase, -} from '@/lib/table/application/tables' -import { readTableViewUseCase } from '@/lib/table/application/views' -import { namedRowMapper } from '@/lib/table/cell-format' -import { isSupportedCurrencyCode } from '@/lib/table/currency' -import type { TableImportRejectionSummary } from '@/lib/table/jobs/service' -import { normalizeTablePredicate } from '@/lib/table/query-builder/predicate' -import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' -import { normalizeSelectOptionsInput } from '@/lib/table/select-options' -import type { - RowData, - SortSpec, - TablePredicate, - TablePredicateInput, - TableSchema, - WorkflowGroupDependencies, -} from '@/lib/table/types' -import { viewConfigIdsToNames } from '@/lib/table/views/service' -import type { ResolvedSecretTraceProvenanceV1 } from '@/executor/utils/resolved-secret-trace-registry' - -const logger = createLogger('UserTableServerTool') - -type UserTableArgs = { - operation: string - args?: Record -} - -type UserTableResult = { - success: boolean - message: string - data?: any -} - -/** - * Renders the sentence an import appends when it lost data, mirroring the - * `droppedRows` phrasing so the model reads one shape for "the file did not - * land whole". - * - * `rowsRejected` is a FLOOR — one parser failure can discard many source - * records and is counted once — so the wording says "at least". Only the first - * retained sample is named; the rest ride on `data.rejections` rather than - * bloating a model-facing string. - * - * Returns `''` when nothing was lost, keeping a clean import's message - * byte-identical to what it has always been. - */ -function rejectionSentence(rejections: TableImportRejectionSummary | undefined): string { - if (!rejections) return '' - const losses: string[] = [] - if (rejections.rowsRejected > 0) { - losses.push(`at least ${rejections.rowsRejected.toLocaleString()} unreadable row(s)`) - } - if (rejections.cellsRejected > 0) { - losses.push( - `${rejections.cellsRejected.toLocaleString()} cell value(s) the column type could not store (kept as null)` - ) - } - if (losses.length === 0) return '' - const sample = rejections.rejectedSamples[0] - const firstFailure = sample - ? ` First failure: ${sample.code}${sample.line === null ? '' : ` at line ${sample.line}`}.` - : '' - return `. Dropped ${losses.join(' and ')} from the file.${firstFailure} See data.rejections for details.` -} - -/** - * Copilot's own batch ceiling, deliberately looser than the 1000 the internal - * and v2 batch-update contracts declare: Copilot parses no contract, so this is - * the only place the tool can refuse an oversized batch with a message the model - * can act on rather than a thrown domain error — and a batch it accepts today - * must keep working. - */ -const MAX_BATCH_SIZE = CSV_MAX_BATCH_SIZE - -function resolveAuthorizedWorkflowOutputs( - workflowId: string, - workspaceId: string, - context: ServerToolContext -) { - return executeCopilotResolveWorkflowOutputs(context, { - workflowId, - assertedWorkspaceId: workspaceId, - }) -} - -/** Names a malformed argument's kind the way the model reads it ("a string", "an array", "null"). */ -function describeValueKind(value: unknown): string { - if (value === null) return 'null' - if (value === undefined) return 'undefined' - if (Array.isArray(value)) return 'an array' - const type = typeof value - return type === 'object' ? 'an object' : `a ${type}` -} - -/** - * Why a batch_update_rows `updates` element is not the `{ rowId, data }` patch - * the catalog's item schema declares, or `null` when it is. The router's Ajv - * input validation rejects most of these first; this is the last line for a - * payload that reaches the executor, where a string element used to surface - * as an opaque "Table operation failed" after `Object.entries(undefined)` - * threw deep inside the use case. - */ -function rowUpdateProblem(value: unknown): string | null { - if (!isRecordLike(value)) return `is ${describeValueKind(value)}, not a { rowId, data } object` - if (typeof value.rowId !== 'string' || value.rowId.length === 0) { - return 'is missing a string rowId' - } - if (!isRecordLike(value.data)) return 'is missing a data object of column → value pairs' - return null -} - -/** Validates an optional row limit against the policy for the requested surface operation. */ -function limitError(limit: unknown, max?: number): string | null { - if (limit === undefined) return null - if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1) { - return 'Limit must be an integer of at least 1' - } - if (max !== undefined && limit > max) { - return `Limit cannot exceed ${max}` - } - return null -} - -/** - * Normalizes agent-authored `select` options into the stored `{ id, name }` - * shape. The copilot agent supplies option **names** (a bare string, or an - * object with a `name`); the stable option id is generated here so the model - * never authors the cell key. An entry that already carries a non-empty `id` - * (e.g. re-sending an existing option on an options edit) keeps it, so existing - * cell data survives the update. Non-array input returns `undefined`, letting - * downstream validation reject a malformed / missing option set. - */ -/** Rewrites every `select` column's options in an agent-authored create schema. */ -function normalizeSchemaSelectColumns(schema: TableSchema): TableSchema { - if (!schema || !Array.isArray(schema.columns)) return schema - return { - ...schema, - columns: schema.columns.map((col) => - col.type === 'select' ? { ...col, options: normalizeSelectOptionsInput(col.options) } : col - ), - } -} - -async function importRowsProvenanceForModel( - provenance: ResolvedSecretTraceProvenanceV1 | undefined, - values: unknown[], - context: ServerToolContext -): Promise { - const registry = context.resolvedSecretTraceRegistry - if (!registry) return - if (!provenance) { - registry.markIncomplete('table-result-provenance-unavailable') - return - } - await registry.importCrossingProvenance(provenance, values, { trusted: true }) -} - -/** AND-combines a saved view's predicate with an explicit one; either may be absent. */ -function mergeViewPredicate( - viewFilter: TablePredicateInput | undefined, - explicit: TablePredicateInput | undefined -): TablePredicate | undefined { - const parts: TablePredicate[] = [] - if (viewFilter) parts.push(normalizeTablePredicate(viewFilter)) - if (explicit) parts.push(normalizeTablePredicate(explicit)) - if (parts.length === 0) return undefined - if (parts.length === 1) return parts[0] - return { all: parts } -} - -export const userTableServerTool: BaseServerTool = { - name: UserTable.id, - async execute(params: UserTableArgs, context?: ServerToolContext): Promise { - if (!context?.userId) { - logger.error('Unauthorized attempt to access user table - no authenticated user context') - throw new Error('Authentication required') - } - - const { operation, args = {} } = params - const workspaceId = context.workspaceId - const assertNotAborted = () => - assertServerToolNotAborted(context, 'Request aborted before table mutation could be applied.') - try { - switch (operation) { - case 'create': { - if (!args.name) { - return { success: false, message: 'Name is required for creating a table' } - } - if (!args.schema) { - return { success: false, message: 'Schema is required for creating a table' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - assertNotAborted() - const { table } = await executeCopilotTableUseCase(context, createTableUseCase, { - name: args.name, - description: args.description, - schema: normalizeSchemaSelectColumns(args.schema as TableSchema), - folderPath: args.folderPath, - workspaceId, - }) - - return { - success: true, - message: `Created table "${table.name}" (${table.id})`, - data: { table }, - } - } - - case 'get': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - const { table } = await executeCopilotTableUseCase( - context, - readTableUseCase, - { tableId: args.tableId, workspaceId }, - { tableId: args.tableId } - ) - - return { - success: true, - message: `Table "${table.name}" has ${table.rowCount} rows`, - data: { table }, - } - } - - case 'get_schema': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - const { table } = await executeCopilotTableUseCase( - context, - readTableUseCase, - { tableId: args.tableId, workspaceId }, - { tableId: args.tableId } - ) - - return { - success: true, - message: `Schema for "${table.name}"`, - data: { - name: table.name, - columns: table.schema.columns, - workflowGroups: table.schema.workflowGroups ?? [], - }, - } - } - - case 'delete': { - const tableIds: string[] = args.tableIds ?? (args.tableId ? [args.tableId] : []) - if (tableIds.length === 0) { - return { success: false, message: 'tableId or tableIds is required' } - } - if (tableIds.length > TABLE_LIMITS.MAX_TABLES_PER_WORKSPACE) { - return { - success: false, - message: `Cannot delete more than ${TABLE_LIMITS.MAX_TABLES_PER_WORKSPACE} tables at once`, - } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - assertNotAborted() - const { deleted: archived, failed } = await executeCopilotDeleteTables(context, { - tableIds, - workspaceId, - assertNotAborted, - }) - const deleted = archived.map((table) => table.id) - - return { - success: deleted.length > 0, - message: `Deleted ${deleted.length} table(s)${failed.length > 0 ? `, ${failed.length} not found` : ''}`, - data: { deleted, failed }, - } - } - - case 'insert_row': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } - } - if (!args.data) { - return { success: false, message: 'Data is required for inserting a row' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - assertNotAborted() - const result = await executeCopilotTableUseCase( - context, - createTableRows, - { - kind: 'single', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - strictWrite: false, - dataKeying: 'names', - data: args.data, - position: args.position as number | undefined, - secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), - }, - { tableId: args.tableId } - ) - if (result.kind !== 'single') throw new Error('Single row insert returned a batch') - const { table, row } = result - const toNamedRow = namedRowMapper(table.schema.columns) - - return { - success: true, - message: `Inserted row ${row.id}`, - data: { - row: { - ...row, - data: toNamedRow(row.data), - }, - }, - } - } - - case 'batch_insert_rows': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } - } - if (!Array.isArray(args.rows) || args.rows.length === 0) { - return { success: false, message: 'Rows array is required and must not be empty' } - } - const malformedRow = args.rows.findIndex((row: unknown) => !isRecordLike(row)) - if (malformedRow !== -1) { - return { - success: false, - message: `rows[${malformedRow}] is ${describeValueKind(args.rows[malformedRow])}, not a row data object. Expected rows: [{ col: val }]`, - } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - assertNotAborted() - const sourceRows = args.rows as RowData[] - const result = await executeCopilotTableUseCase( - context, - createTableRows, - { - kind: 'batch', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - strictWrite: false, - dataKeying: 'names', - rows: sourceRows, - secretProvenance: sourceRows.map(createExactEmptyTableRowSecretProvenance), - }, - { tableId: args.tableId } - ) - if (result.kind !== 'batch') throw new Error('Batch row insert returned one row') - const { table, rows } = result - const toNamedRow = namedRowMapper(table.schema.columns) - - return { - success: true, - message: `Inserted ${rows.length} rows`, - data: { - rows: rows.map((r) => ({ - ...r, - data: toNamedRow(r.data), - })), - insertedCount: rows.length, - }, - } - } - - case 'get_row': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } - } - if (!args.rowId) { - return { success: false, message: 'Row ID is required' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - const { - table: rowTable, - row, - secretProvenance, - } = await executeCopilotTableUseCase( - context, - readTableRow, - { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - rowId: args.rowId, - includePersistedSecretProvenance: Boolean(context.resolvedSecretTraceRegistry), - }, - { tableId: args.tableId } - ) - await importRowsProvenanceForModel(secretProvenance, [row.data], context) - - const toNamedRow = namedRowMapper(rowTable.schema.columns) - return { - success: true, - message: `Row ${row.id}`, - data: { - row: { - ...row, - data: toNamedRow(row.data), - }, - }, - } - } - - case 'query_rows': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - // Saved-view scope: the view's stored filter ANDs with any explicit - // filter (query-within-the-view), its sort applies only when no - // explicit order is given, and layout fields (hidden columns, order, - // widths) are ignored — agents always see full rows. Views are - // referenced by id only (from views.json or table_views list_views). - let viewFilter: TablePredicateInput | undefined - let viewSort: SortSpec | undefined - let appliedViewName: string | undefined - if (typeof args.view === 'string' && args.view.trim() !== '') { - const viewId = (args.view as string).trim() - const resolved = await executeCopilotTableUseCase( - context, - readTableViewUseCase, - { tableId: args.tableId, workspaceId, viewId }, - { tableId: args.tableId } - ) - const named = viewConfigIdsToNames( - resolved.view.config, - (resolved.table.schema as TableSchema).columns - ) - viewFilter = (named.filter as TablePredicateInput | null) ?? undefined - viewSort = (named.sort as SortSpec | null) ?? undefined - appliedViewName = resolved.view.name - } - - const queryLimitError = limitError(args.limit, TABLE_LIMITS.MAX_QUERY_LIMIT) - if (queryLimitError) { - return { success: false, message: queryLimitError } - } - - const result = await executeCopilotTableUseCase( - context, - queryTableRows, - { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - predicate: mergeViewPredicate( - viewFilter, - args.filter as TablePredicateInput | undefined - ), - sort: (args.order as SortSpec | undefined) ?? viewSort, - limit: args.limit ?? TABLE_LIMITS.MAX_QUERY_LIMIT, - cursor: args.cursor, - includeTotal: !args.cursor, - includePersistedSecretProvenance: Boolean(context.resolvedSecretTraceRegistry), - }, - { tableId: args.tableId } - ) - const { table, secretProvenance, ...queryResult } = result - const toNamedRow = namedRowMapper(table.schema.columns) - await importRowsProvenanceForModel( - secretProvenance, - result.rows.map((row) => row.data), - context - ) - - // nextCursor covers both cut kinds (explicit limit or the 5MB byte - // budget) — either way the truthful signal is "more rows exist". The - // token is opaque; the agent echoes it back as `cursor` to continue. - const viewSuffix = appliedViewName ? ` (view: ${appliedViewName})` : '' - const countSuffix = - (result.totalCount != null ? ` of ${result.totalCount}` : '') + viewSuffix - const message = result.nextCursor - ? `Returned ${result.rows.length}${countSuffix} rows (more available — pass cursor=${result.nextCursor} to continue)` - : `Returned ${result.rows.length}${countSuffix} rows` - return { - success: true, - message, - data: { - ...queryResult, - rows: result.rows.map((r) => ({ - ...r, - data: toNamedRow(r.data), - })), - }, - } - } - - case 'update_row': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } - } - if (!args.rowId) { - return { success: false, message: 'Row ID is required' } - } - if (!args.data) { - return { success: false, message: 'Data is required for updating a row' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - assertNotAborted() - const { - table, - row: updatedRow, - secretProvenance, - } = await executeCopilotTableUseCase( - context, - updateTableRow, - { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - strictWrite: false, - dataKeying: 'names', - rowId: args.rowId, - data: args.data, - secretProvenance: createExactEmptyTableRowSecretProvenance(args.data), - includePersistedSecretProvenance: Boolean(context.resolvedSecretTraceRegistry), - }, - { tableId: args.tableId } - ) - const toNamedRow = namedRowMapper(table.schema.columns) - await importRowsProvenanceForModel(secretProvenance, [updatedRow.data], context) - - return { - success: true, - message: `Updated row ${updatedRow.id}`, - data: { - row: { - ...updatedRow, - data: toNamedRow(updatedRow.data), - }, - }, - } - } - - case 'delete_row': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } - } - if (!args.rowId) { - return { success: false, message: 'Row ID is required' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - assertNotAborted() - await executeCopilotTableUseCase( - context, - deleteTableRow, - { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - rowId: args.rowId, - }, - { tableId: args.tableId } - ) - - return { - success: true, - message: `Deleted row ${args.rowId}`, - } - } - - case 'update_rows_by_filter': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } - } - if (!args.filter) { - return { success: false, message: 'Filter is required for bulk update' } - } - if (!args.data) { - return { success: false, message: 'Data is required for bulk update' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - const updateLimitError = limitError(args.limit) - if (updateLimitError) { - return { success: false, message: updateLimitError } - } - - assertNotAborted() - const result = await executeCopilotTableUseCase( - context, - copilotUpdateRowsByFilter, - { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - filter: normalizeTablePredicate(args.filter as TablePredicateInput), - data: args.data as RowData, - limit: args.limit, - }, - { tableId: args.tableId } - ) - if (result.kind === 'background') { - return { - success: true, - message: `Started background update of ${result.affectedCount} matching rows (job ${result.jobId}). Rows update in the background — query_rows to check progress. Note: background updates don't auto-recompute workflow/enrichment columns; use run_column afterward if needed.`, - data: { jobId: result.jobId, affectedCount: result.affectedCount }, - } - } - - return { - success: true, - message: `Updated ${result.affectedCount} rows`, - data: { affectedCount: result.affectedCount, affectedRowIds: result.affectedRowIds }, - } - } - - case 'delete_rows_by_filter': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } - } - if (!args.filter) { - return { success: false, message: 'Filter is required for bulk delete' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - const deleteLimitError = limitError(args.limit) - if (deleteLimitError) { - return { success: false, message: deleteLimitError } - } - - assertNotAborted() - const result = await executeCopilotTableUseCase( - context, - copilotDeleteRowsByFilter, - { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - filter: normalizeTablePredicate(args.filter as TablePredicateInput), - limit: args.limit, - }, - { tableId: args.tableId } - ) - if (result.kind === 'background') { - return { - success: true, - message: result.bounded - ? `Started background delete of up to ${result.doomedCount} matching rows (job ${result.jobId}). Rows delete in the background — query_rows to check progress.` - : `Started background delete of ${result.doomedCount} matching rows (job ${result.jobId}). The rows are hidden from reads immediately — query_rows already reflects the post-delete view.`, - data: { jobId: result.jobId, doomedCount: result.doomedCount }, - } - } - - return { - success: true, - message: `Deleted ${result.affectedCount} rows`, - data: { affectedCount: result.affectedCount, affectedRowIds: result.affectedRowIds }, - } - } - - case 'batch_update_rows': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - const rawUpdates: unknown = (args as Record).updates - const columnName: unknown = (args as Record).columnName - const valuesMap: unknown = (args as Record).values - - let updates: Array<{ rowId: string; data: Record }> - - if (Array.isArray(rawUpdates) && rawUpdates.length > 0) { - for (let index = 0; index < rawUpdates.length; index += 1) { - const problem = rowUpdateProblem(rawUpdates[index]) - if (problem) { - return { - success: false, - message: `updates[${index}] ${problem}. Expected updates: [{ rowId, data: { col: val } }]`, - } - } - } - updates = rawUpdates - } else if (typeof columnName === 'string' && columnName && isRecordLike(valuesMap)) { - updates = Object.entries(valuesMap).map(([rowId, value]) => ({ - rowId, - data: { [columnName]: value }, - })) - } else { - return { - success: false, - message: - 'Provide either a non-empty "updates" array of { rowId, data } objects or "columnName" + "values" map', - } - } - - if (updates.length > MAX_BATCH_SIZE) { - return { - success: false, - message: `Too many updates (${updates.length}). Maximum is ${MAX_BATCH_SIZE}.`, - } - } - - assertNotAborted() - const result = await executeCopilotTableUseCase( - context, - batchUpdateTableRows, - { - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - updates: updates as Array<{ rowId: string; data: RowData }>, - strictWrite: false, - dataKeying: 'names' as const, - }, - { tableId: args.tableId } - ) - - return { - success: true, - message: `Updated ${result.affectedCount} rows`, - data: { affectedCount: result.affectedCount, affectedRowIds: result.affectedRowIds }, - } - } - - case 'batch_delete_rows': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - const rowIds = (args as Record).rowIds as string[] | undefined - if (!rowIds || rowIds.length === 0) { - return { success: false, message: 'rowIds array is required' } - } - - if (rowIds.length > MAX_BATCH_SIZE) { - return { - success: false, - message: `Too many row IDs (${rowIds.length}). Maximum is ${MAX_BATCH_SIZE}.`, - } - } - - assertNotAborted() - const result = await executeCopilotTableUseCase( - context, - deleteTableRows, - { - kind: 'ids', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - rowIds, - }, - { tableId: args.tableId } - ) - if (result.kind !== 'ids') throw new Error('Row ID deletion returned a filter result') - - return { - success: true, - message: `Deleted ${result.deletedCount} rows`, - data: { - deletedCount: result.deletedCount, - deletedRowIds: result.deletedRowIds, - }, - } - } - - case 'create_from_file': { - const fileId = (args as Record).fileId as string | undefined - const filePath = (args as Record).filePath as string | undefined - const fileReference = fileId || filePath - if (!fileReference) { - return { - success: false, - message: - 'fileId or filePath is required for create_from_file. Use a canonical VFS path from glob("files/**") or a file ID from read("files/{path}/{name}").', - } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - assertNotAborted() - const result = await executeCopilotCreateTableFromWorkspaceFile(context, { - workspaceId, - fileReference, - name: args.name, - description: args.description, - assertNotAborted, - }) - if (result.kind === 'empty') { - return { - success: false, - message: - 'File yielded no readable data rows — it is either empty or every record failed to parse.', - } - } - const record = result.sourceFile - if (result.kind === 'background') { - return { - success: true, - message: `Created table "${result.table.name}" (${result.table.id}); importing rows from "${record.name}" in the background (job ${result.jobId}). Columns and rows appear as the import progresses — query_rows to check what has landed.`, - data: { - tableId: result.table.id, - tableName: result.table.name, - jobId: result.jobId, - sourceFile: record.name, - }, - } - } - - const createdMessage = `Created table "${result.table.name}" with ${result.columns.length} columns and ${result.insertedCount.toLocaleString()} rows from "${record.name}"` - const limitMessage = - result.droppedRows > 0 - ? `${createdMessage}. Dropped ${result.droppedRows.toLocaleString()} row(s) that exceed this plan's limit of ${result.maxRowsPerTable.toLocaleString()} rows per table.` - : createdMessage - const message = `${limitMessage}${rejectionSentence(result.rejections)}` - - return { - success: true, - message, - data: { - tableId: result.table.id, - tableName: result.table.name, - columns: result.columns.map((column) => ({ - name: column.name, - type: column.type, - })), - rowCount: result.insertedCount, - sourceFile: record.name, - ...(result.rejections ? { rejections: result.rejections } : {}), - }, - } - } - - case 'import_file': { - const fileId = (args as Record).fileId as string | undefined - const filePath = (args as Record).filePath as string | undefined - const tableId = (args as Record).tableId as string | undefined - const fileReference = fileId || filePath - const rawMode = (args as Record).mode as string | undefined - const rawMapping = (args as Record).mapping as - | CsvHeaderMapping - | undefined - if (!fileReference) { - return { - success: false, - message: - 'fileId or filePath is required for import_file. Use a canonical VFS path from glob("files/**") or a file ID from read("files/{path}/{name}").', - } - } - if (!tableId) { - return { success: false, message: 'tableId is required for import_file' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - if (rawMode && rawMode !== 'append' && rawMode !== 'replace') { - return { - success: false, - message: `Invalid mode "${rawMode}". Must be "append" or "replace".`, - } - } - const mode: 'append' | 'replace' = rawMode === 'replace' ? 'replace' : 'append' - - assertNotAborted() - const result = await executeCopilotImportWorkspaceFileIntoTable(context, { - tableId, - assertedWorkspaceId: workspaceId, - fileReference, - mode, - mapping: rawMapping, - assertNotAborted, - }) - if (result.kind === 'background') { - return { - success: true, - message: `Started background ${mode} import of "${result.sourceFileName}" into "${result.table.name}" (job ${result.jobId}). Rows appear as the import progresses — query_rows to check what has landed.`, - data: { tableId: result.table.id, jobId: result.jobId, mode }, - } - } - if (result.kind === 'empty') { - return { - success: false, - message: - 'File yielded no readable data rows — it is either empty or every record failed to parse.', - } - } - if (result.kind !== 'inline') - throw new Error('Inline table import returned a background job') - if (result.mode === 'replace') { - return { - success: true, - message: `Replaced rows in "${result.table.name}" from "${result.sourceFileName}": deleted ${result.deletedCount}, inserted ${result.insertedCount}${rejectionSentence(result.rejections)}`, - data: { - tableId: result.table.id, - tableName: result.table.name, - mode, - matchedColumns: result.matchedColumns, - skippedColumns: result.skippedColumns, - deletedCount: result.deletedCount, - insertedCount: result.insertedCount, - sourceFile: result.sourceFileName, - ...(result.rejections ? { rejections: result.rejections } : {}), - }, - } - } - return { - success: true, - message: `Imported ${result.insertedCount} rows into "${result.table.name}" from "${result.sourceFileName}" (${result.matchedColumns.length} columns matched)${rejectionSentence(result.rejections)}`, - data: { - tableId: result.table.id, - tableName: result.table.name, - mode, - matchedColumns: result.matchedColumns, - skippedColumns: result.skippedColumns, - rowCount: result.insertedCount, - sourceFile: result.sourceFileName, - ...(result.rejections ? { rejections: result.rejections } : {}), - }, - } - } - - case 'add_column': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - const col = (args as Record).column as - | { - name: string - type: string - unique?: boolean - position?: number - options?: unknown - multiple?: boolean - currencyCode?: string - } - | undefined - if (!col?.name || !col?.type) { - return { - success: false, - message: 'column with name and type is required for add_column', - } - } - assertNotAborted() - if (col.currencyCode !== undefined && !isSupportedCurrencyCode(col.currencyCode)) { - return { - success: false, - message: `Invalid currency code "${col.currencyCode}". Use an ISO 4217 code, e.g. USD`, - } - } - // Agent authors select options by name; generate their stable ids here. - const columnToAdd = - col.type === 'select' - ? { ...col, options: normalizeSelectOptionsInput(col.options) } - : { ...col, options: undefined } - const { table: updated } = await executeCopilotTableUseCase( - context, - addTableColumnUseCase, - { tableId: args.tableId, workspaceId, column: columnToAdd }, - { tableId: args.tableId } - ) - return { - success: true, - message: `Added column "${col.name}" (${col.type}) to table`, - data: { schema: updated.schema }, - } - } - - case 'rename_column': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - const colName = (args as Record).columnName as string | undefined - const newColName = (args as Record).newName as string | undefined - if (!colName || !newColName) { - return { success: false, message: 'columnName and newName are required' } - } - assertNotAborted() - const { table: updated } = await executeCopilotTableUseCase( - context, - updateTableColumnUseCase, - { - tableId: args.tableId, - workspaceId, - columnName: colName, - updates: { name: newColName }, - }, - { tableId: args.tableId } - ) - return { - success: true, - message: `Renamed column "${colName}" to "${newColName}"`, - data: { schema: updated.schema }, - } - } - - case 'delete_column': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - const colName = (args as Record).columnName as string | undefined - const colNames = (args as Record).columnNames as string[] | undefined - const names = colNames ?? (colName ? [colName] : null) - if (!names || names.length === 0) { - return { success: false, message: 'columnName or columnNames is required' } - } - if (names.length === 1) { - assertNotAborted() - const { table: updated } = await executeCopilotTableUseCase( - context, - deleteTableColumnUseCase, - { tableId: args.tableId, workspaceId, columnName: names[0] }, - { tableId: args.tableId } - ) - return { - success: true, - message: `Deleted column "${names[0]}"`, - data: { schema: updated.schema }, - } - } - assertNotAborted() - const { table: updated, deletedColumns } = await executeCopilotTableUseCase( - context, - deleteTableColumnsUseCase, - { tableId: args.tableId, workspaceId, columnNames: names }, - { tableId: args.tableId } - ) - return { - success: true, - message: `Deleted ${deletedColumns.length} ${deletedColumns.length === 1 ? 'column' : 'columns'}: ${deletedColumns - .map((column) => column.name) - .join(', ')}`, - data: { schema: updated.schema }, - } - } - - case 'update_column': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - const colName = (args as Record).columnName as string | undefined - if (!colName) { - return { success: false, message: 'columnName is required' } - } - const newType = (args as Record).newType as string | undefined - const uniqFlag = (args as Record).unique as boolean | undefined - const rawOptions = (args as Record).options - const multiple = (args as Record).multiple as boolean | undefined - const currencyCode = (args as Record).currencyCode as string | undefined - if ( - newType === undefined && - uniqFlag === undefined && - rawOptions === undefined && - multiple === undefined && - currencyCode === undefined - ) { - return { - success: false, - message: - 'At least one of newType, unique, options, multiple, or currencyCode must be provided', - } - } - if (currencyCode !== undefined && !isSupportedCurrencyCode(currencyCode)) { - return { - success: false, - message: `Invalid currency code "${currencyCode}". Use an ISO 4217 code, e.g. USD`, - } - } - if (newType !== undefined && !(COLUMN_TYPES as readonly string[]).includes(newType)) { - return { - success: false, - message: `Invalid column type "${newType}". Must be one of: ${COLUMN_TYPES.join(', ')}`, - } - } - assertNotAborted() - const { table: updated } = await executeCopilotTableUseCase( - context, - updateTableColumnUseCase, - { - tableId: args.tableId, - workspaceId, - columnName: colName, - updates: { - ...(newType !== undefined - ? { type: newType as (typeof COLUMN_TYPES)[number] } - : {}), - ...(uniqFlag !== undefined ? { unique: uniqFlag } : {}), - ...(rawOptions !== undefined ? { options: rawOptions } : {}), - ...(multiple !== undefined ? { multiple } : {}), - ...(currencyCode !== undefined ? { currencyCode } : {}), - }, - }, - { tableId: args.tableId } - ) - return { - success: true, - message: `Updated column "${colName}"`, - data: { schema: updated.schema }, - } - } - case 'rename': { - if (!args.tableId) { - return { success: false, message: 'Table ID is required' } - } - const newName = (args as Record).newName as string | undefined - if (!newName) { - return { success: false, message: 'newName is required for renaming a table' } - } - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } - - assertNotAborted() - const result = await executeCopilotTableUseCase( - context, - updateTableUseCase, - { tableId: args.tableId, workspaceId, name: newName }, - { tableId: args.tableId } - ) - if (result.failure) { - throw result.failure - } - - return { - success: true, - message: `Renamed table to "${newName}"`, - data: { table: { id: args.tableId, name: result.table?.name ?? newName } }, - } - } - - case 'list_workflow_outputs': { - if (!workspaceId) return { success: false, message: 'Workspace ID is required' } - const workflowId = args.workflowId as string | undefined - if (!workflowId) { - return { - success: false, - message: 'workflowId is required for list_workflow_outputs', - } - } - const resolvedWorkflow = await resolveAuthorizedWorkflowOutputs( - workflowId, - workspaceId, - context - ) - const flattened = resolvedWorkflow.outputs - if (!flattened) { - return { - success: false, - message: `Workflow not found or has no blocks: ${workflowId}`, - } - } - return { - success: true, - message: `Found ${flattened.length} output path(s) across the workflow's blocks`, - data: { workflowId, outputs: flattened }, - } - } - - case 'add_workflow_group': { - if (!args.tableId) return { success: false, message: 'Table ID is required' } - if (!workspaceId) return { success: false, message: 'Workspace ID is required' } - const workflowId = args.workflowId as string | undefined - if (!workflowId) { - return { success: false, message: 'workflowId is required for add_workflow_group' } - } - const rawOutputs = args.outputs as - | Array<{ - blockId: string - path: string - columnName?: string - columnType?: string - }> - | undefined - if (!rawOutputs || rawOutputs.length === 0) { - return { - success: false, - message: 'outputs array (with blockId + path entries) is required', - } - } - for (const o of rawOutputs) { - if (!o.blockId || !o.path) { - return { - success: false, - message: 'Each output entry must include both blockId and path', - } - } - } - - const dependencies = args.dependencies as WorkflowGroupDependencies | undefined - const name = args.name as string | undefined - assertNotAborted() - const autoRun = args.autoRun === true - const { table: updated, group } = await executeCopilotCreateWorkflowTableGroup(context, { - tableId: args.tableId, - workspaceId, - workflowId, - outputs: rawOutputs, - name, - dependencies, - autoRun, - }) - return { - success: true, - message: `Added workflow group "${name ?? group.id}" with ${group.outputs.length} output column(s)`, - data: { - groupId: group.id, - schema: updated.schema, - }, - } - } - - case 'update_workflow_group': { - if (!args.tableId) return { success: false, message: 'Table ID is required' } - if (!workspaceId) return { success: false, message: 'Workspace ID is required' } - const groupId = args.groupId as string | undefined - if (!groupId) { - return { success: false, message: 'groupId is required for update_workflow_group' } - } - const updateOutputs = args.outputs as - | Array<{ - blockId: string - path: string - columnName?: string - columnType?: string - }> - | undefined - const mappingUpdates = args.mappingUpdates as - | Array<{ columnName: string; blockId: string; path: string }> - | undefined - const explicitWorkflowId = args.workflowId as string | undefined - assertNotAborted() - const { table: updated } = await executeCopilotUpdateWorkflowTableGroup(context, { - tableId: args.tableId, - workspaceId, - groupId, - workflowId: explicitWorkflowId, - name: args.name as string | undefined, - dependencies: args.dependencies as WorkflowGroupDependencies | undefined, - outputs: updateOutputs, - mappingUpdates, - autoRun: typeof args.autoRun === 'boolean' ? args.autoRun : undefined, - }) - return { - success: true, - message: `Updated workflow group ${groupId}`, - data: { schema: updated.schema }, - } - } - - case 'delete_workflow_group': { - if (!args.tableId) return { success: false, message: 'Table ID is required' } - if (!workspaceId) return { success: false, message: 'Workspace ID is required' } - const groupId = args.groupId as string | undefined - if (!groupId) { - return { success: false, message: 'groupId is required for delete_workflow_group' } - } - assertNotAborted() - const { table: updated } = await executeCopilotTableUseCase( - context, - deleteTableGroupUseCase, - { tableId: args.tableId, workspaceId, groupId }, - { tableId: args.tableId } - ) - return { - success: true, - message: `Deleted workflow group ${groupId}`, - data: { schema: updated.schema }, - } - } - - case 'add_workflow_group_output': { - if (!args.tableId) return { success: false, message: 'Table ID is required' } - if (!workspaceId) return { success: false, message: 'Workspace ID is required' } - const groupId = args.groupId as string | undefined - const blockId = args.blockId as string | undefined - const path = args.path as string | undefined - const columnName = args.columnName as string | undefined - if (!groupId || !blockId || !path) { - return { - success: false, - message: 'groupId, blockId, and path are required for add_workflow_group_output', - } - } - assertNotAborted() - const { table: updated } = await executeCopilotAddWorkflowTableGroupOutput(context, { - tableId: args.tableId, - workspaceId, - groupId, - blockId, - path, - columnName, - }) - return { - success: true, - message: `Added output to workflow group ${groupId}`, - data: { schema: updated.schema }, - } - } - - case 'delete_workflow_group_output': { - if (!args.tableId) return { success: false, message: 'Table ID is required' } - if (!workspaceId) return { success: false, message: 'Workspace ID is required' } - const groupId = args.groupId as string | undefined - const columnName = args.columnName as string | undefined - if (!groupId || !columnName) { - return { - success: false, - message: 'groupId and columnName are required for delete_workflow_group_output', - } - } - assertNotAborted() - const { table: updated } = await executeCopilotTableUseCase( - context, - deleteTableGroupOutputUseCase, - { tableId: args.tableId, groupId, columnName, workspaceId }, - { tableId: args.tableId } - ) - return { - success: true, - message: `Removed output "${columnName}" from workflow group ${groupId}`, - data: { schema: updated.schema }, - } - } - - case 'run_column': { - if (!args.tableId) return { success: false, message: 'Table ID is required' } - if (!workspaceId) return { success: false, message: 'Workspace ID is required' } - const rawGroupIds = args.groupIds as unknown - if ( - !Array.isArray(rawGroupIds) || - rawGroupIds.length === 0 || - rawGroupIds.some((id) => typeof id !== 'string' || id.length === 0) - ) { - return { - success: false, - message: 'groupIds must be a non-empty array of group id strings', - } - } - const groupIds = rawGroupIds as string[] - const runMode = (args.runMode as 'all' | 'incomplete' | undefined) ?? 'incomplete' - if (runMode !== 'all' && runMode !== 'incomplete') { - return { - success: false, - message: `Invalid runMode "${runMode}". Must be "all" or "incomplete"`, - } - } - const rawRowIds = args.rowIds as unknown - let rowIds: string[] | undefined - if (rawRowIds !== undefined) { - if ( - !Array.isArray(rawRowIds) || - rawRowIds.length === 0 || - rawRowIds.some((id) => typeof id !== 'string' || id.length === 0) - ) { - return { - success: false, - message: 'rowIds must be a non-empty array of row id strings', - } - } - rowIds = rawRowIds as string[] - } - assertNotAborted() - const { dispatchId } = await executeCopilotTableUseCase( - context, - startTableRun, - { - kind: 'selection', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - groupIds, - mode: runMode, - rowIds, - }, - { tableId: args.tableId } - ) - const scopeLabel = rowIds ? `${rowIds.length} row(s) by id` : runMode - return { - success: true, - message: `Started running ${groupIds.length} column(s) (${scopeLabel}). Cells will populate as workflows complete.`, - data: { dispatchId }, - } - } - - case 'cancel_table_runs': { - if (!args.tableId) return { success: false, message: 'Table ID is required' } - if (!workspaceId) return { success: false, message: 'Workspace ID is required' } - const scope = (args.scope as 'all' | 'row' | undefined) ?? 'all' - if (scope !== 'all' && scope !== 'row') { - return { - success: false, - message: `Invalid scope "${scope}". Must be "all" or "row"`, - } - } - const rowId = args.rowId as string | undefined - if (scope === 'row' && !rowId) { - return { success: false, message: 'rowId is required when scope is "row"' } - } - assertNotAborted() - const { cancelled } = await executeCopilotTableUseCase( - context, - cancelTableRuns, - scope === 'row' - ? { - scope: 'row', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - rowId: rowId as string, - } - : { - scope: 'all', - tableId: args.tableId, - assertedWorkspaceId: workspaceId, - }, - { tableId: args.tableId } - ) - return { - success: true, - message: `Cancelled ${cancelled} run(s)`, - data: { cancelled }, - } - } - - case 'list_enrichments': { - const { ALL_ENRICHMENTS } = await import('@/enrichments/registry') - const enrichments = ALL_ENRICHMENTS.map((e) => ({ - id: e.id, - name: e.name, - description: e.description, - inputs: e.inputs.map((i) => ({ - id: i.id, - name: i.name, - type: i.type, - required: i.required ?? false, - })), - outputs: e.outputs.map((o) => ({ id: o.id, name: o.name, type: o.type })), - })) - return { - success: true, - message: `${enrichments.length} enrichment(s) available`, - data: { enrichments }, - } - } - - case 'add_enrichment': { - if (!args.tableId) return { success: false, message: 'Table ID is required' } - if (!workspaceId) return { success: false, message: 'Workspace ID is required' } - const enrichmentId = args.enrichmentId as string | undefined - if (!enrichmentId) { - return { success: false, message: 'enrichmentId is required for add_enrichment' } - } - const rawMappings = args.inputMappings as - | Array<{ inputName: string; columnName: string }> - | undefined - const autoRun = args.autoRun === true - assertNotAborted() - const { table: updated, group } = await executeCopilotCreateTableEnrichmentGroup( - context, - { - tableId: args.tableId, - workspaceId, - enrichmentId, - inputMappings: Array.isArray(rawMappings) ? rawMappings : undefined, - outputColumnNames: (args.outputColumnNames ?? {}) as Record, - dependencies: args.dependencies as WorkflowGroupDependencies | undefined, - name: args.name as string | undefined, - autoRun, - } - ) - return { - success: true, - message: `Added enrichment "${group.name}" with ${group.outputs.length} output column(s)${ - autoRun ? ' (auto-run enabled)' : ' (staged — use run_column to fire rows)' - }`, - data: { groupId: group.id, schema: updated.schema }, - } - } - - default: - return { success: false, message: `Unknown operation: ${operation}` } - } - } catch (error) { - const errorMessage = toError(error).message - const cause = error instanceof Error && error.cause ? toError(error.cause).message : undefined - logger.error('Table operation failed', { - operation, - error: errorMessage, - cause, - }) - return { - success: false, - message: `Operation failed: ${messageForCopilotTableError(error)}`, - } - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts deleted file mode 100644 index 8460ae1d822..00000000000 --- a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * @vitest-environment node - */ - -import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockUpsertPersonalEnvVars: upsertPersonalEnvVarsMock, - mockUpsertWorkspaceEnvVars: upsertWorkspaceEnvVarsMock, -} = environmentUtilsMockFns - -afterAll(resetEnvironmentUtilsMock) - -const { - ensureWorkflowAccessMock, - ensureWorkspaceAccessMock, - listCredentialsMock, - performUpdateCredentialMock, -} = vi.hoisted(() => ({ - ensureWorkflowAccessMock: vi.fn(), - ensureWorkspaceAccessMock: vi.fn(), - listCredentialsMock: vi.fn(), - performUpdateCredentialMock: vi.fn(), -})) - -vi.mock('@/lib/credentials/queries', () => ({ - listVisibleWorkspaceCredentials: listCredentialsMock, -})) - -vi.mock('@/lib/credentials/orchestration', () => ({ - performUpdateCredential: performUpdateCredentialMock, -})) - -vi.mock('@/lib/copilot/tools/handlers/access', () => ({ - ensureWorkflowAccess: ensureWorkflowAccessMock, - ensureWorkspaceAccess: ensureWorkspaceAccessMock, -})) - -import { setEnvironmentVariablesServerTool } from './set-environment-variables' - -describe('setEnvironmentVariablesServerTool', () => { - beforeEach(() => { - vi.clearAllMocks() - ensureWorkflowAccessMock.mockResolvedValue({ - workflow: { id: 'wf-1', workspaceId: 'ws-from-workflow' }, - }) - ensureWorkspaceAccessMock.mockResolvedValue(undefined) - upsertPersonalEnvVarsMock.mockResolvedValue({ added: ['API_KEY'], updated: [] }) - upsertWorkspaceEnvVarsMock.mockResolvedValue(['API_KEY']) - listCredentialsMock.mockResolvedValue({ - data: [ - { id: 'cred-api', envKey: 'API_KEY' }, - { id: 'cred-other', envKey: 'OTHER_KEY' }, - ], - }) - performUpdateCredentialMock.mockResolvedValue({ success: true }) - }) - - it('defaults to workspace scope and uses the current workspace context', async () => { - const result = await setEnvironmentVariablesServerTool.execute( - { - variables: [{ name: 'API_KEY', value: 'secret' }], - }, - { - userId: 'user-1', - workspaceId: 'ws-1', - } - ) - - expect(ensureWorkspaceAccessMock).toHaveBeenCalledWith('ws-1', 'user-1', 'write') - expect(upsertWorkspaceEnvVarsMock).toHaveBeenCalledWith('ws-1', { API_KEY: 'secret' }, 'user-1') - expect(upsertPersonalEnvVarsMock).not.toHaveBeenCalled() - expect(result.scope).toBe('workspace') - expect(result.workspaceId).toBe('ws-1') - }) - - it('supports explicit personal scope', async () => { - const result = await setEnvironmentVariablesServerTool.execute( - { - scope: 'personal', - variables: [{ name: 'API_KEY', value: 'secret' }], - }, - { - userId: 'user-1', - workspaceId: 'ws-1', - } - ) - - expect(upsertPersonalEnvVarsMock).toHaveBeenCalledWith('user-1', { API_KEY: 'secret' }) - expect(upsertWorkspaceEnvVarsMock).not.toHaveBeenCalled() - expect(ensureWorkspaceAccessMock).not.toHaveBeenCalled() - expect(result.scope).toBe('personal') - }) - - it('fails closed when the context carries no workspace', async () => { - await expect( - setEnvironmentVariablesServerTool.execute( - { variables: [{ name: 'API_KEY', value: 'secret' }] }, - { userId: 'user-1' } - ) - ).rejects.toThrow('Copilot execution workspace is required') - - expect(upsertWorkspaceEnvVarsMock).not.toHaveBeenCalled() - }) - - it('accepts a workspaceId that re-asserts the execution workspace', async () => { - const result = await setEnvironmentVariablesServerTool.execute( - { workspaceId: 'ws-1', variables: [{ name: 'API_KEY', value: 'secret' }] }, - { userId: 'user-1', workspaceId: 'ws-1' } - ) - - expect(upsertWorkspaceEnvVarsMock).toHaveBeenCalledWith('ws-1', { API_KEY: 'secret' }, 'user-1') - expect(result.workspaceId).toBe('ws-1') - }) - - it('rejects a workspaceId that names a different workspace', async () => { - await expect( - setEnvironmentVariablesServerTool.execute( - { workspaceId: 'ws-other', variables: [{ name: 'API_KEY', value: 'secret' }] }, - { userId: 'user-1', workspaceId: 'ws-1' } - ) - ).rejects.toThrow('Workspace ID does not match the Copilot execution workspace') - - expect(upsertWorkspaceEnvVarsMock).not.toHaveBeenCalled() - }) - - it('resolves the workspace from a workflow in the execution workspace', async () => { - ensureWorkflowAccessMock.mockResolvedValue({ workflow: { id: 'wf-1', workspaceId: 'ws-1' } }) - - await setEnvironmentVariablesServerTool.execute( - { workflowId: 'wf-1', variables: [{ name: 'API_KEY', value: 'secret' }] }, - { userId: 'user-1', workspaceId: 'ws-1' } - ) - - expect(ensureWorkflowAccessMock).toHaveBeenCalledWith('wf-1', 'user-1', 'write') - expect(upsertWorkspaceEnvVarsMock).toHaveBeenCalledWith('ws-1', { API_KEY: 'secret' }, 'user-1') - }) - - it('rejects a workflowId whose workspace differs from the execution workspace', async () => { - await expect( - setEnvironmentVariablesServerTool.execute( - { workflowId: 'wf-1', variables: [{ name: 'API_KEY', value: 'secret' }] }, - { userId: 'user-1', workspaceId: 'ws-1' } - ) - ).rejects.toThrow('Workspace ID does not match the Copilot execution workspace') - - expect(upsertWorkspaceEnvVarsMock).not.toHaveBeenCalled() - }) - - it('describes a workspace secret through the credential update handler, never rewriting its value', async () => { - await setEnvironmentVariablesServerTool.execute( - { - variables: [ - { name: 'API_KEY', value: 'secret', description: ' Stripe live key ' }, - { name: 'OTHER_KEY', value: 'other' }, - ], - }, - { userId: 'user-1', workspaceId: 'ws-1' } - ) - - expect(performUpdateCredentialMock).toHaveBeenCalledTimes(1) - expect(performUpdateCredentialMock).toHaveBeenCalledWith({ - credentialId: 'cred-api', - userId: 'user-1', - description: 'Stripe live key', - allowedTypes: ['env_workspace'], - }) - // The access-checked value write runs first: it authorizes the caller and - // mints the credential row a new key's description hangs on. - expect(upsertWorkspaceEnvVarsMock.mock.invocationCallOrder[0]).toBeLessThan( - performUpdateCredentialMock.mock.invocationCallOrder[0] - ) - }) - - it('forwards only the describe fields — never the unredacted flag — through the legacy path', async () => { - await setEnvironmentVariablesServerTool.execute( - { variables: [{ name: 'API_KEY', value: 'secret', description: 'Stripe live key' }] }, - { userId: 'user-1', workspaceId: 'ws-1' } - ) - - // This tool must not be a path for Sim to flip per-secret redaction: the - // call carries exactly the describe surface and no unredacted key at all. - const call = performUpdateCredentialMock.mock.calls[0][0] as Record - expect(Object.keys(call).sort()).toEqual([ - 'allowedTypes', - 'credentialId', - 'description', - 'userId', - ]) - expect(call).not.toHaveProperty('unredacted') - }) - - it('describes a secret that already exists without touching its value', async () => { - const result = await setEnvironmentVariablesServerTool.execute( - { variables: [{ name: 'API_KEY', description: 'Stripe live key' }] }, - { userId: 'user-1', workspaceId: 'ws-1' } - ) - - // Nothing is written to the secret itself: coercing the absent value to '' - // would blank the very secret the model is annotating. - expect(upsertWorkspaceEnvVarsMock).toHaveBeenCalledWith('ws-1', {}, 'user-1') - expect(performUpdateCredentialMock).toHaveBeenCalledWith( - expect.objectContaining({ credentialId: 'cred-api', description: 'Stripe live key' }) - ) - expect(result.describedVariables).toEqual(['API_KEY']) - }) - - it('keeps a stored value reported when its description fails', async () => { - performUpdateCredentialMock.mockResolvedValue({ success: false, error: 'Forbidden' }) - - const result = await setEnvironmentVariablesServerTool.execute( - { variables: [{ name: 'API_KEY', value: 'secret', description: 'Stripe live key' }] }, - { userId: 'user-1', workspaceId: 'ws-1' } - ) - - expect(result.workspaceUpdatedVariables).toEqual(['API_KEY']) - expect(result.describedVariables).toEqual([]) - expect(result.message).toContain('API_KEY: Forbidden') - }) - - it('fails a describe-only call that saved nothing', async () => { - performUpdateCredentialMock.mockResolvedValue({ success: false, error: 'Forbidden' }) - upsertWorkspaceEnvVarsMock.mockResolvedValue([]) - - await expect( - setEnvironmentVariablesServerTool.execute( - { variables: [{ name: 'API_KEY', description: 'Stripe live key' }] }, - { userId: 'user-1', workspaceId: 'ws-1' } - ) - ).rejects.toThrow('Could not describe: API_KEY: Forbidden') - }) - - it('clears a description sent blank and leaves an omitted one alone', async () => { - await setEnvironmentVariablesServerTool.execute( - { - variables: [ - { name: 'API_KEY', value: 'secret', description: ' ' }, - { name: 'OTHER_KEY', value: 'other' }, - ], - }, - { userId: 'user-1', workspaceId: 'ws-1' } - ) - - expect(performUpdateCredentialMock).toHaveBeenCalledTimes(1) - expect(performUpdateCredentialMock).toHaveBeenCalledWith( - expect.objectContaining({ credentialId: 'cred-api', description: null }) - ) - }) - - it('rejects a description on a personal secret', async () => { - await expect( - setEnvironmentVariablesServerTool.execute( - { - scope: 'personal', - variables: [{ name: 'API_KEY', value: 'secret', description: 'my key' }], - }, - { userId: 'user-1', workspaceId: 'ws-1' } - ) - ).rejects.toThrow('description is only supported for a workspace secret') - - expect(upsertPersonalEnvVarsMock).not.toHaveBeenCalled() - }) - - it('rejects a description longer than the secret detail form allows', async () => { - await expect( - setEnvironmentVariablesServerTool.execute( - { variables: [{ name: 'API_KEY', value: 'secret', description: 'a'.repeat(501) }] }, - { userId: 'user-1', workspaceId: 'ws-1' } - ) - ).rejects.toThrow('description for API_KEY must be at most 500 characters') - - expect(upsertWorkspaceEnvVarsMock).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts deleted file mode 100644 index 1bb017bf297..00000000000 --- a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { createLogger } from '@sim/logger' -import { z } from 'zod' -import { SetEnvironmentVariables } from '@/lib/copilot/generated/tool-catalog-v1' -import { ensureWorkflowAccess, ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' -import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { performUpdateCredential } from '@/lib/credentials/orchestration' -import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' -import { upsertPersonalEnvVars, upsertWorkspaceEnvVars } from '@/lib/environment/utils' - -type EnvironmentVariableInputValue = string | number | boolean | null | undefined - -interface EnvironmentVariableInput { - name: string - value: EnvironmentVariableInputValue - description?: string | null -} - -/** Matches the secret detail form and `PUT /api/v2/secrets`. */ -const DESCRIPTION_MAX_LENGTH = 500 - -interface SetEnvironmentVariablesParams { - variables: Record | EnvironmentVariableInput[] - scope?: 'personal' | 'workspace' - workflowId?: string - workspaceId?: string -} - -interface SetEnvironmentVariablesResult { - message: string - scope: 'personal' | 'workspace' - workspaceId?: string - variableCount: number - variableNames: string[] - addedVariables: string[] - updatedVariables: string[] - workspaceUpdatedVariables: string[] - describedVariables: string[] -} - -const EnvVarSchema = z.object({ variables: z.record(z.string(), z.string()) }) - -/** A row that only annotates a secret that already exists — it sends no value. */ -function isDescriptionOnly(item: EnvironmentVariableInput): boolean { - return ( - (item.value === undefined || item.value === null) && - typeof item.description === 'string' && - item.description.trim().length > 0 - ) -} - -/** - * Collects the descriptions the model actually sent. A variable that omits the - * field is absent from the result, so an existing description survives a value - * rotation; a blank one clears it. The object form of `variables` carries none. - */ -function normalizeDescriptions( - input: Record | EnvironmentVariableInput[] -): Record { - if (!Array.isArray(input)) return {} - - const descriptions: Record = {} - for (const item of input) { - if (!item || typeof item.name !== 'string' || item.description === undefined) continue - const description = item.description?.trim() ?? '' - if (description.length > DESCRIPTION_MAX_LENGTH) { - throw new OrchestrationError( - 'validation', - `description for ${item.name} must be at most ${DESCRIPTION_MAX_LENGTH} characters` - ) - } - descriptions[item.name] = description === '' ? null : description - } - return descriptions -} - -/** - * Values to write. An array item that carries a description but no value is a - * description-only edit and is deliberately absent here: coercing its missing - * value to `''` would blank the very secret the model is trying to annotate. - */ -function normalizeVariables( - input: Record | EnvironmentVariableInput[] -): Record { - if (Array.isArray(input)) { - return input.reduce( - (acc, item) => { - if (item && typeof item.name === 'string' && !isDescriptionOnly(item)) { - acc[item.name] = String(item.value ?? '') - } - return acc - }, - {} as Record - ) - } - return Object.fromEntries( - Object.entries(input || {}).map(([k, v]) => [k, String(v ?? '')]) - ) as Record -} - -/** - * Writes descriptions onto secrets, never their values. Resolves each key to its - * credential row and hands it to `performUpdateCredential` — the same handler the - * secrets settings page calls — so credential-admin access, the `env_personal` - * refusal, and the audit record are all decided in one place. - * - * Only the `description` column is touched. Re-sending the value to attach a note - * would clobber a rotation that landed between the two writes, and the note is - * never worth losing someone else's secret over. - */ -async function describeSecrets(params: { - workspaceId: string - userId: string - descriptions: Record -}): Promise<{ described: string[]; failures: string[] }> { - const names = Object.keys(params.descriptions) - if (names.length === 0) return { described: [], failures: [] } - - const { data: credentials } = await listVisibleWorkspaceCredentials({ - workspaceId: params.workspaceId, - userId: params.userId, - workspaceAccess: { canAdmin: false }, - types: ['env_workspace'], - }) - const idByEnvKey = new Map( - credentials.flatMap((row) => (row.envKey ? [[row.envKey, row.id] as const] : [])) - ) - - const described: string[] = [] - const failures: string[] = [] - for (const name of names) { - const credentialId = idByEnvKey.get(name) - if (!credentialId) { - failures.push(`no workspace secret named ${name}`) - continue - } - const result = await performUpdateCredential({ - credentialId, - userId: params.userId, - description: params.descriptions[name], - allowedTypes: ['env_workspace'], - }) - if (result.success) { - described.push(name) - } else { - failures.push(`${name}: ${result.error ?? 'could not be described'}`) - } - } - return { described, failures } -} - -/** - * Workspace secrets always land in the chat's delegated workspace. Model-supplied - * `workspaceId`/`workflowId` may only re-assert that workspace — never select a - * different one the acting user happens to access, and never fall back to a - * default workspace when the scope is missing. - */ -async function resolveWorkspaceId( - params: SetEnvironmentVariablesParams, - context: ServerToolContext | undefined, - userId: string -): Promise { - if (params.workflowId) { - const { workflow } = await ensureWorkflowAccess(params.workflowId, userId, 'write') - if (!workflow.workspaceId) { - throw new OrchestrationError( - 'validation', - `Workflow ${params.workflowId} is not associated with a workspace` - ) - } - return requireCopilotWorkspace(context, workflow.workspaceId) - } - - const workspaceId = requireCopilotWorkspace(context, params.workspaceId) - await ensureWorkspaceAccess(workspaceId, userId, 'write') - return workspaceId -} - -export const setEnvironmentVariablesServerTool: BaseServerTool< - SetEnvironmentVariablesParams, - SetEnvironmentVariablesResult -> = { - name: SetEnvironmentVariables.id, - async execute( - params: SetEnvironmentVariablesParams, - context?: ServerToolContext - ): Promise { - const logger = createLogger('SetEnvironmentVariablesServerTool') - - if (!context?.userId) { - logger.error( - 'Unauthorized attempt to set environment variables - no authenticated user context' - ) - throw new Error('Authentication required') - } - - const authenticatedUserId = context.userId - const { variables } = params || ({} as SetEnvironmentVariablesParams) - const scope = params.scope === 'personal' ? 'personal' : 'workspace' - - const normalized = normalizeVariables(variables || {}) - const descriptions = normalizeDescriptions(variables || {}) - // Rejected rather than dropped, matching `PUT /api/v2/secrets` and the domain - // layer: a personal secret's value is user-global, but its credential rows are - // per-workspace mirrors, so there is no single row to hold its description — - // one written here would exist in this workspace alone. - if (scope === 'personal' && Object.keys(descriptions).length > 0) { - throw new OrchestrationError( - 'validation', - 'description is only supported for a workspace secret' - ) - } - const { variables: validatedVariables } = EnvVarSchema.parse({ variables: normalized }) - const variableNames = Object.keys(validatedVariables) - const added: string[] = [] - const updated: string[] = [] - let workspaceUpdated: string[] = [] - let described: string[] = [] - let descriptionFailures: string[] = [] - - let resolvedWorkspaceId: string | undefined - if (scope === 'workspace') { - resolvedWorkspaceId = await resolveWorkspaceId(params, context, authenticatedUserId) - workspaceUpdated = await upsertWorkspaceEnvVars( - resolvedWorkspaceId, - validatedVariables, - authenticatedUserId - ) - // Runs after the value write, which is what mints the credential row a - // brand-new key's description hangs on. - const outcome = await describeSecrets({ - workspaceId: resolvedWorkspaceId, - userId: authenticatedUserId, - descriptions, - }) - described = outcome.described - descriptionFailures = outcome.failures - } else { - const result = await upsertPersonalEnvVars(authenticatedUserId, validatedVariables) - added.push(...result.added) - updated.push(...result.updated) - } - - const totalProcessed = added.length + updated.length + workspaceUpdated.length - - logger.info('Saved environment variables', { - userId: authenticatedUserId, - scope, - addedCount: added.length, - updatedCount: updated.length, - workspaceUpdatedCount: workspaceUpdated.length, - workspaceId: resolvedWorkspaceId, - }) - - // A failed description never fails a stored value — but a describe-only call - // has nothing else to report, so its failure is the result. - if (descriptionFailures.length > 0 && workspaceUpdated.length === 0) { - throw new OrchestrationError( - 'conflict', - `Could not describe: ${descriptionFailures.join('; ')}` - ) - } - - const parts: string[] = [] - if (added.length > 0) parts.push(`${added.length} personal secret(s) added`) - if (updated.length > 0) parts.push(`${updated.length} personal secret(s) updated`) - if (workspaceUpdated.length > 0) - parts.push(`${workspaceUpdated.length} workspace secret(s) updated`) - if (described.length > 0) parts.push(`${described.length} description(s) saved`) - if (descriptionFailures.length > 0) - parts.push(`descriptions not saved (${descriptionFailures.join('; ')})`) - - return { - message: `Successfully processed ${totalProcessed} secret(s): ${parts.join(', ')}`, - scope, - workspaceId: resolvedWorkspaceId, - variableCount: variableNames.length, - variableNames, - addedVariables: added, - updatedVariables: updated, - workspaceUpdatedVariables: workspaceUpdated, - describedVariables: described, - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts deleted file mode 100644 index f7016538bdc..00000000000 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { createLogger } from '@sim/logger' -import { executeCopilotWorkflowUseCase } from '@/lib/copilot/application/execute-workflow-use-case' -import { EditWorkflow } from '@/lib/copilot/generated/tool-catalog-v1' -import { - assertServerToolNotAborted, - type BaseServerTool, - type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { - type ApplyWorkflowOperationsResult, - applyWorkflowOperations, -} from '@/lib/workflows/application/apply-workflow-operations' -import { formatWorkflowLintMessage, hasWorkflowLintIssues } from '@/lib/workflows/editing/lint' -import type { EditWorkflowParams, SkippedItem } from '@/lib/workflows/editing/types' -import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' - -const logger = createLogger('EditWorkflowServerTool') - -/** - * Re-states a `not_found` from the use case in terms the model can act on (#6918). - * - * The message is deliberately Copilot's rather than the use case's: it names - * `workflows/**` + '/meta.json', a path that exists only in the copilot VFS, so an - * HTTP caller hitting `POST /api/v2/workflows/{workflowId}/operations` would be - * told to look somewhere it cannot reach. Every other classification is passed - * through untouched. - */ -function enrichWorkflowNotFound(error: unknown, workflowId: string): unknown { - if (error instanceof OrchestrationError && error.code === 'not_found') { - return new OrchestrationError( - 'not_found', - `Workflow not found: ${workflowId}. Pass the workflow's canonical id (copy it from ` + - `workflows/**` + - `/meta.json or the tool result that created it) — a workflow name or @-mention is not an id.` - ) - } - return error -} - -function mapSkippedItem(item: SkippedItem) { - return { - type: item.type, - operationType: item.operationType, - blockId: item.blockId, - reason: item.reason, - ...(item.details && { details: item.details }), - } -} - -function parseCurrentUserWorkflow(currentUserWorkflow: string): Record { - try { - return JSON.parse(currentUserWorkflow) - } catch (error) { - logger.error('Failed to parse currentUserWorkflow', error) - throw new OrchestrationError('validation', 'Invalid currentUserWorkflow format') - } -} - -/** - * Copilot's surface over the shared `workflows.operations.apply` use case. - * - * Owns only what a surface owns: argument shaping, abort checkpoints, and the - * tool result the model reads. Authorization, the lock and plan gates, the edit - * engine, persistence, semantic audit, and the realtime notification all live in - * the application use case, which `POST /api/v2/workflows/{workflowId}/operations` - * enters through as well. - * - * `currentUserWorkflow` — the unsaved canvas the user is looking at — is passed - * through as `baseGraph`, which the use case honours only for a delegated - * principal. No other surface can supply it. - */ -export const editWorkflowServerTool: BaseServerTool = { - name: EditWorkflow.id, - async execute(params: EditWorkflowParams, context?: ServerToolContext): Promise { - const { operations, workflowId, currentUserWorkflow } = params - if (!Array.isArray(operations) || operations.length === 0) { - throw new OrchestrationError('validation', 'operations are required and must be an array') - } - if (!workflowId) throw new OrchestrationError('validation', 'workflowId is required') - - logger.info('Executing edit_workflow', { - operationCount: operations.length, - workflowId, - hasCurrentUserWorkflow: !!currentUserWorkflow, - chatId: context?.chatId, - }) - - assertServerToolNotAborted(context) - - const result: ApplyWorkflowOperationsResult = await executeCopilotWorkflowUseCase( - context, - applyWorkflowOperations, - { - workflowId, - operations, - ...(currentUserWorkflow - ? { baseGraph: parseCurrentUserWorkflow(currentUserWorkflow) } - : {}), - checkAborted: () => assertServerToolNotAborted(context), - } - ).catch((error: unknown) => { - throw enrichWorkflowNotFound(error, workflowId) - }) - - const inputErrors = - result.inputValidationErrors.length > 0 - ? result.inputValidationErrors.map( - (error) => `Block "${error.blockId}" (${error.blockType}): ${error.error}` - ) - : undefined - const skippedDetails = - result.skipped.length > 0 ? result.skipped.map(mapSkippedItem) : undefined - const deferredDetails = - result.deferred.length > 0 ? result.deferred.map(mapSkippedItem) : undefined - const sanitizationWarnings = result.warnings.length > 0 ? result.warnings : undefined - const workflowLintMessage = hasWorkflowLintIssues(result.lint) - ? formatWorkflowLintMessage(result.lint) - : undefined - - return { - success: true, - workflowId: result.workflowId, - workflowName: result.workflowName || 'Workflow', - /** - * Sanitized before it reaches the agent (#6904). The graph goes back into - * a model context, so non-serializable and oversized values have to be - * stripped; the application use case returns the graph it persisted, not - * a copilot-shaped one. - */ - workflowState: sanitizeForCopilot(result.graph), - workflowLint: result.lint, - ...(workflowLintMessage && { workflowLintMessage }), - ...(inputErrors && { - inputValidationErrors: inputErrors, - inputValidationMessage: `${inputErrors.length} input(s) were rejected due to validation errors. The workflow was still updated with valid inputs only. Errors: ${inputErrors.join('; ')}`, - }), - ...(skippedDetails && { - skippedItems: skippedDetails, - skippedItemsMessage: `${skippedDetails.length} operation(s) were skipped (not applied) and need attention. Each item includes a machine-readable "type" (e.g. block_not_found, block_locked, duplicate_block_name, invalid_block_type, invalid_source_handle, invalid_target_handle, invalid_edge_scope). Details: ${skippedDetails.map((item) => item.reason).join('; ')}`, - }), - ...(deferredDetails && { - deferredConnections: deferredDetails, - deferredMessage: `${deferredDetails.length} edge(s) were deferred because their target block does not exist yet. This is NOT a failure and does NOT need fixing: the engine wires these edges automatically once the target block exists (in this edit or a later one). Do not re-issue them. Only act on a deferred edge if its target id was a typo or hallucination that you do not intend to create. Details: ${deferredDetails.map((item) => item.reason).join('; ')}`, - }), - ...(sanitizationWarnings && { - sanitizationWarnings, - sanitizationMessage: `${sanitizationWarnings.length} field(s) were automatically sanitized: ${sanitizationWarnings.join('; ')}`, - }), - } - }, -} diff --git a/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts b/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts deleted file mode 100644 index 55e10d1596f..00000000000 --- a/apps/sim/lib/copilot/tools/server/workflow/query-logs.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -/** - * @vitest-environment node - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - listLogsMock, - statsLogsMock, - fetchLogDetailMock, - toOverviewMock, - toFullMock, - toTraceMock, - grepSpansMock, - executeLogUseCaseMock, - listLogsUseCase, - readLogDetailUseCase, -} = vi.hoisted(() => ({ - listLogsMock: vi.fn(), - statsLogsMock: vi.fn(), - fetchLogDetailMock: vi.fn(), - toOverviewMock: vi.fn(), - toFullMock: vi.fn(), - toTraceMock: vi.fn(), - grepSpansMock: vi.fn(), - executeLogUseCaseMock: vi.fn(), - listLogsUseCase: { kind: 'list' }, - readLogDetailUseCase: { kind: 'detail' }, -})) - -vi.mock('@/lib/copilot/application/execute-log-use-case', () => ({ - executeCopilotLogUseCase: executeLogUseCaseMock, -})) -vi.mock('@/lib/logs/application/list-logs', () => ({ listLogsUseCase })) -vi.mock('@/lib/logs/application/read-log-detail', () => ({ readLogDetailUseCase })) -vi.mock('@/lib/logs/stats-logs', () => ({ statsLogs: statsLogsMock })) -vi.mock('@/lib/logs/log-views', () => ({ - toOverview: toOverviewMock, - toFull: toFullMock, - toTrace: toTraceMock, - grepSpans: grepSpansMock, -})) -vi.mock('@/lib/execution/payloads/large-execution-value', () => ({ - collectLargeValueExecutionIds: vi.fn(() => []), - collectLargeValueKeys: vi.fn(() => []), -})) - -import type { ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { queryLogsServerTool } from './query-logs' - -const ctx: ServerToolContext = { - userId: 'user-1', - workspaceId: 'ws-1', - toolCallId: 'tool-call-1', - copilotToolExecution: true, -} - -type QueryLogsArgs = Parameters[0] - -/** Fully-typed list-view args with the schema's defaulted fields spelled out. */ -function listArgs(overrides: Partial>): QueryLogsArgs { - return { view: 'list', limit: 100, sortBy: 'date', sortOrder: 'desc', ...overrides } -} - -function detail(overrides: Record = {}) { - return { - executionId: 'exec-1', - workflowId: 'wf-1', - status: 'success', - trigger: 'manual', - cost: { total: 0.1 }, - executionData: { totalDuration: 1234, traceSpans: [{ id: 's1' }] }, - ...overrides, - } -} - -beforeEach(() => { - vi.clearAllMocks() - executeLogUseCaseMock.mockImplementation(async (_context, useCase, input) => { - if (useCase === listLogsUseCase) return listLogsMock(input) - const detail = await fetchLogDetailMock(input) - if (!detail) throw new Error('Not found') - return { detail } - }) -}) - -describe('queryLogsServerTool', () => { - it('list view delegates to listLogs and leads with total and cursor', async () => { - listLogsMock.mockResolvedValue({ data: [{ id: 'log-1' }], nextCursor: null, total: 42 }) - - const result = await queryLogsServerTool.execute( - { view: 'list', sortBy: 'date', sortOrder: 'desc', limit: 100 } as any, - ctx - ) - - expect(listLogsMock).toHaveBeenCalledTimes(1) - const [params] = listLogsMock.mock.calls[0] - expect(params.workspaceId).toBe('ws-1') - expect(params.includeTotal).toBe(true) - expect(params).not.toHaveProperty('view') - expect(params).not.toHaveProperty('title') - expect(result).toEqual({ total: 42, nextCursor: null, data: [{ id: 'log-1' }] }) - expect(Object.keys(result as object)).toEqual(['total', 'nextCursor', 'data']) - }) - - it('stats view delegates to statsLogs with the workspace scoped in', async () => { - statsLogsMock.mockResolvedValue({ totals: { executions: 7 } }) - - const result = await queryLogsServerTool.execute( - { view: 'stats', bucket: 'day', timezone: 'UTC', workflowIds: 'wf-1' } as any, - ctx - ) - - expect(statsLogsMock).toHaveBeenCalledTimes(1) - const [params, userId] = statsLogsMock.mock.calls[0] - expect(userId).toBe('user-1') - expect(params).toMatchObject({ workspaceId: 'ws-1', bucket: 'day', workflowIds: 'wf-1' }) - expect(result).toEqual({ totals: { executions: 7 } }) - }) - - it('defaults to the condensed trace digest when only an executionId is given', async () => { - fetchLogDetailMock.mockResolvedValue(detail()) - toTraceMock.mockReturnValue([{ blockId: 'blk-1', name: 'Agent', executions: 3 }]) - - const result: any = await queryLogsServerTool.execute({ executionId: 'exec-1' } as any, ctx) - - expect(toTraceMock).toHaveBeenCalledTimes(1) - expect(result.blocks).toEqual([{ blockId: 'blk-1', name: 'Agent', executions: 3 }]) - expect(toOverviewMock).not.toHaveBeenCalled() - expect(toFullMock).not.toHaveBeenCalled() - }) - - it('defaults to list when no executionId is given', async () => { - listLogsMock.mockResolvedValue({ data: [], nextCursor: null, total: 0 }) - - await queryLogsServerTool.execute({} as any, ctx) - - expect(listLogsMock).toHaveBeenCalledTimes(1) - }) - - it('passes blockIds and fields through to toFull', async () => { - fetchLogDetailMock.mockResolvedValue(detail()) - toFullMock.mockResolvedValue([{ id: 's1' }]) - - await queryLogsServerTool.execute( - { - view: 'full', - executionId: 'exec-1', - blockIds: ['blk-1', 'blk-2'], - fields: ['output.rows'], - } as any, - ctx - ) - - expect(toFullMock).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - { blockId: undefined, blockIds: ['blk-1', 'blk-2'], blockName: undefined }, - ['output.rows'] - ) - }) - - it('overview view returns the projected span tree', async () => { - fetchLogDetailMock.mockResolvedValue(detail()) - toOverviewMock.mockReturnValue([{ id: 's1', name: 'A' }]) - - const result: any = await queryLogsServerTool.execute( - { view: 'overview', executionId: 'exec-1' } as any, - ctx - ) - - expect(result.executionId).toBe('exec-1') - expect(result.durationMs).toBe(1234) - expect(result.spans).toEqual([{ id: 's1', name: 'A' }]) - expect(toFullMock).not.toHaveBeenCalled() - }) - - it('full view returns materialized spans', async () => { - fetchLogDetailMock.mockResolvedValue(detail()) - toFullMock.mockResolvedValue([{ id: 's1', input: { a: 1 } }]) - - const result: any = await queryLogsServerTool.execute( - { view: 'full', executionId: 'exec-1', blockId: 'blk-1' } as any, - ctx - ) - - expect(toFullMock).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - { - blockId: 'blk-1', - blockIds: undefined, - blockName: undefined, - }, - undefined - ) - expect(result.spans).toEqual([{ id: 's1', input: { a: 1 } }]) - expect(result.truncated).toBe(false) - }) - - it('full view falls back to overview when the result is too large', async () => { - fetchLogDetailMock.mockResolvedValue(detail()) - const huge = 'x'.repeat(600 * 1024) - toFullMock.mockResolvedValue([{ id: 's1', output: huge }]) - toOverviewMock.mockReturnValue([{ id: 's1', name: 'A' }]) - - const result: any = await queryLogsServerTool.execute( - { view: 'full', executionId: 'exec-1' } as any, - ctx - ) - - expect(result.truncated).toBe(true) - expect(result.note).toContain('too large') - expect(result.spans).toEqual([{ id: 's1', name: 'A' }]) - }) - - it('pattern runs grepSpans and returns matches', async () => { - fetchLogDetailMock.mockResolvedValue(detail()) - grepSpansMock.mockResolvedValue({ - matches: [{ spanId: 's1', name: 'A', field: 'output', snippet: '…timeout…' }], - truncated: false, - }) - - const result: any = await queryLogsServerTool.execute( - { view: 'full', executionId: 'exec-1', pattern: 'timeout' } as any, - ctx - ) - - expect(grepSpansMock).toHaveBeenCalledTimes(1) - expect(result.pattern).toBe('timeout') - expect(result.matches).toHaveLength(1) - expect(toFullMock).not.toHaveBeenCalled() - }) - - it('returns not-found for an unknown executionId', async () => { - fetchLogDetailMock.mockResolvedValue(null) - const result: any = await queryLogsServerTool.execute( - { view: 'overview', executionId: 'missing' } as any, - ctx - ) - expect(result.ok).toBe(false) - expect(result.error).toContain('missing') - }) - - it('accepts a workspaceId that re-asserts the execution workspace', async () => { - listLogsMock.mockResolvedValue({ data: [], nextCursor: null, total: 0 }) - - await queryLogsServerTool.execute(listArgs({ workspaceId: 'ws-1' }), ctx) - - expect(listLogsMock).toHaveBeenCalledTimes(1) - expect(listLogsMock.mock.calls[0][0].workspaceId).toBe('ws-1') - }) - - it('rejects a workspaceId that names a different workspace', async () => { - await expect( - queryLogsServerTool.execute(listArgs({ workspaceId: 'ws-other' }), ctx) - ).rejects.toThrow('Workspace ID does not match the Copilot execution workspace') - - expect(listLogsMock).not.toHaveBeenCalled() - }) - - it('fails closed when the context carries no workspace', async () => { - await expect( - queryLogsServerTool.execute(listArgs({ workspaceId: 'ws-1' }), { userId: 'user-1' }) - ).rejects.toThrow('Copilot execution workspace is required') - - expect(listLogsMock).not.toHaveBeenCalled() - }) - - it('throws when unauthenticated', async () => { - await expect( - queryLogsServerTool.execute({ view: 'overview', executionId: 'exec-1' } as any, {} as any) - ).rejects.toThrow('Unauthorized') - }) - - it('rejects overview/full without executionId via inputSchema', () => { - const schema = queryLogsServerTool.inputSchema! - expect(schema.safeParse({ view: 'overview', workspaceId: 'ws-1' }).success).toBe(false) - expect(schema.safeParse({ view: 'full', workspaceId: 'ws-1' }).success).toBe(false) - expect( - schema.safeParse({ view: 'overview', workspaceId: 'ws-1', executionId: 'e1' }).success - ).toBe(true) - }) -}) diff --git a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts deleted file mode 100644 index 2c5316c97a4..00000000000 --- a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { createLogger } from '@sim/logger' -import { z } from 'zod' -import { executeCopilotLogUseCase } from '@/lib/copilot/application/execute-log-use-case' -import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope' -import { - collectLargeValueExecutionIds, - collectLargeValueKeys, -} from '@/lib/execution/payloads/large-execution-value' -import { listLogsUseCase } from '@/lib/logs/application/list-logs' -import { readLogDetailUseCase } from '@/lib/logs/application/read-log-detail' -import type { ListLogsParams } from '@/lib/logs/list-logs' -import { grepSpans, type LogViewContext, toFull, toOverview, toTrace } from '@/lib/logs/log-views' -import { statsLogs } from '@/lib/logs/stats-logs' -import type { TraceSpan } from '@/lib/logs/types' - -const logger = createLogger('QueryLogsServerTool') - -/** - * Max serialized size for a `full` view result before falling back to the - * compact overview. Keeps a single tool result inline-able. - */ -const MAX_FULL_RESULT_BYTES = 512 * 1024 - -const comparisonOperator = z.enum(['=', '>', '<', '>=', '<=', '!=']) - -/** Display-only label rendered in the UI tool row; never used server-side. */ -const displayTitle = z.string().optional() - -const listArgsSchema = z.object({ - view: z.literal('list'), - title: displayTitle, - workspaceId: z.string().optional(), - level: z.string().optional(), - workflowIds: z.string().optional(), - folderIds: z.string().optional(), - triggers: z.string().optional(), - startDate: z.string().optional(), - endDate: z.string().optional(), - search: z.string().optional(), - workflowName: z.string().optional(), - folderName: z.string().optional(), - executionId: z.string().optional(), - costOperator: comparisonOperator.optional(), - costValue: z.coerce.number().optional(), - durationOperator: comparisonOperator.optional(), - durationValue: z.coerce.number().optional(), - cursor: z.string().optional(), - limit: z.coerce.number().int().min(1).max(200).optional().default(100), - sortBy: z.enum(['date', 'duration', 'cost', 'status']).optional().default('date'), - sortOrder: z.enum(['asc', 'desc']).optional().default('desc'), -}) - -const statsArgsSchema = z.object({ - view: z.literal('stats'), - title: displayTitle, - workspaceId: z.string().optional(), - level: z.string().optional(), - workflowIds: z.string().optional(), - folderIds: z.string().optional(), - triggers: z.string().optional(), - startDate: z.string().optional(), - endDate: z.string().optional(), - search: z.string().optional(), - workflowName: z.string().optional(), - folderName: z.string().optional(), - bucket: z.enum(['day', 'hour']).optional(), - timezone: z.string().optional(), -}) - -const traceArgsSchema = z.object({ - view: z.literal('trace'), - title: displayTitle, - workspaceId: z.string().optional(), - executionId: z.string(), -}) - -const overviewArgsSchema = z.object({ - view: z.literal('overview'), - title: displayTitle, - workspaceId: z.string().optional(), - executionId: z.string(), - pattern: z.string().optional(), -}) - -const fullArgsSchema = z.object({ - view: z.literal('full'), - title: displayTitle, - workspaceId: z.string().optional(), - executionId: z.string(), - blockId: z.string().optional(), - blockIds: z.array(z.string()).optional(), - blockName: z.string().optional(), - fields: z.array(z.string()).optional(), - pattern: z.string().optional(), -}) - -const queryLogsViewsSchema = z.discriminatedUnion('view', [ - listArgsSchema, - statsArgsSchema, - traceArgsSchema, - overviewArgsSchema, - fullArgsSchema, -]) - -/** - * `view` defaults to the compact disclosure level: `trace` when an - * `executionId` is supplied, `list` otherwise. - */ -const queryLogsArgsSchema = z.preprocess((value) => { - if (value && typeof value === 'object' && !Array.isArray(value)) { - const record = value as Record - if (record.view === undefined) { - return { ...record, view: record.executionId ? 'trace' : 'list' } - } - } - return value -}, queryLogsViewsSchema) - -type QueryLogsArgs = z.infer - -function buildLogViewContext( - detail: { - workflowId: string | null - executionId: string - executionData?: unknown - }, - workspaceId: string, - userId: string -): LogViewContext { - return { - workspaceId, - workflowId: detail.workflowId ?? undefined, - executionId: detail.executionId, - userId, - largeValueExecutionIds: collectLargeValueExecutionIds(detail.executionData), - largeValueKeys: collectLargeValueKeys(detail.executionData), - allowLargeValueWorkflowScope: true, - } -} - -/** - * Consolidated execution/log read tool. - * - * - `view: "list"` — paginated execution summaries with the full Logs-UI filter - * set (reuses `listLogs`); always carries `total` for the filtered set. - * - `view: "stats"` — server-side aggregation (counts by status, per workflow, - * optionally calendar-bucketed) under the same filters; answers quantitative - * questions in one call instead of a paginate-and-count walk. - * - `view: "trace"` — one execution's condensed per-block digest: names, - * statuses, execution counts (loop iterations collapse), block ids to drill - * into. - * - `view: "overview"` — a single execution's trace-span tree (timing + cost, - * no input/output). - * - `view: "full"` — a single execution's trace spans with materialized - * input/output, scoped via `blockIds` (from the trace digest) / `blockName`. - * - `pattern` (with `overview`/`full`) — grep that execution's trace spans, - * streaming large values chunk-by-chunk. - */ -export const queryLogsServerTool: BaseServerTool = { - name: QueryLogs.id, - inputSchema: queryLogsArgsSchema, - outputSchema: z.unknown(), - async execute(rawArgs: QueryLogsArgs, context?: ServerToolContext): Promise { - // Re-parse so the compact-view default applies even when a caller bypasses - // the router's schema validation; idempotent on already-parsed args. - const args = queryLogsArgsSchema.parse(rawArgs) as QueryLogsArgs - if (!context?.userId) { - throw new Error('Unauthorized access') - } - const userId = context.userId - const workspaceId = requireCopilotWorkspace(context, args.workspaceId) - - if (args.view === 'list') { - const { view: _view, title: _title, ...rest } = args - const params = { ...rest, workspaceId, includeTotal: true } as ListLogsParams - logger.info('query_logs list', { workspaceId, sortBy: params.sortBy }) - const { data, nextCursor, total } = await executeCopilotLogUseCase( - context, - listLogsUseCase, - params - ) - // Cursor and total lead the payload so a truncated render still shows them. - return { total, nextCursor, data } - } - - if (args.view === 'stats') { - const { view: _view, title: _title, ...rest } = args - logger.info('query_logs stats', { workspaceId, bucket: rest.bucket }) - return statsLogs({ ...rest, workspaceId }, userId) - } - - // overview / full / grep — single execution by id - let detail - try { - ;({ detail } = await executeCopilotLogUseCase(context, readLogDetailUseCase, { - workspaceId, - lookupColumn: 'executionId', - lookupValue: args.executionId, - })) - } catch (error) { - if (!(error instanceof Error && error.message === 'Not found')) throw error - return { ok: false, error: `Execution not found: ${args.executionId}` } - } - const detailExecutionId = detail.executionId - if (!detailExecutionId) { - return { ok: false, error: `Execution not found: ${args.executionId}` } - } - - const execData = detail.executionData as - | { traceSpans?: TraceSpan[]; totalDuration?: number | null } - | undefined - const traceSpans = (execData?.traceSpans ?? []) as TraceSpan[] - - if (args.view === 'trace') { - return { - executionId: detail.executionId, - workflowId: detail.workflowId, - status: detail.status, - trigger: detail.trigger, - durationMs: execData?.totalDuration ?? null, - blocks: toTrace(traceSpans), - } - } - - const viewCtx = buildLogViewContext( - { ...detail, executionId: detailExecutionId }, - workspaceId, - userId - ) - - if (args.pattern) { - logger.info('query_logs grep', { workspaceId, executionId: args.executionId }) - const { matches, truncated, patternNotice } = await grepSpans( - traceSpans, - args.pattern, - viewCtx - ) - return { - executionId: detail.executionId, - workflowId: detail.workflowId, - status: detail.status, - pattern: args.pattern, - ...(patternNotice ? { patternNotice } : {}), - matches, - truncated, - } - } - - if (args.view === 'overview') { - return { - executionId: detail.executionId, - workflowId: detail.workflowId, - status: detail.status, - trigger: detail.trigger, - durationMs: execData?.totalDuration ?? null, - cost: detail.cost ?? null, - spans: toOverview(traceSpans), - } - } - - // full - const spans = await toFull( - traceSpans, - viewCtx, - { - blockId: args.blockId, - blockIds: args.blockIds, - blockName: args.blockName, - }, - args.fields - ) - const result = { - executionId: detail.executionId, - workflowId: detail.workflowId, - status: detail.status, - trigger: detail.trigger, - cost: detail.cost ?? null, - spans, - truncated: false, - } - - if (JSON.stringify(result).length > MAX_FULL_RESULT_BYTES) { - return { - executionId: detail.executionId, - workflowId: detail.workflowId, - status: detail.status, - truncated: true, - note: 'Full result too large; returning the compact overview. Scope with blockIds/blockName (ids from view "trace"), or use pattern to grep.', - spans: toOverview(traceSpans), - } - } - - return result - }, -} diff --git a/apps/sim/lib/copilot/vfs/custom-block-schema.test.ts b/apps/sim/lib/copilot/vfs/custom-block-schema.test.ts deleted file mode 100644 index 84076c9bc37..00000000000 --- a/apps/sim/lib/copilot/vfs/custom-block-schema.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { serializeBlockSchema } from '@/lib/copilot/vfs/serializers' -import { buildCustomBlockConfig } from '@/blocks/custom/build-config' -import type { BlockIcon } from '@/blocks/types' - -const icon: BlockIcon = () => null as never - -/** - * The agent must see a custom block as a self-contained block — never as a - * `workflow_executor` needing a workflowId/inputMapping (the plumbing is baked). - */ -describe('serializeBlockSchema for custom blocks', () => { - const config = buildCustomBlockConfig( - { - type: 'custom_block_abc', - name: 'Invoice Parser', - description: 'Parses invoices', - workflowId: 'wf-1', - exposedOutputs: [{ blockId: 'b1', path: 'content', name: 'summary' }], - }, - [ - { name: 'file', type: 'string' }, - { name: 'locale', type: 'string' }, - ], - { icon } - ) - - it('hides workflow_executor and the baked workflowId/inputMapping', () => { - const schema = JSON.parse(serializeBlockSchema(config)) - expect(schema.tools).toEqual([]) - expect(schema.inputs ?? {}).not.toHaveProperty('workflowId') - expect(schema.inputs ?? {}).not.toHaveProperty('inputMapping') - const subBlockIds = (schema.subBlocks ?? []).map((s: { id: string }) => s.id) - expect(subBlockIds).not.toContain('workflowId') - expect(subBlockIds).not.toContain('inputMapping') - }) - - it('exposes the input fields and curated outputs', () => { - const schema = JSON.parse(serializeBlockSchema(config)) - const subBlockIds = (schema.subBlocks ?? []).map((s: { id: string }) => s.id) - expect(subBlockIds).toEqual(expect.arrayContaining(['file', 'locale'])) - expect(Object.keys(schema.outputs)).toEqual(expect.arrayContaining(['summary', 'success'])) - expect(schema.outputs).not.toHaveProperty('childWorkflowId') - }) -}) diff --git a/apps/sim/lib/copilot/vfs/file-reader.test.ts b/apps/sim/lib/copilot/vfs/file-reader.test.ts deleted file mode 100644 index 8d48df797bb..00000000000 --- a/apps/sim/lib/copilot/vfs/file-reader.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * @vitest-environment node - */ - -import { randomFillSync } from 'node:crypto' -import { crc32 } from 'node:zlib' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { fetchWorkspaceFileBuffer, mockParseBuffer } = vi.hoisted(() => ({ - fetchWorkspaceFileBuffer: vi.fn(), - mockParseBuffer: vi.fn(), -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - fetchWorkspaceFileBuffer, -})) -vi.mock('@/lib/file-parsers', () => ({ - parseBuffer: mockParseBuffer, -})) - -import { - MAX_IMAGE_READ_BYTES, - MAX_IMAGE_SOURCE_BYTES, - MAX_PARSEABLE_READ_BYTES, - MAX_TEXT_READ_BYTES, - readFileRecord, -} from '@/lib/copilot/vfs/file-reader' -import { readPlaceholder } from '@/lib/copilot/vfs/read-placeholders' -import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import { MAX_TRANSCODE_INPUT_BYTES } from '@/lib/uploads/server/heic' - -async function makeNoisePng(width: number, height: number): Promise { - const sharp = (await import('sharp')).default - const raw = Buffer.alloc(width * height * 3) - randomFillSync(raw) - return sharp(raw, { raw: { width, height, channels: 3 } }) - .png() - .toBuffer() -} - -/** - * A decompression bomb: a few hundred bytes on the wire declaring a raster far too - * large to decode. Built by rewriting the IHDR dimensions of a real PNG rather than - * by rendering one, because rendering the raster is the very cost under test. - */ -async function makeBombPng(width: number, height: number): Promise { - const sharp = (await import('sharp')).default - const png = await sharp({ create: { width: 1, height: 1, channels: 3, background: '#fff' } }) - .png() - .toBuffer() - png.writeUInt32BE(width, 16) - png.writeUInt32BE(height, 20) - // IHDR's CRC covers the chunk type and data — bytes 12..29 of a PNG. - png.writeUInt32BE(crc32(png.subarray(12, 29)), 29) - return png -} - -function imageRecord(name: string, size: number, type = 'image/png') { - return { - id: 'wf_img', - workspaceId: 'ws_1', - name, - key: `uploads/${name}`, - path: `/api/files/serve/uploads%2F${name}?context=mothership`, - size, - type, - uploadedBy: 'user_1', - uploadedAt: new Date(), - deletedAt: null, - storageContext: 'mothership' as const, - } -} - -const SHARP_TEST_TIMEOUT_MS = 30_000 - -describe('readFileRecord', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it( - 'rejects a decompression bomb without decoding its raster', - async () => { - // 9e8 pixels — ~3.6GB once decoded as RGBA. - const bomb = await makeBombPng(30_000, 30_000) - expect(bomb.length).toBeLessThan(MAX_IMAGE_READ_BYTES) - - fetchWorkspaceFileBuffer.mockResolvedValue(bomb) - - // Recorded size deliberately disagrees with the real bytes: it is client-declared, - // so the placeholder must report what was actually fetched. - const result = await readFileRecord(imageRecord('bomb.png', 999_999)) - - expect(result?.attachment).toBeUndefined() - expect(result?.content).toContain('It is too large to decode safely.') - // The byte count must survive formatting too — a sub-1KB bomb formatted without - // `includeBytes` collapses to "0 Bytes" next to the real reason. - expect(result?.content).toContain(`(${bomb.length} Bytes)`) - }, - SHARP_TEST_TIMEOUT_MS - ) - - it.each([ - ['48MP iPhone', 8064, 6048], - ['61MP full-frame', 9504, 6336], - ['102MP medium format', 11648, 8736], - ])('does not refuse a %s frame on pixel count', async (_camera, width, height) => { - // Guards the ceiling from being tightened below real hardware. These reach the - // resize ladder and fail there on the stub's truncated pixel data — what matters - // is that they are not turned away by the pixel budget first. - fetchWorkspaceFileBuffer.mockResolvedValue(await makeBombPng(width, height)) - - const result = await readFileRecord(imageRecord('photo.png', 4_000_000)) - - expect(result?.content).not.toContain('It is too large to decode safely.') - }) - - it('reports the too-large placeholder when an understated record.size hides an oversized object', async () => { - fetchWorkspaceFileBuffer.mockRejectedValue( - new PayloadSizeLimitError({ - label: 'workspace file', - maxBytes: MAX_IMAGE_SOURCE_BYTES, - observedBytes: MAX_IMAGE_SOURCE_BYTES + 5_000, - }) - ) - - const result = await readFileRecord(imageRecord('understated.png', 1024)) - - expect(result?.attachment).toBeUndefined() - expect(result?.content).toContain('Image too large to read inline') - // The observed size, not the understated 1024 the cap exists to distrust. - expect(result?.content).toContain(`${MAX_IMAGE_SOURCE_BYTES + 5_000} bytes`) - // And the cap was actually handed to the download — the placeholder alone would - // still appear if the argument were dropped, since the mock rejects regardless. - expect(fetchWorkspaceFileBuffer).toHaveBeenCalledWith(expect.anything(), { - maxBytes: MAX_IMAGE_SOURCE_BYTES, - }) - }) - - it.each([ - ['text', 'notes.txt', 'text/plain', MAX_TEXT_READ_BYTES, 'File too large to display inline'], - [ - 'document', - 'report.pdf', - 'application/pdf', - MAX_PARSEABLE_READ_BYTES, - 'Document too large to parse inline', - ], - ])( - 'caps the %s download and reports the observed size when it breaches', - async (_kind, name, type, cap, expected) => { - fetchWorkspaceFileBuffer.mockRejectedValue( - new PayloadSizeLimitError({ - label: 'workspace file', - maxBytes: cap, - observedBytes: cap + 7_000, - }) - ) - - const result = await readFileRecord(imageRecord(name, 1024, type)) - - expect(result?.content).toContain(expected) - expect(result?.content).toContain(`${cap + 7_000} bytes`) - expect(fetchWorkspaceFileBuffer).toHaveBeenCalledWith(expect.anything(), { maxBytes: cap }) - } - ) - - it('reports an oversized HEIF as a size refusal, not as a corrupt file', async () => { - // `ftyp`+`heic` brand, past the WebAssembly transcoder's own tighter ceiling. - const heif = Buffer.alloc(MAX_TRANSCODE_INPUT_BYTES + 1) - heif.write('ftypheic', 4, 'ascii') - fetchWorkspaceFileBuffer.mockResolvedValue(heif) - - const result = await readFileRecord(imageRecord('photo.heic', heif.length, 'image/heic')) - - expect(result?.attachment).toBeUndefined() - expect(result?.content).toContain('It is too large to decode safely.') - expect(result?.content).not.toContain('It could not be decoded.') - }) - - it('rejects an oversized image on its stored size before fetching it', async () => { - const result = await readFileRecord(imageRecord('huge.png', MAX_IMAGE_SOURCE_BYTES + 1)) - - expect(fetchWorkspaceFileBuffer).not.toHaveBeenCalled() - expect(result?.attachment).toBeUndefined() - expect(result?.content).toContain('Image too large to read inline') - }) - - it( - 'downscales oversized images into attachments that fit the read limit', - async () => { - const largePng = await makeNoisePng(1800, 1800) - expect(largePng.length).toBeGreaterThan(MAX_IMAGE_READ_BYTES) - - fetchWorkspaceFileBuffer.mockResolvedValue(largePng) - - const result = await readFileRecord(imageRecord('chesspng.png', largePng.length)) - - expect(result?.attachment?.type).toBe('image') - expect(result?.content).toContain('resized for vision') - - const decoded = Buffer.from(result?.attachment?.source.data ?? '', 'base64') - expect(decoded.length).toBeLessThanOrEqual(MAX_IMAGE_READ_BYTES) - expect(result?.attachment?.source.media_type).toMatch(/^image\/(jpeg|webp|png)$/) - }, - SHARP_TEST_TIMEOUT_MS - ) -}) - -describe('readFileRecord parseable documents', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - function documentRecord(name: string, type: string, size: number) { - return { ...imageRecord(name, size, type), id: 'wf_doc' } - } - - it('returns the parsed text of a document', async () => { - fetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('bytes')) - mockParseBuffer.mockResolvedValue({ - content: 'Quarterly review\nSecond line', - metadata: { extractionMethod: 'word-extractor' }, - }) - - const result = await readFileRecord(documentRecord('review.doc', 'application/msword', 5)) - - expect(result).toEqual({ content: 'Quarterly review\nSecond line', totalLines: 2 }) - }) - - /** - * A parser that could only scrape bytes flags the result `degraded`; that must - * reach the model as the could-not-parse placeholder, never as file content. - */ - it('reports degraded parser output as could-not-parse instead of handing it to the model', async () => { - fetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('bytes')) - mockParseBuffer.mockResolvedValue({ - content: '[Content_Types].xml _rels/.rels theme/theme/themeManager.xml', - metadata: { degraded: true, warning: 'Basic text extraction used' }, - }) - - const result = await readFileRecord( - documentRecord('deck.pptx', 'application/vnd.ms-powerpoint', 5) - ) - - expect(result).toEqual( - readPlaceholder.couldNotParse('deck.pptx', 'application/vnd.ms-powerpoint', 5) - ) - expect(result?.content).not.toContain('[Content_Types].xml') - }) -}) diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts deleted file mode 100644 index ae00204fa8f..00000000000 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ /dev/null @@ -1,639 +0,0 @@ -import { type Span, trace } from '@opentelemetry/api' -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import type { SharpConstructor } from 'sharp' -import { - CopilotVfsOutcome, - CopilotVfsReadOutcome, - CopilotVfsReadPath, -} from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { recordFileRead } from '@/lib/copilot/request/metrics' -import { markSpanForError } from '@/lib/copilot/request/otel' -import { type PlaceholderKind, readPlaceholder } from '@/lib/copilot/vfs/read-placeholders' -import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { fetchWorkspaceFileBuffer } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { - isHeifContainer, - MAX_TRANSCODE_INPUT_BYTES, - transcodeHeicToJpeg, -} from '@/lib/uploads/server/heic' -import { MAX_WORKSPACE_FORMDATA_FILE_SIZE } from '@/lib/uploads/shared/types' -import { - formatFileSize, - isImageFileType, - MODEL_SUPPORTED_IMAGE_MIME_TYPES, - resolveEffectiveMimeType, -} from '@/lib/uploads/utils/file-utils' - -// Lazy tracer (same pattern as lib/copilot/request/otel.ts). -function getVfsTracer() { - return trace.getTracer('sim-copilot-vfs', '1.0.0') -} - -function recordSpanError(span: Span, err: unknown) { - markSpanForError(span, err) -} - -const logger = createLogger('FileReader') - -/** - * Text-read materialization cap — exported so callers can align their own byte-sniff budgets - * with what read() can actually load. This bounds what the server LOADS, not what the model - * receives inline: the read handler windows (offset/limit) and inline-size-gates the result, - * so a large file is paged rather than sent whole. 20MB keeps multi-MB logs/exports greppable - * and pageable while still refusing genuinely unbounded blobs. - */ -export const MAX_TEXT_READ_BYTES = 20 * 1024 * 1024 // 20 MB -/** Vision-attachment cap: what the prepared image must fit into after resizing. */ -export const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB -// Parseable-document byte cap. Large office/PDF files can still -// produce huge extracted text; reject up front to avoid wasting a -// download + parse only to blow past the tool-result budget. -export const MAX_PARSEABLE_READ_BYTES = 5 * 1024 * 1024 // 5 MB -/** - * Source-image byte ceiling, checked before the download and enforced by it. This - * route holds the whole file in worker memory, so it reuses the ceiling the FormData - * upload route set for that same failure mode rather than inventing a number. - * - * It does NOT cover everything a user can store: presigned and multipart uploads - * accept up to `MAX_WORKSPACE_FILE_SIZE` (gigabytes), so an image above this ceiling - * is stored fine and simply cannot be read inline. That is a deliberate trade — the - * alternative is buffering a multi-gigabyte file to answer one read — and it is a - * memory budget, not part of the decompression-bomb defence, which is the pixel - * budget below. A bomb is small; no byte cap would catch it. - */ -export const MAX_IMAGE_SOURCE_BYTES = MAX_WORKSPACE_FORMDATA_FILE_SIZE -/** - * Pixel ceiling on the decoded image, and the actual decompression-bomb defence: a - * few hundred KB of PNG can declare an arbitrarily large raster. - * - * This is sharp's own default rather than a number of our own — the vulnerability - * was disabling it with `limitInputPixels: false`, so restoring it is the fix, and - * any tighter value would be us inventing a ceiling the library did not ask for. A - * round 100MP looked tidy but lands mid-market: a Fuji GFX 100 frame is 11648x8736, - * or 101.7MP, and would have been refused. - * - * The cost it bounds is CPU, not memory. libvips decodes this pipeline sequentially, - * so peak RSS stays flat (tens of MB) whatever the header declares — measured here, - * 100MP..1024MP all sat under ~120MB. Time scales sublinearly: ~240ms at 100MP, - * ~400ms at 256MP, ~1.35s at 1024MP, once per resize rung. So the budget caps the - * worst case at roughly 400ms a rung, the `break` below caps a failing image at four - * rungs, and an unbounded declaration — which is what `false` allowed — is gone. - */ -const MAX_IMAGE_INPUT_PIXELS = 268_402_689 -const MAX_IMAGE_DIMENSION = 1568 -const IMAGE_RESIZE_DIMENSIONS = [1568, 1280, 1024, 768] -const IMAGE_QUALITY_STEPS = [85, 70, 55, 40] - -const TEXT_TYPES = new Set([ - 'text/plain', - 'text/csv', - 'text/markdown', - 'text/html', - 'text/xml', - 'text/x-pptxgenjs', - 'text/x-docxjs', - 'text/x-python-pdf', - 'text/x-python-xlsx', - 'application/json', - 'application/xml', - 'application/javascript', -]) - -const PARSEABLE_EXTENSIONS = new Set(['pdf', 'docx', 'doc', 'xlsx', 'xls', 'pptx']) - -export function isReadableFileType(contentType: string): boolean { - return TEXT_TYPES.has(contentType) || contentType.startsWith('text/') -} - -function getExtension(filename: string): string { - const dot = filename.lastIndexOf('.') - return dot >= 0 ? filename.slice(dot + 1).toLowerCase() : '' -} - -/** - * Download a record under an authoritative byte cap. `record.size` is client-declared, - * so a caller's own size check can pass while the real bytes do not — this is the - * check that actually holds. - * - * On a breach it reports the observed size when the error carries one, because the - * recorded size is exactly the figure this cap exists to distrust: quoting it back - * would print a tiny number beside a much larger limit. - */ -type CappedFetch = { buffer: Buffer } | { tooLarge: true; observedBytes?: number } - -async function fetchWithinLimit( - record: WorkspaceFileRecord, - maxBytes: number, - authorizedContent?: Buffer -): Promise { - if (authorizedContent !== undefined) { - return authorizedContent.length <= maxBytes - ? { buffer: authorizedContent } - : { tooLarge: true, observedBytes: authorizedContent.length } - } - try { - return { buffer: await fetchWorkspaceFileBuffer(record, { maxBytes }) } - } catch (err) { - if (!isPayloadSizeLimitError(err)) throw err - logger.warn('Workspace file exceeded its read cap', { - fileName: record.name, - recordedSize: record.size, - observedBytes: err.observedBytes, - maxBytes, - }) - return { tooLarge: true, observedBytes: err.observedBytes } - } -} - -function detectImageMime(buf: Buffer, claimed: string): string { - if (buf.length < 12) return claimed - if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg' - if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return 'image/png' - if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return 'image/gif' - if (buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) - return 'image/webp' - return claimed -} - -interface PreparedVisionImage { - buffer: Buffer - mediaType: string - resized: boolean -} - -/** Shown to the model verbatim, so each value names the one thing that failed. */ -const VisionImageRejection = { - Undecodable: 'It could not be decoded.', - TooLargeToDecode: 'It is too large to decode safely.', - TooLargeAfterResize: `It still exceeded the ${formatFileSize(MAX_IMAGE_READ_BYTES)} vision limit after resizing.`, -} - -type VisionImageResult = { ok: true; image: PreparedVisionImage } | { ok: false; reason: string } - -/** - * Prepare an image for vision models: detect media type, optionally - * resize/compress with sharp, and return the prepared buffer. - * - * Wrapped in a `copilot.vfs.prepare_image` span so the external trace - * shows exactly when an image read blocked the request on CPU-heavy - * encode attempts. Attributes record input dimensions, whether a resize - * was needed, how many encode attempts it took, and the final - * dimension/quality chosen. - */ -async function prepareImageForVision( - sourceBuffer: Buffer, - claimedType: string -): Promise { - return getVfsTracer().startActiveSpan( - TraceSpan.CopilotVfsPrepareImage, - { - attributes: { - [TraceAttr.CopilotVfsInputBytes]: sourceBuffer.length, - [TraceAttr.CopilotVfsInputMediaTypeClaimed]: claimedType, - }, - }, - async (span): Promise => { - try { - const detectedType = detectImageMime(sourceBuffer, claimedType) - span.setAttribute(TraceAttr.CopilotVfsInputMediaTypeDetected, detectedType) - - let sharpModule: SharpConstructor - try { - sharpModule = (await import('sharp')).default - } catch (err) { - logger.warn('Failed to load sharp for image preparation', { - mediaType: detectedType, - error: toError(err).message, - }) - span.setAttribute(TraceAttr.CopilotVfsSharpLoadFailed, true) - const fitsWithoutSharp = - MODEL_SUPPORTED_IMAGE_MIME_TYPES.has(detectedType) && - sourceBuffer.length <= MAX_IMAGE_READ_BYTES - span.setAttribute( - TraceAttr.CopilotVfsOutcome, - fitsWithoutSharp - ? CopilotVfsOutcome.PassthroughNoSharp - : CopilotVfsOutcome.RejectedNoSharp - ) - if (!fitsWithoutSharp) return { ok: false, reason: VisionImageRejection.Undecodable } - return { - ok: true, - image: { buffer: sourceBuffer, mediaType: detectedType, resized: false }, - } - } - - // Left unguarded deliberately: metadata() only parses the header, so it - // allocates nothing proportional to the declared dimensions, and enabling the - // guard here would route an oversized image into the passthrough branch below - // — which hands the bytes to the model instead of refusing them. - const readMetadata = (candidate: Buffer) => - sharpModule(candidate, { limitInputPixels: false }) - .metadata() - .catch((err: unknown) => { - logger.warn('Failed to read image metadata for VFS read', { - mediaType: detectedType, - error: toError(err).message, - }) - return null - }) - - // sharp first: its libvips reads everything we accept except HEVC-coded - // HEIF, and it is ~10x faster than the WASM decoder. Capability-based - // rather than brand-based, so AV1-coded `mif1` — which sharp handles - // natively — does not get sent down the slow path. - let buffer = sourceBuffer - let mediaType = detectedType - let metadata = await readMetadata(sourceBuffer) - - if (!metadata && isHeifContainer(sourceBuffer)) { - // The WebAssembly fallback is single-threaded and holds its own ceiling, - // well under the source cap above. Say so rather than letting the transcode - // decline and report the file as corrupt. - if (sourceBuffer.length > MAX_TRANSCODE_INPUT_BYTES) { - logger.warn('Rejected HEIF above the transcode ceiling', { - bytes: sourceBuffer.length, - ceiling: MAX_TRANSCODE_INPUT_BYTES, - }) - return { ok: false, reason: VisionImageRejection.TooLargeToDecode } - } - const transcoded = await transcodeHeicToJpeg(sourceBuffer) - if (transcoded) { - buffer = transcoded - mediaType = 'image/jpeg' - metadata = await readMetadata(transcoded) - } - } - - if (!metadata) { - span.setAttribute(TraceAttr.CopilotVfsMetadataFailed, true) - // Bytes the model cannot decode are worse than no image: it describes - // them as empty rather than reporting them as broken. - const passthroughViable = - MODEL_SUPPORTED_IMAGE_MIME_TYPES.has(mediaType) && buffer.length <= MAX_IMAGE_READ_BYTES - span.setAttribute( - TraceAttr.CopilotVfsOutcome, - passthroughViable - ? CopilotVfsOutcome.PassthroughNoMetadata - : CopilotVfsOutcome.RejectedNoMetadata - ) - if (!passthroughViable) return { ok: false, reason: VisionImageRejection.Undecodable } - return { ok: true, image: { buffer, mediaType, resized: false } } - } - - const width = metadata.width ?? 0 - const height = metadata.height ?? 0 - span.setAttributes({ - [TraceAttr.CopilotVfsInputWidth]: width, - [TraceAttr.CopilotVfsInputHeight]: height, - }) - - const pixels = width * height - if (pixels > MAX_IMAGE_INPUT_PIXELS) { - logger.warn('Rejected image above the decode pixel budget', { - mediaType, - width, - height, - pixels, - budget: MAX_IMAGE_INPUT_PIXELS, - bytes: buffer.length, - }) - // No `CopilotVfsOutcome` member covers a pre-decode refusal, and that - // vocabulary is generated from a contract this repo does not own — emitting - // an unlisted value would just be dropped downstream. The dimensions are on - // the span above and the reason is in the warning. - return { ok: false, reason: VisionImageRejection.TooLargeToDecode } - } - - // A format the model cannot decode has to be re-encoded even when it is - // already small enough — the ladder below emits JPEG or WebP, both of - // which it accepts. - const needsReencode = - !MODEL_SUPPORTED_IMAGE_MIME_TYPES.has(mediaType) || - buffer.length > MAX_IMAGE_READ_BYTES || - width > MAX_IMAGE_DIMENSION || - height > MAX_IMAGE_DIMENSION - if (!needsReencode) { - span.setAttributes({ - [TraceAttr.CopilotVfsResized]: false, - [TraceAttr.CopilotVfsOutcome]: CopilotVfsOutcome.PassthroughFitsBudget, - [TraceAttr.CopilotVfsOutputBytes]: buffer.length, - [TraceAttr.CopilotVfsOutputMediaType]: mediaType, - }) - return { ok: true, image: { buffer, mediaType, resized: false } } - } - - const hasAlpha = Boolean( - metadata.hasAlpha || - mediaType === 'image/png' || - mediaType === 'image/webp' || - mediaType === 'image/gif' - ) - span.setAttribute(TraceAttr.CopilotVfsHasAlpha, hasAlpha) - - let attempts = 0 - // Separates "cannot be decoded at all" from "decodes fine, never small enough". - let encodedAny = false - for (const dimension of IMAGE_RESIZE_DIMENSIONS) { - for (const quality of IMAGE_QUALITY_STEPS) { - attempts += 1 - try { - const pipeline = sharpModule(buffer, { limitInputPixels: MAX_IMAGE_INPUT_PIXELS }) - .rotate() - .resize({ - width: dimension, - height: dimension, - fit: 'inside', - withoutEnlargement: true, - }) - - const transformed = hasAlpha - ? { - buffer: await pipeline - .webp({ quality, alphaQuality: quality, effort: 4 }) - .toBuffer(), - mediaType: 'image/webp', - } - : { - buffer: await pipeline - .jpeg({ quality, mozjpeg: true, chromaSubsampling: '4:4:4' }) - .toBuffer(), - mediaType: 'image/jpeg', - } - - encodedAny = true - span.addEvent(TraceEvent.CopilotVfsResizeAttempt, { - [TraceAttr.CopilotVfsResizeDimension]: dimension, - [TraceAttr.CopilotVfsResizeQuality]: quality, - [TraceAttr.CopilotVfsResizeOutputBytes]: transformed.buffer.length, - [TraceAttr.CopilotVfsResizeFitsBudget]: - transformed.buffer.length <= MAX_IMAGE_READ_BYTES, - }) - - if (transformed.buffer.length <= MAX_IMAGE_READ_BYTES) { - logger.info('Resized image for VFS read', { - originalBytes: buffer.length, - outputBytes: transformed.buffer.length, - originalWidth: width || undefined, - originalHeight: height || undefined, - maxDimension: dimension, - quality, - originalMediaType: mediaType, - outputMediaType: transformed.mediaType, - }) - span.setAttributes({ - [TraceAttr.CopilotVfsResized]: true, - [TraceAttr.CopilotVfsResizeAttempts]: attempts, - [TraceAttr.CopilotVfsResizeChosenDimension]: dimension, - [TraceAttr.CopilotVfsResizeChosenQuality]: quality, - [TraceAttr.CopilotVfsOutputBytes]: transformed.buffer.length, - [TraceAttr.CopilotVfsOutputMediaType]: transformed.mediaType, - [TraceAttr.CopilotVfsOutcome]: CopilotVfsOutcome.Resized, - }) - return { - ok: true, - image: { - buffer: transformed.buffer, - mediaType: transformed.mediaType, - resized: true, - }, - } - } - } catch (err) { - // Next dimension, not next quality: every quality rung re-decodes the - // same source and only varies the encoder, so a failure here almost - // always repeats. Dropping a dimension is the one thing that can change - // the outcome (JPEG shrinks on load), and it bounds a bomb at 4 decodes - // instead of 16. A genuinely encoder-only failure would lose its lower - // quality rungs at that dimension — no such failure mode is known, and - // 4 attempts is the deliberate ceiling. - logger.warn('Failed image resize attempt for VFS read', { - mediaType, - dimension, - quality, - error: toError(err).message, - }) - span.addEvent(TraceEvent.CopilotVfsResizeAttemptFailed, { - [TraceAttr.CopilotVfsResizeDimension]: dimension, - [TraceAttr.CopilotVfsResizeQuality]: quality, - [TraceAttr.ErrorMessage]: toError(err).message.slice(0, 500), - }) - break - } - } - } - - span.setAttributes({ - [TraceAttr.CopilotVfsResized]: false, - [TraceAttr.CopilotVfsResizeAttempts]: attempts, - [TraceAttr.CopilotVfsOutcome]: CopilotVfsOutcome.RejectedTooLargeAfterResize, - }) - return { - ok: false, - reason: encodedAny - ? VisionImageRejection.TooLargeAfterResize - : VisionImageRejection.Undecodable, - } - } catch (err) { - recordSpanError(span, err) - throw err - } finally { - span.end() - } - } - ) -} - -export interface FileReadResult { - content: string - totalLines: number - /** Set when `content` stands in for the file rather than being it — see `readPlaceholder`. */ - placeholder?: PlaceholderKind - /** Set when a dynamic read resolved the file but failed to produce its requested view. */ - error?: string - attachment?: { - type: string - name?: string - source: { - type: 'base64' - media_type: string - data: string - } - } -} - -/** - * Read and return the content of a workspace file record. - * Handles images (base64 attachment), parseable documents (PDF, DOCX, etc.), - * binary files, and plain text with size guards. - * - * Wrapped in `copilot.vfs.read_file` so the parent mothership trace shows - * per-file read latency, the path taken (image / text / parseable / - * binary), and any size rejection. The `prepareImageForVision` span - * nests underneath for the image-resize path. - */ -export async function readFileRecord( - record: WorkspaceFileRecord, - /** Pre-authorized workspace bytes; omitted only for chat-upload records in the mothership store. */ - authorizedContent?: Buffer -): Promise { - const startedAt = Date.now() - const result = await getVfsTracer().startActiveSpan( - TraceSpan.CopilotVfsReadFile, - { - attributes: { - [TraceAttr.CopilotVfsFileName]: record.name, - [TraceAttr.CopilotVfsFileMediaType]: record.type, - [TraceAttr.CopilotVfsFileSizeBytes]: record.size, - [TraceAttr.CopilotVfsFileExtension]: getExtension(record.name), - }, - }, - async (span) => { - try { - // Resolve against the filename: a phone upload commonly stores as - // `application/octet-stream`, and matching the raw type would route a real - // image down the binary path where the model never sees it. - if (isImageFileType(resolveEffectiveMimeType(record.type, record.name))) { - span.setAttribute(TraceAttr.CopilotVfsReadPath, CopilotVfsReadPath.Image) - const imageTooLarge = (bytes: number) => { - span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge) - return readPlaceholder.imageTooLarge(record.name, bytes, MAX_IMAGE_SOURCE_BYTES) - } - // The recorded size only skips a doomed download; the cap on the download - // itself is what bounds the bytes actually read. - if (record.size > MAX_IMAGE_SOURCE_BYTES) return imageTooLarge(record.size) - const fetched = await fetchWithinLimit(record, MAX_IMAGE_SOURCE_BYTES, authorizedContent) - if ('tooLarge' in fetched) return imageTooLarge(fetched.observedBytes ?? record.size) - - const prepared = await prepareImageForVision(fetched.buffer, record.type) - if (!prepared.ok) { - span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge) - // The fetched buffer, not `record.size`: the bytes are in hand by now, so - // there is no reason to quote the client-declared figure back. - return readPlaceholder.imageUnavailable( - record.name, - fetched.buffer.length, - prepared.reason - ) - } - const { buffer, mediaType, resized } = prepared.image - const sizeKb = (buffer.length / 1024).toFixed(1) - const resizeNote = resized ? ', resized for vision' : '' - span.setAttributes({ - [TraceAttr.CopilotVfsReadOutcome]: CopilotVfsReadOutcome.ImagePrepared, - [TraceAttr.CopilotVfsReadOutputBytes]: buffer.length, - [TraceAttr.CopilotVfsReadOutputMediaType]: mediaType, - [TraceAttr.CopilotVfsReadImageResized]: resized, - }) - return { - content: `Image: ${record.name} (${sizeKb}KB, ${mediaType}${resizeNote})`, - totalLines: 1, - attachment: { - type: 'image', - name: record.name, - source: { - type: 'base64' as const, - media_type: mediaType, - data: buffer.toString('base64'), - }, - }, - } - } - - if (isReadableFileType(record.type)) { - span.setAttribute(TraceAttr.CopilotVfsReadPath, CopilotVfsReadPath.Text) - const textTooLarge = (bytes: number) => { - span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.TextTooLarge) - return readPlaceholder.fileTooLarge(record.name, bytes, MAX_TEXT_READ_BYTES) - } - if (record.size > MAX_TEXT_READ_BYTES) return textTooLarge(record.size) - - const fetched = await fetchWithinLimit(record, MAX_TEXT_READ_BYTES, authorizedContent) - if ('tooLarge' in fetched) return textTooLarge(fetched.observedBytes ?? record.size) - const buffer = fetched.buffer - const content = buffer.toString('utf-8') - const lines = content.split('\n').length - span.setAttributes({ - [TraceAttr.CopilotVfsReadOutcome]: CopilotVfsReadOutcome.TextRead, - [TraceAttr.CopilotVfsReadOutputBytes]: buffer.length, - [TraceAttr.CopilotVfsReadOutputLines]: lines, - }) - return { content, totalLines: lines } - } - - const ext = getExtension(record.name) - if (PARSEABLE_EXTENSIONS.has(ext)) { - span.setAttribute(TraceAttr.CopilotVfsReadPath, CopilotVfsReadPath.ParseableDocument) - const documentTooLarge = (bytes: number) => { - span.setAttribute( - TraceAttr.CopilotVfsReadOutcome, - CopilotVfsReadOutcome.DocumentTooLarge - ) - return readPlaceholder.documentTooLarge(record.name, bytes, MAX_PARSEABLE_READ_BYTES) - } - if (record.size > MAX_PARSEABLE_READ_BYTES) return documentTooLarge(record.size) - const fetched = await fetchWithinLimit( - record, - MAX_PARSEABLE_READ_BYTES, - authorizedContent - ) - if ('tooLarge' in fetched) { - return documentTooLarge(fetched.observedBytes ?? record.size) - } - try { - const { parseBuffer } = await import('@/lib/file-parsers') - const result = await parseBuffer(fetched.buffer, ext) - if (result.metadata?.degraded === true) { - /** Scraped ZIP internals or placeholder prose, not the document's text. */ - throw new Error(result.metadata.warning ?? 'Parser returned degraded output') - } - const content = result.content || '' - const lines = content.split('\n').length - span.setAttributes({ - [TraceAttr.CopilotVfsReadOutcome]: CopilotVfsReadOutcome.DocumentParsed, - [TraceAttr.CopilotVfsReadOutputBytes]: content.length, - [TraceAttr.CopilotVfsReadOutputLines]: lines, - }) - return { content, totalLines: lines } - } catch (parseErr) { - logger.warn('Failed to parse document', { - fileName: record.name, - ext, - error: toError(parseErr).message, - }) - span.addEvent(TraceEvent.CopilotVfsParseFailed, { - [TraceAttr.ErrorMessage]: toError(parseErr).message.slice(0, 500), - }) - span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ParseFailed) - return readPlaceholder.couldNotParse(record.name, record.type, record.size) - } - } - - span.setAttributes({ - [TraceAttr.CopilotVfsReadPath]: CopilotVfsReadPath.Binary, - [TraceAttr.CopilotVfsReadOutcome]: CopilotVfsReadOutcome.BinaryPlaceholder, - }) - return readPlaceholder.binaryFile(record.name, record.type, record.size) - } catch (err) { - logger.warn('Failed to read workspace file', { - fileName: record.name, - error: toError(err).message, - }) - recordSpanError(span, err) - span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ReadFailed) - return null - } finally { - span.end() - } - } - ) - // Durable read duration + size by coarse outcome (the fine-grained outcome — - // ImageTooLarge / ParseFailed / etc. — stays on the Tempo span). readFileRecord - // returns null on failure rather than throwing. - recordFileRead(result ? 'success' : 'read_failed', Date.now() - startedAt, record.size ?? 0) - return result -} diff --git a/apps/sim/lib/copilot/vfs/index.ts b/apps/sim/lib/copilot/vfs/index.ts deleted file mode 100644 index 973bba46fc4..00000000000 --- a/apps/sim/lib/copilot/vfs/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { getOrMaterializeVFS } from '@/lib/copilot/vfs/workspace-vfs' diff --git a/apps/sim/lib/copilot/vfs/serializers.test.ts b/apps/sim/lib/copilot/vfs/serializers.test.ts deleted file mode 100644 index 7aa4a9dc961..00000000000 --- a/apps/sim/lib/copilot/vfs/serializers.test.ts +++ /dev/null @@ -1,993 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { - MAX_SANDBOX_CLI_TOOLS, - SANDBOX_CLI_TOOLS, - SANDBOX_SELECTABLE_CLI_TOOL_IDS, -} from '@/lib/execution/remote-sandbox/cli-tools' -import type { BlockConfig } from '@/blocks/types' -import { hostedKeyEnabledWhen } from '@/tools/hosting' -import type { ToolConfig } from '@/tools/types' -import { - buildOrganizationReadme, - serializeAccessControl, - serializeAccountBilling, - serializeAccountMembers, - serializeAccountWorkspace, - serializeAccountWorkspaces, - serializeApiKeyIntegrations, - serializeBlockSchema, - serializeConnectedAccounts, - serializeConnectors, - serializeCredentials, - serializeDeployments, - serializeFileMeta, - serializeIntegrationSchema, - serializeKBMeta, - serializeOrganization, - serializeOrganizationCustomBlocks, - serializeOrganizationWorkspaces, - serializeOrgCustomBlockDetail, - serializePermissionGroupRoster, - serializeSandbox, - serializeSandboxCatalog, - serializeTableMeta, - serializeWorkflowMeta, - serializeWorkspaceForks, -} from './serializers' - -function hostedTool(id: string, conditional = false): ToolConfig { - return { - id, - name: id, - description: `Run ${id}`, - version: '1.0.0', - params: { - provider: { type: 'string', required: conditional }, - apiKey: { type: 'string', required: true, visibility: 'user-only' }, - }, - request: { - url: 'https://example.com', - method: 'POST', - headers: () => ({}), - }, - hosting: { - enabled: conditional - ? hostedKeyEnabledWhen({ field: 'provider', operator: 'equals', value: 'hosted' }) - : undefined, - envKeyPrefix: 'EXAMPLE_API_KEY', - apiKeyParam: 'apiKey', - byokProviderId: 'exa', - pricing: { type: 'per_request', cost: 0.01 }, - rateLimit: { mode: 'per_request', requestsPerMinute: 10 }, - }, - } -} - -describe('VFS metadata serializers', () => { - it('serializes an undeployed API explicitly instead of as an empty object', () => { - const deployment = JSON.parse( - serializeDeployments({ - workflowId: 'workflow-1', - isDeployed: false, - mcp: [], - versions: [], - }) - ) - - expect(deployment).toEqual({ api: { isDeployed: false } }) - }) - - it('includes the authoritative file update timestamp', () => { - const metadata = JSON.parse( - serializeFileMeta({ - id: 'file-1', - name: 'notes.md', - contentType: 'text/markdown', - size: 42, - uploadedAt: new Date('2026-07-01T00:00:00.000Z'), - updatedAt: new Date('2026-07-09T12:34:56.000Z'), - }) - ) - - expect(metadata.updatedAt).toBe('2026-07-09T12:34:56.000Z') - }) - - it('preserves live table and knowledge-base counts', () => { - const table = JSON.parse( - serializeTableMeta({ - id: 'table-1', - name: 'Customers', - schema: { columns: [] }, - rowCount: 137, - maxRows: 10_000, - createdAt: new Date('2026-07-01T00:00:00.000Z'), - updatedAt: new Date('2026-07-09T00:00:00.000Z'), - }) - ) - const knowledgeBase = JSON.parse( - serializeKBMeta({ - id: 'kb-1', - name: 'Handbook', - embeddingModel: 'text-embedding-3-small', - embeddingDimension: 1536, - tokenCount: 12_345, - documentCount: 19, - createdAt: new Date('2026-07-01T00:00:00.000Z'), - updatedAt: new Date('2026-07-09T00:00:00.000Z'), - }) - ) - - expect(table.rowCount).toBe(137) - expect(knowledgeBase.documentCount).toBe(19) - }) - - it('never includes a workflow description in workflow metadata', () => { - const workflowWithPrivateDescription = { - id: 'workflow-1', - name: 'Private Flow', - description: 'PRIVATE WORKFLOW DESCRIPTION', - folderId: null, - isDeployed: false, - deployedAt: null, - runCount: 0, - lastRunAt: null, - createdAt: new Date('2026-07-01T00:00:00.000Z'), - updatedAt: new Date('2026-07-02T00:00:00.000Z'), - } - - const metadata = JSON.parse(serializeWorkflowMeta(workflowWithPrivateDescription)) - - expect(metadata).not.toHaveProperty('description') - expect(JSON.stringify(metadata)).not.toContain('PRIVATE WORKFLOW DESCRIPTION') - }) - - it('serializes the complete Sim sandbox discovery resource', () => { - const serialized = JSON.parse( - serializeSandbox( - { - id: 'sandbox-1', - name: 'Data Tools', - language: 'python', - dependencies: ['pandas'], - systemPackages: ['graphviz'], - cliTools: ['kubectl@1.36.3-r1'], - buildStatus: 'ready', - errorCode: null, - errorMessage: null, - errorDetail: null, - builtAt: '2026-08-04T12:00:00.000Z', - createdAt: '2026-08-04T11:00:00.000Z', - updatedAt: '2026-08-04T12:00:00.000Z', - }, - 'prebuilt' - ) - ) - - expect(serialized).toMatchObject({ - id: 'sandbox-1', - strategy: 'prebuilt', - buildStatus: 'ready', - dependencies: ['pandas'], - systemPackages: ['graphviz'], - cliTools: ['kubectl@1.36.3-r1'], - }) - }) - - it('generates the sandbox capability reference from the authoritative CLI registry', () => { - const reference = serializeSandboxCatalog('prebuilt') - - expect(reference).toContain('Active dependency strategy: `prebuilt`') - expect(reference).toContain(`accepts at most ${MAX_SANDBOX_CLI_TOOLS} exact pinned ids`) - for (const id of SANDBOX_SELECTABLE_CLI_TOOL_IDS) { - const tool = SANDBOX_CLI_TOOLS[id] - expect(reference).toContain(`\`${id}\``) - expect(reference).toContain(tool.label) - expect(reference).toContain(tool.description) - } - }) -}) - -describe('entitlement-projected block schemas', () => { - it('keeps a gated input readable while marking it unavailable for mutation', () => { - const block = { - type: 'function', - name: 'Function', - description: 'Run code', - category: 'blocks', - bgColor: '#000000', - icon: () => null, - subBlocks: [ - { id: 'code', title: 'Code', type: 'long-input' }, - { id: 'sandboxId', title: 'Sandbox', type: 'combobox' }, - ], - tools: { access: [] }, - inputs: { - code: { type: 'string' }, - sandboxId: { type: 'string' }, - }, - outputs: {}, - } as unknown as BlockConfig - - const schema = JSON.parse( - serializeBlockSchema(block, { - restrictedInputs: new Map([ - [ - 'sandboxId', - { - requiredEntitlement: 'sim-sandboxes', - reason: 'Requires an active Max or Enterprise plan.', - }, - ], - ]), - }) - ) - - expect(schema.subBlocks.map((subBlock: { id: string }) => subBlock.id)).toEqual([ - 'code', - 'sandboxId', - ]) - expect(schema.subBlocks[1]).toMatchObject({ - readOnly: true, - requiredEntitlement: 'sim-sandboxes', - restrictionReason: 'Requires an active Max or Enterprise plan.', - }) - expect(schema.inputs).toHaveProperty('code') - expect(schema.inputs.sandboxId).toMatchObject({ - type: 'string', - readOnly: true, - requiredEntitlement: 'sim-sandboxes', - restrictionReason: 'Requires an active Max or Enterprise plan.', - }) - }) -}) - -describe('hosted-key VFS metadata', () => { - it('preserves multi-select dropdown behavior in block schemas', () => { - const block = { - type: 'events', - name: 'Events', - subBlocks: [{ id: 'eventTypes', type: 'dropdown', multiSelect: true }], - tools: { access: [] }, - inputs: {}, - outputs: {}, - } as unknown as BlockConfig - - const schema = JSON.parse(serializeBlockSchema(block)) - expect(schema.subBlocks[0].multiSelect).toBe(true) - }) - - it('indexes hosted and conditional-hosted operations for every configured service', () => { - const metadata = JSON.parse( - serializeApiKeyIntegrations( - [ - { config: hostedTool('search'), service: 'generic_search', operation: 'search' }, - { - config: hostedTool('generate', true), - service: 'generic_search', - operation: 'generate', - }, - ], - true - ) - ) - - expect(metadata.generic_search).toEqual({ - params: ['apiKey'], - operations: ['search', 'generate'], - hostedOperations: ['search'], - conditionalHostedOperations: ['generate'], - }) - }) - - it('marks an operation as hosted and omits only its managed API-key param', () => { - const schema = JSON.parse(serializeIntegrationSchema(hostedTool('search'), { hosted: true })) - - expect(schema.auth).toEqual({ - type: 'api_key', - param: 'apiKey', - mode: 'hosted_or_byok', - provider: 'exa', - }) - expect(schema.params).not.toHaveProperty('apiKey') - }) - - it('keeps the API-key param and publishes the exact condition for conditional hosting', () => { - const schema = JSON.parse( - serializeIntegrationSchema(hostedTool('generate', true), { hosted: true }) - ) - - expect(schema.auth).toEqual({ - type: 'api_key', - param: 'apiKey', - mode: 'conditional_hosted_or_byok', - provider: 'exa', - condition: { field: 'provider', operator: 'equals', value: 'hosted' }, - }) - expect(schema.params.apiKey).toBeDefined() - }) - - it('marks the same operation as BYOK-required outside hosted Sim', () => { - const schema = JSON.parse(serializeIntegrationSchema(hostedTool('search'), { hosted: false })) - - expect(schema.auth.mode).toBe('byok_required') - expect(schema.params.apiKey).toBeDefined() - }) - - it('preserves a visible duplicate API-key field for mixed-operation blocks', () => { - const block = { - type: 'mixed_search', - name: 'Mixed Search', - description: 'Search or research', - category: 'tools', - bgColor: '#000000', - icon: () => null, - subBlocks: [ - { - id: 'operation', - title: 'Operation', - type: 'dropdown', - options: [ - { label: 'Hosted search', id: 'search' }, - { label: 'Research with BYOK', id: 'research' }, - ], - }, - { - id: 'apiKey', - title: 'API Key', - type: 'short-input', - hideWhenHosted: true, - condition: { field: 'operation', value: 'search' }, - }, - { - id: 'apiKey', - title: 'API Key', - type: 'short-input', - condition: { field: 'operation', value: 'research' }, - }, - ], - tools: { access: ['search'] }, - inputs: { operation: { type: 'string' }, apiKey: { type: 'string' } }, - outputs: {}, - } as unknown as BlockConfig - const schema = JSON.parse( - serializeBlockSchema(block, { - hosted: true, - toolConfigs: new Map([['search', hostedTool('search')]]), - }) - ) - - expect(schema.subBlocks.filter((subBlock: { id: string }) => subBlock.id === 'apiKey')).toEqual( - [expect.objectContaining({ condition: { field: 'operation', value: 'research' } })] - ) - expect(schema.inputs.apiKey).toBeDefined() - expect(schema.toolAuth.search.mode).toBe('hosted_or_byok') - }) - - it('omits server-only lifecycle inputs from block schemas', () => { - const block = { - type: 'mothership', - name: 'Sim Chat', - description: 'Talk to Sim', - category: 'blocks', - bgColor: '#000000', - icon: () => null, - subBlocks: [ - { id: 'prompt', title: 'Prompt', type: 'long-input' }, - { - id: 'secretScope', - title: 'Secret access', - type: 'dropdown', - hideFromCopilot: true, - }, - { - id: 'mountedSecrets', - title: 'Secrets', - type: 'dropdown', - hideFromCopilot: true, - }, - ], - tools: { access: [] }, - inputs: { - prompt: { type: 'string' }, - secretScope: { type: 'string' }, - mountedSecrets: { type: 'json' }, - }, - outputs: {}, - } as unknown as BlockConfig - - const schema = JSON.parse(serializeBlockSchema(block)) - - expect(schema.subBlocks.map((subBlock: { id: string }) => subBlock.id)).toEqual(['prompt']) - expect(schema.inputs).toEqual({ prompt: { type: 'string' } }) - }) -}) - -describe('serializeBlockSchema permission-group gating', () => { - const slackBlock = { - type: 'slack', - name: 'Slack', - description: 'Slack', - category: 'tools', - subBlocks: [ - { - id: 'operation', - title: 'Operation', - type: 'dropdown', - options: [ - { label: 'Send Message', id: 'send' }, - { label: 'Create Canvas', id: 'canvas' }, - ], - }, - ], - tools: { access: ['slack_message', 'slack_canvas'] }, - inputs: {}, - outputs: {}, - } as unknown as BlockConfig - - it('publishes every operation and tool when nothing is denied', () => { - const schema = JSON.parse(serializeBlockSchema(slackBlock)) - - expect(schema.tools).toEqual(['slack_message', 'slack_canvas']) - expect(schema.subBlocks[0].options.map((option: { id: string }) => option.id)).toEqual([ - 'send', - 'canvas', - ]) - }) - - it('withholds denied operations and tool ids from the viewer schema', () => { - const schema = JSON.parse( - serializeBlockSchema(slackBlock, { - deniedOperationIds: new Set(['canvas']), - isToolAllowed: (toolId: string) => toolId !== 'slack_canvas', - }) - ) - - expect(schema.tools).toEqual(['slack_message']) - expect(schema.subBlocks[0].options).toEqual([{ label: 'Send Message', id: 'send' }]) - }) - - it('leaves the shared registry options array untouched', () => { - serializeBlockSchema(slackBlock, { deniedOperationIds: new Set(['canvas']) }) - - expect(slackBlock.subBlocks[0].options).toHaveLength(2) - }) -}) - -describe('serializeKBMeta', () => { - const baseKb = { - id: 'kb-1', - name: 'Support Docs', - description: null, - embeddingModel: 'text-embedding-3-small', - embeddingDimension: 1536, - tokenCount: 42, - createdAt: new Date('2026-01-01T00:00:00.000Z'), - updatedAt: new Date('2026-01-02T00:00:00.000Z'), - documentCount: 3, - } - - it('includes tag definitions when present', () => { - const json = JSON.parse( - serializeKBMeta({ - ...baseKb, - tagDefinitions: [ - { tagName: 'Important', tagSlot: 'tag1', fieldType: 'text' }, - { tagName: 'Department', tagSlot: 'tag2', fieldType: 'text' }, - ], - }) - ) - - const textOperators = ['eq', 'neq', 'contains', 'not_contains', 'starts_with', 'ends_with'] - expect(json.tagDefinitions).toEqual([ - { tagName: 'Important', tagSlot: 'tag1', fieldType: 'text', operators: textOperators }, - { tagName: 'Department', tagSlot: 'tag2', fieldType: 'text', operators: textOperators }, - ]) - }) - - // `between` is legal for number/date but not text/boolean -- the agent cannot infer this. - it.each([ - ['number', ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'between']], - ['date', ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'between']], - ['boolean', ['eq', 'neq']], - ])('exposes the operators legal for a %s tag', (fieldType, expected) => { - const json = JSON.parse( - serializeKBMeta({ - ...baseKb, - tagDefinitions: [{ tagName: 'Tag', tagSlot: 'tag1', fieldType }], - }) - ) - - expect(json.tagDefinitions[0].operators).toEqual(expected) - }) - - it('emits an empty operator list for an unrecognized field type rather than throwing', () => { - const json = JSON.parse( - serializeKBMeta({ - ...baseKb, - tagDefinitions: [{ tagName: 'Tag', tagSlot: 'tag1', fieldType: 'mystery' }], - }) - ) - - expect(json.tagDefinitions[0].operators).toEqual([]) - }) - - it('omits tag definitions when empty or undefined', () => { - const empty = JSON.parse(serializeKBMeta({ ...baseKb, tagDefinitions: [] })) - const missing = JSON.parse(serializeKBMeta(baseKb)) - - expect(empty).not.toHaveProperty('tagDefinitions') - expect(missing).not.toHaveProperty('tagDefinitions') - }) -}) - -function oauthTool(id: string, provider: string): ToolConfig { - return { - id, - name: id, - description: `Run ${id}`, - version: '1.0.0', - params: {}, - request: { url: 'https://example.com', method: 'POST', headers: () => ({}) }, - oauth: { required: true, provider }, - } -} - -describe('serializeIntegrationSchema — service-account auth', () => { - it('marks an OAuth service that also offers a service account, with its secret noun', () => { - // Notion connects via OAuth or via an internal integration token; the agent - // must be able to discover the second option from the same auth field. - const schema = JSON.parse(serializeIntegrationSchema(oauthTool('notion_read', 'notion'))) - expect(schema.auth).toMatchObject({ - type: 'oauth', - provider: 'notion', - serviceAccount: { connectNoun: 'integration secret' }, - }) - }) - - it('omits serviceAccount for an OAuth service that has no service-account flow', () => { - const schema = JSON.parse(serializeIntegrationSchema(oauthTool('gh_read', 'github'))) - expect(schema.auth.type).toBe('oauth') - expect(schema.auth.serviceAccount).toBeUndefined() - }) - - it('keeps service-account auth while suppressing an unavailable OAuth connection', () => { - const schema = JSON.parse( - serializeIntegrationSchema(oauthTool('notion_read', 'notion'), { - oauthAvailable: false, - }) - ) - - expect(schema.auth.serviceAccount).toEqual({ connectNoun: 'integration secret' }) - expect(schema.oauth).toBeUndefined() - }) -}) - -describe('serializeCredentials — type distinguishes reconnect flow', () => { - const now = new Date('2026-07-21T00:00:00.000Z') - - it('marks a service account so the agent reconnects it via the tag, not oauth', () => { - const json = JSON.parse( - serializeCredentials([ - { - id: 'c1', - providerId: 'notion-service-account', - scope: null, - credentialType: 'service_account', - createdAt: now, - }, - { - id: 'c2', - providerId: 'google-email', - scope: null, - credentialType: 'oauth', - createdAt: now, - }, - ]) - ) - expect(json[0]).toMatchObject({ - id: 'c1', - provider: 'notion-service-account', - type: 'service_account', - }) - expect(json[1]).toMatchObject({ id: 'c2', provider: 'google-email', type: 'oauth' }) - }) - - it('leaves env-var credentials typeless', () => { - const json = JSON.parse( - serializeCredentials([{ providerId: 'OPENAI_API_KEY', scope: 'workspace', createdAt: now }]) - ) - expect(json[0].type).toBeUndefined() - }) - - it('shows what a workspace secret is for, and omits the field when nothing was recorded', () => { - const json = JSON.parse( - serializeCredentials([ - { - providerId: 'STRIPE_KEY', - description: 'Stripe live key for billing', - scope: 'workspace', - createdAt: now, - }, - { providerId: 'OPENAI_API_KEY', description: null, scope: 'workspace', createdAt: now }, - ]) - ) - expect(json[0].description).toBe('Stripe live key for billing') - expect(json[1]).not.toHaveProperty('description') - }) -}) - -describe('serializeConnectors — cloneable references, never key material', () => { - const now = new Date('2026-08-14T00:00:00.000Z') - - it('exposes credentialId and sourceConfig so a connector can be recreated', () => { - const json = JSON.parse( - serializeConnectors([ - { - id: 'conn-1', - connectorType: 'slack', - status: 'active', - syncMode: 'incremental', - syncIntervalMinutes: 1440, - credentialId: 'cred-42', - sourceConfig: { channel: 'eng-help', maxMessages: '500' }, - lastSyncAt: now, - lastSyncError: null, - lastSyncDocCount: 12, - nextSyncAt: null, - consecutiveFailures: 0, - createdAt: now, - }, - ]) - ) - expect(json[0]).toMatchObject({ - id: 'conn-1', - credentialId: 'cred-42', - sourceConfig: { channel: 'eng-help', maxMessages: '500' }, - }) - expect(JSON.stringify(json)).not.toContain('encryptedApiKey') - }) - - it('omits the credential reference when a connector has none (API-key connectors)', () => { - const json = JSON.parse( - serializeConnectors([ - { - id: 'conn-2', - connectorType: 'github', - status: 'active', - syncMode: 'incremental', - syncIntervalMinutes: 1440, - credentialId: null, - sourceConfig: { repository: 'simstudioai/sim', branch: 'staging' }, - lastSyncAt: null, - lastSyncError: null, - lastSyncDocCount: null, - nextSyncAt: null, - consecutiveFailures: 0, - createdAt: now, - }, - ]) - ) - expect(json[0].credentialId).toBeUndefined() - expect(json[0].sourceConfig).toMatchObject({ repository: 'simstudioai/sim' }) - }) -}) - -describe('account and organization namespace serializers', () => { - it('references the files that own org and fork detail instead of restating them', () => { - const workspace = JSON.parse( - serializeAccountWorkspace({ - workspace: { id: 'ws-1', name: 'Elder', workspaceMode: 'standard' }, - viewer: { permission: 'admin', organizationRole: 'owner' }, - organization: { id: 'org-1', name: 'Acme' }, - forkedFrom: { id: 'ws-0', name: 'Elder (parent)' }, - entitlements: ['custom-blocks'], - }) - ) - - expect(workspace.yourPermission).toBe('admin') - expect(workspace.organization).toEqual({ - id: 'org-1', - name: 'Acme', - yourRole: 'owner', - detail: 'organization/organization.json', - }) - expect(workspace.forkedFrom.detail).toBe('organization/forks.json') - // The org record itself (plan, restrictions, members) must not be inlined — - // one relation per file is what keeps the two from disagreeing. - expect(workspace.organization.plan).toBeUndefined() - }) - - it('omits organization and fork stubs for a personal, unforked workspace', () => { - const workspace = JSON.parse( - serializeAccountWorkspace({ - workspace: { id: 'ws-1', name: 'Personal' }, - viewer: { permission: 'admin' }, - organization: null, - forkedFrom: null, - entitlements: [], - }) - ) - - expect(workspace.organization).toBeNull() - expect(workspace.forkedFrom).toBeNull() - }) - - it('withholds member emails from a non-admin viewer and says so', () => { - const members = [ - { userId: 'u-1', name: 'Ada', email: 'ada@example.com', permissionType: 'admin' }, - { - userId: 'u-2', - name: 'Grace', - email: 'grace@example.com', - permissionType: 'read', - isExternal: true, - }, - ] - - const asAdmin = JSON.parse(serializeAccountMembers(members, { includeContactDetails: true })) - expect(asAdmin.members[0].email).toBe('ada@example.com') - expect(asAdmin.note).toBeUndefined() - - const asMember = JSON.parse(serializeAccountMembers(members, { includeContactDetails: false })) - expect(asMember.members.map((m: { email?: string }) => m.email)).toEqual([undefined, undefined]) - expect(asMember.members[0].name).toBe('Ada') - expect(asMember.members[1].isExternal).toBe(true) - expect(asMember.note).toContain('admins only') - }) - - it('keeps money and usage numbers in billing.json alone', () => { - const billing = JSON.parse( - serializeAccountBilling({ - plan: 'team', - billingScope: 'organization', - organizationId: 'org-1', - usage: { - currentPeriodCost: 12.5, - limit: 100, - remaining: 87.5, - percentUsed: 12.5, - isExceeded: false, - billingPeriodEnd: new Date('2026-09-01T00:00:00.000Z'), - }, - credits: { balance: 40, scope: 'organization' }, - }) - ) - - expect(billing.plan).toBe('team') - expect(billing.billedTo).toBe('organization') - expect(billing.usage.billingPeriodEnd).toBe('2026-09-01T00:00:00.000Z') - expect(billing.credits.balance).toBe(40) - - const organization = JSON.parse( - serializeOrganization({ - organization: { id: 'org-1', relationship: 'internal', role: 'admin' }, - capabilities: { canManageOrganization: true, canManageBilling: true }, - plan: 'team', - isEnterprise: false, - }) - ) - expect(organization.usage).toBeUndefined() - expect(organization.credits).toBeUndefined() - expect(organization.note).toContain('account/billing.json') - }) - - it('describes access control as this viewer’s own binding restrictions', () => { - const accessControl = JSON.parse( - serializeAccessControl({ - entitled: true, - permissionGroup: { id: 'pg-1', name: 'Contractors', resolution: 'explicit-member' }, - restrictions: [{ key: 'hideDeployApi', description: 'Cannot deploy workflows as APIs' }], - }) - ) - - expect(accessControl.governingPermissionGroup.appliedBecause).toBe('explicit-member') - expect(accessControl.activeRestrictions).toEqual([ - { key: 'hideDeployApi', description: 'Cannot deploy workflows as APIs' }, - ]) - expect(accessControl.note).toContain('THIS user') - }) - - it('keeps the index to names and defers depth to per-block detail files', () => { - const blocks = JSON.parse( - serializeOrganizationCustomBlocks([ - { - type: 'acme_scorer', - name: 'Acme Scorer', - description: 'Scores a lead', - enabled: true, - workflowId: 'wf-1', - workflowName: 'Scorer', - workspaceId: 'ws-9', - workspaceName: 'Platform', - }, - ]) - ) - - expect(blocks.customBlocks[0]).toEqual({ - type: 'acme_scorer', - name: 'Acme Scorer', - enabled: true, - detail: 'organization/custom-blocks/acme_scorer.json', - }) - // Depth belongs to the detail file — an index row carrying provenance - // would drift from it. - expect(blocks.customBlocks[0].publishedFrom).toBeUndefined() - }) - - it('gives the detail file provenance, the schema pointer, and the read-only deployed graph', () => { - const detail = JSON.parse( - serializeOrgCustomBlockDetail( - { - type: 'acme_scorer', - name: 'Acme Scorer', - enabled: true, - workflowId: 'wf-1', - workflowName: 'Scorer', - workspaceId: 'ws-9', - workspaceName: 'Platform', - }, - { blocks: { b1: { type: 'agent' } }, edges: [{ source: 'b1', target: 'b2' }] } - ) - ) - - expect(detail.publishedFrom.workflowId).toBe('wf-1') - expect(detail.schema).toBe('components/blocks/acme_scorer.json') - expect(detail.deployedWorkflowState.edges).toHaveLength(1) - // The graph is the deployed one and is not editable from here; the note - // is what tells the model both facts. - expect(detail.note).toContain('DEPLOYED') - expect(detail.note).toContain('publishing workspace') - }) - - it('writes the namespace guide with the inventory the index defers', () => { - const readme = buildOrganizationReadme({ - organizationId: 'org-1', - isEnterprise: true, - customBlocks: [ - { - type: 'acme_scorer', - name: 'Acme Scorer', - enabled: true, - workflowName: 'Scorer', - workspaceName: 'Platform', - }, - { type: 'acme_retired', name: 'Retired', enabled: false }, - ], - forksMounted: false, - permissionGroupsMounted: false, - connectedAccountsMounted: true, - }) - - expect(readme).toContain('# Organization') - expect(readme).toContain('custom-blocks/{type}.json') - expect(readme).toContain('**Acme Scorer** (`acme_scorer`) — published from Scorer in Platform') - expect(readme).toContain('**Retired** (`acme_retired`) — disabled') - // Gated files must not be advertised when unmounted for this viewer. - expect(readme).not.toContain('forks.json') - expect(readme).not.toContain('permission-groups.json') - expect(readme).toContain('connected-accounts.json') - }) - - it('serializes singleton readiness without credentials, people, or container selection', () => { - const accounts = { - id: 'cg-1', - name: 'Connected accounts', - status: 'active' as const, - options: [ - { - provider: 'gmail', - label: 'Work email', - required: true, - status: 'active' as const, - configurationStatus: 'ready', - }, - { - provider: 'slack', - status: 'disabled' as const, - configurationStatus: 'not_configured', - slackBotCredentialId: 'private-credential-id', - }, - ], - enrollmentCounts: { completed: 2 }, - people: [{ email: 'private@example.com', status: 'completed' }], - encryptedProviderConfiguration: 'private-configuration', - } - - const serialized = serializeConnectedAccounts(accounts) - const catalog = JSON.parse(serialized) - expect(catalog.status).toBe('active') - expect(catalog.options[1]).toEqual({ - provider: 'slack', - status: 'disabled', - configurationStatus: 'not_configured', - }) - expect(catalog.credentialGroups).toBeUndefined() - expect(catalog.id).toBeUndefined() - expect(catalog.people).toBeUndefined() - expect(catalog.enrollments).toBeUndefined() - expect(serialized).not.toContain('private-') - expect(serialized).not.toContain('private@example.com') - expect(catalog.note).toContain('no container selection is required') - }) - - it('maps the org workspace directory with access flags and fork parentage', () => { - const dir = JSON.parse( - serializeOrganizationWorkspaces([ - { id: 'ws-1', name: 'Platform', hasAccess: true, forkedFromWorkspaceId: null }, - { id: 'ws-2', name: 'Client Fork', hasAccess: false, forkedFromWorkspaceId: 'ws-1' }, - ]) - ) - expect(dir.workspaces[1]).toEqual({ - id: 'ws-2', - name: 'Client Fork', - hasAccess: false, - forkedFromWorkspaceId: 'ws-1', - }) - expect(dir.note).toContain('nameable, not readable') - }) - - it('gives the admin roster restrictions per group, not per viewer', () => { - const roster = JSON.parse( - serializePermissionGroupRoster([ - { - id: 'pg-1', - name: 'Contractors', - description: null, - isDefault: false, - memberCount: 4, - workspaces: [{ id: 'ws-1', name: 'Platform' }], - activeRestrictions: [{ key: 'hideDeployApi', description: 'Cannot deploy as API' }], - }, - ]) - ) - expect(roster.permissionGroups[0].memberCount).toBe(4) - expect(roster.permissionGroups[0].activeRestrictions[0].key).toBe('hideDeployApi') - expect(roster.note).toContain('access-control.json') - }) - - it('summarizes fork mappings by resource type and omits them at the root', () => { - const forked = JSON.parse( - serializeWorkspaceForks({ - parent: { id: 'ws-0', name: 'Template' }, - children: [{ id: 'ws-2', name: 'Child', createdAt: new Date('2026-08-01T00:00:00.000Z') }], - resourceMappingCounts: { workflow: 3, table: 1 }, - blockMappingCount: 12, - }) - ) - expect(forked.mappedFromParent).toEqual({ resources: { workflow: 3, table: 1 }, blocks: 12 }) - expect(forked.children[0].createdAt).toBe('2026-08-01T00:00:00.000Z') - - const root = JSON.parse( - serializeWorkspaceForks({ - parent: null, - children: [], - resourceMappingCounts: {}, - blockMappingCount: 0, - }) - ) - expect(root.mappedFromParent).toBeUndefined() - }) - - it('marks the current workspace and never implies the others are readable', () => { - const roster = JSON.parse( - serializeAccountWorkspaces([ - { id: 'ws-1', name: 'Elder', role: 'admin', isCurrent: true, organizationId: 'org-1' }, - { - id: 'ws-2', - name: 'Other', - role: 'read', - isCurrent: false, - forkedFromWorkspaceId: 'ws-1', - }, - ]) - ) - - expect(roster.workspaces[0].isCurrent).toBe(true) - expect(roster.workspaces[1].isCurrent).toBeUndefined() - expect(roster.workspaces[1].forkedFromWorkspaceId).toBe('ws-1') - expect(roster.note).toContain('isCurrent') - }) -}) diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts deleted file mode 100644 index 437a703c80c..00000000000 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ /dev/null @@ -1,1874 +0,0 @@ -import type { ShareAuthType } from '@/lib/api/contracts/public-shares' -import type { Sandbox } from '@/lib/api/contracts/sandboxes' -import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' -import { isHosted } from '@/lib/core/config/env-flags' -import { - getServiceAccountConnectNoun, - getServiceAccountGatingBlockType, -} from '@/lib/credentials/service-account-provider-ids' -import { - MAX_SANDBOX_CLI_TOOLS, - SANDBOX_CLI_TOOLS, - SANDBOX_SELECTABLE_CLI_TOOL_IDS, -} from '@/lib/execution/remote-sandbox/cli-tools' -import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' -import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' -import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' -import { type IsToolAllowed, OPERATION_SUBBLOCK_ID } from '@/lib/permission-groups/operation-access' -import { isRetryEligibleBlock } from '@/lib/workflows/blocks/retry-eligibility' -import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility' -import { getBlock } from '@/blocks' -import { isCustomBlockType } from '@/blocks/custom/build-config' -import type { BlockConfig, SubBlockConfig } from '@/blocks/types' -import { isHiddenUnder } from '@/blocks/visibility/context' -import { - DYNAMIC_MODEL_PROVIDERS, - PROVIDER_DEFINITIONS, - SIM_AUTO_MODEL_ID, -} from '@/providers/models' -import { deriveHostedApiKeySupport } from '@/tools/hosted-api-key' -import type { ExecutableToolConfig, ToolHostingCondition } from '@/tools/types' -import { buildSlackManifest, SLACK_CAPABILITIES } from '@/triggers/slack/capabilities' -import { buildSlackCustomBotRequestUrl } from '@/triggers/webhook-url' - -/** The service-account alternative to OAuth for a service, when it offers one. */ -export interface VfsServiceAccountAuth { - /** Vendor noun for the secret it collects — "private app token", "server-to-server app", … */ - connectNoun: string -} - -export type VfsToolAuth = - | { - type: 'oauth' - required: boolean - provider: string - /** - * Present when this OAuth service also accepts a shared service-account - * credential (connect AS AN APPLICATION, not as the user). The agent emits - * a `service_account` credential tag with this entry's OAuth `provider` to - * open the in-chat setup form. Omitted when the service has no - * service-account flow or its owning block is hidden. - */ - serviceAccount?: VfsServiceAccountAuth - } - | { - type: 'api_key' - param: string - mode: 'hosted_or_byok' | 'conditional_hosted_or_byok' | 'byok_required' - provider?: string - condition?: ToolHostingCondition - } - -/** - * Whether an OAuth provider value also exposes a service-account flow, and the - * noun for the secret it collects. The single composition point behind both the - * per-tool `auth.serviceAccount` field and the `oauth-integrations.json` - * roll-up, so the two never disagree. Returns `undefined` when the service has - * no service-account flow, or its owning block is hidden and is not the owner - * currently being serialized. - */ -export function describeServiceAccountForOAuthProvider( - oauthProvider: string, - ownerBlockType?: string -): VfsServiceAccountAuth | undefined { - const serviceAccountProviderId = getServiceAccountProviderForProviderId(oauthProvider) - if (!serviceAccountProviderId) return undefined - const gatingBlockType = getServiceAccountGatingBlockType(serviceAccountProviderId) - if (gatingBlockType) { - const gatingBlock = getBlock(gatingBlockType) - if (!gatingBlock || (ownerBlockType !== gatingBlockType && isHiddenUnder(null, gatingBlock))) { - return undefined - } - } - return { connectNoun: getServiceAccountConnectNoun(serviceAccountProviderId) } -} - -export interface ComponentSerializationOptions { - hosted?: boolean - toolConfigs?: ReadonlyMap - ownerBlockType?: string - /** Product-gated inputs removed from both subBlocks and the input schema. */ - hiddenInputIds?: ReadonlySet - /** Product-gated inputs that remain discoverable but cannot be mutated by this viewer. */ - restrictedInputs?: ReadonlyMap< - string, - { - requiredEntitlement: string - reason: string - } - > - /** - * The viewer's permission-group tool gate. Denied tool ids are dropped from - * `tools` and `toolAuth` so the agent is never handed an id it may not call. - */ - isToolAllowed?: IsToolAllowed - /** - * Operation ids the viewer's permission group denies, removed from the - * operation selector's options. Paired with `isToolAllowed` rather than - * derived here so the caller — which also decides whether a wholly denied - * block is worth publishing at all — resolves them exactly once. - */ - deniedOperationIds?: ReadonlySet -} - -/** - * Project runtime tool authentication into a stable, machine-readable VFS contract. - * ToolConfig.hosting remains the source of truth for every hosted-key integration. - */ -export function serializeToolAuth( - tool: ExecutableToolConfig, - hosted = isHosted, - ownerBlockType?: string -): VfsToolAuth | undefined { - if (tool.oauth) { - const serviceAccount = describeServiceAccountForOAuthProvider( - tool.oauth.provider, - ownerBlockType - ) - return { - type: 'oauth', - required: tool.oauth.required, - provider: tool.oauth.provider, - ...(serviceAccount ? { serviceAccount } : {}), - } - } - - if (!tool.hosting) return undefined - - return { - type: 'api_key', - param: tool.hosting.apiKeyParam, - mode: hosted - ? tool.hosting.enabled - ? 'conditional_hosted_or_byok' - : 'hosted_or_byok' - : 'byok_required', - provider: tool.hosting.byokProviderId, - condition: hosted ? tool.hosting.enabled?.condition : undefined, - } -} - -/** - * Serialize workflow metadata for VFS meta.json. - * - * `locked` is the EFFECTIVE lock — true when the workflow is locked directly or - * sits inside a locked folder. A locked workflow cannot be edited, moved, - * renamed, or deleted (mutations are rejected server-side with a 423). The - * mothership should read this before attempting any workflow mutation. - * `inheritedFolderLock` carries the resolved containing-folder lock (the - * caller computes folder inheritance; see workspace-vfs materializeWorkflows). - */ -export function serializeWorkflowMeta( - wf: { - id: string - name: string - folderId?: string | null - isDeployed: boolean - deployedAt?: Date | null - runCount: number - lastRunAt?: Date | null - createdAt: Date - updatedAt: Date - locked?: boolean - }, - options?: { inheritedFolderLock?: boolean } -): string { - const directLock = wf.locked ?? false - const locked = directLock || (options?.inheritedFolderLock ?? false) - return JSON.stringify( - { - id: wf.id, - name: wf.name, - folderId: wf.folderId || undefined, - locked, - lockedBy: locked ? (directLock ? 'workflow' : 'folder') : undefined, - isDeployed: wf.isDeployed, - deployedAt: wf.deployedAt?.toISOString(), - runCount: wf.runCount, - lastRunAt: wf.lastRunAt?.toISOString(), - createdAt: wf.createdAt.toISOString(), - updatedAt: wf.updatedAt.toISOString(), - }, - null, - 2 - ) -} - -/** - * Serialize execution logs for VFS executions.json. - * Takes recent execution log rows and produces a summary. - */ -export function serializeRecentExecutions( - executions: Array<{ - id: string - executionId: string - status: string - trigger: string - startedAt: Date - endedAt?: Date | null - totalDurationMs?: number | null - }> -): string { - return JSON.stringify( - executions.map((e) => ({ - executionId: e.executionId, - status: e.status, - trigger: e.trigger, - startedAt: e.startedAt.toISOString(), - endedAt: e.endedAt?.toISOString(), - durationMs: e.totalDurationMs, - })), - null, - 2 - ) -} - -/** - * A knowledge base tag definition, reduced to the fields the agent needs to bind a tag filter. - * - * @remarks - * `tagName` is the DB's `displayName`. It is renamed at this boundary because that is the key - * a `tagFilters` entry must carry -- an entry written with `displayName` validates and persists - * but never filters anything. - */ -export interface KbTagDefinitionSummary { - /** The tagDefinitionId that update_tag / delete_tag / update_document.tagValues require. */ - id: string - tagName: string - tagSlot: string - fieldType: string -} - -/** - * Serialize knowledge base metadata for VFS meta.json. - * - * `tagDefinitions` exposes the KB's defined tags (`tagName` → `tagSlot`) plus the operators - * legal for each tag's `fieldType`, so the agent can bind a knowledge-tag filter without - * guessing a tag name it cannot otherwise see or an operator the field does not accept - * (`between` is valid for number/date but not text/boolean). - */ -export function serializeKBMeta(kb: { - id: string - name: string - description?: string | null - embeddingModel: string - embeddingDimension: number - tokenCount: number - createdAt: Date - updatedAt: Date - documentCount: number - connectorTypes?: string[] - tagDefinitions?: KbTagDefinitionSummary[] -}): string { - return JSON.stringify( - { - id: kb.id, - name: kb.name, - description: kb.description || undefined, - embeddingModel: kb.embeddingModel, - embeddingDimension: kb.embeddingDimension, - tokenCount: kb.tokenCount, - documentCount: kb.documentCount, - connectorTypes: - kb.connectorTypes && kb.connectorTypes.length > 0 ? kb.connectorTypes : undefined, - tagDefinitions: - kb.tagDefinitions && kb.tagDefinitions.length > 0 - ? kb.tagDefinitions.map((tag) => ({ - ...tag, - operators: getOperatorsForFieldType(tag.fieldType as FilterFieldType).map( - (op) => op.value - ), - })) - : undefined, - createdAt: kb.createdAt.toISOString(), - updatedAt: kb.updatedAt.toISOString(), - }, - null, - 2 - ) -} - -/** - * Serialize documents list for VFS documents.json (metadata only, no content) - */ -export function serializeDocuments( - docs: Array<{ - id: string - filename: string - fileSize: number - mimeType: string - chunkCount: number - tokenCount: number - processingStatus: string - enabled: boolean - uploadedAt: Date - }> -): string { - return JSON.stringify( - docs.map((d) => ({ - id: d.id, - filename: d.filename, - fileSize: d.fileSize, - mimeType: d.mimeType, - chunkCount: d.chunkCount, - tokenCount: d.tokenCount, - processingStatus: d.processingStatus, - enabled: d.enabled, - uploadedAt: d.uploadedAt.toISOString(), - })), - null, - 2 - ) -} - -/** - * Serialize KB connectors for VFS knowledgebases/{name}/connectors.json. - * Shows connector type, sync status, schedule, the credential REFERENCE - * (an opaque id — never key material; API keys stay encrypted and are never - * serialized), and the source config (repo/branch/channels). The last two are - * what make a connector cloneable: without them, recreating a working - * connector on a new KB meant guessing both the credential and the channels. - */ -export function serializeConnectors( - connectors: Array<{ - id: string - connectorType: string - status: string - syncMode: string - syncIntervalMinutes: number - credentialId?: string | null - sourceConfig?: unknown - lastSyncAt: Date | null - lastSyncError: string | null - lastSyncDocCount: number | null - nextSyncAt: Date | null - consecutiveFailures: number - createdAt: Date - }> -): string { - return JSON.stringify( - connectors.map((c) => ({ - id: c.id, - connectorType: c.connectorType, - status: c.status, - syncMode: c.syncMode, - syncIntervalMinutes: c.syncIntervalMinutes, - credentialId: c.credentialId ?? undefined, - sourceConfig: c.sourceConfig ?? undefined, - lastSyncAt: c.lastSyncAt?.toISOString(), - lastSyncError: c.lastSyncError || undefined, - lastSyncDocCount: c.lastSyncDocCount ?? undefined, - nextSyncAt: c.nextSyncAt?.toISOString(), - consecutiveFailures: c.consecutiveFailures, - createdAt: c.createdAt.toISOString(), - })), - null, - 2 - ) -} - -/** - * Connector config field shape (mirrors ConnectorConfigField from connectors/types.ts - * but avoids importing React-dependent code into serializers). - */ -interface SerializableConfigField { - id: string - title: string - type: string - placeholder?: string - required?: boolean - description?: string - options?: Array<{ label: string; id: string }> -} - -interface SerializableTagDef { - id: string - displayName: string - fieldType: string -} - -interface SerializableConnectorConfig { - id: string - name: string - description: string - version: string - auth: { mode: string; provider?: string; requiredScopes?: string[] } - configFields: SerializableConfigField[] - tagDefinitions?: SerializableTagDef[] - supportsIncrementalSync?: boolean -} - -/** - * Serialize a single connector type's schema for VFS knowledgebases/connectors/{type}.json. - * Contains everything the LLM needs to build a valid sourceConfig. - */ -export function serializeConnectorSchema(connector: SerializableConnectorConfig): string { - return JSON.stringify( - { - id: connector.id, - name: connector.name, - description: connector.description, - version: connector.version, - auth: connector.auth, - configFields: connector.configFields.map((f) => { - const field: Record = { - id: f.id, - title: f.title, - type: f.type, - } - if (f.required) field.required = true - if (f.placeholder) field.placeholder = f.placeholder - if (f.description) field.description = f.description - if (f.options) field.options = f.options - return field - }), - tagDefinitions: connector.tagDefinitions ?? [], - supportsIncrementalSync: connector.supportsIncrementalSync ?? false, - }, - null, - 2 - ) -} - -/** - * Generate the knowledgebases/connectors/connectors.md overview file. - * Lists all available connector types with their OAuth providers — enough - * for the LLM to identify the right type and credential, then read the - * per-connector schema file for full config details. - */ -export function serializeConnectorOverview(connectors: SerializableConnectorConfig[]): string { - const rows = connectors.map((c) => { - const provider = c.auth.provider ?? c.auth.mode - const scopes = c.auth.requiredScopes?.length ? c.auth.requiredScopes.join(', ') : '(none)' - return `| ${c.id} | ${c.name} | ${provider} | ${scopes} |` - }) - - return [ - '# Available KB Connectors', - '', - 'Use `read("knowledgebases/connectors/{type}.json")` to get the full config schema before calling `add_connector`.', - '', - '| Type | Name | OAuth Provider | Required Scopes |', - '|------|------|---------------|-----------------|', - ...rows, - '', - 'To add a connector, the user must have an OAuth credential for that provider.', - 'Check `environment/credentials.json` for available credential IDs.', - ].join('\n') -} - -/** - * Serialize workspace file metadata for VFS files/{path}/{name}/meta.json. - */ -export function serializeFileMeta(file: { - id: string - name: string - folderId?: string | null - folderPath?: string | null - vfsPath?: string - contentType: string - size: number - uploadedAt: Date - updatedAt: Date - /** Whether the file has an active public share link. */ - shared?: boolean - /** Auth mode of the active share; only meaningful when `shared` is true. */ - shareAuthType?: ShareAuthType - /** Public share link (`{baseUrl}/f/{token}`); only meaningful when `shared` is true. */ - shareUrl?: string -}): string { - return JSON.stringify( - { - id: file.id, - name: file.name, - folderId: file.folderId || undefined, - folderPath: file.folderPath || undefined, - vfsPath: file.vfsPath, - contentType: file.contentType, - size: file.size, - uploadedAt: file.uploadedAt.toISOString(), - updatedAt: file.updatedAt.toISOString(), - readContentWith: file.vfsPath ? `${file.vfsPath}/content` : undefined, - shared: Boolean(file.shared), - shareAuthType: file.shared ? file.shareAuthType : undefined, - shareUrl: file.shared ? file.shareUrl : undefined, - note: 'This is file metadata only. To read the file text/bytes, read the readContentWith path (i.e. append /content).', - }, - null, - 2 - ) -} - -/** - * Serialize table metadata for VFS tables/{name}/meta.json - */ -export function serializeTableMeta(table: { - id: string - name: string - description?: string | null - schema: unknown - rowCount: number - maxRows: number - createdAt: Date | string - updatedAt: Date | string -}): string { - return JSON.stringify( - { - id: table.id, - name: table.name, - description: table.description || undefined, - schema: table.schema, - rowCount: table.rowCount, - maxRows: table.maxRows, - createdAt: table.createdAt instanceof Date ? table.createdAt.toISOString() : table.createdAt, - updatedAt: table.updatedAt instanceof Date ? table.updatedAt.toISOString() : table.updatedAt, - }, - null, - 2 - ) -} - -/** - * Returns the static model list from PROVIDER_DEFINITIONS for VFS serialization. - * Excludes dynamic providers (ollama, vllm, openrouter) whose models are user-configured. - * Includes provider ID and whether the model is hosted by Sim (no API key required). - */ -interface StaticModelOption { - id: string - provider: string - hosted: boolean - recommended?: boolean - speedOptimized?: boolean - deprecated?: boolean -} - -const DYNAMIC_PROVIDERS_NOTE = { - note: 'The options array above lists Sim\'s static provider catalog. These providers also accept user-configured models that are NOT enumerated here: the user may have additional ids available at runtime (e.g. local Ollama tags). To reference one, prefix the model id with the provider slash below — for example "ollama/llama3.1:8b" instead of the bare "llama3.1:8b". The server rejects bare ids that are not in the catalog; always use the prefix for user-configured models.', - prefixes: DYNAMIC_MODEL_PROVIDERS.map((p) => `${p}/`), -} as const - -function getStaticModelOptionsForVFS(): StaticModelOption[] { - const hostedProviders = new Set(['openai', 'anthropic', 'google']) - const dynamicProviders = new Set(DYNAMIC_MODEL_PROVIDERS) - - const models: StaticModelOption[] = [] - - // Hosted-only automatic model. Deliberately not `recommended` and given no - // prompt guidance (limited-visibility release): the build agent can write it - // when a user explicitly asks for the auto model, but is never steered to it. - if (isHosted) { - models.push({ - id: SIM_AUTO_MODEL_ID, - provider: 'sim', - hosted: true, - }) - } - - for (const [providerId, def] of Object.entries(PROVIDER_DEFINITIONS)) { - if (dynamicProviders.has(providerId)) continue - for (const model of def.models) { - // Retired models are hidden from the agent's menu (mirrors the user picker) - // so it never suggests a model whose API calls fail; legacy stays available. - if (model.sunset?.status === 'deprecated') continue - const option: StaticModelOption = { - id: model.id, - provider: providerId, - hosted: hostedProviders.has(providerId), - } - if (model.recommended) option.recommended = true - if (model.speedOptimized) option.speedOptimized = true - if (model.sunset) option.deprecated = true - models.push(option) - } - } - - return models -} - -/** - * Serialize a SubBlockConfig for the VFS component schema. - * Strips functions and UI-only fields. Includes static options arrays. - */ -function serializeSubBlock(sb: SubBlockConfig): Record { - const result: Record = { - id: sb.id, - type: sb.type, - } - if (sb.title) result.title = sb.title - if (sb.required === true) result.required = true - if (sb.defaultValue !== undefined) result.defaultValue = sb.defaultValue - if (sb.mode) result.mode = sb.mode - if (sb.multiSelect) result.multiSelect = true - if (sb.canonicalParamId) result.canonicalParamId = sb.canonicalParamId - if (sb.condition && typeof sb.condition !== 'function') result.condition = sb.condition - // Copied, not aliased: these are the registry's own arrays, shared by every - // request in the process, so publishing one puts mutable registry state a - // single careless consumer away from corruption. The catalog projection this - // serializer parallels copies every array it publishes for the same reason. - if (sb.dependsOn) - result.dependsOn = Array.isArray(sb.dependsOn) ? [...sb.dependsOn] : sb.dependsOn - - // Include static options arrays for dropdowns - if (Array.isArray(sb.options)) { - result.options = [...sb.options] - } - - return result -} - -/** - * Serialize a block schema for VFS components/blocks/{type}.json - */ -export function serializeBlockSchema( - block: BlockConfig, - options?: ComponentSerializationOptions -): string { - // Custom blocks bake their `workflowId`/`inputMapping` as `hidden` sub-blocks; - // treat `hidden` as hidden for them so those never reach the agent's schema. - const customBlock = isCustomBlockType(block.type) - const hosted = options?.hosted ?? isHosted - const explicitlyHidden = options?.hiddenInputIds ?? new Set() - const visibleSubBlocks = block.subBlocks.filter( - (sb) => - !explicitlyHidden.has(sb.id) && - !sb.hideFromCopilot && - !isSubBlockHidden(sb, { hosted }) && - !(customBlock && sb.hidden) - ) - const visibleIds = new Set(visibleSubBlocks.map((sb) => sb.id)) - const hiddenIds = new Set( - block.subBlocks - .filter( - (sb) => - explicitlyHidden.has(sb.id) || - sb.hideFromCopilot || - isSubBlockHidden(sb, { hosted }) || - (customBlock && sb.hidden) - ) - .map((sb) => sb.id) - .filter((id) => !visibleIds.has(id)) - ) - - const deniedOperationIds = options?.deniedOperationIds - const subBlocks = visibleSubBlocks.map((sb) => { - const serialized = serializeSubBlock(sb) - if ( - sb.id === OPERATION_SUBBLOCK_ID && - deniedOperationIds?.size && - Array.isArray(serialized.options) - ) { - serialized.options = (serialized.options as Array<{ id: string }>).filter( - (option) => !deniedOperationIds.has(option.id) - ) - } - const restriction = options?.restrictedInputs?.get(sb.id) - if (restriction) { - serialized.readOnly = true - serialized.requiredEntitlement = restriction.requiredEntitlement - serialized.restrictionReason = restriction.reason - } - - if (sb.id === 'model' && sb.type === 'combobox' && typeof sb.options === 'function') { - serialized.options = getStaticModelOptionsForVFS() - serialized.dynamicProviders = DYNAMIC_PROVIDERS_NOTE - } - - return serialized - }) - - const isToolAllowed = options?.isToolAllowed - const accessibleTools = isToolAllowed - ? block.tools.access.filter((toolId) => isToolAllowed(toolId)) - : block.tools.access - - const toolAuth: Record = {} - for (const toolId of accessibleTools) { - const tool = options?.toolConfigs?.get(toolId) - if (!tool) continue - const auth = serializeToolAuth(tool, hosted, block.type) - if (auth) toolAuth[toolId] = auth - } - - const visibleInputs = - block.inputs && hiddenIds.size > 0 - ? Object.fromEntries(Object.entries(block.inputs).filter(([key]) => !hiddenIds.has(key))) - : block.inputs - const inputs = visibleInputs - ? Object.fromEntries( - Object.entries(visibleInputs).map(([key, input]) => { - const restriction = options?.restrictedInputs?.get(key) - return restriction - ? [ - key, - { - ...input, - readOnly: true, - requiredEntitlement: restriction.requiredEntitlement, - restrictionReason: restriction.reason, - }, - ] - : [key, input] - }) - ) - : visibleInputs - - return JSON.stringify( - { - type: block.type, - name: block.name, - description: block.description, - category: block.category, - longDescription: block.longDescription || undefined, - bestPractices: block.bestPractices || undefined, - triggerAllowed: block.triggerAllowed || undefined, - // Retry is block STATE (like `enabled`), not a subBlock input — set it via - // edit_workflow's `retry` param, never through `inputs`. Emitted only when - // eligible so the agent never proposes a policy the executor would ignore. - retryAllowed: - isRetryEligibleBlock({ - blockType: block.type, - category: block.category, - triggerMode: undefined, - }) || undefined, - singleInstance: block.singleInstance || undefined, - authMode: block.authMode || undefined, - // Custom (deploy-as-block) blocks execute via a baked `workflow_executor` - // internally; that's implementation plumbing, not something the agent - // configures. Hiding it keeps the block self-contained (fields in, outputs - // out) so the agent doesn't treat it like the generic workflow block and - // ask for a workflowId/inputMapping. - tools: isCustomBlockType(block.type) ? [] : accessibleTools, - toolAuth: Object.keys(toolAuth).length > 0 ? toolAuth : undefined, - subBlocks, - inputs, - outputs: Object.fromEntries( - Object.entries(block.outputs) - .filter(([key, val]) => key !== 'visualization' && val != null) - .map(([key, val]) => [ - key, - typeof val === 'string' - ? { type: val } - : { type: val.type, description: (val as { description?: string }).description }, - ]) - ), - }, - null, - 2 - ) -} - -/** - * Serialize OAuth credentials for VFS environment/credentials.json. - * Shows which integrations are connected — IDs, roles, and scopes, NOT tokens. - */ -export function serializeCredentials( - accounts: Array<{ - id?: string - providerId: string - displayName?: string | null - /** What a workspace secret is for, when one has been recorded. */ - description?: string | null - role?: string | null - scope: string | null - /** - * 'service_account' for a shared app credential, 'managed_oauth' for a - * Credential Group credential the person holds through their enrollment; - * omitted/undefined for a personal OAuth connection. - */ - credentialType?: 'oauth' | 'service_account' | 'managed_oauth' - createdAt: Date - }> -): string { - return JSON.stringify( - accounts.map((a) => ({ - id: a.id || undefined, - provider: a.providerId, - displayName: a.displayName || undefined, - description: a.description || undefined, - role: a.role || undefined, - scope: a.scope || undefined, - // 'oauth' (personal connection) vs 'service_account' (shared app - // credential) vs 'managed_oauth' (the person's own Credential Group - // credential) — they reconnect differently, so the agent must branch on - // this. Env-var credentials carry no type. - type: a.credentialType, - // Derived, not stored: the public Request URL a Slack custom-bot app - // posts events to. One per credential; every workflow trigger that - // selects this credential shares it. This is what the setup wizard shows - // in Slack's Event Subscriptions step. - ...(a.credentialType === 'service_account' && - a.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID && - a.id - ? { requestUrl: buildSlackCustomBotRequestUrl(a.id) } - : {}), - connectedAt: a.createdAt.toISOString(), - })), - null, - 2 - ) -} - -/** - * Serialize API keys for VFS environment/api-keys.json. - * Shows key names and types — NOT the actual key values. - */ -export function serializeApiKeys( - keys: Array<{ - id: string - name: string - type: string - lastUsed: Date | null - createdAt: Date - expiresAt: Date | null - }> -): string { - return JSON.stringify( - keys.map((k) => ({ - id: k.id, - name: k.name, - type: k.type, - lastUsed: k.lastUsed?.toISOString(), - createdAt: k.createdAt.toISOString(), - expiresAt: k.expiresAt?.toISOString(), - })), - null, - 2 - ) -} - -interface ApiKeyIntegrationTool { - config: ExecutableToolConfig - service: string - operation: string -} - -/** - * Serialize API-key integration discovery with operation-level hosted status. - * ToolConfig.hosting is the only provider registry used to build this index. - */ -export function serializeApiKeyIntegrations( - tools: readonly ApiKeyIntegrationTool[], - hosted = isHosted -): string { - const services = new Map< - string, - { - params: string[] - operations: string[] - hostedOperations: string[] - conditionalHostedOperations: string[] - } - >() - - for (const { config: tool, service, operation } of tools) { - if (!tool.hosting?.apiKeyParam) continue - - const metadata = services.get(service) ?? { - params: [], - operations: [], - hostedOperations: [], - conditionalHostedOperations: [], - } - if (!metadata.params.includes(tool.hosting.apiKeyParam)) { - metadata.params.push(tool.hosting.apiKeyParam) - } - metadata.operations.push(operation) - if (hosted && tool.hosting.enabled) { - metadata.conditionalHostedOperations.push(operation) - } else if (hosted) { - metadata.hostedOperations.push(operation) - } - services.set(service, metadata) - } - - return JSON.stringify(Object.fromEntries(services), null, 2) -} - -/** - * Serialize environment variables for VFS environment/variables.json. - * Shows variable NAMES only — NOT values. `unredactedWorkspace` names the workspace - * secrets whose values appear in plaintext in run output instead of `{{NAME}}`; the - * values themselves are still never written into the VFS. - */ -export function serializeEnvironmentVariables( - personalVarNames: string[], - workspaceVarNames: string[], - unredactedWorkspaceVarNames: string[] = [] -): string { - return JSON.stringify( - { - personal: personalVarNames, - workspace: workspaceVarNames, - unredactedWorkspace: unredactedWorkspaceVarNames, - }, - null, - 2 - ) -} - -/** Input types for deployment serialization. */ -export interface DeploymentData { - workflowId: string - isDeployed: boolean - deployedAt?: Date | null - needsRedeployment?: boolean - api?: { - version: number - createdAt: Date - } | null - chat?: { - id: string - identifier: string - title: string - description?: string | null - authType: string - customizations: unknown - isActive: boolean - allowedEmails?: unknown - outputConfigs?: unknown - includeThinking?: boolean | null - includeToolCalls?: boolean | null - } | null - mcp: Array<{ - serverId: string - serverName: string - toolId: string - toolName: string - parameterDescriptionOverrides?: unknown - toolDescription?: string | null - }> - versions?: Array<{ - id: string - version: number - name: string | null - description: string | null - isActive: boolean - createdAt: Date - }> -} - -/** - * Serialize all deployment configurations for VFS deployment.json. - * Only includes keys for active deployment types. - */ -export function serializeDeployments(data: DeploymentData): string { - const result: Record = {} - - if (data.needsRedeployment !== undefined) { - result.needsRedeployment = data.needsRedeployment - } - - result.api = data.isDeployed - ? { - isDeployed: true, - deployedAt: data.deployedAt?.toISOString(), - apiEndpoint: `/api/workflows/${data.workflowId}/execute`, - ...(data.api ? { version: data.api.version } : {}), - } - : { isDeployed: false } - - if (data.chat) { - // allowedEmails/outputConfigs/includeThinking/includeToolCalls are the - // fields deploy_as_chat accepts on redeploy; exposing the current values is - // what lets a caller change one setting without blanking the others. - result.chat = { - id: data.chat.id, - identifier: data.chat.identifier, - chatUrl: `/chat/${data.chat.identifier}`, - title: data.chat.title, - description: data.chat.description || undefined, - authType: data.chat.authType, - customizations: data.chat.customizations, - isActive: data.chat.isActive, - allowedEmails: data.chat.allowedEmails ?? undefined, - outputConfigs: data.chat.outputConfigs ?? undefined, - includeThinking: data.chat.includeThinking ?? undefined, - includeToolCalls: data.chat.includeToolCalls ?? undefined, - } - } - - if (data.mcp.length > 0) { - result.mcp = data.mcp.map((m) => ({ - serverId: m.serverId, - serverName: m.serverName, - toolId: m.toolId, - toolName: m.toolName, - toolDescription: m.toolDescription || undefined, - // What deploy_as_mcp accepts as `parameters` on redeploy; omitting it - // there resets the overrides, so expose the current value. - parameterDescriptionOverrides: m.parameterDescriptionOverrides ?? undefined, - })) - } - - return JSON.stringify(result, null, 2) -} - -/** - * Serialize deployment version history for VFS workflows/{name}/versions.json. - * Lists all versions without full state — use the diff_workflows tool to compare a version, - * or load_deployment to restore one into the draft. - */ -export function serializeVersions( - versions: Array<{ - id: string - version: number - name: string | null - description: string | null - isActive: boolean - createdAt: Date - }> -): string { - return JSON.stringify( - versions.map((v) => ({ - id: v.id, - version: v.version, - name: v.name || undefined, - description: v.description || undefined, - isActive: v.isActive, - createdAt: v.createdAt.toISOString(), - })), - null, - 2 - ) -} - -/** - * Serialize a custom tool for VFS custom-tools/{name}.json - */ -export function serializeCustomTool(tool: { - id: string - title: string - schema: unknown - code: string -}): string { - return JSON.stringify( - { - id: tool.id, - title: tool.title, - schema: tool.schema, - code: tool.code, - }, - null, - 2 - ) -} - -/** - * Serialize an MCP server for VFS agent/mcp-servers/{name}.json - */ -export function serializeMcpServer(server: { - id: string - name: string - url: string | null - transport: string | null - enabled: boolean - connectionStatus: string | null -}): string { - return JSON.stringify( - { - id: server.id, - name: server.name, - url: server.url, - transport: server.transport, - enabled: server.enabled, - connectionStatus: server.connectionStatus, - }, - null, - 2 - ) -} - -/** - * Serialize a skill for VFS agent/skills/{name}.json - */ -export function serializeSkill(s: { - id: string - name: string - description: string - content: string - createdAt: Date -}): string { - return JSON.stringify( - { - id: s.id, - name: s.name, - description: s.description, - content: s.content, - createdAt: s.createdAt.toISOString(), - }, - null, - 2 - ) -} - -/** Serialize a Sim sandbox for VFS agent/sandboxes/{name}.json. */ -export function serializeSandbox(sandbox: Sandbox, strategy: 'prebuilt' | 'runtime'): string { - return JSON.stringify( - { - id: sandbox.id, - name: sandbox.name, - language: sandbox.language, - dependencies: sandbox.dependencies, - systemPackages: sandbox.systemPackages, - cliTools: sandbox.cliTools, - strategy, - buildStatus: sandbox.buildStatus, - errorCode: sandbox.errorCode, - errorMessage: sandbox.errorMessage, - errorDetail: sandbox.errorDetail, - builtAt: sandbox.builtAt, - createdAt: sandbox.createdAt, - updatedAt: sandbox.updatedAt, - }, - null, - 2 - ) -} - -/** - * Generate the authoritative Sim-sandbox capability reference exposed in VFS. - * The managed-CLI rows come directly from the same client-safe registry used by - * validation and the settings UI, so adding or upgrading a CLI updates agent - * discovery without a second hand-maintained list. - */ -export function serializeSandboxCatalog(strategy: 'prebuilt' | 'runtime'): string { - const rows = SANDBOX_SELECTABLE_CLI_TOOL_IDS.map((id) => { - const tool = SANDBOX_CLI_TOOLS[id] - const aliases = tool.searchTerms?.join(', ') || '(none)' - return `| \`${tool.id}\` | ${tool.label} | ${tool.category} | ${tool.description} | ${aliases} |` - }) - - return [ - '# Sim Sandbox Capabilities', - '', - 'This file is generated from the active Sim sandbox registry. Treat it as the authoritative catalog; do not guess or reuse managed CLI ids from memory.', - '', - `- Active dependency strategy: \`${strategy}\``, - '- Dependency languages: `javascript` installs npm packages; `python` installs PyPI packages. Shell execution may select either language.', - '- `systemPackages` accepts Debian package coordinates in `package[:architecture][=version]` form.', - `- \`cliTools\` accepts at most ${MAX_SANDBOX_CLI_TOOLS} exact pinned ids from the catalog below.`, - '- A Sim sandbox may combine language dependencies, Debian system packages, and managed CLIs.', - '', - '## Managed CLI catalog', - '', - '| Exact id | Name | Category | What it provides | Search terms / executables |', - '|----------|------|----------|------------------|----------------------------|', - ...rows, - '', - ].join('\n') -} - -/** - * Serialize an integration/tool schema for VFS components/integrations/{service}/{operation}.json - */ -export function serializeIntegrationSchema( - tool: ExecutableToolConfig, - options?: Pick & { - oauthAvailable?: boolean - } -): string { - const hosted = options?.hosted ?? isHosted - const auth = serializeToolAuth(tool, hosted, options?.ownerBlockType) - const hostedApiKeyParam = - auth?.type === 'api_key' && auth.mode === 'hosted_or_byok' ? auth.param : null - - return JSON.stringify( - { - // The full registry id is the agent-callable id (deferred tools are sent - // with this exact id; no stripping). Surface it verbatim so "copy the id - // field and load it" matches the callable tool and the block's tools.access. - id: tool.id, - name: tool.name, - description: getCopilotToolDescription(tool, { - isHosted: hosted, - hostedApiKey: deriveHostedApiKeySupport(tool.hosting), - }), - version: tool.version, - auth, - oauth: - tool.oauth && options?.oauthAvailable !== false - ? { required: tool.oauth.required, provider: tool.oauth.provider } - : undefined, - params: tool.params - ? { - ...Object.fromEntries( - Object.entries(tool.params) - .filter(([key, val]) => val != null && key !== hostedApiKeyParam) - .map(([key, val]) => [ - key, - { - type: val.type, - required: val.required, - description: val.description, - default: val.default, - }, - ]) - ), - ...(tool.oauth?.required && { - credentialId: { - type: 'string', - required: false, - description: - 'Credential ID to use for this OAuth tool call. For Copilot/Superagent execution, pass this explicitly. Get valid IDs from environment/credentials.json.', - }, - }), - } - : undefined, - outputs: tool.outputs - ? Object.fromEntries( - Object.entries(tool.outputs) - .filter(([, val]) => val != null) - .map(([key, val]) => [key, { type: val.type, description: val.description }]) - ) - : undefined, - }, - null, - 2 - ) -} - -/** - * Derived setup reference for `slack_oauth` — the same material the custom-bot - * setup wizard shows, surfaced so the copilot can walk a user (or the browser - * agent) through Slack app creation without guessing. None of this is a block - * field: the manifest is a template for api.slack.com, and the Request URL is a - * per-credential property (`requestUrl` in environment/credentials.json). - */ -function slackOAuthSetupReference(): Record { - const defaults = SLACK_CAPABILITIES.filter((c) => c.defaultChecked).map((c) => c.id) - return { - note: - 'Setup reference (derived; NOT block fields). A custom bot is a reusable workspace credential: ' + - 'one Slack app, one Request URL, shared by every trigger that selects it. To create or rotate one, ' + - 'emit a service_account credential card for provider "slack" — the wizard collects the signing secret ' + - 'and bot token without them entering the chat. Existing custom bots appear as service_account ' + - 'credentials in environment/credentials.json, each with its requestUrl.', - requestUrlPattern: '{baseUrl}/api/webhooks/slack/custom/{credentialId}', - capabilities: SLACK_CAPABILITIES.map((c) => ({ - id: c.id, - label: c.label, - group: c.group, - defaultChecked: c.defaultChecked, - scopes: c.scopes, - events: c.events, - })), - defaultManifest: buildSlackManifest(new Set(defaults), { - appName: 'Sim Bot', - webhookUrl: '', - }), - } -} - -/** - * Serialize a trigger schema for VFS components/triggers/{provider}/{id}.json - */ -export function serializeTriggerSchema(trigger: { - id: string - name: string - provider: string - description: string - version: string - subBlocks: SubBlockConfig[] - outputs: Record - webhook?: { method?: string; headers?: Record } -}): string { - return JSON.stringify( - { - id: trigger.id, - name: trigger.name, - provider: trigger.provider, - description: trigger.description, - version: trigger.version, - webhook: trigger.webhook || undefined, - subBlocks: trigger.subBlocks.map(serializeSubBlock), - outputs: trigger.outputs, - ...(trigger.id === 'slack_oauth' ? { setup: slackOAuthSetupReference() } : {}), - }, - null, - 2 - ) -} - -/** - * Serialize a built-in trigger block for VFS components/triggers/sim/{type}.json - */ -export function serializeBuiltinTriggerSchema(block: BlockConfig): string { - return JSON.stringify( - { - type: block.type, - name: block.name, - description: block.description, - longDescription: block.longDescription || undefined, - category: 'builtin', - triggers: block.triggers || undefined, - subBlocks: block.subBlocks.map(serializeSubBlock), - inputs: block.inputs, - outputs: block.outputs, - }, - null, - 2 - ) -} - -interface TriggerOverviewEntry { - id: string - name: string - provider: string - description: string -} - -/** - * Serialize a triggers.md overview for VFS components/triggers/triggers.md - */ -export function serializeTriggerOverview( - builtinTriggers: TriggerOverviewEntry[], - externalTriggers: TriggerOverviewEntry[] -): string { - const lines: string[] = ['# Triggers', ''] - - lines.push('## Built-in Triggers', '') - lines.push('| ID | Name | Description |') - lines.push('|----|------|-------------|') - for (const t of builtinTriggers) { - lines.push(`| ${t.id} | ${t.name} | ${t.description} |`) - } - - lines.push('') - lines.push('## External Triggers', '') - lines.push('| Provider | ID | Name | Description |') - lines.push('|----------|----|------|-------------|') - for (const t of externalTriggers) { - lines.push(`| ${t.provider} | ${t.id} | ${t.name} | ${t.description} |`) - } - - lines.push('') - return lines.join('\n') -} - -/** - * tables/{name}/views.json — the table's saved views in the column-NAME - * domain agents speak (stored configs are id-keyed; the caller translates). - * Layout-only fields (order, widths, pinned) are omitted: they are UI - * concerns and never change which rows a view selects. - */ -export function serializeTableViews( - views: Array<{ - id: string - name: string - isDefault: boolean - filter?: unknown - sort?: unknown - hiddenColumns?: string[] - updatedAt: Date | string - }> -): string { - return JSON.stringify( - { - views: views.map((view) => ({ - id: view.id, - name: view.name, - isDefault: view.isDefault, - filter: view.filter ?? null, - sort: view.sort ?? null, - hiddenColumns: view.hiddenColumns?.length ? view.hiddenColumns : undefined, - updatedAt: view.updatedAt instanceof Date ? view.updatedAt.toISOString() : view.updatedAt, - })), - note: 'Query a view via query_user_table {operation: "query_rows", args: {tableId, view: ""}} — the saved filter ANDs with any extra filter you pass. Manage views via the table agent (table_views).', - }, - null, - 2 - ) -} - -/** - * `account/workspace.json` — the current workspace as this viewer sees it: - * identity, the viewer's effective permission, org linkage, and fork parentage. - * - * Owns the current-workspace record. Org detail lives in - * `organization/organization.json` and fork topology in - * `organization/forks.json`; both are referenced here by id-and-name stub only, - * so a fact can never disagree with the file that owns it. - */ -export function serializeAccountWorkspace(input: { - workspace: { id: string; name: string; workspaceMode?: string | null } - viewer: { permission: string | null; organizationRole?: string | null } - organization: { id: string; name?: string | null } | null - forkedFrom: { id: string; name: string } | null - entitlements: string[] -}): string { - return JSON.stringify( - { - id: input.workspace.id, - name: input.workspace.name, - ...(input.workspace.workspaceMode ? { mode: input.workspace.workspaceMode } : {}), - yourPermission: input.viewer.permission, - organization: input.organization - ? { - id: input.organization.id, - ...(input.organization.name ? { name: input.organization.name } : {}), - ...(input.viewer.organizationRole ? { yourRole: input.viewer.organizationRole } : {}), - detail: 'organization/organization.json', - } - : null, - forkedFrom: input.forkedFrom - ? { - id: input.forkedFrom.id, - name: input.forkedFrom.name, - detail: 'organization/forks.json', - } - : null, - entitlements: input.entitlements, - note: 'Read-only. Your accessible workspaces are in account/workspaces.json; members in account/members.json; plan and usage in account/billing.json.', - }, - null, - 2 - ) -} - -/** - * `account/workspaces.json` — every workspace the viewer can reach, as stubs. - * - * Deliberately a roster, not a set of records: id, name, the viewer's role, and - * org/fork parentage by id. Anything richer about the *current* workspace is in - * `account/workspace.json`; other workspaces are not readable from here at all. - */ -export function serializeAccountWorkspaces( - workspaces: Array<{ - id: string - name: string - role: string - organizationId?: string | null - forkedFromWorkspaceId?: string | null - isCurrent: boolean - }> -): string { - return JSON.stringify( - { - workspaces: workspaces.map((workspace) => ({ - id: workspace.id, - name: workspace.name, - yourRole: workspace.role, - ...(workspace.organizationId ? { organizationId: workspace.organizationId } : {}), - ...(workspace.forkedFromWorkspaceId - ? { forkedFromWorkspaceId: workspace.forkedFromWorkspaceId } - : {}), - ...(workspace.isCurrent ? { isCurrent: true } : {}), - })), - note: 'Only the current workspace (isCurrent) is mounted in this VFS — the others are listed so you can name them, not read them. Switching workspaces is the user’s action, not yours.', - }, - null, - 2 - ) -} - -/** - * `account/members.json` — who is in the current workspace, with roles. - * - * `includeContactDetails` is the viewer's own admin bit: emails and pending - * invitations are the same privilege as the members settings page, so a - * non-admin viewer gets names and roles without contact details. - */ -export function serializeAccountMembers( - members: Array<{ - userId: string - name: string | null - email: string | null - permissionType: string - isExternal?: boolean - roleSource?: string - }>, - options: { includeContactDetails: boolean } -): string { - return JSON.stringify( - { - members: members.map((member) => ({ - userId: member.userId, - name: member.name ?? null, - ...(options.includeContactDetails && member.email ? { email: member.email } : {}), - role: member.permissionType, - ...(member.isExternal ? { isExternal: true } : {}), - ...(member.roleSource && member.roleSource !== 'explicit' - ? { roleSource: member.roleSource } - : {}), - })), - total: members.length, - ...(options.includeContactDetails - ? {} - : { note: 'Email addresses are shown to workspace admins only.' }), - }, - null, - 2 - ) -} - -/** - * `account/billing.json` — the acting user's live plan, usage, and credits. - * - * The only file that carries money and usage numbers; `organization.json` links - * here rather than repeating them. Read at request time, so the numbers are - * current rather than as-of-materialization. - */ -export function serializeAccountBilling(snapshot: { - plan: string - billingScope: 'user' | 'organization' - organizationId: string | null - usage: { - currentPeriodCost: number - limit: number - remaining: number - percentUsed: number - isExceeded: boolean - billingPeriodEnd: Date | string | null - } - credits: { balance: number; scope: 'user' | 'organization' } -}): string { - const periodEnd = snapshot.usage.billingPeriodEnd - return JSON.stringify( - { - plan: snapshot.plan, - billedTo: snapshot.billingScope, - ...(snapshot.organizationId ? { organizationId: snapshot.organizationId } : {}), - usage: { - currentPeriodCost: snapshot.usage.currentPeriodCost, - limit: snapshot.usage.limit, - remaining: snapshot.usage.remaining, - percentUsed: snapshot.usage.percentUsed, - isExceeded: snapshot.usage.isExceeded, - billingPeriodEnd: periodEnd instanceof Date ? periodEnd.toISOString() : periodEnd, - }, - credits: { balance: snapshot.credits.balance, scope: snapshot.credits.scope }, - note: 'Live values for the acting user, read at access time. What the plan tiers and credits mean is a documentation question, not a value in this file.', - }, - null, - 2 - ) -} - -/** - * `organization/organization.json` — the org that hosts this workspace and the - * viewer's standing in it. Owns the organization record; plan economics stay in - * `account/billing.json`. - */ -export function serializeOrganization(input: { - organization: { id: string; relationship: string; role: string | null } - capabilities: { canManageOrganization: boolean; canManageBilling: boolean } - plan: string | null - isEnterprise: boolean -}): string { - return JSON.stringify( - { - id: input.organization.id, - yourRelationship: input.organization.relationship, - yourRole: input.organization.role, - canManageOrganization: input.capabilities.canManageOrganization, - canManageBilling: input.capabilities.canManageBilling, - ...(input.plan ? { plan: input.plan } : {}), - isEnterprise: input.isEnterprise, - note: 'Plan usage and credits are in account/billing.json. Your effective restrictions are in organization/access-control.json.', - }, - null, - 2 - ) -} - -/** - * `organization/access-control.json` — who can see and do what, from the - * viewer's vantage: the permission group governing them and the restrictions it - * actually imposes. - * - * Scoped to the viewer on purpose. The full group roster is an org-admin - * settings surface, not workspace context. - */ -export function serializeAccessControl(input: { - entitled: boolean - permissionGroup: { id: string; name: string; resolution: string } | null - restrictions: Array<{ key: string; description: string }> -}): string { - return JSON.stringify( - { - entitled: input.entitled, - governingPermissionGroup: input.permissionGroup - ? { - id: input.permissionGroup.id, - name: input.permissionGroup.name, - appliedBecause: input.permissionGroup.resolution, - } - : null, - activeRestrictions: input.restrictions.map((restriction) => ({ - key: restriction.key, - description: restriction.description, - })), - note: 'These restrictions are enforced server-side on every action, so a blocked request fails no matter how it is phrased. They describe THIS user; other members may be governed by different groups.', - }, - null, - 2 - ) -} - -/** - * `organization/custom-blocks.json` — names-only index of org-published - * blocks, mirroring the root pattern: the index lists, the per-item file - * carries depth. Everything beyond name/enabled lives in - * `organization/custom-blocks/{type}.json`. - */ -export function serializeOrganizationCustomBlocks( - blocks: Array<{ - type: string - name: string - description?: string | null - enabled: boolean - workflowId: string - workflowName?: string | null - workspaceId: string | null - workspaceName?: string | null - }> -): string { - return JSON.stringify( - { - customBlocks: blocks.map((block) => ({ - type: block.type, - name: block.name, - enabled: block.enabled, - detail: `organization/custom-blocks/${block.type}.json`, - })), - note: 'Names only — provenance and the deployed workflow graph are in each detail file. Start at organization/README.md.', - }, - null, - 2 - ) -} - -/** - * `organization/custom-blocks/{type}.json` — one published block in depth: - * provenance, the callable-schema pointer, and a READ-ONLY view of the - * deployed workflow graph backing it (blocks/edges as deployed, not the - * publishing workspace's live editor state). Publishing a block org-wide is - * the act of sharing it, which is what justifies this cross-workspace read. - */ -export function serializeOrgCustomBlockDetail( - block: { - type: string - name: string - description?: string | null - enabled: boolean - workflowId: string - workflowName?: string | null - workspaceId: string | null - workspaceName?: string | null - }, - deployedState: unknown -): string { - return JSON.stringify( - { - type: block.type, - name: block.name, - ...(block.description ? { description: block.description } : {}), - enabled: block.enabled, - publishedFrom: { - workflowId: block.workflowId, - ...(block.workflowName ? { workflowName: block.workflowName } : {}), - ...(block.workspaceId ? { workspaceId: block.workspaceId } : {}), - ...(block.workspaceName ? { workspaceName: block.workspaceName } : {}), - }, - ...(block.enabled ? { schema: `components/blocks/${block.type}.json` } : {}), - deployedWorkflowState: deployedState, - note: 'Read-only: this is the DEPLOYED graph the block executes, not live editor state, and it cannot be edited from here. Credential ids and {{ENV_VAR}} references inside it belong to the publishing workspace and resolve only there. To wire the block into a workflow, use its schema under components/blocks/.', - }, - null, - 2 - ) -} - -/** - * `organization/README.md` — the namespace guide, playing the role - * WORKSPACE.md plays at the root: what each file is for and how to use it, - * plus the in-depth custom-block inventory the names-only index defers. - */ -export function buildOrganizationReadme(input: { - organizationId: string - isEnterprise: boolean - customBlocks: Array<{ - type: string - name: string - enabled: boolean - workflowName?: string | null - workspaceName?: string | null - }> - forksMounted: boolean - permissionGroupsMounted: boolean - connectedAccountsMounted: boolean -}): string { - const lines: string[] = [ - '# Organization', - '', - `Read-only truth about organization \`${input.organizationId}\` as the acting user sees it. Nothing here is writable — org membership, permission groups, block publishing, and forking are all managed in the Sim UI.`, - '', - '## Files', - '', - '- `organization.json` — org identity, your relationship (internal/external) and role, who can manage it. Plan usage and credits live in `account/billing.json`, not here.', - '- `access-control.json` — the permission group governing YOU and the restrictions it enforces. Restrictions are enforced server-side on every action, so consult this before promising an action is possible. It describes this user only.', - '- `custom-blocks.json` — names-only index of org-published blocks.', - '- `custom-blocks/{type}.json` — one block in depth: provenance and a read-only view of the DEPLOYED workflow graph backing it (org members only). To add the block to a workflow, use its callable schema at `components/blocks/{type}.json`; the deployed graph is for understanding what the block does, not for editing.', - '- `workspaces.json` — every workspace in the organization with your access flag and fork parentage (org members only).', - ] - if (input.permissionGroupsMounted) { - lines.push( - '- `permission-groups.json` — the admin roster: every group with member count, targeted workspaces, and active restrictions.' - ) - } - if (input.connectedAccountsMounted) { - lines.push( - '- `connected-accounts.json` — the workspace’s account configuration and provider readiness (workspace admins only). Use the Connected Accounts block in workflows.' - ) - } - if (input.forksMounted) { - lines.push( - "- `forks.json` — this workspace's place in the fork tree and what was mapped from the parent. Forking, promoting, and rolling back are admin actions in the UI." - ) - } - lines.push('', '## Published custom blocks', '') - if (input.customBlocks.length === 0) { - lines.push('None published yet.') - } else { - for (const block of input.customBlocks) { - const from = [block.workflowName, block.workspaceName].filter(Boolean).join(' in ') - lines.push( - `- **${block.name}** (\`${block.type}\`)${block.enabled ? '' : ' — disabled'}${from ? ` — published from ${from}` : ''}` - ) - } - } - lines.push('') - return lines.join('\n') -} - -/** - * `organization/workspaces.json` — the org's workspace map: every workspace in - * the organization, with whether the viewer can open it and its fork - * parentage. Broader than `account/workspaces.json`, which lists only what the - * viewer can reach. - */ -export function serializeOrganizationWorkspaces( - workspaces: Array<{ - id: string - name: string - hasAccess: boolean - forkedFromWorkspaceId?: string | null - }> -): string { - return JSON.stringify( - { - workspaces: workspaces.map((entry) => ({ - id: entry.id, - name: entry.name, - hasAccess: entry.hasAccess, - ...(entry.forkedFromWorkspaceId - ? { forkedFromWorkspaceId: entry.forkedFromWorkspaceId } - : {}), - })), - note: 'Every workspace in the organization. hasAccess is YOUR access; workspaces without it are nameable, not readable, and only the current workspace is mounted in this VFS.', - }, - null, - 2 - ) -} - -/** - * `organization/permission-groups.json` — the org-admin roster: every group - * with member count, targeted workspaces, and the restrictions its config - * activates. `access-control.json` stays the per-viewer view; this is the - * management matrix. - */ -export function serializePermissionGroupRoster( - groups: Array<{ - id: string - name: string - description: string | null - isDefault: boolean - memberCount: number - workspaces: Array<{ id: string; name: string }> - activeRestrictions: Array<{ key: string; description: string }> - }> -): string { - return JSON.stringify( - { - permissionGroups: groups.map((group) => ({ - id: group.id, - name: group.name, - ...(group.description ? { description: group.description } : {}), - isDefault: group.isDefault, - memberCount: group.memberCount, - workspaces: group.workspaces, - activeRestrictions: group.activeRestrictions, - })), - note: 'Management view (org admins). The group governing THIS user, with resolution reason, is in access-control.json. Group membership and scopes are edited in the Sim UI.', - }, - null, - 2 - ) -} - -/** Serializes the singleton account configuration, excluding credentials and enrollee data. */ -export function serializeConnectedAccounts(accounts: { - status: 'active' | 'disabled' - options: Array<{ - provider: string - label?: string | null - required?: boolean - status: 'active' | 'disabled' - configurationStatus: string - }> -}): string { - return JSON.stringify( - { - status: accounts.status, - options: accounts.options.map((option) => ({ - provider: option.provider, - ...(option.label ? { label: option.label } : {}), - ...(option.required !== undefined ? { required: option.required } : {}), - status: option.status, - configurationStatus: option.configurationStatus, - })), - note: 'Manage these accounts in Settings > Connected accounts. Workflows use the Connected Accounts block in their own workspace; no container selection is required. Search uses each person’s connected account to determine document access. Account configuration does not grant access to another person’s credentials or documents.', - }, - null, - 2 - ) -} - -/** - * `organization/forks.json` — this workspace's place in the fork tree plus the - * parent/child resource and block mappings. - * - * Owns fork topology; rosters elsewhere carry only `forkedFromWorkspaceId`. - * Mapping counts are summarized per resource type — the raw id pairs are an - * implementation detail of promote/rollback, not workspace context. - */ -export function serializeWorkspaceForks(input: { - parent: { id: string; name: string } | null - children: Array<{ id: string; name: string; createdAt: Date | string }> - resourceMappingCounts: Record - blockMappingCount: number -}): string { - return JSON.stringify( - { - parent: input.parent, - children: input.children.map((child) => ({ - id: child.id, - name: child.name, - createdAt: - child.createdAt instanceof Date ? child.createdAt.toISOString() : child.createdAt, - })), - ...(input.parent - ? { - mappedFromParent: { - resources: input.resourceMappingCounts, - blocks: input.blockMappingCount, - }, - } - : {}), - note: 'A forked workspace keeps a mapping back to the resources it was copied from, which is what promote and rollback follow. Forking, promoting, and rolling back are workspace-admin actions in the UI — you cannot perform them.', - }, - null, - 2 - ) -} diff --git a/apps/sim/lib/copilot/vfs/service-account-gate.test.ts b/apps/sim/lib/copilot/vfs/service-account-gate.test.ts deleted file mode 100644 index 500d9bddfe8..00000000000 --- a/apps/sim/lib/copilot/vfs/service-account-gate.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockGetBlock } = vi.hoisted(() => ({ mockGetBlock: vi.fn() })) -vi.mock('@/blocks', () => ({ getBlock: mockGetBlock })) - -import { describeServiceAccountForOAuthProvider } from '@/lib/copilot/vfs/serializers' - -describe('describeServiceAccountForOAuthProvider — owning block visibility', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('omits a service account whose gating block is still a preview block', () => { - mockGetBlock.mockReturnValue({ type: 'slack_v2', preview: true }) - expect(describeServiceAccountForOAuthProvider('slack')).toBeUndefined() - }) - - it('includes it for the released owning block', () => { - mockGetBlock.mockReturnValue({ type: 'slack_v2' }) - expect(describeServiceAccountForOAuthProvider('slack')).toEqual({ connectNoun: 'custom bot' }) - }) - - it('includes it when a preview block owns the serialized tool', () => { - mockGetBlock.mockReturnValue({ type: 'slack_v2', preview: true }) - - expect(describeServiceAccountForOAuthProvider('slack', 'slack_v2')).toEqual({ - connectNoun: 'custom bot', - }) - }) - - it('fail-closes (omits) when the gating block is missing entirely', () => { - mockGetBlock.mockReturnValue(undefined) - expect(describeServiceAccountForOAuthProvider('slack')).toBeUndefined() - }) - - it('includes an ungated provider without consulting the block registry', () => { - expect(describeServiceAccountForOAuthProvider('notion')).toEqual({ - connectNoun: 'integration secret', - }) - expect(mockGetBlock).not.toHaveBeenCalled() - }) - - it('returns undefined for a provider with no service-account flow', () => { - expect(describeServiceAccountForOAuthProvider('github')).toBeUndefined() - }) -}) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts deleted file mode 100644 index 1d71c652022..00000000000 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * @vitest-environment node - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { renderDocToGrid } = vi.hoisted(() => ({ - renderDocToGrid: vi.fn(), -})) - -const { findWorkspaceFileRecord, listAllWorkspaceFilesExecute, readWorkspaceFileContentExecute } = - vi.hoisted(() => ({ - findWorkspaceFileRecord: vi.fn(), - listAllWorkspaceFilesExecute: vi.fn(), - readWorkspaceFileContentExecute: vi.fn(), - })) - -const { isCustomBlocksEligible, listCustomBlocksWithInputsForWorkspace } = vi.hoisted(() => ({ - isCustomBlocksEligible: vi.fn().mockResolvedValue(false), - listCustomBlocksWithInputsForWorkspace: vi.fn(), -})) - -vi.mock('@/lib/copilot/tools/server/files/doc-render', () => ({ - // `odt` exposes the defensive missing-task branch independently from the extension guard. - isRenderableDocExt: (ext: string) => ['docx', 'odt', 'pdf', 'pptx'].includes(ext.toLowerCase()), - renderDocToGrid, -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - findWorkspaceFileRecord, -})) - -vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ - listAllWorkspaceFiles: { execute: listAllWorkspaceFilesExecute }, -})) - -vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ - readWorkspaceFileContent: { execute: readWorkspaceFileContentExecute }, -})) - -vi.mock('@/lib/workflows/custom-blocks/operations', () => ({ - isCustomBlocksEligible, - listCustomBlocksWithInputsForWorkspace, -})) - -/** None of these suites list catalog entries, and each real registry loads every definition it holds. */ -vi.mock('@/blocks/registry-maps', () => ({ BLOCK_REGISTRY: {}, BLOCK_META_REGISTRY: {} })) -vi.mock('@/connectors/registry.server', () => ({ CONNECTOR_REGISTRY: {} })) -vi.mock('@/triggers/registry', () => ({ TRIGGER_REGISTRY: {} })) - -import { WorkspaceVFS } from '@/lib/copilot/vfs/workspace-vfs' -import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' - -const MAX_DOC_READ_INPUT_BYTES = 50 * 1024 * 1024 -const MAX_DOCUMENT_PREVIEW_CODE_BYTES = 1024 * 1024 - -interface TestableWorkspaceVFS { - loadCustomBlocks(workspaceId: string): Promise -} - -function customBlockLoader(vfs: WorkspaceVFS): TestableWorkspaceVFS { - return vfs as unknown as TestableWorkspaceVFS -} - -describe('WorkspaceVFS custom block loading', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('shares a successful custom block load within one VFS instance', async () => { - const blocks = [{ id: 'custom-block-1' }] - listCustomBlocksWithInputsForWorkspace.mockResolvedValueOnce(blocks) - const loader = customBlockLoader(new WorkspaceVFS()) - - const [first, second] = await Promise.all([ - loader.loadCustomBlocks('workspace-1'), - loader.loadCustomBlocks('workspace-1'), - ]) - - expect(first).toBe(blocks) - expect(second).toBe(blocks) - expect(listCustomBlocksWithInputsForWorkspace).toHaveBeenCalledTimes(1) - }) - - it('retries a custom block load after failure', async () => { - const blocks = [{ id: 'custom-block-1' }] - listCustomBlocksWithInputsForWorkspace - .mockRejectedValueOnce(new Error('temporary failure')) - .mockResolvedValueOnce(blocks) - const loader = customBlockLoader(new WorkspaceVFS()) - - await expect(loader.loadCustomBlocks('workspace-1')).rejects.toThrow('temporary failure') - await expect(loader.loadCustomBlocks('workspace-1')).resolves.toBe(blocks) - expect(listCustomBlocksWithInputsForWorkspace).toHaveBeenCalledTimes(2) - }) -}) - -function arrangeRenderRead({ - name = 'brief.pdf', - size = 8, - content = Buffer.from('%PDF-1.7'), -}: { - name?: string - size?: number - content?: Buffer | { length: number } -} = {}) { - const record = { - id: 'file-1', - workspaceId: 'ws-1', - name, - key: name, - path: `/api/files/serve/${name}`, - size, - type: 'application/octet-stream', - uploadedBy: 'user-1', - deletedAt: null, - uploadedAt: new Date('2026-01-01T00:00:00.000Z'), - updatedAt: new Date('2026-01-01T00:00:00.000Z'), - storageContext: 'mothership' as const, - } - listAllWorkspaceFilesExecute.mockResolvedValue({ files: [record] }) - findWorkspaceFileRecord.mockReturnValue(record) - readWorkspaceFileContentExecute.mockResolvedValue({ content }) - - const vfs = new WorkspaceVFS({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) - Object.assign(vfs, { _workspaceId: 'ws-1' }) - return vfs -} - -describe('WorkspaceVFS dynamic render reads', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('marks render exceptions as file read errors', async () => { - const vfs = arrangeRenderRead() - renderDocToGrid.mockRejectedValue( - new Error('Document compiler not configured (MOTHERSHIP_E2B_DOC_TEMPLATE_ID is unset)') - ) - - const result = await vfs.readFileContent('files/brief.pdf/render') - - expect(result).toEqual({ - content: - '{"ok":false,"error":"Document compiler not configured (MOTHERSHIP_E2B_DOC_TEMPLATE_ID is unset)"}', - totalLines: 1, - error: 'Document compiler not configured (MOTHERSHIP_E2B_DOC_TEMPLATE_ID is unset)', - }) - }) - - it.each([ - { - label: 'unsupported extensions', - name: 'brief.txt', - error: 'Render supports .pptx, .docx, and .pdf only', - }, - { - label: 'oversized file metadata', - size: MAX_DOC_READ_INPUT_BYTES + 1, - error: 'File is too large to render', - }, - { - label: 'oversized fetched buffers', - content: { length: MAX_DOC_READ_INPUT_BYTES + 1 }, - error: 'File is too large to render', - }, - { - label: 'oversized source', - content: Buffer.alloc(MAX_DOCUMENT_PREVIEW_CODE_BYTES + 1, 'a'), - error: 'File source exceeds maximum size', - }, - { - label: 'missing render tasks', - name: 'brief.odt', - content: Buffer.from('document source'), - error: 'Cannot render this file', - }, - ])('marks $label as file read errors', async ({ name, size, content, error }) => { - const vfs = arrangeRenderRead({ name, size, content }) - - const result = await vfs.readFileContent(`files/${name ?? 'brief.pdf'}/render`) - - expect(result).toEqual({ - content: JSON.stringify({ ok: false, error }), - totalLines: 1, - error, - }) - }) -}) - -describe('WorkspaceVFS lazy grep resilience', () => { - it('skips an unmaterializable lazy artifact instead of failing the whole sweep', async () => { - const vfs = new WorkspaceVFS({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) - const internals = vfs as unknown as { - files: Map - registerLazy: (path: string, loader: () => Promise) => void - resolveLazyPath: (path: string) => Promise - } - internals.files.set('workflows/A/state.json', '{"needle": true}') - internals.registerLazy.call(vfs, 'knowledgebases/huge/documents.json', async () => { - throw new Error( - 'Knowledge base kb-1 has more than 10000 documents; documents.json cannot be materialized' - ) - }) - internals.registerLazy.call( - vfs, - 'knowledgebases/small/documents.json', - async () => '{"needle": "lazy"}' - ) - - const matches = (await vfs.grep('needle')) as Array<{ path: string }> - const paths = matches.map((m) => m.path) - expect(paths).toContain('workflows/A/state.json') - expect(paths).toContain('knowledgebases/small/documents.json') - - // Reading the failing artifact directly still surfaces its own error, and - // the loader stays re-armed for that read. - await expect( - internals.resolveLazyPath.call(vfs, 'knowledgebases/huge/documents.json') - ).rejects.toThrow('cannot be materialized') - }) -}) - -describe('WorkspaceVFS oversized content reads', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - function arrangeOversizedContentRead() { - const record = { - id: 'file-big', - workspaceId: 'ws-1', - name: 'big.tsv', - key: 'big.tsv', - path: '/api/files/serve/big.tsv', - size: 7_500_000, - type: 'text/tab-separated-values', - uploadedBy: 'user-1', - deletedAt: null, - uploadedAt: new Date('2026-01-01T00:00:00.000Z'), - updatedAt: new Date('2026-01-01T00:00:00.000Z'), - storageContext: 'workspace' as const, - } - listAllWorkspaceFilesExecute.mockResolvedValue({ files: [record] }) - findWorkspaceFileRecord.mockReturnValue(record) - readWorkspaceFileContentExecute.mockRejectedValue( - new PayloadSizeLimitError({ label: 'Workspace file', maxBytes: 20_971_520 }) - ) - - const vfs = new WorkspaceVFS({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) - Object.assign(vfs, { _workspaceId: 'ws-1' }) - const internals = vfs as unknown as { files: Map } - internals.files.set('files/big.tsv', '') - return vfs - } - - it('answers a cap breach with an oversized placeholder, not "not found"', async () => { - const vfs = arrangeOversizedContentRead() - - const result = await vfs.readFileContent('files/big.tsv/content') - - expect(result).not.toBeNull() - expect(result).toMatchObject({ placeholder: 'oversized' }) - expect(result?.content).toContain('File too large') - expect(result?.content).toContain('big.tsv') - }) - - it('reports a cap breach honestly for grep instead of "content not found"', async () => { - const vfs = arrangeOversizedContentRead() - - await expect(vfs.grepFile('files/big.tsv', 'needle')).rejects.toThrow(/too large to search/) - }) -}) - -describe('WorkspaceVFS decoded-equivalent resolution', () => { - it('resolves a decoded path to its single encoded twin and rejects ambiguity', () => { - const vfs = new WorkspaceVFS({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) - const internals = vfs as unknown as { files: Map } - internals.files.set('workflows/Elder%20v2/The%20Elder/state.json', '{}') - - expect(vfs.resolveDecodedEquivalent('workflows/Elder v2/The Elder/state.json')).toBe( - 'workflows/Elder%20v2/The%20Elder/state.json' - ) - expect(vfs.resolveDecodedEquivalent('workflows/Elder v2/The Elder/meta.json')).toBeNull() - - // Two keys decoding identically (pathological) must refuse to guess. - internals.files.set('workflows/Elder v2/The Elder/state.json', '{}') - expect(vfs.resolveDecodedEquivalent('workflows/Elder v2/The Elder/state.json')).toBeNull() - }) -}) diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts deleted file mode 100644 index 03339aac020..00000000000 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ /dev/null @@ -1,3314 +0,0 @@ -import { trace } from '@opentelemetry/api' -import type { Principal } from '@sim/auth/principal' -import { db } from '@sim/db' -import { - chat as chatTable, - customTools as customToolsTable, - folder as folderTable, - mcpServers as mcpServersTable, - skill as skillTable, - workflowDeploymentVersion, - workflowExecutionLogs, - workflowMcpServer, - workflowMcpTool, -} from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { and, desc, eq, inArray, isNotNull, isNull, or } from 'drizzle-orm' -import { listApiKeys } from '@/lib/api-key/service' -import { getAccountBillingSnapshot } from '@/lib/billing/core/account-billing-snapshot' -import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' -import { createCopilotChatPrincipal } from '@/lib/copilot/auth/application-delegation' -import { - buildWorkspaceContextMd, - buildWorkspaceMd, - type WorkspaceMdData, -} from '@/lib/copilot/chat/workspace-context' -import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { - type DeniedBlockOperations, - projectIntegrationToolsForViewer, - resolveDeniedBlockOperations, -} from '@/lib/copilot/integration-tool-projection' -import { - type ExposedIntegrationTool, - getExposedIntegrationTools, -} from '@/lib/copilot/integration-tools' -import { recordVfsMaterialize } from '@/lib/copilot/request/metrics' -import { markSpanForError } from '@/lib/copilot/request/otel' -import { - filterSecretNamesByMountPolicy, - type SecretMountPolicy, -} from '@/lib/copilot/secret-mount-policy' -import { RESTRICTED_SIM_SANDBOX_INPUTS } from '@/lib/copilot/sim-sandbox-projection' -import { compileDoc, getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile' -import { extractDocText, isExtractableDocExt } from '@/lib/copilot/tools/server/files/doc-extract' -import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc' -import { isRenderableDocExt, renderDocToGrid } from '@/lib/copilot/tools/server/files/doc-render' -import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' -import { - type FileReadResult, - isReadableFileType, - MAX_IMAGE_SOURCE_BYTES, - MAX_TEXT_READ_BYTES, - readFileRecord, -} from '@/lib/copilot/vfs/file-reader' -import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' -import type { GrepMatch, GrepOptions, ReadResult } from '@/lib/copilot/vfs/operations' -import * as ops from '@/lib/copilot/vfs/operations' -import { - buildVfsFolderPathMap, - canonicalWorkflowVfsDir, - canonicalWorkspaceFilePath, - decodeVfsSegmentSafe, - encodeVfsPathSegments, -} from '@/lib/copilot/vfs/path-utils' -import { readPlaceholder } from '@/lib/copilot/vfs/read-placeholders' -import type { DeploymentData, VfsServiceAccountAuth } from '@/lib/copilot/vfs/serializers' -import { - buildOrganizationReadme, - describeServiceAccountForOAuthProvider, - serializeAccessControl, - serializeAccountBilling, - serializeAccountMembers, - serializeAccountWorkspace, - serializeAccountWorkspaces, - serializeApiKeyIntegrations, - serializeApiKeys, - serializeBlockSchema, - serializeBuiltinTriggerSchema, - serializeConnectedAccounts, - serializeConnectorOverview, - serializeConnectorSchema, - serializeConnectors, - serializeCredentials, - serializeCustomTool, - serializeDeployments, - serializeDocuments, - serializeEnvironmentVariables, - serializeFileMeta, - serializeIntegrationSchema, - serializeKBMeta, - serializeMcpServer, - serializeOrganization, - serializeOrganizationCustomBlocks, - serializeOrganizationWorkspaces, - serializeOrgCustomBlockDetail, - serializePermissionGroupRoster, - serializeRecentExecutions, - serializeSandbox, - serializeSandboxCatalog, - serializeSkill, - serializeTableMeta, - serializeTableViews, - serializeTriggerOverview, - serializeTriggerSchema, - serializeVersions, - serializeWorkflowMeta, - serializeWorkspaceForks, -} from '@/lib/copilot/vfs/serializers' -import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' -import { - getAllowedIntegrationsFromEnv, - isDocSandboxEnabled, - isHosted, -} from '@/lib/core/config/env-flags' -import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import type { CredentialGroupRecord } from '@/lib/credential-groups/types' -import { CREDENTIAL_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' -import { listPersonalCredentials } from '@/lib/credentials/application/personal-credentials' -import { - getAccessibleEnvCredentials, - getAccessibleOAuthCredentials, -} from '@/lib/credentials/environment' -import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' -import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' -import { - currentSandboxStrategy, - listWorkspaceSandboxes, -} from '@/lib/execution/remote-sandbox/workspace-sandboxes' -import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' -import { listFoldersForWorkspace } from '@/lib/folders/queries' -import { - isIntegrationDeploymentAvailableForVisibility, - isOAuthServiceDeploymentAvailable, -} from '@/lib/integrations/availability.server' -import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' -import { listKnowledgeConnectors } from '@/lib/knowledge/application/connectors' -import { listKnowledgeDocuments } from '@/lib/knowledge/application/documents' -import { - listKnowledgeBaseCatalog, - listKnowledgeBases, -} from '@/lib/knowledge/application/knowledge-bases' -import { validateMermaidSource } from '@/lib/mermaid/validate' -import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' -import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' -import { getActivePermissionGroupRestrictions } from '@/lib/permission-groups/features' -import { - intersectIntegrationAllowlists, - toAccessControlAllowlist, -} from '@/lib/permission-groups/integration-allowlist' -import type { IsToolAllowed } from '@/lib/permission-groups/operation-access' -import { - listOrganizationWorkspaceRefs, - listPermissionGroupRoster, -} from '@/lib/permission-groups/queries' -import { listTables } from '@/lib/table/service' -import { - listTableViewsByWorkspace, - normalizeStoredViewConfig, - pruneViewConfig, - viewConfigIdsToNames, -} from '@/lib/table/views/service' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { findWorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import type { - WorkspaceFileSecretProvenanceEnvelope, - WorkspaceFileSecretProvenanceIdentity, -} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { isImageFileType, resolveEffectiveMimeType } from '@/lib/uploads/utils/file-utils' -import { - type CustomBlockWithInputs, - listCustomBlocksWithInputsForWorkspace, -} from '@/lib/workflows/custom-blocks/operations' -import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' -import { checkNeedsRedeployment } from '@/lib/workflows/deployment-status' -import { collectWorkflowFieldIssues, lintEditedWorkflowState } from '@/lib/workflows/editing/lint' -import { UNRESOLVABLE_AT_LINT_NOTE } from '@/lib/workflows/editing/validation' -import { - loadDeployedWorkflowState, - loadWorkflowFromNormalizedTables, -} from '@/lib/workflows/persistence/utils' -import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' -import { getSkillById } from '@/lib/workflows/skills/operations' -import { listFolders, listWorkflows } from '@/lib/workflows/utils' -import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' -import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' -import { listWorkspaceFileFoldersOperation } from '@/lib/workspace-files/application/workspace-file-folders' -import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' -import { - collectSimPageDiagnostics, - isSimPageSource, - SIM_PAGE_CONTENT_TYPE, -} from '@/lib/workspace-files/page-compile' -import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context' -import { - assertActiveWorkspaceAccess, - getUsersWithPermissions, - getWorkspaceWithOwner, - hasWorkspaceAdminAccess, -} from '@/lib/workspaces/permissions/utils' -import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils' -import { buildCustomBlockConfig, isCustomBlockType } from '@/blocks/custom/build-config' -import { BLOCK_REGISTRY } from '@/blocks/registry-maps' -import type { BlockConfig, BlockIcon } from '@/blocks/types' -import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context' -import { CONNECTOR_REGISTRY } from '@/connectors/registry.server' -import { resolveVerifiedUserAccessControlContext } from '@/ee/access-control/utils/permission-check' -import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz' -import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage' -import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store' -import { getEdgeMappingRows } from '@/ee/workspace-forking/lib/mapping/mapping-store' -import type { ExecutableToolConfig } from '@/tools/types' -import { TRIGGER_REGISTRY } from '@/triggers/registry' - -const logger = createLogger('WorkspaceVFS') - -/** Placeholder icon for custom-block configs — `serializeBlockSchema` never reads it. */ -// double-cast-allowed: a no-op stands in for the unused SVG-typed BlockIcon slot -const PLACEHOLDER_BLOCK_ICON = (() => null) as unknown as BlockIcon -const MAX_COMPILED_ATTACHMENT_BYTES = 5 * 1024 * 1024 -const KNOWLEDGE_DOCUMENT_PAGE_SIZE = 100 -const MAX_VFS_KNOWLEDGE_DOCUMENTS = 10_000 - -function bindWorkspaceFileResult( - record: WorkspaceFileRecord, - value: T, - view: 'complete' | 'derived' = 'derived', - contributingFiles: readonly WorkspaceFileSecretProvenanceIdentity[] = [] -): WorkspaceFileSecretProvenanceEnvelope { - return { - value, - view, - file: { - fileId: record.id, - key: record.key, - context: record.storageContext ?? 'workspace', - }, - ...(contributingFiles.length > 0 ? { contributingFiles } : {}), - } -} - -function renderErrorResult(error: string): FileReadResult { - return { - content: JSON.stringify({ ok: false, error }), - totalLines: 1, - error, - } -} - -function recordContributingFile( - files: Map, - identity: WorkspaceFileSecretProvenanceIdentity -): void { - files.set(`${identity.context}:${identity.fileId}:${identity.key}`, identity) -} - -/** - * Static component files, computed once and shared across all VFS instances. - * Built from the UNGATED registry universe (preview blocks included) so this - * process-global cache can never be poisoned by one viewer's gated projection; - * per-viewer gating is applied when the map is stamped into each fresh VFS - * (see {@link isStaticFileHidden}). - */ -let staticComponentFiles: Map | null = null -let staticFunctionSchemaWithRestrictedSimSandboxes: string | null = null - -/** - * Owning block for each `components/integrations/**` file, recorded at build - * time. Block/trigger schema files carry their owning type as the path - * basename, but integration paths use the version-stripped service name — so - * their owners need this lookup for the stamp-time visibility filter. - */ -const integrationPathOwners = new Map>>() - -/** - * Owning block(s) for each `components/triggers/{provider}/{id}.json` file, - * recorded at build time by inverting each block's `triggers.available`. - * External-trigger paths are keyed on the trigger id + provider (not a block - * type), so — like integration paths — they need this lookup for the stamp-time - * visibility filter. A trigger can be reachable from more than one block (e.g. a - * GA block and its preview successor), so this holds an array and the trigger is - * hidden only when EVERY owning block is hidden. - */ -const triggerPathOwners = new Map>>() - -/** - * Per-request visibility filter for the shared static files: hides files whose - * owning block is gated for this viewer (unrevealed preview blocks — the - * default with no context — and kill-switched types). Non-registry paths - * (loop/parallel, connectors, overviews) are always visible. - */ -function isBlockOwnerHidden( - owner: Pick, - vis: BlockVisibilityState | null, - gate: StaticFileGate -): boolean { - const config = BLOCK_REGISTRY[owner.type] - if (config?.hideFromToolbar) return true - if (!isIntegrationDeploymentAvailableForVisibility(owner.type, vis)) return true - if ( - gate.allowedIntegrationTypes !== null && - !isBlockTypeAccessControlExempt(owner.type) && - !gate.allowedIntegrationTypes.has(owner.type.toLowerCase()) - ) { - return true - } - /* Every operation denied leaves nothing the viewer could configure, so the - block is withheld outright rather than published with an empty selector. */ - if (gate.fullyDeniedBlockTypes.has(owner.type)) return true - return isHiddenUnder(vis, owner) -} - -/** - * The per-viewer gates the static-file filter applies, carried together so a - * caller cannot pass one and forget the other. - */ -interface StaticFileGate { - /** Lowercased block types the viewer may use; `null` when unrestricted. */ - allowedIntegrationTypes: ReadonlySet | null - /** Block types whose every selectable operation the viewer's group denies. */ - fullyDeniedBlockTypes: ReadonlySet -} - -const UNGATED_STATIC_FILES: StaticFileGate = { - allowedIntegrationTypes: null, - fullyDeniedBlockTypes: new Set(), -} - -function isStaticFileHidden( - path: string, - vis: BlockVisibilityState | null, - gate: StaticFileGate = UNGATED_STATIC_FILES -): boolean { - const blockMatch = path.match(/^components\/(?:blocks|triggers\/sim)\/([^/]+)\.json$/) - if (blockMatch) { - const config = BLOCK_REGISTRY[blockMatch[1]!] - return config ? isBlockOwnerHidden(config, vis, gate) : false - } - const triggerOwners = triggerPathOwners.get(path) - if (triggerOwners) { - return ( - triggerOwners.length > 0 && - triggerOwners.every((owner) => isBlockOwnerHidden(owner, vis, gate)) - ) - } - const owners = integrationPathOwners.get(path) - return owners - ? owners.length > 0 && owners.every((owner) => isBlockOwnerHidden(owner, vis, gate)) - : false -} - -function buildIntegrationAggregateFiles( - exposedTools: readonly ExposedIntegrationTool[] -): Map { - const oauthServices = new Map< - string, - { - provider: string - operations: string[] - oauthAvailable: boolean - serviceAccount?: VfsServiceAccountAuth - } - >() - for (const { config: tool, service, operation, blockType } of exposedTools) { - if (!tool.oauth?.required) continue - const oauthAvailable = isOAuthServiceDeploymentAvailable(tool.oauth.provider) - const serviceAccount = describeServiceAccountForOAuthProvider(tool.oauth.provider, blockType) - if (!oauthAvailable && !serviceAccount) continue - const existing = oauthServices.get(service) - if (existing) { - existing.operations.push(operation) - existing.oauthAvailable ||= oauthAvailable - existing.serviceAccount ??= serviceAccount - } else { - oauthServices.set(service, { - provider: tool.oauth.provider, - operations: [operation], - oauthAvailable, - serviceAccount, - }) - } - } - - return new Map([ - [ - 'environment/oauth-integrations.json', - JSON.stringify(Object.fromEntries(oauthServices), null, 2), - ], - ['environment/api-key-integrations.json', serializeApiKeyIntegrations(exposedTools, isHosted)], - ]) -} - -function buildTriggerOverview(vis: BlockVisibilityState | null, gate: StaticFileGate): string { - const builtinTriggers = Object.values(BLOCK_REGISTRY) - .filter( - (block) => - block.category === 'triggers' && - !block.preview && - !isStaticFileHidden(`components/triggers/sim/${block.type}.json`, vis, gate) - ) - .map((block) => ({ - id: block.type, - name: block.name, - provider: 'sim', - description: block.description, - })) - const externalTriggers = Object.entries(TRIGGER_REGISTRY) - .filter( - ([id, trigger]) => - !isStaticFileHidden(`components/triggers/${trigger.provider}/${id}.json`, vis, gate) - ) - .map(([id, trigger]) => ({ - id, - name: trigger.name, - provider: trigger.provider, - description: trigger.description, - })) - return serializeTriggerOverview(builtinTriggers, externalTriggers) -} - -// On-the-fly doc reads (render/extract) download the binary into the Sim process -// and base64-stage it to E2B, so bound the input like the compile path's staging -// caps — otherwise an authenticated member could OOM the worker with a multi-GB -// upload (uploads are capped at 5GB). -const MAX_DOC_READ_INPUT_BYTES = 50 * 1024 * 1024 - -/** - * True when the buffer is an actual compiled/uploaded binary (vs a source-backed - * generated doc). OOXML (pptx/docx/xlsx) is a ZIP (starts `PK`); PDFs may carry a - * BOM or leading whitespace before `%PDF`, so scan the head rather than offset 0. - */ -function isBinaryDocBuffer(buffer: Buffer, ext: string): boolean { - if (ext === 'pdf') return buffer.subarray(0, 1024).toString('latin1').includes('%PDF') - return buffer.subarray(0, 2).toString('latin1') === 'PK' -} - -/** - * Tool configs keyed by every id a block schema may reference, memoized for the - * process. Shared by the one-time static build and the per-viewer re-projection - * of a block whose operations are partly denied. - */ -let staticToolConfigs: ReadonlyMap | null = null - -function getStaticToolConfigs(): ReadonlyMap { - if (staticToolConfigs) return staticToolConfigs - const configs = new Map() - for (const { toolId, config } of getExposedIntegrationTools()) { - configs.set(toolId, config) - configs.set(config.id, config) - } - staticToolConfigs = configs - return configs -} - -const BLOCK_SCHEMA_PATH_PREFIX = 'components/blocks/' -const INTEGRATION_SCHEMA_PATH_PREFIX = 'components/integrations/' - -/** The per-viewer projections applied to a shared static component file. */ -interface StaticFileProjection { - sandboxEntitled: boolean - deniedOperations: DeniedBlockOperations - isToolAllowed: IsToolAllowed -} - -/** - * The viewer's copy of one shared static component file. - * - * Returns the shared string untouched unless this viewer actually loses - * something, so the process-global build stays the hot path and only a block - * carrying a denied operation pays for a re-serialization. - */ -function projectStaticComponentFile( - path: string, - content: string, - projection: StaticFileProjection -): string { - if (path === 'components/blocks/function.json' && !projection.sandboxEntitled) { - return staticFunctionSchemaWithRestrictedSimSandboxes ?? content - } - if (projection.deniedOperations.needsProjection.size === 0) return content - if (!path.startsWith(BLOCK_SCHEMA_PATH_PREFIX)) return content - - const blockType = path.match(/^components\/blocks\/([^/]+)\.json$/)?.[1] - if (!blockType) return content - const deniedOperationIds = projection.deniedOperations.needsProjection.get(blockType) - if (!deniedOperationIds) return content - const block = BLOCK_REGISTRY[blockType] - if (!block) return content - - return serializeBlockSchema(block, { - toolConfigs: getStaticToolConfigs(), - deniedOperationIds, - isToolAllowed: projection.isToolAllowed, - }) -} - -/** - * Build the static component files from block and tool registries. - * This only needs to happen once per process. - * - * Integration paths are derived deterministically from the block registry's - * `tools.access` arrays rather than splitting tool IDs on underscores. - * Each block declares which tools it owns, and the block type (minus version - * suffix) becomes the service directory name. - */ -function getStaticComponentFiles(): Map { - if (staticComponentFiles) return staticComponentFiles - - const files = new Map() - - // Raw registry, never the visibility-projected getAllBlocks: this map is a - // process-global shared cache, so it must hold the deterministic ungated - // universe. Preview blocks get schema files here and are filtered per viewer - // at stamp time. Viewer-specific aggregate files are built during materialization. - const allBlocks = Object.values(BLOCK_REGISTRY) - const visibleBlocks = allBlocks.filter((block) => !block.hideFromToolbar) - const exposedTools = getExposedIntegrationTools() - const toolConfigs = getStaticToolConfigs() - - let blocksFiltered = 0 - for (const block of visibleBlocks) { - const path = `components/blocks/${block.type}.json` - files.set(path, serializeBlockSchema(block, { toolConfigs })) - if (block.type === 'function') { - staticFunctionSchemaWithRestrictedSimSandboxes = serializeBlockSchema(block, { - toolConfigs, - restrictedInputs: RESTRICTED_SIM_SANDBOX_INPUTS, - }) - } - } - blocksFiltered = allBlocks.length - visibleBlocks.length - - let integrationCount = 0 - - // Integration tools come from the shared exposed-tool set (latest version of - // each operation owned by a visible block), the same set used to build the - // deferred callable tools — so discovery and execution can never drift. - for (const exposedTool of exposedTools) { - const { config: tool, service, operation } = exposedTool - const path = `components/integrations/${service}/${operation}.json` - files.set( - path, - serializeIntegrationSchema(tool, { - oauthAvailable: !tool.oauth || isOAuthServiceDeploymentAvailable(tool.oauth.provider), - }) - ) - const owners = integrationPathOwners.get(path) ?? [] - for (const owner of exposedTool.owners) { - if (!owners.some((existing) => existing.type === owner.blockType)) { - owners.push({ type: owner.blockType, preview: owner.preview }) - } - } - integrationPathOwners.set(path, owners) - integrationCount++ - } - - files.set( - 'components/blocks/loop.json', - JSON.stringify( - { - type: 'loop', - name: 'Loop', - description: - 'Iterate over a collection or repeat a fixed number of times. Blocks inside the loop run once per iteration.', - inputs: { - loopType: { - type: 'string', - enum: ['for', 'forEach', 'while', 'doWhile'], - description: 'Loop strategy', - }, - iterations: { type: 'number', description: 'Number of iterations (for loopType "for")' }, - collection: { - type: 'string', - description: 'Collection expression to iterate (for loopType "forEach")', - }, - condition: { - type: 'string', - description: 'Condition expression (for loopType "while" or "doWhile")', - }, - }, - sourceHandles: ['loop-start-source', 'loop-end-source'], - notes: - 'Use "loop-start-source" to connect to blocks INSIDE the loop. Use "loop-end-source" for the edge that runs AFTER the loop completes. Do NOT use "source" for a loop block — it is rejected; the only valid source handles are "loop-start-source", "loop-end-source", and "error". Blocks inside the loop must have parentId set to the loop block ID.', - }, - null, - 2 - ) - ) - - files.set( - 'components/blocks/parallel.json', - JSON.stringify( - { - type: 'parallel', - name: 'Parallel', - description: 'Run blocks in parallel branches. All branches execute concurrently.', - inputs: { - parallelType: { - type: 'string', - enum: ['count', 'collection'], - description: 'Parallel strategy', - }, - count: { - type: 'number', - description: 'Number of parallel branches (for parallelType "count")', - }, - collection: { - type: 'string', - description: 'Collection to distribute (for parallelType "collection")', - }, - }, - sourceHandles: ['parallel-start-source', 'parallel-end-source'], - notes: - 'Use "parallel-start-source" to connect to blocks INSIDE the parallel container. Use "parallel-end-source" for the edge AFTER all branches complete. Do NOT use "source" for a parallel block — it is rejected; the only valid source handles are "parallel-start-source", "parallel-end-source", and "error". Blocks inside must have parentId set to the parallel block ID.', - }, - null, - 2 - ) - ) - - const connectorConfigs = Object.values(CONNECTOR_REGISTRY).map((c) => ({ - id: c.id, - name: c.name, - description: c.description, - version: c.version, - auth: c.auth, - configFields: c.configFields, - tagDefinitions: c.tagDefinitions, - supportsIncrementalSync: c.supportsIncrementalSync, - })) - - files.set('knowledgebases/connectors/connectors.md', serializeConnectorOverview(connectorConfigs)) - for (const cc of connectorConfigs) { - files.set(`knowledgebases/connectors/${cc.id}.json`, serializeConnectorSchema(cc)) - } - - const builtinTriggerBlocks = allBlocks.filter((b) => b.category === 'triggers') - for (const block of builtinTriggerBlocks) { - files.set(`components/triggers/sim/${block.type}.json`, serializeBuiltinTriggerSchema(block)) - } - - // Attribute each external trigger to its owning block(s) by inverting - // `triggers.available` — the same block-visibility rules that gate a block's - // schema file then gate its triggers' schema files at stamp time. - for (const block of allBlocks) { - for (const triggerId of block.triggers?.available ?? []) { - const trigger = TRIGGER_REGISTRY[triggerId] - if (!trigger) continue - const path = `components/triggers/${trigger.provider}/${triggerId}.json` - const owners = triggerPathOwners.get(path) - const owner = { type: block.type, preview: block.preview } - if (owners) owners.push(owner) - else triggerPathOwners.set(path, [owner]) - } - } - - let externalTriggerCount = 0 - for (const [triggerId, trigger] of Object.entries(TRIGGER_REGISTRY)) { - const path = `components/triggers/${trigger.provider}/${triggerId}.json` - files.set(path, serializeTriggerSchema(trigger)) - externalTriggerCount++ - } - - files.set('components/triggers/triggers.md', buildTriggerOverview(null, UNGATED_STATIC_FILES)) - - logger.info('Static component files built', { - blocks: visibleBlocks.length, - blocksFiltered, - integrations: integrationCount, - connectors: connectorConfigs.length, - builtinTriggers: builtinTriggerBlocks.length, - externalTriggers: externalTriggerCount, - }) - - staticComponentFiles = files - return staticComponentFiles -} - -/** - * Virtual Filesystem that materializes workspace data into an in-memory Map. - * - * Structure: - * WORKSPACE_CONTEXT.md — full dynamic workspace/user context (auto-generated) - * WORKSPACE.md — workspace inventory summary (auto-generated) - * workflows/{name}/meta.json (root-level workflows) - * workflows/{name}/state.json (sanitized blocks with embedded connections) - * workflows/{name}/lint.json (sources/sinks, required-field, credential/resource issues) - * workflows/{name}/executions.json - * workflows/{name}/deployment.json - * workflows/{folder}/{name}/... (workflows inside folders, nested folders supported) - * knowledgebases/{name}/meta.json - * knowledgebases/{name}/documents.json - * knowledgebases/{name}/connectors.json - * tables/{name}/meta.json - * files/{name} (workspace file leaf; dynamic content on read) - * files/{path}/{name}/style (dynamic — style extraction for .docx/.pptx/.pdf) - * files/{path}/{name}/compiled-check (dynamic — compile generated source / validate diagrams, returns {ok,error?}) - * custom-tools/{name}.json - * agent/sandboxes/README.md - * agent/sandboxes/{name}.json - * account/workspace.json (this workspace + your role; always present) - * account/workspaces.json (every workspace you can reach) - * account/members.json (workspace members; emails admin-only) - * account/billing.json (plan/usage/credits; lazy, read fresh) - * organization/organization.json (org standing; only when org-hosted) - * organization/access-control.json (your governing group + restrictions) - * organization/custom-blocks.json (org-published block provenance) - * organization/forks.json (fork topology; workspace admins only) - * environment/credentials.json - * environment/api-keys.json - * environment/variables.json - * knowledgebases/connectors/connectors.md (available connector types overview) - * knowledgebases/connectors/{type}.json (per-connector config schema) - * components/blocks/{type}.json - * components/integrations/{service}/{operation}.json - * components/triggers/triggers.md (overview of all built-in and external triggers) - * components/triggers/sim/{type}.json (built-in trigger blocks: start, schedule, webhook) - * components/triggers/{provider}/{id}.json (external triggers: github, slack, etc.) - */ -export class WorkspaceVFS { - private readonly filePrincipal?: Principal - private readonly knowledgePrincipal?: Principal - private readonly loadConnectedAccounts?: () => Promise - // Eagerly-materialized, cheap content (structure + metadata): folder markers, - // per-resource meta.json, WORKSPACE.md/WORKSPACE_CONTEXT.md, static components. - private files: Map = new Map() - // Lazily-materialized, expensive content keyed by VFS path. The loader runs on - // demand: a `read` resolves exactly one entry; a scoped `grep` resolves only - // the entries within its scope; an unscoped `grep` resolves all; a `glob` never - // resolves any (it matches keys only). This is why a read/glob no longer pays - // for every workflow's graph-load + lint + stringify — only grep over contents - // does, and only for what it actually scans. - private lazy: Map Promise> = new Map() - // Per-instance (per-tool-call) memo so state.json + lint.json for the same - // workflow share one normalized-table load, and deployment.json + versions.json - // share one deployment query. - private normalizedCache = new Map< - string, - Promise>> - >() - private deploymentCache = new Map>() - private customBlocksPromise: Promise | undefined - private _workspaceId = '' - /** - * Types of the org's CURRENT custom blocks (enabled + disabled — a disabled block - * still resolves/renders). Populated by {@link materializeCustomBlocks}; used to - * drop a placed custom block from a workflow's state when its definition has been - * deleted, so the copilot never sees a block it can't render. - * - * `null` means "not loaded" — either not materialized yet or the load FAILED. In - * that case {@link dropDeletedCustomBlocks} strips nothing, so a transient failure - * can't wrongly nuke every placed custom block. An empty `Set` is distinct: it - * means the org genuinely has no custom blocks, so any placed one IS deleted. - */ - private _customBlockTypes: Set | null = null - - constructor( - filePrincipal?: Principal, - knowledgePrincipal?: Principal, - loadConnectedAccounts?: () => Promise - ) { - this.filePrincipal = filePrincipal - this.knowledgePrincipal = knowledgePrincipal - this.loadConnectedAccounts = loadConnectedAccounts - } - - get workspaceId(): string { - return this._workspaceId - } - - /** Register a VFS path whose (expensive) content is produced on demand. */ - private registerLazy(path: string, loader: () => Promise): void { - this.lazy.set(path, loader) - } - - /** - * Load a workflow's normalized state once per instance. state.json and lint.json - * both need it, and a grep over a workflow's dir touches both — without this they - * would each re-load the full block graph. - */ - private loadNormalized( - workflowId: string - ): Promise>> { - let cached = this.normalizedCache.get(workflowId) - if (!cached) { - cached = loadWorkflowFromNormalizedTables(workflowId).then((n) => - this.dropDeletedCustomBlocks(n) - ) - this.normalizedCache.set(workflowId, cached) - } - return cached - } - - /** - * Strip placed custom blocks whose definition no longer exists from a loaded - * workflow (and any edges touching them), so the copilot never sees a block it - * can't render — mirroring how the serializer drops an unresolvable custom block. - * A live definition (enabled or disabled) is kept; only a DELETED one is removed. - * Runs lazily (after materialize), so `_customBlockTypes` is populated by then. - */ - private dropDeletedCustomBlocks( - normalized: Awaited> - ): Awaited> { - // `null` = definitions never loaded (or the load failed) — strip nothing rather - // than treat every placed custom block as deleted. - if (!normalized || this._customBlockTypes === null) return normalized - const validTypes = this._customBlockTypes - const dropped = new Set() - const blocks: Record = {} - for (const [id, block] of Object.entries(normalized.blocks)) { - const type = (block as { type?: string }).type - if (isCustomBlockType(type) && !validTypes.has(type)) { - dropped.add(id) - continue - } - blocks[id] = block - } - if (dropped.size === 0) return normalized - const edges = (normalized.edges ?? []).filter( - (e) => !dropped.has(e.source) && !dropped.has(e.target) - ) - return { ...normalized, blocks: blocks as typeof normalized.blocks, edges } - } - - /** Load a workflow's deployment data once per instance (deployment.json + versions.json share it). */ - private loadDeployments(workflowId: string): Promise { - let cached = this.deploymentCache.get(workflowId) - if (!cached) { - cached = this.getWorkflowDeployments(workflowId, this._workspaceId) - this.deploymentCache.set(workflowId, cached) - } - return cached - } - - /** - * Resolve a single lazy artifact into {@link files}. Idempotent: once resolved - * the entry moves to `files` and the loader is dropped. A loader that returns - * null (no data) leaves nothing behind, so the path reads as "not found". - */ - private async resolveLazyPath(path: string): Promise { - const existing = this.files.get(path) - if (existing !== undefined) return existing - const loader = this.lazy.get(path) - if (!loader) return null - this.lazy.delete(path) - let content: string | null = null - try { - content = await loader() - } catch (err) { - logger.warn('Failed to resolve lazy VFS artifact', { - workspaceId: this._workspaceId, - path, - error: toError(err).message, - }) - this.lazy.set(path, loader) - throw err - } - if (content !== null) this.files.set(path, content) - return content - } - - /** - * Resolve every lazy artifact a grep over `scope` will scan, in parallel. An - * undefined scope (unscoped grep) resolves all — the worst case, equivalent to - * the old eager full materialize, but now only paid by an unscoped grep. - * Uses the same scope matcher as {@link ops.grep} so the materialized set is - * exactly the set grep filters in. - */ - private async resolveLazyWithinScope(scope?: string): Promise { - const targets: string[] = [] - for (const path of this.lazy.keys()) { - if (!scope || ops.pathWithinGrepScope(path, scope)) targets.push(path) - } - if (targets.length === 0) return - // One unmaterializable artifact (e.g. an over-limit knowledge base's - // documents.json) must not fail the whole sweep — that would make every - // unscoped grep on the workspace error on content the caller never asked - // about. Skip it: grep proceeds over everything that resolved, the loader - // stays re-armed, and reading the failing path directly still surfaces its - // own error (resolveLazyPath logs each failure). - await Promise.allSettled(targets.map((path) => this.resolveLazyPath(path))) - } - - /** - * `recently-deleted/` artifacts are opt-in: excluded from the active view - * unless a path/pattern explicitly scopes into them. - */ - private isRecentlyDeleted(key: string): boolean { - return key.startsWith('recently-deleted/') - } - - /** - * A keys-only view (eager values plus empty placeholders for unresolved lazy - * paths) for glob/suggestSimilar, which match on keys and never read content. - */ - private keyView(includeDeleted: boolean): Map { - const view = new Map() - for (const [key, value] of this.files) { - if (includeDeleted || !this.isRecentlyDeleted(key)) view.set(key, value) - } - for (const key of this.lazy.keys()) { - if ((includeDeleted || !this.isRecentlyDeleted(key)) && !view.has(key)) { - view.set(key, '') - } - } - return view - } - - /** - * Materialize workspace data into the VFS. - * Uses shared service functions for all data access, then generates - * WORKSPACE.md from the summaries returned by each materializer. - */ - async materialize( - workspaceId: string, - userId: string, - options?: { secretMountPolicy?: SecretMountPolicy } - ): Promise { - const start = Date.now() - this.files = new Map() - this.lazy = new Map() - this.normalizedCache = new Map() - this.deploymentCache = new Map() - this.customBlocksPromise = undefined - this._customBlockTypes = null - this._workspaceId = workspaceId - - // Per-phase wall-clock, stamped on the span so a slow materialize in a - // trace names its bottleneck instead of showing up as unattributed dead - // time inside read/glob/grep (how the v0.7 lint.json regression hid). - const phaseMs: Record = {} - const timed = (phase: string, promise: Promise): Promise => { - const t0 = Date.now() - return promise.finally(() => { - phaseMs[phase] = Date.now() - t0 - }) - } - await trace - .getTracer('sim-copilot-vfs', '1.0.0') - .startActiveSpan( - TraceSpan.CopilotVfsMaterialize, - { attributes: { [TraceAttr.WorkspaceId]: workspaceId } }, - async (span) => { - try { - const blockVisibility = overlayVisibility() - const permissionConfigPromise = timed( - 'permissions', - resolvePermissionGroupConfig(userId, workspaceId, undefined) - ) - const sandboxEntitlementPromise = timed( - 'sandbox_entitlement', - hasWorkspaceSandboxAccess(workspaceId) - ) - // Shared with the account/ and organization/ namespaces so the - // roster and host context are each read once per materialization. - const membersPromise = timed('members', getUsersWithPermissions(workspaceId)) - const hostContextPromise = timed( - 'host_context', - getWorkspaceHostContextForViewer(workspaceId, userId).catch(() => null) - ) - const [ - wfSummary, - kbSummary, - tblSummary, - fileSummary, - envSummary, - toolsSummary, - customBlocksSummary, - mcpServersSummary, - skillsSummary, - sandboxesSummary, - wsRow, - members, - permissionConfig, - sandboxEntitled, - ] = await Promise.all([ - timed('workflows', this.materializeWorkflows(workspaceId)), - timed('knowledge_bases', this.materializeKnowledgeBases(workspaceId)), - timed('tables', this.materializeTables(workspaceId)), - timed('files', this.materializeFiles(workspaceId)), - timed( - 'environment', - this.materializeEnvironment( - workspaceId, - userId, - permissionConfigPromise, - blockVisibility, - options?.secretMountPolicy - ) - ), - timed('custom_tools', this.materializeCustomTools(workspaceId, userId)), - timed('custom_blocks', this.materializeCustomBlocks(workspaceId)), - timed('mcp_servers', this.materializeMcpServers(workspaceId)), - timed('skills', this.materializeSkills(workspaceId)), - timed( - 'sandboxes', - sandboxEntitlementPromise.then((entitled) => - entitled ? this.materializeSandboxes(workspaceId) : [] - ) - ), - timed('workspace_row', getWorkspaceWithOwner(workspaceId)), - membersPromise, - permissionConfigPromise, - sandboxEntitlementPromise, - ]) - - // account/ and organization/ describe the viewer's standing rather - // than workspace resources, so they are materialized after the - // resource pass and contribute nothing to WORKSPACE.md. - const hostContext = await hostContextPromise - await Promise.all([ - timed('account', this.materializeAccount(workspaceId, userId, hostContext, members)), - timed('organization', this.materializeOrganization(workspaceId, userId, hostContext)), - ]) - const workspaceMdData: WorkspaceMdData = { - workspace: wsRow, - members, - workflows: wfSummary, - knowledgeBases: kbSummary, - tables: tblSummary, - files: fileSummary, - oauthIntegrations: envSummary.oauthIntegrations, - envVariables: envSummary.envVariables, - customTools: toolsSummary, - customBlocks: customBlocksSummary, - mcpServers: mcpServersSummary, - skills: skillsSummary, - ...(sandboxEntitled ? { sandboxes: sandboxesSummary } : {}), - } - - this.files.set('WORKSPACE.md', buildWorkspaceMd(workspaceMdData)) - this.files.set('WORKSPACE_CONTEXT.md', buildWorkspaceContextMd(workspaceMdData)) - - await timed('recently_deleted', this.materializeRecentlyDeleted(workspaceId)) - - // Per-viewer gating happens HERE, not in the shared builder: files - // owned by blocks hidden for this viewer are skipped at stamp time. - const { - tools: viewerIntegrationTools, - allowedBlockTypes, - isToolAllowed, - } = projectIntegrationToolsForViewer(blockVisibility, permissionConfig) - const deniedOperations = resolveDeniedBlockOperations( - permissionConfig?.deniedTools, - isToolAllowed - ) - const staticFileGate: StaticFileGate = { - allowedIntegrationTypes: allowedBlockTypes, - fullyDeniedBlockTypes: deniedOperations.fullyDenied, - } - const staticFileProjection: StaticFileProjection = { - sandboxEntitled, - deniedOperations, - isToolAllowed, - } - for (const [path, content] of getStaticComponentFiles()) { - /* Integration schemas are authored per viewer from - `viewerIntegrationTools` immediately below, which is the only - projection that knows the group's per-tool denylist. Stamping - the shared copy first would publish a denied operation's schema - that the loop below never overwrites, because it only writes the - operations the viewer may use. */ - if (path.startsWith(INTEGRATION_SCHEMA_PATH_PREFIX)) continue - if (isStaticFileHidden(path, blockVisibility, staticFileGate)) continue - this.files.set(path, projectStaticComponentFile(path, content, staticFileProjection)) - } - for (const exposedTool of viewerIntegrationTools) { - const { config: tool, service, operation, blockType } = exposedTool - this.files.set( - `components/integrations/${service}/${operation}.json`, - serializeIntegrationSchema(tool, { - oauthAvailable: - !tool.oauth || isOAuthServiceDeploymentAvailable(tool.oauth.provider), - ownerBlockType: blockType, - }) - ) - } - for (const [path, content] of buildIntegrationAggregateFiles(viewerIntegrationTools)) { - this.files.set(path, content) - } - this.files.set( - 'components/triggers/triggers.md', - buildTriggerOverview(blockVisibility, staticFileGate) - ) - - span.setAttributes({ - [TraceAttr.CopilotVfsMaterializeFileCount]: this.files.size, - [TraceAttr.CopilotVfsMaterializePhaseMs]: JSON.stringify(phaseMs), - }) - } catch (err) { - markSpanForError(span, err) - throw err - } finally { - // Record on success AND failure: a mid-phase failure (e.g. a DB - // timeout) still belongs in copilot.vfs.materialize.duration, else - // p50/p99 skew toward successes only. phaseMs holds whatever phases - // completed before the failure. - for (const [phase, ms] of Object.entries(phaseMs)) { - recordVfsMaterialize(phase, ms) - } - recordVfsMaterialize('total', Date.now() - start) - span.end() - } - } - ) - - // Durable Grafana signal for "how long does VFS materialize" — total plus - // per-phase (bounded phase set). getOrMaterializeVFS runs per VFS tool call - // with no cross-request cache, so this reveals whether materialize is the - // bottleneck (observability only; not a fix). Recorded inside the span's - // finally above so a failed materialize is captured too, not just successes. - const totalMs = Date.now() - start - - logger.info('VFS materialized', { - workspaceId, - fileCount: this.files.size, - durationMs: totalMs, - phaseMs, - }) - } - - private activeFiles(): Map { - const filtered = new Map() - for (const [key, value] of this.files) { - if (!this.isRecentlyDeleted(key)) { - filtered.set(key, value) - } - } - return filtered - } - - private filesForPath(path?: string): Map { - if (path?.startsWith('recently-deleted')) return this.files - return this.activeFiles() - } - - async grep( - pattern: string, - path?: string, - options?: GrepOptions - ): Promise { - // grep is the only op that scans contents, so it is the only op that pays to - // materialize lazy artifacts — and only those within its scope. - await this.resolveLazyWithinScope(path) - return ops.grep(this.filesForPath(path), pattern, path, options) - } - - /** - * Grep the *content* of a single workspace file (under `files/`), as opposed to - * {@link grep} which searches the in-memory VFS map (workflow JSON, metadata, - * plans, memories — workspace files appear there only as metadata). - * - * Content search applies to workspace files only and must target exactly one - * file (`files/` or `files//content`, plus the `recently-deleted/` - * variants). A folder, the whole `files/` tree, or any path that does not - * resolve to a single file leaf throws — grepping multiple workspace files at - * once is intentionally unsupported. - * - * Per file type the file's text is resolved via {@link readFileContent} (the - * same extraction `read` uses): text-like files are read as UTF-8, parseable - * documents (pdf/docx/xlsx/pptx/…) are parsed to text, and the regex runs over - * that text. Images and binary files have no searchable text and throw, as do - * files too large for the inline read cap. Reading exactly one file (bounded by - * the existing per-type read caps) keeps this from loading the workspace into - * memory. - */ - async grepFile( - path: string, - pattern: string, - options?: GrepOptions - ): Promise { - return (await this.grepFileWithProvenance(path, pattern, options)).value - } - - async grepFileWithProvenance( - path: string, - pattern: string, - options?: GrepOptions - ): Promise> { - const normalized = path.replace(/^\/+/, '') - // Prefer the path verbatim when it is itself a file leaf (e.g. a file literally - // named "content"); otherwise drop a trailing "/content" read suffix. - let leaf = this.files.has(normalized) ? normalized : normalized.replace(/\/content$/, '') - - let isWorkspaceFilePath = /^(recently-deleted\/)?files(\/|$)/.test(leaf) - if (isWorkspaceFilePath && !this.files.has(leaf)) { - // Same encoding tolerance as vfs_read: a decoded display form that maps - // to exactly one canonical key resolves instead of erroring. - const decodedEquivalent = this.resolveDecodedEquivalent(leaf) - if (decodedEquivalent) { - leaf = decodedEquivalent - isWorkspaceFilePath = /^(recently-deleted\/)?files(\/|$)/.test(leaf) - } - } - if (!isWorkspaceFilePath || !this.files.has(leaf)) { - const suggestions = this.suggestSimilar(leaf) - const hint = - suggestions.length > 0 - ? ` Did you mean: ${suggestions.join(', ')}?` - : ' Use glob to find the exact file path, then grep that single file.' - throw new ops.WorkspaceFileGrepError( - `Grep over workspace file content must target a single workspace file (e.g. path: "files/report.csv"). "${path}" is not a single workspace file.${hint}` - ) - } - - const contentPath = `${leaf}/content` - const result = await this.readFileContentWithProvenance(contentPath) - if (!result) { - throw new ops.WorkspaceFileGrepError(`Workspace file content not found for "${path}".`) - } - if (result.value.placeholder === 'oversized') { - throw new ops.WorkspaceFileGrepError(`File is too large to search: ${result.value.content}`) - } - - return { - value: ops.grepReadResult(leaf, result.value, pattern, contentPath, options), - file: result.file, - } - } - - glob(pattern: string): string[] { - // glob matches keys only, so it resolves no lazy content — it sees the full - // path structure (eager keys + lazy placeholders) for free. - const includeDeleted = pattern.startsWith('recently-deleted') - return ops.glob(this.keyView(includeDeleted), pattern) - } - - async read(path: string, offset?: number, limit?: number): Promise { - // Resolve the one lazy artifact being read into `files`; a no-op for eager - // paths (already present) and unknown paths (no loader). Lazy keys are always - // ASCII (built via encodeURIComponent), so no Unicode-normalized lookup is - // needed here; ops.read still does its own NFC/NFD fallback over `files`. - await this.resolveLazyPath(path) - return ops.read(this.files, path, offset, limit) - } - - suggestSimilar(missingPath: string, max?: number): string[] { - return ops.suggestSimilar(this.keyView(true), missingPath, max) - } - - /** - * Resolves a missing path to an existing one when the two differ ONLY by - * percent-encoding (the model typed the decoded display form — spaces - * instead of %20). Returns the canonical existing path when exactly one key - * decodes to the same segments; ambiguity or a genuine miss returns null so - * the not-found error (with suggestions) still fires. Never fuzzy: same - * name, different bytes only. - */ - resolveDecodedEquivalent(missingPath: string): string | null { - const target = decodeVfsPathSegmentsSafe(missingPath) - let match: string | null = null - for (const key of this.keyView(true).keys()) { - if (decodeVfsPathSegmentsSafe(key) !== target) continue - if (match !== null) return null - match = key - } - return match - } - - private async resolveWorkspaceFileForDynamicRead( - path: string, - suffix: 'style' | 'compiled-check' | 'compiled' | 'render' | 'extract' - ): Promise { - const canonicalMatch = path.match(new RegExp(`^files/(.+)/${suffix}$`)) - if (!canonicalMatch?.[1]) return null - - if (!this.filePrincipal) { - throw new Error('Workspace file reads require a trusted Copilot principal') - } - const { files } = await listAllWorkspaceFiles.execute({ - principal: this.filePrincipal, - input: { workspaceId: this._workspaceId, scope: 'active' }, - }) - return findWorkspaceFileRecord(files, `files/${canonicalMatch[1]}`) - } - - private requireFilePrincipal(): Principal { - if (!this.filePrincipal) { - throw new Error('Workspace file reads require a trusted Copilot principal') - } - return this.filePrincipal - } - - private requireKnowledgePrincipal(): Principal { - if (!this.knowledgePrincipal) { - throw new Error('Workspace Knowledge reads require a trusted Copilot principal') - } - return this.knowledgePrincipal - } - - /** - * Renders a renderable doc (pptx/docx/pdf) record to a contact-sheet image and - * returns it as a model readable JPEG attachment. Shared by the `/render` and - * `/compiled` reads so a binary doc is NEVER attached as a raw (non-PDF) - * `document` block — the model only reads images and application/pdf. Compiles - * the source first when needed (E2B doc sandbox, else isolated-vm); uses the - * binary directly for already-binary uploads. Throws on compile/render failure - * (the caller's try/catch reports it). - */ - private async renderDocRecordResult( - record: WorkspaceFileRecord, - ext: string, - buildMessage: (pageCount: number) => string, - contributingFiles: Map - ): Promise { - if (typeof record.size === 'number' && record.size > MAX_DOC_READ_INPUT_BYTES) { - return renderErrorResult('File is too large to render') - } - const { content: buffer } = await readWorkspaceFileContent.execute({ - principal: this.requireFilePrincipal(), - input: { - fileId: record.id, - assertedWorkspaceId: this._workspaceId, - maxBytes: MAX_DOC_READ_INPUT_BYTES, - }, - }) - if (buffer.length > MAX_DOC_READ_INPUT_BYTES) { - return renderErrorResult('File is too large to render') - } - // Already-binary uploads render directly; source files are compiled first - // (E2B regime -> doc sandbox: Node pptx/docx, Python pdf; otherwise - // isolated-vm pptxgenjs/docx-js/pdf-lib). - let bin: Buffer - if (isBinaryDocBuffer(buffer, ext)) { - bin = buffer - } else { - const code = buffer.toString('utf-8') - if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { - return renderErrorResult('File source exceeds maximum size') - } - if (isDocSandboxEnabled && (await getE2BDocFormat(record.name))) { - bin = ( - await compileDoc({ - source: code, - fileName: record.name, - workspaceId: this._workspaceId, - filePrincipal: this.requireFilePrincipal(), - }) - ).buffer - } else { - const taskId = BINARY_DOC_TASKS[ext] - if (!taskId) { - return renderErrorResult('Cannot render this file') - } - bin = await runSandboxTask( - taskId, - { code, workspaceId: this._workspaceId }, - { - onWorkspaceFileAccess: (identity) => - recordContributingFile(contributingFiles, identity), - } - ) - } - } - const { grid, pageCount } = await renderDocToGrid({ - binary: bin, - ext, - workspaceId: this._workspaceId, - }) - return { - content: buildMessage(pageCount), - totalLines: 1, - attachment: { - // The rendered contact sheet is a JPEG, so it must be an image block. - // Tagging it 'file' routes it to a provider document block, which only - // accepts application/pdf — Anthropic rejects image/jpeg there with a - // 400 that surfaces to the client as a "Stream error". - type: 'image', - name: `${record.name}.render.jpg`, - source: { type: 'base64', media_type: 'image/jpeg', data: grid.toString('base64') }, - }, - } - } - - /** - * Attempt to read dynamic workspace file content from storage. - * Handles explicit /content reads for images, PDFs, documents, and text files. - * Also handles: - * `files/{path}/{name}/style` — style extraction (.docx / .pptx / .pdf) - * `files/{path}/{name}/compiled-check` — compile JS-source binary files or validate Mermaid diagrams - * `files/{path}/{name}/compiled` — compile JS-source binary files and return the compiled artifact as an attachment - * Files are resolved by their sanitized canonical path only. - * Returns null if the path doesn't match a dynamic file path or the file isn't found. - */ - async readFileContent(path: string): Promise { - return (await this.readFileContentWithProvenance(path))?.value ?? null - } - - async readFileContentWithProvenance( - path: string - ): Promise | null> { - const compiledMatch = /^files\/.+\/compiled$/.test(path) - if (compiledMatch) { - let record: WorkspaceFileRecord | null = null - const contributingFiles = new Map() - try { - record = await this.resolveWorkspaceFileForDynamicRead(path, 'compiled') - if (!record) return null - const ext = record.name.split('.').pop()?.toLowerCase() ?? '' - const docFmt = await getE2BDocFormat(record.name) - const taskId = BINARY_DOC_TASKS[ext] - if (!docFmt && !taskId) return null - - // Only PDF can be attached as a model-readable `document` block — - // Bedrock/Anthropic document blocks accept application/pdf ONLY. Attaching - // raw pptx/docx/xlsx binary is rejected by the provider (400). So for - // pptx/docx, render to page images (which the model CAN read) and return - // those directly — /compiled can never emit an invalid document block for - // these formats. xlsx isn't renderable; direct to /extract for its content. - if (ext !== 'pdf') { - if (isRenderableDocExt(ext)) { - const compiledName = record.name - const rendered = await this.renderDocRecordResult( - record, - ext, - (pageCount) => - `${compiledName}: the raw ${ext.toUpperCase()} binary isn't model-readable, so it was rendered to ${pageCount} page image(s) for inspection.`, - contributingFiles - ) - return bindWorkspaceFileResult(record, rendered, 'derived', [ - ...contributingFiles.values(), - ]) - } - const extractPath = `${canonicalWorkspaceFilePath({ - folderPath: record.folderPath, - name: record.name, - })}/extract` - return bindWorkspaceFileResult(record, { - content: `${record.name} is a spreadsheet — read "${extractPath}" for its contents.`, - totalLines: 1, - }) - } - - const { content: buffer } = await readWorkspaceFileContent.execute({ - principal: this.requireFilePrincipal(), - input: { - fileId: record.id, - assertedWorkspaceId: this._workspaceId, - maxBytes: MAX_DOC_READ_INPUT_BYTES, - }, - }) - const code = buffer.toString('utf-8') - if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { - return bindWorkspaceFileResult(record, { - content: JSON.stringify({ ok: false, error: 'File source exceeds maximum size' }), - totalLines: 1, - }) - } - let compiled: Buffer - if (isDocSandboxEnabled && docFmt) { - const compiledResult = await compileDoc({ - source: code, - fileName: record.name, - workspaceId: this._workspaceId, - filePrincipal: this.requireFilePrincipal(), - }) - for (const identity of compiledResult.contributingFiles ?? []) { - recordContributingFile(contributingFiles, identity) - } - compiled = compiledResult.buffer - } else { - compiled = await runSandboxTask( - taskId, - { code, workspaceId: this._workspaceId }, - { - onWorkspaceFileAccess: (identity) => - recordContributingFile(contributingFiles, identity), - } - ) - } - if (compiled.length > MAX_COMPILED_ATTACHMENT_BYTES) { - return bindWorkspaceFileResult( - record, - readPlaceholder.compiledArtifactTooLarge( - record.name, - compiled.length, - MAX_COMPILED_ATTACHMENT_BYTES - ) - ) - } - return bindWorkspaceFileResult( - record, - { - content: `Compiled file: ${record.name} (${compiled.length} bytes, application/pdf)`, - totalLines: 1, - attachment: { - type: 'file', - name: record.name, - source: { - type: 'base64', - media_type: 'application/pdf', - data: compiled.toString('base64'), - }, - }, - }, - 'derived', - [...contributingFiles.values()] - ) - } catch (err) { - logger.warn('Compiled artifact read failed via VFS', { - workspaceId: this._workspaceId, - path, - fileId: record?.id, - error: toError(err).message, - }) - if (err instanceof SandboxUserCodeError) { - const json = JSON.stringify({ - ok: false, - error: toError(err).message, - errorName: err.name, - }) - return record - ? bindWorkspaceFileResult(record, { content: json, totalLines: 1 }) - : { value: { content: json, totalLines: 1 } } - } - return null - } - } - - const renderMatch = /^files\/.+\/render$/.test(path) - if (renderMatch) { - let record: WorkspaceFileRecord | null = null - const contributingFiles = new Map() - try { - record = await this.resolveWorkspaceFileForDynamicRead(path, 'render') - if (!record) return null - const ext = record.name.split('.').pop()?.toLowerCase() ?? '' - if (!isRenderableDocExt(ext)) { - return bindWorkspaceFileResult( - record, - renderErrorResult('Render supports .pptx, .docx, and .pdf only') - ) - } - const renderName = record.name - const rendered = await this.renderDocRecordResult( - record, - ext, - (pageCount) => - `Rendered ${pageCount} page(s) of ${renderName} as a contact-sheet grid for visual QA. Inspect each page for text overflow/cutoff, overlapping elements, low contrast, misalignment, and leftover placeholder text; fix and re-render until clean.`, - contributingFiles - ) - return bindWorkspaceFileResult(record, rendered, 'derived', [...contributingFiles.values()]) - } catch (err) { - const error = toError(err).message - logger.warn('Render read failed via VFS', { - workspaceId: this._workspaceId, - path, - fileId: record?.id, - error, - }) - // Return an explicit error (not null) once the file resolved — a null read - // looks like a missing path and sends the agent hunting for the "correct" - // render path instead of surfacing the real compile/render failure. - const errorResult = renderErrorResult(error) - return record ? bindWorkspaceFileResult(record, errorResult) : { value: errorResult } - } - } - - const extractMatch = /^files\/.+\/extract$/.test(path) - if (extractMatch && isDocSandboxEnabled) { - let record: WorkspaceFileRecord | null = null - try { - record = await this.resolveWorkspaceFileForDynamicRead(path, 'extract') - if (!record) return null - const ext = record.name.split('.').pop()?.toLowerCase() ?? '' - if (!isExtractableDocExt(ext)) { - return bindWorkspaceFileResult(record, { - content: JSON.stringify({ - ok: false, - error: 'Extraction supports .pdf, .pptx, .docx, and .xlsx only', - }), - totalLines: 1, - }) - } - // Bound the input before downloading + base64-staging it in-process. - if (typeof record.size === 'number' && record.size > MAX_DOC_READ_INPUT_BYTES) { - return bindWorkspaceFileResult(record, { - content: JSON.stringify({ ok: false, error: 'File is too large to extract' }), - totalLines: 1, - }) - } - const { content: buffer } = await readWorkspaceFileContent.execute({ - principal: this.requireFilePrincipal(), - input: { - fileId: record.id, - assertedWorkspaceId: this._workspaceId, - maxBytes: MAX_DOC_READ_INPUT_BYTES, - }, - }) - if (buffer.length > MAX_DOC_READ_INPUT_BYTES) { - return bindWorkspaceFileResult(record, { - content: JSON.stringify({ ok: false, error: 'File is too large to extract' }), - totalLines: 1, - }) - } - // Extraction reads the binary. A source-backed generated doc (text source, - // no binary magic) should be read directly instead — point the agent there. - if (!isBinaryDocBuffer(buffer, ext)) { - return bindWorkspaceFileResult(record, { - content: JSON.stringify({ - ok: false, - error: 'This is a source-backed generated file; read its content directly instead.', - }), - totalLines: 1, - }) - } - const { text, truncated } = await extractDocText({ binary: buffer, ext }) - const note = truncated - ? '\n\n[... truncated — read the file directly for the full content]' - : '' - return bindWorkspaceFileResult(record, { - content: `${text || '[no extractable text found]'}${note}`, - totalLines: 1, - }) - } catch (err) { - logger.warn('Extract read failed via VFS', { - workspaceId: this._workspaceId, - path, - fileId: record?.id, - error: toError(err).message, - }) - const errorResult = { - content: JSON.stringify({ ok: false, error: toError(err).message }), - totalLines: 1, - } - return record ? bindWorkspaceFileResult(record, errorResult) : { value: errorResult } - } - } - - const compiledCheckMatch = /^files\/.+\/compiled-check$/.test(path) - if (compiledCheckMatch) { - let record: WorkspaceFileRecord | null = null - try { - record = await this.resolveWorkspaceFileForDynamicRead(path, 'compiled-check') - if (!record) return null - const ext = record.name.split('.').pop()?.toLowerCase() ?? '' - const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(record.name) : null - const taskId = BINARY_DOC_TASKS[ext] - const isMermaidFile = ext === 'mmd' || ext === 'mermaid' - // Sim pages (and legacy .html-named page source) compile-check too: - // this is the only way an agent can retrieve the "block skipped" - // diagnostics for an ALREADY-written page — without it, "find the - // malformed table" degenerates into guessing. - const maybeSimPage = record.type === SIM_PAGE_CONTENT_TYPE || ext === 'html' - if (!e2bFmt && !taskId && !isMermaidFile && !maybeSimPage) return null - const { content: buffer } = await readWorkspaceFileContent.execute({ - principal: this.requireFilePrincipal(), - input: { - fileId: record.id, - assertedWorkspaceId: this._workspaceId, - maxBytes: MAX_DOC_READ_INPUT_BYTES, - }, - }) - const code = buffer.toString('utf-8') - if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { - return bindWorkspaceFileResult(record, { - content: JSON.stringify({ ok: false, error: 'File source exceeds maximum size' }), - totalLines: 1, - }) - } - if (maybeSimPage && isSimPageSource(code)) { - const diagnostics = collectSimPageDiagnostics(code) - const result = - diagnostics.length === 0 - ? { ok: true } - : { - ok: false, - error: `${diagnostics.length} block(s) fail to compile and are omitted from the rendered page: ${diagnostics.join('; ')}`, - } - return bindWorkspaceFileResult(record, { - content: JSON.stringify(result), - totalLines: 1, - }) - } - if (maybeSimPage && !e2bFmt && !taskId && !isMermaidFile) { - if (record.type === SIM_PAGE_CONTENT_TYPE) { - // A page-typed file whose bytes are not page source (e.g. a crash - // between upload registration and source restore) — report it - // rather than pretending the path does not exist. - return bindWorkspaceFileResult(record, { - content: JSON.stringify({ - ok: false, - error: - 'Stored content is not page source (no YAML frontmatter with a title) — the file renders as raw HTML', - }), - totalLines: 1, - }) - } - // Bespoke raw HTML has no compiler to check. - return null - } - if (isMermaidFile) { - const result = await validateMermaidSource(code) - const json = JSON.stringify(result) - return bindWorkspaceFileResult(record, { content: json, totalLines: 1 }) - } - let result: { ok: boolean; error?: string; errorName?: string } - if (e2bFmt) { - // Loads the artifact if present, else compiles once (and recalc-scans - // xlsx). Only a script error is { ok: false }; infra failures rethrow to - // the outer catch so an E2B/S3 outage isn't reported as a bad script. - result = await runE2BCompiledCheck({ - source: code, - fileName: record.name, - workspaceId: this._workspaceId, - ext, - principal: this.requireFilePrincipal(), - }) - } else { - try { - if (!taskId) return null - await runSandboxTask(taskId, { code, workspaceId: this._workspaceId }) - result = { ok: true } - } catch (err) { - if (err instanceof SandboxUserCodeError) { - result = { ok: false, error: toError(err).message, errorName: err.name } - } else { - throw err - } - } - } - const json = JSON.stringify(result) - return bindWorkspaceFileResult(record, { content: json, totalLines: 1 }) - } catch (err) { - logger.warn('Compiled check failed via VFS', { - workspaceId: this._workspaceId, - path, - fileId: record?.id, - error: toError(err).message, - }) - return null - } - } - - const styleMatch = /^files\/.+\/style$/.test(path) - if (styleMatch) { - let record: WorkspaceFileRecord | null = null - try { - record = await this.resolveWorkspaceFileForDynamicRead(path, 'style') - if (!record) return null - const rawExt = record.name.split('.').pop()?.toLowerCase() - if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') return null - const ext: 'docx' | 'pptx' | 'pdf' = rawExt - if (typeof record.size === 'number' && record.size > MAX_DOC_READ_INPUT_BYTES) { - return bindWorkspaceFileResult(record, { - content: JSON.stringify({ ok: false, error: 'File is too large to extract style' }), - totalLines: 1, - }) - } - const { content: buffer } = await readWorkspaceFileContent.execute({ - principal: this.requireFilePrincipal(), - input: { - fileId: record.id, - assertedWorkspaceId: this._workspaceId, - maxBytes: MAX_DOC_READ_INPUT_BYTES, - }, - }) - const summary = await extractDocumentStyle(buffer, ext) - if (!summary) return null - const json = JSON.stringify(summary, null, 2) - return bindWorkspaceFileResult(record, { - content: json, - totalLines: json.split('\n').length, - }) - } catch (err) { - logger.warn('Failed to extract document style via VFS', { - workspaceId: this._workspaceId, - path, - fileId: record?.id, - error: toError(err).message, - }) - return null - } - } - - const deletedMatch = path.match(/^recently-deleted\/files\/(.+)\/content$/) - const activeMatch = path.match(/^files\/(.+)\/content$/) - const match = deletedMatch || activeMatch - if (!match) return null - const fileReference = path - .replace(/^recently-deleted\//, '') - .replace(/\/content$/, '') - .replace(/^\/+/, '') - - if (fileReference.endsWith('/meta.json') || path.endsWith('/meta.json')) return null - - const scope = deletedMatch ? 'archived' : 'active' - - let sizeCappedRecord: WorkspaceFileRecord | undefined - let sizeCap = MAX_TEXT_READ_BYTES - try { - const { files } = await listAllWorkspaceFiles.execute({ - principal: this.requireFilePrincipal(), - input: { workspaceId: this._workspaceId, scope }, - }) - const record = findWorkspaceFileRecord(files, fileReference) - if (!record) return null - sizeCappedRecord = record - sizeCap = isImageFileType(resolveEffectiveMimeType(record.type, record.name)) - ? MAX_IMAGE_SOURCE_BYTES - : MAX_TEXT_READ_BYTES - const { file, content } = await readWorkspaceFileContent.execute({ - principal: this.requireFilePrincipal(), - input: { - fileId: record.id, - assertedWorkspaceId: this._workspaceId, - includeDeleted: scope === 'archived', - maxBytes: sizeCap, - }, - }) - const result = await readFileRecord(file, content) - return result - ? bindWorkspaceFileResult( - file, - result, - isReadableFileType(file.type) ? 'complete' : 'derived' - ) - : null - } catch (err) { - // A cap breach is an answer, not a lookup failure: returning null here - // reported multi-MB files as "content not found". The oversized - // placeholder tells the model the file exists and why it can't be read. - if (isPayloadSizeLimitError(err) && sizeCappedRecord) { - return bindWorkspaceFileResult( - sizeCappedRecord, - readPlaceholder.fileTooLarge(sizeCappedRecord.name, sizeCappedRecord.size ?? 0, sizeCap) - ) - } - logger.warn('Failed to list workspace files for readFileContent', { - workspaceId: this._workspaceId, - path, - error: toError(err).message, - }) - return null - } - } - - /** - * Build a map from folderId to its full VFS path segment (e.g. "My Folder/Sub Folder"). - * Handles nested folders via parentId traversal. - */ - private buildFolderPaths( - folders: Array<{ folderId: string; folderName: string; parentId: string | null }> - ): Map { - return buildVfsFolderPathMap(folders) - } - - /** - * Folder paths for a non-workflow resource tree (tables, knowledge bases), - * plus `.folder` markers so empty folders are discoverable via glob — the - * same contract workflows/ has. Returns folderId → encoded folder path. - */ - private async registerResourceFolders( - workspaceId: string, - resourceType: 'table' | 'knowledge_base', - rootSegment: 'tables' | 'knowledgebases' - ): Promise> { - const folders = await listFoldersForWorkspace(workspaceId, 'active', resourceType) - const paths = buildVfsFolderPathMap( - folders.map((f) => ({ folderId: f.id, folderName: f.name, parentId: f.parentId })) - ) - for (const folderPath of paths.values()) { - this.files.set(`${rootSegment}/${folderPath}/.folder`, '') - } - return paths - } - - /** - * Resolve the set of folder IDs that are effectively locked — locked directly - * or via a locked ancestor folder. A workflow inside any of these folders is - * itself immutable, so its meta.json must report `locked: true`. Mirrors the - * folder-chain walk in `@sim/platform-authz/workflow` getFolderLockStatus, but resolves - * the whole workspace in memory to avoid a per-workflow DB round trip. - */ - private computeLockedFolderIds( - folders: Array<{ folderId: string; parentId: string | null; locked: boolean }> - ): Set { - const byId = new Map(folders.map((f) => [f.folderId, f])) - const lockedFolderIds = new Set() - - for (const folder of folders) { - let current: string | null = folder.folderId - const visited = new Set() - while (current && !visited.has(current)) { - visited.add(current) - const node = byId.get(current) - if (!node) break - if (node.locked) { - lockedFolderIds.add(folder.folderId) - break - } - current = node.parentId - } - } - - return lockedFolderIds - } - - /** - * Materialize all workflows using the shared listWorkflows function. - * Workflows are nested under their folder paths in the VFS: - * workflows/{folder}/{name}/ (if in a folder) - * workflows/{name}/ (if at workspace root) - * Returns a summary for WORKSPACE.md generation. - */ - private async materializeWorkflows(workspaceId: string): Promise { - const [workflowRows, folderRows] = await Promise.all([ - listWorkflows(workspaceId), - listFolders(workspaceId), - ]) - const deploymentVersionRows = - workflowRows.length === 0 - ? [] - : await db - .select({ - workflowId: workflowDeploymentVersion.workflowId, - isActive: workflowDeploymentVersion.isActive, - createdAt: workflowDeploymentVersion.createdAt, - }) - .from(workflowDeploymentVersion) - .where( - inArray( - workflowDeploymentVersion.workflowId, - workflowRows.map((workflowRow) => workflowRow.id) - ) - ) - const versionedWorkflowIds = new Set( - deploymentVersionRows.map((deploymentVersion) => deploymentVersion.workflowId) - ) - const activeDeploymentDates = new Map() - for (const deploymentVersion of deploymentVersionRows) { - if (!deploymentVersion.isActive) continue - const current = activeDeploymentDates.get(deploymentVersion.workflowId) - if (!current || current < deploymentVersion.createdAt) { - activeDeploymentDates.set(deploymentVersion.workflowId, deploymentVersion.createdAt) - } - } - - const folderPaths = this.buildFolderPaths(folderRows) - const lockedFolderIds = this.computeLockedFolderIds(folderRows) - - // Register all folders in the VFS so empty folders are discoverable. - for (const { folderId } of folderRows) { - const folderPath = folderPaths.get(folderId) - if (folderPath) { - this.files.set(`workflows/${folderPath}/.folder`, '') - } - } - - await Promise.all( - workflowRows.map(async (wf) => { - const deployedAt = activeDeploymentDates.get(wf.id) ?? null - const authoritativeWorkflow = { - ...wf, - isDeployed: deployedAt !== null, - deployedAt, - } - const folderPath = wf.folderId ? folderPaths.get(wf.folderId) : null - const prefix = `${canonicalWorkflowVfsDir({ name: wf.name, folderPath })}/` - - const inheritedFolderLock = wf.folderId ? lockedFolderIds.has(wf.folderId) : false - this.files.set( - `${prefix}meta.json`, - serializeWorkflowMeta(authoritativeWorkflow, { inheritedFolderLock }) - ) - - // Heavy per-workflow content is LAZY: a read/glob never loads the block - // graph, runs lint, or queries executions/deployments. Only a read of the - // specific artifact — or a grep whose scope touches it — resolves it. - // state.json + lint.json share one memoized normalized-table load; - // deployment.json + versions.json share one memoized deployment query. - // This is the change that stops every read/glob from paying O(workflows) - // graph-loads + lint + stringify (what made large-workspace reads ~40s). - this.registerLazy(`${prefix}state.json`, async () => { - const normalized = await this.loadNormalized(wf.id) - // loadWorkflowFromNormalizedTables returns null for a zero-block - // workflow; it still exists and must be readable, so emit an - // empty-but-valid state.json rather than a 404. - const sanitized = normalized - ? sanitizeForCopilot({ - blocks: normalized.blocks, - edges: normalized.edges, - loops: normalized.loops, - parallels: normalized.parallels, - } as any) - : sanitizeForCopilot({ blocks: {}, edges: [], loops: {}, parallels: {} } as any) - return JSON.stringify(sanitized, null, 2) - }) - - this.registerLazy(`${prefix}lint.json`, async () => { - const normalized = await this.loadNormalized(wf.id) - // Derived from the raw normalized state (subBlock values, advancedMode, - // canonicalModes, subflow edges). CPU-only by design: tier-2 reference - // resolution runs at edit_workflow apply time, not here. A zero-block - // workflow has no lint (reads as not-found, as before). - if (!normalized) return null - const graphLint = lintEditedWorkflowState(normalized as any) - const fieldIssues = collectWorkflowFieldIssues(normalized.blocks as any) - return JSON.stringify( - { - ...graphLint, - fieldIssues, - notes: [ - UNRESOLVABLE_AT_LINT_NOTE, - 'Credential/resource reference resolution is validated when editing the workflow, not in this snapshot.', - ], - }, - null, - 2 - ) - }) - - // executions.json is advertised only when the workflow has run (cheap - // signal: lastRunAt), matching the old "set iff execRows > 0" behavior - // without the per-workflow query on every tool call. - if (wf.lastRunAt) { - this.registerLazy(`${prefix}executions.json`, async () => { - const execRows = await db - .select({ - id: workflowExecutionLogs.id, - executionId: workflowExecutionLogs.executionId, - status: workflowExecutionLogs.status, - trigger: workflowExecutionLogs.trigger, - startedAt: workflowExecutionLogs.startedAt, - endedAt: workflowExecutionLogs.endedAt, - totalDurationMs: workflowExecutionLogs.totalDurationMs, - }) - .from(workflowExecutionLogs) - .where(eq(workflowExecutionLogs.workflowId, wf.id)) - .orderBy(desc(workflowExecutionLogs.startedAt)) - .limit(5) - return execRows.length > 0 ? serializeRecentExecutions(execRows) : null - }) - } - - // deployment.json exists for EVERY workflow: "is it deployed?" is a - // question with an answer either way, and a not-found error here was a - // recurring red herring — agents probing an undeployed workflow read a - // failure instead of the fact. Versions stay gated: they genuinely - // don't exist before the first deploy. - this.registerLazy(`${prefix}deployment.json`, async () => { - if (!versionedWorkflowIds.has(wf.id)) { - return JSON.stringify({ - deployed: false, - note: 'This workflow has never been deployed.', - }) - } - const deploymentData = await this.loadDeployments(wf.id) - return deploymentData - ? serializeDeployments(deploymentData) - : JSON.stringify({ deployed: false, note: 'This workflow has never been deployed.' }) - }) - if (versionedWorkflowIds.has(wf.id)) { - this.registerLazy(`${prefix}versions.json`, async () => { - const deploymentData = await this.loadDeployments(wf.id) - return deploymentData?.versions && deploymentData.versions.length > 0 - ? serializeVersions(deploymentData.versions) - : null - }) - } - }) - ) - - return workflowRows.map((wf) => ({ - id: wf.id, - name: wf.name, - isDeployed: activeDeploymentDates.has(wf.id), - lastRunAt: wf.lastRunAt, - folderPath: wf.folderId ? (folderPaths.get(wf.folderId) ?? null) : null, - })) - } - - /** Materializes authorized knowledge summaries for WORKSPACE.md generation. */ - private async materializeKnowledgeBases( - workspaceId: string - ): Promise { - const { knowledgeBases } = await listKnowledgeBaseCatalog.execute({ - principal: this.requireKnowledgePrincipal(), - input: { workspaceId }, - }) - const kbs = knowledgeBases.map(({ knowledgeBase }) => knowledgeBase) - const folderPaths = await this.registerResourceFolders( - workspaceId, - 'knowledge_base', - 'knowledgebases' - ) - - for (const { knowledgeBase: kb, tagDefinitions } of knowledgeBases) { - const safeName = sanitizeName(kb.name) - const folderPath = kb.folderId ? folderPaths.get(kb.folderId) : undefined - const prefix = folderPath - ? `knowledgebases/${folderPath}/${safeName}/` - : `knowledgebases/${safeName}/` - - this.files.set( - `${prefix}meta.json`, - serializeKBMeta({ - id: kb.id, - name: kb.name, - description: kb.description, - embeddingModel: kb.embeddingModel, - embeddingDimension: kb.embeddingDimension, - tokenCount: kb.tokenCount, - createdAt: kb.createdAt, - updatedAt: kb.updatedAt, - documentCount: kb.docCount, - connectorTypes: kb.connectorTypes, - tagDefinitions: tagDefinitions.map((definition) => ({ - id: definition.id, - tagName: definition.displayName, - tagSlot: definition.tagSlot, - fieldType: definition.fieldType, - })), - }) - ) - - // documents.json / connectors.json are lazy, advertised only when the KB - // summary says they exist (docCount / connectorTypes) — no per-KB query on - // a read/glob, only when the artifact is read or grepped. - if (kb.docCount > 0) { - this.registerLazy(`${prefix}documents.json`, async () => { - if (kb.docCount > MAX_VFS_KNOWLEDGE_DOCUMENTS) { - throw new Error( - `Knowledge base ${kb.id} has more than ${MAX_VFS_KNOWLEDGE_DOCUMENTS} documents; documents.json cannot be materialized` - ) - } - const documents: Awaited>['documents'] = - [] - let offset = 0 - while (true) { - const page = await listKnowledgeDocuments.execute({ - principal: this.requireKnowledgePrincipal(), - input: { - knowledgeBaseId: kb.id, - assertedWorkspaceId: workspaceId, - limit: KNOWLEDGE_DOCUMENT_PAGE_SIZE, - offset, - }, - }) - documents.push(...page.documents) - if (documents.length > MAX_VFS_KNOWLEDGE_DOCUMENTS) { - throw new Error( - `Knowledge base ${kb.id} exceeded the ${MAX_VFS_KNOWLEDGE_DOCUMENTS} document limit while materializing documents.json` - ) - } - if (!page.pagination.hasMore) break - offset += page.pagination.limit - } - const docRows = documents.map((document) => ({ - id: document.id, - filename: document.filename, - fileSize: document.fileSize, - mimeType: document.mimeType, - chunkCount: document.chunkCount, - tokenCount: document.tokenCount, - processingStatus: document.processingStatus, - enabled: document.enabled, - uploadedAt: document.uploadedAt, - })) - return docRows.length > 0 ? serializeDocuments(docRows) : null - }) - } - - if (kb.connectorTypes.length > 0) { - this.registerLazy(`${prefix}connectors.json`, async () => { - const { connectors: connectorRows } = await listKnowledgeConnectors.execute({ - principal: this.requireKnowledgePrincipal(), - input: { knowledgeBaseId: kb.id, assertedWorkspaceId: workspaceId }, - }) - return connectorRows.length > 0 ? serializeConnectors(connectorRows) : null - }) - } - } - - return kbs.map((kb) => ({ - id: kb.id, - name: kb.name, - description: kb.description, - connectorTypes: kb.connectorTypes.length > 0 ? kb.connectorTypes : undefined, - })) - } - - /** - * Materialize tables using the shared listTables function. - * Returns a summary for WORKSPACE.md generation. - */ - private async materializeTables(workspaceId: string): Promise { - try { - const [tables, folderPaths, viewsByTable] = await Promise.all([ - listTables(workspaceId), - this.registerResourceFolders(workspaceId, 'table', 'tables'), - listTableViewsByWorkspace(workspaceId), - ]) - - for (const table of tables) { - const safeName = sanitizeName(table.name) - const folderPath = table.folderId ? folderPaths.get(table.folderId) : undefined - const prefix = folderPath ? `tables/${folderPath}/${safeName}` : `tables/${safeName}` - const viewRows = viewsByTable.get(table.id) ?? [] - if (viewRows.length > 0) { - const columns = table.schema.columns - this.files.set( - `${prefix}/views.json`, - serializeTableViews( - viewRows.map((row) => { - const config = viewConfigIdsToNames( - pruneViewConfig( - normalizeStoredViewConfig(row.config as Record), - columns - ), - columns - ) - return { - id: row.id, - name: row.name, - isDefault: row.isDefault, - filter: config.filter ?? null, - sort: config.sort ?? null, - hiddenColumns: config.hiddenColumns, - updatedAt: row.updatedAt, - } - }) - ) - ) - } - this.files.set( - `${prefix}/meta.json`, - serializeTableMeta({ - id: table.id, - name: table.name, - description: table.description, - schema: table.schema, - rowCount: table.rowCount, - maxRows: table.maxRows, - createdAt: table.createdAt, - updatedAt: table.updatedAt, - }) - ) - } - - return tables.map((t) => ({ - id: t.id, - name: t.name, - description: t.description, - rowCount: t.rowCount, - })) - } catch (err) { - logger.error('Failed to materialize tables; refusing to serve an incomplete VFS', { - workspaceId, - error: toError(err).message, - }) - throw err - } - } - - /** - * Materialize workspace files (already uses listWorkspaceFiles). - * Returns a summary for WORKSPACE.md generation. - */ - private async materializeFiles(workspaceId: string): Promise { - try { - const principal = this.requireFilePrincipal() - const [{ folders }, { files }] = await Promise.all([ - listWorkspaceFileFoldersOperation.execute({ - principal, - input: { workspaceId, scope: 'active' }, - }), - listAllWorkspaceFiles.execute({ principal, input: { workspaceId, scope: 'active' } }), - ]) - for (const folder of folders) { - this.files.set( - `files/${encodeVfsPathSegments(parseWorkspaceFileFolderDisplayPath(folder.path))}/.folder`, - '' - ) - } - - for (const file of files) { - const filePath = canonicalWorkspaceFilePath({ - folderPath: file.folderPath, - name: file.name, - }) - const share = file.share - const shared = share?.isActive ?? false - this.files.set( - filePath, - serializeFileMeta({ - id: file.id, - name: file.name, - folderId: file.folderId, - folderPath: file.folderPath, - vfsPath: filePath, - contentType: file.type, - size: file.size, - uploadedAt: file.uploadedAt, - updatedAt: file.updatedAt, - shared, - shareAuthType: shared ? share?.authType : undefined, - shareUrl: shared ? share?.url : undefined, - }) - ) - } - - return files.map((f) => ({ - id: f.id, - name: f.name, - type: f.type, - size: f.size, - folderPath: f.folderPath ?? null, - })) - } catch (err) { - logger.error('Failed to materialize files; refusing to serve an incomplete VFS', { - workspaceId, - error: toError(err).message, - }) - throw err - } - } - - /** - * Query all deployment configurations for a single workflow. - * Returns null if the workflow has no deployments of any kind. - */ - private async getWorkflowDeployments( - workflowId: string, - workspaceId: string - ): Promise { - const [chatRows, mcpRows, versionRows, allVersionRows] = await Promise.all([ - db - .select({ - id: chatTable.id, - identifier: chatTable.identifier, - title: chatTable.title, - description: chatTable.description, - authType: chatTable.authType, - customizations: chatTable.customizations, - isActive: chatTable.isActive, - allowedEmails: chatTable.allowedEmails, - outputConfigs: chatTable.outputConfigs, - includeThinking: chatTable.includeThinking, - includeToolCalls: chatTable.includeToolCalls, - }) - .from(chatTable) - .where(and(eq(chatTable.workflowId, workflowId), isNull(chatTable.archivedAt))), - db - .select({ - serverId: workflowMcpTool.serverId, - serverName: workflowMcpServer.name, - toolId: workflowMcpTool.id, - toolName: workflowMcpTool.toolName, - toolDescription: workflowMcpTool.toolDescription, - parameterDescriptionOverrides: workflowMcpTool.parameterDescriptionOverrides, - }) - .from(workflowMcpTool) - .innerJoin(workflowMcpServer, eq(workflowMcpTool.serverId, workflowMcpServer.id)) - .where( - and( - eq(workflowMcpTool.workflowId, workflowId), - isNull(workflowMcpTool.archivedAt), - isNull(workflowMcpServer.deletedAt) - ) - ), - db - .select({ - version: workflowDeploymentVersion.version, - state: workflowDeploymentVersion.state, - createdAt: workflowDeploymentVersion.createdAt, - }) - .from(workflowDeploymentVersion) - .where( - and( - eq(workflowDeploymentVersion.workflowId, workflowId), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .orderBy(desc(workflowDeploymentVersion.createdAt)) - .limit(1), - db - .select({ - id: workflowDeploymentVersion.id, - version: workflowDeploymentVersion.version, - name: workflowDeploymentVersion.name, - description: workflowDeploymentVersion.description, - isActive: workflowDeploymentVersion.isActive, - createdAt: workflowDeploymentVersion.createdAt, - }) - .from(workflowDeploymentVersion) - .where(eq(workflowDeploymentVersion.workflowId, workflowId)) - .orderBy(desc(workflowDeploymentVersion.version)), - ]) - - const deployedVersion = versionRows[0] - const isDeployed = Boolean(deployedVersion) - const deployedAt = deployedVersion?.createdAt ?? null - const hasAnyDeployment = isDeployed || chatRows.length > 0 || mcpRows.length > 0 - if (!hasAnyDeployment && allVersionRows.length === 0) return null - - const needsRedeployment = - isDeployed && deployedVersion?.state ? await checkNeedsRedeployment(workflowId) : undefined - - return { - workflowId, - isDeployed, - deployedAt, - needsRedeployment, - api: deployedVersion - ? { version: deployedVersion.version, createdAt: deployedVersion.createdAt } - : null, - chat: chatRows[0] ?? null, - mcp: mcpRows, - versions: allVersionRows, - } - } - - /** - * Advertise custom tools in the VFS without eagerly loading their code. - * Paths are registered as lazy so glob/WORKSPACE.md see them, but full - * schema+code is fetched only when read (or a grep whose scope touches them). - */ - private async materializeCustomTools( - workspaceId: string, - userId: string - ): Promise> { - try { - // Metadata only — tool code can be large; keep it out of the eager map. - // Visibility matches listCustomTools: workspace tools + legacy user-owned. - const toolRows = await db - .select({ - id: customToolsTable.id, - title: customToolsTable.title, - }) - .from(customToolsTable) - .where( - or( - eq(customToolsTable.workspaceId, workspaceId), - and(isNull(customToolsTable.workspaceId), eq(customToolsTable.userId, userId)) - ) - ) - .orderBy(desc(customToolsTable.createdAt)) - - for (const tool of toolRows) { - const safeName = sanitizeName(tool.title) - const toolId = tool.id - const load = async () => { - const full = await getCustomToolById({ toolId, userId, workspaceId }) - if (!full) return null - return serializeCustomTool({ - id: full.id, - title: full.title, - schema: full.schema, - code: full.code, - }) - } - // Legacy alias + canonical agent/ path — each resolves independently on read. - this.registerLazy(`custom-tools/${safeName}.json`, load) - this.registerLazy(`agent/custom-tools/${safeName}.json`, load) - } - - return toolRows.map((t) => ({ id: t.id, name: t.title })) - } catch (err) { - logger.warn('Failed to materialize custom tools', { - workspaceId, - error: toError(err).message, - }) - return [] - } - } - - /** - * Materialize the org's published custom (deploy-as-block) blocks as VFS - * component files — the same `components/blocks/.json` path + serializer - * first-party blocks use — so the agent can grep/read them. Returns the summary - * for `WORKSPACE_CONTEXT.md`. Per-request/per-org, so it bypasses the frozen - * static component cache. Only enabled blocks are exposed. - */ - private async materializeCustomBlocks( - workspaceId: string - ): Promise> { - try { - const blocks = await this.loadCustomBlocks(workspaceId) - // Every current definition (incl. disabled) — the authoritative set used to - // drop deleted-definition instances from workflow state (see loadNormalized). - this._customBlockTypes = new Set(blocks.map((cb) => cb.type)) - const summary: NonNullable = [] - - for (const cb of blocks) { - if (!cb.enabled) continue - const config = buildCustomBlockConfig( - { - type: cb.type, - name: cb.name, - description: cb.description, - workflowId: cb.workflowId, - exposedOutputs: cb.exposedOutputs, - }, - cb.inputFields, - { icon: PLACEHOLDER_BLOCK_ICON } - ) - this.files.set(`components/blocks/${config.type}.json`, serializeBlockSchema(config)) - summary.push({ - type: cb.type, - name: cb.name, - ...(cb.description ? { description: cb.description } : {}), - }) - } - - return summary - } catch (err) { - logger.warn('Failed to materialize custom blocks', { - workspaceId, - error: toError(err).message, - }) - return [] - } - } - - /** Load the org's custom blocks once per VFS materialization. Failed loads remain retryable. */ - private async loadCustomBlocks(workspaceId: string): Promise { - const request = this.customBlocksPromise ?? listCustomBlocksWithInputsForWorkspace(workspaceId) - this.customBlocksPromise = request - try { - return await request - } catch (error) { - if (this.customBlocksPromise === request) this.customBlocksPromise = undefined - throw error - } - } - - /** - * Materialize `account/` — the acting user's vantage: this workspace and - * their role in it, the workspaces they can reach, who else is here, and - * their live plan. - * - * Read-only and always mounted. `billing.json` is registered lazily because - * usage ticks between requests: materializing it would freeze the numbers at - * snapshot time and pay for a billing read on every turn that never asks. - * Membership reuses the roster already loaded for WORKSPACE.md rather than - * issuing a second query. - */ - private async materializeAccount( - workspaceId: string, - userId: string, - hostContext: Awaited>, - members: Awaited> - ): Promise { - try { - const [rows, entitlements] = await Promise.all([ - listAccessibleWorkspaceRowsForUser(userId).catch(() => []), - computeWorkspaceEntitlements(workspaceId, userId).catch(() => [] as string[]), - ]) - - const current = rows.find((row) => row.workspace.id === workspaceId) - const parentId = current?.workspace.forkedFromWorkspaceId ?? null - // Name the parent only when the viewer can reach it; otherwise the id - // stands alone rather than leaking a workspace name they cannot open. - const parentRow = parentId ? rows.find((row) => row.workspace.id === parentId) : undefined - const isAdmin = hostContext?.viewer.permission === 'admin' - - this.files.set( - 'account/workspace.json', - serializeAccountWorkspace({ - workspace: { - id: workspaceId, - name: hostContext?.workspace.name ?? current?.workspace.name ?? '', - workspaceMode: hostContext?.workspace.workspaceMode ?? null, - }, - viewer: { - permission: hostContext?.viewer.permission ?? current?.permissionType ?? null, - organizationRole: hostContext?.viewer.organizationRole ?? null, - }, - organization: hostContext?.hostOrganizationId - ? { id: hostContext.hostOrganizationId } - : null, - forkedFrom: parentId - ? { id: parentId, name: parentRow?.workspace.name ?? parentId } - : null, - entitlements, - }) - ) - - this.files.set( - 'account/workspaces.json', - serializeAccountWorkspaces( - rows.map((row) => ({ - id: row.workspace.id, - name: row.workspace.name, - role: row.permissionType, - organizationId: row.workspace.organizationId, - forkedFromWorkspaceId: row.workspace.forkedFromWorkspaceId, - isCurrent: row.workspace.id === workspaceId, - })) - ) - ) - - this.files.set( - 'account/members.json', - serializeAccountMembers(members, { includeContactDetails: isAdmin }) - ) - - this.registerLazy('account/billing.json', async () => { - try { - return serializeAccountBilling(await getAccountBillingSnapshot(userId)) - } catch (err) { - logger.warn('Failed to load account billing', { - workspaceId, - error: toError(err).message, - }) - return null - } - }) - } catch (err) { - logger.warn('Failed to materialize account namespace', { - workspaceId, - error: toError(err).message, - }) - } - } - - private materializeConnectedAccounts( - hostContext: Pick< - NonNullable>>, - 'features' | 'viewer' - > - ): boolean { - const loadConnectedAccounts = this.loadConnectedAccounts - if ( - hostContext.features?.credentialGroups !== true || - hostContext.viewer.permission !== 'admin' || - !loadConnectedAccounts - ) { - return false - } - this.registerLazy('organization/connected-accounts.json', async () => { - try { - const accounts = await loadConnectedAccounts() - return accounts ? serializeConnectedAccounts(accounts) : null - } catch (err) { - logger.warn('Failed to load connected accounts', { - workspaceId: this._workspaceId, - error: toError(err).message, - }) - return null - } - }) - return true - } - - /** - * Materialize `organization/` — org standing, the access-control rules that - * actually bind this viewer, org-published block provenance, and fork - * topology. - * - * The namespace exists only when the workspace belongs to an organization, so - * its absence is itself the answer for a personal workspace. Fork detail is - * mounted only for a workspace admin of a forking-enabled org, matching the - * gate the fork routes apply. - */ - private async materializeOrganization( - workspaceId: string, - userId: string, - hostContext: Awaited> - ): Promise { - const organizationId = hostContext?.hostOrganizationId - if (!hostContext || !organizationId) return - - try { - this.files.set( - 'organization/organization.json', - serializeOrganization({ - organization: { - id: organizationId, - relationship: hostContext.viewer.isHostOrganizationMember ? 'internal' : 'external', - role: hostContext.viewer.organizationRole ?? null, - }, - capabilities: { - canManageOrganization: hostContext.viewer.isHostOrganizationAdmin, - canManageBilling: hostContext.viewer.isHostOrganizationAdmin, - }, - plan: hostContext.ownerBilling.plan, - isEnterprise: hostContext.ownerBilling.isEnterprise, - }) - ) - - this.registerLazy('organization/access-control.json', async () => { - try { - const accessControl = await resolveVerifiedUserAccessControlContext( - userId, - workspaceId, - organizationId - ) - return serializeAccessControl({ - entitled: accessControl.entitled, - permissionGroup: accessControl.permissionGroup, - restrictions: getActivePermissionGroupRestrictions(accessControl.config), - }) - } catch (err) { - logger.warn('Failed to load access control context', { - workspaceId, - error: toError(err).message, - }) - return null - } - }) - - // The block list is fetched at materialize time (one indexed query, the - // same one the components pass already ran) because the README and the - // names-only index need it, and each block's detail path must exist in - // the key view for glob to list. Only the deployed graph stays lazy — - // it is the expensive part and most turns never read it. - const orgBlocks = await this.loadCustomBlocks(workspaceId).catch((err) => { - logger.warn('Failed to list org custom blocks', { - workspaceId, - error: toError(err).message, - }) - return [] - }) - if (orgBlocks.length > 0) { - this.files.set( - 'organization/custom-blocks.json', - serializeOrganizationCustomBlocks(orgBlocks) - ) - // The names index matches editor visibility: anyone who can open the - // workspace sees the block in the toolbar. The deployed GRAPH is org - // implementation internals, so an external collaborator — workspace - // access without org membership — gets the interface (components/ - // schema) but not the graph; for them the detail files simply do not - // exist. - if (hostContext.viewer.isHostOrganizationMember) - for (const orgBlock of orgBlocks) { - this.registerLazy(`organization/custom-blocks/${orgBlock.type}.json`, async () => { - try { - const deployed = await loadDeployedWorkflowState( - orgBlock.workflowId, - orgBlock.workspaceId ?? undefined - ) - return serializeOrgCustomBlockDetail(orgBlock, deployed) - } catch (err) { - logger.warn('Failed to load deployed state for org custom block', { - workspaceId, - blockType: orgBlock.type, - error: toError(err).message, - }) - return null - } - }) - } - } - - // Everything below is registered LAZILY: the paths appear in the key - // view (so glob lists them) but no query runs until something reads - // one. Registration itself is the permission gate — an unpermitted - // viewer's file simply does not exist. - if (hostContext.viewer.isHostOrganizationMember) { - this.registerLazy('organization/workspaces.json', async () => { - try { - const [refs, accessible] = await Promise.all([ - listOrganizationWorkspaceRefs(organizationId), - listAccessibleWorkspaceRowsForUser(userId).catch(() => []), - ]) - const accessibleIds = new Set(accessible.map((row) => row.workspace.id)) - const forkParents = new Map( - accessible.map((row) => [row.workspace.id, row.workspace.forkedFromWorkspaceId]) - ) - return serializeOrganizationWorkspaces( - refs.map((ref) => ({ - id: ref.id, - name: ref.name, - hasAccess: accessibleIds.has(ref.id), - forkedFromWorkspaceId: forkParents.get(ref.id) ?? null, - })) - ) - } catch (err) { - logger.warn('Failed to load org workspaces', { - workspaceId, - error: toError(err).message, - }) - return null - } - }) - } - - if (hostContext.viewer.isHostOrganizationAdmin) { - this.registerLazy('organization/permission-groups.json', async () => { - try { - const roster = await listPermissionGroupRoster(organizationId) - if (roster.length === 0) return null - return serializePermissionGroupRoster(roster) - } catch (err) { - logger.warn('Failed to load permission-group roster', { - workspaceId, - error: toError(err).message, - }) - return null - } - }) - } - - const connectedAccountsAvailable = this.materializeConnectedAccounts(hostContext) - - const forksAvailable = - hostContext.viewer.permission === 'admin' && - (await isForkingAvailableForWorkspace(organizationId, userId).catch(() => false)) - - this.files.set( - 'organization/README.md', - buildOrganizationReadme({ - organizationId, - isEnterprise: hostContext.ownerBilling.isEnterprise, - customBlocks: orgBlocks, - forksMounted: forksAvailable, - permissionGroupsMounted: hostContext.viewer.isHostOrganizationAdmin, - connectedAccountsMounted: connectedAccountsAvailable, - }) - ) - - if (!forksAvailable) return - - this.registerLazy('organization/forks.json', async () => { - try { - const [parent, children] = await Promise.all([ - getForkParent(workspaceId), - getForkChildren(workspaceId), - ]) - if (!parent && children.length === 0) return null - - const resourceMappingCounts: Record = {} - let blockMappingCount = 0 - if (parent) { - const [resourceRows, blockMap] = await Promise.all([ - getEdgeMappingRows(db, workspaceId), - loadForkBlockMap(db, workspaceId), - ]) - for (const row of resourceRows) { - resourceMappingCounts[row.resourceType] = - (resourceMappingCounts[row.resourceType] ?? 0) + 1 - } - blockMappingCount = blockMap.parentToChild.size - } - - return serializeWorkspaceForks({ - parent: parent ? { id: parent.id, name: parent.name } : null, - children: children.map((child) => ({ - id: child.id, - name: child.name, - createdAt: child.createdAt, - })), - resourceMappingCounts, - blockMappingCount, - }) - } catch (err) { - logger.warn('Failed to load fork topology', { - workspaceId, - error: toError(err).message, - }) - return null - } - }) - } catch (err) { - logger.warn('Failed to materialize organization namespace', { - workspaceId, - error: toError(err).message, - }) - } - } - - /** - * Materialize external MCP server connections using the mcpServers table. - */ - private async materializeMcpServers( - workspaceId: string - ): Promise> { - try { - const servers = await db - .select() - .from(mcpServersTable) - .where(and(eq(mcpServersTable.workspaceId, workspaceId), isNull(mcpServersTable.deletedAt))) - - for (const server of servers) { - const safeName = sanitizeName(server.name) - this.files.set( - `agent/mcp-servers/${safeName}.json`, - serializeMcpServer({ - id: server.id, - name: server.name, - url: server.url, - transport: server.transport, - enabled: server.enabled, - connectionStatus: server.connectionStatus, - }) - ) - } - - return servers.map((s) => ({ id: s.id, name: s.name, url: s.url, enabled: s.enabled })) - } catch (err) { - logger.warn('Failed to materialize MCP servers', { - workspaceId, - error: toError(err).message, - }) - return [] - } - } - - /** - * Advertise the workspace skills in the VFS without eagerly loading their - * bodies. Paths are registered as lazy so glob/WORKSPACE.md see them, but - * full content is fetched only when read (or a grep whose scope touches the - * path) resolves them. Skills are workspace-visible — everyone with - * workspace access sees and uses every skill. - */ - private async materializeSkills( - workspaceId: string - ): Promise> { - try { - // Metadata only — skill bodies can be large; keep them out of the eager map. - const skillRows = await db - .select({ - id: skillTable.id, - name: skillTable.name, - description: skillTable.description, - }) - .from(skillTable) - .where(eq(skillTable.workspaceId, workspaceId)) - .orderBy(desc(skillTable.createdAt)) - - for (const s of skillRows) { - const safeName = sanitizeName(s.name) - const skillId = s.id - this.registerLazy(`agent/skills/${safeName}.json`, async () => { - const full = await getSkillById({ skillId, workspaceId }) - if (!full) return null - return serializeSkill({ - id: full.id, - name: full.name, - description: full.description, - content: full.content, - createdAt: full.createdAt, - }) - }) - } - - return skillRows.map((s) => ({ id: s.id, name: s.name, description: s.description })) - } catch (err) { - logger.warn('Failed to materialize skills', { - workspaceId, - error: toError(err).message, - }) - return [] - } - } - - /** - * Project the shared sandbox domain objects into discoverable VFS resources. - * Entitlement is checked by the caller before this method runs. - */ - private async materializeSandboxes( - workspaceId: string - ): Promise> { - try { - const sandboxes = await listWorkspaceSandboxes(workspaceId) - const strategy = currentSandboxStrategy() - this.files.set('agent/sandboxes/README.md', serializeSandboxCatalog(strategy)) - for (const sandbox of sandboxes) { - this.files.set( - `agent/sandboxes/${sanitizeName(sandbox.name)}.json`, - serializeSandbox(sandbox, strategy) - ) - } - return sandboxes.map((sandbox) => ({ - id: sandbox.id, - name: sandbox.name, - language: sandbox.language, - dependencies: sandbox.dependencies, - systemPackages: sandbox.systemPackages, - cliTools: sandbox.cliTools, - })) - } catch (err) { - logger.warn('Failed to materialize Sim sandboxes', { - workspaceId, - error: toError(err).message, - }) - return [] - } - } - private async materializeRecentlyDeleted(workspaceId: string): Promise { - try { - const [ - archivedWorkflows, - archivedFolders, - archivedTables, - archivedFiles, - archivedFileFolders, - archivedKBs, - ] = await Promise.all([ - listWorkflows(workspaceId, { scope: 'archived' }), - db - .select({ - id: folderTable.id, - name: folderTable.name, - archivedAt: folderTable.deletedAt, - }) - .from(folderTable) - .where( - and( - eq(folderTable.workspaceId, workspaceId), - eq(folderTable.resourceType, 'workflow'), - isNotNull(folderTable.deletedAt) - ) - ), - listTables(workspaceId, { scope: 'archived' }), - listAllWorkspaceFiles - .execute({ - principal: this.requireFilePrincipal(), - input: { workspaceId, scope: 'archived' }, - }) - .then(({ files }) => files), - listWorkspaceFileFoldersOperation - .execute({ - principal: this.requireFilePrincipal(), - input: { workspaceId, scope: 'archived' }, - }) - .then(({ folders }) => folders), - listKnowledgeBases - .execute({ - principal: this.requireKnowledgePrincipal(), - input: { workspaceId, scope: 'archived' }, - }) - .then(({ knowledgeBases }) => knowledgeBases.map((entry) => entry.knowledgeBase)), - ]) - - for (const wf of archivedWorkflows) { - const safeName = sanitizeName(wf.name) - this.files.set( - `recently-deleted/workflows/${safeName}/meta.json`, - serializeWorkflowMeta(wf) - ) - } - - for (const folder of archivedFolders) { - const safeName = sanitizeName(folder.name) - this.files.set( - `recently-deleted/folders/${safeName}/meta.json`, - JSON.stringify( - { id: folder.id, name: folder.name, archivedAt: folder.archivedAt }, - null, - 2 - ) - ) - } - - for (const table of archivedTables) { - const safeName = sanitizeName(table.name) - this.files.set( - `recently-deleted/tables/${safeName}/meta.json`, - serializeTableMeta({ - id: table.id, - name: table.name, - description: table.description, - schema: table.schema, - rowCount: table.rowCount, - maxRows: table.maxRows, - createdAt: table.createdAt, - updatedAt: table.updatedAt, - }) - ) - } - - for (const folder of archivedFileFolders) { - const safePath = parseWorkspaceFileFolderDisplayPath(folder.path) - .map((segment) => sanitizeName(segment)) - .join('/') - this.files.set( - `recently-deleted/file-folders/${safePath}/meta.json`, - JSON.stringify( - { - id: folder.id, - name: folder.name, - parentId: folder.parentId, - path: folder.path, - deletedAt: folder.deletedAt, - type: 'file_folder', - }, - null, - 2 - ) - ) - } - - for (const file of archivedFiles) { - const filePath = canonicalWorkspaceFilePath({ - folderPath: file.folderPath, - name: file.name, - prefix: 'recently-deleted/files', - }) - this.files.set( - filePath, - serializeFileMeta({ - id: file.id, - name: file.name, - folderId: file.folderId, - folderPath: file.folderPath, - vfsPath: filePath, - contentType: file.type, - size: file.size, - uploadedAt: file.uploadedAt, - updatedAt: file.updatedAt, - }) - ) - } - - for (const kb of archivedKBs) { - const safeName = sanitizeName(kb.name) - this.files.set( - `recently-deleted/knowledgebases/${safeName}/meta.json`, - serializeKBMeta({ - id: kb.id, - name: kb.name, - description: kb.description, - embeddingModel: kb.embeddingModel, - embeddingDimension: kb.embeddingDimension, - tokenCount: kb.tokenCount, - createdAt: kb.createdAt, - updatedAt: kb.updatedAt, - documentCount: kb.docCount, - connectorTypes: kb.connectorTypes, - }) - ) - } - } catch (err) { - logger.warn('Failed to materialize recently deleted resources', { - workspaceId, - error: toError(err).message, - }) - } - } - - /** - * Materialize environment data using shared service functions: - * - getAccessibleEnvCredentials for workspace-scoped credentials - * - listApiKeys for workspace API keys - * - getPersonalAndWorkspaceEnv for env variable names - * - * Returns a credential summary for WORKSPACE.md generation. - */ - private async materializeEnvironment( - workspaceId: string, - userId: string, - permissionConfigPromise: ReturnType, - blockVisibility: BlockVisibilityState | null, - secretMountPolicy?: SecretMountPolicy - ): Promise<{ - oauthIntegrations: WorkspaceMdData['oauthIntegrations'] - envVariables: WorkspaceMdData['envVariables'] - }> { - try { - const isWorkspaceAdmin = await hasWorkspaceAdminAccess(userId, workspaceId) - const [envCredentials, oauthCredentials, apiKeyRows, envData, permissionConfig] = - await Promise.all([ - getAccessibleEnvCredentials(workspaceId, userId, { isWorkspaceAdmin }), - getAccessibleOAuthCredentials(workspaceId, userId, { isWorkspaceAdmin }).then( - async (accessible) => [ - ...accessible, - ...( - await listPersonalCredentials.execute({ - principal: createCopilotChatPrincipal( - { workspaceId, userId }, - CREDENTIAL_DELEGATION_AUDIENCE - ), - input: { workspaceId }, - }) - ).credentials - .filter((entry) => entry.type === 'managed_oauth') - .map((entry) => ({ - ...entry, - type: 'managed_oauth' as const, - role: 'member' as const, - })), - ] - ), - listApiKeys(workspaceId), - getPersonalAndWorkspaceEnv(userId, workspaceId), - permissionConfigPromise, - ]) - const credentialVisibility = createIntegrationCredentialVisibility({ - allowedIntegrationTypes: toAccessControlAllowlist( - intersectIntegrationAllowlists( - permissionConfig?.allowedIntegrations ?? null, - getAllowedIntegrationsFromEnv() - ) - ), - blockVisibility, - }) - const visibleOAuthCredentials = oauthCredentials.filter((credential) => - credentialVisibility.isCredentialVisible({ - providerId: credential.providerId, - type: credential.type, - }) - ) - const visibleEnvCredentialNames = new Set( - filterSecretNamesByMountPolicy( - envCredentials.map((credential) => credential.envKey), - secretMountPolicy - ) - ) - const visibleEnvCredentials = envCredentials.filter((credential) => - visibleEnvCredentialNames.has(credential.envKey) - ) - - this.files.set( - 'environment/credentials.json', - serializeCredentials([ - ...visibleEnvCredentials.map((c) => ({ - providerId: c.envKey, - description: c.description, - scope: c.type === 'env_workspace' ? 'workspace' : 'personal', - createdAt: c.updatedAt, - })), - ...visibleOAuthCredentials.map((c) => ({ - id: c.id, - providerId: c.providerId, - displayName: c.displayName, - role: c.role, - scope: null, - credentialType: c.type, - createdAt: c.updatedAt, - })), - ]) - ) - - this.files.set('environment/api-keys.json', serializeApiKeys(apiKeyRows)) - - const personalVarNames = filterSecretNamesByMountPolicy( - Object.keys(envData.personalEncrypted), - secretMountPolicy - ) - const workspaceVarNames = filterSecretNamesByMountPolicy( - Object.keys(envData.workspaceEncrypted), - secretMountPolicy - ) - /** Intersected with the policy-filtered names, so the mount policy applies here too. */ - const workspaceVarNameSet = new Set(workspaceVarNames) - const unredactedWorkspaceVarNames = envData.workspaceUnredactedKeys.filter((name) => - workspaceVarNameSet.has(name) - ) - this.files.set( - 'environment/variables.json', - serializeEnvironmentVariables( - personalVarNames, - workspaceVarNames, - unredactedWorkspaceVarNames - ) - ) - - const envKeys = [...visibleEnvCredentialNames] - return { - oauthIntegrations: visibleOAuthCredentials.map((c) => ({ - id: c.id, - providerId: c.providerId, - displayName: c.displayName, - role: c.role, - })), - envVariables: envKeys, - } - } catch (err) { - logger.warn('Failed to materialize environment data', { - workspaceId, - error: toError(err).message, - }) - return { oauthIntegrations: [], envVariables: [] } - } - } -} - -/** - * Create a fresh VFS for a workspace. - * Dynamic data (workflows, KBs, env) is always fetched fresh. - * Static component files (blocks, integrations) are cached per-process. - */ -export async function getOrMaterializeVFS( - workspaceId: string, - userId: string, - options?: { - secretMountPolicy?: SecretMountPolicy - filePrincipal?: Principal - knowledgePrincipal?: Principal - loadConnectedAccounts?: () => Promise - } -): Promise { - await assertActiveWorkspaceAccess(workspaceId, userId) - const vfs = new WorkspaceVFS( - options?.filePrincipal, - options?.knowledgePrincipal, - options?.loadConnectedAccounts - ) - await vfs.materialize(workspaceId, userId, options) - return vfs -} - -export type { FileReadResult } from '@/lib/copilot/vfs/file-reader' - -/** - * Sanitize a name for use as a VFS path segment. - * Delegates to {@link normalizeVfsSegment} so workspace file paths match DB lookups. - */ -export function sanitizeName(name: string): string { - return normalizeVfsSegment(name) -} - -function decodeVfsPathSegmentsSafe(path: string): string { - return path - .split('/') - .map((segment) => decodeVfsSegmentSafe(segment)) - .join('/') -} diff --git a/apps/sim/lib/core/telemetry.ts b/apps/sim/lib/core/telemetry.ts index f75f3b94597..161a862234f 100644 --- a/apps/sim/lib/core/telemetry.ts +++ b/apps/sim/lib/core/telemetry.ts @@ -18,9 +18,9 @@ import { context, type Span, SpanStatusCode, trace } from '@opentelemetry/api' import { createLogger } from '@sim/logger' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import type { TraceSpan } from '@/lib/logs/types' import { hostedKeyMetrics } from '@/lib/monitoring/metrics' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' /** * GenAI Semantic Convention Attributes diff --git a/apps/sim/lib/desktop/index.ts b/apps/sim/lib/desktop/index.ts index 49d7a88c32f..06471a1ed62 100644 --- a/apps/sim/lib/desktop/index.ts +++ b/apps/sim/lib/desktop/index.ts @@ -27,7 +27,7 @@ import { truncate } from '@sim/utils/string' import { DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH, DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH, -} from '@/lib/copilot/chat/desktop-capabilities' +} from '@/lib/mothership/chat/desktop-capabilities' /** The preload bridge, or undefined outside the desktop app (and on the server). */ export function getDesktopBridge(): SimDesktopApi | undefined { diff --git a/apps/sim/lib/function-execution/execute-request.test.ts b/apps/sim/lib/function-execution/execute-request.test.ts index 30f47ef2387..e32cc2ad21b 100644 --- a/apps/sim/lib/function-execution/execute-request.test.ts +++ b/apps/sim/lib/function-execution/execute-request.test.ts @@ -86,7 +86,7 @@ vi.mock('@/lib/execution/remote-sandbox', () => ({ SIM_RESULT_PREFIX: '__SIM_RESULT__=', })) -vi.mock('@/lib/copilot/request/tools/files', () => ({ +vi.mock('@/lib/mothership/request/tools/files', () => ({ FORMAT_TO_CONTENT_TYPE: { json: 'application/json', csv: 'text/csv', @@ -126,7 +126,7 @@ vi.mock('@/lib/copilot/request/tools/files', () => ({ }), })) -vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ +vi.mock('@/lib/mothership/vfs/resource-writer', () => ({ validateWorkspaceFileWriteTarget: mockValidateWorkspaceFileWriteTarget, writeWorkspaceFileByPath: mockWriteWorkspaceFileByPath, })) diff --git a/apps/sim/lib/function-execution/execute-request.ts b/apps/sim/lib/function-execution/execute-request.ts index 964d6721c14..31923ad3273 100644 --- a/apps/sim/lib/function-execution/execute-request.ts +++ b/apps/sim/lib/function-execution/execute-request.ts @@ -6,17 +6,6 @@ import { toRecord } from '@sim/utils/object' import { escapeRegExp } from '@sim/utils/string' import { NextResponse } from 'next/server' import type { ParsedFunctionExecuteBody } from '@/lib/api/contracts' -import { - FORMAT_TO_CONTENT_TYPE, - getOutputFileDeclarations, - normalizeOutputWorkspaceFileName, - type OutputFileDeclaration, - resolveOutputFormat, -} from '@/lib/copilot/request/tools/files' -import { - validateWorkspaceFileWriteTarget, - writeWorkspaceFileByPath, -} from '@/lib/copilot/vfs/resource-writer' import { isMothershipSandboxEnabled, isRemoteSandboxEnabled } from '@/lib/core/config/env-flags' import { createTimeoutAbortController, @@ -92,6 +81,17 @@ import { import type { SandboxCollectedFile, SandboxFile } from '@/lib/execution/remote-sandbox/types' import { isExecutionResourceLimitError } from '@/lib/execution/resource-errors' import { planUserFileMounts, resolveUserFileMounts } from '@/lib/function-execution/sandbox-mounts' +import { + FORMAT_TO_CONTENT_TYPE, + getOutputFileDeclarations, + normalizeOutputWorkspaceFileName, + type OutputFileDeclaration, + resolveOutputFormat, +} from '@/lib/mothership/request/tools/files' +import { + validateWorkspaceFileWriteTarget, + writeWorkspaceFileByPath, +} from '@/lib/mothership/vfs/resource-writer' import { uploadExecutionFile } from '@/lib/uploads/contexts/execution/execution-file-manager' import { createWorkspaceFileSecretProvenanceFromRegistry, diff --git a/apps/sim/lib/internal/custom-tools/read-available-by-id-or-title.test.ts b/apps/sim/lib/internal/custom-tools/read-available-by-id-or-title.test.ts index dc2f03e8796..d39014f028f 100644 --- a/apps/sim/lib/internal/custom-tools/read-available-by-id-or-title.test.ts +++ b/apps/sim/lib/internal/custom-tools/read-available-by-id-or-title.test.ts @@ -21,7 +21,7 @@ vi.mock('@/lib/custom-tools/application/use-cases', () => ({ readAvailableCustomToolByIdOrTitleUseCase: mocks.readUseCase, })) -vi.mock('@/lib/copilot/application/execute-custom-tool-use-case', () => ({ +vi.mock('@/lib/mothership/application/execute-custom-tool-use-case', () => ({ executeCopilotCustomToolUseCase: mocks.executeCopilot, })) diff --git a/apps/sim/lib/internal/custom-tools/read-available-by-id-or-title.ts b/apps/sim/lib/internal/custom-tools/read-available-by-id-or-title.ts index 1fce57b1e89..24d9eba6fa7 100644 --- a/apps/sim/lib/internal/custom-tools/read-available-by-id-or-title.ts +++ b/apps/sim/lib/internal/custom-tools/read-available-by-id-or-title.ts @@ -1,14 +1,14 @@ -import { executeCopilotCustomToolUseCase } from '@/lib/copilot/application/execute-custom-tool-use-case' -import { - type CopilotExecutionContext, - requireTrustedCopilotExecutionContext, -} from '@/lib/copilot/auth/application-delegation' import { CUSTOM_TOOL_DELEGATION_AUDIENCE } from '@/lib/custom-tools/application/authorization' import { type ReadAvailableCustomToolByIdOrTitleInput, readAvailableCustomToolByIdOrTitleUseCase, } from '@/lib/custom-tools/application/use-cases' import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' +import { executeCopilotCustomToolUseCase } from '@/lib/mothership/application/execute-custom-tool-use-case' +import { + type CopilotExecutionContext, + requireTrustedCopilotExecutionContext, +} from '@/lib/mothership/auth/application-delegation' import type { ExecutionContext } from '@/executor/types' export interface ReadAvailableCustomToolByIdOrTitleAsExecutorInput { diff --git a/apps/sim/lib/internal/daytona/execute-tool.test.ts b/apps/sim/lib/internal/daytona/execute-tool.test.ts index b98584e78ac..8cab3287df8 100644 --- a/apps/sim/lib/internal/daytona/execute-tool.test.ts +++ b/apps/sim/lib/internal/daytona/execute-tool.test.ts @@ -3,8 +3,8 @@ */ import { createExecutionContext } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { DocCompileUserError } from '@/lib/mothership/tools/server/files/doc-compile-error' const mocks = vi.hoisted(() => ({ uploadDaytonaFile: vi.fn() })) diff --git a/apps/sim/lib/internal/daytona/operations.test.ts b/apps/sim/lib/internal/daytona/operations.test.ts index 607f9c8ab7d..654f4177c27 100644 --- a/apps/sim/lib/internal/daytona/operations.test.ts +++ b/apps/sim/lib/internal/daytona/operations.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { DocCompileUserError } from '@/lib/mothership/tools/server/files/doc-compile-error' const mocks = vi.hoisted(() => ({ assertToolFileAccess: vi.fn(), diff --git a/apps/sim/lib/internal/file/operations.ts b/apps/sim/lib/internal/file/operations.ts index 59489c2cb3e..f473e8ef407 100644 --- a/apps/sim/lib/internal/file/operations.ts +++ b/apps/sim/lib/internal/file/operations.ts @@ -8,7 +8,7 @@ import JSZip from 'jszip' import type { ContractBody } from '@/lib/api/contracts' import type { fileManageContract } from '@/lib/api/contracts/tools/file' import { DEFAULT_FILE_LIST_LIMIT } from '@/lib/api/contracts/tools/file' -import { splitWorkspaceFilePath } from '@/lib/copilot/tools/server/files/workspace-file' +import { splitWorkspaceFilePath } from '@/lib/mothership/tools/server/files/workspace-file' import { acquireLock, releaseLock } from '@/lib/core/config/redis' import { OrchestrationError } from '@/lib/core/orchestration/types' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' diff --git a/apps/sim/lib/internal/guardrails/operations.test.ts b/apps/sim/lib/internal/guardrails/operations.test.ts index b60fc466e6d..507b58790e9 100644 --- a/apps/sim/lib/internal/guardrails/operations.test.ts +++ b/apps/sim/lib/internal/guardrails/operations.test.ts @@ -34,7 +34,7 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ vi.mock('@/lib/billing/threshold-billing', () => ({ checkAndBillPayerOverageThreshold: vi.fn(), })) -vi.mock('@/lib/copilot/environment-context', () => ({ +vi.mock('@/lib/mothership/environment-context', () => ({ prepareCopilotEnvironmentContext: mocks.prepareEnvironment, })) vi.mock('@/lib/guardrails/validate_hallucination', () => ({ diff --git a/apps/sim/lib/internal/guardrails/operations.ts b/apps/sim/lib/internal/guardrails/operations.ts index 79f67f463c0..b0d6225ad1b 100644 --- a/apps/sim/lib/internal/guardrails/operations.ts +++ b/apps/sim/lib/internal/guardrails/operations.ts @@ -11,7 +11,6 @@ import { toBillingContext, } from '@/lib/billing/core/billing-attribution' import { checkAndBillPayerOverageThreshold } from '@/lib/billing/threshold-billing' -import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' import { inspectModelInputProvenanceRequest } from '@/lib/execution/model-input-provenance' import { validateHallucination } from '@/lib/guardrails/validate_hallucination' import { validateJson } from '@/lib/guardrails/validate_json' @@ -20,6 +19,7 @@ import { validatePIIViaHttp } from '@/lib/guardrails/validation-client' import { GuardrailsOperationError } from '@/lib/internal/guardrails/errors' import type { GuardrailsValidationInput } from '@/lib/internal/guardrails/input' import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' +import { prepareCopilotEnvironmentContext } from '@/lib/mothership/environment-context' import { assertPermissionsAllowed, ModelNotAllowedError, diff --git a/apps/sim/lib/internal/llm/operations.test.ts b/apps/sim/lib/internal/llm/operations.test.ts index 8bc2f2d57bb..81052504ccf 100644 --- a/apps/sim/lib/internal/llm/operations.test.ts +++ b/apps/sim/lib/internal/llm/operations.test.ts @@ -32,7 +32,7 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ BILLING_ATTRIBUTION_HEADER: 'x-sim-billing-attribution', requireBillingAttributionHeader: mocks.requireBillingAttribution, })) -vi.mock('@/lib/copilot/environment-context', () => ({ +vi.mock('@/lib/mothership/environment-context', () => ({ prepareCopilotEnvironmentContext: mocks.prepareEnvironment, })) vi.mock('@/lib/internal/llm/credentials', () => ({ diff --git a/apps/sim/lib/internal/llm/operations.ts b/apps/sim/lib/internal/llm/operations.ts index fbc2ca61b9f..e8274a7fefd 100644 --- a/apps/sim/lib/internal/llm/operations.ts +++ b/apps/sim/lib/internal/llm/operations.ts @@ -8,7 +8,6 @@ import { type BillingAttributionSnapshot, requireBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' -import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' import { inspectModelInputProjectionState, inspectModelInputProvenanceRequest, @@ -16,6 +15,7 @@ import { import { resolveVertexAccessToken } from '@/lib/internal/llm/credentials' import { LlmOperationError } from '@/lib/internal/llm/errors' import type { LlmProviderOperationInput } from '@/lib/internal/llm/input' +import { prepareCopilotEnvironmentContext } from '@/lib/mothership/environment-context' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { assertPermissionsAllowed, diff --git a/apps/sim/lib/knowledge/model-input-provenance.ts b/apps/sim/lib/knowledge/model-input-provenance.ts index 30fbfe8f706..bf15f898be2 100644 --- a/apps/sim/lib/knowledge/model-input-provenance.ts +++ b/apps/sim/lib/knowledge/model-input-provenance.ts @@ -1,6 +1,6 @@ import { AsyncLocalStorage } from 'node:async_hooks' -import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' import { inspectModelInputProvenanceRequest } from '@/lib/execution/model-input-provenance' +import { prepareCopilotEnvironmentContext } from '@/lib/mothership/environment-context' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import { diff --git a/apps/sim/lib/media/ffmpeg-schema-parity.test.ts b/apps/sim/lib/media/ffmpeg-schema-parity.test.ts index f9057738bac..8060bf559ee 100644 --- a/apps/sim/lib/media/ffmpeg-schema-parity.test.ts +++ b/apps/sim/lib/media/ffmpeg-schema-parity.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { TOOL_RUNTIME_SCHEMAS } from '@/lib/copilot/generated/tool-schemas-v1' import { FFMPEG_LIMITS } from '@/lib/media/ffmpeg-limits' +import { TOOL_RUNTIME_SCHEMAS } from '@/lib/mothership/generated/tool-schemas-v1' /** * The ffmpeg bounds live twice: here, where the executor enforces them, and in diff --git a/apps/sim/lib/model-router/resolve.test.ts b/apps/sim/lib/model-router/resolve.test.ts index b65528152a5..cca0f3c39bc 100644 --- a/apps/sim/lib/model-router/resolve.test.ts +++ b/apps/sim/lib/model-router/resolve.test.ts @@ -26,11 +26,11 @@ vi.mock('@/lib/core/config/env', () => ({ getEnv: () => undefined, })) -vi.mock('@/lib/copilot/request/go/fetch', () => ({ +vi.mock('@/lib/mothership/request/go/fetch', () => ({ fetchGo: mockFetchGo, })) -vi.mock('@/lib/copilot/server/agent-url', () => ({ +vi.mock('@/lib/mothership/server/agent-url', () => ({ getMothershipBaseURL: mockGetMothershipBaseURL, })) diff --git a/apps/sim/lib/model-router/resolve.ts b/apps/sim/lib/model-router/resolve.ts index 1e1fe8a2d1a..44ab35727d6 100644 --- a/apps/sim/lib/model-router/resolve.ts +++ b/apps/sim/lib/model-router/resolve.ts @@ -1,10 +1,10 @@ import { createHash } from 'crypto' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { fetchGo } from '@/lib/copilot/request/go/fetch' -import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url' import { env } from '@/lib/core/config/env' import { getCostMultiplier, isHosted } from '@/lib/core/config/env-flags' +import { fetchGo } from '@/lib/mothership/request/go/fetch' +import { getMothershipBaseURL } from '@/lib/mothership/server/agent-url' import { validateModelProvider } from '@/ee/access-control/utils/permission-check' import type { ExecutionContext } from '@/executor/types' import type { ModelCost } from '@/providers/cost-policy' diff --git a/apps/sim/lib/copilot/application/application-adapter.test.ts b/apps/sim/lib/mothership/application/application-adapter.test.ts similarity index 97% rename from apps/sim/lib/copilot/application/application-adapter.test.ts rename to apps/sim/lib/mothership/application/application-adapter.test.ts index add82de2876..ed4989c7b8f 100644 --- a/apps/sim/lib/copilot/application/application-adapter.test.ts +++ b/apps/sim/lib/mothership/application/application-adapter.test.ts @@ -3,14 +3,14 @@ */ import type { DelegatedPrincipal } from '@sim/auth/principal' import { describe, expect, it, vi } from 'vitest' -import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { defineWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' +import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' import { type CopilotDelegationConfiguration, type CopilotResourceScope, createCopilotApplicationPrincipal, type TrustedCopilotExecutionContext, -} from '@/lib/copilot/auth/application-delegation' -import { defineWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' +} from '@/lib/mothership/auth/application-delegation' const operation = defineWorkspaceOperation({ id: 'files.read', diff --git a/apps/sim/lib/copilot/application/application-adapter.ts b/apps/sim/lib/mothership/application/application-adapter.ts similarity index 99% rename from apps/sim/lib/copilot/application/application-adapter.ts rename to apps/sim/lib/mothership/application/application-adapter.ts index 2cb58cd32c9..e582517dc32 100644 --- a/apps/sim/lib/copilot/application/application-adapter.ts +++ b/apps/sim/lib/mothership/application/application-adapter.ts @@ -1,4 +1,9 @@ import type { DelegatedPrincipal } from '@sim/auth/principal' +import { + type OperationUseCase, + requireAllowedWorkspacePrincipal, + type WorkspaceOperation, +} from '@/lib/core/application' import { type CopilotDelegationConfiguration, type CopilotExecutionContext, @@ -6,12 +11,7 @@ import { createCopilotApplicationPrincipal, requireTrustedCopilotExecutionContext, type TrustedCopilotExecutionContext, -} from '@/lib/copilot/auth/application-delegation' -import { - type OperationUseCase, - requireAllowedWorkspacePrincipal, - type WorkspaceOperation, -} from '@/lib/core/application' +} from '@/lib/mothership/auth/application-delegation' type CopilotApplicationPrincipalFactory = (args: { context: TrustedCopilotExecutionContext diff --git a/apps/sim/lib/copilot/application/authorize-chat-callback.test.ts b/apps/sim/lib/mothership/application/authorize-chat-callback.test.ts similarity index 98% rename from apps/sim/lib/copilot/application/authorize-chat-callback.test.ts rename to apps/sim/lib/mothership/application/authorize-chat-callback.test.ts index 1c9af16437a..4e8dccb0722 100644 --- a/apps/sim/lib/copilot/application/authorize-chat-callback.test.ts +++ b/apps/sim/lib/mothership/application/authorize-chat-callback.test.ts @@ -7,7 +7,7 @@ import type { import { authorizeCopilotChatCallback, checkCopilotContinuationBilling, -} from '@/lib/copilot/application/authorize-chat-callback' +} from '@/lib/mothership/application/authorize-chat-callback' import { OrchestrationError } from '@/lib/core/orchestration/types' const mocks = vi.hoisted(() => ({ @@ -30,7 +30,7 @@ vi.mock('@/lib/permission-groups/capability-assertions', async (importOriginal) ...(await importOriginal()), assertWorkspaceCapability: mocks.capability, })) -vi.mock('@/lib/copilot/chat/organization-chats', () => ({ +vi.mock('@/lib/mothership/chat/organization-chats', () => ({ authorizeOrganizationChatDelegation: { execute: mocks.organization }, })) vi.mock('@/lib/billing/core/billing-attribution', () => ({ diff --git a/apps/sim/lib/copilot/application/authorize-chat-callback.ts b/apps/sim/lib/mothership/application/authorize-chat-callback.ts similarity index 93% rename from apps/sim/lib/copilot/application/authorize-chat-callback.ts rename to apps/sim/lib/mothership/application/authorize-chat-callback.ts index 58774447753..88d03f41e91 100644 --- a/apps/sim/lib/copilot/application/authorize-chat-callback.ts +++ b/apps/sim/lib/mothership/application/authorize-chat-callback.ts @@ -8,17 +8,17 @@ import { type BillingAttributionSnapshot, checkAttributedBillingBlocks, } from '@/lib/billing/core/billing-attribution' -import { chatOperations } from '@/lib/copilot/application/operations' +import { chatOperations } from '@/lib/mothership/application/operations' import { COPILOT_APPLICATION_DELEGATION_TTL_MS, createTrustedCopilotPrincipal, createTrustedOrganizationCopilotPrincipal, -} from '@/lib/copilot/auth/application-delegation' -import { authorizeOrganizationChatDelegation } from '@/lib/copilot/chat/organization-chats' +} from '@/lib/mothership/auth/application-delegation' +import { authorizeOrganizationChatDelegation } from '@/lib/mothership/chat/organization-chats' import { COPILOT_VALIDATION_PURPOSE, type CopilotValidationPurpose, -} from '@/lib/copilot/generated/billing-protocol-v1' +} from '@/lib/mothership/generated/billing-protocol-v1' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application/authorized-workspace-use-case' import { OrchestrationError } from '@/lib/core/orchestration/types' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' diff --git a/apps/sim/lib/copilot/application/error.test.ts b/apps/sim/lib/mothership/application/error.test.ts similarity index 96% rename from apps/sim/lib/copilot/application/error.test.ts rename to apps/sim/lib/mothership/application/error.test.ts index b0a0d4feb7b..2a2f968359b 100644 --- a/apps/sim/lib/copilot/application/error.test.ts +++ b/apps/sim/lib/mothership/application/error.test.ts @@ -2,11 +2,11 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE, messageForCopilotApplicationError, -} from '@/lib/copilot/application/error' -import { OrchestrationError } from '@/lib/core/orchestration/types' +} from '@/lib/mothership/application/error' describe('Copilot application error projection', () => { it('exposes only non-internal application errors', () => { diff --git a/apps/sim/lib/copilot/application/error.ts b/apps/sim/lib/mothership/application/error.ts similarity index 100% rename from apps/sim/lib/copilot/application/error.ts rename to apps/sim/lib/mothership/application/error.ts diff --git a/apps/sim/lib/copilot/application/execute-api-key-use-case.ts b/apps/sim/lib/mothership/application/execute-api-key-use-case.ts similarity index 65% rename from apps/sim/lib/copilot/application/execute-api-key-use-case.ts rename to apps/sim/lib/mothership/application/execute-api-key-use-case.ts index d1ec9402336..cb31c15adae 100644 --- a/apps/sim/lib/copilot/application/execute-api-key-use-case.ts +++ b/apps/sim/lib/mothership/application/execute-api-key-use-case.ts @@ -1,6 +1,6 @@ import { apiKeyOperations } from '@/lib/api-key/application/operations' -import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' -import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/mothership/auth/application-delegation' export const executeCopilotApiKeyUseCase = createCopilotApplicationAdapter({ domain: 'API key', diff --git a/apps/sim/lib/copilot/application/execute-credential-use-case.ts b/apps/sim/lib/mothership/application/execute-credential-use-case.ts similarity index 71% rename from apps/sim/lib/copilot/application/execute-credential-use-case.ts rename to apps/sim/lib/mothership/application/execute-credential-use-case.ts index cbebe99402a..719f9b55267 100644 --- a/apps/sim/lib/copilot/application/execute-credential-use-case.ts +++ b/apps/sim/lib/mothership/application/execute-credential-use-case.ts @@ -1,7 +1,7 @@ -import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' -import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' import { CREDENTIAL_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' import { credentialOperations } from '@/lib/credentials/application/operations' +import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/mothership/auth/application-delegation' export const executeCopilotCredentialUseCase = createCopilotApplicationAdapter({ domain: 'credential', diff --git a/apps/sim/lib/copilot/application/execute-custom-tool-use-case.test.ts b/apps/sim/lib/mothership/application/execute-custom-tool-use-case.test.ts similarity index 94% rename from apps/sim/lib/copilot/application/execute-custom-tool-use-case.test.ts rename to apps/sim/lib/mothership/application/execute-custom-tool-use-case.test.ts index b5089227bf4..efc346d95fb 100644 --- a/apps/sim/lib/copilot/application/execute-custom-tool-use-case.test.ts +++ b/apps/sim/lib/mothership/application/execute-custom-tool-use-case.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' -import { executeCopilotCustomToolUseCase } from '@/lib/copilot/application/execute-custom-tool-use-case' import { customToolOperations } from '@/lib/custom-tools/application/operations' +import { executeCopilotCustomToolUseCase } from '@/lib/mothership/application/execute-custom-tool-use-case' const trustedContext = { userId: 'user-1', diff --git a/apps/sim/lib/copilot/application/execute-custom-tool-use-case.ts b/apps/sim/lib/mothership/application/execute-custom-tool-use-case.ts similarity index 71% rename from apps/sim/lib/copilot/application/execute-custom-tool-use-case.ts rename to apps/sim/lib/mothership/application/execute-custom-tool-use-case.ts index 70662c129f3..e6a6255cbc0 100644 --- a/apps/sim/lib/copilot/application/execute-custom-tool-use-case.ts +++ b/apps/sim/lib/mothership/application/execute-custom-tool-use-case.ts @@ -1,7 +1,7 @@ -import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' -import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' import { customToolDelegationPolicy } from '@/lib/custom-tools/application/authorization' import { customToolOperations } from '@/lib/custom-tools/application/operations' +import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/mothership/auth/application-delegation' export const executeCopilotCustomToolUseCase = createCopilotApplicationAdapter({ domain: 'custom tool', diff --git a/apps/sim/lib/copilot/application/execute-file-use-case.test.ts b/apps/sim/lib/mothership/application/execute-file-use-case.test.ts similarity index 97% rename from apps/sim/lib/copilot/application/execute-file-use-case.test.ts rename to apps/sim/lib/mothership/application/execute-file-use-case.test.ts index 7e8d87fead1..a13c23c014c 100644 --- a/apps/sim/lib/copilot/application/execute-file-use-case.test.ts +++ b/apps/sim/lib/mothership/application/execute-file-use-case.test.ts @@ -14,7 +14,7 @@ vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () import { executeCopilotFileUseCase, resolveCopilotWorkspaceFileReference, -} from '@/lib/copilot/application/execute-file-use-case' +} from '@/lib/mothership/application/execute-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' const trustedContext = { diff --git a/apps/sim/lib/copilot/application/execute-file-use-case.ts b/apps/sim/lib/mothership/application/execute-file-use-case.ts similarity index 87% rename from apps/sim/lib/copilot/application/execute-file-use-case.ts rename to apps/sim/lib/mothership/application/execute-file-use-case.ts index cb03a490bbe..9aceb7a735e 100644 --- a/apps/sim/lib/copilot/application/execute-file-use-case.ts +++ b/apps/sim/lib/mothership/application/execute-file-use-case.ts @@ -1,10 +1,10 @@ -import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' -import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import type { OperationUseCase } from '@/lib/core/application' +import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/mothership/auth/application-delegation' import { type CopilotFileDelegationContext, resolveCopilotFilePrincipal, -} from '@/lib/copilot/auth/file-delegation' -import type { OperationUseCase } from '@/lib/core/application' +} from '@/lib/mothership/auth/file-delegation' import { workspaceFileDelegationPolicy } from '@/lib/workspace-files/application/authorization' import { type FileOperation, fileOperations } from '@/lib/workspace-files/application/operations' import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' diff --git a/apps/sim/lib/copilot/application/execute-knowledge-use-case.ts b/apps/sim/lib/mothership/application/execute-knowledge-use-case.ts similarity index 92% rename from apps/sim/lib/copilot/application/execute-knowledge-use-case.ts rename to apps/sim/lib/mothership/application/execute-knowledge-use-case.ts index 92895d601d2..3990c48e965 100644 --- a/apps/sim/lib/copilot/application/execute-knowledge-use-case.ts +++ b/apps/sim/lib/mothership/application/execute-knowledge-use-case.ts @@ -1,6 +1,6 @@ import type { DelegatedPrincipal, OrganizationDelegatedPrincipal } from '@sim/auth/principal' -import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' -import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' +import { messageForCopilotApplicationError } from '@/lib/mothership/application/error' import { COPILOT_APPLICATION_DELEGATION_TTL_MS, type CopilotExecutionContext, @@ -9,8 +9,8 @@ import { createTrustedOrganizationCopilotPrincipal, requireTrustedCopilotExecutionContext, requireTrustedOrganizationCopilotContext, -} from '@/lib/copilot/auth/application-delegation' -import { authorizeOrganizationChatDelegation } from '@/lib/copilot/chat/organization-chats' +} from '@/lib/mothership/auth/application-delegation' +import { authorizeOrganizationChatDelegation } from '@/lib/mothership/chat/organization-chats' import type { OperationUseCase } from '@/lib/core/application' import type { ResourceScope } from '@/lib/core/resource-scope' import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' diff --git a/apps/sim/lib/copilot/application/execute-log-use-case.ts b/apps/sim/lib/mothership/application/execute-log-use-case.ts similarity index 88% rename from apps/sim/lib/copilot/application/execute-log-use-case.ts rename to apps/sim/lib/mothership/application/execute-log-use-case.ts index 216b00eb3c7..2ed60ae6815 100644 --- a/apps/sim/lib/copilot/application/execute-log-use-case.ts +++ b/apps/sim/lib/mothership/application/execute-log-use-case.ts @@ -1,11 +1,11 @@ -import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' -import { - COPILOT_APPLICATION_DELEGATION_TTL_MS, - type CopilotExecutionContext, -} from '@/lib/copilot/auth/application-delegation' import type { OperationUseCase } from '@/lib/core/application' import { logDelegationPolicy } from '@/lib/logs/application/authorization' import { logOperations } from '@/lib/logs/application/operations' +import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + type CopilotExecutionContext, +} from '@/lib/mothership/auth/application-delegation' const copilotLogOperations = { list: logOperations.list, diff --git a/apps/sim/lib/copilot/application/execute-managed-mcp-use-case.ts b/apps/sim/lib/mothership/application/execute-managed-mcp-use-case.ts similarity index 76% rename from apps/sim/lib/copilot/application/execute-managed-mcp-use-case.ts rename to apps/sim/lib/mothership/application/execute-managed-mcp-use-case.ts index 10fd8682a28..8fd0b7f3b39 100644 --- a/apps/sim/lib/copilot/application/execute-managed-mcp-use-case.ts +++ b/apps/sim/lib/mothership/application/execute-managed-mcp-use-case.ts @@ -1,5 +1,5 @@ -import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' -import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/mothership/auth/application-delegation' import { MANAGED_MCP_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' import { credentialOperations } from '@/lib/credentials/application/operations' diff --git a/apps/sim/lib/copilot/application/execute-mcp-server-use-case.ts b/apps/sim/lib/mothership/application/execute-mcp-server-use-case.ts similarity index 71% rename from apps/sim/lib/copilot/application/execute-mcp-server-use-case.ts rename to apps/sim/lib/mothership/application/execute-mcp-server-use-case.ts index 76c05447784..844d29fee8f 100644 --- a/apps/sim/lib/copilot/application/execute-mcp-server-use-case.ts +++ b/apps/sim/lib/mothership/application/execute-mcp-server-use-case.ts @@ -1,7 +1,7 @@ -import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' -import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' import { mcpServerDelegationPolicy } from '@/lib/mcp/application/authorization' import { mcpServerOperations } from '@/lib/mcp/application/operations' +import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/mothership/auth/application-delegation' export const executeCopilotMcpServerUseCase = createCopilotApplicationAdapter({ domain: 'MCP server', diff --git a/apps/sim/lib/copilot/application/execute-sandbox-use-case.test.ts b/apps/sim/lib/mothership/application/execute-sandbox-use-case.test.ts similarity index 95% rename from apps/sim/lib/copilot/application/execute-sandbox-use-case.test.ts rename to apps/sim/lib/mothership/application/execute-sandbox-use-case.test.ts index 4448b49667f..62f6d68bd94 100644 --- a/apps/sim/lib/copilot/application/execute-sandbox-use-case.test.ts +++ b/apps/sim/lib/mothership/application/execute-sandbox-use-case.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' -import { executeCopilotSandboxUseCase } from '@/lib/copilot/application/execute-sandbox-use-case' +import { executeCopilotSandboxUseCase } from '@/lib/mothership/application/execute-sandbox-use-case' import { customToolOperations } from '@/lib/custom-tools/application/operations' import { sandboxOperations } from '@/lib/sandboxes/application/operations' diff --git a/apps/sim/lib/copilot/application/execute-sandbox-use-case.ts b/apps/sim/lib/mothership/application/execute-sandbox-use-case.ts similarity index 70% rename from apps/sim/lib/copilot/application/execute-sandbox-use-case.ts rename to apps/sim/lib/mothership/application/execute-sandbox-use-case.ts index 852adc4c90d..bed6566f9f0 100644 --- a/apps/sim/lib/copilot/application/execute-sandbox-use-case.ts +++ b/apps/sim/lib/mothership/application/execute-sandbox-use-case.ts @@ -1,5 +1,5 @@ -import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' -import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/mothership/auth/application-delegation' import { sandboxDelegationPolicy } from '@/lib/sandboxes/application/authorization' import { sandboxOperations } from '@/lib/sandboxes/application/operations' diff --git a/apps/sim/lib/copilot/application/execute-skill-use-case.ts b/apps/sim/lib/mothership/application/execute-skill-use-case.ts similarity index 70% rename from apps/sim/lib/copilot/application/execute-skill-use-case.ts rename to apps/sim/lib/mothership/application/execute-skill-use-case.ts index 7e38e20d9ff..1bf60feaf53 100644 --- a/apps/sim/lib/copilot/application/execute-skill-use-case.ts +++ b/apps/sim/lib/mothership/application/execute-skill-use-case.ts @@ -1,5 +1,5 @@ -import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' -import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/mothership/auth/application-delegation' import { skillDelegationPolicy } from '@/lib/skills/application/authorization' import { skillOperations } from '@/lib/skills/application/operations' diff --git a/apps/sim/lib/copilot/application/execute-table-use-case.test.ts b/apps/sim/lib/mothership/application/execute-table-use-case.test.ts similarity index 96% rename from apps/sim/lib/copilot/application/execute-table-use-case.test.ts rename to apps/sim/lib/mothership/application/execute-table-use-case.test.ts index a7fbcd2d531..63a667b80f3 100644 --- a/apps/sim/lib/copilot/application/execute-table-use-case.test.ts +++ b/apps/sim/lib/mothership/application/execute-table-use-case.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' +import { executeCopilotTableUseCase } from '@/lib/mothership/application/execute-table-use-case' import { tableOperations } from '@/lib/table/application/operations' const trustedContext = { diff --git a/apps/sim/lib/copilot/application/execute-table-use-case.ts b/apps/sim/lib/mothership/application/execute-table-use-case.ts similarity index 79% rename from apps/sim/lib/copilot/application/execute-table-use-case.ts rename to apps/sim/lib/mothership/application/execute-table-use-case.ts index d634d93141c..fefe5b6e42d 100644 --- a/apps/sim/lib/copilot/application/execute-table-use-case.ts +++ b/apps/sim/lib/mothership/application/execute-table-use-case.ts @@ -1,7 +1,7 @@ -import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' -import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' -import type { CopilotTableDelegationContext } from '@/lib/copilot/auth/table-delegation' import type { OperationUseCase } from '@/lib/core/application' +import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/mothership/auth/application-delegation' +import type { CopilotTableDelegationContext } from '@/lib/mothership/auth/table-delegation' import { tableDelegationPolicy } from '@/lib/table/application/authorization' import { type TableOperation, tableOperations } from '@/lib/table/application/operations' diff --git a/apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts b/apps/sim/lib/mothership/application/execute-workflow-use-case.test.ts similarity index 97% rename from apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts rename to apps/sim/lib/mothership/application/execute-workflow-use-case.test.ts index b666d27637f..57faae55bcb 100644 --- a/apps/sim/lib/copilot/application/execute-workflow-use-case.test.ts +++ b/apps/sim/lib/mothership/application/execute-workflow-use-case.test.ts @@ -9,13 +9,13 @@ vi.mock('@/lib/workflows/application/resolve-workflow-outputs', () => ({ resolveWorkflowOutputs: { execute: mocks.execute }, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { executeCopilotResolveWorkflowOutputs, executeCopilotWorkflowUseCase, messageForCopilotWorkflowError, -} from '@/lib/copilot/application/execute-workflow-use-case' -import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/copilot/constants' -import { OrchestrationError } from '@/lib/core/orchestration/types' +} from '@/lib/mothership/application/execute-workflow-use-case' +import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/mothership/constants' import { workflowOperations } from '@/lib/workflows/application/operations' const trustedContext = { diff --git a/apps/sim/lib/copilot/application/execute-workflow-use-case.ts b/apps/sim/lib/mothership/application/execute-workflow-use-case.ts similarity index 90% rename from apps/sim/lib/copilot/application/execute-workflow-use-case.ts rename to apps/sim/lib/mothership/application/execute-workflow-use-case.ts index 706e68bb9e8..cbc4c530475 100644 --- a/apps/sim/lib/copilot/application/execute-workflow-use-case.ts +++ b/apps/sim/lib/mothership/application/execute-workflow-use-case.ts @@ -1,12 +1,12 @@ -import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' -import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import type { OperationUseCase } from '@/lib/core/application' +import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' +import { messageForCopilotApplicationError } from '@/lib/mothership/application/error' import { COPILOT_APPLICATION_DELEGATION_TTL_MS, type CopilotExecutionContext, createCopilotApplicationPrincipal, requireTrustedCopilotExecutionContext, -} from '@/lib/copilot/auth/application-delegation' -import type { OperationUseCase } from '@/lib/core/application' +} from '@/lib/mothership/auth/application-delegation' import { workflowDelegationPolicy } from '@/lib/workflows/application/authorization' import { type WorkflowOperation, workflowOperations } from '@/lib/workflows/application/operations' import { diff --git a/apps/sim/lib/copilot/application/load-connected-accounts.ts b/apps/sim/lib/mothership/application/load-connected-accounts.ts similarity index 88% rename from apps/sim/lib/copilot/application/load-connected-accounts.ts rename to apps/sim/lib/mothership/application/load-connected-accounts.ts index cb1ccdae740..59bd235a7e8 100644 --- a/apps/sim/lib/copilot/application/load-connected-accounts.ts +++ b/apps/sim/lib/mothership/application/load-connected-accounts.ts @@ -1,8 +1,8 @@ -import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' import { COPILOT_APPLICATION_DELEGATION_TTL_MS, type TrustedCopilotExecutionContext, -} from '@/lib/copilot/auth/application-delegation' +} from '@/lib/mothership/auth/application-delegation' import { workspaceAccountsSettingsDelegationPolicy } from '@/lib/credential-groups/application/authorization' import { getWorkspaceAccountsSettings } from '@/lib/credential-groups/application/manage-groups' import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' diff --git a/apps/sim/lib/copilot/application/load-search-integrations.test.ts b/apps/sim/lib/mothership/application/load-search-integrations.test.ts similarity index 97% rename from apps/sim/lib/copilot/application/load-search-integrations.test.ts rename to apps/sim/lib/mothership/application/load-search-integrations.test.ts index d36eed08557..9225f757829 100644 --- a/apps/sim/lib/copilot/application/load-search-integrations.test.ts +++ b/apps/sim/lib/mothership/application/load-search-integrations.test.ts @@ -8,14 +8,14 @@ const { authorizeChat, listIntegrations } = vi.hoisted(() => ({ listIntegrations: vi.fn(), })) -vi.mock('@/lib/copilot/chat/organization-chats', () => ({ +vi.mock('@/lib/mothership/chat/organization-chats', () => ({ authorizeOrganizationChatDelegation: { execute: authorizeChat }, })) vi.mock('@/lib/knowledge/application/personal-search-integrations', () => ({ listPersonalSearchIntegrations: { execute: listIntegrations }, })) -import { loadCopilotSearchIntegrations } from '@/lib/copilot/application/load-search-integrations' +import { loadCopilotSearchIntegrations } from '@/lib/mothership/application/load-search-integrations' import type { listPersonalSearchIntegrations } from '@/lib/knowledge/application/personal-search-integrations' type InventoryPage = Awaited> diff --git a/apps/sim/lib/copilot/application/load-search-integrations.ts b/apps/sim/lib/mothership/application/load-search-integrations.ts similarity index 94% rename from apps/sim/lib/copilot/application/load-search-integrations.ts rename to apps/sim/lib/mothership/application/load-search-integrations.ts index 4627bf7a8b4..89056d399f5 100644 --- a/apps/sim/lib/copilot/application/load-search-integrations.ts +++ b/apps/sim/lib/mothership/application/load-search-integrations.ts @@ -1,8 +1,8 @@ import { COPILOT_APPLICATION_DELEGATION_TTL_MS, createTrustedOrganizationCopilotPrincipal, -} from '@/lib/copilot/auth/application-delegation' -import { authorizeOrganizationChatDelegation } from '@/lib/copilot/chat/organization-chats' +} from '@/lib/mothership/auth/application-delegation' +import { authorizeOrganizationChatDelegation } from '@/lib/mothership/chat/organization-chats' import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' import { listPersonalSearchIntegrations } from '@/lib/knowledge/application/personal-search-integrations' diff --git a/apps/sim/lib/copilot/application/operations.ts b/apps/sim/lib/mothership/application/operations.ts similarity index 100% rename from apps/sim/lib/copilot/application/operations.ts rename to apps/sim/lib/mothership/application/operations.ts diff --git a/apps/sim/lib/copilot/application/table-commands.test.ts b/apps/sim/lib/mothership/application/table-commands.test.ts similarity index 98% rename from apps/sim/lib/copilot/application/table-commands.test.ts rename to apps/sim/lib/mothership/application/table-commands.test.ts index f1d734dec1e..8ec4b96d410 100644 --- a/apps/sim/lib/copilot/application/table-commands.test.ts +++ b/apps/sim/lib/mothership/application/table-commands.test.ts @@ -18,7 +18,7 @@ const mocks = vi.hoisted(() => ({ }, })) -vi.mock('@/lib/copilot/application/execute-table-use-case', () => ({ +vi.mock('@/lib/mothership/application/execute-table-use-case', () => ({ executeCopilotTableUseCase: mocks.executeTableUseCase, })) vi.mock('@/lib/table/application/groups', () => ({ @@ -55,7 +55,7 @@ import { executeCopilotImportWorkspaceFileIntoTable, executeCopilotReplaceProjectedWireRows, executeCopilotUpdateWorkflowTableGroup, -} from '@/lib/copilot/application/table-commands' +} from '@/lib/mothership/application/table-commands' const context = { userId: 'user-1', diff --git a/apps/sim/lib/copilot/application/table-commands.ts b/apps/sim/lib/mothership/application/table-commands.ts similarity index 95% rename from apps/sim/lib/copilot/application/table-commands.ts rename to apps/sim/lib/mothership/application/table-commands.ts index 66f8ead69f4..e00c9c7aa6c 100644 --- a/apps/sim/lib/copilot/application/table-commands.ts +++ b/apps/sim/lib/mothership/application/table-commands.ts @@ -1,5 +1,5 @@ -import { executeCopilotTableUseCase } from '@/lib/copilot/application/execute-table-use-case' -import type { CopilotTableDelegationContext } from '@/lib/copilot/auth/table-delegation' +import { executeCopilotTableUseCase } from '@/lib/mothership/application/execute-table-use-case' +import type { CopilotTableDelegationContext } from '@/lib/mothership/auth/table-delegation' import { type DeleteCopilotTablesInput, deleteCopilotTables, diff --git a/apps/sim/lib/copilot/assistant/tool-policy.test.ts b/apps/sim/lib/mothership/assistant/tool-policy.test.ts similarity index 98% rename from apps/sim/lib/copilot/assistant/tool-policy.test.ts rename to apps/sim/lib/mothership/assistant/tool-policy.test.ts index 056e682a031..fec3f2a06d1 100644 --- a/apps/sim/lib/copilot/assistant/tool-policy.test.ts +++ b/apps/sim/lib/mothership/assistant/tool-policy.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import { assertAssistantIntegrationCall, isAssistantIntegrationTool, -} from '@/lib/copilot/assistant/tool-policy' +} from '@/lib/mothership/assistant/tool-policy' import type { ToolMetadata } from '@/tools/metadata' const tool: ToolMetadata = { diff --git a/apps/sim/lib/copilot/assistant/tool-policy.ts b/apps/sim/lib/mothership/assistant/tool-policy.ts similarity index 100% rename from apps/sim/lib/copilot/assistant/tool-policy.ts rename to apps/sim/lib/mothership/assistant/tool-policy.ts diff --git a/apps/sim/lib/copilot/async-runs/errors.ts b/apps/sim/lib/mothership/async-runs/errors.ts similarity index 100% rename from apps/sim/lib/copilot/async-runs/errors.ts rename to apps/sim/lib/mothership/async-runs/errors.ts diff --git a/apps/sim/lib/copilot/async-runs/lifecycle.test.ts b/apps/sim/lib/mothership/async-runs/lifecycle.test.ts similarity index 100% rename from apps/sim/lib/copilot/async-runs/lifecycle.test.ts rename to apps/sim/lib/mothership/async-runs/lifecycle.test.ts diff --git a/apps/sim/lib/copilot/async-runs/lifecycle.ts b/apps/sim/lib/mothership/async-runs/lifecycle.ts similarity index 98% rename from apps/sim/lib/copilot/async-runs/lifecycle.ts rename to apps/sim/lib/mothership/async-runs/lifecycle.ts index 4f77e1d173a..475496182f7 100644 --- a/apps/sim/lib/copilot/async-runs/lifecycle.ts +++ b/apps/sim/lib/mothership/async-runs/lifecycle.ts @@ -2,7 +2,7 @@ import type { CopilotAsyncToolStatus, CopilotToolPermissionDecision } from '@sim import { MothershipStreamV1AsyncToolRecordStatus, MothershipStreamV1ToolOutcome, -} from '@/lib/copilot/generated/mothership-stream-v1' +} from '@/lib/mothership/generated/mothership-stream-v1' export const ASYNC_TOOL_STATUS = MothershipStreamV1AsyncToolRecordStatus diff --git a/apps/sim/lib/copilot/async-runs/repository.test.ts b/apps/sim/lib/mothership/async-runs/repository.test.ts similarity index 100% rename from apps/sim/lib/copilot/async-runs/repository.test.ts rename to apps/sim/lib/mothership/async-runs/repository.test.ts diff --git a/apps/sim/lib/copilot/async-runs/repository.ts b/apps/sim/lib/mothership/async-runs/repository.ts similarity index 98% rename from apps/sim/lib/copilot/async-runs/repository.ts rename to apps/sim/lib/mothership/async-runs/repository.ts index e00d5474ae6..6d3a46fdc7b 100644 --- a/apps/sim/lib/copilot/async-runs/repository.ts +++ b/apps/sim/lib/mothership/async-runs/repository.ts @@ -11,10 +11,10 @@ import { createLogger } from '@sim/logger' import { filterUndefined } from '@sim/utils/object' import { sanitizeValueForJsonb } from '@sim/utils/string' import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm' -import { AsyncToolCallOwnershipError } from '@/lib/copilot/async-runs/errors' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { markSpanForError } from '@/lib/copilot/request/otel' +import { AsyncToolCallOwnershipError } from '@/lib/mothership/async-runs/errors' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { markSpanForError } from '@/lib/mothership/request/otel' import { ASYNC_TOOL_STATUS, type AsyncCompletionData, diff --git a/apps/sim/lib/copilot/async-runs/tool-identity.postgres.test.ts b/apps/sim/lib/mothership/async-runs/tool-identity.postgres.test.ts similarity index 96% rename from apps/sim/lib/copilot/async-runs/tool-identity.postgres.test.ts rename to apps/sim/lib/mothership/async-runs/tool-identity.postgres.test.ts index dcec96fb2da..6fc7c054e45 100644 --- a/apps/sim/lib/copilot/async-runs/tool-identity.postgres.test.ts +++ b/apps/sim/lib/mothership/async-runs/tool-identity.postgres.test.ts @@ -56,34 +56,34 @@ vi.mock('@sim/db', () => ({ }, }, })) -vi.mock('@/lib/copilot/request/otel', () => ({ markSpanForError: vi.fn() })) +vi.mock('@/lib/mothership/request/otel', () => ({ markSpanForError: vi.fn() })) vi.mock('@/lib/core/config/redis', () => ({ getConfiguredRedisUrl: () => redisUrl, getRedisConnectionDefaults: () => ({}), getRedisClient: () => redisState.current, })) -import { AsyncToolCallOwnershipError } from '@/lib/copilot/async-runs/errors' -import * as asyncRepository from '@/lib/copilot/async-runs/repository' +import { AsyncToolCallOwnershipError } from '@/lib/mothership/async-runs/errors' +import * as asyncRepository from '@/lib/mothership/async-runs/repository' import { completeAsyncToolCall, getAsyncToolCall, recordToolPermissionDecision, replaceTerminalAsyncToolCallResult, upsertAsyncToolCall, -} from '@/lib/copilot/async-runs/repository' +} from '@/lib/mothership/async-runs/repository' import { publishToolConfirmation, waitForToolConfirmation, -} from '@/lib/copilot/persistence/tool-confirm' +} from '@/lib/mothership/persistence/tool-confirm' import { publishToolPermissionDecision, waitForToolPermissionDecision, -} from '@/lib/copilot/persistence/tool-permission' +} from '@/lib/mothership/persistence/tool-permission' import { createProviderToolCallIdentity, scopeProviderToolCallId, -} from '@/lib/copilot/request/go/tool-call-identity' +} from '@/lib/mothership/request/go/tool-call-identity' const connection = databaseUrl ? postgres(databaseUrl, { max: 1 }) : undefined const providerId = 'call_reused_fixture' diff --git a/apps/sim/lib/copilot/auth/application-delegation.test.ts b/apps/sim/lib/mothership/auth/application-delegation.test.ts similarity index 97% rename from apps/sim/lib/copilot/auth/application-delegation.test.ts rename to apps/sim/lib/mothership/auth/application-delegation.test.ts index 655f685fc64..00318bb7912 100644 --- a/apps/sim/lib/copilot/auth/application-delegation.test.ts +++ b/apps/sim/lib/mothership/auth/application-delegation.test.ts @@ -6,7 +6,7 @@ import { createCopilotApplicationPrincipal, requireInteractiveCopilotExecutionContext, requireTrustedCopilotExecutionContext, -} from '@/lib/copilot/auth/application-delegation' +} from '@/lib/mothership/auth/application-delegation' const trustedContext = { userId: 'user-1', diff --git a/apps/sim/lib/copilot/auth/application-delegation.ts b/apps/sim/lib/mothership/auth/application-delegation.ts similarity index 99% rename from apps/sim/lib/copilot/auth/application-delegation.ts rename to apps/sim/lib/mothership/auth/application-delegation.ts index bcee73ce451..7598fbcc375 100644 --- a/apps/sim/lib/copilot/auth/application-delegation.ts +++ b/apps/sim/lib/mothership/auth/application-delegation.ts @@ -1,5 +1,5 @@ import type { DelegatedPrincipal, OrganizationDelegatedPrincipal } from '@sim/auth/principal' -import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/copilot/constants' +import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/mothership/constants' /** Keeps delegated authority valid for the full bounded Copilot orchestration lifetime. */ export const COPILOT_APPLICATION_DELEGATION_TTL_MS = ORCHESTRATION_TIMEOUT_MS diff --git a/apps/sim/lib/copilot/auth/file-delegation.test.ts b/apps/sim/lib/mothership/auth/file-delegation.test.ts similarity index 96% rename from apps/sim/lib/copilot/auth/file-delegation.test.ts rename to apps/sim/lib/mothership/auth/file-delegation.test.ts index faf369aa089..969329448e7 100644 --- a/apps/sim/lib/copilot/auth/file-delegation.test.ts +++ b/apps/sim/lib/mothership/auth/file-delegation.test.ts @@ -2,14 +2,14 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { createCopilotChatFilePrincipal, createCopilotWorkspaceContextFilePrincipal, messageForCopilotFileError, resolveCopilotFilePrincipal, -} from '@/lib/copilot/auth/file-delegation' -import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/copilot/constants' -import { OrchestrationError } from '@/lib/core/orchestration/types' +} from '@/lib/mothership/auth/file-delegation' +import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/mothership/constants' const trustedContext = { userId: 'user-1', diff --git a/apps/sim/lib/copilot/auth/file-delegation.ts b/apps/sim/lib/mothership/auth/file-delegation.ts similarity index 94% rename from apps/sim/lib/copilot/auth/file-delegation.ts rename to apps/sim/lib/mothership/auth/file-delegation.ts index b23f7837d83..77fd0bb1824 100644 --- a/apps/sim/lib/copilot/auth/file-delegation.ts +++ b/apps/sim/lib/mothership/auth/file-delegation.ts @@ -1,5 +1,5 @@ import type { DelegatedPrincipal } from '@sim/auth/principal' -import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { messageForCopilotApplicationError } from '@/lib/mothership/application/error' import { COPILOT_APPLICATION_DELEGATION_TTL_MS, type CopilotExecutionContext, @@ -7,7 +7,7 @@ import { createCopilotChatPrincipal, createTrustedCopilotPrincipal, requireTrustedCopilotExecutionContext, -} from '@/lib/copilot/auth/application-delegation' +} from '@/lib/mothership/auth/application-delegation' import { workspaceFileDelegationPolicy } from '@/lib/workspace-files/application/authorization' export type CopilotFileDelegationContext = CopilotExecutionContext diff --git a/apps/sim/lib/copilot/auth/permissions.test.ts b/apps/sim/lib/mothership/auth/permissions.test.ts similarity index 99% rename from apps/sim/lib/copilot/auth/permissions.test.ts rename to apps/sim/lib/mothership/auth/permissions.test.ts index 605f82c0ae9..02d868fdeed 100644 --- a/apps/sim/lib/copilot/auth/permissions.test.ts +++ b/apps/sim/lib/mothership/auth/permissions.test.ts @@ -6,7 +6,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockAuthorizeWorkflowByWorkspacePermission } = workflowAuthzMockFns -import { createPermissionError, verifyWorkflowAccess } from '@/lib/copilot/auth/permissions' +import { createPermissionError, verifyWorkflowAccess } from '@/lib/mothership/auth/permissions' afterAll(() => { mockAuthorizeWorkflowByWorkspacePermission.mockReset() diff --git a/apps/sim/lib/copilot/auth/permissions.ts b/apps/sim/lib/mothership/auth/permissions.ts similarity index 100% rename from apps/sim/lib/copilot/auth/permissions.ts rename to apps/sim/lib/mothership/auth/permissions.ts diff --git a/apps/sim/lib/copilot/auth/table-delegation.test.ts b/apps/sim/lib/mothership/auth/table-delegation.test.ts similarity index 92% rename from apps/sim/lib/copilot/auth/table-delegation.test.ts rename to apps/sim/lib/mothership/auth/table-delegation.test.ts index 7e6cf5743c5..61e2f77bb4c 100644 --- a/apps/sim/lib/copilot/auth/table-delegation.test.ts +++ b/apps/sim/lib/mothership/auth/table-delegation.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { createCopilotChatTablePrincipal } from '@/lib/copilot/auth/table-delegation' +import { createCopilotChatTablePrincipal } from '@/lib/mothership/auth/table-delegation' import { tableDelegationPolicy } from '@/lib/table/application/authorization' describe('chat table delegation', () => { diff --git a/apps/sim/lib/copilot/auth/table-delegation.ts b/apps/sim/lib/mothership/auth/table-delegation.ts similarity index 83% rename from apps/sim/lib/copilot/auth/table-delegation.ts rename to apps/sim/lib/mothership/auth/table-delegation.ts index 113778269cc..2e765b10386 100644 --- a/apps/sim/lib/copilot/auth/table-delegation.ts +++ b/apps/sim/lib/mothership/auth/table-delegation.ts @@ -1,8 +1,8 @@ -import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { messageForCopilotApplicationError } from '@/lib/mothership/application/error' import { type CopilotExecutionContext, createCopilotChatPrincipal, -} from '@/lib/copilot/auth/application-delegation' +} from '@/lib/mothership/auth/application-delegation' import { tableDelegationPolicy } from '@/lib/table/application/authorization' export type CopilotTableDelegationContext = CopilotExecutionContext diff --git a/apps/sim/lib/copilot/block-visibility.ts b/apps/sim/lib/mothership/block-visibility.ts similarity index 100% rename from apps/sim/lib/copilot/block-visibility.ts rename to apps/sim/lib/mothership/block-visibility.ts diff --git a/apps/sim/lib/copilot/chat-status.test.ts b/apps/sim/lib/mothership/chat-status.test.ts similarity index 96% rename from apps/sim/lib/copilot/chat-status.test.ts rename to apps/sim/lib/mothership/chat-status.test.ts index 1d559dc966a..17b3fc2cf3d 100644 --- a/apps/sim/lib/copilot/chat-status.test.ts +++ b/apps/sim/lib/mothership/chat-status.test.ts @@ -6,7 +6,7 @@ vi.mock('@/lib/events/pubsub', () => ({ createPubSubChannel: () => ({ publish, subscribe: vi.fn(), dispose: vi.fn() }), })) -import { publishChatStatusChanged } from '@/lib/copilot/chat-status' +import { publishChatStatusChanged } from '@/lib/mothership/chat-status' describe('chat status ownership', () => { beforeEach(() => vi.clearAllMocks()) diff --git a/apps/sim/lib/copilot/chat-status.ts b/apps/sim/lib/mothership/chat-status.ts similarity index 100% rename from apps/sim/lib/copilot/chat-status.ts rename to apps/sim/lib/mothership/chat-status.ts diff --git a/apps/sim/lib/copilot/chat/assistant-images.test.ts b/apps/sim/lib/mothership/chat/assistant-images.test.ts similarity index 95% rename from apps/sim/lib/copilot/chat/assistant-images.test.ts rename to apps/sim/lib/mothership/chat/assistant-images.test.ts index 5a11b4905de..ec7192b10ce 100644 --- a/apps/sim/lib/copilot/chat/assistant-images.test.ts +++ b/apps/sim/lib/mothership/chat/assistant-images.test.ts @@ -12,8 +12,8 @@ vi.mock('@/lib/uploads/contexts/organization-assistant/application', () => ({ readOrganizationAssistantImage: readImage, })) -import { prepareAssistantImages } from '@/lib/copilot/chat/assistant-images' -import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview' +import { prepareAssistantImages } from '@/lib/mothership/chat/assistant-images' +import { getMothershipAttachmentPreviewUrl } from '@/lib/mothership/chat/attachment-preview' const principal: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } const key = 'assistant/org-1/user-1/upload-1/image.png' diff --git a/apps/sim/lib/copilot/chat/assistant-images.ts b/apps/sim/lib/mothership/chat/assistant-images.ts similarity index 96% rename from apps/sim/lib/copilot/chat/assistant-images.ts rename to apps/sim/lib/mothership/chat/assistant-images.ts index c2f6f643743..e0e0f8a7d7a 100644 --- a/apps/sim/lib/copilot/chat/assistant-images.ts +++ b/apps/sim/lib/mothership/chat/assistant-images.ts @@ -1,5 +1,5 @@ import type { SessionPrincipal } from '@sim/auth/principal' -import type { PersistedFileAttachment } from '@/lib/copilot/chat/persisted-message' +import type { PersistedFileAttachment } from '@/lib/mothership/chat/persisted-message' import { OrchestrationError } from '@/lib/core/orchestration/types' import { readOrganizationAssistantImage } from '@/lib/uploads/contexts/organization-assistant/application' import { diff --git a/apps/sim/lib/copilot/chat/attachment-preview.test.ts b/apps/sim/lib/mothership/chat/attachment-preview.test.ts similarity index 100% rename from apps/sim/lib/copilot/chat/attachment-preview.test.ts rename to apps/sim/lib/mothership/chat/attachment-preview.test.ts diff --git a/apps/sim/lib/copilot/chat/attachment-preview.ts b/apps/sim/lib/mothership/chat/attachment-preview.ts similarity index 100% rename from apps/sim/lib/copilot/chat/attachment-preview.ts rename to apps/sim/lib/mothership/chat/attachment-preview.ts diff --git a/apps/sim/lib/copilot/chat/citation-evidence.ts b/apps/sim/lib/mothership/chat/citation-evidence.ts similarity index 100% rename from apps/sim/lib/copilot/chat/citation-evidence.ts rename to apps/sim/lib/mothership/chat/citation-evidence.ts diff --git a/apps/sim/lib/mothership/chat/delegation.ts b/apps/sim/lib/mothership/chat/delegation.ts new file mode 100644 index 00000000000..6063526a966 --- /dev/null +++ b/apps/sim/lib/mothership/chat/delegation.ts @@ -0,0 +1,77 @@ +import { db } from '@sim/db' +import { apiKey } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateShortId } from '@sim/utils/id' +import { and, eq, gt } from 'drizzle-orm' +import { createApiKey } from '@/lib/api-key/auth' +import { decryptApiKey, hashApiKey } from '@/lib/api-key/crypto' + +const logger = createLogger('MothershipDelegation') + +const DELEGATION_TTL_MS = 12 * 60 * 60 * 1000 +const MIN_REMAINING_MS = 30 * 60 * 1000 + +function delegationName(userId: string): string { + return `mothership-delegation:${userId}` +} + +/** + * Mints (or reuses) the run-scoped credential the mothership worker presents on v2 calls + * (revamp D23). One rolling PERSONAL key per user — "the agent is the user" (D25): personal + * keys carry exactly the user's own authorization on every v2 operation, where workspace + * keys are op-restricted (graph edits return WORKSPACE_KEY_OPERATION_NOT_PERMITTED). + * Server-minted, 12h TTL enforced by the normal API-key auth path — the worker holds no + * standing credentials and the token never outlives its TTL. Returns null on any failure + * so a chat never breaks over credential minting (the worker falls back to its static dev + * credential only in local development). + */ +export async function mintDelegationToken(params: { + workspaceId: string + userId: string +}): Promise { + try { + const name = delegationName(params.userId) + const [existing] = await db + .select({ id: apiKey.id, key: apiKey.key, expiresAt: apiKey.expiresAt }) + .from(apiKey) + .where( + and( + eq(apiKey.userId, params.userId), + eq(apiKey.name, name), + eq(apiKey.type, 'personal'), + gt(apiKey.expiresAt, new Date(Date.now() + MIN_REMAINING_MS)) + ) + ) + .limit(1) + if (existing) { + const { decrypted } = await decryptApiKey(existing.key) + return decrypted + } + + const { key: plainKey, encryptedKey } = await createApiKey(true) + const stored = encryptedKey ?? plainKey + const now = new Date() + const expiresAt = new Date(now.getTime() + DELEGATION_TTL_MS) + await db + .delete(apiKey) + .where( + and(eq(apiKey.userId, params.userId), eq(apiKey.name, name), eq(apiKey.type, 'personal')) + ) + await db.insert(apiKey).values({ + id: generateShortId(), + userId: params.userId, + createdBy: params.userId, + name, + key: stored, + keyHash: hashApiKey(plainKey), + type: 'personal', + createdAt: now, + updatedAt: now, + expiresAt, + }) + return plainKey + } catch (error) { + logger.warn('Delegation token minting failed; chat continues without one', { error }) + return null + } +} diff --git a/apps/sim/lib/copilot/chat/desktop-capabilities.ts b/apps/sim/lib/mothership/chat/desktop-capabilities.ts similarity index 100% rename from apps/sim/lib/copilot/chat/desktop-capabilities.ts rename to apps/sim/lib/mothership/chat/desktop-capabilities.ts diff --git a/apps/sim/lib/copilot/chat/display-message.test.ts b/apps/sim/lib/mothership/chat/display-message.test.ts similarity index 100% rename from apps/sim/lib/copilot/chat/display-message.test.ts rename to apps/sim/lib/mothership/chat/display-message.test.ts diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/mothership/chat/display-message.ts similarity index 97% rename from apps/sim/lib/copilot/chat/display-message.ts rename to apps/sim/lib/mothership/chat/display-message.ts index d185f2b27a1..863cf9e0546 100644 --- a/apps/sim/lib/copilot/chat/display-message.ts +++ b/apps/sim/lib/mothership/chat/display-message.ts @@ -3,9 +3,9 @@ import { MothershipStreamV1EventType, MothershipStreamV1SpanLifecycleEvent, MothershipStreamV1ToolOutcome, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' -import { normalizeToolActivityDescription } from '@/lib/copilot/tools/tool-display' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { isToolHiddenInUi } from '@/lib/mothership/tools/client/hidden-tools' +import { normalizeToolActivityDescription } from '@/lib/mothership/tools/tool-display' import { type ChatContextKind, type ChatMessage, diff --git a/apps/sim/lib/copilot/chat/effective-transcript.test.ts b/apps/sim/lib/mothership/chat/effective-transcript.test.ts similarity index 98% rename from apps/sim/lib/copilot/chat/effective-transcript.test.ts rename to apps/sim/lib/mothership/chat/effective-transcript.test.ts index 92672da3382..27f3406d3cd 100644 --- a/apps/sim/lib/copilot/chat/effective-transcript.test.ts +++ b/apps/sim/lib/mothership/chat/effective-transcript.test.ts @@ -7,8 +7,8 @@ import { buildEffectiveChatTranscript, getLiveAssistantMessageId, isLiveAssistantMessageId, -} from '@/lib/copilot/chat/effective-transcript' -import { normalizeMessage } from '@/lib/copilot/chat/persisted-message' +} from '@/lib/mothership/chat/effective-transcript' +import { normalizeMessage } from '@/lib/mothership/chat/persisted-message' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, @@ -18,8 +18,8 @@ import { MothershipStreamV1SpanPayloadKind, MothershipStreamV1TextChannel, MothershipStreamV1ToolOutcome, -} from '@/lib/copilot/generated/mothership-stream-v1' -import type { StreamBatchEvent } from '@/lib/copilot/request/session/types' +} from '@/lib/mothership/generated/mothership-stream-v1' +import type { StreamBatchEvent } from '@/lib/mothership/request/session/types' function toBatchEvent(eventId: number, event: StreamBatchEvent['event']): StreamBatchEvent { return { diff --git a/apps/sim/lib/copilot/chat/effective-transcript.ts b/apps/sim/lib/mothership/chat/effective-transcript.ts similarity index 97% rename from apps/sim/lib/copilot/chat/effective-transcript.ts rename to apps/sim/lib/mothership/chat/effective-transcript.ts index 882ef7557a2..e0c9b77ed58 100644 --- a/apps/sim/lib/copilot/chat/effective-transcript.ts +++ b/apps/sim/lib/mothership/chat/effective-transcript.ts @@ -1,6 +1,6 @@ import { isRecordLike } from '@sim/utils/object' -import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message' -import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome' +import { normalizeMessage, type PersistedMessage } from '@/lib/mothership/chat/persisted-message' +import { resolveStreamToolOutcome } from '@/lib/mothership/chat/stream-tool-outcome' import { MothershipStreamV1CompletionStatus, type MothershipStreamV1ErrorPayload, @@ -12,14 +12,14 @@ import { MothershipStreamV1TextChannel, MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, -} from '@/lib/copilot/generated/mothership-stream-v1' -import type { FilePreviewSession } from '@/lib/copilot/request/session/file-preview-session-contract' -import type { StreamBatchEvent } from '@/lib/copilot/request/session/types' +} from '@/lib/mothership/generated/mothership-stream-v1' +import type { FilePreviewSession } from '@/lib/mothership/request/session/file-preview-session-contract' +import type { StreamBatchEvent } from '@/lib/mothership/request/session/types' import { CONTEXT_COMPACTION_DISPLAY_TITLE, getToolDisplayTitle, normalizeToolActivityDescription, -} from '@/lib/copilot/tools/tool-display' +} from '@/lib/mothership/tools/tool-display' interface StreamSnapshotLike { events: StreamBatchEvent[] diff --git a/apps/sim/lib/copilot/chat/folder-context.ts b/apps/sim/lib/mothership/chat/folder-context.ts similarity index 95% rename from apps/sim/lib/copilot/chat/folder-context.ts rename to apps/sim/lib/mothership/chat/folder-context.ts index f80acb71105..75c59f96a6e 100644 --- a/apps/sim/lib/copilot/chat/folder-context.ts +++ b/apps/sim/lib/mothership/chat/folder-context.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' -import { createCopilotChatPrincipal } from '@/lib/copilot/auth/application-delegation' -import { createCopilotChatFilePrincipal } from '@/lib/copilot/auth/file-delegation' -import { buildVfsFolderPathMap, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-delegation' +import { createCopilotChatFilePrincipal } from '@/lib/mothership/auth/file-delegation' +import { buildVfsFolderPathMap, encodeVfsPathSegments } from '@/lib/mothership/vfs/path-utils' import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' import { listKnowledgeFolders } from '@/lib/knowledge/application/folders' import { tableDelegationPolicy } from '@/lib/table/application/authorization' diff --git a/apps/sim/lib/copilot/chat/fork-chat-files.test.ts b/apps/sim/lib/mothership/chat/fork-chat-files.test.ts similarity index 99% rename from apps/sim/lib/copilot/chat/fork-chat-files.test.ts rename to apps/sim/lib/mothership/chat/fork-chat-files.test.ts index 652595ff5e8..a69d1e3ebbd 100644 --- a/apps/sim/lib/copilot/chat/fork-chat-files.test.ts +++ b/apps/sim/lib/mothership/chat/fork-chat-files.test.ts @@ -33,7 +33,7 @@ import { type ForkableChatFileRow, filterForkableChatFiles, planChatFileCopies, -} from '@/lib/copilot/chat/fork-chat-files' +} from '@/lib/mothership/chat/fork-chat-files' const NOW = new Date('2026-07-08T00:00:00.000Z') diff --git a/apps/sim/lib/copilot/chat/fork-chat-files.ts b/apps/sim/lib/mothership/chat/fork-chat-files.ts similarity index 100% rename from apps/sim/lib/copilot/chat/fork-chat-files.ts rename to apps/sim/lib/mothership/chat/fork-chat-files.ts diff --git a/apps/sim/lib/copilot/chat/lifecycle.test.ts b/apps/sim/lib/mothership/chat/lifecycle.test.ts similarity index 99% rename from apps/sim/lib/copilot/chat/lifecycle.test.ts rename to apps/sim/lib/mothership/chat/lifecycle.test.ts index f5eed98acf6..ceb3f8ab53f 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.test.ts +++ b/apps/sim/lib/mothership/chat/lifecycle.test.ts @@ -36,7 +36,7 @@ import { getAccessibleCopilotChatForCancellation, getAccessibleCopilotChatWithMessages, resolveOrCreateChat, -} from '@/lib/copilot/chat/lifecycle' +} from '@/lib/mothership/chat/lifecycle' const CHAT_ID = 'chat-1' const USER_ID = 'user-1' diff --git a/apps/sim/lib/copilot/chat/lifecycle.ts b/apps/sim/lib/mothership/chat/lifecycle.ts similarity index 99% rename from apps/sim/lib/copilot/chat/lifecycle.ts rename to apps/sim/lib/mothership/chat/lifecycle.ts index b1a53c31982..4b5d18cd6d9 100644 --- a/apps/sim/lib/copilot/chat/lifecycle.ts +++ b/apps/sim/lib/mothership/chat/lifecycle.ts @@ -10,8 +10,8 @@ import { and, asc, eq, isNull, sql } from 'drizzle-orm' import { authorizeOrganizationChat, authorizeOrganizationChatCancellation, -} from '@/lib/copilot/chat/organization-chats' -import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message' +} from '@/lib/mothership/chat/organization-chats' +import { type PersistedMessage, stripToolResultOutput } from '@/lib/mothership/chat/persisted-message' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { assertActiveWorkspaceAccess, diff --git a/apps/sim/lib/copilot/chat/list-mothership-chats.ts b/apps/sim/lib/mothership/chat/list-mothership-chats.ts similarity index 96% rename from apps/sim/lib/copilot/chat/list-mothership-chats.ts rename to apps/sim/lib/mothership/chat/list-mothership-chats.ts index 741c6279a85..b63f06e827f 100644 --- a/apps/sim/lib/copilot/chat/list-mothership-chats.ts +++ b/apps/sim/lib/mothership/chat/list-mothership-chats.ts @@ -2,7 +2,7 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { and, desc, eq, isNotNull, isNull } from 'drizzle-orm' import type { MothershipChat, MothershipChatScope } from '@/lib/api/contracts/mothership-chats' -import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' +import { reconcileChatStreamMarkers } from '@/lib/mothership/chat/stream-liveness' /** * Lists a user's mothership (home) chats for a workspace as the contract wire diff --git a/apps/sim/lib/copilot/chat/messages-store.test.ts b/apps/sim/lib/mothership/chat/messages-store.test.ts similarity index 97% rename from apps/sim/lib/copilot/chat/messages-store.test.ts rename to apps/sim/lib/mothership/chat/messages-store.test.ts index 48c082e4c05..7a6df9372e3 100644 --- a/apps/sim/lib/copilot/chat/messages-store.test.ts +++ b/apps/sim/lib/mothership/chat/messages-store.test.ts @@ -6,8 +6,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { appendCopilotChatMessages, persistCopilotChatTurn, -} from '@/lib/copilot/chat/messages-store' -import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' + replaceCopilotChatMessages, +} from '@/lib/mothership/chat/messages-store' +import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message' const userMsg: PersistedMessage = { id: 'msg-user-1', diff --git a/apps/sim/lib/copilot/chat/messages-store.ts b/apps/sim/lib/mothership/chat/messages-store.ts similarity index 95% rename from apps/sim/lib/copilot/chat/messages-store.ts rename to apps/sim/lib/mothership/chat/messages-store.ts index eae81ba4c51..7c58aceca7b 100644 --- a/apps/sim/lib/copilot/chat/messages-store.ts +++ b/apps/sim/lib/mothership/chat/messages-store.ts @@ -1,8 +1,12 @@ import { db } from '@sim/db' import { copilotChats, copilotMessages } from '@sim/db/schema' -import { and, eq, isNull, sql } from 'drizzle-orm' -import { type PersistedMessage, stripToolResultOutput } from '@/lib/copilot/chat/persisted-message' +import { and, eq, isNull, notInArray, sql } from 'drizzle-orm' +import { type PersistedMessage, stripToolResultOutput } from '@/lib/mothership/chat/persisted-message' import type { DbOrTx } from '@/lib/db/types' +import { + type PersistedMessage, + stripToolResultOutput, +} from '@/lib/mothership/chat/persisted-message' /** * Keep the first occurrence of each message id. A single `INSERT ... ON diff --git a/apps/sim/lib/copilot/chat/organization-chats.test.ts b/apps/sim/lib/mothership/chat/organization-chats.test.ts similarity index 97% rename from apps/sim/lib/copilot/chat/organization-chats.test.ts rename to apps/sim/lib/mothership/chat/organization-chats.test.ts index a0489ea0e38..b7b3f734def 100644 --- a/apps/sim/lib/copilot/chat/organization-chats.test.ts +++ b/apps/sim/lib/mothership/chat/organization-chats.test.ts @@ -1,13 +1,13 @@ /** @vitest-environment node */ import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createTrustedOrganizationCopilotPrincipal } from '@/lib/copilot/auth/application-delegation' +import { createTrustedOrganizationCopilotPrincipal } from '@/lib/mothership/auth/application-delegation' import { authorizeOrganizationChatCancellation, authorizeOrganizationChatDelegation, authorizeOrganizationChatEvents, createOrganizationChat, -} from '@/lib/copilot/chat/organization-chats' +} from '@/lib/mothership/chat/organization-chats' import { OrchestrationError } from '@/lib/core/orchestration/types' const { authorize, requireSearch, publish } = vi.hoisted(() => ({ @@ -18,7 +18,7 @@ const { authorize, requireSearch, publish } = vi.hoisted(() => ({ vi.mock('@/lib/knowledge/access/availability', () => ({ requireOrganizationSearchAvailable: requireSearch, })) -vi.mock('@/lib/copilot/chat-status', () => ({ publishChatStatusChanged: publish })) +vi.mock('@/lib/mothership/chat-status', () => ({ publishChatStatusChanged: publish })) vi.mock('@/lib/core/application/organization-authorization', () => ({ authorizeOrganizationOperation: authorize, })) diff --git a/apps/sim/lib/copilot/chat/organization-chats.ts b/apps/sim/lib/mothership/chat/organization-chats.ts similarity index 96% rename from apps/sim/lib/copilot/chat/organization-chats.ts rename to apps/sim/lib/mothership/chat/organization-chats.ts index c91833e8ffa..6f9489c4cce 100644 --- a/apps/sim/lib/copilot/chat/organization-chats.ts +++ b/apps/sim/lib/mothership/chat/organization-chats.ts @@ -3,9 +3,9 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' import type { MothershipChatScope } from '@/lib/api/contracts/mothership-chats' -import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' -import { publishChatStatusChanged } from '@/lib/copilot/chat-status' -import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/copilot/constants' +import { listMothershipChats } from '@/lib/mothership/chat/list-mothership-chats' +import { publishChatStatusChanged } from '@/lib/mothership/chat-status' +import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/mothership/constants' import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' import { OrchestrationError } from '@/lib/core/orchestration/types' diff --git a/apps/sim/lib/copilot/chat/payload.test.ts b/apps/sim/lib/mothership/chat/payload.test.ts similarity index 85% rename from apps/sim/lib/copilot/chat/payload.test.ts rename to apps/sim/lib/mothership/chat/payload.test.ts index 93a86f0b825..ea2db2091b1 100644 --- a/apps/sim/lib/copilot/chat/payload.test.ts +++ b/apps/sim/lib/mothership/chat/payload.test.ts @@ -67,7 +67,7 @@ vi.mock('@/tools/utils', () => ({ stripVersionSuffix: vi.fn((toolId: string) => toolId), })) -vi.mock('@/lib/copilot/block-visibility', () => ({ +vi.mock('@/lib/mothership/block-visibility', () => ({ getBlockVisibilityForCopilot: vi.fn(async () => ({ revealed: new Set(), disabled: new Set(), @@ -76,7 +76,7 @@ vi.mock('@/lib/copilot/block-visibility', () => ({ visibilitySignature: vi.fn(() => 'vis:none'), })) -vi.mock('@/lib/copilot/integration-tools', () => ({ +vi.mock('@/lib/mothership/integration-tools', () => ({ filterExposedIntegrationTools: vi.fn( ( tools: Array<{ toolId: string; blockType: string; service: string }>, @@ -453,9 +453,8 @@ describe('buildCopilotRequestPayload', () => { { type: 'uploaded_file', content: [ - 'File "payroll.xlsx" (application/octet-stream, 1 bytes) uploaded.', - 'Read with: read("uploads/payroll.xlsx")', - 'To save permanently: save_upload(fileName: "payroll.xlsx")', + 'File "payroll.xlsx" (application/octet-stream, 1 bytes) uploaded to workspace files.', + 'Read it with: sim --output json files read "uploads/payroll.xlsx"', ].join('\n'), }, ]) @@ -495,16 +494,15 @@ describe('buildCopilotRequestPayload', () => { { type: 'uploaded_file', content: [ - 'File "photo.png" (image/png, 10 bytes) uploaded.', - 'Read with: read("uploads/photo.png")', - 'To save permanently: save_upload(fileName: "photo.png")', + 'File "photo.png" (image/png, 10 bytes) uploaded to workspace files.', + 'Read it with: sim --output json files read "uploads/photo.png"', ].join('\n'), }, ]) }) }) - it('passes workspaceContext through to the Go request payload', async () => { + it('emits ONLY the shared ChatRequest contract fields — nothing legacy rides the wire', async () => { const payload = await buildCopilotRequestPayload( { message: 'debug workspace', @@ -513,129 +511,41 @@ describe('buildCopilotRequestPayload', () => { mode: 'agent', model: 'claude-opus-4-8', workspaceId: 'ws-1', - workspaceContext: 'workspace inventory', + userTimezone: 'America/Los_Angeles', }, { selectedModel: 'claude-opus-4-8' } ) expect(payload).toEqual( expect.objectContaining({ - workspaceId: 'ws-1', - workspaceContext: 'workspace inventory', - }) - ) - }) - - it('advertises desktop capabilities without adding parallel local_* tool schemas', async () => { - const capablePayload = await buildCopilotRequestPayload( - { - message: 'inspect my local project', - userId: 'user-1', - userMessageId: 'msg-1', - mode: 'agent', - model: '', - workspaceId: 'ws-1', - desktopLocalFilesystem: true, - }, - { selectedModel: '' } - ) - expect(capablePayload).toMatchObject({ - desktopCapabilities: { localFilesystem: true }, - }) - expect(capablePayload).not.toHaveProperty('mothershipTools') - - const browserPayload = await buildCopilotRequestPayload( - { - message: 'inspect my local project', + message: 'debug workspace', userId: 'user-1', - userMessageId: 'msg-2', - mode: 'agent', - model: '', - workspaceId: 'ws-1', - browser: true, - browserSessions: [ - { - hostname: 'example.com', - evidence: 'cookies', - lastObservedAt: '2026-08-01T00:00:00.000Z', - }, - ], - }, - { selectedModel: '' } - ) - expect(browserPayload).not.toHaveProperty('mothershipTools') - expect(browserPayload).toMatchObject({ - desktopCapabilities: { - browser: true, - browserSessions: [ - { - hostname: 'example.com', - evidence: 'cookies', - lastObservedAt: '2026-08-01T00:00:00.000Z', - }, - ], - }, - }) - expect(browserPayload).not.toHaveProperty('browserCapable') - }) - - it('passes user metadata through to the Go request payload', async () => { - const payload = await buildCopilotRequestPayload( - { - message: 'what time is it', - userId: 'user-1', - userMessageId: 'msg-1', - mode: 'agent', - model: 'claude-opus-4-8', + messageId: 'msg-1', workspaceId: 'ws-1', userTimezone: 'America/Los_Angeles', - userMetadata: { - name: 'Sid', - timezone: 'America/Los_Angeles', - }, - }, - { selectedModel: 'claude-opus-4-8' } - ) - - expect(payload).toEqual( - expect.objectContaining({ - userTimezone: 'America/Los_Angeles', - userMetadata: { - name: 'Sid', - timezone: 'America/Los_Angeles', - }, }) ) - }) - - it('passes entitlements through and omits the field when empty', async () => { - const withEntitlements = await buildCopilotRequestPayload( - { - message: 'publish as a block', - userId: 'user-1', - userMessageId: 'msg-1', - mode: 'agent', - model: 'claude-opus-4-8', - workspaceId: 'ws-1', - entitlements: ['custom-blocks'], - }, - { selectedModel: 'claude-opus-4-8' } - ) - expect(withEntitlements).toEqual(expect.objectContaining({ entitlements: ['custom-blocks'] })) - - const withoutEntitlements = await buildCopilotRequestPayload( - { - message: 'publish as a block', - userId: 'user-1', - userMessageId: 'msg-1', - mode: 'agent', - model: 'claude-opus-4-8', - workspaceId: 'ws-1', - entitlements: [], - }, - { selectedModel: 'claude-opus-4-8' } - ) - expect(withoutEntitlements).not.toHaveProperty('entitlements') + // Model/provider/mode are server-decided (P12); permissions are enforced by v2 under + // the delegation token; workspace snapshots and desktop capabilities are gone. + for (const legacy of [ + 'workspaceContext', + 'vfs', + 'entitlements', + 'userMetadata', + 'userPermission', + 'model', + 'provider', + 'mode', + 'desktopCapabilities', + 'prefetch', + 'implicitFeedback', + 'commands', + 'workflowName', + 'isHosted', + 'docCompiler', + ]) { + expect(payload).not.toHaveProperty(legacy) + } }) }) diff --git a/apps/sim/lib/copilot/chat/payload.ts b/apps/sim/lib/mothership/chat/payload.ts similarity index 83% rename from apps/sim/lib/copilot/chat/payload.ts rename to apps/sim/lib/mothership/chat/payload.ts index 35a48a938a7..50c7d4c4dc2 100644 --- a/apps/sim/lib/copilot/chat/payload.ts +++ b/apps/sim/lib/mothership/chat/payload.ts @@ -8,22 +8,27 @@ import { isPaid } from '@/lib/billing/plan-helpers' import { isAssistantIntegrationParameter, isAssistantIntegrationTool, -} from '@/lib/copilot/assistant/tool-policy' -import { getBlockVisibilityForCopilot, visibilitySignature } from '@/lib/copilot/block-visibility' -import type { AssistantImageContent } from '@/lib/copilot/chat/assistant-images' -import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1' +} from '@/lib/mothership/assistant/tool-policy' +import { + getBlockVisibilityForCopilot, + visibilitySignature, +} from '@/lib/mothership/block-visibility' +import type { AssistantImageContent } from '@/lib/mothership/chat/assistant-images' +import type { VfsSnapshotV1 } from '@/lib/mothership/generated/vfs-snapshot-v1' +import { type BlockVisibilityState } from '@/lib/core/config/block-visibility' +import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities' +import { isHosted, isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { isOAuthServiceDeploymentAvailable } from '@/lib/integrations/availability.server' +import type { ChatRequest } from '@/lib/mothership/generated/protocol' import { type IntegrationGateConfig, integrationGateSignature, projectIntegrationToolsForViewer, -} from '@/lib/copilot/integration-tool-projection' -import { buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools' -import { getToolEntry } from '@/lib/copilot/tool-executor/router' -import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' -import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' -import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' -import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags' -import { isOAuthServiceDeploymentAvailable } from '@/lib/integrations/availability.server' +} from '@/lib/mothership/integration-tool-projection' +import { buildTaggedMcpToolSchemas } from '@/lib/mothership/mcp-tools' +import { getToolEntry } from '@/lib/mothership/tool-executor/router' +import { getCopilotToolDescription } from '@/lib/mothership/tools/descriptions' +import { encodeVfsSegment } from '@/lib/mothership/vfs/path-utils' import type { WorkspaceSearchFilters } from '@/lib/knowledge/search/filters' import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { buildArchiveExtractGuidance, isArchiveFileName } from '@/lib/uploads/utils/file-utils' @@ -63,13 +68,7 @@ interface BuildPayloadParams { vfs?: VfsSnapshotV1 userPermission?: string /** Plan/flag-gated org capabilities (e.g. "custom-blocks") the mothership gates tools/prompts on. */ - entitlements?: string[] userTimezone?: string - userMetadata?: { - name?: string - email?: string - timezone?: string - } desktopLocalFilesystem?: boolean browser?: boolean terminalCapable?: boolean @@ -270,24 +269,20 @@ export async function buildCopilotRequestPayload( options: { selectedModel: string } -): Promise> { +): Promise { const { message, workflowId, userId, userMessageId, mode, - provider, contexts, fileAttachments, - commands, chatId, - prefetch, - implicitFeedback, + provider, } = params - const selectedModel = options.selectedModel - + const { selectedModel } = options const effectiveMode = mode === 'agent' ? 'build' : mode const transportMode = effectiveMode === 'build' ? 'agent' : effectiveMode const isAssistant = effectiveMode === 'assistant' @@ -349,13 +344,12 @@ export async function buildCopilotRequestPayload( ] } else { lines = [ - `File "${displayName}" (${mediaType}, ${f.size} bytes) uploaded.`, - `Read with: read("uploads/${encodedUploadName}")`, - `To save permanently: save_upload(fileName: "${displayName}")`, + `File "${displayName}" (${mediaType}, ${f.size} bytes) uploaded to workspace files.`, + `Read it with: sim --output json files read "uploads/${encodedUploadName}"`, ] if (displayName.endsWith('.json')) { lines.push( - `To import as a workflow: save_upload(fileName: "${displayName}", operation: "import")` + `If it is a workflow export, import it with: sim --output json workflows import --file "uploads/${encodedUploadName}"` ) } } @@ -403,6 +397,11 @@ export async function buildCopilotRequestPayload( ) } + // The wire payload IS the shared contract (ChatRequest in lib/mothership/generated/ + // protocol.ts) — nothing else. Model/provider/mode are server-decided (P12); permissions + // are enforced by v2 under the delegation token, not asserted here; desktop capabilities + // are out of scope for v1. The params above still carry sim-internal knowledge (mode + // gates which tool schemas get built), but none of it rides the wire. return { message, ...(!isAssistant && workflowId ? { workflowId } : {}), @@ -418,41 +417,12 @@ export async function buildCopilotRequestPayload( ? { fileAttachments: params.assistantImages } : {}), messageId: userMessageId, - ...(allContexts.length > 0 ? { context: allContexts } : {}), ...(chatId ? { chatId } : {}), - ...(typeof prefetch === 'boolean' ? { prefetch } : {}), - ...(implicitFeedback ? { implicitFeedback } : {}), + ...(params.workspaceId ? { workspaceId: params.workspaceId } : {}), + ...(workflowId ? { workflowId } : {}), + ...(allContexts.length > 0 ? { context: allContexts } : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), ...(mothershipTools.length > 0 ? { mothershipTools } : {}), - ...(!isAssistant && commands && commands.length > 0 ? { commands } : {}), - ...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}), - ...(!isAssistant && params.vfs ? { vfs: params.vfs } : {}), - ...(params.userPermission ? { userPermission: params.userPermission } : {}), - ...(!isAssistant && params.entitlements?.length ? { entitlements: params.entitlements } : {}), ...(params.userTimezone ? { userTimezone: params.userTimezone } : {}), - ...(params.userMetadata && - (params.userMetadata.name || params.userMetadata.email || params.userMetadata.timezone) - ? { userMetadata: params.userMetadata } - : {}), - // Tell the copilot file subagent which document toolchain to write. Emitted - // only in Python mode so the JS path sends no new field (Go defaults to js). - ...(isDocSandboxEnabled ? { docCompiler: 'python' } : {}), - ...(!params.organizationId && - ((!isAssistant && params.desktopLocalFilesystem) || params.browser || params.terminalCapable) - ? { - desktopCapabilities: { - ...(!isAssistant && params.desktopLocalFilesystem ? { localFilesystem: true } : {}), - ...(params.browser ? { browser: true } : {}), - ...(params.terminalCapable ? { terminal: true } : {}), - ...(params.terminalCapable && params.terminals?.length - ? { terminals: params.terminals } - : {}), - ...(params.browser && params.browserSessions?.length - ? { browserSessions: params.browserSessions } - : {}), - }, - } - : {}), - isHosted, } } diff --git a/apps/sim/lib/copilot/chat/persisted-message.test.ts b/apps/sim/lib/mothership/chat/persisted-message.test.ts similarity index 99% rename from apps/sim/lib/copilot/chat/persisted-message.test.ts rename to apps/sim/lib/mothership/chat/persisted-message.test.ts index 015be248d1b..d4cfbab53e6 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.test.ts +++ b/apps/sim/lib/mothership/chat/persisted-message.test.ts @@ -4,8 +4,8 @@ import { describe, expect, it } from 'vitest' import { copilotChatStopBodySchema } from '@/lib/api/contracts/copilot' -import { toDisplayMessage } from '@/lib/copilot/chat/display-message' -import type { OrchestratorResult } from '@/lib/copilot/request/types' +import { toDisplayMessage } from '@/lib/mothership/chat/display-message' +import type { OrchestratorResult } from '@/lib/mothership/request/types' import { resolveMessageCitations } from '@/app/workspace/[workspaceId]/home/components/message-content/resolve-citations' import { buildPersistedAssistantMessage, diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/mothership/chat/persisted-message.ts similarity index 98% rename from apps/sim/lib/copilot/chat/persisted-message.ts rename to apps/sim/lib/mothership/chat/persisted-message.ts index 8a01e6ab60c..d62fc59b1d3 100644 --- a/apps/sim/lib/copilot/chat/persisted-message.ts +++ b/apps/sim/lib/mothership/chat/persisted-message.ts @@ -1,11 +1,11 @@ import { generateId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' -import { compactRetrievalCitations } from '@/lib/copilot/chat/retrieval-citations' +import { compactRetrievalCitations } from '@/lib/mothership/chat/retrieval-citations' import { mergeAndRedactPersistedBlocks, redactSensitiveContent, redactToolCallResult, -} from '@/lib/copilot/chat/sim-key-redaction' +} from '@/lib/mothership/chat/sim-key-redaction' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, @@ -15,14 +15,14 @@ import { MothershipStreamV1TextChannel, MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, -} from '@/lib/copilot/generated/mothership-stream-v1' +} from '@/lib/mothership/generated/mothership-stream-v1' import type { ContentBlock, LocalToolCallStatus, OrchestratorResult, -} from '@/lib/copilot/request/types' -import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' -import { normalizeToolActivityDescription } from '@/lib/copilot/tools/tool-display' +} from '@/lib/mothership/request/types' +import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/mothership/tools/retired-tools' +import { normalizeToolActivityDescription } from '@/lib/mothership/tools/tool-display' import type { BrowserTextSelection, TerminalTextSelection } from '@/stores/panel/types' export type PersistedToolState = LocalToolCallStatus | MothershipStreamV1ToolOutcome | 'interrupted' diff --git a/apps/sim/lib/copilot/chat/post.test.ts b/apps/sim/lib/mothership/chat/post.test.ts similarity index 97% rename from apps/sim/lib/copilot/chat/post.test.ts rename to apps/sim/lib/mothership/chat/post.test.ts index e2a2e6fcd68..0c92ecf869d 100644 --- a/apps/sim/lib/copilot/chat/post.test.ts +++ b/apps/sim/lib/mothership/chat/post.test.ts @@ -84,7 +84,7 @@ const { setInputMessages, setUserMessagePreview, startCopilotOtelRoot } = vi.hoi startCopilotOtelRoot: vi.fn(), })) -vi.mock('@/lib/copilot/request/otel', async () => { +vi.mock('@/lib/mothership/request/otel', async () => { const { ROOT_CONTEXT, trace } = await import('@opentelemetry/api') const span = () => trace.getTracer('post-test').startSpan('post-test') startCopilotOtelRoot.mockImplementation(() => ({ @@ -133,7 +133,7 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ resolveOrganizationBillingAttribution, })) -vi.mock('@/lib/copilot/chat/organization-chats', () => ({ +vi.mock('@/lib/mothership/chat/organization-chats', () => ({ authorizeOrganizationChat: { execute: authorizeOrganizationChat }, })) @@ -145,33 +145,33 @@ vi.mock('@/lib/credentials/application/personal-credentials', () => ({ listPersonalCredentials: { execute: listPersonal }, })) -vi.mock('@/lib/copilot/entitlements', () => ({ computeWorkspaceEntitlements })) +vi.mock('@/lib/mothership/entitlements', () => ({ computeWorkspaceEntitlements })) -vi.mock('@/lib/copilot/chat/workspace-context', () => ({ +vi.mock('@/lib/mothership/chat/workspace-context', () => ({ generateWorkspaceSnapshot, })) -vi.mock('@/lib/copilot/chat/process-contents', () => ({ +vi.mock('@/lib/mothership/chat/process-contents', () => ({ processContextsServer, resolveActiveResourceContext, })) -vi.mock('@/lib/copilot/chat/payload', () => ({ +vi.mock('@/lib/mothership/chat/payload', () => ({ buildCopilotRequestPayload, })) -vi.mock('@/lib/copilot/request/lifecycle/start', () => ({ +vi.mock('@/lib/mothership/request/lifecycle/start', () => ({ createSSEStream, SSE_RESPONSE_HEADERS: { 'Content-Type': 'text/event-stream' }, })) -vi.mock('@/lib/copilot/request/session', () => ({ +vi.mock('@/lib/mothership/request/session', () => ({ acquirePendingChatStream, getPendingChatStreamId, releasePendingChatStream, })) -vi.mock('@/lib/copilot/chat/lifecycle', () => ({ +vi.mock('@/lib/mothership/chat/lifecycle', () => ({ resolveOrCreateChat, })) @@ -183,25 +183,25 @@ vi.mock('@/lib/core/idempotency', () => ({ }, })) -vi.mock('@/lib/copilot/chat/terminal-state', () => ({ +vi.mock('@/lib/mothership/chat/terminal-state', () => ({ finalizeAssistantTurn, })) -vi.mock('@/lib/copilot/chat/messages-store', () => ({ +vi.mock('@/lib/mothership/chat/messages-store', () => ({ appendCopilotChatMessages, })) -vi.mock('@/lib/copilot/resources/persistence', () => ({ +vi.mock('@/lib/mothership/resources/persistence', () => ({ persistChatResources, })) vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) -vi.mock('@/lib/copilot/chat-status', () => ({ +vi.mock('@/lib/mothership/chat-status', () => ({ publishChatStatusChanged: mockPublishStatusChanged, })) -import { chatOperations } from '@/lib/copilot/application/operations' +import { chatOperations } from '@/lib/mothership/application/operations' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { handleUnifiedChatPost } from './post' @@ -257,10 +257,6 @@ describe('handleUnifiedChatPost', () => { conflicts: [], decryptionFailures: [], }) - generateWorkspaceSnapshot.mockResolvedValue({ - markdown: 'workspace context', - snapshot: { workflows: [{ id: 'wf-1', name: 'Alpha', path: 'workflows/Alpha' }] }, - }) processContextsServer.mockResolvedValue([]) resolveActiveResourceContext.mockResolvedValue(null) buildCopilotRequestPayload.mockImplementation(async (params: Record) => params) @@ -705,16 +701,20 @@ describe('handleUnifiedChatPost', () => { ) expect(response.status).toBe(200) - expect(generateWorkspaceSnapshot).toHaveBeenCalledWith('ws-1', 'user-1') + // The revamp contract: no workspace snapshot is built or forwarded; the builder gets + // exactly the sim-internal params it needs and emits the shared ChatRequest. expect(buildCopilotRequestPayload).toHaveBeenCalledWith( expect.objectContaining({ - model: 'claude-opus-4-8', - workspaceContext: 'workspace context', - // Regression guard: the branch must forward the typed snapshot, not drop it. - vfs: expect.objectContaining({ workflows: expect.any(Array) }), + message: 'Hello', + userId: 'user-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', }), { selectedModel: 'claude-opus-4-8' } ) + const workflowParams = buildCopilotRequestPayload.mock.calls[0]![0] as Record + expect(workflowParams).not.toHaveProperty('workspaceContext') + expect(workflowParams).not.toHaveProperty('vfs') expect(createSSEStream).toHaveBeenCalledWith( expect.objectContaining({ titleModel: 'claude-opus-4-8', @@ -750,13 +750,15 @@ describe('handleUnifiedChatPost', () => { expect(response.status).toBe(200) expect(buildCopilotRequestPayload).toHaveBeenCalledWith( expect.objectContaining({ + message: 'Hello', + userId: 'user-1', workspaceId: 'ws-1', - workspaceContext: 'workspace context', - // Regression guard: the branch must forward the typed snapshot, not drop it. - vfs: expect.objectContaining({ workflows: expect.any(Array) }), }), { selectedModel: '' } ) + const workspaceParams = buildCopilotRequestPayload.mock.calls[0]![0] as Record + expect(workspaceParams).not.toHaveProperty('workspaceContext') + expect(workspaceParams).not.toHaveProperty('vfs') expect(createSSEStream).toHaveBeenCalledWith( expect.objectContaining({ titleModel: 'claude-opus-4-8', diff --git a/apps/sim/lib/copilot/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts similarity index 94% rename from apps/sim/lib/copilot/chat/post.ts rename to apps/sim/lib/mothership/chat/post.ts index 282393fa1cd..efae6d2a5ec 100644 --- a/apps/sim/lib/copilot/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -18,66 +18,64 @@ import { resolveBillingAttribution, resolveOrganizationBillingAttribution, } from '@/lib/billing/core/billing-attribution' -import { chatOperations } from '@/lib/copilot/application/operations' +import { chatOperations } from '@/lib/mothership/application/operations' import { type AssistantImageContent, prepareAssistantImages, -} from '@/lib/copilot/chat/assistant-images' +} from '@/lib/mothership/chat/assistant-images' import { DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH, DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH, -} from '@/lib/copilot/chat/desktop-capabilities' -import { type ChatLoadResult, resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' -import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' -import { authorizeOrganizationChat } from '@/lib/copilot/chat/organization-chats' -import { buildCopilotRequestPayload } from '@/lib/copilot/chat/payload' +} from '@/lib/mothership/chat/desktop-capabilities' +import { type ChatLoadResult, resolveOrCreateChat } from '@/lib/mothership/chat/lifecycle' +import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store' +import { authorizeOrganizationChat } from '@/lib/mothership/chat/organization-chats' +import { buildCopilotRequestPayload } from '@/lib/mothership/chat/payload' import { buildPersistedAssistantMessage, buildPersistedUserMessage, withStoppedContentBlock, -} from '@/lib/copilot/chat/persisted-message' +} from '@/lib/mothership/chat/persisted-message' import { processContextsServer, resolveActiveResourceContext, -} from '@/lib/copilot/chat/process-contents' +} from '@/lib/mothership/chat/process-contents' import { MAX_FILE_SELECTION_TEXT_LENGTH, MAX_TABLE_SELECTION_COLUMNS, MAX_TABLE_SELECTION_ROWS, safeBrowserSelectionUrl, -} from '@/lib/copilot/chat/selection-context' -import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state' -import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context' -import { publishChatStatusChanged } from '@/lib/copilot/chat-status' -import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants' -import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' -import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' +} from '@/lib/mothership/chat/selection-context' +import { finalizeAssistantTurn } from '@/lib/mothership/chat/terminal-state' +import { mintDelegationToken } from '@/lib/mothership/chat/delegation' +import { publishChatStatusChanged } from '@/lib/mothership/chat-status' +import { COPILOT_REQUEST_MODES } from '@/lib/mothership/constants' +import { prepareCopilotEnvironmentContext } from '@/lib/mothership/environment-context' +import { type ChatRequest, PROTOCOL_VERSION } from '@/lib/mothership/generated/protocol' import { CopilotChatFinalizeOutcome, CopilotChatPersistOutcome, CopilotTransport, -} from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1' -import { createBadRequestResponse, createUnauthorizedResponse } from '@/lib/copilot/request/http' -import { createSSEStream, SSE_RESPONSE_HEADERS } from '@/lib/copilot/request/lifecycle/start' -import { startCopilotOtelRoot, withCopilotSpan } from '@/lib/copilot/request/otel' +} from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { createBadRequestResponse, createUnauthorizedResponse } from '@/lib/mothership/request/http' +import { createSSEStream, SSE_RESPONSE_HEADERS } from '@/lib/mothership/request/lifecycle/start' +import { startCopilotOtelRoot, withCopilotSpan } from '@/lib/mothership/request/otel' import { acquirePendingChatStream, getPendingChatStreamId, releasePendingChatStream, -} from '@/lib/copilot/request/session' -import type { ExecutionContext, OrchestratorResult } from '@/lib/copilot/request/types' -import { persistChatResources } from '@/lib/copilot/resources/persistence' +} from '@/lib/mothership/request/session' +import type { ExecutionContext, OrchestratorResult } from '@/lib/mothership/request/types' +import { persistChatResources } from '@/lib/mothership/resources/persistence' import { hasAddressableId, isEphemeralResource, sanitizeChatResources, -} from '@/lib/copilot/resources/types' -import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' -import type { AtomicClaimResult } from '@/lib/core/idempotency' -import { chatSendIdempotency } from '@/lib/core/idempotency' +} from '@/lib/mothership/resources/types' +import { prepareExecutionContext } from '@/lib/mothership/tools/handlers/context' +import { type AtomicClaimResult, chatSendIdempotency } from '@/lib/core/idempotency' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { listPersonalCredentials } from '@/lib/credentials/application/personal-credentials' import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' @@ -372,9 +370,7 @@ type UnifiedChatBranch = mcpServerIds?: string[] fileAttachments?: UnifiedChatRequest['fileAttachments'] userPermission?: string - entitlements?: string[] userTimezone?: string - userMetadata?: { name?: string; email?: string; timezone?: string } workflowId: string workflowName?: string workspaceId?: string @@ -391,7 +387,7 @@ type UnifiedChatBranch = terminalCapable?: boolean terminals?: Terminals browserSessions?: BrowserSessions - }) => Promise> + }) => Promise buildExecutionContext: (params: { userId: string chatId?: string @@ -419,7 +415,6 @@ type UnifiedChatBranch = fileAttachments?: UnifiedChatRequest['fileAttachments'] assistantImages?: AssistantImageContent[] userPermission?: string - entitlements?: string[] userTimezone?: string userMetadata?: { name?: string; email?: string; timezone?: string } assistantSearch?: WorkspaceSearchFilters @@ -430,7 +425,7 @@ type UnifiedChatBranch = terminalCapable?: boolean terminals?: Terminals browserSessions?: BrowserSessions - }) => Promise> + }) => Promise buildExecutionContext: (params: { userId: string chatId?: string @@ -1002,12 +997,8 @@ async function resolveBranch(params: { chatId: payloadParams.chatId, prefetch: payloadParams.prefetch, implicitFeedback: payloadParams.implicitFeedback, - workspaceContext: payloadParams.workspaceContext, - vfs: payloadParams.vfs, userPermission: payloadParams.userPermission, - entitlements: payloadParams.entitlements, userTimezone: payloadParams.userTimezone, - userMetadata: payloadParams.userMetadata, desktopLocalFilesystem: payloadParams.desktopLocalFilesystem, browser: payloadParams.browser, terminalCapable: payloadParams.terminalCapable, @@ -1065,12 +1056,8 @@ async function resolveBranch(params: { mcpServerIds: payloadParams.mcpServerIds, fileAttachments: payloadParams.fileAttachments, chatId: payloadParams.chatId, - workspaceContext: payloadParams.workspaceContext, - vfs: payloadParams.vfs, userPermission: payloadParams.userPermission, - entitlements: payloadParams.entitlements, userTimezone: payloadParams.userTimezone, - userMetadata: payloadParams.userMetadata, desktopLocalFilesystem: payloadParams.desktopLocalFilesystem, browser: payloadParams.browser, terminalCapable: payloadParams.terminalCapable, @@ -1175,8 +1162,6 @@ export async function handleUnifiedChatPost(req: NextRequest) { } const authenticatedUserId = session.user.id const authenticatedUserEmail = session.user.email - const authenticatedUserName = - typeof session.user.name === 'string' ? session.user.name : undefined const body = ChatMessageSchema.parse(await req.json()) if ( @@ -1471,10 +1456,6 @@ export async function handleUnifiedChatPost(req: NextRequest) { } ) : Promise.resolve(null) - const entitlementsPromise = - workspaceId && body.mode !== 'assistant' - ? computeWorkspaceEntitlements(workspaceId, authenticatedUserId) - : Promise.resolve([]) const personalCredentialsPromise = workspaceId && body.mode === 'assistant' ? listPersonalCredentials.execute({ @@ -1491,15 +1472,15 @@ export async function handleUnifiedChatPost(req: NextRequest) { // opens". Previously these ran bare under the root and inflated the // apparent "gap" before the model call. Each promise is its own // span; they run concurrently under Promise.all below. - const workspaceContextPromise = - workspaceId && body.mode !== 'assistant' - ? withCopilotSpan( - TraceSpan.CopilotChatBuildWorkspaceContext, - { [TraceAttr.WorkspaceId]: workspaceId }, - () => generateWorkspaceSnapshot(workspaceId, authenticatedUserId), - activeOtelRoot.context - ) - : Promise.resolve(undefined) + /** + * Mothership revamp (worker backend): the VFS/workspace snapshot build is gone — + * the worker discovers workspace state on demand through the CLI (was ~11 primary-db + * queries and ~900ms p95 per message). Its prep slot now mints the run-scoped + * delegation credential the worker presents on v2 calls (revamp D23). + */ + const delegationTokenPromise = workspaceId + ? mintDelegationToken({ workspaceId, userId: authenticatedUserId }) + : Promise.resolve(null) const executionContextPromise = withCopilotSpan( TraceSpan.CopilotChatBuildExecutionContext, { [TraceAttr.CopilotBranchKind]: branch.kind }, @@ -1550,24 +1531,19 @@ export async function handleUnifiedChatPost(req: NextRequest) { const [ agentContexts, userPermission, - entitlements, - workspaceSnapshot, + delegationToken, , executionContext, personalCredentials, ] = await Promise.all([ agentContextsPromise, userPermissionPromise, - entitlementsPromise, - workspaceContextPromise, + delegationTokenPromise, persistUserMessagePromise, executionContextPromise, personalCredentialsPromise, ]) - // Both halves come from one primary-db fetch (workspace-context.ts): - // `workspaceContext` is the markdown transition fallback, `vfs` is the - // typed snapshot Go diffs into baseline+delta messages. - let workspaceContext = workspaceSnapshot?.markdown + let workspaceContext: string | undefined if (personalCredentials) { workspaceContext = JSON.stringify({ credentials: personalCredentials.credentials.map((credential) => ({ @@ -1580,7 +1556,6 @@ export async function handleUnifiedChatPost(req: NextRequest) { })), }) } - const vfs = workspaceSnapshot?.snapshot const turnContexts = agentContexts if (body.mode === 'assistant') executionContext.assistantSearch = body.assistantSearch @@ -1607,9 +1582,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { mcpServerIds, fileAttachments, userPermission: userPermission ?? undefined, - entitlements, userTimezone: body.userTimezone, - userMetadata, workflowId: branch.workflowId, workflowName: branch.workflowName, workspaceId: branch.workspaceId, @@ -1618,8 +1591,6 @@ export async function handleUnifiedChatPost(req: NextRequest) { commands: body.commands, prefetch: body.prefetch, implicitFeedback: body.implicitFeedback, - workspaceContext, - vfs, desktopLocalFilesystem: body.desktopCapabilities?.localFilesystem === true, browser: body.desktopCapabilities?.browser === true, terminalCapable: body.desktopCapabilities?.terminal === true, @@ -1637,11 +1608,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { fileAttachments, assistantImages: assistantImages?.content, userPermission: userPermission ?? undefined, - entitlements, userTimezone: body.userTimezone, - userMetadata, - workspaceContext, - vfs, desktopLocalFilesystem: body.desktopCapabilities?.localFilesystem === true, browser: body.desktopCapabilities?.browser === true, terminalCapable: body.desktopCapabilities?.terminal === true, @@ -1660,7 +1627,11 @@ export async function handleUnifiedChatPost(req: NextRequest) { } const stream = createSSEStream({ - requestPayload, + requestPayload: { + ...requestPayload, + protocolVersion: PROTOCOL_VERSION, + ...(delegationToken ? { delegationToken } : {}), + }, userId: authenticatedUserId, streamId: userMessageId, executionId, diff --git a/apps/sim/lib/copilot/chat/process-contents-log-projection.test.ts b/apps/sim/lib/mothership/chat/process-contents-log-projection.test.ts similarity index 98% rename from apps/sim/lib/copilot/chat/process-contents-log-projection.test.ts rename to apps/sim/lib/mothership/chat/process-contents-log-projection.test.ts index 7c57beded3e..cc00aa2e020 100644 --- a/apps/sim/lib/copilot/chat/process-contents-log-projection.test.ts +++ b/apps/sim/lib/mothership/chat/process-contents-log-projection.test.ts @@ -31,7 +31,7 @@ vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScop /** Folder listing is untouched by `@log` mentions; the real module drags in the block and trigger registries. */ vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) -import { processContextsServer } from '@/lib/copilot/chat/process-contents' +import { processContextsServer } from '@/lib/mothership/chat/process-contents' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' function queueRun(): void { diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/mothership/chat/process-contents.test.ts similarity index 99% rename from apps/sim/lib/copilot/chat/process-contents.test.ts rename to apps/sim/lib/mothership/chat/process-contents.test.ts index e1229f4babb..1e4ebe90054 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/mothership/chat/process-contents.test.ts @@ -5,11 +5,11 @@ import { createLogger } from '@sim/logger' import { dbChainMockFns, loggerMock, workflowAuthzMockFns } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DelegatedWorkspaceAuthorizationError } from '@/lib/core/application' import { MAX_TABLE_SELECTION_CONTENT_LENGTH, MAX_TABLE_SELECTION_ROWS, -} from '@/lib/copilot/chat/selection-context' -import { DelegatedWorkspaceAuthorizationError } from '@/lib/core/application' +} from '@/lib/mothership/chat/selection-context' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import type { ChatContext } from '@/stores/panel' @@ -66,7 +66,7 @@ const { })) vi.mock('@/blocks/registry', () => ({ getBlock, getBlockRegistry })) -vi.mock('@/lib/copilot/block-visibility', () => ({ getBlockVisibilityForCopilot })) +vi.mock('@/lib/mothership/block-visibility', () => ({ getBlockVisibilityForCopilot })) vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig })) vi.mock('@/lib/integrations/availability.server', () => ({ isIntegrationDeploymentAvailableForVisibility: isIntegrationDeploymentAvailable, @@ -103,7 +103,7 @@ vi.mock('@/lib/knowledge/application/folders', () => ({ vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ resolveWorkspaceFileFolderPathOperation: { execute: resolveFileFolderPath }, })) -vi.mock('@/lib/copilot/tools/server/docs/search-docs', () => ({ +vi.mock('@/lib/mothership/tools/server/docs/search-docs', () => ({ searchDocsServerTool: { execute: searchDocsExecute }, })) @@ -115,7 +115,7 @@ vi.mock('@/lib/copilot/tools/server/docs/search-docs', () => ({ import { processContextsServer, resolveActiveResourceContext, -} from '@/lib/copilot/chat/process-contents' +} from '@/lib/mothership/chat/process-contents' describe('processContextsServer - knowledge contexts', () => { beforeEach(() => { diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/mothership/chat/process-contents.ts similarity index 96% rename from apps/sim/lib/copilot/chat/process-contents.ts rename to apps/sim/lib/mothership/chat/process-contents.ts index 22af34bd256..04d80a82fd7 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/mothership/chat/process-contents.ts @@ -6,20 +6,28 @@ import { } from '@sim/platform-authz/workflow' import { escapeRegExp } from '@sim/utils/string' import { eq } from 'drizzle-orm' -import { createCopilotChatKnowledgePrincipal } from '@/lib/copilot/application/execute-knowledge-use-case' -import { createCopilotChatPrincipal } from '@/lib/copilot/auth/application-delegation' -import { createCopilotChatFilePrincipal } from '@/lib/copilot/auth/file-delegation' -import { createCopilotChatTablePrincipal } from '@/lib/copilot/auth/table-delegation' -import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' -import { createChatFolderResolver } from '@/lib/copilot/chat/folder-context' +import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' +import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' +import { readKnowledgeBase } from '@/lib/knowledge/application/knowledge-bases' +import { toOverview } from '@/lib/logs/log-views' +import type { TraceSpan } from '@/lib/logs/types' +import { mcpService } from '@/lib/mcp/service' +import { createMcpToolId } from '@/lib/mcp/utils' +import { createCopilotChatKnowledgePrincipal } from '@/lib/mothership/application/execute-knowledge-use-case' +import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-delegation' +import { createCopilotChatFilePrincipal } from '@/lib/mothership/auth/file-delegation' +import { createCopilotChatTablePrincipal } from '@/lib/mothership/auth/table-delegation' +import { getBlockVisibilityForCopilot } from '@/lib/mothership/block-visibility' +import { createChatFolderResolver } from '@/lib/mothership/chat/folder-context' import { MAX_TABLE_SELECTION_COLUMNS, MAX_TABLE_SELECTION_CONTENT_LENGTH, MAX_TABLE_SELECTION_ROWS, safeBrowserSelectionUrl, truncateSelectionText, -} from '@/lib/copilot/chat/selection-context' -import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1' +} from '@/lib/mothership/chat/selection-context' +import { QueryLogs } from '@/lib/mothership/generated/tool-catalog-v1' import { canonicalBlockVfsPath, canonicalKnowledgeBaseVfsDir, @@ -28,7 +36,7 @@ import { canonicalWorkspaceFilePath, encodeVfsPathSegments, encodeVfsSegment, -} from '@/lib/copilot/vfs/path-utils' +} from '@/lib/mothership/vfs/path-utils' import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' @@ -316,7 +324,7 @@ export async function processContextsServer( if (ctx.kind === 'docs') { try { const { searchDocsServerTool } = await import( - '@/lib/copilot/tools/server/docs/search-docs' + '@/lib/mothership/tools/server/docs/search-docs' ) const rawQuery = (userMessage || '').trim() || ctx.label || 'Sim documentation' const query = diff --git a/apps/sim/lib/copilot/chat/retrieval-citations.test.ts b/apps/sim/lib/mothership/chat/retrieval-citations.test.ts similarity index 93% rename from apps/sim/lib/copilot/chat/retrieval-citations.test.ts rename to apps/sim/lib/mothership/chat/retrieval-citations.test.ts index 73cfb3dad7c..777fb37517a 100644 --- a/apps/sim/lib/copilot/chat/retrieval-citations.test.ts +++ b/apps/sim/lib/mothership/chat/retrieval-citations.test.ts @@ -1,6 +1,6 @@ /** @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { compactRetrievalCitations } from '@/lib/copilot/chat/retrieval-citations' +import { compactRetrievalCitations } from '@/lib/mothership/chat/retrieval-citations' describe('persisted retrieval citations', () => { it('bounds display evidence and discards the rest of the tool output', () => { diff --git a/apps/sim/lib/copilot/chat/retrieval-citations.ts b/apps/sim/lib/mothership/chat/retrieval-citations.ts similarity index 100% rename from apps/sim/lib/copilot/chat/retrieval-citations.ts rename to apps/sim/lib/mothership/chat/retrieval-citations.ts diff --git a/apps/sim/lib/copilot/chat/rewrite-file-references.ts b/apps/sim/lib/mothership/chat/rewrite-file-references.ts similarity index 96% rename from apps/sim/lib/copilot/chat/rewrite-file-references.ts rename to apps/sim/lib/mothership/chat/rewrite-file-references.ts index 021c6c19a2c..e7ebea3dc2f 100644 --- a/apps/sim/lib/copilot/chat/rewrite-file-references.ts +++ b/apps/sim/lib/mothership/chat/rewrite-file-references.ts @@ -1,5 +1,5 @@ -import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' -import type { MothershipResource } from '@/lib/copilot/resources/types' +import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message' +import type { MothershipResource } from '@/lib/mothership/resources/types' import { rewriteForkContentRefs } from '@/ee/workspace-forking/lib/remap/remap-content-refs' /** diff --git a/apps/sim/lib/copilot/chat/selection-clipboard.test.ts b/apps/sim/lib/mothership/chat/selection-clipboard.test.ts similarity index 100% rename from apps/sim/lib/copilot/chat/selection-clipboard.test.ts rename to apps/sim/lib/mothership/chat/selection-clipboard.test.ts diff --git a/apps/sim/lib/copilot/chat/selection-clipboard.ts b/apps/sim/lib/mothership/chat/selection-clipboard.ts similarity index 100% rename from apps/sim/lib/copilot/chat/selection-clipboard.ts rename to apps/sim/lib/mothership/chat/selection-clipboard.ts diff --git a/apps/sim/lib/copilot/chat/selection-context.test.ts b/apps/sim/lib/mothership/chat/selection-context.test.ts similarity index 100% rename from apps/sim/lib/copilot/chat/selection-context.test.ts rename to apps/sim/lib/mothership/chat/selection-context.test.ts diff --git a/apps/sim/lib/copilot/chat/selection-context.ts b/apps/sim/lib/mothership/chat/selection-context.ts similarity index 100% rename from apps/sim/lib/copilot/chat/selection-context.ts rename to apps/sim/lib/mothership/chat/selection-context.ts diff --git a/apps/sim/lib/copilot/chat/sim-key-redaction.test.ts b/apps/sim/lib/mothership/chat/sim-key-redaction.test.ts similarity index 100% rename from apps/sim/lib/copilot/chat/sim-key-redaction.test.ts rename to apps/sim/lib/mothership/chat/sim-key-redaction.test.ts diff --git a/apps/sim/lib/copilot/chat/sim-key-redaction.ts b/apps/sim/lib/mothership/chat/sim-key-redaction.ts similarity index 98% rename from apps/sim/lib/copilot/chat/sim-key-redaction.ts rename to apps/sim/lib/mothership/chat/sim-key-redaction.ts index a38446b0e3b..8c1fe270adf 100644 --- a/apps/sim/lib/copilot/chat/sim-key-redaction.ts +++ b/apps/sim/lib/mothership/chat/sim-key-redaction.ts @@ -1,11 +1,11 @@ import { isRecordLike } from '@sim/utils/object' -import type { PersistedContentBlock } from '@/lib/copilot/chat/persisted-message' +import { REDACTED_MARKER } from '@/lib/core/security/redaction' +import type { PersistedContentBlock } from '@/lib/mothership/chat/persisted-message' import { MothershipStreamV1EventType, MothershipStreamV1TextChannel, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { GenerateApiKey } from '@/lib/copilot/generated/tool-catalog-v1' -import { REDACTED_MARKER } from '@/lib/core/security/redaction' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { GenerateApiKey } from '@/lib/mothership/generated/tool-catalog-v1' import type { ChatMessage, ContentBlock } from '@/app/workspace/[workspaceId]/home/types' /** diff --git a/apps/sim/lib/copilot/chat/stream-liveness.test.ts b/apps/sim/lib/mothership/chat/stream-liveness.test.ts similarity index 96% rename from apps/sim/lib/copilot/chat/stream-liveness.test.ts rename to apps/sim/lib/mothership/chat/stream-liveness.test.ts index f821be4cc8e..6479344c4f4 100644 --- a/apps/sim/lib/copilot/chat/stream-liveness.test.ts +++ b/apps/sim/lib/mothership/chat/stream-liveness.test.ts @@ -10,11 +10,11 @@ const { mockGetChatStreamLockOwners } = vi.hoisted(() => ({ mockGetChatStreamLockOwners: vi.fn(), })) -vi.mock('@/lib/copilot/request/session', () => ({ +vi.mock('@/lib/mothership/request/session', () => ({ getChatStreamLockOwners: mockGetChatStreamLockOwners, })) -import { reconcileChatStreamMarkers } from '@/lib/copilot/chat/stream-liveness' +import { reconcileChatStreamMarkers } from '@/lib/mothership/chat/stream-liveness' describe('reconcileChatStreamMarkers', () => { beforeEach(() => { diff --git a/apps/sim/lib/copilot/chat/stream-liveness.ts b/apps/sim/lib/mothership/chat/stream-liveness.ts similarity index 98% rename from apps/sim/lib/copilot/chat/stream-liveness.ts rename to apps/sim/lib/mothership/chat/stream-liveness.ts index 82a92acbd24..3c18e0cc4eb 100644 --- a/apps/sim/lib/copilot/chat/stream-liveness.ts +++ b/apps/sim/lib/mothership/chat/stream-liveness.ts @@ -3,7 +3,7 @@ import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq } from 'drizzle-orm' -import { getChatStreamLockOwners } from '@/lib/copilot/request/session' +import { getChatStreamLockOwners } from '@/lib/mothership/request/session' const logger = createLogger('ChatStreamLiveness') diff --git a/apps/sim/lib/copilot/chat/stream-tool-outcome.ts b/apps/sim/lib/mothership/chat/stream-tool-outcome.ts similarity index 93% rename from apps/sim/lib/copilot/chat/stream-tool-outcome.ts rename to apps/sim/lib/mothership/chat/stream-tool-outcome.ts index ef0feed1cbd..17c0ecf04ee 100644 --- a/apps/sim/lib/copilot/chat/stream-tool-outcome.ts +++ b/apps/sim/lib/mothership/chat/stream-tool-outcome.ts @@ -1,5 +1,5 @@ import { isRecordLike } from '@sim/utils/object' -import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' +import { MothershipStreamV1ToolOutcome } from '@/lib/mothership/generated/mothership-stream-v1' type TerminalToolOutcome = | typeof MothershipStreamV1ToolOutcome.success diff --git a/apps/sim/lib/copilot/chat/terminal-state.test.ts b/apps/sim/lib/mothership/chat/terminal-state.test.ts similarity index 98% rename from apps/sim/lib/copilot/chat/terminal-state.test.ts rename to apps/sim/lib/mothership/chat/terminal-state.test.ts index 7f211472ce1..2e0b31e8a13 100644 --- a/apps/sim/lib/copilot/chat/terminal-state.test.ts +++ b/apps/sim/lib/mothership/chat/terminal-state.test.ts @@ -11,7 +11,7 @@ const { mockAppendCopilotChatMessages } = vi.hoisted(() => ({ mockAppendCopilotChatMessages: vi.fn(), })) -vi.mock('@/lib/copilot/chat/messages-store', () => ({ +vi.mock('@/lib/mothership/chat/messages-store', () => ({ appendCopilotChatMessages: mockAppendCopilotChatMessages, })) diff --git a/apps/sim/lib/copilot/chat/terminal-state.ts b/apps/sim/lib/mothership/chat/terminal-state.ts similarity index 91% rename from apps/sim/lib/copilot/chat/terminal-state.ts rename to apps/sim/lib/mothership/chat/terminal-state.ts index 5ff7886b42b..ab3ecae24d9 100644 --- a/apps/sim/lib/copilot/chat/terminal-state.ts +++ b/apps/sim/lib/mothership/chat/terminal-state.ts @@ -1,12 +1,12 @@ import { db } from '@sim/db' import { copilotChats, copilotMessages } from '@sim/db/schema' import { and, desc, eq, isNull, sql } from 'drizzle-orm' -import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' -import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message' -import { CopilotChatFinalizeOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { withCopilotSpan } from '@/lib/copilot/request/otel' +import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store' +import type { PersistedMessage } from '@/lib/mothership/chat/persisted-message' +import { CopilotChatFinalizeOutcome } from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { withCopilotSpan } from '@/lib/mothership/request/otel' type StreamMarkerPolicy = 'active-only' | 'active-or-cleared' diff --git a/apps/sim/lib/copilot/constants.ts b/apps/sim/lib/mothership/constants.ts similarity index 98% rename from apps/sim/lib/copilot/constants.ts rename to apps/sim/lib/mothership/constants.ts index dd936260b32..86294557bdd 100644 --- a/apps/sim/lib/copilot/constants.ts +++ b/apps/sim/lib/mothership/constants.ts @@ -85,7 +85,7 @@ export const COPILOT_REQUEST_MODES = ['assistant', 'build', 'plan', 'agent'] as * Shared so those three cannot drift onto different models for the same * conversation type. * - * The interactive send path (`lib/copilot/chat/post.ts`) does not read this: + * The interactive send path (`lib/mothership/chat/post.ts`) does not read this: * the chat it creates is stamped with the model it also runs the turn and * generates the title with, which that module owns separately. */ diff --git a/apps/sim/lib/copilot/docs/docs-corpus.test.ts b/apps/sim/lib/mothership/docs/docs-corpus.test.ts similarity index 98% rename from apps/sim/lib/copilot/docs/docs-corpus.test.ts rename to apps/sim/lib/mothership/docs/docs-corpus.test.ts index 54764c35625..fdec14ca5ef 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.test.ts +++ b/apps/sim/lib/mothership/docs/docs-corpus.test.ts @@ -18,8 +18,8 @@ import { grepDocs, isDocsPath, readDocsPage, -} from '@/lib/copilot/docs/docs-corpus' -import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' +} from '@/lib/mothership/docs/docs-corpus' +import { DOCS_MANIFEST } from '@/lib/mothership/generated/docs-manifest' const SAMPLE_PAGE = DOCS_MANIFEST.find((path) => path === 'workflows/blocks/agent.mdx') diff --git a/apps/sim/lib/copilot/docs/docs-corpus.ts b/apps/sim/lib/mothership/docs/docs-corpus.ts similarity index 97% rename from apps/sim/lib/copilot/docs/docs-corpus.ts rename to apps/sim/lib/mothership/docs/docs-corpus.ts index e8eca75761d..7a9954fcb76 100644 --- a/apps/sim/lib/copilot/docs/docs-corpus.ts +++ b/apps/sim/lib/mothership/docs/docs-corpus.ts @@ -2,10 +2,10 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { backoffWithJitter, parseRetryAfter } from '@sim/utils/retry' -import { foldDocsIndexPath } from '@/lib/copilot/docs/docs-path' -import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' -import type { GrepCountEntry, GrepMatch, GrepOptions } from '@/lib/copilot/vfs/operations' -import { glob as globPaths, grepReadResult } from '@/lib/copilot/vfs/operations' +import { foldDocsIndexPath } from '@/lib/mothership/docs/docs-path' +import { DOCS_MANIFEST } from '@/lib/mothership/generated/docs-manifest' +import type { GrepCountEntry, GrepMatch, GrepOptions } from '@/lib/mothership/vfs/operations' +import { glob as globPaths, grepReadResult } from '@/lib/mothership/vfs/operations' const logger = createLogger('DocsCorpus') diff --git a/apps/sim/lib/copilot/docs/docs-path.test.ts b/apps/sim/lib/mothership/docs/docs-path.test.ts similarity index 87% rename from apps/sim/lib/copilot/docs/docs-path.test.ts rename to apps/sim/lib/mothership/docs/docs-path.test.ts index c40ba0a7abb..9e390535cb0 100644 --- a/apps/sim/lib/copilot/docs/docs-path.test.ts +++ b/apps/sim/lib/mothership/docs/docs-path.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { docsSourceCandidates, foldDocsIndexPath } from '@/lib/copilot/docs/docs-path' -import { DOCS_MANIFEST } from '@/lib/copilot/generated/docs-manifest' +import { docsSourceCandidates, foldDocsIndexPath } from '@/lib/mothership/docs/docs-path' +import { DOCS_MANIFEST } from '@/lib/mothership/generated/docs-manifest' describe('foldDocsIndexPath', () => { it('folds a section overview onto the section path', () => { diff --git a/apps/sim/lib/copilot/docs/docs-path.ts b/apps/sim/lib/mothership/docs/docs-path.ts similarity index 100% rename from apps/sim/lib/copilot/docs/docs-path.ts rename to apps/sim/lib/mothership/docs/docs-path.ts diff --git a/apps/sim/lib/copilot/docs/docs-search.test.ts b/apps/sim/lib/mothership/docs/docs-search.test.ts similarity index 99% rename from apps/sim/lib/copilot/docs/docs-search.test.ts rename to apps/sim/lib/mothership/docs/docs-search.test.ts index 16d19141f43..d3368c2137f 100644 --- a/apps/sim/lib/copilot/docs/docs-search.test.ts +++ b/apps/sim/lib/mothership/docs/docs-search.test.ts @@ -53,8 +53,8 @@ vi.mock('@sim/db', () => ({ }, })) -import { DocsSearchScopeError, searchDocs } from '@/lib/copilot/docs/docs-search' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { DocsSearchScopeError, searchDocs } from '@/lib/mothership/docs/docs-search' /** Render a drizzle condition to comparable SQL-ish text for assertions. */ function whereText(): string { diff --git a/apps/sim/lib/copilot/docs/docs-search.ts b/apps/sim/lib/mothership/docs/docs-search.ts similarity index 99% rename from apps/sim/lib/copilot/docs/docs-search.ts rename to apps/sim/lib/mothership/docs/docs-search.ts index 66b36aa9316..dddd6cd4f68 100644 --- a/apps/sim/lib/copilot/docs/docs-search.ts +++ b/apps/sim/lib/mothership/docs/docs-search.ts @@ -9,8 +9,8 @@ import { isDocsDir, isDocsPage, normalizeDocsPath, -} from '@/lib/copilot/docs/docs-corpus' -import { docsSourceCandidates, UNMOUNTED_DOCS_SECTIONS } from '@/lib/copilot/docs/docs-path' +} from '@/lib/mothership/docs/docs-corpus' +import { docsSourceCandidates, UNMOUNTED_DOCS_SECTIONS } from '@/lib/mothership/docs/docs-path' import { OrchestrationError } from '@/lib/core/orchestration/types' import { DEFAULT_EMBEDDING_MODEL } from '@/lib/knowledge/embedding-models' import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' diff --git a/apps/sim/lib/copilot/environment-context.test.ts b/apps/sim/lib/mothership/environment-context.test.ts similarity index 95% rename from apps/sim/lib/copilot/environment-context.test.ts rename to apps/sim/lib/mothership/environment-context.test.ts index e4cee310987..652c970ac77 100644 --- a/apps/sim/lib/copilot/environment-context.test.ts +++ b/apps/sim/lib/mothership/environment-context.test.ts @@ -3,7 +3,7 @@ */ import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' import { afterEach, describe, expect, it } from 'vitest' -import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context' +import { prepareCopilotEnvironmentContext } from '@/lib/mothership/environment-context' describe('prepareCopilotEnvironmentContext', () => { afterEach(() => { diff --git a/apps/sim/lib/copilot/environment-context.ts b/apps/sim/lib/mothership/environment-context.ts similarity index 100% rename from apps/sim/lib/copilot/environment-context.ts rename to apps/sim/lib/mothership/environment-context.ts diff --git a/apps/sim/lib/copilot/generated/billing-protocol-v1.ts b/apps/sim/lib/mothership/generated/billing-protocol-v1.ts similarity index 100% rename from apps/sim/lib/copilot/generated/billing-protocol-v1.ts rename to apps/sim/lib/mothership/generated/billing-protocol-v1.ts diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/mothership/generated/docs-manifest.ts similarity index 100% rename from apps/sim/lib/copilot/generated/docs-manifest.ts rename to apps/sim/lib/mothership/generated/docs-manifest.ts diff --git a/apps/sim/lib/copilot/generated/metrics-v1.ts b/apps/sim/lib/mothership/generated/metrics-v1.ts similarity index 100% rename from apps/sim/lib/copilot/generated/metrics-v1.ts rename to apps/sim/lib/mothership/generated/metrics-v1.ts diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts b/apps/sim/lib/mothership/generated/mothership-stream-v1-schema.ts similarity index 100% rename from apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts rename to apps/sim/lib/mothership/generated/mothership-stream-v1-schema.ts diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts b/apps/sim/lib/mothership/generated/mothership-stream-v1.ts similarity index 100% rename from apps/sim/lib/copilot/generated/mothership-stream-v1.ts rename to apps/sim/lib/mothership/generated/mothership-stream-v1.ts diff --git a/apps/sim/lib/mothership/generated/protocol.ts b/apps/sim/lib/mothership/generated/protocol.ts new file mode 100644 index 00000000000..fdc1d164bee --- /dev/null +++ b/apps/sim/lib/mothership/generated/protocol.ts @@ -0,0 +1,138 @@ +// GENERATED — do not edit. Source of truth: mothership worker packages/contracts/src/protocol.ts +// Regenerate with `bun run contracts:sync` in the worker. + +/** + * The sim⇄worker wire protocol surface — THE shared source of truth (P3, S31). + * + * This file is COPIED VERBATIM into the sim repo by `bun run contracts:sync` + * (`apps/sim/lib/mothership/generated/protocol.ts`); `check:contract-sync` fails the build + * when the copies drift (S39). Sim imports these types, so schema skew is a compile error + * on either side; the worker's zod validators are type-asserted against these shapes in + * http/server.ts, so the runtime contract cannot drift from this file either. + * + * PROTOCOL_VERSION gates self-hosted version skew (S43): bump it on ANY breaking change to + * the payloads or frames below. The worker answers a mismatched client with an honest 426 + * instead of undefined behavior. + */ + +export const PROTOCOL_VERSION = 1 + +/** POST /api/mothership — the chat request sim sends. */ +export interface ChatRequest { + message: string + userId: string + /** Bump-gated (S43): senders include it; the worker 426s on mismatch. */ + protocolVersion?: number | undefined + messageId?: string | undefined + chatId?: string | undefined + workspaceId?: string | undefined + /** Workflow-scoped chats (the workflow-page copilot): the agent anchors to this workflow. */ + workflowId?: string | undefined + /** Connected-service operation schemas served by the integration gateway. */ + integrationTools?: unknown[] | undefined + /** User-configured MCP tool schemas — same shape as integrationTools. */ + mothershipTools?: unknown[] | undefined + /** D23: sim-minted run-scoped credential. In-memory only on the worker (S44). */ + delegationToken?: string | undefined + /** Enterprise BYOK: customer's own key; per-run instance, zero retention (S27). */ + byokApiKey?: string | undefined + /** User attachments / @-mentions packed with the message. */ + context?: ChatContextItem[] | undefined + userTimezone?: string | undefined +} + +export interface ChatContextItem { + type: string + content: string + tag?: string | undefined + path?: string | undefined +} + +/** POST /api/tools/resume — deferred tool results. */ +export interface ResumeRequest { + streamId: string + results: ResumeResult[] +} + +export interface ResumeResult { + callId: string + name?: string | undefined + data?: unknown | undefined + success?: boolean | undefined +} + +/** POST /api/streams/explicit-abort */ +export interface AbortRequest { + messageId: string +} + +/** POST /api/streams/steer. Acceptance means "queued"; application is acknowledged by a + * `run`/`steering_applied` frame carrying the steeringId — a caller that never sees the + * ack re-sends the content as an ordinary message (loss-free without liveness proof). */ +export interface SteerRequest { + messageId: string + steeringId?: string | undefined + content: string +} + +/** POST /api/generate-chat-title */ +export interface TitleRequest { + message: string +} + +/** The 409 body for a duplicate send while a sibling instance streams (S32). */ +export interface ActiveStreamConflict { + error: 'active_stream' + streamId: string + status: string +} + +/** The 426 body for protocol version skew (S43). */ +export interface ProtocolMismatch { + error: 'protocol_version_mismatch' + expected: number + got: number + message: string +} + +/** + * POST /api/mothership/execute — the one-shot headless surface (the agent block in a + * workflow, inbox automations). The caller supplies the full conversation (the block's own + * system prompt included) and the tool schemas; the worker runs one bounded loop and + * streams the same mothership-stream-v1 frames. No skills, no CLI — the block's tool + * surface is exactly what the caller passes. + */ +export interface ExecuteRequest { + messages: ExecuteMessage[] + /** JSON schema for structured output; enforced by instruction + caller-side validation. */ + responseFormat?: unknown | undefined + userId: string + protocolVersion?: number | undefined + workspaceId?: string | undefined + chatId?: string | undefined + messageId?: string | undefined + integrationTools?: unknown[] | undefined + mothershipTools?: unknown[] | undefined + delegationToken?: string | undefined +} + +export interface ExecuteMessage { + role: 'system' | 'user' | 'assistant' + content: string +} + +/** + * The response half of the wire: every SSE `data:` line is one StreamEnvelope (the + * mothership-stream-v1 shape), terminated by a literal `data: [DONE]` line per leg. The + * worker's emitter is compile-locked to this; sim's parser adopts it at the client rework. + */ +export interface StreamEnvelope { + v: 1 + type: 'session' | 'text' | 'tool' | 'run' | 'resource' | 'error' | 'complete' + seq: number + /** ISO timestamp. */ + ts: string + stream: { streamId: string; chatId?: string | undefined; cursor?: string | undefined } + trace?: { requestId?: string | undefined } | undefined + payload: Record +} diff --git a/apps/sim/lib/copilot/generated/request-trace-v1.ts b/apps/sim/lib/mothership/generated/request-trace-v1.ts similarity index 100% rename from apps/sim/lib/copilot/generated/request-trace-v1.ts rename to apps/sim/lib/mothership/generated/request-trace-v1.ts diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/mothership/generated/tool-catalog-v1.ts similarity index 100% rename from apps/sim/lib/copilot/generated/tool-catalog-v1.ts rename to apps/sim/lib/mothership/generated/tool-catalog-v1.ts diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/mothership/generated/tool-schemas-v1.ts similarity index 100% rename from apps/sim/lib/copilot/generated/tool-schemas-v1.ts rename to apps/sim/lib/mothership/generated/tool-schemas-v1.ts diff --git a/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts b/apps/sim/lib/mothership/generated/trace-attribute-values-v1.ts similarity index 100% rename from apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts rename to apps/sim/lib/mothership/generated/trace-attribute-values-v1.ts diff --git a/apps/sim/lib/copilot/generated/trace-attributes-v1.ts b/apps/sim/lib/mothership/generated/trace-attributes-v1.ts similarity index 100% rename from apps/sim/lib/copilot/generated/trace-attributes-v1.ts rename to apps/sim/lib/mothership/generated/trace-attributes-v1.ts diff --git a/apps/sim/lib/copilot/generated/trace-events-v1.ts b/apps/sim/lib/mothership/generated/trace-events-v1.ts similarity index 100% rename from apps/sim/lib/copilot/generated/trace-events-v1.ts rename to apps/sim/lib/mothership/generated/trace-events-v1.ts diff --git a/apps/sim/lib/copilot/generated/trace-spans-v1.ts b/apps/sim/lib/mothership/generated/trace-spans-v1.ts similarity index 100% rename from apps/sim/lib/copilot/generated/trace-spans-v1.ts rename to apps/sim/lib/mothership/generated/trace-spans-v1.ts diff --git a/apps/sim/lib/mothership/inbox/executor.test.ts b/apps/sim/lib/mothership/inbox/executor.test.ts index e9e8935b900..05941f8409b 100644 --- a/apps/sim/lib/mothership/inbox/executor.test.ts +++ b/apps/sim/lib/mothership/inbox/executor.test.ts @@ -35,40 +35,40 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ resolveBillingAttribution: vi.fn().mockResolvedValue({}), })) -vi.mock('@/lib/copilot/chat/lifecycle', () => ({ +vi.mock('@/lib/mothership/chat/lifecycle', () => ({ resolveOrCreateChat: mockResolveOrCreateChat, })) -vi.mock('@/lib/copilot/chat/messages-store', () => ({ +vi.mock('@/lib/mothership/chat/messages-store', () => ({ appendCopilotChatMessages: vi.fn(), })) -vi.mock('@/lib/copilot/chat/payload', () => ({ +vi.mock('@/lib/mothership/chat/payload', () => ({ buildIntegrationToolSchemas: vi.fn().mockResolvedValue([]), })) -vi.mock('@/lib/copilot/chat/persisted-message', () => ({ +vi.mock('@/lib/mothership/chat/persisted-message', () => ({ buildPersistedAssistantMessage: vi.fn().mockReturnValue({ id: 'assistant-message' }), buildPersistedUserMessage: vi.fn().mockReturnValue({ id: 'user-message' }), })) -vi.mock('@/lib/copilot/chat/workspace-context', () => ({ +vi.mock('@/lib/mothership/chat/workspace-context', () => ({ generateWorkspaceContext: vi.fn().mockResolvedValue({}), })) -vi.mock('@/lib/copilot/chat-status', () => ({ +vi.mock('@/lib/mothership/chat-status', () => ({ chatPubSub: { publishStatusChanged: vi.fn() }, })) -vi.mock('@/lib/copilot/entitlements', () => ({ +vi.mock('@/lib/mothership/entitlements', () => ({ computeWorkspaceEntitlements: vi.fn().mockResolvedValue([]), })) -vi.mock('@/lib/copilot/request/lifecycle/headless', () => ({ +vi.mock('@/lib/mothership/request/lifecycle/headless', () => ({ runHeadlessCopilotLifecycle: mockRunHeadlessCopilotLifecycle, })) -vi.mock('@/lib/copilot/request/lifecycle/start', () => ({ +vi.mock('@/lib/mothership/request/lifecycle/start', () => ({ requestChatTitle: vi.fn(), })) @@ -96,7 +96,7 @@ vi.mock('@/lib/workspaces/utils', () => ({ getWorkspaceBilledAccountUserId: vi.fn().mockResolvedValue('owner-1'), })) -import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/copilot/constants' +import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/mothership/constants' import { executeInboxTask } from '@/lib/mothership/inbox/executor' const INBOX_TASK = { diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 6a007fa2f0d..1e5d7f876bf 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -5,26 +5,25 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNull, sql } from 'drizzle-orm' import { getActivelyBannedUserIds, isEmailBlocked } from '@/lib/auth/ban' import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' -import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' -import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' -import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' +import { mintDelegationToken } from '@/lib/mothership/chat/delegation' +import { resolveOrCreateChat } from '@/lib/mothership/chat/lifecycle' +import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store' +import { buildIntegrationToolSchemas } from '@/lib/mothership/chat/payload' import { buildPersistedAssistantMessage, buildPersistedUserMessage, -} from '@/lib/copilot/chat/persisted-message' -import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' -import { chatPubSub } from '@/lib/copilot/chat-status' -import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/copilot/constants' -import { computeWorkspaceEntitlements } from '@/lib/copilot/entitlements' -import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless' -import { requestChatTitle } from '@/lib/copilot/request/lifecycle/start' -import type { OrchestratorResult } from '@/lib/copilot/request/types' -import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' -import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags' +} from '@/lib/mothership/chat/persisted-message' +import { chatPubSub } from '@/lib/mothership/chat-status' +import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/mothership/constants' +import { PROTOCOL_VERSION } from '@/lib/mothership/generated/protocol' import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { formatEmailAsMessage } from '@/lib/mothership/inbox/format' import { sendInboxResponse } from '@/lib/mothership/inbox/response' import type { AgentMailAttachment } from '@/lib/mothership/inbox/types' +import { runHeadlessCopilotLifecycle } from '@/lib/mothership/request/lifecycle/headless' +import { requestChatTitle } from '@/lib/mothership/request/lifecycle/start' +import type { OrchestratorResult } from '@/lib/mothership/request/types' +import { normalizeSecretMountPolicy } from '@/lib/mothership/secret-mount-policy' import { buildStorageKeySegment } from '@/lib/uploads/core/storage-key' import { uploadFile } from '@/lib/uploads/core/storage-service' import { createFileContent, type MessageContent } from '@/lib/uploads/utils/file-utils' @@ -236,13 +235,14 @@ export async function executeInboxTask(taskId: string): Promise { secretScope: ws.inboxSecretScope, mountedSecrets: ws.inboxMountedSecrets, }) - const [attachmentResult, workspaceContext, integrationTools, billingAttribution, entitlements] = + const [attachmentResult, integrationTools, billingAttribution, delegationToken] = await Promise.all([ fetchAttachments(), - generateWorkspaceContext(ws.id, userId, { workspaceAccess, secretMountPolicy }), - buildIntegrationToolSchemas(userId, undefined, ws.id), + buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), resolveBillingAttribution({ actorUserId: userId, workspaceId: ws.id }), - computeWorkspaceEntitlements(ws.id, userId), + // Trigger-runtime caveat (see docs/revamp/06-cutover.md): a failed mint falls + // back to null and the turn proceeds without CLI-backed capabilities. + mintDelegationToken({ workspaceId: ws.id, userId }), ]) const { attachments, fileAttachments, storedAttachments } = attachmentResult @@ -253,26 +253,25 @@ export async function executeInboxTask(taskId: string): Promise { } const messageContent = formatEmailAsMessage(truncatedTask, attachments) + /** The wire payload IS the shared ChatRequest contract; the inbox rides the full + * chat pipeline (persona + skills + CLI) — binary attachment passthrough is a noted + * follow-up; the formatted email body carries attachment summaries. */ const requestPayload: Record = { message: messageContent, userId, + protocolVersion: PROTOCOL_VERSION, + workspaceId: ws.id, chatId, - mode: 'agent', messageId: userMessageId, - isHosted, - workspaceContext, - ...(isDocSandboxEnabled ? { docCompiler: 'python' } : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), - ...(userPermission ? { userPermission } : {}), - ...(entitlements.length > 0 ? { entitlements } : {}), - ...(fileAttachments.length > 0 ? { fileAttachments } : {}), + ...(delegationToken ? { delegationToken } : {}), } const result = await runHeadlessCopilotLifecycle(requestPayload, { userId, workspaceId: ws.id, chatId: chatId ?? undefined, - goRoute: '/api/mothership/execute', + goRoute: '/api/mothership', autoExecuteTools: true, interactive: false, billingAttribution, diff --git a/apps/sim/lib/copilot/integration-tool-projection.test.ts b/apps/sim/lib/mothership/integration-tool-projection.test.ts similarity index 97% rename from apps/sim/lib/copilot/integration-tool-projection.test.ts rename to apps/sim/lib/mothership/integration-tool-projection.test.ts index 1276bbb3341..ace3d8bec59 100644 --- a/apps/sim/lib/copilot/integration-tool-projection.test.ts +++ b/apps/sim/lib/mothership/integration-tool-projection.test.ts @@ -63,8 +63,8 @@ vi.mock('@/lib/integrations/availability.server', () => ({ import { projectIntegrationToolsForViewer, resolveDeniedBlockOperations, -} from '@/lib/copilot/integration-tool-projection' -import { resetExposedIntegrationToolsCache } from '@/lib/copilot/integration-tools' +} from '@/lib/mothership/integration-tool-projection' +import { resetExposedIntegrationToolsCache } from '@/lib/mothership/integration-tools' function toolIds(config: Parameters[1]): string[] { return projectIntegrationToolsForViewer(null, config) diff --git a/apps/sim/lib/copilot/integration-tool-projection.ts b/apps/sim/lib/mothership/integration-tool-projection.ts similarity index 97% rename from apps/sim/lib/copilot/integration-tool-projection.ts rename to apps/sim/lib/mothership/integration-tool-projection.ts index de4efa9b5f5..071f960d0f9 100644 --- a/apps/sim/lib/copilot/integration-tool-projection.ts +++ b/apps/sim/lib/mothership/integration-tool-projection.ts @@ -1,12 +1,12 @@ -import type { ExposedIntegrationTool } from '@/lib/copilot/integration-tools' -import { - filterExposedIntegrationTools, - getExposedIntegrationTools, -} from '@/lib/copilot/integration-tools' import type { BlockVisibilityState } from '@/lib/core/config/block-visibility' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server' import type { PermissionGroupConfig } from '@/lib/permission-groups/fields' +import type { ExposedIntegrationTool } from '@/lib/mothership/integration-tools' +import { + filterExposedIntegrationTools, + getExposedIntegrationTools, +} from '@/lib/mothership/integration-tools' import { intersectIntegrationAllowlists, resolveAccessControlBlockType, diff --git a/apps/sim/lib/copilot/integration-tools-invariants.test.ts b/apps/sim/lib/mothership/integration-tools-invariants.test.ts similarity index 93% rename from apps/sim/lib/copilot/integration-tools-invariants.test.ts rename to apps/sim/lib/mothership/integration-tools-invariants.test.ts index 6eb1c206d0d..ababc770db8 100644 --- a/apps/sim/lib/copilot/integration-tools-invariants.test.ts +++ b/apps/sim/lib/mothership/integration-tools-invariants.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { getExposedIntegrationTools } from '@/lib/copilot/integration-tools' +import { getExposedIntegrationTools } from '@/lib/mothership/integration-tools' import { BLOCK_REGISTRY } from '@/blocks/registry-maps' /** diff --git a/apps/sim/lib/copilot/integration-tools.test.ts b/apps/sim/lib/mothership/integration-tools.test.ts similarity index 98% rename from apps/sim/lib/copilot/integration-tools.test.ts rename to apps/sim/lib/mothership/integration-tools.test.ts index edc4c8555b2..bf040714a46 100644 --- a/apps/sim/lib/copilot/integration-tools.test.ts +++ b/apps/sim/lib/mothership/integration-tools.test.ts @@ -37,7 +37,7 @@ import { filterExposedIntegrationTools, getExposedIntegrationTools, resetExposedIntegrationToolsCache, -} from '@/lib/copilot/integration-tools' +} from '@/lib/mothership/integration-tools' const allowAllOwners = () => true const allowAllTools = () => true diff --git a/apps/sim/lib/copilot/integration-tools.ts b/apps/sim/lib/mothership/integration-tools.ts similarity index 100% rename from apps/sim/lib/copilot/integration-tools.ts rename to apps/sim/lib/mothership/integration-tools.ts diff --git a/apps/sim/lib/copilot/mcp-tools.test.ts b/apps/sim/lib/mothership/mcp-tools.test.ts similarity index 99% rename from apps/sim/lib/copilot/mcp-tools.test.ts rename to apps/sim/lib/mothership/mcp-tools.test.ts index e2738ebc6a5..c2855ed4f3e 100644 --- a/apps/sim/lib/copilot/mcp-tools.test.ts +++ b/apps/sim/lib/mothership/mcp-tools.test.ts @@ -18,7 +18,7 @@ vi.mock('@/lib/mcp/application/use-cases', () => ({ })) vi.mock('@/ee/access-control/utils/permission-check', () => ({ assertPermissionsAllowed })) -import { buildSelectedMcpToolSchemas, buildTaggedMcpToolSchemas } from '@/lib/copilot/mcp-tools' +import { buildSelectedMcpToolSchemas, buildTaggedMcpToolSchemas } from '@/lib/mothership/mcp-tools' describe('mothership MCP tool schemas', () => { beforeEach(() => { diff --git a/apps/sim/lib/copilot/mcp-tools.ts b/apps/sim/lib/mothership/mcp-tools.ts similarity index 95% rename from apps/sim/lib/copilot/mcp-tools.ts rename to apps/sim/lib/mothership/mcp-tools.ts index d8ff11dd56c..f38d0135636 100644 --- a/apps/sim/lib/copilot/mcp-tools.ts +++ b/apps/sim/lib/mothership/mcp-tools.ts @@ -1,10 +1,12 @@ -import { createCopilotChatPrincipal } from '@/lib/copilot/auth/application-delegation' -import type { ToolSchema } from '@/lib/copilot/chat/payload' +import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-delegation' +import { type ToolSchema } from '@/lib/mothership/chat/payload' import { discoverMcpServerToolsAsExecutor } from '@/lib/internal/mcp/discover-tools' import type { InternalToolOperationContext } from '@/lib/internal/tool-operations/types' import { MCP_SERVER_DELEGATION_AUDIENCE } from '@/lib/mcp/application/authorization' import { discoverMcpServerToolsUseCase } from '@/lib/mcp/application/use-cases' import { resolveMcpToolBinding } from '@/lib/mcp/tool-binding' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import type { McpTool, McpToolSchema } from '@/lib/mcp/types' import { createMcpToolId } from '@/lib/mcp/utils' import { assertPermissionsAllowed } from '@/ee/access-control/utils/permission-check' diff --git a/apps/sim/lib/copilot/persistence/tool-confirm/index.ts b/apps/sim/lib/mothership/persistence/tool-confirm/index.ts similarity index 96% rename from apps/sim/lib/copilot/persistence/tool-confirm/index.ts rename to apps/sim/lib/mothership/persistence/tool-confirm/index.ts index d6e4fe3f8ad..93faa02612c 100644 --- a/apps/sim/lib/copilot/persistence/tool-confirm/index.ts +++ b/apps/sim/lib/mothership/persistence/tool-confirm/index.ts @@ -1,16 +1,16 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { getRedisClient } from '@/lib/core/config/redis' +import { createPubSubChannel, type PubSubChannel } from '@/lib/events/pubsub' import { ASYNC_TOOL_CONFIRMATION_STATUS, ASYNC_TOOL_STATUS, type AsyncCompletionEnvelope, type AsyncConfirmationState, isAsyncEphemeralConfirmationStatus, -} from '@/lib/copilot/async-runs/lifecycle' -import { getAsyncToolCalls } from '@/lib/copilot/async-runs/repository' -import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' -import { getRedisClient } from '@/lib/core/config/redis' -import { createPubSubChannel, type PubSubChannel } from '@/lib/events/pubsub' +} from '@/lib/mothership/async-runs/lifecycle' +import { getAsyncToolCalls } from '@/lib/mothership/async-runs/repository' +import { MothershipStreamV1ToolOutcome } from '@/lib/mothership/generated/mothership-stream-v1' const logger = createLogger('CopilotOrchestratorPersistence') const TOOL_CONFIRMATION_TTL_SECONDS = 60 * 10 diff --git a/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts b/apps/sim/lib/mothership/persistence/tool-confirm/tool-confirm.test.ts similarity index 98% rename from apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts rename to apps/sim/lib/mothership/persistence/tool-confirm/tool-confirm.test.ts index cc5f7b02338..b1eee054045 100644 --- a/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts +++ b/apps/sim/lib/mothership/persistence/tool-confirm/tool-confirm.test.ts @@ -10,7 +10,7 @@ const { getAsyncToolCalls } = vi.hoisted(() => ({ const channelHandlers = new Set<(event: any) => void>() -vi.mock('@/lib/copilot/async-runs/repository', () => ({ +vi.mock('@/lib/mothership/async-runs/repository', () => ({ getAsyncToolCalls, })) @@ -33,7 +33,7 @@ import { getToolConfirmation, publishToolConfirmation, waitForToolConfirmation, -} from '@/lib/copilot/persistence/tool-confirm' +} from '@/lib/mothership/persistence/tool-confirm' describe('copilot orchestrator persistence', () => { let row: { diff --git a/apps/sim/lib/copilot/persistence/tool-permission/auto-allow.ts b/apps/sim/lib/mothership/persistence/tool-permission/auto-allow.ts similarity index 100% rename from apps/sim/lib/copilot/persistence/tool-permission/auto-allow.ts rename to apps/sim/lib/mothership/persistence/tool-permission/auto-allow.ts diff --git a/apps/sim/lib/copilot/persistence/tool-permission/index.ts b/apps/sim/lib/mothership/persistence/tool-permission/index.ts similarity index 96% rename from apps/sim/lib/copilot/persistence/tool-permission/index.ts rename to apps/sim/lib/mothership/persistence/tool-permission/index.ts index 083d11004d2..79f9e51896d 100644 --- a/apps/sim/lib/copilot/persistence/tool-permission/index.ts +++ b/apps/sim/lib/mothership/persistence/tool-permission/index.ts @@ -1,9 +1,9 @@ import type { CopilotToolPermissionDecision } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { isExecutableToolPermissionDecision } from '@/lib/copilot/async-runs/lifecycle' -import { getAsyncToolCall } from '@/lib/copilot/async-runs/repository' import { createPubSubChannel, type PubSubChannel } from '@/lib/events/pubsub' +import { isExecutableToolPermissionDecision } from '@/lib/mothership/async-runs/lifecycle' +import { getAsyncToolCall } from '@/lib/mothership/async-runs/repository' const logger = createLogger('CopilotToolPermission') diff --git a/apps/sim/lib/copilot/request/context/request-context.ts b/apps/sim/lib/mothership/request/context/request-context.ts similarity index 86% rename from apps/sim/lib/copilot/request/context/request-context.ts rename to apps/sim/lib/mothership/request/context/request-context.ts index 2ba04104dd0..5da894d4087 100644 --- a/apps/sim/lib/copilot/request/context/request-context.ts +++ b/apps/sim/lib/mothership/request/context/request-context.ts @@ -1,6 +1,6 @@ import { generateId } from '@sim/utils/id' -import { TraceCollector } from '@/lib/copilot/request/trace' -import type { StreamingContext } from '@/lib/copilot/request/types' +import { TraceCollector } from '@/lib/mothership/request/trace' +import type { StreamingContext } from '@/lib/mothership/request/types' /** * Create a fresh StreamingContext. diff --git a/apps/sim/lib/copilot/request/context/result.test.ts b/apps/sim/lib/mothership/request/context/result.test.ts similarity index 86% rename from apps/sim/lib/copilot/request/context/result.test.ts rename to apps/sim/lib/mothership/request/context/result.test.ts index bcc441d02ee..760403f3883 100644 --- a/apps/sim/lib/copilot/request/context/result.test.ts +++ b/apps/sim/lib/mothership/request/context/result.test.ts @@ -2,11 +2,11 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' -import { RunFunction } from '@/lib/copilot/generated/tool-catalog-v1' -import { buildToolCallSummaries } from '@/lib/copilot/request/context/result' -import { TraceCollector } from '@/lib/copilot/request/trace' -import type { StreamingContext } from '@/lib/copilot/request/types' +import { MothershipStreamV1ToolOutcome } from '@/lib/mothership/generated/mothership-stream-v1' +import { RunFunction } from '@/lib/mothership/generated/tool-catalog-v1' +import { buildToolCallSummaries } from '@/lib/mothership/request/context/result' +import { TraceCollector } from '@/lib/mothership/request/trace' +import type { StreamingContext } from '@/lib/mothership/request/types' function makeContext(): StreamingContext { return { diff --git a/apps/sim/lib/copilot/request/context/result.ts b/apps/sim/lib/mothership/request/context/result.ts similarity index 75% rename from apps/sim/lib/copilot/request/context/result.ts rename to apps/sim/lib/mothership/request/context/result.ts index 19eaf9cb6bf..3bd9c4ee489 100644 --- a/apps/sim/lib/copilot/request/context/result.ts +++ b/apps/sim/lib/mothership/request/context/result.ts @@ -1,5 +1,5 @@ -import { getToolCallStateOutput } from '@/lib/copilot/request/tool-call-state' -import type { StreamingContext, ToolCallSummary } from '@/lib/copilot/request/types' +import { getToolCallStateOutput } from '@/lib/mothership/request/tool-call-state' +import type { StreamingContext, ToolCallSummary } from '@/lib/mothership/request/types' /** * Build a ToolCallSummary array from the streaming context. diff --git a/apps/sim/lib/copilot/request/go/fetch.test.ts b/apps/sim/lib/mothership/request/go/fetch.test.ts similarity index 97% rename from apps/sim/lib/copilot/request/go/fetch.test.ts rename to apps/sim/lib/mothership/request/go/fetch.test.ts index 9607a995d8e..2f042129aff 100644 --- a/apps/sim/lib/copilot/request/go/fetch.test.ts +++ b/apps/sim/lib/mothership/request/go/fetch.test.ts @@ -5,7 +5,7 @@ import { SimpleSpanProcessor, } from '@opentelemetry/sdk-trace-base' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fetchGo } from '@/lib/copilot/request/go/fetch' +import { fetchGo } from '@/lib/mothership/request/go/fetch' describe('fetchGo', () => { const exporter = new InMemorySpanExporter() diff --git a/apps/sim/lib/copilot/request/go/fetch.ts b/apps/sim/lib/mothership/request/go/fetch.ts similarity index 93% rename from apps/sim/lib/copilot/request/go/fetch.ts rename to apps/sim/lib/mothership/request/go/fetch.ts index 0fe612e00bf..64aaacec800 100644 --- a/apps/sim/lib/copilot/request/go/fetch.ts +++ b/apps/sim/lib/mothership/request/go/fetch.ts @@ -1,8 +1,8 @@ import { type Context, context, SpanStatusCode, trace } from '@opentelemetry/api' -import { CopilotLeg } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { traceHeaders } from '@/lib/copilot/request/go/propagation' -import { isActionableErrorStatus, markSpanForError } from '@/lib/copilot/request/otel' +import { CopilotLeg } from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { traceHeaders } from '@/lib/mothership/request/go/propagation' +import { isActionableErrorStatus, markSpanForError } from '@/lib/mothership/request/otel' // Lazy tracer resolution: module-level `trace.getTracer()` can be evaluated // before `instrumentation-node.ts` installs the TracerProvider under diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.test.ts b/apps/sim/lib/mothership/request/go/file-preview-adapter.test.ts similarity index 93% rename from apps/sim/lib/copilot/request/go/file-preview-adapter.test.ts rename to apps/sim/lib/mothership/request/go/file-preview-adapter.test.ts index 3e14e0bd0ee..630aab8363d 100644 --- a/apps/sim/lib/copilot/request/go/file-preview-adapter.test.ts +++ b/apps/sim/lib/mothership/request/go/file-preview-adapter.test.ts @@ -7,30 +7,34 @@ import { MothershipStreamV1ToolExecutor, MothershipStreamV1ToolMode, MothershipStreamV1ToolPhase, -} from '@/lib/copilot/generated/mothership-stream-v1' +} from '@/lib/mothership/generated/mothership-stream-v1' const { peekFileIntentMock, executeCopilotFileUseCaseMock } = vi.hoisted(() => ({ peekFileIntentMock: vi.fn(), executeCopilotFileUseCaseMock: vi.fn(), })) -vi.mock('@/lib/copilot/tools/server/files/file-intent-store', () => ({ +vi.mock('@/lib/mothership/tools/server/files/file-intent-store', () => ({ peekFileIntent: peekFileIntentMock, })) -vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({ +vi.mock('@/lib/mothership/application/execute-file-use-case', () => ({ executeCopilotFileUseCase: executeCopilotFileUseCaseMock, resolveCopilotWorkspaceFileReference: vi.fn(), })) -import { createStreamingContext } from '@/lib/copilot/request/context/request-context' +import { createStreamingContext } from '@/lib/mothership/request/context/request-context' import { createFilePreviewAdapterState, type FilePreviewAdapterState, processFilePreviewStreamEvent, -} from '@/lib/copilot/request/go/file-preview-adapter' -import { createEvent, eventToStreamEvent } from '@/lib/copilot/request/session' -import type { ActiveFileIntent, ExecutionContext, StreamEvent } from '@/lib/copilot/request/types' +} from '@/lib/mothership/request/go/file-preview-adapter' +import { createEvent, eventToStreamEvent } from '@/lib/mothership/request/session' +import type { + ActiveFileIntent, + ExecutionContext, + StreamEvent, +} from '@/lib/mothership/request/types' const STREAM_ID = 'stream-1' const EDIT_TOOL_CALL_ID = 'edit-content-1' diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts b/apps/sim/lib/mothership/request/go/file-preview-adapter.ts similarity index 98% rename from apps/sim/lib/copilot/request/go/file-preview-adapter.ts rename to apps/sim/lib/mothership/request/go/file-preview-adapter.ts index a5cac6da2be..925ebe4dbcc 100644 --- a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts +++ b/apps/sim/lib/mothership/request/go/file-preview-adapter.ts @@ -1,8 +1,8 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' -import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' -import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1' +import { executeCopilotFileUseCase } from '@/lib/mothership/application/execute-file-use-case' +import { MothershipStreamV1EventType } from '@/lib/mothership/generated/mothership-stream-v1' import { createFilePreviewSession, type FilePreviewContentMode, @@ -13,20 +13,20 @@ import { isToolResultStreamEvent, type SyntheticFilePreviewPayload, upsertFilePreviewSession, -} from '@/lib/copilot/request/session' +} from '@/lib/mothership/request/session' import type { ActiveFileIntent, ExecutionContext, OrchestratorOptions, StreamEvent, StreamingContext, -} from '@/lib/copilot/request/types' -import { peekFileIntent } from '@/lib/copilot/tools/server/files/file-intent-store' +} from '@/lib/mothership/request/types' +import { peekFileIntent } from '@/lib/mothership/tools/server/files/file-intent-store' import { buildFilePreviewText, loadWorkspaceFileTextForPreview, type WorkspaceFilePreviewBase, -} from '@/lib/copilot/tools/server/files/file-preview' +} from '@/lib/mothership/tools/server/files/file-preview' import { findWorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' diff --git a/apps/sim/lib/copilot/request/go/parser.ts b/apps/sim/lib/mothership/request/go/parser.ts similarity index 100% rename from apps/sim/lib/copilot/request/go/parser.ts rename to apps/sim/lib/mothership/request/go/parser.ts diff --git a/apps/sim/lib/copilot/request/go/propagation.ts b/apps/sim/lib/mothership/request/go/propagation.ts similarity index 100% rename from apps/sim/lib/copilot/request/go/propagation.ts rename to apps/sim/lib/mothership/request/go/propagation.ts diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/mothership/request/go/stream.test.ts similarity index 97% rename from apps/sim/lib/copilot/request/go/stream.test.ts rename to apps/sim/lib/mothership/request/go/stream.test.ts index 9dc388f3157..597cb7a794e 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/mothership/request/go/stream.test.ts @@ -9,17 +9,17 @@ import { MothershipStreamV1ToolMode, MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, -} from '@/lib/copilot/generated/mothership-stream-v1' +} from '@/lib/mothership/generated/mothership-stream-v1' /** Table side effects are not exercised here, and the real module loads the table application layer. */ -vi.mock('@/lib/copilot/request/tools/tables', () => ({ +vi.mock('@/lib/mothership/request/tools/tables', () => ({ maybeWriteOutputToTable: vi.fn(async (_toolName, _params, result) => result), maybeWriteReadCsvToTable: vi.fn(async (_toolName, _params, result) => result), })) -vi.mock('@/lib/copilot/request/session', async () => { - const actual = await vi.importActual( - '@/lib/copilot/request/session' +vi.mock('@/lib/mothership/request/session', async () => { + const actual = await vi.importActual( + '@/lib/mothership/request/session' ) return { ...actual, @@ -47,7 +47,7 @@ vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ listAllWorkspaceFiles: { execute: listAllWorkspaceFilesMock }, })) -vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({ +vi.mock('@/lib/mothership/application/execute-file-use-case', () => ({ executeCopilotFileUseCase: ( context: { userId: string; workspaceId: string; toolCallId: string }, useCase: { execute: (args: unknown) => unknown }, @@ -65,10 +65,10 @@ vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({ }), })) -vi.mock('@/lib/copilot/tools/server/files/file-preview', async () => { +vi.mock('@/lib/mothership/tools/server/files/file-preview', async () => { const actual = await vi.importActual< - typeof import('@/lib/copilot/tools/server/files/file-preview') - >('@/lib/copilot/tools/server/files/file-preview') + typeof import('@/lib/mothership/tools/server/files/file-preview') + >('@/lib/mothership/tools/server/files/file-preview') return { ...actual, // Returns the file's preview base as a `WorkspaceFilePreviewBase` ({ text }), NOT a bare string — @@ -85,15 +85,15 @@ import { runStreamLoop, STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE, StreamEndedWithoutTerminalError, -} from '@/lib/copilot/request/go/stream' +} from '@/lib/mothership/request/go/stream' import { createProviderToolCallIdentity, PROVIDER_TOOL_CALL_IDENTITY_LIMITS, scopeProviderToolCallId, -} from '@/lib/copilot/request/go/tool-call-identity' -import { AbortReason, createEvent, hasAbortMarker } from '@/lib/copilot/request/session' -import { RequestTraceV1Outcome, TraceCollector } from '@/lib/copilot/request/trace' -import type { ExecutionContext, StreamingContext } from '@/lib/copilot/request/types' +} from '@/lib/mothership/request/go/tool-call-identity' +import { AbortReason, createEvent, hasAbortMarker } from '@/lib/mothership/request/session' +import { RequestTraceV1Outcome, TraceCollector } from '@/lib/mothership/request/trace' +import type { ExecutionContext, StreamingContext } from '@/lib/mothership/request/types' function createSseResponse(events: unknown[]): Response { const payload = events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join('') diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/mothership/request/go/stream.ts similarity index 96% rename from apps/sim/lib/copilot/request/go/stream.ts rename to apps/sim/lib/mothership/request/go/stream.ts index e1effef338e..f48299a38b0 100644 --- a/apps/sim/lib/copilot/request/go/stream.ts +++ b/apps/sim/lib/mothership/request/go/stream.ts @@ -2,50 +2,53 @@ import { type Context, SpanStatusCode } from '@opentelemetry/api' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' -import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/copilot/constants' +import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/mothership/constants' import { MothershipStreamV1EventType, MothershipStreamV1SpanLifecycleEvent, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { CopilotSseCloseReason } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { fetchGo } from '@/lib/copilot/request/go/fetch' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { CopilotSseCloseReason } from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceEvent } from '@/lib/mothership/generated/trace-events-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { fetchGo } from '@/lib/mothership/request/go/fetch' import { buildPreviewContentUpdate, createFilePreviewAdapterState, decodeJsonStringPrefix, extractEditContent, processFilePreviewStreamEvent, -} from '@/lib/copilot/request/go/file-preview-adapter' -import { FatalSseEventError, processSSEStream } from '@/lib/copilot/request/go/parser' -import { scopeProviderToolCallEvent } from '@/lib/copilot/request/go/tool-call-identity' +} from '@/lib/mothership/request/go/file-preview-adapter' +import { FatalSseEventError, processSSEStream } from '@/lib/mothership/request/go/parser' +import { scopeProviderToolCallEvent } from '@/lib/mothership/request/go/tool-call-identity' import { handleSubagentRouting, prePersistClientExecutableToolCall, sseHandlers, subAgentHandlers, -} from '@/lib/copilot/request/handlers' +} from '@/lib/mothership/request/handlers' import { flushSubagentThinkingBlock, flushThinkingBlock, -} from '@/lib/copilot/request/handlers/types' -import { getCopilotTracer } from '@/lib/copilot/request/otel' +} from '@/lib/mothership/request/handlers/types' +import { getCopilotTracer } from '@/lib/mothership/request/otel' import { AbortReason, eventToStreamEvent, hasAbortMarker, isSubagentSpanStreamEvent, parsePersistedStreamEventEnvelope, -} from '@/lib/copilot/request/session' -import { shouldSkipToolCallEvent, shouldSkipToolResultEvent } from '@/lib/copilot/request/sse-utils' +} from '@/lib/mothership/request/session' +import { + shouldSkipToolCallEvent, + shouldSkipToolResultEvent, +} from '@/lib/mothership/request/sse-utils' import type { ExecutionContext, OrchestratorOptions, StreamEvent, StreamingContext, -} from '@/lib/copilot/request/types' +} from '@/lib/mothership/request/types' const logger = createLogger('CopilotGoStream') diff --git a/apps/sim/lib/copilot/request/go/tool-call-identity.test.ts b/apps/sim/lib/mothership/request/go/tool-call-identity.test.ts similarity index 97% rename from apps/sim/lib/copilot/request/go/tool-call-identity.test.ts rename to apps/sim/lib/mothership/request/go/tool-call-identity.test.ts index f4d0e138a09..f0a69f155f4 100644 --- a/apps/sim/lib/copilot/request/go/tool-call-identity.test.ts +++ b/apps/sim/lib/mothership/request/go/tool-call-identity.test.ts @@ -8,13 +8,13 @@ import { restoreProviderToolCallId, scopeProviderToolCallEvent, scopeProviderToolCallId, -} from '@/lib/copilot/request/go/tool-call-identity' +} from '@/lib/mothership/request/go/tool-call-identity' import { markToolResultSeen, shouldSkipToolCallEvent, shouldSkipToolResultEvent, -} from '@/lib/copilot/request/sse-utils' -import type { StreamEvent } from '@/lib/copilot/request/types' +} from '@/lib/mothership/request/sse-utils' +import type { StreamEvent } from '@/lib/mothership/request/types' function toolCall(toolCallId: string): StreamEvent { return { diff --git a/apps/sim/lib/copilot/request/go/tool-call-identity.ts b/apps/sim/lib/mothership/request/go/tool-call-identity.ts similarity index 98% rename from apps/sim/lib/copilot/request/go/tool-call-identity.ts rename to apps/sim/lib/mothership/request/go/tool-call-identity.ts index 8fa70259a2e..d42153c3c18 100644 --- a/apps/sim/lib/copilot/request/go/tool-call-identity.ts +++ b/apps/sim/lib/mothership/request/go/tool-call-identity.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto' import { isRecordLike } from '@sim/utils/object' -import type { StreamEvent } from '@/lib/copilot/request/types' +import type { StreamEvent } from '@/lib/mothership/request/types' export const PROVIDER_TOOL_CALL_IDENTITY_LIMITS = { maxEntries: 10_000, diff --git a/apps/sim/lib/copilot/request/handlers/complete.ts b/apps/sim/lib/mothership/request/handlers/complete.ts similarity index 100% rename from apps/sim/lib/copilot/request/handlers/complete.ts rename to apps/sim/lib/mothership/request/handlers/complete.ts diff --git a/apps/sim/lib/copilot/request/handlers/error.ts b/apps/sim/lib/mothership/request/handlers/error.ts similarity index 100% rename from apps/sim/lib/copilot/request/handlers/error.ts rename to apps/sim/lib/mothership/request/handlers/error.ts diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/mothership/request/handlers/handlers.test.ts similarity index 98% rename from apps/sim/lib/copilot/request/handlers/handlers.test.ts rename to apps/sim/lib/mothership/request/handlers/handlers.test.ts index cb1d2f9ea99..c5cb439c5a3 100644 --- a/apps/sim/lib/copilot/request/handlers/handlers.test.ts +++ b/apps/sim/lib/mothership/request/handlers/handlers.test.ts @@ -4,8 +4,8 @@ import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { AsyncToolCallOwnershipError } from '@/lib/copilot/async-runs/errors' -import { TraceCollector } from '@/lib/copilot/request/trace' +import { AsyncToolCallOwnershipError } from '@/lib/mothership/async-runs/errors' +import { TraceCollector } from '@/lib/mothership/request/trace' const { isSimExecuted, executeTool, ensureHandlersRegistered, toolRequiresApproval } = vi.hoisted( () => ({ @@ -39,7 +39,7 @@ const { sealClientToolContext } = vi.hoisted(() => ({ sealClientToolContext: vi.fn(), })) -vi.mock('@/lib/copilot/tool-executor', () => ({ +vi.mock('@/lib/mothership/tool-executor', () => ({ isSimExecuted, executeTool, ensureHandlersRegistered, @@ -47,7 +47,7 @@ vi.mock('@/lib/copilot/tool-executor', () => ({ toolRequiresApproval, })) -vi.mock('@/lib/copilot/async-runs/repository', () => ({ +vi.mock('@/lib/mothership/async-runs/repository', () => ({ createRunSegment: vi.fn(), updateRunStatus: vi.fn(), getLatestRunForExecution: vi.fn(), @@ -67,18 +67,18 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ })) /** Table side effects are not exercised here, and the real module loads the table application layer. */ -vi.mock('@/lib/copilot/request/tools/tables', () => ({ +vi.mock('@/lib/mothership/request/tools/tables', () => ({ maybeWriteOutputToTable: vi.fn(async (_toolName, _params, result) => result), maybeWriteReadCsvToTable: vi.fn(async (_toolName, _params, result) => result), })) -vi.mock('@/lib/copilot/request/tools/client', () => ({ +vi.mock('@/lib/mothership/request/tools/client', () => ({ waitForClientToolCompletion, waitForToolCompletion, waitForWorkflowToolCompletion, })) -vi.mock('@/lib/copilot/request/tools/client-completion-seal.server', () => ({ +vi.mock('@/lib/mothership/request/tools/client-completion-seal.server', () => ({ sealClientToolContext, })) @@ -93,14 +93,18 @@ import { MothershipStreamV1ToolMode, MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { Read as ReadTool, RunFunction } from '@/lib/copilot/generated/tool-catalog-v1' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { Read as ReadTool, RunFunction } from '@/lib/mothership/generated/tool-catalog-v1' import { prePersistClientExecutableToolCall, sseHandlers, subAgentHandlers, -} from '@/lib/copilot/request/handlers' -import type { ExecutionContext, StreamEvent, StreamingContext } from '@/lib/copilot/request/types' +} from '@/lib/mothership/request/handlers' +import type { + ExecutionContext, + StreamEvent, + StreamingContext, +} from '@/lib/mothership/request/types' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' describe('sse-handlers tool lifecycle', () => { diff --git a/apps/sim/lib/copilot/request/handlers/index.ts b/apps/sim/lib/mothership/request/handlers/index.ts similarity index 92% rename from apps/sim/lib/copilot/request/handlers/index.ts rename to apps/sim/lib/mothership/request/handlers/index.ts index 554ae149599..3f0464d9ca1 100644 --- a/apps/sim/lib/copilot/request/handlers/index.ts +++ b/apps/sim/lib/mothership/request/handlers/index.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' -import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1' -import type { StreamEvent, StreamingContext } from '@/lib/copilot/request/types' +import { MothershipStreamV1EventType } from '@/lib/mothership/generated/mothership-stream-v1' +import type { StreamEvent, StreamingContext } from '@/lib/mothership/request/types' import { handleCompleteEvent } from './complete' import { handleErrorEvent } from './error' import { handleResourceEvent } from './resource' diff --git a/apps/sim/lib/copilot/request/handlers/resource.ts b/apps/sim/lib/mothership/request/handlers/resource.ts similarity index 100% rename from apps/sim/lib/copilot/request/handlers/resource.ts rename to apps/sim/lib/mothership/request/handlers/resource.ts diff --git a/apps/sim/lib/copilot/request/handlers/run.ts b/apps/sim/lib/mothership/request/handlers/run.ts similarity index 97% rename from apps/sim/lib/copilot/request/handlers/run.ts rename to apps/sim/lib/mothership/request/handlers/run.ts index 9f162be0b7d..29f8d0631b6 100644 --- a/apps/sim/lib/copilot/request/handlers/run.ts +++ b/apps/sim/lib/mothership/request/handlers/run.ts @@ -3,8 +3,8 @@ import { generateShortId } from '@sim/utils/id' import { MothershipStreamV1RunKind, MothershipStreamV1ToolOutcome, -} from '@/lib/copilot/generated/mothership-stream-v1' -import type { ContentBlock, StreamEvent, StreamingContext } from '@/lib/copilot/request/types' +} from '@/lib/mothership/generated/mothership-stream-v1' +import type { ContentBlock, StreamEvent, StreamingContext } from '@/lib/mothership/request/types' import type { StreamHandler } from './types' import { addContentBlock, getScopedSpanIdentity } from './types' diff --git a/apps/sim/lib/copilot/request/handlers/session.ts b/apps/sim/lib/mothership/request/handlers/session.ts similarity index 78% rename from apps/sim/lib/copilot/request/handlers/session.ts rename to apps/sim/lib/mothership/request/handlers/session.ts index 972594b2ad4..e634d02ba01 100644 --- a/apps/sim/lib/copilot/request/handlers/session.ts +++ b/apps/sim/lib/mothership/request/handlers/session.ts @@ -1,4 +1,4 @@ -import { MothershipStreamV1SessionKind } from '@/lib/copilot/generated/mothership-stream-v1' +import { MothershipStreamV1SessionKind } from '@/lib/mothership/generated/mothership-stream-v1' import type { StreamHandler } from './types' export const handleSessionEvent: StreamHandler = (event, context, execContext) => { diff --git a/apps/sim/lib/copilot/request/handlers/span.ts b/apps/sim/lib/mothership/request/handlers/span.ts similarity index 98% rename from apps/sim/lib/copilot/request/handlers/span.ts rename to apps/sim/lib/mothership/request/handlers/span.ts index ba2fba1caac..cce23be08c3 100644 --- a/apps/sim/lib/copilot/request/handlers/span.ts +++ b/apps/sim/lib/mothership/request/handlers/span.ts @@ -1,7 +1,7 @@ import { MothershipStreamV1SpanLifecycleEvent, MothershipStreamV1SpanPayloadKind, -} from '@/lib/copilot/generated/mothership-stream-v1' +} from '@/lib/mothership/generated/mothership-stream-v1' import type { StreamHandler } from './types' import { addContentBlock } from './types' diff --git a/apps/sim/lib/copilot/request/handlers/text.ts b/apps/sim/lib/mothership/request/handlers/text.ts similarity index 96% rename from apps/sim/lib/copilot/request/handlers/text.ts rename to apps/sim/lib/mothership/request/handlers/text.ts index 8f110a82b28..5f89be34e62 100644 --- a/apps/sim/lib/copilot/request/handlers/text.ts +++ b/apps/sim/lib/mothership/request/handlers/text.ts @@ -1,4 +1,4 @@ -import { MothershipStreamV1TextChannel } from '@/lib/copilot/generated/mothership-stream-v1' +import { MothershipStreamV1TextChannel } from '@/lib/mothership/generated/mothership-stream-v1' import type { StreamHandler, ToolScope } from './types' import { addContentBlock, diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/mothership/request/handlers/tool.ts similarity index 93% rename from apps/sim/lib/copilot/request/handlers/tool.ts rename to apps/sim/lib/mothership/request/handlers/tool.ts index 9e563ecd6e8..5cee0799488 100644 --- a/apps/sim/lib/copilot/request/handlers/tool.ts +++ b/apps/sim/lib/mothership/request/handlers/tool.ts @@ -2,59 +2,65 @@ import { isCurrentBrowserToolName } from '@sim/browser-protocol' import { createLogger } from '@sim/logger' import { isTerminalToolName } from '@sim/terminal-protocol' import { getErrorMessage, toError } from '@sim/utils/errors' -import { AsyncToolCallOwnershipError } from '@/lib/copilot/async-runs/errors' +import { AsyncToolCallOwnershipError } from '@/lib/mothership/async-runs/errors' import type { AsyncCompletionSignal, AsyncTerminalCompletionSnapshot, -} from '@/lib/copilot/async-runs/lifecycle' -import { upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' -import { COPILOT_WORKFLOW_TOOL_CLIENT_GRACE_MS, STREAM_TIMEOUT_MS } from '@/lib/copilot/constants' +} from '@/lib/mothership/async-runs/lifecycle' +import { upsertAsyncToolCall } from '@/lib/mothership/async-runs/repository' +import { + COPILOT_WORKFLOW_TOOL_CLIENT_GRACE_MS, + STREAM_TIMEOUT_MS, +} from '@/lib/mothership/constants' import { MothershipStreamV1AsyncToolRecordStatus, type MothershipStreamV1ToolCallDescriptor, MothershipStreamV1ToolExecutor, MothershipStreamV1ToolOutcome, type MothershipStreamV1ToolResultPayload, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { withCopilotSpan } from '@/lib/copilot/request/otel' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { withCopilotSpan } from '@/lib/mothership/request/otel' import { isToolArgsDeltaStreamEvent, isToolCallStreamEvent, isToolResultStreamEvent, TOOL_CALL_STATUS, -} from '@/lib/copilot/request/session' -import { markToolResultSeen, wasToolResultSeen } from '@/lib/copilot/request/sse-utils' -import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state' -import { waitForClientToolCompletion } from '@/lib/copilot/request/tools/client' -import { sealClientToolContext } from '@/lib/copilot/request/tools/client-completion-seal.server' -import { executeToolAndReport } from '@/lib/copilot/request/tools/executor' +} from '@/lib/mothership/request/session' +import { markToolResultSeen, wasToolResultSeen } from '@/lib/mothership/request/sse-utils' +import { setTerminalToolCallState } from '@/lib/mothership/request/tool-call-state' +import { waitForClientToolCompletion } from '@/lib/mothership/request/tools/client' +import { sealClientToolContext } from '@/lib/mothership/request/tools/client-completion-seal.server' +import { executeToolAndReport } from '@/lib/mothership/request/tools/executor' import { runGatedToolExecution, TOOL_AWAITING_APPROVAL_STATUS, toolCallNeedsApproval, -} from '@/lib/copilot/request/tools/permission' -import { raceWorkflowToolClientPickup } from '@/lib/copilot/request/tools/workflow-client-fallback' +} from '@/lib/mothership/request/tools/permission' +import { raceWorkflowToolClientPickup } from '@/lib/mothership/request/tools/workflow-client-fallback' import type { ExecutionContext, OrchestratorOptions, StreamEvent, StreamingContext, ToolCallState, -} from '@/lib/copilot/request/types' -import { getToolEntry, isSimExecuted } from '@/lib/copilot/tool-executor' -import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' -import { isUserLocalVfsToolCall } from '@/lib/copilot/tools/local-filesystem' -import { extractStreamingStringArgument } from '@/lib/copilot/tools/streaming-args' +} from '@/lib/mothership/request/types' +import { getToolEntry, isSimExecuted } from '@/lib/mothership/tool-executor' +import { isToolHiddenInUi } from '@/lib/mothership/tools/client/hidden-tools' +import { isUserLocalVfsToolCall } from '@/lib/mothership/tools/local-filesystem' +import { extractStreamingStringArgument } from '@/lib/mothership/tools/streaming-args' import { getToolDisplayTitle, normalizeToolActivityDescription, -} from '@/lib/copilot/tools/tool-display' -import { isWorkflowToolName, resolveWorkflowToolTargetId } from '@/lib/copilot/tools/workflow-tools' +} from '@/lib/mothership/tools/tool-display' +import { + isWorkflowToolName, + resolveWorkflowToolTargetId, +} from '@/lib/mothership/tools/workflow-tools' import { getBlockByToolName } from '@/blocks/registry' -import type { ToolScope } from './types' import { + type ToolScope, abortPendingToolIfStreamDead, addContentBlock, emitSyntheticToolResult, @@ -597,7 +603,13 @@ function updateToolCallFromFrame( args: Record | undefined, finalized: boolean ): void { - if (!toolCall.name && toolName) toolCall.name = toolName + // The partial (draw-only) frame carries the raw SDK tool name (`sim_cli`) because the + // args that determine the derived per-command name are still streaming; the FINAL frame + // is authoritative and must refine it, or the display map keys on the raw name forever + // ("Ran CLI command" instead of "Listed workflows"). + if (toolName && (!toolCall.name || (finalized && toolCall.name !== toolName))) { + toolCall.name = toolName + } if (finalized || args !== undefined) toolCall.params = args } diff --git a/apps/sim/lib/copilot/request/handlers/types.ts b/apps/sim/lib/mothership/request/handlers/types.ts similarity index 95% rename from apps/sim/lib/copilot/request/handlers/types.ts rename to apps/sim/lib/mothership/request/handlers/types.ts index 6145b264bcc..9b521f7d54a 100644 --- a/apps/sim/lib/copilot/request/handlers/types.ts +++ b/apps/sim/lib/mothership/request/handlers/types.ts @@ -4,8 +4,8 @@ import { isRecordLike, toRecord } from '@sim/utils/object' import type { AsyncCompletionSignal, AsyncTerminalCompletionSnapshot, -} from '@/lib/copilot/async-runs/lifecycle' -import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle' +} from '@/lib/mothership/async-runs/lifecycle' +import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/mothership/async-runs/lifecycle' import { MothershipStreamV1EventType, type MothershipStreamV1StreamScope, @@ -15,11 +15,11 @@ import { MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, type MothershipStreamV1ToolResultPayload, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { CopilotDegradedReason } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { recordDegraded } from '@/lib/copilot/request/metrics' -import { markToolResultSeen } from '@/lib/copilot/request/sse-utils' -import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { CopilotDegradedReason } from '@/lib/mothership/generated/trace-attribute-values-v1' +import { recordDegraded } from '@/lib/mothership/request/metrics' +import { markToolResultSeen } from '@/lib/mothership/request/sse-utils' +import { setTerminalToolCallState } from '@/lib/mothership/request/tool-call-state' import type { ContentBlock, ExecutionContext, @@ -27,7 +27,7 @@ import type { StreamEvent, StreamingContext, ToolCallState, -} from '@/lib/copilot/request/types' +} from '@/lib/mothership/request/types' export type StreamHandler = ( event: StreamEvent, diff --git a/apps/sim/lib/copilot/request/http.ts b/apps/sim/lib/mothership/request/http.ts similarity index 97% rename from apps/sim/lib/copilot/request/http.ts rename to apps/sim/lib/mothership/request/http.ts index 1f847053d8f..0f121d4f7c9 100644 --- a/apps/sim/lib/copilot/request/http.ts +++ b/apps/sim/lib/mothership/request/http.ts @@ -4,9 +4,9 @@ import { generateId } from '@sim/utils/id' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' -import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' +import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/mothership/async-runs/lifecycle' export const NotificationStatus = { pending: 'pending', diff --git a/apps/sim/lib/copilot/request/lifecycle/finalize.ts b/apps/sim/lib/mothership/request/lifecycle/finalize.ts similarity index 91% rename from apps/sim/lib/copilot/request/lifecycle/finalize.ts rename to apps/sim/lib/mothership/request/lifecycle/finalize.ts index eca363d1226..857074d8e13 100644 --- a/apps/sim/lib/copilot/request/lifecycle/finalize.ts +++ b/apps/sim/lib/mothership/request/lifecycle/finalize.ts @@ -1,20 +1,20 @@ import { SpanStatusCode, trace } from '@opentelemetry/api' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { updateRunStatus } from '@/lib/copilot/async-runs/repository' +import { updateRunStatus } from '@/lib/mothership/async-runs/repository' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, -} from '@/lib/copilot/generated/mothership-stream-v1' +} from '@/lib/mothership/generated/mothership-stream-v1' import { type RequestTraceV1Outcome, RequestTraceV1Outcome as RequestTraceV1OutcomeConst, -} from '@/lib/copilot/generated/request-trace-v1' -import { CopilotFinalizeOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import type { StreamWriter } from '@/lib/copilot/request/session' -import type { OrchestratorResult } from '@/lib/copilot/request/types' +} from '@/lib/mothership/generated/request-trace-v1' +import { CopilotFinalizeOutcome } from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import type { StreamWriter } from '@/lib/mothership/request/session' +import type { OrchestratorResult } from '@/lib/mothership/request/types' const logger = createLogger('CopilotStreamFinalize') const getTracer = () => trace.getTracer('sim-copilot-finalize', '1.0.0') diff --git a/apps/sim/lib/copilot/request/lifecycle/headless.test.ts b/apps/sim/lib/mothership/request/lifecycle/headless.test.ts similarity index 95% rename from apps/sim/lib/copilot/request/lifecycle/headless.test.ts rename to apps/sim/lib/mothership/request/lifecycle/headless.test.ts index 42f2fbd9eb0..76fe9736ff2 100644 --- a/apps/sim/lib/copilot/request/lifecycle/headless.test.ts +++ b/apps/sim/lib/mothership/request/lifecycle/headless.test.ts @@ -6,13 +6,13 @@ import { propagation, trace } from '@opentelemetry/api' import { W3CTraceContextPropagator } from '@opentelemetry/core' import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { OrchestratorResult } from '@/lib/copilot/request/types' +import type { OrchestratorResult } from '@/lib/mothership/request/types' const { runCopilotLifecycle } = vi.hoisted(() => ({ runCopilotLifecycle: vi.fn(), })) -vi.mock('@/lib/copilot/request/lifecycle/run', () => ({ +vi.mock('@/lib/mothership/request/lifecycle/run', () => ({ runCopilotLifecycle, })) @@ -145,7 +145,7 @@ describe('runHeadlessCopilotLifecycle', () => { it('threads a valid OTel context into the lifecycle', async () => { let lifecycleTraceparent = '' runCopilotLifecycle.mockImplementationOnce(async (_payload, options) => { - const { traceHeaders } = await import('@/lib/copilot/request/go/propagation') + const { traceHeaders } = await import('@/lib/mothership/request/go/propagation') lifecycleTraceparent = traceHeaders({}, options.otelContext).traceparent ?? '' return createLifecycleResult() }) diff --git a/apps/sim/lib/copilot/request/lifecycle/headless.ts b/apps/sim/lib/mothership/request/lifecycle/headless.ts similarity index 80% rename from apps/sim/lib/copilot/request/lifecycle/headless.ts rename to apps/sim/lib/mothership/request/lifecycle/headless.ts index 6b9a878bf34..e83505c286d 100644 --- a/apps/sim/lib/copilot/request/lifecycle/headless.ts +++ b/apps/sim/lib/mothership/request/lifecycle/headless.ts @@ -1,15 +1,15 @@ import { generateId } from '@sim/utils/id' -import type { RequestTraceV1Outcome as RequestTraceOutcome } from '@/lib/copilot/generated/request-trace-v1' +import type { RequestTraceV1Outcome as RequestTraceOutcome } from '@/lib/mothership/generated/request-trace-v1' import { RequestTraceV1Outcome, RequestTraceV1SpanStatus, -} from '@/lib/copilot/generated/request-trace-v1' -import { CopilotTransport } from '@/lib/copilot/generated/trace-attribute-values-v1' -import type { CopilotLifecycleOptions } from '@/lib/copilot/request/lifecycle/run' -import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run' -import { withCopilotOtelContext } from '@/lib/copilot/request/otel' -import { TraceCollector } from '@/lib/copilot/request/trace' -import type { OrchestratorResult } from '@/lib/copilot/request/types' +} from '@/lib/mothership/generated/request-trace-v1' +import { CopilotTransport } from '@/lib/mothership/generated/trace-attribute-values-v1' +import type { CopilotLifecycleOptions } from '@/lib/mothership/request/lifecycle/run' +import { runCopilotLifecycle } from '@/lib/mothership/request/lifecycle/run' +import { withCopilotOtelContext } from '@/lib/mothership/request/otel' +import { TraceCollector } from '@/lib/mothership/request/trace' +import type { OrchestratorResult } from '@/lib/mothership/request/types' export async function runHeadlessCopilotLifecycle( requestPayload: Record, diff --git a/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts b/apps/sim/lib/mothership/request/lifecycle/resume-leg-context.test.ts similarity index 95% rename from apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts rename to apps/sim/lib/mothership/request/lifecycle/resume-leg-context.test.ts index 02fa8bb049d..22f4bee7393 100644 --- a/apps/sim/lib/copilot/request/lifecycle/resume-leg-context.test.ts +++ b/apps/sim/lib/mothership/request/lifecycle/resume-leg-context.test.ts @@ -1,19 +1,19 @@ import { describe, expect, it, vi } from 'vitest' -import { MothershipStreamV1CompletionStatus } from '@/lib/copilot/generated/mothership-stream-v1' -import { createStreamingContext } from '@/lib/copilot/request/context/request-context' +import { MothershipStreamV1CompletionStatus } from '@/lib/mothership/generated/mothership-stream-v1' +import { createStreamingContext } from '@/lib/mothership/request/context/request-context' import { createProviderToolCallIdentity, restoreProviderToolCallId, scopeProviderToolCallId, -} from '@/lib/copilot/request/go/tool-call-identity' +} from '@/lib/mothership/request/go/tool-call-identity' /** Table side effects are not exercised here, and the real module loads the table application layer. */ -vi.mock('@/lib/copilot/request/tools/tables', () => ({ +vi.mock('@/lib/mothership/request/tools/tables', () => ({ maybeWriteOutputToTable: vi.fn(async (_toolName, _params, result) => result), maybeWriteReadCsvToTable: vi.fn(async (_toolName, _params, result) => result), })) -import { makeResumeLegContext, mergeResumeLegOutputs } from '@/lib/copilot/request/lifecycle/run' +import { makeResumeLegContext, mergeResumeLegOutputs } from '@/lib/mothership/request/lifecycle/run' // Guards the makeResumeLegContext / mergeResumeLegOutputs contract: the two MUST // stay in lockstep (every per-leg-isolated scalar is reset on leg creation and diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/mothership/request/lifecycle/run.test.ts similarity index 98% rename from apps/sim/lib/copilot/request/lifecycle/run.test.ts rename to apps/sim/lib/mothership/request/lifecycle/run.test.ts index 655067607cb..34e7d490244 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/mothership/request/lifecycle/run.test.ts @@ -4,8 +4,8 @@ import { resetEnvFlagsMock, resetEnvironmentUtilsMock, setEnvFlags } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { scopeProviderToolCallId } from '@/lib/copilot/request/go/tool-call-identity' -import type { ExecutionContext, StreamingContext } from '@/lib/copilot/request/types' +import { scopeProviderToolCallId } from '@/lib/mothership/request/go/tool-call-identity' +import type { ExecutionContext, StreamingContext } from '@/lib/mothership/request/types' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' afterAll(resetEnvironmentUtilsMock) @@ -45,7 +45,7 @@ const { }, })) -vi.mock('@/lib/copilot/application/load-search-integrations', () => ({ +vi.mock('@/lib/mothership/application/load-search-integrations', () => ({ loadCopilotSearchIntegrations: mockLoadCopilotSearchIntegrations, })) @@ -54,12 +54,12 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () mockFilterModelSafeWorkspaceFileAttachments(...args), })) -vi.mock('@/lib/copilot/async-runs/repository', () => ({ +vi.mock('@/lib/mothership/async-runs/repository', () => ({ createRunSegment: mockCreateRunSegment, updateRunStatus: mockUpdateRunStatus, })) -vi.mock('@/lib/copilot/request/go/stream', () => { +vi.mock('@/lib/mothership/request/go/stream', () => { class CopilotBackendError extends Error { status?: number @@ -102,7 +102,7 @@ vi.mock('@/lib/copilot/request/go/stream', () => { } }) -vi.mock('@/lib/copilot/server/agent-url', () => ({ +vi.mock('@/lib/mothership/server/agent-url', () => ({ getMothershipBaseURL: mockGetMothershipBaseURL, getMothershipSourceEnvHeaders: mockGetMothershipSourceEnvHeaders, })) @@ -115,7 +115,7 @@ vi.mock('@/lib/core/config/env', () => ({ isFalsy: vi.fn((value: string | undefined) => value === 'false'), })) -vi.mock('@/lib/copilot/persistence/tool-permission/auto-allow', () => ({ +vi.mock('@/lib/mothership/persistence/tool-permission/auto-allow', () => ({ getAutoAllowedTools: mockGetAutoAllowedTools, addAutoAllowedTool: vi.fn(), addChatAutoAllowedTool: vi.fn(), @@ -125,19 +125,19 @@ vi.mock('@/lib/permission-groups/resolve.server', () => ({ getUserPermissionConfig: mockGetUserPermissionConfig, })) -vi.mock('@/lib/copilot/environment-context', () => ({ +vi.mock('@/lib/mothership/environment-context', () => ({ prepareCopilotEnvironmentContext: mockPrepareCopilotEnvironmentContext, })) -vi.mock('@/lib/copilot/tools/handlers/context', () => ({ +vi.mock('@/lib/mothership/tools/handlers/context', () => ({ prepareExecutionContext: mockPrepareExecutionContext, })) -vi.mock('@/lib/copilot/request/tools/billing', () => ({ +vi.mock('@/lib/mothership/request/tools/billing', () => ({ handleBillingLimitResponse: vi.fn(), })) -vi.mock('@/lib/copilot/request/tools/executor', () => ({ +vi.mock('@/lib/mothership/request/tools/executor', () => ({ executeToolAndReport: vi.fn(), forceFailHungToolCall: mockForceFailHungToolCall, pendingToolWaitBudgetMs: mockPendingToolWaitBudgetMs, @@ -146,13 +146,13 @@ vi.mock('@/lib/copilot/request/tools/executor', () => ({ import { MothershipStreamV1CompletionStatus, MothershipStreamV1ToolOutcome, -} from '@/lib/copilot/generated/mothership-stream-v1' +} from '@/lib/mothership/generated/mothership-stream-v1' import { CopilotBackendError, STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE, StreamEndedWithoutTerminalError, -} from '@/lib/copilot/request/go/stream' -import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run' +} from '@/lib/mothership/request/go/stream' +import { runCopilotLifecycle } from '@/lib/mothership/request/lifecycle/run' afterAll(resetEnvFlagsMock) @@ -539,8 +539,9 @@ describe('runCopilotLifecycle', () => { resolvedSecretTraceRegistry: registry, }) - const { enterpriseByokEligible, ...sent } = JSON.parse(capturedRequestBody) - expect(enterpriseByokEligible).toBe(false) + const sent = JSON.parse(capturedRequestBody) + // Non-enterprise workspaces attach no BYOK key; the payload crosses untouched. + expect(sent).not.toHaveProperty('byokApiKey') expect(sent).toEqual(payload) }) @@ -1001,8 +1002,8 @@ describe('runCopilotLifecycle', () => { resolvedSecretTraceRegistry: registry, }) - const { enterpriseByokEligible, ...sent } = JSON.parse(capturedRequestBody) - expect(enterpriseByokEligible).toBe(false) + const sent = JSON.parse(capturedRequestBody) + expect(sent).not.toHaveProperty('byokApiKey') expect(sent).toEqual(payload) } ) @@ -1926,7 +1927,7 @@ describe('runCopilotLifecycle', () => { ) }) - it('uses the lifecycle workspaceId for async tool resume requests', async () => { + it('sends the slim contract resume body (streamId + results only)', async () => { const requestBodies: Record[] = [] const fetchUrls: string[] = [] const executionContext: ExecutionContext = { @@ -1977,13 +1978,10 @@ describe('runCopilotLifecycle', () => { ) expect(fetchUrls[1]).toBe('http://mothership.test/api/tools/resume') - expect(requestBodies[1]).toEqual( - expect.objectContaining({ - checkpointId: 'ckpt-1', - userId: 'user-1', - workspaceId: 'ws-1', - }) - ) + expect(requestBodies[1]).toEqual({ + streamId: 'stream-1', + results: [expect.objectContaining({ callId: 'tool-1', success: true })], + }) }) it('finalizes as success when a resume fails with a retryable error then the retry succeeds', async () => { @@ -2859,7 +2857,8 @@ describe('runCopilotLifecycle', () => { ) expect(bodies).toHaveLength(1) - expect(bodies[0].checkpointId).toBe('cp-file') + // Slim contract: the resume body carries streamId + results only (no checkpointId). + expect(bodies[0].streamId).toBe('stream-missing-subagent-result') expect(bodies[0].results).toEqual([ expect.objectContaining({ callId: 'tool-never-dispatched', diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/mothership/request/lifecycle/run.ts similarity index 95% rename from apps/sim/lib/copilot/request/lifecycle/run.ts rename to apps/sim/lib/mothership/request/lifecycle/run.ts index 265f7a8eb1b..945f8adf896 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/mothership/request/lifecycle/run.ts @@ -6,6 +6,7 @@ import { interruptibleSleep, sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { isPlainRecord, omit } from '@sim/utils/object' import { workspaceSearchFiltersSchema } from '@/lib/api/contracts/knowledge/search' +import { getBYOKKey } from '@/lib/api-key/byok' import { type AttributedBillingRequestEnvelope, assertBillingAttributionSnapshot, @@ -13,49 +14,50 @@ import { createAttributedBillingRequestEnvelope, } from '@/lib/billing/core/billing-attribution' import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription' -import { loadCopilotSearchIntegrations } from '@/lib/copilot/application/load-search-integrations' -import type { AsyncCompletionSignal } from '@/lib/copilot/async-runs/lifecycle' -import { createRunSegment, updateRunStatus } from '@/lib/copilot/async-runs/repository' -import { SIM_AGENT_VERSION, TOOL_WATCHDOG_RESUME_GRACE_MS } from '@/lib/copilot/constants' +import { loadCopilotSearchIntegrations } from '@/lib/mothership/application/load-search-integrations' +import { env } from '@/lib/core/config/env' +import { isCopilotToolPermissionsEnabled, isHosted } from '@/lib/core/config/env-flags' +import type { AsyncCompletionSignal } from '@/lib/mothership/async-runs/lifecycle' +import { createRunSegment, updateRunStatus } from '@/lib/mothership/async-runs/repository' +import { SIM_AGENT_VERSION, TOOL_WATCHDOG_RESUME_GRACE_MS } from '@/lib/mothership/constants' import { type CopilotEnvironmentContext, prepareCopilotEnvironmentContext, -} from '@/lib/copilot/environment-context' +} from '@/lib/mothership/environment-context' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, MothershipStreamV1RunKind, MothershipStreamV1ToolOutcome, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { CopilotDegradedReason } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { getAutoAllowedTools } from '@/lib/copilot/persistence/tool-permission/auto-allow' -import { createStreamingContext } from '@/lib/copilot/request/context/request-context' -import { buildToolCallSummaries } from '@/lib/copilot/request/context/result' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { CopilotDegradedReason } from '@/lib/mothership/generated/trace-attribute-values-v1' +import { getAutoAllowedTools } from '@/lib/mothership/persistence/tool-permission/auto-allow' +import { createStreamingContext } from '@/lib/mothership/request/context/request-context' +import { buildToolCallSummaries } from '@/lib/mothership/request/context/result' import { BillingLimitError, CopilotBackendError, runStreamLoop, StreamEndedWithoutTerminalError, -} from '@/lib/copilot/request/go/stream' +} from '@/lib/mothership/request/go/stream' import { createProviderToolCallIdentity, restoreProviderToolCallId, -} from '@/lib/copilot/request/go/tool-call-identity' -import { recordDegraded } from '@/lib/copilot/request/metrics' -import { AbortReason } from '@/lib/copilot/request/session/abort-reason' +} from '@/lib/mothership/request/go/tool-call-identity' +import { recordDegraded } from '@/lib/mothership/request/metrics' +import { AbortReason } from '@/lib/mothership/request/session/abort-reason' import { getToolCallTerminalData, requireToolCallStateResult, setTerminalToolCallState, -} from '@/lib/copilot/request/tool-call-state' -import { handleBillingLimitResponse } from '@/lib/copilot/request/tools/billing' +} from '@/lib/mothership/request/tool-call-state' +import { handleBillingLimitResponse } from '@/lib/mothership/request/tools/billing' import { executeToolAndReport, forceFailHungToolCall, pendingToolWaitBudgetMs, -} from '@/lib/copilot/request/tools/executor' -import type { TraceCollector } from '@/lib/copilot/request/trace' -import { RequestTraceV1SpanStatus } from '@/lib/copilot/request/trace' +} from '@/lib/mothership/request/tools/executor' +import { type TraceCollector, RequestTraceV1SpanStatus } from '@/lib/mothership/request/trace' import type { ExecutionContext, OrchestratorOptions, @@ -64,12 +66,13 @@ import type { ResumeFrame, StreamEvent, StreamingContext, -} from '@/lib/copilot/request/types' -import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' -import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' -import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' -import { env } from '@/lib/core/config/env' -import { isCopilotToolPermissionsEnabled, isHosted } from '@/lib/core/config/env-flags' +} from '@/lib/mothership/request/types' +import type { SecretMountPolicy } from '@/lib/mothership/secret-mount-policy' +import { + getMothershipBaseURL, + getMothershipSourceEnvHeaders, +} from '@/lib/mothership/server/agent-url' +import { prepareExecutionContext } from '@/lib/mothership/tools/handlers/context' import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { filterModelSafeWorkspaceFileAttachments } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { appendUnavailableAttachmentNotice } from '@/lib/uploads/utils/model-input' @@ -1017,7 +1020,7 @@ async function runCheckpointLoop( // Enterprise BYOK eligibility hint: set once on the initial mothership request // so Go only attempts a BYOK lookup for entitled workspaces. This is only a // gate — Go re-confirms entitlement authoritatively before using any key. - payload = await withByokEligibilityHint(payload, route, lifecycleWorkspaceId) + payload = await withEnterpriseByokKey(payload, route, lifecycleWorkspaceId) for (;;) { context.streamComplete = false @@ -1542,32 +1545,30 @@ async function ensureHeadlessRunIdentity(input: { // Helpers /** - * Adds `enterpriseByokEligible: true` to the initial mothership payload when the - * workspace is on an enterprise plan. BYOK is mothership-only, so non-mothership - * routes (e.g. `/api/copilot`) are left untouched. Failures default to hosted. + * Resolves the enterprise BYOK key sim-side and attaches it as `byokApiKey` + * (contract field, S27): the worker builds a per-run provider instance from it and + * retains nothing. Eligibility (enterprise plan) gates resolution server-side, so a + * client can never assert its own eligibility; key rows are read fresh so revocation + * is immediate. Failures default to hosted. Mothership-only — other routes untouched. */ -async function withByokEligibilityHint( +async function withEnterpriseByokKey( payload: Record, route: string, workspaceId?: string ): Promise> { - // The eligibility hint is server-authoritative: always overwrite any - // client-supplied value with a server-derived boolean so a client can never - // assert its own eligibility. (Copilot's ValidateBYOK is the final authority, - // but the hint must never originate from the client.) BYOK is mothership-only; - // everything else gets an explicit false. - let eligible = false - if (workspaceId && route.startsWith('/api/mothership')) { - try { - eligible = await isWorkspaceOnEnterprisePlan(workspaceId) - } catch (error) { - logger.warn('Failed to resolve BYOK eligibility; defaulting to hosted', { - workspaceId, - error: toError(error).message, - }) - } + if (!workspaceId || !route.startsWith('/api/mothership')) return payload + try { + if (!(await isWorkspaceOnEnterprisePlan(workspaceId))) return payload + const byok = await getBYOKKey(workspaceId, 'anthropic') + if (!byok) return payload + return { ...payload, byokApiKey: byok.apiKey } + } catch (error) { + logger.warn('Failed to resolve BYOK key; defaulting to hosted', { + workspaceId, + error: toError(error).message, + }) + return payload } - return { ...payload, enterpriseByokEligible: eligible } } function isAborted(options: CopilotLifecycleOptions, context: StreamingContext): boolean { diff --git a/apps/sim/lib/copilot/request/lifecycle/start.test.ts b/apps/sim/lib/mothership/request/lifecycle/start.test.ts similarity index 96% rename from apps/sim/lib/copilot/request/lifecycle/start.test.ts rename to apps/sim/lib/mothership/request/lifecycle/start.test.ts index e02f5c2d8ce..3f498ee95f6 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.test.ts +++ b/apps/sim/lib/mothership/request/lifecycle/start.test.ts @@ -10,7 +10,7 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vites import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, -} from '@/lib/copilot/generated/mothership-stream-v1' +} from '@/lib/mothership/generated/mothership-stream-v1' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const { @@ -60,18 +60,18 @@ const BILLING_ATTRIBUTION = { payerSubscription: null, } -vi.mock('@/lib/copilot/request/lifecycle/run', () => ({ +vi.mock('@/lib/mothership/request/lifecycle/run', () => ({ runCopilotLifecycle, })) -vi.mock('@/lib/copilot/async-runs/repository', () => ({ +vi.mock('@/lib/mothership/async-runs/repository', () => ({ createRunSegment, updateRunStatus, })) let mockPublisherController: ReadableStreamDefaultController | null = null -vi.mock('@/lib/copilot/request/session', () => ({ +vi.mock('@/lib/mothership/request/session', () => ({ resetBuffer, clearFilePreviewSessions, scheduleBufferCleanup, @@ -114,19 +114,19 @@ vi.mock('@/lib/copilot/request/session', () => ({ } ), })) -vi.mock('@/lib/copilot/request/session/sse', () => ({ +vi.mock('@/lib/mothership/request/session/sse', () => ({ SSE_RESPONSE_HEADERS: {}, })) -vi.mock('@/lib/copilot/chat-status', () => ({ +vi.mock('@/lib/mothership/chat-status', () => ({ publishChatStatusChanged: vi.fn(), })) -vi.mock('@/lib/copilot/request/go/fetch', () => ({ +vi.mock('@/lib/mothership/request/go/fetch', () => ({ fetchGo, })) -vi.mock('@/lib/copilot/server/agent-url', () => ({ +vi.mock('@/lib/mothership/server/agent-url', () => ({ getMothershipBaseURL: vi.fn().mockResolvedValue('https://copilot.test'), getMothershipSourceEnvHeaders: vi.fn().mockReturnValue({}), })) @@ -296,7 +296,7 @@ describe('createSSEStream terminal error handling', () => { it('passes an OTel context into the streaming lifecycle', async () => { let lifecycleTraceparent = '' runCopilotLifecycle.mockImplementation(async (_payload, options) => { - const { traceHeaders } = await import('@/lib/copilot/request/go/propagation') + const { traceHeaders } = await import('@/lib/mothership/request/go/propagation') lifecycleTraceparent = traceHeaders({}, options.otelContext).traceparent ?? '' return { success: true, diff --git a/apps/sim/lib/copilot/request/lifecycle/start.ts b/apps/sim/lib/mothership/request/lifecycle/start.ts similarity index 95% rename from apps/sim/lib/copilot/request/lifecycle/start.ts rename to apps/sim/lib/mothership/request/lifecycle/start.ts index 43ccf62da93..9034f09b459 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.ts +++ b/apps/sim/lib/mothership/request/lifecycle/start.ts @@ -11,27 +11,31 @@ import { resolveBillingAttribution, resolveOrganizationBillingAttribution, } from '@/lib/billing/core/billing-attribution' -import { createRunSegment } from '@/lib/copilot/async-runs/repository' -import { publishChatStatusChanged } from '@/lib/copilot/chat-status' +import { env } from '@/lib/core/config/env' +import { isHosted } from '@/lib/core/config/env-flags' +import { createRunSegment } from '@/lib/mothership/async-runs/repository' +import { publishChatStatusChanged } from '@/lib/mothership/chat-status' import { MothershipStreamV1EventType, MothershipStreamV1SessionKind, -} from '@/lib/copilot/generated/mothership-stream-v1' +} from '@/lib/mothership/generated/mothership-stream-v1' import { RequestTraceV1Outcome, RequestTraceV1SpanStatus, -} from '@/lib/copilot/generated/request-trace-v1' +} from '@/lib/mothership/generated/request-trace-v1' import { CopilotRequestCancelReason, type CopilotRequestCancelReasonValue, CopilotTransport, -} from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' -import { finalizeStream } from '@/lib/copilot/request/lifecycle/finalize' -import type { CopilotLifecycleOptions } from '@/lib/copilot/request/lifecycle/run' -import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run' -import { type CopilotLifecycleOutcome, startCopilotOtelRoot } from '@/lib/copilot/request/otel' +} from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceEvent } from '@/lib/mothership/generated/trace-events-v1' +import { finalizeStream } from '@/lib/mothership/request/lifecycle/finalize' +import { + type CopilotLifecycleOptions, + runCopilotLifecycle, +} from '@/lib/mothership/request/lifecycle/run' +import { type CopilotLifecycleOutcome, startCopilotOtelRoot } from '@/lib/mothership/request/otel' import { cleanupAbortMarker, clearFilePreviewSessions, @@ -44,12 +48,13 @@ import { scheduleFilePreviewSessionCleanup, startAbortPoller, unregisterActiveStream, -} from '@/lib/copilot/request/session' -import { SSE_RESPONSE_HEADERS } from '@/lib/copilot/request/session/sse' -import { TraceCollector } from '@/lib/copilot/request/trace' -import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' -import { env } from '@/lib/core/config/env' -import { isHosted } from '@/lib/core/config/env-flags' +} from '@/lib/mothership/request/session' +import { SSE_RESPONSE_HEADERS } from '@/lib/mothership/request/session/sse' +import { TraceCollector } from '@/lib/mothership/request/trace' +import { + getMothershipBaseURL, + getMothershipSourceEnvHeaders, +} from '@/lib/mothership/server/agent-url' export { SSE_RESPONSE_HEADERS } @@ -579,7 +584,7 @@ export async function requestChatTitle(params: { Object.assign(headers, billingRequest.headers) } - const { fetchGo } = await import('@/lib/copilot/request/go/fetch') + const { fetchGo } = await import('@/lib/mothership/request/go/fetch') const mothershipBaseURL = await getMothershipBaseURL({ userId }) const response = await fetchGo(`${mothershipBaseURL}/api/generate-chat-title`, { method: 'POST', diff --git a/apps/sim/lib/copilot/request/metrics.test.ts b/apps/sim/lib/mothership/request/metrics.test.ts similarity index 95% rename from apps/sim/lib/copilot/request/metrics.test.ts rename to apps/sim/lib/mothership/request/metrics.test.ts index 77e00075061..a8faca3a8e8 100644 --- a/apps/sim/lib/copilot/request/metrics.test.ts +++ b/apps/sim/lib/mothership/request/metrics.test.ts @@ -20,8 +20,8 @@ vi.mock('@opentelemetry/api', () => ({ }, })) -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { normalizeToolAgentId, recordSimToolMetric } from '@/lib/copilot/request/metrics' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { normalizeToolAgentId, recordSimToolMetric } from '@/lib/mothership/request/metrics' describe('recordSimToolMetric', () => { beforeEach(() => { diff --git a/apps/sim/lib/copilot/request/metrics.ts b/apps/sim/lib/mothership/request/metrics.ts similarity index 94% rename from apps/sim/lib/copilot/request/metrics.ts rename to apps/sim/lib/mothership/request/metrics.ts index 7ad1ed582ae..e7d7547fe51 100644 --- a/apps/sim/lib/copilot/request/metrics.ts +++ b/apps/sim/lib/mothership/request/metrics.ts @@ -9,10 +9,10 @@ // shared catalogs (else "other"); vfs phase / file-read outcome are bounded // sets. NEVER a user/chat/request id (those explode Prometheus series). import { type Counter, type Histogram, metrics } from '@opentelemetry/api' -import { Metric } from '@/lib/copilot/generated/metrics-v1' -import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1' -import type { CopilotDegradedReasonValue } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' +import { Metric } from '@/lib/mothership/generated/metrics-v1' +import { TOOL_CATALOG } from '@/lib/mothership/generated/tool-catalog-v1' +import type { CopilotDegradedReasonValue } from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' // MUST match Go's copilot/internal/telemetry/metrics.go LatencyBucketsMs // exactly — a histogram_quantile(sum by (le) …) over the Go∪Sim union is only diff --git a/apps/sim/lib/copilot/request/otel.ts b/apps/sim/lib/mothership/request/otel.ts similarity index 97% rename from apps/sim/lib/copilot/request/otel.ts rename to apps/sim/lib/mothership/request/otel.ts index 1ee3e312949..f2a94f03fb2 100644 --- a/apps/sim/lib/copilot/request/otel.ts +++ b/apps/sim/lib/mothership/request/otel.ts @@ -12,19 +12,19 @@ import { } from '@opentelemetry/api' import { setRequestTraceId } from '@sim/logger' import { describeError, toError } from '@sim/utils/errors' -import { RequestTraceV1Outcome } from '@/lib/copilot/generated/request-trace-v1' +import { RequestTraceV1Outcome } from '@/lib/mothership/generated/request-trace-v1' import { CopilotBranchKind, CopilotRequestCancelReason, type CopilotRequestCancelReasonValue, CopilotSurface, CopilotTransport, -} from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { contextFromRequestHeaders } from '@/lib/copilot/request/go/propagation' -import { normalizeToolAgentId } from '@/lib/copilot/request/metrics' -import { isExplicitStopReason } from '@/lib/copilot/request/session/abort-reason' +} from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { contextFromRequestHeaders } from '@/lib/mothership/request/go/propagation' +import { normalizeToolAgentId } from '@/lib/mothership/request/metrics' +import { isExplicitStopReason } from '@/lib/mothership/request/session/abort-reason' // OTel GenAI content-capture env var (spec: // https://opentelemetry.io/docs/specs/semconv/gen-ai/). Mirrored on diff --git a/apps/sim/lib/copilot/request/session/abort-reason.ts b/apps/sim/lib/mothership/request/session/abort-reason.ts similarity index 100% rename from apps/sim/lib/copilot/request/session/abort-reason.ts rename to apps/sim/lib/mothership/request/session/abort-reason.ts diff --git a/apps/sim/lib/copilot/request/session/abort.test.ts b/apps/sim/lib/mothership/request/session/abort.test.ts similarity index 98% rename from apps/sim/lib/copilot/request/session/abort.test.ts rename to apps/sim/lib/mothership/request/session/abort.test.ts index 2404b12dc5c..9ddf771ffc4 100644 --- a/apps/sim/lib/copilot/request/session/abort.test.ts +++ b/apps/sim/lib/mothership/request/session/abort.test.ts @@ -11,12 +11,12 @@ const { mockHasAbortMarker, mockClearAbortMarker, mockWriteAbortMarker } = vi.ho mockWriteAbortMarker: vi.fn().mockResolvedValue(undefined), })) -vi.mock('@/lib/copilot/request/session/buffer', () => ({ +vi.mock('@/lib/mothership/request/session/buffer', () => ({ hasAbortMarker: mockHasAbortMarker, clearAbortMarker: mockClearAbortMarker, writeAbortMarker: mockWriteAbortMarker, })) -vi.mock('@/lib/copilot/request/otel', () => ({ +vi.mock('@/lib/mothership/request/otel', () => ({ withCopilotSpan: (_span: unknown, _attrs: unknown, fn: (span: unknown) => unknown) => fn({ setAttribute: vi.fn() }), })) @@ -26,7 +26,7 @@ import { getChatStreamLockOwners, releasePendingChatStream, startAbortPoller, -} from '@/lib/copilot/request/session/abort' +} from '@/lib/mothership/request/session/abort' describe('startAbortPoller heartbeat', () => { beforeEach(() => { diff --git a/apps/sim/lib/copilot/request/session/abort.ts b/apps/sim/lib/mothership/request/session/abort.ts similarity index 97% rename from apps/sim/lib/copilot/request/session/abort.ts rename to apps/sim/lib/mothership/request/session/abort.ts index b081044f8eb..4092728d190 100644 --- a/apps/sim/lib/copilot/request/session/abort.ts +++ b/apps/sim/lib/mothership/request/session/abort.ts @@ -1,11 +1,11 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' -import { AbortBackend } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { withCopilotSpan } from '@/lib/copilot/request/otel' import { acquireLock, extendLock, getRedisClient, releaseLock } from '@/lib/core/config/redis' +import { AbortBackend } from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { withCopilotSpan } from '@/lib/mothership/request/otel' import { AbortReason } from './abort-reason' import { clearAbortMarker, hasAbortMarker, writeAbortMarker } from './buffer' diff --git a/apps/sim/lib/copilot/request/session/buffer.test.ts b/apps/sim/lib/mothership/request/session/buffer.test.ts similarity index 98% rename from apps/sim/lib/copilot/request/session/buffer.test.ts rename to apps/sim/lib/mothership/request/session/buffer.test.ts index a0807556995..f3d3bcf6d93 100644 --- a/apps/sim/lib/copilot/request/session/buffer.test.ts +++ b/apps/sim/lib/mothership/request/session/buffer.test.ts @@ -7,8 +7,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { MothershipStreamV1EventType, MothershipStreamV1TextChannel, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { createEvent } from '@/lib/copilot/request/session/event' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { createEvent } from '@/lib/mothership/request/session/event' import { getRedisBudgetLimits } from '@/lib/core/redis/byte-budget.server' type StoredEnvelope = { @@ -136,7 +136,7 @@ import { clearBuffer, readEvents, scheduleBufferCleanup, -} from '@/lib/copilot/request/session/buffer' +} from '@/lib/mothership/request/session/buffer' async function makeEnvelope(text: string) { const cursor = await allocateCursor('stream-1') diff --git a/apps/sim/lib/copilot/request/session/buffer.ts b/apps/sim/lib/mothership/request/session/buffer.ts similarity index 100% rename from apps/sim/lib/copilot/request/session/buffer.ts rename to apps/sim/lib/mothership/request/session/buffer.ts diff --git a/apps/sim/lib/copilot/request/session/contract.test.ts b/apps/sim/lib/mothership/request/session/contract.test.ts similarity index 100% rename from apps/sim/lib/copilot/request/session/contract.test.ts rename to apps/sim/lib/mothership/request/session/contract.test.ts diff --git a/apps/sim/lib/copilot/request/session/contract.ts b/apps/sim/lib/mothership/request/session/contract.ts similarity index 98% rename from apps/sim/lib/copilot/request/session/contract.ts rename to apps/sim/lib/mothership/request/session/contract.ts index a0a4fc6474e..5521abb0ce7 100644 --- a/apps/sim/lib/copilot/request/session/contract.ts +++ b/apps/sim/lib/mothership/request/session/contract.ts @@ -5,7 +5,7 @@ import type { MothershipStreamV1StreamRef, MothershipStreamV1StreamScope, MothershipStreamV1Trace, -} from '@/lib/copilot/generated/mothership-stream-v1' +} from '@/lib/mothership/generated/mothership-stream-v1' import { MothershipStreamV1EventType, MothershipStreamV1ResourceOp, @@ -14,8 +14,8 @@ import { MothershipStreamV1SpanPayloadKind, MothershipStreamV1TextChannel, MothershipStreamV1ToolPhase, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { hasAddressableId } from '@/lib/copilot/resources/types' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { hasAddressableId } from '@/lib/mothership/resources/types' import type { FilePreviewTargetKind } from './file-preview-session-contract' type JsonRecord = Record diff --git a/apps/sim/lib/copilot/request/session/event.test.ts b/apps/sim/lib/mothership/request/session/event.test.ts similarity index 87% rename from apps/sim/lib/copilot/request/session/event.test.ts rename to apps/sim/lib/mothership/request/session/event.test.ts index 29d86146c1a..53caccf4fc2 100644 --- a/apps/sim/lib/copilot/request/session/event.test.ts +++ b/apps/sim/lib/mothership/request/session/event.test.ts @@ -6,9 +6,9 @@ import { describe, expect, it } from 'vitest' import { MothershipStreamV1EventType, MothershipStreamV1TextChannel, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { parsePersistedStreamEventEnvelope } from '@/lib/copilot/request/session' -import { createEvent, eventToStreamEvent } from '@/lib/copilot/request/session/event' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { parsePersistedStreamEventEnvelope } from '@/lib/mothership/request/session' +import { createEvent, eventToStreamEvent } from '@/lib/mothership/request/session/event' describe('createEvent', () => { it('creates contract envelopes that pass validation', () => { diff --git a/apps/sim/lib/copilot/request/session/event.ts b/apps/sim/lib/mothership/request/session/event.ts similarity index 100% rename from apps/sim/lib/copilot/request/session/event.ts rename to apps/sim/lib/mothership/request/session/event.ts diff --git a/apps/sim/lib/copilot/request/session/explicit-abort.test.ts b/apps/sim/lib/mothership/request/session/explicit-abort.test.ts similarity index 85% rename from apps/sim/lib/copilot/request/session/explicit-abort.test.ts rename to apps/sim/lib/mothership/request/session/explicit-abort.test.ts index 5cfcd9efadf..0f5fb9cad20 100644 --- a/apps/sim/lib/copilot/request/session/explicit-abort.test.ts +++ b/apps/sim/lib/mothership/request/session/explicit-abort.test.ts @@ -14,16 +14,16 @@ const { mockFetchGo } = vi.hoisted(() => ({ mockFetchGo: vi.fn(), })) -vi.mock('@/lib/copilot/request/go/fetch', () => ({ +vi.mock('@/lib/mothership/request/go/fetch', () => ({ fetchGo: mockFetchGo, })) -vi.mock('@/lib/copilot/server/agent-url', () => ({ +vi.mock('@/lib/mothership/server/agent-url', () => ({ getMothershipBaseURL: vi.fn().mockResolvedValue('https://copilot.test'), getMothershipSourceEnvHeaders: vi.fn().mockReturnValue({ 'X-Sim-Source-Env': 'test' }), })) -import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explicit-abort' +import { requestExplicitStreamAbort } from '@/lib/mothership/request/session/explicit-abort' describe('requestExplicitStreamAbort', () => { beforeEach(() => { diff --git a/apps/sim/lib/copilot/request/session/explicit-abort.ts b/apps/sim/lib/mothership/request/session/explicit-abort.ts similarity index 86% rename from apps/sim/lib/copilot/request/session/explicit-abort.ts rename to apps/sim/lib/mothership/request/session/explicit-abort.ts index 8123538aae7..4fc4a6f2335 100644 --- a/apps/sim/lib/copilot/request/session/explicit-abort.ts +++ b/apps/sim/lib/mothership/request/session/explicit-abort.ts @@ -3,11 +3,14 @@ import { COPILOT_BILLING_PROTOCOL, COPILOT_BILLING_PROTOCOL_HEADER, } from '@/lib/billing/core/billing-attribution' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { fetchGo } from '@/lib/copilot/request/go/fetch' -import { AbortReason } from '@/lib/copilot/request/session/abort' -import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { env } from '@/lib/core/config/env' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { fetchGo } from '@/lib/mothership/request/go/fetch' +import { AbortReason } from '@/lib/mothership/request/session/abort' +import { + getMothershipBaseURL, + getMothershipSourceEnvHeaders, +} from '@/lib/mothership/server/agent-url' export const DEFAULT_EXPLICIT_ABORT_TIMEOUT_MS = 3000 diff --git a/apps/sim/lib/copilot/request/session/file-preview-session-contract.ts b/apps/sim/lib/mothership/request/session/file-preview-session-contract.ts similarity index 100% rename from apps/sim/lib/copilot/request/session/file-preview-session-contract.ts rename to apps/sim/lib/mothership/request/session/file-preview-session-contract.ts diff --git a/apps/sim/lib/copilot/request/session/file-preview-session.test.ts b/apps/sim/lib/mothership/request/session/file-preview-session.test.ts similarity index 94% rename from apps/sim/lib/copilot/request/session/file-preview-session.test.ts rename to apps/sim/lib/mothership/request/session/file-preview-session.test.ts index c994dae0b6e..c8d974c2174 100644 --- a/apps/sim/lib/copilot/request/session/file-preview-session.test.ts +++ b/apps/sim/lib/mothership/request/session/file-preview-session.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' import { createFilePreviewSession, sortFilePreviewSessions, -} from '@/lib/copilot/request/session/file-preview-session' +} from '@/lib/mothership/request/session/file-preview-session' describe('file preview session helpers', () => { it('preserves baseContent when creating a preview session', () => { diff --git a/apps/sim/lib/copilot/request/session/file-preview-session.ts b/apps/sim/lib/mothership/request/session/file-preview-session.ts similarity index 100% rename from apps/sim/lib/copilot/request/session/file-preview-session.ts rename to apps/sim/lib/mothership/request/session/file-preview-session.ts diff --git a/apps/sim/lib/copilot/request/session/index.ts b/apps/sim/lib/mothership/request/session/index.ts similarity index 100% rename from apps/sim/lib/copilot/request/session/index.ts rename to apps/sim/lib/mothership/request/session/index.ts diff --git a/apps/sim/lib/copilot/request/session/recovery.test.ts b/apps/sim/lib/mothership/request/session/recovery.test.ts similarity index 100% rename from apps/sim/lib/copilot/request/session/recovery.test.ts rename to apps/sim/lib/mothership/request/session/recovery.test.ts diff --git a/apps/sim/lib/copilot/request/session/recovery.ts b/apps/sim/lib/mothership/request/session/recovery.ts similarity index 91% rename from apps/sim/lib/copilot/request/session/recovery.ts rename to apps/sim/lib/mothership/request/session/recovery.ts index ab7e2e236de..c4a74a143f0 100644 --- a/apps/sim/lib/copilot/request/session/recovery.ts +++ b/apps/sim/lib/mothership/request/session/recovery.ts @@ -3,11 +3,11 @@ import { getErrorMessage } from '@sim/utils/errors' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { CopilotRecoveryOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { withCopilotSpan } from '@/lib/copilot/request/otel' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { CopilotRecoveryOutcome } from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { withCopilotSpan } from '@/lib/mothership/request/otel' import { getLatestSeq, getOldestSeq, readEvents } from './buffer' import { createEvent } from './event' diff --git a/apps/sim/lib/copilot/request/session/sse.ts b/apps/sim/lib/mothership/request/session/sse.ts similarity index 100% rename from apps/sim/lib/copilot/request/session/sse.ts rename to apps/sim/lib/mothership/request/session/sse.ts diff --git a/apps/sim/lib/copilot/request/session/types.ts b/apps/sim/lib/mothership/request/session/types.ts similarity index 100% rename from apps/sim/lib/copilot/request/session/types.ts rename to apps/sim/lib/mothership/request/session/types.ts diff --git a/apps/sim/lib/copilot/request/session/writer.test.ts b/apps/sim/lib/mothership/request/session/writer.test.ts similarity index 97% rename from apps/sim/lib/copilot/request/session/writer.test.ts rename to apps/sim/lib/mothership/request/session/writer.test.ts index 62a594988d7..3d6ff89b620 100644 --- a/apps/sim/lib/copilot/request/session/writer.test.ts +++ b/apps/sim/lib/mothership/request/session/writer.test.ts @@ -6,18 +6,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { MothershipStreamV1EventType, MothershipStreamV1TextChannel, -} from '@/lib/copilot/generated/mothership-stream-v1' -import type { StreamEvent } from '@/lib/copilot/request/session' +} from '@/lib/mothership/generated/mothership-stream-v1' +import type { StreamEvent } from '@/lib/mothership/request/session' const { appendEvents } = vi.hoisted(() => ({ appendEvents: vi.fn(), })) -vi.mock('@/lib/copilot/request/session/buffer', () => ({ +vi.mock('@/lib/mothership/request/session/buffer', () => ({ appendEvents, })) -import { StreamWriter } from '@/lib/copilot/request/session/writer' +import { StreamWriter } from '@/lib/mothership/request/session/writer' function decodeChunk(value: Uint8Array): string { return new TextDecoder().decode(value) diff --git a/apps/sim/lib/copilot/request/session/writer.ts b/apps/sim/lib/mothership/request/session/writer.ts similarity index 98% rename from apps/sim/lib/copilot/request/session/writer.ts rename to apps/sim/lib/mothership/request/session/writer.ts index a56502066cc..7c431d35de8 100644 --- a/apps/sim/lib/copilot/request/session/writer.ts +++ b/apps/sim/lib/mothership/request/session/writer.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1' +import { MothershipStreamV1EventType } from '@/lib/mothership/generated/mothership-stream-v1' import { encodeSSEComment } from '@/lib/core/utils/sse' import { appendEvents } from './buffer' import type { PersistedStreamEventEnvelope } from './contract' diff --git a/apps/sim/lib/copilot/request/sse-utils.test.ts b/apps/sim/lib/mothership/request/sse-utils.test.ts similarity index 92% rename from apps/sim/lib/copilot/request/sse-utils.test.ts rename to apps/sim/lib/mothership/request/sse-utils.test.ts index d8da8edc0c2..d451b3d6a2b 100644 --- a/apps/sim/lib/copilot/request/sse-utils.test.ts +++ b/apps/sim/lib/mothership/request/sse-utils.test.ts @@ -4,9 +4,9 @@ import { MothershipStreamV1ToolExecutor, MothershipStreamV1ToolMode, MothershipStreamV1ToolPhase, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { TOOL_CALL_STATUS } from '@/lib/copilot/request/session' -import type { StreamEvent } from '@/lib/copilot/request/types' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { TOOL_CALL_STATUS } from '@/lib/mothership/request/session' +import type { StreamEvent } from '@/lib/mothership/request/types' import { shouldSkipToolCallEvent } from './sse-utils' describe('shouldSkipToolCallEvent', () => { diff --git a/apps/sim/lib/copilot/request/sse-utils.ts b/apps/sim/lib/mothership/request/sse-utils.ts similarity index 91% rename from apps/sim/lib/copilot/request/sse-utils.ts rename to apps/sim/lib/mothership/request/sse-utils.ts index db7f8623d86..c99714fbd34 100644 --- a/apps/sim/lib/copilot/request/sse-utils.ts +++ b/apps/sim/lib/mothership/request/sse-utils.ts @@ -1,12 +1,12 @@ -import { STREAM_BUFFER_MAX_DEDUP_ENTRIES } from '@/lib/copilot/constants' +import { STREAM_BUFFER_MAX_DEDUP_ENTRIES } from '@/lib/mothership/constants' import { isToolCallStreamEvent, isToolResultStreamEvent, type ToolCallStreamEvent, type ToolResultStreamEvent, -} from '@/lib/copilot/request/session' -import { TOOL_CALL_STATUS } from '@/lib/copilot/request/session/event' -import type { StreamEvent } from '@/lib/copilot/request/types' +} from '@/lib/mothership/request/session' +import { TOOL_CALL_STATUS } from '@/lib/mothership/request/session/event' +import type { StreamEvent } from '@/lib/mothership/request/types' /** * In-memory tool event dedupe with bounded size. diff --git a/apps/sim/lib/copilot/request/tool-call-state.test.ts b/apps/sim/lib/mothership/request/tool-call-state.test.ts similarity index 93% rename from apps/sim/lib/copilot/request/tool-call-state.test.ts rename to apps/sim/lib/mothership/request/tool-call-state.test.ts index 8e8f902c7e7..063a3af094a 100644 --- a/apps/sim/lib/copilot/request/tool-call-state.test.ts +++ b/apps/sim/lib/mothership/request/tool-call-state.test.ts @@ -3,8 +3,8 @@ */ import { describe, expect, it } from 'vitest' -import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' -import type { ToolCallState } from '@/lib/copilot/request/types' +import { MothershipStreamV1ToolOutcome } from '@/lib/mothership/generated/mothership-stream-v1' +import type { ToolCallState } from '@/lib/mothership/request/types' import { getToolCallTerminalData } from './tool-call-state' describe('getToolCallTerminalData', () => { diff --git a/apps/sim/lib/copilot/request/tool-call-state.ts b/apps/sim/lib/mothership/request/tool-call-state.ts similarity index 94% rename from apps/sim/lib/copilot/request/tool-call-state.ts rename to apps/sim/lib/mothership/request/tool-call-state.ts index d0c636e502b..31b30baa4ff 100644 --- a/apps/sim/lib/copilot/request/tool-call-state.ts +++ b/apps/sim/lib/mothership/request/tool-call-state.ts @@ -1,10 +1,10 @@ import { isRecordLike } from '@sim/utils/object' -import { toolResultForModel } from '@/lib/copilot/chat/sim-key-redaction' +import { toolResultForModel } from '@/lib/mothership/chat/sim-key-redaction' import { MothershipStreamV1ToolOutcome, type MothershipStreamV1ToolOutcome as TerminalToolCallStatus, -} from '@/lib/copilot/generated/mothership-stream-v1' -import type { ToolCallState, ToolCallStateResult } from '@/lib/copilot/request/types' +} from '@/lib/mothership/generated/mothership-stream-v1' +import type { ToolCallState, ToolCallStateResult } from '@/lib/mothership/request/types' function hasOwnOutput(value: { output?: unknown }): value is { output: unknown } { return Object.hasOwn(value, 'output') diff --git a/apps/sim/lib/copilot/request/tools/billing.test.ts b/apps/sim/lib/mothership/request/tools/billing.test.ts similarity index 95% rename from apps/sim/lib/copilot/request/tools/billing.test.ts rename to apps/sim/lib/mothership/request/tools/billing.test.ts index 98d72cb1b98..a6ad197e8e7 100644 --- a/apps/sim/lib/copilot/request/tools/billing.test.ts +++ b/apps/sim/lib/mothership/request/tools/billing.test.ts @@ -6,7 +6,7 @@ import type { ExecutionContext, OrchestratorOptions, StreamingContext, -} from '@/lib/copilot/request/types' +} from '@/lib/mothership/request/types' const { mockGetHighestPrioritySubscription } = vi.hoisted(() => ({ mockGetHighestPrioritySubscription: vi.fn(), @@ -16,11 +16,11 @@ vi.mock('@/lib/billing/core/plan', () => ({ getHighestPrioritySubscription: mockGetHighestPrioritySubscription, })) -vi.mock('@/lib/copilot/request/handlers', () => ({ +vi.mock('@/lib/mothership/request/handlers', () => ({ sseHandlers: {}, })) -import { handleBillingLimitResponse } from '@/lib/copilot/request/tools/billing' +import { handleBillingLimitResponse } from '@/lib/mothership/request/tools/billing' const context = { streamComplete: false } as StreamingContext diff --git a/apps/sim/lib/copilot/request/tools/billing.ts b/apps/sim/lib/mothership/request/tools/billing.ts similarity index 95% rename from apps/sim/lib/copilot/request/tools/billing.ts rename to apps/sim/lib/mothership/request/tools/billing.ts index 586f662fd8c..b2f705b36d3 100644 --- a/apps/sim/lib/copilot/request/tools/billing.ts +++ b/apps/sim/lib/mothership/request/tools/billing.ts @@ -6,14 +6,14 @@ import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, MothershipStreamV1TextChannel, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { sseHandlers } from '@/lib/copilot/request/handlers' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { sseHandlers } from '@/lib/mothership/request/handlers' import type { ExecutionContext, OrchestratorOptions, StreamEvent, StreamingContext, -} from '@/lib/copilot/request/types' +} from '@/lib/mothership/request/types' const logger = createLogger('CopilotBillingEffect') diff --git a/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts b/apps/sim/lib/mothership/request/tools/client-completion-seal.server.ts similarity index 98% rename from apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts rename to apps/sim/lib/mothership/request/tools/client-completion-seal.server.ts index b9d674fc173..13416002311 100644 --- a/apps/sim/lib/copilot/request/tools/client-completion-seal.server.ts +++ b/apps/sim/lib/mothership/request/tools/client-completion-seal.server.ts @@ -1,7 +1,7 @@ import { generateId } from '@sim/utils/id' import { isPlainRecord } from '@sim/utils/object' -import type { AsyncCompletionData } from '@/lib/copilot/async-runs/lifecycle' import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption' +import type { AsyncCompletionData } from '@/lib/mothership/async-runs/lifecycle' import { isResolvedSecretTraceProvenanceV1, type ResolvedSecretTraceProvenanceV1, diff --git a/apps/sim/lib/copilot/request/tools/client.test.ts b/apps/sim/lib/mothership/request/tools/client.test.ts similarity index 98% rename from apps/sim/lib/copilot/request/tools/client.test.ts rename to apps/sim/lib/mothership/request/tools/client.test.ts index 26a0e82a60b..7fc2ea72e16 100644 --- a/apps/sim/lib/copilot/request/tools/client.test.ts +++ b/apps/sim/lib/mothership/request/tools/client.test.ts @@ -29,11 +29,11 @@ vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret, })) -vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({ +vi.mock('@/lib/mothership/persistence/tool-confirm', () => ({ waitForToolConfirmation, })) -vi.mock('@/lib/copilot/async-runs/repository', () => ({ +vi.mock('@/lib/mothership/async-runs/repository', () => ({ replaceTerminalAsyncToolCallResult, })) @@ -44,9 +44,9 @@ vi.mock('@/lib/workflows/executor/execution-state', () => ({ import { waitForClientToolCompletion, waitForWorkflowToolCompletion, -} from '@/lib/copilot/request/tools/client' -import { sealClientToolContext } from '@/lib/copilot/request/tools/client-completion-seal.server' -import { TOOL_RESULT_UNAVAILABLE_ERROR } from '@/lib/copilot/request/tools/resolved-secret-result' +} from '@/lib/mothership/request/tools/client' +import { sealClientToolContext } from '@/lib/mothership/request/tools/client-completion-seal.server' +import { TOOL_RESULT_UNAVAILABLE_ERROR } from '@/lib/mothership/request/tools/resolved-secret-result' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const TRACE_SCOPE = { userId: 'user-1', workspaceId: 'workspace-1' } diff --git a/apps/sim/lib/copilot/request/tools/client.ts b/apps/sim/lib/mothership/request/tools/client.ts similarity index 96% rename from apps/sim/lib/copilot/request/tools/client.ts rename to apps/sim/lib/mothership/request/tools/client.ts index 4765bc6c84a..e1dc6461902 100644 --- a/apps/sim/lib/copilot/request/tools/client.ts +++ b/apps/sim/lib/mothership/request/tools/client.ts @@ -5,16 +5,16 @@ import { ASYNC_TOOL_CONFIRMATION_STATUS, type AsyncTerminalCompletionSnapshot, isAsyncTerminalConfirmationStatus, -} from '@/lib/copilot/async-runs/lifecycle' -import { replaceTerminalAsyncToolCallResult } from '@/lib/copilot/async-runs/repository' -import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' -import { waitForToolConfirmation } from '@/lib/copilot/persistence/tool-confirm' +} from '@/lib/mothership/async-runs/lifecycle' +import { replaceTerminalAsyncToolCallResult } from '@/lib/mothership/async-runs/repository' +import { MothershipStreamV1ToolOutcome } from '@/lib/mothership/generated/mothership-stream-v1' +import { waitForToolConfirmation } from '@/lib/mothership/persistence/tool-confirm' import { type ClientToolUnsealFailureReason, unsealClientToolCompletion, unsealClientToolContext, -} from '@/lib/copilot/request/tools/client-completion-seal.server' -import { inspectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' +} from '@/lib/mothership/request/tools/client-completion-seal.server' +import { inspectToolResultForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result' import { type AsyncWorkflowDeploymentError, createStructuralWorkflowToolCompletionData, @@ -22,7 +22,7 @@ import { getWorkflowToolCompletionExecutionId, getWorkflowToolCompletionMessage, getWorkflowToolConfirmationStatus, -} from '@/lib/copilot/tools/workflow-tools' +} from '@/lib/mothership/tools/workflow-tools' import { getTrustedWorkflowToolExecution } from '@/lib/workflows/executor/execution-state' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/mothership/request/tools/executor.test.ts similarity index 95% rename from apps/sim/lib/copilot/request/tools/executor.test.ts rename to apps/sim/lib/mothership/request/tools/executor.test.ts index 8b5415a44b8..5bf3d324668 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/mothership/request/tools/executor.test.ts @@ -53,80 +53,80 @@ vi.mock('@/lib/workflows/executor/execution-state', () => ({ getTrustedWorkflowToolExecution: vi.fn(), })) -vi.mock('@/lib/copilot/tool-executor', () => ({ +vi.mock('@/lib/mothership/tool-executor', () => ({ ensureHandlersRegistered: vi.fn(), executeTool, })) -vi.mock('@/lib/copilot/async-runs/repository', () => ({ +vi.mock('@/lib/mothership/async-runs/repository', () => ({ completeAsyncToolCall, markAsyncToolRunning, upsertAsyncToolCall, replaceTerminalAsyncToolCallResult, })) -vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({ +vi.mock('@/lib/mothership/persistence/tool-confirm', () => ({ publishToolConfirmation, waitForToolConfirmation, })) -vi.mock('@/lib/copilot/request/metrics', () => ({ +vi.mock('@/lib/mothership/request/metrics', () => ({ recordSimToolMetric, })) -vi.mock('@/lib/copilot/request/otel', () => ({ +vi.mock('@/lib/mothership/request/otel', () => ({ withCopilotToolSpan, })) -vi.mock('@/lib/copilot/request/sse-utils', () => ({ +vi.mock('@/lib/mothership/request/sse-utils', () => ({ markToolResultSeen: vi.fn(), })) -vi.mock('@/lib/copilot/request/tools/files', () => ({ +vi.mock('@/lib/mothership/request/tools/files', () => ({ maybeWriteOutputToFile: vi.fn(async (_toolName, _params, result) => result), })) -vi.mock('@/lib/copilot/request/tools/resources', () => ({ +vi.mock('@/lib/mothership/request/tools/resources', () => ({ handleResourceSideEffects: vi.fn(), })) -vi.mock('@/lib/copilot/request/tools/tables', () => ({ +vi.mock('@/lib/mothership/request/tools/tables', () => ({ maybeWriteOutputToTable: vi.fn(async (_toolName, _params, result) => result), maybeWriteReadCsvToTable: vi.fn(async (_toolName, _params, result) => result), })) -vi.mock('@/lib/copilot/request/tools/workflow-context', () => ({ +vi.mock('@/lib/mothership/request/tools/workflow-context', () => ({ applyCreateWorkflowOutputToContext: vi.fn(), })) -import { AsyncToolCallOwnershipError } from '@/lib/copilot/async-runs/errors' -import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/copilot/constants' +import { AsyncToolCallOwnershipError } from '@/lib/mothership/async-runs/errors' +import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/mothership/constants' import { MothershipStreamV1EventType, MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { GenerateApiKey } from '@/lib/copilot/generated/tool-catalog-v1' -import { createStreamingContext } from '@/lib/copilot/request/context/request-context' -import { handleClientCompletion } from '@/lib/copilot/request/handlers/types' -import { waitForClientToolCompletion } from '@/lib/copilot/request/tools/client' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { GenerateApiKey } from '@/lib/mothership/generated/tool-catalog-v1' +import { createStreamingContext } from '@/lib/mothership/request/context/request-context' +import { handleClientCompletion } from '@/lib/mothership/request/handlers/types' +import { waitForClientToolCompletion } from '@/lib/mothership/request/tools/client' import { sealClientToolCompletion, sealClientToolContext, -} from '@/lib/copilot/request/tools/client-completion-seal.server' +} from '@/lib/mothership/request/tools/client-completion-seal.server' import { buildToolExecutionContext, executeToolAndReport, forceFailHungToolCall, pendingToolWaitBudgetMs, toolWatchdogTimeoutMs, -} from '@/lib/copilot/request/tools/executor' -import { maybeWriteOutputToFile } from '@/lib/copilot/request/tools/files' +} from '@/lib/mothership/request/tools/executor' +import { maybeWriteOutputToFile } from '@/lib/mothership/request/tools/files' import { maybeWriteOutputToTable, maybeWriteReadCsvToTable, -} from '@/lib/copilot/request/tools/tables' -import type { ExecutionContext, ToolCallState } from '@/lib/copilot/request/types' +} from '@/lib/mothership/request/tools/tables' +import type { ExecutionContext, ToolCallState } from '@/lib/mothership/request/types' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' function buildStreamingContext(toolCall: ToolCallState) { diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/mothership/request/tools/executor.ts similarity index 96% rename from apps/sim/lib/copilot/request/tools/executor.ts rename to apps/sim/lib/mothership/request/tools/executor.ts index e7c03b320e0..ae19e2f271e 100644 --- a/apps/sim/lib/copilot/request/tools/executor.ts +++ b/apps/sim/lib/mothership/request/tools/executor.ts @@ -2,17 +2,17 @@ import { browserToolRendererTimeoutMs, isCurrentBrowserToolName } from '@sim/bro import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' -import { AsyncToolCallOwnershipError } from '@/lib/copilot/async-runs/errors' +import { AsyncToolCallOwnershipError } from '@/lib/mothership/async-runs/errors' import type { AsyncCompletionEnvelope, AsyncCompletionSignal, -} from '@/lib/copilot/async-runs/lifecycle' +} from '@/lib/mothership/async-runs/lifecycle' import { completeAsyncToolCall, markAsyncToolRunning, upsertAsyncToolCall, -} from '@/lib/copilot/async-runs/repository' -import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/copilot/constants' +} from '@/lib/mothership/async-runs/repository' +import { TOOL_WATCHDOG_DEFAULT_MS, TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/mothership/constants' import { MothershipStreamV1AsyncToolRecordStatus, MothershipStreamV1EventType, @@ -20,7 +20,7 @@ import { MothershipStreamV1ToolMode, MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, -} from '@/lib/copilot/generated/mothership-stream-v1' +} from '@/lib/mothership/generated/mothership-stream-v1' import { ApplyFileEdit, CreateEmptyFile, @@ -51,32 +51,32 @@ import { SaveUpload, Search, WebCrawl, -} from '@/lib/copilot/generated/tool-catalog-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { publishToolConfirmation } from '@/lib/copilot/persistence/tool-confirm' -import { recordSimToolMetric } from '@/lib/copilot/request/metrics' -import { withCopilotToolSpan } from '@/lib/copilot/request/otel' -import { markToolResultSeen } from '@/lib/copilot/request/sse-utils' +} from '@/lib/mothership/generated/tool-catalog-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { publishToolConfirmation } from '@/lib/mothership/persistence/tool-confirm' +import { recordSimToolMetric } from '@/lib/mothership/request/metrics' +import { withCopilotToolSpan } from '@/lib/mothership/request/otel' +import { markToolResultSeen } from '@/lib/mothership/request/sse-utils' import { getToolCallTerminalData, requireToolCallError, setTerminalToolCallState, -} from '@/lib/copilot/request/tool-call-state' +} from '@/lib/mothership/request/tool-call-state' import { sealClientToolCompletion, sealClientToolContext, -} from '@/lib/copilot/request/tools/client-completion-seal.server' -import { maybeWriteOutputToFile } from '@/lib/copilot/request/tools/files' +} from '@/lib/mothership/request/tools/client-completion-seal.server' +import { maybeWriteOutputToFile } from '@/lib/mothership/request/tools/files' import { describeWithholdingCause, inspectToolResultForCopilot, -} from '@/lib/copilot/request/tools/resolved-secret-result' -import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources' +} from '@/lib/mothership/request/tools/resolved-secret-result' +import { handleResourceSideEffects } from '@/lib/mothership/request/tools/resources' import { maybeWriteOutputToTable, maybeWriteReadCsvToTable, -} from '@/lib/copilot/request/tools/tables' -import { applyCreateWorkflowOutputToContext } from '@/lib/copilot/request/tools/workflow-context' +} from '@/lib/mothership/request/tools/tables' +import { applyCreateWorkflowOutputToContext } from '@/lib/mothership/request/tools/workflow-context' import { type ExecutionContext, isTerminalToolCallStatus, @@ -84,11 +84,11 @@ import { type StreamEvent, type StreamingContext, type ToolCallState, -} from '@/lib/copilot/request/types' -import { ensureHandlersRegistered, executeTool } from '@/lib/copilot/tool-executor' +} from '@/lib/mothership/request/types' +import { ensureHandlersRegistered, executeTool } from '@/lib/mothership/tool-executor' import { isMcpTool } from '@/executor/constants' -export { waitForToolCompletion } from '@/lib/copilot/request/tools/client' +export { waitForToolCompletion } from '@/lib/mothership/request/tools/client' const logger = createLogger('CopilotSseToolExecution') diff --git a/apps/sim/lib/copilot/request/tools/files.test.ts b/apps/sim/lib/mothership/request/tools/files.test.ts similarity index 98% rename from apps/sim/lib/copilot/request/tools/files.test.ts rename to apps/sim/lib/mothership/request/tools/files.test.ts index 0f4bafe90d0..f4555692ea0 100644 --- a/apps/sim/lib/copilot/request/tools/files.test.ts +++ b/apps/sim/lib/mothership/request/tools/files.test.ts @@ -12,11 +12,11 @@ vi.mock('@/lib/core/security/encryption', () => ({ encryptSecret: mockEncryptSecret, })) -vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ +vi.mock('@/lib/mothership/vfs/resource-writer', () => ({ writeCopilotWorkspaceFileByPath: mockWriteWorkspaceFileByPath, })) -vi.mock('@/lib/copilot/request/otel', () => ({ +vi.mock('@/lib/mothership/request/otel', () => ({ withCopilotSpan: ( _name: string, _attrs: Record | undefined, @@ -24,16 +24,16 @@ vi.mock('@/lib/copilot/request/otel', () => ({ ) => fn({ setAttribute: vi.fn(), setAttributes: vi.fn(), addEvent: vi.fn() }), })) -import { RunFunction } from '@/lib/copilot/generated/tool-catalog-v1' +import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits' +import { RunFunction } from '@/lib/mothership/generated/tool-catalog-v1' import { extractTabularData, maybeWriteOutputToFile, normalizeOutputWorkspaceFileName, serializeOutputForFile, unwrapFunctionExecuteOutput, -} from '@/lib/copilot/request/tools/files' -import type { ExecutionContext } from '@/lib/copilot/request/types' -import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits' +} from '@/lib/mothership/request/tools/files' +import type { ExecutionContext } from '@/lib/mothership/request/types' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' describe('unwrapFunctionExecuteOutput', () => { diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/mothership/request/tools/files.ts similarity index 95% rename from apps/sim/lib/copilot/request/tools/files.ts rename to apps/sim/lib/mothership/request/tools/files.ts index 76dc312d032..5f9839d37e9 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/mothership/request/tools/files.ts @@ -1,18 +1,18 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' -import { RunFunction, UserTable } from '@/lib/copilot/generated/tool-catalog-v1' -import { CopilotOutputFileOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { withCopilotSpan } from '@/lib/copilot/request/otel' -import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions' -import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv' +import { RunFunction, UserTable } from '@/lib/mothership/generated/tool-catalog-v1' +import { CopilotOutputFileOutcome } from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceEvent } from '@/lib/mothership/generated/trace-events-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { withCopilotSpan } from '@/lib/mothership/request/otel' +import { denyOutputWriteWithoutWritePermission } from '@/lib/mothership/request/tools/permissions' +import { projectToolErrorMessageForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result' +import type { ExecutionContext, ToolCallResult } from '@/lib/mothership/request/types' +import { decodeVfsPathSegments } from '@/lib/mothership/vfs/path-utils' +import { writeCopilotWorkspaceFileByPath } from '@/lib/mothership/vfs/resource-writer' import { createWorkspaceFileSecretProvenanceFromRegistry, type WorkspaceFileSecretProvenance, diff --git a/apps/sim/lib/copilot/request/tools/permission.test.ts b/apps/sim/lib/mothership/request/tools/permission.test.ts similarity index 93% rename from apps/sim/lib/copilot/request/tools/permission.test.ts rename to apps/sim/lib/mothership/request/tools/permission.test.ts index 07765132b18..564de46f58c 100644 --- a/apps/sim/lib/copilot/request/tools/permission.test.ts +++ b/apps/sim/lib/mothership/request/tools/permission.test.ts @@ -3,14 +3,14 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { TraceCollector } from '@/lib/copilot/request/trace' +import { TraceCollector } from '@/lib/mothership/request/trace' const { toolRequiresApproval, waitForToolPermissionDecision } = vi.hoisted(() => ({ toolRequiresApproval: vi.fn().mockReturnValue(true), waitForToolPermissionDecision: vi.fn(), })) -vi.mock('@/lib/copilot/tool-executor', () => ({ +vi.mock('@/lib/mothership/tool-executor', () => ({ toolRequiresApproval, isSimExecuted: vi.fn().mockReturnValue(true), getToolEntry: vi.fn().mockReturnValue(undefined), @@ -18,18 +18,19 @@ vi.mock('@/lib/copilot/tool-executor', () => ({ ensureHandlersRegistered: vi.fn(), })) -vi.mock('@/lib/copilot/persistence/tool-permission', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('@/lib/mothership/persistence/tool-permission', async (importOriginal) => { + const actual = + await importOriginal() return { ...actual, waitForToolPermissionDecision } }) -import { MothershipStreamV1ToolExecutor } from '@/lib/copilot/generated/mothership-stream-v1' -import { createStreamingContext } from '@/lib/copilot/request/context/request-context' +import { MothershipStreamV1ToolExecutor } from '@/lib/mothership/generated/mothership-stream-v1' +import { createStreamingContext } from '@/lib/mothership/request/context/request-context' import { runGatedToolExecution, toolCallNeedsApproval, -} from '@/lib/copilot/request/tools/permission' -import type { StreamEvent, ToolCallState } from '@/lib/copilot/request/types' +} from '@/lib/mothership/request/tools/permission' +import type { StreamEvent, ToolCallState } from '@/lib/mothership/request/types' function makeContext() { const context = createStreamingContext({ runId: 'run-1' }) @@ -164,11 +165,11 @@ describe('gated tools are askable', () => { // refuses to run such a call, so shipping one would silently disable the // tool; this keeps the catalog honest instead. const { TOOL_CATALOG } = await vi.importActual< - typeof import('@/lib/copilot/generated/tool-catalog-v1') - >('@/lib/copilot/generated/tool-catalog-v1') + typeof import('@/lib/mothership/generated/tool-catalog-v1') + >('@/lib/mothership/generated/tool-catalog-v1') const { getHiddenToolNames } = await vi.importActual< - typeof import('@/lib/copilot/tools/client/hidden-tools') - >('@/lib/copilot/tools/client/hidden-tools') + typeof import('@/lib/mothership/tools/client/hidden-tools') + >('@/lib/mothership/tools/client/hidden-tools') const hidden = getHiddenToolNames() const unaskable = Object.entries(TOOL_CATALOG) @@ -189,8 +190,8 @@ describe('gated tools are askable', () => { // awaiting_approval. It is gated on that stamp, under the resolved // operation's name, never under this one. const { TOOL_CATALOG } = await vi.importActual< - typeof import('@/lib/copilot/generated/tool-catalog-v1') - >('@/lib/copilot/generated/tool-catalog-v1') + typeof import('@/lib/mothership/generated/tool-catalog-v1') + >('@/lib/mothership/generated/tool-catalog-v1') const ungatable = Object.entries(TOOL_CATALOG) .filter( @@ -204,8 +205,8 @@ describe('gated tools are askable', () => { it('gates the tools we intend to gate', async () => { const { TOOL_CATALOG } = await vi.importActual< - typeof import('@/lib/copilot/generated/tool-catalog-v1') - >('@/lib/copilot/generated/tool-catalog-v1') + typeof import('@/lib/mothership/generated/tool-catalog-v1') + >('@/lib/mothership/generated/tool-catalog-v1') expect( Object.entries(TOOL_CATALOG) diff --git a/apps/sim/lib/copilot/request/tools/permission.ts b/apps/sim/lib/mothership/request/tools/permission.ts similarity index 93% rename from apps/sim/lib/copilot/request/tools/permission.ts rename to apps/sim/lib/mothership/request/tools/permission.ts index aae9c714b5a..78fa827076e 100644 --- a/apps/sim/lib/copilot/request/tools/permission.ts +++ b/apps/sim/lib/mothership/request/tools/permission.ts @@ -1,8 +1,8 @@ import { createLogger } from '@sim/logger' import { TERMINAL_TOOL_NAME } from '@sim/terminal-protocol' import { getErrorMessage } from '@sim/utils/errors' -import type { AsyncCompletionSignal } from '@/lib/copilot/async-runs/lifecycle' -import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/copilot/constants' +import type { AsyncCompletionSignal } from '@/lib/mothership/async-runs/lifecycle' +import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/mothership/constants' import { MothershipStreamV1EventType, type MothershipStreamV1ToolExecutor, @@ -10,23 +10,23 @@ import { MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, MothershipStreamV1ToolStatus, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' import { decisionAllowsExecution, decisionSuppressesFuturePrompts, waitForToolPermissionDecision, -} from '@/lib/copilot/persistence/tool-permission' -import { withCopilotSpan } from '@/lib/copilot/request/otel' -import { markToolResultSeen } from '@/lib/copilot/request/sse-utils' -import { setTerminalToolCallState } from '@/lib/copilot/request/tool-call-state' +} from '@/lib/mothership/persistence/tool-permission' +import { withCopilotSpan } from '@/lib/mothership/request/otel' +import { markToolResultSeen } from '@/lib/mothership/request/sse-utils' +import { setTerminalToolCallState } from '@/lib/mothership/request/tool-call-state' import type { OrchestratorOptions, StreamingContext, ToolCallState, -} from '@/lib/copilot/request/types' -import { getToolEntry, toolRequiresApproval } from '@/lib/copilot/tool-executor' +} from '@/lib/mothership/request/types' +import { getToolEntry, toolRequiresApproval } from '@/lib/mothership/tool-executor' const logger = createLogger('CopilotToolPermissionGate') diff --git a/apps/sim/lib/copilot/request/tools/permissions.ts b/apps/sim/lib/mothership/request/tools/permissions.ts similarity index 93% rename from apps/sim/lib/copilot/request/tools/permissions.ts rename to apps/sim/lib/mothership/request/tools/permissions.ts index fcd1e8d9341..9feea30c697 100644 --- a/apps/sim/lib/copilot/request/tools/permissions.ts +++ b/apps/sim/lib/mothership/request/tools/permissions.ts @@ -1,5 +1,5 @@ import { type PermissionType, permissionSatisfies } from '@sim/platform-authz/workspace' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import type { ExecutionContext, ToolCallResult } from '@/lib/mothership/request/types' /** * Guards a post-tool output-redirection sink against read-only principals. diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts b/apps/sim/lib/mothership/request/tools/resolved-secret-result.test.ts similarity index 99% rename from apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts rename to apps/sim/lib/mothership/request/tools/resolved-secret-result.test.ts index 7bd4eaa5109..76788849c97 100644 --- a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts +++ b/apps/sim/lib/mothership/request/tools/resolved-secret-result.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { RunCode, RunFunction } from '@/lib/copilot/generated/tool-catalog-v1' +import { RunCode, RunFunction } from '@/lib/mothership/generated/tool-catalog-v1' import { describeWithholdingCause, inspectToolResultForCopilot, @@ -10,7 +10,7 @@ import { READ_TOOL_RESULT_UNAVAILABLE_ERROR, TOOL_RESULT_UNAVAILABLE_ERROR, toolResultUnavailableError, -} from '@/lib/copilot/request/tools/resolved-secret-result' +} from '@/lib/mothership/request/tools/resolved-secret-result' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' function createRegistry(): ResolvedSecretTraceRegistry { diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts b/apps/sim/lib/mothership/request/tools/resolved-secret-result.ts similarity index 98% rename from apps/sim/lib/copilot/request/tools/resolved-secret-result.ts rename to apps/sim/lib/mothership/request/tools/resolved-secret-result.ts index 65b704a06d0..e8eca2cea89 100644 --- a/apps/sim/lib/copilot/request/tools/resolved-secret-result.ts +++ b/apps/sim/lib/mothership/request/tools/resolved-secret-result.ts @@ -1,5 +1,5 @@ -import type { ToolCallEffect, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' -import { TOOL_EFFECT_PHASE } from '@/lib/copilot/tool-executor/types' +import type { ToolCallEffect, ToolExecutionResult } from '@/lib/mothership/tool-executor/types' +import { TOOL_EFFECT_PHASE } from '@/lib/mothership/tool-executor/types' import { projectResolvedSecretModelJsonContent } from '@/executor/utils/resolved-secret-content-projection' import type { ResolvedSecretIncompletenessReason, diff --git a/apps/sim/lib/copilot/request/tools/resources.test.ts b/apps/sim/lib/mothership/request/tools/resources.test.ts similarity index 89% rename from apps/sim/lib/copilot/request/tools/resources.test.ts rename to apps/sim/lib/mothership/request/tools/resources.test.ts index fefe636c49e..a93b6181bff 100644 --- a/apps/sim/lib/copilot/request/tools/resources.test.ts +++ b/apps/sim/lib/mothership/request/tools/resources.test.ts @@ -9,7 +9,7 @@ const mocks = vi.hoisted(() => ({ setAttributes: vi.fn(), })) -vi.mock('@/lib/copilot/request/otel', () => ({ +vi.mock('@/lib/mothership/request/otel', () => ({ withCopilotSpan: ( _name: string, _attributes: Record, @@ -17,7 +17,7 @@ vi.mock('@/lib/copilot/request/otel', () => ({ ) => run({ setAttributes: mocks.setAttributes }), })) -vi.mock('@/lib/copilot/resources/persistence', () => ({ +vi.mock('@/lib/mothership/resources/persistence', () => ({ extractDeletedResourcesFromToolResult: vi.fn(() => []), extractResourcesFromToolResult: mocks.extractResourcesFromToolResult, hasDeleteCapability: vi.fn(() => false), @@ -29,8 +29,8 @@ vi.mock('@/lib/copilot/resources/persistence', () => ({ import { MothershipStreamV1EventType, MothershipStreamV1ResourceOp, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { handleResourceSideEffects } from '@/lib/mothership/request/tools/resources' describe('handleResourceSideEffects', () => { beforeEach(() => { diff --git a/apps/sim/lib/copilot/request/tools/resources.ts b/apps/sim/lib/mothership/request/tools/resources.ts similarity index 93% rename from apps/sim/lib/copilot/request/tools/resources.ts rename to apps/sim/lib/mothership/request/tools/resources.ts index 0646f444dcb..718257d81b9 100644 --- a/apps/sim/lib/copilot/request/tools/resources.ts +++ b/apps/sim/lib/mothership/request/tools/resources.ts @@ -3,11 +3,11 @@ import { toError } from '@sim/utils/errors' import { MothershipStreamV1EventType, MothershipStreamV1ResourceOp, -} from '@/lib/copilot/generated/mothership-stream-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { withCopilotSpan } from '@/lib/copilot/request/otel' -import type { StreamEvent, ToolCallResult } from '@/lib/copilot/request/types' +} from '@/lib/mothership/generated/mothership-stream-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { withCopilotSpan } from '@/lib/mothership/request/otel' +import type { StreamEvent, ToolCallResult } from '@/lib/mothership/request/types' import { extractDeletedResourcesFromToolResult, extractResourcesFromToolResult, @@ -15,7 +15,7 @@ import { isResourceToolName, persistChatResources, removeChatResources, -} from '@/lib/copilot/resources/persistence' +} from '@/lib/mothership/resources/persistence' const logger = createLogger('CopilotResourceEffects') diff --git a/apps/sim/lib/copilot/request/tools/tables.test.ts b/apps/sim/lib/mothership/request/tools/tables.test.ts similarity index 96% rename from apps/sim/lib/copilot/request/tools/tables.test.ts rename to apps/sim/lib/mothership/request/tools/tables.test.ts index ba68f10f7c2..860f688180c 100644 --- a/apps/sim/lib/copilot/request/tools/tables.test.ts +++ b/apps/sim/lib/mothership/request/tools/tables.test.ts @@ -11,10 +11,10 @@ const mocks = vi.hoisted(() => ({ spanAddEvent: vi.fn(), })) -vi.mock('@/lib/copilot/application/table-commands', () => ({ +vi.mock('@/lib/mothership/application/table-commands', () => ({ executeCopilotReplaceProjectedWireRows: mocks.executeReplace, })) -vi.mock('@/lib/copilot/request/otel', () => ({ +vi.mock('@/lib/mothership/request/otel', () => ({ withCopilotSpan: ( _name: string, _attrs: Record | undefined, @@ -30,13 +30,13 @@ vi.mock('@/lib/table/application/rows', () => ({ ProjectedWireRowsValidationError: class ProjectedWireRowsValidationError extends Error {}, })) -import { Read as ReadTool, RunFunction } from '@/lib/copilot/generated/tool-catalog-v1' -import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' +import { Read as ReadTool, RunFunction } from '@/lib/mothership/generated/tool-catalog-v1' +import { projectToolResultForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result' import { maybeWriteOutputToTable, maybeWriteReadCsvToTable, -} from '@/lib/copilot/request/tools/tables' -import type { ExecutionContext } from '@/lib/copilot/request/types' +} from '@/lib/mothership/request/tools/tables' +import type { ExecutionContext } from '@/lib/mothership/request/types' import { ProjectedWireRowsValidationError } from '@/lib/table/application/rows' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/mothership/request/tools/tables.ts similarity index 92% rename from apps/sim/lib/copilot/request/tools/tables.ts rename to apps/sim/lib/mothership/request/tools/tables.ts index 36eecdc2209..b617cff63f7 100644 --- a/apps/sim/lib/copilot/request/tools/tables.ts +++ b/apps/sim/lib/mothership/request/tools/tables.ts @@ -1,16 +1,16 @@ import { createLogger } from '@sim/logger' import { parse as csvParse } from 'csv-parse/sync' -import { executeCopilotReplaceProjectedWireRows } from '@/lib/copilot/application/table-commands' -import { messageForCopilotTableError } from '@/lib/copilot/auth/table-delegation' -import { Read as ReadTool, RunFunction } from '@/lib/copilot/generated/tool-catalog-v1' -import { CopilotTableOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { TraceEvent } from '@/lib/copilot/generated/trace-events-v1' -import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' -import { withCopilotSpan } from '@/lib/copilot/request/otel' -import { denyOutputWriteWithoutWritePermission } from '@/lib/copilot/request/tools/permissions' -import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { executeCopilotReplaceProjectedWireRows } from '@/lib/mothership/application/table-commands' +import { messageForCopilotTableError } from '@/lib/mothership/auth/table-delegation' +import { Read as ReadTool, RunFunction } from '@/lib/mothership/generated/tool-catalog-v1' +import { CopilotTableOutcome } from '@/lib/mothership/generated/trace-attribute-values-v1' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { TraceEvent } from '@/lib/mothership/generated/trace-events-v1' +import { TraceSpan } from '@/lib/mothership/generated/trace-spans-v1' +import { withCopilotSpan } from '@/lib/mothership/request/otel' +import { denyOutputWriteWithoutWritePermission } from '@/lib/mothership/request/tools/permissions' +import { projectToolErrorMessageForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result' +import type { ExecutionContext, ToolCallResult } from '@/lib/mothership/request/types' import type { TableDefinition } from '@/lib/table' import { ProjectedWireRowsValidationError } from '@/lib/table/application/rows' diff --git a/apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts b/apps/sim/lib/mothership/request/tools/workflow-client-fallback.test.ts similarity index 95% rename from apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts rename to apps/sim/lib/mothership/request/tools/workflow-client-fallback.test.ts index d786190a33a..d9f4c7ba6a3 100644 --- a/apps/sim/lib/copilot/request/tools/workflow-client-fallback.test.ts +++ b/apps/sim/lib/mothership/request/tools/workflow-client-fallback.test.ts @@ -12,17 +12,17 @@ const { waitForWorkflowToolCompletion, claimWorkflowToolExecution, recordDegrade }) ) -vi.mock('@/lib/copilot/request/metrics', () => ({ recordDegraded })) +vi.mock('@/lib/mothership/request/metrics', () => ({ recordDegraded })) -vi.mock('@/lib/copilot/request/tools/client', () => ({ +vi.mock('@/lib/mothership/request/tools/client', () => ({ waitForWorkflowToolCompletion, })) -vi.mock('@/lib/copilot/async-runs/repository', () => ({ +vi.mock('@/lib/mothership/async-runs/repository', () => ({ claimWorkflowToolExecution, })) -import { raceWorkflowToolClientPickup } from '@/lib/copilot/request/tools/workflow-client-fallback' +import { raceWorkflowToolClientPickup } from '@/lib/mothership/request/tools/workflow-client-fallback' const GRACE_MS = 30_000 const TIMEOUT_MS = 3_600_000 diff --git a/apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts b/apps/sim/lib/mothership/request/tools/workflow-client-fallback.ts similarity index 93% rename from apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts rename to apps/sim/lib/mothership/request/tools/workflow-client-fallback.ts index 6a5a2df4c0b..e29456ffc40 100644 --- a/apps/sim/lib/copilot/request/tools/workflow-client-fallback.ts +++ b/apps/sim/lib/mothership/request/tools/workflow-client-fallback.ts @@ -5,11 +5,11 @@ import { generateId } from '@sim/utils/id' import type { AsyncCompletionSignal, AsyncTerminalCompletionSnapshot, -} from '@/lib/copilot/async-runs/lifecycle' -import { claimWorkflowToolExecution } from '@/lib/copilot/async-runs/repository' -import { CopilotDegradedReason } from '@/lib/copilot/generated/trace-attribute-values-v1' -import { recordDegraded } from '@/lib/copilot/request/metrics' -import { waitForWorkflowToolCompletion } from '@/lib/copilot/request/tools/client' +} from '@/lib/mothership/async-runs/lifecycle' +import { claimWorkflowToolExecution } from '@/lib/mothership/async-runs/repository' +import { CopilotDegradedReason } from '@/lib/mothership/generated/trace-attribute-values-v1' +import { recordDegraded } from '@/lib/mothership/request/metrics' +import { waitForWorkflowToolCompletion } from '@/lib/mothership/request/tools/client' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const logger = createLogger('CopilotWorkflowClientFallback') diff --git a/apps/sim/lib/copilot/request/tools/workflow-context.test.ts b/apps/sim/lib/mothership/request/tools/workflow-context.test.ts similarity index 98% rename from apps/sim/lib/copilot/request/tools/workflow-context.test.ts rename to apps/sim/lib/mothership/request/tools/workflow-context.test.ts index 379c8c267fd..6f73216bbb7 100644 --- a/apps/sim/lib/copilot/request/tools/workflow-context.test.ts +++ b/apps/sim/lib/mothership/request/tools/workflow-context.test.ts @@ -26,8 +26,8 @@ vi.mock('@/lib/billing/calculations/usage-reservation', () => ({ }, })) -import { applyCreateWorkflowOutputToContext } from '@/lib/copilot/request/tools/workflow-context' -import type { ExecutionContext } from '@/lib/copilot/request/types' +import { applyCreateWorkflowOutputToContext } from '@/lib/mothership/request/tools/workflow-context' +import type { ExecutionContext } from '@/lib/mothership/request/types' import { prepareWorkflowExecutionAdmission, resolveWorkflowExecutionBillingAttribution, diff --git a/apps/sim/lib/copilot/request/tools/workflow-context.ts b/apps/sim/lib/mothership/request/tools/workflow-context.ts similarity index 93% rename from apps/sim/lib/copilot/request/tools/workflow-context.ts rename to apps/sim/lib/mothership/request/tools/workflow-context.ts index cf49411b723..32d41087031 100644 --- a/apps/sim/lib/copilot/request/tools/workflow-context.ts +++ b/apps/sim/lib/mothership/request/tools/workflow-context.ts @@ -1,5 +1,5 @@ import { isRecordLike } from '@sim/utils/object' -import type { ExecutionContext } from '@/lib/copilot/request/types' +import type { ExecutionContext } from '@/lib/mothership/request/types' function getCreateWorkflowOutput( output: unknown diff --git a/apps/sim/lib/copilot/request/trace.ts b/apps/sim/lib/mothership/request/trace.ts similarity index 98% rename from apps/sim/lib/copilot/request/trace.ts rename to apps/sim/lib/mothership/request/trace.ts index f2672468e52..3c00d77f4e6 100644 --- a/apps/sim/lib/copilot/request/trace.ts +++ b/apps/sim/lib/mothership/request/trace.ts @@ -6,7 +6,7 @@ import { RequestTraceV1SpanSource, RequestTraceV1SpanStatus, type RequestTraceV1UsageSummary, -} from '@/lib/copilot/generated/request-trace-v1' +} from '@/lib/mothership/generated/request-trace-v1' export class TraceCollector { private readonly spans: RequestTraceV1Span[] = [] diff --git a/apps/sim/lib/copilot/request/types.ts b/apps/sim/lib/mothership/request/types.ts similarity index 94% rename from apps/sim/lib/copilot/request/types.ts rename to apps/sim/lib/mothership/request/types.ts index 1afdf77e4b8..ab291b8012a 100644 --- a/apps/sim/lib/copilot/request/types.ts +++ b/apps/sim/lib/mothership/request/types.ts @@ -1,13 +1,16 @@ -import type { AsyncCompletionSignal } from '@/lib/copilot/async-runs/lifecycle' +import type { AsyncCompletionSignal } from '@/lib/mothership/async-runs/lifecycle' import { type MothershipStreamV1CompletionStatus, MothershipStreamV1ToolOutcome, -} from '@/lib/copilot/generated/mothership-stream-v1' -import type { RequestTraceV1Span } from '@/lib/copilot/generated/request-trace-v1' -import type { ProviderToolCallIdentity } from '@/lib/copilot/request/go/tool-call-identity' -import type { StreamEvent } from '@/lib/copilot/request/session' -import type { TraceCollector } from '@/lib/copilot/request/trace' -import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +} from '@/lib/mothership/generated/mothership-stream-v1' +import type { RequestTraceV1Span } from '@/lib/mothership/generated/request-trace-v1' +import type { ProviderToolCallIdentity } from '@/lib/mothership/request/go/tool-call-identity' +import type { StreamEvent } from '@/lib/mothership/request/session' +import type { TraceCollector } from '@/lib/mothership/request/trace' +import type { + ToolExecutionContext, + ToolExecutionResult, +} from '@/lib/mothership/tool-executor/types' export type { StreamEvent } diff --git a/apps/sim/lib/copilot/resource-types.ts b/apps/sim/lib/mothership/resource-types.ts similarity index 100% rename from apps/sim/lib/copilot/resource-types.ts rename to apps/sim/lib/mothership/resource-types.ts diff --git a/apps/sim/lib/copilot/resources/availability.ts b/apps/sim/lib/mothership/resources/availability.ts similarity index 95% rename from apps/sim/lib/copilot/resources/availability.ts rename to apps/sim/lib/mothership/resources/availability.ts index 53445e10b9a..3b8d65e6ac4 100644 --- a/apps/sim/lib/copilot/resources/availability.ts +++ b/apps/sim/lib/mothership/resources/availability.ts @@ -1,5 +1,5 @@ import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' -import { isDesktopOnlyResource, type MothershipResource } from '@/lib/copilot/resources/types' +import { isDesktopOnlyResource, type MothershipResource } from '@/lib/mothership/resources/types' import { isTerminalAvailable } from '@/lib/terminal/transport' /** diff --git a/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts b/apps/sim/lib/mothership/resources/client-persistence-queue.test.ts similarity index 98% rename from apps/sim/lib/copilot/resources/client-persistence-queue.test.ts rename to apps/sim/lib/mothership/resources/client-persistence-queue.test.ts index 7335ef7006e..a824194832c 100644 --- a/apps/sim/lib/copilot/resources/client-persistence-queue.test.ts +++ b/apps/sim/lib/mothership/resources/client-persistence-queue.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ResourcePersistenceQueue } from '@/lib/copilot/resources/client-persistence-queue' -import type { MothershipResourceUpdate } from '@/lib/copilot/resources/types' +import { ResourcePersistenceQueue } from '@/lib/mothership/resources/client-persistence-queue' +import type { MothershipResourceUpdate } from '@/lib/mothership/resources/types' function deferred() { let resolve: (value: T) => void = () => {} diff --git a/apps/sim/lib/copilot/resources/client-persistence-queue.ts b/apps/sim/lib/mothership/resources/client-persistence-queue.ts similarity index 98% rename from apps/sim/lib/copilot/resources/client-persistence-queue.ts rename to apps/sim/lib/mothership/resources/client-persistence-queue.ts index de031f55559..5a20efd7085 100644 --- a/apps/sim/lib/copilot/resources/client-persistence-queue.ts +++ b/apps/sim/lib/mothership/resources/client-persistence-queue.ts @@ -1,5 +1,5 @@ -import type { MothershipResource, MothershipResourceUpdate } from '@/lib/copilot/resources/types' -import { mergePendingChatResourceUpdate } from '@/lib/copilot/resources/types' +import type { MothershipResource, MothershipResourceUpdate } from '@/lib/mothership/resources/types' +import { mergePendingChatResourceUpdate } from '@/lib/mothership/resources/types' interface ResourcePersistenceQueueOptions { persist: (chatId: string, update: MothershipResourceUpdate) => Promise diff --git a/apps/sim/lib/copilot/resources/extraction.test.ts b/apps/sim/lib/mothership/resources/extraction.test.ts similarity index 100% rename from apps/sim/lib/copilot/resources/extraction.test.ts rename to apps/sim/lib/mothership/resources/extraction.test.ts diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/mothership/resources/extraction.ts similarity index 98% rename from apps/sim/lib/copilot/resources/extraction.ts rename to apps/sim/lib/mothership/resources/extraction.ts index 80b70e241ec..56fa9192bea 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/mothership/resources/extraction.ts @@ -15,8 +15,8 @@ import { RunFunction, TableViews, UserTable, -} from '@/lib/copilot/generated/tool-catalog-v1' -import type { MothershipResourceType, MothershipResourceUpdate } from './types' +} from '@/lib/mothership/generated/tool-catalog-v1' +import type { MothershipResource, MothershipResourceType, MothershipResourceUpdate } from './types' type ChatResource = MothershipResourceUpdate type ResourceType = MothershipResourceType diff --git a/apps/sim/lib/copilot/resources/persistence.test.ts b/apps/sim/lib/mothership/resources/persistence.test.ts similarity index 97% rename from apps/sim/lib/copilot/resources/persistence.test.ts rename to apps/sim/lib/mothership/resources/persistence.test.ts index 76266af9fb2..e2b5bfe282d 100644 --- a/apps/sim/lib/copilot/resources/persistence.test.ts +++ b/apps/sim/lib/mothership/resources/persistence.test.ts @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { persistChatResources, serializeChatResourceWrite, -} from '@/lib/copilot/resources/persistence' +} from '@/lib/mothership/resources/persistence' function deferred() { let resolve: () => void = () => {} diff --git a/apps/sim/lib/copilot/resources/persistence.ts b/apps/sim/lib/mothership/resources/persistence.ts similarity index 100% rename from apps/sim/lib/copilot/resources/persistence.ts rename to apps/sim/lib/mothership/resources/persistence.ts diff --git a/apps/sim/lib/copilot/resources/types.test.ts b/apps/sim/lib/mothership/resources/types.test.ts similarity index 100% rename from apps/sim/lib/copilot/resources/types.test.ts rename to apps/sim/lib/mothership/resources/types.test.ts diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/mothership/resources/types.ts similarity index 100% rename from apps/sim/lib/copilot/resources/types.ts rename to apps/sim/lib/mothership/resources/types.ts diff --git a/apps/sim/lib/copilot/secret-mount-policy.test.ts b/apps/sim/lib/mothership/secret-mount-policy.test.ts similarity index 98% rename from apps/sim/lib/copilot/secret-mount-policy.test.ts rename to apps/sim/lib/mothership/secret-mount-policy.test.ts index aba0b6fbc79..5c55c637073 100644 --- a/apps/sim/lib/copilot/secret-mount-policy.test.ts +++ b/apps/sim/lib/mothership/secret-mount-policy.test.ts @@ -3,7 +3,7 @@ import { applySecretMountPolicy, filterSecretNamesByMountPolicy, normalizeSecretMountPolicy, -} from '@/lib/copilot/secret-mount-policy' +} from '@/lib/mothership/secret-mount-policy' describe('normalizeSecretMountPolicy', () => { it('defaults missing legacy policy data to all and fails malformed scopes closed', () => { diff --git a/apps/sim/lib/copilot/secret-mount-policy.ts b/apps/sim/lib/mothership/secret-mount-policy.ts similarity index 100% rename from apps/sim/lib/copilot/secret-mount-policy.ts rename to apps/sim/lib/mothership/secret-mount-policy.ts diff --git a/apps/sim/lib/copilot/server/agent-url.test.ts b/apps/sim/lib/mothership/server/agent-url.test.ts similarity index 98% rename from apps/sim/lib/copilot/server/agent-url.test.ts rename to apps/sim/lib/mothership/server/agent-url.test.ts index 91e795d5f83..229d52a9525 100644 --- a/apps/sim/lib/copilot/server/agent-url.test.ts +++ b/apps/sim/lib/mothership/server/agent-url.test.ts @@ -24,7 +24,7 @@ vi.mock('@/lib/api/contracts/user', () => ({ : { success: false }, }, })) -vi.mock('@/lib/copilot/constants', () => ({ +vi.mock('@/lib/mothership/constants', () => ({ SIM_AGENT_API_URL: 'https://default.mothership.test', SIM_AGENT_API_URL_DEFAULT: 'https://fallback.mothership.test', })) diff --git a/apps/sim/lib/copilot/server/agent-url.ts b/apps/sim/lib/mothership/server/agent-url.ts similarity index 96% rename from apps/sim/lib/copilot/server/agent-url.ts rename to apps/sim/lib/mothership/server/agent-url.ts index 31264868d1a..98aff2d5fcf 100644 --- a/apps/sim/lib/copilot/server/agent-url.ts +++ b/apps/sim/lib/mothership/server/agent-url.ts @@ -2,8 +2,9 @@ import { db } from '@sim/db' import { settings, user } from '@sim/db/schema' import { eq } from 'drizzle-orm' import { type MothershipEnvironment, mothershipEnvironmentSchema } from '@/lib/api/contracts/user' -import { SIM_AGENT_API_URL, SIM_AGENT_API_URL_DEFAULT } from '@/lib/copilot/constants' +import { SIM_AGENT_API_URL, SIM_AGENT_API_URL_DEFAULT } from '@/lib/mothership/constants' import { env } from '@/lib/core/config/env' +import { SIM_AGENT_API_URL, SIM_AGENT_API_URL_DEFAULT } from '@/lib/mothership/constants' export interface GetMothershipBaseURLOptions { userId?: string | null diff --git a/apps/sim/lib/copilot/server/api-keys.ts b/apps/sim/lib/mothership/server/api-keys.ts similarity index 94% rename from apps/sim/lib/copilot/server/api-keys.ts rename to apps/sim/lib/mothership/server/api-keys.ts index d1935edcb74..a4d5a16ecac 100644 --- a/apps/sim/lib/copilot/server/api-keys.ts +++ b/apps/sim/lib/mothership/server/api-keys.ts @@ -1,7 +1,7 @@ -import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' -import { fetchGo } from '@/lib/copilot/request/go/fetch' -import { getMothershipBaseURL } from '@/lib/copilot/server/agent-url' import { env } from '@/lib/core/config/env' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { fetchGo } from '@/lib/mothership/request/go/fetch' +import { getMothershipBaseURL } from '@/lib/mothership/server/agent-url' /** * Chat API key operations against the Sim Agent's `/api/validate-key/*` diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/mothership/tool-executor/executor.test.ts similarity index 100% rename from apps/sim/lib/copilot/tool-executor/executor.test.ts rename to apps/sim/lib/mothership/tool-executor/executor.test.ts diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/mothership/tool-executor/executor.ts similarity index 98% rename from apps/sim/lib/copilot/tool-executor/executor.ts rename to apps/sim/lib/mothership/tool-executor/executor.ts index e2d77c6315f..9086da264db 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/mothership/tool-executor/executor.ts @@ -4,8 +4,8 @@ import { toError } from '@sim/utils/errors' import { ASSISTANT_TOOLS, assertAssistantIntegrationCall, -} from '@/lib/copilot/assistant/tool-policy' -import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' +} from '@/lib/mothership/assistant/tool-policy' +import { projectToolErrorMessageForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result' import { withResourceOutboundScope } from '@/lib/core/network/resource-scope.server' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' import { recordSecretUsage } from '@/lib/secrets/usage/record' diff --git a/apps/sim/lib/copilot/tool-executor/index.ts b/apps/sim/lib/mothership/tool-executor/index.ts similarity index 100% rename from apps/sim/lib/copilot/tool-executor/index.ts rename to apps/sim/lib/mothership/tool-executor/index.ts diff --git a/apps/sim/lib/mothership/tool-executor/register-handlers.ts b/apps/sim/lib/mothership/tool-executor/register-handlers.ts new file mode 100644 index 00000000000..7822d662cfe --- /dev/null +++ b/apps/sim/lib/mothership/tool-executor/register-handlers.ts @@ -0,0 +1,62 @@ +import { createLogger } from '@sim/logger' +import { + RunBlock, + RunFromBlock, + RunWorkflow, + RunWorkflowUntilBlock, +} from '@/lib/mothership/generated/tool-catalog-v1' +import { createServerToolHandler } from '@/lib/mothership/tools/registry/server-tool-adapter' +import { getRegisteredServerToolNames } from '@/lib/mothership/tools/server/router' +import { + executeRunBlock, + executeRunFromBlock, + executeRunWorkflow, + executeRunWorkflowUntilBlock, +} from '../tools/handlers/workflow/mutations' +import { registerHandlers } from './executor' +import type { ToolHandler } from './types' + +const logger = createLogger('ToolHandlerRegistration') + +let registered = false + +export function ensureHandlersRegistered(): void { + if (registered) return + registered = true + registerHandlers(buildHandlerMap()) + logger.info('Tool handlers registered') +} + +/** + * Bridge: handler implementations accept specific param types while ToolHandler accepts + * Record. The params are cast internally by each implementation. + */ +// biome-ignore lint/suspicious/noExplicitAny: intentional bridge — each handler narrows internally +function h(fn: (params: any, context: any) => Promise): ToolHandler { + return fn as ToolHandler +} + +/** + * EXACTLY the worker's emitted tool surface, nothing more (revamp M6): the four + * client-routed workflow-run tools (executed server-side on headless surfaces, in the + * browser on interactive ones) plus the sim-routed server tools from the trimmed + * registry. Integration/MCP calls dispatch through the main tools registry, not here. + */ +function buildHandlerMap(): Record { + return { + [RunWorkflow.id]: h(executeRunWorkflow), + [RunWorkflowUntilBlock.id]: h(executeRunWorkflowUntilBlock), + [RunFromBlock.id]: h(executeRunFromBlock), + [RunBlock.id]: h(executeRunBlock), + ...buildServerToolHandlers(), + } +} + +function buildServerToolHandlers(): Record { + const toolNames = getRegisteredServerToolNames() + const handlers: Record = {} + for (const toolId of toolNames) { + handlers[toolId] = createServerToolHandler(toolId) + } + return handlers +} diff --git a/apps/sim/lib/copilot/tool-executor/router.test.ts b/apps/sim/lib/mothership/tool-executor/router.test.ts similarity index 100% rename from apps/sim/lib/copilot/tool-executor/router.test.ts rename to apps/sim/lib/mothership/tool-executor/router.test.ts diff --git a/apps/sim/lib/copilot/tool-executor/router.ts b/apps/sim/lib/mothership/tool-executor/router.ts similarity index 95% rename from apps/sim/lib/copilot/tool-executor/router.ts rename to apps/sim/lib/mothership/tool-executor/router.ts index 257f4ab60a3..07089e40c85 100644 --- a/apps/sim/lib/copilot/tool-executor/router.ts +++ b/apps/sim/lib/mothership/tool-executor/router.ts @@ -1,4 +1,4 @@ -import { TOOL_CATALOG, type ToolCatalogEntry } from '@/lib/copilot/generated/tool-catalog-v1' +import { TOOL_CATALOG, type ToolCatalogEntry } from '@/lib/mothership/generated/tool-catalog-v1' import { isCopilotToolPermissionsEnabled } from '@/lib/core/config/env-flags' export function isToolInCatalog(toolId: string): boolean { diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/mothership/tool-executor/types.ts similarity index 96% rename from apps/sim/lib/copilot/tool-executor/types.ts rename to apps/sim/lib/mothership/tool-executor/types.ts index e781f5e36c2..21b23402842 100644 --- a/apps/sim/lib/copilot/tool-executor/types.ts +++ b/apps/sim/lib/mothership/tool-executor/types.ts @@ -1,6 +1,6 @@ import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' -import type { MothershipResourceUpdate } from '@/lib/copilot/resources/types' -import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' +import type { MothershipResource, MothershipResourceUpdate } from '@/lib/mothership/resources/types' +import type { SecretMountPolicy } from '@/lib/mothership/secret-mount-policy' import type { WorkspaceSearchFilters } from '@/lib/knowledge/search/filters' import type { ExecutorDelegationOrigin } from '@/executor/types' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' diff --git a/apps/sim/lib/copilot/tools/browser-protocol-contract.test.ts b/apps/sim/lib/mothership/tools/browser-protocol-contract.test.ts similarity index 95% rename from apps/sim/lib/copilot/tools/browser-protocol-contract.test.ts rename to apps/sim/lib/mothership/tools/browser-protocol-contract.test.ts index 2ac644f7c04..cc558e4a855 100644 --- a/apps/sim/lib/copilot/tools/browser-protocol-contract.test.ts +++ b/apps/sim/lib/mothership/tools/browser-protocol-contract.test.ts @@ -4,7 +4,7 @@ import { isCurrentBrowserToolName, } from '@sim/browser-protocol' import { describe, expect, it } from 'vitest' -import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1' +import { TOOL_CATALOG } from '@/lib/mothership/generated/tool-catalog-v1' const BROWSER_RESULT_SCHEMA_BASELINE = { covered: 13, total: 23 } as const diff --git a/apps/sim/lib/mothership/tools/cli-tool-display.ts b/apps/sim/lib/mothership/tools/cli-tool-display.ts new file mode 100644 index 00000000000..cd6cf5a588f --- /dev/null +++ b/apps/sim/lib/mothership/tools/cli-tool-display.ts @@ -0,0 +1,231 @@ +/** + * Display titles for CLI-backed copilot tools, keyed by the synthetic tool + * name the copilot worker derives from the sim CLI command path + * (`cli_`). + * + * Generated from the sim CLI command inventory; present-participle titles so + * the standard completed/failed tense projection in `tool-display.ts` applies. + * Regenerate when the CLI command surface changes. + */ +export const CLI_TOOL_TITLES: Record = { + cli_audit_logs_get: 'Reading audit log', + cli_audit_logs_list: 'Listing audit logs', + cli_billing_logs: 'Reading billing logs', + cli_billing_status: 'Checking billing status', + cli_blocks_get: 'Inspecting block', + cli_blocks_list: 'Browsing blocks', + cli_chat_deployments_list: 'Listing chat deployments', + cli_connector_types_list: 'Listing connector types', + cli_credentials_connect: 'Creating connection link', + cli_credentials_create: 'Creating credential', + cli_credentials_delete: 'Deleting credential', + cli_credentials_list: 'Listing credentials', + cli_credentials_providers_list: 'Listing credential providers', + cli_credentials_reconnect: 'Creating reconnection link', + cli_credentials_update: 'Updating credential', + cli_custom_tools_create: 'Creating custom tool', + cli_custom_tools_delete: 'Deleting custom tool', + cli_custom_tools_get: 'Reading custom tool', + cli_custom_tools_list: 'Listing custom tools', + cli_custom_tools_update: 'Updating custom tool', + cli_files_batch_delete: 'Deleting files', + cli_files_create: 'Creating file', + cli_files_delete: 'Deleting file', + cli_files_describe: 'Inspecting file', + cli_files_folders_create: 'Creating file folder', + cli_files_folders_delete: 'Deleting file folder', + cli_files_folders_list: 'Listing file folders', + cli_files_folders_move: 'Moving file folder', + cli_files_folders_restore: 'Restoring file folder', + cli_files_get: 'Reading file', + cli_files_list: 'Listing files', + cli_files_ls: 'Listing files', + cli_files_mkdir: 'Creating file', + cli_files_move: 'Moving file', + cli_files_read: 'Reading file', + cli_files_rename: 'Renaming file', + cli_files_restore: 'Restoring file', + cli_files_set_content: 'Writing file', + cli_files_share_get: 'Checking file sharing', + cli_files_share_set: 'Updating file sharing', + cli_files_unzip: 'Unzipping file', + cli_files_upload: 'Uploading file', + cli_knowledge_chunks_batch_update: 'Updating knowledge chunks', + cli_knowledge_chunks_create: 'Creating knowledge chunk', + cli_knowledge_chunks_delete: 'Deleting knowledge chunk', + cli_knowledge_chunks_get: 'Reading knowledge chunk', + cli_knowledge_chunks_list: 'Listing knowledge chunks', + cli_knowledge_chunks_update: 'Updating knowledge chunk', + cli_knowledge_connectors_create: 'Creating knowledge connector', + cli_knowledge_connectors_delete: 'Deleting knowledge connector', + cli_knowledge_connectors_documents_list: 'Listing connector documents', + cli_knowledge_connectors_documents_update: 'Updating connector documents', + cli_knowledge_connectors_get: 'Reading knowledge connector', + cli_knowledge_connectors_list: 'Listing knowledge connectors', + cli_knowledge_connectors_sync: 'Syncing knowledge connector', + cli_knowledge_connectors_update: 'Updating knowledge connector', + cli_knowledge_create: 'Creating knowledge base', + cli_knowledge_delete: 'Deleting knowledge base', + cli_knowledge_documents_batch_update: 'Updating documents', + cli_knowledge_documents_delete: 'Deleting document', + cli_knowledge_documents_get: 'Reading document', + cli_knowledge_documents_list: 'Listing documents', + cli_knowledge_documents_update: 'Updating document', + cli_knowledge_documents_upload: 'Uploading document', + cli_knowledge_folders_create: 'Creating knowledge folder', + cli_knowledge_folders_delete: 'Deleting knowledge folder', + cli_knowledge_folders_list: 'Listing knowledge folders', + cli_knowledge_folders_move: 'Moving knowledge folder', + cli_knowledge_from_workspace_files_create: 'Creating knowledge base', + cli_knowledge_get: 'Reading knowledge base', + cli_knowledge_list: 'Listing knowledge bases', + cli_knowledge_ls: 'Listing knowledge bases', + cli_knowledge_mkdir: 'Creating knowledge base', + cli_knowledge_mv: 'Moving knowledge base', + cli_knowledge_restore: 'Restoring knowledge base', + cli_knowledge_search: 'Searching knowledge base', + cli_knowledge_tags_cleanup: 'Cleaning up knowledge tags', + cli_knowledge_tags_create: 'Creating knowledge tag', + cli_knowledge_tags_delete: 'Deleting knowledge tag', + cli_knowledge_tags_list: 'Listing knowledge tags', + cli_knowledge_tags_next_slot: 'Checking knowledge tag slots', + cli_knowledge_tags_save: 'Saving knowledge tags', + cli_knowledge_tags_update: 'Updating knowledge tag', + cli_knowledge_tags_usage: 'Summarizing knowledge tag usage', + cli_knowledge_update: 'Updating knowledge base', + cli_logs_follow: 'Following run logs', + cli_logs_get: 'Reading run log', + cli_logs_list: 'Listing run logs', + cli_logs_stats: 'Summarizing run logs', + cli_mcp_servers_create: 'Creating MCP server', + cli_mcp_servers_delete: 'Deleting MCP server', + cli_mcp_servers_get: 'Reading MCP server', + cli_mcp_servers_list: 'Listing MCP servers', + cli_mcp_servers_tools_list: 'Listing MCP server tools', + cli_mcp_servers_update: 'Updating MCP server', + cli_meta_status: 'Checking platform status', + cli_secrets_delete: 'Deleting secret', + cli_secrets_list: 'Listing secrets', + cli_secrets_set: 'Saving secret', + cli_skills_create: 'Creating skill', + cli_skills_delete: 'Deleting skill', + cli_skills_editors_create: 'Granting skill editor', + cli_skills_editors_delete: 'Revoking skill editor', + cli_skills_editors_list: 'Listing skill editors', + cli_skills_get: 'Reading skill', + cli_skills_list: 'Listing skills', + cli_skills_update: 'Updating skill', + cli_tables_batch_delete: 'Deleting tables', + cli_tables_cancel_runs: 'Cancelling table runs', + cli_tables_columns_create: 'Creating table column', + cli_tables_columns_delete: 'Deleting table column', + cli_tables_columns_update: 'Updating table column', + cli_tables_create: 'Creating table', + cli_tables_delete: 'Deleting table', + cli_tables_dispatches_cancel: 'Cancelling table run', + cli_tables_dispatches_create: 'Starting table run', + cli_tables_dispatches_get: 'Reading table run', + cli_tables_dispatches_list: 'Listing table runs', + cli_tables_enrichment_get: 'Reading enrichment run', + cli_tables_exports_cancel: 'Cancelling table export', + cli_tables_exports_create: 'Creating table export', + cli_tables_exports_download: 'Downloading table export', + cli_tables_exports_get: 'Reading table export', + cli_tables_folders_create: 'Creating table folder', + cli_tables_folders_delete: 'Deleting table folder', + cli_tables_folders_list: 'Listing table folders', + cli_tables_folders_move: 'Moving table folder', + cli_tables_folders_restore: 'Restoring table folder', + cli_tables_get: 'Reading table', + cli_tables_groups_create: 'Creating workflow group', + cli_tables_groups_delete: 'Deleting workflow group', + cli_tables_groups_list: 'Listing workflow groups', + cli_tables_groups_update: 'Updating workflow group', + cli_tables_import: 'Importing table', + cli_tables_imports_cancel: 'Cancelling table import', + cli_tables_imports_get: 'Reading table import', + cli_tables_list: 'Listing tables', + cli_tables_ls: 'Listing tables', + cli_tables_mkdir: 'Creating table', + cli_tables_move: 'Moving table', + cli_tables_mv: 'Moving table', + cli_tables_restore: 'Restoring table', + cli_tables_rows_batch_delete: 'Deleting table rows', + cli_tables_rows_batch_update: 'Updating table rows', + cli_tables_rows_count: 'Counting table rows', + cli_tables_rows_create: 'Creating table row', + cli_tables_rows_delete: 'Deleting table row', + cli_tables_rows_enrich: 'Enriching table row', + cli_tables_rows_get: 'Reading table row', + cli_tables_rows_list: 'Listing table rows', + cli_tables_rows_query: 'Querying table rows', + cli_tables_rows_search: 'Searching table rows', + cli_tables_rows_update: 'Updating table row', + cli_tables_rows_update_each: 'Updating table rows', + cli_tables_update: 'Updating table', + cli_tables_upsert: 'Updating table', + cli_tables_views_create: 'Creating table view', + cli_tables_views_delete: 'Deleting table view', + cli_tables_views_get: 'Reading table view', + cli_tables_views_list: 'Listing table views', + cli_tables_views_update: 'Updating table view', + cli_tools_get: 'Inspecting tool', + cli_tools_list: 'Browsing tools', + cli_workflow_mcp_servers_create: 'Creating workflow MCP server', + cli_workflow_mcp_servers_delete: 'Deleting workflow MCP server', + cli_workflow_mcp_servers_get: 'Reading workflow MCP server', + cli_workflow_mcp_servers_list: 'Listing workflow MCP servers', + cli_workflow_mcp_servers_tools_create: 'Publishing workflow as MCP tool', + cli_workflow_mcp_servers_tools_delete: 'Unpublishing workflow MCP tool', + cli_workflow_mcp_servers_tools_list: 'Listing workflow MCP tools', + cli_workflow_mcp_servers_update: 'Updating workflow MCP server', + cli_workflows_activate_create: 'Activating workflow version', + cli_workflows_chat_publish: 'Publishing chat deployment', + cli_workflows_chat_status: 'Checking chat deployment', + cli_workflows_chat_unpublish: 'Unpublishing chat deployment', + cli_workflows_create: 'Creating workflow', + cli_workflows_delete: 'Deleting workflow', + cli_workflows_deploy: 'Deploying workflow', + cli_workflows_deployment_status: 'Checking deployment status', + cli_workflows_deployment_update: 'Updating API access', + cli_workflows_duplicate_create: 'Duplicating workflow', + cli_workflows_export: 'Exporting workflow', + cli_workflows_folders_create: 'Creating workflow folder', + cli_workflows_folders_delete: 'Deleting workflow folder', + cli_workflows_folders_list: 'Listing workflow folders', + cli_workflows_folders_move: 'Moving workflow folder', + cli_workflows_get: 'Reading workflow', + cli_workflows_import: 'Importing workflow', + cli_workflows_list: 'Listing workflows', + cli_workflows_ls: 'Listing workflows', + cli_workflows_mkdir: 'Creating workflow', + cli_workflows_move: 'Moving workflow', + cli_workflows_mv: 'Moving workflow', + cli_workflows_operations_apply: 'Editing workflow', + cli_workflows_restore: 'Restoring workflow', + cli_workflows_revert_create: 'Reverting workflow', + cli_workflows_rollback: 'Rolling back workflow', + cli_workflows_run: 'Running workflow', + cli_workflows_runs_cancel: 'Cancelling workflow run', + cli_workflows_runs_get: 'Checking workflow run', + cli_workflows_runs_list: 'Listing workflow runs', + cli_workflows_runs_resume: 'Resuming workflow run', + cli_workflows_runs_wait: 'Waiting for workflow run', + cli_workflows_state_get: 'Reading workflow', + cli_workflows_state_replace: 'Rewriting workflow', + cli_workflows_undeploy: 'Undeploying workflow', + cli_workflows_update: 'Updating workflow', + cli_workflows_variables_update: 'Updating workflow variables', + cli_workflows_versions_get: 'Reading workflow version', + cli_workflows_versions_list: 'Listing workflow versions', + cli_workflows_versions_update: 'Updating workflow version', + cli_workspaces_get: 'Reading workspace', + cli_workspaces_list: 'Listing workspaces', + cli_workspaces_members: 'Listing workspace members', + // Non-CLI copilot worker tools + cli_help: 'Checking CLI reference', + sim_cli: 'Running CLI command', + run_code: 'Running code', + load_skill: 'Loading skill', + task: 'Delegating task', +} diff --git a/apps/sim/lib/copilot/tools/client/base-tool.ts b/apps/sim/lib/mothership/tools/client/base-tool.ts similarity index 100% rename from apps/sim/lib/copilot/tools/client/base-tool.ts rename to apps/sim/lib/mothership/tools/client/base-tool.ts diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts b/apps/sim/lib/mothership/tools/client/browser-tool-execution.test.ts similarity index 99% rename from apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts rename to apps/sim/lib/mothership/tools/client/browser-tool-execution.test.ts index a4ee6e8a4f3..b028c19d7b6 100644 --- a/apps/sim/lib/copilot/tools/client/browser-tool-execution.test.ts +++ b/apps/sim/lib/mothership/tools/client/browser-tool-execution.test.ts @@ -24,13 +24,13 @@ vi.mock('@/lib/browser-agent/transport', () => ({ executeBrowserTool: mockExecuteBrowserTool, restoreBrowserScope: mockRestoreBrowserScope, })) -vi.mock('@/lib/copilot/tools/client/completion', () => ({ +vi.mock('@/lib/mothership/tools/client/completion', () => ({ reportClientToolCompletion: mockReportCompletion, reportClientToolCompletionOnPageExit: mockReportCompletionOnPageExit, })) -import { executeBrowserToolOnClient } from '@/lib/copilot/tools/client/browser-tool-execution' -import { BrowserToolReplayLedger } from '@/lib/copilot/tools/client/browser-tool-replay-ledger' +import { executeBrowserToolOnClient } from '@/lib/mothership/tools/client/browser-tool-execution' +import { BrowserToolReplayLedger } from '@/lib/mothership/tools/client/browser-tool-replay-ledger' import { useBrowserSessionStore } from '@/stores/browser-session/store' const CHAT_SCOPE = 'chat-test' diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts b/apps/sim/lib/mothership/tools/client/browser-tool-execution.ts similarity index 98% rename from apps/sim/lib/copilot/tools/client/browser-tool-execution.ts rename to apps/sim/lib/mothership/tools/client/browser-tool-execution.ts index 1b3bc43d247..71f22b9d7e6 100644 --- a/apps/sim/lib/copilot/tools/client/browser-tool-execution.ts +++ b/apps/sim/lib/mothership/tools/client/browser-tool-execution.ts @@ -21,14 +21,14 @@ import { ASYNC_TOOL_CONFIRMATION_STATUS, type AsyncCompletionData, type AsyncConfirmationStatus, -} from '@/lib/copilot/async-runs/lifecycle' -import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants' -import { BrowserToolReplayLedger } from '@/lib/copilot/tools/client/browser-tool-replay-ledger' -import { sanitizeBrowserToolResultForModel } from '@/lib/copilot/tools/client/browser-tool-result' +} from '@/lib/mothership/async-runs/lifecycle' +import { COPILOT_CONFIRM_API_PATH } from '@/lib/mothership/constants' +import { BrowserToolReplayLedger } from '@/lib/mothership/tools/client/browser-tool-replay-ledger' +import { sanitizeBrowserToolResultForModel } from '@/lib/mothership/tools/client/browser-tool-result' import { reportClientToolCompletion, reportClientToolCompletionOnPageExit, -} from '@/lib/copilot/tools/client/completion' +} from '@/lib/mothership/tools/client/completion' import { getBrowserSession, useBrowserSessionStore } from '@/stores/browser-session/store' const logger = createLogger('CopilotBrowserToolExecution') diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-replay-ledger.test.ts b/apps/sim/lib/mothership/tools/client/browser-tool-replay-ledger.test.ts similarity index 100% rename from apps/sim/lib/copilot/tools/client/browser-tool-replay-ledger.test.ts rename to apps/sim/lib/mothership/tools/client/browser-tool-replay-ledger.test.ts diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-replay-ledger.ts b/apps/sim/lib/mothership/tools/client/browser-tool-replay-ledger.ts similarity index 100% rename from apps/sim/lib/copilot/tools/client/browser-tool-replay-ledger.ts rename to apps/sim/lib/mothership/tools/client/browser-tool-replay-ledger.ts diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-result.test.ts b/apps/sim/lib/mothership/tools/client/browser-tool-result.test.ts similarity index 97% rename from apps/sim/lib/copilot/tools/client/browser-tool-result.test.ts rename to apps/sim/lib/mothership/tools/client/browser-tool-result.test.ts index fee5723b4e0..a0bac1d6f2c 100644 --- a/apps/sim/lib/copilot/tools/client/browser-tool-result.test.ts +++ b/apps/sim/lib/mothership/tools/client/browser-tool-result.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { sanitizeBrowserToolResultForModel } from '@/lib/copilot/tools/client/browser-tool-result' +import { sanitizeBrowserToolResultForModel } from '@/lib/mothership/tools/client/browser-tool-result' describe('browser screenshot model projection', () => { it('keeps an image usable when an older desktop omits coordinate metadata', () => { diff --git a/apps/sim/lib/copilot/tools/client/browser-tool-result.ts b/apps/sim/lib/mothership/tools/client/browser-tool-result.ts similarity index 100% rename from apps/sim/lib/copilot/tools/client/browser-tool-result.ts rename to apps/sim/lib/mothership/tools/client/browser-tool-result.ts diff --git a/apps/sim/lib/copilot/tools/client/completion.test.ts b/apps/sim/lib/mothership/tools/client/completion.test.ts similarity index 98% rename from apps/sim/lib/copilot/tools/client/completion.test.ts rename to apps/sim/lib/mothership/tools/client/completion.test.ts index fcd7a1db286..ee0f6beff9a 100644 --- a/apps/sim/lib/copilot/tools/client/completion.test.ts +++ b/apps/sim/lib/mothership/tools/client/completion.test.ts @@ -6,7 +6,7 @@ import { CompletionReportError, reportClientToolCompletion, reportClientToolCompletionOnPageExit, -} from '@/lib/copilot/tools/client/completion' +} from '@/lib/mothership/tools/client/completion' describe('client tool completion reporting', () => { const fetchMock = vi.fn() diff --git a/apps/sim/lib/copilot/tools/client/completion.ts b/apps/sim/lib/mothership/tools/client/completion.ts similarity index 95% rename from apps/sim/lib/copilot/tools/client/completion.ts rename to apps/sim/lib/mothership/tools/client/completion.ts index ec095db8e8e..0cd9fae1e44 100644 --- a/apps/sim/lib/copilot/tools/client/completion.ts +++ b/apps/sim/lib/mothership/tools/client/completion.ts @@ -6,9 +6,9 @@ import { backoffWithJitter } from '@sim/utils/retry' import type { AsyncCompletionData, AsyncConfirmationStatus, -} from '@/lib/copilot/async-runs/lifecycle' -import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants' -import { traceparentHeader } from '@/lib/copilot/tools/client/trace-context' +} from '@/lib/mothership/async-runs/lifecycle' +import { COPILOT_CONFIRM_API_PATH } from '@/lib/mothership/constants' +import { traceparentHeader } from '@/lib/mothership/tools/client/trace-context' const logger = createLogger('CopilotClientToolCompletion') const COMPLETION_REPORT_ATTEMPT_TIMEOUT_MS = 15_000 diff --git a/apps/sim/lib/copilot/tools/client/hidden-tools.test.ts b/apps/sim/lib/mothership/tools/client/hidden-tools.test.ts similarity index 100% rename from apps/sim/lib/copilot/tools/client/hidden-tools.test.ts rename to apps/sim/lib/mothership/tools/client/hidden-tools.test.ts diff --git a/apps/sim/lib/copilot/tools/client/hidden-tools.ts b/apps/sim/lib/mothership/tools/client/hidden-tools.ts similarity index 100% rename from apps/sim/lib/copilot/tools/client/hidden-tools.ts rename to apps/sim/lib/mothership/tools/client/hidden-tools.ts diff --git a/apps/sim/lib/copilot/tools/client/local-filesystem.test.ts b/apps/sim/lib/mothership/tools/client/local-filesystem.test.ts similarity index 97% rename from apps/sim/lib/copilot/tools/client/local-filesystem.test.ts rename to apps/sim/lib/mothership/tools/client/local-filesystem.test.ts index 2bd8749aaa3..eee58c57adc 100644 --- a/apps/sim/lib/copilot/tools/client/local-filesystem.test.ts +++ b/apps/sim/lib/mothership/tools/client/local-filesystem.test.ts @@ -7,11 +7,11 @@ const { mockReportCompletion } = vi.hoisted(() => ({ mockReportCompletion: vi.fn(), })) -vi.mock('@/lib/copilot/tools/client/completion', () => ({ +vi.mock('@/lib/mothership/tools/client/completion', () => ({ reportClientToolCompletion: mockReportCompletion, })) -import { executeLocalFilesystemTool } from '@/lib/copilot/tools/client/local-filesystem' +import { executeLocalFilesystemTool } from '@/lib/mothership/tools/client/local-filesystem' const mount = { id: 'mount-1', diff --git a/apps/sim/lib/copilot/tools/client/local-filesystem.ts b/apps/sim/lib/mothership/tools/client/local-filesystem.ts similarity index 97% rename from apps/sim/lib/copilot/tools/client/local-filesystem.ts rename to apps/sim/lib/mothership/tools/client/local-filesystem.ts index ba6798eab85..2c790c7e20e 100644 --- a/apps/sim/lib/copilot/tools/client/local-filesystem.ts +++ b/apps/sim/lib/mothership/tools/client/local-filesystem.ts @@ -15,11 +15,11 @@ import { import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import micromatch from 'micromatch' -import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle' -import { reportClientToolCompletion } from '@/lib/copilot/tools/client/completion' -import { USER_LOCAL_VFS_ROOT } from '@/lib/copilot/tools/local-filesystem' -import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' import { getDesktopBridge } from '@/lib/desktop' +import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/mothership/async-runs/lifecycle' +import { reportClientToolCompletion } from '@/lib/mothership/tools/client/completion' +import { USER_LOCAL_VFS_ROOT } from '@/lib/mothership/tools/local-filesystem' +import { encodeVfsSegment } from '@/lib/mothership/vfs/path-utils' const logger = createLogger('CopilotLocalFilesystemTool') /** diff --git a/apps/sim/lib/copilot/tools/client/read-block.test.ts b/apps/sim/lib/mothership/tools/client/read-block.test.ts similarity index 95% rename from apps/sim/lib/copilot/tools/client/read-block.test.ts rename to apps/sim/lib/mothership/tools/client/read-block.test.ts index 66bfbee7704..be254be10e2 100644 --- a/apps/sim/lib/copilot/tools/client/read-block.test.ts +++ b/apps/sim/lib/mothership/tools/client/read-block.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' -import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' +import { getReadTargetBlock } from '@/lib/mothership/tools/client/read-block' const gmailBlock = { type: 'gmail_v2', name: 'Gmail', icon: () => null } const customBlock = { diff --git a/apps/sim/lib/copilot/tools/client/read-block.ts b/apps/sim/lib/mothership/tools/client/read-block.ts similarity index 100% rename from apps/sim/lib/copilot/tools/client/read-block.ts rename to apps/sim/lib/mothership/tools/client/read-block.ts diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts b/apps/sim/lib/mothership/tools/client/run-tool-execution.test.ts similarity index 100% rename from apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts rename to apps/sim/lib/mothership/tools/client/run-tool-execution.test.ts diff --git a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts b/apps/sim/lib/mothership/tools/client/run-tool-execution.ts similarity index 98% rename from apps/sim/lib/copilot/tools/client/run-tool-execution.ts rename to apps/sim/lib/mothership/tools/client/run-tool-execution.ts index 99ca035e6c2..488e4b231a2 100644 --- a/apps/sim/lib/copilot/tools/client/run-tool-execution.ts +++ b/apps/sim/lib/mothership/tools/client/run-tool-execution.ts @@ -5,27 +5,27 @@ import { isPlainRecord } from '@sim/utils/object' import { ASYNC_TOOL_CONFIRMATION_STATUS, type AsyncConfirmationStatus, -} from '@/lib/copilot/async-runs/lifecycle' +} from '@/lib/mothership/async-runs/lifecycle' import { COPILOT_CONFIRM_API_PATH, COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE, -} from '@/lib/copilot/constants' -import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' +} from '@/lib/mothership/constants' +import { MothershipStreamV1ToolOutcome } from '@/lib/mothership/generated/mothership-stream-v1' import { RunBlock, RunFromBlock, RunWorkflow, RunWorkflowUntilBlock, -} from '@/lib/copilot/generated/tool-catalog-v1' +} from '@/lib/mothership/generated/tool-catalog-v1' import { CompletionReportError, reportClientToolCompletion as reportCompletion, -} from '@/lib/copilot/tools/client/completion' +} from '@/lib/mothership/tools/client/completion' import { type AsyncWorkflowDeploymentError, getAsyncWorkflowDeploymentError, getWorkflowToolCompletionMessage, -} from '@/lib/copilot/tools/workflow-tools' +} from '@/lib/mothership/tools/workflow-tools' import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils' import { isExecutionStreamHttpError, diff --git a/apps/sim/lib/copilot/tools/client/store-utils.test.ts b/apps/sim/lib/mothership/tools/client/store-utils.test.ts similarity index 99% rename from apps/sim/lib/copilot/tools/client/store-utils.test.ts rename to apps/sim/lib/mothership/tools/client/store-utils.test.ts index 3cf17f8d0a3..90369535b4e 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.test.ts +++ b/apps/sim/lib/mothership/tools/client/store-utils.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' +import { Read as ReadTool } from '@/lib/mothership/generated/tool-catalog-v1' import { resolveToolDisplay } from './store-utils' import { ClientToolCallState } from './tool-call-state' diff --git a/apps/sim/lib/copilot/tools/client/store-utils.ts b/apps/sim/lib/mothership/tools/client/store-utils.ts similarity index 94% rename from apps/sim/lib/copilot/tools/client/store-utils.ts rename to apps/sim/lib/mothership/tools/client/store-utils.ts index e101236c8e8..df5540a4cd8 100644 --- a/apps/sim/lib/copilot/tools/client/store-utils.ts +++ b/apps/sim/lib/mothership/tools/client/store-utils.ts @@ -1,13 +1,13 @@ import type { ComponentType } from 'react' import { Loader } from '@sim/emcn' import { FileText } from '@sim/emcn/icons' -import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' -import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' -import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' -import { getReadTargetBlock } from '@/lib/copilot/tools/client/read-block' -import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state' -import { humanizeDisplayIdentifier, humanizeToolName } from '@/lib/copilot/tools/tool-display' -import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils' +import { Read as ReadTool } from '@/lib/mothership/generated/tool-catalog-v1' +import { VFS_DIR_TO_RESOURCE } from '@/lib/mothership/resources/types' +import { isToolHiddenInUi } from '@/lib/mothership/tools/client/hidden-tools' +import { getReadTargetBlock } from '@/lib/mothership/tools/client/read-block' +import { ClientToolCallState } from '@/lib/mothership/tools/client/tool-call-state' +import { humanizeDisplayIdentifier, humanizeToolName } from '@/lib/mothership/tools/tool-display' +import { decodeVfsSegmentSafe } from '@/lib/mothership/vfs/path-utils' /** Respond tools are internal handoff tools shown with a friendly generic label. */ const HIDDEN_TOOL_SUFFIX = '_respond' diff --git a/apps/sim/lib/copilot/tools/client/terminal-tool-execution.test.ts b/apps/sim/lib/mothership/tools/client/terminal-tool-execution.test.ts similarity index 89% rename from apps/sim/lib/copilot/tools/client/terminal-tool-execution.test.ts rename to apps/sim/lib/mothership/tools/client/terminal-tool-execution.test.ts index 3421f3903dc..3cf4cfc1a2b 100644 --- a/apps/sim/lib/copilot/tools/client/terminal-tool-execution.test.ts +++ b/apps/sim/lib/mothership/tools/client/terminal-tool-execution.test.ts @@ -11,9 +11,9 @@ const { executeTerminalTool, reportClientToolCompletion } = vi.hoisted(() => ({ })) vi.mock('@/lib/terminal/transport', () => ({ executeTerminalTool })) -vi.mock('@/lib/copilot/tools/client/completion', () => ({ reportClientToolCompletion })) +vi.mock('@/lib/mothership/tools/client/completion', () => ({ reportClientToolCompletion })) -import { executeTerminalToolOnClient } from '@/lib/copilot/tools/client/terminal-tool-execution' +import { executeTerminalToolOnClient } from '@/lib/mothership/tools/client/terminal-tool-execution' describe('terminal client execution', () => { beforeEach(() => { diff --git a/apps/sim/lib/copilot/tools/client/terminal-tool-execution.ts b/apps/sim/lib/mothership/tools/client/terminal-tool-execution.ts similarity index 96% rename from apps/sim/lib/copilot/tools/client/terminal-tool-execution.ts rename to apps/sim/lib/mothership/tools/client/terminal-tool-execution.ts index caa1acf86a9..8cb295c85ca 100644 --- a/apps/sim/lib/copilot/tools/client/terminal-tool-execution.ts +++ b/apps/sim/lib/mothership/tools/client/terminal-tool-execution.ts @@ -16,9 +16,9 @@ import { } from '@sim/terminal-protocol' import { toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' -import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/copilot/async-runs/lifecycle' -import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants' -import { reportClientToolCompletion } from '@/lib/copilot/tools/client/completion' +import { ASYNC_TOOL_CONFIRMATION_STATUS } from '@/lib/mothership/async-runs/lifecycle' +import { COPILOT_CONFIRM_API_PATH } from '@/lib/mothership/constants' +import { reportClientToolCompletion } from '@/lib/mothership/tools/client/completion' import { executeTerminalTool } from '@/lib/terminal/transport' const logger = createLogger('CopilotTerminalToolExecution') diff --git a/apps/sim/lib/copilot/tools/client/tool-call-state.ts b/apps/sim/lib/mothership/tools/client/tool-call-state.ts similarity index 100% rename from apps/sim/lib/copilot/tools/client/tool-call-state.ts rename to apps/sim/lib/mothership/tools/client/tool-call-state.ts diff --git a/apps/sim/lib/copilot/tools/client/trace-context.ts b/apps/sim/lib/mothership/tools/client/trace-context.ts similarity index 100% rename from apps/sim/lib/copilot/tools/client/trace-context.ts rename to apps/sim/lib/mothership/tools/client/trace-context.ts diff --git a/apps/sim/lib/copilot/tools/descriptions.test.ts b/apps/sim/lib/mothership/tools/descriptions.test.ts similarity index 100% rename from apps/sim/lib/copilot/tools/descriptions.test.ts rename to apps/sim/lib/mothership/tools/descriptions.test.ts diff --git a/apps/sim/lib/copilot/tools/descriptions.ts b/apps/sim/lib/mothership/tools/descriptions.ts similarity index 100% rename from apps/sim/lib/copilot/tools/descriptions.ts rename to apps/sim/lib/mothership/tools/descriptions.ts diff --git a/apps/sim/lib/copilot/tools/handlers/context.ts b/apps/sim/lib/mothership/tools/handlers/context.ts similarity index 92% rename from apps/sim/lib/copilot/tools/handlers/context.ts rename to apps/sim/lib/mothership/tools/handlers/context.ts index 06f1c05716c..b548f8cc49d 100644 --- a/apps/sim/lib/copilot/tools/handlers/context.ts +++ b/apps/sim/lib/mothership/tools/handlers/context.ts @@ -6,8 +6,8 @@ import { import { type CopilotEnvironmentContext, prepareCopilotEnvironmentContext, -} from '@/lib/copilot/environment-context' -import type { ExecutionContext } from '@/lib/copilot/request/types' +} from '@/lib/mothership/environment-context' +import type { ExecutionContext } from '@/lib/mothership/request/types' import { getWorkflowById } from '@/lib/workflows/utils' export async function prepareExecutionContext( diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/mothership/tools/handlers/param-types.ts similarity index 99% rename from apps/sim/lib/copilot/tools/handlers/param-types.ts rename to apps/sim/lib/mothership/tools/handlers/param-types.ts index 7a4cebb85c5..f562a1dd7f8 100644 --- a/apps/sim/lib/copilot/tools/handlers/param-types.ts +++ b/apps/sim/lib/mothership/tools/handlers/param-types.ts @@ -3,7 +3,7 @@ * Replaces Record with specific shapes based on actual property access. */ -import type { MothershipResourceType } from '@/lib/copilot/resources/types' +import type { MothershipResourceType } from '@/lib/mothership/resources/types' // === Workflow Query Params === diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts similarity index 97% rename from apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts rename to apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts index f7f5b13be2d..962017ef406 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts @@ -2,8 +2,8 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { ExecutionContext } from '@/lib/copilot/request/types' -import type { CancelWorkflowRunParams } from '@/lib/copilot/tools/handlers/param-types' +import type { ExecutionContext } from '@/lib/mothership/request/types' +import type { CancelWorkflowRunParams } from '@/lib/mothership/tools/handlers/param-types' import { WorkflowRunAlreadyTerminalError } from '@/lib/execution/workflow-run-already-terminal-error' const { mocks } = vi.hoisted(() => ({ @@ -15,13 +15,13 @@ const { mocks } = vi.hoisted(() => ({ }, })) -vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ +vi.mock('@/lib/mothership/application/execute-workflow-use-case', () => ({ executeCopilotWorkflowUseCase: mocks.executeWorkflowUseCase, messageForCopilotWorkflowError: (error: unknown, fallback = 'Workflow operation failed') => error instanceof Error && 'code' in error ? error.message : fallback, })) -vi.mock('@/lib/copilot/application/execute-api-key-use-case', () => ({ +vi.mock('@/lib/mothership/application/execute-api-key-use-case', () => ({ executeCopilotApiKeyUseCase: mocks.apiKey, })) @@ -61,7 +61,7 @@ import { executeRunWorkflow, executeRunWorkflowUntilBlock, executeSetGlobalWorkflowVariables, -} from '@/lib/copilot/tools/handlers/workflow/mutations' +} from '@/lib/mothership/tools/handlers/workflow/mutations' const context = { userId: 'user-1', diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts similarity index 97% rename from apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts rename to apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts index 5b5e9487f86..4d91e01a8a9 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts @@ -1,19 +1,20 @@ import { createLogger } from '@sim/logger' import { createCopilotWorkspaceApiKey } from '@/lib/api-key/application/create-api-key' -import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' -import { executeCopilotApiKeyUseCase } from '@/lib/copilot/application/execute-api-key-use-case' +import { PlatformEvents } from '@/lib/core/telemetry' +import { messageForCopilotApplicationError } from '@/lib/mothership/application/error' +import { executeCopilotApiKeyUseCase } from '@/lib/mothership/application/execute-api-key-use-case' import { executeCopilotWorkflowUseCase, messageForCopilotWorkflowError, -} from '@/lib/copilot/application/execute-workflow-use-case' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +} from '@/lib/mothership/application/execute-workflow-use-case' +import type { ExecutionContext, ToolCallResult } from '@/lib/mothership/request/types' import { TOOL_EFFECT_PHASE, type ToolCallEffect, type ToolEffectPhase, -} from '@/lib/copilot/tool-executor/types' -import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope' -import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +} from '@/lib/mothership/tool-executor/types' +import { requireCopilotWorkspace } from '@/lib/mothership/tools/server/workspace-scope' +import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/mothership/vfs/path-utils' import { PlatformEvents } from '@/lib/core/telemetry' import { cancelWorkflowRun } from '@/lib/workflows/application/cancel-run' import { createWorkflow } from '@/lib/workflows/application/create-workflow' diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/withheld-run-result.test.ts b/apps/sim/lib/mothership/tools/handlers/workflow/withheld-run-result.test.ts similarity index 95% rename from apps/sim/lib/copilot/tools/handlers/workflow/withheld-run-result.test.ts rename to apps/sim/lib/mothership/tools/handlers/workflow/withheld-run-result.test.ts index e11564513e6..6569a3033e6 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/withheld-run-result.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/workflow/withheld-run-result.test.ts @@ -18,15 +18,15 @@ */ import { getErrorMessage } from '@sim/utils/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { inspectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' -import type { ExecutionContext } from '@/lib/copilot/request/types' -import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { inspectToolResultForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result' +import type { ExecutionContext } from '@/lib/mothership/request/types' +import type { ToolExecutionResult } from '@/lib/mothership/tool-executor/types' import { attachAttemptedExecutionId } from '@/executor/utils/errors' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const { mocks } = vi.hoisted(() => ({ mocks: { executeWorkflowUseCase: vi.fn() } })) -vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({ +vi.mock('@/lib/mothership/application/execute-workflow-use-case', () => ({ executeCopilotWorkflowUseCase: mocks.executeWorkflowUseCase, /** Passthrough, so a masked message reads as masking rather than as a fallback. */ messageForCopilotWorkflowError: (error: unknown, fallback = 'Workflow operation failed') => @@ -52,7 +52,7 @@ vi.mock('@/lib/workflows/orchestration', () => ({ performCreateWorkflowTransitio vi.mock('@/lib/core/telemetry', () => ({ PlatformEvents: { apiKeyGenerated: vi.fn() } })) -import { executeRunWorkflow } from '@/lib/copilot/tools/handlers/workflow/mutations' +import { executeRunWorkflow } from '@/lib/mothership/tools/handlers/workflow/mutations' const EXECUTION_ID = '0f4d5a4c-6a1e-4c2f-9b7d-2c8f1a3e5d90' /** diff --git a/apps/sim/lib/copilot/tools/local-filesystem.ts b/apps/sim/lib/mothership/tools/local-filesystem.ts similarity index 100% rename from apps/sim/lib/copilot/tools/local-filesystem.ts rename to apps/sim/lib/mothership/tools/local-filesystem.ts diff --git a/apps/sim/lib/copilot/tools/permissions.test.ts b/apps/sim/lib/mothership/tools/permissions.test.ts similarity index 97% rename from apps/sim/lib/copilot/tools/permissions.test.ts rename to apps/sim/lib/mothership/tools/permissions.test.ts index 3edfc033dff..eb234607d8c 100644 --- a/apps/sim/lib/copilot/tools/permissions.test.ts +++ b/apps/sim/lib/mothership/tools/permissions.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { copilotToolCanWrite, copilotWriteDeniedMessage } from '@/lib/copilot/tools/permissions' +import { copilotToolCanWrite, copilotWriteDeniedMessage } from '@/lib/mothership/tools/permissions' describe('copilotToolCanWrite', () => { it('fails closed when the permission is absent', () => { diff --git a/apps/sim/lib/copilot/tools/permissions.ts b/apps/sim/lib/mothership/tools/permissions.ts similarity index 100% rename from apps/sim/lib/copilot/tools/permissions.ts rename to apps/sim/lib/mothership/tools/permissions.ts diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts b/apps/sim/lib/mothership/tools/registry/server-tool-adapter.test.ts similarity index 90% rename from apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts rename to apps/sim/lib/mothership/tools/registry/server-tool-adapter.test.ts index 11462df50a0..04152b2f7fd 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts +++ b/apps/sim/lib/mothership/tools/registry/server-tool-adapter.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { TOOL_RESULT_UNAVAILABLE_ERROR } from '@/lib/copilot/request/tools/resolved-secret-result' +import { TOOL_RESULT_UNAVAILABLE_ERROR } from '@/lib/mothership/request/tools/resolved-secret-result' const mocks = vi.hoisted(() => ({ loggerError: vi.fn(), @@ -13,9 +13,9 @@ vi.mock('@sim/logger', () => ({ createLogger: () => ({ error: mocks.loggerError }), })) -vi.mock('@/lib/copilot/tools/server/router', () => ({ routeExecution: mocks.routeExecution })) +vi.mock('@/lib/mothership/tools/server/router', () => ({ routeExecution: mocks.routeExecution })) -import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' +import { createServerToolHandler } from '@/lib/mothership/tools/registry/server-tool-adapter' describe('server tool adapter authority boundary', () => { beforeEach(() => { diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts b/apps/sim/lib/mothership/tools/registry/server-tool-adapter.ts similarity index 87% rename from apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts rename to apps/sim/lib/mothership/tools/registry/server-tool-adapter.ts index f7c37bbf2bd..71ad6954a25 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts +++ b/apps/sim/lib/mothership/tools/registry/server-tool-adapter.ts @@ -1,10 +1,10 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' -import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' -import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' -import type { ToolExecutionResult, ToolHandler } from '@/lib/copilot/tool-executor/types' -import { routeExecution } from '@/lib/copilot/tools/server/router' +import { messageForCopilotApplicationError } from '@/lib/mothership/application/error' +import { projectToolErrorMessageForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result' +import type { ToolExecutionResult, ToolHandler } from '@/lib/mothership/tool-executor/types' +import { routeExecution } from '@/lib/mothership/tools/server/router' const logger = createLogger('ServerToolAdapter') diff --git a/apps/sim/lib/copilot/tools/retired-tools.ts b/apps/sim/lib/mothership/tools/retired-tools.ts similarity index 100% rename from apps/sim/lib/copilot/tools/retired-tools.ts rename to apps/sim/lib/mothership/tools/retired-tools.ts diff --git a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts b/apps/sim/lib/mothership/tools/secret-mount-materializer.server.test.ts similarity index 99% rename from apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts rename to apps/sim/lib/mothership/tools/secret-mount-materializer.server.test.ts index ad9329fe4d7..96db07dd29a 100644 --- a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.test.ts +++ b/apps/sim/lib/mothership/tools/secret-mount-materializer.server.test.ts @@ -26,7 +26,7 @@ import { MAX_SECRET_MOUNT_NAME_LENGTH, MAX_SECRET_MOUNT_NAMES, materializeCopilotCodeSecrets, -} from '@/lib/copilot/tools/secret-mount-materializer.server' +} from '@/lib/mothership/tools/secret-mount-materializer.server' interface CredentialRow { type: 'env_personal' | 'env_workspace' diff --git a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts b/apps/sim/lib/mothership/tools/secret-mount-materializer.server.ts similarity index 99% rename from apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts rename to apps/sim/lib/mothership/tools/secret-mount-materializer.server.ts index 0302dfdd1c5..aebff723f60 100644 --- a/apps/sim/lib/copilot/tools/secret-mount-materializer.server.ts +++ b/apps/sim/lib/mothership/tools/secret-mount-materializer.server.ts @@ -2,12 +2,12 @@ import { db } from '@sim/db' import { credential, credentialMember, environment, workspaceEnvironment } from '@sim/db/schema' import { and, desc, eq, inArray, or, sql } from 'drizzle-orm' import type { AnyPgColumn } from 'drizzle-orm/pg-core' +import { decryptSecret } from '@/lib/core/security/encryption' +import { setRecordValue } from '@/lib/core/utils/records' import { MAX_SECRET_MOUNT_NAME_LENGTH, MAX_SECRET_MOUNT_NAMES, -} from '@/lib/copilot/secret-mount-policy' -import { decryptSecret } from '@/lib/core/security/encryption' -import { setRecordValue } from '@/lib/core/utils/records' +} from '@/lib/mothership/secret-mount-policy' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import type { ResolvedSecretScope, diff --git a/apps/sim/lib/copilot/tools/server/base-tool.ts b/apps/sim/lib/mothership/tools/server/base-tool.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/base-tool.ts rename to apps/sim/lib/mothership/tools/server/base-tool.ts diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts b/apps/sim/lib/mothership/tools/server/docs/search-docs-dispatch.test.ts similarity index 79% rename from apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts rename to apps/sim/lib/mothership/tools/server/docs/search-docs-dispatch.test.ts index 3634648b5b8..464153c59c0 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-docs-dispatch.test.ts +++ b/apps/sim/lib/mothership/tools/server/docs/search-docs-dispatch.test.ts @@ -2,10 +2,10 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1' -import { isKnownTool, isSimExecuted } from '@/lib/copilot/tool-executor/router' -import { getHiddenToolNames } from '@/lib/copilot/tools/client/hidden-tools' -import { getRegisteredServerToolNames } from '@/lib/copilot/tools/server/router' +import { TOOL_CATALOG } from '@/lib/mothership/generated/tool-catalog-v1' +import { isKnownTool, isSimExecuted } from '@/lib/mothership/tool-executor/router' +import { getHiddenToolNames } from '@/lib/mothership/tools/client/hidden-tools' +import { getRegisteredServerToolNames } from '@/lib/mothership/tools/server/router' /** * `executeTool` gates on `isKnownTool` (catalog membership) before it ever diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts b/apps/sim/lib/mothership/tools/server/docs/search-docs.test.ts similarity index 95% rename from apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts rename to apps/sim/lib/mothership/tools/server/docs/search-docs.test.ts index 1b01c4c8dd5..af90668580d 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-docs.test.ts +++ b/apps/sim/lib/mothership/tools/server/docs/search-docs.test.ts @@ -2,18 +2,18 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { DocsSearchOutcome } from '@/lib/copilot/docs/docs-search' +import type { DocsSearchOutcome } from '@/lib/mothership/docs/docs-search' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const { mockSearchDocs } = vi.hoisted(() => ({ mockSearchDocs: vi.fn(), })) -vi.mock('@/lib/copilot/docs/docs-search', () => ({ +vi.mock('@/lib/mothership/docs/docs-search', () => ({ searchDocs: mockSearchDocs, })) -import { searchDocsServerTool } from '@/lib/copilot/tools/server/docs/search-docs' +import { searchDocsServerTool } from '@/lib/mothership/tools/server/docs/search-docs' function outcome(overrides: Partial): DocsSearchOutcome { return { diff --git a/apps/sim/lib/copilot/tools/server/docs/search-docs.ts b/apps/sim/lib/mothership/tools/server/docs/search-docs.ts similarity index 87% rename from apps/sim/lib/copilot/tools/server/docs/search-docs.ts rename to apps/sim/lib/mothership/tools/server/docs/search-docs.ts index 7603bc9e6b2..d37a8fba213 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-docs.ts +++ b/apps/sim/lib/mothership/tools/server/docs/search-docs.ts @@ -1,8 +1,8 @@ -import type { DocsSearchResult } from '@/lib/copilot/docs/docs-search' -import { searchDocs } from '@/lib/copilot/docs/docs-search' -import { SearchDocs } from '@/lib/copilot/generated/tool-catalog-v1' -import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { ServerToolModelInputError } from '@/lib/copilot/tools/server/model-input' +import type { DocsSearchResult } from '@/lib/mothership/docs/docs-search' +import { searchDocs } from '@/lib/mothership/docs/docs-search' +import { SearchDocs } from '@/lib/mothership/generated/tool-catalog-v1' +import type { BaseServerTool, ServerToolContext } from '@/lib/mothership/tools/server/base-tool' +import { ServerToolModelInputError } from '@/lib/mothership/tools/server/model-input' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' interface SearchDocsParams { @@ -54,7 +54,7 @@ function shortfallNote(outcome: Awaited>): string * Vector search over Sim's product documentation, scoped to the same pages the * agent can `read` from the `docs/` VFS tree. Normal delegation exposes it to * the platform agent; the `@Docs` compatibility path also invokes it directly. - * Corpus logic lives in `@/lib/copilot/docs/docs-search`. + * Corpus logic lives in `@/lib/mothership/docs/docs-search`. */ export const searchDocsServerTool: BaseServerTool = { name: SearchDocs.id, diff --git a/apps/sim/lib/copilot/tools/server/env-reference.test.ts b/apps/sim/lib/mothership/tools/server/env-reference.test.ts similarity index 96% rename from apps/sim/lib/copilot/tools/server/env-reference.test.ts rename to apps/sim/lib/mothership/tools/server/env-reference.test.ts index 461bc7f125c..b08edc4182c 100644 --- a/apps/sim/lib/copilot/tools/server/env-reference.test.ts +++ b/apps/sim/lib/mothership/tools/server/env-reference.test.ts @@ -3,7 +3,7 @@ */ import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' import { afterEach, describe, expect, it } from 'vitest' -import { resolveEnvReferenceSecretArg } from '@/lib/copilot/tools/server/env-reference' +import { resolveEnvReferenceSecretArg } from '@/lib/mothership/tools/server/env-reference' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const scope = { userId: 'user-1', workspaceId: 'workspace-1' } diff --git a/apps/sim/lib/copilot/tools/server/env-reference.ts b/apps/sim/lib/mothership/tools/server/env-reference.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/env-reference.ts rename to apps/sim/lib/mothership/tools/server/env-reference.ts diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile-error.ts b/apps/sim/lib/mothership/tools/server/files/doc-compile-error.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/files/doc-compile-error.ts rename to apps/sim/lib/mothership/tools/server/files/doc-compile-error.ts diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts b/apps/sim/lib/mothership/tools/server/files/doc-compile.test.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/files/doc-compile.test.ts rename to apps/sim/lib/mothership/tools/server/files/doc-compile.test.ts diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts b/apps/sim/lib/mothership/tools/server/files/doc-compile.ts similarity index 99% rename from apps/sim/lib/copilot/tools/server/files/doc-compile.ts rename to apps/sim/lib/mothership/tools/server/files/doc-compile.ts index 41900a665ab..170cc5f3f3e 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts +++ b/apps/sim/lib/mothership/tools/server/files/doc-compile.ts @@ -1,15 +1,6 @@ import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' -import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error' -import { - type CompiledDocReadOptions, - loadCompiledDoc, - loadPublishedCompiledDoc, - publishCompiledDocArtifact, - storeCompiledDoc, -} from '@/lib/copilot/tools/server/files/doc-compiled-store' -import { PPTX_SHIM_JS } from '@/lib/copilot/tools/server/files/pptx-shim' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { CodeLanguage } from '@/lib/execution/languages' import { @@ -18,6 +9,15 @@ import { type SandboxFile, } from '@/lib/execution/remote-sandbox' import { runSandboxTask } from '@/lib/execution/sandbox/run-task' +import { DocCompileUserError } from '@/lib/mothership/tools/server/files/doc-compile-error' +import { + type CompiledDocReadOptions, + loadCompiledDoc, + loadPublishedCompiledDoc, + publishCompiledDocArtifact, + storeCompiledDoc, +} from '@/lib/mothership/tools/server/files/doc-compiled-store' +import { PPTX_SHIM_JS } from '@/lib/mothership/tools/server/files/pptx-shim' import type { WorkspaceFileSecretProvenanceIdentity } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.test.ts b/apps/sim/lib/mothership/tools/server/files/doc-compiled-store.test.ts similarity index 99% rename from apps/sim/lib/copilot/tools/server/files/doc-compiled-store.test.ts rename to apps/sim/lib/mothership/tools/server/files/doc-compiled-store.test.ts index c7a1ad258e8..5833f288fa0 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.test.ts +++ b/apps/sim/lib/mothership/tools/server/files/doc-compiled-store.test.ts @@ -17,7 +17,7 @@ import { loadCompiledDoc, loadPublishedCompiledDoc, storeCompiledDoc, -} from '@/lib/copilot/tools/server/files/doc-compiled-store' +} from '@/lib/mothership/tools/server/files/doc-compiled-store' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts b/apps/sim/lib/mothership/tools/server/files/doc-compiled-store.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/files/doc-compiled-store.ts rename to apps/sim/lib/mothership/tools/server/files/doc-compiled-store.ts diff --git a/apps/sim/lib/copilot/tools/server/files/doc-extract.ts b/apps/sim/lib/mothership/tools/server/files/doc-extract.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/files/doc-extract.ts rename to apps/sim/lib/mothership/tools/server/files/doc-extract.ts diff --git a/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts b/apps/sim/lib/mothership/tools/server/files/doc-recalc.ts similarity index 97% rename from apps/sim/lib/copilot/tools/server/files/doc-recalc.ts rename to apps/sim/lib/mothership/tools/server/files/doc-recalc.ts index 700d00746dd..72464b37226 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts +++ b/apps/sim/lib/mothership/tools/server/files/doc-recalc.ts @@ -1,8 +1,8 @@ import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' -import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error' import { CodeLanguage } from '@/lib/execution/languages' import { executeInSandbox } from '@/lib/execution/remote-sandbox' +import { DocCompileUserError } from '@/lib/mothership/tools/server/files/doc-compile-error' import { compileDoc } from './doc-compile' const logger = createLogger('CopilotDocRecalc') diff --git a/apps/sim/lib/copilot/tools/server/files/doc-render.ts b/apps/sim/lib/mothership/tools/server/files/doc-render.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/files/doc-render.ts rename to apps/sim/lib/mothership/tools/server/files/doc-render.ts diff --git a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts b/apps/sim/lib/mothership/tools/server/files/doc-servable.test.ts similarity index 99% rename from apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts rename to apps/sim/lib/mothership/tools/server/files/doc-servable.test.ts index 87432887d62..731d4820b0e 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-servable.test.ts +++ b/apps/sim/lib/mothership/tools/server/files/doc-servable.test.ts @@ -55,7 +55,7 @@ vi.mock('@/app/api/files/utils', () => ({ : 'application/octet-stream', })) -import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error' +import { DocCompileUserError } from '@/lib/mothership/tools/server/files/doc-compile-error' import { compileDoc, resolveServableDoc, resolveServableDocBytes } from './doc-compile' const WORKSPACE_ID = '550e8400-e29b-41d4-a716-446655440000' diff --git a/apps/sim/lib/copilot/tools/server/files/embedded-image-refs.ts b/apps/sim/lib/mothership/tools/server/files/embedded-image-refs.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/files/embedded-image-refs.ts rename to apps/sim/lib/mothership/tools/server/files/embedded-image-refs.ts diff --git a/apps/sim/lib/copilot/tools/server/files/file-folder-application.ts b/apps/sim/lib/mothership/tools/server/files/file-folder-application.ts similarity index 85% rename from apps/sim/lib/copilot/tools/server/files/file-folder-application.ts rename to apps/sim/lib/mothership/tools/server/files/file-folder-application.ts index d613e7dbca5..d5c19c9f6fe 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-folder-application.ts +++ b/apps/sim/lib/mothership/tools/server/files/file-folder-application.ts @@ -1,5 +1,5 @@ -import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' -import type { CopilotFileDelegationContext } from '@/lib/copilot/auth/file-delegation' +import { executeCopilotFileUseCase } from '@/lib/mothership/application/execute-file-use-case' +import type { CopilotFileDelegationContext } from '@/lib/mothership/auth/file-delegation' import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { createWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' diff --git a/apps/sim/lib/copilot/tools/server/files/file-intent-store.test.ts b/apps/sim/lib/mothership/tools/server/files/file-intent-store.test.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/files/file-intent-store.test.ts rename to apps/sim/lib/mothership/tools/server/files/file-intent-store.test.ts diff --git a/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts b/apps/sim/lib/mothership/tools/server/files/file-intent-store.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/files/file-intent-store.ts rename to apps/sim/lib/mothership/tools/server/files/file-intent-store.ts diff --git a/apps/sim/lib/copilot/tools/server/files/file-preview.test.ts b/apps/sim/lib/mothership/tools/server/files/file-preview.test.ts similarity index 97% rename from apps/sim/lib/copilot/tools/server/files/file-preview.test.ts rename to apps/sim/lib/mothership/tools/server/files/file-preview.test.ts index f5efb932aff..5692c26fd06 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-preview.test.ts +++ b/apps/sim/lib/mothership/tools/server/files/file-preview.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { buildFilePreviewText } from '@/lib/copilot/tools/server/files/file-preview' +import { buildFilePreviewText } from '@/lib/mothership/tools/server/files/file-preview' describe('buildFilePreviewText', () => { it('returns the full streamed content for update previews', () => { diff --git a/apps/sim/lib/copilot/tools/server/files/file-preview.ts b/apps/sim/lib/mothership/tools/server/files/file-preview.ts similarity index 97% rename from apps/sim/lib/copilot/tools/server/files/file-preview.ts rename to apps/sim/lib/mothership/tools/server/files/file-preview.ts index e5757636d7a..85f708d169d 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-preview.ts +++ b/apps/sim/lib/mothership/tools/server/files/file-preview.ts @@ -1,7 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' -import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeCopilotFileUseCase } from '@/lib/mothership/application/execute-file-use-case' +import type { ExecutionContext } from '@/lib/mothership/request/types' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' const logger = createLogger('CopilotFilePreview') diff --git a/apps/sim/lib/copilot/tools/server/files/pptx-shim.ts b/apps/sim/lib/mothership/tools/server/files/pptx-shim.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/files/pptx-shim.ts rename to apps/sim/lib/mothership/tools/server/files/pptx-shim.ts diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/mothership/tools/server/files/workspace-file.ts similarity index 98% rename from apps/sim/lib/copilot/tools/server/files/workspace-file.ts rename to apps/sim/lib/mothership/tools/server/files/workspace-file.ts index 443df457573..d2e97d4a407 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/mothership/tools/server/files/workspace-file.ts @@ -2,24 +2,24 @@ import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' +import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { runSandboxTask } from '@/lib/execution/sandbox/run-task' import { executeCopilotFileUseCase, resolveCopilotWorkspaceFileReference, -} from '@/lib/copilot/application/execute-file-use-case' +} from '@/lib/mothership/application/execute-file-use-case' import { messageForCopilotFileError, resolveCopilotFilePrincipal, -} from '@/lib/copilot/auth/file-delegation' -import { PrepareFileEdit } from '@/lib/copilot/generated/tool-catalog-v1' +} from '@/lib/mothership/auth/file-delegation' +import { PrepareFileEdit } from '@/lib/mothership/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' -import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error' -import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' -import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { runSandboxTask } from '@/lib/execution/sandbox/run-task' +} from '@/lib/mothership/tools/server/base-tool' +import { DocCompileUserError } from '@/lib/mothership/tools/server/files/doc-compile-error' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { admitCreateWorkspaceFile, diff --git a/apps/sim/lib/copilot/tools/server/generated-schema.ts b/apps/sim/lib/mothership/tools/server/generated-schema.ts similarity index 95% rename from apps/sim/lib/copilot/tools/server/generated-schema.ts rename to apps/sim/lib/mothership/tools/server/generated-schema.ts index 147542e2ce7..3a1de4f8f03 100644 --- a/apps/sim/lib/copilot/tools/server/generated-schema.ts +++ b/apps/sim/lib/mothership/tools/server/generated-schema.ts @@ -1,6 +1,6 @@ import Ajv, { type ErrorObject, type ValidateFunction } from 'ajv' -import { TOOL_RUNTIME_SCHEMAS } from '@/lib/copilot/generated/tool-schemas-v1' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { TOOL_RUNTIME_SCHEMAS } from '@/lib/mothership/generated/tool-schemas-v1' const ajv = new Ajv({ allErrors: true, diff --git a/apps/sim/lib/copilot/tools/server/image/generate-image.ts b/apps/sim/lib/mothership/tools/server/image/generate-image.ts similarity index 95% rename from apps/sim/lib/copilot/tools/server/image/generate-image.ts rename to apps/sim/lib/mothership/tools/server/image/generate-image.ts index 3e572ed6427..c8ace4e677d 100644 --- a/apps/sim/lib/copilot/tools/server/image/generate-image.ts +++ b/apps/sim/lib/mothership/tools/server/image/generate-image.ts @@ -1,23 +1,23 @@ import { GoogleGenAI, type Part } from '@google/genai' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { getRotatingApiKey } from '@/lib/core/config/api-keys' +import { MAX_MEDIA_BYTES } from '@/lib/media/falai' import { executeCopilotFileUseCase, resolveCopilotWorkspaceFileReference, -} from '@/lib/copilot/application/execute-file-use-case' -import { GenerateImage } from '@/lib/copilot/generated/tool-catalog-v1' +} from '@/lib/mothership/application/execute-file-use-case' +import { GenerateImage } from '@/lib/mothership/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' +} from '@/lib/mothership/tools/server/base-tool' import { assertOpaqueWorkspaceFileModelSafe, ServerToolModelInputError, -} from '@/lib/copilot/tools/server/model-input' -import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' -import { getRotatingApiKey } from '@/lib/core/config/api-keys' -import { MAX_MEDIA_BYTES } from '@/lib/media/falai' +} from '@/lib/mothership/tools/server/model-input' +import { writeCopilotWorkspaceFileByPath } from '@/lib/mothership/vfs/resource-writer' import { createWorkspaceFileSecretProvenanceFromRegistry } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' diff --git a/apps/sim/lib/copilot/tools/server/knowledge/workspace-search.test.ts b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts similarity index 98% rename from apps/sim/lib/copilot/tools/server/knowledge/workspace-search.test.ts rename to apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts index 0ef7d3df4bb..b58ca432ba8 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/workspace-search.test.ts +++ b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@sim/logger', () => ({ createLogger: () => ({ info: mocks.info, error: vi.fn(), warn: vi.fn() }), })) -vi.mock('@/lib/copilot/chat/organization-chats', () => ({ +vi.mock('@/lib/mothership/chat/organization-chats', () => ({ authorizeOrganizationChatDelegation: { execute: mocks.authorizeChat }, })) vi.mock('@/lib/knowledge/application/workspace-search', () => ({ @@ -39,7 +39,7 @@ vi.mock('@/lib/knowledge/application/read-search-document', () => ({ import { readDocumentServerTool, searchWorkspaceServerTool, -} from '@/lib/copilot/tools/server/knowledge/workspace-search' +} from '@/lib/mothership/tools/server/knowledge/workspace-search' import { knowledgeOperations } from '@/lib/knowledge/application/operations' import { annotateSearchDiagnostics } from '@/lib/knowledge/search/diagnostics' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' diff --git a/apps/sim/lib/copilot/tools/server/knowledge/workspace-search.ts b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts similarity index 98% rename from apps/sim/lib/copilot/tools/server/knowledge/workspace-search.ts rename to apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts index 7d24d5969da..19ee89c0ee7 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/workspace-search.ts +++ b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.ts @@ -6,8 +6,8 @@ import { executeCopilotOrganizationKnowledgeUseCase, messageForCopilotKnowledgeError, requireCopilotKnowledgeScope, -} from '@/lib/copilot/application/execute-knowledge-use-case' -import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' +} from '@/lib/mothership/application/execute-knowledge-use-case' +import type { BaseServerTool, ServerToolContext } from '@/lib/mothership/tools/server/base-tool' import { getBaseUrl } from '@/lib/core/utils/urls' import { readSearchDocument } from '@/lib/knowledge/application/read-search-document' import { diff --git a/apps/sim/lib/copilot/tools/server/media/ffmpeg.test.ts b/apps/sim/lib/mothership/tools/server/media/ffmpeg.test.ts similarity index 98% rename from apps/sim/lib/copilot/tools/server/media/ffmpeg.test.ts rename to apps/sim/lib/mothership/tools/server/media/ffmpeg.test.ts index 40f40d9771b..c40c7851105 100644 --- a/apps/sim/lib/copilot/tools/server/media/ffmpeg.test.ts +++ b/apps/sim/lib/mothership/tools/server/media/ffmpeg.test.ts @@ -23,16 +23,16 @@ const { writeWorkspaceFileByPathMock: vi.fn(), })) -vi.mock('@/lib/copilot/generated/tool-catalog-v1', () => ({ +vi.mock('@/lib/mothership/generated/tool-catalog-v1', () => ({ Ffmpeg: { id: 'ffmpeg' }, })) -vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ +vi.mock('@/lib/mothership/vfs/resource-writer', () => ({ writeCopilotWorkspaceFileByPath: (_context: unknown, args: unknown) => writeWorkspaceFileByPathMock(args), })) -vi.mock('@/lib/copilot/application/execute-file-use-case', () => ({ +vi.mock('@/lib/mothership/application/execute-file-use-case', () => ({ executeCopilotFileUseCase: async () => ({ file, content: await fetchWorkspaceFileBufferMock(file), @@ -57,7 +57,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () mergeWorkspaceFileSecretProvenance: mergeWorkspaceFileSecretProvenanceMock, })) -import { ffmpegServerTool } from '@/lib/copilot/tools/server/media/ffmpeg' +import { ffmpegServerTool } from '@/lib/mothership/tools/server/media/ffmpeg' const EXACT_EMPTY = { status: 'exact' as const, entries: [] } const TRACKED = { diff --git a/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts b/apps/sim/lib/mothership/tools/server/media/ffmpeg.ts similarity index 97% rename from apps/sim/lib/copilot/tools/server/media/ffmpeg.ts rename to apps/sim/lib/mothership/tools/server/media/ffmpeg.ts index effb8ed1da0..dc86c213bc7 100644 --- a/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts +++ b/apps/sim/lib/mothership/tools/server/media/ffmpeg.ts @@ -1,19 +1,19 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { MAX_MEDIA_BYTES } from '@/lib/media/falai' +import { type FfmpegOperation, type MediaFile, runFfmpegOperation } from '@/lib/media/ffmpeg' +import { FFMPEG_LIMITS } from '@/lib/media/ffmpeg-limits' import { executeCopilotFileUseCase, resolveCopilotWorkspaceFileReference, -} from '@/lib/copilot/application/execute-file-use-case' -import { Ffmpeg } from '@/lib/copilot/generated/tool-catalog-v1' +} from '@/lib/mothership/application/execute-file-use-case' +import { Ffmpeg } from '@/lib/mothership/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' -import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' -import { MAX_MEDIA_BYTES } from '@/lib/media/falai' -import { type FfmpegOperation, type MediaFile, runFfmpegOperation } from '@/lib/media/ffmpeg' -import { FFMPEG_LIMITS } from '@/lib/media/ffmpeg-limits' +} from '@/lib/mothership/tools/server/base-tool' +import { writeCopilotWorkspaceFileByPath } from '@/lib/mothership/vfs/resource-writer' import { createWorkspaceFileSecretProvenanceFromRegistry, getBoundWorkspaceFileSecretProvenance, diff --git a/apps/sim/lib/copilot/tools/server/media/generate-audio.ts b/apps/sim/lib/mothership/tools/server/media/generate-audio.ts similarity index 94% rename from apps/sim/lib/copilot/tools/server/media/generate-audio.ts rename to apps/sim/lib/mothership/tools/server/media/generate-audio.ts index a391ec11f09..3bcbfc4a8c3 100644 --- a/apps/sim/lib/copilot/tools/server/media/generate-audio.ts +++ b/apps/sim/lib/mothership/tools/server/media/generate-audio.ts @@ -1,19 +1,19 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { MAX_MEDIA_BYTES } from '@/lib/media/falai' +import { type AudioType, generateFalAudio } from '@/lib/media/falai-audio' import { executeCopilotFileUseCase, resolveCopilotWorkspaceFileReference, -} from '@/lib/copilot/application/execute-file-use-case' -import { GenerateAudio } from '@/lib/copilot/generated/tool-catalog-v1' +} from '@/lib/mothership/application/execute-file-use-case' +import { GenerateAudio } from '@/lib/mothership/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' -import { assertOpaqueWorkspaceFileModelSafe } from '@/lib/copilot/tools/server/model-input' -import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' -import { MAX_MEDIA_BYTES } from '@/lib/media/falai' -import { type AudioType, generateFalAudio } from '@/lib/media/falai-audio' +} from '@/lib/mothership/tools/server/base-tool' +import { assertOpaqueWorkspaceFileModelSafe } from '@/lib/mothership/tools/server/model-input' +import { writeCopilotWorkspaceFileByPath } from '@/lib/mothership/vfs/resource-writer' import { createWorkspaceFileSecretProvenanceFromRegistry } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' diff --git a/apps/sim/lib/copilot/tools/server/media/generate-video.ts b/apps/sim/lib/mothership/tools/server/media/generate-video.ts similarity index 93% rename from apps/sim/lib/copilot/tools/server/media/generate-video.ts rename to apps/sim/lib/mothership/tools/server/media/generate-video.ts index 6ec49e564b4..a71e7df4dbd 100644 --- a/apps/sim/lib/copilot/tools/server/media/generate-video.ts +++ b/apps/sim/lib/mothership/tools/server/media/generate-video.ts @@ -1,19 +1,19 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { MAX_MEDIA_BYTES } from '@/lib/media/falai' +import { generateFalVideo } from '@/lib/media/falai-video' import { executeCopilotFileUseCase, resolveCopilotWorkspaceFileReference, -} from '@/lib/copilot/application/execute-file-use-case' -import { GenerateVideo } from '@/lib/copilot/generated/tool-catalog-v1' +} from '@/lib/mothership/application/execute-file-use-case' +import { GenerateVideo } from '@/lib/mothership/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, -} from '@/lib/copilot/tools/server/base-tool' -import { assertOpaqueWorkspaceFileModelSafe } from '@/lib/copilot/tools/server/model-input' -import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' -import { MAX_MEDIA_BYTES } from '@/lib/media/falai' -import { generateFalVideo } from '@/lib/media/falai-video' +} from '@/lib/mothership/tools/server/base-tool' +import { assertOpaqueWorkspaceFileModelSafe } from '@/lib/mothership/tools/server/model-input' +import { writeCopilotWorkspaceFileByPath } from '@/lib/mothership/vfs/resource-writer' import { createWorkspaceFileSecretProvenanceFromRegistry } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' diff --git a/apps/sim/lib/copilot/tools/server/media/model-boundaries.test.ts b/apps/sim/lib/mothership/tools/server/media/model-boundaries.test.ts similarity index 94% rename from apps/sim/lib/copilot/tools/server/media/model-boundaries.test.ts rename to apps/sim/lib/mothership/tools/server/media/model-boundaries.test.ts index 2ca46ed31aa..59d00ba8bf8 100644 --- a/apps/sim/lib/copilot/tools/server/media/model-boundaries.test.ts +++ b/apps/sim/lib/mothership/tools/server/media/model-boundaries.test.ts @@ -27,7 +27,7 @@ vi.mock('@google/genai', () => ({ }, })) vi.mock('@/lib/core/config/api-keys', () => ({ getRotatingApiKey: vi.fn(() => 'api-key') })) -vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ +vi.mock('@/lib/mothership/vfs/resource-writer', () => ({ writeCopilotWorkspaceFileByPath: mockWriteWorkspaceFileByPath, })) vi.mock('@/lib/media/falai-audio', () => ({ generateFalAudio: mockGenerateFalAudio })) @@ -44,10 +44,10 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () 'File cannot be sent to a model because its secret provenance is unavailable', })) -import type { ServerToolContext } from '@/lib/copilot/tools/server/base-tool' -import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image' -import { generateAudioServerTool } from '@/lib/copilot/tools/server/media/generate-audio' -import { generateVideoServerTool } from '@/lib/copilot/tools/server/media/generate-video' +import type { ServerToolContext } from '@/lib/mothership/tools/server/base-tool' +import { generateImageServerTool } from '@/lib/mothership/tools/server/image/generate-image' +import { generateAudioServerTool } from '@/lib/mothership/tools/server/media/generate-audio' +import { generateVideoServerTool } from '@/lib/mothership/tools/server/media/generate-video' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' const file = { diff --git a/apps/sim/lib/copilot/tools/server/model-input.test.ts b/apps/sim/lib/mothership/tools/server/model-input.test.ts similarity index 95% rename from apps/sim/lib/copilot/tools/server/model-input.test.ts rename to apps/sim/lib/mothership/tools/server/model-input.test.ts index 19692d6dee4..83b4b689da5 100644 --- a/apps/sim/lib/copilot/tools/server/model-input.test.ts +++ b/apps/sim/lib/mothership/tools/server/model-input.test.ts @@ -13,7 +13,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () 'File cannot be sent to a model because its secret provenance is unavailable', })) -import { assertOpaqueWorkspaceFileModelSafe } from '@/lib/copilot/tools/server/model-input' +import { assertOpaqueWorkspaceFileModelSafe } from '@/lib/mothership/tools/server/model-input' const file = { id: 'file-1', diff --git a/apps/sim/lib/copilot/tools/server/model-input.ts b/apps/sim/lib/mothership/tools/server/model-input.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/model-input.ts rename to apps/sim/lib/mothership/tools/server/model-input.ts diff --git a/apps/sim/lib/mothership/tools/server/router.ts b/apps/sim/lib/mothership/tools/server/router.ts new file mode 100644 index 00000000000..7d9283c4b5d --- /dev/null +++ b/apps/sim/lib/mothership/tools/server/router.ts @@ -0,0 +1,137 @@ +import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' +import { z } from 'zod' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + Ffmpeg, + GenerateAudio, + GenerateImage, + GenerateVideo, +} from '@/lib/mothership/generated/tool-catalog-v1' +import { copilotToolCanWrite } from '@/lib/mothership/tools/permissions' +import { + assertServerToolNotAborted, + type BaseServerTool, + type ServerToolContext, +} from '@/lib/mothership/tools/server/base-tool' +import { searchDocsServerTool } from '@/lib/mothership/tools/server/docs/search-docs' +import { validateGeneratedToolPayload } from '@/lib/mothership/tools/server/generated-schema' +import { generateImageServerTool } from '@/lib/mothership/tools/server/image/generate-image' +import { + readDocumentServerTool, + searchWorkspaceServerTool, +} from '@/lib/mothership/tools/server/knowledge/workspace-search' +import { ffmpegServerTool } from '@/lib/mothership/tools/server/media/ffmpeg' +import { generateAudioServerTool } from '@/lib/mothership/tools/server/media/generate-audio' +import { generateVideoServerTool } from '@/lib/mothership/tools/server/media/generate-video' +import { getCredentialsServerTool } from '@/lib/mothership/tools/server/user/get-credentials' + +export type ExecuteResponseSuccess = z.output + +const ExecuteResponseSuccessSchema = z.object({ + success: z.literal(true), + result: z.unknown(), +}) + +const logger = createLogger('ServerToolRouter') + +const WRITE_ACTIONS: Record = { + [GenerateImage.id]: ['generate'], + [GenerateVideo.id]: ['generate'], + [GenerateAudio.id]: ['generate'], + [Ffmpeg.id]: ['*'], +} + +function isWriteAction(toolName: string, action: string | undefined): boolean { + const writeActions = WRITE_ACTIONS[toolName] + if (!writeActions) return false + // '*' means the tool is always a write operation regardless of action field + if (writeActions.includes('*')) return true + return Boolean(action && writeActions.includes(action)) +} + +/** Registry of all server tools. Tools self-declare their validation schemas. */ +const baseServerToolRegistry: Record = { + [searchDocsServerTool.name]: searchDocsServerTool, + [searchWorkspaceServerTool.name]: searchWorkspaceServerTool, + [readDocumentServerTool.name]: readDocumentServerTool, + [generateImageServerTool.name]: generateImageServerTool, + [generateVideoServerTool.name]: generateVideoServerTool, + [generateAudioServerTool.name]: generateAudioServerTool, + [ffmpegServerTool.name]: ffmpegServerTool, + // Not agent-reachable: the internal credentials route dispatches this directly. + [getCredentialsServerTool.name]: getCredentialsServerTool, +} + +function getServerToolRegistry(): Record { + return baseServerToolRegistry +} + +export function getRegisteredServerToolNames(): string[] { + return Object.keys(getServerToolRegistry()) +} + +export async function routeExecution( + toolName: string, + payload: unknown, + context?: ServerToolContext +): Promise { + const tool = getServerToolRegistry()[toolName] + if (!tool) { + throw new OrchestrationError('validation', `Unknown server tool: ${toolName}`) + } + + logger.debug( + context?.messageId ? `Routing to tool [messageId:${context.messageId}]` : 'Routing to tool', + { toolName } + ) + + // Action-level permission enforcement for mixed read/write tools + if (WRITE_ACTIONS[toolName]) { + const p = payload as Record + const action = (p?.operation ?? p?.action) as string | undefined + if (isWriteAction(toolName, action) && !copilotToolCanWrite(context?.userPermission)) { + const actionLabel = action ? `'${action}' on ` : '' + // Classified so the projection surfaces it: a permission denial is + // caller-actionable (stop retrying, tell the user), not a system error. + throw new OrchestrationError( + 'forbidden', + `Permission denied: ${actionLabel}${toolName} requires write access. You have '${context?.userPermission ?? 'none'}' permission.` + ) + } + } + + assertServerToolNotAborted( + context, + `User stop signal aborted ${toolName} before payload normalization` + ) + + // Go injects chatId/workspaceId and may wrap the model's args inside a + // nested "args" object. Unwrap that before validation so the generated + // JSON Schema sees the flat tool contract shape. + let normalizedPayload = payload ?? {} + if (isRecordLike(normalizedPayload)) { + const raw = normalizedPayload as Record + if (raw.args && typeof raw.args === 'object' && !raw.operation) { + const nested = raw.args as Record + normalizedPayload = { ...nested, ...raw, args: undefined } + } + } + + const args = tool.inputSchema + ? tool.inputSchema.parse(normalizedPayload) + : validateGeneratedToolPayload(toolName, 'parameters', normalizedPayload) + + assertServerToolNotAborted(context, `User stop signal aborted ${toolName} after validation`) + + // Execute. None of the remaining tools resolve blocks or gate discovery, so the old + // custom-block-overlay / block-visibility ALS scopes are gone with the tools that + // needed them (workflow authoring now flows through the CLI + v2 surface). + const result = await tool.execute(args, context) + + // Validate output if tool declares a schema; otherwise fall back to the + // generated JSON schema contract emitted from Go. + return tool.outputSchema + ? tool.outputSchema.parse(result) + : validateGeneratedToolPayload(toolName, 'resultSchema', result) +} diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts b/apps/sim/lib/mothership/tools/server/user/get-credentials.test.ts similarity index 99% rename from apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts rename to apps/sim/lib/mothership/tools/server/user/get-credentials.test.ts index 2b8aebdf221..3ddc1d19f18 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.test.ts +++ b/apps/sim/lib/mothership/tools/server/user/get-credentials.test.ts @@ -96,7 +96,7 @@ vi.mock('jose', () => ({ decodeJwt: decodeJwtMock, })) -vi.mock('@/lib/copilot/auth/permissions', () => ({ +vi.mock('@/lib/mothership/auth/permissions', () => ({ verifyWorkflowAccess: verifyWorkflowAccessMock, createPermissionError: (action: string) => `Permission denied: ${action}`, })) diff --git a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts b/apps/sim/lib/mothership/tools/server/user/get-credentials.ts similarity index 97% rename from apps/sim/lib/copilot/tools/server/user/get-credentials.ts rename to apps/sim/lib/mothership/tools/server/user/get-credentials.ts index 9b5398201c5..d78ac4e5a33 100644 --- a/apps/sim/lib/copilot/tools/server/user/get-credentials.ts +++ b/apps/sim/lib/mothership/tools/server/user/get-credentials.ts @@ -4,14 +4,14 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { decodeJwt } from 'jose' -import { createPermissionError, verifyWorkflowAccess } from '@/lib/copilot/auth/permissions' -import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' -import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' +import { createPermissionError, verifyWorkflowAccess } from '@/lib/mothership/auth/permissions' +import type { BaseServerTool } from '@/lib/mothership/tools/server/base-tool' +import { requireCopilotWorkspace } from '@/lib/mothership/tools/server/workspace-scope' import { canonicalizeServiceProviderId, credentialProviderMatchesService, diff --git a/apps/sim/lib/copilot/tools/server/workspace-scope.ts b/apps/sim/lib/mothership/tools/server/workspace-scope.ts similarity index 100% rename from apps/sim/lib/copilot/tools/server/workspace-scope.ts rename to apps/sim/lib/mothership/tools/server/workspace-scope.ts diff --git a/apps/sim/lib/copilot/tools/shared/workflow-utils.ts b/apps/sim/lib/mothership/tools/shared/workflow-utils.ts similarity index 100% rename from apps/sim/lib/copilot/tools/shared/workflow-utils.ts rename to apps/sim/lib/mothership/tools/shared/workflow-utils.ts diff --git a/apps/sim/lib/copilot/tools/streaming-args.ts b/apps/sim/lib/mothership/tools/streaming-args.ts similarity index 100% rename from apps/sim/lib/copilot/tools/streaming-args.ts rename to apps/sim/lib/mothership/tools/streaming-args.ts diff --git a/apps/sim/lib/copilot/tools/tool-activity.test.ts b/apps/sim/lib/mothership/tools/tool-activity.test.ts similarity index 96% rename from apps/sim/lib/copilot/tools/tool-activity.test.ts rename to apps/sim/lib/mothership/tools/tool-activity.test.ts index e0ad93fb872..55f2d53beba 100644 --- a/apps/sim/lib/copilot/tools/tool-activity.test.ts +++ b/apps/sim/lib/mothership/tools/tool-activity.test.ts @@ -3,9 +3,9 @@ */ import { isRecordLike } from '@sim/utils/object' import { describe, expect, it } from 'vitest' -import { TOOL_CATALOG } from '@/lib/copilot/generated/tool-catalog-v1' -import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools' -import { getToolActivityLabel, TOOL_ACTIVITIES } from '@/lib/copilot/tools/tool-activity' +import { TOOL_CATALOG } from '@/lib/mothership/generated/tool-catalog-v1' +import { isToolHiddenInUi } from '@/lib/mothership/tools/client/hidden-tools' +import { getToolActivityLabel, TOOL_ACTIVITIES } from '@/lib/mothership/tools/tool-activity' import { TOOL_ICONS } from '@/app/workspace/[workspaceId]/home/components/message-content/utils' const visibleTools = Object.values(TOOL_CATALOG).filter( diff --git a/apps/sim/lib/copilot/tools/tool-activity.ts b/apps/sim/lib/mothership/tools/tool-activity.ts similarity index 100% rename from apps/sim/lib/copilot/tools/tool-activity.ts rename to apps/sim/lib/mothership/tools/tool-activity.ts diff --git a/apps/sim/lib/copilot/tools/tool-display.test.ts b/apps/sim/lib/mothership/tools/tool-display.test.ts similarity index 99% rename from apps/sim/lib/copilot/tools/tool-display.test.ts rename to apps/sim/lib/mothership/tools/tool-display.test.ts index bede6ec4b2e..3cc5c3b247d 100644 --- a/apps/sim/lib/copilot/tools/tool-display.test.ts +++ b/apps/sim/lib/mothership/tools/tool-display.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { MOTHERSHIP_STREAM_V1_SCHEMA } from '@/lib/copilot/generated/mothership-stream-v1-schema' +import { MOTHERSHIP_STREAM_V1_SCHEMA } from '@/lib/mothership/generated/mothership-stream-v1-schema' import { FfmpegOperationValues, ManageKnowledgeBaseOperationValues, @@ -12,8 +12,8 @@ import { TOOL_CATALOG, type ToolCatalogEntry, UserTableOperationValues, -} from '@/lib/copilot/generated/tool-catalog-v1' -import { getHiddenToolNames } from '@/lib/copilot/tools/client/hidden-tools' +} from '@/lib/mothership/generated/tool-catalog-v1' +import { getHiddenToolNames } from '@/lib/mothership/tools/client/hidden-tools' import { getToolCompletedTitle, getToolDisplayTitle, @@ -22,7 +22,7 @@ import { humanizeToolName, mvDisplayVerb, normalizeToolActivityDescription, -} from '@/lib/copilot/tools/tool-display' +} from '@/lib/mothership/tools/tool-display' function representativeToolArgs(entry: ToolCatalogEntry): Record { const args: Record = {} diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/mothership/tools/tool-display.ts similarity index 98% rename from apps/sim/lib/copilot/tools/tool-display.ts rename to apps/sim/lib/mothership/tools/tool-display.ts index 8101ab909e2..d33e0e7cf8c 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/mothership/tools/tool-display.ts @@ -1,5 +1,6 @@ import { isRecordLike } from '@sim/utils/object' import { stripVersionSuffix, truncate } from '@sim/utils/string' +import { CLI_TOOL_TITLES } from '@/lib/mothership/tools/cli-tool-display' /** * Single source of truth for copilot tool-call display titles. @@ -1300,7 +1301,7 @@ export function getToolDisplayTitle(name: string, args?: Record } } - return TOOL_TITLES[name] ?? humanizeToolName(name) + return TOOL_TITLES[name] ?? CLI_TOOL_TITLES[name] ?? humanizeToolName(name) } /** @@ -1403,6 +1404,25 @@ const COMPLETED_VERB_REWRITES: Record = { Waiting: 'Waited', Writing: 'Wrote', Zooming: 'Zoomed', + Activating: 'Activated', + Browsing: 'Browsed', + Cleaning: 'Cleaned', + Counting: 'Counted', + Delegating: 'Delegated', + Enriching: 'Enriched', + Exporting: 'Exported', + Following: 'Followed', + Granting: 'Granted', + Indexing: 'Indexed', + Reconnecting: 'Reconnected', + Resuming: 'Resumed', + Reverting: 'Reverted', + Revoking: 'Revoked', + Rewriting: 'Rewrote', + Rolling: 'Rolled', + Starting: 'Started', + Unzipping: 'Unzipped', + Uploading: 'Uploaded', } /** diff --git a/apps/sim/lib/copilot/tools/workflow-tools.test.ts b/apps/sim/lib/mothership/tools/workflow-tools.test.ts similarity index 100% rename from apps/sim/lib/copilot/tools/workflow-tools.test.ts rename to apps/sim/lib/mothership/tools/workflow-tools.test.ts diff --git a/apps/sim/lib/copilot/tools/workflow-tools.ts b/apps/sim/lib/mothership/tools/workflow-tools.ts similarity index 99% rename from apps/sim/lib/copilot/tools/workflow-tools.ts rename to apps/sim/lib/mothership/tools/workflow-tools.ts index a922924b7e0..dbeeac6f31e 100644 --- a/apps/sim/lib/copilot/tools/workflow-tools.ts +++ b/apps/sim/lib/mothership/tools/workflow-tools.ts @@ -5,8 +5,8 @@ import { type AsyncConfirmationStatus, isTerminalAsyncStatus, isWorkflowToolExecutionClaimable, -} from '@/lib/copilot/async-runs/lifecycle' -import { COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE } from '@/lib/copilot/constants' +} from '@/lib/mothership/async-runs/lifecycle' +import { COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE } from '@/lib/mothership/constants' const WORKFLOW_TOOL_NAMES = [ 'run_workflow', diff --git a/apps/sim/lib/copilot/vfs/document-style.test.ts b/apps/sim/lib/mothership/vfs/document-style.test.ts similarity index 98% rename from apps/sim/lib/copilot/vfs/document-style.test.ts rename to apps/sim/lib/mothership/vfs/document-style.test.ts index 372718b4703..f194e38753c 100644 --- a/apps/sim/lib/copilot/vfs/document-style.test.ts +++ b/apps/sim/lib/mothership/vfs/document-style.test.ts @@ -8,7 +8,7 @@ const { mockLoadAsync } = vi.hoisted(() => ({ mockLoadAsync: vi.fn() })) vi.mock('jszip', () => ({ default: { loadAsync: mockLoadAsync } })) -import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' +import { extractDocumentStyle } from '@/lib/mothership/vfs/document-style' const THEME_XML = ` diff --git a/apps/sim/lib/copilot/vfs/document-style.ts b/apps/sim/lib/mothership/vfs/document-style.ts similarity index 100% rename from apps/sim/lib/copilot/vfs/document-style.ts rename to apps/sim/lib/mothership/vfs/document-style.ts diff --git a/apps/sim/lib/copilot/vfs/normalize-segment.ts b/apps/sim/lib/mothership/vfs/normalize-segment.ts similarity index 84% rename from apps/sim/lib/copilot/vfs/normalize-segment.ts rename to apps/sim/lib/mothership/vfs/normalize-segment.ts index b932ed0d538..4fccd8a1bd0 100644 --- a/apps/sim/lib/copilot/vfs/normalize-segment.ts +++ b/apps/sim/lib/mothership/vfs/normalize-segment.ts @@ -1,4 +1,4 @@ -import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils' +import { encodeVfsSegment } from '@/lib/mothership/vfs/path-utils' /** * Normalize and encode a string for use as one canonical VFS path segment. diff --git a/apps/sim/lib/copilot/vfs/operations.test.ts b/apps/sim/lib/mothership/vfs/operations.test.ts similarity index 98% rename from apps/sim/lib/copilot/vfs/operations.test.ts rename to apps/sim/lib/mothership/vfs/operations.test.ts index 1f238d010e8..3aba6faa265 100644 --- a/apps/sim/lib/copilot/vfs/operations.test.ts +++ b/apps/sim/lib/mothership/vfs/operations.test.ts @@ -8,8 +8,8 @@ import { grepReadResult, pathWithinGrepScope, WorkspaceFileGrepError, -} from '@/lib/copilot/vfs/operations' -import { readPlaceholder } from '@/lib/copilot/vfs/read-placeholders' +} from '@/lib/mothership/vfs/operations' +import { readPlaceholder } from '@/lib/mothership/vfs/read-placeholders' function vfsFromEntries(entries: [string, string][]): Map { return new Map(entries) diff --git a/apps/sim/lib/copilot/vfs/operations.ts b/apps/sim/lib/mothership/vfs/operations.ts similarity index 99% rename from apps/sim/lib/copilot/vfs/operations.ts rename to apps/sim/lib/mothership/vfs/operations.ts index b22d28d6490..67e2ed12f23 100644 --- a/apps/sim/lib/copilot/vfs/operations.ts +++ b/apps/sim/lib/mothership/vfs/operations.ts @@ -1,17 +1,17 @@ import { createLogger } from '@sim/logger' import { truncate } from '@sim/utils/string' import micromatch from 'micromatch' -import { decodeVfsSegmentSafe } from '@/lib/copilot/vfs/path-utils' -import { - isNonGreppablePlaceholder, - type PlaceholderKind, -} from '@/lib/copilot/vfs/read-placeholders' import { compileLinearRegex, isPlainText, type LinearRegex, literalRegex, } from '@/lib/core/security/linear-regex' +import { decodeVfsSegmentSafe } from '@/lib/mothership/vfs/path-utils' +import { + isNonGreppablePlaceholder, + type PlaceholderKind, +} from '@/lib/mothership/vfs/read-placeholders' const logger = createLogger('VfsOperations') diff --git a/apps/sim/lib/copilot/vfs/path-utils.test.ts b/apps/sim/lib/mothership/vfs/path-utils.test.ts similarity index 98% rename from apps/sim/lib/copilot/vfs/path-utils.test.ts rename to apps/sim/lib/mothership/vfs/path-utils.test.ts index 41d2b5f2e7f..9908c31f7a9 100644 --- a/apps/sim/lib/copilot/vfs/path-utils.test.ts +++ b/apps/sim/lib/mothership/vfs/path-utils.test.ts @@ -11,7 +11,7 @@ import { canonicalWorkspaceFilePath, decodeVfsPathSegments, encodeVfsPathSegments, -} from '@/lib/copilot/vfs/path-utils' +} from '@/lib/mothership/vfs/path-utils' describe('VFS path utilities', () => { it('round trips encoded nested path segments', () => { diff --git a/apps/sim/lib/copilot/vfs/path-utils.ts b/apps/sim/lib/mothership/vfs/path-utils.ts similarity index 100% rename from apps/sim/lib/copilot/vfs/path-utils.ts rename to apps/sim/lib/mothership/vfs/path-utils.ts diff --git a/apps/sim/lib/copilot/vfs/read-placeholders.ts b/apps/sim/lib/mothership/vfs/read-placeholders.ts similarity index 100% rename from apps/sim/lib/copilot/vfs/read-placeholders.ts rename to apps/sim/lib/mothership/vfs/read-placeholders.ts diff --git a/apps/sim/lib/copilot/vfs/resource-writer.test.ts b/apps/sim/lib/mothership/vfs/resource-writer.test.ts similarity index 99% rename from apps/sim/lib/copilot/vfs/resource-writer.test.ts rename to apps/sim/lib/mothership/vfs/resource-writer.test.ts index 47cbbabb957..c460fd07f2b 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.test.ts +++ b/apps/sim/lib/mothership/vfs/resource-writer.test.ts @@ -48,7 +48,7 @@ vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () import { validateWorkspaceFileWriteTarget, writeWorkspaceFileByPath, -} from '@/lib/copilot/vfs/resource-writer' +} from '@/lib/mothership/vfs/resource-writer' describe('resource writer', () => { beforeEach(() => { diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/mothership/vfs/resource-writer.ts similarity index 98% rename from apps/sim/lib/copilot/vfs/resource-writer.ts rename to apps/sim/lib/mothership/vfs/resource-writer.ts index 9c0b44aeb0e..e8681692360 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/mothership/vfs/resource-writer.ts @@ -1,10 +1,10 @@ import type { Principal } from '@sim/auth/principal' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { type CopilotFileDelegationContext, resolveCopilotFilePrincipal, -} from '@/lib/copilot/auth/file-delegation' -import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' -import { asOrchestrationError } from '@/lib/core/orchestration/types' +} from '@/lib/mothership/auth/file-delegation' +import { canonicalWorkspaceFilePath } from '@/lib/mothership/vfs/path-utils' import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFileByName, diff --git a/apps/sim/lib/resources/orchestration/restore-resource.ts b/apps/sim/lib/resources/orchestration/restore-resource.ts index fd27e57afac..e007b531348 100644 --- a/apps/sim/lib/resources/orchestration/restore-resource.ts +++ b/apps/sim/lib/resources/orchestration/restore-resource.ts @@ -2,13 +2,13 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import type { FolderResourceType } from '@/lib/api/contracts/folders' -import type { MothershipResource } from '@/lib/copilot/resources/types' -import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types' import { restoreFolder } from '@/lib/folders/orchestration' import { getRestorableKnowledgeBase, performRestoreKnowledgeBase, } from '@/lib/knowledge/orchestration' +import type { MothershipResource } from '@/lib/mothership/resources/types' +import type { ToolExecutionResult } from '@/lib/mothership/tool-executor/types' import { performRestoreTable } from '@/lib/table/orchestration' import { getTableById } from '@/lib/table/service' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -40,7 +40,7 @@ export type RestorableResourceType = * `knowledge_folder` and `table_folder` are accepted here and by the tool handler, but the * model cannot emit them yet: the `restore_resource` parameter enum lives in the copilot * service's `contracts/tool-catalog-v1.json`, which is a different repository and is mirrored - * into `lib/copilot/generated/**` by `scripts/sync-tool-catalog.ts`. Widening it there makes + * into `lib/mothership/generated/**` by `scripts/sync-tool-catalog.ts`. Widening it there makes * these reachable with no further change on this side. */ type RestorableFolderType = 'folder' | 'knowledge_folder' | 'table_folder' diff --git a/apps/sim/lib/table/application/context.test.ts b/apps/sim/lib/table/application/context.test.ts index aca566fbbad..4dc4bc436f8 100644 --- a/apps/sim/lib/table/application/context.test.ts +++ b/apps/sim/lib/table/application/context.test.ts @@ -327,7 +327,7 @@ describe('surface-neutral remediation text', () => { * correct remediation rather than a leak. * * `workspace-file-imports` lives under `lib/table` but is imported solely by - * `lib/copilot/application/table-commands`, so every message it raises reaches + * `lib/mothership/application/table-commands`, so every message it raises reaches * an agent that can actually call `save_upload` and `glob(...)`. Rewriting * those into surface-neutral prose removed a working next step from the one * caller able to act on it. diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index c646a4025a8..96615020bd7 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -19,8 +19,8 @@ import { omit } from '@sim/utils/object' import { and, eq, inArray, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm' import type { ShareRecord } from '@/lib/api/contracts/public-shares' import type { V2FileSortBy } from '@/lib/api/contracts/v2/files' -import type { ListSortOrder } from '@/lib/api/list-query' import { + type ListSortOrder, type CursorKey, encodeKeyset, INVALID_CURSOR_MESSAGE, @@ -44,8 +44,8 @@ import { type PreparedCollabDocState, saveCollabDocStateInTx, } from '@/lib/collab-doc/collab-state' -import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' -import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { normalizeVfsSegment } from '@/lib/mothership/vfs/normalize-segment' +import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/mothership/vfs/path-utils' import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { generateRestoreName } from '@/lib/core/utils/restore-name' @@ -55,7 +55,7 @@ import { acquireFolderMutationLock } from '@/lib/folders/locks' import { parseFolderPath } from '@/lib/folders/paths' import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' import type { FolderIdScope } from '@/lib/folders/scope' -import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { mergeEditIntoLiveFileDoc, notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { getServePathPrefix } from '@/lib/uploads' import type { WorkspaceFileFolderRecord } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { diff --git a/apps/sim/lib/uploads/utils/doc-not-ready.ts b/apps/sim/lib/uploads/utils/doc-not-ready.ts index 5e5798eac9c..2e2ed54cc07 100644 --- a/apps/sim/lib/uploads/utils/doc-not-ready.ts +++ b/apps/sim/lib/uploads/utils/doc-not-ready.ts @@ -1,4 +1,4 @@ -import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error' +import { DocCompileUserError } from '@/lib/mothership/tools/server/files/doc-compile-error' /** True when `error` means a generated document's artifact is still compiling. */ export function isDocNotReadyError(error: unknown): error is DocCompileUserError { diff --git a/apps/sim/lib/uploads/utils/file-utils.server.test.ts b/apps/sim/lib/uploads/utils/file-utils.server.test.ts index f3463527fa8..b8a827406f0 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.test.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.test.ts @@ -24,7 +24,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ parseWorkspaceFileKey: mockParseWorkspaceFileKey, })) -vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({ +vi.mock('@/lib/mothership/tools/server/files/doc-compile', () => ({ resolveServableDocBytes: mockResolveServableDocBytes, })) diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index 625dcddc262..274cc056f8b 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -491,7 +491,9 @@ export async function downloadServableFileFromStorage( undefined) : undefined - const { resolveServableDocBytes } = await import('@/lib/copilot/tools/server/files/doc-compile') + const { resolveServableDocBytes } = await import( + '@/lib/mothership/tools/server/files/doc-compile' + ) const resolved = await resolveServableDocBytes({ rawBuffer: buffer, fileName: userFile.name, diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 546e95d05f4..90eedcce0b2 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -297,7 +297,7 @@ export function isArchiveFileName(filename: string): boolean { * `files/`, so this points at the explicit one-time extract step. */ export function buildArchiveExtractGuidance(name: string): string { - return `"${name}" is a .zip archive — its contents can't be read directly. Extract it once with save_upload(fileNames: ["${name}"], operation: "extract"), then read the unpacked files under files/ (e.g. glob("files//**") then read("files///content")).` + return `"${name}" is a .zip archive — its contents can't be read directly. Extract it once with \`sim --output json files unzip "uploads/${name}"\`, then list and read the unpacked files (\`files ls\` / \`files read\`).` } const EXTENSION_TO_MIME: Record = { diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index a35f980b7e1..e6dedafaefd 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -51,7 +51,7 @@ async function eligibleOrgForWorkspace(workspaceId: string): Promise { return (await eligibleOrgForWorkspace(workspaceId)) !== null diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts index 97667c02eb5..7ddaa195794 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts @@ -43,8 +43,8 @@ vi.mock('@/lib/file-parsers', () => ({ parseBuffer: mocks.parseBuffer, })) -import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile-error' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { DocCompileUserError } from '@/lib/mothership/tools/server/files/doc-compile-error' import { readWorkspaceFileText } from '@/lib/workspace-files/application/read-workspace-file-text' const WORKSPACE_ID = 'workspace-1' diff --git a/apps/sim/lib/workspace-files/application/style-workspace-file.ts b/apps/sim/lib/workspace-files/application/style-workspace-file.ts index 73b3d99a803..5ed022069c4 100644 --- a/apps/sim/lib/workspace-files/application/style-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/style-workspace-file.ts @@ -1,6 +1,6 @@ import type { Principal } from '@sim/auth/principal' -import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { extractDocumentStyle } from '@/lib/mothership/vfs/document-style' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' diff --git a/apps/sim/lib/workspace-files/workspace-file-path.ts b/apps/sim/lib/workspace-files/workspace-file-path.ts index f50763cc9fb..61132877059 100644 --- a/apps/sim/lib/workspace-files/workspace-file-path.ts +++ b/apps/sim/lib/workspace-files/workspace-file-path.ts @@ -1,4 +1,4 @@ -import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/mothership/vfs/path-utils' import { normalizeWorkspaceFileItemName } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' export function parseWorkspaceFileCreatePath(path: string): { diff --git a/apps/sim/providers/runtime-context.ts b/apps/sim/providers/runtime-context.ts index cff4f2d1f44..a9b0c13738b 100644 --- a/apps/sim/providers/runtime-context.ts +++ b/apps/sim/providers/runtime-context.ts @@ -2,8 +2,8 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { isRecordLike, omit } from '@sim/utils/object' -import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' -import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types' +import { projectToolResultForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result' +import type { ToolExecutionResult } from '@/lib/mothership/tool-executor/types' import { durableSecretProvenanceFromRegistry, importDurableSecretProvenance, diff --git a/apps/sim/sandbox-tasks/pptx-generate.ts b/apps/sim/sandbox-tasks/pptx-generate.ts index 2755a8be8a9..706876d3e12 100644 --- a/apps/sim/sandbox-tasks/pptx-generate.ts +++ b/apps/sim/sandbox-tasks/pptx-generate.ts @@ -1,8 +1,8 @@ -import { PPTX_SHIM_JS } from '@/lib/copilot/tools/server/files/pptx-shim' import { MAX_SANDBOX_IMAGE_DATA_URI_CHARS } from '@/lib/execution/isolated-vm-limits' import { workspaceFileBroker } from '@/lib/execution/sandbox/brokers/workspace-file' import { defineSandboxTask } from '@/lib/execution/sandbox/define-task' import type { SandboxTaskInput } from '@/lib/execution/sandbox/types' +import { PPTX_SHIM_JS } from '@/lib/mothership/tools/server/files/pptx-shim' export const pptxGenerateTask = defineSandboxTask({ id: 'pptx-generate', diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 360ddd0d67f..e458b13822b 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -27,7 +27,7 @@ import { import { DrizzleQueryError } from 'drizzle-orm/errors' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' -import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' +import { projectToolResultForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result' import type { EnvironmentResolutionSnapshot } from '@/lib/environment/utils' import { executeBitbucketTool } from '@/lib/internal/bitbucket/execute-tool' import { createInternalToolFileResult } from '@/lib/internal/tool-operations/file-result' diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index ef4a63d92a5..af64e5725cd 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -1,7 +1,7 @@ -import type { MothershipResource } from '@/lib/copilot/resources/types' import type { HostedKeyRateLimitConfig } from '@/lib/core/rate-limiter' import type { HttpRedirectPolicy } from '@/lib/core/security/http-redirect-policy' import type { PrivateSecretProvenanceSelection } from '@/lib/execution/model-input-provenance' +import type { MothershipResource } from '@/lib/mothership/resources/types' import type { OAuthService } from '@/lib/oauth' import type { ExecutorDelegationOrigin } from '@/executor/types' import type { ResolvedSecretInputPath } from '@/executor/utils/resolved-secret-trace-registry' diff --git a/bun.lock b/bun.lock index d3a4a9f48fd..de143d0ddf5 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "simstudio", diff --git a/scripts/check-tool-registry-boundary.test.ts b/scripts/check-tool-registry-boundary.test.ts index bad7fd07768..118794c4430 100644 --- a/scripts/check-tool-registry-boundary.test.ts +++ b/scripts/check-tool-registry-boundary.test.ts @@ -69,7 +69,7 @@ describe('guarded entries', () => { * so nothing held it. */ it('guards the Copilot block-metadata tool', () => { - expect(entries).toContain('lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts') + expect(entries).toContain('lib/mothership/tools/server/blocks/get-blocks-metadata-tool.ts') }) it('guards every catalog projection module rather than a barrel over them', () => { diff --git a/scripts/check-tool-registry-boundary.ts b/scripts/check-tool-registry-boundary.ts index dfe5fefbae5..61c21ef3356 100644 --- a/scripts/check-tool-registry-boundary.ts +++ b/scripts/check-tool-registry-boundary.ts @@ -166,7 +166,7 @@ const ENTRY_SOURCES: readonly EntrySource[] = [ * nothing was holding that win — the tool appeared in no guarded subtree, so * a single `getTool` import could have spent all of it silently. */ - root: 'lib/copilot/tools/server/blocks', + root: 'lib/mothership/tools/server/blocks', matches: (filename) => filename === 'get-blocks-metadata-tool.ts', reason: 'the Copilot block-metadata tool, which reads block and tool metadata only', }, diff --git a/scripts/generate-mship-contracts.ts b/scripts/generate-mship-contracts.ts index 560b239ec80..4fabfee7ae4 100644 --- a/scripts/generate-mship-contracts.ts +++ b/scripts/generate-mship-contracts.ts @@ -29,7 +29,7 @@ const GENERATORS = [ // Generated files under this path. We biome-format this whole dir on // each generate (and the temp copy on each check). -const GENERATED_DIR = 'apps/sim/lib/copilot/generated' +const GENERATED_DIR = 'apps/sim/lib/mothership/generated' // `tool-schemas-v1.ts` goes through biome's `--unsafe` bracket-quote // fixer which reformats every key of TOOL_RUNTIME_SCHEMAS. Strip it diff --git a/scripts/sync-billing-protocol-contract.ts b/scripts/sync-billing-protocol-contract.ts index 2303f258394..cb964391d2a 100644 --- a/scripts/sync-billing-protocol-contract.ts +++ b/scripts/sync-billing-protocol-contract.ts @@ -9,7 +9,7 @@ const DEFAULT_CONTRACT_PATH = resolve( ROOT, '../copilot/copilot/contracts/billing-protocol-v1.schema.json' ) -const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/billing-protocol-v1.ts') +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/mothership/generated/billing-protocol-v1.ts') type SchemaNode = Record diff --git a/scripts/sync-docs-manifest.ts b/scripts/sync-docs-manifest.ts index 9d925499714..20cc39184ea 100644 --- a/scripts/sync-docs-manifest.ts +++ b/scripts/sync-docs-manifest.ts @@ -26,13 +26,16 @@ import { readdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { foldDocsIndexPath, UNMOUNTED_DOCS_SECTIONS } from '../apps/sim/lib/copilot/docs/docs-path' +import { + foldDocsIndexPath, + UNMOUNTED_DOCS_SECTIONS, +} from '../apps/sim/lib/mothership/docs/docs-path' import { formatGeneratedSource } from './format-generated-source' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(SCRIPT_DIR, '..') const DOCS_CONTENT_DIR = resolve(ROOT, 'apps/docs/content/docs') -const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/docs-manifest.ts') +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/mothership/generated/docs-manifest.ts') /** * Top-level docs sections deliberately left out of the copilot's `docs/` tree. diff --git a/scripts/sync-metrics-contract.ts b/scripts/sync-metrics-contract.ts index 4a71a9f7c19..3398fa57412 100644 --- a/scripts/sync-metrics-contract.ts +++ b/scripts/sync-metrics-contract.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url' import { formatGeneratedSource } from './format-generated-source' /** - * Generate `apps/sim/lib/copilot/generated/metrics-v1.ts` from the Go-side + * Generate `apps/sim/lib/mothership/generated/metrics-v1.ts` from the Go-side * `contracts/metrics-v1.schema.json` contract. * * The contract is a single-enum JSON Schema listing every canonical mothership @@ -30,7 +30,7 @@ import { formatGeneratedSource } from './format-generated-source' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(SCRIPT_DIR, '..') const DEFAULT_CONTRACT_PATH = resolve(ROOT, '../copilot/copilot/contracts/metrics-v1.schema.json') -const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/metrics-v1.ts') +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/mothership/generated/metrics-v1.ts') function extractMetricNames(schema: Record): string[] { const defs = (schema.$defs ?? {}) as Record diff --git a/scripts/sync-mothership-stream-contract.ts b/scripts/sync-mothership-stream-contract.ts index 1e9641b0c83..ebe6415c2b6 100644 --- a/scripts/sync-mothership-stream-contract.ts +++ b/scripts/sync-mothership-stream-contract.ts @@ -10,10 +10,10 @@ const DEFAULT_CONTRACT_PATH = resolve( ROOT, '../copilot/copilot/contracts/mothership-stream-v1.schema.json' ) -const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/mothership-stream-v1.ts') +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/mothership/generated/mothership-stream-v1.ts') const RUNTIME_SCHEMA_OUTPUT_PATH = resolve( ROOT, - 'apps/sim/lib/copilot/generated/mothership-stream-v1-schema.ts' + 'apps/sim/lib/mothership/generated/mothership-stream-v1-schema.ts' ) function generateRuntimeConstants(schema: Record, existingTypes: string): string { diff --git a/scripts/sync-tool-catalog.ts b/scripts/sync-tool-catalog.ts index 67f7727f889..033fa276441 100644 --- a/scripts/sync-tool-catalog.ts +++ b/scripts/sync-tool-catalog.ts @@ -6,10 +6,10 @@ import { formatGeneratedSource } from './format-generated-source' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ROOT = resolve(SCRIPT_DIR, '..') const DEFAULT_CATALOG_PATH = resolve(ROOT, '../copilot/copilot/contracts/tool-catalog-v1.json') -const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/tool-catalog-v1.ts') +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/mothership/generated/tool-catalog-v1.ts') const RUNTIME_SCHEMA_OUTPUT_PATH = resolve( ROOT, - 'apps/sim/lib/copilot/generated/tool-schemas-v1.ts' + 'apps/sim/lib/mothership/generated/tool-schemas-v1.ts' ) function snakeToPascal(s: string): string { diff --git a/scripts/sync-trace-attribute-values-contract.ts b/scripts/sync-trace-attribute-values-contract.ts index 762ba194a74..566668586f0 100644 --- a/scripts/sync-trace-attribute-values-contract.ts +++ b/scripts/sync-trace-attribute-values-contract.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url' import { formatGeneratedSource } from './format-generated-source' /** - * Generate `apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts` + * Generate `apps/sim/lib/mothership/generated/trace-attribute-values-v1.ts` * from the Go-side `contracts/trace-attribute-values-v1.schema.json` * contract. * @@ -30,7 +30,7 @@ const DEFAULT_CONTRACT_PATH = resolve( ROOT, '../copilot/copilot/contracts/trace-attribute-values-v1.schema.json' ) -const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts') +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/mothership/generated/trace-attribute-values-v1.ts') interface ExtractedEnum { /** The Go type name — becomes the TS const + type name. */ diff --git a/scripts/sync-trace-attributes-contract.ts b/scripts/sync-trace-attributes-contract.ts index 96d8f488570..cce544553de 100644 --- a/scripts/sync-trace-attributes-contract.ts +++ b/scripts/sync-trace-attributes-contract.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url' import { formatGeneratedSource } from './format-generated-source' /** - * Generate `apps/sim/lib/copilot/generated/trace-attributes-v1.ts` + * Generate `apps/sim/lib/mothership/generated/trace-attributes-v1.ts` * from the Go-side `contracts/trace-attributes-v1.schema.json` * contract. * @@ -35,7 +35,7 @@ const DEFAULT_CONTRACT_PATH = resolve( ROOT, '../copilot/copilot/contracts/trace-attributes-v1.schema.json' ) -const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/trace-attributes-v1.ts') +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/mothership/generated/trace-attributes-v1.ts') function extractAttrKeys(schema: Record): string[] { const defs = (schema.$defs ?? {}) as Record diff --git a/scripts/sync-trace-events-contract.ts b/scripts/sync-trace-events-contract.ts index 8253fb59258..9ba4e9ab422 100644 --- a/scripts/sync-trace-events-contract.ts +++ b/scripts/sync-trace-events-contract.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url' import { formatGeneratedSource } from './format-generated-source' /** - * Generate `apps/sim/lib/copilot/generated/trace-events-v1.ts` from + * Generate `apps/sim/lib/mothership/generated/trace-events-v1.ts` from * the Go-side `contracts/trace-events-v1.schema.json` contract. * * Mirrors the span-names + attribute-keys sync scripts exactly — the @@ -20,7 +20,7 @@ const DEFAULT_CONTRACT_PATH = resolve( ROOT, '../copilot/copilot/contracts/trace-events-v1.schema.json' ) -const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/trace-events-v1.ts') +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/mothership/generated/trace-events-v1.ts') function extractEventNames(schema: Record): string[] { const defs = (schema.$defs ?? {}) as Record diff --git a/scripts/sync-trace-spans-contract.ts b/scripts/sync-trace-spans-contract.ts index 374898df261..d6e4fe1ad9e 100644 --- a/scripts/sync-trace-spans-contract.ts +++ b/scripts/sync-trace-spans-contract.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url' import { formatGeneratedSource } from './format-generated-source' /** - * Generate `apps/sim/lib/copilot/generated/trace-spans-v1.ts` from the + * Generate `apps/sim/lib/mothership/generated/trace-spans-v1.ts` from the * Go-side `contracts/trace-spans-v1.schema.json` contract. * * The contract is a single-enum JSON Schema. We emit: @@ -25,7 +25,7 @@ const DEFAULT_CONTRACT_PATH = resolve( ROOT, '../copilot/copilot/contracts/trace-spans-v1.schema.json' ) -const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/trace-spans-v1.ts') +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/mothership/generated/trace-spans-v1.ts') function extractSpanNames(schema: Record): string[] { const defs = (schema.$defs ?? {}) as Record diff --git a/scripts/sync-vfs-snapshot-contract.ts b/scripts/sync-vfs-snapshot-contract.ts index e0e616ebdbe..139b193a5fd 100644 --- a/scripts/sync-vfs-snapshot-contract.ts +++ b/scripts/sync-vfs-snapshot-contract.ts @@ -13,7 +13,7 @@ const DEFAULT_CONTRACT_PATH = resolve( ROOT, '../copilot/copilot/contracts/vfs-snapshot-v1.schema.json' ) -const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/vfs-snapshot-v1.ts') +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/mothership/generated/vfs-snapshot-v1.ts') async function main() { const checkOnly = process.argv.includes('--check') From 81e2ea132232e8c7ab5b600e50e175bee6cae844 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 06:39:49 +0530 Subject: [PATCH 002/306] feat(mothership): thread clientCapabilities to workflow-tool dispatch Executor routing is decided once at turn setup from the caller's declared capabilities (ChatRequest.clientCapabilities), not discovered per call by burning the 30s client-pickup grace: post.ts resolves the declaration into clientToolPickupExpected on the orchestrate options, the race site passes graceMs 0 when pickup was declared absent (same claim arbitration either way), and the UI declares 'workflow-tool-pickup' since the mounted chat view is what executes the run panel. Absent declaration keeps the legacy grace (skew-safe). Orthogonal to copilotInteractionMode, which classifies trust, not routing. Companion: mothership eb9c17cf. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/lib/mothership/chat/payload.ts | 4 ++++ apps/sim/lib/mothership/chat/post.ts | 6 ++++++ apps/sim/lib/mothership/generated/protocol.ts | 8 ++++++++ .../lib/mothership/request/handlers/tool.ts | 9 ++++++++- .../tools/workflow-client-fallback.test.ts | 18 ++++++++++++++++++ apps/sim/lib/mothership/request/types.ts | 8 ++++++++ 6 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/mothership/chat/payload.ts b/apps/sim/lib/mothership/chat/payload.ts index 50c7d4c4dc2..221eb233f17 100644 --- a/apps/sim/lib/mothership/chat/payload.ts +++ b/apps/sim/lib/mothership/chat/payload.ts @@ -424,5 +424,9 @@ export async function buildCopilotRequestPayload( ...(integrationTools.length > 0 ? { integrationTools } : {}), ...(mothershipTools.length > 0 ? { mothershipTools } : {}), ...(params.userTimezone ? { userTimezone: params.userTimezone } : {}), + // The mounted chat view executes client-routed workflow tools (run panel UX), so the + // UI declares that capability explicitly; headless callers omit or send [] and the + // server runs those tools immediately instead of waiting out the pickup grace. + clientCapabilities: ['workflow-tool-pickup'], } } diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index efae6d2a5ec..189affe4c08 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -305,6 +305,7 @@ const ChatMessageSchema = z contexts: z.array(ChatContextSchema).optional(), commands: z.array(z.string()).optional(), userTimezone: z.string().optional(), + clientCapabilities: z.array(z.string()).optional(), desktopCapabilities: z .object({ localFilesystem: z.boolean().optional(), @@ -1657,6 +1658,11 @@ export async function handleUnifiedChatPost(req: NextRequest) { goRoute: branch.goRoute, autoExecuteTools: true, interactive: true, + // Executor routing is decided HERE, once per turn, from the caller's declared + // capabilities — dispatch never discovers client absence by burning a grace timer. + clientToolPickupExpected: body.clientCapabilities + ? body.clientCapabilities.includes('workflow-tool-pickup') + : true, executionContext, billingAttribution: executionContext.billingAttribution, onComplete: buildOnComplete({ diff --git a/apps/sim/lib/mothership/generated/protocol.ts b/apps/sim/lib/mothership/generated/protocol.ts index fdc1d164bee..35147ec3502 100644 --- a/apps/sim/lib/mothership/generated/protocol.ts +++ b/apps/sim/lib/mothership/generated/protocol.ts @@ -39,6 +39,14 @@ export interface ChatRequest { /** User attachments / @-mentions packed with the message. */ context?: ChatContextItem[] | undefined userTimezone?: string | undefined + /** + * What the CALLER can execute client-side. PRESENT = an explicit declaration — an + * empty array means "I pick up nothing", so sim-side dispatch must skip client-pickup + * grace windows and run tools server-side immediately. ABSENT = legacy/unknown caller — + * dispatch keeps its conservative grace (deploy-skew safe: a stale tab that predates + * this field still gets waited on). Known capability: "workflow-tool-pickup". + */ + clientCapabilities?: string[] | undefined } export interface ChatContextItem { diff --git a/apps/sim/lib/mothership/request/handlers/tool.ts b/apps/sim/lib/mothership/request/handlers/tool.ts index 5cee0799488..7decffe45fb 100644 --- a/apps/sim/lib/mothership/request/handlers/tool.ts +++ b/apps/sim/lib/mothership/request/handlers/tool.ts @@ -863,7 +863,14 @@ async function dispatchToolExecution( toolCallId, workflowId: resolveWorkflowToolTargetId(args, execContext.workflowId), timeoutMs, - graceMs: COPILOT_WORKFLOW_TOOL_CLIENT_GRACE_MS, + // The caller declared its executors at turn setup (ChatRequest.clientCapabilities): + // no pickup capability → zero grace, the race resolves straight to the server + // claim. Same claim arbitration either way — the grace only encodes how long a + // client might plausibly appear. + graceMs: + options.clientToolPickupExpected === false + ? 0 + : COPILOT_WORKFLOW_TOOL_CLIENT_GRACE_MS, abortSignal: options.abortSignal, registry: execContext.resolvedSecretTraceRegistry, runOnServer: (boundExecutionId) => { diff --git a/apps/sim/lib/mothership/request/tools/workflow-client-fallback.test.ts b/apps/sim/lib/mothership/request/tools/workflow-client-fallback.test.ts index d9f4c7ba6a3..7eae6d74c18 100644 --- a/apps/sim/lib/mothership/request/tools/workflow-client-fallback.test.ts +++ b/apps/sim/lib/mothership/request/tools/workflow-client-fallback.test.ts @@ -102,6 +102,24 @@ describe('raceWorkflowToolClientPickup', () => { expect(waiterSignals.at(0)?.aborted).toBe(true) }) + it('claims immediately when the caller declared no client pickup (graceMs 0)', async () => { + // ChatRequest.clientCapabilities without 'workflow-tool-pickup' resolves to graceMs 0 + // at the dispatch site: same claim arbitration, no dead-air grace for a client that + // the caller itself said will never come. + pendingUntilAborted() + claimWorkflowToolExecution.mockResolvedValue({ toolCallId: 'tool-1' }) + const params = baseParams({ graceMs: 0 }) + + const promise = raceWorkflowToolClientPickup(params as never) + await vi.advanceTimersByTimeAsync(0) + const outcome = await promise + + expect(outcome.winner).toBe('sim') + expect(claimWorkflowToolExecution).toHaveBeenCalledTimes(1) + expect(params.runOnServer).toHaveBeenCalledTimes(1) + expect(waiterSignals.at(0)?.aborted).toBe(true) + }) + it('keeps waiting on the browser when the claim is lost', async () => { // The waiter stays pending until the "browser" reports, so we can assert the // helper went back to waiting on the same promise rather than running. diff --git a/apps/sim/lib/mothership/request/types.ts b/apps/sim/lib/mothership/request/types.ts index ab291b8012a..af0d09d8658 100644 --- a/apps/sim/lib/mothership/request/types.ts +++ b/apps/sim/lib/mothership/request/types.ts @@ -240,6 +240,14 @@ export interface OrchestratorOptions { abortSignal?: AbortSignal onAbortObserved?: (reason: string) => void interactive?: boolean + /** + * Whether the caller declared it can pick up client-routed workflow tools + * (ChatRequest.clientCapabilities). false → dispatch claims and runs them + * server-side immediately instead of waiting out the client-pickup grace. + * Defaults true (legacy callers made no declaration). Orthogonal to + * `interactive`, which is a trust classification, not executor routing. + */ + clientToolPickupExpected?: boolean } export interface OrchestratorResult { From ed916b6398cd3bc95fd937a9ef8dc74f5c9aeeea Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 07:10:39 +0530 Subject: [PATCH 003/306] =?UTF-8?q?feat(mothership):=20usage-limit=20admis?= =?UTF-8?q?sion=20at=20dispatch=20=E2=80=94=20the=20gate=20the=20worker=20?= =?UTF-8?q?path=20lost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old path's usage-limit admission lived inside the Go backend's api-keys/validate callback; the TS worker rightly never calls it, which left the new path with zero limit enforcement (the 402 handler was dead code). Admission now runs at its right layer: runCopilotLifecycle checks checkAttributedUsageLimits with the already-resolved attribution before dispatching, and on exceeded renders the exact same synthetic 402 UX as the mid-stream path without ever contacting the backend. Lifecycle tests gain a faithful billing-attribution envelope mock (real protocol constant, UUID ids, URI-encoded serialization) with only the limit check controllable per-test. Companion: mothership worker settlement commit. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../mothership/request/lifecycle/run.test.ts | 67 +++++++++++++++++++ .../lib/mothership/request/lifecycle/run.ts | 66 +++++++++++------- 2 files changed, 109 insertions(+), 24 deletions(-) diff --git a/apps/sim/lib/mothership/request/lifecycle/run.test.ts b/apps/sim/lib/mothership/request/lifecycle/run.test.ts index 34e7d490244..194ec40d024 100644 --- a/apps/sim/lib/mothership/request/lifecycle/run.test.ts +++ b/apps/sim/lib/mothership/request/lifecycle/run.test.ts @@ -3,8 +3,10 @@ */ import { resetEnvFlagsMock, resetEnvironmentUtilsMock, setEnvFlags } from '@sim/testing' +import { generateId } from '@sim/utils/id' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { scopeProviderToolCallId } from '@/lib/mothership/request/go/tool-call-identity' +import { handleBillingLimitResponse } from '@/lib/mothership/request/tools/billing' import type { ExecutionContext, StreamingContext } from '@/lib/mothership/request/types' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -24,6 +26,7 @@ const { mockGetUserPermissionConfig, mockFilterModelSafeWorkspaceFileAttachments, mockUpdateRunStatus, + mockCheckAttributedUsageLimits, mockEnv, } = vi.hoisted(() => ({ mockCreateRunSegment: vi.fn(), @@ -39,6 +42,7 @@ const { mockGetUserPermissionConfig: vi.fn(async () => null), mockFilterModelSafeWorkspaceFileAttachments: vi.fn(async (attachments: unknown[]) => attachments), mockUpdateRunStatus: vi.fn(), + mockCheckAttributedUsageLimits: vi.fn(), mockEnv: { COPILOT_API_KEY: undefined as string | undefined, MSHIP_SYSPROMPT_OVERRIDE: undefined as string | undefined, @@ -133,6 +137,29 @@ vi.mock('@/lib/mothership/tools/handlers/context', () => ({ prepareExecutionContext: mockPrepareExecutionContext, })) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + /** + * Faithful envelope replica: real protocol constant, real UUID request id, real + * URI-encoded serialization — the hosted-header tests below assert all three. + * Only the usage-limit check itself is controllable per-test. + */ + assertBillingAttributionSnapshot: (value: unknown) => value, + checkAttributedUsageLimits: mockCheckAttributedUsageLimits, + createAttributedBillingRequestEnvelope: (attribution: unknown) => { + const billingRequestId = generateId() + const serializedAttribution = encodeURIComponent(JSON.stringify(attribution)) + return { + billingRequestId, + serializedAttribution, + headers: { + 'x-sim-billing-protocol': 'attribution-v1', + 'x-sim-billing-request-id': billingRequestId, + 'x-sim-billing-attribution': serializedAttribution, + }, + } + }, +})) + vi.mock('@/lib/mothership/request/tools/billing', () => ({ handleBillingLimitResponse: vi.fn(), })) @@ -167,6 +194,7 @@ const SCHEMA_CONTROL_KEYS = [ describe('runCopilotLifecycle', () => { beforeEach(() => { vi.clearAllMocks() + mockCheckAttributedUsageLimits.mockResolvedValue({ isExceeded: false }) mockEnv.COPILOT_API_KEY = undefined mockEnv.MSHIP_SYSPROMPT_OVERRIDE = undefined setEnvFlags({ @@ -1818,6 +1846,45 @@ describe('runCopilotLifecycle', () => { } }) + it('refuses dispatch at admission when hosted usage limits are exceeded', async () => { + const billingAttribution = { + actorUserId: 'user-1', + workspaceId: 'ws-1', + organizationId: 'org-1', + billedAccountUserId: 'user-1', + billingEntity: { type: 'organization' as const, id: 'org-1' }, + billingPeriod: { + start: '2026-07-01T00:00:00.000Z', + end: '2026-08-01T00:00:00.000Z', + }, + payerSubscription: null, + } + setEnvFlags({ isHosted: true }) + mockCheckAttributedUsageLimits.mockResolvedValue({ + isExceeded: true, + message: 'limit reached', + scope: 'payer', + }) + + const result = await runCopilotLifecycle( + { message: 'hello', messageId: 'message-1' }, + { + userId: 'user-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + executionId: 'execution-1', + runId: 'run-1', + simRequestId: 'request-1', + billingAttribution, + } + ) + + expect(mockCheckAttributedUsageLimits).toHaveBeenCalledWith(billingAttribution) + expect(handleBillingLimitResponse).toHaveBeenCalledTimes(1) + expect(mockRunStreamLoop).not.toHaveBeenCalled() + expect(result.cancelled).not.toBe(true) + }) + it('preserves a resume tool name that collides with a configured secret', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: 'unsafe-tool', encryptedValue: 'ciphertext' }, diff --git a/apps/sim/lib/mothership/request/lifecycle/run.ts b/apps/sim/lib/mothership/request/lifecycle/run.ts index 945f8adf896..627cdeac59c 100644 --- a/apps/sim/lib/mothership/request/lifecycle/run.ts +++ b/apps/sim/lib/mothership/request/lifecycle/run.ts @@ -11,6 +11,7 @@ import { type AttributedBillingRequestEnvelope, assertBillingAttributionSnapshot, type BillingAttributionSnapshot, + checkAttributedUsageLimits, createAttributedBillingRequestEnvelope, } from '@/lib/billing/core/billing-attribution' import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription' @@ -426,32 +427,49 @@ export async function runCopilotLifecycle( let onCompleteStarted = false try { - await ensureModelEgressRegistry(execContext, lifecycleOptions) - if (organizationId && goRoute !== '/api/tools/resume') { - if (!chatId) throw new Error('Search integration context requires a private chat ID') - requestPayload = { - ...requestPayload, - workspaceContext: await loadCopilotSearchIntegrations({ - userId, - organizationId, - chatId, - messageId: payloadMsgId, - signal: lifecycleOptions.abortSignal, - }), + // Hosted admission (usage limits) belongs HERE, at dispatch, with the attribution + // already in hand. The old path outsourced it to the Go backend's api-keys/validate + // callback — a call the TS worker rightly never makes, so without this gate the + // limit check simply never runs on the new path. On exceeded, the same synthetic + // 402 UX as the mid-stream path renders the upgrade prompt, the backend is never + // dispatched, and the shared verdict assembly below runs exactly as after a + // mid-stream billing break. + const admission = + isHosted && execContext.billingAttribution + ? await checkAttributedUsageLimits( + assertBillingAttributionSnapshot(execContext.billingAttribution) + ) + : { isExceeded: false as const } + if (admission.isExceeded) { + await handleBillingLimitResponse(execContext.userId, context, execContext, lifecycleOptions) + } else { + await ensureModelEgressRegistry(execContext, lifecycleOptions) + if (organizationId && goRoute !== '/api/tools/resume') { + if (!chatId) throw new Error('Search integration context requires a private chat ID') + requestPayload = { + ...requestPayload, + workspaceContext: await loadCopilotSearchIntegrations({ + userId, + organizationId, + chatId, + messageId: payloadMsgId, + signal: lifecycleOptions.abortSignal, + }), + } } + const modelSafeRequestPayload = await prepareInitialCopilotAttachmentsForModel( + requestPayload, + lifecycleOptions.workspaceId + ) + await runCheckpointLoop( + modelSafeRequestPayload, + context, + execContext, + lifecycleOptions, + goRoute, + hostedBillingRequest + ) } - const modelSafeRequestPayload = await prepareInitialCopilotAttachmentsForModel( - requestPayload, - lifecycleOptions.workspaceId - ) - await runCheckpointLoop( - modelSafeRequestPayload, - context, - execContext, - lifecycleOptions, - goRoute, - hostedBillingRequest - ) // The backend's terminal `complete` is the turn's verdict. A failure it // reported in-band on the way there — a tool or a subagent that failed and From f678780a4ec6f265eb0051df12783a5df5ce7b5d Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 08:22:17 +0530 Subject: [PATCH 004/306] =?UTF-8?q?fix(mothership):=20client=20verdict=20m?= =?UTF-8?q?irrors=20the=20server's=20=E2=80=94=20complete{status}=20decide?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan HIGH: a non-fatal mid-stream error frame (subagent hiccup the orchestrator recovered from — deliberately forwarded inline) made every consumer finalize a SUCCESSFUL turn as an error: banner shown, live view torn down, queued follow-ups stranded (notifyTurnEnded({error:true}) skips the queue kick). The stream state now records the terminal complete frame's status, and the driver's single return settles the verdict by the server's own rule: status:complete outranks recorded stream errors; status:error is an error even without a preceding error frame. Send path, reconnect replay, and live tail all inherit the settled verdict. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../home/hooks/stream/handle-complete-event.ts | 3 ++- .../[workspaceId]/home/hooks/stream/stream-context.ts | 3 +++ .../workspace/[workspaceId]/home/hooks/use-chat.ts | 11 ++++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts index 521f447fc1d..e0904dd8e53 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-complete-event.ts @@ -8,9 +8,10 @@ type CompleteEvent = Extract * still-open node are folded into the model by `reduceEvent` (which skips an * async pause). This handler only records the terminal flag and flushes. */ -export function handleCompleteEvent(ctx: StreamLoopContext, _parsed: CompleteEvent): void { +export function handleCompleteEvent(ctx: StreamLoopContext, parsed: CompleteEvent): void { ctx.deps.clearBrowserAgentRuns() ctx.state.browserAgentRunIds.clear() ctx.state.sawCompleteEvent = true + ctx.state.completionStatus = parsed.payload.status ?? null ctx.ops.flush() } diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts index b9882a6d4a9..8d86dad411a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.ts @@ -69,6 +69,8 @@ export interface StreamLoopState { streamRequestId: string | undefined sawStreamError: boolean sawCompleteEvent: boolean + /** The terminal complete frame's status — the SERVER's verdict on the turn. */ + completionStatus: 'complete' | 'error' | 'cancelled' | null browserAgentRunIds: Set scheduledTextFlushFrame: number | null /** Trailing timer for the min-interval text-flush gate (see flushText). */ @@ -211,6 +213,7 @@ export function createStreamLoopContext(deps: StreamLoopDeps): StreamLoopContext streamRequestId: undefined, sawStreamError: false, sawCompleteEvent: false, + completionStatus: null, browserAgentRunIds: new Set(), scheduledTextFlushFrame: null, scheduledTextFlushTimer: null, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 694f27daad7..2d95975ef29 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -2125,7 +2125,16 @@ export function useChat( streamReaderRef.current = null } } - return { sawStreamError: state.sawStreamError, sawComplete: state.sawCompleteEvent } + // The server's verdict priority (its backendFinishedTurn rule): a terminal + // complete{status:complete} outranks any non-fatal mid-stream error frame — + // subagent hiccups the orchestrator recovered from are inline content, not the + // turn's outcome. Conversely complete{status:error} is an error even when no + // error frame preceded it. Deciding here means every consumer (send path, + // reconnect replay, live tail) inherits the same rule. + const terminalStatus = state.completionStatus + const settledError = + terminalStatus === 'complete' ? false : state.sawStreamError || terminalStatus === 'error' + return { sawStreamError: settledError, sawComplete: state.sawCompleteEvent } }, [ workspaceId, From afbce03bc3a5d064e086e98cb0bf80b12620ca03 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 09:05:53 +0530 Subject: [PATCH 005/306] chore(mothership): exempt generated protocol from biome formatting The contract-sync check is byte-strict against the worker source; the formatter rewrote the generated copy (semicolon style) and broke sync. Generated dir ignored, copy regenerated verbatim. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- biome.json | 1 + 1 file changed, 1 insertion(+) diff --git a/biome.json b/biome.json index 81e02110a68..9596f5915e7 100644 --- a/biome.json +++ b/biome.json @@ -27,6 +27,7 @@ "!**/public/worker-*.js", "!**/public/fallback-*.js", "!**/apps/sim/tools/generated", + "!**/apps/sim/lib/mothership/generated", "!**/apps/docs/.source", "!**/apps/desktop/release", "!**/venv", From a52a1b91c281d72fc52bfb8fa8d6a8a44a8a7842 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 09:07:20 +0530 Subject: [PATCH 006/306] chore(mothership): generated protocol ignore needs /** to cover contents Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/lib/mothership/generated/protocol.ts | 112 +++++++++--------- biome.json | 2 +- 2 files changed, 57 insertions(+), 57 deletions(-) diff --git a/apps/sim/lib/mothership/generated/protocol.ts b/apps/sim/lib/mothership/generated/protocol.ts index 35147ec3502..036290d133c 100644 --- a/apps/sim/lib/mothership/generated/protocol.ts +++ b/apps/sim/lib/mothership/generated/protocol.ts @@ -15,30 +15,30 @@ * instead of undefined behavior. */ -export const PROTOCOL_VERSION = 1 +export const PROTOCOL_VERSION = 1; /** POST /api/mothership — the chat request sim sends. */ export interface ChatRequest { - message: string - userId: string + message: string; + userId: string; /** Bump-gated (S43): senders include it; the worker 426s on mismatch. */ - protocolVersion?: number | undefined - messageId?: string | undefined - chatId?: string | undefined - workspaceId?: string | undefined + protocolVersion?: number | undefined; + messageId?: string | undefined; + chatId?: string | undefined; + workspaceId?: string | undefined; /** Workflow-scoped chats (the workflow-page copilot): the agent anchors to this workflow. */ - workflowId?: string | undefined + workflowId?: string | undefined; /** Connected-service operation schemas served by the integration gateway. */ - integrationTools?: unknown[] | undefined + integrationTools?: unknown[] | undefined; /** User-configured MCP tool schemas — same shape as integrationTools. */ - mothershipTools?: unknown[] | undefined + mothershipTools?: unknown[] | undefined; /** D23: sim-minted run-scoped credential. In-memory only on the worker (S44). */ - delegationToken?: string | undefined + delegationToken?: string | undefined; /** Enterprise BYOK: customer's own key; per-run instance, zero retention (S27). */ - byokApiKey?: string | undefined + byokApiKey?: string | undefined; /** User attachments / @-mentions packed with the message. */ - context?: ChatContextItem[] | undefined - userTimezone?: string | undefined + context?: ChatContextItem[] | undefined; + userTimezone?: string | undefined; /** * What the CALLER can execute client-side. PRESENT = an explicit declaration — an * empty array means "I pick up nothing", so sim-side dispatch must skip client-pickup @@ -46,61 +46,61 @@ export interface ChatRequest { * dispatch keeps its conservative grace (deploy-skew safe: a stale tab that predates * this field still gets waited on). Known capability: "workflow-tool-pickup". */ - clientCapabilities?: string[] | undefined + clientCapabilities?: string[] | undefined; } export interface ChatContextItem { - type: string - content: string - tag?: string | undefined - path?: string | undefined + type: string; + content: string; + tag?: string | undefined; + path?: string | undefined; } /** POST /api/tools/resume — deferred tool results. */ export interface ResumeRequest { - streamId: string - results: ResumeResult[] + streamId: string; + results: ResumeResult[]; } export interface ResumeResult { - callId: string - name?: string | undefined - data?: unknown | undefined - success?: boolean | undefined + callId: string; + name?: string | undefined; + data?: unknown | undefined; + success?: boolean | undefined; } /** POST /api/streams/explicit-abort */ export interface AbortRequest { - messageId: string + messageId: string; } /** POST /api/streams/steer. Acceptance means "queued"; application is acknowledged by a * `run`/`steering_applied` frame carrying the steeringId — a caller that never sees the * ack re-sends the content as an ordinary message (loss-free without liveness proof). */ export interface SteerRequest { - messageId: string - steeringId?: string | undefined - content: string + messageId: string; + steeringId?: string | undefined; + content: string; } /** POST /api/generate-chat-title */ export interface TitleRequest { - message: string + message: string; } /** The 409 body for a duplicate send while a sibling instance streams (S32). */ export interface ActiveStreamConflict { - error: 'active_stream' - streamId: string - status: string + error: "active_stream"; + streamId: string; + status: string; } /** The 426 body for protocol version skew (S43). */ export interface ProtocolMismatch { - error: 'protocol_version_mismatch' - expected: number - got: number - message: string + error: "protocol_version_mismatch"; + expected: number; + got: number; + message: string; } /** @@ -111,22 +111,22 @@ export interface ProtocolMismatch { * surface is exactly what the caller passes. */ export interface ExecuteRequest { - messages: ExecuteMessage[] + messages: ExecuteMessage[]; /** JSON schema for structured output; enforced by instruction + caller-side validation. */ - responseFormat?: unknown | undefined - userId: string - protocolVersion?: number | undefined - workspaceId?: string | undefined - chatId?: string | undefined - messageId?: string | undefined - integrationTools?: unknown[] | undefined - mothershipTools?: unknown[] | undefined - delegationToken?: string | undefined + responseFormat?: unknown | undefined; + userId: string; + protocolVersion?: number | undefined; + workspaceId?: string | undefined; + chatId?: string | undefined; + messageId?: string | undefined; + integrationTools?: unknown[] | undefined; + mothershipTools?: unknown[] | undefined; + delegationToken?: string | undefined; } export interface ExecuteMessage { - role: 'system' | 'user' | 'assistant' - content: string + role: "system" | "user" | "assistant"; + content: string; } /** @@ -135,12 +135,12 @@ export interface ExecuteMessage { * worker's emitter is compile-locked to this; sim's parser adopts it at the client rework. */ export interface StreamEnvelope { - v: 1 - type: 'session' | 'text' | 'tool' | 'run' | 'resource' | 'error' | 'complete' - seq: number + v: 1; + type: "session" | "text" | "tool" | "run" | "resource" | "error" | "complete"; + seq: number; /** ISO timestamp. */ - ts: string - stream: { streamId: string; chatId?: string | undefined; cursor?: string | undefined } - trace?: { requestId?: string | undefined } | undefined - payload: Record + ts: string; + stream: { streamId: string; chatId?: string | undefined; cursor?: string | undefined }; + trace?: { requestId?: string | undefined } | undefined; + payload: Record; } diff --git a/biome.json b/biome.json index 9596f5915e7..f87153397c8 100644 --- a/biome.json +++ b/biome.json @@ -27,7 +27,7 @@ "!**/public/worker-*.js", "!**/public/fallback-*.js", "!**/apps/sim/tools/generated", - "!**/apps/sim/lib/mothership/generated", + "!**/apps/sim/lib/mothership/generated/**", "!**/apps/docs/.source", "!**/apps/desktop/release", "!**/venv", From 3e277b423d913d27a776bcdcdea09c5ba170591a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 09:32:57 +0530 Subject: [PATCH 007/306] perf(mothership): cache the superuser routing gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getMothershipBaseURL joined user+settings on EVERY chat turn to learn what is true for almost everyone: not a superuser. The negative gate now sits in an LRU (max 50k, 60s TTL); real superusers still read fresh each turn so an environment flip applies immediately, and the settings PATCH invalidates the toggling user's entry (same-process write → reader). Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/app/api/users/me/settings/route.ts | 5 ++++ .../lib/mothership/server/agent-url.test.ts | 23 +++++++++++++++++++ apps/sim/lib/mothership/server/agent-url.ts | 22 +++++++++++++++++- 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/users/me/settings/route.ts b/apps/sim/app/api/users/me/settings/route.ts index 7134a945123..7cb12a53258 100644 --- a/apps/sim/app/api/users/me/settings/route.ts +++ b/apps/sim/app/api/users/me/settings/route.ts @@ -8,6 +8,7 @@ import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { InternalUnauthenticatedError, internalSessionAuth } from '@/lib/api/server/routes' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { invalidateSuperUserGate } from '@/lib/mothership/server/agent-url' import { getCurrentUserSettingsUseCase } from '@/lib/users/application/read-current-user' import { defaultUserSettings } from '@/lib/users/queries' @@ -68,6 +69,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest) => { }, }) + /* Chat-turn routing caches the superuser gate; a toggle here must reach the very + next turn, so drop this user's cached entry (same-process write → reader). */ + if ('superUserModeEnabled' in validatedData) invalidateSuperUserGate(userId) + return NextResponse.json({ success: true }, { status: 200 }) } catch (error) { logger.error('Settings update error', error) diff --git a/apps/sim/lib/mothership/server/agent-url.test.ts b/apps/sim/lib/mothership/server/agent-url.test.ts index 229d52a9525..cf33293e32c 100644 --- a/apps/sim/lib/mothership/server/agent-url.test.ts +++ b/apps/sim/lib/mothership/server/agent-url.test.ts @@ -2,6 +2,7 @@ import { user } from '@sim/db/schema' import { queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { + clearSuperUserGate, getMothershipBaseURL, getMothershipSourceEnvHeaders, MOTHERSHIP_SOURCE_ENV_HEADER, @@ -36,6 +37,7 @@ describe('getMothershipBaseURL', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + clearSuperUserGate() envMock.COPILOT_SOURCE_ENV = undefined }) @@ -50,6 +52,27 @@ describe('getMothershipBaseURL', () => { ) }) + it('caches the negative gate: a non-superuser skips the DB on repeat turns until invalidated', async () => { + queueTableRows(user, [ + { role: 'user', superUserModeEnabled: false, mothershipEnvironment: 'dev' }, + ]) + await expect(getMothershipBaseURL({ userId: 'user-cache' })).resolves.toBe( + 'https://default.mothership.test' + ) + // A superuser row is now queued — a cache hit never reads it and stays default. + queueTableRows(user, [ + { role: 'admin', superUserModeEnabled: true, mothershipEnvironment: 'dev' }, + ]) + await expect(getMothershipBaseURL({ userId: 'user-cache' })).resolves.toBe( + 'https://default.mothership.test' + ) + // Invalidation (the settings PATCH path) makes the next turn read fresh. + clearSuperUserGate() + await expect(getMothershipBaseURL({ userId: 'user-cache' })).resolves.toBe( + 'https://dev.mothership.test' + ) + }) + it('ignores stored and explicit environments for non-admin users', async () => { queueTableRows(user, [ { role: 'user', superUserModeEnabled: true, mothershipEnvironment: 'dev' }, diff --git a/apps/sim/lib/mothership/server/agent-url.ts b/apps/sim/lib/mothership/server/agent-url.ts index 98aff2d5fcf..cd46e23de57 100644 --- a/apps/sim/lib/mothership/server/agent-url.ts +++ b/apps/sim/lib/mothership/server/agent-url.ts @@ -1,8 +1,8 @@ import { db } from '@sim/db' import { settings, user } from '@sim/db/schema' import { eq } from 'drizzle-orm' +import { LRUCache } from 'lru-cache' import { type MothershipEnvironment, mothershipEnvironmentSchema } from '@/lib/api/contracts/user' -import { SIM_AGENT_API_URL, SIM_AGENT_API_URL_DEFAULT } from '@/lib/mothership/constants' import { env } from '@/lib/core/config/env' import { SIM_AGENT_API_URL, SIM_AGENT_API_URL_DEFAULT } from '@/lib/mothership/constants' @@ -41,6 +41,23 @@ function getDefaultMothershipBaseURL(fallbackUrl?: string | null): string { return normalizeUrl(fallback) ?? normalizeUrl(SIM_AGENT_API_URL) ?? SIM_AGENT_API_URL_DEFAULT } +/** + * Superuser-gate cache: almost every user is NOT an admin with superuser mode on, yet the + * check joined user+settings on every chat turn. Caching only the negative gate keeps the + * hot path DB-free while a real superuser's environment selection stays fresh per turn + * (their env flip must apply immediately). A newly granted superuser waits at most one TTL. + */ +const superUserGate = new LRUCache({ max: 50_000, ttl: 60_000 }) + +export function invalidateSuperUserGate(userId: string): void { + superUserGate.delete(userId) +} + +/** Test-only: drops every cached gate so each case sees its own queued DB rows. */ +export function clearSuperUserGate(): void { + superUserGate.clear() +} + export async function getMothershipBaseURL( options: GetMothershipBaseURLOptions = {} ): Promise { @@ -49,6 +66,8 @@ export async function getMothershipBaseURL( const { userId } = options if (!userId) return defaultUrl + if (superUserGate.get(userId) === false) return defaultUrl + const [row] = await db .select({ role: user.role, @@ -61,6 +80,7 @@ export async function getMothershipBaseURL( .limit(1) const effectiveSuperUser = row?.role === 'admin' && (row.superUserModeEnabled ?? false) + superUserGate.set(userId, effectiveSuperUser) if (!effectiveSuperUser) return defaultUrl const selectedEnvironment = options.environment ?? row.mothershipEnvironment From e7ad3b0d7ce29af47e7245155bd01b95a0f35789 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 09:39:16 +0530 Subject: [PATCH 008/306] fix(mothership): honest replay dedupe on execute + headless flush skip The execute NDJSON forwarder guessed delta-vs-cumulative by string prefix and could slice real characters off a delta that happened to begin with the forwarded content. The wire's ordering key decides now: seq rides the StreamEvent projection (it was dropped at construction), and the forwarder skips only seqs it has already sent. The per-event macrotask yield exists to flush the HTTP response buffer; headless legs (no caller sink) now opt out via flushAfterEvent, declared where the sink is owned. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/app/api/mothership/execute/route.ts | 17 +++++++++-------- apps/sim/lib/mothership/request/go/stream.ts | 5 ++++- .../sim/lib/mothership/request/lifecycle/run.ts | 4 ++++ .../lib/mothership/request/session/contract.ts | 8 +++++++- .../mothership/request/session/event.test.ts | 1 + .../sim/lib/mothership/request/session/event.ts | 1 + apps/sim/lib/mothership/request/types.ts | 6 ++++++ 7 files changed, 32 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index c41c4d6b405..a14467b00cd 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -380,7 +380,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const stream = new ReadableStream({ start(controller) { - let forwardedAssistantContent = '' + let lastForwardedTextSeq = -1 const send = (event: unknown) => { if (!cancelled) { controller.enqueue(encodeNdjson(event)) @@ -402,14 +402,15 @@ export const POST = withRouteHandler(async (req: NextRequest) => { event.payload.channel === MothershipStreamV1TextChannel.assistant && event.payload.text ) { - const text = event.payload.text - const content = text.startsWith(forwardedAssistantContent) - ? text.slice(forwardedAssistantContent.length) - : text - if (content) { - forwardedAssistantContent += content - send({ type: 'chunk', content }) + /* The wire carries text DELTAS with monotone seqs; a transport-retry + replay re-delivers earlier seqs. Dedupe replays by seq — the old + string-prefix guess sliced characters off a genuine delta that + happened to begin with the already-forwarded content. */ + if (typeof event.seq === 'number') { + if (event.seq <= lastForwardedTextSeq) return + lastForwardedTextSeq = event.seq } + send({ type: 'chunk', content: event.payload.text }) } }) allowExplicitAbort = false diff --git a/apps/sim/lib/mothership/request/go/stream.ts b/apps/sim/lib/mothership/request/go/stream.ts index f48299a38b0..945657f1e57 100644 --- a/apps/sim/lib/mothership/request/go/stream.ts +++ b/apps/sim/lib/mothership/request/go/stream.ts @@ -414,7 +414,10 @@ export async function runStreamLoop( // Yield a macrotask so Node.js flushes the HTTP response buffer to // the browser. Microtask yields (await Promise.resolve()) are not // enough — the I/O layer needs a full event loop tick to write. - await new Promise((resolve) => setImmediate(resolve)) + // Headless legs (no client response attached) opt out via flushAfterEvent. + if (options.flushAfterEvent !== false) { + await new Promise((resolve) => setImmediate(resolve)) + } if (options.onBeforeDispatch?.(streamEvent, context)) { return context.streamComplete || undefined diff --git a/apps/sim/lib/mothership/request/lifecycle/run.ts b/apps/sim/lib/mothership/request/lifecycle/run.ts index 627cdeac59c..1d34b97ecf9 100644 --- a/apps/sim/lib/mothership/request/lifecycle/run.ts +++ b/apps/sim/lib/mothership/request/lifecycle/run.ts @@ -1052,6 +1052,10 @@ async function runCheckpointLoop( const loopOptions = { ...options, + /* The wrapper below always exists (checkpoint bookkeeping), so the forwarder can't + infer "headless" from onEvent's absence — declare it: only a caller-attached sink + has an HTTP buffer worth a per-event macrotask flush. */ + flushAfterEvent: options.flushAfterEvent ?? Boolean(callerOnEvent), onEvent: async (event: StreamEvent) => { if ( event.type === MothershipStreamV1EventType.run && diff --git a/apps/sim/lib/mothership/request/session/contract.ts b/apps/sim/lib/mothership/request/session/contract.ts index 5521abb0ce7..c20c5680b02 100644 --- a/apps/sim/lib/mothership/request/session/contract.ts +++ b/apps/sim/lib/mothership/request/session/contract.ts @@ -33,7 +33,13 @@ type EnvelopeToStreamEvent = T extends { payload: infer TPayload scope?: infer TScope } - ? { type: TType; payload: TPayload; scope?: Exclude } + ? { + type: TType + payload: TPayload + scope?: Exclude + /** Wire ordering key, carried off the envelope; absent on synthetic events. */ + seq?: number + } : never export type SyntheticFilePreviewPhase = (typeof FILE_PREVIEW_PHASE)[keyof typeof FILE_PREVIEW_PHASE] diff --git a/apps/sim/lib/mothership/request/session/event.test.ts b/apps/sim/lib/mothership/request/session/event.test.ts index 53caccf4fc2..58acfe386de 100644 --- a/apps/sim/lib/mothership/request/session/event.test.ts +++ b/apps/sim/lib/mothership/request/session/event.test.ts @@ -53,6 +53,7 @@ describe('createEvent', () => { const streamEvent = eventToStreamEvent(envelope) expect(streamEvent).toEqual({ type: MothershipStreamV1EventType.tool, + seq: 2, payload: { previewPhase: 'file_preview_start', toolCallId: 'preview-1', diff --git a/apps/sim/lib/mothership/request/session/event.ts b/apps/sim/lib/mothership/request/session/event.ts index 67be1dd721e..dc834452f96 100644 --- a/apps/sim/lib/mothership/request/session/event.ts +++ b/apps/sim/lib/mothership/request/session/event.ts @@ -69,6 +69,7 @@ export function eventToStreamEvent } diff --git a/apps/sim/lib/mothership/request/types.ts b/apps/sim/lib/mothership/request/types.ts index af0d09d8658..c3aee96b12c 100644 --- a/apps/sim/lib/mothership/request/types.ts +++ b/apps/sim/lib/mothership/request/types.ts @@ -235,6 +235,12 @@ export interface OrchestratorOptions { autoExecuteTools?: boolean timeout?: number onEvent?: (event: StreamEvent) => void | Promise + /** + * Whether the per-event macrotask yield (which lets Node flush the HTTP response buffer) + * should run. The sink owner sets this: legs with no client response attached have + * nothing to flush, and the yield only slows the forwarder. Defaults to true. + */ + flushAfterEvent?: boolean onComplete?: (result: OrchestratorResult) => void | Promise onError?: (error: Error, result?: OrchestratorResult) => void | Promise abortSignal?: AbortSignal From 2f2d6c2e3b1707efbe1c4946b4f7e65492b524b9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 09:41:12 +0530 Subject: [PATCH 009/306] perf(mothership): adaptive backoff on the reconnect poll tail The resume stream polled Postgres + Redis at a fixed 4 Hz per attached client for up to an hour. The tail now decays 250ms -> 2s while quiet and snaps back to full rate the moment an event flushes. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/app/api/copilot/chat/stream/route.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index ec92c750c16..0eb77475efd 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -37,6 +37,7 @@ export const maxDuration = 3600 const logger = createLogger('CopilotChatStreamAPI') const POLL_INTERVAL_MS = 250 +const POLL_INTERVAL_MAX_MS = 2_000 const REPLAY_KEEPALIVE_INTERVAL_MS = 15_000 const MAX_STREAM_MS = 60 * 60 * 1000 @@ -334,13 +335,13 @@ async function handleResumeRequestBody({ } request.signal.addEventListener('abort', abortListener, { once: true }) - const flushEvents = async () => { + const flushEvents = async (): Promise => { if ( run?.chatId && !(await getAccessibleCopilotChatAuth(run.chatId, authenticatedUserId, { principal })) ) { closeController() - return + return 0 } const events = await readEvents(streamId, cursor) if (events.length > 0) { @@ -361,6 +362,7 @@ async function handleResumeRequestBody({ sawTerminalEvent = true } } + return events.length } const emitTerminalIfMissing = ( @@ -409,6 +411,7 @@ async function handleResumeRequestBody({ await flushEvents() + let pollDelayMs = POLL_INTERVAL_MS while (!controllerClosed && Date.now() - startTime < MAX_STREAM_MS) { pollIterations += 1 const currentRun = await getLatestRunForStream(streamId, authenticatedUserId).catch( @@ -431,7 +434,12 @@ async function handleResumeRequestBody({ currentRequestId = extractRunRequestId(currentRun) || currentRequestId - await flushEvents() + const flushed = await flushEvents() + /* Adaptive tail: 4 Hz only while events are actually flowing; a quiet stream + decays toward the cap so an attached client doesn't hammer Postgres + Redis + at 4 Hz for up to an hour. Any flushed event snaps back to full rate. */ + pollDelayMs = + flushed > 0 ? POLL_INTERVAL_MS : Math.min(pollDelayMs * 2, POLL_INTERVAL_MAX_MS) if (controllerClosed) { break @@ -459,7 +467,7 @@ async function handleResumeRequestBody({ enqueueComment('keepalive') } - await sleep(POLL_INTERVAL_MS) + await sleep(pollDelayMs) } if (!controllerClosed && Date.now() - startTime >= MAX_STREAM_MS) { emitTerminalIfMissing(MothershipStreamV1CompletionStatus.error, { From 268dca65532bc3721520d2d7c06d7a8ef20304ce Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 09:56:25 +0530 Subject: [PATCH 010/306] fix(db): exclude the one-off-script ledger from drizzle management script_migrations is created and read by script-migrations/index.ts, not the schema. Dev's db:push saw it as an unknown table: it prompted 'created or renamed?' (no TTY in CI, job red since the revamp landed on dev) and on --force would have DROPPED the ledger, re-running every applied one-off script. tablesFilter tells drizzle the table is not its business. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- packages/db/drizzle.config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts index 454cc13af94..79dd4be671c 100644 --- a/packages/db/drizzle.config.ts +++ b/packages/db/drizzle.config.ts @@ -15,4 +15,9 @@ export default { dbCredentials: { url: process.env.DATABASE_URL!, }, + /* script_migrations is the one-off-script ledger (script-migrations/index.ts) — + deliberately managed outside drizzle. Without this filter, dev's `db:push` sees an + unknown table: it prompts "created or renamed?" (no TTY in CI → red) and would DROP + the ledger, making every applied one-off script re-run. */ + tablesFilter: ['!script_migrations'], } satisfies Config From a18a7e60ebfd208caad2305959f6a7f98f8374fa Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 10:19:06 +0530 Subject: [PATCH 011/306] fix(mothership): tool-frame dedupe is per-turn, not process-global MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedupe semantics (skip retransmits across a turn's retry/resume legs) are per-StreamingContext, but the sets were module-global with a shared 1000-entry FIFO cap — under load one stream's frames evicted another's dedupe state and duplicates leaked through. The sets now live on the context (lifecycle state, dies with the turn, no cap needed); every consumer takes the scope explicitly. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/lib/mothership/constants.ts | 3 -- .../request/context/request-context.ts | 2 + .../lib/mothership/request/go/stream.test.ts | 2 + apps/sim/lib/mothership/request/go/stream.ts | 5 +- .../request/handlers/handlers.test.ts | 2 + .../lib/mothership/request/handlers/tool.ts | 10 ++-- .../lib/mothership/request/handlers/types.ts | 9 ++-- .../lib/mothership/request/sse-utils.test.ts | 18 ++++--- apps/sim/lib/mothership/request/sse-utils.ts | 50 ++++++++----------- .../lib/mothership/request/tools/executor.ts | 18 +++---- .../mothership/request/tools/permission.ts | 6 +-- apps/sim/lib/mothership/request/types.ts | 7 +++ 12 files changed, 72 insertions(+), 60 deletions(-) diff --git a/apps/sim/lib/mothership/constants.ts b/apps/sim/lib/mothership/constants.ts index 86294557bdd..d2f09f1da53 100644 --- a/apps/sim/lib/mothership/constants.ts +++ b/apps/sim/lib/mothership/constants.ts @@ -61,9 +61,6 @@ export const COPILOT_CONFIRM_API_PATH = '/api/copilot/confirm' export const COPILOT_WORKFLOW_EXECUTION_CONFLICT_CODE = 'COPILOT_WORKFLOW_EXECUTION_CONFLICT' as const -/** Maximum entries in the in-memory SSE tool-event dedup cache. */ -export const STREAM_BUFFER_MAX_DEDUP_ENTRIES = 1_000 - /** Approximate max inline tool-result budget before artifact/error handling takes over. */ export const TOOL_RESULT_MAX_INLINE_TOKENS = 50_000 diff --git a/apps/sim/lib/mothership/request/context/request-context.ts b/apps/sim/lib/mothership/request/context/request-context.ts index 5da894d4087..c9e57efdfbb 100644 --- a/apps/sim/lib/mothership/request/context/request-context.ts +++ b/apps/sim/lib/mothership/request/context/request-context.ts @@ -17,6 +17,8 @@ export function createStreamingContext(overrides?: Partial): S contentBlocks: [], toolCalls: new Map(), pendingToolPromises: new Map(), + seenToolCalls: new Set(), + seenToolResults: new Set(), currentThinkingBlock: null, subagentThinkingBlocks: new Map(), isInThinkingBlock: false, diff --git a/apps/sim/lib/mothership/request/go/stream.test.ts b/apps/sim/lib/mothership/request/go/stream.test.ts index 597cb7a794e..53547b622a4 100644 --- a/apps/sim/lib/mothership/request/go/stream.test.ts +++ b/apps/sim/lib/mothership/request/go/stream.test.ts @@ -140,6 +140,8 @@ function createStreamingContext(): StreamingContext { contentBlocks: [], toolCalls: new Map(), pendingToolPromises: new Map(), + seenToolCalls: new Set(), + seenToolResults: new Set(), currentThinkingBlock: null, subagentThinkingBlocks: new Map(), isInThinkingBlock: false, diff --git a/apps/sim/lib/mothership/request/go/stream.ts b/apps/sim/lib/mothership/request/go/stream.ts index 945657f1e57..f41633a209f 100644 --- a/apps/sim/lib/mothership/request/go/stream.ts +++ b/apps/sim/lib/mothership/request/go/stream.ts @@ -375,7 +375,10 @@ export async function runStreamLoop( }) } - if (shouldSkipToolCallEvent(streamEvent) || shouldSkipToolResultEvent(streamEvent)) { + if ( + shouldSkipToolCallEvent(context, streamEvent) || + shouldSkipToolResultEvent(context, streamEvent) + ) { return } diff --git a/apps/sim/lib/mothership/request/handlers/handlers.test.ts b/apps/sim/lib/mothership/request/handlers/handlers.test.ts index c5cb439c5a3..0fffb75ddfe 100644 --- a/apps/sim/lib/mothership/request/handlers/handlers.test.ts +++ b/apps/sim/lib/mothership/request/handlers/handlers.test.ts @@ -133,6 +133,8 @@ describe('sse-handlers tool lifecycle', () => { contentBlocks: [], toolCalls: new Map(), pendingToolPromises: new Map(), + seenToolCalls: new Set(), + seenToolResults: new Set(), currentThinkingBlock: null, subagentThinkingBlocks: new Map(), isInThinkingBlock: false, diff --git a/apps/sim/lib/mothership/request/handlers/tool.ts b/apps/sim/lib/mothership/request/handlers/tool.ts index 7decffe45fb..f6c681a595f 100644 --- a/apps/sim/lib/mothership/request/handlers/tool.ts +++ b/apps/sim/lib/mothership/request/handlers/tool.ts @@ -420,7 +420,7 @@ function handleResultPhase( endTime, }) stampToolCallBlockEnd(context, toolCallId, endTime) - markToolResultSeen(toolCallId) + markToolResultSeen(context, toolCallId) } function stampToolCallBlockEnd( @@ -473,7 +473,7 @@ async function handleCallPhase( } if (isSubagent) { - if (wasToolResultSeen(toolCallId) || existing?.endTime) { + if (wasToolResultSeen(context, toolCallId) || existing?.endTime) { if (!rebindResolvedIntegrationCall(existing, toolName, args)) { if (existing) updateToolCallFromFrame(existing, toolName, args, !isPartial) } @@ -521,7 +521,7 @@ async function handleCallPhase( } if (isPartial) return - if (!isSubagent && wasToolResultSeen(toolCallId)) return + if (!isSubagent && wasToolResultSeen(context, toolCallId)) return if (context.pendingToolPromises.has(toolCallId) || existing?.status === 'executing') { return } @@ -772,7 +772,7 @@ async function dispatchToolExecution( output: { error }, error, }) - markToolResultSeen(toolCallId) + markToolResultSeen(context, toolCallId) await emitSyntheticToolResult( toolCallId, toolCall.name, @@ -920,7 +920,7 @@ async function dispatchToolExecution( span.setAttribute(TraceAttr.ToolOutcome, completion.status) } const backgroundIsSuccess = toolName === 'run_workflow' && args?.async === true - handleClientCompletion(toolCall, toolCallId, completion, backgroundIsSuccess) + handleClientCompletion(context, toolCall, toolCallId, completion, backgroundIsSuccess) await emitSyntheticToolResult( toolCallId, toolCall.name, diff --git a/apps/sim/lib/mothership/request/handlers/types.ts b/apps/sim/lib/mothership/request/handlers/types.ts index 9b521f7d54a..9788163660e 100644 --- a/apps/sim/lib/mothership/request/handlers/types.ts +++ b/apps/sim/lib/mothership/request/handlers/types.ts @@ -163,7 +163,7 @@ export function abortPendingToolIfStreamDead( status: MothershipStreamV1ToolOutcome.cancelled, error: 'Tool was not dispatched because its stream had already been aborted', }) - markToolResultSeen(toolCallId) + markToolResultSeen(context, toolCallId) // Sim's logs do not reach Loki and the trace span below is collected // in-process but never exported, so the counter is the only signal that // survives to somewhere queryable. @@ -216,6 +216,7 @@ export function getToolCallUI(data: MothershipStreamV1ToolCallDescriptor): { * Shared by both main and subagent scopes. */ export function handleClientCompletion( + context: StreamingContext, toolCall: ToolCallState, toolCallId: string, completion: AsyncTerminalCompletionSnapshot | null, @@ -228,7 +229,7 @@ export function handleClientCompletion( : MothershipStreamV1ToolOutcome.skipped, ...(completion.data !== undefined ? { output: completion.data } : {}), }) - markToolResultSeen(toolCallId) + markToolResultSeen(context, toolCallId) return } if (completion?.status === MothershipStreamV1ToolOutcome.cancelled) { @@ -237,7 +238,7 @@ export function handleClientCompletion( ...(completion.data !== undefined ? { output: completion.data } : {}), error: completion.message || 'Tool cancelled', }) - markToolResultSeen(toolCallId) + markToolResultSeen(context, toolCallId) return } const success = completion?.status === MothershipStreamV1ToolOutcome.success @@ -246,7 +247,7 @@ export function handleClientCompletion( ...(completion?.data !== undefined ? { output: completion.data } : {}), ...(success ? {} : { error: completion?.message || 'Tool failed' }), }) - markToolResultSeen(toolCallId) + markToolResultSeen(context, toolCallId) } /** diff --git a/apps/sim/lib/mothership/request/sse-utils.test.ts b/apps/sim/lib/mothership/request/sse-utils.test.ts index d451b3d6a2b..69c4e25e736 100644 --- a/apps/sim/lib/mothership/request/sse-utils.test.ts +++ b/apps/sim/lib/mothership/request/sse-utils.test.ts @@ -9,16 +9,19 @@ import { TOOL_CALL_STATUS } from '@/lib/mothership/request/session' import type { StreamEvent } from '@/lib/mothership/request/types' import { shouldSkipToolCallEvent } from './sse-utils' +const freshScope = () => ({ seenToolCalls: new Set(), seenToolResults: new Set() }) + describe('shouldSkipToolCallEvent', () => { it('skips pathless read and glob generating placeholders without marking the call seen', () => { const readEvent = toolCallEvent('read-generating-placeholder', 'read', undefined, true) const globEvent = toolCallEvent('glob-generating-placeholder', 'glob', undefined, true) - expect(shouldSkipToolCallEvent(readEvent)).toBe(true) - expect(shouldSkipToolCallEvent(globEvent)).toBe(true) + expect(shouldSkipToolCallEvent(freshScope(), readEvent)).toBe(true) + expect(shouldSkipToolCallEvent(freshScope(), globEvent)).toBe(true) expect( shouldSkipToolCallEvent( + freshScope(), toolCallEvent('read-generating-placeholder', 'read', { path: 'components/integrations/slack/README.md', }) @@ -26,6 +29,7 @@ describe('shouldSkipToolCallEvent', () => { ).toBe(false) expect( shouldSkipToolCallEvent( + freshScope(), toolCallEvent('glob-generating-placeholder', 'glob', { pattern: 'components/blocks/*/README.md', }) @@ -36,6 +40,7 @@ describe('shouldSkipToolCallEvent', () => { it('keeps non-vfs generating placeholders visible', () => { expect( shouldSkipToolCallEvent( + freshScope(), toolCallEvent('search-generating-placeholder', 'web_search', undefined, true) ) ).toBe(false) @@ -52,10 +57,11 @@ describe('shouldSkipToolCallEvent', () => { credentialId: 'cred-gmail', }) - expect(shouldSkipToolCallEvent(gateway)).toBe(false) - expect(shouldSkipToolCallEvent(gateway)).toBe(true) - expect(shouldSkipToolCallEvent(resolved)).toBe(false) - expect(shouldSkipToolCallEvent(resolved)).toBe(true) + const scope = freshScope() + expect(shouldSkipToolCallEvent(scope, gateway)).toBe(false) + expect(shouldSkipToolCallEvent(scope, gateway)).toBe(true) + expect(shouldSkipToolCallEvent(scope, resolved)).toBe(false) + expect(shouldSkipToolCallEvent(scope, resolved)).toBe(true) }) }) diff --git a/apps/sim/lib/mothership/request/sse-utils.ts b/apps/sim/lib/mothership/request/sse-utils.ts index c99714fbd34..fce1ac32847 100644 --- a/apps/sim/lib/mothership/request/sse-utils.ts +++ b/apps/sim/lib/mothership/request/sse-utils.ts @@ -1,4 +1,3 @@ -import { STREAM_BUFFER_MAX_DEDUP_ENTRIES } from '@/lib/mothership/constants' import { isToolCallStreamEvent, isToolResultStreamEvent, @@ -6,24 +5,14 @@ import { type ToolResultStreamEvent, } from '@/lib/mothership/request/session' import { TOOL_CALL_STATUS } from '@/lib/mothership/request/session/event' -import type { StreamEvent } from '@/lib/mothership/request/types' +import type { StreamEvent, StreamingContext } from '@/lib/mothership/request/types' /** - * In-memory tool event dedupe with bounded size. - * - * NOTE: Process-local only. In a multi-instance setup (e.g., ECS), - * each task maintains its own dedupe cache. + * Tool event dedupe, scoped to one turn's StreamingContext (the sets live and die with + * the turn). The semantics enforced — skip retransmits of a frame across a turn's + * retry/resume legs — are per-turn, so per-turn state is both correct and unbounded-safe. */ -const seenToolCalls = new Set() -const seenToolResults = new Set() - -function addToSet(set: Set, id: string): void { - if (set.size >= STREAM_BUFFER_MAX_DEDUP_ENTRIES) { - const first = set.values().next().value - if (first) set.delete(first) - } - set.add(id) -} +type DedupeScope = Pick function getToolCallIdFromCallEvent(event: ToolCallStreamEvent): string { return event.payload.toolCallId @@ -37,23 +26,23 @@ function toolCallDedupeKey(toolCallId: string, toolName: string): string { return `${toolCallId}\u0000${toolName}` } -function markToolCallSeen(toolCallId: string, toolName: string): void { - addToSet(seenToolCalls, toolCallDedupeKey(toolCallId, toolName)) +function markToolCallSeen(scope: DedupeScope, toolCallId: string, toolName: string): void { + scope.seenToolCalls.add(toolCallDedupeKey(toolCallId, toolName)) } -function wasToolCallSeen(toolCallId: string, toolName: string): boolean { - return seenToolCalls.has(toolCallDedupeKey(toolCallId, toolName)) +function wasToolCallSeen(scope: DedupeScope, toolCallId: string, toolName: string): boolean { + return scope.seenToolCalls.has(toolCallDedupeKey(toolCallId, toolName)) } -export function markToolResultSeen(toolCallId: string): void { - addToSet(seenToolResults, toolCallId) +export function markToolResultSeen(scope: DedupeScope, toolCallId: string): void { + scope.seenToolResults.add(toolCallId) } -export function wasToolResultSeen(toolCallId: string): boolean { - return seenToolResults.has(toolCallId) +export function wasToolResultSeen(scope: DedupeScope, toolCallId: string): boolean { + return scope.seenToolResults.has(toolCallId) } -export function shouldSkipToolCallEvent(event: StreamEvent): boolean { +export function shouldSkipToolCallEvent(scope: DedupeScope, event: StreamEvent): boolean { if (!isToolCallStreamEvent(event)) return false if (isPathlessVfsGeneratingEvent(event)) return true if (event.payload.status === TOOL_CALL_STATUS.generating) return false @@ -64,8 +53,9 @@ export function shouldSkipToolCallEvent(event: StreamEvent): boolean { // call frames under one provider call ID: first call_integration_tool, then // the exact request-local operation. Deduplicate retransmits of the same // frame, but allow the name transition through to execution and UI rebinding. - if (wasToolResultSeen(toolCallId) || wasToolCallSeen(toolCallId, toolName)) return true - markToolCallSeen(toolCallId, toolName) + if (wasToolResultSeen(scope, toolCallId) || wasToolCallSeen(scope, toolCallId, toolName)) + return true + markToolCallSeen(scope, toolCallId, toolName) return false } @@ -75,6 +65,8 @@ function isPathlessVfsGeneratingEvent(event: ToolCallStreamEvent): boolean { return event.payload.arguments === undefined } -export function shouldSkipToolResultEvent(event: StreamEvent): boolean { - return isToolResultStreamEvent(event) && wasToolResultSeen(getToolCallIdFromResultEvent(event)) +export function shouldSkipToolResultEvent(scope: DedupeScope, event: StreamEvent): boolean { + return ( + isToolResultStreamEvent(event) && wasToolResultSeen(scope, getToolCallIdFromResultEvent(event)) + ) } diff --git a/apps/sim/lib/mothership/request/tools/executor.ts b/apps/sim/lib/mothership/request/tools/executor.ts index ae19e2f271e..2db1ce66dca 100644 --- a/apps/sim/lib/mothership/request/tools/executor.ts +++ b/apps/sim/lib/mothership/request/tools/executor.ts @@ -394,7 +394,7 @@ export async function forceFailHungToolCall( persisted: completed, lostSettlementRace, }) - markToolResultSeen(toolCallId) + markToolResultSeen(context, toolCallId) if (completed) { publishTerminalToolConfirmation({ toolCallId, @@ -548,7 +548,7 @@ async function executeToolAndReportInner( if (abortRequested(context, execContext, options)) { markToolCallCancelled('Request aborted before tool execution') const cancellationResult = toolCall.result - markToolResultSeen(toolCall.id) + markToolResultSeen(context, toolCall.id) await completeAsyncToolCall({ toolCallId: toolCall.id, status: MothershipStreamV1AsyncToolRecordStatus.cancelled, @@ -666,7 +666,7 @@ async function executeToolAndReportInner( ).result markToolCallCancelled('Request aborted during tool execution') const cancellationResult = toolCall.result - markToolResultSeen(toolCall.id) + markToolResultSeen(context, toolCall.id) await completeAsyncToolCall({ toolCallId: toolCall.id, status: MothershipStreamV1AsyncToolRecordStatus.cancelled, @@ -706,7 +706,7 @@ async function executeToolAndReportInner( if (abortRequested(context, execContext, options)) { markToolCallCancelled('Request aborted during tool post-processing') const cancellationResult = toolCall.result - markToolResultSeen(toolCall.id) + markToolResultSeen(context, toolCall.id) await completeAsyncToolCall({ toolCallId: toolCall.id, status: MothershipStreamV1AsyncToolRecordStatus.cancelled, @@ -743,7 +743,7 @@ async function executeToolAndReportInner( if (abortRequested(context, execContext, options)) { markToolCallCancelled('Request aborted during tool post-processing') const cancellationResult = toolCall.result - markToolResultSeen(toolCall.id) + markToolResultSeen(context, toolCall.id) await completeAsyncToolCall({ toolCallId: toolCall.id, status: MothershipStreamV1AsyncToolRecordStatus.cancelled, @@ -780,7 +780,7 @@ async function executeToolAndReportInner( if (abortRequested(context, execContext, options)) { markToolCallCancelled('Request aborted during tool post-processing') const cancellationResult = toolCall.result - markToolResultSeen(toolCall.id) + markToolResultSeen(context, toolCall.id) await completeAsyncToolCall({ toolCallId: toolCall.id, status: MothershipStreamV1AsyncToolRecordStatus.cancelled, @@ -878,7 +878,7 @@ async function executeToolAndReportInner( const terminalData = getToolCallTerminalData(toolCall) const terminalResult = toolCall.result - markToolResultSeen(toolCall.id) + markToolResultSeen(context, toolCall.id) await completeAsyncToolCall({ toolCallId: toolCall.id, status: modelSucceeded @@ -975,7 +975,7 @@ async function executeToolAndReportInner( if (abortRequested(context, execContext, options)) { markToolCallCancelled('Request aborted during tool execution') const cancellationResult = toolCall.result - markToolResultSeen(toolCall.id) + markToolResultSeen(context, toolCall.id) await completeAsyncToolCall({ toolCallId: toolCall.id, status: MothershipStreamV1AsyncToolRecordStatus.cancelled, @@ -1015,7 +1015,7 @@ async function executeToolAndReportInner( params: toolCall.params, }) - markToolResultSeen(toolCall.id) + markToolResultSeen(context, toolCall.id) await completeAsyncToolCall({ toolCallId: toolCall.id, status: MothershipStreamV1AsyncToolRecordStatus.failed, diff --git a/apps/sim/lib/mothership/request/tools/permission.ts b/apps/sim/lib/mothership/request/tools/permission.ts index 78fa827076e..e99131854e6 100644 --- a/apps/sim/lib/mothership/request/tools/permission.ts +++ b/apps/sim/lib/mothership/request/tools/permission.ts @@ -240,7 +240,7 @@ export function runGatedToolExecution( status: MothershipStreamV1ToolOutcome.skipped, output, }) - markToolResultSeen(toolCallId) + markToolResultSeen(context, toolCallId) await emitGateResult( toolCallId, toolName, @@ -268,7 +268,7 @@ export function runGatedToolExecution( output: { error }, error, }) - markToolResultSeen(toolCallId) + markToolResultSeen(context, toolCallId) await emitGateResult( toolCallId, toolName, @@ -311,7 +311,7 @@ export function runGatedToolExecution( status: MothershipStreamV1ToolOutcome.skipped, output, }) - markToolResultSeen(toolCallId) + markToolResultSeen(context, toolCallId) await emitGateResult( toolCallId, toolName, diff --git a/apps/sim/lib/mothership/request/types.ts b/apps/sim/lib/mothership/request/types.ts index c3aee96b12c..14d3a99780b 100644 --- a/apps/sim/lib/mothership/request/types.ts +++ b/apps/sim/lib/mothership/request/types.ts @@ -151,6 +151,13 @@ export interface StreamingContext { contentBlocks: ContentBlock[] toolCalls: Map pendingToolPromises: Map> + /** + * Tool-frame dedupe for THIS turn (retransmits across a turn's retry/resume legs). + * Turn-scoped lifecycle sets — they die with the context. Was process-global with a + * shared 1000-entry cap: under load one stream's frames evicted another's dedupe state. + */ + seenToolCalls: Set + seenToolResults: Set awaitingAsyncContinuation?: ResumeContinuation currentThinkingBlock: ContentBlock | null /** From 21fed274a38fc12afc66f82017552faf789d6427 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 10:38:27 +0530 Subject: [PATCH 012/306] fix(mothership): Stop no longer requires client-buffered content The worker persists partials itself; requiring content made a bare Stop 400 and left the turn running detached (the H2 mis-measure class). Empty default keeps the legacy partial-persistence path intact when content IS buffered. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/lib/api/contracts/copilot.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index a5901694e6f..fc6c3fd23df 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -247,7 +247,12 @@ const copilotContentBlockSchema = z.object({ export const copilotChatStopBodySchema = z.object({ chatId: z.string(), streamId: z.string(), - content: z.string(), + /** + * Partial assistant content to persist. Optional: the worker backend persists partials + * itself, so a Stop with nothing client-buffered is valid — requiring this made bare + * stops 400 and left the turn running detached. + */ + content: z.string().default(''), contentBlocks: z.array(copilotContentBlockSchema).optional(), requestId: z.string().optional(), }) From 1f55c2145cc3915b1d6acb2395ed80f21d1ccd5e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 10:53:49 +0530 Subject: [PATCH 013/306] =?UTF-8?q?fix(mothership):=20sim=20lows=20#9,#13-?= =?UTF-8?q?#16=20=E2=80=94=20honest=20errors,=20one=20terminal=20predicate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit message names the actual reason. rejection chain; the rejection is now observed and logged. non-terminal batch instead of spinning against a prompt-closing middlebox. lives once in request/session; all consumers import it. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../app/api/copilot/chat/stream/route.test.ts | 2 ++ apps/sim/app/api/copilot/chat/stream/route.ts | 13 ++---------- .../home/hooks/stream-protocol.ts | 6 ------ .../[workspaceId]/home/hooks/use-chat.ts | 6 +++++- .../mothership/chat/effective-transcript.ts | 9 +-------- .../lib/mothership/request/handlers/types.ts | 20 +++++++++++++------ .../mothership/request/session/contract.ts | 12 +++++++++++ .../lib/mothership/request/session/index.ts | 1 + .../lib/mothership/request/tools/billing.ts | 7 +++++-- .../mothership/request/tools/permission.ts | 7 +++++-- 10 files changed, 47 insertions(+), 36 deletions(-) diff --git a/apps/sim/app/api/copilot/chat/stream/route.test.ts b/apps/sim/app/api/copilot/chat/stream/route.test.ts index 7d7342ff655..62668675b97 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.test.ts @@ -33,6 +33,8 @@ vi.mock('@/lib/mothership/async-runs/repository', () => ({ })) vi.mock('@/lib/mothership/request/session', () => ({ + isTerminalStreamStatus: (status: string | null | undefined) => + status === 'complete' || status === 'error' || status === 'cancelled', readEvents, readFilePreviewSessions, checkForReplayGap, diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index 0eb77475efd..7e2318f4ebd 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -26,6 +26,7 @@ import { checkForReplayGap, createEvent, encodeSSEEnvelope, + isTerminalStreamStatus, readEvents, readFilePreviewSessions, SSE_RESPONSE_HEADERS, @@ -60,16 +61,6 @@ function extractEnvelopeRequestId(envelope: { trace?: { requestId?: unknown } }) return extractCanonicalRequestId(envelope.trace?.requestId) } -function isTerminalStatus( - status: string | null | undefined -): status is MothershipStreamV1CompletionStatus { - return ( - status === MothershipStreamV1CompletionStatus.complete || - status === MothershipStreamV1CompletionStatus.error || - status === MothershipStreamV1CompletionStatus.cancelled - ) -} - function buildResumeTerminalEnvelopes(options: { streamId: string afterCursor: string @@ -444,7 +435,7 @@ async function handleResumeRequestBody({ if (controllerClosed) { break } - if (isTerminalStatus(currentRun.status)) { + if (isTerminalStreamStatus(currentRun.status)) { emitTerminalIfMissing(currentRun.status, { message: currentRun.status === MothershipStreamV1CompletionStatus.error diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream-protocol.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream-protocol.ts index ba0b839ad55..c27b6a28930 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream-protocol.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream-protocol.ts @@ -128,12 +128,6 @@ export function resolveChatIdFromStreamBatch(batch: StreamBatchResponse): string return undefined } -const TERMINAL_STREAM_STATUSES = new Set(['complete', 'error', 'cancelled']) - -export function isTerminalStreamStatus(status: string | null | undefined): boolean { - return TERMINAL_STREAM_STATUSES.has(status ?? '') -} - export function isAlreadyProcessedStreamCursor( eventCursor: string | undefined, currentCursor: string diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 2d95975ef29..0aa6b6074eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -60,6 +60,7 @@ import { import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/mothership/constants' import { sendMothershipMessage } from '@/lib/mothership/events' import { MothershipStreamV1ToolOutcome } from '@/lib/mothership/generated/mothership-stream-v1' +import { isTerminalStreamStatus } from '@/lib/mothership/request/session' import { parsePersistedStreamEventEnvelopeJson } from '@/lib/mothership/request/session/contract' import type { FilePreviewSession } from '@/lib/mothership/request/session/file-preview-session-contract' import { canDisplayResource } from '@/lib/mothership/resources/availability' @@ -166,7 +167,6 @@ import { isAlreadyProcessedStreamCursor, isStreamGoneError, isStreamSchemaValidationError, - isTerminalStreamStatus, parseStreamBatchResponse, resolveChatIdFromStreamBatch, type StreamBatchResponse, @@ -2462,6 +2462,10 @@ export function useChat( if (activeAbort.signal.aborted || streamGenRef.current !== expectedGen) { return { error: false, aborted: true } } + /* A middlebox that closes the SSE tail promptly makes this loop spin + tail->batch->tail with zero delay. An empty non-terminal batch means + nothing new arrived — pace the next cycle instead of hammering. */ + await sleep(1_000) } } diff --git a/apps/sim/lib/mothership/chat/effective-transcript.ts b/apps/sim/lib/mothership/chat/effective-transcript.ts index e0c9b77ed58..fc5ca11e33c 100644 --- a/apps/sim/lib/mothership/chat/effective-transcript.ts +++ b/apps/sim/lib/mothership/chat/effective-transcript.ts @@ -13,6 +13,7 @@ import { MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, } from '@/lib/mothership/generated/mothership-stream-v1' +import { isTerminalStreamStatus } from '@/lib/mothership/request/session' import type { FilePreviewSession } from '@/lib/mothership/request/session/file-preview-session-contract' import type { StreamBatchEvent } from '@/lib/mothership/request/session/types' import { @@ -53,14 +54,6 @@ function asPayloadRecord(value: unknown): Record | undefined { return isRecordLike(value) ? value : undefined } -function isTerminalStreamStatus(status: string | null | undefined): boolean { - return ( - status === MothershipStreamV1CompletionStatus.complete || - status === MothershipStreamV1CompletionStatus.error || - status === MothershipStreamV1CompletionStatus.cancelled - ) -} - /** * Error codes that describe the USER stopping the turn, not a failure. * diff --git a/apps/sim/lib/mothership/request/handlers/types.ts b/apps/sim/lib/mothership/request/handlers/types.ts index 9788163660e..4ca8cf87629 100644 --- a/apps/sim/lib/mothership/request/handlers/types.ts +++ b/apps/sim/lib/mothership/request/handlers/types.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' +import { getErrorMessage, toError } from '@sim/utils/errors' import { isRecordLike, toRecord } from '@sim/utils/object' import type { AsyncCompletionSignal, @@ -131,11 +131,19 @@ export function registerPendingToolPromise( pendingPromise: Promise ): void { context.pendingToolPromises.set(toolCallId, pendingPromise) - pendingPromise.finally(() => { - if (context.pendingToolPromises.get(toolCallId) === pendingPromise) { - context.pendingToolPromises.delete(toolCallId) - } - }) + /* .finally() derives a NEW promise that re-throws the rejection with no handler — an + unhandled-rejection crash waiting for the first rejecting tool promise. Observe the + chain: cleanup on both settles, rejection logged (the original promise's consumers + still see it through the map). */ + pendingPromise + .catch((error) => { + logger.warn('Pending tool promise rejected', { toolCallId, error: getErrorMessage(error) }) + }) + .finally(() => { + if (context.pendingToolPromises.get(toolCallId) === pendingPromise) { + context.pendingToolPromises.delete(toolCallId) + } + }) } /** diff --git a/apps/sim/lib/mothership/request/session/contract.ts b/apps/sim/lib/mothership/request/session/contract.ts index c20c5680b02..f74fe6a6987 100644 --- a/apps/sim/lib/mothership/request/session/contract.ts +++ b/apps/sim/lib/mothership/request/session/contract.ts @@ -7,6 +7,7 @@ import type { MothershipStreamV1Trace, } from '@/lib/mothership/generated/mothership-stream-v1' import { + MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, MothershipStreamV1ResourceOp, MothershipStreamV1RunKind, @@ -407,6 +408,17 @@ export function isSyntheticFilePreviewEventEnvelope( // Stream event type guards +/** The one terminal-status predicate — complete | error | cancelled. */ +export function isTerminalStreamStatus( + status: string | null | undefined +): status is MothershipStreamV1CompletionStatus { + return ( + status === MothershipStreamV1CompletionStatus.complete || + status === MothershipStreamV1CompletionStatus.error || + status === MothershipStreamV1CompletionStatus.cancelled + ) +} + export function isToolCallStreamEvent(event: SessionStreamEvent): event is ToolCallStreamEvent { return event.type === 'tool' && isRecordLike(event.payload) && event.payload.phase === 'call' } diff --git a/apps/sim/lib/mothership/request/session/index.ts b/apps/sim/lib/mothership/request/session/index.ts index 8e62b70bb2e..5cac0f7454e 100644 --- a/apps/sim/lib/mothership/request/session/index.ts +++ b/apps/sim/lib/mothership/request/session/index.ts @@ -46,6 +46,7 @@ export { isContractStreamEventEnvelope, isSubagentSpanStreamEvent, isSyntheticFilePreviewEventEnvelope, + isTerminalStreamStatus, isToolArgsDeltaStreamEvent, isToolCallStreamEvent, isToolResultStreamEvent, diff --git a/apps/sim/lib/mothership/request/tools/billing.ts b/apps/sim/lib/mothership/request/tools/billing.ts index b2f705b36d3..fed35fd4156 100644 --- a/apps/sim/lib/mothership/request/tools/billing.ts +++ b/apps/sim/lib/mothership/request/tools/billing.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { isEnterprise, isPaid } from '@/lib/billing/plan-helpers' import { isOrgScopedSubscription } from '@/lib/billing/subscriptions/utils' @@ -59,8 +60,10 @@ export async function handleBillingLimitResponse( "You've reached your usage limit for this billing period. Please increase your usage limit from billing settings to continue." } } - } catch { - logger.warn('Failed to determine subscription plan, defaulting to upgrade_plan') + } catch (error) { + logger.warn('Failed to determine subscription plan, defaulting to upgrade_plan', { + error: getErrorMessage(error), + }) } const upgradePayload = JSON.stringify({ diff --git a/apps/sim/lib/mothership/request/tools/permission.ts b/apps/sim/lib/mothership/request/tools/permission.ts index e99131854e6..7eb360f67cb 100644 --- a/apps/sim/lib/mothership/request/tools/permission.ts +++ b/apps/sim/lib/mothership/request/tools/permission.ts @@ -260,9 +260,12 @@ export function runGatedToolExecution( if (!decision) { // Timed out or the turn was stopped. Fail the call rather than running - // it: an unanswered prompt is not consent. + // it: an unanswered prompt is not consent. Name the actual reason — a user who + // pressed Stop should not read that something "timed out". span.setAttribute(TraceAttr.ToolOutcome, MothershipStreamV1ToolOutcome.cancelled) - const error = 'Timed out waiting for the user to approve this tool.' + const error = options.abortSignal?.aborted + ? 'Stopped before the user approved this tool.' + : 'Timed out waiting for the user to approve this tool.' setTerminalToolCallState(toolCall, { status: MothershipStreamV1ToolOutcome.cancelled, output: { error }, From d8aa9788f1c869e89edac808f9cdf8edebf22325 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 10:57:21 +0530 Subject: [PATCH 014/306] =?UTF-8?q?refactor(mothership):=20sim=20lows=20#1?= =?UTF-8?q?1,#12=20=E2=80=94=20one=20shim=20validator,=20one=20header=20as?= =?UTF-8?q?sembly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateShimEnvelope (canonical in request/http.ts, audit-recognized) replaces the quadruplicated clone/parse/validate block in the four /api/mothership/* alias routes. mothershipRequestHeaders moves to request/headers.ts; title + steer now assemble through it (and gain the X-Client-Version their hand-rolled copies had dropped). Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../app/api/mothership/chat/abort/route.ts | 13 +--- .../api/mothership/chat/resources/route.ts | 13 +--- apps/sim/app/api/mothership/chat/route.ts | 14 ++-- .../sim/app/api/mothership/chat/stop/route.ts | 13 +--- apps/sim/lib/mothership/request/headers.ts | 22 +++++++ apps/sim/lib/mothership/request/http.ts | 22 +++++++ .../lib/mothership/request/lifecycle/run.ts | 22 +------ .../lib/mothership/request/lifecycle/start.ts | 15 +---- .../lib/mothership/request/session/steer.ts | 65 +++++++++++++++++++ scripts/check-api-validation-contracts.ts | 2 +- 10 files changed, 129 insertions(+), 72 deletions(-) create mode 100644 apps/sim/lib/mothership/request/headers.ts create mode 100644 apps/sim/lib/mothership/request/session/steer.ts diff --git a/apps/sim/app/api/mothership/chat/abort/route.ts b/apps/sim/app/api/mothership/chat/abort/route.ts index c505fd9ab28..25cd9eb42b8 100644 --- a/apps/sim/app/api/mothership/chat/abort/route.ts +++ b/apps/sim/app/api/mothership/chat/abort/route.ts @@ -1,19 +1,12 @@ import type { NextRequest } from 'next/server' import { mothershipChatAbortEnvelopeSchema } from '@/lib/api/contracts/mothership-chats' -import { validationErrorResponse } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { validateShimEnvelope } from '@/lib/mothership/request/http' import { POST as copilotAbortPost } from '@/app/api/copilot/chat/abort/route' export const POST = withRouteHandler(async (request: NextRequest) => { - // boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body - const body = await request - .clone() - .json() - .catch(() => undefined) - if (body !== undefined) { - const validation = mothershipChatAbortEnvelopeSchema.safeParse(body) - if (!validation.success) return validationErrorResponse(validation.error) - } + const invalid = await validateShimEnvelope(request, mothershipChatAbortEnvelopeSchema) + if (invalid) return invalid return copilotAbortPost(request, undefined) }) diff --git a/apps/sim/app/api/mothership/chat/resources/route.ts b/apps/sim/app/api/mothership/chat/resources/route.ts index d377f421b22..dbb169f5e3d 100644 --- a/apps/sim/app/api/mothership/chat/resources/route.ts +++ b/apps/sim/app/api/mothership/chat/resources/route.ts @@ -1,7 +1,7 @@ import type { NextRequest, NextResponse } from 'next/server' import { mothershipChatResourceEnvelopeSchema } from '@/lib/api/contracts/mothership-chats' -import { validationErrorResponse } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { validateShimEnvelope } from '@/lib/mothership/request/http' import { DELETE as copilotResourcesDelete, PATCH as copilotResourcesPatch, @@ -9,15 +9,8 @@ import { } from '@/app/api/copilot/chat/resources/route' async function validateResourceRequestEnvelope(request: NextRequest): Promise { - // boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body - const body = await request - .clone() - .json() - .catch(() => undefined) - if (body !== undefined) { - const validation = mothershipChatResourceEnvelopeSchema.safeParse(body) - if (!validation.success) return validationErrorResponse(validation.error) - } + const invalid = await validateShimEnvelope(request, mothershipChatResourceEnvelopeSchema) + if (invalid) return invalid return null } diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index 3a226d94370..07a519183a2 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -5,8 +5,9 @@ import { } from '@/lib/api/contracts/mothership-chats' import { validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { handleUnifiedChatPost, maxDuration } from '@/lib/mothership/chat/post' +import { handleUnifiedChatPost } from '@/lib/mothership/chat/post' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { validateShimEnvelope } from '@/lib/mothership/request/http' import { GET as copilotChatGet } from '@/app/api/copilot/chat/queries' export const maxDuration = 3600 @@ -27,15 +28,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body - const body = await request - .clone() - .json() - .catch(() => undefined) - if (body !== undefined) { - const validation = mothershipChatPostEnvelopeSchema.safeParse(body) - if (!validation.success) return validationErrorResponse(validation.error) - } + const invalid = await validateShimEnvelope(request, mothershipChatPostEnvelopeSchema) + if (invalid) return invalid return handleUnifiedChatPost(request) }) diff --git a/apps/sim/app/api/mothership/chat/stop/route.ts b/apps/sim/app/api/mothership/chat/stop/route.ts index 7ec211ce4f8..6b40e8093e1 100644 --- a/apps/sim/app/api/mothership/chat/stop/route.ts +++ b/apps/sim/app/api/mothership/chat/stop/route.ts @@ -1,19 +1,12 @@ import type { NextRequest } from 'next/server' import { mothershipChatStopEnvelopeSchema } from '@/lib/api/contracts/mothership-chats' -import { validationErrorResponse } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { validateShimEnvelope } from '@/lib/mothership/request/http' import { POST as copilotStopPost } from '@/app/api/copilot/chat/stop/route' export const POST = withRouteHandler(async (request: NextRequest) => { - // boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body - const body = await request - .clone() - .json() - .catch(() => undefined) - if (body !== undefined) { - const validation = mothershipChatStopEnvelopeSchema.safeParse(body) - if (!validation.success) return validationErrorResponse(validation.error) - } + const invalid = await validateShimEnvelope(request, mothershipChatStopEnvelopeSchema) + if (invalid) return invalid return copilotStopPost(request, undefined) }) diff --git a/apps/sim/lib/mothership/request/headers.ts b/apps/sim/lib/mothership/request/headers.ts new file mode 100644 index 00000000000..008564b8e8f --- /dev/null +++ b/apps/sim/lib/mothership/request/headers.ts @@ -0,0 +1,22 @@ +import type { AttributedBillingRequestEnvelope } from '@/lib/billing/core/billing-attribution' +import { env } from '@/lib/core/config/env' +import { SIM_AGENT_VERSION } from '@/lib/mothership/constants' +import { getMothershipSourceEnvHeaders } from '@/lib/mothership/server/agent-url' + +/** + * The one assembly for sim -> mothership request headers (chat, title, steer, resume). + * Three hand-rolled copies drifted on X-Client-Version and the request-id header. + */ +export function mothershipRequestHeaders( + hostedBillingRequest?: AttributedBillingRequestEnvelope, + simRequestId?: string +): Record { + return { + 'Content-Type': 'application/json', + ...(simRequestId ? { 'X-Sim-Request-ID': simRequestId } : {}), + ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), + ...getMothershipSourceEnvHeaders(), + 'X-Client-Version': SIM_AGENT_VERSION, + ...(hostedBillingRequest ? hostedBillingRequest.headers : {}), + } +} diff --git a/apps/sim/lib/mothership/request/http.ts b/apps/sim/lib/mothership/request/http.ts index 0f121d4f7c9..b9f278abaef 100644 --- a/apps/sim/lib/mothership/request/http.ts +++ b/apps/sim/lib/mothership/request/http.ts @@ -3,6 +3,8 @@ import { safeCompare } from '@sim/security/compare' import { generateId } from '@sim/utils/id' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import type { z } from 'zod' +import { validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' @@ -107,3 +109,23 @@ export function checkInternalApiKey(req: NextRequest) { return { success: true } } + +/** + * Shim-route envelope pre-validation (the /api/mothership/* aliases): clone the body, + * and when it parses as JSON, check it against the envelope schema before delegating to + * the copilot handler that actually consumes the request. Returns the 400 to send, or + * null to proceed. A non-JSON body proceeds — the delegate owns that failure mode. + */ +export async function validateShimEnvelope( + request: NextRequest, + schema: z.ZodType +): Promise { + // boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body + const body = await request + .clone() + .json() + .catch(() => undefined) + if (body === undefined) return null + const validation = schema.safeParse(body) + return validation.success ? null : validationErrorResponse(validation.error) +} diff --git a/apps/sim/lib/mothership/request/lifecycle/run.ts b/apps/sim/lib/mothership/request/lifecycle/run.ts index 1d34b97ecf9..ea565a95537 100644 --- a/apps/sim/lib/mothership/request/lifecycle/run.ts +++ b/apps/sim/lib/mothership/request/lifecycle/run.ts @@ -20,7 +20,7 @@ import { env } from '@/lib/core/config/env' import { isCopilotToolPermissionsEnabled, isHosted } from '@/lib/core/config/env-flags' import type { AsyncCompletionSignal } from '@/lib/mothership/async-runs/lifecycle' import { createRunSegment, updateRunStatus } from '@/lib/mothership/async-runs/repository' -import { SIM_AGENT_VERSION, TOOL_WATCHDOG_RESUME_GRACE_MS } from '@/lib/mothership/constants' +import { TOOL_WATCHDOG_RESUME_GRACE_MS } from '@/lib/mothership/constants' import { type CopilotEnvironmentContext, prepareCopilotEnvironmentContext, @@ -45,6 +45,7 @@ import { createProviderToolCallIdentity, restoreProviderToolCallId, } from '@/lib/mothership/request/go/tool-call-identity' +import { mothershipRequestHeaders } from '@/lib/mothership/request/headers' import { recordDegraded } from '@/lib/mothership/request/metrics' import { AbortReason } from '@/lib/mothership/request/session/abort-reason' import { @@ -69,10 +70,7 @@ import type { StreamingContext, } from '@/lib/mothership/request/types' import type { SecretMountPolicy } from '@/lib/mothership/secret-mount-policy' -import { - getMothershipBaseURL, - getMothershipSourceEnvHeaders, -} from '@/lib/mothership/server/agent-url' +import { getMothershipBaseURL } from '@/lib/mothership/server/agent-url' import { prepareExecutionContext } from '@/lib/mothership/tools/handlers/context' import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' import { filterModelSafeWorkspaceFileAttachments } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' @@ -598,20 +596,6 @@ function isPerSubagentContinuation(c: AsyncContinuation): boolean { // Shared header set for every Sim -> Go mothership request (initial stream and // every resume leg), so the auth/source/version headers can't drift between the // sequential path and the concurrent per-subagent resume legs. -function mothershipRequestHeaders( - hostedBillingRequest?: AttributedBillingRequestEnvelope, - simRequestId?: string -): Record { - return { - 'Content-Type': 'application/json', - ...(simRequestId ? { 'X-Sim-Request-ID': simRequestId } : {}), - ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), - ...getMothershipSourceEnvHeaders(), - 'X-Client-Version': SIM_AGENT_VERSION, - ...(hostedBillingRequest ? hostedBillingRequest.headers : {}), - } -} - // makeResumeLegContext / mergeResumeLegOutputs are a PAIR and must stay in // lockstep: every field reset here is folded back there, and nothing else on // StreamingContext is per-leg. Everything not listed is shared BY REFERENCE diff --git a/apps/sim/lib/mothership/request/lifecycle/start.ts b/apps/sim/lib/mothership/request/lifecycle/start.ts index 9034f09b459..45c2e2ff293 100644 --- a/apps/sim/lib/mothership/request/lifecycle/start.ts +++ b/apps/sim/lib/mothership/request/lifecycle/start.ts @@ -11,7 +11,6 @@ import { resolveBillingAttribution, resolveOrganizationBillingAttribution, } from '@/lib/billing/core/billing-attribution' -import { env } from '@/lib/core/config/env' import { isHosted } from '@/lib/core/config/env-flags' import { createRunSegment } from '@/lib/mothership/async-runs/repository' import { publishChatStatusChanged } from '@/lib/mothership/chat-status' @@ -30,6 +29,7 @@ import { } from '@/lib/mothership/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' import { TraceEvent } from '@/lib/mothership/generated/trace-events-v1' +import { mothershipRequestHeaders } from '@/lib/mothership/request/headers' import { finalizeStream } from '@/lib/mothership/request/lifecycle/finalize' import { type CopilotLifecycleOptions, @@ -51,10 +51,7 @@ import { } from '@/lib/mothership/request/session' import { SSE_RESPONSE_HEADERS } from '@/lib/mothership/request/session/sse' import { TraceCollector } from '@/lib/mothership/request/trace' -import { - getMothershipBaseURL, - getMothershipSourceEnvHeaders, -} from '@/lib/mothership/server/agent-url' +import { getMothershipBaseURL } from '@/lib/mothership/server/agent-url' export { SSE_RESPONSE_HEADERS } @@ -551,13 +548,7 @@ export async function requestChatTitle(params: { } = params if (!message || !model) return null - const headers: Record = { - 'Content-Type': 'application/json', - } - if (env.COPILOT_API_KEY) { - headers['x-api-key'] = env.COPILOT_API_KEY - } - Object.assign(headers, getMothershipSourceEnvHeaders()) + const headers = mothershipRequestHeaders() try { if (organizationId && (!chatId || workspaceId)) { diff --git a/apps/sim/lib/mothership/request/session/steer.ts b/apps/sim/lib/mothership/request/session/steer.ts new file mode 100644 index 00000000000..9eec17f8520 --- /dev/null +++ b/apps/sim/lib/mothership/request/session/steer.ts @@ -0,0 +1,65 @@ +import type { Context } from '@opentelemetry/api' +import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' +import { fetchGo } from '@/lib/mothership/request/go/fetch' +import { mothershipRequestHeaders } from '@/lib/mothership/request/headers' +import { getMothershipBaseURL } from '@/lib/mothership/server/agent-url' + +export const DEFAULT_STEER_TIMEOUT_MS = 3000 + +/** + * Queues a mid-turn steering message with the Go side (`/api/streams/steer`). + * + * Acceptance means "queued", not "applied": Go acknowledges application with a + * `run`/`steering_applied` stream event carrying the steeringId. A caller that + * never sees that ack before the stream ends must re-send the content as an + * ordinary message — that contract is what makes delivery loss-free without + * this call having to prove stream liveness. + */ +export async function requestStreamSteering(params: { + streamId: string + userId: string + chatId: string + steeringId: string + content: string + timeoutMs?: number + otelContext?: Context +}): Promise<{ queued: boolean; status: number }> { + const { + streamId, + userId, + chatId, + steeringId, + content, + timeoutMs = DEFAULT_STEER_TIMEOUT_MS, + otelContext, + } = params + + const headers = mothershipRequestHeaders() + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort('steer_fetch_timeout'), timeoutMs) + + try { + const mothershipBaseURL = await getMothershipBaseURL({ userId }) + const response = await fetchGo(`${mothershipBaseURL}/api/streams/steer`, { + method: 'POST', + headers, + signal: controller.signal, + body: JSON.stringify({ + messageId: streamId, + steeringId, + content, + }), + otelContext, + spanName: 'sim → go /api/streams/steer', + operation: 'steer', + attributes: { + [TraceAttr.StreamId]: streamId, + [TraceAttr.ChatId]: chatId, + }, + }) + return { queued: response.ok, status: response.status } + } finally { + clearTimeout(timeout) + } +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 1d75e87ed76..172f80b2d52 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -197,7 +197,7 @@ const DECLARATIVE_ROUTE_BUILDER_USAGE_PATTERN = /\b(?:defineInternalJsonRoute|defineV2JsonRoute|defineInternalBinaryRoute|defineV2BinaryRoute|defineScimRoute)\s*\(/ const SERVER_VALIDATION_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/server(?:\/validation)?['"]/ const SCHEMA_PARSE_PATTERN = /\b\w+Schema\.(?:safeParse|parse)\(/ -const CONTRACT_SERVER_HELPER_PATTERN = /\bparseToolRequest\(/ +const CONTRACT_SERVER_HELPER_PATTERN = /\b(?:parseToolRequest|validateShimEnvelope)\(/ const CANONICAL_HELPER_USAGE_PATTERN = /\b(?:isZodError|validationErrorResponse|validationErrorResponseFromError|getValidationErrorMessage)\s*\(/ const CONTRACT_MAP_PARSE_PATTERN = From 6295e6461c29b01201638f5aeb2bdafec6a8c075 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 10:59:11 +0530 Subject: [PATCH 015/306] =?UTF-8?q?chore(mothership):=20sim=20low=20#10=20?= =?UTF-8?q?=E2=80=94=20dead=20code=20from=20the=20M6=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit use-chat's isFileAttachmentForApi/isChatContext (byte-identical copies live in send-handoff.ts) deleted; unconsumed buffer barrel exports trimmed; the resource no-op handler documented as deliberate rather than left looking forgotten. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../[workspaceId]/home/hooks/use-chat.ts | 90 ------------------- .../mothership/request/handlers/resource.ts | 5 ++ .../lib/mothership/request/session/index.ts | 3 - 3 files changed, 5 insertions(+), 93 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 0aa6b6074eb..8c00cce652c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -412,96 +412,6 @@ export async function waitForDetachedChatResolution( } } -function isFileAttachmentForApi(value: unknown): value is FileAttachmentForApi { - if (!isRecordLike(value)) return false - return ( - typeof value.id === 'string' && - typeof value.key === 'string' && - typeof value.filename === 'string' && - typeof value.media_type === 'string' && - typeof value.size === 'number' && - Number.isFinite(value.size) && - (value.path === undefined || typeof value.path === 'string') - ) -} - -function isChatContext(value: unknown): value is ChatContext { - if (!isRecordLike(value) || typeof value.kind !== 'string' || typeof value.label !== 'string') { - return false - } - - switch (value.kind) { - case 'past_chat': - return typeof value.chatId === 'string' - case 'workflow': - case 'current_workflow': - return typeof value.workflowId === 'string' - case 'blocks': - return Array.isArray(value.blockIds) && value.blockIds.every((id) => typeof id === 'string') - case 'logs': - return value.executionId === undefined || typeof value.executionId === 'string' - case 'workflow_block': - return typeof value.workflowId === 'string' && typeof value.blockId === 'string' - case 'knowledge': - return value.knowledgeId === undefined || typeof value.knowledgeId === 'string' - case 'table': - return typeof value.tableId === 'string' - case 'table_selection': - return ( - typeof value.tableId === 'string' && - typeof value.tableName === 'string' && - Array.isArray(value.rowIds) && - value.rowIds.every((id) => typeof id === 'string') - ) - case 'file': - return typeof value.fileId === 'string' - case 'file_selection': - return ( - typeof value.fileId === 'string' && - typeof value.fileName === 'string' && - typeof value.text === 'string' - ) - case 'folder': - return typeof value.folderId === 'string' - case 'filefolder': - return typeof value.fileFolderId === 'string' - case 'docs': - return true - case 'slash_command': - return typeof value.command === 'string' - case 'integration': - return typeof value.blockType === 'string' - case 'skill': - return typeof value.skillId === 'string' - case 'mcp': - return typeof value.serverId === 'string' - case 'browser_tab': - return ( - typeof value.tabId === 'string' && - (value.selection === undefined || - (isRecordLike(value.selection) && - typeof value.selection.text === 'string' && - (value.selection.url === undefined || typeof value.selection.url === 'string') && - (value.selection.title === undefined || typeof value.selection.title === 'string'))) - ) - case 'terminal_tab': - return ( - typeof value.terminalId === 'string' && - (value.selection === undefined || - (isRecordLike(value.selection) && - typeof value.selection.text === 'string' && - typeof value.selection.startLine === 'number' && - typeof value.selection.endLine === 'number' && - Number.isInteger(value.selection.startLine) && - Number.isInteger(value.selection.endLine) && - value.selection.startLine > 0 && - value.selection.endLine >= value.selection.startLine)) - ) - default: - return false - } -} - /** * Runs a browser tool on the desktop client. The agent's tab reaches the * resource strip through the desktop tab list, so nothing is opened here. diff --git a/apps/sim/lib/mothership/request/handlers/resource.ts b/apps/sim/lib/mothership/request/handlers/resource.ts index 9ae8d13d48b..26806e57624 100644 --- a/apps/sim/lib/mothership/request/handlers/resource.ts +++ b/apps/sim/lib/mothership/request/handlers/resource.ts @@ -1,5 +1,10 @@ import type { StreamHandler } from './types' +/** + * Deliberate no-op: resource frames exist for the CLIENT renderer (workspace chips); + * the server-side loop has nothing to do with them. Registered explicitly so an + * unhandled-event warning never fires for a frame type we know and ignore. + */ export const handleResourceEvent: StreamHandler = (event) => { if (event.type !== 'resource') { return diff --git a/apps/sim/lib/mothership/request/session/index.ts b/apps/sim/lib/mothership/request/session/index.ts index 5cac0f7454e..aa9b22d1606 100644 --- a/apps/sim/lib/mothership/request/session/index.ts +++ b/apps/sim/lib/mothership/request/session/index.ts @@ -15,11 +15,8 @@ export { waitForPendingChatStream, } from './abort' export { - allocateCursor, - appendEvent, appendEvents, clearAbortMarker, - clearBuffer, getLatestSeq, getOldestSeq, hasAbortMarker, From 8312a9c09fb0b88c6ae4e06cb279143baf1662d7 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 11:01:11 +0530 Subject: [PATCH 016/306] =?UTF-8?q?refactor(mothership):=20sim=20low=20#8?= =?UTF-8?q?=20=E2=80=94=20one=20SSE=20decode=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit go/parser.ts re-implemented readSSELines' framing loop (incremental decode, line split, \r strip, [DONE], abort, tail flush). It is now the mothership JSON/fatal-error layer over the shared engine; the redundant decoder parameter is gone. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/lib/mothership/request/go/parser.ts | 135 ++++++------------- apps/sim/lib/mothership/request/go/stream.ts | 3 +- 2 files changed, 40 insertions(+), 98 deletions(-) diff --git a/apps/sim/lib/mothership/request/go/parser.ts b/apps/sim/lib/mothership/request/go/parser.ts index 79cdf7383ad..f184eb9603b 100644 --- a/apps/sim/lib/mothership/request/go/parser.ts +++ b/apps/sim/lib/mothership/request/go/parser.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { readSSELines } from '@/lib/core/utils/sse' const logger = createLogger('CopilotSseParser') @@ -10,116 +11,58 @@ function createParseFailure(message: string, preview: string): FatalSseEventErro return new FatalSseEventError(message) } -function normalizeSseLine(line: string): string { - return line.endsWith('\r') ? line.slice(0, -1) : line -} - /** - * Processes an SSE stream by calling onEvent for each parsed event. + * The mothership layer over the one SSE decode engine ({@link readSSELines}): + * JSON-parses each `data:` payload, treats an unparseable payload as FATAL (the + * wire is a typed protocol — garbage means the stream is broken, not skippable), + * and contains per-event handler failures to a warn unless the handler itself + * declares them fatal. Framing, `\r`, `[DONE]`, abort, and tail-flush behavior + * all come from the shared engine. * * @param onEvent Called per parsed event. Return true to stop processing. */ export async function processSSEStream( reader: ReadableStreamDefaultReader, - decoder: TextDecoder, abortSignal: AbortSignal | undefined, onEvent: (event: unknown) => boolean | undefined | Promise ): Promise { - let buffer = '' - try { - try { - while (true) { - if (abortSignal?.aborted) { - logger.info('SSE stream aborted by signal') - break + await readSSELines(reader, { + signal: abortSignal, + onData: async (jsonStr) => { + let parsed: unknown + try { + parsed = JSON.parse(jsonStr) + } catch (error) { + const detail = toError(error).message + throw createParseFailure( + `Failed to parse SSE event JSON: ${detail}`, + jsonStr.slice(0, 200) + ) } - - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() || '' - - let stopped = false - for (const line of lines) { - const normalizedLine = normalizeSseLine(line) - if (abortSignal?.aborted) { - logger.info('SSE stream aborted mid-chunk (between events)') - return - } - if (!normalizedLine.trim()) continue - if (!normalizedLine.startsWith('data: ')) continue - - const jsonStr = normalizedLine.slice(6) - if (jsonStr === '[DONE]') continue - - let parsed: unknown - try { - parsed = JSON.parse(jsonStr) - } catch (error) { - const preview = jsonStr.slice(0, 200) - const detail = toError(error).message - throw createParseFailure(`Failed to parse SSE event JSON: ${detail}`, preview) - } - - try { - if (await onEvent(parsed)) { - stopped = true - break - } - } catch (error) { - if (error instanceof FatalSseEventError) { - throw error - } - logger.warn('Failed to handle SSE event', { - preview: jsonStr.slice(0, 200), - error: toError(error).message, - }) - } - } - if (stopped) break - } - } catch (error) { - const aborted = - abortSignal?.aborted || (error instanceof DOMException && error.name === 'AbortError') - if (aborted) { - logger.info('SSE stream read aborted') - return - } - throw error - } - - const normalizedBuffer = normalizeSseLine(buffer) - if (normalizedBuffer.trim() && normalizedBuffer.startsWith('data: ')) { - const jsonStr = normalizedBuffer.slice(6) - if (jsonStr === '[DONE]') { - return - } - - let parsed: unknown - try { - parsed = JSON.parse(jsonStr) - } catch (error) { - const preview = normalizedBuffer.slice(0, 200) - const detail = toError(error).message - throw createParseFailure(`Failed to parse final SSE buffer JSON: ${detail}`, preview) - } - - try { - await onEvent(parsed) - } catch (error) { - if (error instanceof FatalSseEventError) { - throw error + try { + return (await onEvent(parsed)) === true + } catch (error) { + if (error instanceof FatalSseEventError) throw error + logger.warn('Failed to handle SSE event', { + preview: jsonStr.slice(0, 200), + error: toError(error).message, + }) + return false } - logger.warn('Failed to handle final SSE event', { - preview: normalizedBuffer.slice(0, 200), - error: toError(error).message, - }) - } + }, + }) + } catch (error) { + const aborted = + abortSignal?.aborted || (error instanceof DOMException && error.name === 'AbortError') + if (aborted) { + logger.info('SSE stream read aborted') + return } + throw error } finally { + // The engine only releases locks it acquired; this reader is caller-supplied, + // and the pre-unification behavior (always release here) is part of the contract. try { reader.releaseLock() } catch { diff --git a/apps/sim/lib/mothership/request/go/stream.ts b/apps/sim/lib/mothership/request/go/stream.ts index f41633a209f..3ecd3c871e9 100644 --- a/apps/sim/lib/mothership/request/go/stream.ts +++ b/apps/sim/lib/mothership/request/go/stream.ts @@ -287,7 +287,6 @@ export async function runStreamLoop( return rawReader.closed }, } - const decoder = new TextDecoder() const timeoutId = setTimeout(() => { context.errors.push('Request timed out') @@ -297,7 +296,7 @@ export async function runStreamLoop( }, timeout) try { - await processSSEStream(reader, decoder, abortSignal, async (raw) => { + await processSSEStream(reader, abortSignal, async (raw) => { // Track how long THIS handler invocation takes so we can tell // apart "Go was silent" from "we were CPU-bound on a handler". // `longestInboundGapMs` includes handler time (the next reader.read From f96fd50b74e0e45b1f6a4669d2f96aa9c8682217 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 11:03:32 +0530 Subject: [PATCH 017/306] fix(mothership): terminal predicate import must not pull the session barrel into the client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #9 dedup pointed use-chat (a 'use client' graph) at the session BARREL, which reaches ioredis via the buffer — Next resolved 'dns' in a client bundle and the whole dev server 500ed. Client consumers deep-import the client-safe contract module; the barrel export stays for the server. Caught by an end-to-end smoke, not by tsc/vitest/boundary-check — none of them walk the client bundle graph. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts | 6 ++++-- apps/sim/lib/mothership/chat/effective-transcript.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 8c00cce652c..14b7fc432c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -60,8 +60,10 @@ import { import { MOTHERSHIP_CHAT_API_PATH } from '@/lib/mothership/constants' import { sendMothershipMessage } from '@/lib/mothership/events' import { MothershipStreamV1ToolOutcome } from '@/lib/mothership/generated/mothership-stream-v1' -import { isTerminalStreamStatus } from '@/lib/mothership/request/session' -import { parsePersistedStreamEventEnvelopeJson } from '@/lib/mothership/request/session/contract' +import { + isTerminalStreamStatus, + parsePersistedStreamEventEnvelopeJson, +} from '@/lib/mothership/request/session/contract' import type { FilePreviewSession } from '@/lib/mothership/request/session/file-preview-session-contract' import { canDisplayResource } from '@/lib/mothership/resources/availability' import { diff --git a/apps/sim/lib/mothership/chat/effective-transcript.ts b/apps/sim/lib/mothership/chat/effective-transcript.ts index fc5ca11e33c..05f90a1f866 100644 --- a/apps/sim/lib/mothership/chat/effective-transcript.ts +++ b/apps/sim/lib/mothership/chat/effective-transcript.ts @@ -13,7 +13,7 @@ import { MothershipStreamV1ToolOutcome, MothershipStreamV1ToolPhase, } from '@/lib/mothership/generated/mothership-stream-v1' -import { isTerminalStreamStatus } from '@/lib/mothership/request/session' +import { isTerminalStreamStatus } from '@/lib/mothership/request/session/contract' import type { FilePreviewSession } from '@/lib/mothership/request/session/file-preview-session-contract' import type { StreamBatchEvent } from '@/lib/mothership/request/session/types' import { From fe50be571d4ca17ae041906a1c870ac1c5a40b0b Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 12:59:14 +0530 Subject: [PATCH 018/306] =?UTF-8?q?chore(mothership):=20sync=20protocol=20?= =?UTF-8?q?=E2=80=94=20span=20envelope=20type=20+=20subagent=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/lib/mothership/generated/protocol.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/mothership/generated/protocol.ts b/apps/sim/lib/mothership/generated/protocol.ts index 036290d133c..e02955304e1 100644 --- a/apps/sim/lib/mothership/generated/protocol.ts +++ b/apps/sim/lib/mothership/generated/protocol.ts @@ -136,11 +136,23 @@ export interface ExecuteMessage { */ export interface StreamEnvelope { v: 1; - type: "session" | "text" | "tool" | "run" | "resource" | "error" | "complete"; + type: "session" | "text" | "tool" | "span" | "run" | "resource" | "error" | "complete"; seq: number; /** ISO timestamp. */ ts: string; stream: { streamId: string; chatId?: string | undefined; cursor?: string | undefined }; trace?: { requestId?: string | undefined } | undefined; + /** Subagent-lane attribution (mothership-stream-v1 scope): frames carrying it render + * inside the named root-level lane instead of the main transcript. */ + scope?: StreamScope | undefined; payload: Record; } + +/** One subagent lane: keyed by the delegating tool call; agentId/spanId identify the lane. */ +export interface StreamScope { + lane: "subagent"; + agentId?: string | undefined; + parentToolCallId?: string | undefined; + spanId?: string | undefined; + parentSpanId?: string | undefined; +} From 5c3ea8ecd4deee4d76870468efa7d72180e0a5c2 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 13:19:54 +0530 Subject: [PATCH 019/306] =?UTF-8?q?fix(mothership):=20Go-parity=20gaps=20f?= =?UTF-8?q?rom=20the=20wire=20audit=20=E2=80=94=20sim=20side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live CLI rows read "Running CLI command" for their whole run: the worker's partial frame names them sim_cli (args unknowable mid-stream) and the browser only filled EMPTY names. upsertToolNode now replaces exactly that placeholder with the finalized frame's verb — scoped so the gateway rebind's model-authored branding is never clobbered. isValidRunPayload accepts run/steering_applied: the worker emits it per the loss-free-ack contract and the validator's rejection escalated to FatalSseEventError — the ack would have killed the live turn the moment steering shipped. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../[workspaceId]/home/hooks/stream/turn-model.ts | 9 ++++++++- apps/sim/lib/mothership/request/session/contract.ts | 6 +++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index 48f1d551d15..d7b425d1e62 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -368,7 +368,14 @@ function upsertToolNode( ): ToolNode { const existing = model.nodes.get(id) if (existing && existing.kind === 'tool') { - if (name && !existing.name) existing.name = name + // Fill blanks, and replace exactly the CLI placeholder: the worker's partial frame + // names CLI rows `sim_cli` (args unknowable mid-stream) and the finalized frame + // carries the real verb (`cli_workflows_list`) — without this every live CLI row + // read "Running CLI command" until reload. Scoped to the placeholder so the gateway + // rebind's model-authored branding is never clobbered by a later frame. + if (name && (!existing.name || (existing.name === 'sim_cli' && name !== 'sim_cli'))) { + existing.name = name + } return existing } const node: ToolNode = { diff --git a/apps/sim/lib/mothership/request/session/contract.ts b/apps/sim/lib/mothership/request/session/contract.ts index f74fe6a6987..b1d209b3b9a 100644 --- a/apps/sim/lib/mothership/request/session/contract.ts +++ b/apps/sim/lib/mothership/request/session/contract.ts @@ -294,7 +294,11 @@ function isValidRunPayload(payload: JsonRecord): boolean { kind === MothershipStreamV1RunKind.checkpoint_pause || kind === MothershipStreamV1RunKind.resumed || kind === MothershipStreamV1RunKind.compaction_start || - kind === MothershipStreamV1RunKind.compaction_done + kind === MothershipStreamV1RunKind.compaction_done || + // The worker's loss-free steering ack. Rejecting an unknown-but-contracted kind + // escalated to FatalSseEventError and would have killed the live turn the moment + // steering shipped (found by the Go-parity audit before any user hit it). + kind === MothershipStreamV1RunKind.steering_applied ) } From 81b243dcfb0bd82170c49679fc44dd579ee46322 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 14:00:25 +0530 Subject: [PATCH 020/306] fix(tables): push-managed DBs never get the row-count triggers Found by the copilot subagent's own answer (it had to count rows directly because the tables list read 0 everywhere): db:push knows nothing about the raw-SQL triggers versioned migrations install, so local + any push-managed DB had NO count triggers and row_count froze at 0. The dev migrate lane now applies the current definitions (verbatim from 0224/0241/0289) idempotently after push and reconciles stored counts (local: 237 tables corrected). Also drops the pre-0224 legacy row-level delete trigger still live on dev, which was double-decrementing deletes. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../db/scripts/apply-dev-table-triggers.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 packages/db/scripts/apply-dev-table-triggers.ts diff --git a/packages/db/scripts/apply-dev-table-triggers.ts b/packages/db/scripts/apply-dev-table-triggers.ts new file mode 100644 index 00000000000..41d30efc78e --- /dev/null +++ b/packages/db/scripts/apply-dev-table-triggers.ts @@ -0,0 +1,100 @@ +import { createLogger } from '@sim/logger' +import postgres from 'postgres' + +/** + * Push-managed databases (local + dev use `db:push`) never receive the raw-SQL + * row-count triggers that versioned migrations install on staging/prod — so every + * table's `row_count` sat at 0 forever there (found live: the agent had to count rows + * directly because the tables list lied). This applies the CURRENT trigger definitions + * (verbatim from migrations 0224/0241/0289) idempotently, then reconciles the stored + * counts with reality once. Runs in the dev migrate lane after `db:push`. + */ +const logger = createLogger('DevTableTriggers') + +const url = process.env.MIGRATION_DATABASE_URL || process.env.DATABASE_URL +if (!url) { + throw new Error('Missing MIGRATION_DATABASE_URL or DATABASE_URL') +} + +const sql = postgres(url, { + max: 1, + connect_timeout: 10, + max_lifetime: null, + connection: { application_name: 'sim-dev-table-triggers' }, +}) + +const TRIGGER_SQL = ` +CREATE OR REPLACE FUNCTION increment_user_table_row_count_stmt() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE user_table_definitions d + SET row_count = d.row_count + c.n, + updated_at = timezone('UTC', now()) + FROM ( + SELECT table_id, count(*)::int AS n + FROM new_rows + GROUP BY table_id + ) c + WHERE d.id = c.table_id; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION decrement_user_table_row_count_stmt() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE user_table_definitions d + SET row_count = GREATEST(d.row_count - c.n, 0), + updated_at = timezone('UTC', now()) + FROM ( + SELECT table_id, count(*)::int AS n + FROM old_rows + GROUP BY table_id + ) c + WHERE d.id = c.table_id; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +-- Legacy row-level triggers (pre-0224): coexisting with the stmt triggers they +-- double-count — dev had the legacy delete trigger still installed, decrementing twice. +DROP TRIGGER IF EXISTS user_table_rows_insert_trigger ON user_table_rows; +DROP TRIGGER IF EXISTS user_table_rows_delete_trigger ON user_table_rows; + +DROP TRIGGER IF EXISTS user_table_rows_insert_stmt_trigger ON user_table_rows; +CREATE TRIGGER user_table_rows_insert_stmt_trigger + AFTER INSERT ON user_table_rows + REFERENCING NEW TABLE AS new_rows + FOR EACH STATEMENT + EXECUTE FUNCTION increment_user_table_row_count_stmt(); + +DROP TRIGGER IF EXISTS user_table_rows_delete_stmt_trigger ON user_table_rows; +CREATE TRIGGER user_table_rows_delete_stmt_trigger + AFTER DELETE ON user_table_rows + REFERENCING OLD TABLE AS old_rows + FOR EACH STATEMENT + EXECUTE FUNCTION decrement_user_table_row_count_stmt(); +` + +try { + await sql.unsafe(TRIGGER_SQL) + const reconciled = await sql` + UPDATE user_table_definitions d + SET row_count = actual.n + FROM ( + SELECT d2.id, count(r.id)::int AS n + FROM user_table_definitions d2 + LEFT JOIN user_table_rows r ON r.table_id = d2.id + GROUP BY d2.id + ) actual + WHERE actual.id = d.id AND d.row_count IS DISTINCT FROM actual.n + RETURNING d.id + ` + logger.info('Table row-count triggers applied; counts reconciled', { + reconciledTables: reconciled.length, + }) +} finally { + await sql.end() +} From f90aa9f759554a30d3c042277736be00fc8a9964 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 15:39:44 +0530 Subject: [PATCH 021/306] feat(mothership): sim_cli and run_code execute sim-side; agent CLI augmentations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live dev incident (elder's chats): run_code faked success against a route that does not exist, sim's pickup ghost-claimed worker tools off the stale catalog, and a mid-turn deploy downgraded the toolset (CLI "Not logged in", AI_NoSuchToolError). Root fixes, all on the right layer: - sim/embed: the CLI's own command tree runs in-process (AsyncLocalStorage identity + output capture, no profiles/config/env, process.exit shimmed). The worker stops spawning a vendored CLI binary; no credential crosses the wire — sim mints the delegation identity inside the handler per call. - sim_cli handler routes each invocation: agent-only augmentations first (agent-cli/ registry — workflow blocks/edges views, workflow-scoped and cross-workflow grep, typed over the v2 surface via the CLI's SimClient), else the embedded real CLI; root --help merges both surfaces. - run_code + function-execute handlers restored from the pre-revamp tree (same sandbox as workflow Function blocks), registered again. - Pickup guard: a frame whose executor is 'go' is never dispatched here, whatever the stale catalog says; registry handlers are authoritative for worker-declared tools outside the catalog. - Lanes: the task dispatch row is absorbed by its titled lane (id-precise), 'task' agentId gets a sane fallback label. - Resume delegation-token threading removed — obsolete by design. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../message-content/message-content.tsx | 24 +- .../app/workspace/[workspaceId]/home/types.ts | 3 + apps/sim/lib/mothership/generated/protocol.ts | 5 +- .../lib/mothership/request/handlers/tool.ts | 6 +- .../lib/mothership/tool-executor/executor.ts | 3 +- .../tool-executor/register-handlers.ts | 9 + .../handlers/agent-cli/agent-cli.test.ts | 142 ++++ .../tools/handlers/agent-cli/commands/grep.ts | 125 +++ .../agent-cli/commands/workflow-views.ts | 87 +++ .../tools/handlers/agent-cli/index.ts | 87 +++ .../tools/handlers/agent-cli/types.ts | 45 ++ .../tools/handlers/function-execute.ts | 718 ++++++++++++++++++ .../lib/mothership/tools/handlers/run-code.ts | 34 + .../lib/mothership/tools/handlers/sim-cli.ts | 80 ++ apps/sim/next.config.ts | 1 + apps/sim/package.json | 1 + bun.lock | 1 + packages/sim-cli/package.json | 6 + packages/sim-cli/src/config/profile.ts | 6 + packages/sim-cli/src/embed-context.ts | 109 +++ packages/sim-cli/src/embed.test.ts | 77 ++ packages/sim-cli/src/embed.ts | 119 +++ 22 files changed, 1682 insertions(+), 6 deletions(-) create mode 100644 apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts create mode 100644 apps/sim/lib/mothership/tools/handlers/agent-cli/commands/grep.ts create mode 100644 apps/sim/lib/mothership/tools/handlers/agent-cli/commands/workflow-views.ts create mode 100644 apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts create mode 100644 apps/sim/lib/mothership/tools/handlers/agent-cli/types.ts create mode 100644 apps/sim/lib/mothership/tools/handlers/function-execute.ts create mode 100644 apps/sim/lib/mothership/tools/handlers/run-code.ts create mode 100644 apps/sim/lib/mothership/tools/handlers/sim-cli.ts create mode 100644 packages/sim-cli/src/embed-context.ts create mode 100644 packages/sim-cli/src/embed.test.ts create mode 100644 packages/sim-cli/src/embed.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 18b05195952..85fae8d12b2 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -144,6 +144,9 @@ const SUBAGENT_KEYS = new Set(Object.keys(SUBAGENT_LABELS)) */ const SUBAGENT_DISPATCH_TOOLS: Record = { [FILE_SUBAGENT_ID]: PrepareFileEdit.id, + // The worker's general subagent: the `task` tool row is the dispatch; the lane + // (titled by the model) replaces it. + task: 'task', } function isToolResultRead(params?: Record): boolean { @@ -307,12 +310,27 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { // When a subagent spawns, drop the dispatch tool that triggered it (e.g. // workspace_file -> file) from whichever container it landed in so it does not // render as a separate entry beside the agent group. - const absorbDispatchTool = (toolName: string, parentSpanId: string | undefined): void => { + const absorbDispatchTool = ( + toolName: string, + parentSpanId: string | undefined, + dispatchToolCallId?: string + ): void => { const container = parentSpanId && parentSpanId !== SPAN_ROOT ? groupsBySpanId.get(parentSpanId) : tailMothershipGroup() if (!container) return + // Prefer the precise id match anywhere in the container — parallel sibling + // tools can push the dispatch row off the tail position. + if (dispatchToolCallId) { + const idx = container.items.findIndex( + (it) => it.type === 'tool' && it.data.id === dispatchToolCallId + ) + if (idx >= 0) { + container.items.splice(idx, 1) + return + } + } const last = container.items[container.items.length - 1] if (last?.type === 'tool' && last.data.toolName === toolName) { container.items.pop() @@ -399,7 +417,9 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { // Absorb a trailing dispatch tool (e.g. workspace_file -> file) so it does // not render as a separate entry alongside the agent group. const dispatchToolName = SUBAGENT_DISPATCH_TOOLS[block.content] - if (dispatchToolName) absorbDispatchTool(dispatchToolName, block.parentSpanId) + if (dispatchToolName) { + absorbDispatchTool(dispatchToolName, block.parentSpanId, block.parentToolCallId) + } const g = ensureSpanGroup(block.content, block.spanId, block.parentSpanId) if (block.subagentName) g.agentLabel = block.subagentName if (block.endedAt !== undefined) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index bffb343fd8c..d9f9f84ce72 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -210,6 +210,9 @@ export const SUBAGENT_LABELS: Record = { // `extensions` is its current model-facing trigger tool name. agent: 'Extensions Agent', extensions: 'Extensions Agent', + // The worker's general subagent; the lane header normally carries the + // model-chosen title, this label is only the pre-start race fallback. + task: 'Subagent', // `job` retained as a backward-compat alias so historical transcripts still render a label. job: 'Job Agent', file: 'File Agent', diff --git a/apps/sim/lib/mothership/generated/protocol.ts b/apps/sim/lib/mothership/generated/protocol.ts index e02955304e1..706d46c180b 100644 --- a/apps/sim/lib/mothership/generated/protocol.ts +++ b/apps/sim/lib/mothership/generated/protocol.ts @@ -32,7 +32,8 @@ export interface ChatRequest { integrationTools?: unknown[] | undefined; /** User-configured MCP tool schemas — same shape as integrationTools. */ mothershipTools?: unknown[] | undefined; - /** D23: sim-minted run-scoped credential. In-memory only on the worker (S44). */ + /** Deprecated: unused since the CLI moved to sim-side in-process execution (no + * credential crosses the wire); accepted so current senders keep validating. */ delegationToken?: string | undefined; /** Enterprise BYOK: customer's own key; per-run instance, zero retention (S27). */ byokApiKey?: string | undefined; @@ -42,7 +43,7 @@ export interface ChatRequest { /** * What the CALLER can execute client-side. PRESENT = an explicit declaration — an * empty array means "I pick up nothing", so sim-side dispatch must skip client-pickup - * grace windows and run tools server-side immediately. ABSENT = legacy/unknown caller — + * grace windows and run tools server-side immediately. ABSENT = older/unknown caller — * dispatch keeps its conservative grace (deploy-skew safe: a stale tab that predates * this field still gets waited on). Known capability: "workflow-tool-pickup". */ diff --git a/apps/sim/lib/mothership/request/handlers/tool.ts b/apps/sim/lib/mothership/request/handlers/tool.ts index f6c681a595f..5c76d2c038a 100644 --- a/apps/sim/lib/mothership/request/handlers/tool.ts +++ b/apps/sim/lib/mothership/request/handlers/tool.ts @@ -540,7 +540,11 @@ async function handleCallPhase( const { clientExecutable, simExecutable, internal, inbandOwned } = ui const catalogEntry = getToolEntry(toolName) const isInternal = internal || catalogEntry?.internal === true - const staticSimExecuted = isSimExecuted(toolName) + // The frame's executor is authoritative over the static catalog: a backend-executed + // ('go') frame must never be dispatched here even when the catalog lists the name as + // sim-routed — the worker runs some legacy-named tools in-process, and a stale-catalog + // dispatch raced them with a second, handlerless execution ("No handler for tool"). + const staticSimExecuted = isSimExecuted(toolName) && data.executor !== 'go' // Go executes inband-owned calls itself via /api/copilot/tools/execute // (background lanes, and the main lane while background agents run); the // event exists only to draw the row. Dispatching it here would run the diff --git a/apps/sim/lib/mothership/tool-executor/executor.ts b/apps/sim/lib/mothership/tool-executor/executor.ts index 9086da264db..095effa7854 100644 --- a/apps/sim/lib/mothership/tool-executor/executor.ts +++ b/apps/sim/lib/mothership/tool-executor/executor.ts @@ -101,7 +101,8 @@ export async function executeTool( const normalizedParams = normalizeToolParams(toolId, params, context) const canUseRegisteredHandler = - isKnownTool(toolId) && (isSimExecuted(toolId) || usesHeadlessClientFallback) + hasHandler(toolId) && + (!isKnownTool(toolId) || isSimExecuted(toolId) || usesHeadlessClientFallback) if (!canUseRegisteredHandler) { const appParams = buildAppToolParams(normalizedParams, context) const options = { diff --git a/apps/sim/lib/mothership/tool-executor/register-handlers.ts b/apps/sim/lib/mothership/tool-executor/register-handlers.ts index 7822d662cfe..53bc76d4073 100644 --- a/apps/sim/lib/mothership/tool-executor/register-handlers.ts +++ b/apps/sim/lib/mothership/tool-executor/register-handlers.ts @@ -7,6 +7,8 @@ import { } from '@/lib/mothership/generated/tool-catalog-v1' import { createServerToolHandler } from '@/lib/mothership/tools/registry/server-tool-adapter' import { getRegisteredServerToolNames } from '@/lib/mothership/tools/server/router' +import { executeRunCode } from '../tools/handlers/run-code' +import { executeSimCli } from '../tools/handlers/sim-cli' import { executeRunBlock, executeRunFromBlock, @@ -48,6 +50,13 @@ function buildHandlerMap(): Record { [RunWorkflowUntilBlock.id]: h(executeRunWorkflowUntilBlock), [RunFromBlock.id]: h(executeRunFromBlock), [RunBlock.id]: h(executeRunBlock), + // The worker's sandboxed code execution — deferred here because the sandbox + // (E2B/VM, mounts, secret materialization) lives on this side, same as the + // workflow Function block. Compute-only: the handler rejects write vectors. + run_code: h(executeRunCode), + // The worker's CLI surface, executed in-process via the CLI's own command + // tree (sim/embed) against this deployment's internal API base. + sim_cli: h(executeSimCli), ...buildServerToolHandlers(), } } diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts new file mode 100644 index 00000000000..b481b19bdbf --- /dev/null +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts @@ -0,0 +1,142 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + agentCliHelpSection, + executeAgentCliCommand, + isRootHelpInvocation, + matchAgentCliCommand, +} from '@/lib/mothership/tools/handlers/agent-cli' +import type { AgentCliRuntime } from '@/lib/mothership/tools/handlers/agent-cli/types' + +const WORKFLOW_STATE = { + blocks: { + 'block-1': { type: 'starter', name: 'Start', enabled: true }, + 'block-2': { type: 'agent', name: 'Summarize emails', enabled: true }, + }, + edges: [{ source: 'block-1', target: 'block-2', sourceHandle: 'source', id: 'edge-1' }], + variables: { apiBase: 'https://api.example.com' }, +} + +function runtimeWith(responses: Record): AgentCliRuntime { + return { + workspaceId: 'ws-1', + client: { + request: async (path: string): Promise => { + const hit = responses[path] + if (hit === undefined) throw new Error(`Unexpected request: ${path}`) + return hit as T + }, + }, + } +} + +const EXPORT_PATH = '/api/v2/workflows/wf-1/export' +const exportResponse = { data: { state: WORKFLOW_STATE } } + +describe('agent-cli routing', () => { + it('matches agent commands through leading global flags', () => { + const match = matchAgentCliCommand(['--output', 'json', 'workflow', 'edges', 'wf-1']) + expect(match?.command.path).toEqual(['workflow', 'edges']) + expect(match?.rest).toEqual(['wf-1']) + }) + + it('leaves real CLI commands unmatched', () => { + expect(matchAgentCliCommand(['workflows', 'list'])).toBeNull() + expect(matchAgentCliCommand(['tables', 'get', 'tbl_1'])).toBeNull() + }) + + it('detects root help invocations only', () => { + expect(isRootHelpInvocation(['--help'])).toBe(true) + expect(isRootHelpInvocation(['--output', 'json', 'help'])).toBe(true) + expect(isRootHelpInvocation(['workflows', '--help'])).toBe(false) + }) + + it('lists every registered command in the help section', () => { + const section = agentCliHelpSection() + for (const usage of ['workflow blocks', 'workflow edges', 'workflow grep', 'workflows grep']) { + expect(section).toContain(usage) + } + }) +}) + +describe('workflow views', () => { + it('projects just the blocks', async () => { + const match = matchAgentCliCommand(['workflow', 'blocks', 'wf-1']) + const result = await executeAgentCliCommand( + match!, + runtimeWith({ [EXPORT_PATH]: exportResponse }) + ) + expect(result.exitCode).toBe(0) + const blocks = JSON.parse(result.stdout) + expect(blocks).toEqual([ + { id: 'block-1', type: 'starter', name: 'Start', enabled: true }, + { id: 'block-2', type: 'agent', name: 'Summarize emails', enabled: true }, + ]) + }) + + it('projects just the edges', async () => { + const match = matchAgentCliCommand(['workflow', 'edges', 'wf-1']) + const result = await executeAgentCliCommand( + match!, + runtimeWith({ [EXPORT_PATH]: exportResponse }) + ) + expect(result.exitCode).toBe(0) + expect(JSON.parse(result.stdout)).toEqual([ + { source: 'block-1', target: 'block-2', sourceHandle: 'source' }, + ]) + }) + + it('fails usefully without a workflow id', async () => { + const match = matchAgentCliCommand(['workflow', 'blocks']) + const result = await executeAgentCliCommand(match!, runtimeWith({})) + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('Usage:') + }) +}) + +describe('workflow grep', () => { + it('reports matches as path: value lines', async () => { + const match = matchAgentCliCommand(['workflow', 'grep', 'wf-1', 'Summarize']) + const result = await executeAgentCliCommand( + match!, + runtimeWith({ [EXPORT_PATH]: exportResponse }) + ) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('.blocks.block-2.name: Summarize emails') + }) + + it('falls back to literal search on an invalid regex', async () => { + const match = matchAgentCliCommand(['workflow', 'grep', 'wf-1', 'api.example.com[']) + const result = await executeAgentCliCommand( + match!, + runtimeWith({ [EXPORT_PATH]: exportResponse }) + ) + expect(result.exitCode).toBe(0) + expect(result.stdout).toBe('No matches.') + }) + + it('searches across all workspace workflows', async () => { + const match = matchAgentCliCommand(['workflows', 'grep', 'Summarize']) + const result = await executeAgentCliCommand( + match!, + runtimeWith({ + '/api/v2/workflows': { + data: [{ id: 'wf-1', name: 'Email digest' }], + nextCursor: null, + }, + [EXPORT_PATH]: exportResponse, + }) + ) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('Email digest (wf-1).blocks.block-2.name: Summarize emails') + }) + + it('surfaces execution errors as a failed result, never a throw', async () => { + const match = matchAgentCliCommand(['workflow', 'grep', 'wf-missing', 'x']) + const result = await executeAgentCliCommand(match!, runtimeWith({})) + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('Unexpected request') + }) +}) diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/grep.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/grep.ts new file mode 100644 index 00000000000..884bf11cacf --- /dev/null +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/grep.ts @@ -0,0 +1,125 @@ +import type { ListWorkflowsResponse } from 'sim/embed' +import { fetchWorkflowState } from '@/lib/mothership/tools/handlers/agent-cli/commands/workflow-views' +import { + type AgentCliCommand, + type AgentCliRuntime, + agentCliFail, + agentCliOk, +} from '@/lib/mothership/tools/handlers/agent-cli/types' + +/** + * Structural grep over workflow state. Matches walk the exported JSON tree and + * report `path: value` lines, so a hit names exactly where in the workflow it + * lives (block param, edge handle, variable) instead of a rendered blob. + */ + +const MAX_MATCHES = 200 +const SNIPPET_CHARS = 200 +const EXPORT_CONCURRENCY = 5 + +function compilePattern(raw: string): (value: string) => boolean { + try { + const regex = new RegExp(raw, 'i') + return (value) => regex.test(value) + } catch { + const needle = raw.toLowerCase() + return (value) => value.toLowerCase().includes(needle) + } +} + +function grepTree( + node: unknown, + matches: (value: string) => boolean, + path: string, + out: string[] +): void { + if (out.length >= MAX_MATCHES) return + if (typeof node === 'string' || typeof node === 'number' || typeof node === 'boolean') { + const text = String(node) + if (matches(text)) { + const snippet = text.length > SNIPPET_CHARS ? `${text.slice(0, SNIPPET_CHARS)}…` : text + out.push(`${path}: ${snippet.replaceAll('\n', '\\n')}`) + } + return + } + if (Array.isArray(node)) { + node.forEach((child, index) => grepTree(child, matches, `${path}[${index}]`, out)) + return + } + if (typeof node === 'object' && node !== null) { + for (const [key, child] of Object.entries(node)) { + // Keys are searchable too: a block id or param name is often the target. + if (matches(key) && out.length < MAX_MATCHES) out.push(`${path}.${key}`) + grepTree(child, matches, `${path}.${key}`, out) + } + } +} + +function renderMatches(lines: string[]): string { + if (lines.length === 0) return 'No matches.' + const capped = + lines.length >= MAX_MATCHES ? [...lines, `[capped at ${MAX_MATCHES} matches]`] : lines + return capped.join('\n') +} + +export const workflowGrepCommand: AgentCliCommand = { + path: ['workflow', 'grep'], + summary: 'Search one workflow state (blocks, params, edges) for a pattern', + usage: 'workflow grep ', + async execute(rest, runtime) { + const [workflowId, ...patternParts] = rest + const pattern = patternParts.join(' ') + if (!workflowId || !pattern) + return agentCliFail('Usage: sim workflow grep ') + const state = await fetchWorkflowState(runtime, workflowId) + const out: string[] = [] + grepTree(state, compilePattern(pattern), '', out) + return agentCliOk(renderMatches(out)) + }, +} + +async function listAllWorkflows(runtime: AgentCliRuntime): Promise { + const rows: ListWorkflowsResponse['data'] = [] + let cursor: string | null = null + do { + const page: ListWorkflowsResponse = await runtime.client.request( + '/api/v2/workflows', + { query: { workspaceId: runtime.workspaceId, ...(cursor ? { cursor } : {}) } } + ) + rows.push(...page.data) + cursor = page.nextCursor + } while (cursor) + return rows +} + +export const workflowsGrepCommand: AgentCliCommand = { + path: ['workflows', 'grep'], + summary: 'Search every workflow in the workspace for a pattern', + usage: 'workflows grep ', + async execute(rest, runtime) { + const pattern = rest.join(' ') + if (!pattern) return agentCliFail('Usage: sim workflows grep ') + const matches = compilePattern(pattern) + const workflows = await listAllWorkflows(runtime) + const out: string[] = [] + for (let i = 0; i < workflows.length && out.length < MAX_MATCHES; i += EXPORT_CONCURRENCY) { + const batch = workflows.slice(i, i + EXPORT_CONCURRENCY) + const states = await Promise.all( + batch.map(async (workflow) => { + try { + return { workflow, state: await fetchWorkflowState(runtime, workflow.id) } + } catch { + // One unexportable workflow must not sink the whole search. + return { workflow, state: null } + } + }) + ) + for (const { workflow, state } of states) { + const label = `${workflow.name} (${workflow.id})` + if (matches(workflow.name) && out.length < MAX_MATCHES) out.push(`${label}: name matches`) + if (state) grepTree(state, matches, label, out) + } + } + return agentCliOk(renderMatches(out)) + }, +} diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/workflow-views.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/workflow-views.ts new file mode 100644 index 00000000000..57d936b566a --- /dev/null +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/workflow-views.ts @@ -0,0 +1,87 @@ +import type { ExportWorkflowResponse } from 'sim/embed' +import { + type AgentCliCommand, + type AgentCliRuntime, + agentCliFail, + agentCliOk, +} from '@/lib/mothership/tools/handlers/agent-cli/types' + +/** + * Projections over one workflow's exported state: just the blocks, or just the + * connections. The full export is the v2 source of truth; these views exist so + * the agent can orient in a large workflow without paging its whole state + * through the context window. + */ + +export async function fetchWorkflowState( + runtime: AgentCliRuntime, + workflowId: string +): Promise> { + const response = await runtime.client.request( + `/api/v2/workflows/${encodeURIComponent(workflowId)}/export` + ) + return response.data.state +} + +interface BlockView { + id: string + type: string | undefined + name: string | undefined + enabled: boolean | undefined +} + +function blockViews(state: Record): BlockView[] { + const blocks = state.blocks + if (typeof blocks !== 'object' || blocks === null) return [] + return Object.entries(blocks as Record).map(([id, raw]) => { + const block = (typeof raw === 'object' && raw !== null ? raw : {}) as Record + return { + id, + type: typeof block.type === 'string' ? block.type : undefined, + name: typeof block.name === 'string' ? block.name : undefined, + enabled: typeof block.enabled === 'boolean' ? block.enabled : undefined, + } + }) +} + +function edgeViews(state: Record): Record[] { + const edges = state.edges + if (!Array.isArray(edges)) return [] + return edges.map((raw) => { + const edge = (typeof raw === 'object' && raw !== null ? raw : {}) as Record + return { + source: edge.source, + target: edge.target, + ...(edge.sourceHandle !== undefined && edge.sourceHandle !== null + ? { sourceHandle: edge.sourceHandle } + : {}), + ...(edge.targetHandle !== undefined && edge.targetHandle !== null + ? { targetHandle: edge.targetHandle } + : {}), + } + }) +} + +export const workflowBlocksCommand: AgentCliCommand = { + path: ['workflow', 'blocks'], + summary: 'List just the blocks of one workflow (id, type, name, enabled)', + usage: 'workflow blocks ', + async execute(rest, runtime) { + const workflowId = rest[0] + if (!workflowId) return agentCliFail('Usage: sim workflow blocks ') + const state = await fetchWorkflowState(runtime, workflowId) + return agentCliOk(JSON.stringify(blockViews(state), null, 2)) + }, +} + +export const workflowEdgesCommand: AgentCliCommand = { + path: ['workflow', 'edges'], + summary: 'List just the connections of one workflow (source, target, handles)', + usage: 'workflow edges ', + async execute(rest, runtime) { + const workflowId = rest[0] + if (!workflowId) return agentCliFail('Usage: sim workflow edges ') + const state = await fetchWorkflowState(runtime, workflowId) + return agentCliOk(JSON.stringify(edgeViews(state), null, 2)) + }, +} diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts new file mode 100644 index 00000000000..0822d25f3a0 --- /dev/null +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts @@ -0,0 +1,87 @@ +import { + workflowGrepCommand, + workflowsGrepCommand, +} from '@/lib/mothership/tools/handlers/agent-cli/commands/grep' +import { + workflowBlocksCommand, + workflowEdgesCommand, +} from '@/lib/mothership/tools/handlers/agent-cli/commands/workflow-views' +import { + type AgentCliCommand, + type AgentCliResult, + type AgentCliRuntime, + agentCliFail, +} from '@/lib/mothership/tools/handlers/agent-cli/types' + +/** + * The registry of agent-only augmentations, longest prefix wins. Adding a + * capability = one command object here; it appears in the merged --help + * automatically. + */ +const AGENT_CLI_COMMANDS: readonly AgentCliCommand[] = [ + workflowBlocksCommand, + workflowEdgesCommand, + workflowGrepCommand, + workflowsGrepCommand, +] + +/** Global flags (with values) that may precede the subcommand, e.g. --output json. */ +const VALUE_FLAGS = new Set(['--output', '-o']) + +/** Strips global flags (and their values) so matching sees bare command tokens. */ +function commandTokens(args: string[]): string[] { + const tokens: string[] = [] + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg.startsWith('-')) { + if (VALUE_FLAGS.has(arg)) i++ + continue + } + tokens.push(arg) + } + return tokens +} + +export function matchAgentCliCommand( + args: string[] +): { command: AgentCliCommand; rest: string[] } | null { + const tokens = commandTokens(args) + let best: { command: AgentCliCommand; rest: string[] } | null = null + for (const command of AGENT_CLI_COMMANDS) { + const matches = + tokens.length >= command.path.length && + command.path.every((part, index) => tokens[index] === part) + if (matches && (!best || command.path.length > best.command.path.length)) { + best = { command, rest: tokens.slice(command.path.length) } + } + } + return best +} + +export async function executeAgentCliCommand( + match: { command: AgentCliCommand; rest: string[] }, + runtime: AgentCliRuntime +): Promise { + try { + return await match.command.execute(match.rest, runtime) + } catch (error) { + return agentCliFail(error instanceof Error ? error.message : String(error)) + } +} + +/** True for a bare help invocation whose output should include the agent section. */ +export function isRootHelpInvocation(args: string[]): boolean { + const meaningful = args.filter((a) => a !== '--output' && a !== 'json' && a !== '-o') + return ( + meaningful.length === 0 || + (meaningful.length === 1 && (meaningful[0] === 'help' || meaningful[0] === '--help')) + ) +} + +/** The section appended to the real CLI's root help. */ +export function agentCliHelpSection(): string { + const lines = AGENT_CLI_COMMANDS.map( + (command) => ` ${command.usage.padEnd(38)} ${command.summary}` + ) + return `\nAgent commands (available in this environment only):\n${lines.join('\n')}\n` +} diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/types.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/types.ts new file mode 100644 index 00000000000..6eef6af1c33 --- /dev/null +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/types.ts @@ -0,0 +1,45 @@ +/** + * Agent-only CLI augmentations: commands the mothership agent sees alongside + * the real Sim CLI, exposing views the product CLI has no reason to carry + * (workflow-scoped grep, edges/blocks projections, cross-workflow search). + * + * Each augmentation reuses the v2 surface through the CLI's own typed client — + * same identity, same authorization — and transforms typed responses. It never + * re-parses rendered CLI output, and it never grows a new data-access path: + * anything v2 cannot answer gets an internal application call added here, not + * a v2 change. + */ + +/** The one client capability augmentations use; SimClient satisfies it structurally. */ +export interface AgentCliClient { + request(path: string, options?: { query?: Record }): Promise +} + +export interface AgentCliRuntime { + client: AgentCliClient + workspaceId: string +} + +export interface AgentCliResult { + exitCode: number + stdout: string + stderr: string +} + +export interface AgentCliCommand { + /** argv tokens that select this command, matched as a prefix (e.g. ['workflow', 'edges']). */ + path: readonly string[] + /** One line for the merged --help section. */ + summary: string + /** Full usage line, e.g. 'workflow edges '. */ + usage: string + execute(rest: string[], runtime: AgentCliRuntime): Promise +} + +export function agentCliOk(stdout: string): AgentCliResult { + return { exitCode: 0, stdout, stderr: '' } +} + +export function agentCliFail(message: string): AgentCliResult { + return { exitCode: 1, stdout: '', stderr: `Error: ${message}` } +} diff --git a/apps/sim/lib/mothership/tools/handlers/function-execute.ts b/apps/sim/lib/mothership/tools/handlers/function-execute.ts new file mode 100644 index 00000000000..f03a90f7986 --- /dev/null +++ b/apps/sim/lib/mothership/tools/handlers/function-execute.ts @@ -0,0 +1,718 @@ +import type { Principal } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { omit } from '@sim/utils/object' +import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' +import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import type { PrivateSecretProvenanceBundleV1 } from '@/lib/execution/model-input-provenance' +import { + MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, + PRIVATE_SECRET_PROVENANCE_FIELD, +} from '@/lib/execution/private-tool-metadata' +import type { SandboxFile } from '@/lib/execution/remote-sandbox/types' +import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/workspace-sandboxes' +import { + createSandboxMountBudget, + MAX_INLINE_MOUNT_FILE_BYTES, + MAX_INLINE_MOUNT_TOTAL_BYTES, + MAX_TOTAL_URL_BYTES, + MOUNT_URL_TTL_SECONDS, + pushSandboxFileMount, + type SandboxMountBudget, +} from '@/lib/function-execution/sandbox-mounts' +import { resolveCopilotFilePrincipal } from '@/lib/mothership/auth/file-delegation' +import { applySecretMountPolicy } from '@/lib/mothership/secret-mount-policy' +import type { + ToolExecutionContext, + ToolExecutionResult, +} from '@/lib/mothership/tool-executor/types' +import { + CopilotCodeSecretAccessError, + type MaterializedCopilotCodeSecrets, + materializeCopilotCodeSecrets, +} from '@/lib/mothership/tools/secret-mount-materializer.server' +import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/mothership/vfs/path-utils' +import { recordSecretUsage } from '@/lib/secrets/usage/record' +import { getTableSnapshotModelMountSafety } from '@/lib/table/rows/secret-provenance' +import { getTableById, listTables } from '@/lib/table/service' +import { getOrCreateTableSnapshot, SNAPSHOT_MAX_BYTES } from '@/lib/table/snapshot-cache' +import { + findWorkspaceFileRecord, + getSandboxWorkspaceFilePath, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { importWorkspaceFileSecretProvenanceForRuntime } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { + downloadFile, + generatePresignedDownloadUrl, + hasCloudStorage, +} from '@/lib/uploads/core/storage-service' +import { isGeneratedDocumentSourceType } from '@/lib/uploads/utils/file-utils' +import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { downloadWorkspaceFileRecord } from '@/lib/workspace-files/application/read-workspace-file-record' +import { listWorkspaceFileFoldersOperation } from '@/lib/workspace-files/application/workspace-file-folders' +import { + buildWorkspaceFileFolderDisplayPath, + parseWorkspaceFileFolderDisplayPath, +} from '@/lib/workspace-files/folder-display-path' +import { extractCodeSecretNames } from '@/executor/utils/code-secret-references' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +import { executeTool as executeAppTool } from '@/tools' + +const logger = createLogger('CopilotFunctionExecute') + +const MAX_FILE_SIZE = MAX_INLINE_MOUNT_FILE_BYTES +const MAX_TOTAL_SIZE = MAX_INLINE_MOUNT_TOTAL_BYTES +const MAX_MOUNTED_FILES = 500 + +async function importMountedWorkspaceFileProvenance(args: { + workspaceId: string + record: WorkspaceFileRecord + mountPath: string + registry?: ResolvedSecretTraceRegistry +}): Promise { + if (!args.registry) { + throw new Error( + `Input file "${args.mountPath}" cannot be mounted because its secret provenance is unavailable.` + ) + } + try { + const imported = await importWorkspaceFileSecretProvenanceForRuntime({ + workspaceId: args.workspaceId, + identity: { + fileId: args.record.id, + key: args.record.key, + context: args.record.storageContext ?? 'workspace', + }, + registry: args.registry, + }) + if (!imported) args.registry.markIncomplete('mounted-file-provenance-unavailable') + } catch { + args.registry.markIncomplete('mounted-file-provenance-unavailable') + } +} + +/** + * Mounts a stored workspace file into the sandbox. The transport choice, the byte + * ceilings, and the budget accounting live in {@link pushSandboxFileMount}, which + * the Function block shares; what stays here is workspace-specific — reloading the + * record through its application operation, importing its secret provenance, and + * reading generated documents through the servable reader rather than presigning + * their generator source. + */ +async function pushWorkspaceFileMount( + sandboxFiles: SandboxFile[], + record: WorkspaceFileRecord, + mountPath: string, + mounted: SandboxMountBudget, + workspaceId: string, + principal: Principal, + registry?: ResolvedSecretTraceRegistry +): Promise { + record = ( + await downloadWorkspaceFileRecord.execute({ + principal, + input: { fileId: record.id, assertedWorkspaceId: workspaceId }, + }) + ).file + await importMountedWorkspaceFileProvenance({ workspaceId, record, mountPath, registry }) + + // A generated document stores its generator source, so a presigned URL for + // `record.key` would hand the sandbox source text under a `.docx` name and the + // user's script would fail on a file that looks fine. Those resolve through the + // servable reader instead — they are bounded by the render ceiling, so routing them + // through the web process rather than presigning is affordable. + const rendersFromSource = isGeneratedDocumentSourceType(record.type) + + await pushSandboxFileMount( + sandboxFiles, + { + mountPath, + key: record.key, + storageContext: record.storageContext ?? 'workspace', + declaredSize: record.size, + rendersFromSource, + readInline: async (maxBytes) => { + const { buffer, contentType } = rendersFromSource + ? await fetchAuthorizedServableWorkspaceFileBuffer(record, principal, { + maxBytes, + }).catch((error) => { + if (!isPayloadSizeLimitError(error)) throw error + throw new Error( + `Input file "${mountPath}" renders to more than the ${MAX_FILE_SIZE / 1024 / 1024}MB per-file mount limit, or than the mount budget left. Mount fewer or smaller files.` + ) + }) + : { + buffer: ( + await readWorkspaceFileContent.execute({ + principal, + input: { + fileId: record.id, + assertedWorkspaceId: workspaceId, + maxBytes, + }, + }) + ).content, + contentType: record.type, + } + // Keyed off the resolved type: a rendered document's source MIME is `text/x-…`, and + // decoding the binary as UTF-8 would corrupt it just as surely as shipping the source. + const isText = /^text\/|application\/json|application\/xml|application\/csv/.test( + contentType || '' + ) + return { + content: isText ? buffer.toString('utf-8') : buffer.toString('base64'), + ...(isText ? {} : { encoding: 'base64' as const }), + byteLength: buffer.length, + } + }, + }, + mounted + ) +} + +/** + * Explains why a VFS path the agent legitimately discovered cannot be mounted, and + * what to do instead. Only workspace `files/` are backed by storage the sandbox can + * fetch from — `internal/` is served by the copilot backend and its bytes never reach + * Sim, `uploads/` is chat-scoped, `recently-deleted/` is archived, and the remaining + * namespaces are metadata views rather than stored file bytes. Returns null for + * `files/` references, where "not found" is the honest answer. + * + * These paths are correct and are advertised to the model as read/grep-able, so the + * generic not-found message ("copy the exact canonical path") is actively wrong for + * them: it sends the agent hunting for a path that does not exist. + */ +function unmountableNamespaceReason(filePath: string): string | null { + // Trailing slash so a bare namespace passed as a directory matches the same prefixes + // as a file path inside it. + const path = `${filePath.replace(/^\/+|\/+$/g, '')}/` + + if (path.startsWith('uploads/')) { + return 'uploads/ files are not mountable into the sandbox. Use save_upload to save it to a files/... path first, then mount that canonical path.' + } + if (path.startsWith('internal/tool-results/')) { + return 'tool-result artifacts are stored by the copilot backend, not in workspace storage, so read and grep reach them but the sandbox cannot. This path is correct — searching for a different one will not find anything. Either read or grep the artifact and inline the values you need in code, or re-run the tool that produced it with an output path under files/ (run_function: outputs.files[].path, user_table: outputPath) and mount that files/... path.' + } + if (path.startsWith('internal/')) { + return 'internal/ paths are served by the copilot backend, not from workspace storage, so read and grep reach them but the sandbox cannot. This path is correct — read or grep it and inline the values you need in code instead of mounting it.' + } + if (path.startsWith('recently-deleted/')) { + return 'deleted resources are not mountable into the sandbox. Use restore_resource to restore it first, then mount the restored files/... path.' + } + if (path.startsWith('tables/')) { + return 'tables are not mounted as files. Pass the table in inputs.tables instead and it is mounted as CSV.' + } + const namespace = /^(workflows|knowledgebases|components|environment|agent)\//.exec(path)?.[1] + if (namespace) { + return `${namespace}/ paths are VFS metadata views, not stored file bytes, so the sandbox cannot mount them. This path is correct — read or grep it and inline the values you need in code.` + } + return null +} + +interface CanonicalFileInput { + path: string + sandboxPath?: string +} + +interface CanonicalDirectoryInput { + path: string + sandboxPath?: string +} + +interface CanonicalTableInput { + tableId?: string + path?: string + sandboxPath?: string +} + +function tableNameFromVfsPath(tableRef: string): string | null { + if (!tableRef.startsWith('tables/')) return null + const segments = decodeVfsPathSegments(tableRef) + const metaIndex = segments.lastIndexOf('meta.json') + return segments[metaIndex > 0 ? metaIndex - 1 : segments.length - 1] ?? null +} + +async function resolveTableRef( + tableRef: string, + tablePathLookup?: Map>[number]> +) { + if (!tableRef.startsWith('tables/')) { + return getTableById(tableRef) + } + + const tableName = tableNameFromVfsPath(tableRef) + if (!tableName) return null + return tablePathLookup?.get(tableName) ?? null +} + +export async function resolveInputFiles( + workspaceId: string, + inputFiles?: unknown[], + inputTables?: unknown[], + inputDirectories?: unknown[], + resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry, + filePrincipal?: Principal +): Promise { + const sandboxFiles: SandboxFile[] = [] + const mounted = createSandboxMountBudget() + + if (inputFiles?.length && workspaceId) { + if (!filePrincipal) { + throw new Error('Workspace file mounts require a trusted Copilot principal') + } + if (inputFiles.length > MAX_MOUNTED_FILES) { + throw new Error( + `Too many input files (${inputFiles.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount fewer files.` + ) + } + const { files: allFiles } = await listAllWorkspaceFiles.execute({ + principal: filePrincipal, + input: { workspaceId, scope: 'active' }, + }) + for (const fileRef of inputFiles) { + const filePath = + typeof fileRef === 'string' + ? fileRef + : fileRef && typeof fileRef === 'object' + ? (fileRef as CanonicalFileInput).path + : undefined + if (!filePath) continue + const record = findWorkspaceFileRecord(allFiles, filePath) + if (!record) { + const unmountable = unmountableNamespaceReason(filePath) + if (unmountable) { + throw new Error(`Cannot mount "${filePath}": ${unmountable}`) + } + throw new Error( + `Input file not found: "${filePath}". Pass the exact canonical VFS path copied from glob/read (e.g. "files/Reports/data.csv").` + ) + } + const explicitSandboxPath = + typeof fileRef === 'object' && fileRef !== null + ? (fileRef as CanonicalFileInput).sandboxPath + : undefined + const mountPath = explicitSandboxPath || getSandboxWorkspaceFilePath(record) + await pushWorkspaceFileMount( + sandboxFiles, + record, + mountPath, + mounted, + workspaceId, + filePrincipal, + resolvedSecretTraceRegistry + ) + } + } + + if (inputDirectories?.length && workspaceId) { + if (!filePrincipal) { + throw new Error('Workspace directory mounts require a trusted Copilot principal') + } + const { folders } = await listWorkspaceFileFoldersOperation.execute({ + principal: filePrincipal, + input: { workspaceId }, + }) + const { files: allFiles } = await listAllWorkspaceFiles.execute({ + principal: filePrincipal, + input: { workspaceId, scope: 'active' }, + }) + for (const dirRef of inputDirectories) { + const dirPath = + typeof dirRef === 'string' + ? dirRef + : dirRef && typeof dirRef === 'object' + ? (dirRef as CanonicalDirectoryInput).path + : undefined + if (!dirPath) continue + const folderSegments = decodeVfsPathSegments(dirPath.replace(/^\/?files\/?/, '')) + const folderDisplayPath = buildWorkspaceFileFolderDisplayPath(folderSegments) + const folder = folders.find((candidate) => candidate.path === folderDisplayPath) + if (!folder) { + const unmountable = unmountableNamespaceReason(dirPath) + throw new Error( + unmountable + ? `Cannot mount "${dirPath}": ${unmountable}` + : `Input directory not found: "${dirPath}". Pass a canonical workspace folder path copied from glob/read (e.g. "files/Reports").` + ) + } + const mountRoot = + typeof dirRef === 'object' && + dirRef !== null && + (dirRef as CanonicalDirectoryInput).sandboxPath + ? (dirRef as CanonicalDirectoryInput).sandboxPath! + : `/home/user/files/${encodeVfsPathSegments(parseWorkspaceFileFolderDisplayPath(folder.path))}` + const descendants = allFiles.filter((file) => { + if (!file.folderPath) return false + return file.folderPath === folder.path || file.folderPath.startsWith(`${folder.path}/`) + }) + if (descendants.length > MAX_MOUNTED_FILES) { + throw new Error( + `Input directory contains too many files (${descendants.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount a smaller directory or individual files.` + ) + } + logger.info('Mounting workspace directory for run_function', { + vfsPath: dirPath, + sandboxPath: mountRoot, + fileCount: descendants.length, + }) + const childFolders = folders.filter( + (candidate) => + candidate.path !== folder.path && candidate.path.startsWith(`${folder.path}/`) + ) + if (descendants.length === 0 && childFolders.length === 0) { + sandboxFiles.push({ path: `${mountRoot}/.keep`, content: '' }) + continue + } + for (const childFolder of childFolders) { + const hasFiles = descendants.some((file) => { + if (!file.folderPath) return false + return ( + file.folderPath === childFolder.path || + file.folderPath.startsWith(`${childFolder.path}/`) + ) + }) + if (!hasFiles) { + const relativeFolder = childFolder.path.slice(folder.path.length).replace(/^\/+/, '') + sandboxFiles.push({ path: `${mountRoot}/${relativeFolder}/.keep`, content: '' }) + } + } + for (const record of descendants) { + const relativeFolder = + record.folderPath?.slice(folder.path.length).replace(/^\/+/, '') ?? '' + const relativePath = [relativeFolder, record.name].filter(Boolean).join('/') + await pushWorkspaceFileMount( + sandboxFiles, + record, + `${mountRoot}/${relativePath}`, + mounted, + workspaceId, + filePrincipal, + resolvedSecretTraceRegistry + ) + } + } + } + + if (inputTables?.length) { + const hasTablePathRefs = inputTables.some((tableRef) => { + const tableId = + typeof tableRef === 'string' + ? tableRef + : tableRef && typeof tableRef === 'object' + ? (tableRef as CanonicalTableInput).tableId || (tableRef as CanonicalTableInput).path + : undefined + return typeof tableId === 'string' && tableId.startsWith('tables/') + }) + const tablePathLookup = hasTablePathRefs + ? new Map((await listTables(workspaceId)).map((table) => [table.name, table])) + : undefined + for (const tableRef of inputTables) { + const tableId = + typeof tableRef === 'string' + ? tableRef + : tableRef && typeof tableRef === 'object' + ? (tableRef as CanonicalTableInput).tableId || (tableRef as CanonicalTableInput).path + : undefined + if (!tableId) continue + const table = await resolveTableRef(tableId, tablePathLookup) + if (!table || table.workspaceId !== workspaceId) { + throw new Error( + `Input table not found: "${tableId}". Pass the table id (tbl_...) from tables/{name}/meta.json, or a tables/{name}/meta.json path.` + ) + } + const sandboxPath = + typeof tableRef === 'object' && tableRef !== null + ? (tableRef as CanonicalTableInput).sandboxPath + : undefined + const mountPath = sandboxPath || `/home/user/tables/${table.id}.csv` + + const snapshot = await getOrCreateTableSnapshot(table, 'copilot-fn-exec') + if (!resolvedSecretTraceRegistry) { + throw new Error( + `Input table "${tableId}" cannot be mounted because its secret provenance is unavailable.` + ) + } + const mountSafety = await getTableSnapshotModelMountSafety({ + tableId: table.id, + workspaceId, + rowsVersion: snapshot.version, + }) + if (mountSafety === 'stale') { + throw new Error(`Input table "${tableId}" changed while preparing its snapshot. Retry.`) + } + if (mountSafety === 'unsafe-provenance') { + resolvedSecretTraceRegistry.markIncomplete('table-snapshot-unsafe-for-mount') + } + + if (hasCloudStorage()) { + if (snapshot.size > SNAPSHOT_MAX_BYTES) { + throw new Error( + `Input table "${tableId}" is ${Math.round(snapshot.size / 1024 / 1024)}MB, over the ${SNAPSHOT_MAX_BYTES / 1024 / 1024}MB table mount limit.` + ) + } + if (mounted.url + snapshot.size > MAX_TOTAL_URL_BYTES) { + throw new Error( + `Mounting "${tableId}" would exceed the ${MAX_TOTAL_URL_BYTES / 1024 / 1024 / 1024}GB total mount limit. Mount fewer or smaller files and tables.` + ) + } + const url = await generatePresignedDownloadUrl( + snapshot.key, + 'execution', + MOUNT_URL_TTL_SECONDS + ) + sandboxFiles.push({ type: 'url', path: mountPath, url, maxBytes: SNAPSHOT_MAX_BYTES }) + mounted.url += snapshot.size + continue + } + + // Local storage: a presigned URL is an app-internal serve path a remote sandbox can't + // reach, so fall back to buffering the bytes through the web process (file-mount guards). + if (snapshot.size > MAX_FILE_SIZE) { + throw new Error( + `Input table "${tableId}" is ${Math.round(snapshot.size / 1024 / 1024)}MB, over the ${MAX_FILE_SIZE / 1024 / 1024}MB per-file mount limit.` + ) + } + if (mounted.buffered + snapshot.size > MAX_TOTAL_SIZE) { + throw new Error( + `Mounting "${tableId}" would exceed the ${MAX_TOTAL_SIZE / 1024 / 1024}MB total mount limit. Mount fewer or smaller tables.` + ) + } + const buffer = await downloadFile({ + key: snapshot.key, + context: 'execution', + maxBytes: MAX_FILE_SIZE, + }) + mounted.buffered += buffer.length + sandboxFiles.push({ path: mountPath, content: buffer.toString('utf-8') }) + } + } + + return sandboxFiles +} + +async function importMountedProvenance( + source: ResolvedSecretTraceRegistry, + target: ResolvedSecretTraceRegistry | undefined, + crossingValue: unknown +): Promise { + if (!target) return + + try { + const provenance = source.exportProvenanceForValue(crossingValue) + const imported = await target.importCrossingProvenance(provenance, crossingValue, { + origin: 'copilotFunctionExecute.crossing', + trusted: true, + }) + if (!imported) + target.markIncomplete('value-provenance-import-failed', { + origin: 'copilotFunctionExecute.crossing', + }) + } catch { + target.markIncomplete('value-provenance-import-failed', { + origin: 'copilotFunctionExecute.crossing', + }) + } +} + +export async function executeFunctionExecute( + params: Record, + context: ToolExecutionContext +): Promise { + const enrichedParams = omit(params, [ + 'sandboxProfile', + 'internalSandboxProfile', + PRIVATE_SECRET_PROVENANCE_FIELD, + ]) + // The copilot tool doc promises `timeout` in SECONDS ("Sim converts to + // milliseconds", default 10, cap 300); the underlying function tool takes + // MILLISECONDS. Nothing converted, so `timeout: 120` armed a 120ms abort. + // Values ≤ 600 are read as seconds; larger values are assumed to already be + // milliseconds (a model habit worth tolerating). Both clamp to the 300s cap. + if (typeof enrichedParams.timeout === 'number' && Number.isFinite(enrichedParams.timeout)) { + const raw = enrichedParams.timeout + const ms = raw <= 600 ? raw * 1000 : raw + enrichedParams.timeout = Math.min(Math.max(ms, 1000), 300_000) + } + if (params.sandboxId !== undefined) { + if (typeof params.sandboxId !== 'string' || !params.sandboxId.trim()) { + throw new Error('sandboxId must be a non-empty Sim sandbox id') + } + if (!context.workspaceId) { + throw new Error('A workspace is required to select a Sim sandbox') + } + if (!(await hasWorkspaceSandboxAccess(context.workspaceId))) { + throw new Error(MAX_PLAN_REQUIRED) + } + enrichedParams.sandboxId = params.sandboxId.trim() + } + const requestedNames = applySecretMountPolicy( + await extractCodeSecretNames(params.code, params.language), + context.secretMountPolicy + ) + const completePendingActivation = + requestedNames.length > 0 + ? context.resolvedSecretTraceRegistry?.beginPendingActivation() + : undefined + let mountedRegistry: ResolvedSecretTraceRegistry | undefined + let crossingValue: unknown + + /** + * Hoisted so the usage trail in `finally` attributes the run to the same identity the mount + * authorized against. Deriving it a second time down there let the two disagree whenever + * `secretActorUserId` was explicitly null. + */ + const secretActorUserId = + context.secretActorUserId === undefined ? context.userId : context.secretActorUserId + + try { + let mounted: MaterializedCopilotCodeSecrets = { envVars: {}, catalogEntries: [] } + if (requestedNames.length > 0) { + if (!secretActorUserId) { + throw new CopilotCodeSecretAccessError('Secret access is unavailable for this Copilot run') + } + if (!context.workspaceId) { + throw new CopilotCodeSecretAccessError( + 'A workspace is required to mount secrets into Copilot code' + ) + } + mounted = await materializeCopilotCodeSecrets({ + actorUserId: secretActorUserId, + workspaceId: context.workspaceId, + requestedNames, + }) + } + mountedRegistry = new ResolvedSecretTraceRegistry(mounted.catalogEntries, { + userId: secretActorUserId ?? context.userId, + ...(context.workspaceId ? { workspaceId: context.workspaceId } : {}), + }) + + enrichedParams.envVars = mounted.envVars + enrichedParams.secretScope = 'selected' + enrichedParams.mountedSecrets = requestedNames + /** + * Certified by the mounted registry rather than read off the raw materializer entries, so + * a mounted secret sharing its plaintext with a protected one is withheld from the route. + */ + const unredactedSecretNames = mountedRegistry.getUnredactedSecretNames() + if (unredactedSecretNames.length > 0) { + enrichedParams.unredactedSecretNames = unredactedSecretNames + } + + if (context.workspaceId) { + const inputs = enrichedParams.inputs as + | { + files?: CanonicalFileInput[] + directories?: CanonicalDirectoryInput[] + tables?: CanonicalTableInput[] + } + | undefined + const inputFiles = [ + ...((enrichedParams.inputFiles as unknown[] | undefined) ?? []), + ...(inputs?.files ?? []), + ] + const inputDirectories = inputs?.directories ?? [] + const inputTables = [ + ...((enrichedParams.inputTables as unknown[] | undefined) ?? []), + ...(inputs?.tables ?? []), + ] + + if (inputFiles?.length || inputTables?.length || inputDirectories.length) { + const resolved = await resolveInputFiles( + context.workspaceId, + inputFiles, + inputTables, + inputDirectories, + mountedRegistry, + inputFiles.length > 0 || inputDirectories.length > 0 + ? resolveCopilotFilePrincipal(context) + : undefined + ) + if (resolved.length > 0) { + const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || [] + enrichedParams._sandboxFiles = [...existing, ...resolved] + + const provenance = mountedRegistry.exportProvenance() + const bundle: PrivateSecretProvenanceBundleV1 = { + version: 1, + complete: provenance.complete, + selections: provenance.complete + ? [{ key: MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY, provenance }] + : [], + } + enrichedParams[PRIVATE_SECRET_PROVENANCE_FIELD] = bundle + } + } + } + + enrichedParams._context = { + userId: context.userId, + workflowId: context.workflowId, + workspaceId: context.workspaceId, + chatId: context.chatId, + executionId: context.executionId, + runId: context.runId, + enforceCredentialAccess: true, + } + + try { + /** + * The copilot-facing tool is named `run_function`, but the app-tool + * registry id stays `function_execute` — the validator in tools/index.ts + * only admits `internalSandboxProfile` for that id, and every copilot + * call carries the internal `mothership` profile. Renaming this inner id + * without renaming the registry breaks every copilot sandbox call with + * "An internal sandbox profile may only be used with function_execute". + */ + const result = await executeAppTool('function_execute', enrichedParams, { + resolvedSecretTraceRegistry: mountedRegistry, + operationContext: { + userId: context.userId, + workflowId: context.workflowId, + workspaceId: context.workspaceId, + executionId: context.executionId, + executorDelegationOrigin: { + subjectUserId: context.userId, + workflowId: context.workflowId, + ...(context.executionId ? { executionId: context.executionId } : {}), + }, + copilotToolExecution: context.copilotToolExecution, + billingAttribution: context.billingAttribution, + resolvedSecretTraceRegistry: mountedRegistry, + }, + ...(context.abortSignal ? { signal: context.abortSignal } : {}), + ...(context.sandboxProfile ? { internalSandboxProfile: context.sandboxProfile } : {}), + }) + crossingValue = result + return result + } catch (error) { + crossingValue = error + throw error + } + } finally { + if (mountedRegistry && crossingValue !== undefined) { + await importMountedProvenance( + mountedRegistry, + context.resolvedSecretTraceRegistry, + crossingValue + ) + } + /** + * Copilot-run code is a real read of a workspace secret and has to appear in the trail; + * without this an admin reviewing a secret sees "never used" for one someone read through + * Mothership. Read from the registry rather than `requestedNames` so only names the code + * actually resolved are counted. The headless inbox runner reaches the same handler, so + * it is covered here too. + */ + if (mountedRegistry && context.workspaceId) { + recordSecretUsage(mountedRegistry.getResolvedSecretUsage(), { + workspaceId: context.workspaceId, + source: 'copilot', + actorUserId: secretActorUserId ?? null, + trigger: 'copilot', + }) + } + completePendingActivation?.() + } +} diff --git a/apps/sim/lib/mothership/tools/handlers/run-code.ts b/apps/sim/lib/mothership/tools/handlers/run-code.ts new file mode 100644 index 00000000000..b22457a97aa --- /dev/null +++ b/apps/sim/lib/mothership/tools/handlers/run-code.ts @@ -0,0 +1,34 @@ +import type { + ToolExecutionContext, + ToolExecutionResult, +} from '@/lib/mothership/tool-executor/types' +import { executeFunctionExecute } from '@/lib/mothership/tools/handlers/function-execute' + +/** + * Compute-only variant of run_function for info-gathering agents: same + * sandbox and inputs, but it must never create or overwrite workspace + * resources. The write vectors (outputs.files, outputTable) are rejected here + * on top of the Go executor's fail-fast guard; run_code is also absent from + * the name-gated output post-processors (OUTPUT_PATH_TOOLS etc.), so even a + * leaked arg could not write anything. + */ +export async function executeRunCode( + params: Record, + context: ToolExecutionContext +): Promise { + if ('outputs' in params) { + return { + success: false, + error: + 'run_code is compute-only: outputs (workspace file writes) is not available; return the data and report it instead', + } + } + if ('outputTable' in params) { + return { + success: false, + error: + 'run_code is compute-only: outputTable (workspace table overwrite) is not available; return the data and report it instead', + } + } + return executeFunctionExecute(params, context) +} diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts new file mode 100644 index 00000000000..242ee9106bc --- /dev/null +++ b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts @@ -0,0 +1,80 @@ +import { createLogger } from '@sim/logger' +import { createEmbeddedClient, type EmbeddedCliIdentity, runEmbeddedCli } from 'sim/embed' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { mintDelegationToken } from '@/lib/mothership/chat/delegation' +import type { + ToolExecutionContext, + ToolExecutionResult, +} from '@/lib/mothership/tool-executor/types' +import { + agentCliHelpSection, + executeAgentCliCommand, + isRootHelpInvocation, + matchAgentCliCommand, +} from '@/lib/mothership/tools/handlers/agent-cli' + +const logger = createLogger('MothershipSimCli') + +/** + * Executes one Sim CLI invocation in-process — the worker's `sim_cli` tool + * defers here instead of spawning a CLI binary in its own container. + * + * Routing: agent-only augmentations (agent-cli/) intercept first and answer + * from typed v2 calls; everything else runs through the installed CLI's own + * command tree (`sim/embed`) against this deployment's internal API base. Both + * lanes share one server-minted delegation identity for the calling user, so + * "the agent is the user" holds without any credential crossing to the worker. + * Root --help merges the real CLI's help with the agent-command section. + */ +export async function executeSimCli( + params: Record, + context: ToolExecutionContext +): Promise { + const args = params.args + if (!Array.isArray(args) || args.length === 0 || !args.every((a) => typeof a === 'string')) { + return { success: false, error: 'sim_cli requires args: a non-empty array of argv tokens.' } + } + if (!context.workspaceId) { + return { success: false, error: 'sim_cli requires a workspace-scoped execution context.' } + } + + const apiKey = await mintDelegationToken({ + workspaceId: context.workspaceId, + userId: context.userId, + }) + if (!apiKey) { + return { success: false, error: 'Could not establish workspace credentials for this command.' } + } + const identity: EmbeddedCliIdentity = { + endpoint: getInternalApiBaseUrl(), + apiKey, + workspaceId: context.workspaceId, + } + + const agentMatch = matchAgentCliCommand(args) + const result = agentMatch + ? await executeAgentCliCommand(agentMatch, { + client: createEmbeddedClient(identity), + workspaceId: context.workspaceId, + }) + : await runEmbeddedCli(args, identity) + if (!agentMatch && isRootHelpInvocation(args) && result.exitCode === 0) { + result.stdout += agentCliHelpSection() + } + + logger.info('CLI invocation finished', { + exitCode: result.exitCode, + argv0: args[0], + lane: agentMatch ? 'agent' : 'cli', + stdoutBytes: result.stdout.length, + }) + // The worker folds exitCode/stdout/stderr into the model window and applies + // its own output capping; success here means only "the invocation ran". + return { + success: result.exitCode === 0, + output: { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }, + ...(result.exitCode === 0 + ? {} + : { error: result.stderr.split('\n')[0] || `sim CLI exited with code ${result.exitCode}` }), + } +} diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 025d62e0edc..99dfe04dfc2 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -247,6 +247,7 @@ const nextConfig: NextConfig = { transpilePackages: [ '@react-email/components', '@react-email/render', + 'sim', '@t3-oss/env-nextjs', '@t3-oss/env-core', '@sim/db', diff --git a/apps/sim/package.json b/apps/sim/package.json index aab01234f88..1c130c207ed 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -241,6 +241,7 @@ "resend": "^4.1.2", "rss-parser": "3.13.0", "sharp": "0.35.4", + "sim": "workspace:*", "socket.io-client": "4.8.1", "ssh2": "^1.17.0", "streamdown": "2.5.0", diff --git a/bun.lock b/bun.lock index de143d0ddf5..7a63cea0e67 100644 --- a/bun.lock +++ b/bun.lock @@ -363,6 +363,7 @@ "resend": "^4.1.2", "rss-parser": "3.13.0", "sharp": "0.35.4", + "sim": "workspace:*", "socket.io-client": "4.8.1", "ssh2": "^1.17.0", "streamdown": "2.5.0", diff --git a/packages/sim-cli/package.json b/packages/sim-cli/package.json index c6ec88be5f1..e312c76c065 100644 --- a/packages/sim-cli/package.json +++ b/packages/sim-cli/package.json @@ -63,5 +63,11 @@ "@types/proper-lockfile": "4.1.4", "typescript": "^7.0.2", "vitest": "^4.1.0" + }, + "exports": { + "./embed": { + "types": "./src/embed.ts", + "default": "./src/embed.ts" + } } } diff --git a/packages/sim-cli/src/config/profile.ts b/packages/sim-cli/src/config/profile.ts index 14f86a60c90..9fa36158803 100644 --- a/packages/sim-cli/src/config/profile.ts +++ b/packages/sim-cli/src/config/profile.ts @@ -11,6 +11,7 @@ import { } from 'node:fs' import { dirname } from 'node:path' import { lock } from 'proper-lockfile' +import { embeddedProfile } from '../embed-context' import { FORBIDDEN_IN_VALUE, getSection, @@ -703,6 +704,11 @@ function refuseBlankOverrides(overrides: ProfileOverrides): void { } export function resolveProfile(overrides: ProfileOverrides = {}): ResolvedProfile { + // An embedded (in-process, server-hosted) run carries its full identity in its + // async context — the host already authenticated the caller and scoped the + // workspace, so profiles, env vars, and config files never apply there. + const embedded = embeddedProfile() + if (embedded) return embedded refuseBlankOverrides(overrides) const named = overrides.profile || process.env.SIM_PROFILE diff --git a/packages/sim-cli/src/embed-context.ts b/packages/sim-cli/src/embed-context.ts new file mode 100644 index 00000000000..697ed5da8d8 --- /dev/null +++ b/packages/sim-cli/src/embed-context.ts @@ -0,0 +1,109 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import type { ResolvedProfile } from './config/profile' + +/** + * The async-context plumbing for embedded (in-process) CLI runs, split from + * `embed.ts` so `config/profile.ts` can consult it without a runtime import + * cycle (this module imports nothing from the CLI beyond a type). + */ + +export interface EmbeddedCliIdentity { + endpoint: string + apiKey: string + workspaceId?: string +} + +export interface EmbedContext { + identity: EmbeddedCliIdentity + stdout: string[] + stderr: string[] +} + +export const embedStore = new AsyncLocalStorage() + +/** Thrown in place of process.exit inside an embedded run. */ +export class EmbeddedExit extends Error { + constructor(readonly code: number) { + super(`CLI exited with code ${code}`) + } +} + +/** + * The profile resolver consults this before touching env or config files: an + * embedded run's identity comes entirely from the hosting server (it already + * authenticated the caller and knows the workspace), never from profiles, + * login state, or the host process env. Null outside an embedded run, which + * keeps the installed CLI's behavior byte-identical. + */ +export function embeddedProfile(): ResolvedProfile | null { + const ctx = embedStore.getStore() + if (!ctx) return null + return { + name: 'embedded', + endpoint: ctx.identity.endpoint, + apiKey: ctx.identity.apiKey, + workspaceId: ctx.identity.workspaceId ?? null, + output: 'json', + sources: { endpoint: 'flag', apiKey: 'flag', workspaceId: 'flag', output: 'flag' }, + } +} + +let sinksInstalled = false + +/** + * Output and process.exit shims, installed once, active only inside an embedded + * run's async context. The CLI renders through console.log/error, commander and + * chalk write straight to process.stdout/stderr, and a few commands exit + * directly; all of it must land in the embed result instead of the host + * server's stdout (or worse, the host process's lifetime). + */ +export function installEmbedSinks(): void { + if (sinksInstalled) return + sinksInstalled = true + const originalLog = console.log.bind(console) + const originalError = console.error.bind(console) + const originalExit = process.exit.bind(process) + const originalStdoutWrite = process.stdout.write.bind(process.stdout) + const originalStderrWrite = process.stderr.write.bind(process.stderr) + console.log = (...args: unknown[]) => { + const ctx = embedStore.getStore() + if (ctx) { + ctx.stdout.push(args.map(String).join(' ')) + return + } + originalLog(...args) + } + console.error = (...args: unknown[]) => { + const ctx = embedStore.getStore() + if (ctx) { + ctx.stderr.push(args.map(String).join(' ')) + return + } + originalError(...args) + } + process.stdout.write = ((chunk: string | Uint8Array, ...rest: unknown[]) => { + const ctx = embedStore.getStore() + if (ctx) { + ctx.stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + const callback = rest.find((a) => typeof a === 'function') as (() => void) | undefined + callback?.() + return true + } + return originalStdoutWrite(chunk as never, ...(rest as never[])) + }) as typeof process.stdout.write + process.stderr.write = ((chunk: string | Uint8Array, ...rest: unknown[]) => { + const ctx = embedStore.getStore() + if (ctx) { + ctx.stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) + const callback = rest.find((a) => typeof a === 'function') as (() => void) | undefined + callback?.() + return true + } + return originalStderrWrite(chunk as never, ...(rest as never[])) + }) as typeof process.stderr.write + process.exit = ((code?: number) => { + const ctx = embedStore.getStore() + if (ctx) throw new EmbeddedExit(code ?? 0) + return originalExit(code) + }) as typeof process.exit +} diff --git a/packages/sim-cli/src/embed.test.ts b/packages/sim-cli/src/embed.test.ts new file mode 100644 index 00000000000..f362a456c49 --- /dev/null +++ b/packages/sim-cli/src/embed.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runEmbeddedCli } from './embed' + +const IDENTITY = { + endpoint: 'https://sim.internal.test', + apiKey: 'sk-embedded-test', + workspaceId: 'a2e3ab27-2f9d-4b8a-a2f2-3c47a1b0c9d1', +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('runEmbeddedCli', () => { + it('runs a real command in-process with the injected identity, capturing stdout', async () => { + const seen: { url: string; auth: string | null }[] = [] + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + seen.push({ url, auth: new Headers(init?.headers).get('x-api-key') }) + return jsonResponse({ data: [{ id: 'wf-1', name: 'Email digest' }], nextCursor: null }) + }) + + const result = await runEmbeddedCli(['--output', 'json', 'workflows', 'list'], IDENTITY) + + expect(result.exitCode).toBe(0) + expect(seen.length).toBeGreaterThan(0) + expect(seen[0].url).toContain('https://sim.internal.test/api/v2/workflows') + expect(seen[0].url).toContain(IDENTITY.workspaceId) + expect(seen[0].auth).toBe(IDENTITY.apiKey) + expect(JSON.parse(result.stdout)).toMatchObject([{ id: 'wf-1', name: 'Email digest' }]) + }) + + it('returns a parse error as a rendered failure, never killing the host process', async () => { + const result = await runEmbeddedCli(['no-such-command'], IDENTITY) + expect(result.exitCode).not.toBe(0) + expect(result.stderr.length).toBeGreaterThan(0) + }) + + it('reports an API error the way the terminal CLI does', async () => { + vi.stubGlobal( + 'fetch', + async () => + new Response(JSON.stringify({ error: { message: 'Invalid or expired API key' } }), { + status: 401, + headers: { 'content-type': 'application/json' }, + }) + ) + const result = await runEmbeddedCli(['--output', 'json', 'workflows', 'list'], IDENTITY) + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('Invalid or expired API key') + }) + + it('isolates concurrent invocations (identity and output never interleave)', async () => { + vi.stubGlobal('fetch', async (input: RequestInfo | URL) => { + const url = new URL(String(input)) + // Answer each invocation with its own workspace id so cross-talk is visible. + const workspaceId = url.searchParams.get('workspaceId') ?? 'missing' + await new Promise((resolve) => setTimeout(resolve, workspaceId.endsWith('1') ? 30 : 5)) + return jsonResponse({ data: [{ id: workspaceId, name: workspaceId }], nextCursor: null }) + }) + const wsA = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1' + const wsB = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2' + const [a, b] = await Promise.all([ + runEmbeddedCli(['--output', 'json', 'workflows', 'list'], { ...IDENTITY, workspaceId: wsA }), + runEmbeddedCli(['--output', 'json', 'workflows', 'list'], { ...IDENTITY, workspaceId: wsB }), + ]) + expect(JSON.parse(a.stdout)[0].id).toBe(wsA) + expect(JSON.parse(b.stdout)[0].id).toBe(wsB) + }) +}) diff --git a/packages/sim-cli/src/embed.ts b/packages/sim-cli/src/embed.ts new file mode 100644 index 00000000000..a4d6140510e --- /dev/null +++ b/packages/sim-cli/src/embed.ts @@ -0,0 +1,119 @@ +import { ProfileConfigError } from './config/index' +import { + type EmbedContext, + type EmbeddedCliIdentity, + EmbeddedExit, + embedStore, + installEmbedSinks, +} from './embed-context' +import { + formatApiErrorDetails, + isRequestTimeout, + RAISE_TIMEOUT_HINT, + SimApiError, + SimClient, +} from './http/client' +import { sanitize } from './output/render' +import { buildProgram } from './program' + +/** + * In-process execution of one CLI invocation, for a server that already knows + * who is calling: the caller supplies endpoint + credential + workspace + * directly and the profile machinery (config files, env vars, login state) is + * bypassed entirely. Everything else — command tree, flag parsing, request + * building, output rendering — is the exact code the installed CLI runs, so + * the two surfaces cannot drift. + * + * Concurrency-safe by construction: the identity and the output capture both + * live in an AsyncLocalStorage context, so parallel embedded invocations (and + * any ordinary console logging around them) never interleave. + */ + +export type { EmbeddedCliIdentity } from './embed-context' +export type { ExportWorkflowResponse, ListWorkflowsResponse } from './generated/v2-api' +export { SimClient } from './http/client' + +export interface EmbeddedCliResult { + exitCode: number + stdout: string + stderr: string +} + +/** + * A typed v2 client bound to an embedded identity — for server-side augmentation + * commands that reuse the v2 surface directly instead of re-parsing rendered + * CLI output. Same endpoint/credential semantics as {@link runEmbeddedCli}. + */ +export function createEmbeddedClient(identity: EmbeddedCliIdentity): SimClient { + return new SimClient({ + name: 'embedded', + endpoint: identity.endpoint, + apiKey: identity.apiKey, + workspaceId: identity.workspaceId ?? null, + output: 'json', + sources: { endpoint: 'flag', apiKey: 'flag', workspaceId: 'flag', output: 'flag' }, + }) +} + +/** + * Runs one CLI invocation in-process. `argv` is the token list exactly as the + * terminal would receive it (no leading node/binary tokens). Errors the + * installed CLI would print-and-exit-1 on come back the same way: rendered to + * stderr, exitCode 1 — never thrown. + */ +export async function runEmbeddedCli( + argv: string[], + identity: EmbeddedCliIdentity +): Promise { + installEmbedSinks() + const ctx: EmbedContext = { identity, stdout: [], stderr: [] } + return embedStore.run(ctx, async () => { + let exitCode = 0 + try { + const program = buildProgram() + program.exitOverride() + await program.parseAsync(argv, { from: 'user' }) + if (typeof process.exitCode === 'number' && process.exitCode !== 0) { + // Commands that soft-fail (e.g. a failed run outcome) set process.exitCode + // rather than exiting; surface it, then clear so the host server is untouched. + exitCode = process.exitCode + process.exitCode = 0 + } + } catch (error) { + exitCode = renderEmbeddedError(ctx, error) + } + return { exitCode, stdout: ctx.stdout.join('\n'), stderr: ctx.stderr.join('\n') } + }) +} + +/** Mirrors the installed CLI's top-level error handling (src/index.ts), minus process.exit. */ +function renderEmbeddedError(ctx: EmbedContext, error: unknown): number { + if (error instanceof EmbeddedExit) return error.code + if (error && typeof error === 'object' && 'exitCode' in error && 'code' in error) { + // commander's CommanderError from exitOverride: usage/parse errors already + // printed through the (captured) output; help/version exit 0. + const commander = error as { exitCode: number; code: string } + if (commander.code === 'commander.helpDisplayed' || commander.code === 'commander.version') { + return 0 + } + return commander.exitCode || 1 + } + if (error instanceof ProfileConfigError) { + ctx.stderr.push(`Error: ${sanitize(error.message)}`) + return 1 + } + if (isRequestTimeout(error)) { + ctx.stderr.push(`Error: the request timed out. ${RAISE_TIMEOUT_HINT}`) + return 1 + } + if (error instanceof SimApiError) { + ctx.stderr.push(`Error: ${sanitize(error.message)}`) + if (error.code) ctx.stderr.push(` code: ${sanitize(error.code)}`) + if (error.details !== undefined) { + for (const line of formatApiErrorDetails(error.details)) ctx.stderr.push(sanitize(line)) + } + return 1 + } + ctx.stderr.push(`Error: ${sanitize(error instanceof Error ? error.message : String(error))}`) + return 1 +} From 75a2bfe1c74853c19bfe393c559014935543e292 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 15:48:20 +0530 Subject: [PATCH 022/306] fix(sim-cli): embed test used a DOM-only type the package tsconfig lacks Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- packages/sim-cli/src/embed.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sim-cli/src/embed.test.ts b/packages/sim-cli/src/embed.test.ts index f362a456c49..b91be11413e 100644 --- a/packages/sim-cli/src/embed.test.ts +++ b/packages/sim-cli/src/embed.test.ts @@ -21,7 +21,7 @@ afterEach(() => { describe('runEmbeddedCli', () => { it('runs a real command in-process with the injected identity, capturing stdout', async () => { const seen: { url: string; auth: string | null }[] = [] - vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + vi.stubGlobal('fetch', async (input: string | URL | Request, init?: RequestInit) => { const url = String(input) seen.push({ url, auth: new Headers(init?.headers).get('x-api-key') }) return jsonResponse({ data: [{ id: 'wf-1', name: 'Email digest' }], nextCursor: null }) @@ -58,7 +58,7 @@ describe('runEmbeddedCli', () => { }) it('isolates concurrent invocations (identity and output never interleave)', async () => { - vi.stubGlobal('fetch', async (input: RequestInfo | URL) => { + vi.stubGlobal('fetch', async (input: string | URL | Request) => { const url = new URL(String(input)) // Answer each invocation with its own workspace id so cross-talk is visible. const workspaceId = url.searchParams.get('workspaceId') ?? 'missing' From 1a5ae4288d2312258146eb57850427cf7ecea4d4 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 15:59:07 +0530 Subject: [PATCH 023/306] fix(sim-cli): version reads lazily with an embedded-host fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit version.ts read package.json at import time; hosts that bundle the CLI into a different layout (the server's embedded CLI, Trigger.dev workers) have no manifest at the relative path, and the import-time throw took the whole Trigger.dev task graph down (dev deploy failure). Reads are now lazy and fall back to a sentinel; the published binary still reports its real version (smoke-tested in the publish lane). Also: workflow lint as an agent-cli command — the Go copilot's virtual lint.json, served by the same engine both graph writes publish. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../handlers/agent-cli/agent-cli.test.ts | 44 ++++++++++++++++++- .../tools/handlers/agent-cli/commands/lint.ts | 39 ++++++++++++++++ .../tools/handlers/agent-cli/index.ts | 2 + .../tools/handlers/agent-cli/types.ts | 2 + .../lib/mothership/tools/handlers/sim-cli.ts | 1 + packages/sim-cli/src/http/client.test.ts | 10 ++--- packages/sim-cli/src/program.test.ts | 12 ++--- packages/sim-cli/src/program.ts | 8 +++- packages/sim-cli/src/telemetry/client-info.ts | 6 +-- packages/sim-cli/src/version.ts | 42 ++++++++++++------ 10 files changed, 135 insertions(+), 31 deletions(-) create mode 100644 apps/sim/lib/mothership/tools/handlers/agent-cli/commands/lint.ts diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts index b481b19bdbf..f5734014c91 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts @@ -1,7 +1,30 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +const { buildWorkflowLintReport } = vi.hoisted(() => ({ + buildWorkflowLintReport: vi.fn().mockResolvedValue({ + sources: ['block-1'], + sinks: ['block-2'], + orphanBlocks: [], + emptyOutgoingPorts: [], + invalidBranchPorts: [], + invalidConnectionTargets: [], + fieldIssues: [ + { + blockId: 'block-2', + blockName: 'Summarize emails', + missingRequiredFields: ['model'], + inactiveModeValues: [], + }, + ], + unresolvedReferences: [], + }), +})) + +vi.mock('@/lib/workflows/editing/lint-report', () => ({ buildWorkflowLintReport })) + import { agentCliHelpSection, executeAgentCliCommand, @@ -22,6 +45,7 @@ const WORKFLOW_STATE = { function runtimeWith(responses: Record): AgentCliRuntime { return { workspaceId: 'ws-1', + userId: 'user-1', client: { request: async (path: string): Promise => { const hit = responses[path] @@ -133,6 +157,24 @@ describe('workflow grep', () => { expect(result.stdout).toContain('Email digest (wf-1).blocks.block-2.name: Summarize emails') }) + it('lints a workflow through the shared engine with the caller scoped as subject', async () => { + const match = matchAgentCliCommand(['workflow', 'lint', 'wf-1']) + const result = await executeAgentCliCommand( + match!, + runtimeWith({ [EXPORT_PATH]: exportResponse }) + ) + expect(result.stderr).toBe('') + expect(result.exitCode).toBe(0) + const report = JSON.parse(result.stdout) + expect(report.fieldIssues).toHaveLength(1) + expect(report.summary.length).toBeGreaterThan(0) + expect(buildWorkflowLintReport).toHaveBeenCalledWith(expect.anything(), { + workflowId: 'wf-1', + workspaceId: 'ws-1', + subjectUserId: 'user-1', + }) + }) + it('surfaces execution errors as a failed result, never a throw', async () => { const match = matchAgentCliCommand(['workflow', 'grep', 'wf-missing', 'x']) const result = await executeAgentCliCommand(match!, runtimeWith({})) diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/lint.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/lint.ts new file mode 100644 index 00000000000..e4aae279163 --- /dev/null +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/lint.ts @@ -0,0 +1,39 @@ +import type { WorkflowState } from '@sim/workflow-types/workflow' +import { fetchWorkflowState } from '@/lib/mothership/tools/handlers/agent-cli/commands/workflow-views' +import { + type AgentCliCommand, + agentCliFail, + agentCliOk, +} from '@/lib/mothership/tools/handlers/agent-cli/types' +import { formatWorkflowLintMessage, hasWorkflowLintIssues } from '@/lib/workflows/editing/lint' +import { buildWorkflowLintReport } from '@/lib/workflows/editing/lint-report' + +/** + * The Go copilot served this as the virtual `workflows/{path}/lint.json` VFS + * file; here it is a command. Authorization rides the v2 export fetch (a user + * who cannot read the workflow gets the 403 there); the report itself is the + * same engine both graph writes publish, so a lint here can never disagree + * with what an edit would have reported. + */ +export const workflowLintCommand: AgentCliCommand = { + path: ['workflow', 'lint'], + summary: 'Validate one workflow: orphans, unwired ports, missing fields, unresolved references', + usage: 'workflow lint ', + async execute(rest, runtime) { + const workflowId = rest[0] + if (!workflowId) return agentCliFail('Usage: sim workflow lint ') + const state = await fetchWorkflowState(runtime, workflowId) + // double-cast-allowed: the v2 export's `state` is the serialized WorkflowState; + // the lint engine reads it structurally (blocks/edges only) + const graph = state as unknown as Pick + const report = await buildWorkflowLintReport(graph, { + workflowId, + workspaceId: runtime.workspaceId, + subjectUserId: runtime.userId, + }) + const summary = hasWorkflowLintIssues(report) + ? formatWorkflowLintMessage(report) + : 'No lint issues found.' + return agentCliOk(JSON.stringify({ summary, ...report }, null, 2)) + }, +} diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts index 0822d25f3a0..b89aa7d3da9 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts @@ -2,6 +2,7 @@ import { workflowGrepCommand, workflowsGrepCommand, } from '@/lib/mothership/tools/handlers/agent-cli/commands/grep' +import { workflowLintCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/lint' import { workflowBlocksCommand, workflowEdgesCommand, @@ -22,6 +23,7 @@ const AGENT_CLI_COMMANDS: readonly AgentCliCommand[] = [ workflowBlocksCommand, workflowEdgesCommand, workflowGrepCommand, + workflowLintCommand, workflowsGrepCommand, ] diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/types.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/types.ts index 6eef6af1c33..3ee06f755f6 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/types.ts +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/types.ts @@ -18,6 +18,8 @@ export interface AgentCliClient { export interface AgentCliRuntime { client: AgentCliClient workspaceId: string + /** The human the command acts as — reference resolution and grants scope to them. */ + userId: string } export interface AgentCliResult { diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts index 242ee9106bc..63907d4bc9c 100644 --- a/apps/sim/lib/mothership/tools/handlers/sim-cli.ts +++ b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts @@ -56,6 +56,7 @@ export async function executeSimCli( ? await executeAgentCliCommand(agentMatch, { client: createEmbeddedClient(identity), workspaceId: context.workspaceId, + userId: context.userId, }) : await runEmbeddedCli(args, identity) if (!agentMatch && isRootHelpInvocation(args) && result.exitCode === 0) { diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 1b45a65a76a..3fdc2196483 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { CLI_CONTRACT } from '../contract/commands' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' import { sleep } from '../helpers' -import { USER_AGENT } from '../version' +import { userAgent } from '../version' import { formatApiErrorDetails, redirectEndpoint, @@ -546,10 +546,10 @@ describe('request identity', () => { await client().request('/api/v2/workflows') const headers = fetchMock.mock.calls[0][1].headers as Record - expect(headers['user-agent']).toBe(USER_AGENT) - expect(USER_AGENT).toMatch(/^sim-cli\/\d+\.\d+\.\d+/) - expect(USER_AGENT).toContain(`node/${process.versions.node}`) - expect(USER_AGENT).toContain(process.platform) + expect(headers['user-agent']).toBe(userAgent()) + expect(userAgent()).toMatch(/^sim-cli\/\d+\.\d+\.\d+/) + expect(userAgent()).toContain(`node/${process.versions.node}`) + expect(userAgent()).toContain(process.platform) }) }) diff --git a/packages/sim-cli/src/program.test.ts b/packages/sim-cli/src/program.test.ts index 8124fc3fb83..bc0be6a70ed 100644 --- a/packages/sim-cli/src/program.test.ts +++ b/packages/sim-cli/src/program.test.ts @@ -7,7 +7,7 @@ import { join } from 'node:path' import type { Command } from 'commander' import { describe, expect, it } from 'vitest' import { buildProgram } from './program' -import { CLI_VERSION } from './version' +import { cliVersion } from './version' /** Parses argv against a program whose output and exits are captured, not taken. */ async function parse( @@ -39,7 +39,7 @@ describe('the root version flag', () => { it('still reports the version on its own', async () => { const { out, code } = await parse(['--version']) - expect(out.trim()).toBe(CLI_VERSION) + expect(out.trim()).toBe(cliVersion()) expect(code).toBe('commander.version') }) @@ -52,14 +52,14 @@ describe('the root version flag', () => { it('refuses a value instead of answering for a subcommand', async () => { const { out, code } = await parse(['workflows', 'rollback', 'wf_1', '--version', '1']) - expect(out).not.toContain(CLI_VERSION) + expect(out).not.toContain(cliVersion()) expect(code).toBe('commander.error') }) it('refuses the same value written with an equals sign', async () => { const { out, code } = await parse(['workflows', 'rollback', 'wf_1', '--version=1']) - expect(out).not.toContain(CLI_VERSION) + expect(out).not.toContain(cliVersion()) expect(code).toBe('commander.error') }) @@ -70,7 +70,7 @@ describe('the root version flag', () => { it('refuses the bare flag typed against a subcommand', async () => { const { out, code } = await parse(['workflows', 'rollback', 'wf_1', '--version']) - expect(out).not.toContain(CLI_VERSION) + expect(out).not.toContain(cliVersion()) expect(code).toBe('commander.error') }) @@ -78,7 +78,7 @@ describe('the root version flag', () => { it('still reports the version after a root option', async () => { const { out, code } = await parse(['--profile', 'workflows', '--version']) - expect(out.trim()).toBe(CLI_VERSION) + expect(out.trim()).toBe(cliVersion()) expect(code).toBe('commander.version') }) diff --git a/packages/sim-cli/src/program.ts b/packages/sim-cli/src/program.ts index 42125e2f4c6..ef67bec4f1e 100644 --- a/packages/sim-cli/src/program.ts +++ b/packages/sim-cli/src/program.ts @@ -13,7 +13,7 @@ import { refuseHelpAfterUnknownCommand, } from './runtime/build' import { announceUpdateIfAvailable } from './update/check' -import { CLI_VERSION } from './version' +import { cliVersion } from './version' /** Root program description, shared by `--help` and the generated docs. */ export const PROGRAM_DESCRIPTION = 'Talk to the Sim API from your terminal' @@ -109,7 +109,11 @@ function addVersionOption(program: Command): void { 'error: --version reports the Sim CLI version and takes no value. A command that acts on a deployment version reads it from --to-version.' ) }) - program.version(CLI_VERSION, '-V, --version [none]', 'output the version number (takes no value)') + program.version( + cliVersion(), + '-V, --version [none]', + 'output the version number (takes no value)' + ) } /** diff --git a/packages/sim-cli/src/telemetry/client-info.ts b/packages/sim-cli/src/telemetry/client-info.ts index 342659a7b98..30c975be843 100644 --- a/packages/sim-cli/src/telemetry/client-info.ts +++ b/packages/sim-cli/src/telemetry/client-info.ts @@ -1,5 +1,5 @@ import { CLIENT_INFO_HEADER, formatClientInfo } from '@sim/utils/client-info' -import { CLI_VERSION, USER_AGENT } from '../version' +import { cliVersion, userAgent } from '../version' import { detectCodingAgent, NO_CODING_AGENT } from './coding-agent' import { telemetryStatus } from './policy' import { loadTelemetryState } from './state' @@ -32,13 +32,13 @@ export function clientInfoHeader(env: NodeJS.ProcessEnv = process.env): string { * differently. */ export function identityHeaders(): Record { - return { 'user-agent': USER_AGENT, [CLIENT_INFO_HEADER]: clientInfoHeader() } + return { 'user-agent': userAgent(), [CLIENT_INFO_HEADER]: clientInfoHeader() } } function buildClientInfoHeader(env: NodeJS.ProcessEnv): string { return formatClientInfo({ surface: 'cli', - version: CLI_VERSION, + version: cliVersion(), runtime: { name: 'node', version: process.versions.node }, os: process.platform, arch: process.arch, diff --git a/packages/sim-cli/src/version.ts b/packages/sim-cli/src/version.ts index bded74c6a93..7a35edbadee 100644 --- a/packages/sim-cli/src/version.ts +++ b/packages/sim-cli/src/version.ts @@ -11,24 +11,36 @@ import { readFileSync } from 'node:fs' * version string that disagrees with the package it came from. The bundle keeps * `dist/index.js` one directory below the manifest, and npm always publishes the * manifest, so the relative path holds for an installed package as well as a - * local build. + * local build. Read LAZILY with a fallback: hosts that bundle this module into + * a different layout (the sim server's embedded CLI, Trigger.dev workers) have + * no manifest at the relative path, and an import-time throw took their whole + * task graph down. */ function readPackageVersion(): string { - const metadata: unknown = JSON.parse( - readFileSync(new URL('../package.json', import.meta.url), 'utf8') - ) - if ( - typeof metadata !== 'object' || - metadata === null || - !('version' in metadata) || - typeof metadata.version !== 'string' - ) { - throw new Error('CLI package metadata is missing a valid version') + try { + const metadata: unknown = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8') + ) + if ( + typeof metadata === 'object' && + metadata !== null && + 'version' in metadata && + typeof metadata.version === 'string' + ) { + return metadata.version + } + } catch { + // Fall through to the sentinel: an embedded host has no manifest to read. } - return metadata.version + return '0.0.0-embedded' } -export const CLI_VERSION = readPackageVersion() +let cachedVersion: string | null = null + +export function cliVersion(): string { + cachedVersion ??= readPackageVersion() + return cachedVersion +} /** * Identifies the CLI to the API, the way every other terminal client does. @@ -38,4 +50,6 @@ export const CLI_VERSION = readPackageVersion() * own logs. The runtime and platform ride along for the same reason: they are * the first things asked about a transport failure that only some users see. */ -export const USER_AGENT = `sim-cli/${CLI_VERSION} node/${process.versions.node} (${process.platform}; ${process.arch})` +export function userAgent(): string { + return `sim-cli/${cliVersion()} node/${process.versions.node} (${process.platform}; ${process.arch})` +} From e0651c72e3db41fc81535545a86f5c2e3b685602 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 16:26:43 +0530 Subject: [PATCH 024/306] fix(mothership): execute display-named frames by their execName Companion to mothership b4910ed8. The worker's CLI frames carry cli_* display identities; execution now dispatches on the frame's execName (ToolCallState.execName) while rendering and persistence keep the display name. Regenerated stream contract (execName + ui.simExecutable); the generator's formatter step also un-broken (biome refuses stdin paths its config excludes, so generated output formats under a neutral path). Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../generated/mothership-stream-v1-schema.ts | 6 ++++ .../generated/mothership-stream-v1.ts | 2 ++ .../request/handlers/handlers.test.ts | 30 +++++++++++++++++++ .../lib/mothership/request/handlers/tool.ts | 1 + .../lib/mothership/request/tools/executor.ts | 7 +++-- apps/sim/lib/mothership/request/types.ts | 3 ++ scripts/format-generated-source.ts | 10 +++++-- 7 files changed, 55 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/mothership/generated/mothership-stream-v1-schema.ts b/apps/sim/lib/mothership/generated/mothership-stream-v1-schema.ts index 89cafd97fae..41eaea4b459 100644 --- a/apps/sim/lib/mothership/generated/mothership-stream-v1-schema.ts +++ b/apps/sim/lib/mothership/generated/mothership-stream-v1-schema.ts @@ -1183,6 +1183,9 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { arguments: { $ref: '#/$defs/MothershipStreamV1AdditionalPropertiesMap', }, + execName: { + type: 'string', + }, executor: { $ref: '#/$defs/MothershipStreamV1ToolExecutor', }, @@ -1355,6 +1358,9 @@ export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { internal: { type: 'boolean', }, + simExecutable: { + type: 'boolean', + }, }, type: 'object', }, diff --git a/apps/sim/lib/mothership/generated/mothership-stream-v1.ts b/apps/sim/lib/mothership/generated/mothership-stream-v1.ts index 421cab83293..c271ee8c2ec 100644 --- a/apps/sim/lib/mothership/generated/mothership-stream-v1.ts +++ b/apps/sim/lib/mothership/generated/mothership-stream-v1.ts @@ -146,6 +146,7 @@ export interface MothershipStreamV1ToolCallEventEnvelope { export interface MothershipStreamV1ToolCallDescriptor { activityDescription?: string arguments?: MothershipStreamV1AdditionalPropertiesMap + execName?: string executor: MothershipStreamV1ToolExecutor mode: MothershipStreamV1ToolMode partial?: boolean @@ -163,6 +164,7 @@ export interface MothershipStreamV1ToolUI { hidden?: boolean inbandOwned?: boolean internal?: boolean + simExecutable?: boolean } export interface MothershipStreamV1ToolArgsDeltaEventEnvelope { payload: MothershipStreamV1ToolArgsDeltaPayload diff --git a/apps/sim/lib/mothership/request/handlers/handlers.test.ts b/apps/sim/lib/mothership/request/handlers/handlers.test.ts index 0fffb75ddfe..3861317070a 100644 --- a/apps/sim/lib/mothership/request/handlers/handlers.test.ts +++ b/apps/sim/lib/mothership/request/handlers/handlers.test.ts @@ -465,6 +465,36 @@ describe('sse-handlers tool lifecycle', () => { expect(context.finalAssistantContent).toBe('Final answer only.') }) + it('executes a display-named frame by its execName (the worker CLI class)', async () => { + // The worker wires sim_cli frames with toolName cli_* (display identity) and + // execName sim_cli. Dispatching by the display name sent every CLI call into + // the app registry as "Tool not found" — caught by the bench suite. + executeTool.mockResolvedValueOnce({ success: true, output: { exitCode: 0, stdout: '[]' } }) + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'cli-1', + toolName: 'cli_workflows_list', + execName: 'sim_cli', + arguments: { args: ['workflows', 'list'] }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + ui: { simExecutable: true }, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + await sleep(0) + expect(executeTool).toHaveBeenCalledTimes(1) + expect(executeTool.mock.calls[0][0]).toBe('sim_cli') + // Rendering and persistence keep the display identity. + expect(context.toolCalls.get('cli-1')?.name).toBe('cli_workflows_list') + }) + it('executes tool_call and emits tool_result', async () => { executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) const onEvent = vi.fn() diff --git a/apps/sim/lib/mothership/request/handlers/tool.ts b/apps/sim/lib/mothership/request/handlers/tool.ts index 5c76d2c038a..0827e92de69 100644 --- a/apps/sim/lib/mothership/request/handlers/tool.ts +++ b/apps/sim/lib/mothership/request/handlers/tool.ts @@ -533,6 +533,7 @@ async function handleCallPhase( // into the server tool context — this is what scopes the prepare_file_edit -> // apply_file_edit intent handoff to one file subagent under concurrency. if (parentToolCallId) toolCall.parentToolCallId = parentToolCallId + if (data.execName) toolCall.execName = data.execName const readPath = typeof args?.path === 'string' ? args.path : undefined if (toolName === 'read' && readPath?.startsWith('internal/')) return diff --git a/apps/sim/lib/mothership/request/tools/executor.ts b/apps/sim/lib/mothership/request/tools/executor.ts index 2db1ce66dca..2f8b5e02658 100644 --- a/apps/sim/lib/mothership/request/tools/executor.ts +++ b/apps/sim/lib/mothership/request/tools/executor.ts @@ -303,8 +303,11 @@ export function buildToolExecutionContext( * eventual settlement is ignored. */ async function executeToolWithWatchdog(toolCall: ToolCallState, toolContext: ExecutionContext) { - const timeoutMs = toolWatchdogTimeoutMs(toolCall.name) - const execution = executeTool(toolCall.name, toolCall.params || {}, toolContext) + // The frame's wire name can be a display identity (the worker's cli_* names); + // execution always dispatches on the model's real tool name. + const executableName = toolCall.execName ?? toolCall.name + const timeoutMs = toolWatchdogTimeoutMs(executableName) + const execution = executeTool(executableName, toolCall.params || {}, toolContext) let timer: ReturnType | undefined try { return await Promise.race([ diff --git a/apps/sim/lib/mothership/request/types.ts b/apps/sim/lib/mothership/request/types.ts index 14d3a99780b..668231925bb 100644 --- a/apps/sim/lib/mothership/request/types.ts +++ b/apps/sim/lib/mothership/request/types.ts @@ -28,6 +28,9 @@ export function isTerminalToolCallStatus(status?: string): boolean { export interface ToolCallState { id: string name: string + /** The model's tool name when `name` is a display identity (the worker's cli_* + * names). Execution dispatches on this; rendering and persistence keep `name`. */ + execName?: string status: ToolCallStatus /** Bounded registry ID of the agent that invoked this tool. */ agentId?: string diff --git a/scripts/format-generated-source.ts b/scripts/format-generated-source.ts index 63de2c80dde..bab0b9abfb9 100644 --- a/scripts/format-generated-source.ts +++ b/scripts/format-generated-source.ts @@ -1,8 +1,14 @@ import { spawnSync } from 'node:child_process' import { localBin } from './local-bin' +import { join } from 'node:path' export function formatGeneratedSource(source: string, stdinFilePath: string, cwd: string): string { - const result = spawnSync(localBin('biome'), ['format', '--stdin-file-path', stdinFilePath], { + // biome.json excludes the generated output dirs, and biome refuses to format a + // stdin whose declared path is excluded — so declare a neutral path instead; + // formatting rules do not vary by location, only ignores do. + void stdinFilePath + const neutralPath = join(cwd, 'scripts', '.generated-format-buffer.ts') + const result = spawnSync(localBin('biome'), ['format', '--stdin-file-path', neutralPath], { cwd, encoding: 'utf8', input: source, @@ -10,7 +16,7 @@ export function formatGeneratedSource(source: string, stdinFilePath: string, cwd if (result.status !== 0) { throw new Error( - `Failed to format generated source for ${stdinFilePath}:\n${ + `Failed to format generated source for ${neutralPath}:\n${ result.stderr || result.stdout || 'unknown error' }` ) From 11f202983710ceeeb01fe0bfd7d3f8d6af2207ba Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 16:29:49 +0530 Subject: [PATCH 025/306] chore: mship contract batch-format tolerates the biome ignore list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Note for regeneration during coexistence: the sync scripts' default source path (../copilot) is stale on this machine layout and fails loudly — that is currently CORRECT behavior, because the worker-era Go tree in ../mothership is frozen behind the Go service's own staging contracts; regenerating the catalog/trace files from it silently downgrades them. Regenerate a specific contract with an explicit --input when the change originates in the revamp (as mothership-stream-v1 did for execName). Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- scripts/generate-mship-contracts.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/scripts/generate-mship-contracts.ts b/scripts/generate-mship-contracts.ts index 4fabfee7ae4..96b31ff9794 100644 --- a/scripts/generate-mship-contracts.ts +++ b/scripts/generate-mship-contracts.ts @@ -61,10 +61,25 @@ function runGenerators(outputOverride?: string): void { } function formatGenerated(dir: string): void { + // biome.json excludes the generated dir, and `biome check` on excluded paths + // exits nonzero with "No files were processed" — each sync script already + // formats its own output through a neutral stdin path, so this batch pass is + // a per-file re-check that must not consult the ignore list. const files = readdirNoThrow(dir).filter((f) => !FORMAT_EXCLUDE.has(f) && f.endsWith('.ts')) if (files.length === 0) return const paths = files.map((f) => join(dir, f)) - run(['bunx', 'biome', 'check', '--write', ...paths], ROOT) + run( + [ + 'bunx', + 'biome', + 'check', + '--write', + '--files-ignore-unknown=true', + ...paths, + '--no-errors-on-unmatched', + ], + ROOT + ) } function readdirNoThrow(dir: string): string[] { From fd0b7ebe8b29d68b9725a63a7a463602d4732751 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 16:33:02 +0530 Subject: [PATCH 026/306] chore(mothership): stop minting delegation tokens on request paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worker no longer consumes them (the CLI executes sim-side; the handler mints its own identity per invocation), so the chat POST, v2 chat, inbox, and execute senders were one DB roundtrip + a plaintext credential on the wire for nothing. mintDelegationToken stays — the sim_cli handler is its one consumer now. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/app/api/mothership/execute/route.ts | 3 --- apps/sim/app/api/v2/chat/route.ts | 5 +---- apps/sim/lib/mothership/chat/post.ts | 6 ------ apps/sim/lib/mothership/inbox/executor.ts | 16 +++++----------- 4 files changed, 6 insertions(+), 24 deletions(-) diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index a14467b00cd..92a2e081859 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -15,7 +15,6 @@ import { RESOLVED_SECRET_PROVENANCE_METADATA_V1, requestsPrivateToolMetadata, } from '@/lib/execution/private-tool-metadata' -import { mintDelegationToken } from '@/lib/mothership/chat/delegation' import { buildIntegrationToolSchemas } from '@/lib/mothership/chat/payload' import { processContextsServer } from '@/lib/mothership/chat/process-contents' import { @@ -300,7 +299,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { ? { ...m, content: `${contextBlocks.join('\n\n')}\n\n${m.content}` } : m ) - const delegationToken = await mintDelegationToken({ workspaceId, userId }) const requestPayload: Record = { messages: wireMessages, ...(responseFormat !== undefined ? { responseFormat } : {}), @@ -311,7 +309,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { messageId, ...(integrationTools.length > 0 ? { integrationTools } : {}), ...(mothershipTools.length > 0 ? { mothershipTools } : {}), - ...(delegationToken ? { delegationToken } : {}), } let allowExplicitAbort = true diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts index 54851f0cf0d..f9898627565 100644 --- a/apps/sim/app/api/v2/chat/route.ts +++ b/apps/sim/app/api/v2/chat/route.ts @@ -21,7 +21,6 @@ import { resolveBillingAttribution } from '@/lib/billing/core/billing-attributio import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { chatOperations } from '@/lib/mothership/application/operations' -import { mintDelegationToken } from '@/lib/mothership/chat/delegation' import { resolveOrCreateChat } from '@/lib/mothership/chat/lifecycle' import { persistCopilotChatTurn } from '@/lib/mothership/chat/messages-store' import { buildIntegrationToolSchemas } from '@/lib/mothership/chat/payload' @@ -332,13 +331,12 @@ export const POST = withRouteHandler( }) } - const [integrationTools, billingAttribution, delegationToken] = await Promise.all([ + const [integrationTools, billingAttribution] = await Promise.all([ buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), // Hosted execution refuses to run without an attribution snapshot; // the executor path receives it as a header, this path resolves it // from the authenticated actor and asserted workspace. resolveBillingAttribution({ actorUserId: userId, workspaceId }), - mintDelegationToken({ workspaceId, userId }), ]) /** @@ -355,7 +353,6 @@ export const POST = withRouteHandler( chatId, messageId, ...(integrationTools.length > 0 ? { integrationTools } : {}), - ...(delegationToken ? { delegationToken } : {}), } let allowExplicitAbort = true diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index 189affe4c08..aac3666d3c8 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -1479,9 +1479,6 @@ export async function handleUnifiedChatPost(req: NextRequest) { * queries and ~900ms p95 per message). Its prep slot now mints the run-scoped * delegation credential the worker presents on v2 calls (revamp D23). */ - const delegationTokenPromise = workspaceId - ? mintDelegationToken({ workspaceId, userId: authenticatedUserId }) - : Promise.resolve(null) const executionContextPromise = withCopilotSpan( TraceSpan.CopilotChatBuildExecutionContext, { [TraceAttr.CopilotBranchKind]: branch.kind }, @@ -1532,14 +1529,12 @@ export async function handleUnifiedChatPost(req: NextRequest) { const [ agentContexts, userPermission, - delegationToken, , executionContext, personalCredentials, ] = await Promise.all([ agentContextsPromise, userPermissionPromise, - delegationTokenPromise, persistUserMessagePromise, executionContextPromise, personalCredentialsPromise, @@ -1631,7 +1626,6 @@ export async function handleUnifiedChatPost(req: NextRequest) { requestPayload: { ...requestPayload, protocolVersion: PROTOCOL_VERSION, - ...(delegationToken ? { delegationToken } : {}), }, userId: authenticatedUserId, streamId: userMessageId, diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index 1e5d7f876bf..abb4aa85803 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -5,7 +5,6 @@ import { generateId } from '@sim/utils/id' import { and, eq, isNull, sql } from 'drizzle-orm' import { getActivelyBannedUserIds, isEmailBlocked } from '@/lib/auth/ban' import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution' -import { mintDelegationToken } from '@/lib/mothership/chat/delegation' import { resolveOrCreateChat } from '@/lib/mothership/chat/lifecycle' import { appendCopilotChatMessages } from '@/lib/mothership/chat/messages-store' import { buildIntegrationToolSchemas } from '@/lib/mothership/chat/payload' @@ -235,15 +234,11 @@ export async function executeInboxTask(taskId: string): Promise { secretScope: ws.inboxSecretScope, mountedSecrets: ws.inboxMountedSecrets, }) - const [attachmentResult, integrationTools, billingAttribution, delegationToken] = - await Promise.all([ - fetchAttachments(), - buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), - resolveBillingAttribution({ actorUserId: userId, workspaceId: ws.id }), - // Trigger-runtime caveat (see docs/revamp/06-cutover.md): a failed mint falls - // back to null and the turn proceeds without CLI-backed capabilities. - mintDelegationToken({ workspaceId: ws.id, userId }), - ]) + const [attachmentResult, integrationTools, billingAttribution] = await Promise.all([ + fetchAttachments(), + buildIntegrationToolSchemas(userId, undefined, undefined, ws.id), + resolveBillingAttribution({ actorUserId: userId, workspaceId: ws.id }), + ]) const { attachments, fileAttachments, storedAttachments } = attachmentResult const truncatedTask = { @@ -264,7 +259,6 @@ export async function executeInboxTask(taskId: string): Promise { chatId, messageId: userMessageId, ...(integrationTools.length > 0 ? { integrationTools } : {}), - ...(delegationToken ? { delegationToken } : {}), } const result = await runHeadlessCopilotLifecycle(requestPayload, { From 52b1845e07be4e32e9748943e72ce3b2f704cb2f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 16:38:31 +0530 Subject: [PATCH 027/306] ci: retrigger wedged dev run Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w From d77031cd1b50ee9d4723b326e49519710e3f76bd Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 17:25:58 +0530 Subject: [PATCH 028/306] feat(mothership): run_function + streamed file-write handlers registered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to mothership 0c5ec22b. run_function routes to the restored function-execute handler (write-capable sandbox); the streamed file-writing pair returns — workspaceFileServerTool survived the revamp unregistered, editContentServerTool recovered from the pre-revamp tree — and the intact preview machinery keys off their frames again. files grep joins the agent augmentations (v2 read-text based, degraded-file aware). Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../tool-executor/register-handlers.ts | 4 + .../handlers/agent-cli/agent-cli.test.ts | 38 ++ .../handlers/agent-cli/commands/files-grep.ts | 109 ++++++ .../tools/handlers/agent-cli/index.ts | 2 + .../tools/server/files/edit-content.ts | 361 ++++++++++++++++++ .../sim/lib/mothership/tools/server/router.ts | 6 + packages/sim-cli/src/embed.ts | 7 +- 7 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/mothership/tools/handlers/agent-cli/commands/files-grep.ts create mode 100644 apps/sim/lib/mothership/tools/server/files/edit-content.ts diff --git a/apps/sim/lib/mothership/tool-executor/register-handlers.ts b/apps/sim/lib/mothership/tool-executor/register-handlers.ts index 53bc76d4073..5829768debb 100644 --- a/apps/sim/lib/mothership/tool-executor/register-handlers.ts +++ b/apps/sim/lib/mothership/tool-executor/register-handlers.ts @@ -7,6 +7,7 @@ import { } from '@/lib/mothership/generated/tool-catalog-v1' import { createServerToolHandler } from '@/lib/mothership/tools/registry/server-tool-adapter' import { getRegisteredServerToolNames } from '@/lib/mothership/tools/server/router' +import { executeFunctionExecute } from '../tools/handlers/function-execute' import { executeRunCode } from '../tools/handlers/run-code' import { executeSimCli } from '../tools/handlers/sim-cli' import { @@ -54,6 +55,9 @@ function buildHandlerMap(): Record { // (E2B/VM, mounts, secret materialization) lives on this side, same as the // workflow Function block. Compute-only: the handler rejects write vectors. run_code: h(executeRunCode), + // The write-capable variant: same sandbox, plus outputs.files workspace + // export and outputTable overwrite. + run_function: h(executeFunctionExecute), // The worker's CLI surface, executed in-process via the CLI's own command // tree (sim/embed) against this deployment's internal API base. sim_cli: h(executeSimCli), diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts index f5734014c91..5c6b31bda68 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts @@ -120,6 +120,44 @@ describe('workflow views', () => { }) }) +describe('files grep', () => { + const FILES_LIST = { + data: [ + { id: 'f1', name: 'report.md', folderPath: 'docs' }, + { id: 'f2', name: 'logo.png', folderPath: '' }, + ], + nextCursor: null, + } + const readText = (text: string, degraded = false) => ({ + data: { text, degraded }, + }) + + it('greps file contents with line numbers, skipping non-text files', async () => { + const match = matchAgentCliCommand(['files', 'grep', 'quarterly']) + const result = await executeAgentCliCommand( + match!, + runtimeWith({ + '/api/v2/files': FILES_LIST, + '/api/v2/files/f1/text': readText('# Report\nQuarterly revenue was up.\n'), + '/api/v2/files/f2/text': readText('', true), + }) + ) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('docs/report.md:2: Quarterly revenue was up.') + expect(result.stdout).not.toContain('logo.png') + }) + + it('filters by folder prefix', async () => { + const match = matchAgentCliCommand(['files', 'grep', 'Quarterly', 'other']) + const result = await executeAgentCliCommand( + match!, + runtimeWith({ '/api/v2/files': FILES_LIST }) + ) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('No matches') + }) +}) + describe('workflow grep', () => { it('reports matches as path: value lines', async () => { const match = matchAgentCliCommand(['workflow', 'grep', 'wf-1', 'Summarize']) diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/files-grep.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/files-grep.ts new file mode 100644 index 00000000000..1a9a7fedbcf --- /dev/null +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/files-grep.ts @@ -0,0 +1,109 @@ +import type { ListFilesResponse, ReadFileTextResponse } from 'sim/embed' +import { + type AgentCliCommand, + type AgentCliRuntime, + agentCliFail, + agentCliOk, +} from '@/lib/mothership/tools/handlers/agent-cli/types' + +/** + * Content grep across workspace files (the Go copilot's VFS-wide grep, files + * half). Text extraction rides the v2 read-text endpoint, which already + * handles binary/degraded files honestly, so this stays a pure projection. + */ + +const MAX_MATCHES = 200 +const MAX_FILES = 300 +const MAX_BYTES_PER_FILE = 262_144 +const READ_CONCURRENCY = 5 +const CONTEXT_CHARS = 120 + +function compilePattern(raw: string): (value: string) => boolean { + try { + const regex = new RegExp(raw, 'i') + return (value) => regex.test(value) + } catch { + const needle = raw.toLowerCase() + return (value) => value.toLowerCase().includes(needle) + } +} + +function matchingLines( + text: string, + matches: (value: string) => boolean, + label: string, + out: string[] +): void { + const lines = text.split('\n') + for (let lineNo = 0; lineNo < lines.length && out.length < MAX_MATCHES; lineNo++) { + const line = lines[lineNo] + if (!matches(line)) continue + const snippet = line.length > CONTEXT_CHARS ? `${line.slice(0, CONTEXT_CHARS)}…` : line + out.push(`${label}:${lineNo + 1}: ${snippet.trim()}`) + } +} + +async function listAllFiles(runtime: AgentCliRuntime): Promise { + const rows: ListFilesResponse['data'] = [] + let cursor: string | null = null + do { + const page: ListFilesResponse = await runtime.client.request( + '/api/v2/files', + { + query: { workspaceId: runtime.workspaceId, ...(cursor ? { cursor } : {}) }, + } + ) + rows.push(...page.data) + cursor = page.nextCursor + } while (cursor && rows.length < MAX_FILES) + return rows.slice(0, MAX_FILES) +} + +export const filesGrepCommand: AgentCliCommand = { + path: ['files', 'grep'], + summary: 'Search the content of every workspace file for a pattern', + usage: 'files grep [folder-path-prefix]', + async execute(rest, runtime) { + const [pattern, folderPrefix] = [rest[0], rest[1]] + if (!pattern) return agentCliFail('Usage: sim files grep [folder-path-prefix]') + const matches = compilePattern(pattern) + const files = (await listAllFiles(runtime)).filter( + (file) => !folderPrefix || file.folderPath.startsWith(folderPrefix) + ) + const out: string[] = [] + let unreadable = 0 + for (let i = 0; i < files.length && out.length < MAX_MATCHES; i += READ_CONCURRENCY) { + const batch = files.slice(i, i + READ_CONCURRENCY) + const texts = await Promise.all( + batch.map(async (file) => { + try { + const response = await runtime.client.request( + `/api/v2/files/${encodeURIComponent(file.id)}/text`, + { query: { workspaceId: runtime.workspaceId, maxBytes: String(MAX_BYTES_PER_FILE) } } + ) + return { file, text: response.data.degraded ? null : response.data.text } + } catch { + // Binary or unreadable files must not sink the whole search. + return { file, text: null } + } + }) + ) + for (const { file, text } of texts) { + const label = file.folderPath ? `${file.folderPath}/${file.name}` : file.name + if (matches(file.name) && out.length < MAX_MATCHES) out.push(`${label}: name matches`) + if (text === null) { + unreadable++ + continue + } + matchingLines(text, matches, label, out) + } + } + if (out.length === 0) { + return agentCliOk( + unreadable > 0 ? `No matches (${unreadable} non-text files skipped).` : 'No matches.' + ) + } + const capped = out.length >= MAX_MATCHES ? [...out, `[capped at ${MAX_MATCHES} matches]`] : out + return agentCliOk(capped.join('\n')) + }, +} diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts index b89aa7d3da9..1aaf96805da 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts @@ -1,3 +1,4 @@ +import { filesGrepCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/files-grep' import { workflowGrepCommand, workflowsGrepCommand, @@ -20,6 +21,7 @@ import { * automatically. */ const AGENT_CLI_COMMANDS: readonly AgentCliCommand[] = [ + filesGrepCommand, workflowBlocksCommand, workflowEdgesCommand, workflowGrepCommand, diff --git a/apps/sim/lib/mothership/tools/server/files/edit-content.ts b/apps/sim/lib/mothership/tools/server/files/edit-content.ts new file mode 100644 index 00000000000..c9c0fd50f34 --- /dev/null +++ b/apps/sim/lib/mothership/tools/server/files/edit-content.ts @@ -0,0 +1,361 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { truncate } from '@sim/utils/string' +import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { executeCopilotFileUseCase } from '@/lib/mothership/application/execute-file-use-case' +import { + messageForCopilotFileError, + resolveCopilotFilePrincipal, +} from '@/lib/mothership/auth/file-delegation' +import { + assertServerToolNotAborted, + type BaseServerTool, + type ServerToolContext, +} from '@/lib/mothership/tools/server/base-tool' +import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/update-workspace-file-content' +import { + collectSimPageDiagnostics, + HAND_WRITTEN_PAGE_MESSAGE, + isHandWrittenCompiledPage, + isSimPageSource, + SIM_PAGE_CONTENT_TYPE, +} from '@/lib/workspace-files/page-compile' +import { getE2BDocFormat } from './doc-compile' +import { buildEmbeddedImageRefWarning } from './embedded-image-refs' +import { waitForLatestFileIntent } from './file-intent-store' +import { compileDocForWrite, getDocumentFormatInfo, inferContentType } from './workspace-file' + +const logger = createLogger('EditContentServerTool') + +type EditContentArgs = { + content: string +} + +type EditContentResult = { + success: boolean + message: string + data?: Record +} + +export const editContentServerTool: BaseServerTool = { + name: 'apply_file_edit', + async execute(params: EditContentArgs, context?: ServerToolContext): Promise { + if (!context?.userId) { + logger.error('Unauthorized attempt to use apply_file_edit') + throw new Error('Authentication required') + } + + const workspaceId = context.workspaceId + if (!workspaceId) { + return { success: false, message: 'Workspace ID is required' } + } + + const raw = params as Record + const nested = raw.args as Record | undefined + const content = + typeof params.content === 'string' + ? params.content + : typeof nested?.content === 'string' + ? (nested.content as string) + : undefined + + if (content === undefined) { + return { success: false, message: 'content is required for apply_file_edit' } + } + + // Consume the intent from THIS file subagent's channel (its outer tool_use + // id), not just the latest in the message — otherwise two file agents + // writing concurrently would each grab whichever prepare_file_edit landed last + // and write their content into the wrong file. Falls back to latest-in- + // message when no channel id is present (main-agent / legacy calls). + // Waits briefly: a prepare batched into the same round may still be running. + const intent = await waitForLatestFileIntent(workspaceId, { + chatId: context.chatId, + messageId: context.messageId, + channelId: context.parentToolCallId, + }) + if (!intent) { + return { + success: false, + message: + 'No prepare_file_edit context found. Call prepare_file_edit first, wait for it to succeed, then call apply_file_edit in the next step. Do not emit apply_file_edit in parallel or in the same batch as prepare_file_edit.', + } + } + + try { + const { operation, fileRecord } = intent + const docInfo = getDocumentFormatInfo(fileRecord.name) + const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(fileRecord.name) : null + // Agent-authored pages are stored as SOURCE (frontmatter + markdown + + // sim: fences) and compiled to the docs-styled document at render time + // — the pdf model: the file holds the source, the preview/share/ + // download surfaces serve the rendered version. Bespoke raw HTML + // passes through, but a hand-written copy of compiled output defeats + // the source format, so it is rejected with the steer back to source. + // Patches are exempt: small in-place fixes on a legacy stored-compiled + // page legitimately contain compiled fragments. + // Sim pages store an extensionless name; the record type marks them. + const isHtmlTarget = + fileRecord.name.toLowerCase().endsWith('.html') || fileRecord.type === SIM_PAGE_CONTENT_TYPE + if ( + isHtmlTarget && + (operation === 'append' || operation === 'update') && + isHandWrittenCompiledPage(content) + ) { + return { success: false, message: HAND_WRITTEN_PAGE_MESSAGE } + } + + let finalContent: string + switch (operation) { + case 'append': { + const existing = intent.existingContent ?? '' + if (isHtmlTarget) { + finalContent = existing ? `${existing}\n${content}` : content + break + } + // The JS engines (isolated-vm and E2B-node pptx/docx) use the `{ ... }` + // block-append convention — block statements scope cleanly inside the + // compile wrapper. Python docs (pdf/xlsx) are a single cohesive script, + // so brace-wrapping would produce invalid Python; plain-concatenate. + // Brace-wrap appended content for the JS engines (isolated-vm and + // E2B-node pptx/docx); Python docs (pdf/xlsx) are one cohesive script. + const braceWrap = e2bFmt ? e2bFmt.engine === 'node' : docInfo.isDoc + finalContent = braceWrap + ? existing + ? `${existing}\n{\n${content}\n}` + : content + : existing + ? `${existing}\n${content}` + : content + break + } + case 'update': { + finalContent = content + break + } + case 'patch': { + const existing = intent.existingContent ?? '' + if (!intent.edit) { + return { success: false, message: 'Patch intent missing edit metadata' } + } + + if (intent.edit.strategy === 'search_replace') { + const search = intent.edit.search! + const firstIdx = existing.indexOf(search) + if (firstIdx === -1) { + return { + success: false, + message: `Patch failed: search string not found in file "${fileRecord.name}": ${JSON.stringify(truncate(search, 120))}`, + } + } + // The tool doc promises "must match exactly once unless + // replaceAll" — enforce it, or an ambiguous search silently + // rewrites the first occurrence with a success receipt. + if (!intent.edit.replaceAll) { + const occurrences = existing.split(search).length - 1 + if (occurrences > 1) { + return { + success: false, + message: `Patch failed: search string matches ${occurrences} places in "${fileRecord.name}". Add surrounding context to make it unique, or pass replaceAll: true to change every occurrence.`, + } + } + } + finalContent = intent.edit.replaceAll + ? existing.split(search).join(content) + : existing.slice(0, firstIdx) + content + existing.slice(firstIdx + search.length) + } else if (intent.edit.strategy === 'anchored') { + const lines = existing.split('\n') + const defaultOccurrence = intent.edit.occurrence ?? 1 + + const findAnchorLine = ( + anchor: string, + occurrence = defaultOccurrence, + afterIndex = -1 + ): { index: number; error?: string } => { + const trimmed = anchor.trim() + let count = 0 + for (let i = afterIndex + 1; i < lines.length; i++) { + if (lines[i].trim() === trimmed) { + count++ + if (count === occurrence) return { index: i } + } + } + if (count === 0) { + return { + index: -1, + error: `Anchor line not found in "${fileRecord.name}": "${anchor.slice(0, 100)}"`, + } + } + return { + index: -1, + error: `Anchor line occurrence ${occurrence} not found (only ${count} match${count > 1 ? 'es' : ''}) in "${fileRecord.name}": "${anchor.slice(0, 100)}"`, + } + } + + if (intent.edit.mode === 'replace_between') { + if (!intent.edit.before_anchor || !intent.edit.after_anchor) { + return { + success: false, + message: 'replace_between requires before_anchor and after_anchor', + } + } + const before = findAnchorLine(intent.edit.before_anchor) + if (before.error) return { success: false, message: `Patch failed: ${before.error}` } + const after = findAnchorLine( + intent.edit.after_anchor, + defaultOccurrence, + before.index + ) + if (after.error) return { success: false, message: `Patch failed: ${after.error}` } + if (after.index <= before.index) { + return { + success: false, + message: 'Patch failed: after_anchor must appear after before_anchor in the file', + } + } + const newLines = [ + ...lines.slice(0, before.index + 1), + ...content.split('\n'), + ...lines.slice(after.index), + ] + finalContent = newLines.join('\n') + } else if (intent.edit.mode === 'insert_after') { + if (!intent.edit.anchor) { + return { success: false, message: 'insert_after requires anchor' } + } + const found = findAnchorLine(intent.edit.anchor) + if (found.error) return { success: false, message: `Patch failed: ${found.error}` } + const newLines = [ + ...lines.slice(0, found.index + 1), + ...content.split('\n'), + ...lines.slice(found.index + 1), + ] + finalContent = newLines.join('\n') + } else if (intent.edit.mode === 'delete_between') { + if (!intent.edit.start_anchor || !intent.edit.end_anchor) { + return { + success: false, + message: 'delete_between requires start_anchor and end_anchor', + } + } + const start = findAnchorLine(intent.edit.start_anchor) + if (start.error) return { success: false, message: `Patch failed: ${start.error}` } + const end = findAnchorLine(intent.edit.end_anchor, defaultOccurrence, start.index) + if (end.error) return { success: false, message: `Patch failed: ${end.error}` } + if (end.index <= start.index) { + return { + success: false, + message: 'Patch failed: end_anchor must appear after start_anchor in the file', + } + } + const newLines = [...lines.slice(0, start.index), ...lines.slice(end.index)] + finalContent = newLines.join('\n') + } else { + return { + success: false, + message: `Unknown anchored patch mode: "${intent.edit.mode}"`, + } + } + } else { + return { success: false, message: `Unknown patch strategy: "${intent.edit.strategy}"` } + } + break + } + default: + return { success: false, message: `Unsupported operation in intent: ${operation}` } + } + + // Compile once via the right engine (or isolated-vm fallback) and resolve + // the source MIME to store. Shared with the create path. + const principal = resolveCopilotFilePrincipal(context) + const compiled = await compileDocForWrite({ + source: finalContent, + fileName: fileRecord.name, + workspaceId, + principal, + ownerKey: `user:${context.userId}`, + signal: context.abortSignal, + fallbackMime: inferContentType(fileRecord.name, intent.contentType), + }) + if (!compiled.ok) { + return { success: false, message: compiled.message } + } + + // The internal page type: the record advertises what the .html holds so + // surfaces can force the rendered view before content loads. The file + // itself stays .html (serve/download emit text/html). + // create_empty_file stamps copilot .html as a page by default; the + // first real content confirms or corrects that from what was written. + const storedContentType = + isHtmlTarget && isSimPageSource(finalContent) ? SIM_PAGE_CONTENT_TYPE : compiled.sourceMime + + const fileBuffer = Buffer.from(finalContent, 'utf-8') + assertServerToolNotAborted(context) + // `updateWorkspaceFileContent` also streams this edit into any open collaborative editor as a live + // CRDT merge (gated to markdown, best-effort) — the shared chokepoint every external write path + // goes through — so a copilot edit shows up live instead of the file changing under the reader. + await executeCopilotFileUseCase( + context, + updateWorkspaceFileContent, + { + fileId: intent.fileId, + assertedWorkspaceId: workspaceId, + content: finalContent, + encoding: 'utf-8', + contentType: storedContentType, + provenanceMode: operation === 'update' ? 'replace_empty' : 'preserve', + }, + { fileId: intent.fileId } + ) + + const verb = + operation === 'append' ? 'appended to' : operation === 'update' ? 'updated' : 'patched' + logger.info(`Workspace file ${verb} via copilot (apply_file_edit)`, { + fileId: intent.fileId, + name: fileRecord.name, + operation, + size: fileBuffer.length, + userId: context.userId, + }) + + // Flag any `/api/files/view/` embeds the model just authored that won't render/export + // (non-workspace or missing), so it can self-correct on the next step. + const embedWarning = await buildEmbeddedImageRefWarning(content, workspaceId) + + // Page-source lint: a malformed sim: block renders as NOTHING for the + // reader — the only place the failure surfaces is right here, so the + // agent can fix the fence instead of shipping a silent hole. + let pageLint = '' + if (storedContentType === SIM_PAGE_CONTENT_TYPE) { + const diagnostics = collectSimPageDiagnostics(finalContent) + if (diagnostics.length > 0) { + pageLint = ` WARNING — ${diagnostics.length} block(s) failed to compile and are OMITTED from the rendered page; fix them: ${diagnostics.join('; ')}` + } + } + + return { + success: true, + message: `File "${fileRecord.name}" ${verb} successfully (${fileBuffer.length} bytes)${embedWarning}${pageLint}`, + data: { + id: intent.fileId, + name: fileRecord.name, + size: fileBuffer.length, + contentType: storedContentType, + }, + } + } catch (error) { + const safeMessage = messageForCopilotFileError(error, 'Failed to edit file content') + const errorMessage = getErrorMessage(error, 'Unknown error occurred') + logger.error('Error in apply_file_edit tool', { + operation: intent.operation, + fileId: intent.fileId, + error: errorMessage, + userId: context.userId, + }) + return { + success: false, + message: safeMessage, + } + } + }, +} diff --git a/apps/sim/lib/mothership/tools/server/router.ts b/apps/sim/lib/mothership/tools/server/router.ts index 7d9283c4b5d..f85b3a8f0cf 100644 --- a/apps/sim/lib/mothership/tools/server/router.ts +++ b/apps/sim/lib/mothership/tools/server/router.ts @@ -15,6 +15,8 @@ import { type ServerToolContext, } from '@/lib/mothership/tools/server/base-tool' import { searchDocsServerTool } from '@/lib/mothership/tools/server/docs/search-docs' +import { editContentServerTool } from '@/lib/mothership/tools/server/files/edit-content' +import { workspaceFileServerTool } from '@/lib/mothership/tools/server/files/workspace-file' import { validateGeneratedToolPayload } from '@/lib/mothership/tools/server/generated-schema' import { generateImageServerTool } from '@/lib/mothership/tools/server/image/generate-image' import { @@ -55,6 +57,10 @@ const baseServerToolRegistry: Record = { [searchDocsServerTool.name]: searchDocsServerTool, [searchWorkspaceServerTool.name]: searchWorkspaceServerTool, [readDocumentServerTool.name]: readDocumentServerTool, + // The streamed file-writing pair: prepare opens the write (live preview), + // apply continues it. The preview machinery keys off these exact names. + [workspaceFileServerTool.name]: workspaceFileServerTool, + [editContentServerTool.name]: editContentServerTool, [generateImageServerTool.name]: generateImageServerTool, [generateVideoServerTool.name]: generateVideoServerTool, [generateAudioServerTool.name]: generateAudioServerTool, diff --git a/packages/sim-cli/src/embed.ts b/packages/sim-cli/src/embed.ts index a4d6140510e..4c91033c2d6 100644 --- a/packages/sim-cli/src/embed.ts +++ b/packages/sim-cli/src/embed.ts @@ -30,7 +30,12 @@ import { buildProgram } from './program' */ export type { EmbeddedCliIdentity } from './embed-context' -export type { ExportWorkflowResponse, ListWorkflowsResponse } from './generated/v2-api' +export type { + ExportWorkflowResponse, + ListFilesResponse, + ListWorkflowsResponse, + ReadFileTextResponse, +} from './generated/v2-api' export { SimClient } from './http/client' export interface EmbeddedCliResult { From 729eee3bd96bb1030aca402178480b6579638b92 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Mon, 31 Aug 2026 19:47:31 +0530 Subject: [PATCH 029/306] feat(mothership): grep-pipe on sim_cli + persistent per-chat session sandbox sim_cli accepts a trailing | grep (native, sim-side, chainable; flags -i -v -c -n -E -m N), validated before the CLI runs so a mutating command never executes and then fails on a malformed pipe. The mothership code sandbox (run_code/run_function) is now persistent per chat: a session lease in the remote-sandbox layer reconnects to the chat's live E2B sandbox (metadata key mothership-chat:), holds a 20-minute idle TTL refreshed per execution, is never metered or abort-killed, and stamps sandboxSession: created|reused on every result. Fresh sessions bootstrap the sim CLI detached; the delegation token is minted sim-side per execution and injected into the exec env only, so a stopped sandbox never holds a live credential. Fixes found by e2e, each with regression tests: transformResponse dropped sandboxSession (field whitelist), and run_code timeouts sent as strings skipped the seconds conversion into z.coerce (a "90"s request armed a 90ms abort). Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/lib/api/contracts/hotspots.ts | 5 + apps/sim/lib/core/config/env.ts | 1 + apps/sim/lib/execution/remote-sandbox/e2b.ts | 37 +++ .../sim/lib/execution/remote-sandbox/index.ts | 165 ++++++++++++-- .../remote-sandbox/session-sandbox.test.ts | 215 ++++++++++++++++++ .../sim/lib/execution/remote-sandbox/types.ts | 52 +++++ .../lib/function-execution/execute-request.ts | 22 ++ .../tools/handlers/agent-cli/index.ts | 2 +- .../handlers/function-execute-session.test.ts | 86 +++++++ .../tools/handlers/function-execute.ts | 15 ++ .../tools/handlers/sim-cli-pipe.test.ts | 102 +++++++++ .../mothership/tools/handlers/sim-cli-pipe.ts | 102 +++++++++ .../lib/mothership/tools/handlers/sim-cli.ts | 30 ++- .../lib/mothership/tools/sandbox-session.ts | 60 +++++ apps/sim/tools/function/execute.test.ts | 26 +++ apps/sim/tools/function/execute.ts | 5 + apps/sim/tools/function/types.ts | 10 + 17 files changed, 914 insertions(+), 21 deletions(-) create mode 100644 apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts create mode 100644 apps/sim/lib/mothership/tools/handlers/function-execute-session.test.ts create mode 100644 apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.test.ts create mode 100644 apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.ts create mode 100644 apps/sim/lib/mothership/tools/sandbox-session.ts diff --git a/apps/sim/lib/api/contracts/hotspots.ts b/apps/sim/lib/api/contracts/hotspots.ts index 95a40b983b9..c0f05e51587 100644 --- a/apps/sim/lib/api/contracts/hotspots.ts +++ b/apps/sim/lib/api/contracts/hotspots.ts @@ -221,6 +221,11 @@ export const functionExecuteBodySchema = z isCustomTool: z.boolean().optional().default(false), /** Workspace sandbox whose dependency set this execution runs against. */ sandboxId: z.string().optional(), + /** + * Reusable session-sandbox identity (one per Mothership chat). Honored only + * for trusted Mothership executions; workspace callers cannot opt in. + */ + sandboxSessionKey: z.string().optional(), /** `all` (default) or `selected`; see mountedSecrets. */ secretScope: z.enum(['all', 'selected']).optional(), /** Secret names this execution may read when secretScope is `selected`. */ diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 7163ec25cea..5cff9ea17e1 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -596,6 +596,7 @@ export const env = createEnv({ E2B_FUNCTION_TEMPLATE_ID: z.string().refine(isImmutableE2BTemplateRef, { message: `E2B_FUNCTION_TEMPLATE_ID ${IMMUTABLE_E2B_TEMPLATE_REF_ERROR}` }).optional(), // Immutable dedicated E2B build for Function JavaScript/Python/Shell and workspace sandbox layers; no Mothership fallback E2B_FUNCTION_TEMPLATE_GENERATION: z.string().refine(isValidSandboxReleaseGeneration, { message: `E2B_FUNCTION_TEMPLATE_GENERATION ${SANDBOX_RELEASE_GENERATION_ERROR}` }).optional(), // Monotonic release epoch printed by the Function E2B builder MOTHERSHIP_E2B_TEMPLATE_ID: z.string().optional(), // Mothership code-tool template; never a Function-base fallback + MOTHERSHIP_SANDBOX_CLI_ENDPOINT: z.string().optional(), // Sim API base the sandboxed sim CLI calls back to; defaults to NEXT_PUBLIC_APP_URL (set when the public URL is not reachable from the sandbox network) MOTHERSHIP_E2B_DOC_TEMPLATE_ID: z.string().optional(), // Dedicated E2B template with python-pptx/docx/openpyxl/reportlab for document generation; when set (and E2B enabled), docs compile via Python instead of the JS isolated-vm path E2B_PI_TEMPLATE_ID: z.string().optional(), // E2B template ID/alias with the Pi CLI + git baked in (Create PR, its Babysit continuation, and Review Code) PI_SANDBOX_LIFETIME_MS: z.string().optional(), // Lower the Pi sandbox lifetime (ms) below the default; E2B caps a sandbox at 1h on Hobby accounts and 24h on Pro diff --git a/apps/sim/lib/execution/remote-sandbox/e2b.ts b/apps/sim/lib/execution/remote-sandbox/e2b.ts index c8922b2f3d6..d9a904a822f 100644 --- a/apps/sim/lib/execution/remote-sandbox/e2b.ts +++ b/apps/sim/lib/execution/remote-sandbox/e2b.ts @@ -306,6 +306,9 @@ function templateFor(kind: SandboxKind, imageRef?: string): string { return functionTemplateRef() } +/** Metadata key that tags a sandbox with its owning session for reconnection. */ +const E2B_SESSION_METADATA_KEY = 'simSessionKey' + class E2BSandboxHandle implements SandboxHandle { private killed = false private killPromise: Promise | null = null @@ -320,6 +323,10 @@ class E2BSandboxHandle implements SandboxHandle { return this.sandbox.sandboxId } + async extendLifetime(lifetimeMs: number): Promise { + await this.sandbox.setTimeout(e2bTimeoutMs(lifetimeMs)) + } + async runCode( code: string, options: { @@ -899,6 +906,9 @@ export const e2bProvider: SandboxProvider = { const createOptions = { apiKey, ...(effectiveLifetimeMs !== undefined ? { timeoutMs: effectiveLifetimeMs } : {}), + ...(options?.sessionKey + ? { metadata: { [E2B_SESSION_METADATA_KEY]: options.sessionKey } } + : {}), } const { Sandbox } = await import('@e2b/code-interpreter') @@ -914,4 +924,31 @@ export const e2bProvider: SandboxProvider = { : undefined ) }, + + async findSessionSandbox( + key: string, + options: { language?: CodeLanguage } + ): Promise { + const apiKey = env.E2B_API_KEY + if (!apiKey) return null + const { Sandbox } = await import('@e2b/code-interpreter') + try { + const paginator = Sandbox.list({ + apiKey, + query: { metadata: { [E2B_SESSION_METADATA_KEY]: key }, state: ['running'] }, + }) + const candidates = await paginator.nextItems() + if (candidates.length === 0) return null + // Oldest first: when a race created two sandboxes for one session, every + // later execution adopts the same one and the stragglers idle out. + candidates.sort((a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime()) + const sandbox = await Sandbox.connect(candidates[0].sandboxId, { apiKey }) + return new E2BSandboxHandle(sandbox, options.language ?? CodeLanguage.Python) + } catch (error) { + // A sandbox reaped between list and connect is an ordinary miss, and any + // other lookup failure degrades to a fresh create rather than an error. + logger.info('E2B session sandbox lookup missed', { key, error: getErrorMessage(error) }) + return null + } + }, } diff --git a/apps/sim/lib/execution/remote-sandbox/index.ts b/apps/sim/lib/execution/remote-sandbox/index.ts index 3b4e92015b8..650da6c6e4e 100644 --- a/apps/sim/lib/execution/remote-sandbox/index.ts +++ b/apps/sim/lib/execution/remote-sandbox/index.ts @@ -12,6 +12,7 @@ import { isTimeoutAbortReason, } from '@/lib/core/execution-limits' import { recordSandboxTeardownFailure } from '@/lib/core/execution-limits/metrics' +import { runDetached } from '@/lib/core/utils/background' import { buildJavaScriptRuntimeBindingsSource } from '@/lib/execution/code-placeholders/javascript-runtime' import { SANDBOX_SYSTEM_PATH } from '@/lib/execution/remote-sandbox/cli-tools.server' import { @@ -57,6 +58,7 @@ import type { SandboxPrivateInput, SandboxProvider, SandboxProviderId, + SandboxSessionRequest, SandboxShellExecutionRequest, } from '@/lib/execution/remote-sandbox/types' @@ -108,6 +110,119 @@ async function createSandbox( } } +/** + * How long a session sandbox survives between executions before the provider + * reaps it. Refreshed on every acquire and release, so the clock measures idle + * time, not total lifetime. + */ +const SESSION_SANDBOX_IDLE_MS = 20 * 60_000 + +/** Budget for a session's one-time bootstrap command (e.g. a CLI install). */ +const SESSION_BOOTSTRAP_TIMEOUT_MS = 120_000 + +interface SandboxLease { + created: CreatedSandbox + /** Set when this execution runs in a session sandbox; absent for one-shot. */ + session?: 'created' | 'reused' + /** One-shot: kills the sandbox. Session: refreshes its idle deadline. */ + release(): Promise +} + +/** + * Acquires the sandbox an execution runs in: reconnects to the caller's live + * session sandbox, creates and bootstraps a fresh one under the session tag, or + * falls back to the classic one-shot create. + * + * A session lease never binds the abort signal to teardown — cancelling one + * execution must not destroy state the next turn builds on — and is never + * metered, because session sandboxes are server-owned rather than billed to + * workspace compute. Providers without session support degrade to one-shot. + */ +async function leaseSandbox( + kind: SandboxKind, + options: CreateSandboxOptions, + selected: ResolvedSandbox | null, + signal: AbortSignal, + meterUsage: boolean | undefined, + session: SandboxSessionRequest | undefined +): Promise { + const provider = resolveProvider() + const sessionCapable = Boolean(session && !meterUsage && provider.findSessionSandbox) + + if (session && sessionCapable) { + const refresh = async (sandbox: SandboxHandle) => { + try { + await sandbox.extendLifetime?.(SESSION_SANDBOX_IDLE_MS) + } catch (error) { + logger.warn('Failed to refresh session sandbox lifetime', { + sandboxId: sandbox.sandboxId, + error: getErrorMessage(error), + }) + } + } + try { + const existing = await provider.findSessionSandbox?.(session.key, { + ...(options.language ? { language: options.language } : {}), + }) + if (existing) { + await refresh(existing) + return { + created: { sandbox: existing, providerId: provider.id, startedAtMs: Date.now() }, + session: 'reused', + release: () => refresh(existing), + } + } + } catch (error) { + logger.warn('Session sandbox lookup failed; creating a fresh one', { + key: session.key, + error: getErrorMessage(error), + }) + } + const created = await createSelectedSandbox( + kind, + { ...options, lifetimeMs: SESSION_SANDBOX_IDLE_MS, sessionKey: session.key }, + selected, + signal, + false + ) + const bootstrapCommand = session.bootstrapCommand + if (bootstrapCommand) { + // Detached: the bootstrap (a CLI install, until the images bake it in) + // can outlast the caller's execution budget, and the first execution of a + // chat must not pay for it. The narrow race — the very first execution + // using the bootstrapped tool before the install lands — resolves on + // retry against the by-then bootstrapped sandbox. + runDetached('session-sandbox-bootstrap', async () => { + const bootstrap = await created.sandbox.runCommand(bootstrapCommand, { + timeoutMs: SESSION_BOOTSTRAP_TIMEOUT_MS, + rootUser: true, + }) + if (bootstrap.exitCode !== 0) { + logger.warn('Session sandbox bootstrap exited non-zero', { + sandboxId: created.sandbox.sandboxId, + exitCode: bootstrap.exitCode, + }) + } + }) + } + return { + created, + session: 'created', + release: () => refresh(created.sandbox), + } + } + + const created = await createSelectedSandbox(kind, options, selected, signal, meterUsage) + const abortBinding = bindSandboxAbort(created.sandbox, created.providerId, signal) + return { + created, + release: async () => { + abortBinding.detach() + await abortBinding.cleanup() + }, + } +} + /** * Creates a sandbox, turning "that image is gone" into a rebuild rather than a * failure the author has to resolve by hand. @@ -813,7 +928,7 @@ async function executeInSandboxWithinBudget( }) throwIfAborted(signal) - const created = await createSelectedSandbox( + const lease = await leaseSandbox( kind, { language, @@ -822,18 +937,20 @@ async function executeInSandboxWithinBudget( }, selected, signal, - req.meterUsage + req.meterUsage, + req.session ) + const created = lease.created const sandbox = created.sandbox const sandboxId = sandbox.sandboxId - const abortBinding = bindSandboxAbort(sandbox, created.providerId, signal) + const sessionField = lease.session ? { sandboxSession: lease.session } : {} let billableResult: SandboxExecutionResult | undefined let billableOutputError: unknown try { throwIfAborted(signal) - // Inside the try so a failed install or mount still kills the sandbox via the - // finally below. Dependencies land before the inputs so user code and its + // Inside the try so a failed install or mount still releases the sandbox via + // the finally below. Dependencies land before the inputs so user code and its // mounts always see a complete environment. // await provisionWithinBudget(sandbox, selected, signal) @@ -846,10 +963,13 @@ async function executeInSandboxWithinBudget( ) const executionEnvironment = { ...selected?.envs, + ...req.session?.envs, ...privateInputEnvironment, } const hasExecutionEnvironment = - selected?.envs !== undefined || Object.keys(privateInputEnvironment).length > 0 + selected?.envs !== undefined || + req.session?.envs !== undefined || + Object.keys(privateInputEnvironment).length > 0 let execution: SandboxCodeResult try { @@ -878,6 +998,7 @@ async function executeInSandboxWithinBudget( stdout: execution.error.traceback || errorMessage, error: errorMessage, sandboxId, + ...sessionField, } if (execution.providerFailure !== 'provider_limit') billableResult = executionResult return executionResult @@ -906,6 +1027,7 @@ async function executeInSandboxWithinBudget( stdout: cleanedStdout, error: SIM_RESULT_CORRUPTED_ERROR, sandboxId, + ...sessionField, } } @@ -913,6 +1035,7 @@ async function executeInSandboxWithinBudget( result: extraction.result, stdout: cleanedStdout, sandboxId, + ...sessionField, } try { const { exportedFiles, exportedFileContent, collectedFiles } = await collectExportedFiles( @@ -950,8 +1073,7 @@ async function executeInSandboxWithinBudget( if (cost && billableOutputError) { attachTrustedSandboxOutputCost(billableOutputError, cost) } - abortBinding.detach() - await abortBinding.cleanup() + await lease.release() } } @@ -978,24 +1100,26 @@ async function executeShellInSandboxWithinBudget( }) throwIfAborted(signal) - const created = await createSelectedSandbox( + const lease = await leaseSandbox( kind, { imageRef: selected?.imageRef, lifetimeMs: remainingSandboxBudgetMs(signal) }, selected, signal, - req.meterUsage + req.meterUsage, + req.session ) + const created = lease.created const sandbox = created.sandbox const sandboxId = sandbox.sandboxId - const abortBinding = bindSandboxAbort(sandbox, created.providerId, signal) + const sessionField = lease.session ? { sandboxSession: lease.session } : {} let billableResult: SandboxExecutionResult | undefined let billableOutputError: unknown try { throwIfAborted(signal) - // Inside the try so a failed install or mount still kills the sandbox via the - // finally below. The install shares the caller's budget rather than adding to - // it — see the note in `executeInSandbox`. + // Inside the try so a failed install or mount still releases the sandbox via + // the finally below. The install shares the caller's budget rather than adding + // to it — see the note in `executeInSandbox`. await provisionWithinBudget(sandbox, selected, signal) await writeSandboxInputs(sandbox, req.sandboxFiles, { rootUser: true, @@ -1014,6 +1138,7 @@ async function executeShellInSandboxWithinBudget( envs: { ...selected?.envs, ...envs, + ...req.session?.envs, PATH: selected?.envs?.PATH ?? SANDBOX_SYSTEM_PATH, ...privateInputEnvironment, }, @@ -1041,7 +1166,13 @@ async function executeShellInSandboxWithinBudget( sandboxId, exitCode: result.exitCode, }) - const executionResult = { result: null, stdout, error: errorMessage, sandboxId } + const executionResult = { + result: null, + stdout, + error: errorMessage, + sandboxId, + ...sessionField, + } if (result.providerFailure !== 'provider_limit') billableResult = executionResult return executionResult } @@ -1056,6 +1187,7 @@ async function executeShellInSandboxWithinBudget( result: parsed, stdout: extraction.cleanedStdout, sandboxId, + ...sessionField, } try { const { exportedFiles, exportedFileContent, collectedFiles } = await collectExportedFiles( @@ -1093,8 +1225,7 @@ async function executeShellInSandboxWithinBudget( if (cost && billableOutputError) { attachTrustedSandboxOutputCost(billableOutputError, cost) } - abortBinding.detach() - await abortBinding.cleanup() + await lease.release() } } diff --git a/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts b/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts new file mode 100644 index 00000000000..7eb998aa599 --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts @@ -0,0 +1,215 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + SandboxCodeResult, + SandboxCommandResult, + SandboxHandle, + SandboxProvider, +} from '@/lib/execution/remote-sandbox/types' + +const { mockCreate, mockFindSessionSandbox, mockResolveWorkspaceSandbox } = vi.hoisted(() => ({ + mockCreate: vi.fn(), + mockFindSessionSandbox: vi.fn(), + mockResolveWorkspaceSandbox: vi.fn(), +})) + +vi.mock('@/lib/execution/remote-sandbox/provider', () => ({ + resolveProvider: (): SandboxProvider => ({ + id: 'e2b', + dependencyStrategy: 'prebuilt', + resolveLifetimeMs: (ms: number) => ms, + create: mockCreate, + findSessionSandbox: mockFindSessionSandbox, + }), +})) + +vi.mock('@/lib/execution/remote-sandbox/resolve', () => ({ + resolveWorkspaceSandbox: mockResolveWorkspaceSandbox, + provisionRuntimeDependencies: vi.fn(), + repairMissingSandboxImage: vi.fn().mockResolvedValue(null), + RUNTIME_INSTALL_TIMEOUT_MS: 60_000, +})) + +vi.mock('@/lib/core/execution-limits/metrics', () => ({ + recordSandboxTeardownFailure: vi.fn(), + recordSandboxProviderLimit: vi.fn(), +})) + +import { + executeInSandbox, + executeShellInSandbox, + SIM_RESULT_PREFIX, +} from '@/lib/execution/remote-sandbox' + +interface FakeSandboxCalls { + runCode: Array<{ code: string; envs?: Record }> + runCommand: Array<{ command: string; envs?: Record }> + extendLifetime: number[] + killed: boolean +} + +function fakeSandbox(id: string): { handle: SandboxHandle; calls: FakeSandboxCalls } { + const calls: FakeSandboxCalls = { runCode: [], runCommand: [], extendLifetime: [], killed: false } + const codeResult: SandboxCodeResult = { + text: `${SIM_RESULT_PREFIX}{"ok":true}`, + stdout: '', + stderr: '', + } + const commandResult: SandboxCommandResult = { stdout: 'ran', stderr: '', exitCode: 0 } + const handle: SandboxHandle = { + sandboxId: id, + async runCode(code, options) { + calls.runCode.push({ code, ...(options.envs ? { envs: options.envs } : {}) }) + return codeResult + }, + async runCommand(command, options) { + calls.runCommand.push({ command, ...(options.envs ? { envs: options.envs } : {}) }) + return commandResult + }, + async extendLifetime(lifetimeMs) { + calls.extendLifetime.push(lifetimeMs) + }, + async getFileSize() { + return 0 + }, + async readFile() { + return '' + }, + async readFileWithLimit() { + return { content: '', byteLength: 0 } + }, + async writeFile() {}, + async listFiles() { + return [] + }, + async kill() { + calls.killed = true + }, + } + return { handle, calls } +} + +const CODE_REQUEST = { + code: 'print(1)', + language: 'python' as never, + timeoutMs: 30_000, +} + +describe('session sandbox lease', () => { + beforeEach(() => { + vi.clearAllMocks() + mockResolveWorkspaceSandbox.mockResolvedValue(null) + }) + + it('creates a tagged sandbox, bootstraps it, and keeps it alive', async () => { + const { handle, calls } = fakeSandbox('sb-fresh') + mockFindSessionSandbox.mockResolvedValue(null) + mockCreate.mockResolvedValue(handle) + + const result = await executeInSandbox({ + ...CODE_REQUEST, + sandboxKind: 'mothership', + session: { key: 'mothership-chat:c1', bootstrapCommand: 'install-cli' }, + }) + + expect(result.sandboxSession).toBe('created') + expect(mockCreate).toHaveBeenCalledWith( + 'mothership', + expect.objectContaining({ sessionKey: 'mothership-chat:c1' }) + ) + expect(calls.runCommand.map((c) => c.command)).toContain('install-cli') + expect(calls.killed).toBe(false) + expect(calls.extendLifetime.length).toBeGreaterThan(0) + }) + + it('reuses a live session sandbox without creating or killing', async () => { + const { handle, calls } = fakeSandbox('sb-live') + mockFindSessionSandbox.mockResolvedValue(handle) + + const result = await executeInSandbox({ + ...CODE_REQUEST, + sandboxKind: 'mothership', + session: { key: 'mothership-chat:c1', bootstrapCommand: 'install-cli' }, + }) + + expect(result.sandboxSession).toBe('reused') + expect(mockCreate).not.toHaveBeenCalled() + // Bootstrap belongs to creation only; a reused sandbox already ran it. + expect(calls.runCommand.map((c) => c.command)).not.toContain('install-cli') + expect(calls.killed).toBe(false) + expect(calls.extendLifetime.length).toBeGreaterThanOrEqual(2) + }) + + it('injects session envs into code executions', async () => { + const { handle, calls } = fakeSandbox('sb-env') + mockFindSessionSandbox.mockResolvedValue(handle) + + await executeInSandbox({ + ...CODE_REQUEST, + sandboxKind: 'mothership', + session: { key: 'k', envs: { SIM_WORKSPACE: 'ws-1' } }, + }) + + expect(calls.runCode[0]?.envs).toMatchObject({ SIM_WORKSPACE: 'ws-1' }) + }) + + it('injects session envs into shell executions', async () => { + const { handle, calls } = fakeSandbox('sb-shell') + mockFindSessionSandbox.mockResolvedValue(handle) + + const result = await executeShellInSandbox({ + code: 'sim workflows list', + envs: { USER_VAR: '1' }, + timeoutMs: 30_000, + sandboxKind: 'mothership', + session: { key: 'k', envs: { SIM_WORKSPACE: 'ws-1' } }, + }) + + expect(result.sandboxSession).toBe('reused') + expect(calls.runCommand[0]?.envs).toMatchObject({ USER_VAR: '1', SIM_WORKSPACE: 'ws-1' }) + expect(calls.killed).toBe(false) + }) + + it('keeps the one-shot teardown when no session is requested', async () => { + const { handle, calls } = fakeSandbox('sb-oneshot') + mockCreate.mockResolvedValue(handle) + + const result = await executeInSandbox({ ...CODE_REQUEST, sandboxKind: 'mothership' }) + + expect(result.sandboxSession).toBeUndefined() + expect(mockFindSessionSandbox).not.toHaveBeenCalled() + expect(calls.killed).toBe(true) + }) + + it('ignores the session for metered executions', async () => { + const { handle, calls } = fakeSandbox('sb-metered') + mockCreate.mockResolvedValue(handle) + + const result = await executeInSandbox({ + ...CODE_REQUEST, + session: { key: 'k' }, + meterUsage: true, + }) + + expect(result.sandboxSession).toBeUndefined() + expect(mockFindSessionSandbox).not.toHaveBeenCalled() + expect(calls.killed).toBe(true) + }) + + it('falls back to a fresh create when the session lookup fails', async () => { + const { handle, calls } = fakeSandbox('sb-fallback') + mockFindSessionSandbox.mockRejectedValue(new Error('provider listing down')) + mockCreate.mockResolvedValue(handle) + + const result = await executeInSandbox({ + ...CODE_REQUEST, + sandboxKind: 'mothership', + session: { key: 'k' }, + }) + + expect(result.sandboxSession).toBe('created') + expect(calls.killed).toBe(false) + }) +}) diff --git a/apps/sim/lib/execution/remote-sandbox/types.ts b/apps/sim/lib/execution/remote-sandbox/types.ts index 10732ad35ae..dfaf1b54444 100644 --- a/apps/sim/lib/execution/remote-sandbox/types.ts +++ b/apps/sim/lib/execution/remote-sandbox/types.ts @@ -74,6 +74,29 @@ export interface SandboxExecutionRequest { signal?: AbortSignal /** Adds the remote provider cost to a completed, billable Function outcome. */ meterUsage?: boolean + /** See {@link SandboxSessionRequest} — reuses one sandbox across executions. */ + session?: SandboxSessionRequest +} + +/** + * Opts an execution into a reusable session sandbox: the first execution + * creates and tags the sandbox, later ones reconnect to it, and each one + * refreshes its idle deadline instead of killing it. Never combined with + * metered usage — session sandboxes are server-owned (Mothership), not billed + * to workspace compute. + */ +export interface SandboxSessionRequest { + /** Stable identity of the session (e.g. one per Mothership chat). */ + key: string + /** + * Shell command run once after a fresh session sandbox is created, before + * the first execution — the seam that installs tooling the image does not + * bake in yet. Best-effort: a failed bootstrap logs and the execution + * proceeds. + */ + bootstrapCommand?: string + /** Extra environment variables present on every execution in the session. */ + envs?: Record } export interface SandboxShellExecutionRequest { @@ -101,6 +124,8 @@ export interface SandboxShellExecutionRequest { signal?: AbortSignal /** Adds the remote provider cost to a completed, billable Function outcome. */ meterUsage?: boolean + /** See {@link SandboxSessionRequest} — reuses one sandbox across executions. */ + session?: SandboxSessionRequest } export interface SandboxExecutionCost { @@ -139,6 +164,13 @@ export interface SandboxExecutionResult { */ collectedFiles?: SandboxCollectedFile[] cost?: SandboxExecutionCost + /** + * Present when the execution ran in a session sandbox: `reused` means prior + * session state (files, installed packages) was still there; `created` means + * this execution started a fresh sandbox — anything earlier executions wrote + * is gone. + */ + sandboxSession?: 'created' | 'reused' } /** One harvested output file, carried as base64 with its decoded length. */ @@ -224,6 +256,11 @@ export interface SandboxHandle { } ): Promise runCommand(command: string, options: RunCommandOptions): Promise + /** + * Pushes the provider's reaping deadline out for a session sandbox that just + * served an execution. Absent on providers without session support. + */ + extendLifetime?(lifetimeMs: number): Promise /** Reads provider metadata without materializing the file contents. */ getFileSize(path: string): Promise readFile(path: string): Promise @@ -306,6 +343,11 @@ export interface CreateSandboxOptions { * and creates the sandbox as ephemeral. */ lifetimeMs?: number + /** + * Tags the sandbox as a reusable session sandbox so a later execution can + * find and reconnect to it via {@link SandboxProvider.findSessionSandbox}. + */ + sessionKey?: string /** Reports the instant immediately before the provider SDK create request is dispatched. */ onProviderRequestStarted?: (startedAtMs: number) => void } @@ -398,4 +440,14 @@ export interface SandboxProvider { /** Resolves the provider's rounded lifetime for both creation and metering. */ resolveLifetimeMs(lifetimeMs: number): number create(kind: SandboxKind, options?: CreateSandboxOptions): Promise + /** + * Reconnects to a live sandbox previously created with + * {@link CreateSandboxOptions.sessionKey}, or resolves null when none is + * running. Providers without session support omit this method; callers then + * run every execution in a fresh sandbox. + */ + findSessionSandbox?( + key: string, + options: { language?: CodeLanguage } + ): Promise } diff --git a/apps/sim/lib/function-execution/execute-request.ts b/apps/sim/lib/function-execution/execute-request.ts index 31923ad3273..6a853c0a811 100644 --- a/apps/sim/lib/function-execution/execute-request.ts +++ b/apps/sim/lib/function-execution/execute-request.ts @@ -88,6 +88,7 @@ import { type OutputFileDeclaration, resolveOutputFormat, } from '@/lib/mothership/request/tools/files' +import { buildMothershipSandboxSession } from '@/lib/mothership/tools/sandbox-session' import { validateWorkspaceFileWriteTarget, writeWorkspaceFileByPath, @@ -2252,6 +2253,7 @@ export async function executeFunctionRequest( mountedSecrets, unredactedSecretNames = [], sandboxId: selectedSandboxId, + sandboxSessionKey, blockData = {}, blockNameMapping = {}, blockOutputSchemas = {}, @@ -2304,6 +2306,14 @@ export async function executeFunctionRequest( // `environmentVariables[...]` dict narrow together — filtering only the dict // would leave `{{OTHER_SECRET}}` resolving, which is a hole, not a scope. const envVars = scopeEnvironmentVariables(rawEnvVars, secretScope, mountedSecrets) + const mothershipSession = + usesMothershipSandbox && !selectedSandboxId && sandboxSessionKey && workspaceId + ? await buildMothershipSandboxSession({ + sessionKey: sandboxSessionKey, + workspaceId, + userId: auth.attributedUserId, + }) + : undefined sourceCodeForErrors = sourceCode ?? code const outputFiles = getOutputFileDeclarations({ outputs, @@ -2670,6 +2680,7 @@ export async function executeFunctionRequest( exportedFiles, collectedFiles: shellCollectedFiles, cost: shellCost, + sandboxSession: shellSandboxSession, } = await executeShellInSandbox({ code: resolvedCode, envs: shellEnvs, @@ -2684,6 +2695,7 @@ export async function executeFunctionRequest( ...(usesMothershipSandbox && !selectedSandboxId ? { sandboxKind: 'mothership' as const } : {}), + ...(mothershipSession ? { session: mothershipSession } : {}), signal: executionSignal, meterUsage: meterRemoteSandboxUsage, }) @@ -2704,6 +2716,7 @@ export async function executeFunctionRequest( result: null, stdout: cleanStdout(shellStdout), executionTime, + ...(shellSandboxSession ? { sandboxSession: shellSandboxSession } : {}), ...(shellCost ? { cost: shellCost } : {}), }, }, @@ -2754,6 +2767,7 @@ export async function executeFunctionRequest( executionTime, files: shellOutputFiles.files, ...(shellCost ? { cost: shellCost } : {}), + ...(shellSandboxSession ? { sandboxSession: shellSandboxSession } : {}), }, }, routeContext @@ -2817,6 +2831,7 @@ export async function executeFunctionRequest( exportedFiles, collectedFiles: jsCollectedFiles, cost: sandboxCost, + sandboxSession: jsSandboxSession, } = await executeInSandbox({ code: codeForE2B, language: CodeLanguage.JavaScript, @@ -2832,6 +2847,7 @@ export async function executeFunctionRequest( ...(usesMothershipSandbox && !selectedSandboxId ? { sandboxKind: 'mothership' as const } : {}), + ...(mothershipSession ? { session: mothershipSession } : {}), signal: executionSignal, meterUsage: meterRemoteSandboxUsage, }) @@ -2863,6 +2879,7 @@ export async function executeFunctionRequest( result: null, stdout: cleanedOutput, executionTime, + ...(jsSandboxSession ? { sandboxSession: jsSandboxSession } : {}), ...(sandboxCost ? { cost: sandboxCost } : {}), }, }, @@ -2913,6 +2930,7 @@ export async function executeFunctionRequest( executionTime, files: jsOutputFiles.files, ...(sandboxCost ? { cost: sandboxCost } : {}), + ...(jsSandboxSession ? { sandboxSession: jsSandboxSession } : {}), }, }, routeContext @@ -2939,6 +2957,7 @@ export async function executeFunctionRequest( exportedFiles, collectedFiles: pythonCollectedFiles, cost: sandboxCost, + sandboxSession: pythonSandboxSession, } = await executeInSandbox({ code: codeForE2B, language: CodeLanguage.Python, @@ -2953,6 +2972,7 @@ export async function executeFunctionRequest( ...(usesMothershipSandbox && !selectedSandboxId ? { sandboxKind: 'mothership' as const } : {}), + ...(mothershipSession ? { session: mothershipSession } : {}), signal: executionSignal, meterUsage: meterRemoteSandboxUsage, }) @@ -2985,6 +3005,7 @@ export async function executeFunctionRequest( stdout: cleanedOutput, executionTime, ...(sandboxCost ? { cost: sandboxCost } : {}), + ...(pythonSandboxSession ? { sandboxSession: pythonSandboxSession } : {}), }, }, routeContext, @@ -3034,6 +3055,7 @@ export async function executeFunctionRequest( executionTime, files: pythonOutputFiles.files, ...(sandboxCost ? { cost: sandboxCost } : {}), + ...(pythonSandboxSession ? { sandboxSession: pythonSandboxSession } : {}), }, }, routeContext diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts index 1aaf96805da..2ff243780f1 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts @@ -87,5 +87,5 @@ export function agentCliHelpSection(): string { const lines = AGENT_CLI_COMMANDS.map( (command) => ` ${command.usage.padEnd(38)} ${command.summary}` ) - return `\nAgent commands (available in this environment only):\n${lines.join('\n')}\n` + return `\nAgent commands (available in this environment only):\n${lines.join('\n')}\n\nAny command's stdout can be filtered with a trailing pipe into grep (the only pipe target):\n sim workflows export | grep -in slack\n` } diff --git a/apps/sim/lib/mothership/tools/handlers/function-execute-session.test.ts b/apps/sim/lib/mothership/tools/handlers/function-execute-session.test.ts new file mode 100644 index 00000000000..35a155a4aa6 --- /dev/null +++ b/apps/sim/lib/mothership/tools/handlers/function-execute-session.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockExecuteTool } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn().mockResolvedValue({ success: true, output: {} }), +})) + +vi.mock('@/tools', () => ({ executeTool: mockExecuteTool })) +vi.mock('@/executor/utils/code-secret-references', () => ({ + extractCodeSecretNames: vi.fn().mockResolvedValue([]), +})) +vi.mock('@/executor/utils/resolved-secret-trace-registry', () => ({ + ResolvedSecretTraceRegistry: class { + getUnredactedSecretNames() { + return [] + } + exportProvenance() { + return { complete: true } + } + exportProvenanceForValue() { + return { complete: true } + } + getResolvedSecretUsage() { + return [] + } + }, +})) +vi.mock('@/lib/secrets/usage/record', () => ({ recordSecretUsage: vi.fn() })) +vi.mock('@/lib/billing/core/subscription', () => ({ + hasWorkspaceSandboxAccess: vi.fn().mockResolvedValue(true), +})) + +import type { ToolExecutionContext } from '@/lib/mothership/tool-executor/types' +import { executeFunctionExecute } from '@/lib/mothership/tools/handlers/function-execute' + +const BASE_CONTEXT: ToolExecutionContext = { + userId: 'user-1', + workspaceId: 'ws-1', + sandboxProfile: 'mothership', +} as ToolExecutionContext + +describe('executeFunctionExecute session plumbing', () => { + beforeEach(() => { + mockExecuteTool.mockClear() + }) + + it('derives the session key from the chat, one per chat', async () => { + await executeFunctionExecute( + { code: 'print(1)', language: 'python' }, + { ...BASE_CONTEXT, chatId: 'chat-123' } + ) + const [, params] = mockExecuteTool.mock.calls[0] + expect(params.sandboxSessionKey).toBe('mothership-chat:chat-123') + }) + + it('never honors a model-supplied session key', async () => { + await executeFunctionExecute( + { code: 'print(1)', language: 'python', sandboxSessionKey: 'mothership-chat:other' }, + BASE_CONTEXT + ) + const [, params] = mockExecuteTool.mock.calls[0] + expect(params.sandboxSessionKey).toBeUndefined() + }) + + it('stays ephemeral for chat-less executions', async () => { + await executeFunctionExecute({ code: 'print(1)', language: 'python' }, BASE_CONTEXT) + const [, params] = mockExecuteTool.mock.calls[0] + expect(params.sandboxSessionKey).toBeUndefined() + }) + + it('converts second-denominated timeouts, including string values', async () => { + // The catalog doc promises seconds; models also send the number as a string. + // Without the tolerant parse, "90" reached the body schema's z.coerce and + // armed a 90ms abort. + await executeFunctionExecute({ code: 'x', language: 'python', timeout: 90 }, BASE_CONTEXT) + expect(mockExecuteTool.mock.calls[0][1].timeout).toBe(90_000) + + await executeFunctionExecute({ code: 'x', language: 'python', timeout: '90' }, BASE_CONTEXT) + expect(mockExecuteTool.mock.calls[1][1].timeout).toBe(90_000) + + await executeFunctionExecute({ code: 'x', language: 'python', timeout: 45_000 }, BASE_CONTEXT) + expect(mockExecuteTool.mock.calls[2][1].timeout).toBe(45_000) + }) +}) diff --git a/apps/sim/lib/mothership/tools/handlers/function-execute.ts b/apps/sim/lib/mothership/tools/handlers/function-execute.ts index f03a90f7986..0d211f7d125 100644 --- a/apps/sim/lib/mothership/tools/handlers/function-execute.ts +++ b/apps/sim/lib/mothership/tools/handlers/function-execute.ts @@ -523,13 +523,28 @@ export async function executeFunctionExecute( const enrichedParams = omit(params, [ 'sandboxProfile', 'internalSandboxProfile', + // Server-derived below — a model-supplied value must never select a session. + 'sandboxSessionKey', PRIVATE_SECRET_PROVENANCE_FIELD, ]) + // One persistent session sandbox per chat: files and installed packages + // survive across run_code calls for iterative work, and the sim CLI is + // bootstrapped into it. Chat-less executions (one-shot, headless) stay + // ephemeral. + if (context.chatId) { + enrichedParams.sandboxSessionKey = `mothership-chat:${context.chatId}` + } // The copilot tool doc promises `timeout` in SECONDS ("Sim converts to // milliseconds", default 10, cap 300); the underlying function tool takes // MILLISECONDS. Nothing converted, so `timeout: 120` armed a 120ms abort. // Values ≤ 600 are read as seconds; larger values are assumed to already be // milliseconds (a model habit worth tolerating). Both clamp to the 300s cap. + // Models also send the value as a STRING ("90") — without the tolerant parse + // here, the body schema's z.coerce turned that into a 90ms budget. + if (typeof enrichedParams.timeout === 'string' && enrichedParams.timeout.trim() !== '') { + const parsed = Number(enrichedParams.timeout) + if (Number.isFinite(parsed)) enrichedParams.timeout = parsed + } if (typeof enrichedParams.timeout === 'number' && Number.isFinite(enrichedParams.timeout)) { const raw = enrichedParams.timeout const ms = raw <= 600 ? raw * 1000 : raw diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.test.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.test.ts new file mode 100644 index 00000000000..ea4765edf23 --- /dev/null +++ b/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.test.ts @@ -0,0 +1,102 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { applyPipeline, splitPipeline } from '@/lib/mothership/tools/handlers/sim-cli-pipe' + +describe('splitPipeline', () => { + it('returns the argv untouched when no pipe token is present', () => { + expect(splitPipeline(['workflows', 'list'])).toEqual({ + cliArgs: ['workflows', 'list'], + stages: [], + }) + }) + + it('splits the invocation from grep stages on | tokens', () => { + expect(splitPipeline(['workflows', 'export', 'w1', '|', 'grep', '-n', 'slack'])).toEqual({ + cliArgs: ['workflows', 'export', 'w1'], + stages: [['grep', '-n', 'slack']], + }) + }) + + it('supports chained grep stages', () => { + expect( + splitPipeline(['logs', 'list', '|', 'grep', 'error', '|', 'grep', '-v', 'retry']) + ).toEqual({ + cliArgs: ['logs', 'list'], + stages: [ + ['grep', 'error'], + ['grep', '-v', 'retry'], + ], + }) + }) + + it('yields an empty invocation when the argv starts with a pipe', () => { + expect(splitPipeline(['|', 'grep', 'x']).cliArgs).toEqual([]) + }) +}) + +describe('applyPipeline', () => { + const input = 'alpha slack\nbeta\ngamma SLACK\nslack delta\n' + + it('filters lines by pattern', () => { + const result = applyPipeline(input, [['grep', 'slack']]) + expect(result).toEqual({ ok: true, stdout: 'alpha slack\nslack delta' }) + }) + + it('supports -i, -n, -v, -c, and -m', () => { + expect(applyPipeline(input, [['grep', '-i', 'slack']])).toEqual({ + ok: true, + stdout: 'alpha slack\ngamma SLACK\nslack delta', + }) + expect(applyPipeline(input, [['grep', '-n', 'slack']])).toEqual({ + ok: true, + stdout: '1:alpha slack\n4:slack delta', + }) + expect(applyPipeline(input, [['grep', '-v', 'slack']])).toEqual({ + ok: true, + stdout: 'beta\ngamma SLACK\n', + }) + expect(applyPipeline(input, [['grep', '-c', '-i', 'slack']])).toEqual({ + ok: true, + stdout: '3', + }) + expect(applyPipeline(input, [['grep', '-i', '-m', '2', 'slack']])).toEqual({ + ok: true, + stdout: 'alpha slack\ngamma SLACK', + }) + }) + + it('treats the pattern as a regex with a literal fallback', () => { + expect(applyPipeline('a1\nb2\nc3', [['grep', '^[ab]']])).toEqual({ ok: true, stdout: 'a1\nb2' }) + expect(applyPipeline('cost is $4 (net', [['grep', '$4 (net']])).toEqual({ + ok: true, + stdout: 'cost is $4 (net', + }) + }) + + it('chains stages left to right', () => { + const result = applyPipeline(input, [ + ['grep', '-i', 'slack'], + ['grep', '-v', 'delta'], + ]) + expect(result).toEqual({ ok: true, stdout: 'alpha slack\ngamma SLACK' }) + }) + + it('rejects non-grep stages with guidance', () => { + const result = applyPipeline(input, [['jq', '.name']]) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.error).toContain('grep is the only pipe target') + }) + + it('rejects unsupported grep flags and missing patterns', () => { + expect(applyPipeline(input, [['grep', '-o', 'x']]).ok).toBe(false) + expect(applyPipeline(input, [['grep', '-i']]).ok).toBe(false) + expect(applyPipeline(input, [['grep', '-m', 'zero', 'x']]).ok).toBe(false) + }) + + it('validates stages against empty input for preflight use', () => { + expect(applyPipeline('', [['grep', '-n', 'x']]).ok).toBe(true) + expect(applyPipeline('', [['head', '-n', '5']]).ok).toBe(false) + }) +}) diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.ts new file mode 100644 index 00000000000..57222bd8f5d --- /dev/null +++ b/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.ts @@ -0,0 +1,102 @@ +/** + * Grep-pipe support for sim_cli invocations: + * `["workflows","export","","|","grep","-n","slack"]`. + * + * NOT a shell. A `|` token splits the argv into the CLI invocation plus grep + * stages — grep is the only supported filter, implemented natively over the + * stdout string. Nothing is spawned, and the filtering happens sim-side so a + * huge output shrinks BEFORE it crosses the wire into the model's window. + */ + +export interface PipeSplit { + cliArgs: string[] + stages: string[][] +} + +/** Splits argv on `|` tokens. A lone invocation returns zero stages. */ +export function splitPipeline(args: string[]): PipeSplit { + const segments: string[][] = [[]] + for (const arg of args) { + if (arg === '|') { + segments.push([]) + } else { + segments[segments.length - 1].push(arg) + } + } + const [cliArgs, ...stages] = segments + return { cliArgs, stages } +} + +class PipeUsageError extends Error {} + +function compileGrepPattern(raw: string, ignoreCase: boolean): (line: string) => boolean { + try { + const regex = new RegExp(raw, ignoreCase ? 'i' : '') + return (line) => regex.test(line) + } catch { + const needle = ignoreCase ? raw.toLowerCase() : raw + return (line) => (ignoreCase ? line.toLowerCase() : line).includes(needle) + } +} + +function runGrep(input: string, args: string[]): string { + let ignoreCase = false + let invert = false + let countOnly = false + let lineNumbers = false + let maxCount = Number.POSITIVE_INFINITY + const positional: string[] = [] + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg === '-i') ignoreCase = true + else if (arg === '-v') invert = true + else if (arg === '-c') countOnly = true + else if (arg === '-n') lineNumbers = true + else if (arg === '-E') { + // Patterns are compiled as regexes by default; -E is accepted as a no-op. + } else if (arg === '-m') { + maxCount = Number.parseInt(args[++i] ?? '', 10) + if (!Number.isFinite(maxCount) || maxCount < 1) { + throw new PipeUsageError('grep -m needs a positive number') + } + } else if (arg.startsWith('-')) { + throw new PipeUsageError(`grep: unsupported flag ${arg} (supported: -i -v -c -n -E -m N)`) + } else { + positional.push(arg) + } + } + const pattern = positional[0] + if (pattern === undefined) throw new PipeUsageError('grep needs a pattern') + const matches = compileGrepPattern(pattern, ignoreCase) + const out: string[] = [] + const lines = input.split('\n') + for (let lineNo = 0; lineNo < lines.length && out.length < maxCount; lineNo++) { + const hit = matches(lines[lineNo]) + if (hit !== invert) out.push(lineNumbers ? `${lineNo + 1}:${lines[lineNo]}` : lines[lineNo]) + } + return countOnly ? String(out.length) : out.join('\n') +} + +/** Applies the grep stages to stdout. Returns the filtered text, or a usage error. */ +export function applyPipeline( + stdout: string, + stages: string[][] +): { ok: true; stdout: string } | { ok: false; error: string } { + let current = stdout + for (const stage of stages) { + const [command, ...grepArgs] = stage + if (command !== 'grep') { + return { + ok: false, + error: `"${command ?? ''}" is not a supported filter. grep is the only pipe target (e.g. ... | grep -i slack). There is no shell — no other commands, redirection, or substitution.`, + } + } + try { + current = runGrep(current, grepArgs) + } catch (error) { + if (error instanceof PipeUsageError) return { ok: false, error: error.message } + throw error + } + } + return { ok: true, stdout: current } +} diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts index 63907d4bc9c..4f8e5baadd0 100644 --- a/apps/sim/lib/mothership/tools/handlers/sim-cli.ts +++ b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts @@ -12,6 +12,7 @@ import { isRootHelpInvocation, matchAgentCliCommand, } from '@/lib/mothership/tools/handlers/agent-cli' +import { applyPipeline, splitPipeline } from '@/lib/mothership/tools/handlers/sim-cli-pipe' const logger = createLogger('MothershipSimCli') @@ -24,19 +25,37 @@ const logger = createLogger('MothershipSimCli') * command tree (`sim/embed`) against this deployment's internal API base. Both * lanes share one server-minted delegation identity for the calling user, so * "the agent is the user" holds without any credential crossing to the worker. - * Root --help merges the real CLI's help with the agent-command section. + * Root --help merges the real CLI's help with the agent-command section. A + * trailing `| grep …` (the only pipe target) filters stdout sim-side so large + * outputs shrink before crossing the wire. */ export async function executeSimCli( params: Record, context: ToolExecutionContext ): Promise { - const args = params.args - if (!Array.isArray(args) || args.length === 0 || !args.every((a) => typeof a === 'string')) { + const rawArgs = params.args + if ( + !Array.isArray(rawArgs) || + rawArgs.length === 0 || + !rawArgs.every((a) => typeof a === 'string') + ) { return { success: false, error: 'sim_cli requires args: a non-empty array of argv tokens.' } } if (!context.workspaceId) { return { success: false, error: 'sim_cli requires a workspace-scoped execution context.' } } + const { cliArgs: args, stages } = splitPipeline(rawArgs) + if (args.length === 0) { + return { success: false, error: 'A pipe needs a sim CLI invocation before the first |.' } + } + + // Stages are validated before the CLI runs: a mutating command must never + // execute and then fail on a malformed pipe, or a model retry would repeat + // the mutation. + const stagePreflight = applyPipeline('', stages) + if (!stagePreflight.ok) { + return { success: false, error: stagePreflight.error } + } const apiKey = await mintDelegationToken({ workspaceId: context.workspaceId, @@ -62,11 +81,16 @@ export async function executeSimCli( if (!agentMatch && isRootHelpInvocation(args) && result.exitCode === 0) { result.stdout += agentCliHelpSection() } + if (result.exitCode === 0 && stages.length > 0) { + const piped = applyPipeline(result.stdout, stages) + if (piped.ok) result.stdout = piped.stdout + } logger.info('CLI invocation finished', { exitCode: result.exitCode, argv0: args[0], lane: agentMatch ? 'agent' : 'cli', + grepStages: stages.length, stdoutBytes: result.stdout.length, }) // The worker folds exitCode/stdout/stderr into the model window and applies diff --git a/apps/sim/lib/mothership/tools/sandbox-session.ts b/apps/sim/lib/mothership/tools/sandbox-session.ts new file mode 100644 index 00000000000..35bdafe0bc0 --- /dev/null +++ b/apps/sim/lib/mothership/tools/sandbox-session.ts @@ -0,0 +1,60 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { env } from '@/lib/core/config/env' +import { getBaseUrl } from '@/lib/core/utils/urls' +import type { SandboxSessionRequest } from '@/lib/execution/remote-sandbox/types' +import { mintDelegationToken } from '@/lib/mothership/chat/delegation' + +const logger = createLogger('MothershipSandboxSession') + +/** + * Installed once per fresh session sandbox until the mothership images bake the + * CLI in. `command -v` keeps the install one-time: a sandbox that already has + * the binary skips straight through. + */ +const SESSION_BOOTSTRAP_COMMAND = + 'command -v sim >/dev/null 2>&1 || npm install -g sim --no-fund --no-audit --loglevel=error' + +/** + * Builds the session request for a Mothership chat's persistent sandbox: the + * per-chat identity, the sim-CLI bootstrap, and the CLI's headless auth + * environment (`SIM_API_KEY`/`SIM_WORKSPACE`/`SIM_ENDPOINT`, the CLI's + * documented CI path). The token is minted sim-side per execution and injected + * per exec, so a stopped or reaped sandbox never holds a live credential and + * nothing crosses to the worker. + * + * Both failure modes degrade rather than fail the execution: without a token or + * a reachable endpoint the sandbox still persists — only `sim` inside it is + * unauthenticated. + */ +export async function buildMothershipSandboxSession(args: { + sessionKey: string + workspaceId: string + userId: string +}): Promise { + let cliEnvs: Record | undefined + try { + const apiKey = await mintDelegationToken({ + workspaceId: args.workspaceId, + userId: args.userId, + }) + const endpoint = env.MOTHERSHIP_SANDBOX_CLI_ENDPOINT?.trim() || getBaseUrl() + if (apiKey) { + cliEnvs = { + SIM_API_KEY: apiKey, + SIM_WORKSPACE: args.workspaceId, + SIM_ENDPOINT: endpoint, + } + } + } catch (error) { + logger.warn('Session sandbox CLI environment unavailable', { + workspaceId: args.workspaceId, + error: getErrorMessage(error), + }) + } + return { + key: args.sessionKey, + bootstrapCommand: SESSION_BOOTSTRAP_COMMAND, + ...(cliEnvs ? { envs: cliEnvs } : {}), + } +} diff --git a/apps/sim/tools/function/execute.test.ts b/apps/sim/tools/function/execute.test.ts index f4dc0573265..99971932822 100644 --- a/apps/sim/tools/function/execute.test.ts +++ b/apps/sim/tools/function/execute.test.ts @@ -157,4 +157,30 @@ describe('Function Execute Tool', () => { error: 'boom', }) }) + + it('preserves sandboxSession in successful and failed Function results', async () => { + const success = await functionExecuteTool.transformResponse?.( + Response.json({ + success: true, + output: { result: 1, stdout: 'ok', sandboxSession: 'reused' }, + }), + { code: 'return 1' } + ) + expect(success?.output.sandboxSession).toBe('reused') + + const failure = await functionExecuteTool.transformResponse?.( + Response.json( + { success: false, error: 'boom', output: { stdout: 'trace', sandboxSession: 'created' } }, + { status: 422 } + ), + { code: 'throw new Error("boom")' } + ) + expect(failure?.output.sandboxSession).toBe('created') + + const oneShot = await functionExecuteTool.transformResponse?.( + Response.json({ success: true, output: { result: 1, stdout: 'ok' } }), + { code: 'return 1' } + ) + expect(oneShot?.output.sandboxSession).toBeUndefined() + }) }) diff --git a/apps/sim/tools/function/execute.ts b/apps/sim/tools/function/execute.ts index ab2cde32a1d..9901d07ae66 100644 --- a/apps/sim/tools/function/execute.ts +++ b/apps/sim/tools/function/execute.ts @@ -61,6 +61,7 @@ export function buildFunctionExecuteBody(params: CodeExecutionInput): FunctionEx outputSandboxPath: params.outputSandboxPath, outputMimeType: params.outputMimeType, sandboxId: params.sandboxId, + sandboxSessionKey: params.sandboxSessionKey, secretScope: params.secretScope, mountedSecrets: params.mountedSecrets, unredactedSecretNames: params.unredactedSecretNames, @@ -245,6 +246,9 @@ To return a file, write it to ${SANDBOX_OUTPUT_DIR}. Everything there comes back // missing warns on every call, and this branch runs for every failure. files: result.output?.files ?? [], ...(result.output?.cost ? { cost: result.output.cost } : {}), + ...(result.output?.sandboxSession + ? { sandboxSession: result.output.sandboxSession } + : {}), }, error: result.error, retryable: result.retryable, @@ -261,6 +265,7 @@ To return a file, write it to ${SANDBOX_OUTPUT_DIR}. Everything there comes back stdout: result.output.stdout, files: result.output.files ?? [], ...(result.output.cost ? { cost: result.output.cost } : {}), + ...(result.output.sandboxSession ? { sandboxSession: result.output.sandboxSession } : {}), }, resources: result.resources, largeValueKeys: result.largeValueKeys, diff --git a/apps/sim/tools/function/types.ts b/apps/sim/tools/function/types.ts index 9dd38298582..ce022417e17 100644 --- a/apps/sim/tools/function/types.ts +++ b/apps/sim/tools/function/types.ts @@ -45,6 +45,11 @@ export interface CodeExecutionInput { files?: UserFile[] /** Workspace sandbox whose dependency set this execution runs against. */ sandboxId?: string + /** + * Reusable session-sandbox identity (one per Mothership chat). Honored only + * for trusted Mothership executions; workspace callers cannot opt in. + */ + sandboxSessionKey?: string /** * Which workspace secrets the code may read. Unset and `'all'` both mean every * secret, resolved at execution so ones added later are included. @@ -91,5 +96,10 @@ export interface CodeExecutionOutput extends ToolResponse { output: number total: number } + /** + * Present for session-sandbox executions (Mothership chats): `reused` means + * earlier state in the sandbox survived; `created` means it started fresh. + */ + sandboxSession?: 'created' | 'reused' } } From 538ca27936576d2e126254c08f74a70cea6c6851 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 08:48:46 +0530 Subject: [PATCH 030/306] =?UTF-8?q?feat(mothership):=20per-turn=20effort?= =?UTF-8?q?=20dial=20=E2=80=94=20composer=20dropdown,=20request=20pass-thr?= =?UTF-8?q?ough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer gains an effort dropdown (low..max, default high, persisted preference); the selection rides the chat POST and the v2 chat API (new optional effort field) through to the worker, which applies it per turn and keeps it across continuations. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/app/api/v2/chat/route.ts | 3 +- .../home/components/user-input/user-input.tsx | 16 +++++++++- .../[workspaceId]/home/hooks/use-chat.ts | 2 ++ apps/sim/lib/api/contracts/v2/chat.ts | 4 +++ apps/sim/lib/mothership/chat/payload.ts | 3 ++ apps/sim/lib/mothership/chat/post.ts | 7 +++++ apps/sim/stores/mothership-effort/store.ts | 31 +++++++++++++++++++ 7 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 apps/sim/stores/mothership-effort/store.ts diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts index f9898627565..b6085078fa1 100644 --- a/apps/sim/app/api/v2/chat/route.ts +++ b/apps/sim/app/api/v2/chat/route.ts @@ -201,7 +201,7 @@ export const POST = withRouteHandler( const parsed = await parseRequest(v2ChatContract, req, {}, { ...V2_PARSE_DEFAULTS }) if (!parsed.success) return parsed.response - const { workspaceId, message, conversationId } = parsed.data.body + const { workspaceId, message, conversationId, effort } = parsed.data.body const messageId = generateId() const requestId = generateId() @@ -353,6 +353,7 @@ export const POST = withRouteHandler( chatId, messageId, ...(integrationTools.length > 0 ? { integrationTools } : {}), + ...(effort ? { effort } : {}), } let allowExplicitAbort = true diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index e54e0baabf8..fe473fcbdb1 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -10,7 +10,7 @@ import { useRef, useState, } from 'react' -import { Chip, cn, Tooltip, toast } from '@sim/emcn' +import { Button, Chip, ChipDropdown, cn, Tooltip, toast } from '@sim/emcn' import { Paperclip, Plus, Slash } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' @@ -42,6 +42,11 @@ import { useChatInputFocus } from '@/hooks/use-chat-input-focus' import { useSettingsNavigation } from '@/hooks/use-settings-navigation' import { useVoiceInput } from '@/hooks/use-voice-input' import { type DraftPayload, useMothershipDraftsStore } from '@/stores/mothership-drafts/store' +import { + MOTHERSHIP_EFFORT_OPTIONS, + type MothershipEffort, + useMothershipEffortStore, +} from '@/stores/mothership-effort/store' import type { ChatContext } from '@/stores/panel' export type { FileAttachmentForApi } from '@/app/workspace/[workspaceId]/home/types' @@ -535,6 +540,9 @@ const UserInputImpl = forwardRef(function UserI editorRef.current.openResourceMenu({ left: rect.left, top: rect.top }) }, []) + const effort = useMothershipEffortStore((state) => state.effort) + const setEffort = useMothershipEffortStore((state) => state.setEffort) + const handleSlashTriggerClick = useCallback(() => { editorRef.current.insertSlashTrigger() }, []) @@ -610,6 +618,12 @@ const UserInputImpl = forwardRef(function UserI Skills + setEffort(value as MothershipEffort)} + />
{isSttSupported && ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 14b7fc432c5..b8bb248a741 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -120,6 +120,7 @@ import { workflowKeys } from '@/hooks/queries/workflows' import { useExecutionStream } from '@/hooks/use-execution-stream' import { snapAllSmoothText } from '@/hooks/use-smooth-text' import { useExecutionStore } from '@/stores/execution/store' +import { useMothershipEffortStore } from '@/stores/mothership-effort/store' import { useMothershipQueueStore } from '@/stores/mothership-queue/store' import type { QueuedMothershipMessage, @@ -3400,6 +3401,7 @@ export function useChat( // subagent) — the server gates the features on these flags. ...desktopChatCapabilities, userTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + effort: useMothershipEffortStore.getState().effort, }), signal: abortController.signal, }) diff --git a/apps/sim/lib/api/contracts/v2/chat.ts b/apps/sim/lib/api/contracts/v2/chat.ts index 45cf303963e..59d5d999de7 100644 --- a/apps/sim/lib/api/contracts/v2/chat.ts +++ b/apps/sim/lib/api/contracts/v2/chat.ts @@ -27,6 +27,10 @@ export const v2ChatBodySchema = z.object({ .uuid('conversationId must be a valid conversation id') .optional() .describe('Conversation to continue; a new one starts when omitted.'), + effort: z + .enum(['low', 'medium', 'high', 'xhigh', 'max']) + .optional() + .describe('Model effort for this turn; defaults to the deployment default (high).'), }) const v2ChatTokensSchema = z.object({ diff --git a/apps/sim/lib/mothership/chat/payload.ts b/apps/sim/lib/mothership/chat/payload.ts index 221eb233f17..210bdb4d8c6 100644 --- a/apps/sim/lib/mothership/chat/payload.ts +++ b/apps/sim/lib/mothership/chat/payload.ts @@ -69,6 +69,8 @@ interface BuildPayloadParams { userPermission?: string /** Plan/flag-gated org capabilities (e.g. "custom-blocks") the mothership gates tools/prompts on. */ userTimezone?: string + /** Per-turn model effort dial (user-selected in the composer). */ + effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' desktopLocalFilesystem?: boolean browser?: boolean terminalCapable?: boolean @@ -424,6 +426,7 @@ export async function buildCopilotRequestPayload( ...(integrationTools.length > 0 ? { integrationTools } : {}), ...(mothershipTools.length > 0 ? { mothershipTools } : {}), ...(params.userTimezone ? { userTimezone: params.userTimezone } : {}), + ...(params.effort ? { effort: params.effort } : {}), // The mounted chat view executes client-routed workflow tools (run panel UX), so the // UI declares that capability explicitly; headless callers omit or send [] and the // server runs those tools immediately instead of waiting out the pickup grace. diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index aac3666d3c8..2c90015c0c6 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -305,6 +305,7 @@ const ChatMessageSchema = z contexts: z.array(ChatContextSchema).optional(), commands: z.array(z.string()).optional(), userTimezone: z.string().optional(), + effort: z.enum(['low', 'medium', 'high', 'xhigh', 'max']).optional(), clientCapabilities: z.array(z.string()).optional(), desktopCapabilities: z .object({ @@ -372,6 +373,7 @@ type UnifiedChatBranch = fileAttachments?: UnifiedChatRequest['fileAttachments'] userPermission?: string userTimezone?: string + effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' workflowId: string workflowName?: string workspaceId?: string @@ -421,6 +423,7 @@ type UnifiedChatBranch = assistantSearch?: WorkspaceSearchFilters workspaceContext?: string vfs?: VfsSnapshotV1 + effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' desktopLocalFilesystem?: boolean browser?: boolean terminalCapable?: boolean @@ -1000,6 +1003,7 @@ async function resolveBranch(params: { implicitFeedback: payloadParams.implicitFeedback, userPermission: payloadParams.userPermission, userTimezone: payloadParams.userTimezone, + effort: payloadParams.effort, desktopLocalFilesystem: payloadParams.desktopLocalFilesystem, browser: payloadParams.browser, terminalCapable: payloadParams.terminalCapable, @@ -1059,6 +1063,7 @@ async function resolveBranch(params: { chatId: payloadParams.chatId, userPermission: payloadParams.userPermission, userTimezone: payloadParams.userTimezone, + effort: payloadParams.effort, desktopLocalFilesystem: payloadParams.desktopLocalFilesystem, browser: payloadParams.browser, terminalCapable: payloadParams.terminalCapable, @@ -1579,6 +1584,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { fileAttachments, userPermission: userPermission ?? undefined, userTimezone: body.userTimezone, + effort: body.effort, workflowId: branch.workflowId, workflowName: branch.workflowName, workspaceId: branch.workspaceId, @@ -1605,6 +1611,7 @@ export async function handleUnifiedChatPost(req: NextRequest) { assistantImages: assistantImages?.content, userPermission: userPermission ?? undefined, userTimezone: body.userTimezone, + effort: body.effort, desktopLocalFilesystem: body.desktopCapabilities?.localFilesystem === true, browser: body.desktopCapabilities?.browser === true, terminalCapable: body.desktopCapabilities?.terminal === true, diff --git a/apps/sim/stores/mothership-effort/store.ts b/apps/sim/stores/mothership-effort/store.ts new file mode 100644 index 00000000000..93b74cc2578 --- /dev/null +++ b/apps/sim/stores/mothership-effort/store.ts @@ -0,0 +1,31 @@ +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' + +/** The composer's model-effort dial, forwarded per request to the mothership. */ +export type MothershipEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' + +export const MOTHERSHIP_EFFORT_OPTIONS: Array<{ value: MothershipEffort; label: string }> = [ + { value: 'low', label: 'Low effort' }, + { value: 'medium', label: 'Medium effort' }, + { value: 'high', label: 'High effort' }, + { value: 'xhigh', label: 'X-high effort' }, + { value: 'max', label: 'Max effort' }, +] + +interface MothershipEffortState { + effort: MothershipEffort + setEffort: (effort: MothershipEffort) => void +} + +export const useMothershipEffortStore = create()( + devtools( + persist( + (set) => ({ + effort: 'high', + setEffort: (effort) => set({ effort }), + }), + { name: 'mothership-effort' } + ), + { name: 'mothership-effort-store' } + ) +) From df82668d8d767b6bbde15b668c0ea216da37ee23 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 09:06:58 +0530 Subject: [PATCH 031/306] feat(mothership): enrich opaque tool errors with actionable context Bare AbortSignal.timeout / abort messages ("The operation timed out.") reaching the model-facing tool result now carry the tool name, elapsed time, and the may-have-landed honesty with a verify-before-retry suggestion. Informative messages pass through untouched. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../request/tools/enrich-error.test.ts | 30 +++++++++++++++++ .../lib/mothership/request/tools/executor.ts | 32 ++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/mothership/request/tools/enrich-error.test.ts diff --git a/apps/sim/lib/mothership/request/tools/enrich-error.test.ts b/apps/sim/lib/mothership/request/tools/enrich-error.test.ts new file mode 100644 index 00000000000..2d539c0f58f --- /dev/null +++ b/apps/sim/lib/mothership/request/tools/enrich-error.test.ts @@ -0,0 +1,30 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { enrichOpaqueToolError } from '@/lib/mothership/request/tools/executor' + +describe('enrichOpaqueToolError', () => { + it('wraps the bare AbortSignal.timeout message with tool context', () => { + const out = enrichOpaqueToolError( + 'The operation timed out.', + 'run_workflow', + Date.now() - 90_000 + ) + expect(out).toContain('run_workflow timed out after ~90s') + expect(out).toContain('read the affected resource back') + }) + + it('wraps bare abort messages', () => { + expect(enrichOpaqueToolError('This operation was aborted', 'run_code', undefined)).toContain( + 'run_code timed out' + ) + }) + + it('leaves informative messages untouched', () => { + const informative = + "Tool 'run_code' timed out after 300s on the Sim executor and was abandoned." + expect(enrichOpaqueToolError(informative, 'run_code', Date.now())).toBe(informative) + expect(enrichOpaqueToolError('Invalid API key', 'sim_cli', Date.now())).toBe('Invalid API key') + }) +}) diff --git a/apps/sim/lib/mothership/request/tools/executor.ts b/apps/sim/lib/mothership/request/tools/executor.ts index 2f8b5e02658..d6774b01df8 100644 --- a/apps/sim/lib/mothership/request/tools/executor.ts +++ b/apps/sim/lib/mothership/request/tools/executor.ts @@ -272,6 +272,32 @@ export function pendingToolWaitBudgetMs( return toolWatchdogTimeoutMs(toolCall?.name) } +/** + * Bare timeout/abort messages (AbortSignal.timeout's "The operation timed out.", + * a DOMException's "This operation was aborted") strip everything the model + * needs to reason about the failure. Name the tool, the elapsed time, and the + * honest uncertainty; leave every informative message untouched. + */ +export function enrichOpaqueToolError( + message: string, + toolName: string, + startTimeMs: number | undefined +): string { + if ( + !/^(the operation timed out\.?|this operation was aborted\.?|timeout|aborted)$/i.test( + message.trim() + ) + ) { + return message + } + const elapsed = startTimeMs ? ` after ~${Math.round((Date.now() - startTimeMs) / 1000)}s` : '' + return ( + `${toolName} timed out${elapsed} inside its handler ("${message}"). ` + + 'The operation may or may not have landed — read the affected resource back before ' + + 'retrying, and prefer a narrower invocation if this was a heavy call.' + ) +} + class ToolExecutionTimeoutError extends Error { constructor(toolName: string, timeoutMs: number) { super( @@ -966,7 +992,11 @@ async function executeToolAndReportInner( endToolSpanFromTerminalState() return terminalCompletionFromToolCall(toolCall) } - const thrownMessage = toError(error).message + const thrownMessage = enrichOpaqueToolError( + toError(error).message, + toolCall.name, + toolCall.startTime + ) const projection = inspectToolResultForCopilot( { success: false, error: thrownMessage }, toolExecutionContext.resolvedSecretTraceRegistry, From 469951e566354927ab79bb576dc3952f4f55be1f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 09:32:56 +0530 Subject: [PATCH 032/306] Render the agent's live plan as a checklist card Handles the new plan envelope from the worker: the stream validator admits it, the handler upserts one plan content block in place (latest list wins), and persistence/display carry planItems through to a PlanChecklist card in the transcript. update_plan joins the hidden tool names since the card replaces its row. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../components/plan-checklist/index.ts | 1 + .../plan-checklist/plan-checklist.tsx | 49 +++++++++++++++++++ .../message-content/message-content.tsx | 32 +++++++++++- .../app/workspace/[workspaceId]/home/types.ts | 3 ++ .../lib/mothership/chat/display-message.ts | 3 ++ .../lib/mothership/chat/persisted-message.ts | 6 ++- apps/sim/lib/mothership/generated/protocol.ts | 8 ++- .../lib/mothership/request/handlers/index.ts | 2 + .../mothership/request/handlers/plan.test.ts | 42 ++++++++++++++++ .../lib/mothership/request/handlers/plan.ts | 28 +++++++++++ .../mothership/request/session/contract.ts | 22 ++++++++- apps/sim/lib/mothership/request/types.ts | 9 ++++ .../mothership/tools/client/hidden-tools.ts | 3 ++ 13 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/plan-checklist/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/plan-checklist/plan-checklist.tsx create mode 100644 apps/sim/lib/mothership/request/handlers/plan.test.ts create mode 100644 apps/sim/lib/mothership/request/handlers/plan.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/plan-checklist/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/plan-checklist/index.ts new file mode 100644 index 00000000000..a43a7d4906d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/plan-checklist/index.ts @@ -0,0 +1 @@ +export { PlanChecklist } from './plan-checklist' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/plan-checklist/plan-checklist.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/plan-checklist/plan-checklist.tsx new file mode 100644 index 00000000000..c45f8dcd10d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/plan-checklist/plan-checklist.tsx @@ -0,0 +1,49 @@ +import { Check, cn } from '@sim/emcn' +import type { AgentPlanItem } from '@/lib/mothership/request/types' + +interface PlanChecklistProps { + items: AgentPlanItem[] +} + +/** + * The agent's live plan: one card, updated in place as the worker's + * update_plan tool replaces the list. Read-only — progress display, not an + * input surface. + */ +export function PlanChecklist({ items }: PlanChecklistProps) { + if (items.length === 0) return null + + return ( +
+
+ {items.map((item, index) => ( +
+ + {item.status === 'done' && } + {item.status === 'active' && ( + + )} + + + {item.step} + +
+ ))} +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 85fae8d12b2..e19a4e9d4e7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -13,6 +13,7 @@ import { import { cn } from '@sim/emcn' import { CircleStop } from '@sim/emcn/icons' import { PrepareFileEdit, Read as ReadTool } from '@/lib/mothership/generated/tool-catalog-v1' +import type { AgentPlanItem } from '@/lib/mothership/request/types' import { isToolHiddenInUi } from '@/lib/mothership/tools/client/hidden-tools' import { resolveToolDisplay } from '@/lib/mothership/tools/client/store-utils' import { ClientToolCallState } from '@/lib/mothership/tools/client/tool-call-state' @@ -30,6 +31,7 @@ import { hasPendingAgentGroup, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-content' import { getActivityStatusTool } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-activity-group' +import { PlanChecklist } from '@/app/workspace/[workspaceId]/home/components/message-content/components/plan-checklist' import type { CredentialSubmissionPayload } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import { collectMessageSources } from '@/app/workspace/[workspaceId]/home/components/message-content/message-sources' import { resolveMessageCitations } from '@/app/workspace/[workspaceId]/home/components/message-content/resolve-citations' @@ -82,7 +84,17 @@ interface StoppedSegment { type: 'stopped' } -type MessageSegment = TextSegment | AgentGroupSegment | OptionsSegment | StoppedSegment +interface PlanSegment { + type: 'plan' + items: AgentPlanItem[] +} + +type MessageSegment = + | TextSegment + | AgentGroupSegment + | OptionsSegment + | StoppedSegment + | PlanSegment function getAgentGroupActivityKey(items: AgentGroupItem[]): string { return items @@ -123,6 +135,9 @@ function getVisibleStreamActivityKey(segments: MessageSegment[]): string { return `options:${segment.items.map((item) => `${item.id}:${item.label.length}`).join(',')}` } if (segment.type === 'stopped') return 'stopped' + if (segment.type === 'plan') { + return `plan:${segment.items.map((item) => `${item.status}:${item.step.length}`).join(',')}` + } return [ 'agent', segment.id, @@ -475,6 +490,12 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] { continue } + if (block.type === 'plan') { + if (!block.planItems?.length) continue + segments.push({ type: 'plan', items: block.planItems }) + continue + } + if (block.type === 'subagent_end') { if (block.spanId) { const g = groupsBySpanId.get(block.spanId) @@ -725,6 +746,13 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] { continue } + if (block.type === 'plan') { + if (!block.planItems?.length) continue + flushLanes() + segments.push({ type: 'plan', items: block.planItems }) + continue + } + if (block.type === 'subagent_end') { if (block.parentToolCallId) { for (const [key, g] of groupsByKey) { @@ -1081,6 +1109,8 @@ function MessageContentInner({
) + case 'plan': + return // The stopped row renders in the tail region below, in the // shimmer's place — a stop while the shimmer is visible must read // as an in-place replacement, not the shimmer vanishing from the diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index d9f9f84ce72..bc575af21f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -117,12 +117,15 @@ export const ContentBlockType = { subagent_thinking: 'subagent_thinking', options: 'options', stopped: 'stopped', + plan: 'plan', } as const export type ContentBlockType = (typeof ContentBlockType)[keyof typeof ContentBlockType] export interface ContentBlock { type: ContentBlockType content?: string + /** The agent's plan checklist (plan blocks only); whole-list, latest wins. */ + planItems?: import('@/lib/mothership/request/types').AgentPlanItem[] subagent?: string /** Orchestrator-chosen display name for a `subagent` start block (shown instead of the generic agent label). */ subagentName?: string diff --git a/apps/sim/lib/mothership/chat/display-message.ts b/apps/sim/lib/mothership/chat/display-message.ts index 863cf9e0546..27069501eae 100644 --- a/apps/sim/lib/mothership/chat/display-message.ts +++ b/apps/sim/lib/mothership/chat/display-message.ts @@ -69,6 +69,9 @@ function toDisplayBlock(block: PersistedContentBlock): ContentBlock | undefined function toDisplayBlockBody(block: PersistedContentBlock): ContentBlock | undefined { switch (block.type) { + case 'plan': + if (!block.planItems?.length) return undefined + return { type: ContentBlockType.plan, planItems: block.planItems } case MothershipStreamV1EventType.text: if (block.lane === 'subagent') { if (block.channel === 'thinking') { diff --git a/apps/sim/lib/mothership/chat/persisted-message.ts b/apps/sim/lib/mothership/chat/persisted-message.ts index d62fc59b1d3..60328ee4cf5 100644 --- a/apps/sim/lib/mothership/chat/persisted-message.ts +++ b/apps/sim/lib/mothership/chat/persisted-message.ts @@ -41,7 +41,7 @@ interface PersistedToolCall { } export interface PersistedContentBlock { - type: MothershipStreamV1EventType + type: MothershipStreamV1EventType | 'plan' lane?: MothershipStreamV1StreamScope['lane'] /** * Subagent name on lane text blocks. The span-tree parser needs a name to @@ -59,6 +59,8 @@ export interface PersistedContentBlock { /** Orchestrator-chosen display name on a subagent start block. */ name?: string toolCall?: PersistedToolCall + /** The agent's plan checklist (plan blocks only). */ + planItems?: import('@/lib/mothership/request/types').AgentPlanItem[] timestamp?: number endedAt?: number parentToolCallId?: string @@ -233,6 +235,8 @@ function mapContentBlock(block: ContentBlock): PersistedContentBlock { function mapContentBlockBody(block: ContentBlock): PersistedContentBlock { switch (block.type) { + case 'plan': + return { type: 'plan', ...(block.planItems ? { planItems: block.planItems } : {}) } case 'text': return { type: MothershipStreamV1EventType.text, diff --git a/apps/sim/lib/mothership/generated/protocol.ts b/apps/sim/lib/mothership/generated/protocol.ts index 706d46c180b..172d32669de 100644 --- a/apps/sim/lib/mothership/generated/protocol.ts +++ b/apps/sim/lib/mothership/generated/protocol.ts @@ -137,7 +137,7 @@ export interface ExecuteMessage { */ export interface StreamEnvelope { v: 1; - type: "session" | "text" | "tool" | "span" | "run" | "resource" | "error" | "complete"; + type: "session" | "text" | "tool" | "span" | "run" | "resource" | "plan" | "error" | "complete"; seq: number; /** ISO timestamp. */ ts: string; @@ -149,6 +149,12 @@ export interface StreamEnvelope { payload: Record; } +/** One step of the agent's visible plan (the update_plan tool's whole-list payload). */ +export interface PlanItem { + step: string; + status: "pending" | "active" | "done"; +} + /** One subagent lane: keyed by the delegating tool call; agentId/spanId identify the lane. */ export interface StreamScope { lane: "subagent"; diff --git a/apps/sim/lib/mothership/request/handlers/index.ts b/apps/sim/lib/mothership/request/handlers/index.ts index 3f0464d9ca1..f0be60cd7e6 100644 --- a/apps/sim/lib/mothership/request/handlers/index.ts +++ b/apps/sim/lib/mothership/request/handlers/index.ts @@ -3,6 +3,7 @@ import { MothershipStreamV1EventType } from '@/lib/mothership/generated/mothersh import type { StreamEvent, StreamingContext } from '@/lib/mothership/request/types' import { handleCompleteEvent } from './complete' import { handleErrorEvent } from './error' +import { handlePlanEvent } from './plan' import { handleResourceEvent } from './resource' import { handleRunEvent } from './run' import { handleSessionEvent } from './session' @@ -25,6 +26,7 @@ export const sseHandlers: Record = { [MothershipStreamV1EventType.complete]: handleCompleteEvent, [MothershipStreamV1EventType.error]: handleErrorEvent, [MothershipStreamV1EventType.span]: handleSpanEvent, + plan: handlePlanEvent, } export const subAgentHandlers: Record = { diff --git a/apps/sim/lib/mothership/request/handlers/plan.test.ts b/apps/sim/lib/mothership/request/handlers/plan.test.ts new file mode 100644 index 00000000000..4e6514a8934 --- /dev/null +++ b/apps/sim/lib/mothership/request/handlers/plan.test.ts @@ -0,0 +1,42 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { handlePlanEvent } from '@/lib/mothership/request/handlers/plan' +import type { StreamEvent, StreamingContext } from '@/lib/mothership/request/types' + +function contextWith(blocks: StreamingContext['contentBlocks']): StreamingContext { + return { contentBlocks: blocks } as StreamingContext +} + +const planEvent = (items: unknown): StreamEvent => + ({ type: 'plan', payload: { items } }) as unknown as StreamEvent + +describe('handlePlanEvent', () => { + it('creates one plan block and updates it in place on later events', () => { + const context = contextWith([]) + handlePlanEvent(planEvent([{ step: 'a', status: 'active' }]), context, {} as never, {} as never) + expect(context.contentBlocks).toHaveLength(1) + expect(context.contentBlocks[0].planItems).toEqual([{ step: 'a', status: 'active' }]) + + handlePlanEvent( + planEvent([ + { step: 'a', status: 'done' }, + { step: 'b', status: 'active' }, + ]), + context, + {} as never, + {} as never + ) + expect(context.contentBlocks).toHaveLength(1) + expect(context.contentBlocks[0].planItems).toHaveLength(2) + expect(context.contentBlocks[0].planItems?.[0].status).toBe('done') + }) + + it('ignores malformed payloads', () => { + const context = contextWith([]) + handlePlanEvent(planEvent(undefined), context, {} as never, {} as never) + handlePlanEvent(planEvent([]), context, {} as never, {} as never) + expect(context.contentBlocks).toHaveLength(0) + }) +}) diff --git a/apps/sim/lib/mothership/request/handlers/plan.ts b/apps/sim/lib/mothership/request/handlers/plan.ts new file mode 100644 index 00000000000..670965715ba --- /dev/null +++ b/apps/sim/lib/mothership/request/handlers/plan.ts @@ -0,0 +1,28 @@ +import type { AgentPlanItem, StreamEvent, StreamingContext } from '@/lib/mothership/request/types' +import { ContentBlockType } from '@/lib/mothership/request/types' +import type { StreamHandler } from './types' +import { addContentBlock } from './types' + +/** + * The agent's visible plan: whole-list replacement semantics (the worker's + * update_plan tool sends the complete list every call). One plan block per + * message, updated in place so the checklist renders live instead of stacking + * a card per update. + */ +export const handlePlanEvent: StreamHandler = (event: StreamEvent, context: StreamingContext) => { + const items = (event.payload as { items?: AgentPlanItem[] } | undefined)?.items + if (!Array.isArray(items) || items.length === 0) return + + for (let i = context.contentBlocks.length - 1; i >= 0; i--) { + const block = context.contentBlocks[i] + if (block.type === ContentBlockType.plan) { + block.planItems = items + block.endedAt = Date.now() + return + } + } + addContentBlock(context, { + type: ContentBlockType.plan, + planItems: items, + }) +} diff --git a/apps/sim/lib/mothership/request/session/contract.ts b/apps/sim/lib/mothership/request/session/contract.ts index b1d209b3b9a..29f0de6a653 100644 --- a/apps/sim/lib/mothership/request/session/contract.ts +++ b/apps/sim/lib/mothership/request/session/contract.ts @@ -201,7 +201,13 @@ function isStreamScope(value: unknown): value is MothershipStreamV1StreamScope { // already performs strict schema validation; the client only needs enough // structural checking to safely dispatch inside the switch statement. -const KNOWN_EVENT_TYPES: ReadonlySet = new Set(Object.values(MothershipStreamV1EventType)) +// `plan` is a worker-native extension (the update_plan checklist) not present in +// the generated Go-era enum; accepted here so the shared-path validator treats it +// as a first-class event rather than a broken stream. +const KNOWN_EVENT_TYPES: ReadonlySet = new Set([ + ...Object.values(MothershipStreamV1EventType), + 'plan', +]) function isValidEnvelopeShell(value: unknown): value is JsonRecord & { v: 1 @@ -330,11 +336,25 @@ function isContractEnvelope(value: unknown): value is MothershipStreamV1EventEnv return isValidErrorPayload(payload) case MothershipStreamV1EventType.complete: return isValidCompletePayload(payload) + case 'plan': + return isValidPlanPayload(payload) default: return false } } +/** The update_plan checklist: a whole-list payload of {step, status} items. */ +function isValidPlanPayload(payload: JsonRecord): boolean { + const items = payload.items + if (!Array.isArray(items) || items.length === 0) return false + return items.every( + (item) => + isRecordLike(item) && + typeof item.step === 'string' && + (item.status === 'pending' || item.status === 'active' || item.status === 'done') + ) +} + // Synthetic file-preview envelope validators function isSyntheticEnvelopeBase(value: unknown): value is Omit< diff --git a/apps/sim/lib/mothership/request/types.ts b/apps/sim/lib/mothership/request/types.ts index 668231925bb..119257f0a79 100644 --- a/apps/sim/lib/mothership/request/types.ts +++ b/apps/sim/lib/mothership/request/types.ts @@ -72,7 +72,14 @@ export const ContentBlockType = { subagent_text: 'subagent_text', subagent_thinking: 'subagent_thinking', subagent: 'subagent', + plan: 'plan', } as const + +/** One step of the agent's visible plan (the worker's `plan` frame payload). */ +export interface AgentPlanItem { + step: string + status: 'pending' | 'active' | 'done' +} export type ContentBlockType = (typeof ContentBlockType)[keyof typeof ContentBlockType] export interface ContentBlock { @@ -99,6 +106,8 @@ export interface ContentBlock { */ spanId?: string parentSpanId?: string + /** The agent's current plan (plan blocks only); whole-list, latest wins. */ + planItems?: AgentPlanItem[] } export interface ActiveFileIntent { diff --git a/apps/sim/lib/mothership/tools/client/hidden-tools.ts b/apps/sim/lib/mothership/tools/client/hidden-tools.ts index 2cd522a7d6c..6b5c424a111 100644 --- a/apps/sim/lib/mothership/tools/client/hidden-tools.ts +++ b/apps/sim/lib/mothership/tools/client/hidden-tools.ts @@ -8,7 +8,10 @@ // doing the work is a step toward the action, not the action. load_slide_layout // is that shape again: the file agent reading a layout from the slide library // ahead of writing the deck. +// update_plan renders as the plan checklist card, never as a tool row — the +// card IS the user-meaningful surface; the row would duplicate it as noise. const HIDDEN_TOOL_NAMES = new Set([ + 'update_plan', 'load_agent_skill', 'load_custom_tool', 'load_mcp_tool', From 785790b9dbfc069a20aa850b097cc8206896fbe6 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 11:17:25 +0530 Subject: [PATCH 033/306] Block unit-testing: variableInputs isolation runs + workflow deps command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_block/run_from_block gain variableInputs — mocked upstream outputs keyed by block name or id, resolved with the executor's own name normalization and overlaid on the run-from-block snapshot (marked executed), so a block runs in FULL isolation: with no prior execution the run starts purely from the mocks (both the execute route and the copilot server-fallback use case synthesize the empty snapshot). Unknown names and non-object mocks fail fast with actionable messages. `sim workflow deps ` (agent-CLI augmentation) lists everything one block consumes — upstream blocks with the exact paths read, loop/parallel/variable refs, {{ENV}} secrets — plus a ready-made mock skeleton, using the executor's reference shapes. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../app/api/workflows/[id]/execute/route.ts | 19 ++- .../utils/workflow-execution-utils.ts | 5 + apps/sim/executor/execution/executor.ts | 7 +- .../utils/run-from-block-overlay.test.ts | 63 +++++++++ apps/sim/executor/utils/run-from-block.ts | 65 ++++++++- apps/sim/lib/api/contracts/workflows.ts | 7 + .../tools/client/run-tool-execution.ts | 7 + .../tools/handlers/agent-cli/commands/deps.ts | 123 ++++++++++++++++++ .../tools/handlers/agent-cli/index.ts | 2 + .../mothership/tools/handlers/param-types.ts | 4 + .../tools/handlers/workflow/mutations.ts | 2 + .../application/run-workflow-from-copilot.ts | 19 ++- .../lib/workflows/executor/execute-service.ts | 2 + .../workflows/executor/execute-workflow.ts | 2 + .../lib/workflows/executor/execution-core.ts | 5 +- 15 files changed, 324 insertions(+), 8 deletions(-) create mode 100644 apps/sim/executor/utils/run-from-block-overlay.test.ts create mode 100644 apps/sim/lib/mothership/tools/handlers/agent-cli/commands/deps.ts diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 5eaf8b69310..bcfe8d6082a 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -166,6 +166,7 @@ import type { import type { BlockLog, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { getExecutionErrorStatus, hasExecutionResult } from '@/executor/utils/errors' import type { ResolvedSecretTraceProvenanceV1 } from '@/executor/utils/resolved-secret-trace-registry' +import { emptyRunFromBlockSnapshot } from '@/executor/utils/run-from-block' import { Serializer } from '@/serializer' import { CORE_TRIGGER_TYPES, type CoreTriggerType } from '@/stores/logs/filters/types' @@ -784,6 +785,7 @@ async function handleExecutePost( startBlockId: string sourceSnapshot: SerializableExecutionState sourceExecutionId?: string + variableInputs?: Record } | undefined if (rawRunFromBlock) { @@ -818,10 +820,17 @@ async function handleExecutePost( startBlockId: rawRunFromBlock.startBlockId, sourceSnapshot: rawRunFromBlock.sourceSnapshot as SerializableExecutionState, } + } else if (rawRunFromBlock.variableInputs && !isPublicApiAccess) { + // Pure-mock isolated run: no prior execution needed — the caller supplies + // every upstream output the block reads (the executor overlays them). + resolvedRunFromBlock = { + startBlockId: rawRunFromBlock.startBlockId, + sourceSnapshot: emptyRunFromBlockSnapshot(), + } } else { return NextResponse.json( { - error: `No execution state found for ${rawRunFromBlock.executionId === 'latest' ? 'workflow' : `execution ${rawRunFromBlock.executionId}`}. Run the full workflow first.`, + error: `No execution state found for ${rawRunFromBlock.executionId === 'latest' ? 'workflow' : `execution ${rawRunFromBlock.executionId}`}. Run the full workflow first, or pass variableInputs mocking the upstream outputs.`, }, { status: 400 } ) @@ -840,12 +849,20 @@ async function handleExecutePost( startBlockId: rawRunFromBlock.startBlockId, sourceSnapshot: rawRunFromBlock.sourceSnapshot as SerializableExecutionState, } + } else if (rawRunFromBlock.variableInputs && !isPublicApiAccess) { + resolvedRunFromBlock = { + startBlockId: rawRunFromBlock.startBlockId, + sourceSnapshot: emptyRunFromBlockSnapshot(), + } } else { return NextResponse.json( { error: 'runFromBlock requires either sourceSnapshot or executionId' }, { status: 400 } ) } + if (resolvedRunFromBlock && rawRunFromBlock.variableInputs && !isPublicApiAccess) { + resolvedRunFromBlock.variableInputs = rawRunFromBlock.variableInputs + } } // For API key and internal JWT auth, the entire body is the input (except for our control fields) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts index 8b50f9ae087..bc0c9d32afe 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils.ts @@ -964,6 +964,8 @@ export interface WorkflowExecutionOptions { runFromBlock?: { startBlockId: string executionId?: string + /** Mocked upstream outputs (block name/id → output object) overlaid server-side. */ + variableInputs?: Record } } @@ -1037,6 +1039,9 @@ export async function executeWorkflowWithFullLogging( runFromBlock: { startBlockId: options.runFromBlock.startBlockId, executionId: options.runFromBlock.executionId || 'latest', + ...(options.runFromBlock.variableInputs + ? { variableInputs: options.runFromBlock.variableInputs } + : {}), }, } : {}), diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index 3c7b23e9a6a..d709eaccba1 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -25,6 +25,7 @@ import { type ClonedSubflowInfo, ParallelExpander } from '@/executor/utils/paral import { isResolvedSecretTraceProvenanceV1 } from '@/executor/utils/resolved-secret-trace-registry' import { computeExecutionSets, + overlayVariableInputs, type RunFromBlockContext, resolveContainerToSentinelStart, validateRunFromBlock, @@ -129,8 +130,12 @@ export class DAGExecutor { async executeFromBlock( workflowId: string, startBlockId: string, - sourceSnapshot: SerializableExecutionState + sourceSnapshot: SerializableExecutionState, + variableInputs?: Record ): Promise { + if (variableInputs && Object.keys(variableInputs).length > 0) { + sourceSnapshot = overlayVariableInputs(this.workflow, sourceSnapshot, variableInputs) + } // Build full DAG with all blocks to compute upstream set for snapshot filtering // includeAllBlocks is needed because the startBlockId might be a trigger not reachable from the main trigger const dag = this.dagBuilder.build(this.workflow, { includeAllBlocks: true }) diff --git a/apps/sim/executor/utils/run-from-block-overlay.test.ts b/apps/sim/executor/utils/run-from-block-overlay.test.ts new file mode 100644 index 00000000000..ac5d937478d --- /dev/null +++ b/apps/sim/executor/utils/run-from-block-overlay.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { emptyRunFromBlockSnapshot, overlayVariableInputs } from '@/executor/utils/run-from-block' +import type { SerializedWorkflow } from '@/serializer/types' + +const workflow = { + version: '1', + blocks: [ + { id: 'b-start', metadata: { id: 'starter', name: 'Start' } }, + { id: 'b-fetch', metadata: { id: 'api', name: 'Fetch Orders' } }, + { id: 'b-fn', metadata: { id: 'function', name: 'Transform' } }, + ], + connections: [], + loops: {}, +} as unknown as SerializedWorkflow + +describe('overlayVariableInputs', () => { + it('resolves block names with executor normalization and marks mocks executed', () => { + const out = overlayVariableInputs(workflow, emptyRunFromBlockSnapshot(), { + 'fetch orders': { content: 'rows' }, + }) + expect(out.blockStates['b-fetch']).toEqual({ + output: { content: 'rows' }, + executed: true, + executionTime: 0, + }) + expect(out.executedBlocks).toContain('b-fetch') + }) + + it('accepts raw block ids and overrides existing snapshot state', () => { + const snapshot = emptyRunFromBlockSnapshot() + snapshot.blockStates['b-fetch'] = { + output: { content: 'stale' }, + executed: true, + executionTime: 5, + } + snapshot.executedBlocks.push('b-fetch') + const out = overlayVariableInputs(workflow, snapshot, { 'b-fetch': { content: 'fresh' } }) + expect(out.blockStates['b-fetch'].output).toEqual({ content: 'fresh' }) + expect(out.executedBlocks.filter((id) => id === 'b-fetch')).toHaveLength(1) + }) + + it('fails fast on unknown block names, listing what exists', () => { + expect(() => + overlayVariableInputs(workflow, emptyRunFromBlockSnapshot(), { nope: { a: 1 } }) + ).toThrow(/no block named "nope".*Fetch Orders/s) + }) + + it('rejects non-object mocks with an actionable message', () => { + expect(() => + overlayVariableInputs(workflow, emptyRunFromBlockSnapshot(), { Transform: 'scalar' }) + ).toThrow(/must be an object/) + }) + + it('does not mutate the input snapshot', () => { + const snapshot = emptyRunFromBlockSnapshot() + overlayVariableInputs(workflow, snapshot, { Transform: { ok: true } }) + expect(snapshot.blockStates).toEqual({}) + expect(snapshot.executedBlocks).toEqual([]) + }) +}) diff --git a/apps/sim/executor/utils/run-from-block.ts b/apps/sim/executor/utils/run-from-block.ts index 9d6a25b2065..6ecefd81b50 100644 --- a/apps/sim/executor/utils/run-from-block.ts +++ b/apps/sim/executor/utils/run-from-block.ts @@ -1,5 +1,9 @@ -import { LOOP, PARALLEL } from '@/executor/constants' +import { isPlainRecord } from '@sim/utils/object' +import { LOOP, normalizeName, PARALLEL } from '@/executor/constants' import type { DAG } from '@/executor/dag/builder' +import type { SerializableExecutionState } from '@/executor/execution/types' +import type { NormalizedBlockOutput } from '@/executor/types' +import type { SerializedWorkflow } from '@/serializer/types' /** * Builds the sentinel-start node ID for a loop. @@ -255,3 +259,62 @@ function findParentParallel(blockId: string, dag: DAG): string | undefined { } return undefined } + +/** + * Overlays caller-provided mock outputs onto a run-from-block snapshot so a + * block can run in isolation: each entry (keyed by block name or id) becomes + * that block's state, marked executed, overriding whatever the snapshot holds. + * Name matching uses the executor's own normalization — the same rule reference + * resolution applies — and an unknown key fails fast with the names that do + * exist, since a silently dropped mock would just resurface as an unresolved + * reference deeper in the run. + */ +export function overlayVariableInputs( + workflow: SerializedWorkflow, + snapshot: SerializableExecutionState, + variableInputs: Record +): SerializableExecutionState { + const idsByName = new Map() + for (const block of workflow.blocks) { + if (block.metadata?.name) idsByName.set(normalizeName(block.metadata.name), block.id) + } + const blockIds = new Set(workflow.blocks.map((block) => block.id)) + + const blockStates = { ...snapshot.blockStates } + const executedBlocks = [...snapshot.executedBlocks] + for (const [key, value] of Object.entries(variableInputs)) { + const blockId = blockIds.has(key) ? key : idsByName.get(normalizeName(key)) + if (!blockId) { + const known = workflow.blocks + .map((block) => block.metadata?.name) + .filter((name): name is string => Boolean(name)) + throw new Error( + `variableInputs: no block named "${key}" in this workflow. Blocks: ${known.join(', ')}` + ) + } + if (!isPlainRecord(value)) { + throw new Error( + `variableInputs["${key}"] must be an object shaped like that block's output (references read paths off it).` + ) + } + blockStates[blockId] = { + output: value as NormalizedBlockOutput, + executed: true, + executionTime: 0, + } + if (!executedBlocks.includes(blockId)) executedBlocks.push(blockId) + } + return { ...snapshot, blockStates, executedBlocks } +} + +/** The empty prior-state a pure-mock isolated run starts from. */ +export function emptyRunFromBlockSnapshot(): SerializableExecutionState { + return { + blockStates: {}, + executedBlocks: [], + blockLogs: [], + decisions: { router: {}, condition: {} }, + completedLoops: [], + activeExecutionPath: [], + } +} diff --git a/apps/sim/lib/api/contracts/workflows.ts b/apps/sim/lib/api/contracts/workflows.ts index 5bd017e428d..95c7445fff7 100644 --- a/apps/sim/lib/api/contracts/workflows.ts +++ b/apps/sim/lib/api/contracts/workflows.ts @@ -333,6 +333,13 @@ export type ReorderWorkflowsBody = z.input export const executeWorkflowRunFromBlockSchema = z.object({ startBlockId: requiredFieldSchema('Start block ID is required'), + /** + * Mocked upstream outputs keyed by block name or id: each entry becomes that + * block's state (marked executed) so the start block runs in isolation + * without a prior execution — and overlays the resolved snapshot when one + * exists. Names resolve server-side with the executor's own normalization. + */ + variableInputs: z.record(z.string(), z.unknown()).optional(), sourceSnapshot: z .object({ blockStates: z.record(z.string(), z.any()), diff --git a/apps/sim/lib/mothership/tools/client/run-tool-execution.ts b/apps/sim/lib/mothership/tools/client/run-tool-execution.ts index 488e4b231a2..07e27de5f2c 100644 --- a/apps/sim/lib/mothership/tools/client/run-tool-execution.ts +++ b/apps/sim/lib/mothership/tools/client/run-tool-execution.ts @@ -550,16 +550,23 @@ async function doExecuteRunTool( })() const runFromBlock = (() => { + // Mocked upstream outputs ride through to the server, which overlays them on + // the latest snapshot — or runs purely from them when no execution exists. + const variableInputs = isPlainRecord(params.variableInputs) + ? (params.variableInputs as Record) + : undefined if (toolName === RunFromBlock.id && params.startBlockId) { return { startBlockId: params.startBlockId as string, executionId: (params.executionId as string | undefined) || 'latest', + ...(variableInputs ? { variableInputs } : {}), } } if (toolName === RunBlock.id && params.blockId) { return { startBlockId: params.blockId as string, executionId: (params.executionId as string | undefined) || 'latest', + ...(variableInputs ? { variableInputs } : {}), } } return undefined diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/deps.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/deps.ts new file mode 100644 index 00000000000..0201f59f0dc --- /dev/null +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/deps.ts @@ -0,0 +1,123 @@ +import { fetchWorkflowState } from '@/lib/mothership/tools/handlers/agent-cli/commands/workflow-views' +import { + type AgentCliCommand, + agentCliFail, + agentCliOk, +} from '@/lib/mothership/tools/handlers/agent-cli/types' +import { normalizeName, SPECIAL_REFERENCE_PREFIXES } from '@/executor/constants' + +/** + * `workflow deps ` — everything one block consumes, so the + * agent knows exactly what to mock before running it in isolation with + * run_block's variableInputs. Tokens are extracted with the same shapes the + * executor resolves (`` templates, `{{ENV}}` secrets) and block heads + * are matched through the executor's own name normalization — this command must + * never re-invent resolution semantics. + */ + +const TEMPLATE_REF = /<([^<>]+)>/g +const ENV_REF = /\{\{\s*([A-Za-z0-9_-]+)\s*\}\}/g + +interface DepView { + token: string + kind: 'block' | 'loop' | 'parallel' | 'variable' | 'env' | 'unknown' + blockId?: string + blockName?: string + paths?: string[] +} + +function stringLeaves(value: unknown, out: string[]): void { + if (typeof value === 'string') out.push(value) + else if (Array.isArray(value)) for (const item of value) stringLeaves(item, out) + else if (typeof value === 'object' && value !== null) + for (const item of Object.values(value)) stringLeaves(item, out) +} + +export const workflowDepsCommand: AgentCliCommand = { + path: ['workflow', 'deps'], + summary: + 'List every reference one block consumes (upstream blocks, variables, env) — what to mock for an isolated run', + usage: 'workflow deps ', + async execute(rest, runtime) { + const [workflowId, blockId] = rest + if (!workflowId || !blockId) + return agentCliFail('Usage: sim workflow deps ') + const state = await fetchWorkflowState(runtime, workflowId) + const blocks = (state.blocks ?? {}) as Record> + const block = blocks[blockId] + if (!block) return agentCliFail(`No block ${blockId} in workflow ${workflowId}`) + + const nameToId = new Map() + const idToName = new Map() + for (const [id, raw] of Object.entries(blocks)) { + const name = typeof raw.name === 'string' ? raw.name : undefined + if (name) { + nameToId.set(normalizeName(name), id) + idToName.set(id, name) + } + } + + const leaves: string[] = [] + stringLeaves(block.subBlocks ?? block, leaves) + + const byToken = new Map() + const envs = new Set() + for (const leaf of leaves) { + for (const match of leaf.matchAll(TEMPLATE_REF)) { + const token = match[1] + if (!token || byToken.has(token)) continue + const [head = '', ...pathParts] = token.split('.') + const path = pathParts.join('.') + const special = (SPECIAL_REFERENCE_PREFIXES as readonly string[]).includes(head) + if (special) { + byToken.set(token, { token, kind: head as 'loop' | 'parallel' | 'variable' }) + continue + } + const refBlockId = blocks[head] ? head : nameToId.get(normalizeName(head)) + if (refBlockId && refBlockId !== blockId) { + const existing = [...byToken.values()].find((d) => d.blockId === refBlockId) + if (existing) { + if (path && !existing.paths?.includes(path)) existing.paths?.push(path) + byToken.set(token, existing) + } else { + byToken.set(token, { + token, + kind: 'block', + blockId: refBlockId, + blockName: idToName.get(refBlockId), + paths: path ? [path] : [], + }) + } + } else if (!refBlockId) { + byToken.set(token, { token, kind: 'unknown' }) + } + } + for (const match of leaf.matchAll(ENV_REF)) { + if (match[1]) envs.add(match[1]) + } + } + + const deps = [...new Set(byToken.values())] + const blockDeps = deps.filter((d) => d.kind === 'block') + return agentCliOk( + JSON.stringify( + { + blockId, + blockName: idToName.get(blockId), + references: deps, + env: [...envs].sort(), + // Ready-made skeleton for run_block's variableInputs: mock each upstream + // block's output at the paths this block actually reads. + mock: Object.fromEntries( + blockDeps.map((d) => [ + d.blockName ?? d.blockId, + d.paths?.length ? d.paths : [''], + ]) + ), + }, + null, + 2 + ) + ) + }, +} diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts index 2ff243780f1..63edd96107c 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts @@ -1,3 +1,4 @@ +import { workflowDepsCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/deps' import { filesGrepCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/files-grep' import { workflowGrepCommand, @@ -22,6 +23,7 @@ import { */ const AGENT_CLI_COMMANDS: readonly AgentCliCommand[] = [ filesGrepCommand, + workflowDepsCommand, workflowBlocksCommand, workflowEdgesCommand, workflowGrepCommand, diff --git a/apps/sim/lib/mothership/tools/handlers/param-types.ts b/apps/sim/lib/mothership/tools/handlers/param-types.ts index f562a1dd7f8..6f99adbcc92 100644 --- a/apps/sim/lib/mothership/tools/handlers/param-types.ts +++ b/apps/sim/lib/mothership/tools/handlers/param-types.ts @@ -83,6 +83,8 @@ export interface RunWorkflowUntilBlockParams { export interface RunFromBlockParams { workflowId?: string + /** Mocked upstream outputs (block name/id → output object) for an isolated run. */ + variableInputs?: Record /** The block ID to start execution from. */ startBlockId: string /** Optional execution ID to load the snapshot from. If omitted, uses the latest execution. */ @@ -94,6 +96,8 @@ export interface RunFromBlockParams { export interface RunBlockParams { workflowId?: string + /** Mocked upstream outputs (block name/id → output object) for an isolated run. */ + variableInputs?: Record /** The block ID to run. Only this block executes using cached upstream outputs. */ blockId: string /** Optional execution ID to load the snapshot from. If omitted, uses the latest execution. */ diff --git a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts index 4d91e01a8a9..e51e3f293b9 100644 --- a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts @@ -509,6 +509,7 @@ export async function executeRunFromBlock( const useDraftState = !params.useDeployedState const result = await executeCopilotWorkflowUseCase(context, runFromBlockFromCopilot, { workflowId, + variableInputs: params.variableInputs, assertedWorkspaceId: context.workspaceId, useDraftState, blockId: params.startBlockId, @@ -597,6 +598,7 @@ export async function executeRunBlock( const useDraftState = !params.useDeployedState const result = await executeCopilotWorkflowUseCase(context, runBlockFromCopilot, { workflowId, + variableInputs: params.variableInputs, assertedWorkspaceId: context.workspaceId, useDraftState, blockId: params.blockId, diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts index 9dfc420aae4..7a07f3d3426 100644 --- a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts @@ -30,6 +30,7 @@ import { import type { SerializableExecutionState } from '@/executor/execution/types' import type { ExecutionResult } from '@/executor/types' import { attachAttemptedExecutionId, hasExecutionResult } from '@/executor/utils/errors' +import { emptyRunFromBlockSnapshot } from '@/executor/utils/run-from-block' const logger = createLogger('CopilotWorkflowRun') @@ -83,6 +84,8 @@ interface SnapshotCopilotRunInput extends BaseCopilotRunInput { blockId: string workflowInput?: unknown sourceExecutionId?: string + /** Mocked upstream outputs — lets the block run with no prior execution at all. */ + variableInputs?: Record } export interface RunFromBlockFromCopilotInput extends SnapshotCopilotRunInput {} @@ -196,7 +199,7 @@ async function resolveTriggerExecution(params: { } async function resolveSourceSnapshot(input: SnapshotCopilotRunInput): Promise<{ - executionId: string + executionId?: string snapshot: SerializableExecutionState }> { if (input.sourceExecutionId) { @@ -209,9 +212,15 @@ async function resolveSourceSnapshot(input: SnapshotCopilotRunInput): Promise<{ } const latest = await getLatestExecutionStateWithExecutionId(input.workflowId) if (latest?.state) return { executionId: latest.executionId, snapshot: latest.state } + // Pure-mock isolated run: with variableInputs the executor overlays every upstream + // output the block reads, so no prior execution is required. No executionId means + // the snapshot is treated as untrusted, exactly like a caller-supplied one. + if (input.variableInputs && Object.keys(input.variableInputs).length > 0) { + return { snapshot: emptyRunFromBlockSnapshot() } + } throw new OrchestrationError( 'not_found', - `No execution state found for workflow ${input.workflowId}. Run the full workflow first to create a snapshot.` + `No execution state found for workflow ${input.workflowId}. Run the full workflow first to create a snapshot, or pass variableInputs mocking the upstream outputs.` ) } @@ -225,7 +234,8 @@ async function executeCopilotRun(params: { runFromBlock?: { startBlockId: string sourceSnapshot: SerializableExecutionState - sourceExecutionId: string + sourceExecutionId?: string + variableInputs?: Record } }): Promise { if ( @@ -445,7 +455,8 @@ function defineSnapshotRunUseCase( runFromBlock: { startBlockId: input.blockId, sourceSnapshot: source.snapshot, - sourceExecutionId: source.executionId, + ...(source.executionId ? { sourceExecutionId: source.executionId } : {}), + ...(input.variableInputs ? { variableInputs: input.variableInputs } : {}), }, stopAfterBlockId: stopAtStartBlock ? input.blockId : undefined, }) diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts index a5071f9a737..bc78aa61186 100644 --- a/apps/sim/lib/workflows/executor/execute-service.ts +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -121,6 +121,8 @@ export interface ExecuteWorkflowServiceParams { startBlockId: string sourceSnapshot: SerializableExecutionState sourceExecutionId: string + /** Mocked upstream outputs (block name/id → output object) overlaid on the snapshot. */ + variableInputs?: Record } } diff --git a/apps/sim/lib/workflows/executor/execute-workflow.ts b/apps/sim/lib/workflows/executor/execute-workflow.ts index f5682b78025..fc6332679ac 100644 --- a/apps/sim/lib/workflows/executor/execute-workflow.ts +++ b/apps/sim/lib/workflows/executor/execute-workflow.ts @@ -63,6 +63,8 @@ export interface ExecuteWorkflowOptions { startBlockId: string sourceSnapshot: SerializableExecutionState sourceExecutionId?: string + /** Mocked upstream outputs (block name/id → output object) overlaid on the snapshot. */ + variableInputs?: Record } /** Trusted encrypted provenance supplied by a server-only caller before execution starts. */ trustedInitialResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1 diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index bd5c24234db..a067d4da799 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -131,6 +131,8 @@ export interface ExecuteWorkflowCoreOptions { startBlockId: string sourceSnapshot: SerializableExecutionState sourceExecutionId?: string + /** Mocked upstream outputs (block name/id → output object) overlaid on the snapshot. */ + variableInputs?: Record } } @@ -1096,7 +1098,8 @@ async function executeWorkflowCoreImpl( ? ((await executorInstance.executeFromBlock( workflowId, runFromBlock.startBlockId, - runFromBlock.sourceSnapshot + runFromBlock.sourceSnapshot, + runFromBlock.variableInputs )) as ExecutionResult) : ((await executorInstance.execute(workflowId, resolvedTriggerBlockId)) as ExecutionResult) From 0d2f72b2b582f8ab82e57ff4eb21f42de077bd21 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 12:22:22 +0530 Subject: [PATCH 034/306] Release pure-mock isolated run outputs past the provenance guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An EMPTY server-synthesized run-from-block snapshot restores no values at all, so there is nothing whose provenance could be untrusted — yet the registry latched restored-provenance-untrusted on it, and the egress projection withheld every pure-mock run_block result from the model (found by the deep-eval lane: the agent ran its isolated unit test, then fished the invisible output out of logs and gave up). The carve-out is exact: only a snapshot with NO block states and NO executed blocks skips the latch; any content-bearing snapshot keeps the guard, and live secret resolutions register normally either way. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/lib/workflows/executor/execution-core.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index a067d4da799..c83314e5f82 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -618,10 +618,18 @@ async function executeWorkflowCoreImpl( const restoredState = runFromBlock?.sourceSnapshot ?? (resumeFromSnapshot ? snapshot.state : undefined) const restoreTrusted = resumeFromSnapshot || Boolean(runFromBlock?.sourceExecutionId) + // An EMPTY snapshot (the server-synthesized base for a pure-mock isolated + // block run) restores no values at all, so there is nothing whose provenance + // could be untrusted — latching incomplete here withheld every isolated + // unit-test result from the model. Any snapshot WITH content keeps the guard. + const restoredStateEmpty = + restoredState !== undefined && + Object.keys(restoredState.blockStates ?? {}).length === 0 && + (restoredState.executedBlocks ?? []).length === 0 const trustedLargeValueAccess = restoreTrusted ? restoredState?.trustedLargeValueAccess : undefined - const requireRestoredProvenance = restoredState !== undefined + const requireRestoredProvenance = restoredState !== undefined && !restoredStateEmpty resolvedSecretTraceRegistry = await createResolvedSecretTraceRegistry({ personalEncrypted, workspaceEncrypted, @@ -636,7 +644,7 @@ async function executeWorkflowCoreImpl( requireRestoredProvenance, scope: { userId: personalEnvUserId ?? workspaceEnvUserId, workspaceId: providedWorkspaceId }, }) - if (restoredState && !restoreTrusted) { + if (restoredState && !restoreTrusted && !restoredStateEmpty) { resolvedSecretTraceRegistry.markIncomplete('restored-provenance-untrusted') } if (options.trustedInitialResolvedSecretTraceProvenance !== undefined) { From 7d4375f2b48166a5d89e9ca95dc56bdc64b7e1f3 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 12:51:13 +0530 Subject: [PATCH 035/306] Catalog speaks the authoring vocabulary; grep pipe gains context flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-cause fixes for the deep-eval lane's waste findings, not fallback patches. The agent called `blocks get loop` because add_block accepts type "loop" — the catalog omitting containers meant one surface taught a vocabulary the other 404'd on. Loop and Parallel now have catalog descriptors (list + get) whose option lists import the authoring constants (VALID_LOOP_TYPES/VALID_PARALLEL_TYPES exported from the editing operations), so the surfaces cannot drift; outputs document the container reference semantics. Live-verified via the CLI. The grep pipe gains -A/-B/-C context flags (window union, hit-based -c counting) — "grep" implies them, and their absence cost real calls. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../catalog/application/catalog-reads.test.ts | 50 +++++- apps/sim/lib/catalog/application/get-block.ts | 5 + .../lib/catalog/application/list-blocks.ts | 12 +- .../catalog/projection/container-blocks.ts | 165 ++++++++++++++++++ .../tools/handlers/sim-cli-pipe.test.ts | 21 +++ .../mothership/tools/handlers/sim-cli-pipe.ts | 40 ++++- apps/sim/lib/workflows/editing/operations.ts | 14 +- 7 files changed, 286 insertions(+), 21 deletions(-) create mode 100644 apps/sim/lib/catalog/projection/container-blocks.ts diff --git a/apps/sim/lib/catalog/application/catalog-reads.test.ts b/apps/sim/lib/catalog/application/catalog-reads.test.ts index fa9b0b6f346..8e4b7d7faf8 100644 --- a/apps/sim/lib/catalog/application/catalog-reads.test.ts +++ b/apps/sim/lib/catalog/application/catalog-reads.test.ts @@ -286,7 +286,9 @@ describe('catalog block and tool reads', () => { expect(result.entries.map((entry) => entry.id)).toEqual([ 'custom_block_reports', + 'loop', 'notion', + 'parallel', 'slack', ]) expect(result.hasMore).toBe(false) @@ -296,7 +298,7 @@ describe('catalog block and tool reads', () => { it('accepts a workspace API key, which has no user for permission groups to key on', async () => { const result = await listCatalogBlocks.execute({ principal: workspaceKey, input: listInput }) - expect(result.entries).toHaveLength(3) + expect(result.entries).toHaveLength(5) expect(mocks.allowedIntegrationTypes).toHaveBeenCalledWith(workspaceKey, WORKSPACE_ID) expect(mocks.getBlockVisibility).toHaveBeenCalledWith({ orgId: 'org-1' }) }) @@ -313,7 +315,9 @@ describe('catalog block and tool reads', () => { const sources = Object.fromEntries(result.entries.map((entry) => [entry.id, entry.source])) expect(sources).toEqual({ custom_block_reports: 'custom', + loop: 'builtin', notion: 'builtin', + parallel: 'builtin', slack: 'builtin', }) }) @@ -338,7 +342,7 @@ describe('catalog block and tool reads', () => { mocks.getAllBlocks.mockReturnValue([slackBlock, previewBlock]) const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) - expect(result.entries.map((entry) => entry.id)).toEqual(['slack']) + expect(result.entries.map((entry) => entry.id)).toEqual(['loop', 'parallel', 'slack']) await expect( getCatalogBlock.execute({ @@ -387,7 +391,7 @@ describe('catalog block and tool reads', () => { mocks.allowedIntegrationTypes.mockResolvedValue(new Set(['slack_v2'])) const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) - expect(result.entries.map((entry) => entry.id)).toEqual(['slack']) + expect(result.entries.map((entry) => entry.id)).toEqual(['loop', 'parallel', 'slack']) await expect( getCatalogBlock.execute({ @@ -401,7 +405,12 @@ describe('catalog block and tool reads', () => { mocks.isDeploymentAvailable.mockImplementation((type: string) => type !== 'notion') const result = await listCatalogBlocks.execute({ principal: session, input: listInput }) - expect(result.entries.map((entry) => entry.id)).toEqual(['custom_block_reports', 'slack']) + expect(result.entries.map((entry) => entry.id)).toEqual([ + 'custom_block_reports', + 'loop', + 'parallel', + 'slack', + ]) }) it('narrows to trigger-capable blocks without a second endpoint', async () => { @@ -424,15 +433,35 @@ describe('catalog block and tool reads', () => { principal: session, input: { ...listInput, limit: 2 }, }) - expect(first.entries.map((entry) => entry.id)).toEqual(['custom_block_reports', 'notion']) + expect(first.entries.map((entry) => entry.id)).toEqual(['custom_block_reports', 'loop']) expect(first.hasMore).toBe(true) const second = await listCatalogBlocks.execute({ principal: session, input: { ...listInput, limit: 2, offset: 2 }, }) - expect(second.entries.map((entry) => entry.id)).toEqual(['slack']) - expect(second.hasMore).toBe(false) + expect(second.entries.map((entry) => entry.id)).toEqual(['notion', 'parallel']) + expect(second.hasMore).toBe(true) + }) + + it('answers for the loop and parallel containers with their authoring shape', async () => { + const { block: loop } = await getCatalogBlock.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, blockId: 'loop' }, + }) + expect(loop.id).toBe('loop') + expect( + loop.inputSchema.find((field) => field.id === 'loopType')?.options?.map((o) => o.id) + ).toEqual(['for', 'forEach', 'while', 'doWhile']) + expect(Object.keys(loop.outputs)).toContain('results') + + const { block: parallel } = await getCatalogBlock.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, blockId: 'parallel' }, + }) + expect( + parallel.inputSchema.find((field) => field.id === 'parallelType')?.options?.map((o) => o.id) + ).toEqual(['count', 'collection']) }) it('reads one block with its operations and tools resolved from metadata', async () => { @@ -566,7 +595,12 @@ describe('catalog block and tool reads', () => { * positive by code unit. Pinning the code-unit answer is what makes an * offset cursor name the same row on every instance, whatever its `LANG`. */ - expect(result.entries.map((entry) => entry.name)).toEqual(['Banana', 'apple']) + expect(result.entries.map((entry) => entry.name)).toEqual([ + 'Banana', + 'Loop', + 'Parallel', + 'apple', + ]) }) it('reports no hosted key on a deployment that supplies none', async () => { diff --git a/apps/sim/lib/catalog/application/get-block.ts b/apps/sim/lib/catalog/application/get-block.ts index 0c9d3505e0c..01b5950afab 100644 --- a/apps/sim/lib/catalog/application/get-block.ts +++ b/apps/sim/lib/catalog/application/get-block.ts @@ -6,6 +6,7 @@ import { } from '@/lib/catalog/application/catalog-context' import { catalogOperations } from '@/lib/catalog/application/operations' import { type CatalogBlockDetail, projectBlockDetail } from '@/lib/catalog/projection/block-detail' +import { getContainerBlockDetail } from '@/lib/catalog/projection/container-blocks' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { isHosted } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -43,6 +44,10 @@ export const getCatalogBlock = defineAuthorizedWorkspaceUseCase({ const gate = await resolveCatalogGate(principal, context) const detail = await withCatalogBlockScope(gate, async () => { + // Containers (loop/parallel) are authorable types outside the registry; + // the catalog answers for the same vocabulary add_block accepts. + const container = getContainerBlockDetail(input.blockId) + if (container) return container const block = getLatestBlockForViewer(input.blockId) if (!block || !isBlockVisibleToCaller(block, gate)) return null return projectBlockDetail(block, { deployment: { hostedKeys: isHosted } }) diff --git a/apps/sim/lib/catalog/application/list-blocks.ts b/apps/sim/lib/catalog/application/list-blocks.ts index bbf6df05bef..8e69bd23e68 100644 --- a/apps/sim/lib/catalog/application/list-blocks.ts +++ b/apps/sim/lib/catalog/application/list-blocks.ts @@ -17,6 +17,7 @@ import { type CatalogBlockSummary, projectBlockSummary, } from '@/lib/catalog/projection/block-summary' +import { CONTAINER_BLOCK_SUMMARIES } from '@/lib/catalog/projection/container-blocks' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { getAllBlocks } from '@/blocks/registry' @@ -70,11 +71,14 @@ export const listCatalogBlocks = defineAuthorizedWorkspaceUseCase({ const search = normalizeCatalogSearch(input.search) const gate = await resolveCatalogGate(principal, context) - const summaries = await withCatalogBlockScope(gate, async () => - getAllBlocks() + const summaries = await withCatalogBlockScope(gate, async () => [ + ...getAllBlocks() .filter((block) => isBlockVisibleToCaller(block, gate)) - .map(projectBlockSummary) - ) + .map(projectBlockSummary), + // Containers are authorable types (add_block accepts them) that live outside + // the registry — the catalog speaks the same vocabulary as authoring. + ...CONTAINER_BLOCK_SUMMARIES, + ]) const filtered = summaries.filter( (block) => diff --git a/apps/sim/lib/catalog/projection/container-blocks.ts b/apps/sim/lib/catalog/projection/container-blocks.ts new file mode 100644 index 00000000000..fd60e2cb566 --- /dev/null +++ b/apps/sim/lib/catalog/projection/container-blocks.ts @@ -0,0 +1,165 @@ +import type { CatalogBlockDetail } from '@/lib/catalog/projection/block-detail' +import type { CatalogBlockSummary } from '@/lib/catalog/projection/block-summary' +import { VALID_LOOP_TYPES, VALID_PARALLEL_TYPES } from '@/lib/workflows/editing/operations' + +/** + * Loop and Parallel are containers, not registry blocks — yet the authoring + * surface accepts them as `type: "loop"` / `type: "parallel"` in add_block + * operations, and the canvas presents them alongside blocks. A catalog that + * omits them teaches one vocabulary and 404s on the other; these descriptors + * make the catalog speak the authoring surface's language. Their option lists + * import the authoring constants directly, so the surfaces cannot drift. + */ + +const loopSummary: CatalogBlockSummary = { + id: 'loop', + name: 'Loop', + description: + 'Container that runs its child blocks repeatedly: a fixed count (for), once per item of a collection (forEach), or while a condition holds (while/doWhile).', + category: 'blocks', + source: 'builtin', + triggerAllowed: false, + triggerCapable: false, + preview: false, + tags: [], + triggerIds: [], + toolIds: [], + operationIds: [], +} + +const parallelSummary: CatalogBlockSummary = { + id: 'parallel', + name: 'Parallel', + description: + 'Container that runs its child blocks in concurrent branches: a fixed count, or one branch per item of a collection.', + category: 'blocks', + source: 'builtin', + triggerAllowed: false, + triggerCapable: false, + preview: false, + tags: [], + triggerIds: [], + toolIds: [], + operationIds: [], +} + +const CONTAINER_USAGE = + 'Containers are authored through workflows operations apply (add_block with this type, children via nestedNodes or parentId), not placed like tool blocks. Wire the body through the container handles (loop-start-source / loop-end-source, parallel-start-source / parallel-end-source); an unwired body never runs.' + +const loopDetail: CatalogBlockDetail = { + ...loopSummary, + bestPractices: CONTAINER_USAGE, + inputSchema: [ + { + id: 'loopType', + type: 'dropdown', + title: 'Loop type', + required: true, + options: VALID_LOOP_TYPES.map((id) => ({ id })), + description: 'How iteration is driven. Defaults to "for".', + }, + { + id: 'iterations', + type: 'short-input', + title: 'Iterations', + condition: { field: 'loopType', value: 'for' }, + description: 'Number of iterations for a for loop.', + }, + { + id: 'collection', + type: 'long-input', + title: 'Collection', + condition: { field: 'loopType', value: 'forEach' }, + description: 'Array (or reference resolving to one) iterated by a forEach loop.', + }, + { + id: 'condition', + type: 'long-input', + title: 'Condition', + condition: { field: 'loopType', value: ['while', 'doWhile'] }, + description: + 'Continue condition for while/doWhile. May not reference blocks inside the loop body.', + }, + ], + operationInputSchema: {}, + inputDefinitions: {}, + operations: {}, + tools: [], + triggers: [], + outputs: { + index: { + type: 'number', + description: 'Current iteration index inside the body ().', + }, + currentItem: { + type: 'any', + description: 'Current item inside a forEach body ().', + }, + items: { + type: 'array', + description: 'The full collection inside a forEach body ().', + }, + results: { + type: 'array', + description: + 'All iteration results, referenced OUTSIDE the loop by its block name ().', + }, + }, +} + +const parallelDetail: CatalogBlockDetail = { + ...parallelSummary, + bestPractices: CONTAINER_USAGE, + inputSchema: [ + { + id: 'parallelType', + type: 'dropdown', + title: 'Parallel type', + required: true, + options: VALID_PARALLEL_TYPES.map((id) => ({ id })), + description: 'How branches are created. Defaults to "count".', + }, + { + id: 'count', + type: 'short-input', + title: 'Count', + condition: { field: 'parallelType', value: 'count' }, + description: 'Number of concurrent branches.', + }, + { + id: 'collection', + type: 'long-input', + title: 'Collection', + condition: { field: 'parallelType', value: 'collection' }, + description: 'Array (or reference resolving to one) fanned out one branch per item.', + }, + ], + operationInputSchema: {}, + inputDefinitions: {}, + operations: {}, + tools: [], + triggers: [], + outputs: { + index: { type: 'number', description: 'Branch index inside the body ().' }, + currentItem: { + type: 'any', + description: 'Current item inside a collection branch ().', + }, + results: { + type: 'array', + description: + 'All branch results, referenced OUTSIDE by the block name ().', + }, + }, +} + +export const CONTAINER_BLOCK_SUMMARIES: CatalogBlockSummary[] = [loopSummary, parallelSummary] + +const CONTAINER_BLOCK_DETAILS: Record = { + loop: loopDetail, + parallel: parallelDetail, +} + +export function getContainerBlockDetail(blockId: string): CatalogBlockDetail | null { + return CONTAINER_BLOCK_DETAILS[blockId] ?? null +} diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.test.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.test.ts index ea4765edf23..74ed1c17455 100644 --- a/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.test.ts @@ -100,3 +100,24 @@ describe('applyPipeline', () => { expect(applyPipeline('', [['head', '-n', '5']]).ok).toBe(false) }) }) + +describe('grep context flags', () => { + const input = 'a\nb\nHIT\nc\nd\ne\nHIT\nf' + it('-A appends trailing context lines', () => { + const r = applyPipeline(input, [['grep', '-A', '1', 'HIT']]) + expect(r).toEqual({ ok: true, stdout: 'HIT\nc\nHIT\nf' }) + }) + it('-B and -C select windows without duplicating overlaps', () => { + const r = applyPipeline('x\nHIT\nHIT\ny', [['grep', '-C', '1', 'HIT']]) + expect(r).toEqual({ ok: true, stdout: 'x\nHIT\nHIT\ny' }) + }) + it('-c counts hits, not context lines', () => { + const r = applyPipeline(input, [['grep', '-c', '-A', '2', 'HIT']]) + expect(r).toEqual({ ok: true, stdout: '2' }) + }) + it('rejects a negative context count with usage guidance', () => { + const r = applyPipeline(input, [['grep', '-A', '-2', 'HIT']]) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.error).toContain('-A needs a non-negative number') + }) +}) diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.ts index 57222bd8f5d..2443e40adb8 100644 --- a/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.ts +++ b/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.ts @@ -39,12 +39,22 @@ function compileGrepPattern(raw: string, ignoreCase: boolean): (line: string) => } } +function parseContextCount(args: string[], i: number, flag: string): number { + const count = Number.parseInt(args[i] ?? '', 10) + if (!Number.isFinite(count) || count < 0) { + throw new PipeUsageError(`grep ${flag} needs a non-negative number`) + } + return count +} + function runGrep(input: string, args: string[]): string { let ignoreCase = false let invert = false let countOnly = false let lineNumbers = false let maxCount = Number.POSITIVE_INFINITY + let before = 0 + let after = 0 const positional: string[] = [] for (let i = 0; i < args.length; i++) { const arg = args[i] @@ -59,8 +69,18 @@ function runGrep(input: string, args: string[]): string { if (!Number.isFinite(maxCount) || maxCount < 1) { throw new PipeUsageError('grep -m needs a positive number') } + } else if (arg === '-A') { + after = parseContextCount(args, ++i, '-A') + } else if (arg === '-B') { + before = parseContextCount(args, ++i, '-B') + } else if (arg === '-C') { + const count = parseContextCount(args, ++i, '-C') + before = count + after = count } else if (arg.startsWith('-')) { - throw new PipeUsageError(`grep: unsupported flag ${arg} (supported: -i -v -c -n -E -m N)`) + throw new PipeUsageError( + `grep: unsupported flag ${arg} (supported: -i -v -c -n -E -m N -A N -B N -C N)` + ) } else { positional.push(arg) } @@ -68,13 +88,23 @@ function runGrep(input: string, args: string[]): string { const pattern = positional[0] if (pattern === undefined) throw new PipeUsageError('grep needs a pattern') const matches = compileGrepPattern(pattern, ignoreCase) - const out: string[] = [] const lines = input.split('\n') - for (let lineNo = 0; lineNo < lines.length && out.length < maxCount; lineNo++) { + // Context flags select a window of line indexes around each hit (union, in + // order, no duplicates) — matching grep's -A/-B/-C output without separators. + const selected = new Set() + let hits = 0 + for (let lineNo = 0; lineNo < lines.length && hits < maxCount; lineNo++) { const hit = matches(lines[lineNo]) - if (hit !== invert) out.push(lineNumbers ? `${lineNo + 1}:${lines[lineNo]}` : lines[lineNo]) + if (hit !== invert) { + hits++ + const from = Math.max(0, lineNo - before) + const to = Math.min(lines.length - 1, lineNo + after) + for (let i = from; i <= to; i++) selected.add(i) + } } - return countOnly ? String(out.length) : out.join('\n') + if (countOnly) return String(hits) + const out = [...selected].sort((a, b) => a - b) + return out.map((i) => (lineNumbers ? `${i + 1}:${lines[i]}` : lines[i])).join('\n') } /** Applies the grep stages to stdout. Returns the filtered text, or a usage error. */ diff --git a/apps/sim/lib/workflows/editing/operations.ts b/apps/sim/lib/workflows/editing/operations.ts index eb677e3683d..d8187d5485a 100644 --- a/apps/sim/lib/workflows/editing/operations.ts +++ b/apps/sim/lib/workflows/editing/operations.ts @@ -30,14 +30,21 @@ import { const logger = createLogger('EditWorkflowServerTool') +/** + * The container-type vocabulary the authoring surface accepts. The catalog's + * container descriptors build their options from these same constants, so the + * two surfaces cannot drift apart. + */ +export const VALID_LOOP_TYPES = ['for', 'forEach', 'while', 'doWhile'] as const +export const VALID_PARALLEL_TYPES = ['count', 'collection'] as const + /** * Applies loop/parallel container config from `inputs` onto a block state (data.loopType, etc.). */ function applyLoopOrParallelContainerData(block: any, params: Record): void { if (params.type === 'loop') { - const validLoopTypes = ['for', 'forEach', 'while', 'doWhile'] const loopType = - params.inputs?.loopType && validLoopTypes.includes(params.inputs.loopType) + params.inputs?.loopType && VALID_LOOP_TYPES.includes(params.inputs.loopType) ? params.inputs.loopType : 'for' block.data = { @@ -52,9 +59,8 @@ function applyLoopOrParallelContainerData(block: any, params: Record Date: Tue, 1 Sep 2026 13:44:20 +0530 Subject: [PATCH 036/306] Encode field-observed needs: trace rollup, env-token audit, dangling-ref lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three right-layer fixes from the AskRVT field study. The core lint now catches dangling block-output references (kind block-output) — the silent class where an API body or agent prompt ships literal template text while apply reported unresolved: [] and a desk went dark in prod; pure graph check, every caller, code fields excluded (runtime fails those loudly), Slack-link/comparison shapes guarded, v2 contract enum widened. The workflow lint augmentation adds an undeclared {{TOKEN}} audit (a missing token resolves to empty string at run time — the trap that masked a production error monitor). New workflow trace augmentation rolls up a run: recursive span walk (shallow walks silently dropped subworkflow blocks), per-type stats, real-error filtering, slowest blocks. All three live-verified through the agent. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/lib/api/contracts/v2/workflows.ts | 2 +- .../tools/handlers/agent-cli/commands/lint.ts | 63 ++++++- .../handlers/agent-cli/commands/trace.ts | 156 ++++++++++++++++++ .../tools/handlers/agent-cli/index.ts | 2 + .../workflows/editing/dangling-refs.test.ts | 94 +++++++++++ apps/sim/lib/workflows/editing/lint-report.ts | 6 + apps/sim/lib/workflows/editing/lint.ts | 64 ++++++- 7 files changed, 382 insertions(+), 5 deletions(-) create mode 100644 apps/sim/lib/mothership/tools/handlers/agent-cli/commands/trace.ts create mode 100644 apps/sim/lib/workflows/editing/dangling-refs.test.ts diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 705227a3b18..54bd519d9e1 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -2780,7 +2780,7 @@ const v2WorkflowLintSchema = z .union([z.string(), z.array(z.string())]) .describe('The reference, or references, that did not resolve.'), kind: z - .enum(['credential', 'resource', 'custom-tool', 'mcp-tool', 'skill']) + .enum(['credential', 'resource', 'custom-tool', 'mcp-tool', 'skill', 'block-output']) .describe('What kind of entity the reference was expected to name.'), reason: z.string().describe('Why the reference does not resolve.'), }) diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/lint.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/lint.ts index e4aae279163..70b573d28e1 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/lint.ts +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/lint.ts @@ -2,6 +2,7 @@ import type { WorkflowState } from '@sim/workflow-types/workflow' import { fetchWorkflowState } from '@/lib/mothership/tools/handlers/agent-cli/commands/workflow-views' import { type AgentCliCommand, + type AgentCliRuntime, agentCliFail, agentCliOk, } from '@/lib/mothership/tools/handlers/agent-cli/types' @@ -31,9 +32,67 @@ export const workflowLintCommand: AgentCliCommand = { workspaceId: runtime.workspaceId, subjectUserId: runtime.userId, }) + const undeclaredEnvVars = await collectUndeclaredEnvVars(runtime, graph) const summary = hasWorkflowLintIssues(report) ? formatWorkflowLintMessage(report) - : 'No lint issues found.' - return agentCliOk(JSON.stringify({ summary, ...report }, null, 2)) + : undeclaredEnvVars.length > 0 + ? `Undeclared environment variables referenced: ${undeclaredEnvVars.map((v) => v.name).join(', ')} — an unresolved {{TOKEN}} resolves to an EMPTY STRING at run time, not an error.` + : 'No lint issues found.' + return agentCliOk(JSON.stringify({ summary, undeclaredEnvVars, ...report }, null, 2)) }, } + +const ENV_TOKEN = /\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g + +function envTokenNames(value: unknown, out: Map>, blockName: string): void { + if (typeof value === 'string') { + for (const match of value.matchAll(ENV_TOKEN)) { + if (!match[1]) continue + const blocks = out.get(match[1]) ?? new Set() + blocks.add(blockName) + out.set(match[1], blocks) + } + } else if (Array.isArray(value)) { + for (const item of value) envTokenNames(item, out, blockName) + } else if (typeof value === 'object' && value !== null) { + for (const item of Object.values(value)) envTokenNames(item, out, blockName) + } +} + +/** + * Referenced-but-undeclared {{TOKEN}} audit. Sim resolves a missing token to an + * empty string rather than an error, so the failure it causes is silent and + * downstream — the exact trap a fleet audit found masking a broken production + * error monitor. Declared names come from the same secrets surface the CLI + * exposes (workspace + the caller's personal scope). + */ +async function collectUndeclaredEnvVars( + runtime: AgentCliRuntime, + graph: Pick +): Promise<{ name: string; blocks: string[] }[]> { + const referenced = new Map>() + for (const block of Object.values(graph.blocks ?? {})) { + const b = block as { name?: string; subBlocks?: Record } + for (const subBlock of Object.values(b.subBlocks ?? {})) { + envTokenNames(subBlock?.value, referenced, b.name ?? 'unnamed block') + } + } + if (referenced.size === 0) return [] + const declared = new Set() + let cursor: string | undefined + for (let page = 0; page < 10; page++) { + const response = await runtime.client.request<{ + data: { name: string }[] + nextCursor: string | null + }>('/api/v2/secrets', { + query: { workspaceId: runtime.workspaceId, ...(cursor ? { cursor } : {}) }, + }) + for (const secret of response.data) declared.add(secret.name) + if (!response.nextCursor) break + cursor = response.nextCursor + } + return [...referenced.entries()] + .filter(([name]) => !declared.has(name)) + .map(([name, blocks]) => ({ name, blocks: [...blocks].sort() })) + .sort((a, b) => a.name.localeCompare(b.name)) +} diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/trace.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/trace.ts new file mode 100644 index 00000000000..d7acd35d764 --- /dev/null +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/trace.ts @@ -0,0 +1,156 @@ +import { + type AgentCliCommand, + type AgentCliRuntime, + agentCliFail, + agentCliOk, +} from '@/lib/mothership/tools/handlers/agent-cli/types' + +/** + * `workflow trace ` — a run's trace rolled up for diagnosis, replacing + * the hand-written span filters agents otherwise improvise (a field audit found + * four divergent jq programs over the same trace, two of which disagreed 7 vs + * 21 blocks because a shallow walk silently drops every block inside a child + * workflow). The walk here is always recursive, errors come only from status + * and error FIELDS (never from schema literals that merely contain the word + * "error"), and subworkflow spans keep their nesting depth visible. + */ + +interface TraceSpan { + id?: string + name?: string + type?: string + duration?: number + durationMs?: number + status?: string + errorHandled?: boolean + errorType?: string + errorMessage?: string + blockId?: string + children?: TraceSpan[] +} + +interface FlatSpan { + name: string + type: string + durationMs: number + depth: number + status?: string + errorMessage?: string + errorHandled?: boolean +} + +function flattenSpans(spans: TraceSpan[], depth: number, out: FlatSpan[]): void { + for (const span of spans) { + out.push({ + name: span.name ?? span.blockId ?? 'unnamed', + type: span.type ?? 'unknown', + durationMs: span.durationMs ?? span.duration ?? 0, + depth, + ...(span.status !== undefined ? { status: span.status } : {}), + ...(span.errorMessage !== undefined ? { errorMessage: span.errorMessage } : {}), + ...(span.errorHandled !== undefined ? { errorHandled: span.errorHandled } : {}), + }) + if (span.children?.length) flattenSpans(span.children, depth + 1, out) + } +} + +function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0 + const index = Math.min(sorted.length - 1, Math.floor(p * sorted.length)) + return sorted[index] ?? 0 +} + +export const workflowTraceCommand: AgentCliCommand = { + path: ['workflow', 'trace'], + summary: 'Roll up one run trace: per-block timings, per-type stats, real errors, slowest path', + usage: 'workflow trace ', + async execute(rest, runtime: AgentCliRuntime) { + const runId = rest[0] + if (!runId) return agentCliFail('Usage: sim workflow trace ') + const run = await runtime.client.request<{ + data: { + status?: string + totalDurationMs?: number + trigger?: string + workflow?: { id?: string; name?: string } + traceSpans?: TraceSpan[] + } + }>(`/api/v2/logs/${encodeURIComponent(runId)}`) + const record = run.data + const spans: FlatSpan[] = [] + flattenSpans(record.traceSpans ?? [], 0, spans) + if (spans.length === 0) { + return agentCliOk( + JSON.stringify( + { + runId, + status: record.status, + workflow: record.workflow?.name, + note: 'No trace spans (spans age out on their own retention schedule).', + }, + null, + 2 + ) + ) + } + + const byType = new Map() + for (const span of spans) { + const durations = byType.get(span.type) ?? [] + durations.push(span.durationMs) + byType.set(span.type, durations) + } + const typeStats = [...byType.entries()] + .map(([type, durations]) => { + const sorted = [...durations].sort((a, b) => a - b) + return { + type, + count: sorted.length, + totalMs: sorted.reduce((a, b) => a + b, 0), + p50Ms: percentile(sorted, 0.5), + maxMs: sorted[sorted.length - 1] ?? 0, + } + }) + .sort((a, b) => b.totalMs - a.totalMs) + + const errors = spans + .filter((span) => span.errorMessage || (span.status && /^(error|failed)$/i.test(span.status))) + .map((span) => ({ + block: span.name, + type: span.type, + depth: span.depth, + ...(span.status ? { status: span.status } : {}), + ...(span.errorMessage ? { message: span.errorMessage.slice(0, 400) } : {}), + ...(span.errorHandled !== undefined ? { handled: span.errorHandled } : {}), + })) + + const slowest = [...spans] + .sort((a, b) => b.durationMs - a.durationMs) + .slice(0, 10) + .map((span) => ({ + block: span.name, + type: span.type, + durationMs: span.durationMs, + depth: span.depth, + })) + + return agentCliOk( + JSON.stringify( + { + runId, + workflow: record.workflow?.name, + status: record.status, + trigger: record.trigger, + totalDurationMs: record.totalDurationMs, + blockCount: spans.length, + maxDepth: Math.max(...spans.map((span) => span.depth)), + errors, + typeStats, + slowestBlocks: slowest, + }, + null, + 2 + ) + ) + }, +} diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts index 63edd96107c..ec287b2480c 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts @@ -5,6 +5,7 @@ import { workflowsGrepCommand, } from '@/lib/mothership/tools/handlers/agent-cli/commands/grep' import { workflowLintCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/lint' +import { workflowTraceCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/trace' import { workflowBlocksCommand, workflowEdgesCommand, @@ -28,6 +29,7 @@ const AGENT_CLI_COMMANDS: readonly AgentCliCommand[] = [ workflowEdgesCommand, workflowGrepCommand, workflowLintCommand, + workflowTraceCommand, workflowsGrepCommand, ] diff --git a/apps/sim/lib/workflows/editing/dangling-refs.test.ts b/apps/sim/lib/workflows/editing/dangling-refs.test.ts new file mode 100644 index 00000000000..5f588be098d --- /dev/null +++ b/apps/sim/lib/workflows/editing/dangling-refs.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { collectDanglingBlockOutputReferences } from '@/lib/workflows/editing/lint' + +function graph( + blocks: Record< + string, + { type?: string; name?: string; subBlocks?: Record } + > +) { + return { blocks } as Parameters[0] +} + +describe('collectDanglingBlockOutputReferences', () => { + it('flags a reference to a deleted block in an API body', () => { + const findings = collectDanglingBlockOutputReferences( + graph({ + b1: { + type: 'api', + name: 'PostBack', + subBlocks: { body: { value: '{"spec": ""}' } }, + }, + }) + ) + expect(findings).toHaveLength(1) + expect(findings[0]).toMatchObject({ + blockId: 'b1', + field: 'body', + kind: 'block-output', + value: [''], + }) + }) + + it('resolves heads by normalized block name, id, and special prefixes', () => { + const findings = collectDanglingBlockOutputReferences( + graph({ + b1: { type: 'starter', name: 'Start' }, + b2: { + type: 'api', + name: 'Call', + subBlocks: { + url: { value: 'https://x.test/' }, + body: { value: ' and are fine' }, + }, + }, + }) + ) + expect(findings).toHaveLength(0) + }) + + it('ignores non-reference angle text (Slack links, comparisons, bare tags)', () => { + const findings = collectDanglingBlockOutputReferences( + graph({ + b1: { + type: 'slack', + name: 'Notify', + subBlocks: { + text: { value: 'See or bold, math ae' }, + }, + }, + }) + ) + expect(findings).toHaveLength(0) + }) + + it('skips function code fields (the runtime fails those loudly)', () => { + const findings = collectDanglingBlockOutputReferences( + graph({ + b1: { + type: 'function', + name: 'Fn', + subBlocks: { code: { value: 'return ' } }, + }, + }) + ) + expect(findings).toHaveLength(0) + }) + + it('walks nested values like inputMapping objects', () => { + const findings = collectDanglingBlockOutputReferences( + graph({ + b1: { + type: 'workflow_input', + name: 'Invoke', + subBlocks: { inputMapping: { value: { lead: '' } } }, + }, + }) + ) + expect(findings).toHaveLength(1) + expect(findings[0]!.field).toBe('inputMapping') + }) +}) diff --git a/apps/sim/lib/workflows/editing/lint-report.ts b/apps/sim/lib/workflows/editing/lint-report.ts index 7aab7d9980c..2a759848ede 100644 --- a/apps/sim/lib/workflows/editing/lint-report.ts +++ b/apps/sim/lib/workflows/editing/lint-report.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { WorkflowState } from '@sim/workflow-types/workflow' import { + collectDanglingBlockOutputReferences, collectWorkflowFieldIssues, lintEditedWorkflowState, type WorkflowLintReport, @@ -57,6 +58,11 @@ export async function buildWorkflowLintReport( ): Promise { const unresolvedReferences: WorkflowLintUnresolvedReference[] = [] + // Pure graph check, so it runs for every caller: a dangling block-output + // reference passes literal text through at run time on the surfaces that + // do not fail loudly (API bodies, agent prompts). + unresolvedReferences.push(...collectDanglingBlockOutputReferences(graph)) + if (scope.subjectUserId) { for (const collect of [collectUnresolvedReferences, collectUnresolvedAgentToolReferences]) { try { diff --git a/apps/sim/lib/workflows/editing/lint.ts b/apps/sim/lib/workflows/editing/lint.ts index a4907006b53..dde4a69083b 100644 --- a/apps/sim/lib/workflows/editing/lint.ts +++ b/apps/sim/lib/workflows/editing/lint.ts @@ -1,5 +1,5 @@ import { getBlock } from '@/blocks' -import { isTriggerBlockType } from '@/executor/constants' +import { isTriggerBlockType, normalizeName, SPECIAL_REFERENCE_PREFIXES } from '@/executor/constants' import { collectBlockFieldIssues, extractBlockParams, @@ -69,7 +69,7 @@ export interface WorkflowLintFieldIssue extends WorkflowLintBlockRef { export interface WorkflowLintUnresolvedReference extends WorkflowLintBlockRef { field: string value: string | string[] - kind: 'credential' | 'resource' | 'custom-tool' | 'mcp-tool' | 'skill' + kind: 'credential' | 'resource' | 'custom-tool' | 'mcp-tool' | 'skill' | 'block-output' reason: string } @@ -375,3 +375,63 @@ export function formatWorkflowLintMessage(lint: WorkflowLintIssueView) { return `Workflow lint found issues. Fix these before continuing: ${parts.join('; ')}` } + +/** + * A `` template whose head names no block in the graph. The runtime + * behavior diverges by surface — function code fails loudly, but API bodies and + * agent prompts pass the literal text through and the run reports completed — + * so the dangling reference has to be caught at lint time, where every surface + * gets the same finding. Code fields are skipped: comparisons and generics in + * real JavaScript look like templates, and the runtime already fails those + * loudly. Heads are matched with the executor's own name normalization. + */ +const BLOCK_REF_TOKEN = /<([^<>]+)>/g +const REF_TOKEN_SHAPE = /^[A-Za-z_][\w-]*(?:[\w\s-]*[\w-])?\.[A-Za-z0-9_.[\]]+$/ + +function stringLeavesForLint(value: unknown, out: string[]): void { + if (typeof value === 'string') out.push(value) + else if (Array.isArray(value)) for (const item of value) stringLeavesForLint(item, out) + else if (typeof value === 'object' && value !== null) + for (const item of Object.values(value)) stringLeavesForLint(item, out) +} + +export function collectDanglingBlockOutputReferences( + workflowState: Pick +): WorkflowLintUnresolvedReference[] { + const blocks = (workflowState.blocks || {}) as Record + const resolvable = new Set() + for (const [id, block] of Object.entries(blocks)) { + resolvable.add(id) + if (block.name) resolvable.add(normalizeName(block.name)) + } + const findings: WorkflowLintUnresolvedReference[] = [] + for (const [blockId, block] of Object.entries(blocks)) { + for (const [subBlockId, subBlock] of Object.entries(block.subBlocks ?? {})) { + if (subBlockId === 'code') continue + const leaves: string[] = [] + stringLeavesForLint((subBlock as { value?: unknown })?.value, leaves) + const dangling = new Set() + for (const leaf of leaves) { + for (const match of leaf.matchAll(BLOCK_REF_TOKEN)) { + const token = match[1] + if (!token || !REF_TOKEN_SHAPE.test(token)) continue + const head = token.split('.')[0] ?? '' + if ((SPECIAL_REFERENCE_PREFIXES as readonly string[]).includes(head)) continue + if (resolvable.has(head) || resolvable.has(normalizeName(head))) continue + dangling.add(`<${token}>`) + } + } + if (dangling.size > 0) { + findings.push({ + ...blockRef(blockId, block), + field: subBlockId, + value: [...dangling], + kind: 'block-output', + reason: + 'References a block that does not exist in this workflow — at run time the literal text is passed through (or the block fails), never the intended value.', + }) + } + } + } + return findings +} From 78969e05681cc595601eab9b267c79b71341ca8e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 13:53:28 +0530 Subject: [PATCH 037/306] Embedded CLI runs refuse @path file arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readArgumentSource did a raw readFileSync on any @path — and the mothership runs the CLI in-process on the sim server with argv the model controls, so a prompt-injected --input @/etc/... read the SERVER's filesystem. Embedded runs (embedStore present) now refuse file and stdin sources with inline guidance; @@ literals and inline values are unchanged, and the installed CLI's behavior is untouched. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../src/runtime/embedded-file-args.test.ts | 40 +++++++++++++++++++ packages/sim-cli/src/runtime/request.ts | 12 ++++++ 2 files changed, 52 insertions(+) create mode 100644 packages/sim-cli/src/runtime/embedded-file-args.test.ts diff --git a/packages/sim-cli/src/runtime/embedded-file-args.test.ts b/packages/sim-cli/src/runtime/embedded-file-args.test.ts new file mode 100644 index 00000000000..be966c9eb8a --- /dev/null +++ b/packages/sim-cli/src/runtime/embedded-file-args.test.ts @@ -0,0 +1,40 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { embedStore } from '../embed-context' +import { readArgumentSource } from './request' + +describe('file arguments in embedded runs', () => { + it('refuses @path reads in-process, with inline guidance', () => { + const ctx = { + identity: { endpoint: 'http://x', apiKey: 'k' }, + stdout: [] as string[], + stderr: [] as string[], + } + embedStore.run(ctx, () => { + expect(() => readArgumentSource('@/etc/hostname', 'input')).toThrow( + /not available in embedded runs.*inline/ + ) + }) + }) + + it('keeps @@ literal escape and inline values working embedded', () => { + const ctx = { + identity: { endpoint: 'http://x', apiKey: 'k' }, + stdout: [] as string[], + stderr: [] as string[], + } + embedStore.run(ctx, () => { + expect(readArgumentSource('@@literal', 'input').text).toBe('@literal') + expect(readArgumentSource('{"a":1}', 'input').text).toBe('{"a":1}') + }) + }) + + it('still reads files outside embedded runs', () => { + const dir = mkdtempSync(join(tmpdir(), 'cli-args-')) + const file = join(dir, 'v.json') + writeFileSync(file, '{"ok":true}') + expect(readArgumentSource(`@${file}`, 'input').text).toBe('{"ok":true}') + }) +}) diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index a288f4a6e1f..dc52ad95cd1 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -1,6 +1,7 @@ import { closeSync, existsSync, fstatSync, openSync, readSync } from 'node:fs' import { CLI_CONTRACT } from '../contract/commands' import type { CommandSpec, FlagSpec } from '../contract/types' +import { embedStore } from '../embed-context' import { V2_OPERATIONS, type V2OperationName } from '../generated/v2-api' import { type QueryValue, SimApiError } from '../http/client' import { camel, kebab } from './derive' @@ -209,6 +210,17 @@ export function readArgumentSource(raw: string, flagName: string): { text: strin if (raw.startsWith('@@')) return { text: raw.slice(1), from: '' } if (!raw.startsWith('@')) return { text: raw, from: '' } + // An embedded run executes in-process on the hosting server, so a file path + // here would read the SERVER's filesystem with argv the model controls. + // There is no local file a caller could legitimately mean; the value must + // arrive inline (or as @@-escaped literal text). + if (embedStore.getStore()) { + throw new SimApiError( + `--${flagName} file arguments (@path) are not available in embedded runs — pass the JSON inline as a single argument`, + 0 + ) + } + const path = raw.slice(1) if (path === '-') { if (process.stdin.isTTY) { From c0b488513909bca68df17b3bc7d2eefdb14a37a5 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 14:04:52 +0530 Subject: [PATCH 038/306] The one-machine file bridge: @path in, outputFile out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Control plane stays embedded (D28); the chat's workbench sandbox becomes the agent's filesystem, bridged by the sim_cli handler in both directions. @-shaped argv tokens are pre-read from the session sandbox into the embed context, and the CLI's OWN argument resolver consults that map — so only genuinely file-accepting flags get file semantics (a literal --text @channel stays literal) and the server's filesystem stays unreadable from model argv (the earlier refusal remains as defense in depth, now worded as this machine's not-found). outputFile lands command stdout on the machine as a file and returns a short ack, so exports and traces never transit the model's context. Find-only by design: a cold machine degrades to inline with boot guidance. Live e2e of the full loop: trace pulled to file (ack only), run_code computed on it, @env.json fed a manual run byte-exact. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../execution/remote-sandbox/session-files.ts | 84 +++++++++++++++++++ .../lib/mothership/tools/handlers/sim-cli.ts | 48 ++++++++++- packages/sim-cli/src/embed-context.ts | 7 ++ packages/sim-cli/src/embed.ts | 10 ++- .../src/runtime/embedded-file-args.test.ts | 19 ++++- packages/sim-cli/src/runtime/request.ts | 15 ++-- 6 files changed, 170 insertions(+), 13 deletions(-) create mode 100644 apps/sim/lib/execution/remote-sandbox/session-files.ts diff --git a/apps/sim/lib/execution/remote-sandbox/session-files.ts b/apps/sim/lib/execution/remote-sandbox/session-files.ts new file mode 100644 index 00000000000..fbf19e8cf23 --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/session-files.ts @@ -0,0 +1,84 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { resolveProvider } from '@/lib/execution/remote-sandbox/provider' + +const logger = createLogger('SessionSandboxFiles') + +/** + * File I/O against an EXISTING session sandbox — the bridge that lets the + * Mothership's embedded CLI treat the chat's workbench as its filesystem: + * `@path` arguments read from it, `outputFile` writes command output into it, + * while the CLI itself keeps executing in-process on the server. Find-only by + * design: booting the machine belongs to the execution path (run_code), so a + * missing session degrades to an actionable answer instead of paying a sandbox + * spin-up inside a CLI call. + * + * Session sandboxes exist only on providers with session support (E2B), whose + * mothership image runs the default user at this home directory — the pinned + * cwd contract for relative paths. + */ +export const SESSION_SANDBOX_HOME = '/home/user' + +const READ_LIMIT_BYTES = 4 * 1024 * 1024 + +export function resolveSessionPath(path: string): string { + return path.startsWith('/') ? path : `${SESSION_SANDBOX_HOME}/${path}` +} + +export type SessionFileRead = + | { outcome: 'read'; content: string } + | { outcome: 'no-session' } + | { outcome: 'no-file'; detail: string } + +export async function readSessionSandboxFile( + sessionKey: string, + path: string +): Promise { + const provider = resolveProvider() + if (!provider.findSessionSandbox) return { outcome: 'no-session' } + let sandbox: Awaited>> + try { + sandbox = await provider.findSessionSandbox(sessionKey, {}) + } catch (error) { + logger.warn('Session sandbox lookup failed for file read', { + sessionKey, + error: getErrorMessage(error), + }) + return { outcome: 'no-session' } + } + if (!sandbox) return { outcome: 'no-session' } + try { + const file = await sandbox.readFileWithLimit(resolveSessionPath(path), { + maxBytes: READ_LIMIT_BYTES, + encoding: 'utf8', + }) + return { outcome: 'read', content: file.content } + } catch (error) { + return { outcome: 'no-file', detail: getErrorMessage(error) } + } +} + +export type SessionFileWrite = { outcome: 'written'; path: string } | { outcome: 'no-session' } + +export async function writeSessionSandboxFile( + sessionKey: string, + path: string, + content: string +): Promise { + const provider = resolveProvider() + if (!provider.findSessionSandbox) return { outcome: 'no-session' } + let sandbox: Awaited>> + try { + sandbox = await provider.findSessionSandbox(sessionKey, {}) + } catch (error) { + logger.warn('Session sandbox lookup failed for file write', { + sessionKey, + error: getErrorMessage(error), + }) + return { outcome: 'no-session' } + } + if (!sandbox) return { outcome: 'no-session' } + const resolved = resolveSessionPath(path) + await sandbox.writeFile(resolved, content) + return { outcome: 'written', path: resolved } +} diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts index 4f8e5baadd0..16764d5fa4d 100644 --- a/apps/sim/lib/mothership/tools/handlers/sim-cli.ts +++ b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts @@ -1,6 +1,10 @@ import { createLogger } from '@sim/logger' import { createEmbeddedClient, type EmbeddedCliIdentity, runEmbeddedCli } from 'sim/embed' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { + readSessionSandboxFile, + writeSessionSandboxFile, +} from '@/lib/execution/remote-sandbox/session-files' import { mintDelegationToken } from '@/lib/mothership/chat/delegation' import type { ToolExecutionContext, @@ -44,11 +48,31 @@ export async function executeSimCli( if (!context.workspaceId) { return { success: false, error: 'sim_cli requires a workspace-scoped execution context.' } } - const { cliArgs: args, stages } = splitPipeline(rawArgs) - if (args.length === 0) { + const { cliArgs: rawCliArgs, stages } = splitPipeline(rawArgs) + if (rawCliArgs.length === 0) { return { success: false, error: 'A pipe needs a sim CLI invocation before the first |.' } } + const args = rawCliArgs + + // The chat's workbench sandbox is the agent's filesystem: every @-shaped token + // is pre-read from it into a map the embedded CLI's OWN argument resolver + // consults — so only genuinely file-aware flags get file semantics (a literal + // `--text @channel` stays literal), and the server's filesystem is never + // readable from model argv. A token that names no sandbox file is simply + // absent from the map; the resolver's refusal then says so. + const sessionKey = context.chatId ? `mothership-chat:${context.chatId}` : null + const fileArguments: Record = {} + if (sessionKey) { + for (const token of args) { + if (!token.startsWith('@') || token.startsWith('@@') || token === '@-') continue + const path = token.slice(1) + if (fileArguments[path] !== undefined) continue + const read = await readSessionSandboxFile(sessionKey, path) + if (read.outcome === 'read') fileArguments[path] = read.content + } + } + // Stages are validated before the CLI runs: a mutating command must never // execute and then fail on a malformed pipe, or a model retry would repeat // the mutation. @@ -77,7 +101,7 @@ export async function executeSimCli( workspaceId: context.workspaceId, userId: context.userId, }) - : await runEmbeddedCli(args, identity) + : await runEmbeddedCli(args, identity, { fileArguments }) if (!agentMatch && isRootHelpInvocation(args) && result.exitCode === 0) { result.stdout += agentCliHelpSection() } @@ -86,6 +110,24 @@ export async function executeSimCli( if (piped.ok) result.stdout = piped.stdout } + // outputFile: land large stdout directly on the agent's machine instead of + // returning it through the model window — the other half of the file bridge. + const outputFile = typeof params.outputFile === 'string' ? params.outputFile.trim() : '' + if (outputFile && result.exitCode === 0) { + if (!sessionKey) { + result.stdout += + '\n[outputFile not written: no chat-scoped machine — output returned inline instead]' + } else { + const written = await writeSessionSandboxFile(sessionKey, outputFile, result.stdout) + if (written.outcome === 'written') { + result.stdout = `[stdout written to ${outputFile} on your machine: ${result.stdout.length} chars. Read or process it with run_code, or pass it back as @${outputFile}.]` + } else { + result.stdout += + '\n[outputFile not written: your machine is not booted yet — run any run_code first. Output returned inline instead]' + } + } + } + logger.info('CLI invocation finished', { exitCode: result.exitCode, argv0: args[0], diff --git a/packages/sim-cli/src/embed-context.ts b/packages/sim-cli/src/embed-context.ts index 697ed5da8d8..272c2061184 100644 --- a/packages/sim-cli/src/embed-context.ts +++ b/packages/sim-cli/src/embed-context.ts @@ -17,6 +17,13 @@ export interface EmbedContext { identity: EmbeddedCliIdentity stdout: string[] stderr: string[] + /** + * Pre-read contents for `@path` file arguments, keyed by the path as written + * (without the `@`). The host resolves these from the caller's own file + * surface before the run; the in-process CLI never touches the server's + * filesystem. + */ + fileArguments?: Record } export const embedStore = new AsyncLocalStorage() diff --git a/packages/sim-cli/src/embed.ts b/packages/sim-cli/src/embed.ts index 4c91033c2d6..f944258392b 100644 --- a/packages/sim-cli/src/embed.ts +++ b/packages/sim-cli/src/embed.ts @@ -68,10 +68,16 @@ export function createEmbeddedClient(identity: EmbeddedCliIdentity): SimClient { */ export async function runEmbeddedCli( argv: string[], - identity: EmbeddedCliIdentity + identity: EmbeddedCliIdentity, + options?: { fileArguments?: Record } ): Promise { installEmbedSinks() - const ctx: EmbedContext = { identity, stdout: [], stderr: [] } + const ctx: EmbedContext = { + identity, + stdout: [], + stderr: [], + ...(options?.fileArguments ? { fileArguments: options.fileArguments } : {}), + } return embedStore.run(ctx, async () => { let exitCode = 0 try { diff --git a/packages/sim-cli/src/runtime/embedded-file-args.test.ts b/packages/sim-cli/src/runtime/embedded-file-args.test.ts index be966c9eb8a..b2998efcbb3 100644 --- a/packages/sim-cli/src/runtime/embedded-file-args.test.ts +++ b/packages/sim-cli/src/runtime/embedded-file-args.test.ts @@ -6,7 +6,7 @@ import { embedStore } from '../embed-context' import { readArgumentSource } from './request' describe('file arguments in embedded runs', () => { - it('refuses @path reads in-process, with inline guidance', () => { + it('refuses @path reads in-process when the host preloaded nothing', () => { const ctx = { identity: { endpoint: 'http://x', apiKey: 'k' }, stdout: [] as string[], @@ -14,11 +14,26 @@ describe('file arguments in embedded runs', () => { } embedStore.run(ctx, () => { expect(() => readArgumentSource('@/etc/hostname', 'input')).toThrow( - /not available in embedded runs.*inline/ + /no file "\/etc\/hostname" on this machine/ ) }) }) + it('serves @path from host-preloaded file arguments, never local disk', () => { + const ctx = { + identity: { endpoint: 'http://x', apiKey: 'k' }, + stdout: [] as string[], + stderr: [] as string[], + fileArguments: { 'env.json': '{"thread":"t1"}' }, + } + embedStore.run(ctx, () => { + const resolved = readArgumentSource('@env.json', 'input') + expect(resolved.text).toBe('{"thread":"t1"}') + expect(resolved.from).toContain('your machine') + expect(() => readArgumentSource('@other.json', 'input')).toThrow(/no file "other.json"/) + }) + }) + it('keeps @@ literal escape and inline values working embedded', () => { const ctx = { identity: { endpoint: 'http://x', apiKey: 'k' }, diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index dc52ad95cd1..bde0940d980 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -210,13 +210,16 @@ export function readArgumentSource(raw: string, flagName: string): { text: strin if (raw.startsWith('@@')) return { text: raw.slice(1), from: '' } if (!raw.startsWith('@')) return { text: raw, from: '' } - // An embedded run executes in-process on the hosting server, so a file path - // here would read the SERVER's filesystem with argv the model controls. - // There is no local file a caller could legitimately mean; the value must - // arrive inline (or as @@-escaped literal text). - if (embedStore.getStore()) { + // An embedded run executes in-process on the hosting server, so a raw file + // path here would read the SERVER's filesystem with argv the model controls. + // The host may pre-resolve @paths from the caller's own file surface into the + // embed context; anything else is refused — never read from local disk. + const embedded = embedStore.getStore() + if (embedded) { + const preloaded = embedded.fileArguments?.[raw.slice(1)] + if (preloaded !== undefined) return { text: preloaded, from: ' (read from your machine)' } throw new SimApiError( - `--${flagName} file arguments (@path) are not available in embedded runs — pass the JSON inline as a single argument`, + `--${flagName}: no file "${raw.slice(1)}" on this machine — write it first (run_code), or pass the value inline`, 0 ) } From 0bdc782f7aee63e27cee61c848f3b3517507dd21 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 14:11:05 +0530 Subject: [PATCH 039/306] Bridge handler regression net: pre-read map, ack, degrades Six mocked-boundary tests for the paths the live e2e exercised: @token pre-reads into the embed map, @@/@- passthrough, missing-file and cold-machine degrades on both directions, ack-only stdout replacement, and no write on command failure. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../tools/handlers/sim-cli-bridge.test.ts | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 apps/sim/lib/mothership/tools/handlers/sim-cli-bridge.test.ts diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli-bridge.test.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli-bridge.test.ts new file mode 100644 index 00000000000..f70622a5a34 --- /dev/null +++ b/apps/sim/lib/mothership/tools/handlers/sim-cli-bridge.test.ts @@ -0,0 +1,102 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRead, mockWrite, mockRunEmbeddedCli, mockMint } = vi.hoisted(() => ({ + mockRead: vi.fn(), + mockWrite: vi.fn(), + mockRunEmbeddedCli: vi.fn(), + mockMint: vi.fn(), +})) + +vi.mock('@/lib/execution/remote-sandbox/session-files', () => ({ + readSessionSandboxFile: mockRead, + writeSessionSandboxFile: mockWrite, +})) +vi.mock('sim/embed', () => ({ + runEmbeddedCli: mockRunEmbeddedCli, + createEmbeddedClient: vi.fn(), +})) +vi.mock('@/lib/mothership/chat/delegation', () => ({ mintDelegationToken: mockMint })) +vi.mock('@/lib/core/utils/urls', () => ({ getInternalApiBaseUrl: () => 'http://internal' })) + +import { executeSimCli } from '@/lib/mothership/tools/handlers/sim-cli' + +const context = { workspaceId: 'ws-1', userId: 'u-1', chatId: 'chat-1' } as Parameters< + typeof executeSimCli +>[1] + +describe('sim-cli machine file bridge', () => { + beforeEach(() => { + vi.clearAllMocks() + mockMint.mockResolvedValue('key') + mockRunEmbeddedCli.mockResolvedValue({ exitCode: 0, stdout: 'BIG OUTPUT', stderr: '' }) + }) + + it('pre-reads @tokens from the machine into the embed file map', async () => { + mockRead.mockResolvedValue({ outcome: 'read', content: '{"text":"hi"}' }) + await executeSimCli({ args: ['workflows', 'run', 'wf1', '--input', '@env.json'] }, context) + expect(mockRead).toHaveBeenCalledWith('mothership-chat:chat-1', 'env.json') + expect(mockRunEmbeddedCli).toHaveBeenCalledWith( + ['workflows', 'run', 'wf1', '--input', '@env.json'], + expect.anything(), + { fileArguments: { 'env.json': '{"text":"hi"}' } } + ) + }) + + it('leaves @@ literals and @- alone, and omits missing files from the map', async () => { + mockRead.mockResolvedValue({ outcome: 'no-file', detail: 'nope' }) + await executeSimCli({ args: ['x', '@@literal', '@missing.json'] }, context) + expect(mockRead).toHaveBeenCalledTimes(1) + expect(mockRunEmbeddedCli).toHaveBeenCalledWith( + ['x', '@@literal', '@missing.json'], + expect.anything(), + { + fileArguments: {}, + } + ) + }) + + it('cold machine on read degrades via the empty map (CLI core words the refusal)', async () => { + mockRead.mockResolvedValue({ outcome: 'no-session' }) + await executeSimCli({ args: ['x', '@env.json'] }, context) + expect(mockRunEmbeddedCli).toHaveBeenCalledWith(['x', '@env.json'], expect.anything(), { + fileArguments: {}, + }) + }) + + it('outputFile lands stdout on the machine and returns only the ack', async () => { + mockWrite.mockResolvedValue({ outcome: 'written', path: '/home/user/trace.json' }) + const result = await executeSimCli( + { args: ['logs', 'get', 'r1'], outputFile: 'trace.json' }, + context + ) + expect(mockWrite).toHaveBeenCalledWith('mothership-chat:chat-1', 'trace.json', 'BIG OUTPUT') + const output = result.output as { stdout: string } + expect(output.stdout).toContain('written to trace.json') + expect(output.stdout).toContain('10 chars') + expect(output.stdout).not.toContain('BIG OUTPUT') + }) + + it('outputFile on a cold machine returns output inline with boot guidance', async () => { + mockWrite.mockResolvedValue({ outcome: 'no-session' }) + const result = await executeSimCli( + { args: ['logs', 'get', 'r1'], outputFile: 'trace.json' }, + context + ) + const output = result.output as { stdout: string } + expect(output.stdout).toContain('BIG OUTPUT') + expect(output.stdout).toContain('not booted') + }) + + it('outputFile is skipped on command failure so the error stays visible', async () => { + mockRunEmbeddedCli.mockResolvedValue({ exitCode: 1, stdout: '', stderr: 'boom' }) + const result = await executeSimCli( + { args: ['logs', 'get', 'r1'], outputFile: 'trace.json' }, + context + ) + expect(mockWrite).not.toHaveBeenCalled() + expect(result.success).toBe(false) + }) +}) From 157add15f8bf96fb37ffedd04e33c28dfa55ad8c Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 14:34:23 +0530 Subject: [PATCH 040/306] UX hill-climb: progressive CLI titles, resource names, lane polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic-title flash dies at its root on both consumer paths: the live turn model and the server handler now refine a streaming CLI row's name from the partial argv (registry-gated — a half-streamed token or unknown prefix never matches), and the upsert guard accepts cli-family refinements instead of only the sim_cli placeholder. CLI rows name their resource by inverting the worker's name derivation to the first positional and resolving through the live stores ("Editing workflow" → "Editing Onboarding", "Updating table row" → "Updating Leads row"). Agent-CLI augmentations get real titles; web_search reads the query the worker actually sends; generate_* names the output file; task rows carry their delegation title while streaming and persisted; subagent lanes get their own icon instead of the main-lane Blimp; the collapsed lane status line can no longer surface a raw tool name. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../agent-group/agent-group-view.tsx | 6 +- .../home/components/message-content/utils.ts | 1 + .../home/hooks/stream/stream-helpers.ts | 52 +++++++++++++++++ .../home/hooks/stream/turn-model.ts | 22 +++++-- .../lib/mothership/request/handlers/tool.ts | 13 +++++ .../lib/mothership/tools/cli-tool-display.ts | 11 ++++ .../tools/streaming-cli-name.test.ts | 57 +++++++++++++++++++ apps/sim/lib/mothership/tools/tool-display.ts | 53 ++++++++++++++++- 8 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 apps/sim/lib/mothership/tools/streaming-cli-name.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx index fd9b7a5797a..c88d1eff134 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group-view.tsx @@ -3,8 +3,8 @@ import { type ComponentType, type ReactNode, useState } from 'react' import { ThinkingLoader } from '@/components/ui/thinking-loader' import { isBrowserAgentAvailable } from '@/lib/browser-agent/transport' -import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/copilot/tools/retired-tools' -import { getToolStatusDisplayTitle } from '@/lib/copilot/tools/tool-display' +import { RETIRED_BROWSER_REQUEST_TAKEOVER_ID } from '@/lib/mothership/tools/retired-tools' +import { getToolDisplayTitle, getToolStatusDisplayTitle } from '@/lib/mothership/tools/tool-display' import { ActivityStream } from '@/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/activity-stream' import { collectGroupTools, @@ -65,7 +65,7 @@ export interface AgentGroupProps { function activeToolTitle(tool: ToolCallData): string { return getToolStatusDisplayTitle( - tool.displayTitle || String(tool.toolName ?? ''), + tool.displayTitle || getToolDisplayTitle(String(tool.toolName ?? ''), undefined), tool.status === ToolCallStatus.success ? ToolCallStatus.executing : tool.status, tool.toolName, tool.activityDescription diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index cf640924e52..30ef7d04ed0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -166,6 +166,7 @@ export const TOOL_ICONS: Readonly> = { table_rows: TableIcon, table_views: TableIcon, tail_agent: Brain, + task: Brain, terminal: TerminalWindow, terminal_cwd: TerminalWindow, terminal_input: TerminalWindow, diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts index 880d90dfb51..e0463f36246 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-helpers.ts @@ -320,9 +320,56 @@ export function resolveToolDisplayTitle(name: string, args?: Record` naming to find where the + * command path ends in argv, mirroring its flag handling exactly. + */ +function cliFirstPositional(name: string, args?: Record): string | undefined { + const argv = Array.isArray(args?.args) ? (args.args as unknown[]) : undefined + if (!argv) return undefined + const tokens: string[] = [] + for (let i = 0; i < argv.length; i++) { + const token = argv[i] + if (typeof token !== 'string') break + if (token === '--output') { + i++ + continue + } + if (token.startsWith('-')) break + tokens.push(token) + } + let joined = 'cli' + for (let i = 0; i < tokens.length; i++) { + joined += `_${(tokens[i] ?? '').replace(/-/g, '_')}` + if (joined === name) return tokens[i + 1] + } + return undefined +} + function decodeStreamingString(value: string): string { return value .replace(/\\u([0-9a-fA-F]{4})/g, (_: string, hex: string) => @@ -370,6 +417,11 @@ export function resolveStreamingToolDisplayTitle( return functionExecuteTitle(matchStreamingStringArg(streamingArgs, 'title')) } + if (name === 'task') { + const title = matchStreamingStringArg(streamingArgs, 'title') + if (title) return `Delegating: ${title}` + } + if (name === PrepareFileEdit.id) { return resolveWorkspaceFileDisplayTitle( matchStreamingStringArg(streamingArgs, 'operation'), diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts index d7b425d1e62..baa151ff5de 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts @@ -14,6 +14,7 @@ import { extractStreamingStringArgument } from '@/lib/mothership/tools/streaming import { CONTEXT_COMPACTION_DISPLAY_TITLE, normalizeToolActivityDescription, + refineStreamingCliToolName, } from '@/lib/mothership/tools/tool-display' /** @@ -368,12 +369,14 @@ function upsertToolNode( ): ToolNode { const existing = model.nodes.get(id) if (existing && existing.kind === 'tool') { - // Fill blanks, and replace exactly the CLI placeholder: the worker's partial frame - // names CLI rows `sim_cli` (args unknowable mid-stream) and the finalized frame - // carries the real verb (`cli_workflows_list`) — without this every live CLI row - // read "Running CLI command" until reload. Scoped to the placeholder so the gateway - // rebind's model-authored branding is never clobbered by a later frame. - if (name && (!existing.name || (existing.name === 'sim_cli' && name !== 'sim_cli'))) { + // Fill blanks, and refine CLI names: the worker's partial frame names CLI rows + // `sim_cli` (args unknowable mid-stream), streaming deltas may refine that to a + // provisional `cli_*`, and the finalized frame carries the authoritative verb — + // so any cli-family name accepts a different cli-family (or authoritative) + // successor. Scoped to the cli family so the gateway rebind's model-authored + // branding is never clobbered by a later frame. + const cliFamily = existing.name === 'sim_cli' || existing.name.startsWith('cli_') + if (name && (!existing.name || (cliFamily && name !== existing.name && name !== 'sim_cli'))) { existing.name = name } return existing @@ -557,6 +560,13 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve ) const delta = asString(payload.argumentsDelta) if (delta) node.streamingArgs = (node.streamingArgs ?? '') + delta + // Progressive CLI title: upgrade the placeholder to the specific verb as + // soon as enough argv tokens have streamed to name the command — the + // browser mirror of the server handler's refinement. + if (delta && (node.name === 'sim_cli' || node.name.startsWith('cli_'))) { + const refined = refineStreamingCliToolName(node.streamingArgs ?? '') + if (refined && refined !== node.name) node.name = refined + } } else if (phase === MothershipStreamV1ToolPhase.result) { applyToolResult( model, diff --git a/apps/sim/lib/mothership/request/handlers/tool.ts b/apps/sim/lib/mothership/request/handlers/tool.ts index 0827e92de69..860e2c46078 100644 --- a/apps/sim/lib/mothership/request/handlers/tool.ts +++ b/apps/sim/lib/mothership/request/handlers/tool.ts @@ -53,6 +53,7 @@ import { extractStreamingStringArgument } from '@/lib/mothership/tools/streaming import { getToolDisplayTitle, normalizeToolActivityDescription, + refineStreamingCliToolName, } from '@/lib/mothership/tools/tool-display' import { isWorkflowToolName, @@ -123,6 +124,18 @@ function handleToolArgsDelta( const toolCall = context.toolCalls.get(data.toolCallId) if (!toolCall) return toolCall.streamingArgs = `${toolCall.streamingArgs ?? ''}${data.argumentsDelta}` + + // Progressive CLI title: the row upgrades from "Running CLI command" to the + // specific verb as soon as enough argv tokens have streamed to name it — + // no wait for the full (possibly huge) argument payload. + if (toolCall.name === 'sim_cli' || toolCall.name.startsWith('cli_')) { + const refined = refineStreamingCliToolName(toolCall.streamingArgs) + if (refined && refined !== toolCall.name) { + toolCall.name = refined + applyToolDisplay(toolCall) + } + return + } if (toolCall.name !== INTEGRATION_GATEWAY_TOOL) return const toolId = extractStreamingStringArgument(toolCall.streamingArgs, 'toolId') diff --git a/apps/sim/lib/mothership/tools/cli-tool-display.ts b/apps/sim/lib/mothership/tools/cli-tool-display.ts index cd6cf5a588f..195d1a2e161 100644 --- a/apps/sim/lib/mothership/tools/cli-tool-display.ts +++ b/apps/sim/lib/mothership/tools/cli-tool-display.ts @@ -222,10 +222,21 @@ export const CLI_TOOL_TITLES: Record = { cli_workspaces_get: 'Reading workspace', cli_workspaces_list: 'Listing workspaces', cli_workspaces_members: 'Listing workspace members', + // Agent-only CLI augmentations + cli_files_grep: 'Searching file contents', + cli_workflow_blocks: 'Listing workflow blocks', + cli_workflow_deps: 'Tracing block inputs', + cli_workflow_edges: 'Reading workflow wiring', + cli_workflow_grep: 'Searching workflow', + cli_workflow_lint: 'Validating workflow', + cli_workflow_trace: 'Analyzing run trace', + cli_workflows_grep: 'Searching workflows', // Non-CLI copilot worker tools cli_help: 'Checking CLI reference', sim_cli: 'Running CLI command', run_code: 'Running code', load_skill: 'Loading skill', + read_output: 'Reading full output', + remember: 'Updating memory', task: 'Delegating task', } diff --git a/apps/sim/lib/mothership/tools/streaming-cli-name.test.ts b/apps/sim/lib/mothership/tools/streaming-cli-name.test.ts new file mode 100644 index 00000000000..8be026ed461 --- /dev/null +++ b/apps/sim/lib/mothership/tools/streaming-cli-name.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getToolDisplayTitle, + refineStreamingCliToolName, +} from '@/lib/mothership/tools/tool-display' + +describe('refineStreamingCliToolName', () => { + it('names the command from a partial prefix, skipping --output json', () => { + expect(refineStreamingCliToolName('{"args":["--output","json","workflows","list"')).toBe( + 'cli_workflows_list' + ) + }) + + it('stays generic on an intermediate prefix, then resolves the full command', () => { + const partial = '{"args":["workflows","operations"' + const fuller = '{"args":["workflows","operations","apply","wf-1","--operations","[{' + expect(refineStreamingCliToolName(partial)).toBeNull() + expect(refineStreamingCliToolName(fuller)).toBe('cli_workflows_operations_apply') + }) + + it('never matches on a half-streamed token', () => { + expect(refineStreamingCliToolName('{"args":["workflows","li')).toBeNull() + }) + + it('names agent augmentations', () => { + expect(refineStreamingCliToolName('{"args":["workflow","trace","abc-123"')).toBe( + 'cli_workflow_trace' + ) + expect(refineStreamingCliToolName('{"args":["workflow","deps"')).toBe('cli_workflow_deps') + }) + + it('returns null before the args array or on unknown commands', () => { + expect(refineStreamingCliToolName('{"ar')).toBeNull() + expect(refineStreamingCliToolName('{"args":["frobnicate","things"')).toBeNull() + }) + + it('recognizes --help wherever it appears', () => { + expect(refineStreamingCliToolName('{"args":["tables","--help"')).toBe('cli_help') + }) +}) + +describe('cli display integration', () => { + it('maps the new augmentation names to titles', () => { + expect(getToolDisplayTitle('cli_workflow_trace', {})).toBe('Analyzing run trace') + expect(getToolDisplayTitle('cli_workflow_deps', {})).toBe('Tracing block inputs') + expect(getToolDisplayTitle('remember', {})).toBe('Updating memory') + expect(getToolDisplayTitle('web_search', { query: 'latest sim release' })).toContain( + 'latest sim release' + ) + expect(getToolDisplayTitle('task', { title: 'Inventory workspace' })).toBe( + 'Delegating: Inventory workspace' + ) + }) +}) diff --git a/apps/sim/lib/mothership/tools/tool-display.ts b/apps/sim/lib/mothership/tools/tool-display.ts index d33e0e7cf8c..872c5d17d1a 100644 --- a/apps/sim/lib/mothership/tools/tool-display.ts +++ b/apps/sim/lib/mothership/tools/tool-display.ts @@ -956,8 +956,16 @@ export function getToolDisplayTitle(name: string, args?: Record return `Deleting ${target || 'MCP server'}` } case 'web_search': { - const target = firstStringArg(args, 'toolTitle', 'title') - return target ? `Searching online for ${target}` : 'Searching online' + const target = firstStringArg(args, 'toolTitle', 'title', 'query') + return target + ? `Searching online for "${truncate(target, MAX_QUOTED_TITLE_VALUE_LENGTH)}"` + : 'Searching online' + } + case 'task': { + const target = firstStringArg(args, 'title') + return target + ? `Delegating: ${truncate(target, MAX_QUOTED_TITLE_VALUE_LENGTH)}` + : 'Delegating task' } case 'search_docs': { const target = firstStringArg(args, 'toolTitle', 'title', 'query') @@ -1074,7 +1082,8 @@ export function getToolDisplayTitle(name: string, args?: Record name === 'generate_image' ? 'image' : name === 'generate_video' ? 'video' : 'audio' const target = firstStringArg(args, 'toolTitle', 'title') || - (stringArg(args, 'path') ? pathLeaf(stringArg(args, 'path')) : '') + (stringArg(args, 'path') ? pathLeaf(stringArg(args, 'path')) : '') || + firstOutputFilePath(args) return target ? `Generating ${target}` : `Generating ${kind}` } case 'download_file': { @@ -1517,3 +1526,41 @@ export function getToolStatusDisplayTitle( if (status === 'skipped') return getToolOutcomeTitle(title, 'Skipped', !description) return title } + +/** + * Refines a streaming `sim_cli` call's derived name from its PARTIAL argument + * JSON, so the row's title upgrades from "Running CLI command" to the specific + * verb ("Listing workflows") while the arguments are still generating — the + * same progressive pattern the integration gateway rows use. Only complete + * quoted tokens count (a half-streamed token never matches), flags end the + * command path exactly as the worker's own matcher does, and a candidate is + * accepted only when the title registry knows it — an unknown prefix stays on + * the generic name rather than inventing one. + */ +const STREAMING_ARGS_ARRAY = /"args"\s*:\s*\[([^\]]*)/ +const COMPLETE_STRING_TOKEN = /"((?:[^"\\]|\\.)*)"/g + +export function refineStreamingCliToolName(streamingArgs: string): string | null { + const argsMatch = STREAMING_ARGS_ARRAY.exec(streamingArgs) + if (!argsMatch?.[1]) return null + const tokens: string[] = [] + for (const match of argsMatch[1].matchAll(COMPLETE_STRING_TOKEN)) { + tokens.push(match[1] ?? '') + } + if (tokens.includes('--help') || tokens.includes('-h')) return 'cli_help' + const path: string[] = [] + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i] ?? '' + if (token === '--output') { + i++ + continue + } + if (token.startsWith('-')) break + path.push(token) + } + for (let length = Math.min(path.length, 4); length >= 1; length--) { + const candidate = `cli_${path.slice(0, length).join('_').replace(/-/g, '_')}` + if (CLI_TOOL_TITLES[candidate]) return candidate + } + return null +} From 458ef79f10bacac0b45a978a2e6e53ffdfc17927 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 14:49:55 +0530 Subject: [PATCH 041/306] Sweep fixes: guarded sandbox writes, canonical token grammars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit writeSessionSandboxFile degrades on failure instead of throwing — the CLI invocation it follows already ran, possibly a mutation, and an escaping error reported that success as failure and invited a repeating retry (sim-cli maps the new error outcome to the inline fallback). The deps and lint agent commands now use the executor's own createReferencePattern/createEnvVarPattern with trimmed keys, so what they classify is exactly what the runtime resolves — three private regex re-inventions deleted. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../execution/remote-sandbox/session-files.ts | 19 +++++++++++++++++-- .../tools/handlers/agent-cli/commands/deps.ts | 10 +++++++--- .../tools/handlers/agent-cli/commands/lint.ts | 11 +++++++---- .../lib/mothership/tools/handlers/sim-cli.ts | 4 +++- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/apps/sim/lib/execution/remote-sandbox/session-files.ts b/apps/sim/lib/execution/remote-sandbox/session-files.ts index fbf19e8cf23..5d97b9cb24a 100644 --- a/apps/sim/lib/execution/remote-sandbox/session-files.ts +++ b/apps/sim/lib/execution/remote-sandbox/session-files.ts @@ -58,7 +58,10 @@ export async function readSessionSandboxFile( } } -export type SessionFileWrite = { outcome: 'written'; path: string } | { outcome: 'no-session' } +export type SessionFileWrite = + | { outcome: 'written'; path: string } + | { outcome: 'no-session' } + | { outcome: 'error'; detail: string } export async function writeSessionSandboxFile( sessionKey: string, @@ -79,6 +82,18 @@ export async function writeSessionSandboxFile( } if (!sandbox) return { outcome: 'no-session' } const resolved = resolveSessionPath(path) - await sandbox.writeFile(resolved, content) + try { + await sandbox.writeFile(resolved, content) + } catch (error) { + // A failed write must degrade, never throw: the CLI invocation it follows + // already ran — possibly a mutation — and an escaping error here would + // report that successful call as failed and invite a repeating retry. + logger.warn('Session sandbox file write failed', { + sessionKey, + path: resolved, + error: getErrorMessage(error), + }) + return { outcome: 'error', detail: getErrorMessage(error) } + } return { outcome: 'written', path: resolved } } diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/deps.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/deps.ts index 0201f59f0dc..4902d0df5b0 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/deps.ts +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/deps.ts @@ -5,6 +5,7 @@ import { agentCliOk, } from '@/lib/mothership/tools/handlers/agent-cli/types' import { normalizeName, SPECIAL_REFERENCE_PREFIXES } from '@/executor/constants' +import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation' /** * `workflow deps ` — everything one block consumes, so the @@ -15,8 +16,10 @@ import { normalizeName, SPECIAL_REFERENCE_PREFIXES } from '@/executor/constants' * never re-invent resolution semantics. */ -const TEMPLATE_REF = /<([^<>]+)>/g -const ENV_REF = /\{\{\s*([A-Za-z0-9_-]+)\s*\}\}/g +// The executor's own token grammars — this command must classify exactly what +// the runtime resolves, never a private re-invention of the syntax. +const TEMPLATE_REF = createReferencePattern() +const ENV_REF = createEnvVarPattern() interface DepView { token: string @@ -93,7 +96,8 @@ export const workflowDepsCommand: AgentCliCommand = { } } for (const match of leaf.matchAll(ENV_REF)) { - if (match[1]) envs.add(match[1]) + const key = match[1]?.trim() + if (key) envs.add(key) } } diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/lint.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/lint.ts index 70b573d28e1..3b0c1e608c2 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/lint.ts +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/lint.ts @@ -8,6 +8,7 @@ import { } from '@/lib/mothership/tools/handlers/agent-cli/types' import { formatWorkflowLintMessage, hasWorkflowLintIssues } from '@/lib/workflows/editing/lint' import { buildWorkflowLintReport } from '@/lib/workflows/editing/lint-report' +import { createEnvVarPattern } from '@/executor/utils/reference-validation' /** * The Go copilot served this as the virtual `workflows/{path}/lint.json` VFS @@ -42,15 +43,17 @@ export const workflowLintCommand: AgentCliCommand = { }, } -const ENV_TOKEN = /\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g +// The executor's own env-token grammar (keys trimmed to match its resolution). +const ENV_TOKEN = createEnvVarPattern() function envTokenNames(value: unknown, out: Map>, blockName: string): void { if (typeof value === 'string') { for (const match of value.matchAll(ENV_TOKEN)) { - if (!match[1]) continue - const blocks = out.get(match[1]) ?? new Set() + const key = match[1]?.trim() + if (!key) continue + const blocks = out.get(key) ?? new Set() blocks.add(blockName) - out.set(match[1], blocks) + out.set(key, blocks) } } else if (Array.isArray(value)) { for (const item of value) envTokenNames(item, out, blockName) diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts index 16764d5fa4d..06494f85fa0 100644 --- a/apps/sim/lib/mothership/tools/handlers/sim-cli.ts +++ b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts @@ -121,9 +121,11 @@ export async function executeSimCli( const written = await writeSessionSandboxFile(sessionKey, outputFile, result.stdout) if (written.outcome === 'written') { result.stdout = `[stdout written to ${outputFile} on your machine: ${result.stdout.length} chars. Read or process it with run_code, or pass it back as @${outputFile}.]` - } else { + } else if (written.outcome === 'no-session') { result.stdout += '\n[outputFile not written: your machine is not booted yet — run any run_code first. Output returned inline instead]' + } else { + result.stdout += '\n[outputFile write failed — output returned inline instead]' } } } From 1596a4db1ae571789c70f7e954e0a2a32f4df0d8 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 15:25:00 +0530 Subject: [PATCH 042/306] feat(workflows): selected outputs on sync runs + cross-run logs query augmentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectedOutputs was artificially stream-only: a sync run rejected it with a 400 and pointed at a second call (runs get) that resolves block ids only. The sync path already holds the full execution result, so selection now answers in the same response: blockOutputs keyed by the request's selector strings, names resolved against the exact state being run, absent paths omitted, failed and timed-out runs including the blocks that did run, values compacted like output. Route guard deleted, resume sends null, contract + openapi + cli-api + cli-docs regenerated, CLI --select-output works on a plain sync run (only --async still rejects it, locally, with the dialect hint). New agent-cli augmentation: logs query --block [--field ] [--where =] [--status] [--trigger] [--limit] — one row per run across run history, recursive span walk, last match per run wins. The augmentation layer gains command-local flag parsing (--flag value / --flag=value / bare) to support it; positional-only commands are unchanged. Autopsy fix: the formatter neutral-path workaround from the display-frames commit dropped the caller's extension, so generate:openapi (JSON) aborted with a biome parse error while .ts generators stayed green. The extension now survives; regression test added and wired into test:generators. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/docs/content/docs/cli/reference.mdx | 2 +- apps/docs/content/docs/cli/workflows.mdx | 2 +- apps/docs/openapi-v2-workflows.json | 48 +++-- .../[workflowId]/execute/route.test.ts | 46 ++++- .../workflows/[workflowId]/execute/route.ts | 14 +- .../[workflowId]/runs/[runId]/resume/route.ts | 2 + .../lib/api/contracts/v2/openapi/workflows.ts | 1 + apps/sim/lib/api/contracts/v2/workflows.ts | 8 +- .../lib/mothership/tools/cli-tool-display.ts | 1 + .../handlers/agent-cli/agent-cli.test.ts | 127 +++++++++++++ .../handlers/agent-cli/commands/query.ts | 175 ++++++++++++++++++ .../tools/handlers/agent-cli/index.ts | 55 ++++-- .../tools/handlers/agent-cli/types.ts | 9 +- .../executor/execute-service.test.ts | 60 ++++++ .../lib/workflows/executor/execute-service.ts | 75 +++++++- .../protocol/workflow-run-follow.test.ts | 30 ++- .../commands/protocol/workflow-run-follow.ts | 19 +- .../sim-cli/src/contract/commands.test.ts | 11 +- packages/sim-cli/src/contract/commands.ts | 7 +- packages/sim-cli/src/generated/v2-api.ts | 22 +-- scripts/format-generated-source.test.ts | 35 ++++ scripts/format-generated-source.ts | 13 +- 22 files changed, 658 insertions(+), 104 deletions(-) create mode 100644 apps/sim/lib/mothership/tools/handlers/agent-cli/commands/query.ts create mode 100644 apps/sim/lib/workflows/executor/execute-service.test.ts create mode 100644 scripts/format-generated-source.test.ts diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 483d1677144..9de307d400f 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -5669,7 +5669,7 @@ sim workflows run [options] | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | | `--execution-timeout-seconds ` | No | Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`. | -| `--select-output ` | No | Return streamed outputs as blockName.path or childWorkflowId.blockName.path; selecting a child workflow applies to every invocation, requires --follow (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--select-output ` | No | Return blockName.field values (e.g. agent_1.content) — in blockOutputs on a sync run, or from the streamed result with --follow; missing fields are omitted. Not available with --async (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | diff --git a/apps/docs/content/docs/cli/workflows.mdx b/apps/docs/content/docs/cli/workflows.mdx index 7dbfc39efc2..469a028223a 100644 --- a/apps/docs/content/docs/cli/workflows.mdx +++ b/apps/docs/content/docs/cli/workflows.mdx @@ -533,7 +533,7 @@ sim workflows run [options] | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | | `--execution-timeout-seconds ` | No | Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`. | -| `--select-output ` | No | Return streamed outputs as blockName.path or childWorkflowId.blockName.path; selecting a child workflow applies to every invocation, requires --follow (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--select-output ` | No | Return blockName.field values (e.g. agent_1.content) — in blockOutputs on a sync run, or from the streamed result with --follow; missing fields are omitted. Not available with --async (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 5b7c250dca8..4146cca8006 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -6920,7 +6920,14 @@ }, "kind": { "type": "string", - "enum": ["credential", "resource", "custom-tool", "mcp-tool", "skill"], + "enum": [ + "credential", + "resource", + "custom-tool", + "mcp-tool", + "skill", + "block-output" + ], "description": "What kind of entity the reference was expected to name." }, "reason": { @@ -10982,10 +10989,6 @@ "StoredChatDeploymentOutputConfig": { "type": "object", "properties": { - "workflowId": { - "description": "Child workflow containing the selected block. Omitted for the deployed workflow.", - "type": "string" - }, "blockId": { "type": "string", "description": "Block whose output the chat streams." @@ -11295,11 +11298,6 @@ "ChatDeploymentOutputConfig": { "type": "object", "properties": { - "workflowId": { - "description": "Child workflow containing the selected block. Omit for the deployed workflow.", - "type": "string", - "minLength": 1 - }, "blockId": { "type": "string", "minLength": 1, @@ -11492,6 +11490,23 @@ "output": { "description": "Workflow output, including partial output on failure." }, + "blockOutputs": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Output value produced by one workflow block." + } + }, + { + "type": "null" + } + ], + "description": "Outputs of the blocks named by `selectedOutputs`, keyed by those selector strings, or null when none were requested. Selectors whose block did not run or whose path is absent are omitted; failed runs include the outputs of the blocks that did run." + }, "error": { "anyOf": [ { @@ -11519,7 +11534,7 @@ "minimum": 0 } }, - "required": ["runId", "workflowId", "status", "output", "error"], + "required": ["runId", "workflowId", "status", "output", "blockOutputs", "error"], "additionalProperties": false, "title": "Workflow run result", "description": "Synchronous workflow run output and in-band execution status. Run failures are reported in band, not as HTTP errors — a run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"`, so branch on `status`." @@ -11545,6 +11560,7 @@ "output": { "result": "Ticket routed to Support" }, + "blockOutputs": null, "error": null, "startedAt": "2026-08-09T18:04:10.000Z", "endedAt": "2026-08-09T18:04:11.000Z", @@ -11703,7 +11719,7 @@ "type": "boolean" }, "selectedOutputs": { - "description": "Output references for streaming: `.` or `..`, using normalized block names. Child references apply to every invocation. Requires `stream: true` and rejects synchronous or async requests. Use `selectedOutputs` with Get Workflow Run to narrow an existing run.", + "description": "Block output references to include in the response, as `blockId`, `blockId.path`, or `BlockName.path` (resolved against the workflow state being run). On a sync request the named outputs come back in `blockOutputs`, keyed by these selector strings; on a stream they shape the streamed envelope. Selectors that resolve to no block or no value are omitted. Rejected when `async` is true — a queued run has produced nothing to select; narrow the finished run via the run resource instead.", "maxItems": 100, "type": "array", "items": { @@ -12281,6 +12297,7 @@ "output": { "result": "Ticket routed to Support" }, + "blockOutputs": null, "error": null, "startedAt": "2026-08-09T18:04:10.000Z", "endedAt": "2026-08-09T18:04:11.000Z", @@ -12395,7 +12412,7 @@ "description": "Whether a paused execution was cancelled." }, "reason": { - "description": "Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` and `queue_cancelled` are successful cancellation values. `already_cancelled`, `already_completed`, and `already_failed` mean the run had already reached that terminal state, so nothing was cancelled and `durablyRecorded` is false. The remaining values identify a degraded or incomplete cancellation step.", + "description": "Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` is the success value. `already_cancelled`, `already_completed`, and `already_failed` mean the run had already reached that terminal state, so nothing was cancelled and `durablyRecorded` is false. `redis_unavailable` and `redis_write_failed` mean the distributed cancellation signal was not written, so an already-running execution may not observe the cancellation. `paused_event_publish_failed` and `paused_database_cancel_failed` name the failing step for a paused run.", "type": "string", "enum": [ "recorded", @@ -12405,10 +12422,7 @@ "redis_unavailable", "redis_write_failed", "paused_event_publish_failed", - "paused_database_cancel_failed", - "queue_cancelled", - "active_resume_signal_failed", - "cancellation_not_finalized" + "paused_database_cancel_failed" ] } }, diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts index 323a43fd62d..e05a22bb55e 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts @@ -354,6 +354,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => { workflowId: 'workflow-1', status: 'completed', output: { result: 'done' }, + blockOutputs: null, error: null, durationMs: 42, }) @@ -794,12 +795,47 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => { expect(mockPreprocessExecution).not.toHaveBeenCalled() }) - it('rejects selectedOutputs on a sync request rather than ignoring it', async () => { - const res = await callExecute({ selectedOutputs: ['agent_1.content'] }) + it('returns blockOutputs for selectedOutputs on a sync request', async () => { + const agentBlockId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + mockLoadDeployedWorkflowState.mockResolvedValue({ + blocks: { [agentBlockId]: { id: agentBlockId, name: 'Agent 1' } }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + }) + mockExecuteWorkflowCore.mockResolvedValue({ + success: true, + output: { result: 'done' }, + logs: [ + { + blockId: agentBlockId, + blockName: 'Agent 1', + startedAt: 's', + endedAt: 'e', + durationMs: 5, + success: true, + output: { content: 'hi', tokens: { total: 7 } }, + }, + ], + metadata: { + duration: 42, + startTime: '2026-07-31T00:00:00.000Z', + endTime: '2026-07-31T00:00:01.000Z', + }, + }) - expect(res.status).toBe(400) - expect((await res.json()).error.message).toContain('selectedOutputs requires stream: true') - expect(mockPreprocessExecution).not.toHaveBeenCalled() + const res = await callExecute({ + input: {}, + selectedOutputs: ['Agent 1.content', 'Agent 1.absent', agentBlockId], + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.data.blockOutputs).toEqual({ + 'Agent 1.content': 'hi', + [agentBlockId]: { content: 'hi', tokens: { total: 7 } }, + }) }) it.each(['includeThinking', 'includeToolCalls'])( diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts index db03a7bd2db..183207cc518 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts @@ -124,6 +124,7 @@ function presentRun(result: ExecuteWorkflowServiceRun) { workflowId: result.workflowId, status: result.status, output: result.output ?? null, + blockOutputs: result.blockOutputs ?? null, error: result.error, startedAt: result.startedAt, endedAt: result.endedAt, @@ -399,19 +400,6 @@ export const POST = withRouteHandler( 'Async execution does not support streaming or output-shaping options' ) } - /** - * `selectedOutputs` shapes the streamed envelope only — the sync path - * returns the workflow's own final output and never reads it. Accepting - * it silently answered a full, unselected body to a caller who believed - * they had narrowed it, so the option is refused where it does nothing - * and the two paths that honour selection are named instead. - */ - if (body.selectedOutputs?.length && !body.stream) { - return v2Error( - 'BAD_REQUEST', - 'selectedOutputs requires stream: true. For a completed run, request the run resource with ?selectedOutputs= instead.' - ) - } const hasAgentStreamOptions = hasAgentStreamPolicy({ includeThinking: body.includeThinking, includeToolCalls: body.includeToolCalls, diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route.ts index e7d5c4ff63f..68792778d3f 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route.ts @@ -111,6 +111,8 @@ export const POST = withRouteHandler( workflowId, status: result.status as 'completed' | 'failed' | 'paused' | 'cancelled', output: result.output ?? null, + // Resume has no request body to name selectors in, so selection never applies here. + blockOutputs: null, error: typeof result.error === 'string' ? classifyExecutionError(new Error(result.error)) diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 83a22a67f2a..33694a33961 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -182,6 +182,7 @@ const RUN_RESULT_EXAMPLE = { workflowId: WORKFLOW_ID, status: 'completed', output: { result: 'Ticket routed to Support' }, + blockOutputs: null, error: null, startedAt: '2026-08-09T18:04:10.000Z', endedAt: '2026-08-09T18:04:11.000Z', diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 54bd519d9e1..cee85252800 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -1297,7 +1297,7 @@ export const v2ExecuteWorkflowBodySchema = z .max(100) .optional() .describe( - 'Output references for streaming: `.` or `..`, using normalized block names. Child references apply to every invocation. Requires `stream: true` and rejects synchronous or async requests. Use `selectedOutputs` with Get Workflow Run to narrow an existing run.' + 'Block output references to include in the response. Use `.` for the executed workflow or `..` for a child workflow; block names are normalized workflow reference names, and selecting a child workflow applies to every invocation of it. On a sync request the named outputs come back in `blockOutputs`, keyed by these selector strings; on a stream they shape the streamed envelope. Selectors that resolve to no block or no value are omitted. Rejected when `async` is true — a queued run has produced nothing to select; narrow the finished run via the run resource instead.' ), includeThinking: z .boolean() @@ -1369,6 +1369,12 @@ export const v2ExecuteWorkflowDataSchema = z .enum(['completed', 'failed', 'paused', 'cancelled']) .describe('Terminal or paused run status.'), output: z.unknown().describe('Workflow output, including partial output on failure.'), + blockOutputs: z + .record(z.string(), z.unknown().describe('Output value produced by one workflow block.')) + .nullable() + .describe( + 'Outputs of the blocks named by `selectedOutputs`, keyed by those selector strings, or null when none were requested. Selectors whose block did not run or whose path is absent are omitted; failed runs include the outputs of the blocks that did run.' + ), error: v2ExecutionErrorSchema .nullable() .describe('Structured execution failure, or null when none occurred.'), diff --git a/apps/sim/lib/mothership/tools/cli-tool-display.ts b/apps/sim/lib/mothership/tools/cli-tool-display.ts index 195d1a2e161..be3e5d64f93 100644 --- a/apps/sim/lib/mothership/tools/cli-tool-display.ts +++ b/apps/sim/lib/mothership/tools/cli-tool-display.ts @@ -224,6 +224,7 @@ export const CLI_TOOL_TITLES: Record = { cli_workspaces_members: 'Listing workspace members', // Agent-only CLI augmentations cli_files_grep: 'Searching file contents', + cli_logs_query: 'Querying run history', cli_workflow_blocks: 'Listing workflow blocks', cli_workflow_deps: 'Tracing block inputs', cli_workflow_edges: 'Reading workflow wiring', diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts index 5c6b31bda68..20896a480b5 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts @@ -220,3 +220,130 @@ describe('workflow grep', () => { expect(result.stderr).toContain('Unexpected request') }) }) + +describe('flag parsing', () => { + it('collects --flag value, --flag=value, and bare flags without shifting positionals', () => { + const match = matchAgentCliCommand([ + 'logs', + 'query', + 'wf-1', + '--block', + 'Router', + '--limit=5', + '--verbose', + ]) + expect(match?.rest).toEqual(['wf-1']) + expect(match?.flags.get('block')).toBe('Router') + expect(match?.flags.get('limit')).toBe('5') + expect(match?.flags.get('verbose')).toBe(true) + }) +}) + +describe('logs query', () => { + const RUNS_PATH = '/api/v2/workflows/wf-1/runs' + const runsResponse = { + data: [ + { runId: 'run-1', status: 'completed', startedAt: 't1' }, + { runId: 'run-2', status: 'completed', startedAt: 't2' }, + { runId: 'run-3', status: 'failed', startedAt: 't3' }, + ], + } + const routedTrace = { + data: { + traceSpans: [ + { + name: 'Start', + children: [{ name: 'Router', status: 'success', output: { route: 'priority', n: 2 } }], + }, + ], + }, + } + const unroutedTrace = { + data: { traceSpans: [{ name: 'Start', output: {} }] }, + } + + it('emits one row per run with the block field dug from nested spans', async () => { + const match = matchAgentCliCommand([ + 'logs', + 'query', + 'wf-1', + '--block', + 'Router', + '--field', + 'output.route', + ]) + const result = await executeAgentCliCommand( + match!, + runtimeWith({ + [RUNS_PATH]: runsResponse, + '/api/v2/logs/run-1': routedTrace, + '/api/v2/logs/run-2': unroutedTrace, + '/api/v2/logs/run-3': routedTrace, + }) + ) + expect(result.exitCode).toBe(0) + const report = JSON.parse(result.stdout) + expect(report.runsScanned).toBe(3) + expect(report.rows).toEqual([ + { + runId: 'run-1', + startedAt: 't1', + runStatus: 'completed', + hits: 1, + blockStatus: 'success', + value: 'priority', + }, + { runId: 'run-2', startedAt: 't2', runStatus: 'completed', hits: 0, value: null }, + { + runId: 'run-3', + startedAt: 't3', + runStatus: 'failed', + hits: 1, + blockStatus: 'success', + value: 'priority', + }, + ]) + }) + + it('filters rows with --where and reports unavailable traces instead of failing', async () => { + const match = matchAgentCliCommand([ + 'logs', + 'query', + 'wf-1', + '--block', + 'Router', + '--where', + 'output.route=priority', + ]) + const result = await executeAgentCliCommand( + match!, + runtimeWith({ + [RUNS_PATH]: runsResponse, + '/api/v2/logs/run-1': routedTrace, + '/api/v2/logs/run-2': unroutedTrace, + }) + ) + expect(result.exitCode).toBe(0) + const report = JSON.parse(result.stdout) + expect(report.missingTrace).toBe(1) + expect(report.rows).toEqual([ + { + runId: 'run-1', + startedAt: 't1', + runStatus: 'completed', + hits: 1, + blockStatus: 'success', + value: { route: 'priority', n: 2 }, + }, + { runId: 'run-2', startedAt: 't2', runStatus: 'completed', hits: 0, value: null }, + { runId: 'run-3', startedAt: 't3', runStatus: 'failed', note: 'trace unavailable' }, + ]) + }) + + it('fails usefully without a workflow id or --block', async () => { + const match = matchAgentCliCommand(['logs', 'query', 'wf-1']) + const result = await executeAgentCliCommand(match!, runtimeWith({})) + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('--block') + }) +}) diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/query.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/query.ts new file mode 100644 index 00000000000..cfdb6319bed --- /dev/null +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/query.ts @@ -0,0 +1,175 @@ +import { + type AgentCliCommand, + type AgentCliFlags, + type AgentCliRuntime, + agentCliFail, + agentCliOk, +} from '@/lib/mothership/tools/handlers/agent-cli/types' +import { normalizeName } from '@/executor/constants' + +/** + * `logs query --block ` — one value per run for one block's + * field, across a workflow's run history. Replaces the loop agents otherwise + * improvise (list runs, pull each trace, dig the same span path by hand — + * a field audit found that exact loop hand-rolled with per-run sleeps to answer + * "how often does this branch fire, and with what values"). The trace walk is + * recursive so blocks inside loops and subworkflows are found, and the last + * matching span per run wins so loop iterations settle on final state. + */ + +const DEFAULT_LIMIT = 20 +const MAX_LIMIT = 50 +const TRACE_FETCH_CONCURRENCY = 5 +const VALUE_MAX_CHARS = 600 + +interface QueryTraceSpan { + name?: string + status?: string + duration?: number + input?: Record + output?: Record + children?: QueryTraceSpan[] +} + +interface RunListItem { + runId: string + status?: string + trigger?: string + startedAt?: string + durationMs?: number +} + +function collectSpansByName( + spans: QueryTraceSpan[], + normalizedBlockName: string, + out: QueryTraceSpan[] +): void { + for (const span of spans) { + if (normalizeName(span.name ?? '') === normalizedBlockName) out.push(span) + if (span.children?.length) collectSpansByName(span.children, normalizedBlockName, out) + } +} + +function resolveSpanPath(span: QueryTraceSpan, path: string): unknown { + let current: unknown = span + for (const segment of path.split('.')) { + if (current == null || typeof current !== 'object') return undefined + current = (current as Record)[segment] + } + return current +} + +function clipValue(value: unknown): unknown { + if (value === undefined) return null + const serialized = JSON.stringify(value) + if (serialized === undefined || serialized.length <= VALUE_MAX_CHARS) return value + return `${serialized.slice(0, VALUE_MAX_CHARS)}… [${serialized.length} chars total]` +} + +function stringFlag(flags: AgentCliFlags, name: string): string | undefined { + const value = flags.get(name) + return typeof value === 'string' ? value : undefined +} + +export const logsQueryCommand: AgentCliCommand = { + path: ['logs', 'query'], + summary: 'One row per run: a block field across run history (--block, --field, --where)', + usage: 'logs query --block ', + async execute(rest: string[], runtime: AgentCliRuntime, flags: AgentCliFlags) { + const workflowId = rest[0] + const blockName = stringFlag(flags, 'block') ?? '' + if (!workflowId || !blockName) { + return agentCliFail( + 'Usage: sim logs query --block [--field ] [--where =] [--status ] [--trigger ] [--limit N]\n' + + 'Paths resolve inside the matched block span: output.content, input.action_id, status, duration.' + ) + } + const field = stringFlag(flags, 'field') ?? 'output' + const where = stringFlag(flags, 'where') ?? '' + const whereEquals = where.indexOf('=') + if (where && whereEquals <= 0) { + return agentCliFail('--where takes =, e.g. --where output.route=priority') + } + const wherePath = where ? where.slice(0, whereEquals) : '' + const whereValue = where ? where.slice(whereEquals + 1) : '' + const rawLimit = stringFlag(flags, 'limit') + const limit = Math.min( + MAX_LIMIT, + Math.max(1, rawLimit !== undefined ? Number(rawLimit) || DEFAULT_LIMIT : DEFAULT_LIMIT) + ) + + const listQuery: Record = { limit: String(limit) } + const status = stringFlag(flags, 'status') + if (status !== undefined) listQuery.status = status + const trigger = stringFlag(flags, 'trigger') + if (trigger !== undefined) listQuery.trigger = trigger + + const runs = await runtime.client.request<{ data: RunListItem[] }>( + `/api/v2/workflows/${encodeURIComponent(workflowId)}/runs`, + { query: listQuery } + ) + const runItems = runs.data ?? [] + const normalizedBlockName = normalizeName(blockName) + + const rows: (Record | undefined)[] = new Array(runItems.length) + let filteredOut = 0 + let missingTrace = 0 + for (let start = 0; start < runItems.length; start += TRACE_FETCH_CONCURRENCY) { + const chunk = runItems.slice(start, start + TRACE_FETCH_CONCURRENCY) + await Promise.all( + chunk.map(async (run, offset) => { + const base = { + runId: run.runId, + startedAt: run.startedAt, + runStatus: run.status, + } + let spans: QueryTraceSpan[] + try { + const trace = await runtime.client.request<{ + data: { traceSpans?: QueryTraceSpan[] } + }>(`/api/v2/logs/${encodeURIComponent(run.runId)}`) + spans = trace.data.traceSpans ?? [] + } catch { + missingTrace++ + rows[start + offset] = { ...base, note: 'trace unavailable' } + return + } + const matches: QueryTraceSpan[] = [] + collectSpansByName(spans, normalizedBlockName, matches) + const last = matches[matches.length - 1] + if (!last) { + rows[start + offset] = { ...base, hits: 0, value: null } + return + } + if (wherePath && String(resolveSpanPath(last, wherePath)) !== whereValue) { + filteredOut++ + return + } + rows[start + offset] = { + ...base, + hits: matches.length, + blockStatus: last.status ?? 'success', + value: clipValue(resolveSpanPath(last, field)), + } + }) + ) + } + + const kept = rows.filter((row): row is Record => row !== undefined) + return agentCliOk( + JSON.stringify( + { + workflowId, + block: blockName, + field, + runsScanned: runItems.length, + ...(wherePath ? { where, filteredOut } : {}), + ...(missingTrace ? { missingTrace } : {}), + rows: kept, + }, + null, + 2 + ) + ) + }, +} diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts index ec287b2480c..fe179138fa9 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts @@ -5,6 +5,7 @@ import { workflowsGrepCommand, } from '@/lib/mothership/tools/handlers/agent-cli/commands/grep' import { workflowLintCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/lint' +import { logsQueryCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/query' import { workflowTraceCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/trace' import { workflowBlocksCommand, @@ -24,6 +25,7 @@ import { */ const AGENT_CLI_COMMANDS: readonly AgentCliCommand[] = [ filesGrepCommand, + logsQueryCommand, workflowDepsCommand, workflowBlocksCommand, workflowEdgesCommand, @@ -36,42 +38,67 @@ const AGENT_CLI_COMMANDS: readonly AgentCliCommand[] = [ /** Global flags (with values) that may precede the subcommand, e.g. --output json. */ const VALUE_FLAGS = new Set(['--output', '-o']) -/** Strips global flags (and their values) so matching sees bare command tokens. */ -function commandTokens(args: string[]): string[] { +/** + * Splits an invocation into bare command tokens and command-local flags. + * `--flag value` and `--flag=value` become string entries, a trailing or + * value-less `--flag` becomes `true`; global rendering flags are dropped. + */ +function parseInvocation(args: string[]): { tokens: string[]; flags: Map } { const tokens: string[] = [] + const flags = new Map() for (let i = 0; i < args.length; i++) { const arg = args[i] - if (arg.startsWith('-')) { - if (VALUE_FLAGS.has(arg)) i++ + if (!arg.startsWith('-')) { + tokens.push(arg) continue } - tokens.push(arg) + if (VALUE_FLAGS.has(arg)) { + i++ + continue + } + const name = arg.replace(/^-+/, '') + const equalsIndex = name.indexOf('=') + if (equalsIndex > 0) { + flags.set(name.slice(0, equalsIndex), name.slice(equalsIndex + 1)) + continue + } + const next = args[i + 1] + if (next !== undefined && !next.startsWith('-')) { + flags.set(name, next) + i++ + } else { + flags.set(name, true) + } } - return tokens + return { tokens, flags } +} + +export interface AgentCliMatch { + command: AgentCliCommand + rest: string[] + flags: Map } -export function matchAgentCliCommand( - args: string[] -): { command: AgentCliCommand; rest: string[] } | null { - const tokens = commandTokens(args) - let best: { command: AgentCliCommand; rest: string[] } | null = null +export function matchAgentCliCommand(args: string[]): AgentCliMatch | null { + const { tokens, flags } = parseInvocation(args) + let best: AgentCliMatch | null = null for (const command of AGENT_CLI_COMMANDS) { const matches = tokens.length >= command.path.length && command.path.every((part, index) => tokens[index] === part) if (matches && (!best || command.path.length > best.command.path.length)) { - best = { command, rest: tokens.slice(command.path.length) } + best = { command, rest: tokens.slice(command.path.length), flags } } } return best } export async function executeAgentCliCommand( - match: { command: AgentCliCommand; rest: string[] }, + match: AgentCliMatch, runtime: AgentCliRuntime ): Promise { try { - return await match.command.execute(match.rest, runtime) + return await match.command.execute(match.rest, runtime, match.flags) } catch (error) { return agentCliFail(error instanceof Error ? error.message : String(error)) } diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/types.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/types.ts index 3ee06f755f6..f5628cf0015 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/types.ts +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/types.ts @@ -28,6 +28,13 @@ export interface AgentCliResult { stderr: string } +/** + * Command-local flags parsed from the invocation: `--flag value` and + * `--flag=value` map to strings, a bare `--flag` maps to `true`. Global + * rendering flags (`--output`) are stripped before parsing and never appear. + */ +export type AgentCliFlags = ReadonlyMap + export interface AgentCliCommand { /** argv tokens that select this command, matched as a prefix (e.g. ['workflow', 'edges']). */ path: readonly string[] @@ -35,7 +42,7 @@ export interface AgentCliCommand { summary: string /** Full usage line, e.g. 'workflow edges '. */ usage: string - execute(rest: string[], runtime: AgentCliRuntime): Promise + execute(rest: string[], runtime: AgentCliRuntime, flags: AgentCliFlags): Promise } export function agentCliOk(stdout: string): AgentCliResult { diff --git a/apps/sim/lib/workflows/executor/execute-service.test.ts b/apps/sim/lib/workflows/executor/execute-service.test.ts new file mode 100644 index 00000000000..02f2b1ded45 --- /dev/null +++ b/apps/sim/lib/workflows/executor/execute-service.test.ts @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { pickRunBlockOutputs } from '@/lib/workflows/executor/execute-service' +import type { BlockLog } from '@/executor/types' + +const AGENT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const ROUTER_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + +const blocks = { + [AGENT_ID]: { id: AGENT_ID, name: 'Agent 1' }, + [ROUTER_ID]: { id: ROUTER_ID, name: 'Router' }, +} + +function log(blockId: string, output: Record): BlockLog { + return { + blockId, + startedAt: 's', + endedAt: 'e', + durationMs: 1, + success: true, + output, + } +} + +describe('pickRunBlockOutputs', () => { + it('returns null when no selectors were requested', () => { + expect(pickRunBlockOutputs(undefined, blocks, [log(AGENT_ID, {})])).toBeNull() + expect(pickRunBlockOutputs([], blocks, [log(AGENT_ID, {})])).toBeNull() + }) + + it('resolves block names and ids, digging nested paths', () => { + const logs = [log(AGENT_ID, { content: 'hi', tokens: { total: 7 } })] + + expect( + pickRunBlockOutputs(['Agent 1.content', 'Agent 1.tokens.total', AGENT_ID], blocks, logs) + ).toEqual({ + 'Agent 1.content': 'hi', + 'Agent 1.tokens.total': 7, + [AGENT_ID]: { content: 'hi', tokens: { total: 7 } }, + }) + }) + + it('omits selectors for unknown blocks, unexecuted blocks, and absent paths', () => { + const logs = [log(AGENT_ID, { content: 'hi' })] + + expect( + pickRunBlockOutputs(['Missing.content', 'Router.route', 'Agent 1.absent'], blocks, logs) + ).toEqual({}) + }) + + it('reports the last log per block so loop iterations settle on final state', () => { + const logs = [log(AGENT_ID, { content: 'first' }), log(AGENT_ID, { content: 'last' })] + + expect(pickRunBlockOutputs(['Agent 1.content'], blocks, logs)).toEqual({ + 'Agent 1.content': 'last', + }) + }) +}) diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts index bc78aa61186..81b80966e3f 100644 --- a/apps/sim/lib/workflows/executor/execute-service.ts +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -43,7 +43,7 @@ import { workflowHasResponseBlock } from '@/lib/workflows/utils' import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types' -import type { NormalizedBlockOutput } from '@/executor/types' +import type { BlockLog, NormalizedBlockOutput } from '@/executor/types' import { classifyExecutionError, hasExecutionResult, @@ -145,6 +145,8 @@ export interface ExecuteWorkflowServiceRun { aborted: 'client' | 'timeout' | null output: NormalizedBlockOutput | undefined error: StructuredExecutionError | null + /** Outputs of the blocks named by `selectedOutputs`, keyed by the caller's selector strings. */ + blockOutputs?: Record | null /** Trusted execution-local catalog used by internal callers to project model-visible output. */ resolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1 hasResponseBlock: boolean @@ -745,6 +747,7 @@ export async function executeWorkflowService( status: 'failed', aborted: 'timeout', output: compactTimeoutOutput, + blockOutputs: await compactServiceOutput(pickRunBlockOutputs(selectedOutputs, workflowBlocks, result.logs), compactionContext), error: { message: timeoutErrorMessage, code: 'TIMEOUT' }, resolvedSecretTraceProvenance: result.executionState?.resolvedSecretTraceProvenance, hasResponseBlock: false, @@ -790,6 +793,7 @@ export async function executeWorkflowService( status, aborted: null, output: compactOutput, + blockOutputs: await compactServiceOutput(pickRunBlockOutputs(selectedOutputs, workflowBlocks, result.logs), compactionContext), error: status === 'failed' || (status === 'cancelled' && result.error) ? classifyExecutionError(result.error ? new Error(result.error) : undefined, result) @@ -837,12 +841,14 @@ export async function executeWorkflowService( reqLogger.error(`Execution failed: ${errorMessage}`) let compactErrorOutput: NormalizedBlockOutput | undefined + let compactErrorBlockOutputs: Record | null = null if (executionResult && Object.hasOwn(executionResult, 'output')) { try { compactErrorOutput = await compactServiceOutput( executionResult.output, compactionContext ) + compactErrorBlockOutputs = await compactServiceOutput(pickRunBlockOutputs(selectedOutputs, workflowBlocks, executionResult.logs), compactionContext) } catch (compactError) { if ( compactError instanceof PayloadSizeLimitError && @@ -868,6 +874,7 @@ export async function executeWorkflowService( status: 'failed', aborted: null, output: compactErrorOutput, + blockOutputs: compactErrorBlockOutputs, error: classifyExecutionError(error, executionResult), resolvedSecretTraceProvenance: executionResult?.executionState?.resolvedSecretTraceProvenance, @@ -922,3 +929,69 @@ export async function resolveOutputIds( currentBlocks: blocks as Record, }) } + +const UUID_LENGTH = 36 + +function resolveOutputPath(value: unknown, path: string[]): unknown { + let current: unknown = value + for (const segment of path) { + if (current == null || typeof current !== 'object') return undefined + current = (current as Record)[segment] + } + return current +} + +/** + * Projects `selectedOutputs` onto a finished run's block logs, so a sync run + * answers with the named blocks' outputs in the same response — no second call + * to the run resource and no block-name→id translation for the caller. + * + * Keys are the caller's original selector strings. A selector whose block never + * ran, resolved to no block, or whose path is absent is omitted — the same + * missing-fields-are-omitted contract the streamed and finished-run selections + * follow. The last log per block wins, so a block inside a loop reports its + * final iteration's output. + */ +export function pickRunBlockOutputs( + selectedOutputs: string[] | undefined, + blocks: Record, + logs: BlockLog[] | undefined +): Record | null { + if (!selectedOutputs || selectedOutputs.length === 0) return null + + const outputByBlockId = new Map() + for (const log of logs ?? []) { + if (log.output !== undefined) outputByBlockId.set(log.blockId, log.output) + } + + const resolved = resolveOutputIds(selectedOutputs, blocks) ?? [] + const picked: Record = {} + for (let i = 0; i < selectedOutputs.length; i++) { + const selector = selectedOutputs[i] + const resolvedId = resolved[i] + if (!selector || !resolvedId) continue + + let blockId: string + let path: string[] + if (isValidUuid(resolvedId)) { + blockId = resolvedId + path = [] + } else if ( + resolvedId.charAt(UUID_LENGTH) === '_' && + isValidUuid(resolvedId.slice(0, UUID_LENGTH)) + ) { + blockId = resolvedId.slice(0, UUID_LENGTH) + path = resolvedId.slice(UUID_LENGTH + 1).split('.') + } else { + continue + } + + if (!outputByBlockId.has(blockId)) continue + const value = + path.length === 0 + ? outputByBlockId.get(blockId) + : resolveOutputPath(outputByBlockId.get(blockId), path) + if (value !== undefined) picked[selector] = value + } + return picked +} diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts b/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts index 1e74f4dd4c0..1b44d216277 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts @@ -303,32 +303,28 @@ describe('sim workflows run --follow', () => { expect(request).not.toHaveBeenCalled() }) - it('refuses --select-output without --follow and sends nothing', async () => { - await expect(run(WORKFLOW_ID, '--select-output', 'agent_1.content')).rejects.toThrow( - /add --follow/ - ) - expect(request).not.toHaveBeenCalled() + it('sends --select-output through the sync path without --follow', async () => { + request.mockResolvedValue({ data: { success: true, output: {}, blockOutputs: {} } }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await run(WORKFLOW_ID, '--select-output', 'agent_1.content') + expect(requestRaw).not.toHaveBeenCalled() + expect(request.mock.calls[0][1].body).toEqual({ selectedOutputs: ['agent_1.content'] }) }) - it('points a refused --select-output at the dialect the run resource takes', async () => { + it('refuses --async --select-output and points at the run-resource dialect', async () => { // The caller just typed a block *name*, which is what this flag accepts and - // what `workflows runs get` rejects, so a hint that only repeated the flag - // would send them into a second 400. - await expect(run(WORKFLOW_ID, '--select-output', 'agent_1.content')).rejects.toThrow( - /workflows runs get .*--select-output \[\.path\].*block ids, not the block names/s - ) - }) - - it('tells --async --select-output that no stream is coming, rather than to follow', async () => { - // `--async --follow` is refused outright, so "add --follow" would be advice - // that cannot be taken. + // what `workflows runs get` rejects, so the hint must name that dialect + // shift instead of repeating the flag into a second failure. const failure = await run(WORKFLOW_ID, '--async', '--select-output', 'agent_1.content').catch( (error: Error) => error ) expect(failure?.message).toContain('--async returns as soon as the run is queued') - expect(failure?.message).not.toContain('add --follow') + expect(failure?.message).toMatch( + /workflows runs get .*--select-output \[\.path\].*block ids, not the block names/s + ) expect(request).not.toHaveBeenCalled() }) diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts index 887ccf1c1c9..bc491572ca5 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts @@ -412,16 +412,17 @@ function followOrDelegate(previous: ((args: unknown[]) => unknown) | null) { const flags = command.optsWithGlobals() as Record if (flags.follow !== true) { - // `selectedOutputs` is stream-only server-side, so without `--follow` the - // generated path spends a request to be told so. The recovery names the - // run resource and its dialect: `--select-output` here takes block names, - // and `workflows runs get` resolves block ids only, so repeating what was - // just typed there fails a second time. - if (Array.isArray(flags.selectOutput) && flags.selectOutput.length > 0) { + // A queued run has produced nothing to select from, so the server would + // answer 400; failing locally names the recovery. The finished-run + // resource speaks a different dialect — it matches block ids only, so + // repeating the block names typed here would fail a second time. + if ( + Array.isArray(flags.selectOutput) && + flags.selectOutput.length > 0 && + flags.async === true + ) { throw new SimApiError( - flags.async === true - ? '--select-output shapes a streamed result, and --async returns as soon as the run is queued, so there is no stream to shape. Drop one of them, or read the finished run with: sim workflows runs get --workflow --select-output [.path] — that resource matches block ids, not the block names --select-output takes here.' - : '--select-output shapes a streamed result; add --follow. To narrow a run that has already finished: sim workflows runs get --workflow --select-output [.path] — that resource matches block ids, not the block names --select-output takes here.', + '--select-output names outputs of a completed run, and --async returns as soon as the run is queued. Drop one of them, or read the finished run with: sim workflows runs get --workflow --select-output [.path] — that resource matches block ids, not the block names --select-output takes here.', 0 ) } diff --git a/packages/sim-cli/src/contract/commands.test.ts b/packages/sim-cli/src/contract/commands.test.ts index 6c2120133a2..bda09721a58 100644 --- a/packages/sim-cli/src/contract/commands.test.ts +++ b/packages/sim-cli/src/contract/commands.test.ts @@ -682,14 +682,15 @@ describe('help and gates state what is actually true', () => { expect(confirm).toContain('--scope') }) - it('names the flag its stream requirement, the way its siblings do', () => { - // `--include-thinking` and `--include-tool-calls` both say so; the flag - // that shares their server-side rule said nothing and spent a 400 to - // discover it. + it('states where selected outputs land and the one mode that rejects them', () => { + // The flag once required --follow; now a plain sync run answers in + // `blockOutputs`, and the help must say so — plus the surviving + // restriction (--async), so the caller never spends a 400 to learn it. const help = flatHelp('workflows', 'run') expect(help).toContain('--select-output') - expect(help).toContain('requires --follow') + expect(help).toContain('blockOutputs on a sync run') + expect(help).toContain('Not available with --async') }) it('promises the dialect a finished run actually matches', () => { diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index c3cde4daf57..e94744f7680 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1649,14 +1649,13 @@ export const CLI_CONTRACT: CliContract = { hidden: true, describe: 'Low-level workflow state and entry-point selection', }, - // Stream-only on the wire, so the requirement is stated where the flag - // is read rather than left to the 400. The dialect differs from the one - // `workflows runs get` takes, which is why both describes name theirs. + // The dialect differs from the one `workflows runs get` takes (names + // resolve here, ids only there), which is why both describes name theirs. selectedOutputs: { name: 'select-output', list: true, describe: - 'Return streamed outputs as blockName.path or childWorkflowId.blockName.path; selecting a child workflow applies to every invocation, requires --follow', + 'Return blockName.field values (e.g. agent_1.content), or childWorkflowId.blockName.field for a child workflow (applies to every invocation) — in blockOutputs on a sync run, or from the streamed result with --follow; missing fields are omitted. Not available with --async', }, // SSE, not JSON — the generic client cannot consume it, so the response // encoding is chosen by `--follow`, which `workflow-run-follow.ts` adds to diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index dc8616b41b2..1bc7dab4f24 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -553,7 +553,7 @@ type ApplyWorkflowOperationsResponseRef2 = { blockType: string | null field: string value: string | Array - kind: 'credential' | 'resource' | 'custom-tool' | 'mcp-tool' | 'skill' + kind: 'credential' | 'resource' | 'custom-tool' | 'mcp-tool' | 'skill' | 'block-output' reason: string }> notes: Array @@ -1049,9 +1049,6 @@ type CancelWorkflowRunResponseRef0 = { | 'redis_write_failed' | 'paused_event_publish_failed' | 'paused_database_cancel_failed' - | 'queue_cancelled' - | 'active_resume_signal_failed' - | 'cancellation_not_finalized' } export type CancelWorkflowRunResponse = { @@ -1065,6 +1062,7 @@ export type ChatBody = { workspaceId: string message: string conversationId?: string + effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' } export type ChatResponse = { @@ -3688,6 +3686,7 @@ type ExecuteWorkflowResponseRef1 = { workflowId: string status: 'completed' | 'failed' | 'paused' | 'cancelled' output: unknown + blockOutputs: Record | null error: ExecuteWorkflowResponseRef0 | null startedAt?: string endedAt?: string @@ -4508,7 +4507,6 @@ type GetLogResponseRef2 = { endedAt: string | null totalDurationMs: number | null files: Array | null - executedByEmail: string | null workflow: { id: string | null name: string @@ -5250,7 +5248,6 @@ type GetWorkflowChatDeploymentResponseRef0 = { } type GetWorkflowChatDeploymentResponseRef1 = { - workflowId?: string blockId: string path: string } @@ -5998,7 +5995,6 @@ type ListChatDeploymentsResponseRef0 = { } type ListChatDeploymentsResponseRef1 = { - workflowId?: string blockId: string path: string } @@ -8804,7 +8800,6 @@ type ReplaceWorkflowChatDeploymentBodyRef0 = { } type ReplaceWorkflowChatDeploymentBodyRef1 = { - workflowId?: string blockId: string path: string } @@ -8829,7 +8824,6 @@ type ReplaceWorkflowChatDeploymentResponseRef0 = { } type ReplaceWorkflowChatDeploymentResponseRef1 = { - workflowId?: string blockId: string path: string } @@ -9015,7 +9009,7 @@ type ReplaceWorkflowStateResponseRef0 = { blockType: string | null field: string value: string | Array - kind: 'credential' | 'resource' | 'custom-tool' | 'mcp-tool' | 'skill' + kind: 'credential' | 'resource' | 'custom-tool' | 'mcp-tool' | 'skill' | 'block-output' reason: string }> notes: Array @@ -9286,6 +9280,7 @@ type ResumeWorkflowResponseRef1 = { workflowId: string status: 'completed' | 'failed' | 'paused' | 'cancelled' output: unknown + blockOutputs: Record | null error: ResumeWorkflowResponseRef0 | null startedAt?: string endedAt?: string @@ -11602,6 +11597,11 @@ export const V2_OPERATIONS = { kind: 'string', describe: 'Conversation to continue; a new one starts when omitted.', }, + effort: { + kind: 'enum', + values: ['low', 'medium', 'high', 'xhigh', 'max'] as const, + describe: 'Model effort for this turn; defaults to the deployment default (high).', + }, }, }, completeFileUpload: { @@ -13084,7 +13084,7 @@ export const V2_OPERATIONS = { selectedOutputs: { kind: 'array', describe: - 'Output references for streaming: `.` or `..`, using normalized block names. Child references apply to every invocation. Requires `stream: true` and rejects synchronous or async requests. Use `selectedOutputs` with Get Workflow Run to narrow an existing run.', + 'Block output references to include in the response, as `blockId`, `blockId.path`, or `BlockName.path` (resolved against the workflow state being run). On a sync request the named outputs come back in `blockOutputs`, keyed by these selector strings; on a stream they shape the streamed envelope. Selectors that resolve to no block or no value are omitted. Rejected when `async` is true — a queued run has produced nothing to select; narrow the finished run via the run resource instead.', }, includeThinking: { kind: 'boolean', diff --git a/scripts/format-generated-source.test.ts b/scripts/format-generated-source.test.ts new file mode 100644 index 00000000000..705c384ffa9 --- /dev/null +++ b/scripts/format-generated-source.test.ts @@ -0,0 +1,35 @@ +/** + * @vitest-environment node + */ +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { formatGeneratedSource } from './format-generated-source' + +const ROOT = path.resolve(import.meta.dirname, '..') + +/** + * The neutral-path workaround (biome refuses stdin paths its config excludes) + * must preserve the caller's extension: biome parses the stdin as the language + * the declared path names, so a `.json` document declared as `.ts` aborts with + * a parse error. That exact regression shipped once — generate:openapi (JSON) + * broke while generate:cli-api (TS) stayed green. + */ +describe('formatGeneratedSource', () => { + it('formats JSON output under a JSON-typed neutral path', () => { + const formatted = formatGeneratedSource( + '{"a":1,\n "b": [1,2]}\n', + path.join(ROOT, 'apps/docs/openapi-probe.json'), + ROOT + ) + expect(JSON.parse(formatted)).toEqual({ a: 1, b: [1, 2] }) + }) + + it('formats TypeScript output under a TS-typed neutral path', () => { + const formatted = formatGeneratedSource( + 'export const x = {a: 1}\n', + path.join(ROOT, 'packages/sim-cli/src/generated/probe.ts'), + ROOT + ) + expect(formatted).toContain('export const x') + }) +}) diff --git a/scripts/format-generated-source.ts b/scripts/format-generated-source.ts index bab0b9abfb9..b0ff2f24da2 100644 --- a/scripts/format-generated-source.ts +++ b/scripts/format-generated-source.ts @@ -1,13 +1,18 @@ import { spawnSync } from 'node:child_process' +import { extname, join } from 'node:path' import { localBin } from './local-bin' -import { join } from 'node:path' export function formatGeneratedSource(source: string, stdinFilePath: string, cwd: string): string { // biome.json excludes the generated output dirs, and biome refuses to format a // stdin whose declared path is excluded — so declare a neutral path instead; - // formatting rules do not vary by location, only ignores do. - void stdinFilePath - const neutralPath = join(cwd, 'scripts', '.generated-format-buffer.ts') + // formatting rules do not vary by location, only ignores do. The caller's + // extension survives, because it is what biome parses the stdin as — a .json + // document declared as .ts aborts with a parse error, not a format. + const neutralPath = join( + cwd, + 'scripts', + `.generated-format-buffer${extname(stdinFilePath) || '.ts'}` + ) const result = spawnSync(localBin('biome'), ['format', '--stdin-file-path', neutralPath], { cwd, encoding: 'utf8', From 770d643b7f3139ae6bd501db746bcc598102e107 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 16:13:10 +0530 Subject: [PATCH 043/306] =?UTF-8?q?fix(mothership):=20BYOK=20rides=20every?= =?UTF-8?q?=20worker=20leg=20=E2=80=94=20resume,=20copilot=20route,=20exec?= =?UTF-8?q?ute,=20title?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to worker batch A. The key attached only to '/api/mothership*' sends: workflow-scoped copilot chats never pinned, and a resume landing on a dead run became a hosted-key continuation. Resolution moves to a shared resolveEnterpriseByokKey (entitlement-gated, revocation-fresh, fails to hosted), applied per leg in the lifecycle loop, on child-chain resume legs, and on title generation (which reads message content). Regenerated protocol mirror carries the new optional fields. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/lib/mothership/generated/protocol.ts | 11 +++++ .../lib/mothership/request/enterprise-byok.ts | 34 +++++++++++++ .../mothership/request/lifecycle/run.test.ts | 26 ++++++++++ .../lib/mothership/request/lifecycle/run.ts | 49 +++++++++---------- .../lib/mothership/request/lifecycle/start.ts | 4 ++ 5 files changed, 99 insertions(+), 25 deletions(-) create mode 100644 apps/sim/lib/mothership/request/enterprise-byok.ts diff --git a/apps/sim/lib/mothership/generated/protocol.ts b/apps/sim/lib/mothership/generated/protocol.ts index 172d32669de..8ffcf293ac7 100644 --- a/apps/sim/lib/mothership/generated/protocol.ts +++ b/apps/sim/lib/mothership/generated/protocol.ts @@ -61,6 +61,13 @@ export interface ChatContextItem { export interface ResumeRequest { streamId: string; results: ResumeResult[]; + /** + * Enterprise BYOK, re-resolved by sim per call (S27: context-only, zero retention). + * A LIVE run keeps its key inside the loop closure and ignores this; a DEAD run's + * continuation leg has no closure, so without it that leg would silently fall back + * to the hosted key mid-chat. + */ + byokApiKey?: string | undefined; } export interface ResumeResult { @@ -87,6 +94,8 @@ export interface SteerRequest { /** POST /api/generate-chat-title */ export interface TitleRequest { message: string; + /** Enterprise BYOK: the title call reads user content, so it pins the same key (S27). */ + byokApiKey?: string | undefined; } /** The 409 body for a duplicate send while a sibling instance streams (S32). */ @@ -123,6 +132,8 @@ export interface ExecuteRequest { integrationTools?: unknown[] | undefined; mothershipTools?: unknown[] | undefined; delegationToken?: string | undefined; + /** Enterprise BYOK: one-shot executions pin the customer key like chat turns (S27). */ + byokApiKey?: string | undefined; } export interface ExecuteMessage { diff --git a/apps/sim/lib/mothership/request/enterprise-byok.ts b/apps/sim/lib/mothership/request/enterprise-byok.ts new file mode 100644 index 00000000000..c9842dd1b41 --- /dev/null +++ b/apps/sim/lib/mothership/request/enterprise-byok.ts @@ -0,0 +1,34 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { getBYOKKey } from '@/lib/api-key/byok' +import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription' + +const logger = createLogger('EnterpriseByok') + +/** + * Resolves the enterprise BYOK key sim-side for a mothership call (contract field + * `byokApiKey`, S27): the worker builds per-request provider instances from it and + * retains nothing. Eligibility (enterprise plan) gates resolution server-side, so a + * client can never assert its own eligibility; key rows are read fresh so revocation is + * immediate. Failures default to hosted. + * + * Every worker call that reaches a model must resolve this — the initial send, the + * workflow-scoped copilot send, one-shot executes, tool-resume (a dead-run continuation + * leg re-applies it), and title generation — or that leg silently runs on the hosted key. + */ +export async function resolveEnterpriseByokKey( + workspaceId: string | undefined +): Promise { + if (!workspaceId) return null + try { + if (!(await isWorkspaceOnEnterprisePlan(workspaceId))) return null + const byok = await getBYOKKey(workspaceId, 'anthropic') + return byok?.apiKey ?? null + } catch (error) { + logger.warn('Failed to resolve BYOK key; defaulting to hosted', { + workspaceId, + error: toError(error).message, + }) + return null + } +} diff --git a/apps/sim/lib/mothership/request/lifecycle/run.test.ts b/apps/sim/lib/mothership/request/lifecycle/run.test.ts index 194ec40d024..c8ac61db2e4 100644 --- a/apps/sim/lib/mothership/request/lifecycle/run.test.ts +++ b/apps/sim/lib/mothership/request/lifecycle/run.test.ts @@ -170,6 +170,13 @@ vi.mock('@/lib/mothership/request/tools/executor', () => ({ pendingToolWaitBudgetMs: mockPendingToolWaitBudgetMs, })) +const { mockResolveEnterpriseByokKey } = vi.hoisted(() => ({ + mockResolveEnterpriseByokKey: vi.fn().mockResolvedValue(null), +})) +vi.mock('@/lib/mothership/request/enterprise-byok', () => ({ + resolveEnterpriseByokKey: mockResolveEnterpriseByokKey, +})) + import { MothershipStreamV1CompletionStatus, MothershipStreamV1ToolOutcome, @@ -573,6 +580,25 @@ describe('runCopilotLifecycle', () => { expect(sent).toEqual(payload) }) + it('attaches the resolved enterprise BYOK key to the outbound payload', async () => { + mockResolveEnterpriseByokKey.mockResolvedValueOnce('sk-ant-enterprise-test') + const payload = { message: 'hi', workspaceId: 'ws-ent', messageId: 'stream-byok-attach' } + let capturedRequestBody = '' + mockRunStreamLoop.mockImplementationOnce(async (_url: string, request: RequestInit) => { + capturedRequestBody = String(request.body) + }) + + await runCopilotLifecycle(payload, { + userId: 'user-1', + workspaceId: 'ws-ent', + executionContext: { userId: 'user-1', workflowId: '', workspaceId: 'ws-ent' }, + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry([]), + }) + + const sent = JSON.parse(capturedRequestBody) + expect(sent.byokApiKey).toBe('sk-ant-enterprise-test') + }) + it('preserves large ordinary tool catalogs without scanning configured secret values', async () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: 'catalog-secret', encryptedValue: 'ciphertext' }, diff --git a/apps/sim/lib/mothership/request/lifecycle/run.ts b/apps/sim/lib/mothership/request/lifecycle/run.ts index ea565a95537..fbe8d158105 100644 --- a/apps/sim/lib/mothership/request/lifecycle/run.ts +++ b/apps/sim/lib/mothership/request/lifecycle/run.ts @@ -6,7 +6,6 @@ import { interruptibleSleep, sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { isPlainRecord, omit } from '@sim/utils/object' import { workspaceSearchFiltersSchema } from '@/lib/api/contracts/knowledge/search' -import { getBYOKKey } from '@/lib/api-key/byok' import { type AttributedBillingRequestEnvelope, assertBillingAttributionSnapshot, @@ -14,7 +13,6 @@ import { checkAttributedUsageLimits, createAttributedBillingRequestEnvelope, } from '@/lib/billing/core/billing-attribution' -import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription' import { loadCopilotSearchIntegrations } from '@/lib/mothership/application/load-search-integrations' import { env } from '@/lib/core/config/env' import { isCopilotToolPermissionsEnabled, isHosted } from '@/lib/core/config/env-flags' @@ -35,6 +33,7 @@ import { CopilotDegradedReason } from '@/lib/mothership/generated/trace-attribut import { getAutoAllowedTools } from '@/lib/mothership/persistence/tool-permission/auto-allow' import { createStreamingContext } from '@/lib/mothership/request/context/request-context' import { buildToolCallSummaries } from '@/lib/mothership/request/context/result' +import { resolveEnterpriseByokKey } from '@/lib/mothership/request/enterprise-byok' import { BillingLimitError, CopilotBackendError, @@ -851,6 +850,9 @@ async function driveOneChildChain( options.onAbortObserved?.(reason) }, } + // Same per-leg BYOK rule as the main loop: this child-chain resume can also land on + // a dead run and become a hosted-key continuation without it. + const byokApiKey = await resolveEnterpriseByokKey(workspaceId) await runResumeLegWithRetry( `${baseURL}/api/tools/resume`, { @@ -862,6 +864,7 @@ async function driveOneChildChain( ? { organizationId: execContext.organizationId, chatId: execContext.chatId } : {}), results, + ...(byokApiKey ? { byokApiKey } : {}), }, leg, execContext, @@ -1019,15 +1022,15 @@ async function runCheckpointLoop( payload = { ...payload, organizationId: lifecycleOrganizationId, chatId: execContext.chatId } } - // Enterprise BYOK eligibility hint: set once on the initial mothership request - // so Go only attempts a BYOK lookup for entitled workspaces. This is only a - // gate — Go re-confirms entitlement authoritatively before using any key. - payload = await withEnterpriseByokKey(payload, route, lifecycleWorkspaceId) - for (;;) { context.streamComplete = false const isResume = route === '/api/tools/resume' + // Enterprise BYOK rides EVERY leg, resume included: a resume that lands on a dead + // run becomes a continuation with no closure holding the key. Re-resolved per leg so + // revocation is immediate (key rows are read fresh; entitlement is cached). + payload = await withEnterpriseByokKey(payload, route, lifecycleWorkspaceId) + if (isResume && isAborted(options, context)) { cancelPendingTools(context) context.awaitingAsyncContinuation = undefined @@ -1551,30 +1554,26 @@ async function ensureHeadlessRunIdentity(input: { // Helpers /** - * Resolves the enterprise BYOK key sim-side and attaches it as `byokApiKey` - * (contract field, S27): the worker builds a per-run provider instance from it and - * retains nothing. Eligibility (enterprise plan) gates resolution server-side, so a - * client can never assert its own eligibility; key rows are read fresh so revocation - * is immediate. Failures default to hosted. Mothership-only — other routes untouched. + * Routes whose payloads carry `byokApiKey` (see resolveEnterpriseByokKey): every + * model-reaching worker call, INCLUDING tool-resume — a resume that lands on a dead run + * becomes a continuation leg with no closure holding the key, so omitting it there + * silently finishes an enterprise chat on the hosted key. */ +const BYOK_ROUTES = [ + '/api/mothership', + '/api/mothership/execute', + '/api/copilot', + '/api/tools/resume', +] + async function withEnterpriseByokKey( payload: Record, route: string, workspaceId?: string ): Promise> { - if (!workspaceId || !route.startsWith('/api/mothership')) return payload - try { - if (!(await isWorkspaceOnEnterprisePlan(workspaceId))) return payload - const byok = await getBYOKKey(workspaceId, 'anthropic') - if (!byok) return payload - return { ...payload, byokApiKey: byok.apiKey } - } catch (error) { - logger.warn('Failed to resolve BYOK key; defaulting to hosted', { - workspaceId, - error: toError(error).message, - }) - return payload - } + if (!BYOK_ROUTES.includes(route)) return payload + const byokApiKey = await resolveEnterpriseByokKey(workspaceId) + return byokApiKey ? { ...payload, byokApiKey } : payload } function isAborted(options: CopilotLifecycleOptions, context: StreamingContext): boolean { diff --git a/apps/sim/lib/mothership/request/lifecycle/start.ts b/apps/sim/lib/mothership/request/lifecycle/start.ts index 45c2e2ff293..fdde2d0e509 100644 --- a/apps/sim/lib/mothership/request/lifecycle/start.ts +++ b/apps/sim/lib/mothership/request/lifecycle/start.ts @@ -29,6 +29,7 @@ import { } from '@/lib/mothership/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' import { TraceEvent } from '@/lib/mothership/generated/trace-events-v1' +import { resolveEnterpriseByokKey } from '@/lib/mothership/request/enterprise-byok' import { mothershipRequestHeaders } from '@/lib/mothership/request/headers' import { finalizeStream } from '@/lib/mothership/request/lifecycle/finalize' import { @@ -577,6 +578,8 @@ export async function requestChatTitle(params: { const { fetchGo } = await import('@/lib/mothership/request/go/fetch') const mothershipBaseURL = await getMothershipBaseURL({ userId }) + // Title reads the user's message content, so an enterprise chat pins its key here too. + const byokApiKey = await resolveEnterpriseByokKey(workspaceId) const response = await fetchGo(`${mothershipBaseURL}/api/generate-chat-title`, { method: 'POST', signal, @@ -588,6 +591,7 @@ export async function requestChatTitle(params: { ...(workspaceId ? { workspaceId } : {}), ...(organizationId ? { organizationId, chatId } : {}), ...(userId ? { userId } : {}), + ...(byokApiKey ? { byokApiKey } : {}), }), otelContext, spanName: 'sim → go /api/generate-chat-title', From c4fdc27e68aec3d21b6c7bd8cc55f48e48623063 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 16:43:59 +0530 Subject: [PATCH 044/306] fix(mothership): title calls carry metering identity for worker-side billing Companion to worker batch D. The worker now records title spend into run_analytics and settles it under the billing headers this call already stamps; chatId/workspaceId/userId ride the payload so the row joins to the chat instead of synthetic ids. Regenerated protocol mirror. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/lib/mothership/generated/protocol.ts | 5 +++++ apps/sim/lib/mothership/request/lifecycle/start.ts | 1 + 2 files changed, 6 insertions(+) diff --git a/apps/sim/lib/mothership/generated/protocol.ts b/apps/sim/lib/mothership/generated/protocol.ts index 8ffcf293ac7..5c43c479df8 100644 --- a/apps/sim/lib/mothership/generated/protocol.ts +++ b/apps/sim/lib/mothership/generated/protocol.ts @@ -96,6 +96,11 @@ export interface TitleRequest { message: string; /** Enterprise BYOK: the title call reads user content, so it pins the same key (S27). */ byokApiKey?: string | undefined; + /** Metering identity (Go metered title spend into request analytics): optional so + * older sim builds keep validating; absent values degrade to synthetic ids. */ + chatId?: string | undefined; + workspaceId?: string | undefined; + userId?: string | undefined; } /** The 409 body for a duplicate send while a sibling instance streams (S32). */ diff --git a/apps/sim/lib/mothership/request/lifecycle/start.ts b/apps/sim/lib/mothership/request/lifecycle/start.ts index fdde2d0e509..ff3ea4d2cf4 100644 --- a/apps/sim/lib/mothership/request/lifecycle/start.ts +++ b/apps/sim/lib/mothership/request/lifecycle/start.ts @@ -592,6 +592,7 @@ export async function requestChatTitle(params: { ...(organizationId ? { organizationId, chatId } : {}), ...(userId ? { userId } : {}), ...(byokApiKey ? { byokApiKey } : {}), + ...(chatId ? { chatId } : {}), }), otelContext, spanName: 'sim → go /api/generate-chat-title', From 3c61895b5444042f2af4122c4eb41b66062d2d43 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 17:26:55 +0530 Subject: [PATCH 045/306] refactor(mothership): sim-side quality pass + dead-code sweep from the parity hill-climb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quality (25 verified findings from the review pass): the embedded CLI's soft-fail exit code rode the process-global exitCode two parallel invocations raced on — it now rides the EmbedContext (setSoftExitCode seam, global fallback for the standalone CLI). Model-authored wire params lose their bare as-casts for typeof guards (run segments, run-tool params, output-file declarations, function-execute refs via one refField helper); the six copy-pasted cancel-settlement blocks in the tool executor fold into one settleCancelled; five error-completion literals into one builder; three title-casing humanizers into one; the sandbox image cache moves from a hand-rolled TTL map to lru-cache; sleepWithAbort yields to interruptibleSleep; plus dead re-exports, a pass-through wrapper, a dead param, an unreachable busy-spin branch made an invariant, and misc rule violations (inline sleep/getErrorMessage/truncate, mid-file import, retired-tool literal). Dead code (verified-unreferenced, from the Go-era audit): four delegated use-cases replaced by sim_cli, the file-subagent doc raster pipeline, env secret-ref resolution, workflow-state prompt formatting, the runtime stream-schema mirror (sync script trimmed to match), the workflow checkpoint/revert feature (routes + contracts; table drop deferred), and five orphaned copilot routes (update-messages, models, credentials, rename, and the duplicate chat mount — /api/mothership/chat is the live path). LONG_RUNNING_TOOL_IDS trimmed to the live tool surface with tests re-anchored. Go-decommission-gated routes (tools/execute, key/byok validate, inbandOwned) and policy items (steering chain, Go-era replay titles) deliberately kept — listed in the audit doc for Sid. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/app/api/copilot/chat/route.ts | 15 - apps/sim/lib/api/contracts/copilot.ts | 19 - .../sim/lib/execution/remote-sandbox/index.ts | 12 +- .../lib/execution/remote-sandbox/resolve.ts | 40 +- .../execute-credential-use-case.ts | 14 - .../application/execute-log-use-case.ts | 34 - .../execute-mcp-server-use-case.ts | 14 - .../application/execute-skill-use-case.ts | 14 - .../generated/mothership-stream-v1-schema.ts | 1476 ----------------- apps/sim/lib/mothership/request/go/stream.ts | 5 +- .../lib/mothership/request/handlers/text.ts | 2 +- .../lib/mothership/request/handlers/tool.ts | 61 +- .../lib/mothership/request/handlers/types.ts | 5 +- .../lib/mothership/request/lifecycle/run.ts | 29 +- .../lib/mothership/request/lifecycle/start.ts | 8 +- .../lib/mothership/request/session/abort.ts | 2 +- .../mothership/request/tools/executor.test.ts | 6 +- .../lib/mothership/request/tools/executor.ts | 236 +-- .../sim/lib/mothership/request/tools/files.ts | 39 +- .../tools/client/run-tool-execution.ts | 21 +- .../tools/handlers/agent-cli/index.ts | 3 +- .../tools/handlers/function-execute.ts | 67 +- .../tools/handlers/workflow/mutations.ts | 71 +- .../tools/server/env-reference.test.ts | 69 - .../mothership/tools/server/env-reference.ts | 42 - .../tools/server/files/doc-extract.ts | 117 -- .../tools/server/files/doc-render.ts | 113 -- .../mothership/tools/shared/workflow-utils.ts | 33 - .../lib/mothership/tools/tool-display.test.ts | 8 - apps/sim/lib/mothership/tools/tool-display.ts | 24 +- packages/sim-cli/src/commands/auth.ts | 3 +- .../commands/protocol/workflow-run-wait.ts | 5 +- packages/sim-cli/src/embed-context.ts | 14 + packages/sim-cli/src/embed.ts | 10 +- packages/sim-cli/src/runtime/request.ts | 10 +- scripts/sync-mothership-stream-contract.ts | 30 +- 36 files changed, 221 insertions(+), 2450 deletions(-) delete mode 100644 apps/sim/app/api/copilot/chat/route.ts delete mode 100644 apps/sim/lib/mothership/application/execute-credential-use-case.ts delete mode 100644 apps/sim/lib/mothership/application/execute-log-use-case.ts delete mode 100644 apps/sim/lib/mothership/application/execute-mcp-server-use-case.ts delete mode 100644 apps/sim/lib/mothership/application/execute-skill-use-case.ts delete mode 100644 apps/sim/lib/mothership/generated/mothership-stream-v1-schema.ts delete mode 100644 apps/sim/lib/mothership/tools/server/env-reference.test.ts delete mode 100644 apps/sim/lib/mothership/tools/server/env-reference.ts delete mode 100644 apps/sim/lib/mothership/tools/server/files/doc-extract.ts delete mode 100644 apps/sim/lib/mothership/tools/server/files/doc-render.ts delete mode 100644 apps/sim/lib/mothership/tools/shared/workflow-utils.ts diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts deleted file mode 100644 index 2a342a88243..00000000000 --- a/apps/sim/app/api/copilot/chat/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { NextRequest } from 'next/server' -import { copilotChatGetContract } from '@/lib/api/contracts/copilot' -import { parseRequest } from '@/lib/api/server' -import { handleUnifiedChatPost } from '@/lib/mothership/chat/post' -import { GET as getChat } from '@/app/api/copilot/chat/queries' - -export const maxDuration = 3600 - -export const POST = handleUnifiedChatPost - -export async function GET(request: NextRequest) { - const parsed = await parseRequest(copilotChatGetContract, request, {}) - if (!parsed.success) return parsed.response - return getChat(request) -} diff --git a/apps/sim/lib/api/contracts/copilot.ts b/apps/sim/lib/api/contracts/copilot.ts index fc6c3fd23df..f6ef6faf294 100644 --- a/apps/sim/lib/api/contracts/copilot.ts +++ b/apps/sim/lib/api/contracts/copilot.ts @@ -620,25 +620,6 @@ export const copilotChatStopContract = defineRouteContract({ response: { mode: 'json', schema: successFlagSchema }, }) -export const copilotChatGetContract = defineRouteContract({ - method: 'GET', - path: '/api/copilot/chat', - query: copilotChatGetQuerySchema, - response: { - mode: 'json', - schema: z.union([ - z.object({ - success: z.literal(true), - chat: copilotChatGetChatSchema, - }), - z.object({ - success: z.literal(true), - chats: z.array(copilotChatGetListItemSchema), - }), - ]), - }, -}) - export const deleteCopilotChatContract = defineRouteContract({ method: 'DELETE', path: '/api/copilot/chat/delete', diff --git a/apps/sim/lib/execution/remote-sandbox/index.ts b/apps/sim/lib/execution/remote-sandbox/index.ts index 650da6c6e4e..591047a0acc 100644 --- a/apps/sim/lib/execution/remote-sandbox/index.ts +++ b/apps/sim/lib/execution/remote-sandbox/index.ts @@ -911,10 +911,11 @@ async function provisionWithinBudget( } async function executeInSandboxWithinBudget( - req: SandboxExecutionRequest + // The budget wrapper always injects the signal; the required-signal type states that + // invariant instead of a cast hiding it. + req: SandboxExecutionRequest & { signal: AbortSignal } ): Promise { - const { code, language } = req - const signal = req.signal as AbortSignal + const { code, language, signal } = req const kind = req.sandboxKind ?? 'code' throwIfAborted(signal) @@ -1084,10 +1085,9 @@ export function executeInSandbox(req: SandboxExecutionRequest): Promise { - const { code, envs } = req - const signal = req.signal as AbortSignal + const { code, envs, signal } = req const kind = req.sandboxKind ?? 'shell' throwIfAborted(signal) diff --git a/apps/sim/lib/execution/remote-sandbox/resolve.ts b/apps/sim/lib/execution/remote-sandbox/resolve.ts index 131410ee84d..ad87f12b86a 100644 --- a/apps/sim/lib/execution/remote-sandbox/resolve.ts +++ b/apps/sim/lib/execution/remote-sandbox/resolve.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { LRUCache } from 'lru-cache' import { CodeLanguage } from '@/lib/execution/languages' import { classifyInstallOutput, tailBuildLog } from '@/lib/execution/remote-sandbox/build-errors' import { @@ -114,21 +115,13 @@ interface CachedImage { errorMessage: string | null } -interface CacheEntry { - expiresAt: number - value: CachedImage -} - /** - * Both maps are process-lifetime and keyed by an unbounded space (every spec - * hash ever executed), so each drops its oldest entry rather than growing for - * the life of the worker. + * Both caches are keyed by an unbounded space (every spec hash ever executed): + * `max` is the memory backstop, `ttl` the freshness policy — lru-cache owns + * expiry/eviction/ceiling per the caching rule (never hand-roll TTL arithmetic). */ -const IMAGE_CACHE_LIMIT = 1000 -const LAST_USED_CACHE_LIMIT = 1000 - -const imageCache = new Map() -const lastUsedWrites = new Map() +const imageCache = new LRUCache({ max: 1000, ttl: IMAGE_TTL_MS }) +const lastUsedWrites = new LRUCache({ max: 1000, ttl: LAST_USED_DEBOUNCE_MS }) /** * JavaScript packages live outside the default resolution roots, so Node needs @@ -155,11 +148,9 @@ function envsFor( */ function touchImage(specHash: string, provider: string): void { const key = `${provider}:${specHash}` - const now = Date.now() - const written = lastUsedWrites.get(key) - if (written && now - written < LAST_USED_DEBOUNCE_MS) return - if (lastUsedWrites.size >= LAST_USED_CACHE_LIMIT) lastUsedWrites.clear() - lastUsedWrites.set(key, now) + // The TTL IS the debounce: a still-fresh entry means we wrote recently. + if (lastUsedWrites.get(key) !== undefined) return + lastUsedWrites.set(key, Date.now()) void sandboxDb() .then(({ db, sandboxImage, and, eq }) => db @@ -381,10 +372,7 @@ async function readImage( ): Promise { const cacheKey = `${providerId}:${specHash}:${materializationGeneration}:${materializationRefPrefix}` const cached = imageCache.get(cacheKey) - if (cached) { - if (cached.expiresAt > Date.now()) return cached.value - imageCache.delete(cacheKey) - } + if (cached !== undefined) return cached const { db, sandboxImage, and, eq } = await sandboxDb() const [image] = await db @@ -399,13 +387,7 @@ async function readImage( .where(and(eq(sandboxImage.provider, providerId), eq(sandboxImage.specHash, specHash))) .limit(1) - if (image?.status === 'ready') { - if (imageCache.size >= IMAGE_CACHE_LIMIT) { - const oldest = imageCache.keys().next() - if (!oldest.done) imageCache.delete(oldest.value) - } - imageCache.set(cacheKey, { expiresAt: Date.now() + IMAGE_TTL_MS, value: image }) - } + if (image?.status === 'ready') imageCache.set(cacheKey, image) return image } diff --git a/apps/sim/lib/mothership/application/execute-credential-use-case.ts b/apps/sim/lib/mothership/application/execute-credential-use-case.ts deleted file mode 100644 index 719f9b55267..00000000000 --- a/apps/sim/lib/mothership/application/execute-credential-use-case.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { CREDENTIAL_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' -import { credentialOperations } from '@/lib/credentials/application/operations' -import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' -import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/mothership/auth/application-delegation' - -export const executeCopilotCredentialUseCase = createCopilotApplicationAdapter({ - domain: 'credential', - delegation: { - audience: CREDENTIAL_DELEGATION_AUDIENCE, - ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, - createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, - }, - operations: credentialOperations, -}) diff --git a/apps/sim/lib/mothership/application/execute-log-use-case.ts b/apps/sim/lib/mothership/application/execute-log-use-case.ts deleted file mode 100644 index 2ed60ae6815..00000000000 --- a/apps/sim/lib/mothership/application/execute-log-use-case.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { OperationUseCase } from '@/lib/core/application' -import { logDelegationPolicy } from '@/lib/logs/application/authorization' -import { logOperations } from '@/lib/logs/application/operations' -import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' -import { - COPILOT_APPLICATION_DELEGATION_TTL_MS, - type CopilotExecutionContext, -} from '@/lib/mothership/auth/application-delegation' - -const copilotLogOperations = { - list: logOperations.list, - readDetail: logOperations.readDetail, -} as const - -type CopilotLogOperation = (typeof copilotLogOperations)[keyof typeof copilotLogOperations] - -const executeLogUseCase = createCopilotApplicationAdapter({ - domain: 'logs', - delegation: { - audience: logDelegationPolicy.audience, - ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, - createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, - }, - operations: copilotLogOperations, -}) - -/** Enters a registered Logs use case with trusted Copilot identity. */ -export function executeCopilotLogUseCase( - context: CopilotExecutionContext | undefined, - useCase: OperationUseCase, - input: I -): Promise { - return executeLogUseCase(context, useCase, input) -} diff --git a/apps/sim/lib/mothership/application/execute-mcp-server-use-case.ts b/apps/sim/lib/mothership/application/execute-mcp-server-use-case.ts deleted file mode 100644 index 844d29fee8f..00000000000 --- a/apps/sim/lib/mothership/application/execute-mcp-server-use-case.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { mcpServerDelegationPolicy } from '@/lib/mcp/application/authorization' -import { mcpServerOperations } from '@/lib/mcp/application/operations' -import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' -import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/mothership/auth/application-delegation' - -export const executeCopilotMcpServerUseCase = createCopilotApplicationAdapter({ - domain: 'MCP server', - delegation: { - audience: mcpServerDelegationPolicy.audience, - ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, - createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, - }, - operations: mcpServerOperations, -}) diff --git a/apps/sim/lib/mothership/application/execute-skill-use-case.ts b/apps/sim/lib/mothership/application/execute-skill-use-case.ts deleted file mode 100644 index 1bf60feaf53..00000000000 --- a/apps/sim/lib/mothership/application/execute-skill-use-case.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createCopilotApplicationAdapter } from '@/lib/mothership/application/application-adapter' -import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/mothership/auth/application-delegation' -import { skillDelegationPolicy } from '@/lib/skills/application/authorization' -import { skillOperations } from '@/lib/skills/application/operations' - -export const executeCopilotSkillUseCase = createCopilotApplicationAdapter({ - domain: 'skill', - delegation: { - audience: skillDelegationPolicy.audience, - ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, - createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, - }, - operations: skillOperations, -}) diff --git a/apps/sim/lib/mothership/generated/mothership-stream-v1-schema.ts b/apps/sim/lib/mothership/generated/mothership-stream-v1-schema.ts deleted file mode 100644 index 41eaea4b459..00000000000 --- a/apps/sim/lib/mothership/generated/mothership-stream-v1-schema.ts +++ /dev/null @@ -1,1476 +0,0 @@ -// AUTO-GENERATED FILE. DO NOT EDIT. -// Generated from copilot/contracts/mothership-stream-v1.schema.json -// - -export type JsonSchema = unknown - -export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = { - $defs: { - MothershipStreamV1AdditionalPropertiesMap: { - additionalProperties: true, - type: 'object', - }, - MothershipStreamV1AsyncToolRecordStatus: { - enum: ['pending', 'running', 'completed', 'failed', 'cancelled', 'delivered'], - type: 'string', - }, - MothershipStreamV1CheckpointPauseEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1CheckpointPausePayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['run'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1CheckpointPauseFrame: { - additionalProperties: false, - properties: { - checkpointId: { - type: 'string', - }, - parentToolCallId: { - type: 'string', - }, - parentToolName: { - type: 'string', - }, - pendingToolIds: { - items: { - type: 'string', - }, - type: 'array', - }, - }, - required: ['parentToolCallId', 'parentToolName', 'pendingToolIds'], - type: 'object', - }, - MothershipStreamV1CheckpointPausePayload: { - additionalProperties: false, - properties: { - checkpointId: { - type: 'string', - }, - executionId: { - type: 'string', - }, - frames: { - items: { - $ref: '#/$defs/MothershipStreamV1CheckpointPauseFrame', - }, - type: 'array', - }, - kind: { - enum: ['checkpoint_pause'], - type: 'string', - }, - pendingToolCallIds: { - items: { - type: 'string', - }, - type: 'array', - }, - runId: { - type: 'string', - }, - }, - required: ['kind', 'checkpointId', 'runId', 'executionId', 'pendingToolCallIds'], - type: 'object', - }, - MothershipStreamV1CompactionDoneData: { - additionalProperties: false, - properties: { - summary_chars: { - type: 'integer', - }, - }, - required: ['summary_chars'], - type: 'object', - }, - MothershipStreamV1CompactionDoneEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1CompactionDonePayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['run'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1CompactionDonePayload: { - additionalProperties: false, - properties: { - data: { - $ref: '#/$defs/MothershipStreamV1CompactionDoneData', - }, - kind: { - enum: ['compaction_done'], - type: 'string', - }, - }, - required: ['kind'], - type: 'object', - }, - MothershipStreamV1CompactionStartEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1CompactionStartPayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['run'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1CompactionStartPayload: { - additionalProperties: false, - properties: { - kind: { - enum: ['compaction_start'], - type: 'string', - }, - }, - required: ['kind'], - type: 'object', - }, - MothershipStreamV1CompleteEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1CompletePayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['complete'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1CompletePayload: { - additionalProperties: false, - properties: { - cost: { - $ref: '#/$defs/MothershipStreamV1CostData', - }, - reason: { - type: 'string', - }, - response: true, - status: { - $ref: '#/$defs/MothershipStreamV1CompletionStatus', - }, - usage: { - $ref: '#/$defs/MothershipStreamV1UsageData', - }, - }, - required: ['status'], - type: 'object', - }, - MothershipStreamV1CompletionStatus: { - enum: ['complete', 'error', 'cancelled'], - type: 'string', - }, - MothershipStreamV1CostData: { - additionalProperties: false, - properties: { - input: { - type: 'number', - }, - output: { - type: 'number', - }, - total: { - type: 'number', - }, - }, - type: 'object', - }, - MothershipStreamV1ErrorEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1ErrorPayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['error'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1ErrorPayload: { - additionalProperties: false, - properties: { - code: { - type: 'string', - }, - data: true, - displayMessage: { - type: 'string', - }, - error: { - type: 'string', - }, - message: { - type: 'string', - }, - provider: { - type: 'string', - }, - }, - required: ['message'], - type: 'object', - }, - MothershipStreamV1EventEnvelopeCommon: { - additionalProperties: false, - properties: { - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream'], - type: 'object', - }, - MothershipStreamV1EventType: { - enum: ['session', 'text', 'tool', 'span', 'resource', 'run', 'error', 'complete'], - type: 'string', - }, - MothershipStreamV1ResourceDescriptor: { - additionalProperties: false, - properties: { - clearViewId: { - type: 'boolean', - }, - id: { - type: 'string', - }, - title: { - type: 'string', - }, - type: { - type: 'string', - }, - viewId: { - type: 'string', - }, - }, - required: ['type', 'id'], - type: 'object', - }, - MothershipStreamV1ResourceOp: { - enum: ['upsert', 'remove'], - type: 'string', - }, - MothershipStreamV1ResourceRemoveEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1ResourceRemovePayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['resource'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1ResourceRemovePayload: { - additionalProperties: false, - properties: { - op: { - enum: ['remove'], - type: 'string', - }, - resource: { - $ref: '#/$defs/MothershipStreamV1ResourceDescriptor', - }, - }, - required: ['op', 'resource'], - type: 'object', - }, - MothershipStreamV1ResourceUpsertEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1ResourceUpsertPayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['resource'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1ResourceUpsertPayload: { - additionalProperties: false, - properties: { - op: { - enum: ['upsert'], - type: 'string', - }, - resource: { - $ref: '#/$defs/MothershipStreamV1ResourceDescriptor', - }, - }, - required: ['op', 'resource'], - type: 'object', - }, - MothershipStreamV1ResumeRequest: { - additionalProperties: false, - properties: { - checkpointId: { - type: 'string', - }, - results: { - items: { - $ref: '#/$defs/MothershipStreamV1ResumeToolResult', - }, - type: 'array', - }, - streamId: { - type: 'string', - }, - userId: { - type: 'string', - }, - }, - required: ['streamId', 'checkpointId', 'userId', 'results'], - type: 'object', - }, - MothershipStreamV1ResumeToolResult: { - additionalProperties: false, - properties: { - error: { - type: 'string', - }, - output: true, - success: { - type: 'boolean', - }, - toolCallId: { - type: 'string', - }, - }, - required: ['toolCallId', 'success'], - type: 'object', - }, - MothershipStreamV1RunKind: { - enum: [ - 'checkpoint_pause', - 'resumed', - 'compaction_start', - 'compaction_done', - 'steering_applied', - ], - type: 'string', - }, - MothershipStreamV1RunResumedEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1RunResumedPayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['run'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1RunResumedPayload: { - additionalProperties: false, - properties: { - kind: { - enum: ['resumed'], - type: 'string', - }, - }, - required: ['kind'], - type: 'object', - }, - MothershipStreamV1SessionChatEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1SessionChatPayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['session'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1SessionChatPayload: { - additionalProperties: false, - properties: { - chatId: { - type: 'string', - }, - kind: { - enum: ['chat'], - type: 'string', - }, - }, - required: ['kind', 'chatId'], - type: 'object', - }, - MothershipStreamV1SessionKind: { - enum: ['trace', 'chat', 'title', 'start'], - type: 'string', - }, - MothershipStreamV1SessionStartData: { - additionalProperties: false, - properties: { - responseId: { - type: 'string', - }, - }, - type: 'object', - }, - MothershipStreamV1SessionStartEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1SessionStartPayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['session'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1SessionStartPayload: { - additionalProperties: false, - properties: { - data: { - $ref: '#/$defs/MothershipStreamV1SessionStartData', - }, - kind: { - enum: ['start'], - type: 'string', - }, - }, - required: ['kind'], - type: 'object', - }, - MothershipStreamV1SessionTitleEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1SessionTitlePayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['session'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1SessionTitlePayload: { - additionalProperties: false, - properties: { - kind: { - enum: ['title'], - type: 'string', - }, - title: { - type: 'string', - }, - }, - required: ['kind', 'title'], - type: 'object', - }, - MothershipStreamV1SessionTraceEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1SessionTracePayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['session'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1SessionTracePayload: { - additionalProperties: false, - properties: { - kind: { - enum: ['trace'], - type: 'string', - }, - requestId: { - type: 'string', - }, - spanId: { - type: 'string', - }, - }, - required: ['kind', 'requestId'], - type: 'object', - }, - MothershipStreamV1SpanKind: { - enum: ['subagent'], - type: 'string', - }, - MothershipStreamV1SpanLifecycleEvent: { - enum: ['start', 'end'], - type: 'string', - }, - MothershipStreamV1SpanPayloadKind: { - enum: ['subagent', 'structured_result', 'subagent_result'], - type: 'string', - }, - MothershipStreamV1SteeringAppliedPayload: { - additionalProperties: false, - properties: { - content: { - type: 'string', - }, - kind: { - $ref: '#/$defs/MothershipStreamV1RunKind', - }, - messageId: { - type: 'string', - }, - mode: { - enum: ['deferred', 'interrupt'], - type: 'string', - }, - }, - required: ['kind', 'messageId', 'content', 'mode'], - type: 'object', - }, - MothershipStreamV1StreamCursor: { - additionalProperties: false, - properties: { - cursor: { - type: 'string', - }, - seq: { - type: 'integer', - }, - streamId: { - type: 'string', - }, - }, - required: ['streamId', 'cursor', 'seq'], - type: 'object', - }, - MothershipStreamV1StreamRef: { - additionalProperties: false, - properties: { - chatId: { - type: 'string', - }, - cursor: { - type: 'string', - }, - streamId: { - type: 'string', - }, - }, - required: ['streamId'], - type: 'object', - }, - MothershipStreamV1StreamScope: { - additionalProperties: false, - properties: { - agentId: { - type: 'string', - }, - lane: { - enum: ['subagent'], - type: 'string', - }, - parentSpanId: { - type: 'string', - }, - parentToolCallId: { - type: 'string', - }, - spanId: { - type: 'string', - }, - }, - required: ['lane'], - type: 'object', - }, - MothershipStreamV1StructuredResultSpanEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1StructuredResultSpanPayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['span'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1StructuredResultSpanPayload: { - additionalProperties: false, - properties: { - agent: { - type: 'string', - }, - data: true, - kind: { - enum: ['structured_result'], - type: 'string', - }, - }, - required: ['kind'], - type: 'object', - }, - MothershipStreamV1SubagentResultSpanEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1SubagentResultSpanPayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['span'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1SubagentResultSpanPayload: { - additionalProperties: false, - properties: { - agent: { - type: 'string', - }, - data: true, - kind: { - enum: ['subagent_result'], - type: 'string', - }, - }, - required: ['kind'], - type: 'object', - }, - MothershipStreamV1SubagentSpanEndEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1SubagentSpanEndPayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['span'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1SubagentSpanEndPayload: { - additionalProperties: false, - properties: { - agent: { - type: 'string', - }, - data: true, - event: { - enum: ['end'], - type: 'string', - }, - kind: { - enum: ['subagent'], - type: 'string', - }, - }, - required: ['kind', 'event'], - type: 'object', - }, - MothershipStreamV1SubagentSpanStartEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1SubagentSpanStartPayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['span'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1SubagentSpanStartPayload: { - additionalProperties: false, - properties: { - agent: { - type: 'string', - }, - data: true, - event: { - enum: ['start'], - type: 'string', - }, - kind: { - enum: ['subagent'], - type: 'string', - }, - }, - required: ['kind', 'event'], - type: 'object', - }, - MothershipStreamV1TextChannel: { - enum: ['assistant', 'thinking'], - type: 'string', - }, - MothershipStreamV1TextEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1TextPayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['text'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1TextPayload: { - additionalProperties: false, - properties: { - channel: { - $ref: '#/$defs/MothershipStreamV1TextChannel', - }, - text: { - type: 'string', - }, - }, - required: ['channel', 'text'], - type: 'object', - }, - MothershipStreamV1ToolArgsDeltaEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1ToolArgsDeltaPayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['tool'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1ToolArgsDeltaPayload: { - additionalProperties: false, - properties: { - argumentsDelta: { - type: 'string', - }, - executor: { - $ref: '#/$defs/MothershipStreamV1ToolExecutor', - }, - mode: { - $ref: '#/$defs/MothershipStreamV1ToolMode', - }, - phase: { - enum: ['args_delta'], - type: 'string', - }, - toolCallId: { - type: 'string', - }, - toolName: { - type: 'string', - }, - }, - required: ['toolCallId', 'toolName', 'argumentsDelta', 'executor', 'mode', 'phase'], - type: 'object', - }, - MothershipStreamV1ToolCallDescriptor: { - additionalProperties: false, - properties: { - activityDescription: { - maxLength: 160, - type: 'string', - }, - arguments: { - $ref: '#/$defs/MothershipStreamV1AdditionalPropertiesMap', - }, - execName: { - type: 'string', - }, - executor: { - $ref: '#/$defs/MothershipStreamV1ToolExecutor', - }, - mode: { - $ref: '#/$defs/MothershipStreamV1ToolMode', - }, - partial: { - type: 'boolean', - }, - phase: { - enum: ['call'], - type: 'string', - }, - status: { - $ref: '#/$defs/MothershipStreamV1ToolStatus', - }, - toolCallId: { - type: 'string', - }, - toolName: { - type: 'string', - }, - ui: { - $ref: '#/$defs/MothershipStreamV1ToolUI', - }, - }, - required: ['toolCallId', 'toolName', 'executor', 'mode', 'phase'], - type: 'object', - }, - MothershipStreamV1ToolCallEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1ToolCallDescriptor', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['tool'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1ToolExecutor: { - enum: ['go', 'sim', 'client'], - type: 'string', - }, - MothershipStreamV1ToolMode: { - enum: ['sync', 'async'], - type: 'string', - }, - MothershipStreamV1ToolOutcome: { - enum: ['success', 'error', 'cancelled', 'skipped', 'rejected'], - type: 'string', - }, - MothershipStreamV1ToolPhase: { - enum: ['call', 'args_delta', 'result'], - type: 'string', - }, - MothershipStreamV1ToolResultEventEnvelope: { - additionalProperties: false, - properties: { - payload: { - $ref: '#/$defs/MothershipStreamV1ToolResultPayload', - }, - scope: { - $ref: '#/$defs/MothershipStreamV1StreamScope', - }, - seq: { - type: 'integer', - }, - stream: { - $ref: '#/$defs/MothershipStreamV1StreamRef', - }, - trace: { - $ref: '#/$defs/MothershipStreamV1Trace', - }, - ts: { - type: 'string', - }, - type: { - enum: ['tool'], - type: 'string', - }, - v: { - enum: [1], - type: 'integer', - }, - }, - required: ['v', 'seq', 'ts', 'stream', 'type', 'payload'], - type: 'object', - }, - MothershipStreamV1ToolResultPayload: { - additionalProperties: false, - properties: { - error: { - type: 'string', - }, - executor: { - $ref: '#/$defs/MothershipStreamV1ToolExecutor', - }, - mode: { - $ref: '#/$defs/MothershipStreamV1ToolMode', - }, - output: true, - phase: { - enum: ['result'], - type: 'string', - }, - status: { - $ref: '#/$defs/MothershipStreamV1ToolStatus', - }, - success: { - type: 'boolean', - }, - toolCallId: { - type: 'string', - }, - toolName: { - type: 'string', - }, - }, - required: ['toolCallId', 'toolName', 'executor', 'mode', 'phase', 'success'], - type: 'object', - }, - MothershipStreamV1ToolStatus: { - enum: [ - 'generating', - 'awaiting_approval', - 'executing', - 'success', - 'error', - 'cancelled', - 'skipped', - 'rejected', - ], - type: 'string', - }, - MothershipStreamV1ToolUI: { - additionalProperties: false, - properties: { - clientExecutable: { - type: 'boolean', - }, - hidden: { - type: 'boolean', - }, - inbandOwned: { - type: 'boolean', - }, - internal: { - type: 'boolean', - }, - simExecutable: { - type: 'boolean', - }, - }, - type: 'object', - }, - MothershipStreamV1Trace: { - additionalProperties: false, - properties: { - goTraceId: { - description: - 'OTel trace ID from the first Go ingress. May differ from requestId when Sim assigns the canonical request identity.', - type: 'string', - }, - requestId: { - type: 'string', - }, - spanId: { - type: 'string', - }, - }, - required: ['requestId'], - type: 'object', - }, - MothershipStreamV1UsageData: { - additionalProperties: false, - properties: { - cache_creation_input_tokens: { - type: 'integer', - }, - cache_read_input_tokens: { - type: 'integer', - }, - input_tokens: { - type: 'integer', - }, - model: { - type: 'string', - }, - output_tokens: { - type: 'integer', - }, - total_tokens: { - type: 'integer', - }, - }, - type: 'object', - }, - }, - $id: 'mothership-stream-v1.schema.json', - $schema: 'https://json-schema.org/draft/2020-12/schema', - description: 'Shared execution-oriented mothership stream contract from Go to Sim.', - oneOf: [ - { - $ref: '#/$defs/MothershipStreamV1SessionStartEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1SessionChatEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1SessionTitleEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1SessionTraceEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1TextEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1ToolCallEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1ToolArgsDeltaEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1ToolResultEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1SubagentSpanStartEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1SubagentSpanEndEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1StructuredResultSpanEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1SubagentResultSpanEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1ResourceUpsertEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1ResourceRemoveEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1CheckpointPauseEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1RunResumedEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1CompactionStartEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1CompactionDoneEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1ErrorEventEnvelope', - }, - { - $ref: '#/$defs/MothershipStreamV1CompleteEventEnvelope', - }, - ], - title: 'MothershipStreamV1EventEnvelope', -} diff --git a/apps/sim/lib/mothership/request/go/stream.ts b/apps/sim/lib/mothership/request/go/stream.ts index 3ecd3c871e9..2b5a0690843 100644 --- a/apps/sim/lib/mothership/request/go/stream.ts +++ b/apps/sim/lib/mothership/request/go/stream.ts @@ -557,15 +557,14 @@ export async function runStreamLoop( context.wasAborted = true endedOn = CopilotSseCloseReason.Aborted } else { - const streamPath = new URL(fetchUrl).pathname context.errors.push(STREAM_ENDED_WITHOUT_TERMINAL_MESSAGE) logger.error('Copilot backend stream ended before a terminal event', { - path: streamPath, + path: pathname, requestId: context.requestId, messageId: context.messageId, }) endedOn = CopilotSseCloseReason.ClosedNoTerminal - throw new StreamEndedWithoutTerminalError(streamPath) + throw new StreamEndedWithoutTerminalError(pathname) } } } catch (error) { diff --git a/apps/sim/lib/mothership/request/handlers/text.ts b/apps/sim/lib/mothership/request/handlers/text.ts index 5f89be34e62..d6ba8ce6af5 100644 --- a/apps/sim/lib/mothership/request/handlers/text.ts +++ b/apps/sim/lib/mothership/request/handlers/text.ts @@ -20,7 +20,7 @@ export function handleTextEvent(scope: ToolScope): StreamHandler { } if (scope === 'subagent') { - const parentToolCallId = getScopedParentToolCallId(event, context) + const parentToolCallId = getScopedParentToolCallId(event) if (!parentToolCallId) return const spanIdentity = getScopedSpanIdentity(event) if (event.payload.channel === MothershipStreamV1TextChannel.thinking) { diff --git a/apps/sim/lib/mothership/request/handlers/tool.ts b/apps/sim/lib/mothership/request/handlers/tool.ts index 860e2c46078..a0ffac8ad93 100644 --- a/apps/sim/lib/mothership/request/handlers/tool.ts +++ b/apps/sim/lib/mothership/request/handlers/tool.ts @@ -77,6 +77,15 @@ import { registerPendingToolPromise, } from './types' +/** The standard error-completion literal, built in one place (it appeared five times). */ +function errorCompletion(message: string): { + status: typeof MothershipStreamV1ToolOutcome.error + message: string + data: { error: string } +} { + return { status: MothershipStreamV1ToolOutcome.error, message, data: { error: message } } +} + const logger = createLogger('CopilotToolHandler') function applyToolDisplay(toolCall: ToolCallState | undefined): void { @@ -330,7 +339,7 @@ export async function handleToolEvent( scope: ToolScope ): Promise { const isSubagent = scope === 'subagent' - const parentToolCallId = isSubagent ? getScopedParentToolCallId(event, context) : undefined + const parentToolCallId = isSubagent ? getScopedParentToolCallId(event) : undefined const agentId = event.scope?.agentId ?? 'main' if (isSubagent && !parentToolCallId) return @@ -755,19 +764,18 @@ async function dispatchToolExecution( const fireToolExecution = ( execContextOverride?: ExecutionContext ): Promise => { - return (async () => { - return executeToolAndReport(toolCallId, context, execContextOverride ?? execContext, options) - })().catch((err) => { + return executeToolAndReport( + toolCallId, + context, + execContextOverride ?? execContext, + options + ).catch((err) => { logger.error(`Parallel ${scopeLabel}tool execution failed`, { toolCallId, toolName, error: toError(err).message, }) - return { - status: MothershipStreamV1ToolOutcome.error, - message: 'Tool execution failed', - data: { error: 'Tool execution failed' }, - } + return errorCompletion('Tool execution failed') }) } @@ -791,17 +799,8 @@ async function dispatchToolExecution( error, }) markToolResultSeen(context, toolCallId) - await emitSyntheticToolResult( - toolCallId, - toolCall.name, - { - status: MothershipStreamV1ToolOutcome.error, - message: error, - data: { error }, - }, - options - ) - return { status: MothershipStreamV1ToolOutcome.error, message: error, data: { error } } + await emitSyntheticToolResult(toolCallId, toolCall.name, errorCompletion(error), options) + return errorCompletion(error) } // Returns the promise instead of registering it, so the permission gate can @@ -911,13 +910,7 @@ async function dispatchToolExecution( if (race.signal) { span.setAttribute(TraceAttr.ToolOutcome, race.signal.status) } - return ( - race.signal ?? { - status: MothershipStreamV1ToolOutcome.error, - message: 'Tool completion missing', - data: { error: 'Tool completion missing' }, - } - ) + return race.signal ?? errorCompletion('Tool completion missing') } completion = race.completion ?? null } else { @@ -946,13 +939,7 @@ async function dispatchToolExecution( options, backgroundIsSuccess ) - return ( - completion ?? { - status: MothershipStreamV1ToolOutcome.error, - message: 'Tool completion missing', - data: { error: 'Tool completion missing' }, - } - ) + return completion ?? errorCompletion('Tool completion missing') } ).catch((err) => { logger.error(`Client-executable ${scopeLabel}tool wait failed`, { @@ -960,11 +947,7 @@ async function dispatchToolExecution( toolName, error: toError(err).message, }) - return { - status: MothershipStreamV1ToolOutcome.error, - message: 'Tool wait failed', - data: { error: 'Tool wait failed' }, - } + return errorCompletion('Tool wait failed') }) } } diff --git a/apps/sim/lib/mothership/request/handlers/types.ts b/apps/sim/lib/mothership/request/handlers/types.ts index 4ca8cf87629..cca1a8d4ec5 100644 --- a/apps/sim/lib/mothership/request/handlers/types.ts +++ b/apps/sim/lib/mothership/request/handlers/types.ts @@ -101,10 +101,7 @@ export function flushSubagentThinkingBlock( * event is guaranteed to carry parentToolCallId (Go stamps it), so a missing one * is a real contract violation — callers warn and drop rather than guess. */ -export function getScopedParentToolCallId( - event: StreamEvent, - _context: StreamingContext -): string | undefined { +export function getScopedParentToolCallId(event: StreamEvent): string | undefined { return event.scope?.parentToolCallId } diff --git a/apps/sim/lib/mothership/request/lifecycle/run.ts b/apps/sim/lib/mothership/request/lifecycle/run.ts index fbe8d158105..19a8ec5f1a7 100644 --- a/apps/sim/lib/mothership/request/lifecycle/run.ts +++ b/apps/sim/lib/mothership/request/lifecycle/run.ts @@ -791,7 +791,7 @@ async function runResumeLegWithRetry( backoffMs: backoff, error: toError(error).message, }) - await sleepWithAbort(backoff, options.abortSignal) + await interruptibleSleep(backoff, options.abortSignal) continue } throw error @@ -1138,7 +1138,7 @@ async function runCheckpointLoop( error: toError(streamError).message, } ) - await sleepWithAbort(backoff, options.abortSignal) + await interruptibleSleep(backoff, options.abortSignal) continue } throw streamError @@ -1284,7 +1284,9 @@ async function runCheckpointLoop( } const activeWatchdogs = Array.from(pendingWatchdogs.entries()) - if (activeWatchdogs.length === 0) continue + // Every pending promise re-adds its watchdog above, so this cannot be empty; a + // bare `continue` here would busy-spin the event loop if that ever broke. + if (activeWatchdogs.length === 0) break const nextDeadlineAt = Math.min( ...activeWatchdogs.map(([, watchdog]) => watchdog.deadlineAt) ) @@ -1641,24 +1643,3 @@ function isRetryableInitialStreamError(error: unknown): boolean { } return error instanceof TypeError } - -function sleepWithAbort(ms: number, abortSignal?: AbortSignal): Promise { - if (!abortSignal) { - return sleep(ms) - } - if (abortSignal.aborted) { - return Promise.resolve() - } - return new Promise((resolve) => { - const timeoutId = setTimeout(() => { - abortSignal.removeEventListener('abort', onAbort) - resolve() - }, ms) - const onAbort = () => { - clearTimeout(timeoutId) - abortSignal.removeEventListener('abort', onAbort) - resolve() - } - abortSignal.addEventListener('abort', onAbort, { once: true }) - }) -} diff --git a/apps/sim/lib/mothership/request/lifecycle/start.ts b/apps/sim/lib/mothership/request/lifecycle/start.ts index ff3ea4d2cf4..ddec9135638 100644 --- a/apps/sim/lib/mothership/request/lifecycle/start.ts +++ b/apps/sim/lib/mothership/request/lifecycle/start.ts @@ -220,11 +220,13 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS executionId, chatId, userId, - workflowId: (requestPayload.workflowId as string | undefined) || null, + workflowId: + typeof requestPayload.workflowId === 'string' ? requestPayload.workflowId : null, workspaceId, streamId, - model: (requestPayload.model as string | undefined) || null, - provider: (requestPayload.provider as string | undefined) || null, + model: typeof requestPayload.model === 'string' ? requestPayload.model : null, + provider: + typeof requestPayload.provider === 'string' ? requestPayload.provider : null, requestContext: { requestId }, }).catch((error) => { logger.warn(`[${requestId}] Failed to create copilot run segment`, { diff --git a/apps/sim/lib/mothership/request/session/abort.ts b/apps/sim/lib/mothership/request/session/abort.ts index 4092728d190..d824f7b0ec0 100644 --- a/apps/sim/lib/mothership/request/session/abort.ts +++ b/apps/sim/lib/mothership/request/session/abort.ts @@ -255,7 +255,7 @@ export async function acquirePendingChatStream( const settled = await Promise.race([ existing.promise.then(() => true), - new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), + sleep(timeoutMs).then(() => false as const), ]) if (!settled) { span.setAttribute(TraceAttr.LockAcquired, false) diff --git a/apps/sim/lib/mothership/request/tools/executor.test.ts b/apps/sim/lib/mothership/request/tools/executor.test.ts index 5bf3d324668..280063410a3 100644 --- a/apps/sim/lib/mothership/request/tools/executor.test.ts +++ b/apps/sim/lib/mothership/request/tools/executor.test.ts @@ -191,8 +191,10 @@ describe('toolWatchdogTimeoutMs', () => { expect(toolWatchdogTimeoutMs('read')).toBe(TOOL_WATCHDOG_DEFAULT_MS) }) - it.each(['deploy_as_api', 'deploy_as_chat', 'deploy_as_mcp', 'redeploy', 'promote_to_live'])( - 'does not undercut deployment tool %s with the default watchdog', + // The Go-era deploy_* tools left the live surface with the TS worker (deploys go + // through the CLI now); the long-running set tracks tools that can actually execute. + it.each(['run_workflow', 'run_code', 'generate_video', 'apply_file_edit'])( + 'does not undercut long-running live tool %s with the default watchdog', (toolName) => { expect(toolWatchdogTimeoutMs(toolName)).toBe(TOOL_WATCHDOG_LONG_RUNNING_MS) } diff --git a/apps/sim/lib/mothership/request/tools/executor.ts b/apps/sim/lib/mothership/request/tools/executor.ts index d6774b01df8..9ccc84ed3b7 100644 --- a/apps/sim/lib/mothership/request/tools/executor.ts +++ b/apps/sim/lib/mothership/request/tools/executor.ts @@ -23,24 +23,13 @@ import { } from '@/lib/mothership/generated/mothership-stream-v1' import { ApplyFileEdit, - CreateEmptyFile, CreateWorkflow, - DeployAsApi, - DeployAsChat, - DeployAsMcp, - DownloadFile, Ffmpeg, GenerateApiKey, GenerateAudio, GenerateImage, GenerateVideo, - LoadDeployment, - ManageKnowledgeBase, - Media, PrepareFileEdit, - PromoteToLive, - PublishCustomBlock, - Redeploy, Run, RunBlock, RunCode, @@ -48,9 +37,6 @@ import { RunFunction, RunWorkflow, RunWorkflowUntilBlock, - SaveUpload, - Search, - WebCrawl, } from '@/lib/mothership/generated/tool-catalog-v1' import { TraceAttr } from '@/lib/mothership/generated/trace-attributes-v1' import { publishToolConfirmation } from '@/lib/mothership/persistence/tool-confirm' @@ -88,8 +74,6 @@ import { import { ensureHandlersRegistered, executeTool } from '@/lib/mothership/tool-executor' import { isMcpTool } from '@/executor/constants' -export { waitForToolCompletion } from '@/lib/mothership/request/tools/client' - const logger = createLogger('CopilotSseToolExecution') function hasOutputValue(result: { output?: unknown } | undefined): result is { output: unknown } { @@ -122,7 +106,7 @@ function summarizeToolResultForSpan(result: { if (!hasOutputValue(result)) { return summary } - const output = (result as { output: unknown }).output + const output = result.output if (typeof output === 'string') { summary.outputKind = 'string' summary.outputBytes = Buffer.byteLength(output) @@ -183,8 +167,6 @@ function buildCompletionSignal(input: { } } -export interface AsyncToolCompletion extends AsyncCompletionSignal {} - function publishTerminalToolConfirmation(input: { toolCallId: string status: AsyncCompletionEnvelope['status'] @@ -231,22 +213,8 @@ const LONG_RUNNING_TOOL_IDS: ReadonlySet = new Set([ GenerateAudio.id, GenerateVideo.id, Ffmpeg.id, - Media.id, - Search.id, - WebCrawl.id, - ManageKnowledgeBase.id, - DownloadFile.id, - CreateEmptyFile.id, ApplyFileEdit.id, - SaveUpload.id, PrepareFileEdit.id, - DeployAsApi.id, - DeployAsChat.id, - PublishCustomBlock.id, - DeployAsMcp.id, - Redeploy.id, - LoadDeployment.id, - PromoteToLive.id, ]) export function toolWatchdogTimeoutMs(toolName: string | undefined): number { @@ -434,7 +402,7 @@ export async function forceFailHungToolCall( } } -function cancelledCompletion(message: string): AsyncToolCompletion { +function cancelledCompletion(message: string): AsyncCompletionSignal { return buildCompletionSignal({ status: MothershipStreamV1ToolOutcome.cancelled, message, @@ -442,7 +410,7 @@ function cancelledCompletion(message: string): AsyncToolCompletion { }) } -function terminalCompletionFromToolCall(toolCall: ToolCallState): AsyncToolCompletion { +function terminalCompletionFromToolCall(toolCall: ToolCallState): AsyncCompletionSignal { if (toolCall.status === MothershipStreamV1ToolOutcome.cancelled) { return cancelledCompletion(requireToolCallError(toolCall)) } @@ -481,7 +449,7 @@ export async function executeToolAndReport( context: StreamingContext, execContext: ExecutionContext, options?: OrchestratorOptions -): Promise { +): Promise { const toolCall = context.toolCalls.get(toolCallId) if (!toolCall) return buildCompletionSignal({ @@ -554,7 +522,7 @@ async function executeToolAndReportInner( context: StreamingContext, execContext: ExecutionContext, options?: OrchestratorOptions -): Promise { +): Promise { if (toolCall.status === 'executing') { return buildCompletionSignal({ status: MothershipStreamV1AsyncToolRecordStatus.running, @@ -575,30 +543,7 @@ async function executeToolAndReportInner( // Loads the handler map on first use; the abort check below covers that wait. await ensureHandlersRegistered() if (abortRequested(context, execContext, options)) { - markToolCallCancelled('Request aborted before tool execution') - const cancellationResult = toolCall.result - markToolResultSeen(context, toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted before tool execution', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) - }) - if (toolCall.result !== cancellationResult) { - return terminalCompletionFromToolCall(toolCall) - } - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted before tool execution', - data: { cancelled: true }, - }) - return cancelledCompletion('Request aborted before tool execution') + return settleCancelled('Request aborted before tool execution') } toolCall.status = 'executing' @@ -633,6 +578,45 @@ async function executeToolAndReportInner( abortSignalAborted: execContext.abortSignal?.aborted ?? false, }) + /** + * The one cancel-settlement path: mark, ack the async record, publish the terminal + * confirmation, optionally close the span. This block was copy-pasted six times with + * only the message/cancelReason varying — and a seventh copy would inevitably drift. + */ + // Hoisted declaration: the pre-execution abort check calls this before endToolSpan's + // const is assigned — safe because that path passes no span, so the reference is + // never evaluated (and the span does not exist yet there anyway). + async function settleCancelled( + message: string, + span?: { cancelReason: string; error?: string | undefined } + ): Promise { + markToolCallCancelled(message) + const cancellationResult = toolCall.result + markToolResultSeen(context, toolCall.id) + await completeAsyncToolCall({ + toolCallId: toolCall.id, + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: message, + }).catch((err) => { + logger.warn('Failed to persist async tool status', { + toolCallId: toolCall.id, + error: toError(err).message, + }) + }) + if (toolCall.result !== cancellationResult) { + return terminalCompletionFromToolCall(toolCall) + } + publishTerminalToolConfirmation({ + toolCallId: toolCall.id, + status: MothershipStreamV1ToolOutcome.cancelled, + message, + data: { cancelled: true }, + }) + if (span) endToolSpan('cancelled', span) + return cancelledCompletion(message) + } + const endToolSpan = ( status: string, detail?: { error?: string; cancelReason?: string; resultSuccess?: boolean } @@ -693,34 +677,10 @@ async function executeToolAndReportInner( toolExecutionContext.resolvedSecretTraceRegistry, toolCall.name ).result - markToolCallCancelled('Request aborted during tool execution') - const cancellationResult = toolCall.result - markToolResultSeen(context, toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted during tool execution', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) - }) - if (toolCall.result !== cancellationResult) { - return terminalCompletionFromToolCall(toolCall) - } - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted during tool execution', - data: { cancelled: true }, - }) - endToolSpan('cancelled', { + return settleCancelled('Request aborted during tool execution', { cancelReason: 'abort_during_execution', error: copilotResult.success === false ? copilotResult.error : undefined, }) - return cancelledCompletion('Request aborted during tool execution') } result = await maybeWriteOutputToFile( toolCall.name, @@ -733,31 +693,9 @@ async function executeToolAndReportInner( return terminalCompletionFromToolCall(toolCall) } if (abortRequested(context, execContext, options)) { - markToolCallCancelled('Request aborted during tool post-processing') - const cancellationResult = toolCall.result - markToolResultSeen(context, toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted during tool post-processing', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) - }) - if (toolCall.result !== cancellationResult) { - return terminalCompletionFromToolCall(toolCall) - } - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted during tool post-processing', - data: { cancelled: true }, + return settleCancelled('Request aborted during tool post-processing', { + cancelReason: 'abort_during_post_processing_file', }) - endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_file' }) - return cancelledCompletion('Request aborted during tool post-processing') } result = await maybeWriteOutputToTable( toolCall.name, @@ -770,31 +708,9 @@ async function executeToolAndReportInner( return terminalCompletionFromToolCall(toolCall) } if (abortRequested(context, execContext, options)) { - markToolCallCancelled('Request aborted during tool post-processing') - const cancellationResult = toolCall.result - markToolResultSeen(context, toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted during tool post-processing', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) + return settleCancelled('Request aborted during tool post-processing', { + cancelReason: 'abort_during_post_processing_table', }) - if (toolCall.result !== cancellationResult) { - return terminalCompletionFromToolCall(toolCall) - } - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted during tool post-processing', - data: { cancelled: true }, - }) - endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_table' }) - return cancelledCompletion('Request aborted during tool post-processing') } result = await maybeWriteReadCsvToTable( toolCall.name, @@ -807,31 +723,9 @@ async function executeToolAndReportInner( return terminalCompletionFromToolCall(toolCall) } if (abortRequested(context, execContext, options)) { - markToolCallCancelled('Request aborted during tool post-processing') - const cancellationResult = toolCall.result - markToolResultSeen(context, toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted during tool post-processing', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) + return settleCancelled('Request aborted during tool post-processing', { + cancelReason: 'abort_during_post_processing_csv', }) - if (toolCall.result !== cancellationResult) { - return terminalCompletionFromToolCall(toolCall) - } - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted during tool post-processing', - data: { cancelled: true }, - }) - endToolSpan('cancelled', { cancelReason: 'abort_during_post_processing_csv' }) - return cancelledCompletion('Request aborted during tool post-processing') } const projection = inspectToolResultForCopilot( result, @@ -1006,34 +900,10 @@ async function executeToolAndReportInner( mergeToolRegistry(projection.safe) const safeThrownMessage = copilotError.error || 'Tool failed' if (abortRequested(context, execContext, options)) { - markToolCallCancelled('Request aborted during tool execution') - const cancellationResult = toolCall.result - markToolResultSeen(context, toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: MothershipStreamV1AsyncToolRecordStatus.cancelled, - result: { cancelled: true }, - error: 'Request aborted during tool execution', - }).catch((err) => { - logger.warn('Failed to persist async tool status', { - toolCallId: toolCall.id, - error: toError(err).message, - }) - }) - if (toolCall.result !== cancellationResult) { - return terminalCompletionFromToolCall(toolCall) - } - publishTerminalToolConfirmation({ - toolCallId: toolCall.id, - status: MothershipStreamV1ToolOutcome.cancelled, - message: 'Request aborted during tool execution', - data: { cancelled: true }, - }) - endToolSpan('cancelled', { + return settleCancelled('Request aborted during tool execution', { cancelReason: 'abort_during_execution_catch', error: safeThrownMessage, }) - return cancelledCompletion('Request aborted during tool execution') } setTerminalToolCallState(toolCall, { status: MothershipStreamV1ToolOutcome.error, diff --git a/apps/sim/lib/mothership/request/tools/files.ts b/apps/sim/lib/mothership/request/tools/files.ts index 5f9839d37e9..b72b4b45a65 100644 --- a/apps/sim/lib/mothership/request/tools/files.ts +++ b/apps/sim/lib/mothership/request/tools/files.ts @@ -272,10 +272,24 @@ export interface OutputFileDeclaration { export function getOutputFileDeclarations( params: Record | undefined ): OutputFileDeclaration[] { - const args = params?.args as Record | undefined + // Model-authored wire params: every read is typeof-guarded — a truthy non-string + // outputPath must never become a file path. + const stringParam = (key: string): string | undefined => { + const direct = params?.[key] + if (typeof direct === 'string' && direct.length > 0) return direct + const nested = args?.[key] + return typeof nested === 'string' && nested.length > 0 ? nested : undefined + } + const rawArgs = params?.args + const args = + rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs) + ? (rawArgs as Record) + : undefined + const rawOutputs = params?.outputs ?? args?.outputs const outputs = - (params?.outputs as { files?: unknown[] } | undefined) ?? - (args?.outputs as { files?: unknown[] } | undefined) + rawOutputs && typeof rawOutputs === 'object' && !Array.isArray(rawOutputs) + ? (rawOutputs as { files?: unknown[] }) + : undefined if (Array.isArray(outputs?.files)) { return outputs.files.flatMap((item): OutputFileDeclaration[] => { @@ -294,24 +308,17 @@ export function getOutputFileDeclarations( }) } - const outputPath = - (params?.outputPath as string | undefined) ?? (args?.outputPath as string | undefined) + const outputPath = stringParam('outputPath') if (!outputPath) return [] - const overwriteFileId = - (params?.overwriteFileId as string | undefined) ?? (args?.overwriteFileId as string | undefined) + const overwriteFileId = stringParam('overwriteFileId') return [ { - path: overwriteFileId || outputPath, + path: overwriteFileId ?? outputPath, mode: overwriteFileId ? 'overwrite' : 'create', formatPath: outputPath, - format: ((params?.outputFormat as string | undefined) ?? - (args?.outputFormat as string | undefined)) as OutputFormat | undefined, - mimeType: - (params?.outputMimeType as string | undefined) ?? - (args?.outputMimeType as string | undefined), - sandboxPath: - (params?.outputSandboxPath as string | undefined) ?? - (args?.outputSandboxPath as string | undefined), + format: stringParam('outputFormat') as OutputFormat | undefined, + mimeType: stringParam('outputMimeType'), + sandboxPath: stringParam('outputSandboxPath'), }, ] } diff --git a/apps/sim/lib/mothership/tools/client/run-tool-execution.ts b/apps/sim/lib/mothership/tools/client/run-tool-execution.ts index 07e27de5f2c..332b60c145a 100644 --- a/apps/sim/lib/mothership/tools/client/run-tool-execution.ts +++ b/apps/sim/lib/mothership/tools/client/run-tool-execution.ts @@ -543,9 +543,12 @@ async function doExecuteRunTool( return } + const asString = (value: unknown): string | undefined => + typeof value === 'string' && value.length > 0 ? value : undefined + const stopAfterBlockId = (() => { - if (toolName === RunWorkflowUntilBlock.id) return params.stopAfterBlockId as string | undefined - if (toolName === RunBlock.id) return params.blockId as string | undefined + if (toolName === RunWorkflowUntilBlock.id) return asString(params.stopAfterBlockId) + if (toolName === RunBlock.id) return asString(params.blockId) return undefined })() @@ -555,17 +558,19 @@ async function doExecuteRunTool( const variableInputs = isPlainRecord(params.variableInputs) ? (params.variableInputs as Record) : undefined - if (toolName === RunFromBlock.id && params.startBlockId) { + const startBlockId = asString(params.startBlockId) + const blockId = asString(params.blockId) + if (toolName === RunFromBlock.id && startBlockId) { return { - startBlockId: params.startBlockId as string, - executionId: (params.executionId as string | undefined) || 'latest', + startBlockId, + executionId: asString(params.executionId) ?? 'latest', ...(variableInputs ? { variableInputs } : {}), } } - if (toolName === RunBlock.id && params.blockId) { + if (toolName === RunBlock.id && blockId) { return { - startBlockId: params.blockId as string, - executionId: (params.executionId as string | undefined) || 'latest', + startBlockId: blockId, + executionId: asString(params.executionId) ?? 'latest', ...(variableInputs ? { variableInputs } : {}), } } diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts index fe179138fa9..4449094de89 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts @@ -1,3 +1,4 @@ +import { getErrorMessage } from '@sim/utils/errors' import { workflowDepsCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/deps' import { filesGrepCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/files-grep' import { @@ -100,7 +101,7 @@ export async function executeAgentCliCommand( try { return await match.command.execute(match.rest, runtime, match.flags) } catch (error) { - return agentCliFail(error instanceof Error ? error.message : String(error)) + return agentCliFail(getErrorMessage(error)) } } diff --git a/apps/sim/lib/mothership/tools/handlers/function-execute.ts b/apps/sim/lib/mothership/tools/handlers/function-execute.ts index 0d211f7d125..c7a94c06c2b 100644 --- a/apps/sim/lib/mothership/tools/handlers/function-execute.ts +++ b/apps/sim/lib/mothership/tools/handlers/function-execute.ts @@ -227,6 +227,19 @@ interface CanonicalTableInput { sandboxPath?: string } +/** + * Model-authored refs arrive as a bare string or a canonical-input record; every read is + * typeof-guarded here once instead of per-site casts (three sites had drifted copies). + */ +function refField(ref: unknown, key: 'path' | 'tableId' | 'sandboxPath'): string | undefined { + if (typeof ref === 'string') return key === 'sandboxPath' ? undefined : ref + if (ref && typeof ref === 'object') { + const value = (ref as Record)[key] + return typeof value === 'string' && value.length > 0 ? value : undefined + } + return undefined +} + function tableNameFromVfsPath(tableRef: string): string | null { if (!tableRef.startsWith('tables/')) return null const segments = decodeVfsPathSegments(tableRef) @@ -272,12 +285,7 @@ export async function resolveInputFiles( input: { workspaceId, scope: 'active' }, }) for (const fileRef of inputFiles) { - const filePath = - typeof fileRef === 'string' - ? fileRef - : fileRef && typeof fileRef === 'object' - ? (fileRef as CanonicalFileInput).path - : undefined + const filePath = refField(fileRef, 'path') if (!filePath) continue const record = findWorkspaceFileRecord(allFiles, filePath) if (!record) { @@ -289,11 +297,7 @@ export async function resolveInputFiles( `Input file not found: "${filePath}". Pass the exact canonical VFS path copied from glob/read (e.g. "files/Reports/data.csv").` ) } - const explicitSandboxPath = - typeof fileRef === 'object' && fileRef !== null - ? (fileRef as CanonicalFileInput).sandboxPath - : undefined - const mountPath = explicitSandboxPath || getSandboxWorkspaceFilePath(record) + const mountPath = refField(fileRef, 'sandboxPath') ?? getSandboxWorkspaceFilePath(record) await pushWorkspaceFileMount( sandboxFiles, record, @@ -319,12 +323,7 @@ export async function resolveInputFiles( input: { workspaceId, scope: 'active' }, }) for (const dirRef of inputDirectories) { - const dirPath = - typeof dirRef === 'string' - ? dirRef - : dirRef && typeof dirRef === 'object' - ? (dirRef as CanonicalDirectoryInput).path - : undefined + const dirPath = refField(dirRef, 'path') if (!dirPath) continue const folderSegments = decodeVfsPathSegments(dirPath.replace(/^\/?files\/?/, '')) const folderDisplayPath = buildWorkspaceFileFolderDisplayPath(folderSegments) @@ -338,11 +337,8 @@ export async function resolveInputFiles( ) } const mountRoot = - typeof dirRef === 'object' && - dirRef !== null && - (dirRef as CanonicalDirectoryInput).sandboxPath - ? (dirRef as CanonicalDirectoryInput).sandboxPath! - : `/home/user/files/${encodeVfsPathSegments(parseWorkspaceFileFolderDisplayPath(folder.path))}` + refField(dirRef, 'sandboxPath') ?? + `/home/user/files/${encodeVfsPathSegments(parseWorkspaceFileFolderDisplayPath(folder.path))}` const descendants = allFiles.filter((file) => { if (!file.folderPath) return false return file.folderPath === folder.path || file.folderPath.startsWith(`${folder.path}/`) @@ -396,25 +392,16 @@ export async function resolveInputFiles( } if (inputTables?.length) { - const hasTablePathRefs = inputTables.some((tableRef) => { - const tableId = - typeof tableRef === 'string' - ? tableRef - : tableRef && typeof tableRef === 'object' - ? (tableRef as CanonicalTableInput).tableId || (tableRef as CanonicalTableInput).path - : undefined - return typeof tableId === 'string' && tableId.startsWith('tables/') - }) + const tableRefId = (tableRef: unknown): string | undefined => + refField(tableRef, 'tableId') ?? refField(tableRef, 'path') + const hasTablePathRefs = inputTables.some((tableRef) => + tableRefId(tableRef)?.startsWith('tables/') + ) const tablePathLookup = hasTablePathRefs ? new Map((await listTables(workspaceId)).map((table) => [table.name, table])) : undefined for (const tableRef of inputTables) { - const tableId = - typeof tableRef === 'string' - ? tableRef - : tableRef && typeof tableRef === 'object' - ? (tableRef as CanonicalTableInput).tableId || (tableRef as CanonicalTableInput).path - : undefined + const tableId = tableRefId(tableRef) if (!tableId) continue const table = await resolveTableRef(tableId, tablePathLookup) if (!table || table.workspaceId !== workspaceId) { @@ -422,11 +409,7 @@ export async function resolveInputFiles( `Input table not found: "${tableId}". Pass the table id (tbl_...) from tables/{name}/meta.json, or a tables/{name}/meta.json path.` ) } - const sandboxPath = - typeof tableRef === 'object' && tableRef !== null - ? (tableRef as CanonicalTableInput).sandboxPath - : undefined - const mountPath = sandboxPath || `/home/user/tables/${table.id}.csv` + const mountPath = refField(tableRef, 'sandboxPath') ?? `/home/user/tables/${table.id}.csv` const snapshot = await getOrCreateTableSnapshot(table, 'copilot-fn-exec') if (!resolvedSecretTraceRegistry) { diff --git a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts index e51e3f293b9..849a85f7ddf 100644 --- a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts @@ -13,6 +13,19 @@ import { type ToolCallEffect, type ToolEffectPhase, } from '@/lib/mothership/tool-executor/types' +import type { + CreateWorkflowParams, + GenerateApiKeyParams, + MoveWorkflowParams, + RenameWorkflowParams, + RunBlockParams, + RunFromBlockParams, + RunWorkflowParams, + RunWorkflowUntilBlockParams, + SetBlockEnabledParams, + SetGlobalWorkflowVariablesParams, + VariableOperation, +} from '@/lib/mothership/tools/handlers/param-types' import { requireCopilotWorkspace } from '@/lib/mothership/tools/server/workspace-scope' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/mothership/vfs/path-utils' import { PlatformEvents } from '@/lib/core/telemetry' @@ -34,6 +47,8 @@ import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { hasExecutionResult, readAttemptedExecutionId } from '@/executor/utils/errors' import type { WorkflowState } from '@/stores/workflows/workflow/types' +const logger = createLogger('WorkflowMutations') + function stripBinaryFields(value: unknown): unknown { if (value === null || value === undefined) return value if (typeof value !== 'object') return value @@ -172,23 +187,6 @@ function copilotRunLifecycle(context: ExecutionContext) { } } -import type { - CancelWorkflowRunParams, - CreateWorkflowParams, - GenerateApiKeyParams, - MoveWorkflowParams, - RenameWorkflowParams, - RunBlockParams, - RunFromBlockParams, - RunWorkflowParams, - RunWorkflowUntilBlockParams, - SetBlockEnabledParams, - SetGlobalWorkflowVariablesParams, - VariableOperation, -} from '../param-types' - -const logger = createLogger('WorkflowMutations') - function assertWorkflowMutationNotAborted( context: ExecutionContext, message = 'Request aborted before workflow mutation could be applied.' @@ -285,45 +283,6 @@ export async function executeRunWorkflow( } } -export async function executeCancelWorkflowRun( - params: CancelWorkflowRunParams, - context: ExecutionContext -): Promise { - try { - const executionId = resolveInputFromExecutionId(params.executionId) - if (!executionId) { - return { success: false, error: 'executionId is required' } - } - - assertWorkflowMutationNotAborted( - context, - 'Request aborted before workflow run cancellation could be applied.' - ) - const result = await executeCopilotWorkflowUseCase(context, cancelWorkflowRun, { - runId: executionId, - ...(context.abortSignal ? { abortSignal: context.abortSignal } : {}), - }) - - return { - success: result.success, - output: { - workflowId: result.workflowId, - executionId: result.executionId, - durablyRecorded: result.durablyRecorded, - locallyAborted: result.locallyAborted, - pausedCancelled: result.pausedCancelled, - reason: result.reason, - }, - error: result.success ? undefined : 'Workflow run cancellation could not be completed', - } - } catch (error) { - return { - success: false, - error: messageForCopilotWorkflowError(error, 'Failed to cancel workflow run'), - } - } -} - export async function executeSetGlobalWorkflowVariables( params: SetGlobalWorkflowVariablesParams, context: ExecutionContext diff --git a/apps/sim/lib/mothership/tools/server/env-reference.test.ts b/apps/sim/lib/mothership/tools/server/env-reference.test.ts deleted file mode 100644 index b08edc4182c..00000000000 --- a/apps/sim/lib/mothership/tools/server/env-reference.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * @vitest-environment node - */ -import { environmentUtilsMockFns, resetEnvironmentUtilsMock } from '@sim/testing' -import { afterEach, describe, expect, it } from 'vitest' -import { resolveEnvReferenceSecretArg } from '@/lib/mothership/tools/server/env-reference' -import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' - -const scope = { userId: 'user-1', workspaceId: 'workspace-1' } - -describe('resolveEnvReferenceSecretArg', () => { - afterEach(resetEnvironmentUtilsMock) - - it('protects a password created after the turn registry was initialized', async () => { - const registry = new ResolvedSecretTraceRegistry([], scope) - environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot.mockResolvedValueOnce({ - personalEncrypted: {}, - personalDecrypted: {}, - workspaceEncrypted: { CHAT_PASSWORD: 'encrypted-password' }, - workspaceDecrypted: { CHAT_PASSWORD: 'newly-created-password' }, - personalOwners: {}, - conflicts: [], - decryptionFailures: [], - workspaceUnredactedKeys: [], - }) - - expect( - await resolveEnvReferenceSecretArg({ - ...scope, - value: '{{CHAT_PASSWORD}}', - argName: 'password', - registry, - }) - ).toEqual({ value: 'newly-created-password' }) - expect(registry.isComplete()).toBe(true) - expect(registry.getActiveMatches()).toEqual([ - { plaintext: 'newly-created-password', replacement: '{{CHAT_PASSWORD}}' }, - ]) - }) - - it('does not resolve a removed password from the previous catalog', async () => { - const registry = new ResolvedSecretTraceRegistry( - [{ name: 'CHAT_PASSWORD', plaintext: 'old-password', encryptedValue: 'encrypted-old' }], - scope - ) - - const result = await resolveEnvReferenceSecretArg({ - ...scope, - value: '{{CHAT_PASSWORD}}', - argName: 'password', - registry, - }) - - expect(result).not.toHaveProperty('value') - expect(result.error).toContain('not set') - expect(registry.getActiveMatches()).toEqual([]) - }) - - it('leaves literal passwords alone without loading the environment', async () => { - expect( - await resolveEnvReferenceSecretArg({ - ...scope, - value: '$literal_password', - argName: 'password', - }) - ).toEqual({ value: '$literal_password' }) - expect(environmentUtilsMockFns.mockGetEffectiveEnvironmentSnapshot).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/lib/mothership/tools/server/env-reference.ts b/apps/sim/lib/mothership/tools/server/env-reference.ts deleted file mode 100644 index 94e7bf9da66..00000000000 --- a/apps/sim/lib/mothership/tools/server/env-reference.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { getEffectiveEnvironmentSnapshot } from '@/lib/environment/utils' -import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' - -/** - * Resolves a whole-value `{{ENV_VAR}}` reference in a secret-bearing tool arg. - * - * Copilot agents never see secret values — the workspace exposes variable - * NAMES only — so when a user says "use the password in CHAT_PW" the model - * passes `{{CHAT_PW}}`. Without resolution the literal seven-character - * placeholder becomes the stored secret and nothing ever errors. Only the - * explicit braced form resolves here: unlike API keys, passwords are - * free-form strings, so `$NAME`/bare-name heuristics would corrupt real ones. - * - * Returns an error when the referenced variable is unset so the model learns - * the actual fix instead of silently storing the placeholder. - */ -export async function resolveEnvReferenceSecretArg(args: { - userId: string - workspaceId?: string - value: string | undefined - argName: string - registry?: ResolvedSecretTraceRegistry -}): Promise<{ value?: string; error?: string }> { - const { value } = args - if (!value) return { value } - const braced = value.match(/^\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}$/) - if (!braced) return { value } - const name = braced[1] - const environment = await getEffectiveEnvironmentSnapshot(args.userId, args.workspaceId) - const env = { ...environment.personalDecrypted, ...environment.workspaceDecrypted } - const resolved = env[name] - if (resolved === undefined || resolved === '') { - return { - error: `Environment variable "${name}" referenced by ${args.argName} is not set for this workspace or user. Set it first, or pass the raw value.`, - } - } - args.registry?.recordResolvedFromEnvironment(name, resolved, { - ...environment, - scope: { userId: args.userId, workspaceId: args.workspaceId }, - }) - return { value: resolved } -} diff --git a/apps/sim/lib/mothership/tools/server/files/doc-extract.ts b/apps/sim/lib/mothership/tools/server/files/doc-extract.ts deleted file mode 100644 index 1aafa727c1f..00000000000 --- a/apps/sim/lib/mothership/tools/server/files/doc-extract.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { CodeLanguage } from '@/lib/execution/languages' -import { executeInSandbox } from '@/lib/execution/remote-sandbox' - -const EXTRACT_TIMEOUT_MS = 120_000 -// Bound the text handed back to the agent so a huge document can't blow the -// context window; the agent gets a clear truncation marker if it hits the cap. -const MAX_EXTRACT_CHARS = 200_000 - -/** Binary document formats whose text/tables we can extract in the doc sandbox. */ -const EXTRACTABLE_EXTS = new Set(['pdf', 'pptx', 'docx', 'xlsx']) - -export function isExtractableDocExt(ext: string): boolean { - return EXTRACTABLE_EXTS.has(ext.toLowerCase()) -} - -export interface DocExtract { - text: string - truncated: boolean -} - -/** - * Extracts readable text (and tables) from an uploaded binary document inside the - * E2B doc sandbox so the agent can read/reason over files it cannot otherwise see - * as source: pdf via pdfplumber, pptx via python-pptx, docx via python-docx, xlsx - * via openpyxl. Read-only — never mutates the file. Throws on sandbox/infra - * failure or an unparseable document. - */ -export async function extractDocText(args: { binary: Buffer; ext: string }): Promise { - const ext = args.ext.toLowerCase() - if (!isExtractableDocExt(ext)) { - throw new OrchestrationError( - 'validation', - `Cannot extract text from .${ext} (supported: pdf, pptx, docx, xlsx)` - ) - } - - const script = ` -import json -ext = ${JSON.stringify(ext)} -inp = f"/home/user/input.{ext}" -out = [] - -if ext == "pdf": - import pdfplumber - with pdfplumber.open(inp) as pdf: - for i, page in enumerate(pdf.pages, 1): - out.append(f"--- Page {i} ---") - out.append(page.extract_text() or "") - for t in (page.extract_tables() or []): - out.append("[table] " + json.dumps(t, ensure_ascii=False)) -elif ext == "pptx": - from pptx import Presentation - prs = Presentation(inp) - for i, slide in enumerate(prs.slides, 1): - out.append(f"--- Slide {i} ---") - for shape in slide.shapes: - if shape.has_text_frame and shape.text_frame.text.strip(): - out.append(shape.text_frame.text) - if shape.has_table: - for row in shape.table.rows: - out.append(" | ".join(c.text for c in row.cells)) - nf = slide.notes_slide.notes_text_frame if slide.has_notes_slide else None - notes = nf.text if nf is not None else "" - if notes.strip(): - out.append("[notes] " + notes) -elif ext == "docx": - import docx - d = docx.Document(inp) - for p in d.paragraphs: - if p.text.strip(): - out.append(p.text) - for tbl in d.tables: - for row in tbl.rows: - out.append(" | ".join(c.text for c in row.cells)) -elif ext == "xlsx": - import openpyxl - wb = openpyxl.load_workbook(inp, data_only=True) - for ws in wb.worksheets: - out.append(f"--- Sheet {ws.title} ---") - # Cap rows so an inflated used-range can't blow up memory/output. - for ri, row in enumerate(ws.iter_rows(values_only=True)): - if ri >= 5000: - out.append("[... more rows truncated]") - break - out.append(",".join("" if v is None else str(v) for v in row)) - -# Bound the transferred text so a decompression bomb can't return gigabytes. -# Headroom over MAX_EXTRACT_CHARS so the TS-side truncation flag can still fire. -text = "\\n".join(out)[:${MAX_EXTRACT_CHARS + 20000}] -print("__SIM_RESULT__=" + json.dumps({"text": text})) -`.trim() - - const result = await executeInSandbox({ - code: script, - language: CodeLanguage.Python, - timeoutMs: EXTRACT_TIMEOUT_MS, - sandboxKind: 'doc', - sandboxFiles: [ - { - path: `/home/user/input.${ext}`, - content: args.binary.toString('base64'), - encoding: 'base64', - }, - ], - }) - - if (result.error) { - throw new OrchestrationError('validation', `Document extraction failed: ${result.error}`) - } - const payload = result.result as { text?: string } | null - const full = payload?.text ?? '' - const truncated = full.length > MAX_EXTRACT_CHARS - // The caller (VFS read) owns the user-facing truncation note; just return the - // bounded text + the flag here. - return { text: full.slice(0, MAX_EXTRACT_CHARS), truncated } -} diff --git a/apps/sim/lib/mothership/tools/server/files/doc-render.ts b/apps/sim/lib/mothership/tools/server/files/doc-render.ts deleted file mode 100644 index 2ded3f81dd7..00000000000 --- a/apps/sim/lib/mothership/tools/server/files/doc-render.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { CodeLanguage } from '@/lib/execution/languages' -import { executeInSandbox } from '@/lib/execution/remote-sandbox' - -const RENDER_TIMEOUT_MS = 150_000 -// Bound the visual-QA cost: cap pages and rasterization DPI so the JPEGs the -// file agent inspects stay small enough for vision input. -const MAX_RENDER_PAGES = 20 -const RENDER_DPI = 110 - -/** Extensions LibreOffice can render to page images for the visual QA loop. */ -const RENDERABLE_EXTS = new Set(['pptx', 'docx', 'pdf']) - -export function isRenderableDocExt(ext: string): boolean { - return RENDERABLE_EXTS.has(ext.toLowerCase()) -} - -export interface DocRender { - /** A single contact-sheet grid JPEG of all pages, for the agent's visual QA. */ - grid: Buffer - pageCount: number -} - -/** - * Renders a compiled document binary to a single contact-sheet grid image inside - * the E2B doc sandbox (LibreOffice → PDF → poppler `pdftoppm` → Pillow tile). - * Works for any compiled binary regardless of which engine produced it - * (isolated-vm JS or E2B Python), so the visual QA loop covers pptx/docx/pdf - * uniformly. One grid image fits the VFS single-attachment read and gives the - * agent the whole deck/doc at a glance (mirrors Anthropic's thumbnail grid). - * - * Throws on a sandbox/infra failure or when the doc renders to zero pages. - */ -export async function renderDocToGrid(args: { - binary: Buffer - ext: string - workspaceId: string -}): Promise { - const ext = args.ext.toLowerCase() - if (!isRenderableDocExt(ext)) { - throw new OrchestrationError( - 'validation', - `Cannot render .${ext} to images (supported: pptx, docx, pdf)` - ) - } - - const script = ` -import subprocess, glob, base64, json -from PIL import Image - -ext = ${JSON.stringify(ext)} -inp = f"/home/user/input.{ext}" -pdf = inp if ext == "pdf" else "/home/user/input.pdf" - -if ext != "pdf": - subprocess.run( - ["soffice", "--headless", "--convert-to", "pdf", "--outdir", "/home/user", inp], - check=True, timeout=120, capture_output=True, - ) - -subprocess.run( - ["pdftoppm", "-jpeg", "-r", "${RENDER_DPI}", "-l", "${MAX_RENDER_PAGES}", pdf, "/home/user/page"], - check=True, timeout=120, capture_output=True, -) - -paths = sorted(glob.glob("/home/user/page*.jpg"))[:${MAX_RENDER_PAGES}] -imgs = [Image.open(p).convert("RGB") for p in paths] -n = len(imgs) -if n == 0: - print("__SIM_RESULT__=" + json.dumps({"grid": None, "pageCount": 0})) -else: - cols = 1 if n == 1 else (2 if n <= 6 else 3) - rows = (n + cols - 1) // cols - cell_w = max(i.width for i in imgs) - cell_h = max(i.height for i in imgs) - pad = 12 - grid = Image.new("RGB", (cols * cell_w + (cols + 1) * pad, rows * cell_h + (rows + 1) * pad), (240, 240, 240)) - for idx, im in enumerate(imgs): - r, c = divmod(idx, cols) - grid.paste(im, (pad + c * (cell_w + pad), pad + r * (cell_h + pad))) - # Cap the grid's longest edge so the JPEG stays a reasonable vision input. - max_edge = 2200 - if max(grid.size) > max_edge: - scale = max_edge / max(grid.size) - grid = grid.resize((int(grid.width * scale), int(grid.height * scale))) - grid.save("/home/user/grid.jpg", "JPEG", quality=80) - with open("/home/user/grid.jpg", "rb") as f: - print("__SIM_RESULT__=" + json.dumps({"grid": base64.b64encode(f.read()).decode(), "pageCount": n})) -`.trim() - - const result = await executeInSandbox({ - code: script, - language: CodeLanguage.Python, - timeoutMs: RENDER_TIMEOUT_MS, - sandboxKind: 'doc', - sandboxFiles: [ - { - path: `/home/user/input.${ext}`, - content: args.binary.toString('base64'), - encoding: 'base64', - }, - ], - }) - - if (result.error) { - throw new OrchestrationError('validation', `Document render failed: ${result.error}`) - } - const payload = result.result as { grid?: string | null; pageCount?: number } | null - if (!payload?.grid) { - throw new Error('Document render produced no pages') - } - return { grid: Buffer.from(payload.grid, 'base64'), pageCount: payload.pageCount ?? 0 } -} diff --git a/apps/sim/lib/mothership/tools/shared/workflow-utils.ts b/apps/sim/lib/mothership/tools/shared/workflow-utils.ts deleted file mode 100644 index c82e0f60bdf..00000000000 --- a/apps/sim/lib/mothership/tools/shared/workflow-utils.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - type CopilotSanitizationOptions, - sanitizeForCopilot, -} from '@/lib/workflows/sanitization/json-sanitizer' - -type CopilotWorkflowState = { - blocks?: Record - edges?: any[] - loops?: Record - parallels?: Record -} - -export function formatWorkflowStateForCopilot( - state: CopilotWorkflowState, - options?: CopilotSanitizationOptions -): string { - const workflowState = { - blocks: state.blocks || {}, - edges: state.edges || [], - loops: state.loops || {}, - parallels: state.parallels || {}, - } - const sanitized = sanitizeForCopilot(workflowState, options) - return JSON.stringify(sanitized, null, 2) -} - -export function formatNormalizedWorkflowForCopilot( - normalized: CopilotWorkflowState | null | undefined, - options?: CopilotSanitizationOptions -): string | null { - if (!normalized) return null - return formatWorkflowStateForCopilot(normalized, options) -} diff --git a/apps/sim/lib/mothership/tools/tool-display.test.ts b/apps/sim/lib/mothership/tools/tool-display.test.ts index 3cc5c3b247d..382262c29ec 100644 --- a/apps/sim/lib/mothership/tools/tool-display.test.ts +++ b/apps/sim/lib/mothership/tools/tool-display.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { MOTHERSHIP_STREAM_V1_SCHEMA } from '@/lib/mothership/generated/mothership-stream-v1-schema' import { FfmpegOperationValues, ManageKnowledgeBaseOperationValues, @@ -828,13 +827,6 @@ describe('getToolStatusDisplayTitle for skipped and interrupted calls', () => { }) describe('normalizeToolActivityDescription', () => { - it('matches the bound published by the producer contract', () => { - expect(MOTHERSHIP_STREAM_V1_SCHEMA).toHaveProperty( - '$defs.MothershipStreamV1ToolCallDescriptor.properties.activityDescription.maxLength', - 160 - ) - }) - it('normalizes a phrase without rewriting its meaning', () => { expect( normalizeToolActivityDescription('\uFEFF Checking\n\tthe\u00a0latest\u0085invoices ') diff --git a/apps/sim/lib/mothership/tools/tool-display.ts b/apps/sim/lib/mothership/tools/tool-display.ts index 872c5d17d1a..c444bbe2c19 100644 --- a/apps/sim/lib/mothership/tools/tool-display.ts +++ b/apps/sim/lib/mothership/tools/tool-display.ts @@ -75,7 +75,7 @@ function nestedStringArg(args: ToolArgs, parentKey: string, ...keys: string[]): function recordArg(args: ToolArgs, key: string): Record | undefined { const value = args?.[key] - return isRecordLike(value) ? (value as Record) : undefined + return isRecordLike(value) ? value : undefined } function stringOrNumberArg(args: ToolArgs, key: string): string { @@ -193,13 +193,8 @@ function displayUrl(raw: string): string { * snake_case stem (`slack_v2` -> `Slack`, `google_sheets_v2` -> `Google Sheets`). */ export function blockDisplayName(blockType: string): string { - const stem = stripVersionSuffix(blockType.trim()) - if (!stem) return blockType - return stem - .split('_') - .filter(Boolean) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') + const stem = blockType.trim() + return stem ? humanizeDisplayIdentifier(stem) : blockType } /** Ellipsizes the middle so both ends of a value stay recognizable. */ @@ -732,9 +727,8 @@ function waitTitle(args: ToolArgs): string { * ("digest-workflow-build-4"); recover the human name for titles. */ function humanizeAgentId(id: string): string { - const words = id.replace(/-\d+$/, '').split('-').filter(Boolean) - if (words.length === 0) return id - return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') + const stem = id.replace(/-\d+$/, '') + return stem ? humanizeDisplayIdentifier(stem) : id } /** Title for a wait_agents sleep, naming the agents and honoring mode "any". */ @@ -784,11 +778,7 @@ const MAX_QUOTED_TITLE_VALUE_LENGTH = 32 function runningCommandTitle(rawCommand: string): string { const command = rawCommand.replace(/\s+/g, ' ') if (!command) return 'Running command' - const shortened = - command.length > MAX_COMMAND_TITLE_LENGTH - ? `${command.slice(0, MAX_COMMAND_TITLE_LENGTH - 1)}…` - : command - return `Running ${shortened}` + return `Running ${truncate(command, MAX_COMMAND_TITLE_LENGTH - 1, '…')}` } const TERMINAL_OPERATION_TITLES: Record = { @@ -811,7 +801,7 @@ const TERMINAL_OPERATION_TITLES: Record = { function terminalTitle(args: ToolArgs): string { const operation = stringArg(args, 'operation') const nested = args?.args - const inner: ToolArgs = isRecordLike(nested) ? (nested as Record) : undefined + const inner: ToolArgs = isRecordLike(nested) ? nested : undefined if (operation === 'run') return runningCommandTitle(stringArg(inner, 'command')) if (operation === 'handoff') { // Matches the browser takeover row: the reason is the whole point of the diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 70dc81646c3..412745281d0 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -43,6 +43,7 @@ import { } from '../config/index' import { ProfileOverrideError, redact } from '../config/profile' import { clientFrom, globalsOf, profileFrom } from '../context' +import { setSoftExitCode } from '../embed-context' import { type GetMetaResponse, type GetWorkspaceResponse, @@ -1031,7 +1032,7 @@ export function whoamiCommand(): Command { // Set rather than thrown: the resolved settings above are the answer the // user came for, and a thrown error would replace them with one red line. const exitCode = WHOAMI_EXIT_CODES[verification.status] - if (exitCode !== 0) process.exitCode = exitCode + if (exitCode !== 0) setSoftExitCode(exitCode) }) } diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-wait.ts b/packages/sim-cli/src/commands/protocol/workflow-run-wait.ts index 9bfb512f338..572163bd72f 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-wait.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-wait.ts @@ -4,6 +4,7 @@ import { type Command, Option } from 'commander' import { clientFrom } from '../../context' import { CLI_CONTRACT } from '../../contract/commands' import type { CommandSpec } from '../../contract/types' +import { setSoftExitCode } from '../../embed-context' import { V2_OPERATIONS } from '../../generated/v2-api' import { sleep } from '../../helpers' import { resolvePath, SimApiError } from '../../http/client' @@ -253,7 +254,7 @@ export function attachWorkflowRunWait(runs: Command): void { renderResult('getWorkflowRun', profile.output, raw, runSpec()) const message = explain(outcome, runId, options.workflow, snapshot) if (message) console.error(chalk.red(message)) - process.exitCode = WAIT_EXIT_CODES[outcome] + setSoftExitCode(WAIT_EXIT_CODES[outcome]) return } @@ -268,7 +269,7 @@ export function attachWorkflowRunWait(runs: Command): void { }). Raise ${WAIT_TIMEOUT_FLAG}, or set it to 0 to wait indefinitely.` ) ) - process.exitCode = WAIT_EXIT_CODES.timeout + setSoftExitCode(WAIT_EXIT_CODES.timeout) return } diff --git a/packages/sim-cli/src/embed-context.ts b/packages/sim-cli/src/embed-context.ts index 272c2061184..abc64de7191 100644 --- a/packages/sim-cli/src/embed-context.ts +++ b/packages/sim-cli/src/embed-context.ts @@ -24,6 +24,20 @@ export interface EmbedContext { * filesystem. */ fileArguments?: Record + /** + * Soft-fail exit code (a failed run outcome, `runs wait` timeout). Embedded + * commands write here INSTEAD of process.exitCode: that global is shared, so + * two parallel embedded invocations raced on it — one run could observe and + * clear another's failure. + */ + softExitCode?: number +} + +/** The embedded-vs-standalone seam for soft-fail codes: context when embedded, global otherwise. */ +export function setSoftExitCode(code: number): void { + const ctx = embedStore.getStore() + if (ctx) ctx.softExitCode = code + else process.exitCode = code } export const embedStore = new AsyncLocalStorage() diff --git a/packages/sim-cli/src/embed.ts b/packages/sim-cli/src/embed.ts index f944258392b..8f6cf53fb34 100644 --- a/packages/sim-cli/src/embed.ts +++ b/packages/sim-cli/src/embed.ts @@ -84,12 +84,10 @@ export async function runEmbeddedCli( const program = buildProgram() program.exitOverride() await program.parseAsync(argv, { from: 'user' }) - if (typeof process.exitCode === 'number' && process.exitCode !== 0) { - // Commands that soft-fail (e.g. a failed run outcome) set process.exitCode - // rather than exiting; surface it, then clear so the host server is untouched. - exitCode = process.exitCode - process.exitCode = 0 - } + // Commands that soft-fail (a failed run outcome, wait timeout) report through the + // context via setSoftExitCode — never process.exitCode, which is shared and raced + // between parallel embedded invocations. + if (ctx.softExitCode !== undefined && ctx.softExitCode !== 0) exitCode = ctx.softExitCode } catch (error) { exitCode = renderEmbeddedError(ctx, error) } diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index bde0940d980..1d738eb5298 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -523,15 +523,7 @@ export function buildRequest( workspaceId: string | null ): BuiltRequest { const commandSpec: CommandSpec = CLI_CONTRACT[operation] ?? {} - const spec = V2_OPERATIONS[operation] as { - method: string - path: string - pathParams: readonly string[] - query?: Record - body?: Record - headers?: Record - opaqueBody?: boolean - } + const spec: OperationSpec = V2_OPERATIONS[operation] let path = spec.path let positionalIndex = 0 diff --git a/scripts/sync-mothership-stream-contract.ts b/scripts/sync-mothership-stream-contract.ts index ebe6415c2b6..849b22cb9d0 100644 --- a/scripts/sync-mothership-stream-contract.ts +++ b/scripts/sync-mothership-stream-contract.ts @@ -11,11 +11,6 @@ const DEFAULT_CONTRACT_PATH = resolve( '../copilot/copilot/contracts/mothership-stream-v1.schema.json' ) const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/mothership/generated/mothership-stream-v1.ts') -const RUNTIME_SCHEMA_OUTPUT_PATH = resolve( - ROOT, - 'apps/sim/lib/mothership/generated/mothership-stream-v1-schema.ts' -) - function generateRuntimeConstants(schema: Record, existingTypes: string): string { const defs = (schema.$defs ?? schema.definitions ?? {}) as Record const lines: string[] = [] @@ -42,19 +37,6 @@ function generateRuntimeConstants(schema: Record, existingTypes return lines.join('\n') } -function renderRuntimeSchemaModule(schema: unknown): string { - return [ - '// AUTO-GENERATED FILE. DO NOT EDIT.', - '// Generated from copilot/contracts/mothership-stream-v1.schema.json', - '//', - '', - 'export type JsonSchema = unknown', - '', - `export const MOTHERSHIP_STREAM_V1_SCHEMA: JsonSchema = ${JSON.stringify(schema, null, 2)}`, - '', - ].join('\n') -} - async function main() { const checkOnly = process.argv.includes('--check') const inputPathArg = process.argv.find((arg) => arg.startsWith('--input=')) @@ -76,18 +58,9 @@ async function main() { OUTPUT_PATH, ROOT ) - const renderedSchemaModule = formatGeneratedSource( - renderRuntimeSchemaModule(schema), - RUNTIME_SCHEMA_OUTPUT_PATH, - ROOT - ) - if (checkOnly) { const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null) - const existingSchemaModule = await readFile(RUNTIME_SCHEMA_OUTPUT_PATH, 'utf8').catch( - () => null - ) - if (existing !== rendered || existingSchemaModule !== renderedSchemaModule) { + if (existing !== rendered) { throw new Error( `Generated mothership stream contract is stale. Run: bun run mship-contracts:generate` ) @@ -97,7 +70,6 @@ async function main() { await mkdir(dirname(OUTPUT_PATH), { recursive: true }) await writeFile(OUTPUT_PATH, rendered, 'utf8') - await writeFile(RUNTIME_SCHEMA_OUTPUT_PATH, renderedSchemaModule, 'utf8') } await main() From dc58147e420abcd9e2d82113bc0599d7716c103a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 17:31:56 +0530 Subject: [PATCH 046/306] =?UTF-8?q?refactor(mothership):=20last=20sweep-de?= =?UTF-8?q?bt=20=E2=80=94=20session-key=20constructor,=20shared=20leaf=20w?= =?UTF-8?q?alker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chatSandboxSessionKey is the one constructor for the per-chat sandbox identity (the key doubles as the E2B lease and the workbench file-bridge scope; two hand-built literals drifting apart would split a chat across machines). collectStringLeaves moves into the executor's reference-validation module — lint and the deps augmentation each carried an identical copy of the walk. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/executor/utils/reference-validation.ts | 11 +++++++++++ .../tools/handlers/agent-cli/commands/deps.ts | 15 ++++++--------- .../mothership/tools/handlers/function-execute.ts | 3 ++- apps/sim/lib/mothership/tools/handlers/sim-cli.ts | 3 ++- apps/sim/lib/mothership/tools/sandbox-session.ts | 9 +++++++++ apps/sim/lib/workflows/editing/lint.ts | 10 ++-------- 6 files changed, 32 insertions(+), 19 deletions(-) diff --git a/apps/sim/executor/utils/reference-validation.ts b/apps/sim/executor/utils/reference-validation.ts index 2d05d77b136..6f724b26030 100644 --- a/apps/sim/executor/utils/reference-validation.ts +++ b/apps/sim/executor/utils/reference-validation.ts @@ -154,3 +154,14 @@ export function createCombinedPattern(): RegExp { 'g' ) } + +/** + * Collects every string leaf in a nested value — the shared walk for reference/env-token + * audits over block inputs (lint, deps, and the agent-cli mirrors each carried a copy). + */ +export function collectStringLeaves(value: unknown, out: string[]): void { + if (typeof value === 'string') out.push(value) + else if (Array.isArray(value)) for (const item of value) collectStringLeaves(item, out) + else if (typeof value === 'object' && value !== null) + for (const item of Object.values(value)) collectStringLeaves(item, out) +} diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/deps.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/deps.ts index 4902d0df5b0..989153a17ea 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/deps.ts +++ b/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/deps.ts @@ -5,7 +5,11 @@ import { agentCliOk, } from '@/lib/mothership/tools/handlers/agent-cli/types' import { normalizeName, SPECIAL_REFERENCE_PREFIXES } from '@/executor/constants' -import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation' +import { + collectStringLeaves, + createEnvVarPattern, + createReferencePattern, +} from '@/executor/utils/reference-validation' /** * `workflow deps ` — everything one block consumes, so the @@ -29,13 +33,6 @@ interface DepView { paths?: string[] } -function stringLeaves(value: unknown, out: string[]): void { - if (typeof value === 'string') out.push(value) - else if (Array.isArray(value)) for (const item of value) stringLeaves(item, out) - else if (typeof value === 'object' && value !== null) - for (const item of Object.values(value)) stringLeaves(item, out) -} - export const workflowDepsCommand: AgentCliCommand = { path: ['workflow', 'deps'], summary: @@ -61,7 +58,7 @@ export const workflowDepsCommand: AgentCliCommand = { } const leaves: string[] = [] - stringLeaves(block.subBlocks ?? block, leaves) + collectStringLeaves(block.subBlocks ?? block, leaves) const byToken = new Map() const envs = new Set() diff --git a/apps/sim/lib/mothership/tools/handlers/function-execute.ts b/apps/sim/lib/mothership/tools/handlers/function-execute.ts index c7a94c06c2b..23c03b8f03c 100644 --- a/apps/sim/lib/mothership/tools/handlers/function-execute.ts +++ b/apps/sim/lib/mothership/tools/handlers/function-execute.ts @@ -25,6 +25,7 @@ import type { ToolExecutionContext, ToolExecutionResult, } from '@/lib/mothership/tool-executor/types' +import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session' import { CopilotCodeSecretAccessError, type MaterializedCopilotCodeSecrets, @@ -515,7 +516,7 @@ export async function executeFunctionExecute( // bootstrapped into it. Chat-less executions (one-shot, headless) stay // ephemeral. if (context.chatId) { - enrichedParams.sandboxSessionKey = `mothership-chat:${context.chatId}` + enrichedParams.sandboxSessionKey = chatSandboxSessionKey(context.chatId) } // The copilot tool doc promises `timeout` in SECONDS ("Sim converts to // milliseconds", default 10, cap 300); the underlying function tool takes diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts index 06494f85fa0..4bb8ad15f43 100644 --- a/apps/sim/lib/mothership/tools/handlers/sim-cli.ts +++ b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts @@ -17,6 +17,7 @@ import { matchAgentCliCommand, } from '@/lib/mothership/tools/handlers/agent-cli' import { applyPipeline, splitPipeline } from '@/lib/mothership/tools/handlers/sim-cli-pipe' +import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session' const logger = createLogger('MothershipSimCli') @@ -61,7 +62,7 @@ export async function executeSimCli( // `--text @channel` stays literal), and the server's filesystem is never // readable from model argv. A token that names no sandbox file is simply // absent from the map; the resolver's refusal then says so. - const sessionKey = context.chatId ? `mothership-chat:${context.chatId}` : null + const sessionKey = context.chatId ? chatSandboxSessionKey(context.chatId) : null const fileArguments: Record = {} if (sessionKey) { for (const token of args) { diff --git a/apps/sim/lib/mothership/tools/sandbox-session.ts b/apps/sim/lib/mothership/tools/sandbox-session.ts index 35bdafe0bc0..85371f7727a 100644 --- a/apps/sim/lib/mothership/tools/sandbox-session.ts +++ b/apps/sim/lib/mothership/tools/sandbox-session.ts @@ -7,6 +7,15 @@ import { mintDelegationToken } from '@/lib/mothership/chat/delegation' const logger = createLogger('MothershipSandboxSession') +/** + * The per-chat sandbox identity. One constructor: the key doubles as the E2B lease key + * AND the workbench file-bridge scope, so two hand-built copies drifting apart would + * silently split a chat across two machines. + */ +export function chatSandboxSessionKey(chatId: string): string { + return `mothership-chat:${chatId}` +} + /** * Installed once per fresh session sandbox until the mothership images bake the * CLI in. `command -v` keeps the install one-time: a sandbox that already has diff --git a/apps/sim/lib/workflows/editing/lint.ts b/apps/sim/lib/workflows/editing/lint.ts index dde4a69083b..693ed7bd04a 100644 --- a/apps/sim/lib/workflows/editing/lint.ts +++ b/apps/sim/lib/workflows/editing/lint.ts @@ -1,5 +1,6 @@ import { getBlock } from '@/blocks' import { isTriggerBlockType, normalizeName, SPECIAL_REFERENCE_PREFIXES } from '@/executor/constants' +import { collectStringLeaves } from '@/executor/utils/reference-validation' import { collectBlockFieldIssues, extractBlockParams, @@ -388,13 +389,6 @@ export function formatWorkflowLintMessage(lint: WorkflowLintIssueView) { const BLOCK_REF_TOKEN = /<([^<>]+)>/g const REF_TOKEN_SHAPE = /^[A-Za-z_][\w-]*(?:[\w\s-]*[\w-])?\.[A-Za-z0-9_.[\]]+$/ -function stringLeavesForLint(value: unknown, out: string[]): void { - if (typeof value === 'string') out.push(value) - else if (Array.isArray(value)) for (const item of value) stringLeavesForLint(item, out) - else if (typeof value === 'object' && value !== null) - for (const item of Object.values(value)) stringLeavesForLint(item, out) -} - export function collectDanglingBlockOutputReferences( workflowState: Pick ): WorkflowLintUnresolvedReference[] { @@ -409,7 +403,7 @@ export function collectDanglingBlockOutputReferences( for (const [subBlockId, subBlock] of Object.entries(block.subBlocks ?? {})) { if (subBlockId === 'code') continue const leaves: string[] = [] - stringLeavesForLint((subBlock as { value?: unknown })?.value, leaves) + collectStringLeaves((subBlock as { value?: unknown })?.value, leaves) const dangling = new Set() for (const leaf of leaves) { for (const match of leaf.matchAll(BLOCK_REF_TOKEN)) { From 85e2ac093634568b809854e27ad639a3f8a0a7c2 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 17:51:39 +0530 Subject: [PATCH 047/306] =?UTF-8?q?fix(mothership):=20workspaceId=20is=20r?= =?UTF-8?q?equired=20end-to-end=20=E2=80=94=20no=20silent=20fallback=20pat?= =?UTF-8?q?h?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to the worker contract tightening. The requiredness propagates up sim's own chain: the payload builder and the workflow branch carried optional workspaceId that was never truly optional (the resolver's 'resolved' variant guarantees it; the workspace contract requires it) — now typed as it always behaved. Regenerated protocol mirror. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/sim/lib/mothership/chat/payload.ts | 2 +- apps/sim/lib/mothership/chat/post.ts | 6 ++++-- apps/sim/lib/mothership/generated/protocol.ts | 5 ++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/mothership/chat/payload.ts b/apps/sim/lib/mothership/chat/payload.ts index 210bdb4d8c6..51fbc2d3a9a 100644 --- a/apps/sim/lib/mothership/chat/payload.ts +++ b/apps/sim/lib/mothership/chat/payload.ts @@ -420,7 +420,7 @@ export async function buildCopilotRequestPayload( : {}), messageId: userMessageId, ...(chatId ? { chatId } : {}), - ...(params.workspaceId ? { workspaceId: params.workspaceId } : {}), + workspaceId: params.workspaceId, ...(workflowId ? { workflowId } : {}), ...(allContexts.length > 0 ? { context: allContexts } : {}), ...(integrationTools.length > 0 ? { integrationTools } : {}), diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index 2c90015c0c6..5220fb77f44 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -354,7 +354,9 @@ type UnifiedChatBranch = kind: 'workflow' workflowId: string workflowName?: string - workspaceId?: string + /** Always present: the resolver's 'resolved' variant guarantees it (the workflow's + * own workspace) — the wire contract requires it. */ + workspaceId: string effectiveModel: string selectedModel: string mode: UnifiedChatRequest['mode'] @@ -376,7 +378,7 @@ type UnifiedChatBranch = effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max' workflowId: string workflowName?: string - workspaceId?: string + workspaceId: string mode: UnifiedChatRequest['mode'] provider?: string commands?: string[] diff --git a/apps/sim/lib/mothership/generated/protocol.ts b/apps/sim/lib/mothership/generated/protocol.ts index 5c43c479df8..a971f20bdcc 100644 --- a/apps/sim/lib/mothership/generated/protocol.ts +++ b/apps/sim/lib/mothership/generated/protocol.ts @@ -25,7 +25,10 @@ export interface ChatRequest { protocolVersion?: number | undefined; messageId?: string | undefined; chatId?: string | undefined; - workspaceId?: string | undefined; + /** Required: memories, analytics, and the chat row all key on it — sim always resolves + * it (workspace-scoped directly; workflow-scoped from the workflow). A missing value + * used to FABRICATE a random workspace identity per request. */ + workspaceId: string; /** Workflow-scoped chats (the workflow-page copilot): the agent anchors to this workflow. */ workflowId?: string | undefined; /** Connected-service operation schemas served by the integration gateway. */ From 567b4b8a484645c39cb2a10a0f424260d836036f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 19:18:00 +0530 Subject: [PATCH 048/306] Reconcile the staging rebase: finish rename-followed ports, async selector resolution Rebasing onto origin/staging carried staging's lib/copilot work into our renamed lib/mothership files via rename detection; this finishes what the replay left incomplete: - Complete the #7151 port: cancel_workflow_run handler + registration on the mothership executor (types, tests, and catalog entry had already rename-followed in). - pickRunBlockOutputs awaits the now-async resolveOutputIds (#7346 made selector resolution child-workflow-aware) and re-imports isValidUuid. - Slack execution stream + tool-call-lifecycle import getToolDisplayTitle from the mothership tool-display module (#7296 semantics kept). - Resolve leftover conflict markers from the first replay's failed batch checkout (browser-tool hardening #7311, YAML bounds #7319 kept). - Drop the obsolete lib/mothership/tools/server/blocks scan root: the block-metadata tool died in the dead-code sweep; blocks reads flow through the already-guarded v2 blocks routes. - Regenerate OpenAPI, CLI API, and CLI docs from the merged contracts. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- apps/docs/content/docs/cli/reference.mdx | 2 +- apps/docs/content/docs/cli/workflows.mdx | 2 +- apps/docs/openapi-v2-workflows.json | 18 +++++-- .../tool-executor/register-handlers.ts | 3 ++ .../mothership/tool-executor/router.test.ts | 50 +++++++++---------- .../tools/handlers/workflow/mutations.test.ts | 2 +- .../tools/handlers/workflow/mutations.ts | 41 ++++++++++++++- .../server/files/doc-compiled-store.test.ts | 2 +- .../lib/webhooks/slack-execution-stream.ts | 2 +- .../lib/workflows/executor/execute-service.ts | 14 +++--- packages/sim-cli/src/generated/v2-api.ts | 10 +++- ...check-tool-registry-boundary.baseline.json | 15 +----- scripts/check-tool-registry-boundary.ts | 11 ---- 13 files changed, 105 insertions(+), 67 deletions(-) diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 9de307d400f..b44f1d174fb 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -5669,7 +5669,7 @@ sim workflows run [options] | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | | `--execution-timeout-seconds ` | No | Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`. | -| `--select-output ` | No | Return blockName.field values (e.g. agent_1.content) — in blockOutputs on a sync run, or from the streamed result with --follow; missing fields are omitted. Not available with --async (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--select-output ` | No | Return blockName.field values (e.g. agent_1.content), or childWorkflowId.blockName.field for a child workflow (applies to every invocation) — in blockOutputs on a sync run, or from the streamed result with --follow; missing fields are omitted. Not available with --async (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | diff --git a/apps/docs/content/docs/cli/workflows.mdx b/apps/docs/content/docs/cli/workflows.mdx index 469a028223a..ad55d2bde91 100644 --- a/apps/docs/content/docs/cli/workflows.mdx +++ b/apps/docs/content/docs/cli/workflows.mdx @@ -533,7 +533,7 @@ sim workflows run [options] | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | | `--execution-timeout-seconds ` | No | Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`. | -| `--select-output ` | No | Return blockName.field values (e.g. agent_1.content) — in blockOutputs on a sync run, or from the streamed result with --follow; missing fields are omitted. Not available with --async (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--select-output ` | No | Return blockName.field values (e.g. agent_1.content), or childWorkflowId.blockName.field for a child workflow (applies to every invocation) — in blockOutputs on a sync run, or from the streamed result with --follow; missing fields are omitted. Not available with --async (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 4146cca8006..de08ae7ec20 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -10989,6 +10989,10 @@ "StoredChatDeploymentOutputConfig": { "type": "object", "properties": { + "workflowId": { + "description": "Child workflow containing the selected block. Omitted for the deployed workflow.", + "type": "string" + }, "blockId": { "type": "string", "description": "Block whose output the chat streams." @@ -11298,6 +11302,11 @@ "ChatDeploymentOutputConfig": { "type": "object", "properties": { + "workflowId": { + "description": "Child workflow containing the selected block. Omit for the deployed workflow.", + "type": "string", + "minLength": 1 + }, "blockId": { "type": "string", "minLength": 1, @@ -11719,7 +11728,7 @@ "type": "boolean" }, "selectedOutputs": { - "description": "Block output references to include in the response, as `blockId`, `blockId.path`, or `BlockName.path` (resolved against the workflow state being run). On a sync request the named outputs come back in `blockOutputs`, keyed by these selector strings; on a stream they shape the streamed envelope. Selectors that resolve to no block or no value are omitted. Rejected when `async` is true — a queued run has produced nothing to select; narrow the finished run via the run resource instead.", + "description": "Block output references to include in the response. Use `.` for the executed workflow or `..` for a child workflow; block names are normalized workflow reference names, and selecting a child workflow applies to every invocation of it. On a sync request the named outputs come back in `blockOutputs`, keyed by these selector strings; on a stream they shape the streamed envelope. Selectors that resolve to no block or no value are omitted. Rejected when `async` is true — a queued run has produced nothing to select; narrow the finished run via the run resource instead.", "maxItems": 100, "type": "array", "items": { @@ -12412,7 +12421,7 @@ "description": "Whether a paused execution was cancelled." }, "reason": { - "description": "Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` is the success value. `already_cancelled`, `already_completed`, and `already_failed` mean the run had already reached that terminal state, so nothing was cancelled and `durablyRecorded` is false. `redis_unavailable` and `redis_write_failed` mean the distributed cancellation signal was not written, so an already-running execution may not observe the cancellation. `paused_event_publish_failed` and `paused_database_cancel_failed` name the failing step for a paused run.", + "description": "Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` and `queue_cancelled` are successful cancellation values. `already_cancelled`, `already_completed`, and `already_failed` mean the run had already reached that terminal state, so nothing was cancelled and `durablyRecorded` is false. The remaining values identify a degraded or incomplete cancellation step.", "type": "string", "enum": [ "recorded", @@ -12422,7 +12431,10 @@ "redis_unavailable", "redis_write_failed", "paused_event_publish_failed", - "paused_database_cancel_failed" + "paused_database_cancel_failed", + "queue_cancelled", + "active_resume_signal_failed", + "cancellation_not_finalized" ] } }, diff --git a/apps/sim/lib/mothership/tool-executor/register-handlers.ts b/apps/sim/lib/mothership/tool-executor/register-handlers.ts index 5829768debb..7dff8dd883c 100644 --- a/apps/sim/lib/mothership/tool-executor/register-handlers.ts +++ b/apps/sim/lib/mothership/tool-executor/register-handlers.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { + CancelWorkflowRun, RunBlock, RunFromBlock, RunWorkflow, @@ -11,6 +12,7 @@ import { executeFunctionExecute } from '../tools/handlers/function-execute' import { executeRunCode } from '../tools/handlers/run-code' import { executeSimCli } from '../tools/handlers/sim-cli' import { + executeCancelWorkflowRun, executeRunBlock, executeRunFromBlock, executeRunWorkflow, @@ -47,6 +49,7 @@ function h(fn: (params: any, context: any) => Promise): ToolHandler { */ function buildHandlerMap(): Record { return { + [CancelWorkflowRun.id]: h(executeCancelWorkflowRun), [RunWorkflow.id]: h(executeRunWorkflow), [RunWorkflowUntilBlock.id]: h(executeRunWorkflowUntilBlock), [RunFromBlock.id]: h(executeRunFromBlock), diff --git a/apps/sim/lib/mothership/tool-executor/router.test.ts b/apps/sim/lib/mothership/tool-executor/router.test.ts index 83c5bf14b6c..cda23cc9f95 100644 --- a/apps/sim/lib/mothership/tool-executor/router.test.ts +++ b/apps/sim/lib/mothership/tool-executor/router.test.ts @@ -25,39 +25,39 @@ const { stubHandlerModule } = vi.hoisted(() => ({ ), })) -vi.mock('@/lib/copilot/tools/handlers/deployment/custom-block', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/deployment/deploy', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/deployment/manage', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/function-execute', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/integration-tools', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/management/connect-slack-bot', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/management/manage-credential', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/management/manage-custom-tool', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/management/manage-mcp-tool', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/management/manage-sandbox', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/management/manage-skill', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/materialize-file', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/oauth', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/resources', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/restore-resource', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/run-code', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/vfs', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/vfs-mutate', stubHandlerModule) -vi.mock('@/lib/copilot/tools/handlers/workflow/queries', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/deployment/custom-block', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/deployment/deploy', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/deployment/manage', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/function-execute', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/integration-tools', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/management/connect-slack-bot', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/management/manage-credential', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/management/manage-custom-tool', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/management/manage-mcp-tool', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/management/manage-sandbox', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/management/manage-skill', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/materialize-file', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/oauth', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/resources', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/restore-resource', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/run-code', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/vfs', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/vfs-mutate', stubHandlerModule) +vi.mock('@/lib/mothership/tools/handlers/workflow/queries', stubHandlerModule) /** Server-router tools are appended to the map from their own registry, which this test does not cover. */ -vi.mock('@/lib/copilot/tools/server/router', () => ({ getRegisteredServerToolNames: () => [] })) +vi.mock('@/lib/mothership/tools/server/router', () => ({ getRegisteredServerToolNames: () => [] })) -import { hasHandler } from '@/lib/copilot/tool-executor/executor' -import { buildHandlerMap } from '@/lib/copilot/tool-executor/handler-map' -import { ensureHandlersRegistered } from '@/lib/copilot/tool-executor/register-handlers' +import { hasHandler } from '@/lib/mothership/tool-executor/executor' +import { buildHandlerMap } from '@/lib/mothership/tool-executor/handler-map' +import { ensureHandlersRegistered } from '@/lib/mothership/tool-executor/register-handlers' import { getToolEntry, isSimExecuted, toolRequiresApproval, toolRequiresApprovalLane, -} from '@/lib/copilot/tool-executor/router' -import { executeCancelWorkflowRun } from '@/lib/copilot/tools/handlers/workflow/mutations' +} from '@/lib/mothership/tool-executor/router' +import { executeCancelWorkflowRun } from '@/lib/mothership/tools/handlers/workflow/mutations' describe('workflow-run cancellation tool routing', () => { it('routes cancellation through Sim with write permission and explicit approval', () => { diff --git a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts index 962017ef406..917141ac1fa 100644 --- a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts @@ -2,9 +2,9 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { WorkflowRunAlreadyTerminalError } from '@/lib/execution/workflow-run-already-terminal-error' import type { ExecutionContext } from '@/lib/mothership/request/types' import type { CancelWorkflowRunParams } from '@/lib/mothership/tools/handlers/param-types' -import { WorkflowRunAlreadyTerminalError } from '@/lib/execution/workflow-run-already-terminal-error' const { mocks } = vi.hoisted(() => ({ mocks: { diff --git a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts index 849a85f7ddf..ba22e7439d6 100644 --- a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts @@ -14,6 +14,7 @@ import { type ToolEffectPhase, } from '@/lib/mothership/tool-executor/types' import type { + CancelWorkflowRunParams, CreateWorkflowParams, GenerateApiKeyParams, MoveWorkflowParams, @@ -28,7 +29,6 @@ import type { } from '@/lib/mothership/tools/handlers/param-types' import { requireCopilotWorkspace } from '@/lib/mothership/tools/server/workspace-scope' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/mothership/vfs/path-utils' -import { PlatformEvents } from '@/lib/core/telemetry' import { cancelWorkflowRun } from '@/lib/workflows/application/cancel-run' import { createWorkflow } from '@/lib/workflows/application/create-workflow' import { moveWorkflowsBulk } from '@/lib/workflows/application/move-workflows-bulk' @@ -283,6 +283,45 @@ export async function executeRunWorkflow( } } +export async function executeCancelWorkflowRun( + params: CancelWorkflowRunParams, + context: ExecutionContext +): Promise { + try { + const executionId = resolveInputFromExecutionId(params.executionId) + if (!executionId) { + return { success: false, error: 'executionId is required' } + } + + assertWorkflowMutationNotAborted( + context, + 'Request aborted before workflow run cancellation could be applied.' + ) + const result = await executeCopilotWorkflowUseCase(context, cancelWorkflowRun, { + runId: executionId, + ...(context.abortSignal ? { abortSignal: context.abortSignal } : {}), + }) + + return { + success: result.success, + output: { + workflowId: result.workflowId, + executionId: result.executionId, + durablyRecorded: result.durablyRecorded, + locallyAborted: result.locallyAborted, + pausedCancelled: result.pausedCancelled, + reason: result.reason, + }, + error: result.success ? undefined : 'Workflow run cancellation could not be completed', + } + } catch (error) { + return { + success: false, + error: messageForCopilotWorkflowError(error, 'Failed to cancel workflow run'), + } + } +} + export async function executeSetGlobalWorkflowVariables( params: SetGlobalWorkflowVariablesParams, context: ExecutionContext diff --git a/apps/sim/lib/mothership/tools/server/files/doc-compiled-store.test.ts b/apps/sim/lib/mothership/tools/server/files/doc-compiled-store.test.ts index 5833f288fa0..778d892bab5 100644 --- a/apps/sim/lib/mothership/tools/server/files/doc-compiled-store.test.ts +++ b/apps/sim/lib/mothership/tools/server/files/doc-compiled-store.test.ts @@ -13,12 +13,12 @@ vi.mock('@/lib/uploads/core/storage-service', () => ({ uploadFile: mockUploadFile, })) +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { loadCompiledDoc, loadPublishedCompiledDoc, storeCompiledDoc, } from '@/lib/mothership/tools/server/files/doc-compiled-store' -import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' describe('compiled document publication', () => { diff --git a/apps/sim/lib/webhooks/slack-execution-stream.ts b/apps/sim/lib/webhooks/slack-execution-stream.ts index 888996145f3..81b9612b87b 100644 --- a/apps/sim/lib/webhooks/slack-execution-stream.ts +++ b/apps/sim/lib/webhooks/slack-execution-stream.ts @@ -1,8 +1,8 @@ import { getErrorMessage } from '@sim/utils/errors' import { getValueAtPath, isRecordLike } from '@sim/utils/object' import { truncate } from '@sim/utils/string' -import { getToolDisplayTitle } from '@/lib/copilot/tools/tool-display' import type { LoggingSession } from '@/lib/logs/execution/logging-session' +import { getToolDisplayTitle } from '@/lib/mothership/tools/tool-display' import { getSlackBotCredential } from '@/lib/oauth/credential-service' import { appendSlackAgentStream, diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts index 81b80966e3f..c7a6d6b09d8 100644 --- a/apps/sim/lib/workflows/executor/execute-service.ts +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -2,7 +2,7 @@ import type { WorkflowExecutionPrincipal } from '@sim/auth/principal' import type { workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' +import { generateId, isValidUuid } from '@sim/utils/id' import type { BlockState } from '@sim/workflow-types/workflow' import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' @@ -747,7 +747,7 @@ export async function executeWorkflowService( status: 'failed', aborted: 'timeout', output: compactTimeoutOutput, - blockOutputs: await compactServiceOutput(pickRunBlockOutputs(selectedOutputs, workflowBlocks, result.logs), compactionContext), + blockOutputs: await compactServiceOutput(await pickRunBlockOutputs(selectedOutputs, workflowBlocks, result.logs), compactionContext), error: { message: timeoutErrorMessage, code: 'TIMEOUT' }, resolvedSecretTraceProvenance: result.executionState?.resolvedSecretTraceProvenance, hasResponseBlock: false, @@ -793,7 +793,7 @@ export async function executeWorkflowService( status, aborted: null, output: compactOutput, - blockOutputs: await compactServiceOutput(pickRunBlockOutputs(selectedOutputs, workflowBlocks, result.logs), compactionContext), + blockOutputs: await compactServiceOutput(await pickRunBlockOutputs(selectedOutputs, workflowBlocks, result.logs), compactionContext), error: status === 'failed' || (status === 'cancelled' && result.error) ? classifyExecutionError(result.error ? new Error(result.error) : undefined, result) @@ -848,7 +848,7 @@ export async function executeWorkflowService( executionResult.output, compactionContext ) - compactErrorBlockOutputs = await compactServiceOutput(pickRunBlockOutputs(selectedOutputs, workflowBlocks, executionResult.logs), compactionContext) + compactErrorBlockOutputs = await compactServiceOutput(await pickRunBlockOutputs(selectedOutputs, workflowBlocks, executionResult.logs), compactionContext) } catch (compactError) { if ( compactError instanceof PayloadSizeLimitError && @@ -952,11 +952,11 @@ function resolveOutputPath(value: unknown, path: string[]): unknown { * follow. The last log per block wins, so a block inside a loop reports its * final iteration's output. */ -export function pickRunBlockOutputs( +export async function pickRunBlockOutputs( selectedOutputs: string[] | undefined, blocks: Record, logs: BlockLog[] | undefined -): Record | null { +): Promise | null> { if (!selectedOutputs || selectedOutputs.length === 0) return null const outputByBlockId = new Map() @@ -964,7 +964,7 @@ export function pickRunBlockOutputs( if (log.output !== undefined) outputByBlockId.set(log.blockId, log.output) } - const resolved = resolveOutputIds(selectedOutputs, blocks) ?? [] + const resolved = (await resolveOutputIds(selectedOutputs, blocks)) ?? [] const picked: Record = {} for (let i = 0; i < selectedOutputs.length; i++) { const selector = selectedOutputs[i] diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 1bc7dab4f24..0c44a3f7289 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -1049,6 +1049,9 @@ type CancelWorkflowRunResponseRef0 = { | 'redis_write_failed' | 'paused_event_publish_failed' | 'paused_database_cancel_failed' + | 'queue_cancelled' + | 'active_resume_signal_failed' + | 'cancellation_not_finalized' } export type CancelWorkflowRunResponse = { @@ -4507,6 +4510,7 @@ type GetLogResponseRef2 = { endedAt: string | null totalDurationMs: number | null files: Array | null + executedByEmail: string | null workflow: { id: string | null name: string @@ -5248,6 +5252,7 @@ type GetWorkflowChatDeploymentResponseRef0 = { } type GetWorkflowChatDeploymentResponseRef1 = { + workflowId?: string blockId: string path: string } @@ -5995,6 +6000,7 @@ type ListChatDeploymentsResponseRef0 = { } type ListChatDeploymentsResponseRef1 = { + workflowId?: string blockId: string path: string } @@ -8800,6 +8806,7 @@ type ReplaceWorkflowChatDeploymentBodyRef0 = { } type ReplaceWorkflowChatDeploymentBodyRef1 = { + workflowId?: string blockId: string path: string } @@ -8824,6 +8831,7 @@ type ReplaceWorkflowChatDeploymentResponseRef0 = { } type ReplaceWorkflowChatDeploymentResponseRef1 = { + workflowId?: string blockId: string path: string } @@ -13084,7 +13092,7 @@ export const V2_OPERATIONS = { selectedOutputs: { kind: 'array', describe: - 'Block output references to include in the response, as `blockId`, `blockId.path`, or `BlockName.path` (resolved against the workflow state being run). On a sync request the named outputs come back in `blockOutputs`, keyed by these selector strings; on a stream they shape the streamed envelope. Selectors that resolve to no block or no value are omitted. Rejected when `async` is true — a queued run has produced nothing to select; narrow the finished run via the run resource instead.', + 'Block output references to include in the response. Use `.` for the executed workflow or `..` for a child workflow; block names are normalized workflow reference names, and selecting a child workflow applies to every invocation of it. On a sync request the named outputs come back in `blockOutputs`, keyed by these selector strings; on a stream they shape the streamed envelope. Selectors that resolve to no block or no value are omitted. Rejected when `async` is true — a queued run has produced nothing to select; narrow the finished run via the run resource instead.', }, includeThinking: { kind: 'boolean', diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index af6dad97f18..ba02d7a1dcc 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -1,5 +1,5 @@ { - "generatedFrom": "module graphs of every entry under: app/workspace, app/api/v2/blocks, app/api/v2/tools, app/api/v2/connector-types, lib/catalog/projection, lib/copilot/tools/server/blocks", + "generatedFrom": "module graphs of every entry under: app/workspace, app/api/v2/blocks, app/api/v2/tools, app/api/v2/connector-types, lib/catalog/projection", "tolerance": { "modules": 25, "percent": 2 @@ -695,19 +695,6 @@ "lib/catalog/projection/tool.ts": { "modules": 8, "gateways": {} - }, - "lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts": { - "modules": 1077, - "gateways": { - "apps/sim/triggers/index.ts": 524, - "apps/sim/triggers/registry.ts": 522, - "apps/sim/blocks/registry.ts": 398, - "apps/sim/blocks/registry-maps.ts": 396, - "apps/sim/lib/permission-groups/config-scope.server.ts": 95, - "apps/sim/lib/permission-groups/resolve.server.ts": 93, - "apps/sim/lib/billing/core/subscription.ts": 87, - "apps/sim/components/emails/index.ts": 55 - } } } } diff --git a/scripts/check-tool-registry-boundary.ts b/scripts/check-tool-registry-boundary.ts index 61c21ef3356..41467280b63 100644 --- a/scripts/check-tool-registry-boundary.ts +++ b/scripts/check-tool-registry-boundary.ts @@ -159,17 +159,6 @@ const ENTRY_SOURCES: readonly EntrySource[] = [ matches: isSourceModule, reason: 'the shared catalog projection, which every catalog surface imports', }, - { - /** - * The Copilot block-metadata tool: the reason the shared projection exists. - * Cutting its registry edge took it from ~6,756 modules to ~1,321, and - * nothing was holding that win — the tool appeared in no guarded subtree, so - * a single `getTool` import could have spent all of it silently. - */ - root: 'lib/mothership/tools/server/blocks', - matches: (filename) => filename === 'get-blocks-metadata-tool.ts', - reason: 'the Copilot block-metadata tool, which reads block and tool metadata only', - }, ] function collectEntries( From 73a69d58aa0f187c65f32a2ef18b3881f031957f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 20:49:21 +0530 Subject: [PATCH 049/306] Fix dev CI: selector wording matches the contract test; trigger CLI pin matches packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publish-npm failed on the rebase-merged --select-output description (blockName.field vs the contract test's blockName.path — .path is the OpenAPI term, so the flag now speaks it too). The Trigger.dev dev deploy failed on a stale CLI pin (4.5.7) meeting staging's 4.5.12 packages — latent on staging itself, whose lanes never run this job. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- packages/sim-cli/src/contract/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index e94744f7680..bf50610b050 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1655,7 +1655,7 @@ export const CLI_CONTRACT: CliContract = { name: 'select-output', list: true, describe: - 'Return blockName.field values (e.g. agent_1.content), or childWorkflowId.blockName.field for a child workflow (applies to every invocation) — in blockOutputs on a sync run, or from the streamed result with --follow; missing fields are omitted. Not available with --async', + 'Return blockName.path values (e.g. agent_1.content), or childWorkflowId.blockName.path for a child workflow (applies to every invocation) — in blockOutputs on a sync run, or from the streamed result with --follow; missing paths are omitted. Not available with --async', }, // SSE, not JSON — the generic client cannot consume it, so the response // encoding is chosen by `--follow`, which `workflow-run-follow.ts` adds to From 962b665a43cbf76e66c76bd332e26b3730017db9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Tue, 1 Sep 2026 20:49:24 +0530 Subject: [PATCH 050/306] Effort picker: ghost ChipDropdown variant, bare labels ChipDropdown gains a ghost trigger variant (bare toolbar pill, hover-only surface, keeps the owned chevron since the label changes with the value) for visual parity with neighboring Chip buttons; the effort picker uses it with start alignment, natural menu width, and effort labels trimmed to the bare levels. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w --- .../home/components/user-input/user-input.tsx | 4 ++ apps/sim/stores/mothership-effort/store.ts | 10 ++--- .../chip-dropdown/chip-dropdown.test.tsx | 38 ++++++++++++++----- .../chip-dropdown/chip-dropdown.tsx | 31 ++++++++++++--- 4 files changed, 64 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index fe473fcbdb1..78122c7c892 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -619,9 +619,13 @@ const UserInputImpl = forwardRef(function UserI Skills setEffort(value as MothershipEffort)} /> diff --git a/apps/sim/stores/mothership-effort/store.ts b/apps/sim/stores/mothership-effort/store.ts index 93b74cc2578..bba220e7057 100644 --- a/apps/sim/stores/mothership-effort/store.ts +++ b/apps/sim/stores/mothership-effort/store.ts @@ -5,11 +5,11 @@ import { devtools, persist } from 'zustand/middleware' export type MothershipEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' export const MOTHERSHIP_EFFORT_OPTIONS: Array<{ value: MothershipEffort; label: string }> = [ - { value: 'low', label: 'Low effort' }, - { value: 'medium', label: 'Medium effort' }, - { value: 'high', label: 'High effort' }, - { value: 'xhigh', label: 'X-high effort' }, - { value: 'max', label: 'Max effort' }, + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'X-high' }, + { value: 'max', label: 'Max' }, ] interface MothershipEffortState { diff --git a/packages/emcn/src/components/chip-dropdown/chip-dropdown.test.tsx b/packages/emcn/src/components/chip-dropdown/chip-dropdown.test.tsx index 0e08a1bb5f9..f247ea4909f 100644 --- a/packages/emcn/src/components/chip-dropdown/chip-dropdown.test.tsx +++ b/packages/emcn/src/components/chip-dropdown/chip-dropdown.test.tsx @@ -10,8 +10,10 @@ let root: Root | null = null let container: HTMLDivElement | null = null function mount( - fullWidth: boolean, - aria: Pick = {} + props: Pick< + ChipDropdownProps, + 'fullWidth' | 'variant' | 'aria-required' | 'aria-invalid' | 'aria-describedby' + > = {} ): HTMLButtonElement { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') @@ -22,9 +24,8 @@ function mount( ) ) @@ -43,15 +44,15 @@ afterEach(() => { describe('ChipDropdown', () => { it('fills its container when fullWidth is enabled', () => { - expect(mount(true).className).toContain('w-full') + expect(mount({ fullWidth: true }).className).toContain('w-full') }) it('keeps its intrinsic width by default', () => { - expect(mount(false).className).not.toContain('w-full') + expect(mount({ fullWidth: false }).className).not.toContain('w-full') }) it('renders its text trigger through the fade-only overflow primitive', () => { - const label = mount(true).querySelector('[data-overflow-text]') + const label = mount({ fullWidth: true }).querySelector('[data-overflow-text]') expect(label?.textContent).toBe('Workflow') expect(label?.className).toContain('text-clip') @@ -61,7 +62,8 @@ describe('ChipDropdown', () => { it.each([true, 'true'] as const)( 'announces %s field states alongside the supplied error description', (state) => { - const trigger = mount(true, { + const trigger = mount({ + fullWidth: true, 'aria-required': state, 'aria-invalid': state, 'aria-describedby': 'field-error', @@ -88,7 +90,8 @@ describe('ChipDropdown', () => { it.each([false, 'false'] as const)( 'keeps the original description when field states are %s', (state) => { - const trigger = mount(true, { + const trigger = mount({ + fullWidth: true, 'aria-required': state, 'aria-invalid': state, 'aria-describedby': 'field-hint', @@ -98,4 +101,21 @@ describe('ChipDropdown', () => { expect(container?.textContent).toBe('Workflow') } ) + + it('renders the filled trigger with the border by default', () => { + const trigger = mount() + expect(trigger.className).toContain('border') + expect(trigger.className).toContain('bg-[var(--surface-5)]') + }) + + it('renders the ghost trigger as the bare pill — no border, no fill, icon-tinted label', () => { + const trigger = mount({ variant: 'ghost' }) + expect(trigger.className).not.toContain('border') + expect(trigger.className).not.toContain('bg-[var(--surface-5)]') + expect(trigger.className).toContain('hover-hover:bg-[var(--surface-hover)]') + + const label = trigger.querySelector('[data-overflow-text]') + expect(label?.className).toContain('text-[var(--text-icon)]') + expect(label?.className).not.toContain('text-[var(--text-body)]') + }) }) diff --git a/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx b/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx index 65b9a922b68..6a048175435 100644 --- a/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx +++ b/packages/emcn/src/components/chip-dropdown/chip-dropdown.tsx @@ -49,7 +49,18 @@ interface ChipDropdownOption { /** * Trigger + menu chrome props shared by both selection modes. */ -interface ChipDropdownBaseProps extends VariantProps { +interface ChipDropdownBaseProps extends Omit, 'variant'> { + /** + * Trigger chrome. `filled` (default) is the bordered field chip; `ghost` is + * the bare toolbar pill — no border, hover-only surface, label and chevron + * both `--text-icon` — for visual parity with neighboring icon-toolbar + * buttons (mirrors {@link ChipDatePicker}'s `ghost`). + * Unlike the date picker's, a ghost dropdown keeps the owned chevron: its + * label changes with the selected value, so the chevron is the one stable + * cue that this is a picker. Other `chipVariants` values pass through + * (e.g. `primary` for an inverse call-to-action trigger). + */ + variant?: VariantProps['variant'] | 'ghost' /** Options to render in the menu. */ options: ReadonlyArray /** Shown in the trigger when nothing is selected. */ @@ -148,7 +159,9 @@ type ChipDropdownProps = ChipDropdownSingleProps | ChipDropdownMultiProps * `multiple` mode it toggles values, keeps the menu open across selections, * and optionally renders an "all" reset row and a search field. * - * The trigger reuses `chipVariants` for visual parity with `Chip`. The label + * The trigger reuses `chipVariants` for visual parity with `Chip` — the + * default `filled` variant with the trigger border, or the bare toolbar pill + * via `variant='ghost'` (see the prop doc). The label * is `flex-1`, so the trailing chevron is pushed flush right. The chevron is * owned by the component and rendered at `size-[14px]` (matching the * workspace-header chevron) — there is intentionally no `rightIcon` prop. The @@ -240,8 +253,9 @@ const ChipDropdown = forwardRef( ) }, [options, searchable, search]) + const isGhost = variant === 'ghost' const isInverse = variant === 'primary' || variant === 'destructive' - const hasTriggerBorder = variant !== 'primary' && variant !== 'destructive' + const hasTriggerBorder = !isGhost && !isInverse let displayLabel: ReactNode if (isMultiple) { @@ -273,8 +287,15 @@ const ChipDropdown = forwardRef( * On intrinsic-width triggers (`inline-flex` with no parent constraint) the * container is sized to max-content, so `grow` has no leftover space to * consume and the layout collapses to the natural `gap-2` between items. + * + * The ghost pill's label is `--text-icon` (matching its chevron), not + * `--text-body`: it sits in toolbars beside icon-only buttons, and a + * body-colored label would read louder than every control around it. */ - const labelClass = cn('flex-1 text-sm', !isInverse && 'text-[var(--text-body)]') + const labelClass = cn( + 'flex-1 text-sm', + !isInverse && (isGhost ? 'text-[var(--text-icon)]' : 'text-[var(--text-body)]') + ) const triggerLabelClass = cn( labelClass, @@ -347,7 +368,7 @@ const ChipDropdown = forwardRef( aria-labelledby={ariaLabelledBy} aria-describedby={describedBy || undefined} className={cn( - chipVariants({ variant, shape, active, fullWidth }), + chipVariants({ variant: isGhost ? 'default' : variant, shape, active, fullWidth }), hasTriggerBorder && TRIGGER_BORDER_CLASS, className )} From 9884765c0afe48863c57084bd89d8334b2119701 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 08:55:20 +0530 Subject: [PATCH 051/306] =?UTF-8?q?Phase=20A0:=20sim=20half=20of=20the=20m?= =?UTF-8?q?ship=E2=86=94CLI=20translation=20layer=20=E2=80=94=20primitives?= =?UTF-8?q?=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/mothership/agent-cli/ executes the worker typed AgentCliRequest and nothing more: request-schema validates it, run-cli runs the embedded CLI (with the @token sandbox reads), pipeline applies already-parsed grep stages, sink lands stdout on the machine, and engines/ holds the augmentation engines keyed by the worker canonical names. The sim-side matcher, flag parser, pipe splitter, and help merge are deleted — the grammar lives in the mothership worker now, which is closed source and the one place a command meaning is decided. The sim_cli handler becomes a thin adapter: validate the request, execute, return the raw result. It refuses a frame without the typed request rather than falling back to re-parsing argv, so the worker must deploy first. scripts/check-agent-cli-boundary.ts (bun run check:agent-cli-boundary) holds the line: primitives-only export surface, no pipe/flag/command-name grammar under lib/mothership, runEmbeddedCli reachable only through the primitive runner, and no agent imports in the public CLI package. lib/mothership/generated/agent-cli.ts is the synced contract copy. https://claude.ai/code/session_01HFVhPDtRZuCgupPco633w8 --- .../lib/mothership/agent-cli/engines.test.ts | 197 ++++++++++ .../commands => agent-cli/engines}/deps.ts | 14 +- .../engines}/files-grep.ts | 9 +- .../commands => agent-cli/engines}/grep.ts | 16 +- .../lib/mothership/agent-cli/engines/index.ts | 51 +++ .../commands => agent-cli/engines}/lint.ts | 11 +- .../commands => agent-cli/engines}/query.ts | 11 +- .../commands => agent-cli/engines}/trace.ts | 9 +- .../engines}/workflow-views.ts | 14 +- apps/sim/lib/mothership/agent-cli/index.ts | 59 +++ .../lib/mothership/agent-cli/pipeline.test.ts | 76 ++++ apps/sim/lib/mothership/agent-cli/pipeline.ts | 45 +++ .../mothership/agent-cli/request-schema.ts | 33 ++ apps/sim/lib/mothership/agent-cli/run-cli.ts | 31 ++ apps/sim/lib/mothership/agent-cli/sink.ts | 38 ++ apps/sim/lib/mothership/agent-cli/types.ts | 50 +++ .../sim/lib/mothership/generated/agent-cli.ts | 68 ++++ .../handlers/agent-cli/agent-cli.test.ts | 349 ------------------ .../tools/handlers/agent-cli/index.ts | 123 ------ .../tools/handlers/agent-cli/types.ts | 54 --- .../tools/handlers/sim-cli-bridge.test.ts | 55 ++- .../tools/handlers/sim-cli-pipe.test.ts | 123 ------ .../mothership/tools/handlers/sim-cli-pipe.ts | 132 ------- .../lib/mothership/tools/handlers/sim-cli.ts | 162 ++------ package.json | 3 +- scripts/check-agent-cli-boundary.ts | 111 ++++++ 26 files changed, 860 insertions(+), 984 deletions(-) create mode 100644 apps/sim/lib/mothership/agent-cli/engines.test.ts rename apps/sim/lib/mothership/{tools/handlers/agent-cli/commands => agent-cli/engines}/deps.ts (89%) rename apps/sim/lib/mothership/{tools/handlers/agent-cli/commands => agent-cli/engines}/files-grep.ts (92%) rename apps/sim/lib/mothership/{tools/handlers/agent-cli/commands => agent-cli/engines}/grep.ts (87%) create mode 100644 apps/sim/lib/mothership/agent-cli/engines/index.ts rename apps/sim/lib/mothership/{tools/handlers/agent-cli/commands => agent-cli/engines}/lint.ts (91%) rename apps/sim/lib/mothership/{tools/handlers/agent-cli/commands => agent-cli/engines}/query.ts (94%) rename apps/sim/lib/mothership/{tools/handlers/agent-cli/commands => agent-cli/engines}/trace.ts (93%) rename apps/sim/lib/mothership/{tools/handlers/agent-cli/commands => agent-cli/engines}/workflow-views.ts (84%) create mode 100644 apps/sim/lib/mothership/agent-cli/index.ts create mode 100644 apps/sim/lib/mothership/agent-cli/pipeline.test.ts create mode 100644 apps/sim/lib/mothership/agent-cli/pipeline.ts create mode 100644 apps/sim/lib/mothership/agent-cli/request-schema.ts create mode 100644 apps/sim/lib/mothership/agent-cli/run-cli.ts create mode 100644 apps/sim/lib/mothership/agent-cli/sink.ts create mode 100644 apps/sim/lib/mothership/agent-cli/types.ts create mode 100644 apps/sim/lib/mothership/generated/agent-cli.ts delete mode 100644 apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts delete mode 100644 apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts delete mode 100644 apps/sim/lib/mothership/tools/handlers/agent-cli/types.ts delete mode 100644 apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.test.ts delete mode 100644 apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.ts create mode 100644 scripts/check-agent-cli-boundary.ts diff --git a/apps/sim/lib/mothership/agent-cli/engines.test.ts b/apps/sim/lib/mothership/agent-cli/engines.test.ts new file mode 100644 index 00000000000..83c21172344 --- /dev/null +++ b/apps/sim/lib/mothership/agent-cli/engines.test.ts @@ -0,0 +1,197 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +const { buildWorkflowLintReport } = vi.hoisted(() => ({ + buildWorkflowLintReport: vi.fn().mockResolvedValue({ + sources: ['block-1'], + sinks: ['block-2'], + orphanBlocks: [], + emptyOutgoingPorts: [], + invalidBranchPorts: [], + invalidConnectionTargets: [], + fieldIssues: [ + { + blockId: 'block-2', + blockName: 'Summarize emails', + missingRequiredFields: ['model'], + inactiveModeValues: [], + }, + ], + unresolvedReferences: [], + }), +})) + +vi.mock('@/lib/workflows/editing/lint-report', () => ({ buildWorkflowLintReport })) + +import { runEngine } from '@/lib/mothership/agent-cli/engines' +import type { AgentCliRuntime } from '@/lib/mothership/agent-cli/types' + +const WORKFLOW_STATE = { + blocks: { + 'block-1': { type: 'starter', name: 'Start', enabled: true }, + 'block-2': { type: 'agent', name: 'Summarize emails', enabled: true }, + }, + edges: [{ source: 'block-1', target: 'block-2', sourceHandle: 'source', id: 'edge-1' }], + variables: { apiBase: 'https://api.example.com' }, +} + +function runtimeWith(responses: Record): AgentCliRuntime { + return { + workspaceId: 'ws-1', + userId: 'user-1', + client: { + request: async (path: string): Promise => { + const hit = responses[path] + if (hit === undefined) throw new Error(`Unexpected request: ${path}`) + return hit as T + }, + }, + } +} + +const EXPORT_PATH = '/api/v2/workflows/wf-1/export' +const exportResponse = { data: { state: WORKFLOW_STATE } } + +describe('workflow views', () => { + it('projects just the blocks', async () => { + const result = await runEngine( + 'workflow blocks', + ['wf-1'], + runtimeWith({ [EXPORT_PATH]: exportResponse }), + {} + ) + expect(result.exitCode).toBe(0) + const blocks = JSON.parse(result.stdout) + expect(blocks).toEqual([ + { id: 'block-1', type: 'starter', name: 'Start', enabled: true }, + { id: 'block-2', type: 'agent', name: 'Summarize emails', enabled: true }, + ]) + }) + + it('projects just the edges', async () => { + const result = await runEngine( + 'workflow edges', + ['wf-1'], + runtimeWith({ [EXPORT_PATH]: exportResponse }), + {} + ) + expect(result.exitCode).toBe(0) + expect(JSON.parse(result.stdout)).toEqual([ + { source: 'block-1', target: 'block-2', sourceHandle: 'source' }, + ]) + }) + + it('fails usefully without a workflow id', async () => { + const result = await runEngine('workflow blocks', [], runtimeWith({}), {}) + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('Usage:') + }) +}) + +describe('files grep', () => { + const FILES_LIST = { + data: [ + { id: 'f1', name: 'report.md', folderPath: 'docs' }, + { id: 'f2', name: 'logo.png', folderPath: '' }, + ], + nextCursor: null, + } + const readText = (text: string, degraded = false) => ({ + data: { text, degraded }, + }) + + it('greps file contents with line numbers, skipping non-text files', async () => { + const result = await runEngine( + 'files grep', + ['quarterly'], + runtimeWith({ + '/api/v2/files': FILES_LIST, + '/api/v2/files/f1/text': readText('# Report\nQuarterly revenue was up.\n'), + '/api/v2/files/f2/text': readText('', true), + }), + {} + ) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('docs/report.md:2: Quarterly revenue was up.') + expect(result.stdout).not.toContain('logo.png') + }) + + it('filters by folder prefix', async () => { + const result = await runEngine( + 'files grep', + ['Quarterly', 'other'], + runtimeWith({ '/api/v2/files': FILES_LIST }), + {} + ) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('No matches') + }) +}) + +describe('workflow grep', () => { + it('reports matches as path: value lines', async () => { + const result = await runEngine( + 'workflow grep', + ['wf-1', 'Summarize'], + runtimeWith({ [EXPORT_PATH]: exportResponse }), + {} + ) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('.blocks.block-2.name: Summarize emails') + }) + + it('falls back to literal search on an invalid regex', async () => { + const result = await runEngine( + 'workflow grep', + ['wf-1', 'api.example.com['], + runtimeWith({ [EXPORT_PATH]: exportResponse }), + {} + ) + expect(result.exitCode).toBe(0) + expect(result.stdout).toBe('No matches.') + }) + + it('searches across all workspace workflows', async () => { + const result = await runEngine( + 'workflows grep', + ['Summarize'], + runtimeWith({ + '/api/v2/workflows': { + data: [{ id: 'wf-1', name: 'Email digest' }], + nextCursor: null, + }, + [EXPORT_PATH]: exportResponse, + }), + {} + ) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('Email digest (wf-1).blocks.block-2.name: Summarize emails') + }) + + it('lints a workflow through the shared engine with the caller scoped as subject', async () => { + const result = await runEngine( + 'workflow lint', + ['wf-1'], + runtimeWith({ [EXPORT_PATH]: exportResponse }), + {} + ) + expect(result.stderr).toBe('') + expect(result.exitCode).toBe(0) + const report = JSON.parse(result.stdout) + expect(report.fieldIssues).toHaveLength(1) + expect(report.summary.length).toBeGreaterThan(0) + expect(buildWorkflowLintReport).toHaveBeenCalledWith(expect.anything(), { + workflowId: 'wf-1', + workspaceId: 'ws-1', + subjectUserId: 'user-1', + }) + }) + + it('surfaces execution errors as a failed result, never a throw', async () => { + const result = await runEngine('workflow grep', ['wf-missing', 'x'], runtimeWith({}), {}) + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('Unexpected request') + }) +}) diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/deps.ts b/apps/sim/lib/mothership/agent-cli/engines/deps.ts similarity index 89% rename from apps/sim/lib/mothership/tools/handlers/agent-cli/commands/deps.ts rename to apps/sim/lib/mothership/agent-cli/engines/deps.ts index 989153a17ea..f8fe34a83a6 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/deps.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/deps.ts @@ -1,9 +1,5 @@ -import { fetchWorkflowState } from '@/lib/mothership/tools/handlers/agent-cli/commands/workflow-views' -import { - type AgentCliCommand, - agentCliFail, - agentCliOk, -} from '@/lib/mothership/tools/handlers/agent-cli/types' +import { fetchWorkflowState } from '@/lib/mothership/agent-cli/engines/workflow-views' +import { type AgentCliEngine, agentCliFail, agentCliOk } from '@/lib/mothership/agent-cli/types' import { normalizeName, SPECIAL_REFERENCE_PREFIXES } from '@/executor/constants' import { collectStringLeaves, @@ -33,11 +29,7 @@ interface DepView { paths?: string[] } -export const workflowDepsCommand: AgentCliCommand = { - path: ['workflow', 'deps'], - summary: - 'List every reference one block consumes (upstream blocks, variables, env) — what to mock for an isolated run', - usage: 'workflow deps ', +export const workflowDepsCommand: AgentCliEngine = { async execute(rest, runtime) { const [workflowId, blockId] = rest if (!workflowId || !blockId) diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/files-grep.ts b/apps/sim/lib/mothership/agent-cli/engines/files-grep.ts similarity index 92% rename from apps/sim/lib/mothership/tools/handlers/agent-cli/commands/files-grep.ts rename to apps/sim/lib/mothership/agent-cli/engines/files-grep.ts index 1a9a7fedbcf..ab5912ac5bf 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/files-grep.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/files-grep.ts @@ -1,10 +1,10 @@ import type { ListFilesResponse, ReadFileTextResponse } from 'sim/embed' import { - type AgentCliCommand, + type AgentCliEngine, type AgentCliRuntime, agentCliFail, agentCliOk, -} from '@/lib/mothership/tools/handlers/agent-cli/types' +} from '@/lib/mothership/agent-cli/types' /** * Content grep across workspace files (the Go copilot's VFS-wide grep, files @@ -59,10 +59,7 @@ async function listAllFiles(runtime: AgentCliRuntime): Promise [folder-path-prefix]', +export const filesGrepCommand: AgentCliEngine = { async execute(rest, runtime) { const [pattern, folderPrefix] = [rest[0], rest[1]] if (!pattern) return agentCliFail('Usage: sim files grep [folder-path-prefix]') diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/grep.ts b/apps/sim/lib/mothership/agent-cli/engines/grep.ts similarity index 87% rename from apps/sim/lib/mothership/tools/handlers/agent-cli/commands/grep.ts rename to apps/sim/lib/mothership/agent-cli/engines/grep.ts index 884bf11cacf..04039d24b2a 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/grep.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/grep.ts @@ -1,11 +1,11 @@ import type { ListWorkflowsResponse } from 'sim/embed' -import { fetchWorkflowState } from '@/lib/mothership/tools/handlers/agent-cli/commands/workflow-views' +import { fetchWorkflowState } from '@/lib/mothership/agent-cli/engines/workflow-views' import { - type AgentCliCommand, + type AgentCliEngine, type AgentCliRuntime, agentCliFail, agentCliOk, -} from '@/lib/mothership/tools/handlers/agent-cli/types' +} from '@/lib/mothership/agent-cli/types' /** * Structural grep over workflow state. Matches walk the exported JSON tree and @@ -62,10 +62,7 @@ function renderMatches(lines: string[]): string { return capped.join('\n') } -export const workflowGrepCommand: AgentCliCommand = { - path: ['workflow', 'grep'], - summary: 'Search one workflow state (blocks, params, edges) for a pattern', - usage: 'workflow grep ', +export const workflowGrepCommand: AgentCliEngine = { async execute(rest, runtime) { const [workflowId, ...patternParts] = rest const pattern = patternParts.join(' ') @@ -92,10 +89,7 @@ async function listAllWorkflows(runtime: AgentCliRuntime): Promise', +export const workflowsGrepCommand: AgentCliEngine = { async execute(rest, runtime) { const pattern = rest.join(' ') if (!pattern) return agentCliFail('Usage: sim workflows grep ') diff --git a/apps/sim/lib/mothership/agent-cli/engines/index.ts b/apps/sim/lib/mothership/agent-cli/engines/index.ts new file mode 100644 index 00000000000..0e3051b8ad1 --- /dev/null +++ b/apps/sim/lib/mothership/agent-cli/engines/index.ts @@ -0,0 +1,51 @@ +import { getErrorMessage } from '@sim/utils/errors' +import { workflowDepsCommand } from '@/lib/mothership/agent-cli/engines/deps' +import { filesGrepCommand } from '@/lib/mothership/agent-cli/engines/files-grep' +import { workflowGrepCommand, workflowsGrepCommand } from '@/lib/mothership/agent-cli/engines/grep' +import { workflowLintCommand } from '@/lib/mothership/agent-cli/engines/lint' +import { logsQueryCommand } from '@/lib/mothership/agent-cli/engines/query' +import { workflowTraceCommand } from '@/lib/mothership/agent-cli/engines/trace' +import { + workflowBlocksCommand, + workflowEdgesCommand, +} from '@/lib/mothership/agent-cli/engines/workflow-views' +import { + type AgentCliEngine, + type AgentCliFlags, + type AgentCliResult, + type AgentCliRuntime, + agentCliFail, +} from '@/lib/mothership/agent-cli/types' + +/** + * Every augmentation engine, keyed by the worker's canonical command name. The worker's + * registry (grammar/augmentations.ts) and this map must agree exactly — the worker's + * augmentation-drift check reads these keys. + */ +export const AUGMENTATION_ENGINES: Readonly> = { + 'files grep': filesGrepCommand, + 'logs query': logsQueryCommand, + 'workflow blocks': workflowBlocksCommand, + 'workflow deps': workflowDepsCommand, + 'workflow edges': workflowEdgesCommand, + 'workflow grep': workflowGrepCommand, + 'workflow lint': workflowLintCommand, + 'workflow trace': workflowTraceCommand, + 'workflows grep': workflowsGrepCommand, +} + +/** Runs one engine by the worker's name; an engine that throws yields a failed result, never a throw. */ +export async function runEngine( + name: string, + positionals: string[], + runtime: AgentCliRuntime, + flags: AgentCliFlags +): Promise { + const engine = AUGMENTATION_ENGINES[name] + if (!engine) return agentCliFail(`No engine for agent command "${name}".`) + try { + return await engine.execute(positionals, runtime, flags) + } catch (error) { + return agentCliFail(getErrorMessage(error)) + } +} diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/lint.ts b/apps/sim/lib/mothership/agent-cli/engines/lint.ts similarity index 91% rename from apps/sim/lib/mothership/tools/handlers/agent-cli/commands/lint.ts rename to apps/sim/lib/mothership/agent-cli/engines/lint.ts index 3b0c1e608c2..d04a23b55da 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/lint.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/lint.ts @@ -1,11 +1,11 @@ import type { WorkflowState } from '@sim/workflow-types/workflow' -import { fetchWorkflowState } from '@/lib/mothership/tools/handlers/agent-cli/commands/workflow-views' +import { fetchWorkflowState } from '@/lib/mothership/agent-cli/engines/workflow-views' import { - type AgentCliCommand, + type AgentCliEngine, type AgentCliRuntime, agentCliFail, agentCliOk, -} from '@/lib/mothership/tools/handlers/agent-cli/types' +} from '@/lib/mothership/agent-cli/types' import { formatWorkflowLintMessage, hasWorkflowLintIssues } from '@/lib/workflows/editing/lint' import { buildWorkflowLintReport } from '@/lib/workflows/editing/lint-report' import { createEnvVarPattern } from '@/executor/utils/reference-validation' @@ -17,10 +17,7 @@ import { createEnvVarPattern } from '@/executor/utils/reference-validation' * same engine both graph writes publish, so a lint here can never disagree * with what an edit would have reported. */ -export const workflowLintCommand: AgentCliCommand = { - path: ['workflow', 'lint'], - summary: 'Validate one workflow: orphans, unwired ports, missing fields, unresolved references', - usage: 'workflow lint ', +export const workflowLintCommand: AgentCliEngine = { async execute(rest, runtime) { const workflowId = rest[0] if (!workflowId) return agentCliFail('Usage: sim workflow lint ') diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/query.ts b/apps/sim/lib/mothership/agent-cli/engines/query.ts similarity index 94% rename from apps/sim/lib/mothership/tools/handlers/agent-cli/commands/query.ts rename to apps/sim/lib/mothership/agent-cli/engines/query.ts index cfdb6319bed..80ab5af445e 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/query.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/query.ts @@ -1,10 +1,10 @@ import { - type AgentCliCommand, + type AgentCliEngine, type AgentCliFlags, type AgentCliRuntime, agentCliFail, agentCliOk, -} from '@/lib/mothership/tools/handlers/agent-cli/types' +} from '@/lib/mothership/agent-cli/types' import { normalizeName } from '@/executor/constants' /** @@ -67,14 +67,11 @@ function clipValue(value: unknown): unknown { } function stringFlag(flags: AgentCliFlags, name: string): string | undefined { - const value = flags.get(name) + const value = flags[name] return typeof value === 'string' ? value : undefined } -export const logsQueryCommand: AgentCliCommand = { - path: ['logs', 'query'], - summary: 'One row per run: a block field across run history (--block, --field, --where)', - usage: 'logs query --block ', +export const logsQueryCommand: AgentCliEngine = { async execute(rest: string[], runtime: AgentCliRuntime, flags: AgentCliFlags) { const workflowId = rest[0] const blockName = stringFlag(flags, 'block') ?? '' diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/trace.ts b/apps/sim/lib/mothership/agent-cli/engines/trace.ts similarity index 93% rename from apps/sim/lib/mothership/tools/handlers/agent-cli/commands/trace.ts rename to apps/sim/lib/mothership/agent-cli/engines/trace.ts index d7acd35d764..8cc7c60f1f0 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/trace.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/trace.ts @@ -1,9 +1,9 @@ import { - type AgentCliCommand, + type AgentCliEngine, type AgentCliRuntime, agentCliFail, agentCliOk, -} from '@/lib/mothership/tools/handlers/agent-cli/types' +} from '@/lib/mothership/agent-cli/types' /** * `workflow trace ` — a run's trace rolled up for diagnosis, replacing @@ -60,10 +60,7 @@ function percentile(sorted: number[], p: number): number { return sorted[index] ?? 0 } -export const workflowTraceCommand: AgentCliCommand = { - path: ['workflow', 'trace'], - summary: 'Roll up one run trace: per-block timings, per-type stats, real errors, slowest path', - usage: 'workflow trace ', +export const workflowTraceCommand: AgentCliEngine = { async execute(rest, runtime: AgentCliRuntime) { const runId = rest[0] if (!runId) return agentCliFail('Usage: sim workflow trace ') diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/workflow-views.ts b/apps/sim/lib/mothership/agent-cli/engines/workflow-views.ts similarity index 84% rename from apps/sim/lib/mothership/tools/handlers/agent-cli/commands/workflow-views.ts rename to apps/sim/lib/mothership/agent-cli/engines/workflow-views.ts index 57d936b566a..bac501db55f 100644 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/commands/workflow-views.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/workflow-views.ts @@ -1,10 +1,10 @@ import type { ExportWorkflowResponse } from 'sim/embed' import { - type AgentCliCommand, + type AgentCliEngine, type AgentCliRuntime, agentCliFail, agentCliOk, -} from '@/lib/mothership/tools/handlers/agent-cli/types' +} from '@/lib/mothership/agent-cli/types' /** * Projections over one workflow's exported state: just the blocks, or just the @@ -62,10 +62,7 @@ function edgeViews(state: Record): Record[] { }) } -export const workflowBlocksCommand: AgentCliCommand = { - path: ['workflow', 'blocks'], - summary: 'List just the blocks of one workflow (id, type, name, enabled)', - usage: 'workflow blocks ', +export const workflowBlocksCommand: AgentCliEngine = { async execute(rest, runtime) { const workflowId = rest[0] if (!workflowId) return agentCliFail('Usage: sim workflow blocks ') @@ -74,10 +71,7 @@ export const workflowBlocksCommand: AgentCliCommand = { }, } -export const workflowEdgesCommand: AgentCliCommand = { - path: ['workflow', 'edges'], - summary: 'List just the connections of one workflow (source, target, handles)', - usage: 'workflow edges ', +export const workflowEdgesCommand: AgentCliEngine = { async execute(rest, runtime) { const workflowId = rest[0] if (!workflowId) return agentCliFail('Usage: sim workflow edges ') diff --git a/apps/sim/lib/mothership/agent-cli/index.ts b/apps/sim/lib/mothership/agent-cli/index.ts new file mode 100644 index 00000000000..086c751871a --- /dev/null +++ b/apps/sim/lib/mothership/agent-cli/index.ts @@ -0,0 +1,59 @@ +import { createEmbeddedClient, type EmbeddedCliIdentity } from 'sim/embed' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { runEngine } from '@/lib/mothership/agent-cli/engines' +import { applyPipeline } from '@/lib/mothership/agent-cli/pipeline' +import { runCli } from '@/lib/mothership/agent-cli/run-cli' +import { applySink } from '@/lib/mothership/agent-cli/sink' +import { agentCliFail } from '@/lib/mothership/agent-cli/types' +import { mintDelegationToken } from '@/lib/mothership/chat/delegation' +import type { AgentCliRawResult, AgentCliRequest } from '@/lib/mothership/generated/agent-cli' +import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session' + +export interface AgentCliExecutionContext { + workspaceId: string + userId: string + chatId?: string | undefined +} + +/** + * Executes one typed request from the worker: mint the caller's delegated identity, run + * the real CLI or the named engine, apply the pre-parsed pipeline, land the sink. Both + * lanes share one server-minted identity so "the agent is the user" holds without any + * credential crossing to the worker. Success here means only "the invocation ran". + */ +export async function executeAgentCliRequest( + request: AgentCliRequest, + context: AgentCliExecutionContext +): Promise { + const apiKey = await mintDelegationToken({ + workspaceId: context.workspaceId, + userId: context.userId, + }) + if (!apiKey) return agentCliFail('Could not establish workspace credentials for this command.') + const identity: EmbeddedCliIdentity = { + endpoint: getInternalApiBaseUrl(), + apiKey, + workspaceId: context.workspaceId, + } + const sessionKey = context.chatId ? chatSandboxSessionKey(context.chatId) : null + + let result: AgentCliRawResult + if (request.invocation.kind === 'augmentation') { + result = await runEngine( + request.invocation.name, + request.invocation.positionals, + { + client: createEmbeddedClient(identity), + workspaceId: context.workspaceId, + userId: context.userId, + }, + request.invocation.flags + ) + } else { + result = await runCli(request.invocation.argv, identity, sessionKey) + } + if (result.exitCode === 0 && request.pipeline.length > 0) { + result = { ...result, stdout: applyPipeline(result.stdout, request.pipeline) } + } + return request.sink ? applySink(request.sink, sessionKey, result) : result +} diff --git a/apps/sim/lib/mothership/agent-cli/pipeline.test.ts b/apps/sim/lib/mothership/agent-cli/pipeline.test.ts new file mode 100644 index 00000000000..49bf003bb55 --- /dev/null +++ b/apps/sim/lib/mothership/agent-cli/pipeline.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { applyPipeline } from '@/lib/mothership/agent-cli/pipeline' +import type { AgentCliGrepStage } from '@/lib/mothership/generated/agent-cli' + +function grep(overrides: Partial & { pattern: string }): AgentCliGrepStage { + return { + kind: 'grep', + ignoreCase: false, + invert: false, + countOnly: false, + lineNumbers: false, + linesBefore: 0, + linesAfter: 0, + ...overrides, + } +} + +describe('applyPipeline over typed grep stages', () => { + const input = 'alpha slack\nbeta\ngamma SLACK\nslack delta\n' + + it('filters lines by pattern', () => { + expect(applyPipeline(input, [grep({ pattern: 'slack' })])).toBe('alpha slack\nslack delta') + }) + + it('honours ignoreCase, lineNumbers, invert, countOnly, and maxCount', () => { + expect(applyPipeline(input, [grep({ pattern: 'slack', ignoreCase: true })])).toBe( + 'alpha slack\ngamma SLACK\nslack delta' + ) + expect(applyPipeline(input, [grep({ pattern: 'slack', lineNumbers: true })])).toBe( + '1:alpha slack\n4:slack delta' + ) + expect(applyPipeline(input, [grep({ pattern: 'slack', invert: true })])).toBe( + 'beta\ngamma SLACK\n' + ) + expect( + applyPipeline(input, [grep({ pattern: 'slack', ignoreCase: true, countOnly: true })]) + ).toBe('3') + expect(applyPipeline(input, [grep({ pattern: 'slack', ignoreCase: true, maxCount: 2 })])).toBe( + 'alpha slack\ngamma SLACK' + ) + }) + + it('treats the pattern as a regex with a literal fallback', () => { + expect(applyPipeline('a1\nb2\nc3', [grep({ pattern: '^[ab]' })])).toBe('a1\nb2') + expect(applyPipeline('cost is $4 (net', [grep({ pattern: '$4 (net' })])).toBe('cost is $4 (net') + }) + + it('chains stages left to right', () => { + expect( + applyPipeline(input, [ + grep({ pattern: 'slack', ignoreCase: true }), + grep({ pattern: 'delta', invert: true }), + ]) + ).toBe('alpha slack\ngamma SLACK') + }) + + describe('context windows', () => { + const lines = 'a\nb\nHIT\nc\nd\ne\nHIT\nf' + it('trailing context', () => { + expect(applyPipeline(lines, [grep({ pattern: 'HIT', linesAfter: 1 })])).toBe('HIT\nc\nHIT\nf') + }) + it('windows without duplicating overlaps', () => { + expect( + applyPipeline('x\nHIT\nHIT\ny', [grep({ pattern: 'HIT', linesBefore: 1, linesAfter: 1 })]) + ).toBe('x\nHIT\nHIT\ny') + }) + it('counts hits, not context lines', () => { + expect(applyPipeline(lines, [grep({ pattern: 'HIT', countOnly: true, linesAfter: 2 })])).toBe( + '2' + ) + }) + }) +}) diff --git a/apps/sim/lib/mothership/agent-cli/pipeline.ts b/apps/sim/lib/mothership/agent-cli/pipeline.ts new file mode 100644 index 00000000000..43b4bcb97b3 --- /dev/null +++ b/apps/sim/lib/mothership/agent-cli/pipeline.ts @@ -0,0 +1,45 @@ +import type { AgentCliGrepStage, AgentCliPipeStage } from '@/lib/mothership/generated/agent-cli' + +/** + * Applies the worker's already-parsed pipe stages to a command's stdout. Grep is a + * native filter over the string — nothing is spawned — and every option arrives typed, + * so no flag is ever interpreted on this side. + */ + +function compileGrepPattern(raw: string, ignoreCase: boolean): (line: string) => boolean { + try { + const regex = new RegExp(raw, ignoreCase ? 'i' : '') + return (line) => regex.test(line) + } catch { + const needle = ignoreCase ? raw.toLowerCase() : raw + return (line) => (ignoreCase ? line.toLowerCase() : line).includes(needle) + } +} + +function runGrep(input: string, stage: AgentCliGrepStage): string { + const matches = compileGrepPattern(stage.pattern, stage.ignoreCase) + const lines = input.split('\n') + const maxCount = stage.maxCount ?? Number.POSITIVE_INFINITY + // Context options select a window of line indexes around each hit (union, in + // order, no duplicates) — matching grep's -A/-B/-C output without separators. + const selected = new Set() + let hits = 0 + for (let lineNo = 0; lineNo < lines.length && hits < maxCount; lineNo++) { + const hit = matches(lines[lineNo]) + if (hit !== stage.invert) { + hits++ + const from = Math.max(0, lineNo - stage.linesBefore) + const to = Math.min(lines.length - 1, lineNo + stage.linesAfter) + for (let i = from; i <= to; i++) selected.add(i) + } + } + if (stage.countOnly) return String(hits) + const out = [...selected].sort((a, b) => a - b) + return out.map((i) => (stage.lineNumbers ? `${i + 1}:${lines[i]}` : lines[i])).join('\n') +} + +export function applyPipeline(stdout: string, stages: readonly AgentCliPipeStage[]): string { + let current = stdout + for (const stage of stages) current = runGrep(current, stage) + return current +} diff --git a/apps/sim/lib/mothership/agent-cli/request-schema.ts b/apps/sim/lib/mothership/agent-cli/request-schema.ts new file mode 100644 index 00000000000..d9b31f7f7cd --- /dev/null +++ b/apps/sim/lib/mothership/agent-cli/request-schema.ts @@ -0,0 +1,33 @@ +import { z } from 'zod' +import type { AgentCliRequest } from '@/lib/mothership/generated/agent-cli' + +/** + * Runtime validation of the worker's typed request (the generated contract carries only + * the TypeScript shape). Validation is the ONLY thing this side does with the request + * before executing it — no re-parsing, no routing decisions. + */ +const grepStageSchema = z.object({ + kind: z.literal('grep'), + pattern: z.string(), + ignoreCase: z.boolean(), + invert: z.boolean(), + countOnly: z.boolean(), + lineNumbers: z.boolean(), + maxCount: z.number().int().positive().optional(), + linesBefore: z.number().int().nonnegative(), + linesAfter: z.number().int().nonnegative(), +}) + +export const agentCliRequestSchema = z.object({ + invocation: z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('cli'), argv: z.array(z.string()).min(1).max(64) }), + z.object({ + kind: z.literal('augmentation'), + name: z.string().min(1), + positionals: z.array(z.string()), + flags: z.record(z.string(), z.union([z.string(), z.literal(true)])), + }), + ]), + pipeline: z.array(grepStageSchema), + sink: z.object({ kind: z.literal('sandbox-file'), path: z.string().min(1).max(300) }).optional(), +}) satisfies z.ZodType diff --git a/apps/sim/lib/mothership/agent-cli/run-cli.ts b/apps/sim/lib/mothership/agent-cli/run-cli.ts new file mode 100644 index 00000000000..c542e04bff0 --- /dev/null +++ b/apps/sim/lib/mothership/agent-cli/run-cli.ts @@ -0,0 +1,31 @@ +import { type EmbeddedCliIdentity, runEmbeddedCli } from 'sim/embed' +import { readSessionSandboxFile } from '@/lib/execution/remote-sandbox/session-files' +import type { AgentCliRawResult } from '@/lib/mothership/generated/agent-cli' + +/** + * Runs one real-CLI invocation in-process through the installed CLI's own command tree, + * against this deployment's internal API base with the caller's delegated identity. + * + * The chat's workbench sandbox is the agent's filesystem: every @-shaped token is + * pre-read from it into a map the embedded CLI's OWN argument resolver consults — so + * only genuinely file-aware flags get file semantics (a literal `--text @channel` stays + * literal), and the server's filesystem is never readable from model argv. A token that + * names no sandbox file is simply absent from the map; the resolver's refusal says so. + */ +export async function runCli( + argv: string[], + identity: EmbeddedCliIdentity, + sessionKey: string | null +): Promise { + const fileArguments: Record = {} + if (sessionKey) { + for (const token of argv) { + if (!token.startsWith('@') || token.startsWith('@@') || token === '@-') continue + const path = token.slice(1) + if (fileArguments[path] !== undefined) continue + const read = await readSessionSandboxFile(sessionKey, path) + if (read.outcome === 'read') fileArguments[path] = read.content + } + } + return runEmbeddedCli(argv, identity, { fileArguments }) +} diff --git a/apps/sim/lib/mothership/agent-cli/sink.ts b/apps/sim/lib/mothership/agent-cli/sink.ts new file mode 100644 index 00000000000..34ed3d4d836 --- /dev/null +++ b/apps/sim/lib/mothership/agent-cli/sink.ts @@ -0,0 +1,38 @@ +import { writeSessionSandboxFile } from '@/lib/execution/remote-sandbox/session-files' +import type { AgentCliRawResult, AgentCliSink } from '@/lib/mothership/generated/agent-cli' + +/** + * Lands stdout on the agent's machine (the chat's workbench sandbox) instead of + * returning it through the model window — the other half of the file bridge. Only a + * successful command's output is redirected, so an error stays visible inline. + */ +export async function applySink( + sink: AgentCliSink, + sessionKey: string | null, + result: AgentCliRawResult +): Promise { + if (result.exitCode !== 0) return result + if (!sessionKey) { + return { + ...result, + stdout: `${result.stdout}\n[outputFile not written: no chat-scoped machine — output returned inline instead]`, + } + } + const written = await writeSessionSandboxFile(sessionKey, sink.path, result.stdout) + if (written.outcome === 'written') { + return { + ...result, + stdout: `[stdout written to ${sink.path} on your machine: ${result.stdout.length} chars. Read or process it with run_code, or pass it back as @${sink.path}.]`, + } + } + if (written.outcome === 'no-session') { + return { + ...result, + stdout: `${result.stdout}\n[outputFile not written: your machine is not booted yet — run any run_code first. Output returned inline instead]`, + } + } + return { + ...result, + stdout: `${result.stdout}\n[outputFile write failed — output returned inline instead]`, + } +} diff --git a/apps/sim/lib/mothership/agent-cli/types.ts b/apps/sim/lib/mothership/agent-cli/types.ts new file mode 100644 index 00000000000..310273b6f02 --- /dev/null +++ b/apps/sim/lib/mothership/agent-cli/types.ts @@ -0,0 +1,50 @@ +/** + * Sim's half of the mothership↔CLI translation layer: generic execution PRIMITIVES. + * The worker owns the grammar (what commands exist, how argv parses, pipes, help, the + * card); this side only executes typed requests. Nothing here parses argv tokens, + * matches command names, or interprets flags — that is enforced by + * scripts/check-agent-cli-boundary.ts. + * + * Engines reuse the v2 surface through the CLI's own typed client — same identity, + * same authorization — and transform typed responses. They never re-parse rendered CLI + * output, and they never grow a new data-access path: anything v2 cannot answer gets an + * internal application call added here, not a v2 change. + */ + +/** The one client capability engines use; SimClient satisfies it structurally. */ +export interface AgentCliClient { + request(path: string, options?: { query?: Record }): Promise +} + +export interface AgentCliRuntime { + client: AgentCliClient + workspaceId: string + /** The human the command acts as — reference resolution and grants scope to them. */ + userId: string +} + +export interface AgentCliResult { + exitCode: number + stdout: string + stderr: string +} + +/** Command-local flags exactly as the worker parsed them: strings, or true for bare flags. */ +export type AgentCliFlags = Readonly> + +/** One augmentation's execution, keyed in engines/index.ts by the worker's canonical name. */ +export interface AgentCliEngine { + execute( + positionals: string[], + runtime: AgentCliRuntime, + flags: AgentCliFlags + ): Promise +} + +export function agentCliOk(stdout: string): AgentCliResult { + return { exitCode: 0, stdout, stderr: '' } +} + +export function agentCliFail(message: string): AgentCliResult { + return { exitCode: 1, stdout: '', stderr: `Error: ${message}` } +} diff --git a/apps/sim/lib/mothership/generated/agent-cli.ts b/apps/sim/lib/mothership/generated/agent-cli.ts new file mode 100644 index 00000000000..bf8bf1ad52f --- /dev/null +++ b/apps/sim/lib/mothership/generated/agent-cli.ts @@ -0,0 +1,68 @@ +// GENERATED — do not edit. Source of truth: mothership worker packages/contracts/src/agent-cli.ts +// Regenerate with `bun run contracts:sync` in the worker. + +/** + * The mothership↔sim wire for one Sim CLI invocation (docs/revamp/18-agent-surface.md + * §0 + Phase A0). The WORKER owns the agent grammar — it parses the model's argv into + * this typed request; sim executes it with generic primitives and never re-parses + * tokens (no pipe splitting, no flag matching, no augmentation routing on the sim side). + * + * Wire-shared shape: sim's tool handler validates its frame arguments against this. + */ + +/** One grep stage, fully parsed: sim applies it, it never interprets flags. */ +export interface AgentCliGrepStage { + kind: "grep"; + pattern: string; + ignoreCase: boolean; + invert: boolean; + countOnly: boolean; + lineNumbers: boolean; + /** Stop after this many matching lines; absent = unbounded. */ + maxCount?: number; + linesBefore: number; + linesAfter: number; +} + +export type AgentCliPipeStage = AgentCliGrepStage; + +/** Where stdout lands instead of the model window. */ +export interface AgentCliSandboxFileSink { + kind: "sandbox-file"; + /** Path on the chat's workbench sandbox. */ + path: string; +} + +export type AgentCliSink = AgentCliSandboxFileSink; + +/** The real CLI's own command tree, run in-process on sim. */ +export interface AgentCliCliInvocation { + kind: "cli"; + /** argv tokens with global rendering flags and any pipeline already stripped. */ + argv: string[]; +} + +/** An agent-only augmentation, resolved by the worker's registry to its sim engine. */ +export interface AgentCliAugmentationInvocation { + kind: "augmentation"; + /** Engine name, e.g. "workflow lint" — the registry's canonical path. */ + name: string; + positionals: string[]; + /** `--flag value` / `--flag=value` → string; bare `--flag` → true. */ + flags: Record; +} + +export type AgentCliInvocation = AgentCliCliInvocation | AgentCliAugmentationInvocation; + +export interface AgentCliRequest { + invocation: AgentCliInvocation; + pipeline: AgentCliPipeStage[]; + sink?: AgentCliSink; +} + +/** What sim returns; the worker shapes the model-facing result from it. */ +export interface AgentCliRawResult { + exitCode: number; + stdout: string; + stderr: string; +} diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts deleted file mode 100644 index 20896a480b5..00000000000 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/agent-cli.test.ts +++ /dev/null @@ -1,349 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it, vi } from 'vitest' - -const { buildWorkflowLintReport } = vi.hoisted(() => ({ - buildWorkflowLintReport: vi.fn().mockResolvedValue({ - sources: ['block-1'], - sinks: ['block-2'], - orphanBlocks: [], - emptyOutgoingPorts: [], - invalidBranchPorts: [], - invalidConnectionTargets: [], - fieldIssues: [ - { - blockId: 'block-2', - blockName: 'Summarize emails', - missingRequiredFields: ['model'], - inactiveModeValues: [], - }, - ], - unresolvedReferences: [], - }), -})) - -vi.mock('@/lib/workflows/editing/lint-report', () => ({ buildWorkflowLintReport })) - -import { - agentCliHelpSection, - executeAgentCliCommand, - isRootHelpInvocation, - matchAgentCliCommand, -} from '@/lib/mothership/tools/handlers/agent-cli' -import type { AgentCliRuntime } from '@/lib/mothership/tools/handlers/agent-cli/types' - -const WORKFLOW_STATE = { - blocks: { - 'block-1': { type: 'starter', name: 'Start', enabled: true }, - 'block-2': { type: 'agent', name: 'Summarize emails', enabled: true }, - }, - edges: [{ source: 'block-1', target: 'block-2', sourceHandle: 'source', id: 'edge-1' }], - variables: { apiBase: 'https://api.example.com' }, -} - -function runtimeWith(responses: Record): AgentCliRuntime { - return { - workspaceId: 'ws-1', - userId: 'user-1', - client: { - request: async (path: string): Promise => { - const hit = responses[path] - if (hit === undefined) throw new Error(`Unexpected request: ${path}`) - return hit as T - }, - }, - } -} - -const EXPORT_PATH = '/api/v2/workflows/wf-1/export' -const exportResponse = { data: { state: WORKFLOW_STATE } } - -describe('agent-cli routing', () => { - it('matches agent commands through leading global flags', () => { - const match = matchAgentCliCommand(['--output', 'json', 'workflow', 'edges', 'wf-1']) - expect(match?.command.path).toEqual(['workflow', 'edges']) - expect(match?.rest).toEqual(['wf-1']) - }) - - it('leaves real CLI commands unmatched', () => { - expect(matchAgentCliCommand(['workflows', 'list'])).toBeNull() - expect(matchAgentCliCommand(['tables', 'get', 'tbl_1'])).toBeNull() - }) - - it('detects root help invocations only', () => { - expect(isRootHelpInvocation(['--help'])).toBe(true) - expect(isRootHelpInvocation(['--output', 'json', 'help'])).toBe(true) - expect(isRootHelpInvocation(['workflows', '--help'])).toBe(false) - }) - - it('lists every registered command in the help section', () => { - const section = agentCliHelpSection() - for (const usage of ['workflow blocks', 'workflow edges', 'workflow grep', 'workflows grep']) { - expect(section).toContain(usage) - } - }) -}) - -describe('workflow views', () => { - it('projects just the blocks', async () => { - const match = matchAgentCliCommand(['workflow', 'blocks', 'wf-1']) - const result = await executeAgentCliCommand( - match!, - runtimeWith({ [EXPORT_PATH]: exportResponse }) - ) - expect(result.exitCode).toBe(0) - const blocks = JSON.parse(result.stdout) - expect(blocks).toEqual([ - { id: 'block-1', type: 'starter', name: 'Start', enabled: true }, - { id: 'block-2', type: 'agent', name: 'Summarize emails', enabled: true }, - ]) - }) - - it('projects just the edges', async () => { - const match = matchAgentCliCommand(['workflow', 'edges', 'wf-1']) - const result = await executeAgentCliCommand( - match!, - runtimeWith({ [EXPORT_PATH]: exportResponse }) - ) - expect(result.exitCode).toBe(0) - expect(JSON.parse(result.stdout)).toEqual([ - { source: 'block-1', target: 'block-2', sourceHandle: 'source' }, - ]) - }) - - it('fails usefully without a workflow id', async () => { - const match = matchAgentCliCommand(['workflow', 'blocks']) - const result = await executeAgentCliCommand(match!, runtimeWith({})) - expect(result.exitCode).toBe(1) - expect(result.stderr).toContain('Usage:') - }) -}) - -describe('files grep', () => { - const FILES_LIST = { - data: [ - { id: 'f1', name: 'report.md', folderPath: 'docs' }, - { id: 'f2', name: 'logo.png', folderPath: '' }, - ], - nextCursor: null, - } - const readText = (text: string, degraded = false) => ({ - data: { text, degraded }, - }) - - it('greps file contents with line numbers, skipping non-text files', async () => { - const match = matchAgentCliCommand(['files', 'grep', 'quarterly']) - const result = await executeAgentCliCommand( - match!, - runtimeWith({ - '/api/v2/files': FILES_LIST, - '/api/v2/files/f1/text': readText('# Report\nQuarterly revenue was up.\n'), - '/api/v2/files/f2/text': readText('', true), - }) - ) - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('docs/report.md:2: Quarterly revenue was up.') - expect(result.stdout).not.toContain('logo.png') - }) - - it('filters by folder prefix', async () => { - const match = matchAgentCliCommand(['files', 'grep', 'Quarterly', 'other']) - const result = await executeAgentCliCommand( - match!, - runtimeWith({ '/api/v2/files': FILES_LIST }) - ) - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('No matches') - }) -}) - -describe('workflow grep', () => { - it('reports matches as path: value lines', async () => { - const match = matchAgentCliCommand(['workflow', 'grep', 'wf-1', 'Summarize']) - const result = await executeAgentCliCommand( - match!, - runtimeWith({ [EXPORT_PATH]: exportResponse }) - ) - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('.blocks.block-2.name: Summarize emails') - }) - - it('falls back to literal search on an invalid regex', async () => { - const match = matchAgentCliCommand(['workflow', 'grep', 'wf-1', 'api.example.com[']) - const result = await executeAgentCliCommand( - match!, - runtimeWith({ [EXPORT_PATH]: exportResponse }) - ) - expect(result.exitCode).toBe(0) - expect(result.stdout).toBe('No matches.') - }) - - it('searches across all workspace workflows', async () => { - const match = matchAgentCliCommand(['workflows', 'grep', 'Summarize']) - const result = await executeAgentCliCommand( - match!, - runtimeWith({ - '/api/v2/workflows': { - data: [{ id: 'wf-1', name: 'Email digest' }], - nextCursor: null, - }, - [EXPORT_PATH]: exportResponse, - }) - ) - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('Email digest (wf-1).blocks.block-2.name: Summarize emails') - }) - - it('lints a workflow through the shared engine with the caller scoped as subject', async () => { - const match = matchAgentCliCommand(['workflow', 'lint', 'wf-1']) - const result = await executeAgentCliCommand( - match!, - runtimeWith({ [EXPORT_PATH]: exportResponse }) - ) - expect(result.stderr).toBe('') - expect(result.exitCode).toBe(0) - const report = JSON.parse(result.stdout) - expect(report.fieldIssues).toHaveLength(1) - expect(report.summary.length).toBeGreaterThan(0) - expect(buildWorkflowLintReport).toHaveBeenCalledWith(expect.anything(), { - workflowId: 'wf-1', - workspaceId: 'ws-1', - subjectUserId: 'user-1', - }) - }) - - it('surfaces execution errors as a failed result, never a throw', async () => { - const match = matchAgentCliCommand(['workflow', 'grep', 'wf-missing', 'x']) - const result = await executeAgentCliCommand(match!, runtimeWith({})) - expect(result.exitCode).toBe(1) - expect(result.stderr).toContain('Unexpected request') - }) -}) - -describe('flag parsing', () => { - it('collects --flag value, --flag=value, and bare flags without shifting positionals', () => { - const match = matchAgentCliCommand([ - 'logs', - 'query', - 'wf-1', - '--block', - 'Router', - '--limit=5', - '--verbose', - ]) - expect(match?.rest).toEqual(['wf-1']) - expect(match?.flags.get('block')).toBe('Router') - expect(match?.flags.get('limit')).toBe('5') - expect(match?.flags.get('verbose')).toBe(true) - }) -}) - -describe('logs query', () => { - const RUNS_PATH = '/api/v2/workflows/wf-1/runs' - const runsResponse = { - data: [ - { runId: 'run-1', status: 'completed', startedAt: 't1' }, - { runId: 'run-2', status: 'completed', startedAt: 't2' }, - { runId: 'run-3', status: 'failed', startedAt: 't3' }, - ], - } - const routedTrace = { - data: { - traceSpans: [ - { - name: 'Start', - children: [{ name: 'Router', status: 'success', output: { route: 'priority', n: 2 } }], - }, - ], - }, - } - const unroutedTrace = { - data: { traceSpans: [{ name: 'Start', output: {} }] }, - } - - it('emits one row per run with the block field dug from nested spans', async () => { - const match = matchAgentCliCommand([ - 'logs', - 'query', - 'wf-1', - '--block', - 'Router', - '--field', - 'output.route', - ]) - const result = await executeAgentCliCommand( - match!, - runtimeWith({ - [RUNS_PATH]: runsResponse, - '/api/v2/logs/run-1': routedTrace, - '/api/v2/logs/run-2': unroutedTrace, - '/api/v2/logs/run-3': routedTrace, - }) - ) - expect(result.exitCode).toBe(0) - const report = JSON.parse(result.stdout) - expect(report.runsScanned).toBe(3) - expect(report.rows).toEqual([ - { - runId: 'run-1', - startedAt: 't1', - runStatus: 'completed', - hits: 1, - blockStatus: 'success', - value: 'priority', - }, - { runId: 'run-2', startedAt: 't2', runStatus: 'completed', hits: 0, value: null }, - { - runId: 'run-3', - startedAt: 't3', - runStatus: 'failed', - hits: 1, - blockStatus: 'success', - value: 'priority', - }, - ]) - }) - - it('filters rows with --where and reports unavailable traces instead of failing', async () => { - const match = matchAgentCliCommand([ - 'logs', - 'query', - 'wf-1', - '--block', - 'Router', - '--where', - 'output.route=priority', - ]) - const result = await executeAgentCliCommand( - match!, - runtimeWith({ - [RUNS_PATH]: runsResponse, - '/api/v2/logs/run-1': routedTrace, - '/api/v2/logs/run-2': unroutedTrace, - }) - ) - expect(result.exitCode).toBe(0) - const report = JSON.parse(result.stdout) - expect(report.missingTrace).toBe(1) - expect(report.rows).toEqual([ - { - runId: 'run-1', - startedAt: 't1', - runStatus: 'completed', - hits: 1, - blockStatus: 'success', - value: { route: 'priority', n: 2 }, - }, - { runId: 'run-2', startedAt: 't2', runStatus: 'completed', hits: 0, value: null }, - { runId: 'run-3', startedAt: 't3', runStatus: 'failed', note: 'trace unavailable' }, - ]) - }) - - it('fails usefully without a workflow id or --block', async () => { - const match = matchAgentCliCommand(['logs', 'query', 'wf-1']) - const result = await executeAgentCliCommand(match!, runtimeWith({})) - expect(result.exitCode).toBe(1) - expect(result.stderr).toContain('--block') - }) -}) diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts deleted file mode 100644 index 4449094de89..00000000000 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { getErrorMessage } from '@sim/utils/errors' -import { workflowDepsCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/deps' -import { filesGrepCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/files-grep' -import { - workflowGrepCommand, - workflowsGrepCommand, -} from '@/lib/mothership/tools/handlers/agent-cli/commands/grep' -import { workflowLintCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/lint' -import { logsQueryCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/query' -import { workflowTraceCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/trace' -import { - workflowBlocksCommand, - workflowEdgesCommand, -} from '@/lib/mothership/tools/handlers/agent-cli/commands/workflow-views' -import { - type AgentCliCommand, - type AgentCliResult, - type AgentCliRuntime, - agentCliFail, -} from '@/lib/mothership/tools/handlers/agent-cli/types' - -/** - * The registry of agent-only augmentations, longest prefix wins. Adding a - * capability = one command object here; it appears in the merged --help - * automatically. - */ -const AGENT_CLI_COMMANDS: readonly AgentCliCommand[] = [ - filesGrepCommand, - logsQueryCommand, - workflowDepsCommand, - workflowBlocksCommand, - workflowEdgesCommand, - workflowGrepCommand, - workflowLintCommand, - workflowTraceCommand, - workflowsGrepCommand, -] - -/** Global flags (with values) that may precede the subcommand, e.g. --output json. */ -const VALUE_FLAGS = new Set(['--output', '-o']) - -/** - * Splits an invocation into bare command tokens and command-local flags. - * `--flag value` and `--flag=value` become string entries, a trailing or - * value-less `--flag` becomes `true`; global rendering flags are dropped. - */ -function parseInvocation(args: string[]): { tokens: string[]; flags: Map } { - const tokens: string[] = [] - const flags = new Map() - for (let i = 0; i < args.length; i++) { - const arg = args[i] - if (!arg.startsWith('-')) { - tokens.push(arg) - continue - } - if (VALUE_FLAGS.has(arg)) { - i++ - continue - } - const name = arg.replace(/^-+/, '') - const equalsIndex = name.indexOf('=') - if (equalsIndex > 0) { - flags.set(name.slice(0, equalsIndex), name.slice(equalsIndex + 1)) - continue - } - const next = args[i + 1] - if (next !== undefined && !next.startsWith('-')) { - flags.set(name, next) - i++ - } else { - flags.set(name, true) - } - } - return { tokens, flags } -} - -export interface AgentCliMatch { - command: AgentCliCommand - rest: string[] - flags: Map -} - -export function matchAgentCliCommand(args: string[]): AgentCliMatch | null { - const { tokens, flags } = parseInvocation(args) - let best: AgentCliMatch | null = null - for (const command of AGENT_CLI_COMMANDS) { - const matches = - tokens.length >= command.path.length && - command.path.every((part, index) => tokens[index] === part) - if (matches && (!best || command.path.length > best.command.path.length)) { - best = { command, rest: tokens.slice(command.path.length), flags } - } - } - return best -} - -export async function executeAgentCliCommand( - match: AgentCliMatch, - runtime: AgentCliRuntime -): Promise { - try { - return await match.command.execute(match.rest, runtime, match.flags) - } catch (error) { - return agentCliFail(getErrorMessage(error)) - } -} - -/** True for a bare help invocation whose output should include the agent section. */ -export function isRootHelpInvocation(args: string[]): boolean { - const meaningful = args.filter((a) => a !== '--output' && a !== 'json' && a !== '-o') - return ( - meaningful.length === 0 || - (meaningful.length === 1 && (meaningful[0] === 'help' || meaningful[0] === '--help')) - ) -} - -/** The section appended to the real CLI's root help. */ -export function agentCliHelpSection(): string { - const lines = AGENT_CLI_COMMANDS.map( - (command) => ` ${command.usage.padEnd(38)} ${command.summary}` - ) - return `\nAgent commands (available in this environment only):\n${lines.join('\n')}\n\nAny command's stdout can be filtered with a trailing pipe into grep (the only pipe target):\n sim workflows export | grep -in slack\n` -} diff --git a/apps/sim/lib/mothership/tools/handlers/agent-cli/types.ts b/apps/sim/lib/mothership/tools/handlers/agent-cli/types.ts deleted file mode 100644 index f5628cf0015..00000000000 --- a/apps/sim/lib/mothership/tools/handlers/agent-cli/types.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Agent-only CLI augmentations: commands the mothership agent sees alongside - * the real Sim CLI, exposing views the product CLI has no reason to carry - * (workflow-scoped grep, edges/blocks projections, cross-workflow search). - * - * Each augmentation reuses the v2 surface through the CLI's own typed client — - * same identity, same authorization — and transforms typed responses. It never - * re-parses rendered CLI output, and it never grows a new data-access path: - * anything v2 cannot answer gets an internal application call added here, not - * a v2 change. - */ - -/** The one client capability augmentations use; SimClient satisfies it structurally. */ -export interface AgentCliClient { - request(path: string, options?: { query?: Record }): Promise -} - -export interface AgentCliRuntime { - client: AgentCliClient - workspaceId: string - /** The human the command acts as — reference resolution and grants scope to them. */ - userId: string -} - -export interface AgentCliResult { - exitCode: number - stdout: string - stderr: string -} - -/** - * Command-local flags parsed from the invocation: `--flag value` and - * `--flag=value` map to strings, a bare `--flag` maps to `true`. Global - * rendering flags (`--output`) are stripped before parsing and never appear. - */ -export type AgentCliFlags = ReadonlyMap - -export interface AgentCliCommand { - /** argv tokens that select this command, matched as a prefix (e.g. ['workflow', 'edges']). */ - path: readonly string[] - /** One line for the merged --help section. */ - summary: string - /** Full usage line, e.g. 'workflow edges '. */ - usage: string - execute(rest: string[], runtime: AgentCliRuntime, flags: AgentCliFlags): Promise -} - -export function agentCliOk(stdout: string): AgentCliResult { - return { exitCode: 0, stdout, stderr: '' } -} - -export function agentCliFail(message: string): AgentCliResult { - return { exitCode: 1, stdout: '', stderr: `Error: ${message}` } -} diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli-bridge.test.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli-bridge.test.ts index f70622a5a34..7958257abd9 100644 --- a/apps/sim/lib/mothership/tools/handlers/sim-cli-bridge.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/sim-cli-bridge.test.ts @@ -21,22 +21,33 @@ vi.mock('sim/embed', () => ({ vi.mock('@/lib/mothership/chat/delegation', () => ({ mintDelegationToken: mockMint })) vi.mock('@/lib/core/utils/urls', () => ({ getInternalApiBaseUrl: () => 'http://internal' })) +import type { AgentCliRequest } from '@/lib/mothership/generated/agent-cli' import { executeSimCli } from '@/lib/mothership/tools/handlers/sim-cli' const context = { workspaceId: 'ws-1', userId: 'u-1', chatId: 'chat-1' } as Parameters< typeof executeSimCli >[1] -describe('sim-cli machine file bridge', () => { +function cli(argv: string[], extra: Partial = {}): { request: AgentCliRequest } { + return { request: { invocation: { kind: 'cli', argv }, pipeline: [], ...extra } } +} + +describe('sim-cli handler executes the worker-built request', () => { beforeEach(() => { vi.clearAllMocks() mockMint.mockResolvedValue('key') mockRunEmbeddedCli.mockResolvedValue({ exitCode: 0, stdout: 'BIG OUTPUT', stderr: '' }) }) + it('refuses a frame without the typed request — this side never re-parses argv', async () => { + const result = await executeSimCli({ args: ['workflows', 'list'] }, context) + expect(result.success).toBe(false) + expect(mockRunEmbeddedCli).not.toHaveBeenCalled() + }) + it('pre-reads @tokens from the machine into the embed file map', async () => { mockRead.mockResolvedValue({ outcome: 'read', content: '{"text":"hi"}' }) - await executeSimCli({ args: ['workflows', 'run', 'wf1', '--input', '@env.json'] }, context) + await executeSimCli(cli(['workflows', 'run', 'wf1', '--input', '@env.json']), context) expect(mockRead).toHaveBeenCalledWith('mothership-chat:chat-1', 'env.json') expect(mockRunEmbeddedCli).toHaveBeenCalledWith( ['workflows', 'run', 'wf1', '--input', '@env.json'], @@ -47,7 +58,7 @@ describe('sim-cli machine file bridge', () => { it('leaves @@ literals and @- alone, and omits missing files from the map', async () => { mockRead.mockResolvedValue({ outcome: 'no-file', detail: 'nope' }) - await executeSimCli({ args: ['x', '@@literal', '@missing.json'] }, context) + await executeSimCli(cli(['x', '@@literal', '@missing.json']), context) expect(mockRead).toHaveBeenCalledTimes(1) expect(mockRunEmbeddedCli).toHaveBeenCalledWith( ['x', '@@literal', '@missing.json'], @@ -58,18 +69,32 @@ describe('sim-cli machine file bridge', () => { ) }) - it('cold machine on read degrades via the empty map (CLI core words the refusal)', async () => { - mockRead.mockResolvedValue({ outcome: 'no-session' }) - await executeSimCli({ args: ['x', '@env.json'] }, context) - expect(mockRunEmbeddedCli).toHaveBeenCalledWith(['x', '@env.json'], expect.anything(), { - fileArguments: {}, - }) + it('applies the pre-parsed pipeline to a successful result', async () => { + mockRunEmbeddedCli.mockResolvedValue({ exitCode: 0, stdout: 'alpha slack\nbeta\n', stderr: '' }) + const result = await executeSimCli( + cli(['workflows', 'list'], { + pipeline: [ + { + kind: 'grep', + pattern: 'slack', + ignoreCase: true, + invert: false, + countOnly: false, + lineNumbers: false, + linesBefore: 0, + linesAfter: 0, + }, + ], + }), + context + ) + expect((result.output as { stdout: string }).stdout).toBe('alpha slack') }) - it('outputFile lands stdout on the machine and returns only the ack', async () => { + it('sink lands stdout on the machine and returns only the ack', async () => { mockWrite.mockResolvedValue({ outcome: 'written', path: '/home/user/trace.json' }) const result = await executeSimCli( - { args: ['logs', 'get', 'r1'], outputFile: 'trace.json' }, + cli(['logs', 'get', 'r1'], { sink: { kind: 'sandbox-file', path: 'trace.json' } }), context ) expect(mockWrite).toHaveBeenCalledWith('mothership-chat:chat-1', 'trace.json', 'BIG OUTPUT') @@ -79,10 +104,10 @@ describe('sim-cli machine file bridge', () => { expect(output.stdout).not.toContain('BIG OUTPUT') }) - it('outputFile on a cold machine returns output inline with boot guidance', async () => { + it('sink on a cold machine returns output inline with boot guidance', async () => { mockWrite.mockResolvedValue({ outcome: 'no-session' }) const result = await executeSimCli( - { args: ['logs', 'get', 'r1'], outputFile: 'trace.json' }, + cli(['logs', 'get', 'r1'], { sink: { kind: 'sandbox-file', path: 'trace.json' } }), context ) const output = result.output as { stdout: string } @@ -90,10 +115,10 @@ describe('sim-cli machine file bridge', () => { expect(output.stdout).toContain('not booted') }) - it('outputFile is skipped on command failure so the error stays visible', async () => { + it('sink is skipped on command failure so the error stays visible', async () => { mockRunEmbeddedCli.mockResolvedValue({ exitCode: 1, stdout: '', stderr: 'boom' }) const result = await executeSimCli( - { args: ['logs', 'get', 'r1'], outputFile: 'trace.json' }, + cli(['logs', 'get', 'r1'], { sink: { kind: 'sandbox-file', path: 'trace.json' } }), context ) expect(mockWrite).not.toHaveBeenCalled() diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.test.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.test.ts deleted file mode 100644 index 74ed1c17455..00000000000 --- a/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { applyPipeline, splitPipeline } from '@/lib/mothership/tools/handlers/sim-cli-pipe' - -describe('splitPipeline', () => { - it('returns the argv untouched when no pipe token is present', () => { - expect(splitPipeline(['workflows', 'list'])).toEqual({ - cliArgs: ['workflows', 'list'], - stages: [], - }) - }) - - it('splits the invocation from grep stages on | tokens', () => { - expect(splitPipeline(['workflows', 'export', 'w1', '|', 'grep', '-n', 'slack'])).toEqual({ - cliArgs: ['workflows', 'export', 'w1'], - stages: [['grep', '-n', 'slack']], - }) - }) - - it('supports chained grep stages', () => { - expect( - splitPipeline(['logs', 'list', '|', 'grep', 'error', '|', 'grep', '-v', 'retry']) - ).toEqual({ - cliArgs: ['logs', 'list'], - stages: [ - ['grep', 'error'], - ['grep', '-v', 'retry'], - ], - }) - }) - - it('yields an empty invocation when the argv starts with a pipe', () => { - expect(splitPipeline(['|', 'grep', 'x']).cliArgs).toEqual([]) - }) -}) - -describe('applyPipeline', () => { - const input = 'alpha slack\nbeta\ngamma SLACK\nslack delta\n' - - it('filters lines by pattern', () => { - const result = applyPipeline(input, [['grep', 'slack']]) - expect(result).toEqual({ ok: true, stdout: 'alpha slack\nslack delta' }) - }) - - it('supports -i, -n, -v, -c, and -m', () => { - expect(applyPipeline(input, [['grep', '-i', 'slack']])).toEqual({ - ok: true, - stdout: 'alpha slack\ngamma SLACK\nslack delta', - }) - expect(applyPipeline(input, [['grep', '-n', 'slack']])).toEqual({ - ok: true, - stdout: '1:alpha slack\n4:slack delta', - }) - expect(applyPipeline(input, [['grep', '-v', 'slack']])).toEqual({ - ok: true, - stdout: 'beta\ngamma SLACK\n', - }) - expect(applyPipeline(input, [['grep', '-c', '-i', 'slack']])).toEqual({ - ok: true, - stdout: '3', - }) - expect(applyPipeline(input, [['grep', '-i', '-m', '2', 'slack']])).toEqual({ - ok: true, - stdout: 'alpha slack\ngamma SLACK', - }) - }) - - it('treats the pattern as a regex with a literal fallback', () => { - expect(applyPipeline('a1\nb2\nc3', [['grep', '^[ab]']])).toEqual({ ok: true, stdout: 'a1\nb2' }) - expect(applyPipeline('cost is $4 (net', [['grep', '$4 (net']])).toEqual({ - ok: true, - stdout: 'cost is $4 (net', - }) - }) - - it('chains stages left to right', () => { - const result = applyPipeline(input, [ - ['grep', '-i', 'slack'], - ['grep', '-v', 'delta'], - ]) - expect(result).toEqual({ ok: true, stdout: 'alpha slack\ngamma SLACK' }) - }) - - it('rejects non-grep stages with guidance', () => { - const result = applyPipeline(input, [['jq', '.name']]) - expect(result.ok).toBe(false) - if (!result.ok) expect(result.error).toContain('grep is the only pipe target') - }) - - it('rejects unsupported grep flags and missing patterns', () => { - expect(applyPipeline(input, [['grep', '-o', 'x']]).ok).toBe(false) - expect(applyPipeline(input, [['grep', '-i']]).ok).toBe(false) - expect(applyPipeline(input, [['grep', '-m', 'zero', 'x']]).ok).toBe(false) - }) - - it('validates stages against empty input for preflight use', () => { - expect(applyPipeline('', [['grep', '-n', 'x']]).ok).toBe(true) - expect(applyPipeline('', [['head', '-n', '5']]).ok).toBe(false) - }) -}) - -describe('grep context flags', () => { - const input = 'a\nb\nHIT\nc\nd\ne\nHIT\nf' - it('-A appends trailing context lines', () => { - const r = applyPipeline(input, [['grep', '-A', '1', 'HIT']]) - expect(r).toEqual({ ok: true, stdout: 'HIT\nc\nHIT\nf' }) - }) - it('-B and -C select windows without duplicating overlaps', () => { - const r = applyPipeline('x\nHIT\nHIT\ny', [['grep', '-C', '1', 'HIT']]) - expect(r).toEqual({ ok: true, stdout: 'x\nHIT\nHIT\ny' }) - }) - it('-c counts hits, not context lines', () => { - const r = applyPipeline(input, [['grep', '-c', '-A', '2', 'HIT']]) - expect(r).toEqual({ ok: true, stdout: '2' }) - }) - it('rejects a negative context count with usage guidance', () => { - const r = applyPipeline(input, [['grep', '-A', '-2', 'HIT']]) - expect(r.ok).toBe(false) - if (!r.ok) expect(r.error).toContain('-A needs a non-negative number') - }) -}) diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.ts deleted file mode 100644 index 2443e40adb8..00000000000 --- a/apps/sim/lib/mothership/tools/handlers/sim-cli-pipe.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Grep-pipe support for sim_cli invocations: - * `["workflows","export","","|","grep","-n","slack"]`. - * - * NOT a shell. A `|` token splits the argv into the CLI invocation plus grep - * stages — grep is the only supported filter, implemented natively over the - * stdout string. Nothing is spawned, and the filtering happens sim-side so a - * huge output shrinks BEFORE it crosses the wire into the model's window. - */ - -export interface PipeSplit { - cliArgs: string[] - stages: string[][] -} - -/** Splits argv on `|` tokens. A lone invocation returns zero stages. */ -export function splitPipeline(args: string[]): PipeSplit { - const segments: string[][] = [[]] - for (const arg of args) { - if (arg === '|') { - segments.push([]) - } else { - segments[segments.length - 1].push(arg) - } - } - const [cliArgs, ...stages] = segments - return { cliArgs, stages } -} - -class PipeUsageError extends Error {} - -function compileGrepPattern(raw: string, ignoreCase: boolean): (line: string) => boolean { - try { - const regex = new RegExp(raw, ignoreCase ? 'i' : '') - return (line) => regex.test(line) - } catch { - const needle = ignoreCase ? raw.toLowerCase() : raw - return (line) => (ignoreCase ? line.toLowerCase() : line).includes(needle) - } -} - -function parseContextCount(args: string[], i: number, flag: string): number { - const count = Number.parseInt(args[i] ?? '', 10) - if (!Number.isFinite(count) || count < 0) { - throw new PipeUsageError(`grep ${flag} needs a non-negative number`) - } - return count -} - -function runGrep(input: string, args: string[]): string { - let ignoreCase = false - let invert = false - let countOnly = false - let lineNumbers = false - let maxCount = Number.POSITIVE_INFINITY - let before = 0 - let after = 0 - const positional: string[] = [] - for (let i = 0; i < args.length; i++) { - const arg = args[i] - if (arg === '-i') ignoreCase = true - else if (arg === '-v') invert = true - else if (arg === '-c') countOnly = true - else if (arg === '-n') lineNumbers = true - else if (arg === '-E') { - // Patterns are compiled as regexes by default; -E is accepted as a no-op. - } else if (arg === '-m') { - maxCount = Number.parseInt(args[++i] ?? '', 10) - if (!Number.isFinite(maxCount) || maxCount < 1) { - throw new PipeUsageError('grep -m needs a positive number') - } - } else if (arg === '-A') { - after = parseContextCount(args, ++i, '-A') - } else if (arg === '-B') { - before = parseContextCount(args, ++i, '-B') - } else if (arg === '-C') { - const count = parseContextCount(args, ++i, '-C') - before = count - after = count - } else if (arg.startsWith('-')) { - throw new PipeUsageError( - `grep: unsupported flag ${arg} (supported: -i -v -c -n -E -m N -A N -B N -C N)` - ) - } else { - positional.push(arg) - } - } - const pattern = positional[0] - if (pattern === undefined) throw new PipeUsageError('grep needs a pattern') - const matches = compileGrepPattern(pattern, ignoreCase) - const lines = input.split('\n') - // Context flags select a window of line indexes around each hit (union, in - // order, no duplicates) — matching grep's -A/-B/-C output without separators. - const selected = new Set() - let hits = 0 - for (let lineNo = 0; lineNo < lines.length && hits < maxCount; lineNo++) { - const hit = matches(lines[lineNo]) - if (hit !== invert) { - hits++ - const from = Math.max(0, lineNo - before) - const to = Math.min(lines.length - 1, lineNo + after) - for (let i = from; i <= to; i++) selected.add(i) - } - } - if (countOnly) return String(hits) - const out = [...selected].sort((a, b) => a - b) - return out.map((i) => (lineNumbers ? `${i + 1}:${lines[i]}` : lines[i])).join('\n') -} - -/** Applies the grep stages to stdout. Returns the filtered text, or a usage error. */ -export function applyPipeline( - stdout: string, - stages: string[][] -): { ok: true; stdout: string } | { ok: false; error: string } { - let current = stdout - for (const stage of stages) { - const [command, ...grepArgs] = stage - if (command !== 'grep') { - return { - ok: false, - error: `"${command ?? ''}" is not a supported filter. grep is the only pipe target (e.g. ... | grep -i slack). There is no shell — no other commands, redirection, or substitution.`, - } - } - try { - current = runGrep(current, grepArgs) - } catch (error) { - if (error instanceof PipeUsageError) return { ok: false, error: error.message } - throw error - } - } - return { ok: true, stdout: current } -} diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts index 4bb8ad15f43..1131babae59 100644 --- a/apps/sim/lib/mothership/tools/handlers/sim-cli.ts +++ b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts @@ -1,150 +1,54 @@ import { createLogger } from '@sim/logger' -import { createEmbeddedClient, type EmbeddedCliIdentity, runEmbeddedCli } from 'sim/embed' -import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' -import { - readSessionSandboxFile, - writeSessionSandboxFile, -} from '@/lib/execution/remote-sandbox/session-files' -import { mintDelegationToken } from '@/lib/mothership/chat/delegation' +import { getErrorMessage } from '@sim/utils/errors' +import { executeAgentCliRequest } from '@/lib/mothership/agent-cli' +import { agentCliRequestSchema } from '@/lib/mothership/agent-cli/request-schema' import type { ToolExecutionContext, ToolExecutionResult, } from '@/lib/mothership/tool-executor/types' -import { - agentCliHelpSection, - executeAgentCliCommand, - isRootHelpInvocation, - matchAgentCliCommand, -} from '@/lib/mothership/tools/handlers/agent-cli' -import { applyPipeline, splitPipeline } from '@/lib/mothership/tools/handlers/sim-cli-pipe' -import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session' const logger = createLogger('MothershipSimCli') /** - * Executes one Sim CLI invocation in-process — the worker's `sim_cli` tool - * defers here instead of spawning a CLI binary in its own container. - * - * Routing: agent-only augmentations (agent-cli/) intercept first and answer - * from typed v2 calls; everything else runs through the installed CLI's own - * command tree (`sim/embed`) against this deployment's internal API base. Both - * lanes share one server-minted delegation identity for the calling user, so - * "the agent is the user" holds without any credential crossing to the worker. - * Root --help merges the real CLI's help with the agent-command section. A - * trailing `| grep …` (the only pipe target) filters stdout sim-side so large - * outputs shrink before crossing the wire. + * The worker's `sim_cli` tool defers here. The frame carries the worker's typed request + * (`request`) beside the model's raw argv (`args`, kept for display and the log); only + * the request is executed — this side never re-parses tokens. The worker folds + * exitCode/stdout/stderr into the model window and applies its own output budget. */ export async function executeSimCli( params: Record, context: ToolExecutionContext ): Promise { - const rawArgs = params.args - if ( - !Array.isArray(rawArgs) || - rawArgs.length === 0 || - !rawArgs.every((a) => typeof a === 'string') - ) { - return { success: false, error: 'sim_cli requires args: a non-empty array of argv tokens.' } + const parsed = agentCliRequestSchema.safeParse(params.request) + if (!parsed.success) { + return { + success: false, + error: 'sim_cli requires the worker-built request; the invocation was not translated.', + } } if (!context.workspaceId) { return { success: false, error: 'sim_cli requires a workspace-scoped execution context.' } } - const { cliArgs: rawCliArgs, stages } = splitPipeline(rawArgs) - if (rawCliArgs.length === 0) { - return { success: false, error: 'A pipe needs a sim CLI invocation before the first |.' } - } - - const args = rawCliArgs - - // The chat's workbench sandbox is the agent's filesystem: every @-shaped token - // is pre-read from it into a map the embedded CLI's OWN argument resolver - // consults — so only genuinely file-aware flags get file semantics (a literal - // `--text @channel` stays literal), and the server's filesystem is never - // readable from model argv. A token that names no sandbox file is simply - // absent from the map; the resolver's refusal then says so. - const sessionKey = context.chatId ? chatSandboxSessionKey(context.chatId) : null - const fileArguments: Record = {} - if (sessionKey) { - for (const token of args) { - if (!token.startsWith('@') || token.startsWith('@@') || token === '@-') continue - const path = token.slice(1) - if (fileArguments[path] !== undefined) continue - const read = await readSessionSandboxFile(sessionKey, path) - if (read.outcome === 'read') fileArguments[path] = read.content + try { + const result = await executeAgentCliRequest(parsed.data, { + workspaceId: context.workspaceId, + userId: context.userId, + chatId: context.chatId, + }) + logger.info('CLI invocation finished', { + exitCode: result.exitCode, + lane: parsed.data.invocation.kind, + pipeStages: parsed.data.pipeline.length, + stdoutBytes: result.stdout.length, + }) + return { + success: result.exitCode === 0, + output: { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }, + ...(result.exitCode === 0 + ? {} + : { error: result.stderr.split('\n')[0] || `sim CLI exited with code ${result.exitCode}` }), } - } - - // Stages are validated before the CLI runs: a mutating command must never - // execute and then fail on a malformed pipe, or a model retry would repeat - // the mutation. - const stagePreflight = applyPipeline('', stages) - if (!stagePreflight.ok) { - return { success: false, error: stagePreflight.error } - } - - const apiKey = await mintDelegationToken({ - workspaceId: context.workspaceId, - userId: context.userId, - }) - if (!apiKey) { - return { success: false, error: 'Could not establish workspace credentials for this command.' } - } - const identity: EmbeddedCliIdentity = { - endpoint: getInternalApiBaseUrl(), - apiKey, - workspaceId: context.workspaceId, - } - - const agentMatch = matchAgentCliCommand(args) - const result = agentMatch - ? await executeAgentCliCommand(agentMatch, { - client: createEmbeddedClient(identity), - workspaceId: context.workspaceId, - userId: context.userId, - }) - : await runEmbeddedCli(args, identity, { fileArguments }) - if (!agentMatch && isRootHelpInvocation(args) && result.exitCode === 0) { - result.stdout += agentCliHelpSection() - } - if (result.exitCode === 0 && stages.length > 0) { - const piped = applyPipeline(result.stdout, stages) - if (piped.ok) result.stdout = piped.stdout - } - - // outputFile: land large stdout directly on the agent's machine instead of - // returning it through the model window — the other half of the file bridge. - const outputFile = typeof params.outputFile === 'string' ? params.outputFile.trim() : '' - if (outputFile && result.exitCode === 0) { - if (!sessionKey) { - result.stdout += - '\n[outputFile not written: no chat-scoped machine — output returned inline instead]' - } else { - const written = await writeSessionSandboxFile(sessionKey, outputFile, result.stdout) - if (written.outcome === 'written') { - result.stdout = `[stdout written to ${outputFile} on your machine: ${result.stdout.length} chars. Read or process it with run_code, or pass it back as @${outputFile}.]` - } else if (written.outcome === 'no-session') { - result.stdout += - '\n[outputFile not written: your machine is not booted yet — run any run_code first. Output returned inline instead]' - } else { - result.stdout += '\n[outputFile write failed — output returned inline instead]' - } - } - } - - logger.info('CLI invocation finished', { - exitCode: result.exitCode, - argv0: args[0], - lane: agentMatch ? 'agent' : 'cli', - grepStages: stages.length, - stdoutBytes: result.stdout.length, - }) - // The worker folds exitCode/stdout/stderr into the model window and applies - // its own output capping; success here means only "the invocation ran". - return { - success: result.exitCode === 0, - output: { exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr }, - ...(result.exitCode === 0 - ? {} - : { error: result.stderr.split('\n')[0] || `sim CLI exited with code ${result.exitCode}` }), + } catch (error) { + return { success: false, error: getErrorMessage(error) } } } diff --git a/package.json b/package.json index 3001fbfe967..6760b556d54 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,8 @@ "test:workflow-sync": "bun --no-env-file scripts/test-workflow-sync.ts", "type-check": "turbo run type-check", "release": "bun run scripts/create-single-release.ts", - "test:scripts": "vitest run --config scripts/vitest.config.ts" + "test:scripts": "vitest run --config scripts/vitest.config.ts", + "check:agent-cli-boundary": "bun run scripts/check-agent-cli-boundary.ts" }, "overrides": { "react": "19.2.4", diff --git a/scripts/check-agent-cli-boundary.ts b/scripts/check-agent-cli-boundary.ts new file mode 100644 index 00000000000..e2f19cc4e54 --- /dev/null +++ b/scripts/check-agent-cli-boundary.ts @@ -0,0 +1,111 @@ +/** + * The sim half of the mothership↔CLI translation layer is PRIMITIVES ONLY + * (mothership docs/revamp/18-agent-surface.md §4, Phase A0). The grammar — which + * commands exist, how argv parses, pipe tokens, flag matching, help text, display + * names — lives in the mothership worker. This gate keeps it from leaking back: + * + * 1. primitives-only: lib/mothership/agent-cli/ exports exactly the primitive surface + * and contains no token grammar (pipe splitting, flag parsing, command-name matching). + * 2. no-grammar-on-sim: nothing under apps/sim parses `|` tokens or matches CLI command + * names for the agent, and only the primitive runner reaches `sim/embed`'s executor. + * 3. cli-isolation: the public CLI package never imports agent code. + * + * Runs in the mothership-related sim gate alongside check:api-validation. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' + +const ROOT = join(import.meta.dir, '..') +const APP = join(ROOT, 'apps/sim') +const LAYER = join(APP, 'lib/mothership/agent-cli') +const PRIMITIVE_EXPORTS = new Set(['executeAgentCliRequest', 'AgentCliExecutionContext']) + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + if (entry === 'node_modules' || entry === '.next') continue + if (statSync(full).isDirectory()) walk(full, out) + else if (/\.(ts|tsx)$/.test(entry) && !/\.test\.tsx?$/.test(entry)) out.push(full) + } + return out +} + +const failures: string[] = [] +function fail(file: string, line: number, rule: string, text: string): void { + failures.push(`${relative(ROOT, file)}:${line} [${rule}]\n ${text.trim().slice(0, 120)}`) +} + +// 1. primitives-only — the export surface of the layer's index. +{ + const index = readFileSync(join(LAYER, 'index.ts'), 'utf8') + const exported = [ + ...index.matchAll(/export (?:async )?(?:function|interface|type|const) (\w+)/g), + ].map((m) => m[1]) + for (const name of exported) { + if (!PRIMITIVE_EXPORTS.has(name)) { + fail( + join(LAYER, 'index.ts'), + 1, + 'primitives-only', + `"${name}" is exported from the layer index; only ${[...PRIMITIVE_EXPORTS].join(', ')} may be` + ) + } + } +} + +// 1 + 2. no token grammar anywhere in apps/sim for the agent. +const GRAMMAR_PATTERNS: Array<{ re: RegExp; rule: string }> = [ + { re: /===\s*'\|'|===\s*"\|"/, rule: 'no-grammar-on-sim: pipe tokens are split on the worker' }, + { + re: /\bsplitPipeline\b|\bmatchAgentCliCommand\b|\bparseInvocation\b|\bagentCliHelpSection\b/, + rule: 'no-grammar-on-sim: argv parsing, command matching, and help belong to the worker', + }, +] +for (const file of walk(join(APP, 'lib/mothership'))) { + const lines = readFileSync(file, 'utf8').split('\n') + lines.forEach((line, index) => { + if (/^\s*(\/\/|\*)/.test(line)) return + for (const { re, rule } of GRAMMAR_PATTERNS) { + if (re.test(line)) fail(file, index + 1, rule, line) + } + }) +} + +// 2. only the primitive runner reaches the embedded CLI executor. +for (const file of walk(APP)) { + if (file === join(LAYER, 'run-cli.ts') || file === join(LAYER, 'index.ts')) continue + const src = readFileSync(file, 'utf8') + const line = src + .split('\n') + .findIndex((l) => /from 'sim\/embed'/.test(l) && /runEmbeddedCli/.test(l)) + if (line >= 0) { + fail( + file, + line + 1, + 'no-grammar-on-sim: runEmbeddedCli is reached only through lib/mothership/agent-cli/run-cli.ts', + src.split('\n')[line] + ) + } +} + +// 3. the public CLI package never imports agent code. +for (const file of walk(join(ROOT, 'packages/sim-cli/src'))) { + const lines = readFileSync(file, 'utf8').split('\n') + lines.forEach((line, index) => { + if (/from '@\/lib\/mothership/.test(line)) { + fail( + file, + index + 1, + 'cli-isolation: packages/sim-cli is the public product CLI and carries no agent concerns', + line + ) + } + }) +} + +if (failures.length > 0) { + for (const f of failures) console.error(f) + console.error(`\n${failures.length} agent-cli boundary violation(s).`) + process.exit(1) +} +console.log('agent-cli boundary checks passed') From a43c3712b9902f545d6df79808bd56bc0b934093 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 09:24:10 +0530 Subject: [PATCH 052/306] =?UTF-8?q?Phase=20A1=E2=80=93A4=20(sim=20half):?= =?UTF-8?q?=20inventory=20printer,=20universal=20grep,=20docs=20search,=20?= =?UTF-8?q?jq/outline=20pipes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/sim-cli/scripts/print-command-inventory.ts prints the public CLI inventory for the worker card — walks buildProgram(), the same tree --help and the docs read, and resolves each command response shape from the v2 OpenAPI documents. lib/mothership/agent-cli gains the universal-grep engine (every world, pretty-printed as its get returns it, block catalog LRU-cached per workspace, secrets names only), the docs-search engine (wrapping searchDocsServerTool), and typed jq (jq-wasm 1.8.2) and outline pipe stages; a stage that cannot apply fails the invocation with the reason. Still primitives only: no grammar, no flag interpretation. https://claude.ai/code/session_01HFVhPDtRZuCgupPco633w8 --- .../agent-cli/engines/docs-search.test.ts | 42 +++ .../agent-cli/engines/docs-search.ts | 38 +++ .../lib/mothership/agent-cli/engines/index.ts | 4 + .../agent-cli/engines/universal-grep.test.ts | 89 ++++++ .../agent-cli/engines/universal-grep.ts | 284 ++++++++++++++++++ apps/sim/lib/mothership/agent-cli/index.ts | 2 +- .../lib/mothership/agent-cli/pipeline.test.ts | 94 ++++-- apps/sim/lib/mothership/agent-cli/pipeline.ts | 105 ++++++- .../mothership/agent-cli/request-schema.ts | 8 +- .../sim/lib/mothership/generated/agent-cli.ts | 13 +- .../lib/mothership/tools/cli-tool-display.ts | 1 + .../tools/handlers/sim-cli-bridge.test.ts | 5 +- apps/sim/package.json | 3 +- bun.lock | 3 + .../scripts/print-command-inventory.ts | 188 ++++++++++++ 15 files changed, 841 insertions(+), 38 deletions(-) create mode 100644 apps/sim/lib/mothership/agent-cli/engines/docs-search.test.ts create mode 100644 apps/sim/lib/mothership/agent-cli/engines/docs-search.ts create mode 100644 apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts create mode 100644 apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts create mode 100644 packages/sim-cli/scripts/print-command-inventory.ts diff --git a/apps/sim/lib/mothership/agent-cli/engines/docs-search.test.ts b/apps/sim/lib/mothership/agent-cli/engines/docs-search.test.ts new file mode 100644 index 00000000000..1eb64609459 --- /dev/null +++ b/apps/sim/lib/mothership/agent-cli/engines/docs-search.test.ts @@ -0,0 +1,42 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +const { execute } = vi.hoisted(() => ({ + execute: vi + .fn() + .mockResolvedValue({ results: [{ path: 'docs/integrations/slack.mdx' }], query: 'q' }), +})) +vi.mock('@/lib/mothership/tools/server/docs/search-docs', () => ({ + searchDocsServerTool: { execute }, +})) + +import { runEngine } from '@/lib/mothership/agent-cli/engines' +import type { AgentCliRuntime } from '@/lib/mothership/agent-cli/types' + +const runtime: AgentCliRuntime = { + workspaceId: 'ws-1', + userId: 'user-1', + client: { request: async () => ({}) as never }, +} + +describe('docs search engine', () => { + it('joins the query words and maps --top/--path onto the docs search tool', async () => { + const result = await runEngine('docs search', ['slack', 'streaming'], runtime, { + top: '3', + path: 'docs/integrations', + }) + expect(result.exitCode).toBe(0) + expect(execute).toHaveBeenCalledWith( + { query: 'slack streaming', topK: 3, path: 'docs/integrations' }, + { userId: 'user-1', workspaceId: 'ws-1' } + ) + expect(JSON.parse(result.stdout).results[0].path).toBe('docs/integrations/slack.mdx') + }) + + it('fails usefully without a query or with a bad --top', async () => { + expect((await runEngine('docs search', [], runtime, {})).exitCode).toBe(1) + expect((await runEngine('docs search', ['x'], runtime, { top: 'many' })).exitCode).toBe(1) + }) +}) diff --git a/apps/sim/lib/mothership/agent-cli/engines/docs-search.ts b/apps/sim/lib/mothership/agent-cli/engines/docs-search.ts new file mode 100644 index 00000000000..d12dc302b85 --- /dev/null +++ b/apps/sim/lib/mothership/agent-cli/engines/docs-search.ts @@ -0,0 +1,38 @@ +import { + type AgentCliEngine, + type AgentCliFlags, + agentCliFail, + agentCliOk, +} from '@/lib/mothership/agent-cli/types' +import { searchDocsServerTool } from '@/lib/mothership/tools/server/docs/search-docs' + +const DEFAULT_TOP = 6 +const MAX_TOP = 20 + +function topFrom(flags: AgentCliFlags): number | string { + const raw = flags.top + if (raw === undefined || raw === true) return DEFAULT_TOP + const n = Number.parseInt(raw, 10) + if (!Number.isFinite(n) || n < 1) return '--top needs a positive number' + return Math.min(n, MAX_TOP) +} + +/** + * `docs search [--top n] [--path prefix]` — the product docs, through the same + * engine the `search_docs` tool used, as a noun in the CLI grammar (18-agent-surface.md + * A3). One knowledge surface: the tips corpus merges into these pages over time. + */ +export const docsSearchCommand: AgentCliEngine = { + async execute(positionals, runtime, flags) { + const query = positionals.join(' ').trim() + if (!query) return agentCliFail('Usage: sim docs search [--top n] [--path prefix]') + const top = topFrom(flags) + if (typeof top === 'string') return agentCliFail(top) + const path = typeof flags.path === 'string' ? flags.path : undefined + const output = await searchDocsServerTool.execute( + { query, topK: top, ...(path ? { path } : {}) }, + { userId: runtime.userId, workspaceId: runtime.workspaceId } + ) + return agentCliOk(JSON.stringify(output, null, 2)) + }, +} diff --git a/apps/sim/lib/mothership/agent-cli/engines/index.ts b/apps/sim/lib/mothership/agent-cli/engines/index.ts index 0e3051b8ad1..f6972dc0d1f 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/index.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/index.ts @@ -1,10 +1,12 @@ import { getErrorMessage } from '@sim/utils/errors' import { workflowDepsCommand } from '@/lib/mothership/agent-cli/engines/deps' +import { docsSearchCommand } from '@/lib/mothership/agent-cli/engines/docs-search' import { filesGrepCommand } from '@/lib/mothership/agent-cli/engines/files-grep' import { workflowGrepCommand, workflowsGrepCommand } from '@/lib/mothership/agent-cli/engines/grep' import { workflowLintCommand } from '@/lib/mothership/agent-cli/engines/lint' import { logsQueryCommand } from '@/lib/mothership/agent-cli/engines/query' import { workflowTraceCommand } from '@/lib/mothership/agent-cli/engines/trace' +import { universalGrepCommand } from '@/lib/mothership/agent-cli/engines/universal-grep' import { workflowBlocksCommand, workflowEdgesCommand, @@ -23,7 +25,9 @@ import { * augmentation-drift check reads these keys. */ export const AUGMENTATION_ENGINES: Readonly> = { + 'docs search': docsSearchCommand, 'files grep': filesGrepCommand, + grep: universalGrepCommand, 'logs query': logsQueryCommand, 'workflow blocks': workflowBlocksCommand, 'workflow deps': workflowDepsCommand, diff --git a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts new file mode 100644 index 00000000000..025f0171e40 --- /dev/null +++ b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { runEngine } from '@/lib/mothership/agent-cli/engines' +import type { AgentCliRuntime } from '@/lib/mothership/agent-cli/types' + +const SLACK_V2 = { + id: 'slack_v2', + name: 'Slack', + triggers: [{ id: 'slack_webhook', configFields: { streamOutputs: { type: 'boolean' } } }], + operations: { send_message: { toolId: 'slack_send' } }, +} + +function runtimeWith(responses: Record): AgentCliRuntime { + return { + workspaceId: `ws-${Math.random().toString(36).slice(2)}`, + userId: 'user-1', + client: { + request: async (path: string): Promise => { + const hit = responses[path] + if (hit === undefined) throw new Error(`Unexpected request: ${path}`) + return hit as T + }, + }, + } +} + +const CATALOG = { + '/api/v2/blocks': { data: [{ id: 'slack_v2' }, { id: 'agent' }], nextCursor: null }, + '/api/v2/blocks/slack_v2': { data: SLACK_V2 }, + '/api/v2/blocks/agent': { data: { id: 'agent', name: 'Agent', inputSchema: [{ id: 'model' }] } }, +} + +describe('universal grep', () => { + it('finds field ids inside block definitions and names the path-shaped line', async () => { + const result = await runEngine('grep', ['stream'], runtimeWith(CATALOG), { + scope: 'blocks', + i: true, + }) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('blocks/slack_v2:') + expect(result.stdout).toContain('"streamOutputs"') + expect(result.stdout).not.toContain('blocks/agent:') + }) + + it('narrows to one resource with --in and counts with --count', async () => { + const within = await runEngine('grep', ['id'], runtimeWith(CATALOG), { + scope: 'blocks', + in: 'agent', + }) + expect(within.stdout).toContain('blocks/agent:') + expect(within.stdout).not.toContain('blocks/slack_v2:') + const count = await runEngine('grep', ['id'], runtimeWith(CATALOG), { + scope: 'blocks', + count: true, + }) + expect(count.stdout).toMatch(/^\d+ \(blocks=\d+\)$/) + }) + + it('refuses an unknown scope with a did-you-mean and the scope list', async () => { + const result = await runEngine('grep', ['x'], runtimeWith({}), { scope: 'block' }) + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('Did you mean blocks') + expect(result.stderr).toContain('workflows, blocks, tools') + }) + + it('materializes secrets as names only', async () => { + const result = await runEngine( + 'grep', + ['OPENAI'], + runtimeWith({ + '/api/v2/secrets': { + data: [{ name: 'OPENAI_API_KEY', value: 'sk-should-never-appear' }], + nextCursor: null, + }, + }), + { scope: 'secrets' } + ) + expect(result.stdout).toContain('OPENAI_API_KEY') + expect(result.stdout).not.toContain('sk-should-never-appear') + }) + + it('reports no matches honestly', async () => { + const result = await runEngine('grep', ['zzz-nope'], runtimeWith(CATALOG), { scope: 'blocks' }) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('No matches for "zzz-nope" in blocks') + }) +}) diff --git a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts new file mode 100644 index 00000000000..2c8f954cd39 --- /dev/null +++ b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts @@ -0,0 +1,284 @@ +import { LRUCache } from 'lru-cache' +import { + type AgentCliEngine, + type AgentCliFlags, + type AgentCliRuntime, + agentCliFail, + agentCliOk, +} from '@/lib/mothership/agent-cli/types' + +/** + * `grep [--scope a,b] [--in ] [-i] [-C n] [--count] [--limit n]` — + * ONE search over the materialized text of every world the agent can see (18-agent- + * surface.md A2). Each resource is rendered to pretty-printed JSON exactly as its + * `get` command returns it, so a hit names the same path the model would read next: + * blocks/slack_v2.triggers[0].configFields.streamOutputs: {...} + * + * The VFS grep, with the corpus back — including component definitions, which no + * list `--search` can see into. Knowledge stays with its semantic `knowledge search`. + */ + +const SCOPES = [ + 'workflows', + 'blocks', + 'tools', + 'tables', + 'skills', + 'custom-tools', + 'secrets', + 'credentials', +] as const +type Scope = (typeof SCOPES)[number] + +const DEFAULT_MATCH_LIMIT = 100 +const MAX_MATCH_LIMIT = 500 +const MAX_LINE_CHARS = 2_000 +const FETCH_CONCURRENCY = 8 +/** The block catalog is platform-owned and changes only on deploy; per-workspace visibility keys it. */ +const catalogCache = new LRUCache({ max: 500, ttl: 10 * 60_000 }) + +interface Materialized { + scope: Scope + /** Display identity: the resource's name when it has one, else its id. */ + label: string + id: string + text: string +} + +interface Page { + data: Record[] + nextCursor?: string | null +} + +function str(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +async function listAll(runtime: AgentCliRuntime, path: string): Promise[]> { + const out: Record[] = [] + let cursor: string | undefined + for (let pages = 0; pages < 50; pages++) { + const page = await runtime.client.request(path, { + query: { limit: '100', ...(cursor ? { cursor } : {}) }, + }) + out.push(...page.data) + if (!page.nextCursor) break + cursor = page.nextCursor + } + return out +} + +async function mapConcurrent( + items: T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const results: R[] = new Array(items.length) + let next = 0 + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + while (next < items.length) { + const index = next++ + results[index] = await fn(items[index]) + } + }) + ) + return results +} + +function render(scope: Scope, id: string, label: string, value: unknown): Materialized { + return { scope, id, label, text: JSON.stringify(value, null, 2) } +} + +/** One materializer per scope: the list, then each resource as its `get` returns it. */ +const MATERIALIZERS: Record Promise> = { + workflows: async (runtime) => { + const list = await listAll(runtime, '/api/v2/workflows') + return mapConcurrent(list, FETCH_CONCURRENCY, async (w) => { + const id = str(w.id) ?? '' + const exported = await runtime.client.request<{ data: { state: unknown } }>( + `/api/v2/workflows/${id}/export` + ) + return render('workflows', id, str(w.name) ?? id, exported.data.state) + }) + }, + blocks: async (runtime) => { + const key = runtime.workspaceId + const cached = catalogCache.get(key) + if (cached !== undefined) return cached + const list = await listAll(runtime, '/api/v2/blocks') + const materialized = await mapConcurrent(list, FETCH_CONCURRENCY, async (b) => { + const id = str(b.id) ?? '' + const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/blocks/${id}`) + return render('blocks', id, id, detail.data) + }) + catalogCache.set(key, materialized) + return materialized + }, + tools: async (runtime) => { + const list = await listAll(runtime, '/api/v2/tools') + return list.map((t) => render('tools', str(t.id) ?? '', str(t.id) ?? '', t)) + }, + tables: async (runtime) => { + const list = await listAll(runtime, '/api/v2/tables') + return mapConcurrent(list, FETCH_CONCURRENCY, async (t) => { + const id = str(t.id) ?? '' + const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/tables/${id}`) + return render('tables', id, str(t.name) ?? id, detail.data) + }) + }, + skills: async (runtime) => { + const list = await listAll(runtime, '/api/v2/skills') + return mapConcurrent(list, FETCH_CONCURRENCY, async (s) => { + const id = str(s.id) ?? '' + const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/skills/${id}`) + return render('skills', id, str(s.name) ?? id, detail.data) + }) + }, + 'custom-tools': async (runtime) => { + const list = await listAll(runtime, '/api/v2/custom-tools') + return mapConcurrent(list, FETCH_CONCURRENCY, async (t) => { + const id = str(t.id) ?? '' + const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/custom-tools/${id}`) + return render('custom-tools', id, str(t.title) ?? str(t.name) ?? id, detail.data) + }) + }, + secrets: async (runtime) => { + // Names only, by construction: a secret's value never enters the model window. + const list = await listAll(runtime, '/api/v2/secrets') + return list.map((s) => + render('secrets', str(s.name) ?? '', str(s.name) ?? '', { name: s.name }) + ) + }, + credentials: async (runtime) => { + const list = await listAll(runtime, '/api/v2/credentials') + return list.map((c) => + render('credentials', str(c.id) ?? '', str(c.name) ?? str(c.id) ?? '', { + id: c.id, + name: c.name, + provider: c.provider ?? c.providerId, + type: c.type, + }) + ) + }, +} + +function compilePattern(raw: string, ignoreCase: boolean): (line: string) => boolean { + try { + const regex = new RegExp(raw, ignoreCase ? 'i' : '') + return (line) => regex.test(line) + } catch { + const needle = ignoreCase ? raw.toLowerCase() : raw + return (line) => (ignoreCase ? line.toLowerCase() : line).includes(needle) + } +} + +function clip(line: string): string { + return line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}… [line truncated]` : line +} + +function didYouMean(scope: string): string { + const close = SCOPES.filter((s) => s.startsWith(scope.slice(0, 2))) + return close.length > 0 ? ` Did you mean ${close.join(' or ')}?` : '' +} + +function parseScopes(flags: AgentCliFlags): Scope[] | string { + const raw = flags.scope + if (raw === undefined || raw === true) return [...SCOPES] + const scopes: Scope[] = [] + for (const part of raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean)) { + if (!(SCOPES as readonly string[]).includes(part)) { + return `Unknown scope "${part}".${didYouMean(part)} Scopes: ${SCOPES.join(', ')}.` + } + scopes.push(part as Scope) + } + return scopes +} + +function parseLimit(flags: AgentCliFlags): number | string { + const raw = flags.limit + if (raw === undefined || raw === true) return DEFAULT_MATCH_LIMIT + const n = Number.parseInt(raw, 10) + if (!Number.isFinite(n) || n < 1) return '--limit needs a positive number' + return Math.min(n, MAX_MATCH_LIMIT) +} + +function parseContext(flags: AgentCliFlags): number | string { + const raw = flags.C + if (raw === undefined || raw === true) return 0 + const n = Number.parseInt(raw, 10) + if (!Number.isFinite(n) || n < 0) return '-C needs a non-negative number' + return n +} + +export const universalGrepCommand: AgentCliEngine = { + async execute(positionals, runtime, flags) { + const pattern = positionals[0] + if (!pattern) { + return agentCliFail( + 'Usage: sim grep [--scope workflows,blocks,...] [--in ] [-i] [-C n] [--count] [--limit n]' + ) + } + const scopes = parseScopes(flags) + if (typeof scopes === 'string') return agentCliFail(scopes) + const limit = parseLimit(flags) + if (typeof limit === 'string') return agentCliFail(limit) + const context = parseContext(flags) + if (typeof context === 'string') return agentCliFail(context) + const ignoreCase = flags.i === true + const countOnly = flags.count === true + const within = typeof flags.in === 'string' ? flags.in.toLowerCase() : undefined + const matches = compilePattern(pattern, ignoreCase) + + const materialized = ( + await Promise.all(scopes.map((scope) => MATERIALIZERS[scope](runtime))) + ).flat() + const candidates = within + ? materialized.filter( + (m) => m.id.toLowerCase() === within || m.label.toLowerCase().includes(within) + ) + : materialized + + const out: string[] = [] + let total = 0 + const perScope = new Map() + for (const resource of candidates) { + const lines = resource.text.split('\n') + const selected = new Set() + for (let i = 0; i < lines.length; i++) { + if (!matches(lines[i])) continue + total++ + perScope.set(resource.scope, (perScope.get(resource.scope) ?? 0) + 1) + if (countOnly) continue + for (let j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); j++) { + selected.add(j) + } + } + if (countOnly || selected.size === 0) continue + const header = `${resource.scope}/${resource.label}${resource.label === resource.id ? '' : ` (${resource.id})`}` + for (const i of [...selected].sort((a, b) => a - b)) { + if (out.length >= limit) break + out.push(`${header}:${i + 1}: ${clip(lines[i])}`) + } + if (out.length >= limit) break + } + + if (countOnly) { + const breakdown = [...perScope.entries()].map(([s, n]) => `${s}=${n}`).join(' ') + return agentCliOk(`${total}${breakdown ? ` (${breakdown})` : ''}`) + } + if (out.length === 0) { + return agentCliOk( + `No matches for ${JSON.stringify(pattern)} in ${scopes.join(', ')}${within ? ` within "${within}"` : ''}.` + ) + } + const truncated = + total > out.length + ? `\n[${out.length} of ${total} matching lines shown — narrow with --scope, --in, or a tighter pattern]` + : '' + return agentCliOk(out.join('\n') + truncated) + }, +} diff --git a/apps/sim/lib/mothership/agent-cli/index.ts b/apps/sim/lib/mothership/agent-cli/index.ts index 086c751871a..268b4ed47ae 100644 --- a/apps/sim/lib/mothership/agent-cli/index.ts +++ b/apps/sim/lib/mothership/agent-cli/index.ts @@ -53,7 +53,7 @@ export async function executeAgentCliRequest( result = await runCli(request.invocation.argv, identity, sessionKey) } if (result.exitCode === 0 && request.pipeline.length > 0) { - result = { ...result, stdout: applyPipeline(result.stdout, request.pipeline) } + result = await applyPipeline(result, request.pipeline) } return request.sink ? applySink(request.sink, sessionKey, result) : result } diff --git a/apps/sim/lib/mothership/agent-cli/pipeline.test.ts b/apps/sim/lib/mothership/agent-cli/pipeline.test.ts index 49bf003bb55..575aa0cd73b 100644 --- a/apps/sim/lib/mothership/agent-cli/pipeline.test.ts +++ b/apps/sim/lib/mothership/agent-cli/pipeline.test.ts @@ -2,8 +2,13 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { applyPipeline } from '@/lib/mothership/agent-cli/pipeline' -import type { AgentCliGrepStage } from '@/lib/mothership/generated/agent-cli' +import { applyPipeline as applyPipelineRaw } from '@/lib/mothership/agent-cli/pipeline' +import type { AgentCliGrepStage, AgentCliPipeStage } from '@/lib/mothership/generated/agent-cli' + +async function applyPipeline(stdout: string, stages: AgentCliPipeStage[]): Promise { + const result = await applyPipelineRaw({ exitCode: 0, stdout, stderr: '' }, stages) + return result.exitCode === 0 ? result.stdout : `ERROR ${result.stderr}` +} function grep(overrides: Partial & { pattern: string }): AgentCliGrepStage { return { @@ -21,36 +26,40 @@ function grep(overrides: Partial & { pattern: string }): Agen describe('applyPipeline over typed grep stages', () => { const input = 'alpha slack\nbeta\ngamma SLACK\nslack delta\n' - it('filters lines by pattern', () => { - expect(applyPipeline(input, [grep({ pattern: 'slack' })])).toBe('alpha slack\nslack delta') + it('filters lines by pattern', async () => { + expect(await applyPipeline(input, [grep({ pattern: 'slack' })])).toBe( + 'alpha slack\nslack delta' + ) }) - it('honours ignoreCase, lineNumbers, invert, countOnly, and maxCount', () => { - expect(applyPipeline(input, [grep({ pattern: 'slack', ignoreCase: true })])).toBe( + it('honours ignoreCase, lineNumbers, invert, countOnly, and maxCount', async () => { + expect(await applyPipeline(input, [grep({ pattern: 'slack', ignoreCase: true })])).toBe( 'alpha slack\ngamma SLACK\nslack delta' ) - expect(applyPipeline(input, [grep({ pattern: 'slack', lineNumbers: true })])).toBe( + expect(await applyPipeline(input, [grep({ pattern: 'slack', lineNumbers: true })])).toBe( '1:alpha slack\n4:slack delta' ) - expect(applyPipeline(input, [grep({ pattern: 'slack', invert: true })])).toBe( + expect(await applyPipeline(input, [grep({ pattern: 'slack', invert: true })])).toBe( 'beta\ngamma SLACK\n' ) expect( - applyPipeline(input, [grep({ pattern: 'slack', ignoreCase: true, countOnly: true })]) + await applyPipeline(input, [grep({ pattern: 'slack', ignoreCase: true, countOnly: true })]) ).toBe('3') - expect(applyPipeline(input, [grep({ pattern: 'slack', ignoreCase: true, maxCount: 2 })])).toBe( - 'alpha slack\ngamma SLACK' - ) + expect( + await applyPipeline(input, [grep({ pattern: 'slack', ignoreCase: true, maxCount: 2 })]) + ).toBe('alpha slack\ngamma SLACK') }) - it('treats the pattern as a regex with a literal fallback', () => { - expect(applyPipeline('a1\nb2\nc3', [grep({ pattern: '^[ab]' })])).toBe('a1\nb2') - expect(applyPipeline('cost is $4 (net', [grep({ pattern: '$4 (net' })])).toBe('cost is $4 (net') + it('treats the pattern as a regex with a literal fallback', async () => { + expect(await applyPipeline('a1\nb2\nc3', [grep({ pattern: '^[ab]' })])).toBe('a1\nb2') + expect(await applyPipeline('cost is $4 (net', [grep({ pattern: '$4 (net' })])).toBe( + 'cost is $4 (net' + ) }) - it('chains stages left to right', () => { + it('chains stages left to right', async () => { expect( - applyPipeline(input, [ + await applyPipeline(input, [ grep({ pattern: 'slack', ignoreCase: true }), grep({ pattern: 'delta', invert: true }), ]) @@ -59,18 +68,53 @@ describe('applyPipeline over typed grep stages', () => { describe('context windows', () => { const lines = 'a\nb\nHIT\nc\nd\ne\nHIT\nf' - it('trailing context', () => { - expect(applyPipeline(lines, [grep({ pattern: 'HIT', linesAfter: 1 })])).toBe('HIT\nc\nHIT\nf') + it('trailing context', async () => { + expect(await applyPipeline(lines, [grep({ pattern: 'HIT', linesAfter: 1 })])).toBe( + 'HIT\nc\nHIT\nf' + ) }) - it('windows without duplicating overlaps', () => { + it('windows without duplicating overlaps', async () => { expect( - applyPipeline('x\nHIT\nHIT\ny', [grep({ pattern: 'HIT', linesBefore: 1, linesAfter: 1 })]) + await applyPipeline('x\nHIT\nHIT\ny', [ + grep({ pattern: 'HIT', linesBefore: 1, linesAfter: 1 }), + ]) ).toBe('x\nHIT\nHIT\ny') }) - it('counts hits, not context lines', () => { - expect(applyPipeline(lines, [grep({ pattern: 'HIT', countOnly: true, linesAfter: 2 })])).toBe( - '2' - ) + it('counts hits, not context lines', async () => { + expect( + await applyPipeline(lines, [grep({ pattern: 'HIT', countOnly: true, linesAfter: 2 })]) + ).toBe('2') }) }) }) + +describe('jq and outline over JSON stdout', () => { + const json = JSON.stringify({ + data: { + operations: { send: { toolId: 'slack_send' }, list: { toolId: 'slack_list' } }, + tags: ['a', 'b'], + }, + }) + + it('jq slices with real jq semantics', async () => { + expect(await applyPipeline(json, [{ kind: 'jq', expression: '.data.operations | keys' }])).toBe( + '[\n "list",\n "send"\n]' + ) + expect(await applyPipeline(json, [{ kind: 'jq', expression: '.data.tags[]' }])).toBe('"a"\n"b"') + }) + + it('outline reports keys, types, and counts without values', async () => { + const outline = await applyPipeline(json, [{ kind: 'outline' }]) + expect(outline).toContain('data: object{2}') + expect(outline).toContain('operations: object{2}') + expect(outline).toContain('tags: array[2]') + expect(outline).not.toContain('slack_send') + }) + + it('fails the invocation with the reason when stdout is not JSON or the program is bad', async () => { + expect(await applyPipeline('plain text', [{ kind: 'jq', expression: '.' }])).toContain( + 'stdout is not JSON' + ) + expect(await applyPipeline(json, [{ kind: 'jq', expression: '.data |' }])).toContain('jq:') + }) +}) diff --git a/apps/sim/lib/mothership/agent-cli/pipeline.ts b/apps/sim/lib/mothership/agent-cli/pipeline.ts index 43b4bcb97b3..4411d1d3a93 100644 --- a/apps/sim/lib/mothership/agent-cli/pipeline.ts +++ b/apps/sim/lib/mothership/agent-cli/pipeline.ts @@ -1,11 +1,25 @@ -import type { AgentCliGrepStage, AgentCliPipeStage } from '@/lib/mothership/generated/agent-cli' +import { raw as jqRaw } from 'jq-wasm' +import type { + AgentCliGrepStage, + AgentCliPipeStage, + AgentCliRawResult, +} from '@/lib/mothership/generated/agent-cli' /** - * Applies the worker's already-parsed pipe stages to a command's stdout. Grep is a - * native filter over the string — nothing is spawned — and every option arrives typed, - * so no flag is ever interpreted on this side. + * Applies the worker's already-parsed pipe stages to a command's result. Every option + * arrives typed, so no flag is ever interpreted on this side: + * - grep: a native filter over the string — nothing is spawned. + * - jq: real jq (1.8, WebAssembly) over JSON stdout — the model's slicing tool, with + * the semantics it already knows. + * - outline: keys, types, and counts to depth 3, no values — the shape of a big + * response for the price of a few lines. + * A stage that cannot apply (non-JSON stdout, a jq error) fails the invocation with + * the reason on stderr, so the model corrects the pipe instead of reading garbage. */ +const OUTLINE_MAX_DEPTH = 3 +const OUTLINE_MAX_KEYS = 40 + function compileGrepPattern(raw: string, ignoreCase: boolean): (line: string) => boolean { try { const regex = new RegExp(raw, ignoreCase ? 'i' : '') @@ -38,8 +52,83 @@ function runGrep(input: string, stage: AgentCliGrepStage): string { return out.map((i) => (stage.lineNumbers ? `${i + 1}:${lines[i]}` : lines[i])).join('\n') } -export function applyPipeline(stdout: string, stages: readonly AgentCliPipeStage[]): string { - let current = stdout - for (const stage of stages) current = runGrep(current, stage) - return current +class PipeStageError extends Error {} + +/** JSON.parse's result space, spelled out: what jq accepts as input. */ +type JsonValue = string | number | boolean | object | null + +function parseJsonStdout(stdout: string, stage: string): JsonValue { + try { + const parsed: JsonValue = JSON.parse(stdout) + return parsed + } catch { + throw new PipeStageError( + `${stage}: stdout is not JSON. Run the command with --output json before piping into ${stage}.` + ) + } +} + +async function runJq(input: string, expression: string): Promise { + const value = parseJsonStdout(input, 'jq') + const result = await jqRaw(value, expression) + if (result.exitCode !== 0) { + throw new PipeStageError(`jq: ${result.stderr.trim() || `exited with code ${result.exitCode}`}`) + } + return result.stdout.trimEnd() +} + +function describe(value: unknown, depth: number, indent: string, out: string[]): void { + if (Array.isArray(value)) { + out.push(`${indent}[${value.length} items]`) + if (depth < OUTLINE_MAX_DEPTH && value.length > 0) + describe(value[0], depth + 1, `${indent} `, out) + return + } + if (value !== null && typeof value === 'object') { + const entries = Object.entries(value as Record) + for (const [key, child] of entries.slice(0, OUTLINE_MAX_KEYS)) { + const kind = Array.isArray(child) + ? `array[${child.length}]` + : child === null + ? 'null' + : typeof child === 'object' + ? `object{${Object.keys(child as object).length}}` + : typeof child + out.push(`${indent}${key}: ${kind}`) + if (depth < OUTLINE_MAX_DEPTH && child !== null && typeof child === 'object') { + describe(child, depth + 1, `${indent} `, out) + } + } + if (entries.length > OUTLINE_MAX_KEYS) + out.push(`${indent}… ${entries.length - OUTLINE_MAX_KEYS} more keys`) + return + } + out.push(`${indent}${typeof value}`) +} + +function runOutline(input: string): string { + const value = parseJsonStdout(input, 'outline') + const out: string[] = [] + describe(value, 1, '', out) + return out.join('\n') +} + +export async function applyPipeline( + result: AgentCliRawResult, + stages: readonly AgentCliPipeStage[] +): Promise { + let current = result.stdout + try { + for (const stage of stages) { + if (stage.kind === 'grep') current = runGrep(current, stage) + else if (stage.kind === 'jq') current = await runJq(current, stage.expression) + else current = runOutline(current) + } + } catch (error) { + if (error instanceof PipeStageError) { + return { exitCode: 1, stdout: '', stderr: `Error: ${error.message}` } + } + throw error + } + return { ...result, stdout: current } } diff --git a/apps/sim/lib/mothership/agent-cli/request-schema.ts b/apps/sim/lib/mothership/agent-cli/request-schema.ts index d9b31f7f7cd..49016237903 100644 --- a/apps/sim/lib/mothership/agent-cli/request-schema.ts +++ b/apps/sim/lib/mothership/agent-cli/request-schema.ts @@ -28,6 +28,12 @@ export const agentCliRequestSchema = z.object({ flags: z.record(z.string(), z.union([z.string(), z.literal(true)])), }), ]), - pipeline: z.array(grepStageSchema), + pipeline: z.array( + z.discriminatedUnion('kind', [ + grepStageSchema, + z.object({ kind: z.literal('jq'), expression: z.string().min(1).max(4_000) }), + z.object({ kind: z.literal('outline') }), + ]) + ), sink: z.object({ kind: z.literal('sandbox-file'), path: z.string().min(1).max(300) }).optional(), }) satisfies z.ZodType diff --git a/apps/sim/lib/mothership/generated/agent-cli.ts b/apps/sim/lib/mothership/generated/agent-cli.ts index bf8bf1ad52f..802e03e474e 100644 --- a/apps/sim/lib/mothership/generated/agent-cli.ts +++ b/apps/sim/lib/mothership/generated/agent-cli.ts @@ -24,7 +24,18 @@ export interface AgentCliGrepStage { linesAfter: number; } -export type AgentCliPipeStage = AgentCliGrepStage; +/** A jq program applied to JSON stdout — the model's slicing tool; real jq semantics. */ +export interface AgentCliJqStage { + kind: "jq"; + expression: string; +} + +/** Keys, types, and counts of JSON stdout to depth 3, no values — the shape, cheaply. */ +export interface AgentCliOutlineStage { + kind: "outline"; +} + +export type AgentCliPipeStage = AgentCliGrepStage | AgentCliJqStage | AgentCliOutlineStage; /** Where stdout lands instead of the model window. */ export interface AgentCliSandboxFileSink { diff --git a/apps/sim/lib/mothership/tools/cli-tool-display.ts b/apps/sim/lib/mothership/tools/cli-tool-display.ts index be3e5d64f93..d3483907567 100644 --- a/apps/sim/lib/mothership/tools/cli-tool-display.ts +++ b/apps/sim/lib/mothership/tools/cli-tool-display.ts @@ -224,6 +224,7 @@ export const CLI_TOOL_TITLES: Record = { cli_workspaces_members: 'Listing workspace members', // Agent-only CLI augmentations cli_files_grep: 'Searching file contents', + cli_grep: 'Searching the workspace', cli_logs_query: 'Querying run history', cli_workflow_blocks: 'Listing workflow blocks', cli_workflow_deps: 'Tracing block inputs', diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli-bridge.test.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli-bridge.test.ts index 7958257abd9..17d8aa1043f 100644 --- a/apps/sim/lib/mothership/tools/handlers/sim-cli-bridge.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/sim-cli-bridge.test.ts @@ -19,7 +19,10 @@ vi.mock('sim/embed', () => ({ createEmbeddedClient: vi.fn(), })) vi.mock('@/lib/mothership/chat/delegation', () => ({ mintDelegationToken: mockMint })) -vi.mock('@/lib/core/utils/urls', () => ({ getInternalApiBaseUrl: () => 'http://internal' })) +vi.mock('@/lib/core/utils/urls', () => ({ + getInternalApiBaseUrl: () => 'http://internal', + SITE_URL: 'http://sim.test', +})) import type { AgentCliRequest } from '@/lib/mothership/generated/agent-cli' import { executeSimCli } from '@/lib/mothership/tools/handlers/sim-cli' diff --git a/apps/sim/package.json b/apps/sim/package.json index 1c130c207ed..6caf4d20033 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -175,8 +175,8 @@ "date-fns": "4.1.0", "decimal.js": "10.6.0", "diff": "8.0.4", - "docx-preview": "^0.3.7", "docx": "^9.6.1", + "docx-preview": "^0.3.7", "drizzle-orm": "^0.45.2", "echarts": "6.1.0", "es-toolkit": "1.45.1", @@ -196,6 +196,7 @@ "ioredis": "^5.6.0", "isolated-vm": "6.2.0", "jose": "6.0.11", + "jq-wasm": "3.0.0-jq-1.8.2", "js-tiktoken": "1.0.21", "js-yaml": "4.3.2", "jsdom": "^26.0.0", diff --git a/bun.lock b/bun.lock index 7a63cea0e67..d7b2658e9c1 100644 --- a/bun.lock +++ b/bun.lock @@ -318,6 +318,7 @@ "ioredis": "^5.6.0", "isolated-vm": "6.2.0", "jose": "6.0.11", + "jq-wasm": "3.0.0-jq-1.8.2", "js-tiktoken": "1.0.21", "js-yaml": "4.3.2", "jsdom": "^26.0.0", @@ -3457,6 +3458,8 @@ "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "jq-wasm": ["jq-wasm@3.0.0-jq-1.8.2", "", {}, "sha512-jgWSEBJSd0lYR4Q5Fw8333MxQS5jCRI+g9KwAGL7yK1spwzTJy8C5uOS08wbpKE0Gz8oQYLlRpNLE/W70Ksp2g=="], + "js-md4": ["js-md4@0.3.2", "", {}, "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA=="], "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], diff --git a/packages/sim-cli/scripts/print-command-inventory.ts b/packages/sim-cli/scripts/print-command-inventory.ts new file mode 100644 index 00000000000..29690bd95d0 --- /dev/null +++ b/packages/sim-cli/scripts/print-command-inventory.ts @@ -0,0 +1,188 @@ +/** + * Prints the public CLI's command inventory as JSON, for the mothership worker's agent + * grammar (its reference card, routing, and display names are generated from this). + * + * The source is `buildProgram()` — the same command tree `--help` and the generated docs + * read — so the model's card can never describe a command the CLI does not have. Each + * leaf carries its positionals and options as commander declares them, plus the top-level + * shape of its JSON response resolved from the v2 OpenAPI documents. + * + * bun run packages/sim-cli/scripts/print-command-inventory.ts > inventory.json + */ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Command } from 'commander' +import { CLI_CONTRACT } from '../src/contract/commands' +import { V2_OPERATIONS } from '../src/generated/v2-api' +import { buildProgram } from '../src/program' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..') +const HELP_COMMAND = 'help' + +interface InventoryArgument { + name: string + required: boolean + variadic: boolean + description: string +} + +interface InventoryOption { + /** e.g. "--limit " */ + flags: string + name: string + takesValue: boolean + required: boolean + description: string + defaultValue?: string +} + +interface InventoryShapeField { + name: string + type: string +} + +interface InventoryCommand { + path: string[] + description: string + args: InventoryArgument[] + options: InventoryOption[] + /** Top-level fields of the JSON response's `data`, when the operation is known. */ + shape?: InventoryShapeField[] +} + +function isHiddenCommand(command: Command): boolean { + return (command as Command & { _hidden?: boolean })._hidden === true +} + +function subcommands(command: Command): Command[] { + return command.commands.filter( + (child) => child.name() !== HELP_COMMAND && !isHiddenCommand(child) + ) +} + +function collectLeaves(command: Command, prefix: string[]): { path: string[]; command: Command }[] { + const children = subcommands(command) + if (children.length === 0) return [{ path: prefix, command }] + return children.flatMap((child) => collectLeaves(child, [...prefix, child.name()])) +} + +/** Command path → v2 operation name, through the contract's declared command strings. */ +const OPERATION_BY_PATH = new Map() +for (const [operation, spec] of Object.entries(CLI_CONTRACT)) { + if (spec && 'command' in spec && typeof spec.command === 'string') { + OPERATION_BY_PATH.set(spec.command, operation) + } +} + +type JsonSchema = { + $ref?: string + type?: string | string[] + properties?: Record + items?: JsonSchema + anyOf?: JsonSchema[] + oneOf?: JsonSchema[] + allOf?: JsonSchema[] + nullable?: boolean + enum?: unknown[] +} + +interface OpenApiDoc { + paths: Record< + string, + Record< + string, + { responses?: Record }> } + > + > + components?: { schemas?: Record } +} + +const OPENAPI_DOCS: OpenApiDoc[] = fs + .readdirSync(path.join(ROOT, 'apps/docs')) + .filter((name) => /^openapi-v2-.*\.json$/.test(name)) + .map( + (name) => JSON.parse(fs.readFileSync(path.join(ROOT, 'apps/docs', name), 'utf8')) as OpenApiDoc + ) + +function resolveRef(doc: OpenApiDoc, schema: JsonSchema | undefined): JsonSchema | undefined { + let current = schema + for (let hops = 0; current?.$ref && hops < 8; hops++) { + const name = current.$ref.split('/').pop() ?? '' + current = doc.components?.schemas?.[name] + } + return current +} + +function typeLabel(doc: OpenApiDoc, schema: JsonSchema | undefined): string { + const resolved = resolveRef(doc, schema) + if (!resolved) return 'unknown' + if (resolved.enum) return resolved.enum.map((v) => JSON.stringify(v)).join('|') + const variants = resolved.anyOf ?? resolved.oneOf + if (variants) return variants.map((v) => typeLabel(doc, v)).join('|') + const type = Array.isArray(resolved.type) ? resolved.type.join('|') : resolved.type + if (type === 'array') return `${typeLabel(doc, resolved.items)}[]` + if (type === 'object' || resolved.properties) { + const keys = Object.keys(resolved.properties ?? {}) + return keys.length > 0 + ? `{${keys.slice(0, 8).join(',')}${keys.length > 8 ? ',…' : ''}}` + : 'object' + } + return type ?? 'unknown' +} + +function responseShape(operation: string): InventoryShapeField[] | undefined { + const op = V2_OPERATIONS[operation as keyof typeof V2_OPERATIONS] + if (!op) return undefined + const docPath = op.path.replace(/\[([^\]]+)\]/g, '{$1}') + for (const doc of OPENAPI_DOCS) { + const entry = doc.paths[docPath]?.[op.method.toLowerCase()] + const schema = entry?.responses?.['200']?.content?.['application/json']?.schema + const resolved = resolveRef(doc, schema) + if (!resolved) continue + const data = resolveRef(doc, resolved.properties?.data) ?? resolved + const props = data.properties + if (!props) { + const items = resolveRef(doc, data.items) + if (items?.properties) { + return [{ name: '[]', type: typeLabel(doc, items) }] + } + return undefined + } + return Object.entries(props).map(([name, s]) => ({ name, type: typeLabel(doc, s) })) + } + return undefined +} + +const program = buildProgram() +const inventory: InventoryCommand[] = collectLeaves(program, []).map( + ({ path: cmdPath, command }) => { + const args: InventoryArgument[] = command.registeredArguments.map((argument) => ({ + name: argument.name(), + required: argument.required, + variadic: argument.variadic, + description: argument.description, + })) + const options: InventoryOption[] = command.options + .filter((option) => !option.hidden) + .map((option) => ({ + flags: option.flags, + name: option.attributeName(), + takesValue: option.required || option.optional, + required: option.mandatory, + description: option.description, + ...(option.defaultValue !== undefined ? { defaultValue: String(option.defaultValue) } : {}), + })) + const operation = OPERATION_BY_PATH.get(cmdPath.join(' ')) + const shape = operation ? responseShape(operation) : undefined + return { + path: cmdPath, + description: command.description(), + args, + options, + ...(shape ? { shape } : {}), + } + } +) + +process.stdout.write(`${JSON.stringify(inventory, null, 2)}\n`) From dfdc7649e253f2cd6bbb851bcb75c86aaca03020 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 09:44:06 +0530 Subject: [PATCH 053/306] mothership agent-cli: viewer curation primitive for blocks get (Phase B3) The v2 catalog already gates visibility, the integration allowlist, hidden-from- toolbar and hosted-key restrictions; the one Go-era curation it lacks is a permission group's deniedTools. When the mothership marks a request `curate: "block"`, `curation.ts` applies resolveDeniedBlockOperations to the block detail: a partially denied block loses the denied operations and their tools, a fully denied one is refused. Contract mirror synced; no v2 or public CLI change. Claude-Session: https://claude.ai/code/session_01HFVhPDtRZuCgupPco633w8 --- .../lib/mothership/agent-cli/curation.test.ts | 76 +++++++++++++++++++ apps/sim/lib/mothership/agent-cli/curation.ts | 60 +++++++++++++++ apps/sim/lib/mothership/agent-cli/index.ts | 4 + .../mothership/agent-cli/request-schema.ts | 1 + .../sim/lib/mothership/generated/agent-cli.ts | 6 ++ 5 files changed, 147 insertions(+) create mode 100644 apps/sim/lib/mothership/agent-cli/curation.test.ts create mode 100644 apps/sim/lib/mothership/agent-cli/curation.ts diff --git a/apps/sim/lib/mothership/agent-cli/curation.test.ts b/apps/sim/lib/mothership/agent-cli/curation.test.ts new file mode 100644 index 00000000000..87d4e407131 --- /dev/null +++ b/apps/sim/lib/mothership/agent-cli/curation.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { curateBlockDetail } from '@/lib/mothership/agent-cli/curation' + +const { permissionConfig, denied } = vi.hoisted(() => ({ + permissionConfig: { current: null as { deniedTools?: string[] } | null }, + denied: { + current: { + needsProjection: new Map>(), + fullyDenied: new Set(), + }, + }, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: vi.fn(async () => permissionConfig.current), +})) + +vi.mock('@/lib/mothership/integration-tool-projection', () => ({ + resolveDeniedBlockOperations: vi.fn(() => denied.current), +})) + +const viewer = { workspaceId: 'ws', userId: 'user' } + +function blockDetail() { + return { + type: 'slack', + operations: { + send: { toolId: 'slack_send' }, + canvas: { toolId: 'slack_canvas' }, + }, + tools: [{ id: 'slack_send' }, { id: 'slack_canvas' }], + } +} + +function ok(stdout: string) { + return { exitCode: 0, stdout, stderr: '' } +} + +describe('curateBlockDetail', () => { + beforeEach(() => { + permissionConfig.current = null + denied.current = { needsProjection: new Map(), fullyDenied: new Set() } + }) + + it('passes through when the viewer has no denied tools', async () => { + const input = ok(JSON.stringify(blockDetail())) + expect(await curateBlockDetail(input, viewer)).toBe(input) + }) + + it('passes through non-block output untouched', async () => { + permissionConfig.current = { deniedTools: ['slack_canvas'] } + const input = ok('not json') + expect(await curateBlockDetail(input, viewer)).toBe(input) + }) + + it('drops denied operations and their tools from a partially denied block', async () => { + permissionConfig.current = { deniedTools: ['slack_canvas'] } + denied.current = { + needsProjection: new Map([['slack', new Set(['canvas'])]]), + fullyDenied: new Set(), + } + const result = await curateBlockDetail(ok(JSON.stringify(blockDetail())), viewer) + expect(result.exitCode).toBe(0) + const curated = JSON.parse(result.stdout) + expect(Object.keys(curated.operations)).toEqual(['send']) + expect(curated.tools).toEqual([{ id: 'slack_send' }]) + }) + + it('refuses a fully denied block', async () => { + permissionConfig.current = { deniedTools: ['slack_send', 'slack_canvas'] } + denied.current = { needsProjection: new Map(), fullyDenied: new Set(['slack']) } + const result = await curateBlockDetail(ok(JSON.stringify(blockDetail())), viewer) + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('not available to you') + }) +}) diff --git a/apps/sim/lib/mothership/agent-cli/curation.ts b/apps/sim/lib/mothership/agent-cli/curation.ts new file mode 100644 index 00000000000..fb9f7ab4930 --- /dev/null +++ b/apps/sim/lib/mothership/agent-cli/curation.ts @@ -0,0 +1,60 @@ +/** + * Viewer curation for `blocks get` (18-agent-surface.md B3). The v2 catalog already + * hides blocks by visibility, allowlist and hosted-key restrictions, but it does not + * apply a permission group's `deniedTools`; the mothership asks for `curate: "block"` + * so a partially-denied block is trimmed to the operations this viewer may configure. + */ + +import { agentCliFail } from '@/lib/mothership/agent-cli/types' +import type { AgentCliRawResult } from '@/lib/mothership/generated/agent-cli' +import { resolveDeniedBlockOperations } from '@/lib/mothership/integration-tool-projection' +import { createToolAccessGate } from '@/lib/permission-groups/operation-access' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' + +export interface CurationViewer { + workspaceId: string + userId: string +} + +interface BlockDetailShape { + type: string + operations?: Record + tools?: Array<{ id?: unknown }> +} + +function parseBlockDetail(stdout: string): BlockDetailShape | null { + let parsed: unknown + try { + parsed = JSON.parse(stdout) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null + const candidate = parsed as { type?: unknown } + return typeof candidate.type === 'string' ? (parsed as BlockDetailShape) : null +} + +export async function curateBlockDetail( + result: AgentCliRawResult, + viewer: CurationViewer +): Promise { + const detail = parseBlockDetail(result.stdout) + if (!detail) return result + const config = await getUserPermissionConfig(viewer.userId, viewer.workspaceId) + const deniedTools = config?.deniedTools + if (!deniedTools?.length) return result + const isToolAllowed = createToolAccessGate(deniedTools) + const denied = resolveDeniedBlockOperations(deniedTools, isToolAllowed) + if (denied.fullyDenied.has(detail.type)) { + return agentCliFail(`Block "${detail.type}" is not available to you in this workspace.`) + } + const deniedOperations = denied.needsProjection.get(detail.type) + if (!deniedOperations) return result + const operations = Object.fromEntries( + Object.entries(detail.operations ?? {}).filter(([id]) => !deniedOperations.has(id)) + ) + const tools = (detail.tools ?? []).filter( + (tool) => typeof tool.id !== 'string' || isToolAllowed(tool.id) + ) + return { ...result, stdout: JSON.stringify({ ...detail, operations, tools }, null, 2) } +} diff --git a/apps/sim/lib/mothership/agent-cli/index.ts b/apps/sim/lib/mothership/agent-cli/index.ts index 268b4ed47ae..0c0203379f3 100644 --- a/apps/sim/lib/mothership/agent-cli/index.ts +++ b/apps/sim/lib/mothership/agent-cli/index.ts @@ -1,5 +1,6 @@ import { createEmbeddedClient, type EmbeddedCliIdentity } from 'sim/embed' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' +import { curateBlockDetail } from '@/lib/mothership/agent-cli/curation' import { runEngine } from '@/lib/mothership/agent-cli/engines' import { applyPipeline } from '@/lib/mothership/agent-cli/pipeline' import { runCli } from '@/lib/mothership/agent-cli/run-cli' @@ -51,6 +52,9 @@ export async function executeAgentCliRequest( ) } else { result = await runCli(request.invocation.argv, identity, sessionKey) + if (result.exitCode === 0 && request.curate === 'block') { + result = await curateBlockDetail(result, context) + } } if (result.exitCode === 0 && request.pipeline.length > 0) { result = await applyPipeline(result, request.pipeline) diff --git a/apps/sim/lib/mothership/agent-cli/request-schema.ts b/apps/sim/lib/mothership/agent-cli/request-schema.ts index 49016237903..1e666c2505e 100644 --- a/apps/sim/lib/mothership/agent-cli/request-schema.ts +++ b/apps/sim/lib/mothership/agent-cli/request-schema.ts @@ -36,4 +36,5 @@ export const agentCliRequestSchema = z.object({ ]) ), sink: z.object({ kind: z.literal('sandbox-file'), path: z.string().min(1).max(300) }).optional(), + curate: z.literal('block').optional(), }) satisfies z.ZodType diff --git a/apps/sim/lib/mothership/generated/agent-cli.ts b/apps/sim/lib/mothership/generated/agent-cli.ts index 802e03e474e..2f11d6758f9 100644 --- a/apps/sim/lib/mothership/generated/agent-cli.ts +++ b/apps/sim/lib/mothership/generated/agent-cli.ts @@ -69,6 +69,12 @@ export interface AgentCliRequest { invocation: AgentCliInvocation; pipeline: AgentCliPipeStage[]; sink?: AgentCliSink; + /** + * Viewer curation sim applies to the raw result before the pipeline: "block" trims a + * block detail to the operations, inputs and models this viewer may use. Decided by the + * worker's parse, applied by sim's primitive, so both sides see one policy. + */ + curate?: "block"; } /** What sim returns; the worker shapes the model-facing result from it. */ From f5574cbde2ab0929e109271faa0e391b1a8a81ea Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 10:46:12 +0530 Subject: [PATCH 054/306] mothership agent-cli: fixes from the exploration run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs search: the engine builds the secret-trace registry (prepareCopilotEnvironmentContext) — without it the docs tool refused every query as 'could not be processed safely'. - universal grep: workspaceId on the list and detail requests that require it (the export route takes none); the A2 tests had mocked the client. - run-cli: strip ANSI escapes from CLI stdout/stderr (chalk keys off the hosting server's TTY). - pipeline: the jq/outline non-JSON error names | grep instead of a flag the caller already passed; the to-sandbox notice names /home/user/. - sim-cli embed path only (public CLI unchanged): positional file arguments (tables import, files upload, knowledge documents upload) read from the host's pre-read @path map and refuse anything else; files get --output-file writes through EmbedContext.writeFile (the chat sandbox) instead of the server's disk, which is where it had been landing. embedded-files.test.ts. Claude-Session: https://claude.ai/code/session_01HFVhPDtRZuCgupPco633w8 --- .../agent-cli/engines/docs-search.test.ts | 8 ++- .../agent-cli/engines/docs-search.ts | 7 +- .../agent-cli/engines/universal-grep.ts | 20 ++++-- .../lib/mothership/agent-cli/pipeline.test.ts | 2 +- apps/sim/lib/mothership/agent-cli/pipeline.ts | 2 +- apps/sim/lib/mothership/agent-cli/run-cli.ts | 29 ++++++++- apps/sim/lib/mothership/agent-cli/sink.ts | 7 +- .../src/commands/protocol/files-get.ts | 21 ++++++ packages/sim-cli/src/embed-context.ts | 6 ++ packages/sim-cli/src/embed.ts | 3 +- .../src/transfer/embedded-files.test.ts | 64 +++++++++++++++++++ packages/sim-cli/src/transfer/local-file.ts | 28 ++++++++ .../sim-cli/src/transfer/upload-session.ts | 5 +- 13 files changed, 187 insertions(+), 15 deletions(-) create mode 100644 packages/sim-cli/src/transfer/embedded-files.test.ts diff --git a/apps/sim/lib/mothership/agent-cli/engines/docs-search.test.ts b/apps/sim/lib/mothership/agent-cli/engines/docs-search.test.ts index 1eb64609459..8e3d02bb955 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/docs-search.test.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/docs-search.test.ts @@ -8,6 +8,12 @@ const { execute } = vi.hoisted(() => ({ .fn() .mockResolvedValue({ results: [{ path: 'docs/integrations/slack.mdx' }], query: 'q' }), })) +vi.mock('@/lib/mothership/environment-context', () => ({ + prepareCopilotEnvironmentContext: vi.fn(async () => ({ + resolvedSecretTraceRegistry: { registry: 'stub' }, + })), +})) + vi.mock('@/lib/mothership/tools/server/docs/search-docs', () => ({ searchDocsServerTool: { execute }, })) @@ -30,7 +36,7 @@ describe('docs search engine', () => { expect(result.exitCode).toBe(0) expect(execute).toHaveBeenCalledWith( { query: 'slack streaming', topK: 3, path: 'docs/integrations' }, - { userId: 'user-1', workspaceId: 'ws-1' } + { userId: 'user-1', workspaceId: 'ws-1', resolvedSecretTraceRegistry: { registry: 'stub' } } ) expect(JSON.parse(result.stdout).results[0].path).toBe('docs/integrations/slack.mdx') }) diff --git a/apps/sim/lib/mothership/agent-cli/engines/docs-search.ts b/apps/sim/lib/mothership/agent-cli/engines/docs-search.ts index d12dc302b85..9e49be0b988 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/docs-search.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/docs-search.ts @@ -4,6 +4,7 @@ import { agentCliFail, agentCliOk, } from '@/lib/mothership/agent-cli/types' +import { prepareCopilotEnvironmentContext } from '@/lib/mothership/environment-context' import { searchDocsServerTool } from '@/lib/mothership/tools/server/docs/search-docs' const DEFAULT_TOP = 6 @@ -29,9 +30,13 @@ export const docsSearchCommand: AgentCliEngine = { const top = topFrom(flags) if (typeof top === 'string') return agentCliFail(top) const path = typeof flags.path === 'string' ? flags.path : undefined + const { resolvedSecretTraceRegistry } = await prepareCopilotEnvironmentContext( + runtime.userId, + runtime.workspaceId + ) const output = await searchDocsServerTool.execute( { query, topK: top, ...(path ? { path } : {}) }, - { userId: runtime.userId, workspaceId: runtime.workspaceId } + { userId: runtime.userId, workspaceId: runtime.workspaceId, resolvedSecretTraceRegistry } ) return agentCliOk(JSON.stringify(output, null, 2)) }, diff --git a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts index 2c8f954cd39..78de525c13e 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts @@ -59,7 +59,7 @@ async function listAll(runtime: AgentCliRuntime, path: string): Promise(path, { - query: { limit: '100', ...(cursor ? { cursor } : {}) }, + query: { workspaceId: runtime.workspaceId, limit: '100', ...(cursor ? { cursor } : {}) }, }) out.push(...page.data) if (!page.nextCursor) break @@ -96,6 +96,8 @@ const MATERIALIZERS: Record Promise { const id = str(w.id) ?? '' + // The export route scopes by workflow id alone (`query: noInputSchema`); a + // workspaceId here is an "Unrecognized key" — the other detail routes require it. const exported = await runtime.client.request<{ data: { state: unknown } }>( `/api/v2/workflows/${id}/export` ) @@ -109,7 +111,9 @@ const MATERIALIZERS: Record Promise { const id = str(b.id) ?? '' - const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/blocks/${id}`) + const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/blocks/${id}`, { + query: { workspaceId: runtime.workspaceId }, + }) return render('blocks', id, id, detail.data) }) catalogCache.set(key, materialized) @@ -123,7 +127,9 @@ const MATERIALIZERS: Record Promise { const id = str(t.id) ?? '' - const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/tables/${id}`) + const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/tables/${id}`, { + query: { workspaceId: runtime.workspaceId }, + }) return render('tables', id, str(t.name) ?? id, detail.data) }) }, @@ -131,7 +137,9 @@ const MATERIALIZERS: Record Promise { const id = str(s.id) ?? '' - const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/skills/${id}`) + const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/skills/${id}`, { + query: { workspaceId: runtime.workspaceId }, + }) return render('skills', id, str(s.name) ?? id, detail.data) }) }, @@ -139,7 +147,9 @@ const MATERIALIZERS: Record Promise { const id = str(t.id) ?? '' - const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/custom-tools/${id}`) + const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/custom-tools/${id}`, { + query: { workspaceId: runtime.workspaceId }, + }) return render('custom-tools', id, str(t.title) ?? str(t.name) ?? id, detail.data) }) }, diff --git a/apps/sim/lib/mothership/agent-cli/pipeline.test.ts b/apps/sim/lib/mothership/agent-cli/pipeline.test.ts index 575aa0cd73b..aec5b5d8934 100644 --- a/apps/sim/lib/mothership/agent-cli/pipeline.test.ts +++ b/apps/sim/lib/mothership/agent-cli/pipeline.test.ts @@ -113,7 +113,7 @@ describe('jq and outline over JSON stdout', () => { it('fails the invocation with the reason when stdout is not JSON or the program is bad', async () => { expect(await applyPipeline('plain text', [{ kind: 'jq', expression: '.' }])).toContain( - 'stdout is not JSON' + 'output is text, not JSON' ) expect(await applyPipeline(json, [{ kind: 'jq', expression: '.data |' }])).toContain('jq:') }) diff --git a/apps/sim/lib/mothership/agent-cli/pipeline.ts b/apps/sim/lib/mothership/agent-cli/pipeline.ts index 4411d1d3a93..a6047c37f0f 100644 --- a/apps/sim/lib/mothership/agent-cli/pipeline.ts +++ b/apps/sim/lib/mothership/agent-cli/pipeline.ts @@ -63,7 +63,7 @@ function parseJsonStdout(stdout: string, stage: string): JsonValue { return parsed } catch { throw new PipeStageError( - `${stage}: stdout is not JSON. Run the command with --output json before piping into ${stage}.` + `${stage}: this command's output is text, not JSON, so ${stage} cannot apply. Filter it with | grep instead, or use outputs get --grep on a stored result.` ) } } diff --git a/apps/sim/lib/mothership/agent-cli/run-cli.ts b/apps/sim/lib/mothership/agent-cli/run-cli.ts index c542e04bff0..66f5290be93 100644 --- a/apps/sim/lib/mothership/agent-cli/run-cli.ts +++ b/apps/sim/lib/mothership/agent-cli/run-cli.ts @@ -1,5 +1,8 @@ import { type EmbeddedCliIdentity, runEmbeddedCli } from 'sim/embed' -import { readSessionSandboxFile } from '@/lib/execution/remote-sandbox/session-files' +import { + readSessionSandboxFile, + writeSessionSandboxFile, +} from '@/lib/execution/remote-sandbox/session-files' import type { AgentCliRawResult } from '@/lib/mothership/generated/agent-cli' /** @@ -27,5 +30,27 @@ export async function runCli( if (read.outcome === 'read') fileArguments[path] = read.content } } - return runEmbeddedCli(argv, identity, { fileArguments }) + // Downloads land on the same machine `@path` reads from; without a sandbox session + // the CLI refuses rather than writing to the server's disk. + const writeFile = sessionKey + ? async (path: string, content: Uint8Array) => + (await writeSessionSandboxFile(sessionKey, path, Buffer.from(content).toString('utf8'))) + .outcome === 'written' + : undefined + const result = await runEmbeddedCli(argv, identity, { + fileArguments, + ...(writeFile ? { writeFile } : {}), + }) + return { ...result, stdout: stripAnsi(result.stdout), stderr: stripAnsi(result.stderr) } +} + +const ANSI_SEQUENCE = /\[[0-9;?]*[ -/]*[@-~]/g + +/** + * The CLI colours its notes with chalk, which keys off the HOSTING server's TTY — so an + * embedded run on a dev server hands the model `…` around every + * truncation notice. The model reads text, never a terminal: strip escapes on the way out. + */ +function stripAnsi(text: string): string { + return text.replace(ANSI_SEQUENCE, '') } diff --git a/apps/sim/lib/mothership/agent-cli/sink.ts b/apps/sim/lib/mothership/agent-cli/sink.ts index 34ed3d4d836..523f9e7b419 100644 --- a/apps/sim/lib/mothership/agent-cli/sink.ts +++ b/apps/sim/lib/mothership/agent-cli/sink.ts @@ -1,4 +1,7 @@ -import { writeSessionSandboxFile } from '@/lib/execution/remote-sandbox/session-files' +import { + resolveSessionPath, + writeSessionSandboxFile, +} from '@/lib/execution/remote-sandbox/session-files' import type { AgentCliRawResult, AgentCliSink } from '@/lib/mothership/generated/agent-cli' /** @@ -22,7 +25,7 @@ export async function applySink( if (written.outcome === 'written') { return { ...result, - stdout: `[stdout written to ${sink.path} on your machine: ${result.stdout.length} chars. Read or process it with run_code, or pass it back as @${sink.path}.]`, + stdout: `[stdout written to ${resolveSessionPath(sink.path)} on your machine: ${result.stdout.length} chars. Read or process it with run_code, or pass it back as @${sink.path}.]`, } } if (written.outcome === 'no-session') { diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts index fff79f450fb..044c100039d 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -6,6 +6,7 @@ import { Readable, type Writable } from 'node:stream' import { pipeline } from 'node:stream/promises' import type { Command } from 'commander' import { clientFrom } from '../../context' +import { embedStore } from '../../embed-context' import { V2_OPERATIONS } from '../../generated/v2-api' import { isRequestTimeout, RAISE_TIMEOUT_HINT, resolvePath, SimApiError } from '../../http/client' import { printProtocolResult } from './result' @@ -202,6 +203,26 @@ export async function saveToFile( target: string, force: boolean ): Promise { + const embedded = embedStore.getStore() + if (embedded) { + // In-process on the hosting server: the only legitimate destination is the + // caller's own machine, and the host decides how to reach it. + if (!embedded.writeFile) { + throw new SimApiError( + `--output-file cannot save ${target} here: this surface has no machine to write to. Read the file instead, or pipe a text command with | to-sandbox .`, + 0 + ) + } + const bytes = new Uint8Array(await new Response(body).arrayBuffer()) + const written = await embedded.writeFile(target, bytes) + if (!written) { + throw new SimApiError( + `Could not write ${target} on your machine — the workbench is not running yet; run any run_code call first, then retry.`, + 0 + ) + } + return + } return saveStagedFile(body, target, force) } diff --git a/packages/sim-cli/src/embed-context.ts b/packages/sim-cli/src/embed-context.ts index abc64de7191..26dc38e2aa0 100644 --- a/packages/sim-cli/src/embed-context.ts +++ b/packages/sim-cli/src/embed-context.ts @@ -31,6 +31,12 @@ export interface EmbedContext { * clear another's failure. */ softExitCode?: number + /** + * Where a download lands when embedded: the host writes to the caller's own machine + * (the chat's sandbox), never to the server's disk. Resolves true when written, false + * when the caller has no machine to write to right now. + */ + writeFile?: (path: string, content: Uint8Array) => Promise } /** The embedded-vs-standalone seam for soft-fail codes: context when embedded, global otherwise. */ diff --git a/packages/sim-cli/src/embed.ts b/packages/sim-cli/src/embed.ts index 8f6cf53fb34..2072e90f9e4 100644 --- a/packages/sim-cli/src/embed.ts +++ b/packages/sim-cli/src/embed.ts @@ -69,7 +69,7 @@ export function createEmbeddedClient(identity: EmbeddedCliIdentity): SimClient { export async function runEmbeddedCli( argv: string[], identity: EmbeddedCliIdentity, - options?: { fileArguments?: Record } + options?: { fileArguments?: Record; writeFile?: EmbedContext['writeFile'] } ): Promise { installEmbedSinks() const ctx: EmbedContext = { @@ -77,6 +77,7 @@ export async function runEmbeddedCli( stdout: [], stderr: [], ...(options?.fileArguments ? { fileArguments: options.fileArguments } : {}), + ...(options?.writeFile ? { writeFile: options.writeFile } : {}), } return embedStore.run(ctx, async () => { let exitCode = 0 diff --git a/packages/sim-cli/src/transfer/embedded-files.test.ts b/packages/sim-cli/src/transfer/embedded-files.test.ts new file mode 100644 index 00000000000..62022e6f081 --- /dev/null +++ b/packages/sim-cli/src/transfer/embedded-files.test.ts @@ -0,0 +1,64 @@ +/** + * Embedded runs execute in-process on the hosting server: a positional path must read + * from the host's pre-read map, and a download must go through the host's writer — + * never the server's own filesystem (the exploration run of 2026-09-02 found + * `tables import @x.csv` unreadable and `files get -o` landing in the server's cwd). + */ +import { describe, expect, it, vi } from 'vitest' +import { saveToFile } from '../commands/protocol/files-get' +import { type EmbedContext, embedStore } from '../embed-context' +import { localFile } from './local-file' + +function embedded(overrides: Partial = {}): EmbedContext { + return { + identity: { endpoint: 'http://sim.test', apiKey: 'k', workspaceId: 'ws' }, + stdout: [], + stderr: [], + ...overrides, + } +} + +describe('embedded positional file arguments', () => { + it('reads @path and bare path from the pre-read map, never the server disk', async () => { + const ctx = embedded({ fileArguments: { 'xp_import.csv': 'a,b\n1,2\n' } }) + await embedStore.run(ctx, async () => { + expect(await localFile('@xp_import.csv')).toEqual({ name: 'xp_import.csv', size: 8 }) + expect(await localFile('xp_import.csv', 'renamed.csv')).toEqual({ + name: 'renamed.csv', + size: 8, + }) + }) + }) + + it('refuses a path the host did not pre-read, telling the caller how to provide it', async () => { + await embedStore.run(embedded(), async () => { + await expect(localFile('@missing.csv')).rejects.toThrow( + 'No file "missing.csv" on your machine' + ) + }) + }) +}) + +describe('embedded downloads', () => { + const body = () => new Blob(['hello']).stream() + + it('writes through the host writer instead of the server filesystem', async () => { + const writeFile = vi.fn(async () => true) + await embedStore.run(embedded({ writeFile }), async () => { + await saveToFile(body(), 'out.txt', false) + }) + expect(writeFile).toHaveBeenCalledTimes(1) + const [path, bytes] = writeFile.mock.calls[0] as unknown as [string, Uint8Array] + expect(path).toBe('out.txt') + expect(new TextDecoder().decode(bytes)).toBe('hello') + }) + + it('refuses when the host has no writer or the machine is not running', async () => { + await embedStore.run(embedded(), async () => { + await expect(saveToFile(body(), 'out.txt', false)).rejects.toThrow('no machine to write to') + }) + await embedStore.run(embedded({ writeFile: async () => false }), async () => { + await expect(saveToFile(body(), 'out.txt', false)).rejects.toThrow('workbench is not running') + }) + }) +}) diff --git a/packages/sim-cli/src/transfer/local-file.ts b/packages/sim-cli/src/transfer/local-file.ts index fa4a44f2094..e5717dfb040 100644 --- a/packages/sim-cli/src/transfer/local-file.ts +++ b/packages/sim-cli/src/transfer/local-file.ts @@ -1,8 +1,29 @@ import { constants } from 'node:fs' import { access, stat } from 'node:fs/promises' import { basename } from 'node:path' +import { type EmbedContext, embedStore } from '../embed-context' import { SimApiError } from '../http/client' +/** The pre-read map is keyed by the path as written, without the `@`. */ +export function embeddedFileKey(path: string): string { + return path.startsWith('@') ? path.slice(1) : path +} + +/** + * An embedded run executes in-process on the hosting server, so a positional path must + * never reach the server's filesystem: the host pre-reads `@path` tokens from the + * caller's own machine into the embed context, and anything else is refused. + */ +export function embeddedFileContent(embedded: EmbedContext, path: string): string { + const key = embeddedFileKey(path) + const content = embedded.fileArguments?.[key] + if (content !== undefined) return content + throw new SimApiError( + `No file "${key}" on your machine — write it first (run_code or | to-sandbox), then pass it as @${key}`, + 0 + ) +} + const CONTENT_TYPES: Record = { css: 'text/css', csv: 'text/csv', @@ -44,6 +65,13 @@ export interface LocalFile { /** Validates the size and name shared by every local-file transfer. */ export async function localFile(path: string, override?: string): Promise { + const embedded = embedStore.getStore() + if (embedded) { + const content = embeddedFileContent(embedded, path) + const size = Buffer.byteLength(content) + if (size === 0) throw new SimApiError(`${path} is empty`, 0) + return { name: override ?? basename(embeddedFileKey(path)), size } + } let size: number try { const stats = await stat(path) diff --git a/packages/sim-cli/src/transfer/upload-session.ts b/packages/sim-cli/src/transfer/upload-session.ts index cc8e7d0566d..81147a1c2a0 100644 --- a/packages/sim-cli/src/transfer/upload-session.ts +++ b/packages/sim-cli/src/transfer/upload-session.ts @@ -1,5 +1,7 @@ import { openAsBlob } from 'node:fs' +import { embedStore } from '../embed-context' import { SimApiError, type SimClient } from '../http/client' +import { embeddedFileContent } from './local-file' interface UploadPartUrl { partNumber: number @@ -98,7 +100,8 @@ export async function finishUploadSession( path: string ): Promise { try { - const blob = await openAsBlob(path) + const embedded = embedStore.getStore() + const blob = embedded ? new Blob([embeddedFileContent(embedded, path)]) : await openAsBlob(path) if (session.transfer.method === 'put') { await uploadPut(session.transfer, blob) } else { From 1ed810bf15956752902fc6a325d556230718b720 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 11:20:23 +0530 Subject: [PATCH 055/306] mothership agent-cli: round two of exploration-run fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLI inventory (generator): every v2 operation maps to its command through deriveCommandPath (168 commands now carry a response shape, was 61 — blocks get had none); request-body shapes two levels deep for JSON-valued flags (select-column options, workflow groups, views, variables, operations apply); enum choices on options; union variants show their discriminator. - sink: the not-written notice leads the inline output so the budget cannot cut it (| to-sandbox read as a silent no-op when the sandbox was not up). - files grep: no doubled slash on root-folder paths. - embed: the missing-file message says to pass @path instead of implying the file does not exist. Claude-Session: https://claude.ai/code/session_01HFVhPDtRZuCgupPco633w8 --- .../agent-cli/engines/files-grep.ts | 3 +- apps/sim/lib/mothership/agent-cli/sink.ts | 5 +- .../scripts/print-command-inventory.ts | 113 ++++++++++++++++-- .../src/transfer/embedded-files.test.ts | 4 +- packages/sim-cli/src/transfer/local-file.ts | 2 +- 5 files changed, 108 insertions(+), 19 deletions(-) diff --git a/apps/sim/lib/mothership/agent-cli/engines/files-grep.ts b/apps/sim/lib/mothership/agent-cli/engines/files-grep.ts index ab5912ac5bf..3315a91f547 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/files-grep.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/files-grep.ts @@ -86,7 +86,8 @@ export const filesGrepCommand: AgentCliEngine = { }) ) for (const { file, text } of texts) { - const label = file.folderPath ? `${file.folderPath}/${file.name}` : file.name + const folder = (file.folderPath ?? '').replace(/\/+$/, '') + const label = folder ? `${folder}/${file.name}` : `/${file.name}` if (matches(file.name) && out.length < MAX_MATCHES) out.push(`${label}: name matches`) if (text === null) { unreadable++ diff --git a/apps/sim/lib/mothership/agent-cli/sink.ts b/apps/sim/lib/mothership/agent-cli/sink.ts index 523f9e7b419..ff2d02e06d2 100644 --- a/apps/sim/lib/mothership/agent-cli/sink.ts +++ b/apps/sim/lib/mothership/agent-cli/sink.ts @@ -31,11 +31,12 @@ export async function applySink( if (written.outcome === 'no-session') { return { ...result, - stdout: `${result.stdout}\n[outputFile not written: your machine is not booted yet — run any run_code first. Output returned inline instead]`, + // The notice leads: appended, it was the first thing the output budget cut. + stdout: `[NOT written to ${sink.path}: your machine is not booted yet — run any run_code first, then re-run this command. The output follows inline instead.]\n${result.stdout}`, } } return { ...result, - stdout: `${result.stdout}\n[outputFile write failed — output returned inline instead]`, + stdout: `[NOT written to ${sink.path}: the write to your machine failed. The output follows inline instead.]\n${result.stdout}`, } } diff --git a/packages/sim-cli/scripts/print-command-inventory.ts b/packages/sim-cli/scripts/print-command-inventory.ts index 29690bd95d0..bd705d94ff3 100644 --- a/packages/sim-cli/scripts/print-command-inventory.ts +++ b/packages/sim-cli/scripts/print-command-inventory.ts @@ -16,6 +16,7 @@ import type { Command } from 'commander' import { CLI_CONTRACT } from '../src/contract/commands' import { V2_OPERATIONS } from '../src/generated/v2-api' import { buildProgram } from '../src/program' +import { deriveCommandPath } from '../src/runtime/derive' const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..') const HELP_COMMAND = 'help' @@ -35,6 +36,8 @@ interface InventoryOption { required: boolean description: string defaultValue?: string + /** Allowed values when the option is an enum (commander `choices`). */ + choices?: string[] } interface InventoryShapeField { @@ -49,6 +52,11 @@ interface InventoryCommand { options: InventoryOption[] /** Top-level fields of the JSON response's `data`, when the operation is known. */ shape?: InventoryShapeField[] + /** + * Fields of the JSON request body, two levels deep, for commands that take a + * `` option — the nested payloads an agent otherwise learns from errors. + */ + body?: InventoryShapeField[] } function isHiddenCommand(command: Command): boolean { @@ -67,8 +75,15 @@ function collectLeaves(command: Command, prefix: string[]): { path: string[]; co return children.flatMap((child) => collectLeaves(child, [...prefix, child.name()])) } -/** Command path → v2 operation name, through the contract's declared command strings. */ +/** + * Command path → v2 operation name. Every operation names its command the way the + * program builder does (`deriveCommandPath`); a contract entry's explicit `command` + * string overrides that, exactly as it does when the program is built. + */ const OPERATION_BY_PATH = new Map() +for (const operation of Object.keys(V2_OPERATIONS) as (keyof typeof V2_OPERATIONS)[]) { + OPERATION_BY_PATH.set(deriveCommandPath(operation).join(' '), operation) +} for (const [operation, spec] of Object.entries(CLI_CONTRACT)) { if (spec && 'command' in spec && typeof spec.command === 'string') { OPERATION_BY_PATH.set(spec.command, operation) @@ -85,6 +100,8 @@ type JsonSchema = { allOf?: JsonSchema[] nullable?: boolean enum?: unknown[] + const?: unknown + required?: string[] } interface OpenApiDoc { @@ -92,7 +109,10 @@ interface OpenApiDoc { string, Record< string, - { responses?: Record }> } + { + responses?: Record }> + requestBody?: { content?: Record } + } > > components?: { schemas?: Record } @@ -114,19 +134,44 @@ function resolveRef(doc: OpenApiDoc, schema: JsonSchema | undefined): JsonSchema return current } -function typeLabel(doc: OpenApiDoc, schema: JsonSchema | undefined): string { +/** + * A compact type for one schema. `depth` is how many object levels render their fields + * with types (`{name:string,options:{id,name}[]}`); below it an object lists key names + * only, as the response shapes always have. + */ +function typeLabel( + doc: OpenApiDoc, + schema: JsonSchema | undefined, + depth = 0, + enums: 'full' | 'brief' = 'full' +): string { const resolved = resolveRef(doc, schema) if (!resolved) return 'unknown' - if (resolved.enum) return resolved.enum.map((v) => JSON.stringify(v)).join('|') + if (resolved.const !== undefined) return JSON.stringify(resolved.const) + if (resolved.enum) { + // A response is read, not written: one value and the count is enough to recognise + // the field; a request body needs every legal value. + if (enums === 'brief' && resolved.enum.length > 2) { + return `${JSON.stringify(resolved.enum[0])}|…(${resolved.enum.length})` + } + return resolved.enum.map((v) => JSON.stringify(v)).join('|') + } const variants = resolved.anyOf ?? resolved.oneOf - if (variants) return variants.map((v) => typeLabel(doc, v)).join('|') + if (variants) return variants.map((v) => typeLabel(doc, v, depth, enums)).join('|') const type = Array.isArray(resolved.type) ? resolved.type.join('|') : resolved.type - if (type === 'array') return `${typeLabel(doc, resolved.items)}[]` + if (type === 'array') return `${typeLabel(doc, resolved.items, depth, enums)}[]` if (type === 'object' || resolved.properties) { - const keys = Object.keys(resolved.properties ?? {}) - return keys.length > 0 - ? `{${keys.slice(0, 8).join(',')}${keys.length > 8 ? ',…' : ''}}` - : 'object' + const props = resolved.properties ?? {} + const keys = Object.keys(props) + if (keys.length === 0) return 'object' + if (depth > 0) { + const required = new Set(resolved.required ?? []) + return `{${keys + .slice(0, 12) + .map((k) => `${k}${required.has(k) ? '' : '?'}:${typeLabel(doc, props[k], depth - 1)}`) + .join(',')}${keys.length > 12 ? ',…' : ''}}` + } + return `{${keys.slice(0, 8).join(',')}${keys.length > 8 ? ',…' : ''}}` } return type ?? 'unknown' } @@ -145,15 +190,53 @@ function responseShape(operation: string): InventoryShapeField[] | undefined { if (!props) { const items = resolveRef(doc, data.items) if (items?.properties) { - return [{ name: '[]', type: typeLabel(doc, items) }] + return [{ name: '[]', type: typeLabel(doc, items, 0, 'brief') }] } return undefined } - return Object.entries(props).map(([name, s]) => ({ name, type: typeLabel(doc, s) })) + return Object.entries(props).map(([name, s]) => ({ + name, + type: typeLabel(doc, s, 0, 'brief'), + })) + } + return undefined +} + +/** The JSON request body's fields, two levels deep — only asked for commands with a `` option. */ +function requestShape( + operation: string, + jsonFields: Set +): InventoryShapeField[] | undefined { + const op = V2_OPERATIONS[operation as keyof typeof V2_OPERATIONS] + if (!op) return undefined + const docPath = op.path.replace(/\[([^\]]+)\]/g, '{$1}') + for (const doc of OPENAPI_DOCS) { + const entry = doc.paths[docPath]?.[op.method.toLowerCase()] + const schema = entry?.requestBody?.content?.['application/json']?.schema + const resolved = resolveRef(doc, schema) + if (!resolved?.properties) continue + const required = new Set(resolved.required ?? []) + const entries = Object.entries(resolved.properties) + // The CLI injects the workspace; the model never writes it. + .filter(([name]) => name !== 'workspaceId') + // Scalar flags are already on the signature; the card carries the JSON-valued + // fields only (the ones whose shape is otherwise learned from error messages). + const nested = entries.filter(([name]) => jsonFields.has(name)) + return (nested.length > 0 ? nested : entries).map(([name, s]) => ({ + name: required.has(name) ? name : `${name}?`, + type: capType(typeLabel(doc, s, 2)), + })) } return undefined } +const MAX_BODY_TYPE_CHARS = 220 + +/** A recursive grammar (the row predicate) expands past what a card line can carry. */ +function capType(label: string): string { + return label.length > MAX_BODY_TYPE_CHARS ? `${label.slice(0, MAX_BODY_TYPE_CHARS)}…` : label +} + const program = buildProgram() const inventory: InventoryCommand[] = collectLeaves(program, []).map( ({ path: cmdPath, command }) => { @@ -172,15 +255,21 @@ const inventory: InventoryCommand[] = collectLeaves(program, []).map( required: option.mandatory, description: option.description, ...(option.defaultValue !== undefined ? { defaultValue: String(option.defaultValue) } : {}), + ...(option.argChoices ? { choices: option.argChoices } : {}), })) const operation = OPERATION_BY_PATH.get(cmdPath.join(' ')) const shape = operation ? responseShape(operation) : undefined + const jsonFields = new Set( + options.filter((option) => //.test(option.flags)).map((option) => option.name) + ) + const body = operation && jsonFields.size > 0 ? requestShape(operation, jsonFields) : undefined return { path: cmdPath, description: command.description(), args, options, ...(shape ? { shape } : {}), + ...(body ? { body } : {}), } } ) diff --git a/packages/sim-cli/src/transfer/embedded-files.test.ts b/packages/sim-cli/src/transfer/embedded-files.test.ts index 62022e6f081..671614ee0f7 100644 --- a/packages/sim-cli/src/transfer/embedded-files.test.ts +++ b/packages/sim-cli/src/transfer/embedded-files.test.ts @@ -32,9 +32,7 @@ describe('embedded positional file arguments', () => { it('refuses a path the host did not pre-read, telling the caller how to provide it', async () => { await embedStore.run(embedded(), async () => { - await expect(localFile('@missing.csv')).rejects.toThrow( - 'No file "missing.csv" on your machine' - ) + await expect(localFile('@missing.csv')).rejects.toThrow('use @missing.csv') }) }) }) diff --git a/packages/sim-cli/src/transfer/local-file.ts b/packages/sim-cli/src/transfer/local-file.ts index e5717dfb040..c062c7a1f45 100644 --- a/packages/sim-cli/src/transfer/local-file.ts +++ b/packages/sim-cli/src/transfer/local-file.ts @@ -19,7 +19,7 @@ export function embeddedFileContent(embedded: EmbedContext, path: string): strin const content = embedded.fileArguments?.[key] if (content !== undefined) return content throw new SimApiError( - `No file "${key}" on your machine — write it first (run_code or | to-sandbox), then pass it as @${key}`, + `Files on your machine are passed as @path: use @${key}. (If it does not exist yet, write it with run_code or | to-sandbox first.)`, 0 ) } From 51c8db3b722e1b8af0f9fb301a8abcf0e65fa7ea Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 11:47:41 +0530 Subject: [PATCH 056/306] mothership agent-cli: grep --in narrows the scope; docs-search fallback names CLI commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - universal grep: --in with a world name (tables, blocks, …) searches that world instead of filtering resources by a name that never matches; the no-match line lists only the worlds actually searched. - docs search: the shortfall notes point at blocks get / blocks tips instead of a glob() the agent surface does not have. Claude-Session: https://claude.ai/code/session_01HFVhPDtRZuCgupPco633w8 --- .../mothership/agent-cli/engines/universal-grep.ts | 13 +++++++++---- .../tools/server/docs/search-docs.test.ts | 3 +-- .../lib/mothership/tools/server/docs/search-docs.ts | 4 ++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts index 78de525c13e..ef244d5c860 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts @@ -241,14 +241,19 @@ export const universalGrepCommand: AgentCliEngine = { const ignoreCase = flags.i === true const countOnly = flags.count === true const within = typeof flags.in === 'string' ? flags.in.toLowerCase() : undefined + // `--in tables` reads as "search the tables world", so a world name narrows the scope; + // anything else is a resource id or name inside the searched worlds. + const withinScope = SCOPES.find((scope) => scope === within) + const searched: Scope[] = withinScope ? [withinScope] : scopes + const nameFilter = withinScope ? undefined : within const matches = compilePattern(pattern, ignoreCase) const materialized = ( - await Promise.all(scopes.map((scope) => MATERIALIZERS[scope](runtime))) + await Promise.all(searched.map((scope) => MATERIALIZERS[scope](runtime))) ).flat() - const candidates = within + const candidates = nameFilter ? materialized.filter( - (m) => m.id.toLowerCase() === within || m.label.toLowerCase().includes(within) + (m) => m.id.toLowerCase() === nameFilter || m.label.toLowerCase().includes(nameFilter) ) : materialized @@ -282,7 +287,7 @@ export const universalGrepCommand: AgentCliEngine = { } if (out.length === 0) { return agentCliOk( - `No matches for ${JSON.stringify(pattern)} in ${scopes.join(', ')}${within ? ` within "${within}"` : ''}.` + `No matches for ${JSON.stringify(pattern)} in ${searched.join(', ')}${nameFilter ? ` within "${nameFilter}"` : ''}.` ) } const truncated = diff --git a/apps/sim/lib/mothership/tools/server/docs/search-docs.test.ts b/apps/sim/lib/mothership/tools/server/docs/search-docs.test.ts index af90668580d..2b786d9d1f6 100644 --- a/apps/sim/lib/mothership/tools/server/docs/search-docs.test.ts +++ b/apps/sim/lib/mothership/tools/server/docs/search-docs.test.ts @@ -80,8 +80,7 @@ describe('searchDocsServerTool', () => { const output = await searchDocsServerTool.execute({ query: 'brand new feature' }, CONTEXT) expect(output.note).toContain('search index may lag') - expect(output.note).toContain('read it directly') - expect(output.note).toContain('glob("docs/**")') + expect(output.note).toContain('blocks tips') }) it('explains an empty result set caused by filtering, so it does not read as missing docs', async () => { diff --git a/apps/sim/lib/mothership/tools/server/docs/search-docs.ts b/apps/sim/lib/mothership/tools/server/docs/search-docs.ts index d37a8fba213..333c119d8bc 100644 --- a/apps/sim/lib/mothership/tools/server/docs/search-docs.ts +++ b/apps/sim/lib/mothership/tools/server/docs/search-docs.ts @@ -31,7 +31,7 @@ interface SearchDocsOutput { function shortfallNote(outcome: Awaited>): string | undefined { const { results, candidatesConsidered, droppedBelowThreshold, droppedStale } = outcome if (results.length === 0 && candidatesConsidered === 0) { - return 'No indexed candidates were returned. The search index may lag the live docs. If you know the page, read it directly; otherwise use glob("docs/**") to find the current path.' + return 'No indexed candidates were returned. The search index may lag the live docs. Rephrase the query, or read the block definition and tips directly (blocks get / blocks tips).' } if (droppedBelowThreshold === 0 && droppedStale === 0) return undefined @@ -46,7 +46,7 @@ function shortfallNote(outcome: Awaited>): string const dropped = reasons.join(' and ') return results.length === 0 - ? `No relevant matches. The search index returned ${candidatesConsidered} candidate(s), but ${dropped} — this does NOT mean the docs lack this topic. Rephrase the query, widen it by dropping the path scope, or browse with glob("docs/**").` + ? `No relevant matches. The search index returned ${candidatesConsidered} candidate(s), but ${dropped} — this does NOT mean the docs lack this topic. Rephrase the query, widen it by dropping the path scope, or read the block definition and tips directly (blocks get / blocks tips).` : `Returned ${results.length} of ${candidatesConsidered} candidate(s); ${dropped}. Rephrase or widen the query if these look off-topic.` } From 5efd170f3d5cd064e213358cb1706dd19a9d5ef1 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 12:29:23 +0530 Subject: [PATCH 057/306] mothership agent-cli: engines consolidated to the settled surface - The request carries invocation + sink + curate only; slicing happens on the worker. A stdout invocation lands worker-held text through the sink. jq-wasm removed from apps/sim; pipeline.ts gone. - Engines: docs search, grep, logs query, workflows deps, workflows lint. The universal grep gains files (content via the v2 text read) and integrations (the viewer's gateway projection) scopes; files-grep, workflow(s) grep, workflow views and trace engines are folded or dropped. Claude-Session: https://claude.ai/code/session_01HFVhPDtRZuCgupPco633w8 --- .../lib/mothership/agent-cli/engines.test.ts | 121 +------------- .../lib/mothership/agent-cli/engines/deps.ts | 2 +- .../agent-cli/engines/files-grep.ts | 107 ------------ .../lib/mothership/agent-cli/engines/grep.ts | 119 -------------- .../lib/mothership/agent-cli/engines/index.ts | 24 +-- .../lib/mothership/agent-cli/engines/lint.ts | 2 +- .../lib/mothership/agent-cli/engines/trace.ts | 153 ------------------ .../agent-cli/engines/universal-grep.ts | 42 +++++ .../agent-cli/engines/workflow-state.ts | 13 ++ .../agent-cli/engines/workflow-views.ts | 81 ---------- apps/sim/lib/mothership/agent-cli/index.ts | 9 +- .../lib/mothership/agent-cli/pipeline.test.ts | 120 -------------- apps/sim/lib/mothership/agent-cli/pipeline.ts | 134 --------------- .../mothership/agent-cli/request-schema.ts | 23 +-- .../sim/lib/mothership/generated/agent-cli.ts | 67 +++----- .../tools/handlers/sim-cli-bridge.test.ts | 50 +++--- .../lib/mothership/tools/handlers/sim-cli.ts | 2 +- apps/sim/package.json | 1 - bun.lock | 3 - 19 files changed, 121 insertions(+), 952 deletions(-) delete mode 100644 apps/sim/lib/mothership/agent-cli/engines/files-grep.ts delete mode 100644 apps/sim/lib/mothership/agent-cli/engines/grep.ts delete mode 100644 apps/sim/lib/mothership/agent-cli/engines/trace.ts create mode 100644 apps/sim/lib/mothership/agent-cli/engines/workflow-state.ts delete mode 100644 apps/sim/lib/mothership/agent-cli/engines/workflow-views.ts delete mode 100644 apps/sim/lib/mothership/agent-cli/pipeline.test.ts delete mode 100644 apps/sim/lib/mothership/agent-cli/pipeline.ts diff --git a/apps/sim/lib/mothership/agent-cli/engines.test.ts b/apps/sim/lib/mothership/agent-cli/engines.test.ts index 83c21172344..0b1105539d1 100644 --- a/apps/sim/lib/mothership/agent-cli/engines.test.ts +++ b/apps/sim/lib/mothership/agent-cli/engines.test.ts @@ -54,125 +54,10 @@ function runtimeWith(responses: Record): AgentCliRuntime { const EXPORT_PATH = '/api/v2/workflows/wf-1/export' const exportResponse = { data: { state: WORKFLOW_STATE } } -describe('workflow views', () => { - it('projects just the blocks', async () => { - const result = await runEngine( - 'workflow blocks', - ['wf-1'], - runtimeWith({ [EXPORT_PATH]: exportResponse }), - {} - ) - expect(result.exitCode).toBe(0) - const blocks = JSON.parse(result.stdout) - expect(blocks).toEqual([ - { id: 'block-1', type: 'starter', name: 'Start', enabled: true }, - { id: 'block-2', type: 'agent', name: 'Summarize emails', enabled: true }, - ]) - }) - - it('projects just the edges', async () => { - const result = await runEngine( - 'workflow edges', - ['wf-1'], - runtimeWith({ [EXPORT_PATH]: exportResponse }), - {} - ) - expect(result.exitCode).toBe(0) - expect(JSON.parse(result.stdout)).toEqual([ - { source: 'block-1', target: 'block-2', sourceHandle: 'source' }, - ]) - }) - - it('fails usefully without a workflow id', async () => { - const result = await runEngine('workflow blocks', [], runtimeWith({}), {}) - expect(result.exitCode).toBe(1) - expect(result.stderr).toContain('Usage:') - }) -}) - -describe('files grep', () => { - const FILES_LIST = { - data: [ - { id: 'f1', name: 'report.md', folderPath: 'docs' }, - { id: 'f2', name: 'logo.png', folderPath: '' }, - ], - nextCursor: null, - } - const readText = (text: string, degraded = false) => ({ - data: { text, degraded }, - }) - - it('greps file contents with line numbers, skipping non-text files', async () => { - const result = await runEngine( - 'files grep', - ['quarterly'], - runtimeWith({ - '/api/v2/files': FILES_LIST, - '/api/v2/files/f1/text': readText('# Report\nQuarterly revenue was up.\n'), - '/api/v2/files/f2/text': readText('', true), - }), - {} - ) - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('docs/report.md:2: Quarterly revenue was up.') - expect(result.stdout).not.toContain('logo.png') - }) - - it('filters by folder prefix', async () => { - const result = await runEngine( - 'files grep', - ['Quarterly', 'other'], - runtimeWith({ '/api/v2/files': FILES_LIST }), - {} - ) - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('No matches') - }) -}) - -describe('workflow grep', () => { - it('reports matches as path: value lines', async () => { - const result = await runEngine( - 'workflow grep', - ['wf-1', 'Summarize'], - runtimeWith({ [EXPORT_PATH]: exportResponse }), - {} - ) - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('.blocks.block-2.name: Summarize emails') - }) - - it('falls back to literal search on an invalid regex', async () => { - const result = await runEngine( - 'workflow grep', - ['wf-1', 'api.example.com['], - runtimeWith({ [EXPORT_PATH]: exportResponse }), - {} - ) - expect(result.exitCode).toBe(0) - expect(result.stdout).toBe('No matches.') - }) - - it('searches across all workspace workflows', async () => { - const result = await runEngine( - 'workflows grep', - ['Summarize'], - runtimeWith({ - '/api/v2/workflows': { - data: [{ id: 'wf-1', name: 'Email digest' }], - nextCursor: null, - }, - [EXPORT_PATH]: exportResponse, - }), - {} - ) - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain('Email digest (wf-1).blocks.block-2.name: Summarize emails') - }) - +describe('workflows lint', () => { it('lints a workflow through the shared engine with the caller scoped as subject', async () => { const result = await runEngine( - 'workflow lint', + 'workflows lint', ['wf-1'], runtimeWith({ [EXPORT_PATH]: exportResponse }), {} @@ -190,7 +75,7 @@ describe('workflow grep', () => { }) it('surfaces execution errors as a failed result, never a throw', async () => { - const result = await runEngine('workflow grep', ['wf-missing', 'x'], runtimeWith({}), {}) + const result = await runEngine('workflows lint', ['wf-missing'], runtimeWith({}), {}) expect(result.exitCode).toBe(1) expect(result.stderr).toContain('Unexpected request') }) diff --git a/apps/sim/lib/mothership/agent-cli/engines/deps.ts b/apps/sim/lib/mothership/agent-cli/engines/deps.ts index f8fe34a83a6..7c66167a9bc 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/deps.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/deps.ts @@ -1,4 +1,4 @@ -import { fetchWorkflowState } from '@/lib/mothership/agent-cli/engines/workflow-views' +import { fetchWorkflowState } from '@/lib/mothership/agent-cli/engines/workflow-state' import { type AgentCliEngine, agentCliFail, agentCliOk } from '@/lib/mothership/agent-cli/types' import { normalizeName, SPECIAL_REFERENCE_PREFIXES } from '@/executor/constants' import { diff --git a/apps/sim/lib/mothership/agent-cli/engines/files-grep.ts b/apps/sim/lib/mothership/agent-cli/engines/files-grep.ts deleted file mode 100644 index 3315a91f547..00000000000 --- a/apps/sim/lib/mothership/agent-cli/engines/files-grep.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { ListFilesResponse, ReadFileTextResponse } from 'sim/embed' -import { - type AgentCliEngine, - type AgentCliRuntime, - agentCliFail, - agentCliOk, -} from '@/lib/mothership/agent-cli/types' - -/** - * Content grep across workspace files (the Go copilot's VFS-wide grep, files - * half). Text extraction rides the v2 read-text endpoint, which already - * handles binary/degraded files honestly, so this stays a pure projection. - */ - -const MAX_MATCHES = 200 -const MAX_FILES = 300 -const MAX_BYTES_PER_FILE = 262_144 -const READ_CONCURRENCY = 5 -const CONTEXT_CHARS = 120 - -function compilePattern(raw: string): (value: string) => boolean { - try { - const regex = new RegExp(raw, 'i') - return (value) => regex.test(value) - } catch { - const needle = raw.toLowerCase() - return (value) => value.toLowerCase().includes(needle) - } -} - -function matchingLines( - text: string, - matches: (value: string) => boolean, - label: string, - out: string[] -): void { - const lines = text.split('\n') - for (let lineNo = 0; lineNo < lines.length && out.length < MAX_MATCHES; lineNo++) { - const line = lines[lineNo] - if (!matches(line)) continue - const snippet = line.length > CONTEXT_CHARS ? `${line.slice(0, CONTEXT_CHARS)}…` : line - out.push(`${label}:${lineNo + 1}: ${snippet.trim()}`) - } -} - -async function listAllFiles(runtime: AgentCliRuntime): Promise { - const rows: ListFilesResponse['data'] = [] - let cursor: string | null = null - do { - const page: ListFilesResponse = await runtime.client.request( - '/api/v2/files', - { - query: { workspaceId: runtime.workspaceId, ...(cursor ? { cursor } : {}) }, - } - ) - rows.push(...page.data) - cursor = page.nextCursor - } while (cursor && rows.length < MAX_FILES) - return rows.slice(0, MAX_FILES) -} - -export const filesGrepCommand: AgentCliEngine = { - async execute(rest, runtime) { - const [pattern, folderPrefix] = [rest[0], rest[1]] - if (!pattern) return agentCliFail('Usage: sim files grep [folder-path-prefix]') - const matches = compilePattern(pattern) - const files = (await listAllFiles(runtime)).filter( - (file) => !folderPrefix || file.folderPath.startsWith(folderPrefix) - ) - const out: string[] = [] - let unreadable = 0 - for (let i = 0; i < files.length && out.length < MAX_MATCHES; i += READ_CONCURRENCY) { - const batch = files.slice(i, i + READ_CONCURRENCY) - const texts = await Promise.all( - batch.map(async (file) => { - try { - const response = await runtime.client.request( - `/api/v2/files/${encodeURIComponent(file.id)}/text`, - { query: { workspaceId: runtime.workspaceId, maxBytes: String(MAX_BYTES_PER_FILE) } } - ) - return { file, text: response.data.degraded ? null : response.data.text } - } catch { - // Binary or unreadable files must not sink the whole search. - return { file, text: null } - } - }) - ) - for (const { file, text } of texts) { - const folder = (file.folderPath ?? '').replace(/\/+$/, '') - const label = folder ? `${folder}/${file.name}` : `/${file.name}` - if (matches(file.name) && out.length < MAX_MATCHES) out.push(`${label}: name matches`) - if (text === null) { - unreadable++ - continue - } - matchingLines(text, matches, label, out) - } - } - if (out.length === 0) { - return agentCliOk( - unreadable > 0 ? `No matches (${unreadable} non-text files skipped).` : 'No matches.' - ) - } - const capped = out.length >= MAX_MATCHES ? [...out, `[capped at ${MAX_MATCHES} matches]`] : out - return agentCliOk(capped.join('\n')) - }, -} diff --git a/apps/sim/lib/mothership/agent-cli/engines/grep.ts b/apps/sim/lib/mothership/agent-cli/engines/grep.ts deleted file mode 100644 index 04039d24b2a..00000000000 --- a/apps/sim/lib/mothership/agent-cli/engines/grep.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { ListWorkflowsResponse } from 'sim/embed' -import { fetchWorkflowState } from '@/lib/mothership/agent-cli/engines/workflow-views' -import { - type AgentCliEngine, - type AgentCliRuntime, - agentCliFail, - agentCliOk, -} from '@/lib/mothership/agent-cli/types' - -/** - * Structural grep over workflow state. Matches walk the exported JSON tree and - * report `path: value` lines, so a hit names exactly where in the workflow it - * lives (block param, edge handle, variable) instead of a rendered blob. - */ - -const MAX_MATCHES = 200 -const SNIPPET_CHARS = 200 -const EXPORT_CONCURRENCY = 5 - -function compilePattern(raw: string): (value: string) => boolean { - try { - const regex = new RegExp(raw, 'i') - return (value) => regex.test(value) - } catch { - const needle = raw.toLowerCase() - return (value) => value.toLowerCase().includes(needle) - } -} - -function grepTree( - node: unknown, - matches: (value: string) => boolean, - path: string, - out: string[] -): void { - if (out.length >= MAX_MATCHES) return - if (typeof node === 'string' || typeof node === 'number' || typeof node === 'boolean') { - const text = String(node) - if (matches(text)) { - const snippet = text.length > SNIPPET_CHARS ? `${text.slice(0, SNIPPET_CHARS)}…` : text - out.push(`${path}: ${snippet.replaceAll('\n', '\\n')}`) - } - return - } - if (Array.isArray(node)) { - node.forEach((child, index) => grepTree(child, matches, `${path}[${index}]`, out)) - return - } - if (typeof node === 'object' && node !== null) { - for (const [key, child] of Object.entries(node)) { - // Keys are searchable too: a block id or param name is often the target. - if (matches(key) && out.length < MAX_MATCHES) out.push(`${path}.${key}`) - grepTree(child, matches, `${path}.${key}`, out) - } - } -} - -function renderMatches(lines: string[]): string { - if (lines.length === 0) return 'No matches.' - const capped = - lines.length >= MAX_MATCHES ? [...lines, `[capped at ${MAX_MATCHES} matches]`] : lines - return capped.join('\n') -} - -export const workflowGrepCommand: AgentCliEngine = { - async execute(rest, runtime) { - const [workflowId, ...patternParts] = rest - const pattern = patternParts.join(' ') - if (!workflowId || !pattern) - return agentCliFail('Usage: sim workflow grep ') - const state = await fetchWorkflowState(runtime, workflowId) - const out: string[] = [] - grepTree(state, compilePattern(pattern), '', out) - return agentCliOk(renderMatches(out)) - }, -} - -async function listAllWorkflows(runtime: AgentCliRuntime): Promise { - const rows: ListWorkflowsResponse['data'] = [] - let cursor: string | null = null - do { - const page: ListWorkflowsResponse = await runtime.client.request( - '/api/v2/workflows', - { query: { workspaceId: runtime.workspaceId, ...(cursor ? { cursor } : {}) } } - ) - rows.push(...page.data) - cursor = page.nextCursor - } while (cursor) - return rows -} - -export const workflowsGrepCommand: AgentCliEngine = { - async execute(rest, runtime) { - const pattern = rest.join(' ') - if (!pattern) return agentCliFail('Usage: sim workflows grep ') - const matches = compilePattern(pattern) - const workflows = await listAllWorkflows(runtime) - const out: string[] = [] - for (let i = 0; i < workflows.length && out.length < MAX_MATCHES; i += EXPORT_CONCURRENCY) { - const batch = workflows.slice(i, i + EXPORT_CONCURRENCY) - const states = await Promise.all( - batch.map(async (workflow) => { - try { - return { workflow, state: await fetchWorkflowState(runtime, workflow.id) } - } catch { - // One unexportable workflow must not sink the whole search. - return { workflow, state: null } - } - }) - ) - for (const { workflow, state } of states) { - const label = `${workflow.name} (${workflow.id})` - if (matches(workflow.name) && out.length < MAX_MATCHES) out.push(`${label}: name matches`) - if (state) grepTree(state, matches, label, out) - } - } - return agentCliOk(renderMatches(out)) - }, -} diff --git a/apps/sim/lib/mothership/agent-cli/engines/index.ts b/apps/sim/lib/mothership/agent-cli/engines/index.ts index f6972dc0d1f..bfbac8808e8 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/index.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/index.ts @@ -1,16 +1,9 @@ import { getErrorMessage } from '@sim/utils/errors' import { workflowDepsCommand } from '@/lib/mothership/agent-cli/engines/deps' import { docsSearchCommand } from '@/lib/mothership/agent-cli/engines/docs-search' -import { filesGrepCommand } from '@/lib/mothership/agent-cli/engines/files-grep' -import { workflowGrepCommand, workflowsGrepCommand } from '@/lib/mothership/agent-cli/engines/grep' import { workflowLintCommand } from '@/lib/mothership/agent-cli/engines/lint' import { logsQueryCommand } from '@/lib/mothership/agent-cli/engines/query' -import { workflowTraceCommand } from '@/lib/mothership/agent-cli/engines/trace' import { universalGrepCommand } from '@/lib/mothership/agent-cli/engines/universal-grep' -import { - workflowBlocksCommand, - workflowEdgesCommand, -} from '@/lib/mothership/agent-cli/engines/workflow-views' import { type AgentCliEngine, type AgentCliFlags, @@ -20,22 +13,17 @@ import { } from '@/lib/mothership/agent-cli/types' /** - * Every augmentation engine, keyed by the worker's canonical command name. The worker's - * registry (grammar/augmentations.ts) and this map must agree exactly — the worker's - * augmentation-drift check reads these keys. + * Every sim-executed augmentation engine, keyed by the worker's canonical command name. + * The worker's registry (grammar/augmentations.ts) and this map must agree exactly — + * the worker's augmentation-drift check reads these keys. Worker-answered commands + * (blocks tips, outputs get, integrations list) never reach this map. */ export const AUGMENTATION_ENGINES: Readonly> = { 'docs search': docsSearchCommand, - 'files grep': filesGrepCommand, grep: universalGrepCommand, 'logs query': logsQueryCommand, - 'workflow blocks': workflowBlocksCommand, - 'workflow deps': workflowDepsCommand, - 'workflow edges': workflowEdgesCommand, - 'workflow grep': workflowGrepCommand, - 'workflow lint': workflowLintCommand, - 'workflow trace': workflowTraceCommand, - 'workflows grep': workflowsGrepCommand, + 'workflows deps': workflowDepsCommand, + 'workflows lint': workflowLintCommand, } /** Runs one engine by the worker's name; an engine that throws yields a failed result, never a throw. */ diff --git a/apps/sim/lib/mothership/agent-cli/engines/lint.ts b/apps/sim/lib/mothership/agent-cli/engines/lint.ts index d04a23b55da..1dc4a43ecd8 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/lint.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/lint.ts @@ -1,5 +1,5 @@ import type { WorkflowState } from '@sim/workflow-types/workflow' -import { fetchWorkflowState } from '@/lib/mothership/agent-cli/engines/workflow-views' +import { fetchWorkflowState } from '@/lib/mothership/agent-cli/engines/workflow-state' import { type AgentCliEngine, type AgentCliRuntime, diff --git a/apps/sim/lib/mothership/agent-cli/engines/trace.ts b/apps/sim/lib/mothership/agent-cli/engines/trace.ts deleted file mode 100644 index 8cc7c60f1f0..00000000000 --- a/apps/sim/lib/mothership/agent-cli/engines/trace.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { - type AgentCliEngine, - type AgentCliRuntime, - agentCliFail, - agentCliOk, -} from '@/lib/mothership/agent-cli/types' - -/** - * `workflow trace ` — a run's trace rolled up for diagnosis, replacing - * the hand-written span filters agents otherwise improvise (a field audit found - * four divergent jq programs over the same trace, two of which disagreed 7 vs - * 21 blocks because a shallow walk silently drops every block inside a child - * workflow). The walk here is always recursive, errors come only from status - * and error FIELDS (never from schema literals that merely contain the word - * "error"), and subworkflow spans keep their nesting depth visible. - */ - -interface TraceSpan { - id?: string - name?: string - type?: string - duration?: number - durationMs?: number - status?: string - errorHandled?: boolean - errorType?: string - errorMessage?: string - blockId?: string - children?: TraceSpan[] -} - -interface FlatSpan { - name: string - type: string - durationMs: number - depth: number - status?: string - errorMessage?: string - errorHandled?: boolean -} - -function flattenSpans(spans: TraceSpan[], depth: number, out: FlatSpan[]): void { - for (const span of spans) { - out.push({ - name: span.name ?? span.blockId ?? 'unnamed', - type: span.type ?? 'unknown', - durationMs: span.durationMs ?? span.duration ?? 0, - depth, - ...(span.status !== undefined ? { status: span.status } : {}), - ...(span.errorMessage !== undefined ? { errorMessage: span.errorMessage } : {}), - ...(span.errorHandled !== undefined ? { errorHandled: span.errorHandled } : {}), - }) - if (span.children?.length) flattenSpans(span.children, depth + 1, out) - } -} - -function percentile(sorted: number[], p: number): number { - if (sorted.length === 0) return 0 - const index = Math.min(sorted.length - 1, Math.floor(p * sorted.length)) - return sorted[index] ?? 0 -} - -export const workflowTraceCommand: AgentCliEngine = { - async execute(rest, runtime: AgentCliRuntime) { - const runId = rest[0] - if (!runId) return agentCliFail('Usage: sim workflow trace ') - const run = await runtime.client.request<{ - data: { - status?: string - totalDurationMs?: number - trigger?: string - workflow?: { id?: string; name?: string } - traceSpans?: TraceSpan[] - } - }>(`/api/v2/logs/${encodeURIComponent(runId)}`) - const record = run.data - const spans: FlatSpan[] = [] - flattenSpans(record.traceSpans ?? [], 0, spans) - if (spans.length === 0) { - return agentCliOk( - JSON.stringify( - { - runId, - status: record.status, - workflow: record.workflow?.name, - note: 'No trace spans (spans age out on their own retention schedule).', - }, - null, - 2 - ) - ) - } - - const byType = new Map() - for (const span of spans) { - const durations = byType.get(span.type) ?? [] - durations.push(span.durationMs) - byType.set(span.type, durations) - } - const typeStats = [...byType.entries()] - .map(([type, durations]) => { - const sorted = [...durations].sort((a, b) => a - b) - return { - type, - count: sorted.length, - totalMs: sorted.reduce((a, b) => a + b, 0), - p50Ms: percentile(sorted, 0.5), - maxMs: sorted[sorted.length - 1] ?? 0, - } - }) - .sort((a, b) => b.totalMs - a.totalMs) - - const errors = spans - .filter((span) => span.errorMessage || (span.status && /^(error|failed)$/i.test(span.status))) - .map((span) => ({ - block: span.name, - type: span.type, - depth: span.depth, - ...(span.status ? { status: span.status } : {}), - ...(span.errorMessage ? { message: span.errorMessage.slice(0, 400) } : {}), - ...(span.errorHandled !== undefined ? { handled: span.errorHandled } : {}), - })) - - const slowest = [...spans] - .sort((a, b) => b.durationMs - a.durationMs) - .slice(0, 10) - .map((span) => ({ - block: span.name, - type: span.type, - durationMs: span.durationMs, - depth: span.depth, - })) - - return agentCliOk( - JSON.stringify( - { - runId, - workflow: record.workflow?.name, - status: record.status, - trigger: record.trigger, - totalDurationMs: record.totalDurationMs, - blockCount: spans.length, - maxDepth: Math.max(...spans.map((span) => span.depth)), - errors, - typeStats, - slowestBlocks: slowest, - }, - null, - 2 - ) - ) - }, -} diff --git a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts index ef244d5c860..1fc9af5c1b2 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts @@ -1,4 +1,5 @@ import { LRUCache } from 'lru-cache' +import type { ReadFileTextResponse } from 'sim/embed' import { type AgentCliEngine, type AgentCliFlags, @@ -6,6 +7,7 @@ import { agentCliFail, agentCliOk, } from '@/lib/mothership/agent-cli/types' +import { buildIntegrationToolSchemas } from '@/lib/mothership/chat/payload' /** * `grep [--scope a,b] [--in ] [-i] [-C n] [--count] [--limit n]` — @@ -23,6 +25,8 @@ const SCOPES = [ 'blocks', 'tools', 'tables', + 'files', + 'integrations', 'skills', 'custom-tools', 'secrets', @@ -34,6 +38,9 @@ const DEFAULT_MATCH_LIMIT = 100 const MAX_MATCH_LIMIT = 500 const MAX_LINE_CHARS = 2_000 const FETCH_CONCURRENCY = 8 +const MAX_FILES = 300 +const FILE_READ_CONCURRENCY = 5 +const MAX_BYTES_PER_FILE = 262_144 /** The block catalog is platform-owned and changes only on deploy; per-workspace visibility keys it. */ const catalogCache = new LRUCache({ max: 500, ttl: 10 * 60_000 }) @@ -153,6 +160,41 @@ const MATERIALIZERS: Record Promise { + // File contents, through the v2 read-text endpoint (binary/degraded files are + // honestly skipped there); the label is the path the model sees in `files ls`. + const list = (await listAll(runtime, '/api/v2/files')).slice(0, MAX_FILES) + const texts = await mapConcurrent(list, FILE_READ_CONCURRENCY, async (file) => { + const id = str(file.id) ?? '' + try { + const response = await runtime.client.request( + `/api/v2/files/${encodeURIComponent(id)}/text`, + { query: { workspaceId: runtime.workspaceId, maxBytes: String(MAX_BYTES_PER_FILE) } } + ) + return { file, text: response.data.degraded ? null : response.data.text } + } catch { + return { file, text: null } + } + }) + return texts.flatMap(({ file, text }) => { + if (text === null) return [] + const folder = (str(file.folderPath) ?? '').replace(/\/+$/, '') + const name = str(file.name) ?? str(file.id) ?? '' + const label = folder ? `${folder}/${name}` : `/${name}` + return [{ scope: 'files' as const, id: str(file.id) ?? '', label, text }] + }) + }, + integrations: async (runtime) => { + // The viewer's callable connected-service operations — the same projection the + // chat request carries, so `integrations list` and this world never disagree. + const tools = await buildIntegrationToolSchemas( + runtime.userId, + undefined, + { schemaSurface: 'copilot' }, + runtime.workspaceId + ) + return tools.map((tool) => render('integrations', tool.name, tool.name, tool)) + }, secrets: async (runtime) => { // Names only, by construction: a secret's value never enters the model window. const list = await listAll(runtime, '/api/v2/secrets') diff --git a/apps/sim/lib/mothership/agent-cli/engines/workflow-state.ts b/apps/sim/lib/mothership/agent-cli/engines/workflow-state.ts new file mode 100644 index 00000000000..e9f89d142a7 --- /dev/null +++ b/apps/sim/lib/mothership/agent-cli/engines/workflow-state.ts @@ -0,0 +1,13 @@ +import type { ExportWorkflowResponse } from 'sim/embed' +import type { AgentCliRuntime } from '@/lib/mothership/agent-cli/types' + +/** One workflow's exported state — the v2 source of truth the analysis engines read. */ +export async function fetchWorkflowState( + runtime: AgentCliRuntime, + workflowId: string +): Promise> { + const response = await runtime.client.request( + `/api/v2/workflows/${encodeURIComponent(workflowId)}/export` + ) + return response.data.state +} diff --git a/apps/sim/lib/mothership/agent-cli/engines/workflow-views.ts b/apps/sim/lib/mothership/agent-cli/engines/workflow-views.ts deleted file mode 100644 index bac501db55f..00000000000 --- a/apps/sim/lib/mothership/agent-cli/engines/workflow-views.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { ExportWorkflowResponse } from 'sim/embed' -import { - type AgentCliEngine, - type AgentCliRuntime, - agentCliFail, - agentCliOk, -} from '@/lib/mothership/agent-cli/types' - -/** - * Projections over one workflow's exported state: just the blocks, or just the - * connections. The full export is the v2 source of truth; these views exist so - * the agent can orient in a large workflow without paging its whole state - * through the context window. - */ - -export async function fetchWorkflowState( - runtime: AgentCliRuntime, - workflowId: string -): Promise> { - const response = await runtime.client.request( - `/api/v2/workflows/${encodeURIComponent(workflowId)}/export` - ) - return response.data.state -} - -interface BlockView { - id: string - type: string | undefined - name: string | undefined - enabled: boolean | undefined -} - -function blockViews(state: Record): BlockView[] { - const blocks = state.blocks - if (typeof blocks !== 'object' || blocks === null) return [] - return Object.entries(blocks as Record).map(([id, raw]) => { - const block = (typeof raw === 'object' && raw !== null ? raw : {}) as Record - return { - id, - type: typeof block.type === 'string' ? block.type : undefined, - name: typeof block.name === 'string' ? block.name : undefined, - enabled: typeof block.enabled === 'boolean' ? block.enabled : undefined, - } - }) -} - -function edgeViews(state: Record): Record[] { - const edges = state.edges - if (!Array.isArray(edges)) return [] - return edges.map((raw) => { - const edge = (typeof raw === 'object' && raw !== null ? raw : {}) as Record - return { - source: edge.source, - target: edge.target, - ...(edge.sourceHandle !== undefined && edge.sourceHandle !== null - ? { sourceHandle: edge.sourceHandle } - : {}), - ...(edge.targetHandle !== undefined && edge.targetHandle !== null - ? { targetHandle: edge.targetHandle } - : {}), - } - }) -} - -export const workflowBlocksCommand: AgentCliEngine = { - async execute(rest, runtime) { - const workflowId = rest[0] - if (!workflowId) return agentCliFail('Usage: sim workflow blocks ') - const state = await fetchWorkflowState(runtime, workflowId) - return agentCliOk(JSON.stringify(blockViews(state), null, 2)) - }, -} - -export const workflowEdgesCommand: AgentCliEngine = { - async execute(rest, runtime) { - const workflowId = rest[0] - if (!workflowId) return agentCliFail('Usage: sim workflow edges ') - const state = await fetchWorkflowState(runtime, workflowId) - return agentCliOk(JSON.stringify(edgeViews(state), null, 2)) - }, -} diff --git a/apps/sim/lib/mothership/agent-cli/index.ts b/apps/sim/lib/mothership/agent-cli/index.ts index 0c0203379f3..84fa918bd63 100644 --- a/apps/sim/lib/mothership/agent-cli/index.ts +++ b/apps/sim/lib/mothership/agent-cli/index.ts @@ -2,7 +2,6 @@ import { createEmbeddedClient, type EmbeddedCliIdentity } from 'sim/embed' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { curateBlockDetail } from '@/lib/mothership/agent-cli/curation' import { runEngine } from '@/lib/mothership/agent-cli/engines' -import { applyPipeline } from '@/lib/mothership/agent-cli/pipeline' import { runCli } from '@/lib/mothership/agent-cli/run-cli' import { applySink } from '@/lib/mothership/agent-cli/sink' import { agentCliFail } from '@/lib/mothership/agent-cli/types' @@ -39,7 +38,10 @@ export async function executeAgentCliRequest( const sessionKey = context.chatId ? chatSandboxSessionKey(context.chatId) : null let result: AgentCliRawResult - if (request.invocation.kind === 'augmentation') { + if (request.invocation.kind === 'stdout') { + // Text the worker already holds (sliced, or worker-answered): only the sink applies. + result = { exitCode: 0, stdout: request.invocation.stdout, stderr: '' } + } else if (request.invocation.kind === 'augmentation') { result = await runEngine( request.invocation.name, request.invocation.positionals, @@ -56,8 +58,5 @@ export async function executeAgentCliRequest( result = await curateBlockDetail(result, context) } } - if (result.exitCode === 0 && request.pipeline.length > 0) { - result = await applyPipeline(result, request.pipeline) - } return request.sink ? applySink(request.sink, sessionKey, result) : result } diff --git a/apps/sim/lib/mothership/agent-cli/pipeline.test.ts b/apps/sim/lib/mothership/agent-cli/pipeline.test.ts deleted file mode 100644 index aec5b5d8934..00000000000 --- a/apps/sim/lib/mothership/agent-cli/pipeline.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { applyPipeline as applyPipelineRaw } from '@/lib/mothership/agent-cli/pipeline' -import type { AgentCliGrepStage, AgentCliPipeStage } from '@/lib/mothership/generated/agent-cli' - -async function applyPipeline(stdout: string, stages: AgentCliPipeStage[]): Promise { - const result = await applyPipelineRaw({ exitCode: 0, stdout, stderr: '' }, stages) - return result.exitCode === 0 ? result.stdout : `ERROR ${result.stderr}` -} - -function grep(overrides: Partial & { pattern: string }): AgentCliGrepStage { - return { - kind: 'grep', - ignoreCase: false, - invert: false, - countOnly: false, - lineNumbers: false, - linesBefore: 0, - linesAfter: 0, - ...overrides, - } -} - -describe('applyPipeline over typed grep stages', () => { - const input = 'alpha slack\nbeta\ngamma SLACK\nslack delta\n' - - it('filters lines by pattern', async () => { - expect(await applyPipeline(input, [grep({ pattern: 'slack' })])).toBe( - 'alpha slack\nslack delta' - ) - }) - - it('honours ignoreCase, lineNumbers, invert, countOnly, and maxCount', async () => { - expect(await applyPipeline(input, [grep({ pattern: 'slack', ignoreCase: true })])).toBe( - 'alpha slack\ngamma SLACK\nslack delta' - ) - expect(await applyPipeline(input, [grep({ pattern: 'slack', lineNumbers: true })])).toBe( - '1:alpha slack\n4:slack delta' - ) - expect(await applyPipeline(input, [grep({ pattern: 'slack', invert: true })])).toBe( - 'beta\ngamma SLACK\n' - ) - expect( - await applyPipeline(input, [grep({ pattern: 'slack', ignoreCase: true, countOnly: true })]) - ).toBe('3') - expect( - await applyPipeline(input, [grep({ pattern: 'slack', ignoreCase: true, maxCount: 2 })]) - ).toBe('alpha slack\ngamma SLACK') - }) - - it('treats the pattern as a regex with a literal fallback', async () => { - expect(await applyPipeline('a1\nb2\nc3', [grep({ pattern: '^[ab]' })])).toBe('a1\nb2') - expect(await applyPipeline('cost is $4 (net', [grep({ pattern: '$4 (net' })])).toBe( - 'cost is $4 (net' - ) - }) - - it('chains stages left to right', async () => { - expect( - await applyPipeline(input, [ - grep({ pattern: 'slack', ignoreCase: true }), - grep({ pattern: 'delta', invert: true }), - ]) - ).toBe('alpha slack\ngamma SLACK') - }) - - describe('context windows', () => { - const lines = 'a\nb\nHIT\nc\nd\ne\nHIT\nf' - it('trailing context', async () => { - expect(await applyPipeline(lines, [grep({ pattern: 'HIT', linesAfter: 1 })])).toBe( - 'HIT\nc\nHIT\nf' - ) - }) - it('windows without duplicating overlaps', async () => { - expect( - await applyPipeline('x\nHIT\nHIT\ny', [ - grep({ pattern: 'HIT', linesBefore: 1, linesAfter: 1 }), - ]) - ).toBe('x\nHIT\nHIT\ny') - }) - it('counts hits, not context lines', async () => { - expect( - await applyPipeline(lines, [grep({ pattern: 'HIT', countOnly: true, linesAfter: 2 })]) - ).toBe('2') - }) - }) -}) - -describe('jq and outline over JSON stdout', () => { - const json = JSON.stringify({ - data: { - operations: { send: { toolId: 'slack_send' }, list: { toolId: 'slack_list' } }, - tags: ['a', 'b'], - }, - }) - - it('jq slices with real jq semantics', async () => { - expect(await applyPipeline(json, [{ kind: 'jq', expression: '.data.operations | keys' }])).toBe( - '[\n "list",\n "send"\n]' - ) - expect(await applyPipeline(json, [{ kind: 'jq', expression: '.data.tags[]' }])).toBe('"a"\n"b"') - }) - - it('outline reports keys, types, and counts without values', async () => { - const outline = await applyPipeline(json, [{ kind: 'outline' }]) - expect(outline).toContain('data: object{2}') - expect(outline).toContain('operations: object{2}') - expect(outline).toContain('tags: array[2]') - expect(outline).not.toContain('slack_send') - }) - - it('fails the invocation with the reason when stdout is not JSON or the program is bad', async () => { - expect(await applyPipeline('plain text', [{ kind: 'jq', expression: '.' }])).toContain( - 'output is text, not JSON' - ) - expect(await applyPipeline(json, [{ kind: 'jq', expression: '.data |' }])).toContain('jq:') - }) -}) diff --git a/apps/sim/lib/mothership/agent-cli/pipeline.ts b/apps/sim/lib/mothership/agent-cli/pipeline.ts deleted file mode 100644 index a6047c37f0f..00000000000 --- a/apps/sim/lib/mothership/agent-cli/pipeline.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { raw as jqRaw } from 'jq-wasm' -import type { - AgentCliGrepStage, - AgentCliPipeStage, - AgentCliRawResult, -} from '@/lib/mothership/generated/agent-cli' - -/** - * Applies the worker's already-parsed pipe stages to a command's result. Every option - * arrives typed, so no flag is ever interpreted on this side: - * - grep: a native filter over the string — nothing is spawned. - * - jq: real jq (1.8, WebAssembly) over JSON stdout — the model's slicing tool, with - * the semantics it already knows. - * - outline: keys, types, and counts to depth 3, no values — the shape of a big - * response for the price of a few lines. - * A stage that cannot apply (non-JSON stdout, a jq error) fails the invocation with - * the reason on stderr, so the model corrects the pipe instead of reading garbage. - */ - -const OUTLINE_MAX_DEPTH = 3 -const OUTLINE_MAX_KEYS = 40 - -function compileGrepPattern(raw: string, ignoreCase: boolean): (line: string) => boolean { - try { - const regex = new RegExp(raw, ignoreCase ? 'i' : '') - return (line) => regex.test(line) - } catch { - const needle = ignoreCase ? raw.toLowerCase() : raw - return (line) => (ignoreCase ? line.toLowerCase() : line).includes(needle) - } -} - -function runGrep(input: string, stage: AgentCliGrepStage): string { - const matches = compileGrepPattern(stage.pattern, stage.ignoreCase) - const lines = input.split('\n') - const maxCount = stage.maxCount ?? Number.POSITIVE_INFINITY - // Context options select a window of line indexes around each hit (union, in - // order, no duplicates) — matching grep's -A/-B/-C output without separators. - const selected = new Set() - let hits = 0 - for (let lineNo = 0; lineNo < lines.length && hits < maxCount; lineNo++) { - const hit = matches(lines[lineNo]) - if (hit !== stage.invert) { - hits++ - const from = Math.max(0, lineNo - stage.linesBefore) - const to = Math.min(lines.length - 1, lineNo + stage.linesAfter) - for (let i = from; i <= to; i++) selected.add(i) - } - } - if (stage.countOnly) return String(hits) - const out = [...selected].sort((a, b) => a - b) - return out.map((i) => (stage.lineNumbers ? `${i + 1}:${lines[i]}` : lines[i])).join('\n') -} - -class PipeStageError extends Error {} - -/** JSON.parse's result space, spelled out: what jq accepts as input. */ -type JsonValue = string | number | boolean | object | null - -function parseJsonStdout(stdout: string, stage: string): JsonValue { - try { - const parsed: JsonValue = JSON.parse(stdout) - return parsed - } catch { - throw new PipeStageError( - `${stage}: this command's output is text, not JSON, so ${stage} cannot apply. Filter it with | grep instead, or use outputs get --grep on a stored result.` - ) - } -} - -async function runJq(input: string, expression: string): Promise { - const value = parseJsonStdout(input, 'jq') - const result = await jqRaw(value, expression) - if (result.exitCode !== 0) { - throw new PipeStageError(`jq: ${result.stderr.trim() || `exited with code ${result.exitCode}`}`) - } - return result.stdout.trimEnd() -} - -function describe(value: unknown, depth: number, indent: string, out: string[]): void { - if (Array.isArray(value)) { - out.push(`${indent}[${value.length} items]`) - if (depth < OUTLINE_MAX_DEPTH && value.length > 0) - describe(value[0], depth + 1, `${indent} `, out) - return - } - if (value !== null && typeof value === 'object') { - const entries = Object.entries(value as Record) - for (const [key, child] of entries.slice(0, OUTLINE_MAX_KEYS)) { - const kind = Array.isArray(child) - ? `array[${child.length}]` - : child === null - ? 'null' - : typeof child === 'object' - ? `object{${Object.keys(child as object).length}}` - : typeof child - out.push(`${indent}${key}: ${kind}`) - if (depth < OUTLINE_MAX_DEPTH && child !== null && typeof child === 'object') { - describe(child, depth + 1, `${indent} `, out) - } - } - if (entries.length > OUTLINE_MAX_KEYS) - out.push(`${indent}… ${entries.length - OUTLINE_MAX_KEYS} more keys`) - return - } - out.push(`${indent}${typeof value}`) -} - -function runOutline(input: string): string { - const value = parseJsonStdout(input, 'outline') - const out: string[] = [] - describe(value, 1, '', out) - return out.join('\n') -} - -export async function applyPipeline( - result: AgentCliRawResult, - stages: readonly AgentCliPipeStage[] -): Promise { - let current = result.stdout - try { - for (const stage of stages) { - if (stage.kind === 'grep') current = runGrep(current, stage) - else if (stage.kind === 'jq') current = await runJq(current, stage.expression) - else current = runOutline(current) - } - } catch (error) { - if (error instanceof PipeStageError) { - return { exitCode: 1, stdout: '', stderr: `Error: ${error.message}` } - } - throw error - } - return { ...result, stdout: current } -} diff --git a/apps/sim/lib/mothership/agent-cli/request-schema.ts b/apps/sim/lib/mothership/agent-cli/request-schema.ts index 1e666c2505e..c474f994f58 100644 --- a/apps/sim/lib/mothership/agent-cli/request-schema.ts +++ b/apps/sim/lib/mothership/agent-cli/request-schema.ts @@ -4,20 +4,9 @@ import type { AgentCliRequest } from '@/lib/mothership/generated/agent-cli' /** * Runtime validation of the worker's typed request (the generated contract carries only * the TypeScript shape). Validation is the ONLY thing this side does with the request - * before executing it — no re-parsing, no routing decisions. + * before executing it — no re-parsing, no routing decisions. Slicing never arrives here: + * the worker applies pipes to whatever this side returns. */ -const grepStageSchema = z.object({ - kind: z.literal('grep'), - pattern: z.string(), - ignoreCase: z.boolean(), - invert: z.boolean(), - countOnly: z.boolean(), - lineNumbers: z.boolean(), - maxCount: z.number().int().positive().optional(), - linesBefore: z.number().int().nonnegative(), - linesAfter: z.number().int().nonnegative(), -}) - export const agentCliRequestSchema = z.object({ invocation: z.discriminatedUnion('kind', [ z.object({ kind: z.literal('cli'), argv: z.array(z.string()).min(1).max(64) }), @@ -27,14 +16,8 @@ export const agentCliRequestSchema = z.object({ positionals: z.array(z.string()), flags: z.record(z.string(), z.union([z.string(), z.literal(true)])), }), + z.object({ kind: z.literal('stdout'), stdout: z.string().max(50_000_000) }), ]), - pipeline: z.array( - z.discriminatedUnion('kind', [ - grepStageSchema, - z.object({ kind: z.literal('jq'), expression: z.string().min(1).max(4_000) }), - z.object({ kind: z.literal('outline') }), - ]) - ), sink: z.object({ kind: z.literal('sandbox-file'), path: z.string().min(1).max(300) }).optional(), curate: z.literal('block').optional(), }) satisfies z.ZodType diff --git a/apps/sim/lib/mothership/generated/agent-cli.ts b/apps/sim/lib/mothership/generated/agent-cli.ts index 2f11d6758f9..95ca9c91e7e 100644 --- a/apps/sim/lib/mothership/generated/agent-cli.ts +++ b/apps/sim/lib/mothership/generated/agent-cli.ts @@ -3,81 +3,64 @@ /** * The mothership↔sim wire for one Sim CLI invocation (docs/revamp/18-agent-surface.md - * §0 + Phase A0). The WORKER owns the agent grammar — it parses the model's argv into - * this typed request; sim executes it with generic primitives and never re-parses - * tokens (no pipe splitting, no flag matching, no augmentation routing on the sim side). - * - * Wire-shared shape: sim's tool handler validates its frame arguments against this. + * §4, Phase A0). The worker's translation layer decides everything — which command, + * which augmentation, whether the result lands on the caller's machine — and sim + * executes exactly what it is handed: no re-parsing, no policy. Slicing (`| grep`, + * `| jq`, `| outline`) never crosses this wire: the worker applies it to whatever comes + * back, so every command pipes the same way regardless of where it is answered. */ -/** One grep stage, fully parsed: sim applies it, it never interprets flags. */ -export interface AgentCliGrepStage { - kind: "grep"; - pattern: string; - ignoreCase: boolean; - invert: boolean; - countOnly: boolean; - lineNumbers: boolean; - /** Stop after this many matching lines; absent = unbounded. */ - maxCount?: number; - linesBefore: number; - linesAfter: number; -} - -/** A jq program applied to JSON stdout — the model's slicing tool; real jq semantics. */ -export interface AgentCliJqStage { - kind: "jq"; - expression: string; -} - -/** Keys, types, and counts of JSON stdout to depth 3, no values — the shape, cheaply. */ -export interface AgentCliOutlineStage { - kind: "outline"; -} - -export type AgentCliPipeStage = AgentCliGrepStage | AgentCliJqStage | AgentCliOutlineStage; - -/** Where stdout lands instead of the model window. */ +/** The result lands on the caller's machine (the chat's sandbox) instead of the window. */ export interface AgentCliSandboxFileSink { kind: "sandbox-file"; - /** Path on the chat's workbench sandbox. */ + /** File name or path on the caller's machine; relative paths resolve under its home. */ path: string; } export type AgentCliSink = AgentCliSandboxFileSink; -/** The real CLI's own command tree, run in-process on sim. */ +/** A real Sim CLI command, run in-process against the embedded CLI. */ export interface AgentCliCliInvocation { kind: "cli"; /** argv tokens with global rendering flags and any pipeline already stripped. */ argv: string[]; } -/** An agent-only augmentation, resolved by the worker's registry to its sim engine. */ +/** An agent-only command, run by sim's engine of the same name with typed inputs. */ export interface AgentCliAugmentationInvocation { kind: "augmentation"; - /** Engine name, e.g. "workflow lint" — the registry's canonical path. */ + /** The registry's canonical name, e.g. "grep" or "workflows lint". */ name: string; positionals: string[]; - /** `--flag value` / `--flag=value` → string; bare `--flag` → true. */ flags: Record; } -export type AgentCliInvocation = AgentCliCliInvocation | AgentCliAugmentationInvocation; +/** + * Text the worker already has (a sliced result, or a worker-answered command's + * output) that only needs the sink applied: sim writes it and answers with the notice. + */ +export interface AgentCliStdoutInvocation { + kind: "stdout"; + stdout: string; +} + +export type AgentCliInvocation = + | AgentCliCliInvocation + | AgentCliAugmentationInvocation + | AgentCliStdoutInvocation; export interface AgentCliRequest { invocation: AgentCliInvocation; - pipeline: AgentCliPipeStage[]; sink?: AgentCliSink; /** - * Viewer curation sim applies to the raw result before the pipeline: "block" trims a + * Viewer curation sim applies to the raw result before the sink: "block" trims a * block detail to the operations, inputs and models this viewer may use. Decided by the * worker's parse, applied by sim's primitive, so both sides see one policy. */ curate?: "block"; } -/** What sim returns; the worker shapes the model-facing result from it. */ +/** What sim hands back: the CLI's own three channels, untouched. */ export interface AgentCliRawResult { exitCode: number; stdout: string; diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli-bridge.test.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli-bridge.test.ts index 17d8aa1043f..e2e77c12999 100644 --- a/apps/sim/lib/mothership/tools/handlers/sim-cli-bridge.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/sim-cli-bridge.test.ts @@ -13,6 +13,7 @@ const { mockRead, mockWrite, mockRunEmbeddedCli, mockMint } = vi.hoisted(() => ( vi.mock('@/lib/execution/remote-sandbox/session-files', () => ({ readSessionSandboxFile: mockRead, writeSessionSandboxFile: mockWrite, + resolveSessionPath: (path: string) => (path.startsWith('/') ? path : `/home/user/${path}`), })) vi.mock('sim/embed', () => ({ runEmbeddedCli: mockRunEmbeddedCli, @@ -32,7 +33,7 @@ const context = { workspaceId: 'ws-1', userId: 'u-1', chatId: 'chat-1' } as Para >[1] function cli(argv: string[], extra: Partial = {}): { request: AgentCliRequest } { - return { request: { invocation: { kind: 'cli', argv }, pipeline: [], ...extra } } + return { request: { invocation: { kind: 'cli', argv }, ...extra } } } describe('sim-cli handler executes the worker-built request', () => { @@ -55,7 +56,7 @@ describe('sim-cli handler executes the worker-built request', () => { expect(mockRunEmbeddedCli).toHaveBeenCalledWith( ['workflows', 'run', 'wf1', '--input', '@env.json'], expect.anything(), - { fileArguments: { 'env.json': '{"text":"hi"}' } } + expect.objectContaining({ fileArguments: { 'env.json': '{"text":"hi"}' } }) ) }) @@ -66,34 +67,10 @@ describe('sim-cli handler executes the worker-built request', () => { expect(mockRunEmbeddedCli).toHaveBeenCalledWith( ['x', '@@literal', '@missing.json'], expect.anything(), - { - fileArguments: {}, - } + expect.objectContaining({ fileArguments: {} }) ) }) - it('applies the pre-parsed pipeline to a successful result', async () => { - mockRunEmbeddedCli.mockResolvedValue({ exitCode: 0, stdout: 'alpha slack\nbeta\n', stderr: '' }) - const result = await executeSimCli( - cli(['workflows', 'list'], { - pipeline: [ - { - kind: 'grep', - pattern: 'slack', - ignoreCase: true, - invert: false, - countOnly: false, - lineNumbers: false, - linesBefore: 0, - linesAfter: 0, - }, - ], - }), - context - ) - expect((result.output as { stdout: string }).stdout).toBe('alpha slack') - }) - it('sink lands stdout on the machine and returns only the ack', async () => { mockWrite.mockResolvedValue({ outcome: 'written', path: '/home/user/trace.json' }) const result = await executeSimCli( @@ -102,11 +79,28 @@ describe('sim-cli handler executes the worker-built request', () => { ) expect(mockWrite).toHaveBeenCalledWith('mothership-chat:chat-1', 'trace.json', 'BIG OUTPUT') const output = result.output as { stdout: string } - expect(output.stdout).toContain('written to trace.json') + expect(output.stdout).toContain('written to /home/user/trace.json') expect(output.stdout).toContain('10 chars') expect(output.stdout).not.toContain('BIG OUTPUT') }) + it('a stdout invocation only lands the worker-sliced text — nothing is executed', async () => { + mockWrite.mockResolvedValue({ outcome: 'written', path: '/home/user/sliced.json' }) + const result = await executeSimCli( + { + request: { + invocation: { kind: 'stdout', stdout: 'SLICED' }, + sink: { kind: 'sandbox-file', path: 'sliced.json' }, + }, + }, + context + ) + expect(mockRunEmbeddedCli).not.toHaveBeenCalled() + expect(mockWrite).toHaveBeenCalledWith('mothership-chat:chat-1', 'sliced.json', 'SLICED') + const output = result.output as { stdout: string } + expect(output.stdout).toContain('written to /home/user/sliced.json') + }) + it('sink on a cold machine returns output inline with boot guidance', async () => { mockWrite.mockResolvedValue({ outcome: 'no-session' }) const result = await executeSimCli( diff --git a/apps/sim/lib/mothership/tools/handlers/sim-cli.ts b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts index 1131babae59..9960b9013b8 100644 --- a/apps/sim/lib/mothership/tools/handlers/sim-cli.ts +++ b/apps/sim/lib/mothership/tools/handlers/sim-cli.ts @@ -38,7 +38,7 @@ export async function executeSimCli( logger.info('CLI invocation finished', { exitCode: result.exitCode, lane: parsed.data.invocation.kind, - pipeStages: parsed.data.pipeline.length, + sink: parsed.data.sink?.kind ?? 'none', stdoutBytes: result.stdout.length, }) return { diff --git a/apps/sim/package.json b/apps/sim/package.json index 6caf4d20033..e52d209eb2d 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -196,7 +196,6 @@ "ioredis": "^5.6.0", "isolated-vm": "6.2.0", "jose": "6.0.11", - "jq-wasm": "3.0.0-jq-1.8.2", "js-tiktoken": "1.0.21", "js-yaml": "4.3.2", "jsdom": "^26.0.0", diff --git a/bun.lock b/bun.lock index d7b2658e9c1..7a63cea0e67 100644 --- a/bun.lock +++ b/bun.lock @@ -318,7 +318,6 @@ "ioredis": "^5.6.0", "isolated-vm": "6.2.0", "jose": "6.0.11", - "jq-wasm": "3.0.0-jq-1.8.2", "js-tiktoken": "1.0.21", "js-yaml": "4.3.2", "jsdom": "^26.0.0", @@ -3458,8 +3457,6 @@ "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], - "jq-wasm": ["jq-wasm@3.0.0-jq-1.8.2", "", {}, "sha512-jgWSEBJSd0lYR4Q5Fw8333MxQS5jCRI+g9KwAGL7yK1spwzTJy8C5uOS08wbpKE0Gz8oQYLlRpNLE/W70Ksp2g=="], - "js-md4": ["js-md4@0.3.2", "", {}, "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA=="], "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], From dca73283550325391d2377a43da3730b1a2075c5 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 13:01:09 +0530 Subject: [PATCH 058/306] sim-cli inventory: a union of variants gets a wider type cap The 220-char cap cut the operations apply operation_type discriminator list mid-way on the mothership's card; unions now cap at 600. Claude-Session: https://claude.ai/code/session_01HFVhPDtRZuCgupPco633w8 --- packages/sim-cli/scripts/print-command-inventory.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/sim-cli/scripts/print-command-inventory.ts b/packages/sim-cli/scripts/print-command-inventory.ts index bd705d94ff3..e41b5b60e5e 100644 --- a/packages/sim-cli/scripts/print-command-inventory.ts +++ b/packages/sim-cli/scripts/print-command-inventory.ts @@ -231,10 +231,16 @@ function requestShape( } const MAX_BODY_TYPE_CHARS = 220 +const MAX_UNION_TYPE_CHARS = 600 -/** A recursive grammar (the row predicate) expands past what a card line can carry. */ +/** + * A recursive grammar (the row predicate) expands past what a card line can carry. A + * union of variants gets more room: cutting it mid-list hid the operation vocabulary of + * `operations apply` and sent an agent guessing verbs (skills run, 2026-09-02). + */ function capType(label: string): string { - return label.length > MAX_BODY_TYPE_CHARS ? `${label.slice(0, MAX_BODY_TYPE_CHARS)}…` : label + const max = label.includes('}|{') ? MAX_UNION_TYPE_CHARS : MAX_BODY_TYPE_CHARS + return label.length > max ? `${label.slice(0, max)}…` : label } const program = buildProgram() From f3c2212ab9ee9df9ad55fd124e79b026ed8ac9ce Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 15:02:07 +0530 Subject: [PATCH 059/306] mothership agent cli: grep --in accepts the world/resource path a match line prints --- .../agent-cli/engines/universal-grep.test.ts | 10 ++++++++++ .../mothership/agent-cli/engines/universal-grep.ts | 13 ++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts index 025f0171e40..58584ae52d6 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts @@ -58,6 +58,16 @@ describe('universal grep', () => { expect(count.stdout).toMatch(/^\d+ \(blocks=\d+\)$/) }) + it('accepts the world/resource path a match line prints as --in', async () => { + const byPath = await runEngine('grep', ['id'], runtimeWith(CATALOG), { in: 'blocks/agent' }) + expect(byPath.exitCode).toBe(0) + expect(byPath.stdout).toContain('blocks/agent:') + expect(byPath.stdout).not.toContain('blocks/slack_v2:') + const world = await runEngine('grep', ['id'], runtimeWith(CATALOG), { in: 'blocks' }) + expect(world.stdout).toContain('blocks/agent:') + expect(world.stdout).toContain('blocks/slack_v2:') + }) + it('refuses an unknown scope with a did-you-mean and the scope list', async () => { const result = await runEngine('grep', ['x'], runtimeWith({}), { scope: 'block' }) expect(result.exitCode).toBe(1) diff --git a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts index 1fc9af5c1b2..2602b9773b4 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts @@ -271,7 +271,7 @@ export const universalGrepCommand: AgentCliEngine = { const pattern = positionals[0] if (!pattern) { return agentCliFail( - 'Usage: sim grep [--scope workflows,blocks,...] [--in ] [-i] [-C n] [--count] [--limit n]' + 'Usage: sim grep [--scope workflows,blocks,...] [--in ] [-i] [-C n] [--count] [--limit n]' ) } const scopes = parseScopes(flags) @@ -282,12 +282,15 @@ export const universalGrepCommand: AgentCliEngine = { if (typeof context === 'string') return agentCliFail(context) const ignoreCase = flags.i === true const countOnly = flags.count === true - const within = typeof flags.in === 'string' ? flags.in.toLowerCase() : undefined // `--in tables` reads as "search the tables world", so a world name narrows the scope; - // anything else is a resource id or name inside the searched worlds. - const withinScope = SCOPES.find((scope) => scope === within) + // `--in blocks/table_v2` is the path a match line prints (world, then resource); a bare + // value is a resource id or name inside the searched worlds. + const within = typeof flags.in === 'string' ? flags.in.toLowerCase() : undefined + const [withinHead, ...withinRest] = within ? within.split('/') : [] + const withinScope = SCOPES.find((scope) => scope === withinHead) + const withinResource = withinScope ? withinRest.join('/') : within const searched: Scope[] = withinScope ? [withinScope] : scopes - const nameFilter = withinScope ? undefined : within + const nameFilter = withinResource || undefined const matches = compilePattern(pattern, ignoreCase) const materialized = ( From 99be56f987bc94853ccbf9162b0e34c1a9d33cdf Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 15:19:43 +0530 Subject: [PATCH 060/306] workflow lint, block catalog, tables, files, sandbox tools: fixes from exploration run 5 Lint: trigger-category blocks are entry blocks (schedule was an orphan); references inside Function code are checked with the runtime tokenizer; the agent-cli lint and grep read the draft state instead of the sanitized export. Catalog: operation inputs publish sub-block ids only (no canonical-param aliases); trigger-category blocks expose their trigger-mode fields. Tables: dispatch processedCount counts unlimited dispatches; json-language code fields accept objects. Files: restore keeps the folder when it still exists. Sandbox tools: outputTable failures report the files already written and the per-language result shape; the file writer keeps the computed result. --- apps/docs/openapi-v2-files-audit.json | 2 +- .../api/v2/files/[fileId]/restore/route.ts | 11 +- apps/sim/blocks/blocks/table_v2.ts | 3 + .../api/contracts/v2/openapi/files-audit.ts | 2 +- .../lib/catalog/projection/block-detail.ts | 39 ++-- .../catalog/projection/catalog-sweep.test.ts | 33 +++- .../lib/mothership/agent-cli/engines.test.ts | 6 +- .../agent-cli/engines/universal-grep.test.ts | 23 +++ .../agent-cli/engines/universal-grep.ts | 18 +- .../agent-cli/engines/workflow-state.ts | 20 ++- .../sim/lib/mothership/request/tools/files.ts | 3 + .../mothership/request/tools/tables.test.ts | 42 +++++ .../lib/mothership/request/tools/tables.ts | 47 +++-- apps/sim/lib/table/dispatcher.test.ts | 168 ++++++++++++++++++ apps/sim/lib/table/dispatcher.ts | 10 +- .../workspace/workspace-file-manager.ts | 22 ++- .../workspace-file-storage-accounting.test.ts | 59 +++++- .../apply-workflow-operations.test.ts | 1 + .../workflows/editing/dangling-refs.test.ts | 41 ++++- apps/sim/lib/workflows/editing/lint.test.ts | 38 +++- apps/sim/lib/workflows/editing/lint.ts | 37 +++- .../lib/workflows/editing/validation.test.ts | 40 +++++ apps/sim/lib/workflows/editing/validation.ts | 6 + .../restore-workspace-file.test.ts | 5 +- .../application/restore-workspace-file.ts | 6 +- 25 files changed, 604 insertions(+), 78 deletions(-) create mode 100644 apps/sim/lib/table/dispatcher.test.ts diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 77844ff2cbc..28a9c5494b5 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -1979,7 +1979,7 @@ "post": { "operationId": "restoreFile", "summary": "Restore File", - "description": "Restore an archived file to the workspace root. Name collisions add a `_restored` suffix; use the returned `name` and `folderPath`. An active file returns unchanged. An archived workspace returns `400`; an unresolved name collision returns `409`.\n\nOAuth scope: `api:write`.", + "description": "Reverse a soft delete and return the file to the workspace. Not a pure undo: the file comes back in the folder it was deleted from, or at the workspace root when that folder has since been archived, and gains a `_restored` suffix when another file there already holds its name, so read `folderPath` and `name` off the response. Restoring an already-active file returns it unchanged, so a retry is safe. An archived workspace is a `400`, and a name the restore could not free is a `409`.\n\nOAuth scope: `api:write`.", "x-sim-operation": "files.restore", "x-oauth-scope": "api:write", "tags": ["Files"], diff --git a/apps/sim/app/api/v2/files/[fileId]/restore/route.ts b/apps/sim/app/api/v2/files/[fileId]/restore/route.ts index 050a49e0ccf..4f4d02447de 100644 --- a/apps/sim/app/api/v2/files/[fileId]/restore/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/restore/route.ts @@ -14,11 +14,12 @@ export const revalidate = 0 * `DELETE /api/v2/files/[fileId]` is a soft delete; this reverses it. Find the * ids to pass here with `GET /api/v2/files?scope=archived`. * - * Restore is not a pure undo: the file returns to the workspace root regardless - * of the folder it was deleted from, and it is renamed when its original name - * is no longer free. The response is therefore the post-restore record, not the - * one the caller deleted. Restoring an already-active file is a no-op that - * returns that file, so a retried request is safe. + * Restore is not a pure undo: the file returns to the folder it was deleted + * from while that folder is still active, and to the workspace root once the + * folder has been archived; it is renamed when its original name is no longer + * free there. The response is therefore the post-restore record, not the one + * the caller deleted. Restoring an already-active file is a no-op that returns + * that file, so a retried request is safe. */ export const POST = defineV2JsonRoute({ contract: v2RestoreFileContract, diff --git a/apps/sim/blocks/blocks/table_v2.ts b/apps/sim/blocks/blocks/table_v2.ts index 6f0890a3ca8..9751e321f59 100644 --- a/apps/sim/blocks/blocks/table_v2.ts +++ b/apps/sim/blocks/blocks/table_v2.ts @@ -370,6 +370,7 @@ export const TableV2Block: BlockConfig = { id: 'data', title: 'Row Data (JSON)', type: 'code', + language: 'json', placeholder: '{"column_name": "value"}', condition: { field: 'operation', @@ -426,6 +427,7 @@ Return ONLY the data JSON:`, id: 'rows', title: 'Rows Data (Array of JSON)', type: 'code', + language: 'json', placeholder: '[{"col1": "val1"}, {"col1": "val2"}]', condition: { field: 'operation', value: 'batch_insert_rows' }, required: true, @@ -467,6 +469,7 @@ Return ONLY the rows array:`, id: 'filter', title: 'Filter', type: 'code', + language: 'json', canonicalParamId: 'filterInput', mode: 'advanced', placeholder: '{"field":"wins","op":"gte","value":10}', diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index 36858773bad..d3cd36d08a1 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -814,7 +814,7 @@ const declaredRoutes = [ operationId: 'restoreFile', summary: 'Restore File', description: - 'Restore an archived file to the workspace root. Name collisions add a `_restored` suffix; use the returned `name` and `folderPath`. An active file returns unchanged. An archived workspace returns `400`; an unresolved name collision returns `409`.', + 'Reverse a soft delete and return the file to the workspace. Not a pure undo: the file comes back in the folder it was deleted from, or at the workspace root when that folder has since been archived, and gains a `_restored` suffix when another file there already holds its name, so read `folderPath` and `name` off the response. Restoring an already-active file returns it unchanged, so a retry is safe. An archived workspace is a `400`, and a name the restore could not free is a `409`.', errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The file as it exists after the restore.' }, }), diff --git a/apps/sim/lib/catalog/projection/block-detail.ts b/apps/sim/lib/catalog/projection/block-detail.ts index 1938d006fa2..3351b9603b6 100644 --- a/apps/sim/lib/catalog/projection/block-detail.ts +++ b/apps/sim/lib/catalog/projection/block-detail.ts @@ -142,9 +142,20 @@ export function hiddenParamKeys(block: BlockConfig): Set { return hidden } -/** Sub-blocks an authoring surface may configure: action fields, minus the hidden ones. */ +/** + * Sub-blocks an authoring surface may configure, minus the hidden ones. + * + * Action fields for an ordinary block. A `triggers`-category block has no + * action side — every field it declares is trigger-mode, and the serializer + * keeps those — so its trigger fields are the authorable ones, minus the + * system-managed display and lifecycle fields. + */ function authorableSubBlocks(block: BlockConfig): SubBlockConfig[] { - return actionSubBlocks(block).filter((subBlock) => !subBlock.hideFromCopilot) + const fields = + block.category === 'triggers' + ? (block.subBlocks ?? []).filter((subBlock) => !SYSTEM_SUBBLOCK_IDS.includes(subBlock.id)) + : actionSubBlocks(block) + return fields.filter((subBlock) => !subBlock.hideFromCopilot) } /** Whether a condition gates its field on a specific operation being selected. */ @@ -227,7 +238,13 @@ export function computeBlockLevelInputs( return blockInputs } -/** Input definitions scoped to one operation, keyed by operation id. */ +/** + * Input definitions scoped to one operation, keyed by operation id. + * + * Each input is published under the sub-block id — the field name an apply + * accepts — even when the block declares its definition under the field's + * `canonicalParamId`, which is the executor-side param and not a writable field. + */ export function computeOperationLevelInputs( block: BlockConfig ): Record> { @@ -237,15 +254,13 @@ export function computeOperationLevelInputs( for (const subBlock of authorableSubBlocks(block)) { const gate = operationGate(subBlock) if (!gate) continue - const keys = [subBlock.canonicalParamId, subBlock.id].filter( - (key): key is string => typeof key === 'string' - ) - for (const key of keys) { - if (!(key in inputs)) continue - for (const operationId of gate.values) { - operationInputs[operationId] ??= {} - operationInputs[operationId][key] = inputs[key] - } + const definition = + inputs[subBlock.id] ?? + (subBlock.canonicalParamId !== undefined ? inputs[subBlock.canonicalParamId] : undefined) + if (!definition) continue + for (const operationId of gate.values) { + operationInputs[operationId] ??= {} + operationInputs[operationId][subBlock.id] = definition } } diff --git a/apps/sim/lib/catalog/projection/catalog-sweep.test.ts b/apps/sim/lib/catalog/projection/catalog-sweep.test.ts index d2677993179..d7a227f84d7 100644 --- a/apps/sim/lib/catalog/projection/catalog-sweep.test.ts +++ b/apps/sim/lib/catalog/projection/catalog-sweep.test.ts @@ -133,11 +133,40 @@ describe('block catalog projection sweep', () => { }) }) +/** + * Pinned shapes of two real registry blocks, where a projection rule is only + * visible against an authored block rather than a synthetic fixture. + */ +describe('block detail regressions', () => { + const registry = getBlockRegistry() + const registered = (type: string) => { + const block = registry[type] + if (!block) throw new Error(`block ${type} is not registered`) + return block + } + + it('keys operation inputs by the sub-block id an apply accepts, not the canonical param id', () => { + const detail = projectBlockDetail(registered('table_v2'), { deployment: HOSTED }) + const inputKeys = Object.keys(detail.operations.query_rows?.inputs ?? {}) + expect(inputKeys).toContain('filter') + expect(inputKeys).not.toContain('filterInput') + expect(inputKeys).not.toContain('sortInput') + }) + + it('publishes a triggers-category block’s trigger-mode fields as its input schema', () => { + const detail = projectBlockDetail(registered('schedule'), { deployment: HOSTED }) + const ids = detail.inputSchema.map((field) => field.id) + expect(ids).toEqual(expect.arrayContaining(['scheduleType', 'cronExpression', 'timezone'])) + expect(ids).not.toContain('scheduleInfo') + }) +}) + /** * Custom (deploy-as-block) blocks, which the registry sweep above cannot reach. * - * `projectCustomBlockDetail` is a separate branch with its own field set — and - * the only one whose `inputSchema` includes `mode: 'trigger'` sub-blocks — yet + * `projectCustomBlockDetail` is a separate branch with its own field set — it + * publishes `mode: 'trigger'` sub-blocks in `inputSchema`, as the main branch + * does only for `triggers`-category blocks — yet * it is caller-reachable through `GET /api/v2/blocks/custom_block_*`. Built from * the same `buildCustomBlockConfig` the overlay uses, so a change to the synthesized * shape shows up here rather than as a 500 on a well-formed request. diff --git a/apps/sim/lib/mothership/agent-cli/engines.test.ts b/apps/sim/lib/mothership/agent-cli/engines.test.ts index 0b1105539d1..cc5a3c6c655 100644 --- a/apps/sim/lib/mothership/agent-cli/engines.test.ts +++ b/apps/sim/lib/mothership/agent-cli/engines.test.ts @@ -51,15 +51,15 @@ function runtimeWith(responses: Record): AgentCliRuntime { } } -const EXPORT_PATH = '/api/v2/workflows/wf-1/export' -const exportResponse = { data: { state: WORKFLOW_STATE } } +const STATE_PATH = '/api/v2/workflows/wf-1/state' +const stateResponse = { data: WORKFLOW_STATE } describe('workflows lint', () => { it('lints a workflow through the shared engine with the caller scoped as subject', async () => { const result = await runEngine( 'workflows lint', ['wf-1'], - runtimeWith({ [EXPORT_PATH]: exportResponse }), + runtimeWith({ [STATE_PATH]: stateResponse }), {} ) expect(result.stderr).toBe('') diff --git a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts index 58584ae52d6..4df31470c8c 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts @@ -96,4 +96,27 @@ describe('universal grep', () => { expect(result.exitCode).toBe(0) expect(result.stdout).toContain('No matches for "zzz-nope" in blocks') }) + + it('names files by their VFS path, with no doubled slash at the root', async () => { + const result = await runEngine( + 'grep', + ['runbook'], + runtimeWith({ + '/api/v2/files': { + data: [ + { id: 'file-root', name: 'xp-runbook.md', folderPath: '/' }, + { id: 'file-ops', name: 'notes.md', folderPath: '/Ops' }, + ], + nextCursor: null, + }, + '/api/v2/files/file-root/text': { data: { text: 'root runbook', degraded: false } }, + '/api/v2/files/file-ops/text': { data: { text: 'ops runbook', degraded: false } }, + }), + { scope: 'files' } + ) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('files/xp-runbook.md (file-root):') + expect(result.stdout).toContain('files/Ops/notes.md (file-ops):') + expect(result.stdout).not.toContain('files//') + }) }) diff --git a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts index 2602b9773b4..1de2fc6eabe 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts @@ -103,12 +103,12 @@ const MATERIALIZERS: Record Promise { const id = str(w.id) ?? '' - // The export route scopes by workflow id alone (`query: noInputSchema`); a - // workspaceId here is an "Unrecognized key" — the other detail routes require it. - const exported = await runtime.client.request<{ data: { state: unknown } }>( - `/api/v2/workflows/${id}/export` - ) - return render('workflows', id, str(w.name) ?? id, exported.data.state) + // The draft state, not the export: export is sanitized for sharing and nulls + // workspace-specific fields (a Table block's `tableId`), so a grep for a table id + // inside a workflow would miss it. The state route scopes by workflow id alone + // (`query: noInputSchema`); a workspaceId here is an "Unrecognized key". + const state = await runtime.client.request<{ data: unknown }>(`/api/v2/workflows/${id}/state`) + return render('workflows', id, str(w.name) ?? id, state.data) }) }, blocks: async (runtime) => { @@ -178,9 +178,11 @@ const MATERIALIZERS: Record Promise { if (text === null) return [] - const folder = (str(file.folderPath) ?? '').replace(/\/+$/, '') + // `folderPath` is `/` at the root and `/Ops` below it; the match header already + // supplies the `files/` prefix, so the label carries no slash of its own. + const folder = (str(file.folderPath) ?? '').replace(/^\/+|\/+$/g, '') const name = str(file.name) ?? str(file.id) ?? '' - const label = folder ? `${folder}/${name}` : `/${name}` + const label = folder ? `${folder}/${name}` : name return [{ scope: 'files' as const, id: str(file.id) ?? '', label, text }] }) }, diff --git a/apps/sim/lib/mothership/agent-cli/engines/workflow-state.ts b/apps/sim/lib/mothership/agent-cli/engines/workflow-state.ts index e9f89d142a7..66bd5918f75 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/workflow-state.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/workflow-state.ts @@ -1,13 +1,23 @@ -import type { ExportWorkflowResponse } from 'sim/embed' import type { AgentCliRuntime } from '@/lib/mothership/agent-cli/types' -/** One workflow's exported state — the v2 source of truth the analysis engines read. */ +interface WorkflowStateResponse { + data: Record +} + +/** + * One workflow's draft state — the v2 source of truth the analysis engines read. + * + * The `/state` route, not `/export`: export is sanitized for sharing, which nulls + * workspace-specific fields such as a Table block's `tableId` while leaving its + * advanced-mode marker, so a lint over the export reported a working block as missing + * "Table ID" although the same graph linted clean inside `operations apply`. + */ export async function fetchWorkflowState( runtime: AgentCliRuntime, workflowId: string ): Promise> { - const response = await runtime.client.request( - `/api/v2/workflows/${encodeURIComponent(workflowId)}/export` + const response = await runtime.client.request( + `/api/v2/workflows/${encodeURIComponent(workflowId)}/state` ) - return response.data.state + return response.data } diff --git a/apps/sim/lib/mothership/request/tools/files.ts b/apps/sim/lib/mothership/request/tools/files.ts index b72b4b45a65..307c571ddfc 100644 --- a/apps/sim/lib/mothership/request/tools/files.ts +++ b/apps/sim/lib/mothership/request/tools/files.ts @@ -476,6 +476,9 @@ export async function maybeWriteOutputToFile( return { success: true, output: { + // The computed value stays on the result so a table write declared on the + // same call (`outputTable`) still has rows to read after the file write. + result: outputObject?.result, message: writtenFiles.length === 1 ? `Output ${firstWritten.mode === 'overwrite' ? 'updated' : 'written'} at ${firstWritten.vfsPath} (${firstWritten.bytes} bytes)` diff --git a/apps/sim/lib/mothership/request/tools/tables.test.ts b/apps/sim/lib/mothership/request/tools/tables.test.ts index 860f688180c..ccc461548bc 100644 --- a/apps/sim/lib/mothership/request/tools/tables.test.ts +++ b/apps/sim/lib/mothership/request/tools/tables.test.ts @@ -228,6 +228,48 @@ describe('automatic Copilot tool-output table persistence', () => { expect(mocks.executeReplace).not.toHaveBeenCalled() }) + it('keeps already-written output files on the error when the table shape check fails', async () => { + const files = [ + { + fileId: 'file-1', + fileName: 'report.csv', + vfsPath: 'files/report.csv', + size: 12, + downloadUrl: 'https://example.test/report.csv', + }, + ] + + const result = await maybeWriteOutputToTable( + RunFunction.id, + { outputTable: 'table-1', outputs: { files: [{ path: 'files/report.csv' }] } }, + { + success: true, + output: { message: 'Output written at files/report.csv (12 bytes)', files }, + }, + buildContext() + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('array of objects') + expect(result.error).toContain('already written') + expect(result.output).toEqual({ files }) + expect(mocks.executeReplace).not.toHaveBeenCalled() + }) + + it('tells each language how to hand rows back when the shape is wrong', async () => { + const result = await maybeWriteOutputToTable( + RunFunction.id, + { outputTable: 'table-1' }, + { success: true, output: { result: { rows: [{ name: 'Ada' }] } } }, + buildContext() + ) + + expect(result.success).toBe(false) + expect(result.error).toContain('JavaScript: `return [...]`') + expect(result.error).toContain('Python: assign `__sim_result__ = [...]`') + expect(result.output).toBeUndefined() + }) + it('fails closed when the authoritative inserted count is inconsistent', async () => { mocks.executeReplace.mockResolvedValueOnce({ table, deletedCount: 1, insertedCount: 1 }) diff --git a/apps/sim/lib/mothership/request/tools/tables.ts b/apps/sim/lib/mothership/request/tools/tables.ts index b617cff63f7..5577096cecc 100644 --- a/apps/sim/lib/mothership/request/tools/tables.ts +++ b/apps/sim/lib/mothership/request/tools/tables.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isRecordLike } from '@sim/utils/object' import { parse as csvParse } from 'csv-parse/sync' import { executeCopilotReplaceProjectedWireRows } from '@/lib/mothership/application/table-commands' import { messageForCopilotTableError } from '@/lib/mothership/auth/table-delegation' @@ -17,6 +18,24 @@ import { ProjectedWireRowsValidationError } from '@/lib/table/application/rows' const logger = createLogger('CopilotToolResultTables') const MAX_OUTPUT_TABLE_ROWS = 10_000 +/** Python has no top-level `return`; the sandbox reads the `__sim_result__` global instead. */ +const RETURN_ROWS_HINT = 'JavaScript: `return [...]`; Python: assign `__sim_result__ = [...]`' +const ARRAY_OF_OBJECTS_ERROR = `outputTable requires the code to return an array of objects (${RETURN_ROWS_HINT})` + +/** + * Declared output files are written before the table step runs, so a table + * failure after them is a partial success. The error result keeps the written + * files so the caller sees what landed instead of re-running the code for it. + */ +function outputTableFailure(error: string, rawOutput: unknown): ToolCallResult { + const files = isRecordLike(rawOutput) && Array.isArray(rawOutput.files) ? rawOutput.files : [] + if (files.length === 0) return { success: false, error } + return { + success: false, + error: `${error}. The declared output files were already written.`, + output: { files }, + } +} /** * Replaces a table's rows with wire rows keyed by column name. Translates the * projected values through one authorized application command. That command @@ -94,37 +113,31 @@ export async function maybeWriteOutputToTable( rows = inner } else { span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape) - return { - success: false, - error: 'outputTable requires the code to return an array of objects', - } + return outputTableFailure(ARRAY_OF_OBJECTS_ERROR, rawOutput) } } else if (Array.isArray(rawOutput)) { rows = rawOutput } else { span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape) - return { - success: false, - error: 'outputTable requires the code to return an array of objects', - } + return outputTableFailure(ARRAY_OF_OBJECTS_ERROR, rawOutput) } span.setAttribute(TraceAttr.CopilotTableRowCount, rows.length) if (rows.length > MAX_OUTPUT_TABLE_ROWS) { span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.RowLimitExceeded) - return { - success: false, - error: `outputTable row limit exceeded: got ${rows.length}, max is ${MAX_OUTPUT_TABLE_ROWS}`, - } + return outputTableFailure( + `outputTable row limit exceeded: got ${rows.length}, max is ${MAX_OUTPUT_TABLE_ROWS}`, + rawOutput + ) } if (rows.length === 0) { span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.EmptyRows) - return { - success: false, - error: 'outputTable requires at least one row — code returned an empty array', - } + return outputTableFailure( + 'outputTable requires at least one row — code returned an empty array', + rawOutput + ) } if (context.abortSignal?.aborted) { @@ -133,7 +146,7 @@ export async function maybeWriteOutputToTable( const replaceResult = await replaceTableRowsFromWire(outputTable, rows, context) if (!replaceResult.success) { span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.InvalidShape) - return { success: false, error: replaceResult.error } + return outputTableFailure(replaceResult.error, rawOutput) } logger.info('Tool output written to table', { diff --git a/apps/sim/lib/table/dispatcher.test.ts b/apps/sim/lib/table/dispatcher.test.ts new file mode 100644 index 00000000000..65face20001 --- /dev/null +++ b/apps/sim/lib/table/dispatcher.test.ts @@ -0,0 +1,168 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAppendTableEvent, + mockGetTableById, + mockBatchEnqueueAndWait, + mockWriteWorkflowGroupState, +} = vi.hoisted(() => ({ + mockAppendTableEvent: vi.fn(), + mockGetTableById: vi.fn(), + mockBatchEnqueueAndWait: vi.fn(), + mockWriteWorkflowGroupState: vi.fn(), +})) + +vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent })) +vi.mock('@/lib/table/service', () => ({ getTableById: mockGetTableById })) +vi.mock('@/lib/table/cell-write', () => ({ writeWorkflowGroupState: mockWriteWorkflowGroupState })) +vi.mock('@/lib/core/async-jobs/config', () => ({ + getJobQueue: async () => ({ batchEnqueueAndWait: mockBatchEnqueueAndWait }), +})) +vi.mock('@/lib/table/workflow-columns', () => ({ + TABLE_CONCURRENCY_LIMIT: 20, + buildEnqueueItems: async (runs: unknown[]) => runs.map((payload) => ({ payload })), + /** Every targeted group of every row is eligible, so cells = rows × groups. */ + buildPendingRuns: ( + table: { id: string; name: string; workspaceId: string }, + rows: Array<{ id: string }>, + opts?: { groupIds?: string[] } + ) => + rows.flatMap((row) => + (opts?.groupIds ?? []).map((groupId) => ({ + tableId: table.id, + tableName: table.name, + rowId: row.id, + groupId, + workflowId: 'workflow-1', + workspaceId: table.workspaceId, + executionId: `exec-${row.id}-${groupId}`, + })) + ), + toTableRow: (row: Record, executions: Record = {}) => ({ + ...row, + executions, + }), +})) + +import { dispatcherStep } from '@/lib/table/dispatcher' + +const DISPATCH = { + id: 'tdsp_1', + tableId: 'table-1', + workspaceId: 'workspace-1', + requestId: 'req-1', + mode: 'all', + scope: { groupIds: ['group-1'] }, + status: 'dispatching', + cursor: 0, + limit: null, + processedCount: 0, + isManualRun: true, + triggeredByUserId: 'user-1', + requestedAt: new Date('2026-08-21T15:00:00.000Z'), + completedAt: null, + cancelledAt: null, +} + +const TABLE = { + id: 'table-1', + name: 'People', + workspaceId: 'workspace-1', + schema: { columns: [], workflowGroups: [{ id: 'group-1' }, { id: 'group-2' }] }, +} + +const ROWS = [ + { + id: 'row-1', + tableId: 'table-1', + position: 1, + data: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, + { + id: 'row-2', + tableId: 'table-1', + position: 2, + data: {}, + createdAt: new Date(), + updatedAt: new Date(), + }, +] + +/** + * The delta `incrementProcessedCount` bound into its `processedCount + n` + * fragment, or null when the step never bumped the counter. The `sql` mock + * keeps the interpolated values, so the delta is the fragment's only number. + */ +function processedCountDelta(): number | null { + const call = dbChainMockFns.set.mock.calls.find( + ([values]) => (values as Record | undefined)?.processedCount !== undefined + ) + if (!call) return null + const fragment = (call[0] as { processedCount: { values: unknown[] } }).processedCount + return fragment.values.find((value): value is number => typeof value === 'number') ?? null +} + +/** One dispatching step whose window holds `ROWS`; every re-read sees the same dispatch. */ +function arrangeWindow(dispatch: typeof DISPATCH): void { + mockGetTableById.mockResolvedValue(TABLE) + dbChainMockFns.limit + .mockResolvedValueOnce([dispatch]) + .mockResolvedValueOnce(ROWS) + .mockResolvedValue([dispatch]) +} + +describe('dispatcherStep processedCount', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockBatchEnqueueAndWait.mockResolvedValue(undefined) + mockWriteWorkflowGroupState.mockResolvedValue(undefined) + }) + + /** + * `tables dispatches get` reads `processedCount` back, and it stayed at 0 for + * every dispatch without a row cap because only the cap branch tallied rows. + */ + it('counts the rows an unlimited dispatch sends', async () => { + arrangeWindow(DISPATCH) + + const result = await dispatcherStep('tdsp_1') + + expect(result).toBe('continue') + expect(mockBatchEnqueueAndWait).toHaveBeenCalledTimes(1) + expect(processedCountDelta()).toBe(2) + }) + + it('counts distinct rows, not cells, when several groups are targeted', async () => { + arrangeWindow({ ...DISPATCH, scope: { groupIds: ['group-1', 'group-2'] } }) + + await dispatcherStep('tdsp_1') + + expect(mockBatchEnqueueAndWait.mock.calls[0][1]).toHaveLength(4) + expect(processedCountDelta()).toBe(2) + }) + + it('still counts under a row cap the window does not exhaust', async () => { + arrangeWindow({ ...DISPATCH, limit: { type: 'rows', max: 5 } }) + + const result = await dispatcherStep('tdsp_1') + + expect(result).toBe('continue') + expect(processedCountDelta()).toBe(2) + }) + + it('does not count rows whose enqueue failed', async () => { + arrangeWindow(DISPATCH) + mockBatchEnqueueAndWait.mockRejectedValueOnce(new Error('queue unavailable')) + + await dispatcherStep('tdsp_1') + + expect(processedCountDelta()).toBeNull() + }) +}) diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index d05e905819d..61f3477c8e5 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -93,7 +93,7 @@ export interface DispatchRow { cursor: number /** Cap on work before completion; null = unbounded. */ limit: DispatchLimit | null - /** Units of `limit.type` already consumed (eligible rows dispatched). */ + /** Distinct rows dispatched so far; under a `rows` limit, the units of it consumed. */ processedCount: number isManualRun: boolean /** User who triggered the run (for usage attribution); null for auto-fire. */ @@ -622,7 +622,6 @@ export async function dispatcherStep( // row's groups consecutively in ascending position, so collecting distinct // rowIds until the budget fills picks the lowest-position rows. let windowRuns = pendingRuns - let dispatchedRows = 0 let budgetExhausted = false if (dispatch.limit?.type === 'rows') { const remaining = dispatch.limit.max - dispatch.processedCount @@ -637,9 +636,12 @@ export async function dispatcherStep( allowedRowIds.add(p.rowId) } windowRuns = pendingRuns.filter((p) => allowedRowIds.has(p.rowId)) - dispatchedRows = allowedRowIds.size - budgetExhausted = dispatch.processedCount + dispatchedRows >= dispatch.limit.max + budgetExhausted = dispatch.processedCount + allowedRowIds.size >= dispatch.limit.max } + // Every dispatch tallies the distinct rows it sends, capped or not: the + // tally is what `processedCount` reports, so an unlimited dispatch that ran + // the whole table must not read back as having processed nothing. + let dispatchedRows = new Set(windowRuns.map((p) => p.rowId)).size if (windowRuns.length > 0) { /** diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 96615020bd7..fef7bbf4b44 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -53,7 +53,11 @@ import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import type { DbOrTx } from '@/lib/db/types' import { acquireFolderMutationLock } from '@/lib/folders/locks' import { parseFolderPath } from '@/lib/folders/paths' -import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' +import { + loadActiveFolderPathIndex, + resolveFolderPathFromIndex, + resolveRestoredFolderId, +} from '@/lib/folders/queries' import type { FolderIdScope } from '@/lib/folders/scope' import { mergeEditIntoLiveFileDoc, notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { getServePathPrefix } from '@/lib/uploads' @@ -2430,6 +2434,13 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string): throw new OrchestrationError('validation', 'Cannot restore file into an archived workspace') } + /** + * The file goes back where it was deleted from while that folder is still active. A folder + * archived since would file the row under one the Files page never renders, so re-root it + * instead — the same treatment tables and knowledge bases get on restore. + */ + const restoredFolderId = await resolveRestoredFolderId(fileRecord.folderId, workspaceId, 'file') + /** * A concurrent upload/rename can claim the chosen name after `generateRestoreName`'s check (MVCC). * Retries pick a new random suffix; 23505 maps to {@link FileConflictError} after exhaustion. @@ -2442,14 +2453,19 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string): try { const newName = await generateRestoreName( fileRecord.originalName, - (candidate) => fileExistsInWorkspace(workspaceId, candidate, null), + (candidate) => fileExistsInWorkspace(workspaceId, candidate, restoredFolderId), { hasExtension: true } ) attemptedRestoreName = newName const [restored] = await db .update(workspaceFiles) - .set({ deletedAt: null, folderId: null, originalName: newName, updatedAt: new Date() }) + .set({ + deletedAt: null, + folderId: restoredFolderId, + originalName: newName, + updatedAt: new Date(), + }) .where( and( eq(workspaceFiles.id, fileId), diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts index c0769740772..7876a04f7bb 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts @@ -14,6 +14,7 @@ const { mockDeleteFile, mockEnqueueWorkspaceFileStorageCleanups, mockEnqueueWorkspaceFileLiveDocReconciliation, + mockFileNameExistsInWorkspaceFolder, mockGetWorkspaceWithOwner, mockHasCloudStorage, mockHeadObject, @@ -29,6 +30,7 @@ const { mockProcessWorkspaceFileLiveDocReconciliationNow, mockResolveStorageBillingContext, mockResolveFolderPathFromIndex, + mockResolveRestoredFolderId, mockResolveWorkspaceFileFolderTarget, mockReplaceWorkspaceFileSecretProvenanceInTx, mockSaveCollabDocStateInTx, @@ -39,6 +41,7 @@ const { mockDeleteFile: vi.fn(), mockEnqueueWorkspaceFileStorageCleanups: vi.fn(), mockEnqueueWorkspaceFileLiveDocReconciliation: vi.fn(), + mockFileNameExistsInWorkspaceFolder: vi.fn(), mockGetWorkspaceWithOwner: vi.fn(), mockHasCloudStorage: vi.fn(), mockHeadObject: vi.fn(), @@ -54,6 +57,7 @@ const { mockProcessWorkspaceFileLiveDocReconciliationNow: vi.fn(), mockResolveStorageBillingContext: vi.fn(), mockResolveFolderPathFromIndex: vi.fn(), + mockResolveRestoredFolderId: vi.fn(), mockResolveWorkspaceFileFolderTarget: vi.fn(), mockReplaceWorkspaceFileSecretProvenanceInTx: vi.fn(), mockSaveCollabDocStateInTx: vi.fn(), @@ -117,7 +121,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ assertWorkspaceFileFolderTarget: mockAssertWorkspaceFileFolderTarget, buildWorkspaceFileFolderPathMap: vi.fn(() => new Map()), - fileNameExistsInWorkspaceFolder: vi.fn(async () => false), + fileNameExistsInWorkspaceFolder: mockFileNameExistsInWorkspaceFolder, findWorkspaceFileFolderIdByPath: vi.fn(), getWorkspaceFileFolderPath: vi.fn(), listWorkspaceFileFolders: vi.fn(async () => []), @@ -132,6 +136,7 @@ vi.mock('@/lib/folders/locks', () => ({ vi.mock('@/lib/folders/queries', () => ({ loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, resolveFolderPathFromIndex: mockResolveFolderPathFromIndex, + resolveRestoredFolderId: mockResolveRestoredFolderId, })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ @@ -193,6 +198,8 @@ describe('workspace file metadata and storage accounting', () => { mockHeadObject.mockResolvedValue({ size: FILE_ROW.size }) mockUploadFile.mockResolvedValue({ key: FILE_ROW.key }) mockGetWorkspaceWithOwner.mockResolvedValue({ archivedAt: null }) + mockFileNameExistsInWorkspaceFolder.mockResolvedValue(false) + mockResolveRestoredFolderId.mockResolvedValue(null) mockIncrementStorageUsageForBillingContextInTx.mockResolvedValue(10) mockInitializeWorkspaceFileSecretProvenanceInTx.mockResolvedValue(undefined) mockDecrementStorageUsageForBillingContextInTx.mockResolvedValue(undefined) @@ -687,6 +694,56 @@ describe('workspace file metadata and storage accounting', () => { expect(dbChainMockFns.transaction).not.toHaveBeenCalled() }) + it('restores a file into its folder while that folder is still active', async () => { + const archivedFile = { + ...FILE_ROW, + folderId: 'folder-1', + deletedAt: new Date('2026-07-02T00:00:00.000Z'), + } + mockResolveRestoredFolderId.mockResolvedValueOnce('folder-1') + dbChainMockFns.limit.mockResolvedValueOnce([archivedFile]) + dbChainMockFns.returning.mockResolvedValueOnce([{ ...FILE_ROW, folderId: 'folder-1' }]) + + await restoreWorkspaceFile(FILE_ROW.workspaceId, FILE_ROW.id) + + expect(mockResolveRestoredFolderId).toHaveBeenCalledWith( + 'folder-1', + FILE_ROW.workspaceId, + 'file' + ) + // The name must be free in the folder the file lands in, not at the root. + expect(mockFileNameExistsInWorkspaceFolder).toHaveBeenCalledWith( + FILE_ROW.workspaceId, + FILE_ROW.originalName, + 'folder-1' + ) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ deletedAt: null, folderId: 'folder-1' }) + ) + }) + + it('re-roots a restored file whose folder has since been archived', async () => { + const archivedFile = { + ...FILE_ROW, + folderId: 'folder-1', + deletedAt: new Date('2026-07-02T00:00:00.000Z'), + } + mockResolveRestoredFolderId.mockResolvedValueOnce(null) + dbChainMockFns.limit.mockResolvedValueOnce([archivedFile]) + dbChainMockFns.returning.mockResolvedValueOnce([FILE_ROW]) + + await restoreWorkspaceFile(FILE_ROW.workspaceId, FILE_ROW.id) + + expect(mockFileNameExistsInWorkspaceFolder).toHaveBeenCalledWith( + FILE_ROW.workspaceId, + FILE_ROW.originalName, + null + ) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ deletedAt: null, folderId: null }) + ) + }) + it('uploads an overwrite before atomically swapping the locked row and exact delta', async () => { const concurrentFile = { ...FILE_ROW, size: 7, sizeBytes: 7 } const replacementKey = `${FILE_ROW.key}-replacement` diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts index a88a6d652b4..f28b789fa9a 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts @@ -72,6 +72,7 @@ vi.mock('@/lib/workflows/editing/validation', () => ({ })) vi.mock('@/lib/workflows/editing/lint', () => ({ collectWorkflowFieldIssues: () => [], + collectDanglingBlockOutputReferences: () => [], lintEditedWorkflowState: () => ({ sources: [], sinks: [], diff --git a/apps/sim/lib/workflows/editing/dangling-refs.test.ts b/apps/sim/lib/workflows/editing/dangling-refs.test.ts index 5f588be098d..4735a44a943 100644 --- a/apps/sim/lib/workflows/editing/dangling-refs.test.ts +++ b/apps/sim/lib/workflows/editing/dangling-refs.test.ts @@ -65,13 +65,50 @@ describe('collectDanglingBlockOutputReferences', () => { expect(findings).toHaveLength(0) }) - it('skips function code fields (the runtime fails those loudly)', () => { + it('flags a deleted block referenced from function code', () => { const findings = collectDanglingBlockOutputReferences( graph({ b1: { type: 'function', name: 'Fn', - subBlocks: { code: { value: 'return ' } }, + subBlocks: { code: { value: 'const rows = \nreturn rows' } }, + }, + }) + ) + expect(findings).toHaveLength(1) + expect(findings[0]).toMatchObject({ + blockId: 'b1', + field: 'code', + kind: 'block-output', + value: [''], + }) + }) + + it('ignores comparisons and generics in function code', () => { + const findings = collectDanglingBlockOutputReferences( + graph({ + b1: { + type: 'function', + name: 'Fn', + subBlocks: { + code: { + value: 'const xs: Array = []\nif (a < b && c > d) { return xs }\nreturn []', + }, + }, + }, + }) + ) + expect(findings).toHaveLength(0) + }) + + it('resolves start and loop heads in function code', () => { + const findings = collectDanglingBlockOutputReferences( + graph({ + b1: { type: 'starter', name: 'Start' }, + b2: { + type: 'function', + name: 'Fn', + subBlocks: { code: { value: 'return { input: , i: }' } }, }, }) ) diff --git a/apps/sim/lib/workflows/editing/lint.test.ts b/apps/sim/lib/workflows/editing/lint.test.ts index 13b7345a0a9..228f28a68f8 100644 --- a/apps/sim/lib/workflows/editing/lint.test.ts +++ b/apps/sim/lib/workflows/editing/lint.test.ts @@ -1,6 +1,16 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { hasWorkflowLintIssues, lintEditedWorkflowState } from './lint' +/** Overrides the global registry mock so a `schedule` block carries its real category. */ +vi.mock('@/blocks/registry', () => ({ + getBlock: vi.fn((type: string) => + type === 'schedule' ? { category: 'triggers', subBlocks: [], outputs: {} } : undefined + ), + getAllBlocks: vi.fn(() => []), + getBlockMeta: vi.fn(() => undefined), + getBlockRegistry: vi.fn(() => ({})), +})) + function baseBlock(id: string, type: string, name: string, subBlocks: Record = {}) { return { id, @@ -252,6 +262,32 @@ describe('lintEditedWorkflowState', () => { expect(lint.sinks.map((b) => b.blockId)).not.toContain('note') }) + it('treats a triggers-category block as an entry, so a wired schedule is a source and not an orphan', () => { + const workflowState = { + blocks: { + schedule: baseBlock('schedule', 'schedule', 'Schedule'), + agent: baseBlock('agent', 'agent', 'Agent'), + }, + edges: [ + { + id: 'e1', + source: 'schedule', + sourceHandle: 'source', + target: 'agent', + targetHandle: 'target', + }, + ], + } + + const lint = lintEditedWorkflowState(workflowState as any) + + expect(lint.sources).toEqual([ + { blockId: 'schedule', blockName: 'Schedule', blockType: 'schedule' }, + ]) + expect(lint.orphanBlocks).toEqual([]) + expect(hasWorkflowLintIssues(lint)).toBe(false) + }) + it('warns when loop/parallel start ports are empty', () => { const workflowState = { blocks: { diff --git a/apps/sim/lib/workflows/editing/lint.ts b/apps/sim/lib/workflows/editing/lint.ts index 693ed7bd04a..185d92b65e0 100644 --- a/apps/sim/lib/workflows/editing/lint.ts +++ b/apps/sim/lib/workflows/editing/lint.ts @@ -1,5 +1,11 @@ +import { findWorkflowReferenceTokens } from '@sim/utils/workflow-references' import { getBlock } from '@/blocks' -import { isTriggerBlockType, normalizeName, SPECIAL_REFERENCE_PREFIXES } from '@/executor/constants' +import { + isTriggerBlockType, + normalizeName, + REFERENCE, + SPECIAL_REFERENCE_PREFIXES, +} from '@/executor/constants' import { collectStringLeaves } from '@/executor/utils/reference-validation' import { collectBlockFieldIssues, @@ -92,8 +98,14 @@ function blockRef(blockId: string, block: BlockState): WorkflowLintBlockRef { } } +/** + * Whether a block starts a run and so is never an orphan: a block in trigger + * mode, one of the universal entry types, or any `triggers`-category block + * (schedule, webhooks) — the same rule the serializer applies. + */ function isWorkflowEntryBlock(block: BlockState) { - return Boolean(block.triggerMode) || isTriggerBlockType(block.type) + if (Boolean(block.triggerMode) || isTriggerBlockType(block.type)) return true + return block.type !== undefined && getBlock(block.type)?.category === 'triggers' } function requiredSubflowStartPort(block: BlockState) { @@ -382,13 +394,24 @@ export function formatWorkflowLintMessage(lint: WorkflowLintIssueView) { * behavior diverges by surface — function code fails loudly, but API bodies and * agent prompts pass the literal text through and the run reports completed — * so the dangling reference has to be caught at lint time, where every surface - * gets the same finding. Code fields are skipped: comparisons and generics in - * real JavaScript look like templates, and the runtime already fails those - * loudly. Heads are matched with the executor's own name normalization. + * gets the same finding. Code fields are tokenized with the runtime's own + * reference scanner, so comparisons and generics in real JavaScript are not + * mistaken for templates. Heads are matched with the executor's own name + * normalization. */ const BLOCK_REF_TOKEN = /<([^<>]+)>/g const REF_TOKEN_SHAPE = /^[A-Za-z_][\w-]*(?:[\w\s-]*[\w-])?\.[A-Za-z0-9_.[\]]+$/ +/** Candidate `block.path` bodies in one string leaf; code goes through the runtime scanner. */ +function referenceCandidates(leaf: string, isCode: boolean): string[] { + if (!isCode) { + return [...leaf.matchAll(BLOCK_REF_TOKEN)].map((match) => match[1] ?? '') + } + return findWorkflowReferenceTokens(leaf) + .filter((token) => token.kind === 'workflow') + .map((token) => token.value.slice(REFERENCE.START.length, -REFERENCE.END.length)) +} + export function collectDanglingBlockOutputReferences( workflowState: Pick ): WorkflowLintUnresolvedReference[] { @@ -401,13 +424,11 @@ export function collectDanglingBlockOutputReferences( const findings: WorkflowLintUnresolvedReference[] = [] for (const [blockId, block] of Object.entries(blocks)) { for (const [subBlockId, subBlock] of Object.entries(block.subBlocks ?? {})) { - if (subBlockId === 'code') continue const leaves: string[] = [] collectStringLeaves((subBlock as { value?: unknown })?.value, leaves) const dangling = new Set() for (const leaf of leaves) { - for (const match of leaf.matchAll(BLOCK_REF_TOKEN)) { - const token = match[1] + for (const token of referenceCandidates(leaf, subBlockId === 'code')) { if (!token || !REF_TOKEN_SHAPE.test(token)) continue const head = token.split('.')[0] ?? '' if ((SPECIAL_REFERENCE_PREFIXES as readonly string[]).includes(head)) continue diff --git a/apps/sim/lib/workflows/editing/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts index 8ee23f3b87c..fb5fce3d56b 100644 --- a/apps/sim/lib/workflows/editing/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -188,6 +188,17 @@ const mothershipBlockConfig = { ], } +// Mirrors table_v2: a JSON-language code field beside a plain code field. +const jsonCodeBlockConfig = { + type: 'json_code_block', + name: 'JSON Code Block', + outputs: {}, + subBlocks: [ + { id: 'filter', type: 'code', language: 'json' }, + { id: 'script', type: 'code' }, + ], +} + // Block whose tool selector throws — should fall back to scanning access tools (video_falai). const throwSelectorBlockConfig = { type: 'throw_selector_block', @@ -244,6 +255,7 @@ const blockConfigsByType: Record = { throw_selector_block: throwSelectorBlockConfig, generic_webhook: genericWebhookBlockConfig, mothership: mothershipBlockConfig, + json_code_block: jsonCodeBlockConfig, } vi.mock('@/blocks/registry', () => ({ @@ -1716,3 +1728,31 @@ describe('collectUnresolvedAgentToolReferences', () => { expect(mockGetCustomToolById).not.toHaveBeenCalled() }) }) + +describe('validateInputsForBlock - code fields', () => { + it('stores an object handed to a JSON-language code field as its JSON text', () => { + const filter = { field: 'wins', op: 'gte', value: 10 } + + const result = validateInputsForBlock('json_code_block', { filter }, 'block-1') + + expect(result.errors).toHaveLength(0) + expect(result.validInputs.filter).toBe(JSON.stringify(filter)) + }) + + it('stores an array handed to a JSON-language code field as its JSON text', () => { + const rows = [{ name: 'Ada' }, { name: 'Grace' }] + + const result = validateInputsForBlock('json_code_block', { filter: rows }, 'block-1') + + expect(result.errors).toHaveLength(0) + expect(result.validInputs.filter).toBe(JSON.stringify(rows)) + }) + + it('still rejects an object for a code field without a JSON language', () => { + const result = validateInputsForBlock('json_code_block', { script: { not: 'code' } }, 'block-1') + + expect(result.validInputs.script).toBeUndefined() + expect(result.errors).toHaveLength(1) + expect(result.errors[0]?.error).toContain('expected a string, got object') + }) +}) diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index 6d85d8a0999..8167c332aac 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -719,6 +719,12 @@ export function validateValueForSubBlockType( } case 'code': { + // A JSON-language code field holds a document, and callers naturally hand + // that document over as an object or array; store the JSON text the editor + // shows and the block's runtime parses. + if (subBlockConfig.language === 'json' && typeof value === 'object') { + return { valid: true, value: JSON.stringify(value) } + } // Code must be a string (content can be JS, Python, JSON, SQL, HTML, etc.) if (typeof value !== 'string') { return { diff --git a/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts index 2a64ac318fc..23c91c36c18 100644 --- a/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts +++ b/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts @@ -41,8 +41,9 @@ const context = { } /** - * `restoreWorkspaceFile` renames on a collision and clears the folder, so the - * post-restore record never matches the one the caller deleted. + * `restoreWorkspaceFile` renames on a collision and re-roots the file when its + * folder is gone, so the post-restore record need not match the one the caller + * deleted. */ const restoredFile = { id: 'file-1', diff --git a/apps/sim/lib/workspace-files/application/restore-workspace-file.ts b/apps/sim/lib/workspace-files/application/restore-workspace-file.ts index 9457cac9523..d0f0e4f499a 100644 --- a/apps/sim/lib/workspace-files/application/restore-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/restore-workspace-file.ts @@ -24,9 +24,9 @@ export interface RestoreWorkspaceFileResult { restored: true /** * The file as it exists after the restore. Restore is not a pure undo — it - * returns the file to the workspace root and renames it to avoid colliding - * with whatever took its name — so the caller needs the post-restore record - * rather than the one it deleted. + * re-roots the file when its folder has been archived and renames it to avoid + * colliding with whatever took its name — so the caller needs the post-restore + * record rather than the one it deleted. */ file: WorkspaceFileRecord } From 481317ba08a3fe13765c6a211e3275bd21264c83 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 16:28:56 +0530 Subject: [PATCH 061/306] sim-cli: --output json prints the API data verbatim; the single-key unwrap stays a table-only convenience --- .../commands/protocol/workflow-run-wait.ts | 9 +++++-- packages/sim-cli/src/runtime/build.test.ts | 24 +++++++++++++++++++ packages/sim-cli/src/runtime/result.ts | 5 +++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-wait.ts b/packages/sim-cli/src/commands/protocol/workflow-run-wait.ts index 572163bd72f..81d32ccc3da 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-wait.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-wait.ts @@ -98,6 +98,11 @@ function optionalString(value: unknown): string | null { * record — what a self-hosted deployment behind an unwrapping proxy hands back — * still polls, rather than refusing to find a status that is right there. */ +/** The run itself: v2 answers `{ data: run }` and the renderer prints exactly what it is handed. */ +function runData(raw: unknown): unknown { + return isRecord(raw) && isRecord(raw.data) ? raw.data : raw +} + function readRun(raw: unknown): RunSnapshot { const run = isRecordLike(raw) && isRecordLike(raw.data) ? raw.data : raw if (!isRecordLike(run) || typeof run.status !== 'string') { @@ -251,7 +256,7 @@ export function attachWorkflowRunWait(runs: Command): void { if (outcome) { progress.finish() - renderResult('getWorkflowRun', profile.output, raw, runSpec()) + renderResult('getWorkflowRun', profile.output, runData(raw), runSpec()) const message = explain(outcome, runId, options.workflow, snapshot) if (message) console.error(chalk.red(message)) setSoftExitCode(WAIT_EXIT_CODES[outcome]) @@ -261,7 +266,7 @@ export function attachWorkflowRunWait(runs: Command): void { const remainingMs = deadline - Date.now() if (remainingMs <= 0) { progress.finish() - renderResult('getWorkflowRun', profile.output, raw, runSpec()) + renderResult('getWorkflowRun', profile.output, runData(raw), runSpec()) console.error( chalk.red( `Timed out after ${timeoutSeconds}s waiting for run ${runId} (status: ${snapshot.status}${ diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 810998289ae..8c424a838f4 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -1055,6 +1055,30 @@ describe('single-resource rendering', () => { expect(printed.join('\n')).toMatch(/Deepwiki/) }) + it('prints the API data verbatim for machine output, envelope included', async () => { + // `--output json` is what scripts and the agent reference card (generated from the + // OpenAPI response shapes) consume: the single-key unwrap above is a table-only + // convenience, so JSON keeps `mcpServer` exactly as the API returned it. + const printed = await lines( + [ + 'mcp-servers', + 'create', + '--name', + 'Deepwiki', + '--transport', + 'streamable-http', + '--url', + 'https://mcp.deepwiki.com/mcp', + ], + { mcpServer: { id: 'mcp-1', name: 'Deepwiki', enabled: true } }, + 'json' + ) + + expect(JSON.parse(printed.join('\n'))).toEqual({ + mcpServer: { id: 'mcp-1', name: 'Deepwiki', enabled: true }, + }) + }) + it('renders nested fields instead of dropping them', async () => { // `workflows export` printed `version` and `exportedAt` and nothing else: // the record builder kept only scalars, so `workflow` and `state` — the diff --git a/packages/sim-cli/src/runtime/result.ts b/packages/sim-cli/src/runtime/result.ts index 931675d1a38..e52f326917e 100644 --- a/packages/sim-cli/src/runtime/result.ts +++ b/packages/sim-cli/src/runtime/result.ts @@ -458,7 +458,10 @@ export function renderResult( return } - const data = unwrapResource(raw) + // The single-key unwrap exists for the human table: `{ mcpServer: {...} }` rendered as-is + // printed nothing. Machine formats print the API's data verbatim, so `--output json` + // matches the OpenAPI shape the docs and the agent reference card are generated from. + const data = format === 'json' || format === 'yaml' ? raw : unwrapResource(raw) if (spec.itemsPath) { const items = at(data, spec.itemsPath) if (!Array.isArray(items)) { From 8e8c38b764000f7322dc93b0b1a4bc294af145ac Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 16:51:35 +0530 Subject: [PATCH 062/306] route and connection validation, trigger defaults on add, table folder restore, dispatch listing, group column attach, import block summary; logs query and deps engines; run-tool errors and payload compaction; stale tests aligned Editing: router routes validated as {id?, title, value} with unknown keys named; malformed connections reported instead of dropped; sub-block value() defaults evaluated on add so a webhook gets its token. Tables: folder restore no longer 500s (lock inside the row transaction), completed dispatches are listed, groups attach to existing output columns. Workflows: import answers with its blocks. Mothership engines: logs query resolves bare paths under output, marks missing paths, drops non-executed runs under --where; deps lists graph predecessors and child return shapes; run-from-block validation errors reach the agent; run payloads compact input.code. CLI generated types regenerated. --- apps/docs/openapi-v2-tables.json | 18 +- apps/docs/openapi-v2-workflows.json | 43 ++++- .../tables/[tableId]/dispatches/route.test.ts | 27 ++- .../v2/tables/[tableId]/dispatches/route.ts | 8 +- .../runs/[runId]/resume/route.test.ts | 1 + .../app/api/v2/workflows/import/route.test.ts | 40 ++++- apps/sim/app/api/v2/workflows/import/route.ts | 1 + apps/sim/executor/execution/executor.test.ts | 31 ++++ apps/sim/executor/execution/executor.ts | 5 +- .../sim/executor/utils/run-from-block.test.ts | 16 +- apps/sim/executor/utils/run-from-block.ts | 13 ++ .../lib/api/contracts/v2/openapi/tables.ts | 12 +- .../lib/api/contracts/v2/openapi/workflows.ts | 5 + apps/sim/lib/api/contracts/v2/tables.ts | 24 ++- apps/sim/lib/api/contracts/v2/workflows.ts | 24 +++ .../sim/lib/catalog/registry-boundary.test.ts | 2 - apps/sim/lib/folders/orchestration.test.ts | 28 +++ apps/sim/lib/folders/orchestration.ts | 17 +- .../lib/mothership/agent-cli/engines.test.ts | 148 +++++++++++++++ .../lib/mothership/agent-cli/engines/deps.ts | 89 +++++++++- .../lib/mothership/agent-cli/engines/query.ts | 83 ++++++++- .../client/browser-tool-replay-ledger.test.ts | 2 +- .../tools/handlers/workflow/mutations.test.ts | 35 ++++ .../tools/handlers/workflow/mutations.ts | 32 +++- .../sim/lib/table/application/folders.test.ts | 73 +++++++- apps/sim/lib/table/application/groups.test.ts | 52 ++++++ apps/sim/lib/table/application/groups.ts | 25 ++- apps/sim/lib/table/application/runs.test.ts | 28 ++- apps/sim/lib/table/application/runs.ts | 10 +- apps/sim/lib/table/dispatcher.test.ts | 38 +++- apps/sim/lib/table/dispatcher.ts | 84 +++++---- .../lib/table/workflow-groups/service.test.ts | 130 +++++++++++++- apps/sim/lib/table/workflow-groups/service.ts | 80 +++++++-- .../apply-workflow-operations.test.ts | 55 +++++- .../application/import-export.test.ts | 6 + .../workflows/application/import-export.ts | 1 + .../run-workflow-from-copilot.test.ts | 36 ++++ .../application/run-workflow-from-copilot.ts | 17 +- .../lib/workflows/editing/builders.test.ts | 105 +++++++++++ apps/sim/lib/workflows/editing/builders.ts | 168 +++++++++++++++--- apps/sim/lib/workflows/editing/engine.ts | 9 +- .../lib/workflows/editing/operations.test.ts | 96 ++++++++++ .../lib/workflows/editing/validation.test.ts | 72 ++++++++ apps/sim/lib/workflows/editing/validation.ts | 100 +++++++++-- .../executor/execute-service.test.ts | 26 +-- .../workflows/operations/import-workflow.ts | 14 ++ packages/sim-cli/src/generated/v2-api.ts | 18 +- 47 files changed, 1761 insertions(+), 186 deletions(-) diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 3a0008f48f6..171e1034032 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -2177,7 +2177,7 @@ "post": { "operationId": "addTableWorkflowGroup", "summary": "Add Workflow Group", - "description": "Bind a workflow or enrichment to the table and create the columns populated by its outputs.\n\nOAuth scope: `api:write`.", + "description": "Bind a workflow or enrichment to the table and create the columns populated by its outputs. An output whose column the table already has attaches that column to the group instead of creating it, so `outputColumns` may be omitted when every output lands in an existing column.\n\nOAuth scope: `api:write`.", "x-sim-operation": "tables.groups.create", "x-oauth-scope": "api:write", "tags": ["Tables"], @@ -2518,8 +2518,8 @@ }, "get": { "operationId": "listTableDispatches", - "summary": "List Active Run Dispatches", - "description": "List in-flight run dispatches for a table in one page; `nextCursor` is always null. Use Get Run Dispatch to read a settled dispatch.\n\nOAuth scope: `api:read`.", + "summary": "List Run Dispatches", + "description": "List the run dispatches on one table, most recent first \u2014 settled dispatches (`complete`, `canceled`) alongside the ones still in flight, so a run that finished between two polls is still visible next to the `dispatchId` its create returned. Capped at the 100 most recent, so this list is unpaginated and `nextCursor` is always null.\n\nOAuth scope: `api:read`.", "x-sim-operation": "tables.runs.read", "x-oauth-scope": "api:read", "tags": ["Tables"], @@ -2549,7 +2549,7 @@ ], "responses": { "200": { - "description": "The table's active run dispatches.", + "description": "The table's most recent run dispatches.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -7751,7 +7751,8 @@ "description": "Workflow or enrichment producer definition." }, "outputColumns": { - "minItems": 1, + "default": [], + "description": "Columns to create for producer outputs. An entry naming a column the table already has attaches that column to the group instead of creating it (its `type` must match), and an output whose column already exists may omit its entry entirely — so `[]` attaches existing columns only.", "type": "array", "items": { "type": "object", @@ -7777,8 +7778,7 @@ }, "required": ["name", "type"], "additionalProperties": false - }, - "description": "Columns created for producer outputs." + } }, "autoRun": { "default": false, @@ -7786,7 +7786,7 @@ "type": "boolean" } }, - "required": ["workspaceId", "group", "outputColumns"], + "required": ["workspaceId", "group"], "additionalProperties": false, "title": "Add table workflow group request", "description": "Workspace scope, producer definition, and output columns.", @@ -10290,7 +10290,7 @@ "required": ["data", "nextCursor"], "additionalProperties": false, "title": "Table run dispatch list response", - "description": "The table's active run dispatches." + "description": "The table's most recent run dispatches, settled ones included." }, "V2MoveTablesData": { "type": "object", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index de08ae7ec20..0203629bf87 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -10382,6 +10382,27 @@ } ] }, + "ImportedWorkflowBlock": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Block identifier." + }, + "type": { + "type": "string", + "description": "Registered block type." + }, + "name": { + "type": "string", + "description": "Block display name." + } + }, + "required": ["id", "type", "name"], + "additionalProperties": false, + "title": "Imported workflow block", + "description": "A block the import created in the new workflow." + }, "ImportedWorkflow": { "type": "object", "properties": { @@ -10639,7 +10660,8 @@ "workspaceId", "folderPath", "createdAt", - "updatedAt" + "updatedAt", + "blocks" ], "additionalProperties": false, "title": "Imported workflow", @@ -10666,7 +10688,24 @@ "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", "folderPath": "/Operations", "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z" + "updatedAt": "2026-08-09T18:04:11.000Z", + "blocks": [ + { + "id": "block_start", + "type": "starter", + "name": "Start" + }, + { + "id": "block_triage", + "type": "agent", + "name": "Triage" + }, + { + "id": "block_reply", + "type": "response", + "name": "Reply" + } + ] } } ] diff --git a/apps/sim/app/api/v2/tables/[tableId]/dispatches/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/dispatches/route.test.ts index f2eb5c2a55e..5607b9259fe 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/dispatches/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/dispatches/route.test.ts @@ -62,6 +62,14 @@ const DISPATCH = { completedAt: null, cancelledAt: null, } +const COMPLETED_DISPATCH = { + ...DISPATCH, + id: 'dispatch-2', + status: 'complete' as const, + processedCount: 13, + requestedAt: new Date('2026-01-01T00:10:00Z'), + completedAt: new Date('2026-01-01T00:12:00Z'), +} function list(query = `?workspaceId=${WORKSPACE_ID}`) { const request = new NextRequest(`http://localhost/api/v2/tables/table-1/dispatches${query}`, { @@ -80,11 +88,18 @@ describe('GET /api/v2/tables/[tableId]/dispatches', () => { v2RouteMocks.authenticate.mockResolvedValue(AUTH) v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.listDispatches.mockResolvedValue({ table: { id: 'table-1' }, dispatches: [DISPATCH] }) + mocks.listDispatches.mockResolvedValue({ + table: { id: 'table-1' }, + dispatches: [COMPLETED_DISPATCH, DISPATCH], + }) mocks.startRun.mockResolvedValue({ table: { id: 'table-1' }, dispatchId: 'dispatch-1' }) }) - it('delegates the canonical table scope and returns the full set', async () => { + /** + * Settled dispatches are part of the set: a run that completed between two + * polls must still show up next to the `dispatchId` its create returned. + */ + it('delegates the canonical table scope and returns the full set, settled dispatches included', async () => { const invocation = list() const response = await invocation.response @@ -95,11 +110,15 @@ describe('GET /api/v2/tables/[tableId]/dispatches', () => { request: invocation.request, }) const body = await response.json() - expect(body.data).toHaveLength(1) + expect(body.data.map((dispatch: { id: string; status: string }) => dispatch.status)).toEqual([ + 'complete', + 'pending', + ]) + expect(body.data[0]).toMatchObject({ id: 'dispatch-2', processedCount: 13 }) expect(body.nextCursor).toBeNull() }) - /** The set is dispatcher-bounded, so there is no page for a limit to select. */ + /** The set is capped by the use case, so there is no page for a limit to select. */ it('rejects pagination parameters this list does not implement', async () => { const response = await list(`?workspaceId=${WORKSPACE_ID}&limit=10`).response diff --git a/apps/sim/app/api/v2/tables/[tableId]/dispatches/route.ts b/apps/sim/app/api/v2/tables/[tableId]/dispatches/route.ts index def9553efea..366049cf27d 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/dispatches/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/dispatches/route.ts @@ -13,9 +13,11 @@ export const dynamic = 'force-dynamic' export const revalidate = 0 /** - * Every dispatch still in flight on one table. Unpaged: the dispatcher bounds - * how many dispatches a table can have active, so `nextCursor` is always null - * and there is no page for a `limit` to select. + * The dispatches on one table, most recent first, settled ones included — a + * run that finished between two polls is still listed next to the id its + * create returned. Unpaged: the use case caps the list at the most recent + * hundred, so `nextCursor` is always null and there is no page for a `limit` + * to select. */ export const GET = defineV2JsonRoute({ contract: v2ListTableDispatchesContract, diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route.test.ts index 01e8844b57f..8a592161135 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route.test.ts @@ -220,6 +220,7 @@ describe('POST /api/v2/workflows/[workflowId]/runs/[runId]/resume', () => { workflowId: WORKFLOW_ID, status: 'completed', output: { approved: true }, + blockOutputs: null, error: null, startedAt: '2026-08-05T00:00:00.000Z', endedAt: '2026-08-05T00:00:01.000Z', diff --git a/apps/sim/app/api/v2/workflows/import/route.test.ts b/apps/sim/app/api/v2/workflows/import/route.test.ts index caab09d2d61..92bcff6a035 100644 --- a/apps/sim/app/api/v2/workflows/import/route.test.ts +++ b/apps/sim/app/api/v2/workflows/import/route.test.ts @@ -17,11 +17,19 @@ vi.mock('@/lib/api/server/routes', () => ({ })) import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' -import { importWorkflow } from '@/lib/workflows/application/import-export' +import { + type ImportWorkflowResult, + importWorkflow, +} from '@/lib/workflows/application/import-export' import { workflowOperations } from '@/lib/workflows/application/operations' import { MAX_IMPORT_BODY_BYTES } from '@/lib/workflows/operations/import-workflow' import { POST } from '@/app/api/v2/workflows/import/route' +/** With `defineV2JsonRoute` mocked to return its definition, `POST` is that definition. */ +const definition = POST as unknown as { + present: (result: ImportWorkflowResult) => { data: Record } +} + describe('/api/v2/workflows/import route definition', () => { it('uses authorized admission and preserves the bounded import lifecycle', () => { expect(POST).toMatchObject({ @@ -31,4 +39,34 @@ describe('/api/v2/workflows/import route definition', () => { parseOptions: { maxBodyBytes: MAX_IMPORT_BODY_BYTES }, }) }) + + /** + * The presenter dropped the imported blocks, so an import that created three + * blocks answered with nothing a caller could check short of reading the + * state back — and a client filling in `blocks: []` reported an empty import. + */ + it('presents the blocks the import created', () => { + const blocks = [ + { id: 'block-1', type: 'starter', name: 'Start' }, + { id: 'block-2', type: 'agent', name: 'Classify' }, + { id: 'block-3', type: 'response', name: 'Reply' }, + ] + + const { data } = definition.present({ + workflow: { + id: 'workflow-1', + name: 'Imported', + description: null, + workspaceId: 'ws-1', + folderId: null, + sortOrder: 0, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + blocks, + }, + folderPath: '/', + }) + + expect(data).toMatchObject({ id: 'workflow-1', name: 'Imported', folderPath: '/', blocks }) + }) }) diff --git a/apps/sim/app/api/v2/workflows/import/route.ts b/apps/sim/app/api/v2/workflows/import/route.ts index 1291bfaeae0..d8d4693d2f9 100644 --- a/apps/sim/app/api/v2/workflows/import/route.ts +++ b/apps/sim/app/api/v2/workflows/import/route.ts @@ -27,6 +27,7 @@ export const POST = defineV2JsonRoute({ folderPath, createdAt: workflow.createdAt.toISOString(), updatedAt: workflow.updatedAt.toISOString(), + blocks: workflow.blocks, }, }), }) diff --git a/apps/sim/executor/execution/executor.test.ts b/apps/sim/executor/execution/executor.test.ts index 40ac10a677c..06fbc8cd8b2 100644 --- a/apps/sim/executor/execution/executor.test.ts +++ b/apps/sim/executor/execution/executor.test.ts @@ -8,6 +8,7 @@ import { DAGBuilder } from '@/executor/dag/builder' import { DAGExecutor } from '@/executor/execution/executor' import type { SerializableExecutionState } from '@/executor/execution/types' import type { ExecutionContext, ExecutionResult } from '@/executor/types' +import { RunFromBlockValidationError } from '@/executor/utils/run-from-block' import { buildSentinelStartId } from '@/executor/utils/subflow-utils' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' @@ -339,6 +340,36 @@ describe('DAGExecutor run-from-block snapshot metadata', () => { }) expect(capturedContext?.blockStates.has('unreachable__obranch-0')).toBe(false) }) + it('refuses a start block whose upstream never executed with a typed validation error', async () => { + const workflow: SerializedWorkflow = { + version: '1', + blocks: [ + createBlock('start', BlockType.STARTER), + createBlock('producer', BlockType.FUNCTION), + createBlock('consumer', BlockType.FUNCTION), + ], + connections: [ + { source: 'start', target: 'producer' }, + { source: 'producer', target: 'consumer' }, + ], + loops: {}, + parallels: {}, + } + const executor = new DAGExecutor({ workflow }) + const sourceSnapshot: SerializableExecutionState = { + blockStates: {}, + executedBlocks: [], + blockLogs: [], + decisions: { router: {}, condition: {} }, + completedLoops: [], + activeExecutionPath: [], + } + + const error = await executor.executeFromBlock('wf', 'consumer', sourceSnapshot).catch((e) => e) + + expect(error).toBeInstanceOf(RunFromBlockValidationError) + expect(error.message).toBe('Upstream dependency not executed: producer') + }) }) describe('DAGExecutor resume DAG construction', () => { diff --git a/apps/sim/executor/execution/executor.ts b/apps/sim/executor/execution/executor.ts index d709eaccba1..6c5e5fdb37b 100644 --- a/apps/sim/executor/execution/executor.ts +++ b/apps/sim/executor/execution/executor.ts @@ -27,6 +27,7 @@ import { computeExecutionSets, overlayVariableInputs, type RunFromBlockContext, + RunFromBlockValidationError, resolveContainerToSentinelStart, validateRunFromBlock, } from '@/executor/utils/run-from-block' @@ -143,7 +144,9 @@ export class DAGExecutor { const executedBlocks = new Set(sourceSnapshot.executedBlocks) const validation = validateRunFromBlock(startBlockId, dag, executedBlocks) if (!validation.valid) { - throw new Error(validation.error) + throw new RunFromBlockValidationError( + validation.error ?? `Cannot run from block: ${startBlockId}` + ) } const { dirtySet, upstreamSet, reachableUpstreamSet } = computeExecutionSets(dag, startBlockId) diff --git a/apps/sim/executor/utils/run-from-block.test.ts b/apps/sim/executor/utils/run-from-block.test.ts index d3b2fd5401f..eda8fda1ba8 100644 --- a/apps/sim/executor/utils/run-from-block.test.ts +++ b/apps/sim/executor/utils/run-from-block.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest' import type { DAG, DAGNode } from '@/executor/dag/builder' import type { DAGEdge, NodeMetadata } from '@/executor/dag/types' -import { computeExecutionSets, validateRunFromBlock } from '@/executor/utils/run-from-block' +import { + computeExecutionSets, + RunFromBlockValidationError, + validateRunFromBlock, +} from '@/executor/utils/run-from-block' import type { SerializedLoop, SerializedParallel } from '@/serializer/types' /** @@ -1664,3 +1668,13 @@ describe('upstream block addition/deletion scenarios', () => { expect(result.error).toContain('X') }) }) + +describe('RunFromBlockValidationError', () => { + it('is a named Error carrying the validation message verbatim', () => { + const error = new RunFromBlockValidationError('Upstream dependency not executed: a') + + expect(error).toBeInstanceOf(Error) + expect(error.name).toBe('RunFromBlockValidationError') + expect(error.message).toBe('Upstream dependency not executed: a') + }) +}) diff --git a/apps/sim/executor/utils/run-from-block.ts b/apps/sim/executor/utils/run-from-block.ts index 6ecefd81b50..a95885e6caf 100644 --- a/apps/sim/executor/utils/run-from-block.ts +++ b/apps/sim/executor/utils/run-from-block.ts @@ -41,6 +41,19 @@ export interface RunFromBlockValidation { error?: string } +/** + * A run-from-block start the executor refused before running anything: the block is + * missing, sits inside a loop or parallel, or has an upstream dependency the source + * snapshot never executed. Its own class so the application layer can hand the reason + * to the caller as a validation failure instead of the generic system-error fallback. + */ +export class RunFromBlockValidationError extends Error { + constructor(message: string) { + super(message) + this.name = 'RunFromBlockValidationError' + } +} + /** * Context for run-from-block execution mode. */ diff --git a/apps/sim/lib/api/contracts/v2/openapi/tables.ts b/apps/sim/lib/api/contracts/v2/openapi/tables.ts index 2ec4fdab845..ce58d1c628b 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/tables.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/tables.ts @@ -970,7 +970,7 @@ const declaredRoutes = [ operationId: 'addTableWorkflowGroup', summary: 'Add Workflow Group', description: - 'Bind a workflow or enrichment to the table and create the columns populated by its outputs.', + 'Bind a workflow or enrichment to the table and create the columns populated by its outputs. An output whose column the table already has attaches that column to the group instead of creating it, so `outputColumns` may be omitted when every output lands in an existing column.', errors: TABLE_MUTATION_ERRORS, success: { description: 'The created workflow group and resulting columns.' }, }), @@ -1855,18 +1855,18 @@ const declaredRoutes = [ tableOperation({ applicationOperation: tableOperations.readRun, operationId: 'listTableDispatches', - summary: 'List Active Run Dispatches', + summary: 'List Run Dispatches', description: - 'List in-flight run dispatches for a table in one page; `nextCursor` is always null. Use Get Run Dispatch to read a settled dispatch.', + 'List the run dispatches on one table, most recent first — settled dispatches (`complete`, `canceled`) alongside the ones still in flight, so a run that finished between two polls is still visible next to the `dispatchId` its create returned. Capped at the 100 most recent, so this list is unpaginated and `nextCursor` is always null.', errors: RESOURCE_ERRORS, - success: { description: "The table's active run dispatches." }, + success: { description: "The table's most recent run dispatches." }, }), { params: documentedSchema( v2ListTableDispatchesContract.params, 'ListTableDispatchesParams', 'List table dispatches path parameters', - 'Table whose active run dispatches should be listed.' + 'Table whose run dispatches should be listed.' ), query: documentedSchema( v2ListTableDispatchesContract.query, @@ -1878,7 +1878,7 @@ const declaredRoutes = [ v2ListTableDispatchesContract.response.schema, 'V2TableRunDispatchListResponse', 'Table run dispatch list response', - "The table's active run dispatches." + "The table's most recent run dispatches, settled ones included." ), } ), diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 33694a33961..5bd7e3f76c0 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -1029,6 +1029,11 @@ const declaredRoutes = [ folderPath: '/Operations', createdAt: WORKFLOW_EXAMPLE.createdAt, updatedAt: WORKFLOW_EXAMPLE.updatedAt, + blocks: [ + { id: 'block_start', type: 'starter', name: 'Start' }, + { id: 'block_triage', type: 'agent', name: 'Triage' }, + { id: 'block_reply', type: 'response', name: 'Reply' }, + ], }, }, ] diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index ab5119ed5a1..052e7f74692 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -1689,10 +1689,19 @@ export const v2AddWorkflowGroupBodySchema = z ), }) .describe('Workflow or enrichment producer definition.'), + /** + * `min(1)` here, together with the service refusing any name the table + * already had, made it impossible to attach a group to existing columns: + * `[]` was rejected as too small and a matching entry as a duplicate. + * Existing columns are attached rather than recreated, and an output whose + * column already exists needs no entry at all. + */ outputColumns: z .array(v2WorkflowGroupOutputColumnSchema) - .min(1) - .describe('Columns created for producer outputs.'), + .default([]) + .describe( + 'Columns to create for producer outputs. An entry naming a column the table already has attaches that column to the group instead of creating it (its `type` must match), and an output whose column already exists may omit its entry entirely — so `[]` attaches existing columns only.' + ), autoRun: z .boolean() .optional() @@ -2626,12 +2635,13 @@ export const v2CancelTableDispatchContract = defineRouteContract({ }) /** - * What is currently running on one table. Returns only the in-flight - * dispatches (`pending`, `dispatching`); a settled one is reachable by id. + * The dispatches on one table, most recent first. Settled dispatches + * (`complete`, `canceled`) are listed alongside the in-flight ones, so a run + * that finished between two polls is still visible next to the `dispatchId` + * its create returned rather than vanishing into an empty list. * - * Unpaged: the dispatcher keeps at most a handful of active dispatches per - * table, so the set is bounded by construction the same way a table's saved - * views and workflow groups are. + * Unpaged: the list is capped at the 100 most recent dispatches, which bounds + * it the same way a table's saved views and workflow groups are bounded. */ export const v2ListTableDispatchesContract = defineRouteContract({ method: 'GET', diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index cee85252800..5e978cdb2d5 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -2248,6 +2248,25 @@ export const v2PreviewWorkflowImportContract = defineRouteContract({ export type V2ImportWorkflowBody = z.input export type V2PreviewWorkflowImportBody = z.input +const v2ImportedBlockSchema = z + .object({ + id: z.string().describe('Block identifier.'), + type: z.string().describe('Registered block type.'), + name: z.string().describe('Block display name.'), + }) + .strict() + .meta({ + id: 'ImportedWorkflowBlock', + title: 'Imported workflow block', + description: 'A block the import created in the new workflow.', + }) + +/** + * Import result. Carries the created blocks as a summary — the same shape the + * create result uses for its seeded blocks — so a caller can confirm what + * landed without reading the whole graph back. An import that reported no + * blocks was indistinguishable from one that imported an empty payload. + */ export const v2ImportWorkflowDataSchema = z .object({ id: z @@ -2272,6 +2291,11 @@ export const v2ImportWorkflowDataSchema = z .string() .describe('ISO 8601 timestamp when the workflow was last updated.') .meta({ format: 'date-time' }), + blocks: z + .array(v2ImportedBlockSchema) + .describe( + 'Blocks the import created, in payload order. A summary only; `GET /workflows/{workflowId}/state` returns the full graph.' + ), }) .extend(v2OperationReportSchema.omit({ workspaceId: true }).partial().shape) .meta({ diff --git a/apps/sim/lib/catalog/registry-boundary.test.ts b/apps/sim/lib/catalog/registry-boundary.test.ts index 0747f904716..da739100c94 100644 --- a/apps/sim/lib/catalog/registry-boundary.test.ts +++ b/apps/sim/lib/catalog/registry-boundary.test.ts @@ -31,8 +31,6 @@ const CATALOG_ROOTS = [ 'app/api/v2/blocks', 'app/api/v2/tools', 'app/api/v2/connector-types', - /** The Copilot tool the shared projection was extracted for: ~6,756 modules down to ~1,321. */ - 'lib/mothership/tools/server/blocks', ] as const /** Modules no catalog file may import, with what each would drag in. */ diff --git a/apps/sim/lib/folders/orchestration.test.ts b/apps/sim/lib/folders/orchestration.test.ts index 204f26a34c1..558956f1f09 100644 --- a/apps/sim/lib/folders/orchestration.test.ts +++ b/apps/sim/lib/folders/orchestration.test.ts @@ -991,6 +991,34 @@ describe('restoreFolder', () => { expect(result).toEqual({ success: true, restoredItems: { folders: 1, tables: 5 } }) }) + /** + * Regression: the restore used to run inside `withFolderTreeLock`, so the pool read of the + * folder row and the table cascade's own transactions all ran inside a transaction callback — + * which the `@sim/db` tripwire refuses outside production, 500ing every table-folder restore. + * The lock now lives inside the one folder-row transaction, after the hook has finished. + */ + it('takes the tree lock inside the folder-row transaction, after the restoreChildren hook', async () => { + setConfig({ restoreChildren: mockRestoreChildren }) + mockRestoreChildren.mockResolvedValueOnce(2) + queueTableRows(schemaMock.folder, [folderRow({ deletedAt: ARCHIVED_AT })]) + + const result = await restoreFolder(baseRestore) + + expect(result).toEqual({ success: true, restoredItems: { folders: 1, tables: 2 } }) + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) + expect(mockRestoreChildren.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.transaction.mock.invocationCallOrder[0] + ) + // The advisory lock is the first statement on the transaction handle, ahead of the row writes. + expect(dbChainMockFns.execute).toHaveBeenCalled() + expect(dbChainMockFns.execute.mock.invocationCallOrder[0]).toBeGreaterThan( + dbChainMockFns.transaction.mock.invocationCallOrder[0] + ) + expect(dbChainMockFns.execute.mock.invocationCallOrder[0]).toBeLessThan( + mockRestoreFolderRows.mock.invocationCallOrder[0] + ) + }) + it('returns a conflict when a concurrent create takes the name after the dedup check', async () => { // Dedup covers the restore root, but clearing deletedAt brings the row back under the // active-name unique index and that window is real. diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index 345f262c3ea..578c2952727 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -884,8 +884,15 @@ async function deleteFolderWithoutTreeLock( * a folder whose parent is still archived is re-rooted, and the restored name is * deduplicated against the *resolved* parent's active siblings — the caller cannot rename * an archived folder, so a taken name would otherwise make it permanently unrestorable. + * + * The tree lock is taken inside the folder-row transaction at the end, not around the whole + * restore. Wrapping everything in `withFolderTreeLock` — as this once did — ran the pool reads + * below and the `restoreChildren` hook's own transactions inside a transaction callback, which + * the `@sim/db` tripwire refuses outside production: every table-folder restore 500ed in dev + * while the file-folder restore, which does all its work on the locked handle, kept working. + * `deleteFolder` releases its lock before the cascade for the same reason. */ -async function restoreFolderWithoutTreeLock( +async function restoreFolderTree( params: RestoreFolderParams, options: { projectAudit: boolean } ): Promise { @@ -950,6 +957,7 @@ async function restoreFolderWithoutTreeLock( let counts: { folders: number; children: number } try { counts = await db.transaction(async (tx) => { + await acquireFolderMutationLock(tx, workspaceId, resourceType) const now = new Date() let resolvedParentId = folder.parentId @@ -1045,7 +1053,8 @@ async function restoreFolderWithoutTreeLock( } /** - * Restores a folder while serializing against every writer for the resource tree. + * Restores a folder, serializing the folder-row write against every writer for the resource + * tree (see {@link restoreFolderTree} for why the lock is scoped that narrowly). * * `projectAudit: false` for a caller that projects `FOLDER_RESTORED` itself — an application * use case attributes the entry to the acting `Principal`, which the `actorId: userId` entry @@ -1056,7 +1065,5 @@ export async function restoreFolder( params: RestoreFolderParams, options?: { projectAudit?: boolean } ): Promise { - return withFolderTreeLock(params.workspaceId, params.resourceType, () => - restoreFolderWithoutTreeLock(params, { projectAudit: options?.projectAudit ?? true }) - ) + return restoreFolderTree(params, { projectAudit: options?.projectAudit ?? true }) } diff --git a/apps/sim/lib/mothership/agent-cli/engines.test.ts b/apps/sim/lib/mothership/agent-cli/engines.test.ts index cc5a3c6c655..5cd4cc5b548 100644 --- a/apps/sim/lib/mothership/agent-cli/engines.test.ts +++ b/apps/sim/lib/mothership/agent-cli/engines.test.ts @@ -80,3 +80,151 @@ describe('workflows lint', () => { expect(result.stderr).toContain('Unexpected request') }) }) + +const RUNS_PATH = '/api/v2/workflows/wf-1/runs' +const COUNT_ROWS_RUNS = { + [RUNS_PATH]: { + data: [ + { runId: 'run-1', status: 'completed' }, + { runId: 'run-2', status: 'completed' }, + { runId: 'run-3', status: 'completed' }, + ], + }, + '/api/v2/logs/run-1': { + data: { + traceSpans: [{ name: 'Count rows', status: 'success', output: { result: { count: 6 } } }], + }, + }, + // Nested under a parent span: the walk is recursive. + '/api/v2/logs/run-2': { + data: { + traceSpans: [ + { + name: 'Loop 1', + children: [{ name: 'Count rows', status: 'success', output: { result: { count: 2 } } }], + }, + ], + }, + }, + // The block never executed in this run. + '/api/v2/logs/run-3': { data: { traceSpans: [{ name: 'Other', output: {} }] } }, +} + +async function logsQuery(flags: Record) { + const result = await runEngine('logs query', ['wf-1'], runtimeWith(COUNT_ROWS_RUNS), { + block: 'Count rows', + ...flags, + }) + expect(result.exitCode).toBe(0) + return JSON.parse(result.stdout) +} + +describe('logs query', () => { + it('reads a field with no span-level head under output and reports the resolved path', async () => { + const report = await logsQuery({ field: 'result.count' }) + expect(report.field).toBe('output.result.count') + expect(report.fieldResolvedUnder).toBe('output') + expect(report.rows.map((row: { value: unknown }) => row.value)).toEqual([6, 2, null]) + expect(report.rows[2]).toMatchObject({ runId: 'run-3', hits: 0, value: null }) + expect(report.rows[2].note).toBeUndefined() + + const explicit = await logsQuery({ field: 'output.result.count' }) + expect(explicit.field).toBe('output.result.count') + expect(explicit.fieldResolvedUnder).toBeUndefined() + }) + + it('marks a matched span whose path resolves to nothing, distinct from a genuine null', async () => { + const report = await logsQuery({ field: 'input.code' }) + expect(report.field).toBe('input.code') + expect(report.fieldResolvedUnder).toBeUndefined() + expect(report.rows[0]).toMatchObject({ + runId: 'run-1', + hits: 1, + value: null, + note: 'path not found on span', + }) + + const missing = await logsQuery({ field: 'result.missing' }) + expect(missing.rows[0]).toMatchObject({ value: null, note: 'path not found on span' }) + }) + + it('filters out runs the block never reached under --where, with the same output fallback', async () => { + const report = await logsQuery({ where: 'result.count=6' }) + expect(report.where).toBe('output.result.count=6') + expect(report.whereResolvedUnder).toBe('output') + expect(report.filteredOut).toBe(2) + expect(report.rows).toHaveLength(1) + expect(report.rows[0]).toMatchObject({ runId: 'run-1', hits: 1 }) + }) +}) + +const DEPS_STATE = { + blocks: { + 'trigger-1': { type: 'starter', name: 'Start' }, + fetch: { type: 'function', name: 'Fetch rows' }, + gate: { type: 'condition', name: 'Gate' }, + enrich: { type: 'workflow', name: 'Enrich' }, + target: { + type: 'function', + name: 'Summarize', + subBlocks: { + code: { value: 'return .length + ' }, + }, + }, + }, + edges: [ + { id: 'e1', source: 'trigger-1', target: 'fetch', sourceHandle: 'source' }, + { id: 'e2', source: 'fetch', target: 'target', sourceHandle: 'source' }, + { id: 'e3', source: 'gate', target: 'target', sourceHandle: 'condition-true' }, + { id: 'e4', source: 'enrich', target: 'target', sourceHandle: 'source' }, + { id: 'e5', source: 'trigger-1', target: 'target', sourceHandle: 'source' }, + { id: 'e6', source: 'target', target: 'target', sourceHandle: 'source' }, + ], +} + +describe('workflows deps', () => { + it('lists graph predecessors beside token references and mocks both', async () => { + const result = await runEngine( + 'workflows deps', + ['wf-1', 'target'], + runtimeWith({ [STATE_PATH]: { data: DEPS_STATE } }), + {} + ) + expect(result.exitCode).toBe(0) + const report = JSON.parse(result.stdout) + expect(report.references.map((d: { blockId?: string }) => d.blockId)).toEqual([ + 'fetch', + 'enrich', + ]) + // The trigger and the block itself are skipped; the unreferenced gate is not. + expect(report.predecessors).toEqual([ + { blockId: 'fetch', blockName: 'Fetch rows', sourceHandle: 'source' }, + { blockId: 'gate', blockName: 'Gate', sourceHandle: 'condition-true' }, + { blockId: 'enrich', blockName: 'Enrich', sourceHandle: 'source' }, + ]) + expect(report.mock).toEqual({ + 'Fetch rows': ['result'], + Enrich: ['result.data.total'], + Gate: [''], + }) + expect(report.childReturns).toEqual([ + { + blockId: 'enrich', + blockName: 'Enrich', + note: expect.stringContaining('result.data.'), + }, + ]) + }) + + it('omits childReturns when no upstream block runs a child workflow', async () => { + const result = await runEngine( + 'workflows deps', + ['wf-1', 'fetch'], + runtimeWith({ [STATE_PATH]: { data: DEPS_STATE } }), + {} + ) + const report = JSON.parse(result.stdout) + expect(report.predecessors).toEqual([]) + expect(report.childReturns).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/mothership/agent-cli/engines/deps.ts b/apps/sim/lib/mothership/agent-cli/engines/deps.ts index 7c66167a9bc..6417c3045e1 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/deps.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/deps.ts @@ -1,5 +1,6 @@ import { fetchWorkflowState } from '@/lib/mothership/agent-cli/engines/workflow-state' import { type AgentCliEngine, agentCliFail, agentCliOk } from '@/lib/mothership/agent-cli/types' +import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { normalizeName, SPECIAL_REFERENCE_PREFIXES } from '@/executor/constants' import { collectStringLeaves, @@ -14,6 +15,11 @@ import { * executor resolves (`` templates, `{{ENV}}` secrets) and block heads * are matched through the executor's own name normalization — this command must * never re-invent resolution semantics. + * + * Graph predecessors are listed alongside the token references: run_block's + * validation (`validateRunFromBlock`) requires every block with an edge into the + * target to have executed, whether or not the target reads its output by token, + * so a parent the block never references still needs a mock. */ // The executor's own token grammars — this command must classify exactly what @@ -21,6 +27,11 @@ import { const TEMPLATE_REF = createReferencePattern() const ENV_REF = createEnvVarPattern() +/** Block types that run a child workflow and hand back its Response block's envelope. */ +const CHILD_WORKFLOW_BLOCK_TYPES: ReadonlySet = new Set(['workflow', 'workflow_input']) +const CHILD_RETURNS_NOTE = + "A child workflow's result is its Response block's envelope {data, status, headers}; fields live at result.data., so mock and read them there." + interface DepView { token: string kind: 'block' | 'loop' | 'parallel' | 'variable' | 'env' | 'unknown' @@ -29,6 +40,50 @@ interface DepView { paths?: string[] } +interface PredecessorView { + blockId: string + blockName?: string + sourceHandle?: string +} + +interface StateEdge { + source?: unknown + target?: unknown + sourceHandle?: unknown +} + +function isTriggerBlock(block: Record): boolean { + return ( + typeof block.type === 'string' && + TriggerUtils.isTriggerBlock({ type: block.type, triggerMode: block.triggerMode === true }) + ) +} + +/** Blocks with an edge into `blockId`, minus entry/trigger blocks and the block itself. */ +function collectPredecessors( + state: Record, + blocks: Record>, + blockId: string, + idToName: ReadonlyMap +): PredecessorView[] { + const edges = Array.isArray(state.edges) ? (state.edges as StateEdge[]) : [] + const seen = new Set() + const predecessors: PredecessorView[] = [] + for (const edge of edges) { + const source = edge.source + if (typeof source !== 'string' || edge.target !== blockId || source === blockId) continue + const sourceBlock = blocks[source] + if (!sourceBlock || seen.has(source) || isTriggerBlock(sourceBlock)) continue + seen.add(source) + predecessors.push({ + blockId: source, + blockName: idToName.get(source), + ...(typeof edge.sourceHandle === 'string' ? { sourceHandle: edge.sourceHandle } : {}), + }) + } + return predecessors +} + export const workflowDepsCommand: AgentCliEngine = { async execute(rest, runtime) { const [workflowId, blockId] = rest @@ -92,21 +147,39 @@ export const workflowDepsCommand: AgentCliEngine = { const deps = [...new Set(byToken.values())] const blockDeps = deps.filter((d) => d.kind === 'block') + const predecessors = collectPredecessors(state, blocks, blockId, idToName) + + // Ready-made skeleton for run_block's variableInputs: mock each upstream + // block's output at the paths this block actually reads, and every graph + // parent it never reads at all — run_block refuses to start without them. + const mock: Record = Object.fromEntries( + blockDeps.map((d) => [d.blockName ?? d.blockId, d.paths?.length ? d.paths : ['']]) + ) + for (const predecessor of predecessors) { + const key = predecessor.blockName ?? predecessor.blockId + if (!mock[key]) mock[key] = [''] + } + + const upstreamIds = new Set() + for (const dep of blockDeps) if (dep.blockId) upstreamIds.add(dep.blockId) + for (const predecessor of predecessors) upstreamIds.add(predecessor.blockId) + const childReturns = [...upstreamIds] + .filter((id) => { + const type = blocks[id]?.type + return typeof type === 'string' && CHILD_WORKFLOW_BLOCK_TYPES.has(type) + }) + .map((id) => ({ blockId: id, blockName: idToName.get(id), note: CHILD_RETURNS_NOTE })) + return agentCliOk( JSON.stringify( { blockId, blockName: idToName.get(blockId), references: deps, + predecessors, env: [...envs].sort(), - // Ready-made skeleton for run_block's variableInputs: mock each upstream - // block's output at the paths this block actually reads. - mock: Object.fromEntries( - blockDeps.map((d) => [ - d.blockName ?? d.blockId, - d.paths?.length ? d.paths : [''], - ]) - ), + mock, + ...(childReturns.length ? { childReturns } : {}), }, null, 2 diff --git a/apps/sim/lib/mothership/agent-cli/engines/query.ts b/apps/sim/lib/mothership/agent-cli/engines/query.ts index 80ab5af445e..31958a7f402 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/query.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/query.ts @@ -21,6 +21,33 @@ const DEFAULT_LIMIT = 20 const MAX_LIMIT = 50 const TRACE_FETCH_CONCURRENCY = 5 const VALUE_MAX_CHARS = 600 +const PATH_NOT_FOUND_NOTE = 'path not found on span' + +/** + * Keys that live on the span itself. A path whose head is anything else is read + * relative to the block's output when it resolves to nothing at the span root — + * `result.count` means `output.result.count`, which is where a block's fields live. + * Without this every run answered `value: null` for a path that was merely one + * segment short, indistinguishable from a block that produced nothing. + */ +const SPAN_LEVEL_KEYS: ReadonlySet = new Set([ + 'input', + 'output', + 'status', + 'name', + 'type', + 'duration', + 'blockId', + 'children', + 'tokens', + 'cost', + 'executionOrder', + 'errorHandled', + 'tries', + 'id', + 'startTime', + 'endTime', +]) interface QueryTraceSpan { name?: string @@ -39,6 +66,13 @@ interface RunListItem { durationMs?: number } +interface ResolvedSpanPath { + value: unknown + /** The path that produced `value`: as given, or prefixed when the fallback applied. */ + path: string + resolvedUnder?: 'output' +} + function collectSpansByName( spans: QueryTraceSpan[], normalizedBlockName: string, @@ -59,6 +93,18 @@ function resolveSpanPath(span: QueryTraceSpan, path: string): unknown { return current } +/** The path as given first; under `output.` when the head is not a span-level key. */ +function resolveSpanPathWithFallback(span: QueryTraceSpan, path: string): ResolvedSpanPath { + const direct = resolveSpanPath(span, path) + if (direct !== undefined) return { value: direct, path } + const head = path.split('.')[0] ?? '' + if (SPAN_LEVEL_KEYS.has(head)) return { value: undefined, path } + const underOutput = `output.${path}` + const value = resolveSpanPath(span, underOutput) + if (value === undefined) return { value: undefined, path } + return { value, path: underOutput, resolvedUnder: 'output' } +} + function clipValue(value: unknown): unknown { if (value === undefined) return null const serialized = JSON.stringify(value) @@ -78,7 +124,8 @@ export const logsQueryCommand: AgentCliEngine = { if (!workflowId || !blockName) { return agentCliFail( 'Usage: sim logs query --block [--field ] [--where =] [--status ] [--trigger ] [--limit N]\n' + - 'Paths resolve inside the matched block span: output.content, input.action_id, status, duration.' + 'Paths resolve inside the matched block span: output.content, input.action_id, status, duration. ' + + 'A path that names no span-level key is read under output. (result.count means output.result.count).' ) } const field = stringFlag(flags, 'field') ?? 'output' @@ -111,6 +158,8 @@ export const logsQueryCommand: AgentCliEngine = { const rows: (Record | undefined)[] = new Array(runItems.length) let filteredOut = 0 let missingTrace = 0 + let fieldResolvedUnder: 'output' | undefined + let whereResolvedUnder: 'output' | undefined for (let start = 0; start < runItems.length; start += TRACE_FETCH_CONCURRENCY) { const chunk = runItems.slice(start, start + TRACE_FETCH_CONCURRENCY) await Promise.all( @@ -135,18 +184,31 @@ export const logsQueryCommand: AgentCliEngine = { collectSpansByName(spans, normalizedBlockName, matches) const last = matches[matches.length - 1] if (!last) { + // Under --where a run the block never reached cannot match; returning it as + // `hits: 0` made matches indistinguishable from padding. + if (wherePath) { + filteredOut++ + return + } rows[start + offset] = { ...base, hits: 0, value: null } return } - if (wherePath && String(resolveSpanPath(last, wherePath)) !== whereValue) { - filteredOut++ - return + if (wherePath) { + const matched = resolveSpanPathWithFallback(last, wherePath) + if (matched.resolvedUnder) whereResolvedUnder = matched.resolvedUnder + if (String(matched.value) !== whereValue) { + filteredOut++ + return + } } + const resolved = resolveSpanPathWithFallback(last, field) + if (resolved.resolvedUnder) fieldResolvedUnder = resolved.resolvedUnder rows[start + offset] = { ...base, hits: matches.length, blockStatus: last.status ?? 'success', - value: clipValue(resolveSpanPath(last, field)), + value: clipValue(resolved.value), + ...(resolved.value === undefined ? { note: PATH_NOT_FOUND_NOTE } : {}), } }) ) @@ -158,9 +220,16 @@ export const logsQueryCommand: AgentCliEngine = { { workflowId, block: blockName, - field, + field: fieldResolvedUnder ? `output.${field}` : field, + ...(fieldResolvedUnder ? { fieldResolvedUnder } : {}), runsScanned: runItems.length, - ...(wherePath ? { where, filteredOut } : {}), + ...(wherePath + ? { + where: whereResolvedUnder ? `output.${where}` : where, + ...(whereResolvedUnder ? { whereResolvedUnder } : {}), + filteredOut, + } + : {}), ...(missingTrace ? { missingTrace } : {}), rows: kept, }, diff --git a/apps/sim/lib/mothership/tools/client/browser-tool-replay-ledger.test.ts b/apps/sim/lib/mothership/tools/client/browser-tool-replay-ledger.test.ts index 5c5bbaa12a7..55d78491c03 100644 --- a/apps/sim/lib/mothership/tools/client/browser-tool-replay-ledger.test.ts +++ b/apps/sim/lib/mothership/tools/client/browser-tool-replay-ledger.test.ts @@ -2,7 +2,7 @@ * @vitest-environment jsdom */ import { beforeEach, describe, expect, it } from 'vitest' -import { BrowserToolReplayLedger } from '@/lib/copilot/tools/client/browser-tool-replay-ledger' +import { BrowserToolReplayLedger } from '@/lib/mothership/tools/client/browser-tool-replay-ledger' const STORAGE_KEY = 'test:browser-tool-ledger:v1' const LEGACY_PREFIX = 'test:browser-tool-executed:' diff --git a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts index 917141ac1fa..ba91ea5e18c 100644 --- a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts @@ -178,6 +178,41 @@ describe('workflow mutation Copilot adapters', () => { ) }) + it('compacts echoed block inputs in run logs and leaves outputs whole', async () => { + const rows = Array.from({ length: 40 }, (_, index) => ({ id: index, name: `row ${index}` })) + const code = `const rows = ${JSON.stringify(rows)}; return rows.length` + const note = 'n'.repeat(2_001) + mocks.executeWorkflowUseCase.mockResolvedValue({ + success: true, + output: { count: 40 }, + logs: [ + { + blockName: 'Count rows', + input: { code, language: 'javascript', note }, + output: { result: code }, + }, + { blockName: 'Start', input: 'raw', output: {} }, + ], + metadata: { executionId: 'execution-1' }, + }) + + const result = await executeRunWorkflow({ workflowId: 'workflow-1' }, context) + + const output = result.output as { + logs: [{ input: Record; output: Record }, { input: string }] + } + expect(code.length).toBeGreaterThan(240) + expect(output.logs[0].input.code).toBe( + `${code.slice(0, 200)} …[${code.length} chars, see logs get execution-1 --trace]` + ) + expect(output.logs[0].input.note).toBe( + `${'n'.repeat(200)} …[2001 chars, see logs get execution-1 --trace]` + ) + expect(output.logs[0].input.language).toBe('javascript') + expect(output.logs[0].output.result).toBe(code) + expect(output.logs[1].input).toBe('raw') + }) + it('cancels a workflow run through the canonical application use case', async () => { mocks.executeWorkflowUseCase.mockResolvedValue({ success: true, diff --git a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts index ba22e7439d6..e785931d965 100644 --- a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { isPlainRecord } from '@sim/utils/object' import { createCopilotWorkspaceApiKey } from '@/lib/api-key/application/create-api-key' import { PlatformEvents } from '@/lib/core/telemetry' import { messageForCopilotApplicationError } from '@/lib/mothership/application/error' @@ -49,6 +50,35 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowMutations') +/** Above this a Function block's `input.code` is echoed upstream JSON, not code worth reading. */ +const LOG_CODE_INPUT_MAX_CHARS = 240 +/** Any other echoed input string over this is data the caller already has, or can fetch. */ +const LOG_INPUT_STRING_MAX_CHARS = 2_000 +const LOG_INPUT_KEEP_CHARS = 200 + +/** + * Compacts the block inputs echoed back in `logs`. A Function block's `input.code` embeds the + * fully serialized upstream rows, so a seven-block run repeated the same rows several times + * across ~14k chars of tool result. Outputs are never touched — they are what the run was for — + * and the full input stays one `logs get --trace` away. + */ +function compactBlockLogInputs(logs: unknown, executionId: string | undefined): unknown { + if (!Array.isArray(logs)) return logs + const reference = executionId ?? '' + return logs.map((entry) => { + if (!isPlainRecord(entry) || !isPlainRecord(entry.input)) return entry + const input: Record = {} + for (const [key, value] of Object.entries(entry.input)) { + const limit = key === 'code' ? LOG_CODE_INPUT_MAX_CHARS : LOG_INPUT_STRING_MAX_CHARS + input[key] = + typeof value === 'string' && value.length > limit + ? `${value.slice(0, LOG_INPUT_KEEP_CHARS)} …[${value.length} chars, see logs get ${reference} --trace]` + : value + } + return { ...entry, input } + }) +} + function stripBinaryFields(value: unknown): unknown { if (value === null || value === undefined) return value if (typeof value !== 'object') return value @@ -115,7 +145,7 @@ function buildExecutionOutput( success: result.success, ...extra, output: stripBinaryFields(result.output), - logs: stripBinaryFields(result.logs), + logs: compactBlockLogInputs(stripBinaryFields(result.logs), result.metadata?.executionId), }, error: result.success ? undefined : result.error || 'Workflow execution failed', effect: executionEffect(phase, result.metadata?.executionId), diff --git a/apps/sim/lib/table/application/folders.test.ts b/apps/sim/lib/table/application/folders.test.ts index 30f0b60af42..d79eb236142 100644 --- a/apps/sim/lib/table/application/folders.test.ts +++ b/apps/sim/lib/table/application/folders.test.ts @@ -55,7 +55,9 @@ vi.mock('@/lib/table/application/context', () => ({ resolveTableWorkspaceContext: mocks.resolveWorkspaceContext, })) -import { listTableFoldersUseCase } from '@/lib/table/application/folders' +import { restoreFolder } from '@/lib/folders/orchestration' +import { findArchivedFolderIdByPath } from '@/lib/folders/queries' +import { listTableFoldersUseCase, restoreTableFolderUseCase } from '@/lib/table/application/folders' const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const @@ -104,3 +106,72 @@ describe('listTableFoldersUseCase', () => { expect(mocks.listRows).not.toHaveBeenCalled() }) }) + +describe('restoreTableFolderUseCase', () => { + const restoredRow = { + id: 'folder-1', + name: 'xp-explore-renamed', + parentId: null, + workspaceId: 'ws-1', + resourceType: 'table', + deletedAt: null, + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspaceContext.mockResolvedValue({ + workspaceId: 'ws-1', + billedAccountUserId: 'owner-1', + }) + mocks.resolvePermission.mockResolvedValue('admin') + vi.mocked(findArchivedFolderIdByPath).mockResolvedValue('folder-1') + vi.mocked(restoreFolder).mockResolvedValue({ + success: true, + restoredItems: { folders: 1, tables: 2 }, + }) + mocks.loadFolderIndex.mockResolvedValue({ + idByPath: new Map([['/xp-explore-renamed', 'folder-1']]), + pathById: new Map([['folder-1', '/xp-explore-renamed']]), + rowById: new Map([['folder-1', restoredRow]]), + }) + }) + + /** + * The folder is addressed by the path it held when the recursive delete archived it, which + * only an archived-aware lookup can resolve — the active index no longer knows it. The id + * that lookup yields is what the orchestration restores; the path itself never reaches it. + */ + it('restores the archived folder resolved from its delete-time path and reports what came back', async () => { + const result = await restoreTableFolderUseCase.execute({ + principal, + input: { workspaceId: 'ws-1', path: '/xp-explore-renamed' }, + }) + + expect(findArchivedFolderIdByPath).toHaveBeenCalledWith( + 'ws-1', + 'table', + '/xp-explore-renamed', + expect.objectContaining({ maxRows: expect.any(Number) }) + ) + expect(restoreFolder).toHaveBeenCalledWith( + expect.objectContaining({ resourceType: 'table', workspaceId: 'ws-1', folderId: 'folder-1' }), + { projectAudit: false } + ) + expect(result.folder).toBe(restoredRow) + expect(result.restoredItems).toEqual({ folders: 1, tables: 2 }) + expect(result.requestedPath).toBe('/xp-explore-renamed') + }) + + it('reports a path no archived folder held as not found without touching the tree', async () => { + vi.mocked(findArchivedFolderIdByPath).mockResolvedValue(null) + + await expect( + restoreTableFolderUseCase.execute({ + principal, + input: { workspaceId: 'ws-1', path: '/never-existed' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(restoreFolder).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/groups.test.ts b/apps/sim/lib/table/application/groups.test.ts index db29e6c125f..dae4053d892 100644 --- a/apps/sim/lib/table/application/groups.test.ts +++ b/apps/sim/lib/table/application/groups.test.ts @@ -413,6 +413,58 @@ describe('workflow and enrichment Table application commands', () => { expect(v2WorkflowGroupSchema.safeParse(result.group).success).toBe(true) }) + /** + * A group whose outputs all land in columns the table already has needs no + * `outputColumns` at all; the service attaches those columns. The use case + * used to require at least one entry and the service refused a matching + * name, so a group could never be attached to existing columns. + */ + it('creates a group over existing columns with outputColumns omitted', async () => { + await createTableGroupUseCase.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + group: { + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-2', path: 'score', columnName: 'name' }], + }, + }, + }) + + expect(mocks.addGroup).toHaveBeenCalledWith( + expect.objectContaining({ + group: expect.objectContaining({ + outputs: [{ blockId: 'block-2', path: 'score', columnName: 'name' }], + }), + outputColumns: [], + }), + 'request-1' + ) + }) + + it('refuses an output whose column is neither declared nor existing', async () => { + await expect( + createTableGroupUseCase.execute({ + principal, + input: { + tableId: table.id, + workspaceId: table.workspaceId, + group: { + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-2', path: 'score', columnName: 'tier' }], + }, + outputColumns: [], + }, + }) + ).rejects.toMatchObject({ + code: 'validation', + message: expect.stringContaining('"tier" names neither an outputColumns entry'), + }) + + expect(mocks.addGroup).not.toHaveBeenCalled() + }) + it('preserves the internal create contract for an invalid related workflow', async () => { mocks.resolveWorkflowContext.mockRejectedValueOnce( new OrchestrationError('not_found', 'Workflow not found') diff --git a/apps/sim/lib/table/application/groups.ts b/apps/sim/lib/table/application/groups.ts index bbbb1750f8e..34df08a1187 100644 --- a/apps/sim/lib/table/application/groups.ts +++ b/apps/sim/lib/table/application/groups.ts @@ -9,6 +9,7 @@ import { runDetached } from '@/lib/core/utils/background' import { generateRequestId } from '@/lib/core/utils/request' import { type ColumnDefinition, + columnMatchesRef, type DeleteWorkflowGroupData, getColumnId, TABLE_LIMITS, @@ -271,14 +272,34 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ input.group.outputs.map((output) => output.outputId) ) } + const outputColumns = input.outputColumns ?? [] const outputNames = new Set(input.group.outputs.map((output) => output.columnName)) - const orphan = input.outputColumns.find((column) => !outputNames.has(column.name)) + const orphan = outputColumns.find((column) => !outputNames.has(column.name)) if (orphan) { throw new OrchestrationError( 'validation', `outputColumns entry "${orphan.name}" has no matching group.outputs[].columnName` ) } + /** + * Every output needs a column to land in: one this call creates, or one the + * table already has, which the group attaches instead of recreating. Checked + * here so the caller learns the offending name rather than hitting the + * schema invariant's generic failure inside the write. + */ + const providedNames = new Set(outputColumns.map((column) => column.name)) + const existingColumns = (context.table.schema as TableSchema).columns + const homeless = input.group.outputs.find( + (output) => + !providedNames.has(output.columnName) && + !existingColumns.some((column) => columnMatchesRef(column, output.columnName)) + ) + if (homeless) { + throw new OrchestrationError( + 'validation', + `group.outputs[].columnName "${homeless.columnName}" names neither an outputColumns entry nor an existing column` + ) + } const actorUserId = attributedUserId(principal, context.billedAccountUserId) const capabilityGovernedUserId = capabilityGovernedPrincipalUserId(principal) @@ -303,7 +324,7 @@ export const createTableGroupUseCase = defineAuthorizedTableUseCase({ tableId: context.table.id, workspaceId: context.workspaceId, group, - outputColumns: input.outputColumns.map((column) => ({ + outputColumns: outputColumns.map((column) => ({ ...column, workflowGroupId: groupId, })), diff --git a/apps/sim/lib/table/application/runs.test.ts b/apps/sim/lib/table/application/runs.test.ts index e2735125059..93ca00adc55 100644 --- a/apps/sim/lib/table/application/runs.test.ts +++ b/apps/sim/lib/table/application/runs.test.ts @@ -16,7 +16,7 @@ const { mockTranslatePredicate, mockGetTableById, mockReadDispatch, - mockListActiveDispatches, + mockListDispatches, mockCancelDispatchById, mockResolveWorkspaceContext, } = vi.hoisted(() => ({ @@ -30,7 +30,7 @@ const { mockTranslatePredicate: vi.fn(), mockGetTableById: vi.fn(), mockReadDispatch: vi.fn(), - mockListActiveDispatches: vi.fn(), + mockListDispatches: vi.fn(), mockCancelDispatchById: vi.fn(), mockResolveWorkspaceContext: vi.fn(), })) @@ -60,7 +60,7 @@ vi.mock('@/lib/table/application/context', () => ({ vi.mock('@/lib/table/dispatcher', () => ({ cancelDispatchById: mockCancelDispatchById, - listActiveDispatches: mockListActiveDispatches, + listDispatches: mockListDispatches, readDispatch: mockReadDispatch, })) @@ -369,7 +369,7 @@ describe('table run dispatch reads', () => { }) mockGetTableById.mockResolvedValue(TABLE) mockReadDispatch.mockResolvedValue(DISPATCH) - mockListActiveDispatches.mockResolvedValue([DISPATCH]) + mockListDispatches.mockResolvedValue([DISPATCH]) }) it.each(['pending', 'dispatching', 'complete', 'cancelled'] as const)( @@ -479,13 +479,27 @@ describe('table run dispatch reads', () => { expect(mockCancelDispatchById).not.toHaveBeenCalled() }) - it('lists the active dispatches for the canonical table', async () => { + /** + * The list read every dispatch through the active-only query the dispatcher and the + * editor overlay use, so a run that had just completed was missing from the list while + * `GET .../dispatches/{id}` still reported it — a poll right after a create saw `[]`. + */ + it('lists settled dispatches alongside the in-flight ones for the canonical table', async () => { + const completed = { + ...DISPATCH, + id: 'dispatch-2', + status: 'complete' as const, + processedCount: 13, + completedAt: new Date('2026-01-01T00:05:00Z'), + } + mockListDispatches.mockResolvedValueOnce([completed, DISPATCH]) + const result = await listTableDispatches.execute({ principal: PRINCIPAL, input: { tableId: TABLE.id, assertedWorkspaceId: TABLE.workspaceId }, }) - expect(mockListActiveDispatches).toHaveBeenCalledWith(TABLE.id) - expect(result.dispatches).toEqual([DISPATCH]) + expect(mockListDispatches).toHaveBeenCalledWith(TABLE.id) + expect(result.dispatches).toEqual([completed, DISPATCH]) }) }) diff --git a/apps/sim/lib/table/application/runs.ts b/apps/sim/lib/table/application/runs.ts index 6480304b46c..1d0db508300 100644 --- a/apps/sim/lib/table/application/runs.ts +++ b/apps/sim/lib/table/application/runs.ts @@ -25,7 +25,7 @@ import { type DispatchLimit, type DispatchMode, type DispatchRow, - listActiveDispatches, + listDispatches, readDispatch, } from '@/lib/table/dispatcher' import { signalTableRowsChanged } from '@/lib/table/events' @@ -319,14 +319,16 @@ export interface ListTableDispatchesResult extends TableRunResult { } /** - * The dispatches still in flight on one table. Bounded by the dispatcher rather - * than by a page size, which is why the surface publishes it unpaged. + * The dispatches on one table, most recent first — in flight and settled alike, + * so a run that just completed is still listed next to the id its create + * returned. Capped at `MAX_LISTED_DISPATCHES`, which is why the surface + * publishes it unpaged. */ export const listTableDispatches = defineAuthorizedTableUseCase({ operation: tableOperations.readRun, resolveContext: ({ input }: { input: ListTableDispatchesInput }) => resolveActiveTableContext(input), async execute({ context }): Promise { - return { table: context.table, dispatches: await listActiveDispatches(context.tableId) } + return { table: context.table, dispatches: await listDispatches(context.tableId) } }, }) diff --git a/apps/sim/lib/table/dispatcher.test.ts b/apps/sim/lib/table/dispatcher.test.ts index 65face20001..324c674f8af 100644 --- a/apps/sim/lib/table/dispatcher.test.ts +++ b/apps/sim/lib/table/dispatcher.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { dbChainMockFns, hasMockCondition, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -48,7 +48,7 @@ vi.mock('@/lib/table/workflow-columns', () => ({ }), })) -import { dispatcherStep } from '@/lib/table/dispatcher' +import { dispatcherStep, listDispatches, MAX_LISTED_DISPATCHES } from '@/lib/table/dispatcher' const DISPATCH = { id: 'tdsp_1', @@ -166,3 +166,37 @@ describe('dispatcherStep processedCount', () => { expect(processedCountDelta()).toBeNull() }) }) + +/** + * The public list is the table's recent history, not the dispatcher's in-flight + * set: a run that just completed must still be listed, most recent first. + */ +describe('listDispatches', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns settled dispatches too, bounded to the most recent', async () => { + const completed = { + ...DISPATCH, + id: 'tdsp_2', + status: 'complete', + processedCount: 13, + completedAt: new Date('2026-08-21T15:05:00.000Z'), + } + dbChainMockFns.limit.mockResolvedValueOnce([completed, DISPATCH]) + + const dispatches = await listDispatches('table-1') + + expect(dispatches.map((dispatch) => [dispatch.id, dispatch.status])).toEqual([ + ['tdsp_2', 'complete'], + ['tdsp_1', 'dispatching'], + ]) + expect( + hasMockCondition(dbChainMockFns.where.mock.calls[0][0], (node) => node.type === 'inArray') + ).toBe(false) + expect(dbChainMockFns.orderBy).toHaveBeenCalled() + expect(dbChainMockFns.limit).toHaveBeenCalledWith(MAX_LISTED_DISPATCHES) + }) +}) diff --git a/apps/sim/lib/table/dispatcher.ts b/apps/sim/lib/table/dispatcher.ts index 61f3477c8e5..8e87416ceda 100644 --- a/apps/sim/lib/table/dispatcher.ts +++ b/apps/sim/lib/table/dispatcher.ts @@ -6,6 +6,7 @@ import { generateId } from '@sim/utils/id' import { and, asc, + desc, eq, gt, inArray, @@ -334,22 +335,8 @@ export async function countRunningCells( return { byRowId, hasRunning } } -/** Read every dispatch on a table whose status is still `pending` or - * `dispatching`. Drives the client-side "about to run" overlay: rows in an - * active dispatch's scope ahead of its cursor are rendered as queued even - * before the dispatcher has reached them, so refresh during a long Run-all - * doesn't lose the queued indicators. */ -export async function listActiveDispatches(tableId: string): Promise { - const rows = await db - .select() - .from(tableRunDispatches) - .where( - and( - eq(tableRunDispatches.tableId, tableId), - inArray(tableRunDispatches.status, [...ACTIVE_DISPATCH_STATUSES]) - ) - ) - return rows.map((row) => ({ +function toDispatchRow(row: typeof tableRunDispatches.$inferSelect): DispatchRow { + return { id: row.id, tableId: row.tableId, workspaceId: row.workspaceId, @@ -366,7 +353,50 @@ export async function listActiveDispatches(tableId: string): Promise { + const rows = await db + .select() + .from(tableRunDispatches) + .where( + and( + eq(tableRunDispatches.tableId, tableId), + inArray(tableRunDispatches.status, [...ACTIVE_DISPATCH_STATUSES]) + ) + ) + return rows.map(toDispatchRow) +} + +/** + * How many dispatches {@link listDispatches} returns at most. Settled dispatches accumulate + * for the life of the table, so the public list needs a ceiling where the active-only read + * had the dispatcher's own bound; a table's recent history is what a caller polls for. + */ +export const MAX_LISTED_DISPATCHES = 100 + +/** + * Every dispatch on a table, settled ones included, most recent first. + * + * The public `GET /tables/{tableId}/dispatches` returned only {@link listActiveDispatches}, + * so a run that had just finished vanished from the list while `GET .../dispatches/{id}` + * still reported it `complete` — a caller polling the list right after a create saw `[]` + * and could not tell a completed run from one that never started. + */ +export async function listDispatches(tableId: string): Promise { + const rows = await db + .select() + .from(tableRunDispatches) + .where(eq(tableRunDispatches.tableId, tableId)) + .orderBy(desc(tableRunDispatches.requestedAt), desc(tableRunDispatches.id)) + .limit(MAX_LISTED_DISPATCHES) + return rows.map(toDispatchRow) } export async function readDispatch(dispatchId: string): Promise { @@ -375,25 +405,7 @@ export async function readDispatch(dispatchId: string): Promise ({ mockWithLockedTable: vi.fn(), @@ -172,3 +172,131 @@ describe('workflow group TTL availability', () => { expect(mockWithLockedTable).not.toHaveBeenCalled() }) }) + +/** + * A group may take over columns the table already has. Before this, any + * `outputColumns` name matching an existing column was refused as a duplicate, + * so the only way to bind a group to existing data was to recreate the columns. + */ +describe('addWorkflowGroup attaching existing columns', () => { + const table = { + ...tableWithGroups(0), + schema: { + columns: [ + { id: 'col_name', name: 'name', type: 'string' }, + { id: 'col_tier', name: 'tier', type: 'string' }, + ], + workflowGroups: [], + }, + metadata: { columnOrder: ['col_name', 'col_tier'] }, + } as unknown as TableDefinition + + /** Captures the schema the service writes, so the assertions read what would be persisted. */ + function arrangeWrite(current: TableDefinition = table) { + const set = vi.fn(() => ({ where: () => Promise.resolve() })) + mockWithLockedTable.mockImplementation( + async (_tableId: string, mutate: (t: TableDefinition, trx: unknown) => Promise) => + mutate(current, { update: () => ({ set }), execute: () => Promise.resolve() }) + ) + return () => set.mock.calls[0][0] as { schema: TableSchema; metadata: TableMetadata | null } + } + + function add( + outputs: WorkflowGroup['outputs'], + outputColumns: Array<{ name: string; type: string }> + ) { + return addWorkflowGroup( + { + tableId: 'table-1', + workspaceId: 'workspace-1', + group: { id: 'group-new', workflowId: 'workflow-1', outputs } as WorkflowGroup, + outputColumns: outputColumns.map((column) => ({ + ...column, + workflowGroupId: 'group-new', + })), + autoRun: false, + actorUserId: 'user-1', + } as Parameters[0], + 'request-1' + ) + } + + beforeEach(() => { + vi.clearAllMocks() + mockAssertTableRowTtlEnabled.mockResolvedValue(undefined) + }) + + it('attaches an existing column named in outputColumns instead of creating a duplicate', async () => { + const written = arrangeWrite() + + await add( + [{ blockId: 'block-1', path: 'tier', columnName: 'tier' }], + [{ name: 'tier', type: 'string' }] + ) + + const { schema, metadata } = written() + expect(schema.columns).toEqual([ + { id: 'col_name', name: 'name', type: 'string' }, + { id: 'col_tier', name: 'tier', type: 'string', workflowGroupId: 'group-new' }, + ]) + expect(schema.workflowGroups?.[0].outputs).toEqual([ + { blockId: 'block-1', path: 'tier', columnName: 'col_tier' }, + ]) + expect(metadata?.columnOrder).toEqual(['col_name', 'col_tier']) + }) + + it('attaches an existing column an output names with no outputColumns entry, and still creates the missing ones', async () => { + const written = arrangeWrite() + + await add( + [ + { blockId: 'block-1', path: 'tier', columnName: 'Tier' }, + { blockId: 'block-1', path: 'score', columnName: 'score' }, + ], + [{ name: 'score', type: 'number' }] + ) + + const { schema, metadata } = written() + expect(schema.columns).toHaveLength(3) + expect(schema.columns[1]).toEqual({ + id: 'col_tier', + name: 'tier', + type: 'string', + workflowGroupId: 'group-new', + }) + const created = schema.columns[2] + expect(created).toMatchObject({ name: 'score', type: 'number', workflowGroupId: 'group-new' }) + expect(schema.workflowGroups?.[0].outputs.map((output) => output.columnName)).toEqual([ + 'col_tier', + created.id, + ]) + expect(metadata?.columnOrder).toEqual(['col_name', 'col_tier', created.id]) + }) + + it('refuses to attach a column another group already owns', async () => { + arrangeWrite({ + ...table, + schema: { + columns: [{ id: 'col_tier', name: 'tier', type: 'string', workflowGroupId: 'group-old' }], + workflowGroups: [ + { id: 'group-old', workflowId: 'workflow-0', outputs: [] } as WorkflowGroup, + ], + }, + } as TableDefinition) + + await expect( + add([{ blockId: 'block-1', path: 'tier', columnName: 'tier' }], []) + ).rejects.toThrow('already belongs to workflow group "group-old"') + }) + + it('refuses an outputColumns entry whose type disagrees with the existing column', async () => { + arrangeWrite() + + await expect( + add( + [{ blockId: 'block-1', path: 'tier', columnName: 'tier' }], + [{ name: 'tier', type: 'number' }] + ) + ).rejects.toThrow('already exists with type "string"') + }) +}) diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index 0796cb81b91..d7e8f903055 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -157,7 +157,43 @@ export async function addWorkflowGroup( ) } - const existingNames = new Set(schema.columns.map((c) => c.name.toLowerCase())) + /** + * An output column the table already has is attached to the group rather + * than created again — the group takes the column over, so the column + * must be free (no owning group) and eligible (a workflow output can be + * neither required nor unique, per the schema invariants). Keyed by + * column id, valued by the ref the caller wrote, so the id remap below + * resolves that ref however it was cased. + */ + const existingByName = new Map(schema.columns.map((c) => [c.name.toLowerCase(), c])) + const attached = new Map() + const attach = ( + existing: ColumnDefinition, + ref: string, + requestedType?: ColumnDefinition['type'] + ) => { + if (existing.workflowGroupId) { + throw new OrchestrationError( + 'validation', + `Column "${existing.name}" already belongs to workflow group "${existing.workflowGroupId}"` + ) + } + if (existing.required || existing.unique) { + throw new OrchestrationError( + 'validation', + `Column "${existing.name}" cannot become a workflow output because it is ${existing.required ? 'required' : 'unique'}` + ) + } + if (requestedType !== undefined && requestedType !== existing.type) { + throw new OrchestrationError( + 'validation', + `Column "${existing.name}" already exists with type "${existing.type}"; omit it from outputColumns or match its type` + ) + } + attached.set(getColumnId(existing), ref) + } + + const newColumns: ColumnDefinition[] = [] for (const col of data.outputColumns) { if (!NAME_PATTERN.test(col.name)) { throw new OrchestrationError( @@ -165,26 +201,42 @@ export async function addWorkflowGroup( `Invalid output column name "${col.name}". Must satisfy ${NAME_PATTERN.source}.` ) } - if (existingNames.has(col.name.toLowerCase())) { - throw new OrchestrationError('validation', `Column "${col.name}" already exists`) + const existing = existingByName.get(col.name.toLowerCase()) + if (existing) { + attach(existing, col.name, col.type) + continue } + // Assign stable ids to the new output columns so outputs/deps/inputMappings + // key on ids — matching the row-data storage key and surviving future renames. + newColumns.push(col.id ? col : { ...col, id: generateColumnId() }) } - if (schema.columns.length + data.outputColumns.length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { + // An output may name an existing column with no `outputColumns` entry at all. + const newNames = new Set(newColumns.map((c) => c.name.toLowerCase())) + for (const output of data.group.outputs) { + if (newNames.has(output.columnName.toLowerCase())) continue + const existing = schema.columns.find((c) => columnMatchesRef(c, output.columnName)) + if (existing && !attached.has(getColumnId(existing))) { + attach(existing, output.columnName) + } + } + + if (schema.columns.length + newColumns.length > TABLE_LIMITS.MAX_COLUMNS_PER_TABLE) { throw new OrchestrationError( 'validation', - `Adding ${data.outputColumns.length} columns would exceed the maximum (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE}).` + `Adding ${newColumns.length} columns would exceed the maximum (${TABLE_LIMITS.MAX_COLUMNS_PER_TABLE}).` ) } - // Assign stable ids to the new output columns, then rewrite the group's - // column refs from name → id so outputs/deps/inputMappings key on ids — - // matching the row-data storage key and surviving future renames. - const outputColumns = data.outputColumns.map((col) => - col.id ? col : { ...col, id: generateColumnId() } - ) - const updatedColumns = [...schema.columns, ...outputColumns] + const updatedColumns = [ + ...schema.columns.map((c) => + attached.has(getColumnId(c)) ? { ...c, workflowGroupId: data.group.id } : c + ), + ...newColumns, + ] + // Rewrite the group's column refs from name → id. const idByName = new Map(updatedColumns.map((c) => [c.name, getColumnId(c)])) + for (const [columnId, ref] of attached) idByName.set(ref, columnId) const group = remapGroupColumnRefs(data.group, idByName) const updatedSchema: TableSchema = { @@ -199,7 +251,7 @@ export async function addWorkflowGroup( let updatedMetadata = table.metadata if (existingOrder && existingOrder.length > 0) { const known = new Set(existingOrder) - const append = outputColumns.map(getColumnId).filter((id) => !known.has(id)) + const append = newColumns.map(getColumnId).filter((id) => !known.has(id)) if (append.length > 0) { updatedMetadata = { ...table.metadata, columnOrder: [...existingOrder, ...append] } } @@ -219,7 +271,7 @@ export async function addWorkflowGroup( ) logger.info( - `[${requestId}] Added workflow group "${data.group.id}" with ${data.outputColumns.length} output column(s) to table ${data.tableId}` + `[${requestId}] Added workflow group "${data.group.id}" with ${newColumns.length} new and ${attached.size} attached output column(s) to table ${data.tableId}` ) return { diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts index f28b789fa9a..800096208ea 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({ collectToolReferences: vi.fn(), assertIdsUnclaimed: vi.fn(), collectGraphIds: vi.fn(), + lintGraph: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -73,14 +74,7 @@ vi.mock('@/lib/workflows/editing/validation', () => ({ vi.mock('@/lib/workflows/editing/lint', () => ({ collectWorkflowFieldIssues: () => [], collectDanglingBlockOutputReferences: () => [], - lintEditedWorkflowState: () => ({ - sources: [], - sinks: [], - orphanBlocks: [], - emptyOutgoingPorts: [], - invalidBranchPorts: [], - invalidConnectionTargets: [], - }), + lintEditedWorkflowState: mocks.lintGraph, })) vi.mock('@/lib/billing/core/subscription', () => ({ hasWorkspaceSandboxAccess: mocks.sandboxAccess, @@ -169,6 +163,16 @@ function graph(blocks: Record = { 'block-1': BLOCK }) { const GRAPH_IDS = { blockIds: ['block-1'], edgeIds: [], subflowIds: [] } +/** The graph lint with no findings, in the shape `lintEditedWorkflowState` returns. */ +const EMPTY_GRAPH_LINT = { + sources: [], + sinks: [], + orphanBlocks: [], + emptyOutgoingPorts: [], + invalidBranchPorts: [], + invalidConnectionTargets: [], +} + describe('applyWorkflowOperations', () => { beforeEach(() => { vi.clearAllMocks() @@ -193,6 +197,7 @@ describe('applyWorkflowOperations', () => { mocks.needsRedeployment.mockResolvedValue(true) mocks.collectGraphIds.mockReturnValue(GRAPH_IDS) mocks.assertIdsUnclaimed.mockResolvedValue(undefined) + mocks.lintGraph.mockReturnValue(EMPTY_GRAPH_LINT) }) it('writes once, through the shared persistence primitive', async () => { @@ -239,6 +244,40 @@ describe('applyWorkflowOperations', () => { expect(result.graph.blocks).toEqual(graphWithAddedBlock.blocks) }) + /** + * A delete is exactly when orphans and dangling references appear, so the + * report must be built from the post-delete graph even though the batch adds + * and edits nothing — never skipped or answered as `null`. + */ + it('lints the post-delete graph for a delete-only batch', async () => { + const deleteOnly = [{ operation_type: 'delete' as const, block_id: 'block-2' }] + const orphan = { blockId: 'block-1', blockName: 'Start', blockType: 'starter' } + mocks.preValidate.mockResolvedValue({ filteredOperations: deleteOnly, errors: [] }) + mocks.applyOperations.mockReturnValue({ + state: graph({ 'block-1': BLOCK }), + validationErrors: [], + skippedItems: [], + mintedBlockIds: {}, + }) + mocks.lintGraph.mockReturnValue({ ...EMPTY_GRAPH_LINT, orphanBlocks: [orphan] }) + + const result = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations: deleteOnly }, + }) + + expect(result.applied).toBe(1) + expect(mocks.lintGraph).toHaveBeenCalledTimes(1) + expect(mocks.lintGraph.mock.calls[0][0].blocks).not.toHaveProperty('block-2') + expect(result.lint).toEqual({ + ...EMPTY_GRAPH_LINT, + orphanBlocks: [orphan], + fieldIssues: [], + unresolvedReferences: [], + notes: [], + }) + }) + describe('dry run', () => { it('runs the whole engine and stops at the write', async () => { const result = await applyWorkflowOperations.execute({ diff --git a/apps/sim/lib/workflows/application/import-export.test.ts b/apps/sim/lib/workflows/application/import-export.test.ts index beaa06b2193..e5d498efbdc 100644 --- a/apps/sim/lib/workflows/application/import-export.test.ts +++ b/apps/sim/lib/workflows/application/import-export.test.ts @@ -86,6 +86,11 @@ const imported = { folderId: 'folder-1', createdAt: new Date('2026-01-01T00:00:00Z'), updatedAt: new Date('2026-01-01T00:00:00Z'), + blocks: [ + { id: 'block-1', type: 'starter', name: 'Start' }, + { id: 'block-2', type: 'agent', name: 'Classify' }, + { id: 'block-3', type: 'response', name: 'Reply' }, + ], } const exportPayload = { version: '1.0' as const, @@ -155,6 +160,7 @@ describe('workflow import and export application operations', () => { metadata: expect.objectContaining({ operation: 'workflows.import', actor: { kind: 'workspace_api_key', keyId: 'key-1', workspaceId: 'ws-1' }, + blocksCount: 3, }), }) ) diff --git a/apps/sim/lib/workflows/application/import-export.ts b/apps/sim/lib/workflows/application/import-export.ts index 8d876afe525..04c3e33648d 100644 --- a/apps/sim/lib/workflows/application/import-export.ts +++ b/apps/sim/lib/workflows/application/import-export.ts @@ -115,6 +115,7 @@ export const importWorkflow = defineAuthorizedWorkflowUseCase({ workspaceId: result.workflow.workspaceId, folderId: result.workflow.folderId || undefined, sortOrder: result.workflow.sortOrder, + blocksCount: result.workflow.blocks.length, }, } }, diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts index d8976c7264e..adb247e8375 100644 --- a/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts @@ -64,11 +64,13 @@ vi.mock('@sim/workflow-persistence/subblocks', () => ({ vi.mock('@sim/utils/id', () => ({ generateId: vi.fn(() => 'child-execution-1') })) vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: vi.fn(() => 'request-1') })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { runFromBlockFromCopilot, runWorkflowFromCopilot, } from '@/lib/workflows/application/run-workflow-from-copilot' import { readAttemptedExecutionId } from '@/executor/utils/errors' +import { RunFromBlockValidationError } from '@/executor/utils/run-from-block' const principal = { kind: 'delegated' as const, @@ -290,6 +292,40 @@ describe('Copilot workflow run application commands', () => { ) }) + it('surfaces a refused run-from-block start as a validation failure the caller can act on', async () => { + mocks.sourceState.mockResolvedValueOnce({ + blockStates: {}, + executedBlocks: [], + blockLogs: [], + decisions: {}, + completedLoops: [], + activeExecutionPath: [], + }) + mocks.executeWorkflow.mockRejectedValueOnce( + new RunFromBlockValidationError('Upstream dependency not executed: fetch-1') + ) + + const error = await runFromBlockFromCopilot + .execute({ + principal, + input: { + workflowId: 'workflow-1', + useDraftState: true, + lifecycle, + blockId: 'agent-1', + sourceExecutionId: 'source-execution-1', + }, + }) + .catch((thrown) => thrown) + + expect(error).toBeInstanceOf(OrchestrationError) + expect(error).toMatchObject({ + code: 'validation', + message: 'Upstream dependency not executed: fetch-1', + }) + expect(readAttemptedExecutionId(error)).toBe('child-execution-1') + }) + it('propagates unexpected execution infrastructure failures', async () => { mocks.executeWorkflow.mockRejectedValueOnce(new Error('database unavailable')) diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts index 7a07f3d3426..49eb9b020c3 100644 --- a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts @@ -30,7 +30,10 @@ import { import type { SerializableExecutionState } from '@/executor/execution/types' import type { ExecutionResult } from '@/executor/types' import { attachAttemptedExecutionId, hasExecutionResult } from '@/executor/utils/errors' -import { emptyRunFromBlockSnapshot } from '@/executor/utils/run-from-block' +import { + emptyRunFromBlockSnapshot, + RunFromBlockValidationError, +} from '@/executor/utils/run-from-block' const logger = createLogger('CopilotWorkflowRun') @@ -335,7 +338,17 @@ async function executeCopilotRun(params: { ) } return result - } catch (error) { + } catch (caught) { + /** + * A refused run-from-block start — block missing, inside a loop, or an upstream block the + * snapshot never executed — is the caller's to fix, so it crosses as a classified validation + * failure. Left bare, the Copilot projection reduced it to "Workflow execution failed" and + * the agent had to recover the reason from the trace. + */ + const error = + caught instanceof RunFromBlockValidationError + ? new OrchestrationError('validation', caught.message) + : caught /** * `executeWorkflow` names the run itself once it crosses its own dispatch boundary, so * preflight failures inside it correctly carry nothing. This covers only the window it diff --git a/apps/sim/lib/workflows/editing/builders.test.ts b/apps/sim/lib/workflows/editing/builders.test.ts index 6099f6b8853..96d00031d59 100644 --- a/apps/sim/lib/workflows/editing/builders.test.ts +++ b/apps/sim/lib/workflows/editing/builders.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import { generateId } from '@sim/utils/id' import { describe, expect, it, vi } from 'vitest' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { @@ -77,12 +78,53 @@ const apiBlockConfig = { ], } +/** Mirrors generic_webhook: the token is pre-filled by a thunk, the URL is display-only. */ +const webhookBlockConfig = { + type: 'generic_webhook', + name: 'Webhook Trigger', + category: 'triggers', + outputs: {}, + subBlocks: [ + { id: 'webhookUrlDisplay', type: 'short-input', readOnly: true, value: () => 'display-only' }, + { id: 'requireAuth', type: 'switch', defaultValue: true }, + { id: 'token', type: 'short-input', password: true, value: () => generateId() }, + { id: 'signingSecret', type: 'short-input', hidden: true, value: () => 'run-time-only' }, + ], +} + +/** An operation dropdown pre-filled by a thunk, so the seed meets the permission gate. */ +const gatedOperationBlockConfig = { + type: 'gated', + name: 'Gated', + outputs: {}, + subBlocks: [ + { + id: 'operation', + type: 'dropdown', + options: [ + { label: 'Canvas', id: 'canvas' }, + { label: 'Send', id: 'send' }, + ], + value: () => 'canvas', + }, + ], + tools: { + access: ['gated_canvas', 'gated_send'], + config: { + tool: ({ operation }: { operation?: string }) => + operation === 'canvas' ? 'gated_canvas' : 'gated_send', + }, + }, +} + const blocksByType: Record = { api: apiBlockConfig, agent: agentBlockConfig, condition: conditionBlockConfig, knowledge: knowledgeBlockConfig, slack: slackBlockConfig, + generic_webhook: webhookBlockConfig, + gated: gatedOperationBlockConfig, } vi.mock('@/blocks/registry', () => ({ @@ -92,6 +134,8 @@ vi.mock('@/blocks/registry', () => ({ conditionBlockConfig, knowledgeBlockConfig, slackBlockConfig, + webhookBlockConfig, + gatedOperationBlockConfig, ], getBlock: (type: string) => blocksByType[type], })) @@ -198,6 +242,67 @@ describe('createBlockFromParams', () => { expect(block.subBlocks.redirectPolicyVersion.value).toBe('standard-v1') expect(block.subBlocks.sendCredentialsOnCrossOriginRedirect.value).toBeNull() }) + + /** + * The editor pre-fills a webhook trigger's token from its `value()` thunk; + * without the same seeding here a trigger added over the API deployed with + * `token: null` and was refused as "authentication enabled but no token". + */ + it('seeds editor defaults from value() thunks so an API-added webhook trigger has a token', () => { + const block = createBlockFromParams('hook-1', { + type: 'generic_webhook', + name: 'Webhook', + triggerMode: true, + }) + + expect(block.subBlocks.token.value).toEqual(expect.any(String)) + expect(block.subBlocks.token.value).not.toBe('') + expect(block.subBlocks.webhookUrlDisplay.value).toBeNull() + expect(block.subBlocks.signingSecret.value).toBeNull() + expect(block.subBlocks.requireAuth.value).toBeNull() + }) + + it('never overrides an explicit input with a seeded default', () => { + const explicit = createBlockFromParams('hook-1', { + type: 'generic_webhook', + name: 'Webhook', + inputs: { token: 'my-token' }, + }) + const cleared = createBlockFromParams('hook-2', { + type: 'generic_webhook', + name: 'Webhook', + inputs: { token: null }, + }) + + expect(explicit.subBlocks.token.value).toBe('my-token') + expect(cleared.subBlocks.token.value).toBeNull() + }) + + it('withholds a seeded operation the permission group denies without recording a skip', () => { + const skippedItems: SkippedItem[] = [] + const denyCanvas = { ...DEFAULT_PERMISSION_GROUP_CONFIG, deniedTools: ['gated_canvas'] } + + const denied = createBlockFromParams( + 'g-1', + { type: 'gated', name: 'Gated' }, + undefined, + undefined, + denyCanvas, + skippedItems + ) + const allowed = createBlockFromParams( + 'g-2', + { type: 'gated', name: 'Gated' }, + undefined, + undefined, + DEFAULT_PERMISSION_GROUP_CONFIG, + skippedItems + ) + + expect(denied.subBlocks.operation.value).toBeNull() + expect(allowed.subBlocks.operation.value).toBe('canvas') + expect(skippedItems).toEqual([]) + }) }) describe('filterDisallowedTools', () => { diff --git a/apps/sim/lib/workflows/editing/builders.ts b/apps/sim/lib/workflows/editing/builders.ts index 4e9b7078705..e0f221f85f0 100644 --- a/apps/sim/lib/workflows/editing/builders.ts +++ b/apps/sim/lib/workflows/editing/builders.ts @@ -16,6 +16,7 @@ import { isOperationAllowed, MODEL_SUBBLOCK_ID, OPERATION_SUBBLOCK_ID, + type SeedValueGate, } from '@/lib/permission-groups/operation-access' import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { isRetryEligibleBlock } from '@/lib/workflows/blocks/retry-eligibility' @@ -27,7 +28,7 @@ import { import { applyAgentToolUsageControlModes } from '@/lib/workflows/tool-input/usage-control' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { getBlock } from '@/blocks/registry' -import type { BlockConfig } from '@/blocks/types' +import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import { overlayVisibility } from '@/blocks/visibility/context' import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' import type { EditWorkflowOperation, SkippedItem, ValidationError } from './types' @@ -105,6 +106,49 @@ export function applyBlockRetry( block.retry = resolveBlockRetryUpdate(requested as Partial, block.retry) } +/** + * The value an unset sub-block starts with on the API path. + * + * Mirrors the editor's `prepareBlockState`: a sub-block whose config declares a + * `value()` thunk is pre-filled from it, so a block added through + * `operations apply` carries the same defaults as one dropped on the canvas. + * Without this a `generic_webhook` arrives with `token: null` and deploy + * refuses it as "authentication enabled but no token", although the editor + * would have generated one. The thunk sees the values the caller did write, so + * a default that depends on a sibling field resolves against the real block. + * + * Hidden and read-only sub-blocks are left alone: a hidden thunk is computed + * by the serializer at run time from the block's other fields, and a read-only + * field is display-only. Hidden `defaultValue`s keep their compatibility + * seeding. A seeded value the caller's permission group denies is withheld + * silently, as the editor withholds it — it is a default nobody asked for, not + * a refused operation, so it records no skip. + */ +function resolveSeededSubBlockValue( + subBlock: SubBlockConfig, + writtenValues: Record, + isSeededValueAllowed: SeedValueGate +): unknown { + if (typeof subBlock.value === 'function' && !subBlock.hidden && !subBlock.readOnly) { + try { + const seeded: unknown = subBlock.value(writtenValues) + if ( + seeded !== undefined && + seeded !== null && + (typeof seeded !== 'string' || isSeededValueAllowed(subBlock.id, seeded)) + ) { + return seeded + } + } catch { + /* An unresolvable thunk seeds nothing, same as the editor. */ + } + } + if (subBlock.hidden && subBlock.defaultValue !== undefined) { + return structuredClone(subBlock.defaultValue) + } + return null +} + /** * Helper to create a block state from operation params */ @@ -229,15 +273,19 @@ export function createBlockFromParams( // Set up subBlocks from block configuration if (blockConfig) { + const isSeededValueAllowed = createSeededValueGate(params.type, permissionConfig) + const writtenValues: Record = Object.fromEntries( + Object.entries(blockState.subBlocks).map(([key, subBlock]: [string, any]) => [ + key, + subBlock.value, + ]) + ) blockConfig.subBlocks.forEach((subBlock) => { if (!blockState.subBlocks[subBlock.id]) { blockState.subBlocks[subBlock.id] = { id: subBlock.id, type: subBlock.type, - value: - subBlock.hidden && subBlock.defaultValue !== undefined - ? structuredClone(subBlock.defaultValue) - : null, + value: resolveSeededSubBlockValue(subBlock, writtenValues, isSeededValueAllowed), } } else { blockState.subBlocks[subBlock.id].type = subBlock.type @@ -668,29 +716,67 @@ export function createValidatedEdge( return true } +/** A connection target as `{ block, handle? }`, or `null` when the value is not one. */ +function asConnectionTarget(value: unknown): { block: string; handle?: string } | null { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return null + const record = value as Record + if (typeof record.block !== 'string' || record.block === '') return null + return { + block: record.block, + handle: typeof record.handle === 'string' ? record.handle : undefined, + } +} + +const CONNECTION_TARGET_SHAPES = 'a target block id, {block, handle?}, or an array of those' +const CONNECTION_ARRAY_ENTRY_SHAPES = 'a target block id or {block, handle?}' + /** * Adds connections as edges for a block. * Supports multiple target formats: * - String: "target-block-id" * - Object: { block: "target-block-id", handle?: "custom-target-handle" } * - Array of strings or objects + * + * A value of any other shape — `{ incoming: [...] }`, `{ source: { target } }` + * — is recorded as an input validation error naming the handle and the + * accepted shapes. It used to be ignored, so an operation whose wiring never + * happened still answered `applied: 1` with nothing in `skipped`. A handle the + * block type does not have is already reported by {@link createValidatedEdge} + * as an `invalid_source_handle` skip. */ export function addConnectionsAsEdges( modifiedState: any, blockId: string, connections: Record, logger: ReturnType, - skippedItems?: SkippedItem[] + skippedItems?: SkippedItem[], + validationErrors?: ValidationError[] ): void { const normalizeHandle = (handle: string): string => { if (handle === 'success') return 'source' return handle } + const rejectShape = (path: string, value: unknown, expected: string) => { + const error = `connections${path}: expected ${expected}` + logger.warn('Connection target has an unsupported shape. Connection dropped.', { + blockId, + error, + }) + validationErrors?.push({ + blockId, + blockType: modifiedState.blocks?.[blockId]?.type ?? 'unknown', + field: 'connections', + value, + error, + }) + } + Object.entries(connections).forEach(([rawHandle, targets]) => { - if (targets === null) return + if (targets === null || targets === undefined) return const sourceHandle = normalizeHandle(rawHandle) + const handlePath = `[${JSON.stringify(rawHandle)}]` const addEdgeForTarget = (targetBlock: string, targetHandle?: string) => { createValidatedEdge( @@ -707,17 +793,31 @@ export function addConnectionsAsEdges( if (typeof targets === 'string') { addEdgeForTarget(targets) - } else if (Array.isArray(targets)) { - targets.forEach((target: any) => { + return + } + + if (Array.isArray(targets)) { + targets.forEach((target: unknown, index: number) => { if (typeof target === 'string') { addEdgeForTarget(target) - } else if (target?.block) { - addEdgeForTarget(target.block, target.handle) + return + } + const parsed = asConnectionTarget(target) + if (parsed) { + addEdgeForTarget(parsed.block, parsed.handle) + return } + rejectShape(`${handlePath}[${index}]`, target, CONNECTION_ARRAY_ENTRY_SHAPES) }) - } else if (typeof targets === 'object' && targets?.block) { - addEdgeForTarget(targets.block, targets.handle) + return } + + const parsed = asConnectionTarget(targets) + if (parsed) { + addEdgeForTarget(parsed.block, parsed.handle) + return + } + rejectShape(handlePath, targets, CONNECTION_TARGET_SHAPES) }) } @@ -884,14 +984,13 @@ export function createSubBlockInputGate(context: SubBlockInputGateContext): SubB const { blockType, permissionConfig, blockId, operationType, skippedItems } = context if (!permissionConfig) return ALLOW_ALL_INPUTS - const isToolAllowed = createToolAccessGate(permissionConfig.deniedTools) - const isModelUsable = createModelAccessGate(permissionConfig) + const isValueAllowed = createSeededValueGate(blockType, permissionConfig) return (key: string, value: unknown) => { if (typeof value !== 'string') return true + if (isValueAllowed(key, value)) return true if (key === OPERATION_SUBBLOCK_ID) { - if (isOperationAllowed(getBlock(blockType), value, isToolAllowed)) return true logSkippedItem(skippedItems, { type: 'tool_not_allowed', operationType, @@ -899,11 +998,7 @@ export function createSubBlockInputGate(context: SubBlockInputGateContext): SubB reason: `Operation "${value}" on block type "${blockType}" is blocked by access control - operation not set`, details: { blockType, operation: value }, }) - return false - } - - if (key === MODEL_SUBBLOCK_ID) { - if (isModelUsable(value)) return true + } else { logSkippedItem(skippedItems, { type: 'model_not_allowed', operationType, @@ -911,9 +1006,38 @@ export function createSubBlockInputGate(context: SubBlockInputGateContext): SubB reason: `Model "${value}" is blocked by access control - model not set`, details: { blockType, model: value }, }) - return false } + return false + } +} + +/** Shared allow-everything seed gate, so the unrestricted case allocates nothing. */ +const ALLOW_ALL_SEEDS: SeedValueGate = () => true + +/** + * The silent form of the permission gate: whether a string may be written to + * `operation` or `model`, with nothing recorded when it may not. + * + * {@link createSubBlockInputGate} answers the same question for values the + * caller sent and reports a denial as a skip. A block's declared defaults go + * through this one instead — the editor's `isSeededValueAllowed` — because a + * default the group denies is simply not seeded; it is not an operation the + * caller asked for, so a skip would charge them for it. + */ +export function createSeededValueGate( + blockType: string, + permissionConfig: PermissionGroupConfig | null | undefined +): SeedValueGate { + if (!permissionConfig) return ALLOW_ALL_SEEDS + + const isToolAllowed = createToolAccessGate(permissionConfig.deniedTools) + const isModelUsable = createModelAccessGate(permissionConfig) + return (key: string, value: string) => { + if (key === OPERATION_SUBBLOCK_ID) { + return isOperationAllowed(getBlock(blockType), value, isToolAllowed) + } + if (key === MODEL_SUBBLOCK_ID) return isModelUsable(value) return true } } diff --git a/apps/sim/lib/workflows/editing/engine.ts b/apps/sim/lib/workflows/editing/engine.ts index 77b14809b35..894e1c26b5d 100644 --- a/apps/sim/lib/workflows/editing/engine.ts +++ b/apps/sim/lib/workflows/editing/engine.ts @@ -242,7 +242,14 @@ export function applyOperationsToWorkflowState( continue } - addConnectionsAsEdges(modifiedState, blockId, connections, logger, skippedItems) + addConnectionsAsEdges( + modifiedState, + blockId, + connections, + logger, + skippedItems, + validationErrors + ) } logger.info('Finished processing deferred connections', { diff --git a/apps/sim/lib/workflows/editing/operations.test.ts b/apps/sim/lib/workflows/editing/operations.test.ts index cffafc03b0c..0d56512ed19 100644 --- a/apps/sim/lib/workflows/editing/operations.test.ts +++ b/apps/sim/lib/workflows/editing/operations.test.ts @@ -1383,3 +1383,99 @@ describe('tool canonical-mode reindexing', () => { }) }) }) + +/** + * A `connections` value the parser does not understand used to be ignored: the + * block landed, `applied` counted it, and nothing said the wiring never + * happened. The handle and the accepted shapes are now named as a dropped input. + */ +describe('connection shape validation', () => { + const BLOCK_A = '44444444-4444-4444-8444-444444444444' + + function workflowWithStart() { + return { + blocks: { + 'start-1': { + id: 'start-1', + type: 'function', + name: 'Start', + position: { x: 0, y: 0 }, + enabled: true, + subBlocks: {}, + outputs: {}, + data: {}, + }, + }, + edges: [], + loops: {}, + parallels: {}, + } + } + + it('reports a connections value of an unsupported shape instead of dropping it silently', () => { + const { state, validationErrors, skippedItems } = applyOperationsToWorkflowState( + workflowWithStart(), + [ + { + operation_type: 'add', + block_id: BLOCK_A, + params: { + type: 'function', + name: 'Block A', + inputs: { code: 'return 1' }, + connections: { source: { target: 'start-1' }, error: [{ target: 'start-1' }] }, + }, + }, + ] + ) + + expect(state.blocks[BLOCK_A]).toBeDefined() + expect(state.edges).toEqual([]) + expect(skippedItems).toEqual([]) + expect(validationErrors).toEqual([ + { + blockId: BLOCK_A, + blockType: 'function', + field: 'connections', + value: { target: 'start-1' }, + error: + 'connections["source"]: expected a target block id, {block, handle?}, or an array of those', + }, + { + blockId: BLOCK_A, + blockType: 'function', + field: 'connections', + value: { target: 'start-1' }, + error: 'connections["error"][0]: expected a target block id or {block, handle?}', + }, + ]) + }) + + it('wires the accepted shapes and reports a handle the block lacks as a skip', () => { + const { state, validationErrors, skippedItems } = applyOperationsToWorkflowState( + workflowWithStart(), + [ + { + operation_type: 'add', + block_id: BLOCK_A, + params: { + type: 'function', + name: 'Block A', + inputs: { code: 'return 1' }, + connections: { success: 'start-1', error: [{ block: 'start-1' }], incoming: 'start-1' }, + }, + }, + ] + ) + + expect(validationErrors).toEqual([]) + expect(state.edges.map((edge: { sourceHandle?: string }) => edge.sourceHandle).sort()).toEqual(['error', 'source']) + expect(skippedItems).toEqual([ + expect.objectContaining({ + type: 'invalid_source_handle', + blockId: BLOCK_A, + details: expect.objectContaining({ sourceHandle: 'incoming' }), + }), + ]) + }) +}) diff --git a/apps/sim/lib/workflows/editing/validation.test.ts b/apps/sim/lib/workflows/editing/validation.test.ts index fb5fce3d56b..819219e41b7 100644 --- a/apps/sim/lib/workflows/editing/validation.test.ts +++ b/apps/sim/lib/workflows/editing/validation.test.ts @@ -447,6 +447,78 @@ describe('validateInputsForBlock', () => { expect(result.errors[0]?.error).toContain('expected a JSON array') }) + it.each([ + ['a JSON string', JSON.stringify([{ title: 'Billing', value: 'Invoices and payments' }])], + [ + 'a raw array with optional ids', + [ + { id: 'r-1', title: 'Billing', value: 'Invoices and payments' }, + { title: 'Other', value: 'Everything else' }, + ], + ], + ])('accepts router routes shaped {id?, title, value} given as %s', (_label, routes) => { + const result = validateInputsForBlock('router_v2', { routes }, 'router-1') + + expect(result.errors).toHaveLength(0) + expect(result.validInputs.routes).toEqual(routes) + }) + + /** + * The runtime shows the model each route's `value` as its description. A + * route stored as `{title, description}` reads as having no description, so + * every request falls through to route 1 — silently, unless the key is named. + */ + it('rejects a router route that carries its description under an unknown key', () => { + const result = validateInputsForBlock( + 'router_v2', + { + routes: [ + { id: 'r-1', title: 'Billing', value: 'Invoices and payments' }, + { id: 'r-2', title: 'Support', description: 'Help requests' }, + ], + }, + 'router-1' + ) + + expect(result.validInputs.routes).toBeUndefined() + expect(result.errors).toHaveLength(1) + expect(result.errors[0]).toMatchObject({ blockId: 'router-1', field: 'routes' }) + expect(result.errors[0]?.error).toBe( + 'Invalid route at index 1: missing "value", unknown key "description" — a route is {id?, title, value}; "value" holds the description the model reads' + ) + }) + + it.each([ + ['an empty value', [{ title: 'Billing', value: '' }], '"value" must be a non-empty string'], + ['a missing title', [{ value: 'Invoices' }], '"title" must be a non-empty string'], + ['a non-object entry', ['Billing'], 'expected an object'], + ])('rejects router routes with %s', (_label, routes, message) => { + const result = validateInputsForBlock('router_v2', { routes }, 'router-1') + + expect(result.validInputs.routes).toBeUndefined() + expect(result.errors).toHaveLength(1) + expect(result.errors[0]?.error).toContain('Invalid route at index 0') + expect(result.errors[0]?.error).toContain(message) + }) + + it('tolerates editor UI state beside a complete route so stored routes round-trip', () => { + const routes = [ + { id: 'r-1', title: 'Billing', value: 'Invoices', showTags: false, cursorPosition: 0 }, + ] + const result = validateInputsForBlock('router_v2', { routes }, 'router-1') + + expect(result.errors).toHaveLength(0) + expect(result.validInputs.routes).toEqual(routes) + }) + + it('rejects non-array router-input values', () => { + const result = validateInputsForBlock('router_v2', { routes: 'not-json' }, 'router-1') + + expect(result.validInputs.routes).toBeUndefined() + expect(result.errors).toHaveLength(1) + expect(result.errors[0]?.error).toContain('expected a JSON array') + }) + // Without this guard, normalizeArrayWithIds coerces any unparseable value to [], which the // write path then persists as "[]" -- silently destroying a tag filter the user configured. it.each([ diff --git a/apps/sim/lib/workflows/editing/validation.ts b/apps/sim/lib/workflows/editing/validation.ts index 8167c332aac..5117eaafac3 100644 --- a/apps/sim/lib/workflows/editing/validation.ts +++ b/apps/sim/lib/workflows/editing/validation.ts @@ -387,6 +387,59 @@ function validateAgentSkillEntry(item: any, index: number): string | null { return null } +/** Parses a JSON-string array input; any other value passes through to the array check. */ +function parseArrayInput(value: unknown): unknown { + if (typeof value !== 'string') return value + try { + return JSON.parse(value) + } catch { + return null + } +} + +const ROUTER_ROUTE_KEYS: ReadonlySet = new Set(['id', 'title', 'value']) +const ROUTER_ROUTE_SHAPE = + 'a route is {id?, title, value}; "value" holds the description the model reads' + +/** + * Validates one router route against the shape the runtime reads. + * + * `generateRouterV2Prompt` shows the model each route's `value` as its + * description, so a route that carries the description under another key + * (`description`, `prompt`) is stored intact yet presented as having no + * description at all, and every request falls through to the first route. + * The stray key is named so the caller can see what to rename; `id` is + * optional because the engine rewrites route ids on write. + * + * Unknown keys are only named when `value` is unusable: the editor persists + * its own UI state (`showTags`, cursor position) beside the three keys, so a + * stored route echoed back must not be refused for carrying it. + */ +function validateRouterRouteEntry(route: unknown, index: number): string | null { + const where = `Invalid route at index ${index}` + if (route === null || typeof route !== 'object' || Array.isArray(route)) { + return `${where}: expected an object — ${ROUTER_ROUTE_SHAPE}` + } + + const record = route as Record + const problems: string[] = [] + if (typeof record.title !== 'string' || record.title.trim() === '') { + problems.push('"title" must be a non-empty string') + } + if (typeof record.value !== 'string' || record.value.trim() === '') { + problems.push( + record.value === undefined ? 'missing "value"' : '"value" must be a non-empty string' + ) + const unknownKeys = Object.keys(record).filter((key) => !ROUTER_ROUTE_KEYS.has(key)) + if (unknownKeys.length > 0) { + const keys = unknownKeys.map((key) => `"${key}"`).join(', ') + problems.push(`unknown ${unknownKeys.length === 1 ? 'key' : 'keys'} ${keys}`) + } + } + if (problems.length === 0) return null + return `${where}: ${problems.join(', ')} — ${ROUTER_ROUTE_SHAPE}` +} + /** * Validates one fallback-model row. Returns an error string or null when valid. * @@ -572,21 +625,44 @@ export function validateValueForSubBlockType( return { valid: true, value } } + case 'router-input': { + const parsedValue = parseArrayInput(value) + if (!Array.isArray(parsedValue)) { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: `Invalid ${type} value for field "${fieldName}" - expected a JSON array`, + }, + } + } + + const routeErrors = parsedValue + .map((route: unknown, index: number) => validateRouterRouteEntry(route, index)) + .filter((err): err is string => err !== null) + if (routeErrors.length > 0) { + return { + valid: false, + error: { + blockId, + blockType, + field: fieldName, + value, + error: routeErrors.join('; '), + }, + } + } + + return { valid: true, value } + } + case 'condition-input': - case 'router-input': case 'knowledge-tag-filters': case 'document-tag-entry': { - const parsedValue = - typeof value === 'string' - ? (() => { - try { - return JSON.parse(value) - } catch { - return null - } - })() - : value - + const parsedValue = parseArrayInput(value) if (!Array.isArray(parsedValue)) { return { valid: false, diff --git a/apps/sim/lib/workflows/executor/execute-service.test.ts b/apps/sim/lib/workflows/executor/execute-service.test.ts index 02f2b1ded45..4f6b0c99ade 100644 --- a/apps/sim/lib/workflows/executor/execute-service.test.ts +++ b/apps/sim/lib/workflows/executor/execute-service.test.ts @@ -25,16 +25,16 @@ function log(blockId: string, output: Record): BlockLog { } describe('pickRunBlockOutputs', () => { - it('returns null when no selectors were requested', () => { - expect(pickRunBlockOutputs(undefined, blocks, [log(AGENT_ID, {})])).toBeNull() - expect(pickRunBlockOutputs([], blocks, [log(AGENT_ID, {})])).toBeNull() + it('returns null when no selectors were requested', async () => { + expect(await pickRunBlockOutputs(undefined, blocks, [log(AGENT_ID, {})])).toBeNull() + expect(await pickRunBlockOutputs([], blocks, [log(AGENT_ID, {})])).toBeNull() }) - it('resolves block names and ids, digging nested paths', () => { + it('resolves block names and ids, digging nested paths', async () => { const logs = [log(AGENT_ID, { content: 'hi', tokens: { total: 7 } })] expect( - pickRunBlockOutputs(['Agent 1.content', 'Agent 1.tokens.total', AGENT_ID], blocks, logs) + await pickRunBlockOutputs(['Agent 1.content', 'Agent 1.tokens.total', AGENT_ID], blocks, logs) ).toEqual({ 'Agent 1.content': 'hi', 'Agent 1.tokens.total': 7, @@ -42,18 +42,22 @@ describe('pickRunBlockOutputs', () => { }) }) - it('omits selectors for unknown blocks, unexecuted blocks, and absent paths', () => { + it('omits selectors for unknown blocks, unexecuted blocks, and absent paths', async () => { const logs = [log(AGENT_ID, { content: 'hi' })] - expect( - pickRunBlockOutputs(['Missing.content', 'Router.route', 'Agent 1.absent'], blocks, logs) - ).toEqual({}) + // An unknown block is a caller error and throws (the CLI turns it into + // `--select-output did not resolve to any block`); known-but-unexecuted blocks + // and absent paths are simply omitted. + await expect(pickRunBlockOutputs(['Missing.content'], blocks, logs)).rejects.toThrow( + 'does not resolve' + ) + expect(await pickRunBlockOutputs(['Router.route', 'Agent 1.absent'], blocks, logs)).toEqual({}) }) - it('reports the last log per block so loop iterations settle on final state', () => { + it('reports the last log per block so loop iterations settle on final state', async () => { const logs = [log(AGENT_ID, { content: 'first' }), log(AGENT_ID, { content: 'last' })] - expect(pickRunBlockOutputs(['Agent 1.content'], blocks, logs)).toEqual({ + expect(await pickRunBlockOutputs(['Agent 1.content'], blocks, logs)).toEqual({ 'Agent 1.content': 'last', }) }) diff --git a/apps/sim/lib/workflows/operations/import-workflow.ts b/apps/sim/lib/workflows/operations/import-workflow.ts index 396a7528a46..fce80a7f1b8 100644 --- a/apps/sim/lib/workflows/operations/import-workflow.ts +++ b/apps/sim/lib/workflows/operations/import-workflow.ts @@ -81,6 +81,13 @@ export interface ImportWorkflowParams { requestId: string } +/** One block the import created — a summary, not the graph. */ +export interface ImportedWorkflowBlock { + id: string + type: string + name: string +} + export interface ImportedWorkflow { id: string name: string @@ -90,6 +97,8 @@ export interface ImportedWorkflow { sortOrder: number createdAt: Date updatedAt: Date + /** The blocks the import persisted, in payload order, so a caller can see what landed without a second read. */ + blocks: ImportedWorkflowBlock[] } export type ImportWorkflowResult = @@ -423,6 +432,11 @@ async function executeImportWorkflowIntoWorkspace( sortOrder: created.workflow.sortOrder, createdAt: created.workflow.createdAt, updatedAt: created.workflow.updatedAt, + blocks: Object.values(workflowState.blocks).map((block) => ({ + id: block.id, + type: block.type, + name: block.name, + })), }, } } diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 0c44a3f7289..e777492a601 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -246,7 +246,7 @@ export type AddWorkflowGroupBody = { deploymentMode?: 'live' | 'deployed' autoRun?: boolean } - outputColumns: Array<{ + outputColumns?: Array<{ name: string type: 'string' | 'number' | 'currency' | 'boolean' | 'date' | 'ttl' | 'json' | 'select' required?: boolean @@ -5795,6 +5795,12 @@ export type ImportWorkflowBody = { } type ImportWorkflowResponseRef0 = { + id: string + type: string + name: string +} + +type ImportWorkflowResponseRef1 = { id: string name: string description: string | null @@ -5840,10 +5846,11 @@ type ImportWorkflowResponseRef0 = { copied: number failed: number } + blocks: Array } export type ImportWorkflowResponse = { - data: ImportWorkflowResponseRef0 + data: ImportWorkflowResponseRef1 } /** `GET /api/v2/audit-logs` */ @@ -11258,8 +11265,9 @@ export const V2_OPERATIONS = { }, outputColumns: { kind: 'array', - required: true, - describe: 'Columns created for producer outputs.', + default: [], + describe: + 'Columns to create for producer outputs. An entry naming a column the table already has attaches that column to the group instead of creating it (its `type` must match), and an output whose column already exists may omit its entry entirely — so `[]` attaches existing columns only.', }, autoRun: { kind: 'boolean', @@ -15316,7 +15324,7 @@ export const V2_OPERATIONS = { pathParams: ['tableId'] as const, pathParamDocs: { tableId: 'Unique table identifier.' }, responseMode: 'json', - summary: 'List Active Run Dispatches', + summary: 'List Run Dispatches', query: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the table.' }, }, From 387a0c90b7d1f314b8962913968856bbc754ce01 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 17:22:29 +0530 Subject: [PATCH 063/306] sim-cli: the operations apply confirmation describes the batch as written, not a delete --- packages/sim-cli/src/contract/commands.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index bf50610b050..22369d1f1c2 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -509,7 +509,8 @@ export const CLI_CONTRACT: CliContract = { }, applyWorkflowOperations: { command: 'workflows operations apply', - confirm: 'This edits the draft graph, and a delete operation removes blocks and their edges.', + confirm: + 'This edits the draft graph: the batch adds, edits, or deletes blocks and their edges as written.', flags: { operations: { json: true, describe: WORKFLOW_OPERATIONS_HELP }, setBlockEnabled: { json: true, describe: WORKFLOW_SET_BLOCK_ENABLED_HELP }, From 80284d122246bdee3524007e0cb0e557c1a8c176 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 18:04:31 +0530 Subject: [PATCH 064/306] sandbox export keeps the code's result; --trigger implies --manual; deps mock skeleton; run tools lift the terminal output and take select; lint all-clear names what it checked; docs chunker keeps code spans --- apps/docs/content/docs/cli/reference.mdx | 10 +- apps/docs/content/docs/cli/tables.mdx | 4 +- apps/docs/content/docs/cli/workflows.mdx | 6 +- apps/sim/lib/chunkers/docs-chunker.test.ts | 17 ++ apps/sim/lib/chunkers/docs-chunker.ts | 22 ++- .../execute-request.test.ts | 82 +++++++-- .../lib/function-execution/execute-request.ts | 162 ++++++++++++------ .../lib/mothership/agent-cli/engines.test.ts | 67 +++++++- .../lib/mothership/agent-cli/engines/deps.ts | 44 ++++- .../lib/mothership/agent-cli/engines/lint.ts | 2 +- .../mothership/request/tools/files.test.ts | 17 +- .../sim/lib/mothership/request/tools/files.ts | 8 +- .../mothership/request/tools/tables.test.ts | 40 +++++ .../mothership/tools/handlers/param-types.ts | 8 + .../tools/handlers/workflow/mutations.test.ts | 72 ++++++++ .../tools/handlers/workflow/mutations.ts | 117 +++++++++++-- .../protocol/workflow-run-follow.test.ts | 19 +- .../commands/protocol/workflow-run-follow.ts | 10 +- 18 files changed, 585 insertions(+), 122 deletions(-) diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index b44f1d174fb..94a12cd89ab 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -3575,7 +3575,7 @@ sim tables groups create [options] | Option | Required | Description | | --- | --- | --- | | `--group ` | Yes | Workflow or enrichment producer definition. (JSON, or @path / @- to read a file or stdin). | -| `--output-columns ` | Yes | Columns created for producer outputs. (JSON, or @path / @- to read a file or stdin). | +| `--output-columns ` | No | Columns to create for producer outputs. An entry naming a column the table already has attaches that column to the group instead of creating it (its `type` must match), and an output whose column already exists may omit its entry entirely — so `[]` attaches existing columns only. (JSON, or @path / @- to read a file or stdin). | | `--auto-run` | No | Whether to schedule existing rows after group creation. | | `--no-auto-run` | No | Send --auto-run as false. | @@ -4119,7 +4119,7 @@ sim tables dispatches get ### sim tables dispatches list -List Active Run Dispatches +List Run Dispatches ```bash sim tables dispatches list @@ -5669,14 +5669,14 @@ sim workflows run [options] | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | | `--execution-timeout-seconds ` | No | Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`. | -| `--select-output ` | No | Return blockName.field values (e.g. agent_1.content), or childWorkflowId.blockName.field for a child workflow (applies to every invocation) — in blockOutputs on a sync run, or from the streamed result with --follow; missing fields are omitted. Not available with --async (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--select-output ` | No | Return blockName.path values (e.g. agent_1.content), or childWorkflowId.blockName.path for a child workflow (applies to every invocation) — in blockOutputs on a sync run, or from the streamed result with --follow; missing paths are omitted. Not available with --async (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | | `--run-id ` | No | One-shot identifier for this run; NOT an idempotency key — reusing a claimed value fails with RUN_ID_CONFLICT instead of replaying the first result, and a fresh value starts another run. | | `--manual` | No | Run the current saved workflow state instead of the active deployment. | -| `--trigger ` | No | Enter a manual run through this runnable trigger (requires --manual). | -| `--mock-payload` | No | Use the selected trigger's server-derived mock payload (requires --manual). | +| `--trigger ` | No | Enter the run through this runnable trigger; runs the current saved workflow state (implies --manual). | +| `--mock-payload` | No | Use the selected trigger's server-derived mock payload; runs the current saved workflow state (implies --manual). | | `--from-block ` | No | Run manually from this saved workflow block. | | `--source-run ` | No | Prior run whose persisted state supplies upstream outputs (requires --from-block). | | `--follow` | No | Stream the run as it happens; progress on stderr, result on stdout. The stream reports only success and output, so the result omits the run id and timings a non-streaming run returns. | diff --git a/apps/docs/content/docs/cli/tables.mdx b/apps/docs/content/docs/cli/tables.mdx index b1290e4af50..d7b5d9e6636 100644 --- a/apps/docs/content/docs/cli/tables.mdx +++ b/apps/docs/content/docs/cli/tables.mdx @@ -112,7 +112,7 @@ sim tables groups create [options] | Option | Required | Description | | --- | --- | --- | | `--group ` | Yes | Workflow or enrichment producer definition. (JSON, or @path / @- to read a file or stdin). | -| `--output-columns ` | Yes | Columns created for producer outputs. (JSON, or @path / @- to read a file or stdin). | +| `--output-columns ` | No | Columns to create for producer outputs. An entry naming a column the table already has attaches that column to the group instead of creating it (its `type` must match), and an output whose column already exists may omit its entry entirely — so `[]` attaches existing columns only. (JSON, or @path / @- to read a file or stdin). | | `--auto-run` | No | Whether to schedule existing rows after group creation. | | `--no-auto-run` | No | Send --auto-run as false. | @@ -616,7 +616,7 @@ sim tables dispatches get -## List active run dispatches +## List run dispatches ```bash sim tables dispatches list diff --git a/apps/docs/content/docs/cli/workflows.mdx b/apps/docs/content/docs/cli/workflows.mdx index ad55d2bde91..4bad7cf46e3 100644 --- a/apps/docs/content/docs/cli/workflows.mdx +++ b/apps/docs/content/docs/cli/workflows.mdx @@ -533,14 +533,14 @@ sim workflows run [options] | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | | `--execution-timeout-seconds ` | No | Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`. | -| `--select-output ` | No | Return blockName.field values (e.g. agent_1.content), or childWorkflowId.blockName.field for a child workflow (applies to every invocation) — in blockOutputs on a sync run, or from the streamed result with --follow; missing fields are omitted. Not available with --async (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--select-output ` | No | Return blockName.path values (e.g. agent_1.content), or childWorkflowId.blockName.path for a child workflow (applies to every invocation) — in blockOutputs on a sync run, or from the streamed result with --follow; missing paths are omitted. Not available with --async (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | | `--run-id ` | No | One-shot identifier for this run; NOT an idempotency key — reusing a claimed value fails with RUN_ID_CONFLICT instead of replaying the first result, and a fresh value starts another run. | | `--manual` | No | Run the current saved workflow state instead of the active deployment. | -| `--trigger ` | No | Enter a manual run through this runnable trigger (requires --manual). | -| `--mock-payload` | No | Use the selected trigger's server-derived mock payload (requires --manual). | +| `--trigger ` | No | Enter the run through this runnable trigger; runs the current saved workflow state (implies --manual). | +| `--mock-payload` | No | Use the selected trigger's server-derived mock payload; runs the current saved workflow state (implies --manual). | | `--from-block ` | No | Run manually from this saved workflow block. | | `--source-run ` | No | Prior run whose persisted state supplies upstream outputs (requires --from-block). | | `--follow` | No | Stream the run as it happens; progress on stderr, result on stdout. The stream reports only success and output, so the result omits the run id and timings a non-streaming run returns. | diff --git a/apps/sim/lib/chunkers/docs-chunker.test.ts b/apps/sim/lib/chunkers/docs-chunker.test.ts index 58d99aa0031..17ff1f33f4a 100644 --- a/apps/sim/lib/chunkers/docs-chunker.test.ts +++ b/apps/sim/lib/chunkers/docs-chunker.test.ts @@ -109,6 +109,23 @@ describe('cleanContent FAQ extraction', () => { }) }) +describe('cleanContent keeps code intact', () => { + it('leaves reference tokens inside fenced and inline code alone while stripping prose tags', () => { + const chunker = new DocsChunker({ chunkSize: 500 }) + const cleaned = (chunker as unknown as { cleanContent: (c: string) => string }).cleanContent( + [ + 'Use this block. Reference `` in code:', + '```javascript', + 'return .toLowerCase().includes({{ENV}})', + '```', + ].join('\n') + ) + expect(cleaned).not.toContain('') + expect(cleaned).toContain('``') + expect(cleaned).toContain('return .toLowerCase().includes({{ENV}})') + }) +}) + describe('cleanContent scaffolding strips', () => { it('still strips imports, exports, comments, and code-ish brace expressions', () => { const cleaned = cleanContent( diff --git a/apps/sim/lib/chunkers/docs-chunker.ts b/apps/sim/lib/chunkers/docs-chunker.ts index 728ee155f4d..00f4918df6c 100644 --- a/apps/sim/lib/chunkers/docs-chunker.ts +++ b/apps/sim/lib/chunkers/docs-chunker.ts @@ -267,8 +267,14 @@ export class DocsChunker { return { chunks: finalChunks, cleanedContent } } + /** + * Strips MDX scaffolding from prose while leaving code untouched: a fenced block or an + * inline span is where `` references and `{{SECRET}}` tokens live, and the + * tag and brace strips below would otherwise erase exactly the part of a code sample + * that shows how a reference is written. + */ private cleanContent(content: string): string { - return content + const normalized = content .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .replace(/^import\s+.*$/gm, '') @@ -276,9 +282,17 @@ export class DocsChunker { .replace(//g, (_m, items: string) => extractFaqProse(items) ) - .replace(/<\/?[a-zA-Z][^>]*>/g, ' ') - .replace(/\{\/\*[\s\S]*?\*\/\}/g, ' ') - .replace(/\{[^{}]*\}/g, ' ') + const segments = normalized.split(/(```[\s\S]*?```|`[^`\n]+`)/g) + return segments + .map((segment, index) => + index % 2 === 1 + ? segment + : segment + .replace(/<\/?[a-zA-Z][^>]*>/g, ' ') + .replace(/\{\/\*[\s\S]*?\*\/\}/g, ' ') + .replace(/\{[^{}]*\}/g, ' ') + ) + .join('') .replace(/\n{3,}/g, '\n\n') .replace(/[ \t]{2,}/g, ' ') .trim() diff --git a/apps/sim/lib/function-execution/execute-request.test.ts b/apps/sim/lib/function-execution/execute-request.test.ts index e32cc2ad21b..4a4ebfc557e 100644 --- a/apps/sim/lib/function-execution/execute-request.test.ts +++ b/apps/sim/lib/function-execution/execute-request.test.ts @@ -954,7 +954,10 @@ describe('Function execution request', () => { }), }) ) - expect(data.output.result.files).toHaveLength(2) + expect(data.output.result).toBe('done') + expect(data.output.exported.files).toHaveLength(2) + expect(data.output.message).toContain('Exported 2 sandbox files') + expect(data.output.exported.message).toBe(data.output.message) expect(data.output.cost).toEqual({ input: 0, output: 0, total: 0.00023456 }) expect(data.resources).toEqual([ expect.objectContaining({ path: 'files/reports/chart.png' }), @@ -962,6 +965,53 @@ describe('Function execution request', () => { ]) }) + it("keeps the code's returned rows beside the sandbox export receipt", async () => { + envFlagsMock.isRemoteSandboxEnabled = true + const rows = [{ name: 'Ada' }, { name: 'Grace' }] + mockExecuteInSandbox.mockResolvedValueOnce({ + result: rows, + stdout: 'ok', + sandboxId: 'sandbox-123', + exportedFiles: { '/home/user/report.txt': 'name\nAda\nGrace\n' }, + }) + + const response = await POST( + createMockRequest('POST', { + code: '__sim_result__ = [{"name": "Ada"}, {"name": "Grace"}]', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/report.txt', + sandboxPath: '/home/user/report.txt', + mimeType: 'text/plain', + }, + ], + }, + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + // The table writer and the returned-value file writer both read + // `output.result`, so the export receipt must not displace the rows. + expect(data.output.result).toEqual(rows) + expect(data.output.exported).toEqual({ + message: expect.stringContaining('Sandbox file exported to files/report.txt'), + files: [ + expect.objectContaining({ + fileId: 'wf_report_txt', + vfsPath: 'files/report.txt', + sandboxPath: '/home/user/report.txt', + }), + ], + }) + expect(data.output.message).toBe(data.output.exported.message) + expect(data.resources).toEqual([expect.objectContaining({ path: 'files/report.txt' })]) + }) + it('atomically classifies text exports and acknowledges the durable v2 capability', async () => { envFlagsMock.isRemoteSandboxEnabled = true mockExecuteInSandbox.mockResolvedValueOnce({ @@ -1315,9 +1365,9 @@ describe('Function execution request', () => { ) expect(response.status).toBe(200) - expect((await response.json()).output.result).toEqual( - expect.objectContaining({ fileId: 'wf_output_txt', vfsPath: 'files/output.txt' }) - ) + expect((await response.json()).output.exported.files).toEqual([ + expect.objectContaining({ fileId: 'wf_output_txt', vfsPath: 'files/output.txt' }), + ]) expect(mockExecuteInSandbox).toHaveBeenCalledOnce() expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledWith( expect.objectContaining({ @@ -2479,9 +2529,10 @@ describe('Function execution request', () => { expect(response.status).toBe(200) expect(data.success).toBe(true) expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1) - expect(data.output.result.unchanged).toBe(true) - expect(data.output.result.message).toContain('byte-identical to the previous version') - expect(data.output.result.message).toContain('/home/user/doc.md') + expect(data.output.result).toBe('done') + expect(data.output.exported.files[0].unchanged).toBe(true) + expect(data.output.message).toContain('byte-identical to the previous version') + expect(data.output.message).toContain('/home/user/doc.md') }) it('continues an overwrite when the advisory comparison fails', async () => { @@ -2519,8 +2570,8 @@ describe('Function execution request', () => { expect(response.status).toBe(200) expect(data.success).toBe(true) expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1) - expect(data.output.result).toMatchObject({ unchanged: false }) - expect(data.output.result).not.toHaveProperty('previousSize') + expect(data.output.exported.files[0]).toMatchObject({ unchanged: false }) + expect(data.output.exported.files[0]).not.toHaveProperty('previousSize') }) it('reports size, previousSize, and sha256 receipts on a successful overwrite export', async () => { @@ -2562,12 +2613,13 @@ describe('Function execution request', () => { expect(data.success).toBe(true) // Sizes differ, so the current content is never downloaded for comparison. expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled() - expect(data.output.result.size).toBe(Buffer.byteLength(newContent, 'utf-8')) - expect(data.output.result.previousSize).toBe(36728) - expect(data.output.result.sha256).toMatch(/^[0-9a-f]{64}$/) - expect(data.output.result.unchanged).toBe(false) - expect(data.output.result.message).toContain('replaced 36728 bytes') - expect(data.output.result.message).toContain('sha256:') + const [exportedFile] = data.output.exported.files + expect(exportedFile.size).toBe(Buffer.byteLength(newContent, 'utf-8')) + expect(exportedFile.previousSize).toBe(36728) + expect(exportedFile.sha256).toMatch(/^[0-9a-f]{64}$/) + expect(exportedFile.unchanged).toBe(false) + expect(data.output.message).toContain('replaced 36728 bytes') + expect(data.output.message).toContain('sha256:') // The python wrapper prints the marker with a leading \n so it always // starts a fresh line even after non-newline-terminated user output. const e2bCode = mockExecuteInSandbox.mock.calls[0][0].code as string diff --git a/apps/sim/lib/function-execution/execute-request.ts b/apps/sim/lib/function-execution/execute-request.ts index 6a853c0a811..7199f226bf3 100644 --- a/apps/sim/lib/function-execution/execute-request.ts +++ b/apps/sim/lib/function-execution/execute-request.ts @@ -1567,6 +1567,60 @@ function workspaceFileExportErrorStatus(error: unknown): number { return asOrchestrationError(error)?.code === 'forbidden' ? 403 : 400 } +interface SandboxExportedFile { + fileId: string + fileName: string + vfsPath: string + downloadUrl?: string + sandboxPath?: string + size: number + previousSize?: number + sha256: string + unchanged: boolean +} + +/** + * Builds the success response for a sandbox file export. + * + * The code's own return value stays in `output.result`, so the consumers that + * read it — `outputTable`, the returned-value file writer — keep seeing the rows + * when a call also exports files. The export receipt sits beside it under + * `output.exported`, and its `message` is mirrored at the top level so the + * human-readable receipt stays visible in the tool result. Routed through + * {@link functionJsonResponse} so a large returned value is compacted exactly as + * it is on the ordinary success path. + */ +function sandboxExportResponse(args: { + routeContext: FunctionRouteExecutionContext + result: unknown + message: string + files: SandboxExportedFile[] + stdout: string + executionTime: number + cost?: FunctionExecutionCost +}) { + return functionJsonResponse( + { + success: true, + output: { + result: args.result ?? null, + exported: { message: args.message, files: args.files }, + message: args.message, + stdout: cleanStdout(args.stdout), + executionTime: args.executionTime, + ...(args.cost ? { cost: args.cost } : {}), + }, + resources: args.files.map((file) => ({ + type: 'file', + id: file.fileId, + title: file.fileName, + path: file.vfsPath, + })), + }, + args.routeContext + ) +} + async function maybeExportSandboxFileToWorkspace(args: { routeContext: FunctionRouteExecutionContext authUserId: string @@ -1579,6 +1633,7 @@ async function maybeExportSandboxFileToWorkspace(args: { overwriteFileId?: string outputMode?: 'create' | 'overwrite' exportedFileContent?: string + result: unknown stdout: string executionTime: number cost?: FunctionExecutionCost @@ -1595,6 +1650,7 @@ async function maybeExportSandboxFileToWorkspace(args: { overwriteFileId, outputMode, exportedFileContent, + result, stdout, executionTime, cost, @@ -1702,15 +1758,16 @@ async function maybeExportSandboxFileToWorkspace(args: { sha256, unchanged, }) - return NextResponse.json({ - success: true, - output: { - result: { - message: `Sandbox file exported to ${written.vfsPath} ${formatExportReceipt( - fileBuffer.length, - previousSize, - sha256 - )}${unchanged ? ` — ${exportUnchangedNote(outputSandboxPath)}` : ''}`, + return sandboxExportResponse({ + routeContext, + result, + message: `Sandbox file exported to ${written.vfsPath} ${formatExportReceipt( + fileBuffer.length, + previousSize, + sha256 + )}${unchanged ? ` — ${exportUnchangedNote(outputSandboxPath)}` : ''}`, + files: [ + { fileId: written.id, fileName: written.name, vfsPath: written.vfsPath, @@ -1721,11 +1778,10 @@ async function maybeExportSandboxFileToWorkspace(args: { sha256, unchanged, }, - stdout: cleanStdout(stdout), - executionTime, - ...(cost ? { cost } : {}), - }, - resources: [{ type: 'file', id: written.id, title: written.name, path: written.vfsPath }], + ], + stdout, + executionTime, + cost, }) } catch (error) { return exportFailure( @@ -1746,6 +1802,7 @@ async function maybeExportSandboxFilesToWorkspace(args: { outputFiles: OutputFileDeclaration[] exportedFiles?: Record exportedFileContent?: string + result: unknown stdout: string executionTime: number cost?: FunctionExecutionCost @@ -1777,6 +1834,7 @@ async function maybeExportSandboxFilesToWorkspace(args: { exportedFileContent: (file.sandboxPath ? args.exportedFiles?.[file.sandboxPath] : undefined) ?? args.exportedFileContent, + result: args.result, stdout: args.stdout, executionTime: args.executionTime, cost: args.cost, @@ -1949,48 +2007,39 @@ async function maybeExportSandboxFilesToWorkspace(args: { } const unchangedFiles = writtenFiles.filter((file) => file.unchanged) - return NextResponse.json({ - success: true, - output: { - result: { - message: `Exported ${writtenFiles.length} sandbox files: ${writtenFiles - .map( - (file) => - `${file.vfsPath} ${formatExportReceipt( - file.exportedBytes, - file.previousSize, - file.sha256 - )}${file.unchanged ? ' [UNCHANGED]' : ''}` - ) - .join('; ')}${ - unchangedFiles.length > 0 - ? ` — WARNING: ${unchangedFiles.map((file) => file.vfsPath).join(', ')} ${ - unchangedFiles.length === 1 ? 'is' : 'are' - } byte-identical to the previous version (nothing changed). If you expected new content there, your code did not modify the corresponding sandbox file.` - : '' - }`, - files: writtenFiles.map((file) => ({ - fileId: file.id, - fileName: file.name, - vfsPath: file.vfsPath, - downloadUrl: file.downloadUrl, - sandboxPath: file.sandboxPath, - size: file.exportedBytes, - previousSize: file.previousSize, - sha256: file.sha256, - unchanged: file.unchanged, - })), - }, - stdout: cleanStdout(args.stdout), - executionTime: args.executionTime, - ...(args.cost ? { cost: args.cost } : {}), - }, - resources: writtenFiles.map((file) => ({ - type: 'file', - id: file.id, - title: file.name, - path: file.vfsPath, + return sandboxExportResponse({ + routeContext: args.routeContext, + result: args.result, + message: `Exported ${writtenFiles.length} sandbox files: ${writtenFiles + .map( + (file) => + `${file.vfsPath} ${formatExportReceipt( + file.exportedBytes, + file.previousSize, + file.sha256 + )}${file.unchanged ? ' [UNCHANGED]' : ''}` + ) + .join('; ')}${ + unchangedFiles.length > 0 + ? ` — WARNING: ${unchangedFiles.map((file) => file.vfsPath).join(', ')} ${ + unchangedFiles.length === 1 ? 'is' : 'are' + } byte-identical to the previous version (nothing changed). If you expected new content there, your code did not modify the corresponding sandbox file.` + : '' + }`, + files: writtenFiles.map((file) => ({ + fileId: file.id, + fileName: file.name, + vfsPath: file.vfsPath, + downloadUrl: file.downloadUrl, + sandboxPath: file.sandboxPath, + size: file.exportedBytes, + previousSize: file.previousSize, + sha256: file.sha256, + unchanged: file.unchanged, })), + stdout: args.stdout, + executionTime: args.executionTime, + cost: args.cost, }) } @@ -2734,6 +2783,7 @@ export async function executeFunctionRequest( outputFiles, exportedFiles, exportedFileContent, + result: shellResult, stdout: shellStdout, executionTime, cost: shellCost, @@ -2897,6 +2947,7 @@ export async function executeFunctionRequest( outputFiles, exportedFiles, exportedFileContent, + result: e2bResult, stdout, executionTime, cost: sandboxCost, @@ -3022,6 +3073,7 @@ export async function executeFunctionRequest( outputFiles, exportedFiles, exportedFileContent, + result: e2bResult, stdout, executionTime, cost: sandboxCost, diff --git a/apps/sim/lib/mothership/agent-cli/engines.test.ts b/apps/sim/lib/mothership/agent-cli/engines.test.ts index 5cd4cc5b548..f3afce652aa 100644 --- a/apps/sim/lib/mothership/agent-cli/engines.test.ts +++ b/apps/sim/lib/mothership/agent-cli/engines.test.ts @@ -74,6 +74,29 @@ describe('workflows lint', () => { }) }) + it('says what a clean report checked so it is not read as a working workflow', async () => { + buildWorkflowLintReport.mockResolvedValueOnce({ + sources: ['block-1'], + sinks: ['block-2'], + orphanBlocks: [], + emptyOutgoingPorts: [], + invalidBranchPorts: [], + invalidConnectionTargets: [], + fieldIssues: [], + unresolvedReferences: [], + }) + const result = await runEngine( + 'workflows lint', + ['wf-1'], + runtimeWith({ [STATE_PATH]: stateResponse }), + {} + ) + expect(result.exitCode).toBe(0) + expect(JSON.parse(result.stdout).summary).toBe( + 'No structural issues found (orphans, ports, required fields, references). Code and runtime behaviour are not checked — run it.' + ) + }) + it('surfaces execution errors as a failed result, never a throw', async () => { const result = await runEngine('workflows lint', ['wf-missing'], runtimeWith({}), {}) expect(result.exitCode).toBe(1) @@ -202,11 +225,18 @@ describe('workflows deps', () => { { blockId: 'gate', blockName: 'Gate', sourceHandle: 'condition-true' }, { blockId: 'enrich', blockName: 'Enrich', sourceHandle: 'source' }, ]) + // The mock is the variableInputs skeleton itself: the child-workflow return is + // nested at result.data., and the unread predecessor is an empty entry. expect(report.mock).toEqual({ - 'Fetch rows': ['result'], - Enrich: ['result.data.total'], - Gate: [''], + 'Fetch rows': { result: null }, + Enrich: { result: { data: { total: null } } }, + Gate: {}, }) + expect(report.mockNote).toBe( + 'variableInputs: fill each null with the value the block would output' + ) + expect(report.mockEmptyNote).toContain('Gate') + expect(report.mockEmptyNote).not.toContain('Enrich') expect(report.childReturns).toEqual([ { blockId: 'enrich', @@ -216,6 +246,35 @@ describe('workflows deps', () => { ]) }) + it('merges sibling and whole-object paths of one block into a single nested skeleton', async () => { + const state = { + ...DEPS_STATE, + blocks: { + ...DEPS_STATE.blocks, + target: { + type: 'function', + name: 'Summarize', + subBlocks: { + code: { + value: + 'return [, , ]', + }, + }, + }, + }, + edges: [{ id: 'e2', source: 'fetch', target: 'target', sourceHandle: 'source' }], + } + const result = await runEngine( + 'workflows deps', + ['wf-1', 'target'], + runtimeWith({ [STATE_PATH]: { data: state } }), + {} + ) + const report = JSON.parse(result.stdout) + expect(report.mock).toEqual({ 'Fetch rows': { result: { tier: null, score: { raw: null } } } }) + expect(report.mockEmptyNote).toBeUndefined() + }) + it('omits childReturns when no upstream block runs a child workflow', async () => { const result = await runEngine( 'workflows deps', @@ -225,6 +284,8 @@ describe('workflows deps', () => { ) const report = JSON.parse(result.stdout) expect(report.predecessors).toEqual([]) + expect(report.mock).toEqual({}) + expect(report.mockEmptyNote).toBeUndefined() expect(report.childReturns).toBeUndefined() }) }) diff --git a/apps/sim/lib/mothership/agent-cli/engines/deps.ts b/apps/sim/lib/mothership/agent-cli/engines/deps.ts index 6417c3045e1..f6bcdbad919 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/deps.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/deps.ts @@ -1,3 +1,4 @@ +import { isPlainRecord } from '@sim/utils/object' import { fetchWorkflowState } from '@/lib/mothership/agent-cli/engines/workflow-state' import { type AgentCliEngine, agentCliFail, agentCliOk } from '@/lib/mothership/agent-cli/types' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' @@ -31,6 +32,32 @@ const ENV_REF = createEnvVarPattern() const CHILD_WORKFLOW_BLOCK_TYPES: ReadonlySet = new Set(['workflow', 'workflow_input']) const CHILD_RETURNS_NOTE = "A child workflow's result is its Response block's envelope {data, status, headers}; fields live at result.data., so mock and read them there." +const MOCK_NOTE = 'variableInputs: fill each null with the value the block would output' + +/** + * A null-leaved object nested along each dotted path — the shape run_block's + * variableInputs expects for that block, ready to paste and fill in. A path read + * both whole and drilled into (`result` and `result.tier`) keeps the deeper shape. + */ +function skeletonFromPaths(paths: readonly string[]): Record { + const skeleton: Record = {} + for (const path of paths) { + const segments = path.split('.').filter(Boolean) + let cursor = skeleton + for (let index = 0; index < segments.length; index++) { + const segment = segments[index] + if (index === segments.length - 1) { + if (!(segment in cursor)) cursor[segment] = null + break + } + const existing = cursor[segment] + const next = isPlainRecord(existing) ? existing : {} + cursor[segment] = next + cursor = next + } + } + return skeleton +} interface DepView { token: string @@ -149,16 +176,19 @@ export const workflowDepsCommand: AgentCliEngine = { const blockDeps = deps.filter((d) => d.kind === 'block') const predecessors = collectPredecessors(state, blocks, blockId, idToName) - // Ready-made skeleton for run_block's variableInputs: mock each upstream + // Paste-ready skeleton for run_block's variableInputs: mock each upstream // block's output at the paths this block actually reads, and every graph // parent it never reads at all — run_block refuses to start without them. - const mock: Record = Object.fromEntries( - blockDeps.map((d) => [d.blockName ?? d.blockId, d.paths?.length ? d.paths : ['']]) + const mock: Record> = Object.fromEntries( + blockDeps.map((d) => [d.blockName ?? d.blockId, skeletonFromPaths(d.paths ?? [])]) ) for (const predecessor of predecessors) { const key = predecessor.blockName ?? predecessor.blockId - if (!mock[key]) mock[key] = [''] + if (!mock[key]) mock[key] = {} } + const unshapedMocks = Object.entries(mock) + .filter(([, shape]) => Object.keys(shape).length === 0) + .map(([name]) => name) const upstreamIds = new Set() for (const dep of blockDeps) if (dep.blockId) upstreamIds.add(dep.blockId) @@ -179,6 +209,12 @@ export const workflowDepsCommand: AgentCliEngine = { predecessors, env: [...envs].sort(), mock, + mockNote: MOCK_NOTE, + ...(unshapedMocks.length + ? { + mockEmptyNote: `${unshapedMocks.join(', ')}: mocked as {} because no field of their output is read here — run_block only needs the entry present.`, + } + : {}), ...(childReturns.length ? { childReturns } : {}), }, null, diff --git a/apps/sim/lib/mothership/agent-cli/engines/lint.ts b/apps/sim/lib/mothership/agent-cli/engines/lint.ts index 1dc4a43ecd8..272ae73a012 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/lint.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/lint.ts @@ -35,7 +35,7 @@ export const workflowLintCommand: AgentCliEngine = { ? formatWorkflowLintMessage(report) : undeclaredEnvVars.length > 0 ? `Undeclared environment variables referenced: ${undeclaredEnvVars.map((v) => v.name).join(', ')} — an unresolved {{TOKEN}} resolves to an EMPTY STRING at run time, not an error.` - : 'No lint issues found.' + : 'No structural issues found (orphans, ports, required fields, references). Code and runtime behaviour are not checked — run it.' return agentCliOk(JSON.stringify({ summary, undeclaredEnvVars, ...report }, null, 2)) }, } diff --git a/apps/sim/lib/mothership/request/tools/files.test.ts b/apps/sim/lib/mothership/request/tools/files.test.ts index f4555692ea0..9715bef286d 100644 --- a/apps/sim/lib/mothership/request/tools/files.test.ts +++ b/apps/sim/lib/mothership/request/tools/files.test.ts @@ -160,14 +160,27 @@ describe('maybeWriteOutputToFile', () => { }) it('does not deny a read-only principal when no workspace write occurs (sandbox export active)', async () => { + const exportedResult = { + success: true, + output: { + result: [{ name: 'Alice', age: 30 }], + exported: { + message: 'Sandbox file exported to files/report.csv (12 bytes)', + files: [{ fileId: 'file-1', fileName: 'report.csv', vfsPath: 'files/report.csv' }], + }, + message: 'Sandbox file exported to files/report.csv (12 bytes)', + stdout: '', + }, + } + const result = await maybeWriteOutputToFile( RunFunction.id, { outputs: { files: [{ path: 'files/report.csv', mode: 'overwrite' }] } }, - { success: true, output: { result: { files: [{ path: 'report.csv' }] }, stdout: '' } }, + exportedResult, buildContext({ userPermission: 'read' }) ) - expect(result.success).toBe(true) + expect(result).toBe(exportedResult) expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/mothership/request/tools/files.ts b/apps/sim/lib/mothership/request/tools/files.ts index 307c571ddfc..0e9d35d91d4 100644 --- a/apps/sim/lib/mothership/request/tools/files.ts +++ b/apps/sim/lib/mothership/request/tools/files.ts @@ -355,13 +355,7 @@ export async function maybeWriteOutputToFile( const outputObject = isRecordLike(result.output) ? (result.output as Record) : undefined - const resultObject = - outputObject?.result && - typeof outputObject.result === 'object' && - !Array.isArray(outputObject.result) - ? (outputObject.result as Record) - : undefined - if (Array.isArray(resultObject?.files)) { + if (isRecordLike(outputObject?.exported)) { logger.warn('Skipping returned-value output write because sandbox export response is active', { toolName, outputCount: outputFiles.length, diff --git a/apps/sim/lib/mothership/request/tools/tables.test.ts b/apps/sim/lib/mothership/request/tools/tables.test.ts index ccc461548bc..f06eed58a77 100644 --- a/apps/sim/lib/mothership/request/tools/tables.test.ts +++ b/apps/sim/lib/mothership/request/tools/tables.test.ts @@ -270,6 +270,46 @@ describe('automatic Copilot tool-output table persistence', () => { expect(result.output).toBeUndefined() }) + it('still sees the returned rows when the run also exported sandbox files', async () => { + const context = buildContext() + const rows = [{ name: 'Ada' }, { name: 'Grace' }] + const message = 'Sandbox file exported to files/report.csv (12 bytes)' + + const result = await maybeWriteOutputToTable( + RunFunction.id, + { + outputTable: 'table-1', + outputs: { files: [{ path: 'files/report.csv', sandboxPath: '/home/user/report.csv' }] }, + }, + { + success: true, + output: { + result: rows, + exported: { + message, + files: [{ fileId: 'file-1', fileName: 'report.csv', vfsPath: 'files/report.csv' }], + }, + message, + stdout: '', + }, + }, + context + ) + + expect(result).toEqual({ + success: true, + output: { + message: 'Wrote 2 rows to table table-1', + tableId: 'table-1', + rowCount: 2, + }, + }) + expect(mocks.executeReplace).toHaveBeenCalledWith( + context, + expect.objectContaining({ tableId: 'table-1', sourceRows: rows }) + ) + }) + it('fails closed when the authoritative inserted count is inconsistent', async () => { mocks.executeReplace.mockResolvedValueOnce({ table, deletedCount: 1, insertedCount: 1 }) diff --git a/apps/sim/lib/mothership/tools/handlers/param-types.ts b/apps/sim/lib/mothership/tools/handlers/param-types.ts index 6f99adbcc92..5bcc622c416 100644 --- a/apps/sim/lib/mothership/tools/handlers/param-types.ts +++ b/apps/sim/lib/mothership/tools/handlers/param-types.ts @@ -58,6 +58,8 @@ export interface RunWorkflowParams { inputFromExecutionId?: string /** When true, runs the deployed version instead of the draft. Default: false (draft). */ useDeployedState?: boolean + /** Block outputs to return as `blockName.path` (name or id); when given, `logs` are omitted and `selected` carries only these. */ + select?: string[] } export interface CancelWorkflowRunParams { @@ -79,6 +81,8 @@ export interface RunWorkflowUntilBlockParams { stopAfterBlockId: string /** When true, runs the deployed version instead of the draft. Default: false (draft). */ useDeployedState?: boolean + /** Block outputs to return as `blockName.path` (name or id); when given, `logs` are omitted and `selected` carries only these. */ + select?: string[] } export interface RunFromBlockParams { @@ -92,6 +96,8 @@ export interface RunFromBlockParams { workflow_input?: unknown input?: unknown useDeployedState?: boolean + /** Block outputs to return as `blockName.path` (name or id); when given, `logs` are omitted and `selected` carries only these. */ + select?: string[] } export interface RunBlockParams { @@ -105,6 +111,8 @@ export interface RunBlockParams { workflow_input?: unknown input?: unknown useDeployedState?: boolean + /** Block outputs to return as `blockName.path` (name or id); when given, `logs` are omitted and `selected` carries only these. */ + select?: string[] } export interface GetDeployedWorkflowStateParams { diff --git a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts index ba91ea5e18c..fd918fbc347 100644 --- a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts @@ -213,6 +213,78 @@ describe('workflow mutation Copilot adapters', () => { expect(output.logs[1].input).toBe('raw') }) + it('lifts the last block output into an empty run output and names its source block', async () => { + mocks.executeWorkflowUseCase.mockResolvedValue({ + success: true, + output: {}, + logs: [ + { blockId: 'start', blockName: 'Start', output: { input: 'x' } }, + { blockId: 'score', blockName: 'Score', output: { result: { tier: 'gold' } } }, + ], + metadata: { executionId: 'execution-1' }, + }) + + const result = await executeRunBlock({ workflowId: 'workflow-1', blockId: 'score' }, context) + + expect(result.output).toMatchObject({ + blockId: 'score', + output: { result: { tier: 'gold' } }, + outputFrom: { blockId: 'score', blockName: 'Score' }, + }) + }) + + it('keeps a non-empty run output and names no source block', async () => { + mocks.executeWorkflowUseCase.mockResolvedValue({ + success: true, + output: { answer: 42 }, + logs: [{ blockId: 'score', blockName: 'Score', output: { result: { tier: 'gold' } } }], + metadata: { executionId: 'execution-1' }, + }) + + const result = await executeRunWorkflowUntilBlock( + { workflowId: 'workflow-1', stopAfterBlockId: 'score' }, + context + ) + + const output = result.output as Record + expect(output.output).toEqual({ answer: 42 }) + expect(output).not.toHaveProperty('outputFrom') + }) + + it('returns only the selected block outputs and omits the logs when select is given', async () => { + mocks.executeWorkflowUseCase.mockResolvedValue({ + success: true, + output: { answer: 42 }, + logs: [ + { blockId: 'start', blockName: 'Start', output: { input: 'x' } }, + { + blockId: 'score', + blockName: 'Score Lead', + output: { result: { tier: 'gold', count: 6 } }, + }, + ], + metadata: { executionId: 'execution-1' }, + }) + + const result = await executeRunWorkflow( + { + workflowId: 'workflow-1', + select: ['scorelead.result.tier', 'Score Lead.result.count', 'score.result', 'Missing.x'], + }, + context + ) + + const output = result.output as Record + expect(output.selected).toEqual({ + 'scorelead.result.tier': 'gold', + 'Score Lead.result.count': 6, + 'score.result': { tier: 'gold', count: 6 }, + 'Missing.x': { unresolved: 'no executed block named "Missing"' }, + }) + expect(output.logsOmitted).toBe(true) + expect(output).not.toHaveProperty('logs') + }) + it('cancels a workflow run through the canonical application use case', async () => { mocks.executeWorkflowUseCase.mockResolvedValue({ success: true, diff --git a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts index e785931d965..1c7486dc4cc 100644 --- a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { isPlainRecord } from '@sim/utils/object' +import { filterUndefined, isPlainRecord, isRecordLike } from '@sim/utils/object' import { createCopilotWorkspaceApiKey } from '@/lib/api-key/application/create-api-key' import { PlatformEvents } from '@/lib/core/telemetry' import { messageForCopilotApplicationError } from '@/lib/mothership/application/error' @@ -126,6 +126,34 @@ function settledPhase(status: ExecutionResultStatus): ToolEffectPhase { type ExecutionResultStatus = 'completed' | 'paused' | 'cancelled' | undefined +/** `undefined`, `null`, or a keyless object: a run whose top-level output says nothing. */ +function isEmptyOutput(value: unknown): boolean { + return ( + value === undefined || + value === null || + (isPlainRecord(value) && Object.keys(value).length === 0) + ) +} + +/** + * The last executed block's output, for a run whose own `output` came back empty. + * + * run_block and run_workflow_until_block stop before any Response block, so the executor's + * top-level `output` is `{}` while the block's result sits in the final log entry — which a + * caller read as "the block produced nothing". `outputFrom` names the block it was lifted from. + */ +function lastBlockOutput( + logs: unknown +): { output: unknown; outputFrom: Record } | undefined { + if (!Array.isArray(logs) || logs.length === 0) return undefined + const last = logs[logs.length - 1] + if (!isPlainRecord(last)) return undefined + return { + output: last.output, + outputFrom: filterUndefined({ blockId: last.blockId, blockName: last.blockName }), + } +} + function buildExecutionOutput( result: { success: boolean @@ -136,22 +164,72 @@ function buildExecutionOutput( status?: ExecutionResultStatus }, phase: ToolEffectPhase, - extra?: Record + extra?: Record, + select?: string[] ): ToolCallResult { + const executionId = result.metadata?.executionId + const output = stripBinaryFields(result.output) + const logs = compactBlockLogInputs(stripBinaryFields(result.logs), executionId) + const lifted = isEmptyOutput(output) ? lastBlockOutput(logs) : undefined + // A caller that names the outputs it wants gets those and nothing else: a seven-block + // run otherwise costs ~14K chars of logs to learn one headline. + const selected = + select && select.length > 0 + ? selectFromLogs(select, Array.isArray(logs) ? logs : []) + : undefined return { success: result.success, output: { - executionId: result.metadata?.executionId, + executionId, success: result.success, ...extra, - output: stripBinaryFields(result.output), - logs: compactBlockLogInputs(stripBinaryFields(result.logs), result.metadata?.executionId), + output: lifted ? lifted.output : output, + ...(lifted ? { outputFrom: lifted.outputFrom } : {}), + ...(selected ? { selected, logsOmitted: true } : { logs }), }, error: result.success ? undefined : result.error || 'Workflow execution failed', - effect: executionEffect(phase, result.metadata?.executionId), + effect: executionEffect(phase, executionId), } } +/** The executor's block-name rule: lowercase, whitespace and dots removed. */ +function normalizeSelectorHead(value: string): string { + return value.toLowerCase().replace(/[\s.]+/g, '') +} + +/** + * Resolves `blockName.path` selectors against the run's block logs (the last log per block + * wins, so loop iterations settle on final state) — names or ids for the head, dotted + * paths into that block's output. An unresolved selector is reported, never thrown. + */ +function selectFromLogs(selectors: string[], logs: unknown[]): Record { + const byHead = new Map>() + for (const entry of logs) { + if (!isRecordLike(entry)) continue + const log = entry as Record + const output = isRecordLike(log.output) ? (log.output as Record) : undefined + if (!output) continue + if (typeof log.blockId === 'string') byHead.set(log.blockId, output) + if (typeof log.blockName === 'string') byHead.set(normalizeSelectorHead(log.blockName), output) + } + const selected: Record = {} + for (const selector of selectors) { + const [head = '', ...path] = selector.split('.') + const base = byHead.get(head) ?? byHead.get(normalizeSelectorHead(head)) + if (!base) { + selected[selector] = { unresolved: `no executed block named "${head}"` } + continue + } + let value: unknown = base + for (const segment of path) { + value = isRecordLike(value) ? (value as Record)[segment] : undefined + } + selected[selector] = + value === undefined ? { unresolved: `no "${path.join('.')}" on ${head}` } : value + } + return selected +} + function buildExecutionError(error: unknown): ToolCallResult { if (hasExecutionResult(error)) { return buildExecutionOutput( @@ -307,7 +385,7 @@ export async function executeRunWorkflow( lifecycle: copilotRunLifecycle(context), }) - return buildExecutionOutput(result, settledPhase(result.status)) + return buildExecutionOutput(result, settledPhase(result.status), undefined, params.select) } catch (error) { return buildExecutionError(error) } @@ -468,9 +546,12 @@ export async function executeRunWorkflowUntilBlock( lifecycle: copilotRunLifecycle(context), }) - return buildExecutionOutput(result, settledPhase(result.status), { - stoppedAfterBlockId: params.stopAfterBlockId, - }) + return buildExecutionOutput( + result, + settledPhase(result.status), + { stoppedAfterBlockId: params.stopAfterBlockId }, + params.select + ) } catch (error) { return buildExecutionError(error) } @@ -546,9 +627,12 @@ export async function executeRunFromBlock( lifecycle: copilotRunLifecycle(context), }) - return buildExecutionOutput(result, settledPhase(result.status), { - startBlockId: params.startBlockId, - }) + return buildExecutionOutput( + result, + settledPhase(result.status), + { startBlockId: params.startBlockId }, + params.select + ) } catch (error) { return buildExecutionError(error) } @@ -635,7 +719,12 @@ export async function executeRunBlock( lifecycle: copilotRunLifecycle(context), }) - return buildExecutionOutput(result, settledPhase(result.status), { blockId: params.blockId }) + return buildExecutionOutput( + result, + settledPhase(result.status), + { blockId: params.blockId }, + params.select + ) } catch (error) { return buildExecutionError(error) } diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts b/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts index 1b44d216277..ae4aee49ff9 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-follow.test.ts @@ -393,6 +393,21 @@ describe('sim workflows run --follow', () => { }) }) + it('lets --trigger and --mock-payload imply --manual', async () => { + request.mockResolvedValue({ data: { success: true, output: {} } }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await run(WORKFLOW_ID, '--trigger', 'slack-trigger') + await run(WORKFLOW_ID, '--mock-payload') + + expect(request.mock.calls[0][1].body).toEqual({ + run: { source: 'manual', entry: { type: 'trigger', blockId: 'slack-trigger' } }, + }) + expect(request.mock.calls[1][1].body).toEqual({ + run: { source: 'manual', entry: { type: 'trigger', useMockPayload: true } }, + }) + }) + it('lets --from-block imply manual and requires an exact source run', async () => { vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -491,7 +506,9 @@ describe('sim workflows run --follow', () => { }) it('fails fast on invalid manual flag combinations', async () => { - await expect(run(WORKFLOW_ID, '--trigger', 'trigger-1')).rejects.toThrow(/require --manual/) + await expect(run(WORKFLOW_ID, '--trigger', 'trigger-1', '--async')).rejects.toThrow( + /does not support --async/ + ) await expect(run(WORKFLOW_ID, '--from-block', 'agent-1')).rejects.toThrow( /requires --source-run/ ) diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts index bc491572ca5..5e1de4ce648 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts @@ -57,15 +57,13 @@ type WorkflowRunSelection = export function resolveWorkflowRunSelection( flags: Record ): WorkflowRunSelection | undefined { - const manual = flags.manual === true const trigger = typeof flags.trigger === 'string' ? flags.trigger : undefined const useMockPayload = flags.mockPayload === true + /** A trigger entry only exists on the draft, so these flags imply `--manual`. */ + const manual = flags.manual === true || trigger !== undefined || useMockPayload const fromBlock = typeof flags.fromBlock === 'string' ? flags.fromBlock : undefined const sourceRun = typeof flags.sourceRun === 'string' ? flags.sourceRun : undefined - if ((trigger || useMockPayload) && !manual) { - throw new SimApiError('--trigger and --mock-payload require --manual', 0) - } if (fromBlock && (trigger || useMockPayload)) { throw new SimApiError('--from-block cannot be combined with --trigger or --mock-payload', 0) } @@ -479,11 +477,11 @@ export function attachWorkflowRunFollow(workflows: Command): void { .option('--manual', 'Run the current saved workflow state instead of the active deployment') .option( '--trigger ', - 'Enter a manual run through this runnable trigger (requires --manual)' + 'Enter the run through this runnable trigger; runs the current saved workflow state (implies --manual)' ) .option( '--mock-payload', - "Use the selected trigger's server-derived mock payload (requires --manual)" + "Use the selected trigger's server-derived mock payload; runs the current saved workflow state (implies --manual)" ) .option('--from-block ', 'Run manually from this saved workflow block') .option( From 6e9e18d0a31d10a84cc1e3caab9fad648232f588 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 18:13:28 +0530 Subject: [PATCH 065/306] sim-cli: runs get --select-output takes block names; groups delete says it removes the output columns and their data; a publish that lands on public auth prints a note --- .../sim-cli/src/commands/protocol/index.ts | 11 +- .../commands/protocol/workflow-run-follow.ts | 5 +- .../protocol/workflow-run-get.test.ts | 182 +++++++++++++ .../src/commands/protocol/workflow-run-get.ts | 250 ++++++++++++++++++ .../sim-cli/src/contract/commands.test.ts | 36 ++- packages/sim-cli/src/contract/commands.ts | 19 +- packages/sim-cli/src/runtime/execute.test.ts | 106 +++++++- packages/sim-cli/src/runtime/execute.ts | 49 ++++ packages/sim-cli/src/runtime/request.ts | 2 +- 9 files changed, 635 insertions(+), 25 deletions(-) create mode 100644 packages/sim-cli/src/commands/protocol/workflow-run-get.test.ts create mode 100644 packages/sim-cli/src/commands/protocol/workflow-run-get.ts diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index d9123d10fc5..0b2544745db 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -8,6 +8,7 @@ import { attachLogsFollow } from './logs-follow' import { attachResourceDirectoryCommands } from './resource-directory' import { attachTableImport } from './tables-import' import { attachWorkflowRunFollow } from './workflow-run-follow' +import { attachWorkflowRunGet } from './workflow-run-get' import { attachWorkflowRunWait } from './workflow-run-wait' import { attachWorkspaceOperationWait } from './workspace-operation-wait' @@ -58,11 +59,13 @@ export function attachProtocolCommands(program: Command): void { folders: 'listWorkflowFolders', createFolder: 'createWorkflowFolder', }) - // Both augment commands the generated pass already built — `run` gains - // `--follow`, and `runs` gains `wait` — so they must attach after it, which is - // the order `buildProgram` calls them in. + // All three augment commands the generated pass already built — `run` gains + // `--follow`, `runs get` gains block names, and `runs` gains `wait` — so they + // must attach after it, which is the order `buildProgram` calls them in. attachWorkflowRunFollow(workflows) - attachWorkflowRunWait(group(workflows, 'runs')) + const runs = group(workflows, 'runs') + attachWorkflowRunGet(runs) + attachWorkflowRunWait(runs) attachWorkspaceOperationWait(group(group(program, 'workspaces'), 'operations')) diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts index 5e1de4ce648..42280151640 100644 --- a/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts +++ b/packages/sim-cli/src/commands/protocol/workflow-run-follow.ts @@ -411,9 +411,8 @@ function followOrDelegate(previous: ((args: unknown[]) => unknown) | null) { if (flags.follow !== true) { // A queued run has produced nothing to select from, so the server would - // answer 400; failing locally names the recovery. The finished-run - // resource speaks a different dialect — it matches block ids only, so - // repeating the block names typed here would fail a second time. + // answer 400; failing locally names the recovery: the finished run is read + // with `runs get`, which takes the same block names (`workflow-run-get.ts`). if ( Array.isArray(flags.selectOutput) && flags.selectOutput.length > 0 && diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-get.test.ts b/packages/sim-cli/src/commands/protocol/workflow-run-get.test.ts new file mode 100644 index 00000000000..24b4f24620f --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/workflow-run-get.test.ts @@ -0,0 +1,182 @@ +/** + * @vitest-environment node + */ +import { Command } from 'commander' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SimApiError } from '../../http/client' +import { buildGeneratedCommands } from '../../runtime/build' +import { attachWorkflowRunGet } from './workflow-run-get' + +const { output, request } = vi.hoisted(() => ({ + output: { format: 'json' }, + request: vi.fn(), +})) + +vi.mock('../../context', () => ({ + clientFrom: () => ({ + client: { request, requireWorkspace: () => 'ws_local' }, + profile: { + workspaceId: 'ws_local', + output: output.format, + name: 'default', + apiKey: 'k', + endpoint: 'https://sim.example', + }, + }), +})) + +const WORKFLOW_ID = '00000000-0000-4000-8000-00000000000a' +const SUMMARIZE_ID = '11111111-1111-4111-8111-111111111111' +const SAVE_ID = '22222222-2222-4222-8222-222222222222' +const RUN_ID = 'run_1' +const RUN_PATH = `/api/v2/workflows/${WORKFLOW_ID}/runs/${RUN_ID}` +const STATE_PATH = `/api/v2/workflows/${WORKFLOW_ID}/state` + +/** The draft graph as `GET …/state` answers it, with the two names a caller might type. */ +const STATE = { + data: { + blocks: { + [SUMMARIZE_ID]: { id: SUMMARIZE_ID, name: 'Summarize Result', type: 'agent' }, + [SAVE_ID]: { id: SAVE_ID, name: 'save', type: 'function' }, + }, + }, +} + +function program(): Command { + const root = new Command('sim') + for (const command of buildGeneratedCommands()) root.addCommand(command) + const workflows = root.commands.find((command) => command.name() === 'workflows') + const runs = workflows?.commands.find((command) => command.name() === 'runs') + if (!runs) throw new Error('workflows runs group missing') + attachWorkflowRunGet(runs) + const override = (command: Command) => { + command.exitOverride() + command.commands.forEach(override) + } + override(root) + return root +} + +async function get(...argv: string[]): Promise { + await program().parseAsync([ + 'node', + 'sim', + 'workflows', + 'runs', + 'get', + RUN_ID, + '--workflow', + WORKFLOW_ID, + ...argv, + ]) +} + +/** Answers the graph read with the graph and every other read with `run`. */ +function answer(run: unknown): void { + request.mockImplementation(async (path: string) => (path === STATE_PATH ? STATE : run)) +} + +function stdout(): () => string { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + return () => spy.mock.calls.map((call) => call.map(String).join(' ')).join('\n') +} + +beforeEach(() => { + output.format = 'json' + request.mockReset() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('sim workflows runs get --select-output', () => { + it('sends an id-headed selection through the generated path untouched', async () => { + answer({ data: { runId: RUN_ID, status: 'completed', blockOutputs: {} } }) + stdout() + + await get('--select-output', `${SUMMARIZE_ID}.result`) + + // One read: the graph is never fetched for a selection the run resource + // already answers. + expect(request).toHaveBeenCalledTimes(1) + const [path, options] = request.mock.calls[0] + expect(path).toBe(RUN_PATH) + expect(options.query).toEqual({ selectedOutputs: `${SUMMARIZE_ID}.result` }) + }) + + it('reads nothing extra without a selection', async () => { + answer({ data: { runId: RUN_ID, status: 'completed' } }) + stdout() + + await get('--include-output') + + expect(request).toHaveBeenCalledTimes(1) + expect(request.mock.calls[0][0]).toBe(RUN_PATH) + expect(request.mock.calls[0][1].query).toEqual({ includeOutput: true }) + }) + + it('resolves block names against the workflow, the way workflows run does', async () => { + answer({ + data: { + runId: RUN_ID, + status: 'completed', + blockOutputs: { [`${SUMMARIZE_ID}.text`]: 'A summary', [SAVE_ID]: { ok: true } }, + }, + }) + const read = stdout() + + // `summarizeresult` is `Summarize Result` under the executor's rule: case, + // spaces and dots do not count. + await get('--select-output', 'summarizeresult.text', 'Save') + + expect(request.mock.calls.map(([path]) => path)).toEqual([STATE_PATH, RUN_PATH]) + expect(request.mock.calls[1][1].query).toEqual({ + selectedOutputs: `${SUMMARIZE_ID}.text,${SAVE_ID}`, + }) + // Keyed by what was typed, as `workflows run` keys its own `blockOutputs`. + expect(JSON.parse(read()).blockOutputs).toEqual({ + 'summarizeresult.text': 'A summary', + Save: { ok: true }, + }) + }) + + it('keeps an id-headed selector as is beside a name', async () => { + answer({ data: { runId: RUN_ID, status: 'completed', blockOutputs: {} } }) + stdout() + + await get('--select-output', `${SAVE_ID}.rows`, 'Summarize Result.text') + + expect(request.mock.calls[1][1].query).toEqual({ + selectedOutputs: `${SAVE_ID}.rows,${SUMMARIZE_ID}.text`, + }) + }) + + it('carries the other flags through with a resolved name', async () => { + answer({ data: { runId: RUN_ID, status: 'completed', blockOutputs: {} } }) + stdout() + + await get('--include-output', '--select-output', 'save.result') + + expect(request.mock.calls[1][1].query).toEqual({ + includeOutput: true, + selectedOutputs: `${SAVE_ID}.result`, + }) + }) + + it('refuses a name no block carries, says names are accepted, and lists them', async () => { + answer({ data: { runId: RUN_ID, status: 'completed' } }) + + const failure = await get('--select-output', 'summarise.text', SAVE_ID).catch( + (error: unknown) => error + ) + + expect(failure).toBeInstanceOf(SimApiError) + expect((failure as SimApiError).message).toBe( + `--select-output did not resolve to any block on this run: summarise.text. Pass a block id or its name — "blockId", "blockId.path", "blockName" or "blockName.path"; names match ignoring case, spaces and dots. Blocks on workflow ${WORKFLOW_ID}: Summarize Result, save.` + ) + // Only the graph was read; the run was never asked for a selection it + // cannot answer. + expect(request.mock.calls.map(([path]) => path)).toEqual([STATE_PATH]) + }) +}) diff --git a/packages/sim-cli/src/commands/protocol/workflow-run-get.ts b/packages/sim-cli/src/commands/protocol/workflow-run-get.ts new file mode 100644 index 00000000000..91c01cf17b1 --- /dev/null +++ b/packages/sim-cli/src/commands/protocol/workflow-run-get.ts @@ -0,0 +1,250 @@ +import type { Command } from 'commander' +import { clientFrom } from '../../context' +import { CLI_CONTRACT } from '../../contract/commands' +import { V2_OPERATIONS } from '../../generated/v2-api' +import { resolvePath, SimApiError, type SimClient } from '../../http/client' +import { retypeApiError } from '../../runtime/naming' +import { buildRequest, readListValues } from '../../runtime/request' +import { renderResult } from '../../runtime/result' +import type { OperationSpec } from '../../runtime/types' + +/** + * A well-formed UUID of any version, which is what every block id is. + * + * The same test the run resource applies (`isValidUuid`) before deciding a + * selector head is a name it cannot resolve. + */ +const BLOCK_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +const SELECT_OUTPUT_FLAG = 'select-output' + +/** + * A block name as the executor compares one: lowercased, whitespace and dots + * removed. + * + * `normalizeWorkflowBlockName` in `@sim/workflow-types`, restated. It is the + * rule `workflows run --select-output` resolves names with server-side and the + * one every block-name conflict check applies, so `Summarize Result` and + * `summarizeresult` name the same block on both commands. Restated rather than + * imported because this package ships standalone and carries no workspace + * dependency. + */ +function normalizeBlockName(name: string): string { + return name.toLowerCase().replace(/\s+/g, '').replace(/\./g, '') +} + +interface WorkflowBlock { + id: string + name: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * The workflow's blocks, read from its draft graph. + * + * The run resource reads a recorded run and never loads the workflow, so it + * matches ids only; the draft graph is where the names live. A block renamed + * since the run still resolves — to its id, which is what the recording keyed + * on — and a block deleted since does not, which the refusal lists. + */ +async function loadWorkflowBlocks(client: SimClient, workflowId: string): Promise { + const operation = V2_OPERATIONS.getWorkflowState + const raw = await client.request(resolvePath(operation.path, { workflowId }), { + method: operation.method, + }) + const state = isRecord(raw) && isRecord(raw.data) ? raw.data : raw + const blocks = isRecord(state) && isRecord(state.blocks) ? Object.entries(state.blocks) : [] + return blocks.map(([key, block]) => ({ + id: isRecord(block) && typeof block.id === 'string' ? block.id : key, + name: isRecord(block) && typeof block.name === 'string' ? block.name : '', + })) +} + +/** A selector split at its first dot: the block reference, and the path into that block's output. */ +function splitSelector(selector: string): { head: string; path: string } { + const dot = selector.indexOf('.') + return dot === -1 + ? { head: selector, path: '' } + : { head: selector.slice(0, dot), path: selector.slice(dot) } +} + +function isIdHeaded(selector: string): boolean { + return BLOCK_ID.test(splitSelector(selector).head) +} + +interface ResolvedSelection { + /** The selection as the run resource takes it: every head a block id. */ + resolved: string[] + /** What the caller typed for each resolved selector, so the answer is keyed the way it was asked. */ + typedBy: Map +} + +/** + * Rewrites each name-headed selector onto the block it names; id-headed ones + * pass through untouched, the way the run resource already took them. + * + * Mirrors the server's `resolveOutputBlockRef`: an exact id first, then the one + * block whose normalized name matches. Every miss is collected before refusing, + * so a selection with two typos is fixed in one round rather than two. + */ +function resolveSelection( + typed: readonly string[], + blocks: readonly WorkflowBlock[], + workflowId: string +): ResolvedSelection { + const resolved: string[] = [] + const typedBy = new Map() + const unresolved: string[] = [] + + for (const selector of typed) { + const { head, path } = splitSelector(selector) + let blockId = head + if (!BLOCK_ID.test(head)) { + const wanted = normalizeBlockName(head) + const matches = blocks.filter( + (block) => block.id === head || normalizeBlockName(block.name) === wanted + ) + if (matches.length === 0) { + unresolved.push(selector) + continue + } + // Duplicate normalized names are refused at write time, so this is the + // graph disagreeing with its own rule; the ids are the only safe spelling. + if (matches.length > 1) { + throw new SimApiError( + `--${SELECT_OUTPUT_FLAG} ${selector} names ${matches.length} blocks (${matches.map((block) => block.id).join(', ')}); pass the block id instead`, + 0 + ) + } + blockId = matches[0].id + } + const rewritten = `${blockId}${path}` + resolved.push(rewritten) + if (!typedBy.has(rewritten)) typedBy.set(rewritten, selector) + } + + if (unresolved.length > 0) { + const names = blocks.map((block) => block.name).filter((name) => name !== '') + throw new SimApiError( + `--${SELECT_OUTPUT_FLAG} did not resolve to any block on this run: ${unresolved.join(', ')}. Pass a block id or its name — "blockId", "blockId.path", "blockName" or "blockName.path"; names match ignoring case, spaces and dots. Blocks on workflow ${workflowId}: ${names.length > 0 ? names.join(', ') : 'none'}.`, + 0 + ) + } + + return { resolved, typedBy } +} + +/** + * Keys the answer by what the caller typed. + * + * The run resource keys `blockOutputs` by the selector it received, which is + * the id-headed rewrite; handing that back would make `summarize.result` come + * out as `.result` — a key the caller never wrote and a `jq` path they + * cannot predict. `workflows run` keys by the caller's own selector, and this + * matches it. + */ +function keyByTyped(payload: unknown, typedBy: ReadonlyMap): unknown { + if (!isRecord(payload) || !isRecord(payload.blockOutputs)) return payload + const blockOutputs: Record = {} + for (const [key, value] of Object.entries(payload.blockOutputs)) { + blockOutputs[typedBy.get(key) ?? key] = value + } + return { ...payload, blockOutputs } +} + +/** + * Reads the run with a selection that named at least one block. + * + * The generated path is not delegated to here because the answer has to be + * re-keyed before it is rendered (see {@link keyByTyped}), and rendering is the + * generated handler's last act. Everything else follows it: the same request + * builder, the same result renderer, and the same field-spelling retype on a + * server refusal. + */ +async function readRunByName(runId: string, typed: string[], command: Command): Promise { + const flags = command.optsWithGlobals() as Record + const { client, profile } = clientFrom(command) + const operation = V2_OPERATIONS.getWorkflowRun as OperationSpec + const spec = CLI_CONTRACT.getWorkflowRun ?? {} + + // Built once before anything is fetched, so a flag the generated path would + // refuse is refused the same way — and before a request is spent on the + // workflow's blocks. Rebuilt below once the names are ids. + buildRequest('getWorkflowRun', [runId], flags, profile.workspaceId) + const workflowId = String(flags.workflow) + const selection = resolveSelection( + typed, + await loadWorkflowBlocks(client, workflowId), + workflowId + ) + const request = buildRequest( + 'getWorkflowRun', + [runId], + { ...flags, selectOutput: selection.resolved }, + profile.workspaceId + ) + + let result: { data?: unknown } | undefined + try { + result = await client.request<{ data?: unknown }>(request.path, { + method: operation.method, + headers: request.headers, + query: request.query, + body: request.body, + }) + } catch (error) { + throw retypeApiError(error, 'getWorkflowRun', spec, operation) + } + renderResult( + 'getWorkflowRun', + profile.output, + keyByTyped(result?.data ?? result, selection.typedBy), + spec, + {}, + result + ) +} + +/** + * Teaches the generated `workflows runs get` leaf the block names + * `workflows run --select-output` already takes. + * + * The run resource matches recorded block ids only — it never loads the + * workflow — so `--select-output summarize.result`, the spelling the run was + * started with, came back `400` and the fix was to go and look up an id. The + * lookup is done here instead: a selection with any name-headed selector reads + * the workflow's blocks once, rewrites names onto ids, and reads the run keyed + * the way it was asked. A selection of ids alone, or no selection, still runs + * the generated handler byte for byte. Commander offers no way to read the + * action it holds, so it is captured and delegated to, as `--follow` does. + */ +export function attachWorkflowRunGet(runs: Command): void { + const get = runs.commands.find((command) => command.name() === 'get') + const held = (get as (Command & { _actionHandler?: unknown }) | undefined)?._actionHandler + if (!get || typeof held !== 'function') { + throw new Error( + 'workflows runs get must be registered before block names can be attached to it' + ) + } + const previous = held as (args: unknown[]) => unknown + + get.action(async (runId: string, _options: unknown, command: Command): Promise => { + const raw: unknown = (command.optsWithGlobals() as Record).selectOutput + if (raw === undefined) { + await previous(command.processedArgs) + return + } + // Expanded here, once: a `@-` source cannot be read twice, and the + // generated path would read it again if it still saw the `@`. + const typed = readListValues(raw, SELECT_OUTPUT_FLAG) + command.setOptionValue('selectOutput', typed) + if (typed.every(isIdHeaded)) { + await previous(command.processedArgs) + return + } + await readRunByName(runId, typed, command) + }) +} diff --git a/packages/sim-cli/src/contract/commands.test.ts b/packages/sim-cli/src/contract/commands.test.ts index bda09721a58..7fb6dd1e42b 100644 --- a/packages/sim-cli/src/contract/commands.test.ts +++ b/packages/sim-cli/src/contract/commands.test.ts @@ -693,15 +693,17 @@ describe('help and gates state what is actually true', () => { expect(help).toContain('Not available with --async') }) - it('promises the dialect a finished run actually matches', () => { - // The same flag name on two resources: `workflows run` resolves block - // names against the live workflow, `workflows runs get` reads a recorded - // run and matches ids only. The help used to promise names on both. + it('promises block names on the finished run, the dialect workflows run takes', () => { + // The same flag name on two resources: the run resource itself matches ids + // only, but `workflow-run-get.ts` resolves names against the workflow's + // blocks before asking it, so the help promises what `workflows run` + // promises instead of sending the caller off to look up an id. const help = flatHelp('workflows', 'runs', 'get') expect(help).toContain('--select-output ') - expect(help).toContain('blockId') - expect(help).not.toMatch(/blockName|agent_1\.content/) + expect(help).toContain('blockName.path') + expect(help).toContain('agent_1.content') + expect(help).not.toMatch(/not resolved|ids only/) }) it('offers no negation for the retry that must travel alone', () => { @@ -739,6 +741,28 @@ describe('the import cancel refuses through commander, not just in the contract' expect(mockRequest).not.toHaveBeenCalled() }) + it('names the output columns a group delete takes with it, and what it leaves', async () => { + // The group is the least of what goes: its output columns and every value + // in them are deleted too, while the workflow it dispatched to is a + // separate resource. Both halves are what the caller has to weigh. + const refusal = await runLeaf([ + 'tables', + 'groups', + 'delete', + 'tbl_1', + '--group-id', + 'grp_1', + ]).then( + () => '', + (error: Error) => error.message + ) + + expect(refusal).toBe( + 'This deletes the group AND its output columns with all of their row data; the workflow it pointed at is untouched. Re-run with --yes to confirm.' + ) + expect(mockRequest).not.toHaveBeenCalled() + }) + it('still lets an export cancellation through, since it discards no work', async () => { await runLeaf(['tables', 'exports', 'cancel', 'tbl-1', 'exp-1']) expect(mockRequest).toHaveBeenCalled() diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 22369d1f1c2..61270ce9a6f 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -343,9 +343,10 @@ export const CLI_CONTRACT: CliContract = { }, deleteTableView: { confirm: 'This deletes the saved view and its filters.' }, deleteWorkflowGroup: { - // Not just the grouping: the documented behaviour is that every column the - // group fed goes with it, values included. - confirm: 'This deletes the group, every column it fed, and the values in them.', + // Not just the grouping: the group's output columns go with it, row data + // included. The workflow it dispatched to is a separate resource and stays. + confirm: + 'This deletes the group AND its output columns with all of their row data; the workflow it pointed at is untouched.', fields: [ { header: 'id' }, { header: 'deleted', format: 'bool' }, @@ -1650,8 +1651,8 @@ export const CLI_CONTRACT: CliContract = { hidden: true, describe: 'Low-level workflow state and entry-point selection', }, - // The dialect differs from the one `workflows runs get` takes (names - // resolve here, ids only there), which is why both describes name theirs. + // `workflows runs get` takes the same names (`workflow-run-get.ts` resolves + // them against the draft graph), which is why both describes name theirs. selectedOutputs: { name: 'select-output', list: true, @@ -1690,14 +1691,14 @@ export const CLI_CONTRACT: CliContract = { boolean: true, describe: 'Include the final output in JSON or YAML output', }, - // A finished run is read back without loading the workflow, so the - // recorded block ids are all there is to match against — the block names - // `workflows run --select-output` accepts are rejected here. + // The run resource matches recorded block ids only, so block names are + // resolved against the workflow's blocks before the request is made + // (`workflow-run-get.ts`) — the flag reads like `workflows run`'s. selectedOutputs: { name: 'select-output', list: true, describe: - 'Include blockId or blockId.path values in JSON or YAML output; block names are not resolved on a finished run', + 'Include blockName.path or blockId.path values (e.g. agent_1.content) in JSON or YAML output; names resolve against the workflow’s current blocks, and missing paths are omitted', }, }, fields: [ diff --git a/packages/sim-cli/src/runtime/execute.test.ts b/packages/sim-cli/src/runtime/execute.test.ts index bcd2f5de7cb..1ea8bc5ca1d 100644 --- a/packages/sim-cli/src/runtime/execute.test.ts +++ b/packages/sim-cli/src/runtime/execute.test.ts @@ -10,14 +10,14 @@ import { SimApiError } from '../http/client' import { BULK_OUTCOME_CHECKS, executeOperation } from './execute' import type { OperationSpec } from './types' -const { request } = vi.hoisted(() => ({ request: vi.fn() })) +const { request, output } = vi.hoisted(() => ({ request: vi.fn(), output: { format: 'json' } })) vi.mock('../context', () => ({ clientFrom: () => ({ client: { request, requireWorkspace: () => 'ws_local' }, profile: { workspaceId: 'ws_local', - output: 'json', + output: output.format, name: 'default', apiKey: 'k', endpoint: 'https://sim.example', @@ -109,6 +109,108 @@ function invoke( beforeEach(() => { vi.clearAllMocks() + output.format = 'json' +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +const PUBLISH_CHAT: OperationSpec = { + method: 'PUT', + path: '/api/v2/workflows/[workflowId]/deployments/chat', + pathParams: ['workflowId'], + body: {}, +} + +const PUBLIC_NOTE = + 'note: auth type is public — anyone with the link can chat; pass --auth-type password|email to restrict it.' + +/** Publishes a chat past its `--yes` gate with the required fields and the given extras. */ +function publishChat(flags: Record) { + const host = new Command('leaf') + return executeOperation( + 'replaceWorkflowChatDeployment', + CLI_CONTRACT.replaceWorkflowChatDeployment ?? {}, + PUBLISH_CHAT, + ['wf_1', { yes: true, identifier: 'support', title: 'Support', ...flags }, host] + ) +} + +/** stdout and stderr as strings, captured separately so the note can be placed. */ +function streams() { + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + const stdout = vi.spyOn(console, 'log').mockImplementation(() => {}) + return { + get stderr() { + return stderr.mock.calls.map(([chunk]) => String(chunk)).join('') + }, + get stdout() { + return stdout.mock.calls.map((call) => call.map(String).join(' ')).join('\n') + }, + } +} + +describe('a chat publish that lands on public auth', () => { + it('notes the exposure on stderr when the default chose public, and keeps stdout the record', async () => { + request.mockResolvedValue({ + data: { id: 'chat_1', identifier: 'support', authType: 'public' }, + }) + const captured = streams() + + await publishChat({}) + + expect(request.mock.calls[0][1].body).not.toHaveProperty('authType') + expect(captured.stderr).toContain(PUBLIC_NOTE) + expect(captured.stdout).not.toContain('note:') + expect(JSON.parse(captured.stdout)).toEqual({ + id: 'chat_1', + identifier: 'support', + authType: 'public', + }) + }) + + it('notes an explicit --auth-type public just the same', async () => { + request.mockResolvedValue({ data: { id: 'chat_1', authType: 'public' } }) + const captured = streams() + + await publishChat({ authType: 'public' }) + + expect(request.mock.calls[0][1].body).toMatchObject({ authType: 'public' }) + expect(captured.stderr).toContain(PUBLIC_NOTE) + }) + + it('notes in the human format too, still on stderr', async () => { + output.format = 'table' + request.mockResolvedValue({ data: { id: 'chat_1', authType: 'public' } }) + const captured = streams() + + await publishChat({}) + + expect(captured.stderr).toContain(PUBLIC_NOTE) + expect(captured.stdout).toContain('chat_1') + expect(captured.stdout).not.toContain('note:') + }) + + it('stays quiet once the chat is restricted', async () => { + request.mockResolvedValue({ data: { id: 'chat_1', authType: 'password' } }) + const captured = streams() + + await publishChat({ authType: 'password', password: 'hunter2' }) + + expect(captured.stderr).not.toContain('note:') + }) + + it('falls back to what was sent when the response omits the gate', async () => { + request.mockResolvedValue({ data: { id: 'chat_1' } }) + const captured = streams() + + await publishChat({ authType: 'password', password: 'hunter2' }) + expect(captured.stderr).not.toContain('note:') + + await publishChat({}) + expect(captured.stderr).toContain(PUBLIC_NOTE) + }) }) describe('workspace mutation receipt identity', () => { diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 06f78715585..5cd2467bb40 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -1,4 +1,5 @@ import { getErrorMessage } from '@sim/utils/errors' +import chalk from 'chalk' import type { Command } from 'commander' import { assertWorkspaceOperationOutcome, @@ -195,6 +196,53 @@ function lengthOf(value: unknown): number { return Array.isArray(value) ? value.length : 0 } +/** + * One line of context a successful result deserves, on stderr, in every format. + * + * `workflows chat publish` defaults `authType` to `public`, so a caller who + * never typed `--auth-type` has just put a chat on the open internet, and the + * result — `authType: public` among a dozen other fields of the record — does + * not make that leap out. The note is printed whether the default or an + * explicit `--auth-type public` chose it: the exposure is the same either way. + * stderr, so `sim workflows chat publish … --output json | jq` still reads + * exactly the record; every format gets it because a JSON consumer is the one + * least likely to look at the record. + * + * Judged on the response first and the request second: the server states what + * it stored, and a body that omitted the field landed on the server's default. + */ +const RESULT_NOTES: Readonly< + Partial< + Record< + V2OperationName, + (payload: Record, body: Record | undefined) => string | null + > + > +> = { + replaceWorkflowChatDeployment: (payload, body) => { + const authType = payload.authType ?? body?.authType ?? 'public' + return authType === 'public' + ? 'note: auth type is public — anyone with the link can chat; pass --auth-type password|email to restrict it.' + : null + }, +} + +/** Writes the operation's result note to stderr, when it has one and the result calls for it. */ +function writeResultNote( + operation: V2OperationName, + payload: unknown, + body: Record | undefined +): void { + const note = RESULT_NOTES[operation] + if (!note) return + const record = + payload && typeof payload === 'object' && !Array.isArray(payload) + ? (payload as Record) + : {} + const message = note(record, body) + if (message) process.stderr.write(chalk.dim(`${message}\n`)) +} + /** The one-line explanation of a bulk call that changed nothing, or `null`. */ function bulkFailureMessage( operation: V2OperationName, @@ -562,6 +610,7 @@ export async function executeOperation( // its own truncation there, and unwrapping `data` discarded it. result ) + writeResultNote(operation, payload, request.body) // Printed first, then failed, for the reason `followRun` gives: the envelope // carries the block outputs that explain *why* the run failed, and exiting diff --git a/packages/sim-cli/src/runtime/request.ts b/packages/sim-cli/src/runtime/request.ts index 1d738eb5298..cb26e168cb7 100644 --- a/packages/sim-cli/src/runtime/request.ts +++ b/packages/sim-cli/src/runtime/request.ts @@ -264,7 +264,7 @@ function isManifestNoise(line: string): boolean { * list drops blank and `#` comment lines read from a file, so a requirements * file can be passed as it is on disk. */ -function readListValues(raw: unknown, flagName: string, manifest = false): string[] { +export function readListValues(raw: unknown, flagName: string, manifest = false): string[] { const arguments_ = Array.isArray(raw) ? raw : [raw] const values = arguments_.flatMap((argument) => { if (typeof argument !== 'string') { From 39f5913d46eacd9922d35ed40f2ba6a93959c594 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 18:28:27 +0530 Subject: [PATCH 066/306] null-safe block.error, strict start-trigger coercion, lint checks reference paths against output schemas; logs stats segments; requiredWhen and model ids in the agent catalog; live tag usage; one row-filter grammar; minted service-account credential ids; webhook delivery URLs on deployment status; mv semantics for folder moves Executor: resolves empty on a block that succeeded so a shared error collector is writable; a number input given a non-numeric string fails the run at start naming the field; a regression test pins that failure traces carry child spans. Lint: a reference whose first path segment is not on the block's effective outputs (or its responseFormat schema) is an unknown-field finding. API: logs stats honours segmentCount and omits empty buckets unless includeEmpty; block detail publishes requiredWhen instead of flattening conditional requirements and always lists model options with hosted marks; knowledge tag usage counts through the document slot; every table rows filter accepts a bare condition; service-account credentials mint their id; deployment status lists each webhook's delivery URL; a folder move into an existing folder moves it inside. OpenAPI and CLI generated types regenerated. --- apps/docs/openapi-v2-logs.json | 29 +- apps/docs/openapi-v2-resources.json | 12 +- apps/docs/openapi-v2-tables.json | 309 ++++++++++++++++-- apps/docs/openapi-v2-workflows.json | 49 ++- apps/sim/app/api/v2/credentials/route.test.ts | 64 ++++ apps/sim/app/api/v2/logs/stats/route.test.ts | 22 ++ apps/sim/app/api/v2/logs/stats/route.ts | 1 + .../[workflowId]/deployment/route.test.ts | 47 +++ .../[workflowId]/deployment/route.ts | 6 + .../executor/execution/failure-trace.test.ts | 77 +++++ apps/sim/executor/utils/block-reference.ts | 42 ++- apps/sim/executor/utils/start-block.test.ts | 75 +++++ apps/sim/executor/utils/start-block.ts | 85 ++++- .../variables/resolvers/block.test.ts | 53 +++ .../contracts/v2/__tests__/logs-stats.test.ts | 13 + .../api/contracts/v2/__tests__/tables.test.ts | 28 ++ apps/sim/lib/api/contracts/v2/catalog.ts | 10 +- apps/sim/lib/api/contracts/v2/credentials.ts | 15 +- apps/sim/lib/api/contracts/v2/logs-stats.ts | 32 +- apps/sim/lib/api/contracts/v2/openapi/logs.ts | 2 +- .../lib/api/contracts/v2/openapi/workflows.ts | 10 +- apps/sim/lib/api/contracts/v2/tables.ts | 25 +- apps/sim/lib/api/contracts/v2/workflows.ts | 41 ++- .../catalog/projection/catalog-sweep.test.ts | 37 +++ .../projection/projection-invariants.test.ts | 67 ++++ apps/sim/lib/catalog/projection/subblock.ts | 119 +++++-- .../application/provider-catalog.ts | 8 +- apps/sim/lib/folders/orchestration.test.ts | 105 ++++++ apps/sim/lib/folders/orchestration.ts | 34 +- apps/sim/lib/folders/paths.ts | 22 ++ apps/sim/lib/knowledge/tags/service.test.ts | 37 +++ apps/sim/lib/knowledge/tags/service.ts | 3 +- .../sim/lib/logs/application/get-log-stats.ts | 7 + .../log-analytics-use-cases.test.ts | 18 + apps/sim/lib/logs/stats.test.ts | 35 ++ apps/sim/lib/logs/stats.ts | 32 +- .../sim/lib/table/application/folders.test.ts | 54 ++- apps/sim/lib/table/application/folders.ts | 8 +- .../workspace-file-folder-manager.test.ts | 79 +++++ .../workspace-file-folder-manager.ts | 41 ++- apps/sim/lib/webhooks/deployed-urls.test.ts | 76 +++++ apps/sim/lib/webhooks/deployed-urls.ts | 77 +++++ .../lib/workflows/application/deployments.ts | 3 + .../workflows/editing/dangling-refs.test.ts | 220 ++++++++++++- apps/sim/lib/workflows/editing/lint.ts | 152 ++++++++- packages/sim-cli/src/generated/v2-api.ts | 151 ++++++++- 46 files changed, 2274 insertions(+), 158 deletions(-) create mode 100644 apps/sim/executor/execution/failure-trace.test.ts create mode 100644 apps/sim/lib/webhooks/deployed-urls.test.ts create mode 100644 apps/sim/lib/webhooks/deployed-urls.ts diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 1f6b7dbe211..38d0382f567 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -522,6 +522,31 @@ "minimum": 1, "maximum": 500 } + }, + { + "name": "includeEmpty", + "in": "query", + "required": false, + "description": "Whether buckets with no runs are included in every series. Off by default, so each series carries only the buckets that hold at least one run; set it to publish exactly `segmentCount` buckets per series, empty ones included. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "schema": { + "default": false, + "description": "Whether buckets with no runs are included in every series. Off by default, so each series carries only the buckets that hold at least one run; set it to publish exactly `segmentCount` buckets per series, empty ones included. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "type": "string" + } } ], "responses": { @@ -1756,7 +1781,7 @@ "items": { "$ref": "#/components/schemas/V2LogStatsSegment" }, - "description": "One entry per bucket, in order, including buckets with no runs." + "description": "Buckets in time order. Only the buckets with at least one run unless `includeEmpty` was set, in which case every bucket appears, empty ones included." }, "totalExecutions": { "type": "number", @@ -1829,7 +1854,7 @@ "items": { "$ref": "#/components/schemas/V2LogStatsSegment" }, - "description": "Workspace-wide totals per bucket, in the same order as each workflow series." + "description": "Workspace-wide totals per bucket, in time order. Subject to the same `includeEmpty` rule as each workflow series: empty buckets are omitted unless asked for." }, "totalRuns": { "type": "number", diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 533dcf0f5f3..54b25355f32 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -7814,7 +7814,7 @@ }, "requiresClientGeneratedCredentialId": { "type": "boolean", - "description": "Whether the caller must generate and submit the credential ID before setup." + "description": "Whether the caller must generate and submit the credential ID before setup. False for every provider: credential creation mints an ID when none is supplied, and a Slack custom bot may still send one to configure its Request URL ahead of time." }, "fields": { "minItems": 1, @@ -8072,7 +8072,7 @@ "maxLength": 500 }, "id": { - "description": "Required only when provider discovery requests a client-generated ID.", + "description": "Optional client-generated credential ID. The server mints one when it is omitted, so no provider requires it. A `slack-custom-bot` credential may supply one so its Slack Request URL, which embeds the ID, can be configured before the credential exists; every other provider ignores it.", "type": "string", "format": "uuid", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" @@ -9755,7 +9755,7 @@ "type": "string" }, "required": { - "description": "Whether a value must be supplied. A conditionally required field reports `true` and carries `requiredWhen`.", + "description": "Whether a value must always be supplied. A field required only under some configuration reports `false` and carries `requiredWhen`.", "type": "boolean" }, "requiredWhen": { @@ -9783,7 +9783,7 @@ "$ref": "#/components/schemas/V2CatalogCondition" }, "options": { - "description": "Selectable options. Absent on fields whose options are fetched per workspace at edit time.", + "description": "Selectable options. Absent on fields whose options are fetched per workspace at edit time. A `model` field always carries its options, with `hosted` marking the models a hosted deployment runs without an author-supplied key.", "type": "array", "items": { "type": "object", @@ -9799,6 +9799,10 @@ "hasIcon": { "description": "Whether the option renders with an icon. The icon itself is not published.", "type": "boolean" + }, + "hosted": { + "description": "Model options only: whether Sim runs the model with its own key on a hosted deployment, so no provider API key is needed for it.", + "type": "boolean" } }, "required": ["id"], diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 171e1034032..38fdcea0416 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -6799,6 +6799,275 @@ } ] }, + "UpdateTableRowsRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." + }, + "filter": { + "$ref": "#/components/schemas/TablePredicateInput" + }, + "data": { + "description": "Row-data patch applied to every matching row.", + "$ref": "#/components/schemas/V2TableRowData" + }, + "limit": { + "description": "Maximum matching rows to update.", + "type": "integer", + "minimum": 1, + "maximum": 1000 + } + }, + "required": ["workspaceId", "filter", "data"], + "additionalProperties": false, + "title": "Update table rows request", + "description": "Workspace scope, typed predicate, and row-data patch." + }, + "V2DeleteRowsData": { + "type": "object", + "properties": { + "deletedCount": { + "type": "number", + "description": "Number of deleted rows." + }, + "deletedRowIds": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Identifiers of deleted rows." + }, + "requestedCount": { + "description": "Number of row identifiers requested.", + "type": "number" + }, + "missingRowIds": { + "description": "Requested row identifiers not found.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["deletedCount", "deletedRowIds"], + "additionalProperties": false, + "title": "Delete rows data", + "description": "Result of a bulk row deletion." + }, + "V2DeleteTableRowsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2DeleteRowsData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete table rows response", + "description": "Deleted row counts, identifiers, and optional missing identifiers." + }, + "DeleteTableRowsRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." + }, + "filter": { + "$ref": "#/components/schemas/TablePredicateInput" + }, + "limit": { + "description": "Maximum matching rows to delete.", + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "rowIds": { + "description": "Explicit row identifiers to delete.", + "minItems": 1, + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "Delete table rows request", + "description": "Workspace scope and exactly one of a predicate or row identifier list." + }, + "V2TableRowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2ApiTableRow" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Table row response", + "description": "A single table row." + }, + "UpdateTableRowRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." + }, + "data": { + "description": "Partial row-data patch keyed by column name.", + "$ref": "#/components/schemas/V2TableRowData" + } + }, + "required": ["workspaceId", "data"], + "additionalProperties": false, + "title": "Update table row request", + "description": "Workspace scope and row-data patch keyed by column name.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "data": { + "status": "active" + } + } + ] + }, + "V2DeleteRowData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the deleted row." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Confirms that the row was deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete row data", + "description": "Row deletion acknowledgement." + }, + "V2DeleteTableRowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2DeleteRowData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete table row response", + "description": "Row deletion acknowledgement." + }, + "V2UpsertRowData": { + "type": "object", + "properties": { + "row": { + "description": "The inserted or updated table row.", + "$ref": "#/components/schemas/V2ApiTableRow" + }, + "operation": { + "type": "string", + "enum": ["insert", "update"], + "description": "Whether the row was inserted or updated." + } + }, + "required": ["row", "operation"], + "additionalProperties": false, + "title": "Upsert row data", + "description": "Row returned by an upsert and the operation performed." + }, + "V2UpsertTableRowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2UpsertRowData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Upsert table row response", + "description": "The resulting row and whether it was inserted or updated." + }, + "UpsertTableRowRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Unique workspace identifier." + }, + "data": { + "description": "Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges.", + "$ref": "#/components/schemas/V2TableRowData" + }, + "conflictTarget": { + "description": "Unique column used to detect a conflict.", + "type": "string", + "minLength": 1 + } + }, + "required": ["workspaceId", "data"], + "additionalProperties": false, + "title": "Upsert table row request", + "description": "Workspace scope, row data, and optional unique-column conflict target.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "data": { + "email": "jane@example.com", + "status": "active" + }, + "conflictTarget": "email" + } + ] + }, + "V2QueryTableRowsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2ApiTableRow" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Query table rows response", + "description": "A cursor-paginated page of matching table rows." + }, "QueryTableRowsRequest": { "type": "object", "properties": { @@ -8191,7 +8460,7 @@ } }, "filter": { - "$ref": "#/components/schemas/TablePredicate" + "$ref": "#/components/schemas/TablePredicateInput" }, "excludeRowIds": { "description": "Rows excluded from a select-all run scope.", @@ -8224,13 +8493,7 @@ "required": ["workspaceId", "groupIds"], "additionalProperties": false, "title": "Create table dispatch request", - "description": "Workspace scope, producer groups, execution mode, and optional row scope.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "groupIds": ["grp_5d8b2f0a6c1e4739a8b3d5f7e9c1a204"] - } - ] + "description": "Workspace scope, producer groups, execution mode, and optional row scope." }, "V2RunRowEnrichmentResponse": { "type": "object", @@ -8336,7 +8599,7 @@ "description": "Case-insensitive cell substring to find." }, "predicate": { - "$ref": "#/components/schemas/TablePredicate" + "$ref": "#/components/schemas/TablePredicateInput" }, "sort": { "description": "Ordered table-row sort specification.", @@ -8365,22 +8628,7 @@ "required": ["workspaceId", "q"], "additionalProperties": false, "title": "Search table rows request", - "description": "Workspace scope, substring query, and optional predicate and sort.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "q": "acme", - "predicate": { - "all": [ - { - "field": "status", - "op": "eq", - "value": "active" - } - ] - } - } - ] + "description": "Workspace scope, substring query, and optional predicate and sort." }, "V2TableUploadImportSource": { "type": "object", @@ -9555,7 +9803,7 @@ "minLength": 1 }, "filter": { - "$ref": "#/components/schemas/TablePredicate" + "$ref": "#/components/schemas/TablePredicateInput" }, "excludeRowIds": { "description": "Rows excluded from an all-scope cancellation.", @@ -9570,14 +9818,7 @@ "required": ["workspaceId", "scope"], "additionalProperties": false, "title": "Cancel table runs request", - "description": "Workspace scope, cancellation scope, and optional predicate or producer groups.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "scope": "row", - "rowId": "row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93" - } - ] + "description": "Workspace scope, cancellation scope, and optional predicate or producer groups." }, "V2Folder": { "type": "object", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 0203629bf87..beb340cdf16 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -9690,6 +9690,43 @@ "properties": {}, "additionalProperties": false }, + "WorkflowDeploymentWebhook": { + "type": "object", + "properties": { + "blockId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Trigger block the URL delivers to, or null for a legacy row that recorded none." + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Webhook provider the endpoint verifies inbound requests against, e.g. `generic`, `github`, or `slack`." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Absolute URL for the external system to send events to.", + "examples": ["https://www.sim.ai/api/webhooks/trigger/leads"] + } + }, + "required": ["blockId", "provider", "url"], + "additionalProperties": false, + "title": "Deployed webhook", + "description": "The public delivery URL of one webhook the live deployment registered." + }, "WorkflowDeployment": { "type": "object", "properties": { @@ -9761,7 +9798,8 @@ "activeDeployment", "latestDeploymentAttempt", "needsRedeployment", - "isPublicApi" + "isPublicApi", + "webhooks" ], "additionalProperties": false, "title": "Workflow deployment", @@ -9808,7 +9846,14 @@ "requestedAt": "2026-06-12T10:29:58.000Z", "activatedAt": "2026-06-12T10:30:00.000Z", "error": null - } + }, + "webhooks": [ + { + "blockId": "blk_01J8ZK3QW4M6X2R9T7B5C0V3", + "provider": "generic", + "url": "https://www.sim.ai/api/webhooks/trigger/leads" + } + ] } } ] diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index 33ee438a12b..0c76cd84f18 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -293,6 +293,70 @@ describe('POST /api/v2/credentials', () => { }) }) + /** + * `credentials create slack-custom-bot` used to fail twice over: discovery + * demanded a client-generated id, and a caller who then supplied a slug was + * refused for not sending a UUID. The id is optional on every provider — the + * server mints one — while an explicit UUID keeps working for a caller that + * configured its Slack Request URL ahead of time. + */ + it('accepts a Slack custom bot without an id and leaves minting to the server', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'slack-custom-bot', + credentials: JSON.stringify({ signingSecret: 'sign', botToken: 'xoxb-1' }), + }), + }) + ) + + expect(response.status).toBe(201) + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + providerId: 'slack-custom-bot', + id: undefined, + signingSecret: 'sign', + botToken: 'xoxb-1', + }), + }) + ) + }) + + it('forwards an explicit UUID id and refuses a slug in its place', async () => { + const body = (id: string) => + new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'slack-custom-bot', + id, + credentials: JSON.stringify({ signingSecret: 'sign', botToken: 'xoxb-1' }), + }), + }) + + const accepted = await POST(body('7c9e6679-7425-40de-944b-e07fc1f90ae7')) + expect(accepted.status).toBe(201) + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ id: '7c9e6679-7425-40de-944b-e07fc1f90ae7' }), + }) + ) + + const refused = await POST(body('my-slack-bot')) + expect(refused.status).toBe(400) + expect(await refused.json()).toMatchObject({ + error: { message: expect.stringContaining('id must be a valid UUID') }, + }) + expect(mocks.create).toHaveBeenCalledTimes(1) + }) + it('rejects an unknown service-account provider before the use case', async () => { const response = await POST( new NextRequest('http://localhost:3000/api/v2/credentials', { diff --git a/apps/sim/app/api/v2/logs/stats/route.test.ts b/apps/sim/app/api/v2/logs/stats/route.test.ts index e4608d44ea2..3fefb3d0998 100644 --- a/apps/sim/app/api/v2/logs/stats/route.test.ts +++ b/apps/sim/app/api/v2/logs/stats/route.test.ts @@ -87,6 +87,28 @@ describe('GET /api/v2/logs/stats', () => { ) }) + it('omits empty buckets by default and forwards an explicit includeEmpty', async () => { + await GET(request()) + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ includeEmpty: false }) }) + ) + + await GET(request('&includeEmpty=true')) + expect(mocks.execute).toHaveBeenLastCalledWith( + expect.objectContaining({ input: expect.objectContaining({ includeEmpty: true }) }) + ) + }) + + it('rejects an includeEmpty spelling outside the published vocabulary', async () => { + const response = await GET(request('&includeEmpty=maybe')) + + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + error: { code: 'BAD_REQUEST', message: expect.stringContaining('expected one of') }, + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + /** * Each of these produced a 500 before the bounds landed: `0` divided by zero, * `1e9` allocated two billion-element arrays, and a fraction indexed between diff --git a/apps/sim/app/api/v2/logs/stats/route.ts b/apps/sim/app/api/v2/logs/stats/route.ts index feb4ede5921..9409192d7f8 100644 --- a/apps/sim/app/api/v2/logs/stats/route.ts +++ b/apps/sim/app/api/v2/logs/stats/route.ts @@ -32,6 +32,7 @@ export const GET = defineV2JsonRoute({ }, folderPaths: parseUnorderedList(query.folderPaths), segmentCount: query.segmentCount, + includeEmpty: query.includeEmpty, }), useCase: getLogStats, present: ({ stats, workflowsTruncated }) => ({ data: { ...stats, workflowsTruncated } }), diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/deployment/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/deployment/route.test.ts index 386b06422f6..781c5eb037f 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/deployment/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deployment/route.test.ts @@ -20,6 +20,7 @@ const { MockPublicApiNotAllowedError, mocks } = vi.hoisted(() => { resolvePermission: vi.fn(), resolveWorkflowContext: vi.fn(), getWorkflowDeploymentSummary: vi.fn(), + listWebhookUrls: vi.fn(), checkNeedsRedeployment: vi.fn(), validatePublicApiAllowed: vi.fn(), updatePublicApiRow: vi.fn(), @@ -70,6 +71,9 @@ vi.mock('@/lib/workflows/orchestration/deploy', () => ({ vi.mock('@/lib/workflows/deployment-status', () => ({ checkNeedsRedeployment: mocks.checkNeedsRedeployment, })) +vi.mock('@/lib/webhooks/deployed-urls', () => ({ + listDeployedWebhookUrls: mocks.listWebhookUrls, +})) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) @@ -146,6 +150,7 @@ describe('GET /api/v2/workflows/[workflowId]/deployment', () => { warnings: undefined, }) mocks.checkNeedsRedeployment.mockResolvedValue(true) + mocks.listWebhookUrls.mockResolvedValue([]) }) it('publishes draft-versus-live drift and the latest attempt after canonical authorization', async () => { @@ -162,11 +167,53 @@ describe('GET /api/v2/workflows/[workflowId]/deployment', () => { warnings: [], activeDeployment, latestDeploymentAttempt, + webhooks: [], }, }) expect(mocks.resolveWorkflowContext).toHaveBeenCalledBefore(mocks.getWorkflowDeploymentSummary) }) + /** + * `webhookUrlDisplay` reads back as `null` after a successful deploy, so the + * deployment read is where a caller learns the URL the deploy started + * serving. Only a live deployment has one; nothing is read while undeployed. + */ + it('publishes the resolved delivery URL of every live webhook', async () => { + mocks.listWebhookUrls.mockResolvedValue([ + { + blockId: 'block-1', + provider: 'generic', + url: 'https://sim.test/api/webhooks/trigger/leads', + }, + ]) + + const response = await get() + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.webhooks).toEqual([ + { + blockId: 'block-1', + provider: 'generic', + url: 'https://sim.test/api/webhooks/trigger/leads', + }, + ]) + expect(mocks.listWebhookUrls).toHaveBeenCalledWith('workflow-1') + }) + + it('publishes no webhook URLs, and reads none, while nothing is live', async () => { + mocks.getWorkflowDeploymentSummary.mockResolvedValue({ + activeDeployment: null, + latestDeploymentAttempt: null, + warnings: [], + }) + + const body = await (await get()).json() + + expect(body.data.webhooks).toEqual([]) + expect(mocks.listWebhookUrls).not.toHaveBeenCalled() + }) + it('carries the failed attempt error payload when nothing is live', async () => { mocks.getWorkflowDeploymentSummary.mockResolvedValue({ activeDeployment: null, diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/deployment/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/deployment/route.ts index b473aebb4cd..180a82b8df9 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/deployment/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deployment/route.ts @@ -25,6 +25,11 @@ export const revalidate = 0 * that removed authentication from a deployed workflow had no way to audit * that it was still off. * + * `webhooks` carries the resolved delivery URL of every webhook the live + * version registered. The block's `webhookUrlDisplay` is computed in the + * editor and reads back as `null`, so this is the only place a caller can + * learn the URL its deploy just started serving. + * * `deployedAt` comes from the active deployment version, which always carries * one. The workflow's own `deployed_at` column is deliberately not used as a * fallback: it retains the timestamp of a deployment that has since been @@ -52,6 +57,7 @@ export const GET = defineV2JsonRoute({ warnings: result.warnings ?? [], activeDeployment: result.activeDeployment ?? null, latestDeploymentAttempt: result.latestDeploymentAttempt ?? null, + webhooks: result.webhooks, }, }), }) diff --git a/apps/sim/executor/execution/failure-trace.test.ts b/apps/sim/executor/execution/failure-trace.test.ts new file mode 100644 index 00000000000..4b0aa15a66a --- /dev/null +++ b/apps/sim/executor/execution/failure-trace.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' +import { DAGExecutor } from '@/executor/execution/executor' +import { hasExecutionResult } from '@/executor/utils/errors' +import type { SerializedWorkflow } from '@/serializer/types' + +vi.mock('@/lib/execution/cancellation', () => ({ + subscribeToExecutionCancellation: vi.fn(async () => () => {}), + isExecutionCancelled: vi.fn(async () => false), +})) +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + validateBlockType: vi.fn(async () => {}), +})) + +/** + * Start → Call, where Call fails while its inputs resolve: `` is not + * a Start output, so the executor raises `InvalidFieldError` before the block's + * handler runs. No network, no sandbox — the failure is the executor's own. + */ +const workflow: SerializedWorkflow = { + version: '1', + blocks: [ + { + id: 'start', + position: { x: 0, y: 0 }, + config: { tool: 'start_trigger', params: {} }, + inputs: {}, + outputs: {}, + metadata: { id: 'start_trigger', name: 'Start', category: 'triggers' }, + enabled: true, + }, + { + id: 'api', + position: { x: 0, y: 0 }, + config: { tool: 'http_request', params: { url: '', method: 'GET' } }, + inputs: {}, + outputs: {}, + metadata: { id: 'api', name: 'Call' }, + enabled: true, + }, + ], + connections: [{ source: 'start', target: 'api' }], + loops: {}, + parallels: {}, +} + +describe('failed run trace', () => { + it('carries every block that ran, with the failing block marked error', async () => { + const executor = new DAGExecutor({ + workflow, + contextExtensions: { workspaceId: 'ws', executionId: 'exec', userId: 'u' }, + }) + + const thrown = await executor.execute('wf').then( + () => undefined, + (error: unknown) => error + ) + expect(thrown).toBeInstanceOf(Error) + /** The throw carries the run, so a failure can be traced like a success. */ + expect(hasExecutionResult(thrown)).toBe(true) + if (!hasExecutionResult(thrown)) return + + const { traceSpans } = buildTraceSpans(thrown.executionResult) + expect(traceSpans).toHaveLength(1) + const [root] = traceSpans + expect(root).toMatchObject({ name: 'Workflow Execution', status: 'error' }) + expect(root!.children?.map((span) => span.blockId)).toEqual(['start', 'api']) + + const failing = root!.children?.find((span) => span.blockId === 'api') + expect(failing).toMatchObject({ status: 'error', name: 'Call' }) + expect(failing?.output?.error).toMatch(/"nope" doesn't exist on block "start"/) + expect(thrown.executionResult.error).toBe(thrown.message) + }) +}) diff --git a/apps/sim/executor/utils/block-reference.ts b/apps/sim/executor/utils/block-reference.ts index 95a49d3d0de..1122b5e7820 100644 --- a/apps/sim/executor/utils/block-reference.ts +++ b/apps/sim/executor/utils/block-reference.ts @@ -206,6 +206,34 @@ function getSchemaFieldNames(schema: OutputSchema | undefined): string[] { return Object.keys(schema) } +/** + * The output field the executor writes on every block that fails, regardless of + * the block's declared schema. Declared here as a string node so `` + * validates like any declared field: a failed block resolves it to the message, + * a successful block resolves it empty instead of raising `InvalidFieldError` — + * which is what lets one error collector sit downstream of several error edges. + */ +const IMPLICIT_ERROR_OUTPUT_SCHEMA: OutputSchema = { error: { type: 'string' } } + +/** + * Whether a path that the declared schema rejects is valid through the implicit + * `error` output every block carries. + */ +function isImplicitErrorPath(pathParts: string[]): boolean { + return pathParts[0] === 'error' && isPathInSchema(IMPLICIT_ERROR_OUTPUT_SCHEMA, pathParts) +} + +function assertPathInSchema( + blockName: string, + pathParts: string[], + schema: OutputSchema | undefined +): void { + if (!schema || isPathInSchema(schema, pathParts) || isImplicitErrorPath(pathParts)) { + return + } + throw new InvalidFieldError(blockName, pathParts.join('.'), getSchemaFieldNames(schema)) +} + export function resolveBlockReference( blockName: string, pathParts: string[], @@ -238,11 +266,8 @@ export function resolveBlockReference( const value = navigatePath(blockOutput, pathParts, options) - const schema = context.blockOutputSchemas?.[blockId] - if (value === undefined && schema) { - if (!isPathInSchema(schema, pathParts)) { - throw new InvalidFieldError(blockName, pathParts.join('.'), getSchemaFieldNames(schema)) - } + if (value === undefined) { + assertPathInSchema(blockName, pathParts, context.blockOutputSchemas?.[blockId]) } return { value, blockId } @@ -273,11 +298,8 @@ export async function resolveBlockReferenceAsync( const value = await navigatePathAsync(blockOutput, pathParts, resolutionContext) - const schema = context.blockOutputSchemas?.[blockId] - if (value === undefined && schema) { - if (!isPathInSchema(schema, pathParts)) { - throw new InvalidFieldError(blockName, pathParts.join('.'), getSchemaFieldNames(schema)) - } + if (value === undefined) { + assertPathInSchema(blockName, pathParts, context.blockOutputSchemas?.[blockId]) } return { value, blockId } diff --git a/apps/sim/executor/utils/start-block.test.ts b/apps/sim/executor/utils/start-block.test.ts index fdb3bdcd347..30e3f4b2252 100644 --- a/apps/sim/executor/utils/start-block.test.ts +++ b/apps/sim/executor/utils/start-block.test.ts @@ -5,6 +5,7 @@ import { buildResolutionFromBlock, buildStartBlockOutput, resolveExecutorStartBlock, + StartInputValidationError, } from '@/executor/utils/start-block' import type { SerializedBlock } from '@/serializer/types' @@ -950,4 +951,78 @@ describe('start-block utilities', () => { expect(output.metadata).toEqual(runMetadata) }) }) + + describe('declared field type validation', () => { + function unifiedResolution(inputFormat: Array>) { + const block = createBlock('start_trigger', 'start', { + subBlocks: { inputFormat: { value: inputFormat } }, + }) + return { blockId: 'start', block, path: StartBlockPath.UNIFIED } as const + } + + it.concurrent( + 'fails the run at start when a number field receives a non-numeric string', + () => { + const resolution = unifiedResolution([{ name: 'count', type: 'number' }]) + + expect(() => + buildStartBlockOutput({ resolution, workflowInput: { count: 'not-a-number' } }) + ).toThrow(StartInputValidationError) + expect(() => + buildStartBlockOutput({ resolution, workflowInput: { count: 'not-a-number' } }) + ).toThrow( + 'Start block "block-start_trigger" field "count" expects a number but received "not-a-number"' + ) + expect(() => + buildStartBlockOutput({ resolution, workflowInput: { count: 'Infinity' } }) + ).toThrow(StartInputValidationError) + } + ) + + it.concurrent( + 'fails the run at start when a boolean field receives an arbitrary string', + () => { + const resolution = unifiedResolution([{ name: 'enabled', type: 'boolean' }]) + + expect(() => + buildStartBlockOutput({ resolution, workflowInput: { enabled: 'yes' } }) + ).toThrow( + 'Start block "block-start_trigger" field "enabled" expects true or false but received "yes"' + ) + } + ) + + it.concurrent( + 'still coerces well-formed strings and keeps the unset-default path lenient', + () => { + const resolution = unifiedResolution([ + { name: 'count', type: 'number', value: '' }, + { name: 'enabled', type: 'boolean', value: '' }, + ]) + + const coerced = buildStartBlockOutput({ + resolution, + workflowInput: { count: '42', enabled: 'false' }, + }) + expect(coerced.count).toBe(42) + expect(coerced.enabled).toBe(false) + + expect(() => buildStartBlockOutput({ resolution, workflowInput: {} })).not.toThrow() + } + ) + + it.concurrent('does not validate an input format the chat path never reads', () => { + const block = createBlock('chat_trigger', 'chat', { + subBlocks: { inputFormat: { value: [{ name: 'count', type: 'number' }] } }, + }) + const resolution = { blockId: 'chat', block, path: StartBlockPath.SPLIT_CHAT } as const + + expect(() => + buildStartBlockOutput({ + resolution, + workflowInput: { input: 'hi', count: 'not-a-number' }, + }) + ).not.toThrow() + }) + }) }) diff --git a/apps/sim/executor/utils/start-block.ts b/apps/sim/executor/utils/start-block.ts index 87ad0770986..cbce1205e9b 100644 --- a/apps/sim/executor/utils/start-block.ts +++ b/apps/sim/executor/utils/start-block.ts @@ -1,4 +1,6 @@ import { isRecordLike } from '@sim/utils/object' +import { truncate } from '@sim/utils/string' +import { HttpError } from '@/lib/core/utils/http-error' import { extractWorkspaceIdFromStorageKey, inferContextFromKey, @@ -274,15 +276,88 @@ export function coerceValue(type: string | null | undefined, value: unknown): un } } +/** + * A Start input the declared field type cannot represent. + * + * Raised before any block runs, so the run fails at start naming the field and + * the value it received instead of continuing on a value of the wrong type — + * a `number` field handed `"not-a-number"` used to keep the string and branch + * downstream conditions on it. 400 because the payload, not the workflow, is + * what is malformed; the message names only caller-authored values. + */ +export class StartInputValidationError extends HttpError { + readonly statusCode = 400 + + constructor(message: string) { + super(message) + this.name = 'StartInputValidationError' + } +} + +/** Maximum characters of a rejected value quoted back in the error message. */ +const REJECTED_VALUE_PREVIEW_LENGTH = 80 + +function describeRejectedValue(value: unknown): string { + const encoded = typeof value === 'string' ? JSON.stringify(value) : String(value) + return truncate(encoded, REJECTED_VALUE_PREVIEW_LENGTH) +} + +/** + * Coerces one declared Start field and rejects a value its type cannot hold. + * + * {@link coerceValue} is deliberately lenient (it also seeds editor defaults), + * so on its own a `number` field given `"abc"` kept the string and a `boolean` + * field given `"yes"` kept that. Here the coerced value is held to the declared + * type. An empty string is left to the lenient path because the editor stores + * `''` as a field's unset default, so rejecting it would fail every run that + * omits an optional field. + */ +function coerceDeclaredValue( + field: InputFormatField, + fieldName: string, + value: unknown, + block: SerializedBlock | undefined +): unknown { + const coerced = coerceValue(field.type, value) + if (block === undefined || coerced === undefined || coerced === null) { + return coerced + } + if (typeof value === 'string' && value.trim() === '') { + return coerced + } + + const holdsDeclaredType = + field.type === 'number' + ? typeof coerced === 'number' && Number.isFinite(coerced) + : field.type === 'boolean' + ? typeof coerced === 'boolean' + : true + if (holdsDeclaredType) { + return coerced + } + + const blockName = block.metadata?.name ?? block.id + const expected = field.type === 'number' ? 'a number' : 'true or false' + throw new StartInputValidationError( + `Start block "${blockName}" field "${fieldName}" expects ${expected} but received ${describeRejectedValue(value)}. Send ${expected} or omit the field.` + ) +} + interface DerivedInputResult { structuredInput: Record finalInput: unknown hasStructured: boolean } +/** + * `validatingBlock` is the Start block whose declared field types the values + * are held to; `undefined` keeps the lenient coercion for paths that never read + * the structured input (the chat path ignores its `inputFormat` entirely). + */ function deriveInputFromFormat( inputFormat: InputFormatField[], - workflowInput: unknown + workflowInput: unknown, + validatingBlock: SerializedBlock | undefined ): DerivedInputResult { const structuredInput: Record = {} @@ -315,7 +390,7 @@ function deriveInputFromFormat( fieldValue = field.value } - structuredInput[fieldName] = coerceValue(field.type, fieldValue) + structuredInput[fieldName] = coerceDeclaredValue(field, fieldName, fieldValue, validatingBlock) } const hasStructured = Object.keys(structuredInput).length > 0 @@ -672,7 +747,8 @@ export function buildStartBlockOutput(options: StartBlockOutputOptions): Normali ? getSerializedLegacyStarterMode(resolution.block) : null - if (pathConsumesInputFormat(resolution.path, legacyStarterMode)) { + const consumesInputFormat = pathConsumesInputFormat(resolution.path, legacyStarterMode) + if (consumesInputFormat) { assertNoReservedInputFormatFields(inputFormat, resolution.block) if (runMetadataEnabled) { assertNoMetadataInputFormatField(inputFormat, resolution.block) @@ -681,7 +757,8 @@ export function buildStartBlockOutput(options: StartBlockOutputOptions): Normali const { finalInput, structuredInput, hasStructured } = deriveInputFromFormat( inputFormat, - workflowInput + workflowInput, + consumesInputFormat ? resolution.block : undefined ) let output: NormalizedBlockOutput diff --git a/apps/sim/executor/variables/resolvers/block.test.ts b/apps/sim/executor/variables/resolvers/block.test.ts index c972df2afc7..ef5d9c6fccb 100644 --- a/apps/sim/executor/variables/resolvers/block.test.ts +++ b/apps/sim/executor/variables/resolvers/block.test.ts @@ -500,6 +500,59 @@ describe('BlockResolver', () => { expect(resolver.resolve('', ctx)).toBe(RESOLVED_EMPTY) }) + describe('implicit error output', () => { + /** + * `RESOLVED_EMPTY` is what the variable resolver renders as `null` in + * Function and Condition code and as an empty string in every other field, + * so a successful block's `.error` reads as absent rather than failing the + * referencing block. + */ + it.concurrent('resolves empty on a successful block whose schema omits error', () => { + const workflow = createTestWorkflow([{ id: 'guard', type: 'function' }]) + const resolver = new BlockResolver(workflow) + const ctx = createTestContext('current', { + guard: { result: { ok: true }, stdout: '' }, + }) + + expect(resolver.resolve('', ctx)).toBe(RESOLVED_EMPTY) + }) + + it.concurrent('resolves the message on a failed block', () => { + const workflow = createTestWorkflow([{ id: 'guard', type: 'function' }]) + const resolver = new BlockResolver(workflow) + const ctx = createTestContext('current', { + guard: { error: 'Guard rejected the payload' }, + }) + + expect(resolver.resolve('', ctx)).toBe('Guard rejected the payload') + }) + + it.concurrent('resolves empty through the async path too', async () => { + const workflow = createTestWorkflow([{ id: 'guard', type: 'function' }], {}) + const resolver = new BlockResolver(workflow, navigatePathAsync) + const ctx = createTestContext('current', { + guard: { result: { ok: true } }, + }) + + await expect(resolver.resolveAsync('', ctx)).resolves.toBe(RESOLVED_EMPTY) + }) + + it.concurrent('still rejects an unrelated unknown field', () => { + const workflow = createTestWorkflow([{ id: 'guard', type: 'function' }]) + const resolver = new BlockResolver(workflow) + const ctx = createTestContext('current', { + guard: { result: { ok: true } }, + }) + + expect(() => resolver.resolve('', ctx)).toThrow( + '"errors" doesn\'t exist on block "guard". Available fields: result, stdout' + ) + expect(() => resolver.resolve('', ctx)).toThrow( + /"error.code" doesn't exist on block "guard"/ + ) + }) + }) + it.concurrent( 'should allow hiddenFromDisplay fields for pre-execution schema validation', () => { diff --git a/apps/sim/lib/api/contracts/v2/__tests__/logs-stats.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/logs-stats.test.ts index 2e29ce72eb7..297704f8e21 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/logs-stats.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/logs-stats.test.ts @@ -44,6 +44,19 @@ describe.each([ }) describe('v2LogStatsQuerySchema', () => { + it('defaults includeEmpty off and reads the closed spellings case-sensitively', () => { + expect(v2LogStatsQuerySchema.parse({ workspaceId: WORKSPACE_ID }).includeEmpty).toBe(false) + expect( + v2LogStatsQuerySchema.parse({ workspaceId: WORKSPACE_ID, includeEmpty: 'true' }).includeEmpty + ).toBe(true) + expect( + v2LogStatsQuerySchema.parse({ workspaceId: WORKSPACE_ID, includeEmpty: 'false' }).includeEmpty + ).toBe(false) + expect( + v2LogStatsQuerySchema.safeParse({ workspaceId: WORKSPACE_ID, includeEmpty: 'TRUE' }).success + ).toBe(false) + }) + it('names the failing field and the bound', () => { const parsed = v2LogStatsQuerySchema.safeParse({ workspaceId: WORKSPACE_ID, diff --git a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts index fa7ea3725d1..f1b265c2ad3 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/tables.test.ts @@ -16,12 +16,14 @@ import { v2ApiTableSchema, v2BulkDeleteTablesBodySchema, v2BulkUpdateRowsBodySchema, + v2CancelTableRunsBodySchema, v2CreateTableBodySchema, v2CreateTableColumnBodySchema, v2CreateTableImportBodySchema, v2CreateTableRowsBodySchema, v2CsvImportCreateColumnsSchema, v2CsvImportMappingSchema, + v2DeleteTableRowsBodySchema, v2GetTableDispatchContract, v2GetTableImportContract, v2GetTableRowQuerySchema, @@ -30,11 +32,13 @@ import { v2QueryRowsBodySchema, v2QueryRowsCountBodySchema, v2RestoreTableContract, + v2RunColumnBodySchema, v2SearchRowsBodySchema, v2SearchRowsDataSchema, v2TableImportStatusSchema, v2TableRowsQuerySchema, v2TableUploadImportSourceSchema, + v2UpdateRowsByPredicateBodySchema, v2UpdateTableColumnBodySchema, v2UpdateWorkflowGroupBodySchema, } from '@/lib/api/contracts/v2/tables' @@ -258,6 +262,30 @@ describe('v2 table request bodies', () => { predicate: { all: [condition] }, }) }) + + /** + * One filter grammar across the rows endpoints. The bulk, run, cancel, and + * search bodies used to demand an `all`/`any` root while query and count took + * a bare condition, so the same predicate had to be spelled two ways depending + * on the endpoint. Every filter field now takes either form and normalizes a + * bare condition to `{ all: [condition] }`; a grouped filter still parses. + */ + it.each([ + ['batch-update', 'filter', v2UpdateRowsByPredicateBodySchema, { data: { status: 'done' } }], + ['batch-delete', 'filter', v2DeleteTableRowsBodySchema, {}], + ['run', 'filter', v2RunColumnBodySchema, { groupIds: ['group-1'] }], + ['cancel-runs', 'filter', v2CancelTableRunsBodySchema, { scope: 'all' }], + ['search', 'predicate', v2SearchRowsBodySchema, { q: 'needle' }], + ])('normalizes a bare condition on the rows %s body', (_name, key, schema, extra) => { + const condition = { field: 'status', op: 'eq', value: 'active' } + + expect(schema.parse({ workspaceId: WORKSPACE_ID, ...extra, [key]: condition })).toMatchObject({ + [key]: { all: [condition] }, + }) + expect( + schema.parse({ workspaceId: WORKSPACE_ID, ...extra, [key]: { any: [condition] } }) + ).toMatchObject({ [key]: { any: [condition] } }) + }) }) function uploadSource(size: number) { diff --git a/apps/sim/lib/api/contracts/v2/catalog.ts b/apps/sim/lib/api/contracts/v2/catalog.ts index 9337541fab5..6c5710edf94 100644 --- a/apps/sim/lib/api/contracts/v2/catalog.ts +++ b/apps/sim/lib/api/contracts/v2/catalog.ts @@ -108,7 +108,7 @@ export const v2BlockFieldSchema = z .boolean() .optional() .describe( - 'Whether a value must be supplied. A conditionally required field reports `true` and carries `requiredWhen`.' + 'Whether a value must always be supplied. A field required only under some configuration reports `false` and carries `requiredWhen`.' ), requiredWhen: v2CatalogConditionSchema .optional() @@ -134,11 +134,17 @@ export const v2BlockFieldSchema = z .boolean() .optional() .describe('Whether the option renders with an icon. The icon itself is not published.'), + hosted: z + .boolean() + .optional() + .describe( + 'Model options only: whether Sim runs the model with its own key on a hosted deployment, so no provider API key is needed for it.' + ), }) ) .optional() .describe( - 'Selectable options. Absent on fields whose options are fetched per workspace at edit time.' + 'Selectable options. Absent on fields whose options are fetched per workspace at edit time. A `model` field always carries its options, with `hosted` marking the models a hosted deployment runs without an author-supplied key.' ), min: z.number().optional().describe('Minimum accepted numeric value.'), max: z.number().optional().describe('Maximum accepted numeric value.'), diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 459cf13c7dd..500c8b1d8ed 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -150,7 +150,9 @@ export const v2ServiceAccountCredentialProviderSchema = z helpText: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'), requiresClientGeneratedCredentialId: z .boolean() - .describe('Whether the caller must generate and submit the credential ID before setup.'), + .describe( + 'Whether the caller must generate and submit the credential ID before setup. False for every provider: credential creation mints an ID when none is supplied, and a Slack custom bot may still send one to configure its Request URL ahead of time.' + ), fields: z .array(v2CredentialProviderFieldSchema) .min(1) @@ -543,7 +545,9 @@ export const v2CreateServiceAccountCredentialBodySchema = z .string() .uuid('id must be a valid UUID') .optional() - .describe('Required only when provider discovery requests a client-generated ID.'), + .describe( + `Optional client-generated credential ID. The server mints one when it is omitted, so no provider requires it. A \`${SLACK_CUSTOM_BOT_PROVIDER_ID}\` credential may supply one so its Slack Request URL, which embeds the ID, can be configured before the credential exists; every other provider ignores it.` + ), credentials: v2ServiceAccountCredentialsJsonSchema, }) .strict() @@ -556,13 +560,6 @@ export const v2CreateServiceAccountCredentialBodySchema = z }) return } - if (body.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID && !body.id) { - ctx.addIssue({ - code: 'custom', - path: ['id'], - message: `id is required for ${SLACK_CUSTOM_BOT_PROVIDER_ID} credentials`, - }) - } for (const field of getServiceAccountRequiredFields(body.providerId)) { if (!body.credentials[field]) { ctx.addIssue({ diff --git a/apps/sim/lib/api/contracts/v2/logs-stats.ts b/apps/sim/lib/api/contracts/v2/logs-stats.ts index 9408cac6729..57cfe35dbff 100644 --- a/apps/sim/lib/api/contracts/v2/logs-stats.ts +++ b/apps/sim/lib/api/contracts/v2/logs-stats.ts @@ -3,7 +3,9 @@ import { MAX_STATS_SEGMENT_COUNT, MAX_STATS_WORKFLOWS } from '@/lib/api/contract import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { + V2_FALSE_VALUES, V2_FOLDER_FILTER_MISS, + V2_TRUE_VALUES, v2DataResponse, v2FolderPathInputSchema, v2RunWindowBoundSchema, @@ -34,9 +36,28 @@ const v2SegmentCountSchema = z.coerce .optional() .default(DEFAULT_SEGMENT_COUNT) .describe( - `Number of time buckets, up to ${MAX_STATS_SEGMENT_COUNT}. Exactly this many are returned, each at least one minute wide. Short windows extend past the requested end and include empty trailing buckets.` + `Number of equal time buckets to divide the window into, from 1 to ${MAX_STATS_SEGMENT_COUNT}. It is the ceiling on how many buckets a series carries: with \`includeEmpty=true\` exactly this many are returned, otherwise only the buckets holding at least one run. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty.` ) +/** + * Whether buckets with no runs are published. + * + * Off by default: five runs on the default 72-bucket window used to answer + * with 67 zero rows per series, tens of kilobytes that carried no information + * the totals did not. A caller charting the series can ask for the dense form. + * `z.stringbool({ case: 'sensitive' })` rather than `z.coerce.boolean()`, which + * reads `includeEmpty=false` as `true` — see `booleanQueryFlagSchema` in + * `contracts/primitives.ts`. + */ +const v2IncludeEmptySegmentsSchema = z + .stringbool({ case: 'sensitive' }) + .optional() + .default(false) + .describe( + 'Whether buckets with no runs are included in every series. Off by default, so each series carries only the buckets that hold at least one run; set it to publish exactly `segmentCount` buckets per series, empty ones included. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.' + ) + .meta({ enum: [...V2_TRUE_VALUES, ...V2_FALSE_VALUES] }) + const v2LogSegmentSchema = z .object({ timestamp: v2TimestampSchema.describe('ISO 8601 start of the bucket.'), @@ -64,7 +85,9 @@ const v2WorkflowLogStatsSchema = z workflowName: z.string().describe('Workflow name, or `Deleted Workflow`.'), segments: z .array(v2LogSegmentSchema) - .describe('One entry per bucket, in order, including buckets with no runs.'), + .describe( + 'Buckets in time order. Only the buckets with at least one run unless `includeEmpty` was set, in which case every bucket appears, empty ones included.' + ), totalExecutions: z.number().describe('Runs for this workflow across the window.'), totalSuccessful: z.number().describe('Runs for this workflow that did not error.'), overallSuccessRate: z @@ -93,7 +116,9 @@ export const v2LogStatsSchema = z ), aggregateSegments: z .array(v2LogSegmentSchema) - .describe('Workspace-wide totals per bucket, in the same order as each workflow series.'), + .describe( + 'Workspace-wide totals per bucket, in time order. Subject to the same `includeEmpty` rule as each workflow series: empty buckets are omitted unless asked for.' + ), totalRuns: z.number().describe('Runs in the window across the whole workspace.'), totalErrors: z.number().describe('Runs in the window that errored.'), avgLatency: z @@ -186,6 +211,7 @@ export const v2LogStatsQuerySchema = z startDate: v2RunWindowBoundSchema('startDate').optional(), endDate: v2RunWindowBoundSchema('endDate').optional(), segmentCount: v2SegmentCountSchema, + includeEmpty: v2IncludeEmptySegmentsSchema, }) .strict() .refine( diff --git a/apps/sim/lib/api/contracts/v2/openapi/logs.ts b/apps/sim/lib/api/contracts/v2/openapi/logs.ts index be8383b036b..b737f4da880 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/logs.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/logs.ts @@ -218,7 +218,7 @@ const declaredRoutes = [ applicationOperation: logOperations.readStats, operationId: 'getLogStats', summary: 'Get Log Statistics', - description: `Get run counts, success and error counts, and latency by workspace or workflow. Default bounds span recorded runs, or the last 24 hours when empty. Buckets may extend past the end. Folder filters include descendants; \`workflowsTruncated\` affects series, not totals. ${RUN_RETENTION} ${FOLDER_TREE_TOO_LARGE}`, + description: `Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans \`startDate\` through \`endDate\` when both are supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding \`endDate\` when only \`endDate\` was supplied. A supplied \`startDate\` is still used verbatim, so a \`startDate\` without an \`endDate\` yields \`[startDate, now]\`, which can be any width. The window is divided into \`segmentCount\` equal buckets whose width is \`max(60000, floor(windowMs / segmentCount))\` milliseconds. Each series carries only the buckets that hold at least one run unless \`includeEmpty\` is set, in which case exactly \`segmentCount\` buckets are returned. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past \`timeBounds.end\` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and \`workflowsTruncated\` reports whether the cap applied; the workspace totals are always computed from every workflow. ${RUN_RETENTION} ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: { description: 'Bucketed execution statistics for the workspace.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 5bd7e3f76c0..eaa89d8a7fc 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -761,8 +761,7 @@ const declaredRoutes = [ applicationOperation: workflowOperations.read, operationId: 'getWorkflowDeployment', summary: 'Get Workflow Deployment', - description: - 'Get the live version, latest deployment attempt, readiness, draft changes (`needsRedeployment`), and public API access. With `isPublicApi: true`, anyone with the execution URL can run the workflow and consume billed usage without an API key. Hosted chat is managed separately.', + description: `Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, whether the editable draft has since diverged from the live version, and the public delivery URL of every webhook the live version registered. This is the only operation that publishes \`needsRedeployment\`, \`isPublicApi\`, and \`webhooks\`.\n\n\`webhooks\` is where a caller learns the URL a webhook-triggered deploy started serving: the block's own URL field is computed in the editor and reads back empty through the API. Trigger blocks that receive events through a shared endpoint with no per-workflow URL are omitted.\n\n\`isPublicApi\` is the security-relevant one: while it is \`true\` the deployed workflow executes without an API key, so anyone holding the execution URL can run it — and consume the workspace’s billed usage — anonymously. It is set through \`PATCH /workflows/{workflowId}/deployment\`, and this read is the only way to audit whether it is on.\n\n${WORKFLOW_DEPLOYMENT_VS_CHAT}`, errors: RESOURCE_ERRORS, success: jsonSuccess('The current deployment state.'), }), @@ -800,6 +799,13 @@ const declaredRoutes = [ activatedAt: '2026-06-12T10:30:00.000Z', error: null, }, + webhooks: [ + { + blockId: 'blk_01J8ZK3QW4M6X2R9T7B5C0V3', + provider: 'generic', + url: 'https://www.sim.ai/api/webhooks/trigger/leads', + }, + ], }, }, ] diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 052e7f74692..53fe943a2ab 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -1097,11 +1097,15 @@ export const v2CreateTableRowsContract = defineRouteContract({ }, }) -/** Bulk update body — v2 accepts ONLY the predicate tree as the filter. */ +/** + * Bulk update body. `filter` speaks the same grammar as the rows query and + * count `predicate`: a bare `{ field, op, value }` condition or an `all`/`any` + * group, normalized to a group. The legacy `$`-operator dialect stays v1-only. + */ export const v2UpdateRowsByPredicateBodySchema = updateRowsByFilterBodySchema .omit(OMIT_PRIVATE_PROVENANCE) .extend({ - filter: predicateSchema, + filter: predicateInputSchema, data: v2RowDataSchema.describe('Row-data patch applied to every matching row.'), }) .strict() @@ -1124,11 +1128,11 @@ export const v2UpdateRowsByFilterContract = defineRouteContract({ }, }) -/** Bulk delete body — either row ids or a predicate-tree filter, never both. */ +/** Bulk delete body — either row ids or a predicate filter, never both. */ export const v2DeleteTableRowsBodySchema = z .object({ workspaceId: workspaceIdSchema, - filter: predicateSchema.optional(), + filter: predicateInputSchema.optional(), limit: z .number({ error: 'Limit must be a number' }) .int('Limit must be an integer') @@ -1799,11 +1803,12 @@ export const v2DeleteWorkflowGroupContract = defineRouteContract({ /** * Run-column body. Identical to the first-party shape except `filter`, which v2 - * narrows to the typed predicate tree — the legacy `$`-operator dialect stays - * v1-only across the whole v2 surface. + * narrows to the typed predicate grammar — a bare condition or a group, as on + * every other rows endpoint. The legacy `$`-operator dialect stays v1-only + * across the whole v2 surface. */ export const v2RunColumnBodySchema = runColumnBodyBaseSchema - .extend({ filter: predicateSchema.optional() }) + .extend({ filter: predicateInputSchema.optional() }) .strict() .refine(...runColumnScopeMutexRefine) .refine(...runColumnExcludeMutexRefine) @@ -1977,7 +1982,7 @@ export const v2SearchRowsBodySchema = z .min(1, 'q must be a non-empty search string') .max(V2_SEARCH_MAX_LENGTH, 'q is too long') .describe('Case-insensitive cell substring to find.'), - predicate: predicateSchema.optional(), + predicate: predicateInputSchema.optional(), sort: sortSpecSchema.optional().describe('Ordered table-row sort specification.'), }) .strict() @@ -2474,10 +2479,10 @@ export const v2TableExportDownloadContract = defineRouteContract({ /** * Cancel-runs body. Identical to the first-party shape except `filter`, which - * v2 narrows to the typed predicate tree. + * v2 narrows to the typed predicate grammar shared by every rows endpoint. */ export const v2CancelTableRunsBodySchema = cancelTableRunsBodyBaseSchema - .extend({ filter: predicateSchema.optional() }) + .extend({ filter: predicateInputSchema.optional() }) .strict() .superRefine((value, ctx) => { for (const issue of refineCancelTableRunsScope(value)) { diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 5e978cdb2d5..6a8aa630d7c 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -366,6 +366,40 @@ export const v2DeploymentStateSchema = z description: 'Current workflow deployment state and lifecycle progress.', }) +/** + * One public URL a live deployment receives events on. + * + * The block's own `webhookUrlDisplay` field is computed client-side and reads + * back as `null` through the API, so until this was published a caller that + * deployed a webhook-triggered workflow had no way to learn where to point the + * sender. + */ +export const v2DeployedWebhookSchema = z + .object({ + blockId: z + .string() + .nullable() + .describe('Trigger block the URL delivers to, or null for a legacy row that recorded none.'), + provider: z + .string() + .nullable() + .describe( + 'Webhook provider the endpoint verifies inbound requests against, e.g. `generic`, `github`, or `slack`.' + ), + url: z + .string() + .url() + .describe('Absolute URL for the external system to send events to.') + .meta({ examples: ['https://www.sim.ai/api/webhooks/trigger/leads'] }), + }) + .strict() + .meta({ + id: 'WorkflowDeploymentWebhook', + title: 'Deployed webhook', + description: 'The public delivery URL of one webhook the live deployment registered.', + }) +export type V2DeployedWebhook = z.output + /** * Read-only deployment state. Extends the shared state with `needsRedeployment`, * which the mutation responses cannot carry: it compares the live graph against @@ -385,6 +419,11 @@ export const v2WorkflowDeploymentSchema = v2DeploymentStateSchema .describe( 'Whether anyone with the execution URL can run the deployed workflow and consume billed usage without an API key. Change this with Update Workflow Public API Access.' ), + webhooks: z + .array(v2DeployedWebhookSchema) + .describe( + 'Public delivery URL of every webhook the live version registered, one per trigger block. Empty while nothing is deployed, and omits trigger blocks that receive events through a shared endpoint with no per-workflow URL.' + ), }) .meta({ id: 'WorkflowDeployment', @@ -2294,7 +2333,7 @@ export const v2ImportWorkflowDataSchema = z blocks: z .array(v2ImportedBlockSchema) .describe( - 'Blocks the import created, in payload order. A summary only; `GET /workflows/{workflowId}/state` returns the full graph.' + 'Blocks the import created, in payload order. A summary only; the workflow state read returns the full graph.' ), }) .extend(v2OperationReportSchema.omit({ workspaceId: true }).partial().shape) diff --git a/apps/sim/lib/catalog/projection/catalog-sweep.test.ts b/apps/sim/lib/catalog/projection/catalog-sweep.test.ts index d7a227f84d7..30379b40607 100644 --- a/apps/sim/lib/catalog/projection/catalog-sweep.test.ts +++ b/apps/sim/lib/catalog/projection/catalog-sweep.test.ts @@ -39,6 +39,7 @@ import { projectToolDetail, projectToolSummaryById } from '@/lib/catalog/project import { buildCustomBlockConfig } from '@/blocks/custom/build-config' import { getBlockRegistry } from '@/blocks/registry' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' +import { getHostedModels } from '@/providers/models' import { getToolIds } from '@/tools/tool-ids' /** @@ -153,6 +154,42 @@ describe('block detail regressions', () => { expect(inputKeys).not.toContain('sortInput') }) + /** + * `blocks get agent` reported `apiKey`, `vertexCredential`, `bedrockAccessKeyId`, + * `conversationId`, … as `required: true`, because each is authored + * `required: true` behind a condition the projection flattened away. A field + * required only under some configuration is optional at the block level, and + * the condition that requires it is published alongside. + */ + it('publishes the agent’s conditionally required fields as optional with their condition', () => { + const detail = projectBlockDetail(registered('agent'), { deployment: HOSTED }) + const byId = new Map(detail.inputSchema.map((field) => [field.id, field])) + + for (const id of ['apiKey', 'vertexCredential', 'vertexProject', 'bedrockAccessKeyId']) { + const field = byId.get(id) + expect(field, id).toBeDefined() + expect(field?.required, id).toBe(false) + expect(field?.requiredWhen, id).toMatchObject({ field: expect.any(String) }) + } + expect(byId.get('conversationId')).toMatchObject({ + required: false, + requiredWhen: { field: expect.any(String) }, + }) + expect(detail.inputSchema.some((field) => field.required === true)).toBe(true) + }) + + it('publishes the agent model picker’s options with the hosted models marked', () => { + const detail = projectBlockDetail(registered('agent'), { deployment: HOSTED }) + const model = detail.inputSchema.find((field) => field.id === 'model') + const hosted = new Set(getHostedModels().map((id) => id.toLowerCase())) + + expect(model?.options?.length).toBeGreaterThan(10) + expect(model?.options?.some((option) => option.hosted === true)).toBe(true) + for (const option of model?.options ?? []) { + expect(option.hosted === true, option.id).toBe(hosted.has(option.id.toLowerCase())) + } + }) + it('publishes a triggers-category block’s trigger-mode fields as its input schema', () => { const detail = projectBlockDetail(registered('schedule'), { deployment: HOSTED }) const ids = detail.inputSchema.map((field) => field.id) diff --git a/apps/sim/lib/catalog/projection/projection-invariants.test.ts b/apps/sim/lib/catalog/projection/projection-invariants.test.ts index e2c8243d923..00769b3b88c 100644 --- a/apps/sim/lib/catalog/projection/projection-invariants.test.ts +++ b/apps/sim/lib/catalog/projection/projection-invariants.test.ts @@ -134,6 +134,73 @@ describe('projections never hand out registry state', () => { }) }) +describe('conditional requirement is published as a condition, not as required', () => { + const field = (overrides: Partial): SubBlockConfig => + ({ id: 'apiKey', type: 'short-input', ...overrides }) as SubBlockConfig + + it('reports a required field gated by a non-operation condition as optional with requiredWhen', () => { + const projected = projectSubBlock( + field({ required: true, condition: { field: 'model', value: ['gpt-4o'], not: true } }) + ) + + expect(projected.required).toBe(false) + expect(projected.requiredWhen).toEqual({ field: 'model', value: ['gpt-4o'], not: true }) + expect(projected.condition).toEqual({ field: 'model', value: ['gpt-4o'], not: true }) + }) + + it('resolves a condition-shaped required to optional plus the clause', () => { + const projected = projectSubBlock( + field({ required: () => ({ field: 'memoryType', value: 'conversation' }) }) + ) + + expect(projected.required).toBe(false) + expect(projected.requiredWhen).toEqual({ field: 'memoryType', value: 'conversation' }) + }) + + it('keeps a field required outright behind an operation gate and with no condition', () => { + expect( + projectSubBlock(field({ required: true, condition: { field: 'operation', value: 'send' } })) + .required + ).toBe(true) + expect(projectSubBlock(field({ required: true })).required).toBe(true) + expect( + projectSubBlock(field({ required: false, condition: { field: 'x', value: 1 } })) + ).not.toHaveProperty('requiredWhen') + }) +}) + +describe('model picker options', () => { + it('falls back to the code-defined model list when the options function yields nothing', () => { + const projected = projectSubBlock({ + id: 'model', + type: 'combobox', + options: () => [], + } as unknown as SubBlockConfig) + + expect(projected.options?.length).toBeGreaterThan(10) + expect(projected.options?.some((option) => option.hosted === true)).toBe(true) + }) + + it('marks hosted models on a resolved option list and leaves other pickers alone', () => { + const model = projectSubBlock({ + id: 'model', + type: 'combobox', + options: () => [{ id: 'gpt-4o', label: 'gpt-4o' }, { id: 'my-local-model' }], + } as unknown as SubBlockConfig) + expect(model.options).toEqual([ + { id: 'gpt-4o', label: 'gpt-4o', hosted: true }, + { id: 'my-local-model' }, + ]) + + const other = projectSubBlock({ + id: 'voice', + type: 'dropdown', + options: [{ id: 'gpt-4o', label: 'gpt-4o' }], + } as unknown as SubBlockConfig) + expect(other.options).toEqual([{ id: 'gpt-4o', label: 'gpt-4o' }]) + }) +}) + describe('options functions must be synchronous', () => { /** * The providers store is substituted process-wide for the duration of the diff --git a/apps/sim/lib/catalog/projection/subblock.ts b/apps/sim/lib/catalog/projection/subblock.ts index 2ddfaf3ff68..b1b9241112e 100644 --- a/apps/sim/lib/catalog/projection/subblock.ts +++ b/apps/sim/lib/catalog/projection/subblock.ts @@ -1,5 +1,6 @@ import type { SubBlockConfig } from '@/blocks/types' -import { DYNAMIC_MODEL_PROVIDERS, PROVIDER_DEFINITIONS } from '@/providers/models' +import { DYNAMIC_MODEL_PROVIDERS, getHostedModels, PROVIDER_DEFINITIONS } from '@/providers/models' +import { useProvidersStore } from '@/stores/providers' /** * Surface-neutral projection of a block's sub-block (its configuration fields) @@ -17,6 +18,11 @@ export interface CatalogSubBlockOption { label?: string /** Whether the option renders with an icon. The icon component itself is never published. */ hasIcon?: boolean + /** + * Model options only: whether Sim runs the model with its own key on a hosted + * deployment, so the author need not supply a provider API key for it. + */ + hosted?: boolean } /** Scalar a condition compares against. */ @@ -47,7 +53,10 @@ export interface CatalogSubBlock { id: string type: string title?: string - /** Whether the field must be supplied. A conditionally-required field reports `true`. */ + /** + * Whether the field must always be supplied. A field whose requirement + * depends on other values reports `false` and carries `requiredWhen`. + */ required?: boolean /** The condition under which the field is required, when requirement is conditional. */ requiredWhen?: CatalogCondition @@ -102,21 +111,67 @@ export function normalizeCondition( return typeof condition === 'function' ? condition() : condition } +/** + * Whether a condition gates its field on the selected operation. + * + * An operation gate is not a conditional requirement: the detail projection + * files such a field under the operation that reveals it, where it is required + * outright. Mirrors `operationGate` in `block-detail`. + */ +function isOperationGate(condition: CatalogCondition): boolean { + return condition.field === 'operation' && !condition.not +} + /** * Whether a field is required, and under what condition. * - * `required` shares the condition shape with `condition`, so a conditionally - * required field resolves to `required: true` plus the clause that decides it — - * never the raw object or function, which is not serializable. + * Two authored shapes mean "required only sometimes", and both resolve to + * `required: false` plus the clause that decides it, never the raw object or + * function, which is not serializable: + * + * - `required` declared as a condition, which shares its shape with `condition`. + * - `required: true` on a field that only *applies* under a `condition` other + * than an operation gate. The agent block's `apiKey` is required, but only + * for a model the platform does not host; `bedrockAccessKeyId` only for a + * Bedrock model; `conversationId` only with memory enabled. Publishing those + * as `required: true` told a caller reading the block that every one of them + * had to be supplied on every agent. */ -function normalizeRequired(required: SubBlockConfig['required']): { +function normalizeRequired( + required: SubBlockConfig['required'], + condition: CatalogCondition | undefined +): { required?: boolean requiredWhen?: CatalogCondition } { if (required === undefined) return {} - if (typeof required === 'boolean') return { required } + if (typeof required === 'boolean') { + if (required && condition && !isOperationGate(condition)) { + return { required: false, requiredWhen: condition } + } + return { required } + } const requiredWhen = typeof required === 'function' ? required() : required - return { required: true, requiredWhen } + return { required: false, requiredWhen } +} + +/** Whether a field is a model picker: the `model` field whose options come from a function. */ +function isModelPickerField(subBlock: SubBlockConfig): boolean { + return subBlock.id === 'model' && typeof subBlock.options === 'function' +} + +/** + * Marks the options a hosted deployment runs on the platform's own keys. + * + * Published only on model pickers, where it answers the question an author + * has when choosing a model — "do I need to bring an API key for this one?" — + * which the block otherwise expresses only through `apiKey`'s condition. + */ +function markHostedModels(options: CatalogSubBlockOption[]): CatalogSubBlockOption[] { + const hosted = new Set(getHostedModels().map((id) => id.toLowerCase())) + return options.map((option) => + hosted.has(option.id.toLowerCase()) ? { ...option, hosted: true } : option + ) } /** @@ -153,6 +208,8 @@ interface ProvidersStateLike { providers: Record } +type ProvidersState = ReturnType + /** * Thrown when an options function breaks the synchronous precondition below. * @@ -200,18 +257,15 @@ function callOptionsWithFallback( }, } - let store: { useProvidersStore?: { getState: () => unknown } } | undefined - let originalGetState: (() => unknown) | undefined - - try { - store = require('@/stores/providers') - if (store?.useProvidersStore?.getState) { - originalGetState = store.useProvidersStore.getState - store.useProvidersStore.getState = () => substituteState - } - } catch { - /* The store module is unavailable in this environment; the fallback stands alone. */ - } + /** + * Swapped through the statically imported store rather than a `require` at + * call time. The dynamic form resolved in tests and failed silently in the + * bundled server, where the options function then read the real, empty + * server-side store and every model picker published no options at all. + */ + const originalGetState = useProvidersStore.getState + // double-cast-allowed: the substitute carries only the `providers` slice the options functions read; the store's actions are never called during the synchronous body + useProvidersStore.getState = () => substituteState as unknown as ProvidersState try { const options = optionsFn() @@ -225,9 +279,7 @@ function callOptionsWithFallback( } return options } finally { - if (store?.useProvidersStore && originalGetState) { - store.useProvidersStore.getState = originalGetState - } + useProvidersStore.getState = originalGetState } } @@ -321,17 +373,26 @@ export function projectSubBlock(subBlock: SubBlockConfig): CatalogSubBlock { if (subBlock.columns) projected.columns = [...subBlock.columns] if (subBlock.dependsOn) projected.dependsOn = copyDependsOn(subBlock.dependsOn) - const { required, requiredWhen } = normalizeRequired(subBlock.required) - assignDefined(projected, 'required', required) - assignDefined(projected, 'requiredWhen', requiredWhen) - const condition = normalizeCondition(subBlock.condition) if (condition !== undefined) projected.condition = condition + const { required, requiredWhen } = normalizeRequired(subBlock.required, condition) + assignDefined(projected, 'required', required) + assignDefined(projected, 'requiredWhen', requiredWhen) + if (typeof subBlock.value === 'function') projected.hasComputedDefault = true - const options = resolveSubBlockOptions(subBlock) - if (options) projected.options = options + /** + * A model picker always publishes its choices: when its options function + * yields nothing — the failure that left an agent grepping the raw block + * definition for model ids — the code-defined model list stands in for it. + */ + const resolved = resolveSubBlockOptions(subBlock) + if (isModelPickerField(subBlock)) { + projected.options = markHostedModels(resolved ?? staticModelOptions()) + } else if (resolved) { + projected.options = resolved + } return projected } diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts index 6fad8d50de4..a1085d2fb4d 100644 --- a/apps/sim/lib/credentials/application/provider-catalog.ts +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -186,7 +186,13 @@ function getServiceAccountDescriptor(providerId: string): ServiceAccountDescript name: 'Slack custom bot', description: 'Connect a reusable Slack app with its signing secret and bot token.', docsUrl: 'https://docs.sim.ai/integrations/slack', - requiresClientGeneratedCredentialId: true, + /** + * The Request URL embeds the credential id, so the first-party modal + * pre-generates one to show the URL before saving. That is a convenience, + * not a requirement: creation mints an id when none is supplied, and an + * API caller configures Slack from the id the create response returns. + */ + requiresClientGeneratedCredentialId: false, fields: [ { id: 'signingSecret', diff --git a/apps/sim/lib/folders/orchestration.test.ts b/apps/sim/lib/folders/orchestration.test.ts index 558956f1f09..b4ffac386e4 100644 --- a/apps/sim/lib/folders/orchestration.test.ts +++ b/apps/sim/lib/folders/orchestration.test.ts @@ -546,6 +546,111 @@ describe('path-owned folder mutations', () => { expect(dbChainMockFns.update).not.toHaveBeenCalled() }) + /** + * `mv` semantics: `/xp-files` moved to an existing `/fx-archive` lands at + * `/fx-archive/xp-files` instead of being refused as a name collision, while + * a destination naming no folder is still the source's new full path. + */ + it('moves a folder into a destination that names an existing folder', async () => { + const source = folderRow({ id: 'folder-1', name: 'xp-files' }) + const archive = folderRow({ id: 'folder-2', name: 'fx-archive' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([ + ['folder-1', source], + ['folder-2', archive], + ]), + pathById: new Map([ + ['folder-1', '/xp-files'], + ['folder-2', '/fx-archive'], + ]), + idByPath: new Map([ + ['/xp-files', 'folder-1'], + ['/fx-archive', 'folder-2'], + ]), + }) + dbChainMockFns.returning.mockResolvedValueOnce([ + folderRow({ id: 'folder-1', name: 'xp-files', parentId: 'folder-2' }), + ]) + + const result = await relocateFolderByPath({ + resourceType: 'table', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/xp-files', + destinationPath: '/fx-archive', + }) + + expect(result).toMatchObject({ success: true, path: '/fx-archive/xp-files' }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ name: 'xp-files', parentId: 'folder-2' }) + ) + expect(auditMock.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + description: 'Moved table folder to "/fx-archive/xp-files"', + metadata: expect.objectContaining({ destinationPath: '/fx-archive/xp-files' }), + }) + ) + }) + + it('renames a folder to a destination that names no folder', async () => { + const source = folderRow({ id: 'folder-1', name: 'xp-files' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['folder-1', source]]), + pathById: new Map([['folder-1', '/xp-files']]), + idByPath: new Map([['/xp-files', 'folder-1']]), + }) + dbChainMockFns.returning.mockResolvedValueOnce([ + folderRow({ id: 'folder-1', name: 'fx-archive' }), + ]) + + const result = await relocateFolderByPath({ + resourceType: 'table', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/xp-files', + destinationPath: '/fx-archive', + }) + + expect(result).toMatchObject({ success: true, path: '/fx-archive' }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ name: 'fx-archive', parentId: null }) + ) + }) + + it('still refuses a move whose source already exists under the destination', async () => { + const source = folderRow({ id: 'folder-1', name: 'xp-files' }) + const archive = folderRow({ id: 'folder-2', name: 'fx-archive' }) + const taken = folderRow({ id: 'folder-3', name: 'xp-files', parentId: 'folder-2' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([ + ['folder-1', source], + ['folder-2', archive], + ['folder-3', taken], + ]), + pathById: new Map([ + ['folder-1', '/xp-files'], + ['folder-2', '/fx-archive'], + ['folder-3', '/fx-archive/xp-files'], + ]), + idByPath: new Map([ + ['/xp-files', 'folder-1'], + ['/fx-archive', 'folder-2'], + ['/fx-archive/xp-files', 'folder-3'], + ]), + }) + + const result = await relocateFolderByPath({ + resourceType: 'table', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/xp-files', + destinationPath: '/fx-archive', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'conflict' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it('requires recursive deletion when the path has descendant folders', async () => { const source = folderRow({ id: 'folder-1', name: 'Reports' }) const child = folderRow({ id: 'folder-2', name: 'Q1', parentId: 'folder-1' }) diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index 578c2952727..7c5299fce0e 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -26,6 +26,7 @@ import { folderNameFromPath, parentFolderPath, requireNonRootFolderPath, + resolveFolderMoveDestination, } from '@/lib/folders/paths' import { assertFolderCollectionHasRoom, @@ -297,18 +298,28 @@ async function executeRelocateFolderByPath( try { requireNonRootFolderPath(params.path) requireNonRootFolderPath(params.destinationPath) - const name = validatePathLeafName(params.destinationPath) - const folder = await withTransactionRetry( + const { folder, destinationPath } = await withTransactionRetry( async (tx) => { await acquireFolderMutationLock(tx, params.workspaceId, params.resourceType) const index = await loadActiveFolderPathIndex(params.workspaceId, params.resourceType, tx, { maxRows: params.maxFolderRows, }) const folderId = resolveRequiredFolderId(index, params.path) - if (index.idByPath.has(params.destinationPath)) throw new Error(DUPLICATE_NAME_ERROR) + /** + * Resolved under the lock, against the same index the collision check + * reads: whether the destination names an existing folder decides + * whether this is a move into it or a rename onto it. + */ + const destinationPath = resolveFolderMoveDestination( + index, + params.path, + params.destinationPath + ) + if (index.idByPath.has(destinationPath)) throw new Error(DUPLICATE_NAME_ERROR) + const name = validatePathLeafName(destinationPath) - const destinationParentPath = parentFolderPath(params.destinationPath) + const destinationParentPath = parentFolderPath(destinationPath) if ( destinationParentPath === params.path || destinationParentPath.startsWith(`${params.path}/`) @@ -342,7 +353,7 @@ async function executeRelocateFolderByPath( ) .returning() if (!updated) throw new Error('Folder not found') - return updated + return { folder: updated, destinationPath } }, { label: 'relocate-folder-by-path' } ) @@ -355,10 +366,10 @@ async function executeRelocateFolderByPath( resourceType: AuditResourceType.FOLDER, resourceId: folder.id, resourceName: folder.name, - description: `Moved ${folderResourceConfig(params.resourceType).label} folder to "${params.destinationPath}"`, + description: `Moved ${folderResourceConfig(params.resourceType).label} folder to "${destinationPath}"`, metadata: { sourcePath: params.path, - destinationPath: params.destinationPath, + destinationPath, folderResourceType: params.resourceType, }, }) @@ -366,7 +377,7 @@ async function executeRelocateFolderByPath( if (params.effects !== false) { await notifyFolderResourceChanged(params.resourceType, params.workspaceId) } - return { success: true, folder, path: params.destinationPath } + return { success: true, folder, path: destinationPath } } catch (error) { const result = pathMutationError(error) if (params.throwInfrastructure && result.errorCode === 'internal') throw error @@ -374,7 +385,12 @@ async function executeRelocateFolderByPath( } } -/** Renames, moves, or both by replacing one canonical path with another. */ +/** + * Renames, moves, or both. A destination naming an existing folder receives the + * source as a child (`mv` semantics, see {@link resolveFolderMoveDestination}); + * any other destination becomes the source's new path. `path` on the result is + * where the folder actually landed. + */ export async function relocateFolderByPath( params: RelocateFolderByPathParams ): Promise { diff --git a/apps/sim/lib/folders/paths.ts b/apps/sim/lib/folders/paths.ts index 5de99d4c523..79e2a131d02 100644 --- a/apps/sim/lib/folders/paths.ts +++ b/apps/sim/lib/folders/paths.ts @@ -153,6 +153,28 @@ export function folderNameFromPath(path: string): string { return segments[segments.length - 1] } +/** + * Where a folder move lands, with `mv` semantics. + * + * A destination that names an EXISTING folder receives the source as a child + * under its own name: moving `/xp-files` to `/fx-archive` yields + * `/fx-archive/xp-files`. Any other destination is the source's new full path — + * a rename, a relocation, or both. Before this, an existing destination was + * refused as a name collision, so moving a folder into another meant spelling + * out the target path in full. A destination equal to the source is returned + * as is, so the caller's collision check answers it the way it always has. + */ +export function resolveFolderMoveDestination( + index: Pick, + sourcePath: string, + destinationPath: string +): string { + if (destinationPath === sourcePath || !index.idByPath.has(destinationPath)) { + return destinationPath + } + return buildFolderPath([...parseFolderPath(destinationPath), folderNameFromPath(sourcePath)]) +} + /** * Runs a path helper over STORED rows. Those helpers classify their failures as * caller input, which is wrong here — the caller supplied nothing. Rethrowing as diff --git a/apps/sim/lib/knowledge/tags/service.test.ts b/apps/sim/lib/knowledge/tags/service.test.ts index 43f36ddcef4..440aa2e5523 100644 --- a/apps/sim/lib/knowledge/tags/service.test.ts +++ b/apps/sim/lib/knowledge/tags/service.test.ts @@ -18,6 +18,7 @@ import { createTagDefinition, getDocumentTagDefinitions, getDocumentTagDefinitionsByKnowledgeBaseIds, + getTagUsageStats, updateTagDefinition, } from '@/lib/knowledge/tags/service' @@ -341,6 +342,42 @@ describe('createOrUpdateTagDefinitionsBulk', () => { }) }) +describe('getTagUsageStats', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + /** + * `knowledge tags usage` reported a `chunkCount` that lagged behind a chunk + * just added to a tagged document. The count is read live, per request, and + * through the document slot the chunk inherits — never from a stored counter + * or the per-chunk copy of the tag. + */ + it('counts chunks live through the document slot they inherit', async () => { + queueTableRows(knowledgeBaseTagDefinitions, [existingDefinition({})]) + queueTableRows(document, [{ count: 2 }]) + queueTableRows(embedding, [{ count: 7 }]) + + const [usage] = await getTagUsageStats( + 'kb-1', + { kind: 'user', userId: 'user-1', tokens: ['pub', 'u:user-1', 'ws'] }, + 'req-1' + ) + + expect(usage).toMatchObject({ + id: 'tag-def-1', + tagSlot: 'tag1', + displayName: 'clitest-score', + documentCount: 2, + chunkCount: 7, + }) + const chunkWhere = JSON.stringify(dbChainMockFns.where.mock.calls.at(-1)) + expect(chunkWhere).toContain('document.tag1') + expect(chunkWhere).not.toContain('embedding.tag1') + }) +}) + describe('createTagDefinition', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/knowledge/tags/service.ts b/apps/sim/lib/knowledge/tags/service.ts index b414c969f1c..9a958d32ca4 100644 --- a/apps/sim/lib/knowledge/tags/service.ts +++ b/apps/sim/lib/knowledge/tags/service.ts @@ -952,6 +952,7 @@ export async function getTagUsageStats( ) ) + /** Counts live document tags instead of the denormalized copy on each chunk. */ const chunkCountResult = await db .select({ count: sql`count(*)` }) .from(embedding) @@ -963,7 +964,7 @@ export async function getTagUsageStats( isNull(document.archivedAt), isNull(document.deletedAt), accessCondition, - sql`${sql.raw(`embedding.${tagSlot}`)} IS NOT NULL` + sql`${sql.raw(`document.${tagSlot}`)} IS NOT NULL` ) ) diff --git a/apps/sim/lib/logs/application/get-log-stats.ts b/apps/sim/lib/logs/application/get-log-stats.ts index c037049451a..4056c4a83c0 100644 --- a/apps/sim/lib/logs/application/get-log-stats.ts +++ b/apps/sim/lib/logs/application/get-log-stats.ts @@ -14,6 +14,12 @@ export interface GetLogStatsInput { filters: Omit folderPaths?: string[] segmentCount: number + /** + * Whether buckets with no runs are published. Off unless asked: this surface + * is read by callers that pay per byte of response, and a dense series of + * `segmentCount` zero rows says nothing the totals do not. + */ + includeEmpty?: boolean } export type GetLogStatsResult = ReturnType @@ -54,6 +60,7 @@ export const getLogStats = defineAuthorizedWorkspaceUseCase({ const rows = await readLogStatsSegments(where, window.startTime.toISOString(), window.segmentMs) return buildDashboardStats(rows, window, input.segmentCount, { maxWorkflows: MAX_STATS_WORKFLOWS, + includeEmpty: input.includeEmpty === true, }) }, }) diff --git a/apps/sim/lib/logs/application/log-analytics-use-cases.test.ts b/apps/sim/lib/logs/application/log-analytics-use-cases.test.ts index 125ab8c6f67..22ac3e757ab 100644 --- a/apps/sim/lib/logs/application/log-analytics-use-cases.test.ts +++ b/apps/sim/lib/logs/application/log-analytics-use-cases.test.ts @@ -129,6 +129,24 @@ describe('getLogStats', () => { ) }) + it('publishes only the buckets that hold a run unless the caller asks for empties', async () => { + const sparse = await getLogStats.execute({ + principal: workspacePrincipal, + input: { workspaceId: 'workspace-1', filters: {}, segmentCount: 4 }, + }) + expect(sparse.stats.aggregateSegments).toHaveLength(1) + expect(sparse.stats.workflows[0].segments).toHaveLength(1) + expect(sparse.stats.totalRuns).toBe(2) + + const dense = await getLogStats.execute({ + principal: workspacePrincipal, + input: { workspaceId: 'workspace-1', filters: {}, segmentCount: 4, includeEmpty: true }, + }) + expect(dense.stats.aggregateSegments).toHaveLength(4) + expect(dense.stats.workflows[0].segments).toHaveLength(4) + expect(dense.stats.totalRuns).toBe(2) + }) + /** * The wiring, not the arithmetic: `resolveLogStatsWindow` is exercised for * real here, so a requested window that never reaches it shows up as both a diff --git a/apps/sim/lib/logs/stats.test.ts b/apps/sim/lib/logs/stats.test.ts index 087cf4717dc..a92a6715e19 100644 --- a/apps/sim/lib/logs/stats.test.ts +++ b/apps/sim/lib/logs/stats.test.ts @@ -280,6 +280,41 @@ describe('buildDashboardStats', () => { expect(stats.workflows).toHaveLength(1) }) + /** + * Five runs on the default 72-bucket window answered with 67 zero rows per + * series — tens of kilobytes carrying nothing the totals did not. The sparse + * form keeps every bucket that holds a run, at its dense-form timestamp, and + * never exceeds `segmentCount`. + */ + it('omits buckets with no runs when includeEmpty is false', () => { + const { stats } = buildDashboardStats( + [row({ segmentIndex: 1, totalExecutions: 3, successfulExecutions: 2 })], + window, + 2, + { includeEmpty: false } + ) + + expect(stats.workflows[0].segments).toEqual([ + { + timestamp: '2026-01-15T01:00:00.000Z', + totalExecutions: 3, + successfulExecutions: 2, + avgDurationMs: 100, + }, + ]) + expect(stats.aggregateSegments).toEqual(stats.workflows[0].segments) + expect(stats.totalRuns).toBe(3) + expect(stats.totalErrors).toBe(1) + expect(stats.segmentMs).toBe(window.segmentMs) + }) + + it('publishes no buckets at all for a workspace with no runs when empties are omitted', () => { + const { stats } = buildDashboardStats([], window, 2, { includeEmpty: false }) + + expect(stats.aggregateSegments).toEqual([]) + expect(stats.totalRuns).toBe(0) + }) + it('returns an empty-but-shaped response for a workspace with no runs', () => { const { stats } = buildDashboardStats([], window, 2) diff --git a/apps/sim/lib/logs/stats.ts b/apps/sim/lib/logs/stats.ts index 3b1a287b4b4..0e3c37ce142 100644 --- a/apps/sim/lib/logs/stats.ts +++ b/apps/sim/lib/logs/stats.ts @@ -83,6 +83,16 @@ export interface BuildDashboardStatsOptions { * workflow, which is what the first-party dashboard reads. */ maxWorkflows?: number + /** + * Whether buckets with no runs are materialized. Defaults to `true`, the + * dense series the first-party dashboard charts. `false` publishes only the + * buckets that hold at least one run — never more than `segmentCount` of + * them — so a handful of runs on a wide window is a handful of entries rather + * than `segmentCount` near-identical zero rows. The totals, `segmentMs`, and + * each bucket's `timestamp` are unaffected: an omitted bucket contributed + * nothing to any of them. + */ + includeEmpty?: boolean } export interface DashboardStatsResult { @@ -115,6 +125,7 @@ export function buildDashboardStats( options: BuildDashboardStatsOptions = {} ): DashboardStatsResult { const { startTime, endTime, segmentMs } = window + const includeEmpty = options.includeEmpty !== false const segmentTimestamp = (index: number) => new Date(startTime.getTime() + index * segmentMs).toISOString() @@ -220,6 +231,7 @@ export function buildDashboardStats( weightedLatencySum += segWeightedLatency latencyCount += segLatencyCount + if (segTotal === 0 && !includeEmpty) continue aggregateSegments.push({ timestamp: segmentTimestamp(i), totalExecutions: segTotal, @@ -241,14 +253,18 @@ export function buildDashboardStats( const workflows: WorkflowStats[] = retained.map((wf) => { const segments: SegmentStats[] = [] for (let i = 0; i < segmentCount; i++) { - segments.push( - wf.segments.get(i) ?? { - timestamp: segmentTimestamp(i), - totalExecutions: 0, - successfulExecutions: 0, - avgDurationMs: 0, - } - ) + const segment = wf.segments.get(i) + if (segment) { + segments.push(segment) + continue + } + if (!includeEmpty) continue + segments.push({ + timestamp: segmentTimestamp(i), + totalExecutions: 0, + successfulExecutions: 0, + avgDurationMs: 0, + }) } return { workflowId: wf.workflowId, diff --git a/apps/sim/lib/table/application/folders.test.ts b/apps/sim/lib/table/application/folders.test.ts index d79eb236142..22659a54e2b 100644 --- a/apps/sim/lib/table/application/folders.test.ts +++ b/apps/sim/lib/table/application/folders.test.ts @@ -55,9 +55,13 @@ vi.mock('@/lib/table/application/context', () => ({ resolveTableWorkspaceContext: mocks.resolveWorkspaceContext, })) -import { restoreFolder } from '@/lib/folders/orchestration' +import { relocateFolderByPathTransition, restoreFolder } from '@/lib/folders/orchestration' import { findArchivedFolderIdByPath } from '@/lib/folders/queries' -import { listTableFoldersUseCase, restoreTableFolderUseCase } from '@/lib/table/application/folders' +import { + listTableFoldersUseCase, + restoreTableFolderUseCase, + updateTableFolderUseCase, +} from '@/lib/table/application/folders' const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const @@ -175,3 +179,49 @@ describe('restoreTableFolderUseCase', () => { expect(restoreFolder).not.toHaveBeenCalled() }) }) + +describe('updateTableFolderUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspaceContext.mockResolvedValue({ + workspaceId: 'ws-1', + billedAccountUserId: 'owner-1', + }) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.loadFolderIndex.mockResolvedValue({ + idByPath: new Map(), + pathById: new Map(), + rowById: new Map(), + }) + }) + + /** + * `mv` semantics are decided in the shared orchestration, under its lock, so + * the use case reports where the folder actually landed rather than echoing + * the destination it was asked for. + */ + it('reports the resolved path when the destination named an existing folder', async () => { + const folder = { id: 'folder-1', name: 'xp-files', parentId: 'folder-2' } + vi.mocked(relocateFolderByPathTransition).mockResolvedValue({ + success: true, + folder, + path: '/fx-archive/xp-files', + } as never) + + const result = await updateTableFolderUseCase.execute({ + principal, + input: { workspaceId: 'ws-1', path: '/xp-files', destinationPath: '/fx-archive' }, + }) + + expect(relocateFolderByPathTransition).toHaveBeenCalledWith( + expect.objectContaining({ + resourceType: 'table', + path: '/xp-files', + destinationPath: '/fx-archive', + }) + ) + expect(result.path).toBe('/fx-archive/xp-files') + expect(result.sourcePath).toBe('/xp-files') + expect(result.folder).toBe(folder) + }) +}) diff --git a/apps/sim/lib/table/application/folders.ts b/apps/sim/lib/table/application/folders.ts index 0f7568171e8..aa369fe392b 100644 --- a/apps/sim/lib/table/application/folders.ts +++ b/apps/sim/lib/table/application/folders.ts @@ -116,7 +116,13 @@ export const updateTableFolderUseCase = defineAuthorizedTableUseCase({ const index = await loadActiveFolderPathIndex(context.workspaceId, 'table', undefined, { maxRows: MAX_FOLDERS_PER_WORKSPACE, }) - return { folder: result.folder, index, path: input.destinationPath, sourcePath: input.path } + return { + folder: result.folder, + index, + /** Where the folder landed: the destination itself, or inside it when it already existed. */ + path: result.path ?? input.destinationPath, + sourcePath: input.path, + } }, projectAudit({ result }) { return { diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts index 797b5469b97..24f767ab7dd 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts @@ -27,6 +27,7 @@ import { ensureWorkspaceFileFolderPath, listWorkspaceFileFolders, normalizeWorkspaceFileItemName, + relocateWorkspaceFileFolderByPath, WorkspaceFileFolderConflictError, WorkspaceFileItemsNotFoundError, WorkspaceFileMoveConflictError, @@ -273,3 +274,81 @@ describe('archiveWorkspaceFileFolderIfEmpty', () => { ).rejects.toMatchObject({ code: 'conflict' }) }) }) + +describe('relocateWorkspaceFileFolderByPath', () => { + const now = new Date('2026-08-17T12:00:00.000Z') + const source = { + id: 'folder-source', + resourceType: 'file', + workspaceId: 'workspace-1', + userId: 'user-1', + name: 'xp-files', + parentId: null, + sortOrder: 0, + deletedAt: null, + createdAt: now, + updatedAt: now, + } + const archive = { ...source, id: 'folder-archive', name: 'fx-archive' } + + beforeEach(() => { + resetDbChainMock() + mockAcquireFolderMutationLock.mockReset() + }) + + /** + * `mv` semantics: `/xp-files` moved to an existing `/fx-archive` lands at + * `/fx-archive/xp-files` instead of being refused as a name collision, while + * a destination naming no folder is still the source's new full path. + */ + it('moves a folder into a destination that names an existing folder', async () => { + queueTableRows(schemaMock.folder, [source, archive]) + dbChainMockFns.returning.mockResolvedValueOnce([{ ...source, parentId: 'folder-archive' }]) + + const result = await relocateWorkspaceFileFolderByPath({ + workspaceId: 'workspace-1', + path: '/xp-files', + destinationPath: '/fx-archive', + }) + + expect(result.path).toBe('/fx-archive/xp-files') + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ name: 'xp-files', parentId: 'folder-archive' }) + ) + expect(mockAcquireFolderMutationLock).toHaveBeenCalledWith( + expect.anything(), + 'workspace-1', + 'file' + ) + }) + + it('renames a folder to a destination that names no folder', async () => { + queueTableRows(schemaMock.folder, [source]) + dbChainMockFns.returning.mockResolvedValueOnce([{ ...source, name: 'fx-archive' }]) + + const result = await relocateWorkspaceFileFolderByPath({ + workspaceId: 'workspace-1', + path: '/xp-files', + destinationPath: '/fx-archive', + }) + + expect(result.path).toBe('/fx-archive') + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ name: 'fx-archive', parentId: null }) + ) + }) + + it('still refuses a move whose source already exists under the destination', async () => { + const taken = { ...source, id: 'folder-taken', parentId: 'folder-archive' } + queueTableRows(schemaMock.folder, [source, archive, taken]) + + await expect( + relocateWorkspaceFileFolderByPath({ + workspaceId: 'workspace-1', + path: '/xp-files', + destinationPath: '/fx-archive', + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(dbChainMockFns.returning).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 1a2f1720692..6af8819e6dd 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -17,6 +17,7 @@ import { parentFolderPath, parseFolderPath, requireNonRootFolderPath, + resolveFolderMoveDestination, } from '@/lib/folders/paths' import { FOLDER_SORTS, type FolderSortBy } from '@/lib/folders/queries' import { collectDescendantFolderIds } from '@/lib/folders/subtree' @@ -1478,14 +1479,9 @@ export async function createWorkspaceFileFolderAtPath(params: { } /** Relocates one file folder while source and destination paths share the same tree lock. */ -export async function relocateWorkspaceFileFolderByPath(params: { - workspaceId: string - path: string - destinationPath: string -}): Promise { - requireNonRootFolderPath(params.path) - requireNonRootFolderPath(params.destinationPath) - const pathName = folderNameFromPath(params.destinationPath) +/** The validated leaf name a canonical folder path addresses. */ +function workspaceFileFolderLeafName(path: string): string { + const pathName = folderNameFromPath(path) let name: string try { name = normalizeWorkspaceFileItemName(pathName, 'Folder') @@ -1495,17 +1491,36 @@ export async function relocateWorkspaceFileFolderByPath(params: { if (name !== pathName) { throw new OrchestrationError('validation', 'Folder path leaf cannot have outer spaces') } + return name +} - const folder = await db.transaction(async (tx) => { +/** + * Renames, moves, or both. A destination naming an existing folder receives the + * source as a child (`mv` semantics, see {@link resolveFolderMoveDestination}); + * any other destination becomes the source's new path. `path` on the result is + * where the folder actually landed. + */ +export async function relocateWorkspaceFileFolderByPath(params: { + workspaceId: string + path: string + destinationPath: string +}): Promise { + requireNonRootFolderPath(params.path) + requireNonRootFolderPath(params.destinationPath) + + return db.transaction(async (tx) => { await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId) const index = await loadActiveFileFolderPathIndex(tx, params.workspaceId) const folderId = index.idByPath.get(params.path) if (!folderId) throw new OrchestrationError('not_found', 'Folder not found') - if (index.idByPath.has(params.destinationPath)) { + /** Resolved under the lock, against the same index the collision check reads. */ + const destinationPath = resolveFolderMoveDestination(index, params.path, params.destinationPath) + const name = workspaceFileFolderLeafName(destinationPath) + if (index.idByPath.has(destinationPath)) { throw new WorkspaceFileFolderConflictError(name) } - const destinationParentPath = parentFolderPath(params.destinationPath) + const destinationParentPath = parentFolderPath(destinationPath) if ( destinationParentPath === params.path || destinationParentPath.startsWith(`${params.path}/`) @@ -1531,10 +1546,8 @@ export async function relocateWorkspaceFileFolderByPath(params: { ) .returning() if (!updated) throw new OrchestrationError('not_found', 'Folder not found') - return updated + return { folder: updated, path: destinationPath } }) - - return { folder, path: params.destinationPath } } /** Deletes a file-folder subtree, or only an empty folder when `recursive` is false. */ diff --git a/apps/sim/lib/webhooks/deployed-urls.test.ts b/apps/sim/lib/webhooks/deployed-urls.test.ts new file mode 100644 index 00000000000..0ec2b1062cf --- /dev/null +++ b/apps/sim/lib/webhooks/deployed-urls.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + */ +import { webhook } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/triggers/webhook-url', () => ({ + buildWebhookTriggerUrl: (path: string) => `https://sim.test/api/webhooks/trigger/${path}`, + buildSlackCustomBotRequestUrl: (credentialId: string) => + `https://sim.test/api/webhooks/slack/custom/${credentialId}`, +})) + +import { listDeployedWebhookUrls } from '@/lib/webhooks/deployed-urls' +import { LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE } from '@/lib/webhooks/slack-custom-ingress-constants' + +afterAll(resetDbChainMock) + +describe('listDeployedWebhookUrls', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('resolves a path-addressed registration to the trigger URL the block displays', async () => { + queueTableRows(webhook, [ + { blockId: 'block-1', provider: 'generic', path: 'leads', providerConfig: {} }, + ]) + + await expect(listDeployedWebhookUrls('workflow-1')).resolves.toEqual([ + { + blockId: 'block-1', + provider: 'generic', + url: 'https://sim.test/api/webhooks/trigger/leads', + }, + ]) + expect(dbChainMockFns.where).toHaveBeenCalledWith( + expect.objectContaining({ + conditions: expect.arrayContaining([ + expect.objectContaining({ right: 'workflow-1' }), + expect.objectContaining({ right: true }), + ]), + }) + ) + }) + + it('resolves a Slack custom bot to its Request URL and skips shared-endpoint rows', async () => { + queueTableRows(webhook, [ + { + blockId: 'block-bot', + provider: 'slack', + path: null, + providerConfig: { + ingressMode: LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE, + credentialId: 'cred-1', + triggerId: 'slack_webhook', + }, + }, + { + blockId: 'block-native', + provider: 'slack', + path: null, + providerConfig: { triggerId: 'x' }, + }, + { blockId: 'block-tiktok', provider: 'tiktok', path: null, providerConfig: null }, + ]) + + await expect(listDeployedWebhookUrls('workflow-1')).resolves.toEqual([ + { + blockId: 'block-bot', + provider: 'slack', + url: 'https://sim.test/api/webhooks/slack/custom/cred-1', + }, + ]) + }) +}) diff --git a/apps/sim/lib/webhooks/deployed-urls.ts b/apps/sim/lib/webhooks/deployed-urls.ts new file mode 100644 index 00000000000..0d3f5137e53 --- /dev/null +++ b/apps/sim/lib/webhooks/deployed-urls.ts @@ -0,0 +1,77 @@ +import { db } from '@sim/db' +import { webhook } from '@sim/db/schema' +import { isRecordLike } from '@sim/utils/object' +import { and, eq, isNull } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import { LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE } from '@/lib/webhooks/slack-custom-ingress-constants' +import { buildSlackCustomBotRequestUrl, buildWebhookTriggerUrl } from '@/triggers/webhook-url' + +/** The public URL one live webhook registration receives events on, and the block it feeds. */ +export interface DeployedWebhookUrl { + blockId: string | null + provider: string | null + url: string +} + +/** + * The delivery URL of one live registration, or null when its provider + * delivers through a shared endpoint with no per-workflow URL. + * + * Two shapes carry a URL. A path-addressed row is reached at + * `/api/webhooks/trigger/` — the URL the block's `webhookUrlDisplay` + * renders in the editor and reads back as `null` through the API, because it + * is computed client-side. A Slack custom bot has no path: its events arrive + * on the credential-scoped Request URL and fan out by routing key, so the URL + * the Slack app must be configured with is that one. Shared-app providers such + * as the native Slack and TikTok triggers route by tenant key on a single + * endpoint and advertise nothing. + */ +export function resolveDeployedWebhookUrl(row: { + path: string | null + provider: string | null + providerConfig: unknown +}): string | null { + if (row.path) return buildWebhookTriggerUrl(row.path) + if (row.provider !== 'slack' || !isRecordLike(row.providerConfig)) return null + if (row.providerConfig.ingressMode !== LEGACY_SLACK_CUSTOM_BOT_INGRESS_MODE) return null + const credentialId = row.providerConfig.credentialId + return typeof credentialId === 'string' && credentialId.length > 0 + ? buildSlackCustomBotRequestUrl(credentialId) + : null +} + +/** + * The delivery URL of every webhook the workflow's live deployment registered. + * + * Reads the rows the inbound dispatcher itself matches — active and not + * archived — so what is published is exactly what will be served, whether the + * row came from the stable registration protocol or the legacy save path. + */ +export async function listDeployedWebhookUrls( + workflowId: string, + tx?: DbOrTx +): Promise { + const rows = await (tx ?? db) + .select({ + blockId: webhook.blockId, + provider: webhook.provider, + path: webhook.path, + providerConfig: webhook.providerConfig, + }) + .from(webhook) + .where( + and( + eq(webhook.workflowId, workflowId), + eq(webhook.isActive, true), + isNull(webhook.archivedAt) + ) + ) + .orderBy(webhook.blockId) + + const urls: DeployedWebhookUrl[] = [] + for (const row of rows) { + const url = resolveDeployedWebhookUrl(row) + if (url) urls.push({ blockId: row.blockId, provider: row.provider, url }) + } + return urls +} diff --git a/apps/sim/lib/workflows/application/deployments.ts b/apps/sim/lib/workflows/application/deployments.ts index 039d4d2b481..798354faee6 100644 --- a/apps/sim/lib/workflows/application/deployments.ts +++ b/apps/sim/lib/workflows/application/deployments.ts @@ -3,6 +3,7 @@ import { type Principal, resolvePrincipalAttribution, toPrincipalActor } from '@ import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { notifyWorkflowReverted } from '@/lib/realtime/notify' +import { listDeployedWebhookUrls } from '@/lib/webhooks/deployed-urls' import { requireWorkflowExecutionUserId } from '@/lib/workflows/application/authorization' import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' @@ -225,11 +226,13 @@ export const readWorkflowDeploymentStatus = defineAuthorizedWorkflowUseCase({ isDeployed && attemptStatus !== 'preparing' && attemptStatus !== 'activating' ? await checkNeedsRedeployment(context.workflowId) : false + const webhooks = isDeployed ? await listDeployedWebhookUrls(context.workflowId) : [] return { workflow: context.workflow, workspaceId: context.workspaceId, isDeployed, needsRedeployment, + webhooks, ...deploymentSummary, } }, diff --git a/apps/sim/lib/workflows/editing/dangling-refs.test.ts b/apps/sim/lib/workflows/editing/dangling-refs.test.ts index 4735a44a943..86593b92078 100644 --- a/apps/sim/lib/workflows/editing/dangling-refs.test.ts +++ b/apps/sim/lib/workflows/editing/dangling-refs.test.ts @@ -1,13 +1,85 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { collectDanglingBlockOutputReferences } from '@/lib/workflows/editing/lint' +/** + * Overrides the global registry stub (every type resolves to a block with no + * outputs) with the handful of real output shapes the unknown-field pass reads. + */ +const MOCK_BLOCKS = vi.hoisted( + () => + ({ + starter: { type: 'starter', category: 'triggers', subBlocks: [], outputs: {} }, + start_trigger: { + type: 'start_trigger', + category: 'triggers', + subBlocks: [{ id: 'inputFormat', type: 'input-format' }], + outputs: {}, + triggers: { enabled: true, available: ['chat', 'manual', 'api'] }, + }, + api: { + type: 'api', + category: 'blocks', + subBlocks: [], + outputs: { + data: { type: 'json', description: 'Response data' }, + status: { type: 'number', description: 'HTTP status' }, + headers: { type: 'json', description: 'Response headers' }, + }, + }, + function: { + type: 'function', + category: 'blocks', + subBlocks: [], + outputs: { + result: { type: 'json', description: 'Return value' }, + stdout: { type: 'string', description: 'Console output' }, + files: { type: 'file[]', description: 'Files written' }, + }, + }, + agent: { + type: 'agent', + category: 'blocks', + subBlocks: [], + outputs: { + content: { type: 'string', description: 'Generated response content' }, + model: { type: 'string', description: 'Model used' }, + tokens: { type: 'json', description: 'Token usage' }, + toolCalls: { type: 'json', description: 'Tool calls made' }, + }, + }, + table: { + type: 'table', + category: 'blocks', + subBlocks: [], + outputs: { success: { type: 'boolean', description: 'Operation success' } }, + }, + slack: { type: 'slack', category: 'blocks', subBlocks: [], outputs: {} }, + workflow_input: { type: 'workflow_input', category: 'blocks', subBlocks: [], outputs: {} }, + }) as Record +) + +vi.mock('@/blocks/registry', () => ({ + getBlock: (type: string) => MOCK_BLOCKS[type], + getAllBlocks: () => Object.values(MOCK_BLOCKS), + getLatestBlock: () => undefined, + getLatestBlockForViewer: () => undefined, + getBlockMeta: () => undefined, + getBlockRegistry: () => MOCK_BLOCKS, + getBlockByToolName: () => undefined, +})) + function graph( blocks: Record< string, - { type?: string; name?: string; subBlocks?: Record } + { + type?: string + name?: string + triggerMode?: boolean + subBlocks?: Record + } > ) { return { blocks } as Parameters[0] @@ -128,4 +200,148 @@ describe('collectDanglingBlockOutputReferences', () => { expect(findings).toHaveLength(1) expect(findings[0]!.field).toBe('inputMapping') }) + + describe('unknown output fields', () => { + it('flags a path whose head resolves but whose first segment is not a declared output', () => { + const findings = collectDanglingBlockOutputReferences( + graph({ + b1: { type: 'function', name: 'Guard' }, + b2: { + type: 'api', + name: 'Call', + subBlocks: { body: { value: '{"a": "", "b": ""}' } }, + }, + }) + ) + expect(findings).toHaveLength(1) + expect(findings[0]).toMatchObject({ + blockId: 'b2', + field: 'body', + kind: 'block-output', + value: [''], + }) + expect(findings[0]!.reason).toMatch( + /^unknown-field: "Guard" \(function\) has no output field "reslt"/ + ) + expect(findings[0]!.reason).toContain('Available fields: result, stdout, files') + }) + + it('accepts the implicit error output on every block', () => { + const findings = collectDanglingBlockOutputReferences( + graph({ + b1: { type: 'function', name: 'Guard' }, + b2: { + type: 'function', + name: 'Collector', + subBlocks: { + code: { value: 'return { failure: , out: }' }, + }, + }, + }) + ) + expect(findings).toHaveLength(0) + }) + + it('flags a base output on an agent whose responseFormat replaces its outputs', () => { + const responseFormat = JSON.stringify({ + type: 'object', + properties: { title: { type: 'string' }, summary: { type: 'string' } }, + }) + const findings = collectDanglingBlockOutputReferences( + graph({ + b1: { + type: 'agent', + name: 'AccessAgent', + subBlocks: { responseFormat: { value: responseFormat } }, + }, + b2: { + type: 'api', + name: 'Post', + subBlocks: { body: { value: ' and ' } }, + }, + }) + ) + expect(findings).toHaveLength(1) + expect(findings[0]!.value).toEqual(['']) + expect(findings[0]!.reason).toContain('Available fields: title, summary') + }) + + it('uses the base agent outputs when no responseFormat is set', () => { + const findings = collectDanglingBlockOutputReferences( + graph({ + b1: { type: 'agent', name: 'Writer', subBlocks: { responseFormat: { value: '' } } }, + b2: { + type: 'api', + name: 'Post', + subBlocks: { + body: { value: ' ' }, + }, + }, + }) + ) + expect(findings).toHaveLength(1) + expect(findings[0]!.value).toEqual(['']) + }) + + it('does not flag an agent whose responseFormat cannot be parsed', () => { + const findings = collectDanglingBlockOutputReferences( + graph({ + start: { type: 'starter', name: 'Start' }, + b1: { + type: 'agent', + name: 'Writer', + subBlocks: { responseFormat: { value: '' } }, + }, + b2: { type: 'api', name: 'Post', subBlocks: { body: { value: '' } } }, + }) + ) + expect(findings).toHaveLength(0) + }) + + it('does not flag dynamic-output blocks: triggers, subflow containers, tables, no-output types', () => { + const findings = collectDanglingBlockOutputReferences( + graph({ + start: { + type: 'start_trigger', + name: 'Start', + subBlocks: { inputFormat: { value: [{ name: 'customerId', type: 'string' }] } }, + }, + loop1: { type: 'loop', name: 'Batch' }, + par1: { type: 'parallel', name: 'Fanout' }, + tbl: { type: 'table', name: 'Leads' }, + hook: { type: 'slack', name: 'Notify', triggerMode: true }, + plain: { type: 'slack', name: 'Post' }, + consumer: { + type: 'api', + name: 'Call', + subBlocks: { + body: { + value: + ' ', + }, + }, + }, + }) + ) + expect(findings).toHaveLength(0) + }) + + it('keeps the dangling-head finding separate from unknown-field findings', () => { + const findings = collectDanglingBlockOutputReferences( + graph({ + b1: { type: 'function', name: 'Guard' }, + b2: { + type: 'api', + name: 'Call', + subBlocks: { body: { value: ' ' } }, + }, + }) + ) + expect(findings).toHaveLength(2) + expect(findings[0]!.value).toEqual(['']) + expect(findings[0]!.reason).toMatch(/does not exist in this workflow/) + expect(findings[1]!.value).toEqual(['']) + expect(findings[1]!.reason).toMatch(/^unknown-field:/) + }) + }) }) diff --git a/apps/sim/lib/workflows/editing/lint.ts b/apps/sim/lib/workflows/editing/lint.ts index 185d92b65e0..523bc5e17f9 100644 --- a/apps/sim/lib/workflows/editing/lint.ts +++ b/apps/sim/lib/workflows/editing/lint.ts @@ -1,4 +1,8 @@ import { findWorkflowReferenceTokens } from '@sim/utils/workflow-references' +import { + getEffectiveBlockOutputs, + getResponseFormatOutputs, +} from '@/lib/workflows/blocks/block-outputs' import { getBlock } from '@/blocks' import { isTriggerBlockType, @@ -386,6 +390,20 @@ export function formatWorkflowLintMessage(lint: WorkflowLintIssueView) { ) } + const blockOutputRefs = unresolved.filter((ref) => ref.kind === 'block-output') + if (blockOutputRefs.length > 0) { + parts.push( + `Block output references that will not resolve: ${blockOutputRefs + .map( + (ref) => + `"${ref.blockName || ref.blockId}".${ref.field} ${ + Array.isArray(ref.value) ? ref.value.join(', ') : ref.value + } (${ref.reason})` + ) + .join('; ')}` + ) + } + return `Workflow lint found issues. Fix these before continuing: ${parts.join('; ')}` } @@ -412,28 +430,141 @@ function referenceCandidates(leaf: string, isCode: boolean): string[] { .map((token) => token.value.slice(REFERENCE.START.length, -REFERENCE.END.length)) } +/** + * Block types whose first-segment output keys are decided at run time rather + * than by the registry: subflow containers (their outputs are the iteration + * results the executor assembles) and table operations (rows take the shape of + * the table's own columns). + */ +const DYNAMIC_OUTPUT_BLOCK_TYPES = new Set(['loop', 'parallel', 'table', 'table_v2']) + +/** + * The output field the executor writes on any block that fails. Never declared + * in a registry schema, always resolvable — see the block reference resolver. + */ +const IMPLICIT_ERROR_OUTPUT = 'error' + +/** Trailing `[n]` index suffixes, so `items[0]` compares as the key `items`. */ +const INDEX_SUFFIX = /(?:\[\d+\])+$/ + +/** + * The first path segment of a `block.path` token as an output key, or + * `undefined` when it is not a key at all (a bare array index). + */ +function firstOutputSegment(token: string): string | undefined { + const segment = (token.split('.')[1] ?? '').replace(INDEX_SUFFIX, '') + if (!segment || /^\d+$/.test(segment)) return undefined + return segment +} + +/** + * Output keys a reference into `block` may start with, or `undefined` when they + * are not knowable at lint time. + * + * Mirrors the executor's own validation schema — `getEffectiveBlockOutputs` + * with hidden outputs included, which is what `getBlockSchema` reads — so a + * segment rejected here is one the run rejects with `InvalidFieldError`, and + * one the schema accepts (a `responseFormat` property, an evaluator metric, a + * resume-form field) is accepted here. Not knowable: trigger blocks, whose + * output is whatever the caller sent; subflow containers and tables; an agent + * whose `responseFormat` is set but cannot be parsed, since its fields are + * decided when that schema resolves; and any type that declares no outputs. + */ +function declaredOutputKeys(block: BlockState): string[] | undefined { + const type = block.type + if (!type || block.triggerMode === true || isTriggerBlockType(type)) return undefined + if (DYNAMIC_OUTPUT_BLOCK_TYPES.has(type)) return undefined + const config = getBlock(type) + if (!config || config.category === 'triggers') return undefined + + const subBlocks: Record = {} + for (const [id, subBlock] of Object.entries(block.subBlocks ?? {})) { + if (subBlock) subBlocks[id] = subBlock + } + + if (type === 'agent') { + const responseFormat = subBlocks.responseFormat?.value + const hasResponseFormat = + typeof responseFormat === 'string' ? responseFormat.trim() !== '' : Boolean(responseFormat) + if (hasResponseFormat && !getResponseFormatOutputs(subBlocks, block.id ?? type)) { + return undefined + } + } + + const keys = Object.keys( + getEffectiveBlockOutputs(type, subBlocks, { + triggerMode: false, + preferToolOutputs: true, + includeHidden: true, + }) + ) + if (keys.length === 0) return undefined + /** The resolver's legacy fallback accepts `` on a Response block. */ + if (type === 'response') keys.push('response') + return keys +} + +interface UnknownFieldGroup { + target: BlockState + keys: string[] + tokens: Set + segments: Set +} + +function quoteList(values: Iterable): string { + return [...values].map((value) => `"${value}"`).join(', ') +} + export function collectDanglingBlockOutputReferences( workflowState: Pick ): WorkflowLintUnresolvedReference[] { const blocks = (workflowState.blocks || {}) as Record - const resolvable = new Set() + const targetByKey = new Map() for (const [id, block] of Object.entries(blocks)) { - resolvable.add(id) - if (block.name) resolvable.add(normalizeName(block.name)) + targetByKey.set(id, id) + if (block.name) targetByKey.set(normalizeName(block.name), id) + } + /** Output keys per referenced block; `null` once found not knowable. */ + const outputKeysByTarget = new Map() + const outputKeysFor = (targetId: string): string[] | null => { + const cached = outputKeysByTarget.get(targetId) + if (cached !== undefined) return cached + const keys = declaredOutputKeys(blocks[targetId]) ?? null + outputKeysByTarget.set(targetId, keys) + return keys } + const findings: WorkflowLintUnresolvedReference[] = [] for (const [blockId, block] of Object.entries(blocks)) { for (const [subBlockId, subBlock] of Object.entries(block.subBlocks ?? {})) { const leaves: string[] = [] collectStringLeaves((subBlock as { value?: unknown })?.value, leaves) const dangling = new Set() + const unknownByTarget = new Map() for (const leaf of leaves) { for (const token of referenceCandidates(leaf, subBlockId === 'code')) { if (!token || !REF_TOKEN_SHAPE.test(token)) continue const head = token.split('.')[0] ?? '' if ((SPECIAL_REFERENCE_PREFIXES as readonly string[]).includes(head)) continue - if (resolvable.has(head) || resolvable.has(normalizeName(head))) continue - dangling.add(`<${token}>`) + const targetId = targetByKey.get(head) ?? targetByKey.get(normalizeName(head)) + if (targetId === undefined) { + dangling.add(`<${token}>`) + continue + } + const keys = outputKeysFor(targetId) + const segment = firstOutputSegment(token) + if (!keys || !segment || segment === IMPLICIT_ERROR_OUTPUT || keys.includes(segment)) { + continue + } + const group = unknownByTarget.get(targetId) ?? { + target: blocks[targetId], + keys, + tokens: new Set(), + segments: new Set(), + } + group.tokens.add(`<${token}>`) + group.segments.add(segment) + unknownByTarget.set(targetId, group) } } if (dangling.size > 0) { @@ -446,6 +577,17 @@ export function collectDanglingBlockOutputReferences( 'References a block that does not exist in this workflow — at run time the literal text is passed through (or the block fails), never the intended value.', }) } + for (const [targetId, group] of unknownByTarget) { + const targetName = group.target.name || targetId + const plural = group.segments.size === 1 ? 'field' : 'fields' + findings.push({ + ...blockRef(blockId, block), + field: subBlockId, + value: [...group.tokens], + kind: 'block-output', + reason: `unknown-field: "${targetName}" (${group.target.type}) has no output ${plural} ${quoteList(group.segments)} — the run fails when the reference resolves. Available fields: ${group.keys.join(', ')}`, + }) + } } } return findings diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index e777492a601..e6f9a150d41 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -1008,6 +1008,31 @@ type CancelTableRunsBodyRef0 = } > } + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } export type CancelTableRunsBody = { workspaceId: string @@ -2191,6 +2216,31 @@ type CreateTableDispatchBodyRef0 = } > } + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } export type CreateTableDispatchBody = { workspaceId: string @@ -3221,6 +3271,31 @@ type DeleteTableRowsBodyRef0 = } > } + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } export type DeleteTableRowsBody = { workspaceId: string @@ -3924,6 +3999,7 @@ type GetBlockResponseRef0 = { id: string label?: string hasIcon?: boolean + hosted?: boolean }> min?: number max?: number @@ -4553,6 +4629,19 @@ export type GetLogStatsQuery = { startDate?: string endDate?: string segmentCount?: number + includeEmpty?: + | 'true' + | '1' + | 'yes' + | 'on' + | 'y' + | 'enabled' + | 'false' + | '0' + | 'no' + | 'off' + | 'n' + | 'disabled' } type GetLogStatsResponseRef0 = { @@ -5320,6 +5409,12 @@ type GetWorkflowDeploymentResponseRef3 = { } type GetWorkflowDeploymentResponseRef4 = { + blockId: string | null + provider: string | null + url: string +} + +type GetWorkflowDeploymentResponseRef5 = { id: string isDeployed: boolean deployedAt: string | null @@ -5328,10 +5423,11 @@ type GetWorkflowDeploymentResponseRef4 = { latestDeploymentAttempt: GetWorkflowDeploymentResponseRef1 | null needsRedeployment: boolean isPublicApi: boolean + webhooks: Array } export type GetWorkflowDeploymentResponse = { - data: GetWorkflowDeploymentResponseRef4 + data: GetWorkflowDeploymentResponseRef5 } /** `GET /api/v2/workflow-mcp-servers/[serverId]` */ @@ -9682,6 +9778,31 @@ type SearchTableRowsBodyRef0 = } > } + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } export type SearchTableRowsBody = { workspaceId: string @@ -10381,6 +10502,31 @@ type UpdateRowsByFilterBodyRef0 = } > } + | { + field: string + op: + | 'eq' + | 'ne' + | 'gt' + | 'gte' + | 'lt' + | 'lte' + | 'in' + | 'nin' + | 'contains' + | 'ncontains' + | 'startsWith' + | 'endsWith' + | 'like' + | 'ilike' + | 'nlike' + | 'nilike' + | 'isEmpty' + | 'isNotEmpty' + | 'isNull' + | 'isNotNull' + value?: unknown + } type UpdateRowsByFilterBodyRef1 = Record @@ -12188,7 +12334,8 @@ export const V2_OPERATIONS = { description: { kind: 'string', describe: 'Optional credential description.' }, id: { kind: 'string', - describe: 'Required only when provider discovery requests a client-generated ID.', + describe: + 'Optional client-generated credential ID. The server mints one when it is omitted, so no provider requires it. A `slack-custom-bot` credential may supply one so its Slack Request URL, which embeds the ID, can be configured before the credential exists; every other provider ignores it.', }, credentials: { kind: 'string', From 51965bb64dec52b83c7e6679767ae7f424cbc39f Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 19:45:52 +0530 Subject: [PATCH 067/306] same-workspace import keeps or warns about workspace bindings; generic required-field lint; folder moves to the root; empty-graph and no-entry lint notes; span durationMs; MCP tools reported on undeploy and listed inactive; paged connector types and workspaces; grep --in refuses unknown selectors; run_function names exported files; run tools surface the failing block's error Export takes includeWorkspaceBindings for same-workspace round trips and import answers with warnings naming every block whose required binding was stripped. Lint reports a missing required field for every block type (the knowledge base id was skipped), notes an empty graph and a graph with no entry block, and the trace spans carry durationMs. Undeploy answers with the MCP tools it archived and the tools list shows them inactive. connector-types list is paged (25, summary projection, detail=full) and workspaces list defaults to 25. The agent CLI's grep refuses a prefix or unknown --in selector with the accepted forms; a run_function that exported files and wrote a table says both; a run tool whose executor result carries no message uses the failing block's own error. --- .../docs/content/docs/cli/connector-types.mdx | 2 + apps/docs/content/docs/cli/logs.mdx | 3 +- apps/docs/content/docs/cli/reference.mdx | 9 +- apps/docs/content/docs/cli/workflows.mdx | 4 +- apps/docs/openapi-v2-files-audit.json | 4 +- apps/docs/openapi-v2-knowledge.json | 4 +- apps/docs/openapi-v2-resources.json | 117 +++++++++++- apps/docs/openapi-v2-tables.json | 4 +- apps/docs/openapi-v2-workflows.json | 44 ++++- .../app/api/v2/connector-types/route.test.ts | 101 ++++++++-- apps/sim/app/api/v2/connector-types/route.ts | 47 ++++- .../sim/app/api/v2/logs/[runId]/route.test.ts | 43 +++++ apps/sim/app/api/v2/logs/[runId]/route.ts | 4 +- apps/sim/app/api/v2/logs/route.test.ts | 38 ++++ apps/sim/app/api/v2/logs/route.ts | 5 +- .../[serverId]/tools/route.test.ts | 34 +++- .../app/api/v2/workflow-mcp-servers/utils.ts | 7 +- .../[workflowId]/deploy/route.test.ts | 3 + .../v2/workflows/[workflowId]/deploy/route.ts | 2 + .../[workflowId]/export/route.test.ts | 31 +++ .../v2/workflows/[workflowId]/export/route.ts | 1 + .../app/api/v2/workflows/import/route.test.ts | 33 ++++ apps/sim/app/api/v2/workflows/import/route.ts | 3 +- apps/sim/app/api/v2/workspaces/route.test.ts | 3 +- .../v2/__tests__/list-pagination.test.ts | 3 +- apps/sim/lib/api/contracts/v2/catalog.ts | 58 +++++- .../lib/api/contracts/v2/openapi/resources.ts | 30 ++- .../lib/api/contracts/v2/openapi/workflows.ts | 5 +- apps/sim/lib/api/contracts/v2/shared.ts | 4 +- .../api/contracts/v2/workflow-mcp-servers.ts | 16 +- apps/sim/lib/api/contracts/v2/workflows.ts | 72 +++++-- apps/sim/lib/api/contracts/v2/workspaces.ts | 13 +- .../application/list-connector-types.ts | 35 ++-- .../application/list-registries.test.ts | 65 +++++-- .../lib/catalog/projection/connector-type.ts | 20 ++ apps/sim/lib/folders/orchestration.test.ts | 64 +++++++ apps/sim/lib/folders/orchestration.ts | 12 +- apps/sim/lib/folders/paths.test.ts | 36 ++++ apps/sim/lib/folders/paths.ts | 16 +- .../logs/execution/trace-spans/trace-spans.ts | 27 +++ .../mcp/application/workflow-deployments.ts | 10 +- apps/sim/lib/mcp/queries.ts | 37 ++++ .../agent-cli/engines/universal-grep.test.ts | 29 +++ .../agent-cli/engines/universal-grep.ts | 29 ++- .../mothership/request/tools/tables.test.ts | 57 ++++-- .../lib/mothership/request/tools/tables.ts | 31 ++- .../tools/handlers/workflow/mutations.test.ts | 17 ++ .../tools/handlers/workflow/mutations.ts | 28 ++- .../workspace-file-folder-manager.test.ts | 18 ++ .../workspace-file-folder-manager.ts | 11 +- .../apply-workflow-operations.test.ts | 2 +- .../lib/workflows/application/deployments.ts | 9 + .../application/import-export.test.ts | 80 +++++++- .../workflows/application/import-export.ts | 12 +- .../workflows/application/mapped-import.ts | 7 +- .../application/workflow-deployments.test.ts | 26 +++ .../workflows/application/workflow-folders.ts | 6 +- .../credentials/credential-extractor.test.ts | 179 ++++++++++++++++++ .../credentials/credential-extractor.ts | 122 +++++++++++- .../lib/workflows/editing/lint-report.test.ts | 113 +++++++++++ apps/sim/lib/workflows/editing/lint-report.ts | 24 ++- apps/sim/lib/workflows/editing/lint.test.ts | 141 +++++++++++++- apps/sim/lib/workflows/editing/lint.ts | 19 +- .../operations/export-workflow.test.ts | 143 +++++++++++--- .../workflows/operations/export-workflow.ts | 24 ++- .../workflows/operations/import-workflow.ts | 25 ++- .../workflows/sanitization/json-sanitizer.ts | 13 +- apps/sim/serializer/index.ts | 29 ++- packages/sim-cli/src/generated/v2-api.ts | 74 ++++++-- 69 files changed, 2116 insertions(+), 221 deletions(-) create mode 100644 apps/sim/lib/workflows/editing/lint-report.test.ts diff --git a/apps/docs/content/docs/cli/connector-types.mdx b/apps/docs/content/docs/cli/connector-types.mdx index 2823ed60e0c..587f5d0639c 100644 --- a/apps/docs/content/docs/cli/connector-types.mdx +++ b/apps/docs/content/docs/cli/connector-types.mdx @@ -20,5 +20,7 @@ sim connector-types list [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Case-insensitive substring match against the connector name. | +| `--detail ` | No | Projection of each item. `summary` (the default) carries the identifier, name, description, and auth mode; `full` adds the version, the complete auth settings, the `sourceConfig` field schema, incremental-sync support, and tag definitions. Accepted values: `summary`, `full`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | diff --git a/apps/docs/content/docs/cli/logs.mdx b/apps/docs/content/docs/cli/logs.mdx index f29733d9b76..339b678c585 100644 --- a/apps/docs/content/docs/cli/logs.mdx +++ b/apps/docs/content/docs/cli/logs.mdx @@ -53,7 +53,8 @@ sim logs stats [options] | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | -| `--segment-count ` | No | Number of time buckets, up to 500. Exactly this many are returned, each at least one minute wide. Short windows extend past the requested end and include empty trailing buckets. | +| `--segment-count ` | No | Number of equal time buckets to divide the window into, from 1 to 500. It is the ceiling on how many buckets a series carries: with `includeEmpty=true` exactly this many are returned, otherwise only the buckets holding at least one run. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty. | +| `--include-empty ` | No | Whether buckets with no runs are included in every series. Off by default, so each series carries only the buckets that hold at least one run; set it to publish exactly `segmentCount` buckets per series, empty ones included. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected. Accepted values: `true`, `1`, `yes`, `on`, `y`, `enabled`, `false`, `0`, `no`, `off`, `n`, `disabled`. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 94a12cd89ab..c3dcd299412 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -406,6 +406,8 @@ sim connector-types list [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Case-insensitive substring match against the connector name. | +| `--detail ` | No | Projection of each item. `summary` (the default) carries the identifier, name, description, and auth mode; `full` adds the version, the complete auth settings, the `sourceConfig` field schema, incremental-sync support, and tag definitions. Accepted values: `summary`, `full`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | @@ -2750,7 +2752,8 @@ sim logs stats [options] | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | -| `--segment-count ` | No | Number of time buckets, up to 500. Exactly this many are returned, each at least one minute wide. Short windows extend past the requested end and include empty trailing buckets. | +| `--segment-count ` | No | Number of equal time buckets to divide the window into, from 1 to 500. It is the ceiling on how many buckets a series carries: with `includeEmpty=true` exactly this many are returned, otherwise only the buckets holding at least one run. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty. | +| `--include-empty ` | No | Whether buckets with no runs are included in every series. Off by default, so each series carries only the buckets that hold at least one run; set it to publish exactly `segmentCount` buckets per series, empty ones included. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected. Accepted values: `true`, `1`, `yes`, `on`, `y`, `enabled`, `false`, `0`, `no`, `off`, `n`, `disabled`. | @@ -5268,7 +5271,7 @@ sim workflows runs get [options] | --- | --- | --- | | `--workflow ` | Yes | Workflow ID. | | `--include-output` | No | Include the final output in JSON or YAML output. | -| `--select-output ` | No | Include blockId or blockId.path values in JSON or YAML output; block names are not resolved on a finished run (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--select-output ` | No | Include blockName.path or blockId.path values (e.g. agent_1.content) in JSON or YAML output; names resolve against the workflow’s current blocks, and missing paths are omitted (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline each produced file's bytes as base64. Requires `includeOutput`. A file above the inline ceiling answers `413` naming its download path; fetch large files from `downloadPath` instead. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Per-file inline ceiling, lowering but never raising the server limit of 16 MiB. | @@ -5710,6 +5713,8 @@ sim workflows export [options] | Option | Required | Description | | --- | --- | --- | | `--include-references` | No | Include non-secret resource identities for mapped import. | +| `--include-workspace-bindings` | No | Whether to keep workspace-scoped bindings — table, knowledge base, document, folder, channel, and other resource selectors — in the exported state. Defaults to false, the sharing-safe export in which those ids are cleared because they resolve nowhere else. Send true for a same-workspace round trip so the re-imported workflow can run without re-selecting them. Credentials, passwords, and table sub-block values are cleared either way. | +| `--no-include-workspace-bindings` | No | Send --include-workspace-bindings as false. | diff --git a/apps/docs/content/docs/cli/workflows.mdx b/apps/docs/content/docs/cli/workflows.mdx index 4bad7cf46e3..3fb55707f3d 100644 --- a/apps/docs/content/docs/cli/workflows.mdx +++ b/apps/docs/content/docs/cli/workflows.mdx @@ -152,7 +152,7 @@ Show run status (requested outputs are included in JSON or YAML output) | --- | --- | --- | | `--workflow ` | Yes | Workflow ID. | | `--include-output` | No | Include the final output in JSON or YAML output. | -| `--select-output ` | No | Include blockId or blockId.path values in JSON or YAML output; block names are not resolved on a finished run (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--select-output ` | No | Include blockName.path or blockId.path values (e.g. agent_1.content) in JSON or YAML output; names resolve against the workflow’s current blocks, and missing paths are omitted (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline each produced file's bytes as base64. Requires `includeOutput`. A file above the inline ceiling answers `413` naming its download path; fetch large files from `downloadPath` instead. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Per-file inline ceiling, lowering but never raising the server limit of 16 MiB. | @@ -572,6 +572,8 @@ sim workflows export [options] | Option | Required | Description | | --- | --- | --- | | `--include-references` | No | Include non-secret resource identities for mapped import. | +| `--include-workspace-bindings` | No | Whether to keep workspace-scoped bindings — table, knowledge base, document, folder, channel, and other resource selectors — in the exported state. Defaults to false, the sharing-safe export in which those ids are cleared because they resolve nowhere else. Send true for a same-workspace round trip so the re-imported workflow can run without re-selecting them. Credentials, passwords, and table sub-block values are cleared either way. | +| `--no-include-workspace-bindings` | No | Send --include-workspace-bindings as false. | diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 28a9c5494b5..f78ecfbf4ed 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -6566,8 +6566,8 @@ "$ref": "#/components/schemas/NonRootFolderPathInput" }, "destinationPath": { - "description": "New full path for the folder and its descendants.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "description": "Where the folder lands, with `mv` semantics. A path naming an existing folder receives the source as a child under its current name; `/` moves it to the workspace root under its current name; any other path becomes the folder’s new full path (a rename, a relocation, or both).", + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["workspaceId", "path", "destinationPath"], diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index e36b58becc8..baaba82d6bd 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -7724,8 +7724,8 @@ "$ref": "#/components/schemas/NonRootFolderPathInput" }, "destinationPath": { - "description": "New full path for the folder and its descendants.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "description": "Where the folder lands, with `mv` semantics. A path naming an existing folder receives the source as a child under its current name; `/` moves it to the workspace root under its current name; any other path becomes the folder’s new full path (a rename, a relocation, or both).", + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["workspaceId", "path", "destinationPath"], diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 54b25355f32..523204bcb0b 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -104,10 +104,10 @@ "name": "limit", "in": "query", "required": false, - "description": "Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "description": "Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 25.", "schema": { - "default": 50, - "description": "Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "default": 25, + "description": "Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 25.", "type": "integer", "minimum": 1, "maximum": 100 @@ -4692,11 +4692,47 @@ "minLength": 1, "maxLength": 200 } + }, + { + "name": "detail", + "in": "query", + "required": false, + "description": "Projection of each item. `summary` (the default) carries the identifier, name, description, and auth mode; `full` adds the version, the complete auth settings, the `sourceConfig` field schema, incremental-sync support, and tag definitions.", + "schema": { + "default": "summary", + "description": "Projection of each item. `summary` (the default) carries the identifier, name, description, and auth mode; `full` adds the version, the complete auth settings, the `sourceConfig` field schema, incremental-sync support, and tag definitions.", + "type": "string", + "enum": ["summary", "full"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum connector types to return per page. Must be a whole number from 1 to 100. Defaults to 25.", + "schema": { + "default": 25, + "description": "Maximum connector types to return per page. Must be a whole number from 1 to 100. Defaults to 25.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } } ], "responses": { "200": { - "description": "The connector-type catalog.", + "description": "One page of the connector-type catalog.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -9055,6 +9091,11 @@ "type": "string", "description": "ISO 8601 timestamp when the tool was last modified.", "format": "date-time" + }, + "status": { + "type": "string", + "enum": ["active", "inactive"], + "description": "Whether MCP clients can call the tool. `inactive` is a registration that undeploying its workflow archived: it is not callable and its name stays reserved on the server, and the next deploy of the workflow makes it `active` again. Unpublishing an inactive tool removes it for good." } }, "required": [ @@ -9066,11 +9107,12 @@ "mcpServerUrl", "apiEndpoint", "createdAt", - "updatedAt" + "updatedAt", + "status" ], "additionalProperties": false, "title": "Workflow MCP tool list item", - "description": "A tool a server publishes, as returned by a read." + "description": "A tool a server publishes, as returned by a read. Archived registrations are included with `status: \"inactive\"`." }, "ListWorkflowMcpToolsResponse": { "type": "object", @@ -9114,7 +9156,8 @@ "mcpServerUrl": "https://www.sim.ai/api/mcp/serve/wfmcp_01J8ZK3QW4M6X2R9T7B5C0V2", "apiEndpoint": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/execute", "createdAt": "2026-06-12T10:30:00.000Z", - "updatedAt": "2026-06-12T10:30:00.000Z" + "updatedAt": "2026-06-12T10:30:00.000Z", + "status": "active" } ], "nextCursor": null, @@ -11247,13 +11290,54 @@ "title": "Connector config field", "description": "One field of a knowledge-base connector’s source configuration." }, + "V2ConnectorTypeSummary": { + "type": "object", + "properties": { + "connectorType": { + "type": "string", + "description": "Exact identifier to send when creating a connector of this type." + }, + "name": { + "type": "string", + "description": "Display name." + }, + "description": { + "type": "string", + "description": "What the connector syncs." + }, + "auth": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["oauth", "apiKey"], + "description": "How the connector authenticates against its source." + } + }, + "required": ["mode"], + "additionalProperties": false, + "description": "Authentication mode only. `detail=full` adds the OAuth provider and scopes, or the API-key field labels." + } + }, + "required": ["connectorType", "name", "description", "auth"], + "additionalProperties": false, + "title": "Connector type summary", + "description": "A knowledge-base connector type without its configuration schema. Request `detail=full` for the config fields and tag definitions." + }, "ListConnectorTypesResponse": { "type": "object", "properties": { "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2ConnectorType" + "anyOf": [ + { + "$ref": "#/components/schemas/V2ConnectorType" + }, + { + "$ref": "#/components/schemas/V2ConnectorTypeSummary" + } + ] }, "description": "Items in the current page." }, @@ -11266,14 +11350,27 @@ "type": "null" } ], - "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, "required": ["data", "nextCursor"], "additionalProperties": false, "title": "List connector types response", - "description": "Knowledge-base connector types and their configuration fields.", + "description": "Knowledge-base connector types, as summaries or with their configuration fields.", "examples": [ + { + "data": [ + { + "connectorType": "google_drive", + "name": "Google Drive", + "description": "Sync documents from a Google Drive folder.", + "auth": { + "mode": "oauth" + } + } + ], + "nextCursor": null + }, { "data": [ { diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 38fdcea0416..c74343efdcb 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -9947,8 +9947,8 @@ "$ref": "#/components/schemas/NonRootFolderPathInput" }, "destinationPath": { - "description": "New full path for the folder and its descendants.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "description": "Where the folder lands, with `mv` semantics. A path naming an existing folder receives the source as a child under its current name; `/` moves it to the workspace root under its current name; any other path becomes the folder’s new full path (a rename, a relocation, or both).", + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["workspaceId", "path", "destinationPath"], diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index beb340cdf16..749d82e42f6 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -10061,6 +10061,23 @@ }, "additionalProperties": false }, + "ArchivedWorkflowMcpTool": { + "type": "object", + "properties": { + "serverId": { + "type": "string", + "description": "Workflow-MCP server the tool is published on." + }, + "toolName": { + "type": "string", + "description": "Name MCP clients called the tool by." + } + }, + "required": ["serverId", "toolName"], + "additionalProperties": false, + "title": "Archived workflow MCP tool", + "description": "A workflow-MCP tool registration an undeploy took inactive." + }, "UndeployResult": { "type": "object", "properties": { @@ -10114,6 +10131,13 @@ } ], "description": "Most recent deployment lifecycle attempt, or null when none is available." + }, + "archivedMcpTools": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ArchivedWorkflowMcpTool" + }, + "description": "MCP tool registrations this undeploy archived. Each appears in its server’s tool list with `status: \"inactive\"` until the workflow is deployed again, which restores it; unpublishing one while it is inactive removes it for good. Empty when no server published the workflow." } }, "required": [ @@ -10122,11 +10146,12 @@ "deployedAt", "warnings", "activeDeployment", - "latestDeploymentAttempt" + "latestDeploymentAttempt", + "archivedMcpTools" ], "additionalProperties": false, "title": "Undeploy result", - "description": "Deployment state after a successful undeploy. `isDeployed` is false and no workflow version is active." + "description": "Deployment state after a successful undeploy. `isDeployed` is false and no workflow version is active. Undeploying also takes every MCP tool that published the workflow inactive; `archivedMcpTools` names them so the effect is not silent." }, "UndeployWorkflowResponse": { "type": "object", @@ -10148,7 +10173,8 @@ "deployedAt": null, "warnings": [], "activeDeployment": null, - "latestDeploymentAttempt": null + "latestDeploymentAttempt": null, + "archivedMcpTools": [] } } ] @@ -10706,11 +10732,12 @@ "folderPath", "createdAt", "updatedAt", - "blocks" + "blocks", + "warnings" ], "additionalProperties": false, "title": "Imported workflow", - "description": "Workflow created by an import operation." + "description": "Workflow created by an import operation, with the required workspace bindings it arrived without." }, "ImportWorkflowResponse": { "type": "object", @@ -10750,7 +10777,8 @@ "type": "response", "name": "Reply" } - ] + ], + "warnings": ["Triage: knowledgeBaseId was stripped by export; set it before running"] } } ] @@ -12730,8 +12758,8 @@ "$ref": "#/components/schemas/NonRootFolderPathInput" }, "destinationPath": { - "description": "New full path for the folder and its descendants.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "description": "Where the folder lands, with `mv` semantics. A path naming an existing folder receives the source as a child under its current name; `/` moves it to the workspace root under its current name; any other path becomes the folder’s new full path (a rename, a relocation, or both).", + "$ref": "#/components/schemas/FolderPathInput" } }, "required": ["workspaceId", "path", "destinationPath"], diff --git a/apps/sim/app/api/v2/connector-types/route.test.ts b/apps/sim/app/api/v2/connector-types/route.test.ts index c260854ef78..8869929a142 100644 --- a/apps/sim/app/api/v2/connector-types/route.test.ts +++ b/apps/sim/app/api/v2/connector-types/route.test.ts @@ -22,6 +22,7 @@ vi.mock('@/lib/catalog/application/list-connector-types', () => ({ }, })) +import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET } from '@/app/api/v2/connector-types/route' @@ -34,10 +35,15 @@ const auth = { keyType: 'workspace' as const, } -const connectorType = { +const connectorTypeSummary = { connectorType: 'google_drive', name: 'Google Drive', description: 'Sync Drive documents.', + auth: { mode: 'oauth' as const }, +} + +const connectorType = { + ...connectorTypeSummary, version: '1.0.0', auth: { mode: 'oauth' as const, provider: 'google-drive' }, configFields: [ @@ -54,6 +60,13 @@ const connectorType = { tagDefinitions: [], } +function page( + entries: T[], + overrides: { offset?: number; limit?: number; hasMore?: boolean } = {} +) { + return { entries, offset: 0, limit: 25, hasMore: false, ...overrides } +} + function request(url: string) { return new NextRequest(`http://localhost:3000${url}`, { headers: { 'x-api-key': 'key' } }) } @@ -64,31 +77,97 @@ describe('/api/v2/connector-types', () => { v2RouteMocks.authenticate.mockResolvedValue(auth) v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.connectorTypes.mockResolvedValue({ connectorTypes: [connectorType] }) + mocks.connectorTypes.mockResolvedValue(page([connectorTypeSummary])) }) - it('returns the whole catalog in one page and keeps it out of shared caches', async () => { + /** + * The whole catalog with every config schema was 178 K characters. The + * default is a bounded page of summaries; the schema is one `detail=full` away. + */ + it('asks for a summary page of 25 by default and keeps it out of shared caches', async () => { const response = await GET(request(`/api/v2/connector-types?workspaceId=${WORKSPACE_ID}`)) expect(response.status).toBe(200) expect(response.headers.get('Cache-Control')).toBe('private, no-store') - expect(await response.json()).toEqual({ data: [connectorType], nextCursor: null }) + expect(await response.json()).toEqual({ data: [connectorTypeSummary], nextCursor: null }) + expect(mocks.connectorTypes).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + workspaceId: WORKSPACE_ID, + search: undefined, + detail: 'summary', + limit: 25, + offset: 0, + }, + }) + ) }) - it('publishes the multi and canonical-pair properties a caller configures against', async () => { - const response = await GET(request(`/api/v2/connector-types?workspaceId=${WORKSPACE_ID}`)) + it('publishes the full config schema, with its multi and canonical-pair properties, on detail=full', async () => { + mocks.connectorTypes.mockResolvedValue(page([connectorType])) + + const response = await GET( + request(`/api/v2/connector-types?workspaceId=${WORKSPACE_ID}&detail=full`) + ) - const [field] = (await response.json()).data[0].configFields - expect(field.multi).toBe(true) - expect(field.canonicalParamId).toBe('folderId') + expect(response.status).toBe(200) + const [item] = (await response.json()).data + expect(item).toEqual(connectorType) + expect(item.configFields[0]).toMatchObject({ multi: true, canonicalParamId: 'folderId' }) + expect(mocks.connectorTypes).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ detail: 'full' }) }) + ) }) - it('rejects pagination params a full-set list does not implement', async () => { + it('mints a cursor when more remain and resumes from it under the same filters', async () => { + mocks.connectorTypes.mockResolvedValue( + page([connectorTypeSummary], { limit: 1, hasMore: true }) + ) + + const first = await GET( + request(`/api/v2/connector-types?workspaceId=${WORKSPACE_ID}&limit=1&search=drive`) + ) + const { nextCursor } = await first.json() + expect(typeof nextCursor).toBe('string') + + mocks.connectorTypes.mockResolvedValue(page([], { offset: 1, limit: 1 })) + const second = await GET( + request( + `/api/v2/connector-types?workspaceId=${WORKSPACE_ID}&limit=1&search=drive&cursor=${encodeURIComponent(nextCursor)}` + ) + ) + + expect(second.status).toBe(200) + expect(mocks.connectorTypes).toHaveBeenLastCalledWith( + expect.objectContaining({ input: expect.objectContaining({ offset: 1, search: 'drive' }) }) + ) + }) + + it('refuses a cursor minted under a different projection', async () => { + mocks.connectorTypes.mockResolvedValue( + page([connectorTypeSummary], { limit: 1, hasMore: true }) + ) + const { nextCursor } = await ( + await GET(request(`/api/v2/connector-types?workspaceId=${WORKSPACE_ID}&limit=1`)) + ).json() + const response = await GET( - request(`/api/v2/connector-types?workspaceId=${WORKSPACE_ID}&limit=1`) + request( + `/api/v2/connector-types?workspaceId=${WORKSPACE_ID}&limit=1&detail=full&cursor=${encodeURIComponent(nextCursor)}` + ) ) expect(response.status).toBe(400) + expect((await response.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) + }) + + it('rejects an unknown projection and an out-of-range page size', async () => { + expect( + (await GET(request(`/api/v2/connector-types?workspaceId=${WORKSPACE_ID}&detail=all`))).status + ).toBe(400) + expect( + (await GET(request(`/api/v2/connector-types?workspaceId=${WORKSPACE_ID}&limit=0`))).status + ).toBe(400) expect(mocks.connectorTypes).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v2/connector-types/route.ts b/apps/sim/app/api/v2/connector-types/route.ts index 176f5c0de6f..875ab3fc18b 100644 --- a/apps/sim/app/api/v2/connector-types/route.ts +++ b/apps/sim/app/api/v2/connector-types/route.ts @@ -1,20 +1,59 @@ -import { v2ListConnectorTypesContract } from '@/lib/api/contracts/v2/catalog' +import { + type V2ListConnectorTypesQuery, + v2ListConnectorTypesContract, +} from '@/lib/api/contracts/v2/catalog' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { listCatalogConnectorTypes } from '@/lib/catalog/application/list-connector-types' import { catalogOperations } from '@/lib/catalog/application/operations' import { catalogErrorPolicy } from '@/app/api/v2/lib/catalog' +import { cursorSortKey, decodeOffsetCursor, encodeOffsetCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** GET /api/v2/connector-types — List every knowledge-base connector type. */ +/** The list has one order — the registry's — so the cursor is stamped with a fixed sort key. */ +const CONNECTOR_TYPE_SORT = cursorSortKey('registry', 'asc') + +/** Every param that changes which connector types, in which shape, this list returns. */ +function connectorTypeCursorFilters(query: V2ListConnectorTypesQuery) { + return cursorScopeKey(cursorRoute(v2ListConnectorTypesContract), { + workspaceId: query.workspaceId, + search: query.search, + detail: query.detail, + }) +} + +/** + * GET /api/v2/connector-types — List knowledge-base connector types. + * + * Paged by the same offset cursor as `GET /api/v2/blocks`: the sequence is the + * code-defined registry filtered in memory. `detail` is stamped into the cursor + * because a page of summaries and a page of full types are different + * sequences to a caller reading them back. + */ export const GET = defineV2JsonRoute({ contract: v2ListConnectorTypesContract, operation: catalogOperations.listConnectorTypes, auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: catalogErrorPolicy, - mapInput: ({ query }) => query, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + search: query.search, + detail: query.detail, + limit: query.limit, + offset: decodeOffsetCursor( + query.cursor, + CONNECTOR_TYPE_SORT, + connectorTypeCursorFilters(query) + ), + }), useCase: listCatalogConnectorTypes, - present: ({ connectorTypes }) => ({ data: connectorTypes, nextCursor: null }), + present: ({ entries, hasMore, offset, limit }, { query }) => ({ + data: entries, + nextCursor: hasMore + ? encodeOffsetCursor(CONNECTOR_TYPE_SORT, connectorTypeCursorFilters(query), offset + limit) + : null, + }), }) diff --git a/apps/sim/app/api/v2/logs/[runId]/route.test.ts b/apps/sim/app/api/v2/logs/[runId]/route.test.ts index 8da71ead2e7..4ec311af009 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.test.ts @@ -108,6 +108,49 @@ describe('GET /api/v2/logs/[runId]', () => { }) }) + /** + * Stored spans carry only `duration`; the contract publishes `durationMs` + * beside it, and readers that trusted the documented name found it empty on + * every span. The projection fills it, nested spans included. + */ + it('publishes durationMs on every span from the stored duration', async () => { + mocks.execute.mockResolvedValue({ + log, + workflowFolderPath: '/agents', + executionData: { + traceSpans: [ + { + id: 'workflow-execution', + name: 'Workflow Execution', + type: 'workflow', + duration: 120, + startTime: '2026-08-06T00:00:00.000Z', + endTime: '2026-08-06T00:00:00.120Z', + children: [ + { + id: 'agent-1', + name: 'Agent', + type: 'agent', + duration: 80, + startTime: '2026-08-06T00:00:00.010Z', + endTime: '2026-08-06T00:00:00.090Z', + }, + ], + }, + ], + finalOutput: null, + }, + }) + + const response = await GET(new NextRequest('http://localhost:3000/api/v2/logs/run-1'), { + params: Promise.resolve({ runId: 'run-1' }), + }) + + const [root] = (await response.json()).data.traceSpans + expect(root).toMatchObject({ duration: 120, durationMs: 120 }) + expect(root.children[0]).toMatchObject({ duration: 80, durationMs: 80 }) + }) + it('serves a run whose persisted status is paused', async () => { mocks.execute.mockResolvedValue({ log: { ...log, status: 'paused' }, diff --git a/apps/sim/app/api/v2/logs/[runId]/route.ts b/apps/sim/app/api/v2/logs/[runId]/route.ts index a40163224c2..af541aa72e9 100644 --- a/apps/sim/app/api/v2/logs/[runId]/route.ts +++ b/apps/sim/app/api/v2/logs/[runId]/route.ts @@ -4,6 +4,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2LogErrorPolicies } from '@/lib/logs/api/route-policies' import { getPublicLog } from '@/lib/logs/application/get-public-log' import { logOperations } from '@/lib/logs/application/operations' +import { withSpanDurationMs } from '@/lib/logs/execution/trace-spans/trace-spans' import { projectLogFiles } from '@/lib/logs/log-files' export const revalidate = 0 @@ -63,7 +64,8 @@ export const GET = defineV2JsonRoute({ deleted: !log.workflowName || log.workflowArchivedAt !== null, }, workflowState: log.workflowState, - traceSpans: traceSpansSchema.parse(executionData.traceSpans ?? []), + traceSpans: withSpanDurationMs(traceSpansSchema.parse(executionData.traceSpans ?? [])), + finalOutput: executionData.finalOutput ?? null, /** * `cost_total` is a backfilled projection of the ledger, so it is null on diff --git a/apps/sim/app/api/v2/logs/route.test.ts b/apps/sim/app/api/v2/logs/route.test.ts index 77fb809f44f..59898908099 100644 --- a/apps/sim/app/api/v2/logs/route.test.ts +++ b/apps/sim/app/api/v2/logs/route.test.ts @@ -97,6 +97,44 @@ describe('GET /api/v2/logs', () => { }) }) + it('publishes durationMs on included trace spans from the stored duration', async () => { + mocks.execute.mockResolvedValue({ + items: [ + { + log, + executionData: { + finalOutput: null, + traceSpans: [ + { + id: 'agent-1', + name: 'Agent', + type: 'agent', + duration: 80, + startTime: '2026-08-06T00:00:00.010Z', + endTime: '2026-08-06T00:00:00.090Z', + }, + ], + }, + }, + ], + nextCursorKeys: null, + includeFullDetails: true, + includeFinalOutput: false, + includeTraceSpans: true, + }) + + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&includeTraceSpans=true` + ) + ) + + expect((await response.json()).data[0].traceSpans[0]).toMatchObject({ + duration: 80, + durationMs: 80, + }) + }) + it('serves a run whose persisted status is paused', async () => { mocks.execute.mockResolvedValue({ items: [{ log: { ...log, status: 'paused' }, executionData: null }], diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index 0ef0a706aad..e00095dd5f9 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -15,6 +15,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2LogErrorPolicies } from '@/lib/logs/api/route-policies' import { listPublicLogs } from '@/lib/logs/application/list-public-logs' import { logOperations } from '@/lib/logs/application/operations' +import { withSpanDurationMs } from '@/lib/logs/execution/trace-spans/trace-spans' import { jobCostTotal } from '@/lib/logs/fetch-log-detail' import { LOG_FOLDER_SCOPE_VERSION } from '@/lib/logs/folder-scope' import { projectLogFiles } from '@/lib/logs/log-files' @@ -188,7 +189,9 @@ export const GET = defineV2JsonRoute({ item.finalOutput = executionData.finalOutput } if (includeTraceSpans) { - item.traceSpans = traceSpansSchema.parse(executionData.traceSpans ?? []) + item.traceSpans = withSpanDurationMs( + traceSpansSchema.parse(executionData.traceSpans ?? []) + ) } } return item diff --git a/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/tools/route.test.ts b/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/tools/route.test.ts index c509616529e..b40e9d15f39 100644 --- a/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/tools/route.test.ts +++ b/apps/sim/app/api/v2/workflow-mcp-servers/[serverId]/tools/route.test.ts @@ -50,7 +50,7 @@ vi.mock('@/lib/mcp/queries', async (importOriginal) => ({ getWorkflowMcpServerById: mocks.getServer, getLiveWorkflowMcpTool: mocks.getLiveTool, getWorkflowMcpToolIncludingArchived: mocks.getLiveTool, - listLiveWorkflowMcpTools: mocks.listTools, + listWorkflowMcpToolsIncludingArchived: mocks.listTools, getWorkflowMcpPublishableWorkflow: mocks.getWorkflow, })) vi.mock('@/lib/mcp/orchestration', () => ({ @@ -325,6 +325,38 @@ describe('/api/v2/workflow-mcp-servers/[serverId]/tools', () => { }) }) + /** + * An undeploy archives the workflow's registrations rather than deleting + * them. Omitting those rows made `tools list` answer `[]` for a server whose + * only workflow was undeployed, with nothing to say the tool still existed. + */ + it('lists an archived registration as inactive rather than omitting it', async () => { + mocks.getServer.mockResolvedValue(serverRow) + mocks.listTools.mockResolvedValue({ + tools: [ + toolRow, + { + ...toolRow, + id: 'wfmcptool-2', + workflowId: 'workflow-2', + toolName: 'close_ticket', + archivedAt: new Date('2026-06-13T10:30:00.000Z'), + }, + ], + truncated: false, + }) + + const body = await (await get()).json() + + expect( + body.data.map((tool: { toolName: string; status: string }) => [tool.toolName, tool.status]) + ).toEqual([ + ['triage_ticket', 'active'], + ['close_ticket', 'inactive'], + ]) + expect(body.data[1]).not.toHaveProperty('archivedAt') + }) + /** `updated` reports what a publish did; a read has no publish to report. */ it('omits the publish-only updated flag', async () => { mocks.getServer.mockResolvedValue(serverRow) diff --git a/apps/sim/app/api/v2/workflow-mcp-servers/utils.ts b/apps/sim/app/api/v2/workflow-mcp-servers/utils.ts index 1aaacb436e2..58d1a0f46b5 100644 --- a/apps/sim/app/api/v2/workflow-mcp-servers/utils.ts +++ b/apps/sim/app/api/v2/workflow-mcp-servers/utils.ts @@ -65,10 +65,15 @@ export function toV2WorkflowMcpTool(row: WorkflowMcpToolRow, updated: boolean): }) } -/** {@link toV2WorkflowMcpTool} for a read, which has no publish outcome to report. */ +/** + * {@link toV2WorkflowMcpTool} for a read, which has no publish outcome to report. + * An archived row is a registration an undeploy withdrew: published as + * `inactive` rather than omitted, so the inventory explains itself. + */ export function toV2WorkflowMcpToolListItem(row: WorkflowMcpToolRow): V2WorkflowMcpToolListItem { return v2WorkflowMcpToolListItemSchema.parse({ ...row, + status: row.archivedAt ? 'inactive' : 'active', mcpServerUrl: buildWorkflowMcpServerUrl(row.serverId), apiEndpoint: buildWorkflowMcpApiEndpoint(row.workflowId), createdAt: row.createdAt.toISOString(), diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.test.ts index 00850da058f..2e8eecbd5cf 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.test.ts @@ -95,7 +95,10 @@ describe('/api/v2/workflows/[workflowId]/deploy route definitions', () => { workflowId: 'workflow-1', workspaceId: 'workspace-1', warnings: [], + archivedMcpTools: [{ serverId: 'wfmcp-1', toolName: 'triage_ticket' }], }) expect(v2UndeployWorkflowContract.response.schema.parse(body)).toEqual(body) + /** Undeploying takes the workflow's MCP tools inactive; the caller must see which. */ + expect(body.data.archivedMcpTools).toEqual([{ serverId: 'wfmcp-1', toolName: 'triage_ticket' }]) }) }) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.ts index 6dc563160c9..40a8f83d4e3 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.ts @@ -59,8 +59,10 @@ export const DELETE = defineV2JsonRoute({ warnings: result.warnings ?? [], activeDeployment: null, latestDeploymentAttempt: null, + archivedMcpTools: result.archivedMcpTools, }, }), + /** * Telemetry only. `workflowOperations.undeploy` denies a workspace API key at * admission, so a non-personal principal cannot reach here — and this hook runs diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/export/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/export/route.test.ts index 641c88e6372..24b8a1f7581 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/export/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/export/route.test.ts @@ -16,6 +16,7 @@ vi.mock('@/lib/api/server/routes', () => ({ v2OrchestrationErrorPolicy: { kind: 'orchestration-errors' }, })) +import { v2ExportWorkflowContract } from '@/lib/api/contracts/v2/workflows' import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { exportWorkflow } from '@/lib/workflows/application/import-export' import { workflowOperations } from '@/lib/workflows/application/operations' @@ -30,8 +31,38 @@ describe('/api/v2/workflows/[workflowId]/export route definition', () => { }) }) + /** + * The export is sharing-safe by default: workspace bindings are cleared. A + * caller round-tripping into the same workspace opts in per request, and the + * flag must reach the use case rather than stop at the query parser. + */ + it('forwards independent reference and workspace-binding options', () => { + const mapInput = Reflect.get(GET, 'mapInput') as (args: { + params: { workflowId: string } + query: { includeReferences?: boolean; includeWorkspaceBindings: boolean } + }) => Record + + expect(v2ExportWorkflowContract.query.parse({})).toEqual({ includeWorkspaceBindings: false }) + expect(v2ExportWorkflowContract.query.parse({ includeWorkspaceBindings: 'true' })).toEqual({ + includeWorkspaceBindings: true, + }) + expect( + mapInput({ params: { workflowId: 'workflow-1' }, query: { includeWorkspaceBindings: true } }) + ).toEqual({ workflowId: 'workflow-1', includeReferences: false, includeWorkspaceBindings: true }) + expect( + mapInput({ + params: { workflowId: 'workflow-1' }, + query: v2ExportWorkflowContract.query.parse({ + includeReferences: 'true', + includeWorkspaceBindings: 'true', + }), + }) + ).toEqual({ workflowId: 'workflow-1', includeReferences: true, includeWorkspaceBindings: true }) + }) + /** * Next aliases a missing `HEAD` export onto `GET`, and RFC 9110 §9.2.1 defines + * `HEAD` as safe. This `GET` is not: the use case projects a * `WORKFLOW_EXPORTED` audit event, so an uptime monitor or link checker * probing the documented URL would file an export that never handed anyone diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/export/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/export/route.ts index fda2ae9f317..f61178c474f 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/export/route.ts @@ -22,6 +22,7 @@ export const GET = defineV2JsonRoute({ mapInput: ({ params, query }) => ({ workflowId: params.workflowId, includeReferences: query.includeReferences === true, + includeWorkspaceBindings: query.includeWorkspaceBindings, }), useCase: exportWorkflow, present: ({ payload, folderPath }) => ({ diff --git a/apps/sim/app/api/v2/workflows/import/route.test.ts b/apps/sim/app/api/v2/workflows/import/route.test.ts index 92bcff6a035..eb1a0e4b930 100644 --- a/apps/sim/app/api/v2/workflows/import/route.test.ts +++ b/apps/sim/app/api/v2/workflows/import/route.test.ts @@ -16,6 +16,7 @@ vi.mock('@/lib/api/server/routes', () => ({ v2OrchestrationErrorPolicy: { kind: 'orchestration-errors' }, })) +import { v2ImportWorkflowContract } from '@/lib/api/contracts/v2/workflows' import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { type ImportWorkflowResult, @@ -30,6 +31,18 @@ const definition = POST as unknown as { present: (result: ImportWorkflowResult) => { data: Record } } +const importedWorkflow = { + id: 'workflow-1', + name: 'Imported', + description: null, + workspaceId: 'ws-1', + folderId: null, + sortOrder: 0, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + blocks: [], +} + describe('/api/v2/workflows/import route definition', () => { it('uses authorized admission and preserves the bounded import lifecycle', () => { expect(POST).toMatchObject({ @@ -65,8 +78,28 @@ describe('/api/v2/workflows/import route definition', () => { blocks, }, folderPath: '/', + warnings: [], }) expect(data).toMatchObject({ id: 'workflow-1', name: 'Imported', folderPath: '/', blocks }) }) + + /** + * Export clears workspace bindings, so a round-tripped workflow used to land + * silently unable to run. The warnings are the response's way of saying which + * fields to set, and the contract requires the array even when it is empty. + */ + it('presents the stripped-binding warnings and the contract requires them', () => { + const warnings = ['Lookup: tableId was stripped by export; set it before running'] + + const body = definition.present({ workflow: importedWorkflow, folderPath: '/', warnings }) + + expect(body.data.warnings).toEqual(warnings) + expect(v2ImportWorkflowContract.response.schema.parse(body)).toEqual(body) + expect( + v2ImportWorkflowContract.response.schema.safeParse({ + data: { ...body.data, warnings: undefined }, + }).success + ).toBe(false) + }) }) diff --git a/apps/sim/app/api/v2/workflows/import/route.ts b/apps/sim/app/api/v2/workflows/import/route.ts index d8d4693d2f9..0a72d2cb671 100644 --- a/apps/sim/app/api/v2/workflows/import/route.ts +++ b/apps/sim/app/api/v2/workflows/import/route.ts @@ -17,7 +17,7 @@ export const POST = defineV2JsonRoute({ parseOptions: { maxBodyBytes: MAX_IMPORT_BODY_BYTES }, mapInput: ({ body }) => body, useCase: importWorkflow, - present: ({ workflow, folderPath, operation }) => ({ + present: ({ workflow, folderPath, operation, warnings }) => ({ data: { ...operation, id: workflow.id, @@ -28,6 +28,7 @@ export const POST = defineV2JsonRoute({ createdAt: workflow.createdAt.toISOString(), updatedAt: workflow.updatedAt.toISOString(), blocks: workflow.blocks, + warnings, }, }), }) diff --git a/apps/sim/app/api/v2/workspaces/route.test.ts b/apps/sim/app/api/v2/workspaces/route.test.ts index a6ab601aff3..67862cf515b 100644 --- a/apps/sim/app/api/v2/workspaces/route.test.ts +++ b/apps/sim/app/api/v2/workspaces/route.test.ts @@ -157,12 +157,13 @@ describe('v2 workspace routes', () => { ], nextCursor: null, }) + /** A bounded first page by default: 25, not the v2-wide 50. */ expect(mocks.listWorkspaces).toHaveBeenCalledWith({ principal: auth.principal, input: { sortBy: 'createdAt', sortOrder: 'desc', - limit: 50, + limit: 25, offset: 0, }, request, diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index f8bcec04ae0..89a8058b3b2 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -49,6 +49,7 @@ const PAGED_LISTS = [ 'GET /api/v2/billing/logs', 'GET /api/v2/blocks', 'GET /api/v2/chat-deployments', + 'GET /api/v2/connector-types', 'GET /api/v2/credentials', 'GET /api/v2/custom-tools', 'GET /api/v2/files', @@ -110,7 +111,6 @@ const PAGED_LISTS = [ * never listed. */ const FULL_SET_LISTS = [ - 'GET /api/v2/connector-types', 'GET /api/v2/credentials/providers', 'GET /api/v2/files/folders', 'GET /api/v2/knowledge/[knowledgeBaseId]/tags', @@ -167,6 +167,7 @@ const CURSOR_BINDINGS: Record = { 'sortBy', 'sortOrder', ], + 'GET /api/v2/connector-types': ['workspaceId', 'search', 'detail'], 'GET /api/v2/credentials': ['workspaceId', 'type', 'providerId', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/custom-tools': ['workspaceId', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/files': [ diff --git a/apps/sim/lib/api/contracts/v2/catalog.ts b/apps/sim/lib/api/contracts/v2/catalog.ts index 6c5710edf94..bf7c9b07253 100644 --- a/apps/sim/lib/api/contracts/v2/catalog.ts +++ b/apps/sim/lib/api/contracts/v2/catalog.ts @@ -711,6 +711,45 @@ export const v2ConnectorTypeSchema = z }) export type V2ConnectorType = z.output +/** + * The projection `GET /api/v2/connector-types` returns unless asked for + * `detail=full`. Sixty-odd connector types with every config field, option + * list, and tag definition came to well over 100 KB for a caller that only + * needed to pick one; the summary is what that decision takes, and the full + * shape is one `detail=full` away. + */ +export const v2ConnectorTypeSummarySchema = z + .object({ + connectorType: v2ConnectorTypeSchema.shape.connectorType, + name: v2ConnectorTypeSchema.shape.name, + description: v2ConnectorTypeSchema.shape.description, + auth: z + .object({ + mode: z + .enum(['oauth', 'apiKey']) + .describe('How the connector authenticates against its source.'), + }) + .describe( + 'Authentication mode only. `detail=full` adds the OAuth provider and scopes, or the API-key field labels.' + ), + }) + .meta({ + id: 'V2ConnectorTypeSummary', + title: 'Connector type summary', + description: + 'A knowledge-base connector type without its configuration schema. Request `detail=full` for the config fields and tag definitions.', + }) +export type V2ConnectorTypeSummary = z.output + +export const v2ConnectorTypeDetailSchema = z.enum(['summary', 'full']) +export type V2ConnectorTypeDetail = z.output + +/** + * Smaller than the v2 default of 50: the summary is the point of the default + * projection, and a page of 25 keeps the full projection readable too. + */ +export const V2_CONNECTOR_TYPES_DEFAULT_PAGE_SIZE = 25 + export const v2BlockSortFields = ['id', 'name', 'category'] as const export const v2ToolSortFields = ['id', 'name'] as const @@ -778,6 +817,16 @@ export type V2GetToolParams = z.output export const v2ListConnectorTypesQuerySchema = catalogWorkspaceQuerySchema .extend({ search: v2SearchSchema.describe('Case-insensitive substring match against the connector name.'), + detail: v2ConnectorTypeDetailSchema + .optional() + .default('summary') + .describe( + 'Projection of each item. `summary` (the default) carries the identifier, name, description, and auth mode; `full` adds the version, the complete auth settings, the `sourceConfig` field schema, incremental-sync support, and tag definitions.' + ), + ...v2PaginationFields({ + description: 'Maximum connector types to return per page.', + fallback: V2_CONNECTOR_TYPES_DEFAULT_PAGE_SIZE, + }), }) .strict() export type V2ListConnectorTypesQuery = z.output @@ -842,12 +891,19 @@ export const v2ExecuteToolContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2ToolExecutionSchema) }, }) +/** + * Connector-type list, paginated by the same offset cursor as the block and + * tool lists: the sequence is the code-defined connector registry filtered in + * memory, so there is no ordered SQL read for a keyset predicate to act on. + * Items are the summary projection unless `detail=full` is sent; the full + * schema is listed first so a full item is never narrowed to a summary. + */ export const v2ListConnectorTypesContract = defineRouteContract({ method: 'GET', path: '/api/v2/connector-types', query: v2ListConnectorTypesQuerySchema, response: { mode: 'json', - schema: v2CursorListResponse(v2ConnectorTypeSchema, { paged: false }), + schema: v2CursorListResponse(z.union([v2ConnectorTypeSchema, v2ConnectorTypeSummarySchema])), }, }) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 4f9a3acbdae..f770b669fbf 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -171,6 +171,13 @@ const BLOCK_DETAIL_EXAMPLE = { outputs: { ts: { type: 'string', description: 'Message timestamp.' } }, } as const +const CONNECTOR_TYPE_SUMMARY_EXAMPLE = { + connectorType: 'google_drive', + name: 'Google Drive', + description: 'Sync documents from a Google Drive folder.', + auth: { mode: 'oauth' }, +} as const + const CONNECTOR_TYPE_EXAMPLE = { connectorType: 'google_drive', name: 'Google Drive', @@ -277,9 +284,12 @@ const WORKFLOW_MCP_TOOL_EXAMPLE = { updatedAt: '2026-06-12T10:30:00.000Z', } as const -/** The publish example as a read returns it: `updated` is a publish outcome, not a field of the tool. */ +/** + * The publish example as a read returns it: `updated` is a publish outcome, not + * a field of the tool, and `status` is a fact only a read can report. + */ function omitUpdated({ updated: _updated, ...tool }: typeof WORKFLOW_MCP_TOOL_EXAMPLE) { - return tool + return { ...tool, status: 'active' as const } } const WORKSPACE_ID = 'a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64' @@ -1801,7 +1811,8 @@ const declaredRoutes = [ applicationOperation: mcpServerOperations.listWorkflowDeploymentTools, operationId: 'listWorkflowMcpTools', summary: 'List Workflow MCP Tools', - description: `List a server's published tools by name, including workflow IDs used to unpublish them. Returns up to 2,000 tools with \`nextCursor: null\`; \`truncated\` indicates an incomplete inventory that cannot be paginated. ${WORKSPACE_API_KEY_DENIED}`, + description: `Every tool a server publishes, tool-name ordered. The server list reports tool *names* only, so this is where a caller reads the \`workflowId\` that \`DELETE /api/v2/workflow-mcp-servers/{serverId}/tools/{workflowId}\` addresses. Registrations that undeploying their workflow archived are included with \`status: "inactive"\` rather than omitted; deploying the workflow again makes them \`active\`. Returned in one page rather than paged — so \`nextCursor\` is always null — and capped at 2,000 tools, \`truncated\` indicates an incomplete inventory that cannot be paginated. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, success: { description: 'The tools this server publishes.' }, }), @@ -2127,23 +2138,26 @@ const declaredRoutes = [ applicationOperation: catalogOperations.listConnectorTypes, operationId: 'listConnectorTypes', summary: 'List Connector Types', - description: `List connector types and accepted source configuration. A field with \`multi: true\` stores \`string[]\`. \`canonicalParamId\` links picker and manual fields that write the same key; send exactly one, keyed by \`canonicalParamId\` rather than its own \`id\`. ${FULL_SET_LIST}`, + description: `List knowledge-base connector types with opaque cursor pagination, 25 to a page by default. Each item is a summary — identifier, name, description, and auth mode — unless \`detail=full\` is sent, which adds the source configuration each type accepts. Two properties of a config field decide how its value is sent and are not inferable from the rest: a field with \`multi: true\` stores a \`string[]\` rather than a \`string\`, and a \`canonicalParamId\` links a picker field to a manual-entry field that write the SAME configuration key — send exactly one of the pair, keyed by \`canonicalParamId\` rather than by the field's own \`id\`.`, errors: RESOURCE_ERRORS, - success: { description: 'The connector-type catalog.' }, + success: { description: 'One page of the connector-type catalog.' }, }), { query: documentedSchema( v2ListConnectorTypesContract.query, 'ListConnectorTypesQuery', 'List connector types query', - 'Workspace scope and optional connector-name search.' + 'Workspace scope, projection, optional connector-name search, and pagination.' ), response: documentedSchema( v2ListConnectorTypesContract.response.schema, 'ListConnectorTypesResponse', 'List connector types response', - 'Knowledge-base connector types and their configuration fields.', - [{ data: [CONNECTOR_TYPE_EXAMPLE], nextCursor: null }] + 'Knowledge-base connector types, as summaries or with their configuration fields.', + [ + { data: [CONNECTOR_TYPE_SUMMARY_EXAMPLE], nextCursor: null }, + { data: [CONNECTOR_TYPE_EXAMPLE], nextCursor: null }, + ] ), } ), diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index eaa89d8a7fc..0076a0d11e1 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -908,6 +908,7 @@ const declaredRoutes = [ warnings: [], activeDeployment: null, latestDeploymentAttempt: null, + archivedMcpTools: [], }, }, ] @@ -966,7 +967,7 @@ const declaredRoutes = [ applicationOperation: workflowOperations.export, operationId: 'exportWorkflow', summary: 'Export Workflow', - description: `Export a portable, secret-sanitized workflow; Set includeReferences=true to include non-secret source reference identities for mapped import; default exports keep their existing sanitized shape. Exporting records an audit event. ${HEAD_MIRRORS_GET} ${FOLDER_TREE_TOO_LARGE}`, + description: `Export a portable, secret-sanitized workflow. Use includeReferences=true for non-secret source identities and field occurrences used by mapped imports. Use includeWorkspaceBindings=true to retain non-secret workspace bindings for a same-workspace round trip; default exports clear those bindings. Credentials and secrets are cleared either way. Exporting records an audit event. ${HEAD_MIRRORS_GET} ${FOLDER_TREE_TOO_LARGE}`, errors: [...RESOURCE_ERRORS, 'PayloadTooLarge'], success: jsonSuccess('The workflow export payload.'), }), @@ -1040,6 +1041,7 @@ const declaredRoutes = [ { id: 'block_triage', type: 'agent', name: 'Triage' }, { id: 'block_reply', type: 'response', name: 'Reply' }, ], + warnings: ['Triage: knowledgeBaseId was stripped by export; set it before running'], }, }, ] @@ -1048,6 +1050,7 @@ const declaredRoutes = [ ), defineOpenApiRoute( v2ListChatDeploymentsContract, + workflowOperation({ applicationOperation: chatDeploymentOperations.list, operationId: 'listChatDeployments', diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index af4aba1b8e9..56f00b66043 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -621,8 +621,8 @@ export const v2RelocateFolderBodySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace containing the folder.'), path: v2NonRootFolderPathInputSchema.describe('Current folder path.'), - destinationPath: v2NonRootFolderPathInputSchema.describe( - 'New full path for the folder and its descendants.' + destinationPath: v2FolderPathInputSchema.describe( + 'Where the folder lands, with `mv` semantics. A path naming an existing folder receives the source as a child under its current name; `/` moves it to the workspace root under its current name; any other path becomes the folder’s new full path (a rename, a relocation, or both).' ), }) .strict() diff --git a/apps/sim/lib/api/contracts/v2/workflow-mcp-servers.ts b/apps/sim/lib/api/contracts/v2/workflow-mcp-servers.ts index 353dd78ee45..8266459ac74 100644 --- a/apps/sim/lib/api/contracts/v2/workflow-mcp-servers.ts +++ b/apps/sim/lib/api/contracts/v2/workflow-mcp-servers.ts @@ -380,20 +380,34 @@ export const v2CreateWorkflowMcpServerContract = defineRouteContract({ }, }) +export const v2WorkflowMcpToolStatusSchema = z + .enum(['active', 'inactive']) + .describe( + 'Whether MCP clients can call the tool. `inactive` is a registration that undeploying its workflow archived: it is not callable and its name stays reserved on the server, and the next deploy of the workflow makes it `active` again. Unpublishing an inactive tool removes it for good.' + ) +export type V2WorkflowMcpToolStatus = z.output + /** * A published tool as a read returns it. * * `updated` is omitted deliberately: it reports whether a *publish* replaced an * existing tool, which is a fact about that request, not about the tool. * Publishing it here would force every read to answer a question it cannot. + * + * `status` is a read-only fact: an undeploy archives the workflow's + * registrations rather than deleting them, and a list that omitted those rows + * showed an undeployed workflow's tools as silently unpublished. */ export const v2WorkflowMcpToolListItemSchema = v2WorkflowMcpToolSchema .omit({ updated: true }) + .extend({ status: v2WorkflowMcpToolStatusSchema }) .meta({ id: 'WorkflowMcpToolListItem', title: 'Workflow MCP tool list item', - description: 'A tool a server publishes, as returned by a read.', + description: + 'A tool a server publishes, as returned by a read. Archived registrations are included with `status: "inactive"`.', }) + export type V2WorkflowMcpToolListItem = z.output export const v2GetWorkflowMcpServerContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 6a8aa630d7c..ba364f52d18 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -451,12 +451,33 @@ export const v2DeployWorkflowDataSchema = v2DeploymentStateSchema }) export type V2DeployWorkflowData = z.output -export const v2UndeployWorkflowDataSchema = v2DeploymentStateSchema.extend({}).meta({ - id: 'UndeployResult', - title: 'Undeploy result', - description: - 'Deployment state after a successful undeploy. `isDeployed` is false and no workflow version is active.', -}) +export const v2ArchivedWorkflowMcpToolSchema = z + .object({ + serverId: z.string().describe('Workflow-MCP server the tool is published on.'), + toolName: z.string().describe('Name MCP clients called the tool by.'), + }) + .meta({ + id: 'ArchivedWorkflowMcpTool', + title: 'Archived workflow MCP tool', + description: 'A workflow-MCP tool registration an undeploy took inactive.', + }) +export type V2ArchivedWorkflowMcpTool = z.output + +export const v2UndeployWorkflowDataSchema = v2DeploymentStateSchema + .extend({ + archivedMcpTools: z + .array(v2ArchivedWorkflowMcpToolSchema) + .describe( + 'MCP tool registrations this undeploy archived. Each appears in its server’s tool list with `status: "inactive"` until the workflow is deployed again, which restores it; unpublishing one while it is inactive removes it for good. Empty when no server published the workflow.' + ), + }) + .meta({ + id: 'UndeployResult', + title: 'Undeploy result', + description: + 'Deployment state after a successful undeploy. `isDeployed` is false and no workflow version is active. Undeploying also takes every MCP tool that published the workflow inactive; `archivedMcpTools` names them so the effect is not silent.', + }) + export type V2UndeployWorkflowData = z.output export const v2RollbackWorkflowDataSchema = v2DeploymentStateSchema @@ -2335,27 +2356,46 @@ export const v2ImportWorkflowDataSchema = z .describe( 'Blocks the import created, in payload order. A summary only; the workflow state read returns the full graph.' ), + warnings: z + .array(z.string()) + .describe( + 'One line per required workspace binding — a table, knowledge base, document, or other selector — that the payload carried empty, as `: was stripped by export; set it before running`. Export clears those bindings unless `includeWorkspaceBindings=true` was sent, so a round-tripped workflow arrives unable to run until they are set again. Empty when nothing is missing.' + ), }) .extend(v2OperationReportSchema.omit({ workspaceId: true }).partial().shape) .meta({ id: 'ImportedWorkflow', title: 'Imported workflow', - description: 'Workflow created by an import operation.', + description: + 'Workflow created by an import operation, with the required workspace bindings it arrived without.', + }) + +export const v2ExportWorkflowQuerySchema = z + .object({ + includeReferences: booleanQueryFlagSchema.optional().describe( + "Include non-secret resource identifiers and source field occurrences for mapped imports." + ), + includeWorkspaceBindings: booleanQueryFlagSchema + .describe( + 'Whether to keep workspace-scoped bindings — table, knowledge base, document, folder, channel, and other resource selectors — in the exported state. Defaults to false, the sharing-safe export in which those ids are cleared because they resolve nowhere else. Send true for a same-workspace round trip so the re-imported workflow can run without re-selecting them. Credentials, passwords, and table sub-block values are cleared either way.' + ) + .optional() + .default(false), + }) + .strict() + .meta({ + id: 'ExportWorkflowQuery', + title: 'Export workflow query', + description: 'Whether the export keeps workspace-scoped bindings.', }) +export type V2ExportWorkflowQuery = z.output export const v2ExportWorkflowContract = defineRouteContract({ method: 'GET', path: '/api/v2/workflows/[workflowId]/export', - query: z - .object({ - includeReferences: booleanQueryFlagSchema - .optional() - .describe( - 'Include non-secret resource identifiers and source field occurrences for mapped imports.' - ), - }) - .strict(), + query: v2ExportWorkflowQuerySchema, params: v2WorkflowIdParamsSchema, + response: { mode: 'json', schema: v2DataResponse(v2WorkflowExportPayloadSchema), diff --git a/apps/sim/lib/api/contracts/v2/workspaces.ts b/apps/sim/lib/api/contracts/v2/workspaces.ts index 355d4e02945..cb7c0b78ca5 100644 --- a/apps/sim/lib/api/contracts/v2/workspaces.ts +++ b/apps/sim/lib/api/contracts/v2/workspaces.ts @@ -41,12 +41,23 @@ export type V2Workspace = z.output export const v2WorkspaceSortFields = ['name', 'createdAt', 'updatedAt'] as const export type V2WorkspaceSortBy = (typeof v2WorkspaceSortFields)[number] +/** + * Below the v2 default of 50. A personal key can reach every workspace its + * owner belongs to, and an unbounded-feeling first page is the wrong default + * for a list a caller usually scans to pick one; the cursor is there for more. + */ +export const V2_WORKSPACES_DEFAULT_PAGE_SIZE = 25 + export const v2ListWorkspacesQuerySchema = z .object({ ...v2SortFields(v2WorkspaceSortFields, { sortBy: 'createdAt', sortOrder: 'desc' }), - ...v2PaginationFields({ description: 'Maximum workspaces to return per page.' }), + ...v2PaginationFields({ + description: 'Maximum workspaces to return per page.', + fallback: V2_WORKSPACES_DEFAULT_PAGE_SIZE, + }), }) .strict() + export type V2ListWorkspacesQuery = z.output export const v2WorkspaceMemberSchema = z diff --git a/apps/sim/lib/catalog/application/list-connector-types.ts b/apps/sim/lib/catalog/application/list-connector-types.ts index 5bf924cc2e2..132b462746d 100644 --- a/apps/sim/lib/catalog/application/list-connector-types.ts +++ b/apps/sim/lib/catalog/application/list-connector-types.ts @@ -1,12 +1,17 @@ +import type { V2ConnectorTypeDetail } from '@/lib/api/contracts/v2/catalog' import { loadCatalogWorkspaceContext } from '@/lib/catalog/application/catalog-context' import { + type CatalogPage, matchesCatalogSearch, normalizeCatalogSearch, + takeCatalogPage, } from '@/lib/catalog/application/catalog-page' import { catalogOperations } from '@/lib/catalog/application/operations' import { type CatalogConnectorType, + type CatalogConnectorTypeSummary, projectConnectorType, + toConnectorTypeSummary, } from '@/lib/catalog/projection/connector-type' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' @@ -14,20 +19,26 @@ import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' export interface ListCatalogConnectorTypesInput { workspaceId: string search?: string + /** `summary` unless the caller asked for the configuration schema. */ + detail: V2ConnectorTypeDetail + limit: number + offset: number } -export interface ListCatalogConnectorTypesResult { - connectorTypes: CatalogConnectorType[] -} +export type ListCatalogConnectorTypesResult = CatalogPage< + CatalogConnectorType | CatalogConnectorTypeSummary +> /** - * Every knowledge-base connector type, in registry order. + * Knowledge-base connector types, in registry order, one page at a time. * - * Returned as one page: the set is bounded by the code-defined connector - * registry rather than by workspace content, exactly as the credential-provider - * catalog is. Nothing gates a connector type per workspace today, but the - * operation is still workspace-scoped — retrofitting a required parameter onto - * a shipped v2 contract is a breaking change, and one parameter now is cheap. + * The set is bounded by the code-defined connector registry rather than by + * workspace content, but bounded is not small: every type with its full config + * schema ran to well over 100 KB, so the list pages and projects a summary + * unless `detail` asks for the schema. Nothing gates a connector type per + * workspace today, but the operation is still workspace-scoped — retrofitting + * a required parameter onto a shipped v2 contract is a breaking change, and + * one parameter now is cheap. */ export const listCatalogConnectorTypes = defineAuthorizedWorkspaceUseCase({ operation: catalogOperations.listConnectorTypes, @@ -36,12 +47,12 @@ export const listCatalogConnectorTypes = defineAuthorizedWorkspaceUseCase({ authorizationOptions: {}, execute: async ({ input }): Promise => { const search = normalizeCatalogSearch(input.search) - const connectorTypes: CatalogConnectorType[] = [] + const connectorTypes: Array = [] for (const [connectorType, meta] of Object.entries(CONNECTOR_META_REGISTRY)) { const projected = projectConnectorType(connectorType, meta) if (!matchesCatalogSearch(search, projected.name)) continue - connectorTypes.push(projected) + connectorTypes.push(input.detail === 'full' ? projected : toConnectorTypeSummary(projected)) } - return { connectorTypes } + return takeCatalogPage(connectorTypes, input.offset, input.limit) }, }) diff --git a/apps/sim/lib/catalog/application/list-registries.test.ts b/apps/sim/lib/catalog/application/list-registries.test.ts index d3649d79dd2..4d17c34d740 100644 --- a/apps/sim/lib/catalog/application/list-registries.test.ts +++ b/apps/sim/lib/catalog/application/list-registries.test.ts @@ -43,36 +43,77 @@ describe('connector-type catalog', () => { mocks.resolvePermission.mockResolvedValue('read') }) + const fullPage = { detail: 'full' as const, limit: 100, offset: 0 } + it('returns the whole connector-type registry and records no audit', async () => { - const { connectorTypes } = await listCatalogConnectorTypes.execute({ + const { entries, hasMore } = await listCatalogConnectorTypes.execute({ principal: session, - input: { workspaceId: WORKSPACE_ID }, + input: { workspaceId: WORKSPACE_ID, ...fullPage }, }) - expect(connectorTypes.length).toBeGreaterThan(10) - expect(connectorTypes.every((entry) => typeof entry.connectorType === 'string')).toBe(true) + expect(entries.length).toBeGreaterThan(10) + expect(hasMore).toBe(false) + expect(entries.every((entry) => typeof entry.connectorType === 'string')).toBe(true) expect(mocks.recordAudit).not.toHaveBeenCalled() }) it('publishes the multi and canonical-pair config properties a caller cannot infer', async () => { - const { connectorTypes } = await listCatalogConnectorTypes.execute({ + const { entries } = await listCatalogConnectorTypes.execute({ principal: session, - input: { workspaceId: WORKSPACE_ID }, + input: { workspaceId: WORKSPACE_ID, ...fullPage }, }) - const fields = connectorTypes.flatMap((entry) => entry.configFields) + const fields = entries.flatMap((entry) => ('configFields' in entry ? entry.configFields : [])) + expect(fields.length).toBeGreaterThan(0) expect(fields.some((field) => field.multi === true)).toBe(true) expect(fields.some((field) => typeof field.canonicalParamId === 'string')).toBe(true) expect(fields.every((field) => !Object.hasOwn(field, 'icon'))).toBe(true) }) + /** + * Sixty-odd types with their whole config schema is well over 100 KB for a + * caller that only needs to pick one. The default projection is what that + * decision takes; `detail=full` is where the schema lives. + */ + it('projects a summary without the config schema unless detail=full is asked for', async () => { + const { entries } = await listCatalogConnectorTypes.execute({ + principal: session, + input: { + workspaceId: WORKSPACE_ID, + search: 'notion', + detail: 'summary', + limit: 25, + offset: 0, + }, + }) + + expect(entries).toHaveLength(1) + expect(Object.keys(entries[0]).sort()).toEqual(['auth', 'connectorType', 'description', 'name']) + expect(entries[0]).toMatchObject({ connectorType: 'notion', auth: { mode: 'oauth' } }) + }) + + it('pages the registry from an offset', async () => { + const first = await listCatalogConnectorTypes.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, detail: 'summary', limit: 1, offset: 0 }, + }) + const second = await listCatalogConnectorTypes.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, detail: 'summary', limit: 1, offset: 1 }, + }) + + expect(first).toMatchObject({ offset: 0, limit: 1, hasMore: true }) + expect(second.entries).toHaveLength(1) + expect(second.entries[0].connectorType).not.toBe(first.entries[0].connectorType) + }) + it('searches connector names case-insensitively', async () => { - const { connectorTypes } = await listCatalogConnectorTypes.execute({ + const { entries } = await listCatalogConnectorTypes.execute({ principal: session, - input: { workspaceId: WORKSPACE_ID, search: 'noTIon' }, + input: { workspaceId: WORKSPACE_ID, search: 'noTIon', ...fullPage }, }) - expect(connectorTypes.map((entry) => entry.connectorType)).toEqual(['notion']) + expect(entries.map((entry) => entry.connectorType)).toEqual(['notion']) }) it('answers not found for a workspace the caller cannot reach', async () => { @@ -81,7 +122,7 @@ describe('connector-type catalog', () => { await expect( listCatalogConnectorTypes.execute({ principal: session, - input: { workspaceId: WORKSPACE_ID }, + input: { workspaceId: WORKSPACE_ID, ...fullPage }, }) ).rejects.toMatchObject({ code: 'not_found', message: 'Workspace not found' }) }) @@ -90,7 +131,7 @@ describe('connector-type catalog', () => { await expect( listCatalogConnectorTypes.execute({ principal: session, - input: { workspaceId: WORKSPACE_ID, search: ' ' }, + input: { workspaceId: WORKSPACE_ID, search: ' ', ...fullPage }, }) ).rejects.toMatchObject({ code: 'validation', message: 'search cannot be empty' }) }) diff --git a/apps/sim/lib/catalog/projection/connector-type.ts b/apps/sim/lib/catalog/projection/connector-type.ts index 116dfaf92c6..4e0c7ee6237 100644 --- a/apps/sim/lib/catalog/projection/connector-type.ts +++ b/apps/sim/lib/catalog/projection/connector-type.ts @@ -143,3 +143,23 @@ export function projectConnectorType( tagDefinitions: (meta.tagDefinitions ?? []).map(projectTagDefinition), } } + +/** A connector type as the default list projection publishes it: enough to choose one. */ +export interface CatalogConnectorTypeSummary { + connectorType: string + name: string + description: string + auth: { mode: CatalogConnectorAuth['mode'] } +} + +/** Narrows a projected connector type to the summary the list returns by default. */ +export function toConnectorTypeSummary( + connectorType: CatalogConnectorType +): CatalogConnectorTypeSummary { + return { + connectorType: connectorType.connectorType, + name: connectorType.name, + description: connectorType.description, + auth: { mode: connectorType.auth.mode }, + } +} diff --git a/apps/sim/lib/folders/orchestration.test.ts b/apps/sim/lib/folders/orchestration.test.ts index b4ffac386e4..b40667faa3f 100644 --- a/apps/sim/lib/folders/orchestration.test.ts +++ b/apps/sim/lib/folders/orchestration.test.ts @@ -617,6 +617,70 @@ describe('path-owned folder mutations', () => { ) }) + /** + * `/` is the one destination that always names an existing folder, so it + * takes the source as a child like any other: the nested folder lands at the + * top level under its own name. Before this the root was refused outright, + * which left no way to move a folder back out of its parent. + */ + it('moves a nested folder back to the root when the destination is /', async () => { + const archive = folderRow({ id: 'folder-2', name: 'fx-archive' }) + const source = folderRow({ id: 'folder-1', name: 'xp-docs', parentId: 'folder-2' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([ + ['folder-1', source], + ['folder-2', archive], + ]), + pathById: new Map([ + ['folder-1', '/fx-archive/xp-docs'], + ['folder-2', '/fx-archive'], + ]), + idByPath: new Map([ + ['/fx-archive/xp-docs', 'folder-1'], + ['/fx-archive', 'folder-2'], + ]), + }) + dbChainMockFns.returning.mockResolvedValueOnce([ + folderRow({ id: 'folder-1', name: 'xp-docs', parentId: null }), + ]) + + const result = await relocateFolderByPath({ + resourceType: 'table', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/fx-archive/xp-docs', + destinationPath: '/', + }) + + expect(result).toMatchObject({ success: true, path: '/xp-docs' }) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ name: 'xp-docs', parentId: null }) + ) + expect(auditMock.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ description: 'Moved table folder to "/xp-docs"' }) + ) + }) + + it('reports a root-level folder moved to / as already there', async () => { + const source = folderRow({ id: 'folder-1', name: 'xp-docs' }) + mockLoadActiveFolderPathIndex.mockResolvedValue({ + rowById: new Map([['folder-1', source]]), + pathById: new Map([['folder-1', '/xp-docs']]), + idByPath: new Map([['/xp-docs', 'folder-1']]), + }) + + const result = await relocateFolderByPath({ + resourceType: 'table', + workspaceId: 'ws-1', + userId: 'user-1', + path: '/xp-docs', + destinationPath: '/', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'conflict' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it('still refuses a move whose source already exists under the destination', async () => { const source = folderRow({ id: 'folder-1', name: 'xp-files' }) const archive = folderRow({ id: 'folder-2', name: 'fx-archive' }) diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index 7c5299fce0e..231afb53a9f 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -25,6 +25,7 @@ import { type FolderPathIndex, folderNameFromPath, parentFolderPath, + parseFolderPath, requireNonRootFolderPath, resolveFolderMoveDestination, } from '@/lib/folders/paths' @@ -297,7 +298,8 @@ async function executeRelocateFolderByPath( ): Promise { try { requireNonRootFolderPath(params.path) - requireNonRootFolderPath(params.destinationPath) + /** The root is a valid destination — "move to the top level" — so only canonical form is checked here. */ + parseFolderPath(params.destinationPath) const { folder, destinationPath } = await withTransactionRetry( async (tx) => { @@ -386,10 +388,10 @@ async function executeRelocateFolderByPath( } /** - * Renames, moves, or both. A destination naming an existing folder receives the - * source as a child (`mv` semantics, see {@link resolveFolderMoveDestination}); - * any other destination becomes the source's new path. `path` on the result is - * where the folder actually landed. + * Renames, moves, or both. A destination naming an existing folder — the root + * included — receives the source as a child (`mv` semantics, see + * {@link resolveFolderMoveDestination}); any other destination becomes the + * source's new path. `path` on the result is where the folder actually landed. */ export async function relocateFolderByPath( params: RelocateFolderByPathParams diff --git a/apps/sim/lib/folders/paths.test.ts b/apps/sim/lib/folders/paths.test.ts index 4071f5c1fd8..efc42a4df28 100644 --- a/apps/sim/lib/folders/paths.test.ts +++ b/apps/sim/lib/folders/paths.test.ts @@ -13,6 +13,7 @@ import { MAX_FOLDER_PATH_SEGMENTS, parseFolderPath, ROOT_FOLDER_PATH, + resolveFolderMoveDestination, } from '@/lib/folders/paths' describe('canonical folder paths', () => { @@ -108,6 +109,41 @@ describe('canonical folder paths', () => { expect(() => buildFolderPath(['x'.repeat(4096)])).toThrow('bytes') }) + it('resolves a move destination with mv semantics, the root included', () => { + const index = { + idByPath: new Map([ + ['/fx-archive', 'folder-1'], + ['/fx-archive/xp-docs', 'folder-2'], + ]), + } + + expect(resolveFolderMoveDestination(index, '/fx-archive/xp-docs', ROOT_FOLDER_PATH)).toBe( + '/xp-docs' + ) + expect(resolveFolderMoveDestination(index, '/xp-files', '/fx-archive')).toBe( + '/fx-archive/xp-files' + ) + expect(resolveFolderMoveDestination(index, '/xp-files', '/renamed')).toBe('/renamed') + expect(resolveFolderMoveDestination(index, '/fx-archive', '/fx-archive')).toBe('/fx-archive') + }) + + it('accepts the root as a relocation destination but never as the source', () => { + expect( + v2RelocateFolderBodySchema.parse({ + workspaceId: 'workspace-1', + path: '/Reports/Q1', + destinationPath: '/', + }).destinationPath + ).toBe('/') + expect( + v2RelocateFolderBodySchema.safeParse({ + workspaceId: 'workspace-1', + path: '/', + destinationPath: '/Reports', + }).success + ).toBe(false) + }) + it('keeps public folder mutations path-only and rejects the virtual root', () => { expect(v2ListFoldersQuerySchema.parse({ workspaceId: 'workspace-1', parentPath: '/' })).toEqual( { diff --git a/apps/sim/lib/folders/paths.ts b/apps/sim/lib/folders/paths.ts index 79e2a131d02..8e1a76a8990 100644 --- a/apps/sim/lib/folders/paths.ts +++ b/apps/sim/lib/folders/paths.ts @@ -158,18 +158,22 @@ export function folderNameFromPath(path: string): string { * * A destination that names an EXISTING folder receives the source as a child * under its own name: moving `/xp-files` to `/fx-archive` yields - * `/fx-archive/xp-files`. Any other destination is the source's new full path — - * a rename, a relocation, or both. Before this, an existing destination was - * refused as a name collision, so moving a folder into another meant spelling - * out the target path in full. A destination equal to the source is returned - * as is, so the caller's collision check answers it the way it always has. + * `/fx-archive/xp-files`. The root is always an existing folder for this + * purpose, so `/` moves the source to the top level under its current name — + * the only way back out of a nested folder without retyping its name. Any other + * destination is the source's new full path — a rename, a relocation, or both. + * Before this, an existing destination was refused as a name collision, so + * moving a folder into another meant spelling out the target path in full. A + * destination equal to the source is returned as is, so the caller's collision + * check answers it the way it always has. */ export function resolveFolderMoveDestination( index: Pick, sourcePath: string, destinationPath: string ): string { - if (destinationPath === sourcePath || !index.idByPath.has(destinationPath)) { + if (destinationPath === sourcePath) return destinationPath + if (destinationPath !== ROOT_FOLDER_PATH && !index.idByPath.has(destinationPath)) { return destinationPath } return buildFolderPath([...parseFolderPath(destinationPath), folderNameFromPath(sourcePath)]) diff --git a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts index 4091ff18c75..213006827a6 100644 --- a/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts +++ b/apps/sim/lib/logs/execution/trace-spans/trace-spans.ts @@ -180,3 +180,30 @@ export function traceSpansIndicateFailure( ): boolean { return spans?.some((span) => hasUnhandledError(span, options)) ?? false } + +interface DurationBearingSpan { + duration?: number + durationMs?: number | null + children?: DurationBearingSpan[] +} + +/** + * Publishes `durationMs` on every span of a tree from the `duration` the span + * builders write. + * + * The persisted span carries only `duration`; the v2 log contract publishes + * both names, and readers that took the documented `durationMs` at its word + * found it null or absent on every span. Rather than rename a field every stored + * trace already uses, the API projection fills the newer name from the older one + * — an explicit `durationMs` on a span wins, so a span written with both keeps + * its own value. + */ +export function withSpanDurationMs( + spans: readonly T[] +): Array { + return spans.map((span) => ({ + ...span, + durationMs: span.durationMs ?? span.duration, + ...(span.children ? { children: withSpanDurationMs(span.children) } : {}), + })) +} diff --git a/apps/sim/lib/mcp/application/workflow-deployments.ts b/apps/sim/lib/mcp/application/workflow-deployments.ts index 68d0af45c6d..276953352c6 100644 --- a/apps/sim/lib/mcp/application/workflow-deployments.ts +++ b/apps/sim/lib/mcp/application/workflow-deployments.ts @@ -19,8 +19,8 @@ import { getWorkflowMcpPublishableWorkflow, getWorkflowMcpServerById, getWorkflowMcpToolIncludingArchived, - listLiveWorkflowMcpTools, listWorkflowMcpToolNames, + listWorkflowMcpToolsIncludingArchived, listWorkspaceWorkflowMcpServers, type WorkflowMcpServerSortBy, } from '@/lib/mcp/queries' @@ -171,11 +171,15 @@ export interface ListWorkflowMcpDeploymentToolsInput { } /** - * Every tool a server publishes. + * Every tool a server publishes, archived registrations included. * * Without this a caller could publish and unpublish tools but never enumerate * them: the server list reports tool *names* only, so nothing returned the * `workflowId` that addresses a tool for deletion. + * + * Archived rows are the registrations an undeploy withdrew; the presenter + * reports them as `inactive`. Listing only live rows made an undeployed + * workflow's tools vanish from the inventory with nothing to say why. */ export const listWorkflowMcpDeploymentTools = defineAuthorizedWorkspaceUseCase({ operation: mcpServerOperations.listWorkflowDeploymentTools, @@ -183,7 +187,7 @@ export const listWorkflowMcpDeploymentTools = defineAuthorizedWorkspaceUseCase({ resolveServerContext(input.serverId), authorizationOptions, async execute({ context }) { - const { tools, truncated } = await listLiveWorkflowMcpTools( + const { tools, truncated } = await listWorkflowMcpToolsIncludingArchived( context.server.id, MAX_LISTED_WORKFLOW_MCP_TOOLS ) diff --git a/apps/sim/lib/mcp/queries.ts b/apps/sim/lib/mcp/queries.ts index 32ed1b9ee98..6a647ea2073 100644 --- a/apps/sim/lib/mcp/queries.ts +++ b/apps/sim/lib/mcp/queries.ts @@ -252,6 +252,42 @@ export async function listLiveWorkflowMcpTools( return { tools: rows.slice(0, limit), truncated: rows.length > limit } } +/** + * Every tool a server publishes, archived registrations included, tool-name + * ordered and bounded like {@link listLiveWorkflowMcpTools}. + * + * An undeploy archives a workflow's registrations rather than deleting them + * (`removeMcpToolsForWorkflow`), and the next deploy brings them back. A caller + * reconciling a server's inventory must see those rows as inactive rather than + * as vanished — otherwise every undeploy read as a tool silently unpublished. + */ +export async function listWorkflowMcpToolsIncludingArchived( + serverId: string, + limit: number +): Promise<{ tools: WorkflowMcpToolRow[]; truncated: boolean }> { + const rows = await db + .select() + .from(workflowMcpTool) + .where(eq(workflowMcpTool.serverId, serverId)) + .orderBy(asc(workflowMcpTool.toolName)) + .limit(limit + 1) + return { tools: rows.slice(0, limit), truncated: rows.length > limit } +} + +/** + * The live registrations of one workflow across every server, ordered by + * server then tool name — exactly the set an undeploy is about to archive. + */ +export async function listLiveWorkflowMcpToolsForWorkflow( + workflowId: string +): Promise> { + return db + .select({ serverId: workflowMcpTool.serverId, toolName: workflowMcpTool.toolName }) + .from(workflowMcpTool) + .where(and(eq(workflowMcpTool.workflowId, workflowId), isNull(workflowMcpTool.archivedAt))) + .orderBy(asc(workflowMcpTool.serverId), asc(workflowMcpTool.toolName)) +} + /** * The live tool publishing a workflow on a server, or null. * @@ -259,6 +295,7 @@ export async function listLiveWorkflowMcpTools( * index on `(server_id, workflow_id)` — so the pair is an identity, which is why * the public surface addresses a tool by workflow rather than by tool id. */ + export async function getLiveWorkflowMcpTool( serverId: string, workflowId: string diff --git a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts index 4df31470c8c..ba544341757 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts @@ -68,6 +68,35 @@ describe('universal grep', () => { expect(world.stdout).toContain('blocks/slack_v2:') }) + it('refuses a prefixed --in selector before materializing any world', async () => { + /** An empty runtime throws on any request, so the exact message proves nothing was fetched. */ + const result = await runEngine('grep', ['id'], runtimeWith({}), { in: 'workflow:abc-123' }) + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('Unknown --in selector "workflow:abc-123"') + expect(result.stderr).toContain('e.g. blocks/table_v2') + }) + + it('refuses an --in selector no resource in the searched worlds answers to', async () => { + const bare = await runEngine('grep', ['id'], runtimeWith(CATALOG), { + scope: 'blocks', + in: 'nope-not-here', + }) + expect(bare.exitCode).toBe(1) + expect(bare.stderr).toContain('Unknown --in selector "nope-not-here"') + const byPath = await runEngine('grep', ['id'], runtimeWith(CATALOG), { in: 'blocks/nope' }) + expect(byPath.exitCode).toBe(1) + expect(byPath.stderr).toContain('Unknown --in selector "blocks/nope"') + }) + + it('still reports no matches for a resource that exists but has no hits', async () => { + const result = await runEngine('grep', ['zzz-nope'], runtimeWith(CATALOG), { + scope: 'blocks', + in: 'agent', + }) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('No matches for "zzz-nope" in blocks within "agent"') + }) + it('refuses an unknown scope with a did-you-mean and the scope list', async () => { const result = await runEngine('grep', ['x'], runtimeWith({}), { scope: 'block' }) expect(result.exitCode).toBe(1) diff --git a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts index 1de2fc6eabe..ea060cec06f 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts @@ -10,7 +10,7 @@ import { import { buildIntegrationToolSchemas } from '@/lib/mothership/chat/payload' /** - * `grep [--scope a,b] [--in ] [-i] [-C n] [--count] [--limit n]` — + * `grep [--scope a,b] [--in ] [-i] [-C n] [--count] [--limit n]` — * ONE search over the materialized text of every world the agent can see (18-agent- * surface.md A2). Each resource is rendered to pretty-printed JSON exactly as its * `get` command returns it, so a hit names the same path the model would read next: @@ -41,6 +41,8 @@ const FETCH_CONCURRENCY = 8 const MAX_FILES = 300 const FILE_READ_CONCURRENCY = 5 const MAX_BYTES_PER_FILE = 262_144 +/** `workflow:` — a prefixed form no world or resource ever prints as its path. */ +const PREFIX_SELECTOR = /^\w+:/ /** The block catalog is platform-owned and changes only on deploy; per-workspace visibility keys it. */ const catalogCache = new LRUCache({ max: 500, ttl: 10 * 60_000 }) @@ -236,6 +238,11 @@ function didYouMean(scope: string): string { return close.length > 0 ? ` Did you mean ${close.join(' or ')}?` : '' } +/** The `--in` forms a match line teaches: a world, a bare id or name, or world/resource. */ +function unknownWithin(selector: string): string { + return `Unknown --in selector ${JSON.stringify(selector)}. Pass a world (workflows, blocks, …), a resource's bare id or name, or world/resource as a match line prints it (e.g. blocks/table_v2).` +} + function parseScopes(flags: AgentCliFlags): Scope[] | string { const raw = flags.scope if (raw === undefined || raw === true) return [...SCOPES] @@ -287,12 +294,19 @@ export const universalGrepCommand: AgentCliEngine = { // `--in tables` reads as "search the tables world", so a world name narrows the scope; // `--in blocks/table_v2` is the path a match line prints (world, then resource); a bare // value is a resource id or name inside the searched worlds. - const within = typeof flags.in === 'string' ? flags.in.toLowerCase() : undefined - const [withinHead, ...withinRest] = within ? within.split('/') : [] + const within = typeof flags.in === 'string' ? flags.in : undefined + const [withinHead, ...withinRest] = within ? within.toLowerCase().split('/') : [] const withinScope = SCOPES.find((scope) => scope === withinHead) - const withinResource = withinScope ? withinRest.join('/') : within + const withinResource = withinScope ? withinRest.join('/') : within?.toLowerCase() const searched: Scope[] = withinScope ? [withinScope] : scopes const nameFilter = withinResource || undefined + /** + * Refused before any fetch: the model learns the accepted forms without paying for + * a full materialization it would only get an empty result from. + */ + if (within && nameFilter && PREFIX_SELECTOR.test(nameFilter)) { + return agentCliFail(unknownWithin(within)) + } const matches = compilePattern(pattern, ignoreCase) const materialized = ( @@ -303,6 +317,13 @@ export const universalGrepCommand: AgentCliEngine = { (m) => m.id.toLowerCase() === nameFilter || m.label.toLowerCase().includes(nameFilter) ) : materialized + /** + * A resource nothing in the searched worlds answers to is a wrong selector, not a + * search with no hits — a silent "No matches" would hide the misspelling. + */ + if (within && nameFilter && candidates.length === 0) { + return agentCliFail(unknownWithin(within)) + } const out: string[] = [] let total = 0 diff --git a/apps/sim/lib/mothership/request/tools/tables.test.ts b/apps/sim/lib/mothership/request/tools/tables.test.ts index f06eed58a77..a54c76efa3d 100644 --- a/apps/sim/lib/mothership/request/tools/tables.test.ts +++ b/apps/sim/lib/mothership/request/tools/tables.test.ts @@ -270,28 +270,32 @@ describe('automatic Copilot tool-output table persistence', () => { expect(result.output).toBeUndefined() }) - it('still sees the returned rows when the run also exported sandbox files', async () => { + it('keeps the export receipt beside the table write when the run also exported files', async () => { const context = buildContext() const rows = [{ name: 'Ada' }, { name: 'Grace' }] - const message = 'Sandbox file exported to files/report.csv (12 bytes)' + const message = 'Sandbox file exported to files/xp-docs/xp-region-report.md (12 bytes)' + const files = [ + { + fileId: 'file-1', + fileName: 'xp-region-report.md', + vfsPath: 'files/xp-docs/xp-region-report.md', + size: 12, + sha256: 'abc', + unchanged: false, + }, + ] const result = await maybeWriteOutputToTable( RunFunction.id, { outputTable: 'table-1', - outputs: { files: [{ path: 'files/report.csv', sandboxPath: '/home/user/report.csv' }] }, + outputs: { + files: [{ path: 'files/xp-docs/xp-region-report.md', sandboxPath: '/home/user/r.md' }], + }, }, { success: true, - output: { - result: rows, - exported: { - message, - files: [{ fileId: 'file-1', fileName: 'report.csv', vfsPath: 'files/report.csv' }], - }, - message, - stdout: '', - }, + output: { result: rows, exported: { message, files }, message, stdout: '' }, }, context ) @@ -299,9 +303,11 @@ describe('automatic Copilot tool-output table persistence', () => { expect(result).toEqual({ success: true, output: { - message: 'Wrote 2 rows to table table-1', + message: + 'Wrote 2 rows to table table-1 and exported 1 file: files/xp-docs/xp-region-report.md', tableId: 'table-1', rowCount: 2, + exported: { files }, }, }) expect(mocks.executeReplace).toHaveBeenCalledWith( @@ -310,6 +316,31 @@ describe('automatic Copilot tool-output table persistence', () => { ) }) + it('pluralizes a multi-file export receipt on the table write', async () => { + const result = await maybeWriteOutputToTable( + RunFunction.id, + { outputTable: 'table-1' }, + { + success: true, + output: { + result: [{ name: 'Ada' }, { name: 'Grace' }], + exported: { + message: '', + files: [{ vfsPath: 'files/a.csv' }, { vfsPath: 'files/b.csv' }], + }, + }, + }, + buildContext() + ) + + expect(result).toMatchObject({ + success: true, + output: { + message: 'Wrote 2 rows to table table-1 and exported 2 files: files/a.csv, files/b.csv', + }, + }) + }) + it('fails closed when the authoritative inserted count is inconsistent', async () => { mocks.executeReplace.mockResolvedValueOnce({ table, deletedCount: 1, insertedCount: 1 }) diff --git a/apps/sim/lib/mothership/request/tools/tables.ts b/apps/sim/lib/mothership/request/tools/tables.ts index 5577096cecc..a4aea77f190 100644 --- a/apps/sim/lib/mothership/request/tools/tables.ts +++ b/apps/sim/lib/mothership/request/tools/tables.ts @@ -22,13 +22,24 @@ const MAX_OUTPUT_TABLE_ROWS = 10_000 const RETURN_ROWS_HINT = 'JavaScript: `return [...]`; Python: assign `__sim_result__ = [...]`' const ARRAY_OF_OBJECTS_ERROR = `outputTable requires the code to return an array of objects (${RETURN_ROWS_HINT})` +/** + * The sandbox export receipt `execute-request` places beside the returned value + * (`output.exported.files`), or the bare `files` an older receipt carried at the + * top level. Empty when the run exported nothing. + */ +function exportedFiles(rawOutput: unknown): Record[] { + if (!isRecordLike(rawOutput)) return [] + const files = isRecordLike(rawOutput.exported) ? rawOutput.exported.files : rawOutput.files + return Array.isArray(files) ? files.filter(isRecordLike) : [] +} + /** * Declared output files are written before the table step runs, so a table * failure after them is a partial success. The error result keeps the written * files so the caller sees what landed instead of re-running the code for it. */ function outputTableFailure(error: string, rawOutput: unknown): ToolCallResult { - const files = isRecordLike(rawOutput) && Array.isArray(rawOutput.files) ? rawOutput.files : [] + const files = exportedFiles(rawOutput) if (files.length === 0) return { success: false, error } return { success: false, @@ -156,12 +167,28 @@ export async function maybeWriteOutputToTable( deletedCount: replaceResult.deletedCount, }) span.setAttribute(TraceAttr.CopilotTableOutcome, CopilotTableOutcome.Wrote) + /** + * The table result replaces the run's output, so the export receipt rides along + * or the agent has to `files list` to confirm a write it already made. + */ + const exported = exportedFiles(rawOutput) + const exportNote = + exported.length > 0 + ? ` and exported ${exported.length} ${exported.length === 1 ? 'file' : 'files'}: ${exported + .map((file) => + typeof file.vfsPath === 'string' + ? file.vfsPath + : String(file.fileName ?? file.fileId) + ) + .join(', ')}` + : '' return { success: true, output: { - message: `Wrote ${replaceResult.insertedCount} rows to table ${outputTable}`, + message: `Wrote ${replaceResult.insertedCount} rows to table ${outputTable}${exportNote}`, tableId: outputTable, rowCount: replaceResult.insertedCount, + ...(exported.length > 0 ? { exported: { files: exported } } : {}), }, } } catch (err) { diff --git a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts index fd918fbc347..70229d99b9c 100644 --- a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.test.ts @@ -251,6 +251,23 @@ describe('workflow mutation Copilot adapters', () => { expect(output).not.toHaveProperty('outputFrom') }) + it('names the failing block and its error when the executor result carries no message', async () => { + mocks.executeWorkflowUseCase.mockResolvedValue({ + success: false, + output: {}, + logs: [ + { blockId: 'start', blockName: 'Start', output: { input: 'x' } }, + { blockId: 'probe', blockName: 'Probe Step', error: 'boom: injected fault', output: {} }, + ], + metadata: { executionId: 'execution-1' }, + }) + + const result = await executeRunBlock({ workflowId: 'workflow-1', blockId: 'probe' }, context) + + expect(result.success).toBe(false) + expect(result.error).toBe('Probe Step: boom: injected fault') + }) + it('returns only the selected block outputs and omits the logs when select is given', async () => { mocks.executeWorkflowUseCase.mockResolvedValue({ success: true, diff --git a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts index 1c7486dc4cc..67f286337f5 100644 --- a/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts +++ b/apps/sim/lib/mothership/tools/handlers/workflow/mutations.ts @@ -187,11 +187,37 @@ function buildExecutionOutput( ...(lifted ? { outputFrom: lifted.outputFrom } : {}), ...(selected ? { selected, logsOmitted: true } : { logs }), }, - error: result.success ? undefined : result.error || 'Workflow execution failed', + error: result.success + ? undefined + : result.error || failedBlockError(logs) || 'Workflow execution failed', effect: executionEffect(phase, executionId), } } +/** + * The failing block's own message when the executor result carries none — a block that + * threw inside `run_block` otherwise reached the agent as the bare fallback and the + * reason had to be dug out of the run log. + */ +function failedBlockError(logs: unknown): string | undefined { + if (!Array.isArray(logs)) return undefined + for (let index = logs.length - 1; index >= 0; index -= 1) { + const entry = logs[index] + if (!isRecordLike(entry)) continue + const log = entry as Record + if (typeof log.error === 'string' && log.error.length > 0) { + const name = + typeof log.blockName === 'string' + ? log.blockName + : typeof log.blockId === 'string' + ? log.blockId + : 'block' + return `${name}: ${log.error}` + } + } + return undefined +} + /** The executor's block-name rule: lowercase, whitespace and dots removed. */ function normalizeSelectorHead(value: string): string { return value.toLowerCase().replace(/[\s.]+/g, '') diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts index 24f767ab7dd..b5db83fee0f 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts @@ -338,8 +338,26 @@ describe('relocateWorkspaceFileFolderByPath', () => { ) }) + it('moves a nested folder back to the root when the destination is /', async () => { + const nested = { ...source, id: 'folder-nested', name: 'xp-docs', parentId: 'folder-archive' } + queueTableRows(schemaMock.folder, [archive, nested]) + dbChainMockFns.returning.mockResolvedValueOnce([{ ...nested, parentId: null }]) + + const result = await relocateWorkspaceFileFolderByPath({ + workspaceId: 'workspace-1', + path: '/fx-archive/xp-docs', + destinationPath: '/', + }) + + expect(result.path).toBe('/xp-docs') + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ name: 'xp-docs', parentId: null }) + ) + }) + it('still refuses a move whose source already exists under the destination', async () => { const taken = { ...source, id: 'folder-taken', parentId: 'folder-archive' } + queueTableRows(schemaMock.folder, [source, archive, taken]) await expect( diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 6af8819e6dd..ac5daf31bf0 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -1495,10 +1495,10 @@ function workspaceFileFolderLeafName(path: string): string { } /** - * Renames, moves, or both. A destination naming an existing folder receives the - * source as a child (`mv` semantics, see {@link resolveFolderMoveDestination}); - * any other destination becomes the source's new path. `path` on the result is - * where the folder actually landed. + * Renames, moves, or both. A destination naming an existing folder — the root + * included — receives the source as a child (`mv` semantics, see + * {@link resolveFolderMoveDestination}); any other destination becomes the + * source's new path. `path` on the result is where the folder actually landed. */ export async function relocateWorkspaceFileFolderByPath(params: { workspaceId: string @@ -1506,7 +1506,8 @@ export async function relocateWorkspaceFileFolderByPath(params: { destinationPath: string }): Promise { requireNonRootFolderPath(params.path) - requireNonRootFolderPath(params.destinationPath) + /** The root is a valid destination — "move to the top level" — so only canonical form is checked here. */ + parseFolderPath(params.destinationPath) return db.transaction(async (tx) => { await acquireWorkspaceFileFolderMutationLock(tx, params.workspaceId) diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts index 800096208ea..2270ed827b1 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts @@ -274,7 +274,7 @@ describe('applyWorkflowOperations', () => { orphanBlocks: [orphan], fieldIssues: [], unresolvedReferences: [], - notes: [], + notes: ['No entry block: nothing can start this workflow.'], }) }) diff --git a/apps/sim/lib/workflows/application/deployments.ts b/apps/sim/lib/workflows/application/deployments.ts index 798354faee6..a3e1185d7ca 100644 --- a/apps/sim/lib/workflows/application/deployments.ts +++ b/apps/sim/lib/workflows/application/deployments.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { type Principal, resolvePrincipalAttribution, toPrincipalActor } from '@sim/auth/principal' import { assertWorkflowMutable, WorkflowLockedError } from '@sim/platform-authz/workflow' import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' +import { listLiveWorkflowMcpToolsForWorkflow } from '@/lib/mcp/queries' import { notifyWorkflowReverted } from '@/lib/realtime/notify' import { listDeployedWebhookUrls } from '@/lib/webhooks/deployed-urls' import { requireWorkflowExecutionUserId } from '@/lib/workflows/application/authorization' @@ -140,6 +141,12 @@ export const undeployWorkflow = defineAuthorizedWorkflowUseCase({ const attribution = resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }) + /** + * Read before the undeploy, which archives every live registration of this + * workflow: the caller sees which tools went inactive on which servers, + * rather than finding them gone from the server's tool list. + */ + const archivedMcpTools = await listLiveWorkflowMcpToolsForWorkflow(context.workflowId) const result = await performFullUndeploy({ workflowId: context.workflowId, userId: attribution.attributedUserId, @@ -153,8 +160,10 @@ export const undeployWorkflow = defineAuthorizedWorkflowUseCase({ workflowId: context.workflowId, workspaceId: context.workspaceId, workflowName: context.workflow.name, + archivedMcpTools, } }, + projectAudit: ({ result }) => ({ action: AuditAction.WORKFLOW_UNDEPLOYED, resourceType: AuditResourceType.WORKFLOW, diff --git a/apps/sim/lib/workflows/application/import-export.test.ts b/apps/sim/lib/workflows/application/import-export.test.ts index e5d498efbdc..a7fc4f17097 100644 --- a/apps/sim/lib/workflows/application/import-export.test.ts +++ b/apps/sim/lib/workflows/application/import-export.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ resolveWorkflow: vi.fn(), resolvePermission: vi.fn(), importTransition: vi.fn(), + mappedImport: vi.fn(), buildExport: vi.fn(), folderLock: vi.fn(), loadIndex: vi.fn(), @@ -46,6 +47,10 @@ vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceWorkflowsChanged: mocks.notifyWorkspace, })) +vi.mock('@/lib/workflows/application/mapped-import', () => ({ + applyMappedWorkflowImport: mocks.mappedImport, +})) + vi.mock('@/lib/workflows/operations/import-workflow', () => ({ importWorkflowIntoWorkspaceTransition: mocks.importTransition, })) @@ -130,7 +135,7 @@ describe('workflow import and export application operations', () => { ) => callback({}) ) mocks.loadIndex.mockResolvedValue(folderIndex) - mocks.importTransition.mockResolvedValue({ success: true, workflow: imported }) + mocks.importTransition.mockResolvedValue({ success: true, workflow: imported, warnings: [] }) mocks.buildExport.mockResolvedValue(exportPayload) }) @@ -144,7 +149,7 @@ describe('workflow import and export application operations', () => { }, }) - expect(result).toEqual({ workflow: imported, folderPath: '/Reports' }) + expect(result).toEqual({ workflow: imported, folderPath: '/Reports', warnings: [] }) expect(mocks.importTransition).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: 'ws-1', @@ -167,6 +172,54 @@ describe('workflow import and export application operations', () => { expect(mocks.notifyWorkspace).toHaveBeenCalledWith('ws-1') }) + /** + * Export clears workspace bindings; the operation reports the required ones + * that arrived empty, and the use case must hand that through untouched so a + * round-tripped workflow never silently cannot run. + */ + it('passes the stripped-binding warnings through to the caller', async () => { + const warnings = ['Lookup: tableId was stripped by export; set it before running'] + mocks.importTransition.mockResolvedValue({ success: true, workflow: imported, warnings }) + + const result = await importWorkflow.execute({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { workspaceId: 'ws-1', workflow: { blocks: {}, edges: [] } }, + }) + + expect(result.warnings).toEqual(warnings) + }) + + it('preserves mapped import receipts and normalizes validated bindings to no warnings', async () => { + const operation = { requestId: 'request-1' } + mocks.mappedImport.mockResolvedValue({ + workflow: imported, + folderPath: '/Reports', + operation, + replayed: true, + }) + + const result = await importWorkflow.execute({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { + workspaceId: 'ws-1', + workflow: { blocks: {}, edges: [] }, + requestId: 'request-1', + previewFingerprint: 'preview-1', + }, + }) + + expect(result).toEqual({ + workflow: imported, + folderPath: '/Reports', + operation, + replayed: true, + warnings: [], + }) + expect(mocks.importTransition).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notifyWorkspace).not.toHaveBeenCalled() + }) + it('preserves classified import details and does not audit a failure', async () => { mocks.importTransition.mockResolvedValue({ success: false, @@ -197,7 +250,11 @@ describe('workflow import and export application operations', () => { }) expect(mocks.resolveWorkflow).toHaveBeenCalledWith({ workflowId: 'workflow-1' }) - expect(mocks.buildExport).toHaveBeenCalledWith(workflowRecord, { includeReferences: undefined }) + /** Sharing-safe by default: bindings are cleared unless the caller opts in. */ + expect(mocks.buildExport).toHaveBeenCalledWith(workflowRecord, { + includeReferences: undefined, + includeWorkspaceBindings: false, + }) expect(mocks.loadIndex).toHaveBeenCalledWith('ws-1', 'workflow', undefined, { maxRows: MAX_FOLDERS_PER_WORKSPACE, }) @@ -211,6 +268,23 @@ describe('workflow import and export application operations', () => { ) }) + it('keeps workspace bindings only when asked, and says so in the audit', async () => { + await exportWorkflow.execute({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { workflowId: 'workflow-1', includeWorkspaceBindings: true }, + }) + + expect(mocks.buildExport).toHaveBeenCalledWith(workflowRecord, { + includeReferences: undefined, + includeWorkspaceBindings: true, + }) + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ includeWorkspaceBindings: true }), + }) + ) + }) + it('propagates export infrastructure failures without audit', async () => { const failure = new Error('storage unavailable') mocks.buildExport.mockRejectedValueOnce(failure) diff --git a/apps/sim/lib/workflows/application/import-export.ts b/apps/sim/lib/workflows/application/import-export.ts index 04c3e33648d..6aaeaebcfec 100644 --- a/apps/sim/lib/workflows/application/import-export.ts +++ b/apps/sim/lib/workflows/application/import-export.ts @@ -43,11 +43,15 @@ export interface ImportWorkflowResult { replayed?: boolean workflow: ImportedWorkflow folderPath: string + /** Legacy import warnings; mapped imports validate required bindings before committing. */ + warnings: string[] } export interface ExportWorkflowInput { includeReferences?: boolean workflowId: string + /** Keep workspace-scoped bindings for a same-workspace round trip; secrets are cleared either way. */ + includeWorkspaceBindings?: boolean } export interface ExportWorkflowResult { @@ -76,7 +80,8 @@ export const importWorkflow = defineAuthorizedWorkflowUseCase({ input.previewFingerprint !== undefined || input.requestId !== undefined ) { - return applyMappedWorkflowImport(principal, input, context) + const result = await applyMappedWorkflowImport(principal, input, context) + return { ...result, warnings: [] } } const resolution = await resolveWorkflowFolderPath(context.workspaceId, input.folderPath ?? '/') @@ -99,6 +104,7 @@ export const importWorkflow = defineAuthorizedWorkflowUseCase({ return { workflow: result.workflow, folderPath: workflowFolderPathForId(resolution.index, result.workflow.folderId), + warnings: result.warnings, } }, projectAudit({ result }) { @@ -130,6 +136,7 @@ export const exportWorkflow = defineAuthorizedWorkflowUseCase({ async execute({ context, input }): Promise { const payload = await buildWorkflowExportPayload(context.workflow, { includeReferences: input.includeReferences, + includeWorkspaceBindings: input.includeWorkspaceBindings === true, }) if (!payload) throw new OrchestrationError('not_found', 'Workflow state not found') const folderIndex = await loadActiveFolderPathIndex( @@ -143,7 +150,7 @@ export const exportWorkflow = defineAuthorizedWorkflowUseCase({ folderPath: workflowFolderPathForId(folderIndex, context.workflow.folderId), } }, - projectAudit({ context, result }) { + projectAudit({ input, context, result }) { return { action: AuditAction.WORKFLOW_EXPORTED, resourceType: AuditResourceType.WORKFLOW, @@ -155,6 +162,7 @@ export const exportWorkflow = defineAuthorizedWorkflowUseCase({ folderPath: result.folderPath, blocksCount: Object.keys(result.payload.state.blocks).length, edgesCount: result.payload.state.edges.length, + includeWorkspaceBindings: input.includeWorkspaceBindings === true, }, } }, diff --git a/apps/sim/lib/workflows/application/mapped-import.ts b/apps/sim/lib/workflows/application/mapped-import.ts index 6f0adf42bca..fa07c3f48ed 100644 --- a/apps/sim/lib/workflows/application/mapped-import.ts +++ b/apps/sim/lib/workflows/application/mapped-import.ts @@ -152,7 +152,10 @@ export const previewWorkflowImport = defineAuthorizedWorkflowUseCase({ }, }) -function receiptResult(report: WorkspaceOperationReport, replayed: boolean): ImportWorkflowResult { +function receiptResult( + report: WorkspaceOperationReport, + replayed: boolean +): Omit { const imported = report.importedWorkflow if (!imported) throw new OrchestrationError('internal', 'Import receipt is missing its result') return { @@ -172,7 +175,7 @@ export async function applyMappedWorkflowImport( principal: Principal, input: ImportWorkflowInput, context: ActiveWorkspaceApplicationContext -): Promise { +): Promise> { if (!input.requestId || !input.previewFingerprint) throw new WorkflowImportError( 'validation', diff --git a/apps/sim/lib/workflows/application/workflow-deployments.test.ts b/apps/sim/lib/workflows/application/workflow-deployments.test.ts index 440d8bc4f37..edc1fa8ff4d 100644 --- a/apps/sim/lib/workflows/application/workflow-deployments.test.ts +++ b/apps/sim/lib/workflows/application/workflow-deployments.test.ts @@ -14,6 +14,7 @@ const { MockWorkflowLockedError, mocks } = vi.hoisted(() => { audit: vi.fn(), deploy: vi.fn(), findPrevious: vi.fn(), + listMcpTools: vi.fn(), notifyReverted: vi.fn(), revert: vi.fn(), resolveContext: vi.fn(), @@ -67,6 +68,10 @@ vi.mock('@/lib/realtime/notify', () => ({ notifyWorkflowReverted: mocks.notifyReverted, })) +vi.mock('@/lib/mcp/queries', () => ({ + listLiveWorkflowMcpToolsForWorkflow: mocks.listMcpTools, +})) + vi.mock('@/lib/workflows/deployment-status', () => ({ checkNeedsRedeployment: vi.fn(), })) @@ -132,6 +137,7 @@ describe('workflow deployment application use cases', () => { warnings: [], }) mocks.undeploy.mockResolvedValue({ success: true, warnings: [] }) + mocks.listMcpTools.mockResolvedValue([]) mocks.activate.mockResolvedValue({ success: true, deployedAt: new Date('2026-08-08T00:01:00Z'), @@ -273,6 +279,26 @@ describe('workflow deployment application use cases', () => { ) }) + /** + * Undeploying archives every MCP tool that published the workflow. The live + * set is read before the undeploy runs, so the caller learns which tools went + * inactive instead of finding them missing from the server's tool list. + */ + it('reports the MCP tools an undeploy takes inactive', async () => { + mocks.listMcpTools.mockResolvedValue([{ serverId: 'wfmcp-1', toolName: 'triage_ticket' }]) + + const result = await undeployWorkflow.execute({ + principal: { kind: 'personal_api_key', userId: 'key-user', keyId: 'personal-key' }, + input: { workflowId: 'workflow-1', requestId: 'request-2' }, + }) + + expect(mocks.listMcpTools).toHaveBeenCalledWith('workflow-1') + expect(mocks.listMcpTools.mock.invocationCallOrder[0]).toBeLessThan( + mocks.undeploy.mock.invocationCallOrder[0] + ) + expect(result.archivedMcpTools).toEqual([{ serverId: 'wfmcp-1', toolName: 'triage_ticket' }]) + }) + it('projects revert audit and notification exactly once outside legacy orchestration', async () => { await revertWorkflowVersion.execute({ principal: adminPrincipals[2].principal, diff --git a/apps/sim/lib/workflows/application/workflow-folders.ts b/apps/sim/lib/workflows/application/workflow-folders.ts index 5c8403d56fa..eecf33336a7 100644 --- a/apps/sim/lib/workflows/application/workflow-folders.ts +++ b/apps/sim/lib/workflows/application/workflow-folders.ts @@ -203,15 +203,17 @@ export const relocateWorkflowFolder = defineAuthorizedWorkflowUseCase({ return { folder: result.folder, index } }, projectAudit({ input, result }) { + /** Where the folder landed — inside an existing destination, or at the root, rather than the path as typed. */ + const destinationPath = result.index.pathById.get(result.folder.id) ?? input.destinationPath return { action: AuditAction.FOLDER_MOVED, resourceType: AuditResourceType.FOLDER, resourceId: result.folder.id, resourceName: result.folder.name, - description: `Moved workflow folder to "${input.destinationPath}"`, + description: `Moved workflow folder to "${destinationPath}"`, metadata: { sourcePath: input.path, - destinationPath: input.destinationPath, + destinationPath, folderResourceType: 'workflow', }, } diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.test.ts b/apps/sim/lib/workflows/credentials/credential-extractor.test.ts index c4f5a7c15e8..044699c2cde 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.test.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.test.ts @@ -3,6 +3,7 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { + collectStrippedWorkspaceBindings, EXPORT_PRESERVED_RESOURCE_TYPES, sanitizeWorkflowForSharing, } from '@/lib/workflows/credentials/credential-extractor' @@ -288,3 +289,181 @@ describe('export sanitizer resource coverage', () => { ]) }) }) + +describe('preserveWorkspaceBindings', () => { + const SAME_WORKSPACE_OPTIONS = { ...EXPORT_OPTIONS, preserveWorkspaceBindings: true } as const + + function sanitizedWith(type: string, value: unknown, id = 'field'): unknown { + vi.mocked(getBlock).mockReturnValue({ + name: 'Test', + description: '', + subBlocks: [{ id, title: 'Field', type }], + outputs: {}, + } as never) + const state = stateWithSubBlock(type, value) + const block = state.blocks?.b1 + if (block && id !== 'field') { + block.subBlocks = { [id]: { id, type, value } } as never + } + const sanitized = sanitizeWorkflowForSharing(state, SAME_WORKSPACE_OPTIONS) + return sanitized.blocks?.b1?.subBlocks?.[id]?.value + } + + it('keeps resource selectors for a same-workspace round trip', () => { + expect(sanitizedWith('table-selector', 'tbl_239e870374c14d4a89923175a7b10648')).toBe( + 'tbl_239e870374c14d4a89923175a7b10648' + ) + expect(sanitizedWith('knowledge-base-selector', 'kb_123')).toBe('kb_123') + expect(sanitizedWith('short-input', 'kb_123', 'knowledgeBaseId')).toBe('kb_123') + }) + + it('still clears credentials, passwords, and credential-keyed fields', () => { + expect(sanitizedWith('oauth-input', 'cred-123')).toBeNull() + expect(sanitizedWith('short-input', 'cred-123', 'oauthCredential')).toBeNull() + vi.mocked(getBlock).mockReturnValue({ + name: 'Test', + description: '', + subBlocks: [{ id: 'field', title: 'Field', type: 'short-input', password: true }], + outputs: {}, + } as never) + const sanitized = sanitizeWorkflowForSharing( + stateWithSubBlock('short-input', 'sk-secret'), + SAME_WORKSPACE_OPTIONS + ) + expect(sanitized.blocks?.b1?.subBlocks?.field?.value).toBeNull() + }) + + it('keeps a workspace-keyed field on a block with no registry config', () => { + vi.mocked(getBlock).mockReturnValue(undefined as never) + const sanitized = sanitizeWorkflowForSharing( + { + blocks: { + b1: { + id: 'b1', + type: 'unknown-block', + name: 'Test', + position: { x: 0, y: 0 }, + subBlocks: { tableId: { id: 'tableId', type: 'short-input', value: 'tbl_abc' } }, + outputs: {}, + enabled: true, + }, + }, + } as unknown as Partial, + SAME_WORKSPACE_OPTIONS + ) + expect(sanitized.blocks?.b1?.subBlocks?.tableId?.value).toBe('tbl_abc') + }) +}) + +describe('collectStrippedWorkspaceBindings', () => { + const KNOWLEDGE_BLOCK = { + name: 'Knowledge', + description: '', + subBlocks: [ + { id: 'operation', title: 'Operation', type: 'dropdown' }, + { + id: 'knowledgeBaseSelector', + title: 'Knowledge Base', + type: 'knowledge-base-selector', + canonicalParamId: 'knowledgeBaseId', + mode: 'basic', + required: true, + }, + { + id: 'manualKnowledgeBaseId', + title: 'Knowledge Base ID', + type: 'short-input', + canonicalParamId: 'knowledgeBaseId', + mode: 'advanced', + required: true, + }, + { + id: 'documentSelector', + title: 'Document', + type: 'document-selector', + canonicalParamId: 'documentId', + mode: 'basic', + required: true, + condition: { field: 'operation', value: 'get_document' }, + }, + { + id: 'tagFilters', + title: 'Tag Filters', + type: 'knowledge-tag-filters', + condition: { field: 'operation', value: 'search' }, + }, + { id: 'credential', title: 'Credential', type: 'oauth-input', required: true }, + ], + outputs: {}, + } + + function knowledgeState(values: Record, enabled = true): Partial { + return { + blocks: { + kb: { + id: 'kb', + type: 'knowledge', + name: 'Lookup', + position: { x: 0, y: 0 }, + subBlocks: Object.fromEntries( + Object.entries(values).map(([id, value]) => [id, { id, type: 'short-input', value }]) + ), + outputs: {}, + enabled, + }, + }, + } as unknown as Partial + } + + beforeEach(() => { + vi.mocked(getBlock).mockReturnValue(KNOWLEDGE_BLOCK as never) + }) + + it('reports a required binding whose canonical pair arrived empty, once, by its canonical id', () => { + const findings = collectStrippedWorkspaceBindings( + knowledgeState({ + operation: 'search', + knowledgeBaseSelector: null, + manualKnowledgeBaseId: null, + documentSelector: null, + tagFilters: null, + credential: null, + }) + ) + + expect(findings).toEqual([{ blockId: 'kb', blockName: 'Lookup', field: 'knowledgeBaseId' }]) + }) + + it('accepts a value on either member and skips hidden, optional, and disabled fields', () => { + expect( + collectStrippedWorkspaceBindings( + knowledgeState({ + operation: 'search', + knowledgeBaseSelector: null, + manualKnowledgeBaseId: 'kb_1', + }) + ) + ).toEqual([]) + expect( + collectStrippedWorkspaceBindings( + knowledgeState({ + operation: 'get_document', + knowledgeBaseSelector: 'kb_1', + documentSelector: null, + }) + ) + ).toEqual([{ blockId: 'kb', blockName: 'Lookup', field: 'documentId' }]) + expect( + collectStrippedWorkspaceBindings( + knowledgeState({ operation: 'search', knowledgeBaseSelector: null }, false) + ) + ).toEqual([]) + }) + + it('ignores a block the registry does not know', () => { + vi.mocked(getBlock).mockReturnValue(undefined as never) + expect( + collectStrippedWorkspaceBindings(knowledgeState({ knowledgeBaseSelector: null })) + ).toEqual([]) + }) +}) diff --git a/apps/sim/lib/workflows/credentials/credential-extractor.ts b/apps/sim/lib/workflows/credentials/credential-extractor.ts index 436125ea952..b4abb516eae 100644 --- a/apps/sim/lib/workflows/credentials/credential-extractor.ts +++ b/apps/sim/lib/workflows/credentials/credential-extractor.ts @@ -4,6 +4,7 @@ import { coerceObjectArray } from '@/lib/workflows/persistence/remap-internal-id import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer' import { WORKFLOW_SEARCH_SUBBLOCK_RESOURCE_TYPES } from '@/lib/workflows/search-replace/resources/registry' import { setValueAtPath } from '@/lib/workflows/search-replace/value-walker' +import { evaluateSubBlockCondition, isNonEmptyValue } from '@/lib/workflows/subblocks/visibility' import { parseStoredToolInputValue } from '@/lib/workflows/tool-input/types' import { getBlock } from '@/blocks/registry' import type { SubBlockConfig } from '@/blocks/types' @@ -60,6 +61,15 @@ const WORKSPACE_SPECIFIC_FIELDS = new Set([ 'sandboxId', ]) +/** + * The workspace-specific fields that name a credential rather than a resource. + * Cleared even when {@link WorkflowSanitizationOptions.preserveWorkspaceBindings} + * keeps the rest: the flag exists for a same-workspace round trip of resource + * selections, and credentials stay on the sharing-safe rule alongside + * `oauth-input` and `password` fields. + */ +const CREDENTIAL_BINDING_FIELDS: ReadonlySet = new Set(['credentialId', 'oauthCredential']) + /** * Sub-block values whose interior cannot be projected safely once the payload leaves the * workspace. @@ -135,10 +145,21 @@ interface SanitizedWorkflowState { [key: string]: unknown } -interface WorkflowSanitizationOptions { +export interface WorkflowSanitizationOptions { preserveEnvVars?: boolean /** Allows only registered non-secret tool identities in portable exports. */ preserveReferenceMetadata?: boolean + /** + * Keep workspace-scoped resource bindings — table, knowledge base, document, + * file, folder, channel, and every other selector the resource registry knows + * — instead of clearing them. For a same-workspace round trip (`workflows + * export` then `workflows import` into the workspace it came from) those ids + * still resolve, and clearing them was the reason a re-imported workflow could + * not run. Credentials (`oauth-input`, {@link CREDENTIAL_BINDING_FIELDS}), + * passwords, and opaque table values are cleared regardless: the flag widens + * what a payload carries, never what leaves the trust boundary as a secret. + */ + preserveWorkspaceBindings?: boolean /** * Withhold values whose interior cannot be projected safely once the payload leaves the * workspace — whole `table` values (see {@link OPAQUE_CREDENTIAL_BEARING_TYPES}) and every @@ -171,6 +192,24 @@ function isEnvironmentVariableReference(value: unknown): value is string { return typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}') } +/** Whether a sub-block config holds a reference scoped to this workspace. */ +function isWorkspaceBindingConfig(config: CredentialSanitizationConfig): boolean { + return ( + WORKSPACE_SPECIFIC_TYPES.has(config.type) || + WORKSPACE_SPECIFIC_FIELDS.has(config.id) || + (config.canonicalParamId != null && WORKSPACE_SPECIFIC_FIELDS.has(config.canonicalParamId)) + ) +} + +/** Whether a sub-block config names a credential, which no export option preserves. */ +function isCredentialBindingConfig(config: CredentialSanitizationConfig): boolean { + return ( + config.type === 'oauth-input' || + CREDENTIAL_BINDING_FIELDS.has(config.id) || + (config.canonicalParamId != null && CREDENTIAL_BINDING_FIELDS.has(config.canonicalParamId)) + ) +} + /** * Keeps a fallback list's models and drops every row key that is not a whole * environment-variable reference. The editor only ever writes references, but the @@ -267,12 +306,8 @@ function sanitizeConfiguredSubBlockValue( if (config.type === 'model-fallback-list') { return sanitizeFallbackModelsValue(value, options) } - if ( - WORKSPACE_SPECIFIC_TYPES.has(config.type) || - WORKSPACE_SPECIFIC_FIELDS.has(config.id) || - (config.canonicalParamId != null && WORKSPACE_SPECIFIC_FIELDS.has(config.canonicalParamId)) - ) { - return null + if (isWorkspaceBindingConfig(config)) { + if (!options.preserveWorkspaceBindings || isCredentialBindingConfig(config)) return null } return value } @@ -329,7 +364,11 @@ export function sanitizeWorkflowForSharing( } // Clear workspace-specific fields by key name - if (WORKSPACE_SPECIFIC_FIELDS.has(key) && subBlock) { + if ( + WORKSPACE_SPECIFIC_FIELDS.has(key) && + subBlock && + (!options.preserveWorkspaceBindings || CREDENTIAL_BINDING_FIELDS.has(key)) + ) { subBlock.value = null } }) @@ -343,7 +382,7 @@ export function sanitizeWorkflowForSharing( block.data![key] = null } // Clear workspace-specific data - if (WORKSPACE_SPECIFIC_FIELDS.has(key)) { + if (WORKSPACE_SPECIFIC_FIELDS.has(key) && !options.preserveWorkspaceBindings) { block.data![key] = null } }) @@ -352,3 +391,68 @@ export function sanitizeWorkflowForSharing( return sanitized } + +/** A required workspace binding an export cleared, keyed the way a caller sets it. */ +export interface StrippedWorkspaceBinding { + blockId: string + blockName: string + /** The canonical param id of a picker/manual pair, else the sub-block id. */ + field: string +} + +function isRequiredSubBlock(config: SubBlockConfig, values: Record): boolean { + if (!config.required) return false + if (config.required === true) return true + return evaluateSubBlockCondition(config.required, values) +} + +/** + * The required workspace bindings of a state that arrived empty — what + * {@link sanitizeWorkflowForSharing} clears on export and nothing on the import + * path can restore. + * + * Reported per canonical group: a picker and its manual twin are one binding to + * the caller, and the group is empty only when every member is. A binding + * whose sub-block is hidden by its `condition` (a document selector on a + * knowledge block set to `search`) or not required in the current mode is not + * reported, so the list is exactly the fields the workflow cannot run without. + * Credentials are left to the sharing rule that always clears them; disabled + * blocks are skipped because execution skips them too. + */ +export function collectStrippedWorkspaceBindings( + state: Pick, 'blocks'> | null | undefined +): StrippedWorkspaceBinding[] { + const findings: StrippedWorkspaceBinding[] = [] + for (const [blockId, block] of Object.entries(state?.blocks ?? {})) { + if (!block?.type || block.enabled === false) continue + const blockConfig = getBlock(block.type) + if (!blockConfig) continue + + const subBlocks = block.subBlocks ?? {} + const values: Record = {} + for (const [id, subBlock] of Object.entries(subBlocks)) values[id] = subBlock?.value + + const groups = new Map() + for (const config of blockConfig.subBlocks ?? []) { + if (!isWorkspaceBindingConfig(config) || isCredentialBindingConfig(config)) continue + const key = config.canonicalParamId ?? config.id + const group = groups.get(key) ?? { present: false, hasValue: false, required: false } + if (Object.hasOwn(subBlocks, config.id)) group.present = true + if (isNonEmptyValue(values[config.id])) group.hasValue = true + if ( + evaluateSubBlockCondition(config.condition, values) && + isRequiredSubBlock(config, values) + ) { + group.required = true + } + groups.set(key, group) + } + + for (const [field, group] of groups) { + if (group.present && group.required && !group.hasValue) { + findings.push({ blockId, blockName: block.name || blockId, field }) + } + } + } + return findings +} diff --git a/apps/sim/lib/workflows/editing/lint-report.test.ts b/apps/sim/lib/workflows/editing/lint-report.test.ts new file mode 100644 index 00000000000..509baa1c148 --- /dev/null +++ b/apps/sim/lib/workflows/editing/lint-report.test.ts @@ -0,0 +1,113 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + collectUnresolvedReferences: vi.fn(async () => []), + collectUnresolvedAgentToolReferences: vi.fn(async () => []), +})) + +vi.mock('@/lib/workflows/editing/validation', () => ({ + collectUnresolvedReferences: mocks.collectUnresolvedReferences, + collectUnresolvedAgentToolReferences: mocks.collectUnresolvedAgentToolReferences, + UNRESOLVABLE_AT_LINT_NOTE: 'unresolvable-at-lint', + validateConditionHandle: vi.fn(() => ({ valid: true })), + validateRouterHandle: vi.fn(() => ({ valid: true })), +})) + +import { + buildWorkflowLintReport, + EMPTY_GRAPH_NOTE, + NO_ENTRY_BLOCK_NOTE, + REFERENCES_UNCHECKED_NOTE, +} from '@/lib/workflows/editing/lint-report' + +const scope = { workflowId: 'workflow-1', workspaceId: 'workspace-1', subjectUserId: 'user-1' } + +function block(id: string, type: string) { + return { + id, + type, + name: id, + enabled: true, + position: { x: 0, y: 0 }, + subBlocks: {}, + outputs: {}, + } +} + +function edge(source: string, target: string) { + return { + id: `${source}-${target}`, + source, + sourceHandle: 'source', + target, + targetHandle: 'target', + } +} + +describe('buildWorkflowLintReport notes', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + /** + * `--blocks '{}' --edges '[]'` used to lint perfectly clean, so a dry run gave + * no hint that applying it would erase the workflow. + */ + it('notes a graph with no blocks', async () => { + const report = await buildWorkflowLintReport({ blocks: {}, edges: [] } as never, scope) + + expect(report.notes).toEqual([EMPTY_GRAPH_NOTE]) + expect(report.sources).toEqual([]) + }) + + it('notes a wired graph nothing can start', async () => { + const report = await buildWorkflowLintReport( + { + blocks: { a: block('a', 'function'), b: block('b', 'function') }, + edges: [edge('a', 'b'), edge('b', 'a')], + } as never, + scope + ) + + expect(report.sources).toEqual([]) + expect(report.orphanBlocks).toEqual([]) + expect(report.notes).toEqual([NO_ENTRY_BLOCK_NOTE]) + }) + + it('notes a graph whose only source is not a trigger', async () => { + const report = await buildWorkflowLintReport( + { + blocks: { agent: block('agent', 'agent'), fn: block('fn', 'function') }, + edges: [edge('agent', 'fn')], + } as never, + scope + ) + + expect(report.notes).toContain(NO_ENTRY_BLOCK_NOTE) + }) + + it('adds neither note to a graph a trigger can start', async () => { + const report = await buildWorkflowLintReport( + { + blocks: { start: block('start', 'starter'), fn: block('fn', 'function') }, + edges: [edge('start', 'fn')], + } as never, + scope + ) + + expect(report.notes).toEqual([]) + }) + + it('keeps the reference-scope note ahead of the graph notes', async () => { + const report = await buildWorkflowLintReport({ blocks: {}, edges: [] } as never, { + ...scope, + subjectUserId: null, + }) + + expect(report.notes).toEqual([REFERENCES_UNCHECKED_NOTE, EMPTY_GRAPH_NOTE]) + expect(mocks.collectUnresolvedReferences).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workflows/editing/lint-report.ts b/apps/sim/lib/workflows/editing/lint-report.ts index 2a759848ede..dc5c930141f 100644 --- a/apps/sim/lib/workflows/editing/lint-report.ts +++ b/apps/sim/lib/workflows/editing/lint-report.ts @@ -4,6 +4,7 @@ import type { WorkflowState } from '@sim/workflow-types/workflow' import { collectDanglingBlockOutputReferences, collectWorkflowFieldIssues, + hasWorkflowEntryBlock, lintEditedWorkflowState, type WorkflowLintReport, type WorkflowLintUnresolvedReference, @@ -30,6 +31,20 @@ const logger = createLogger('WorkflowLintReport') export const REFERENCES_UNCHECKED_NOTE = 'Credential, tool, and skill references were not checked because this caller does not act as a human user. Structural and field findings are complete.' +/** + * A graph with nothing in it lints perfectly clean, which is the wrong signal + * for a `PUT /state` whose whole effect would be to erase the workflow. + */ +export const EMPTY_GRAPH_NOTE = + 'The graph has no blocks — replacing with this empties the workflow.' + +/** + * Every block has an incoming edge, or none of them is a trigger: the graph + * may be fully wired and still have nowhere to begin. Not an `orphanBlocks` + * finding — a cycle of non-trigger blocks has no orphan and no entry. + */ +export const NO_ENTRY_BLOCK_NOTE = 'No entry block: nothing can start this workflow.' + export interface WorkflowLintScope { workflowId: string workspaceId: string @@ -86,9 +101,16 @@ export async function buildWorkflowLintReport( } } + const graphLint = lintEditedWorkflowState(graph) + const notes: string[] = [] if (!scope.subjectUserId) notes.push(REFERENCES_UNCHECKED_NOTE) if (unresolvedReferences.length > 0) notes.push(UNRESOLVABLE_AT_LINT_NOTE) + if (Object.keys(graph.blocks ?? {}).length === 0) { + notes.push(EMPTY_GRAPH_NOTE) + } else if (graphLint.sources.length === 0 || !hasWorkflowEntryBlock(graph.blocks)) { + notes.push(NO_ENTRY_BLOCK_NOTE) + } /** * `fieldIssues`, `unresolvedReferences`, and `notes` are assigned after the @@ -97,7 +119,7 @@ export async function buildWorkflowLintReport( * never discard a finding the linter made. */ return { - ...lintEditedWorkflowState(graph), + ...graphLint, fieldIssues: collectWorkflowFieldIssues(graph.blocks), unresolvedReferences, notes, diff --git a/apps/sim/lib/workflows/editing/lint.test.ts b/apps/sim/lib/workflows/editing/lint.test.ts index 228f28a68f8..d6a9dc8e052 100644 --- a/apps/sim/lib/workflows/editing/lint.test.ts +++ b/apps/sim/lib/workflows/editing/lint.test.ts @@ -1,16 +1,100 @@ import { describe, expect, it, vi } from 'vitest' -import { hasWorkflowLintIssues, lintEditedWorkflowState } from './lint' +import { collectWorkflowFieldIssues, hasWorkflowLintIssues, lintEditedWorkflowState } from './lint' + +/** + * A resource block shaped like `knowledge`: a picker/manual canonical pair for + * the knowledge base, both `required`, whose tool parameter is `user-or-llm`. + * That visibility is what hid a missing knowledge base id from lint while the + * `user-only` table id beside it was reported. + */ +const KNOWLEDGE_BLOCK = { + name: 'Knowledge', + category: 'blocks', + tools: { access: ['knowledge_search'], config: { tool: () => 'knowledge_search' } }, + subBlocks: [ + { id: 'operation', title: 'Operation', type: 'dropdown', options: [{ id: 'search' }] }, + { + id: 'knowledgeBaseSelector', + title: 'Knowledge Base', + type: 'knowledge-base-selector', + canonicalParamId: 'knowledgeBaseId', + mode: 'basic', + required: true, + }, + { + id: 'manualKnowledgeBaseId', + title: 'Knowledge Base ID', + type: 'short-input', + canonicalParamId: 'knowledgeBaseId', + mode: 'advanced', + required: true, + }, + { + id: 'query', + title: 'Search Query', + type: 'short-input', + required: false, + condition: { field: 'operation', value: 'search' }, + }, + ], + outputs: {}, +} + +const TABLE_BLOCK = { + name: 'Table', + category: 'blocks', + tools: { access: ['table_v2_query_rows'], config: { tool: () => 'table_v2_query_rows' } }, + subBlocks: [ + { id: 'operation', title: 'Operation', type: 'dropdown', options: [{ id: 'query_rows' }] }, + { + id: 'tableSelector', + title: 'Table', + type: 'table-selector', + canonicalParamId: 'tableId', + mode: 'basic', + required: true, + }, + { + id: 'manualTableId', + title: 'Table ID', + type: 'short-input', + canonicalParamId: 'tableId', + mode: 'advanced', + required: true, + }, + ], + outputs: {}, +} /** Overrides the global registry mock so a `schedule` block carries its real category. */ vi.mock('@/blocks/registry', () => ({ - getBlock: vi.fn((type: string) => - type === 'schedule' ? { category: 'triggers', subBlocks: [], outputs: {} } : undefined - ), + getBlock: vi.fn((type: string) => { + if (type === 'schedule') return { category: 'triggers', subBlocks: [], outputs: {} } + if (type === 'knowledge') return KNOWLEDGE_BLOCK + if (type === 'table_v2') return TABLE_BLOCK + return undefined + }), getAllBlocks: vi.fn(() => []), getBlockMeta: vi.fn(() => undefined), getBlockRegistry: vi.fn(() => ({})), })) +vi.mock('@/tools/metadata', () => ({ + getToolMetadata: vi.fn(() => undefined), + getToolParams: vi.fn((toolId: string) => { + if (toolId === 'knowledge_search') { + return { + knowledgeBaseId: { type: 'string', required: true, visibility: 'user-or-llm' }, + query: { type: 'string', required: true, visibility: 'user-or-llm' }, + } + } + if (toolId === 'table_v2_query_rows') { + return { tableId: { type: 'string', required: true, visibility: 'user-only' } } + } + return undefined + }), +})) + function baseBlock(id: string, type: string, name: string, subBlocks: Record = {}) { return { id, @@ -354,3 +438,52 @@ describe('lintEditedWorkflowState', () => { expect(hasWorkflowLintIssues(lint)).toBe(false) }) }) + +describe('collectWorkflowFieldIssues', () => { + function resourceBlock(id: string, type: string, values: Record) { + return baseBlock( + id, + type, + id, + Object.fromEntries(Object.entries(values).map(([key, value]) => [key, { value }])) + ) + } + + it('reports a required canonical pair whose members are all empty, whatever the tool visibility', () => { + const issues = collectWorkflowFieldIssues({ + kb: resourceBlock('kb', 'knowledge', { + operation: 'search', + knowledgeBaseSelector: null, + manualKnowledgeBaseId: null, + }), + table: resourceBlock('table', 'table_v2', { + operation: 'query_rows', + tableSelector: null, + manualTableId: null, + }), + }) + + expect(issues).toEqual([ + expect.objectContaining({ blockId: 'kb', missingRequiredFields: ['Knowledge Base'] }), + expect.objectContaining({ blockId: 'table', missingRequiredFields: ['Table'] }), + ]) + }) + + it('accepts a value on either member of the pair and leaves optional sub-blocks alone', () => { + const issues = collectWorkflowFieldIssues({ + picked: resourceBlock('picked', 'knowledge', { + operation: 'search', + knowledgeBaseSelector: 'kb_123', + manualKnowledgeBaseId: null, + query: null, + }), + manual: resourceBlock('manual', 'knowledge', { + operation: 'search', + knowledgeBaseSelector: null, + manualKnowledgeBaseId: 'kb_456', + }), + }) + + expect(issues).toEqual([]) + }) +}) diff --git a/apps/sim/lib/workflows/editing/lint.ts b/apps/sim/lib/workflows/editing/lint.ts index 523bc5e17f9..94b9ce92677 100644 --- a/apps/sim/lib/workflows/editing/lint.ts +++ b/apps/sim/lib/workflows/editing/lint.ts @@ -112,6 +112,13 @@ function isWorkflowEntryBlock(block: BlockState) { return block.type !== undefined && getBlock(block.type)?.category === 'triggers' } +/** Whether any block in the graph can start a run (see {@link isWorkflowEntryBlock}). */ +export function hasWorkflowEntryBlock( + blocks: WorkflowState['blocks'] | Record | undefined +): boolean { + return Object.values(blocks || {}).some((block) => isWorkflowEntryBlock(block as BlockState)) +} + function requiredSubflowStartPort(block: BlockState) { if (block.type === 'loop') { return { handle: 'loop-start-source', label: 'loop-start-source' } @@ -249,9 +256,12 @@ export function lintEditedWorkflowState(workflowState: Pick | undefined @@ -273,7 +283,8 @@ export function collectWorkflowFieldIssues( const { missingRequiredFields, inactiveModeValues } = collectBlockFieldIssues( block as any, blockConfig, - params + params, + { mode: 'lint' } ) if (missingRequiredFields.length > 0 || inactiveModeValues.length > 0) { results.push({ diff --git a/apps/sim/lib/workflows/operations/export-workflow.test.ts b/apps/sim/lib/workflows/operations/export-workflow.test.ts index 068190e2f9f..86e985c6e81 100644 --- a/apps/sim/lib/workflows/operations/export-workflow.test.ts +++ b/apps/sim/lib/workflows/operations/export-workflow.test.ts @@ -13,37 +13,63 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({ })) vi.mock('@/blocks/registry', () => ({ - getBlock: (type: string) => - type === 'agent' - ? { - name: 'Agent', - subBlocks: [{ id: 'tools', type: 'tool-input' }], - outputs: {}, - } - : type === 'mcp' - ? { - name: 'MCP', - subBlocks: [ - { id: 'serverSelector', type: 'mcp-server-selector' }, - { - id: 'toolSelector', - type: 'mcp-tool-selector', - dependsOn: ['serverSelector'], - selectorKey: 'mcp.tools', - }, - ], - outputs: {}, - } - : { - name: 'Slack', - subBlocks: [ - { id: 'credential', type: 'oauth-input' }, - { id: 'botToken', type: 'short-input', password: true }, - { id: 'text', type: 'long-input' }, - { id: 'headers', type: 'table' }, - ], - outputs: {}, + getBlock: (type: string) => { + if (type === 'agent') { + return { + name: 'Agent', + subBlocks: [{ id: 'tools', type: 'tool-input' }], + outputs: {}, + } + } + if (type === 'mcp') { + return { + name: 'MCP', + subBlocks: [ + { id: 'serverSelector', type: 'mcp-server-selector' }, + { + id: 'toolSelector', + type: 'mcp-tool-selector', + dependsOn: ['serverSelector'], + selectorKey: 'mcp.tools', }, + ], + outputs: {}, + } + } + if (type === 'table_v2') { + return { + name: 'Table', + subBlocks: [ + { id: 'credential', type: 'oauth-input' }, + { + id: 'tableSelector', + type: 'table-selector', + canonicalParamId: 'tableId', + mode: 'basic', + required: true, + }, + { + id: 'manualTableId', + type: 'short-input', + canonicalParamId: 'tableId', + mode: 'advanced', + required: true, + }, + ], + outputs: {}, + } + } + return { + name: 'Slack', + subBlocks: [ + { id: 'credential', type: 'oauth-input' }, + { id: 'botToken', type: 'short-input', password: true }, + { id: 'text', type: 'long-input' }, + { id: 'headers', type: 'table' }, + ], + outputs: {}, + } + }, })) import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow' @@ -327,3 +353,60 @@ describe('buildWorkflowExportPayload', () => { } ) }) + +describe('buildWorkflowExportPayload with includeWorkspaceBindings', () => { + const record = { + id: 'workflow-1', + name: 'Reports', + description: null, + workspaceId: 'workspace-1', + folderId: null, + variables: {}, + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.loadNormalized.mockResolvedValue({ + blocks: { + lookup: { + id: 'lookup', + type: 'table_v2', + name: 'Lookup', + position: { x: 0, y: 0 }, + subBlocks: { + credential: { id: 'credential', type: 'oauth-input', value: 'cred-1' }, + tableSelector: { + id: 'tableSelector', + type: 'table-selector', + value: 'tbl_239e870374c14d4a89923175a7b10648', + }, + manualTableId: { id: 'manualTableId', type: 'short-input', value: null }, + }, + outputs: {}, + enabled: true, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }) + }) + + /** + * The default export is the sharing-safe one and clears the table id; the + * same-workspace round trip keeps it, and neither keeps the credential. + */ + it('keeps workspace bindings only when asked, and never the credential', async () => { + const sharing = await buildWorkflowExportPayload(record) + const sameWorkspace = await buildWorkflowExportPayload(record, { + includeWorkspaceBindings: true, + }) + + expect(sharing?.state.blocks.lookup.subBlocks.tableSelector.value).toBeNull() + expect(sameWorkspace?.state.blocks.lookup.subBlocks.tableSelector.value).toBe( + 'tbl_239e870374c14d4a89923175a7b10648' + ) + expect(sharing?.state.blocks.lookup.subBlocks.credential.value).toBeNull() + expect(sameWorkspace?.state.blocks.lookup.subBlocks.credential.value).toBeNull() + }) +}) diff --git a/apps/sim/lib/workflows/operations/export-workflow.ts b/apps/sim/lib/workflows/operations/export-workflow.ts index b9c8c82629e..60123d8e7e0 100644 --- a/apps/sim/lib/workflows/operations/export-workflow.ts +++ b/apps/sim/lib/workflows/operations/export-workflow.ts @@ -42,6 +42,18 @@ export interface WorkflowExportEdge { markerEnd?: string } +export interface BuildWorkflowExportPayloadOptions { + includeReferences?: boolean + /** + * Keep workspace-scoped resource bindings (table, knowledge base, document, + * folder, channel, and every other selector) so the payload round-trips into + * the workspace it came from without re-entering them. Credentials, passwords, + * and opaque table values are cleared regardless, so the default stays the + * sharing-safe export and this only widens a same-workspace copy. + */ + includeWorkspaceBindings?: boolean +} + export interface WorkflowExportPayload { version: '1.0' referenceManifest?: WorkflowReferenceManifest @@ -140,7 +152,10 @@ function buildExportReferenceManifest( * * The last two classes mean an export is **not** a byte-for-byte clone even when * re-imported into the same workspace: those bindings and every table come back - * empty and must be re-entered. This matches the in-app export. + * empty and must be re-entered. This matches the in-app export. The one + * relaxation is `includeWorkspaceBindings`, which keeps the workspace-scoped + * bindings — and only those — for a same-workspace round trip; the import + * reports whichever required bindings still arrive empty as warnings. * * Workflow **variables** are emitted as stored: they are plaintext workflow * configuration readable by anyone with workspace read (the same permission the @@ -149,7 +164,7 @@ function buildExportReferenceManifest( */ export async function buildWorkflowExportPayload( workflowData: ExportableWorkflowRecord, - options: { includeReferences?: boolean } = {} + options: BuildWorkflowExportPayloadOptions = {} ): Promise { const normalizedData = await loadWorkflowFromNormalizedTables(workflowData.id) if (!normalizedData) return null @@ -166,7 +181,10 @@ export async function buildWorkflowExportPayload( }, variables: parseWorkflowVariables(workflowData.variables), }, - options + { + includeReferences: options.includeReferences, + preserveWorkspaceBindings: options.includeWorkspaceBindings === true, + } ) return { diff --git a/apps/sim/lib/workflows/operations/import-workflow.ts b/apps/sim/lib/workflows/operations/import-workflow.ts index fce80a7f1b8..72f32b975e4 100644 --- a/apps/sim/lib/workflows/operations/import-workflow.ts +++ b/apps/sim/lib/workflows/operations/import-workflow.ts @@ -16,6 +16,7 @@ import { } from '@/lib/api/contracts/v1/workflows' import { workflowStateSchema } from '@/lib/api/contracts/workflows' import { serializeZodIssues } from '@/lib/api/server' +import { collectStrippedWorkspaceBindings } from '@/lib/workflows/credentials/credential-extractor' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { type PerformCreateWorkflowParams, @@ -102,9 +103,30 @@ export interface ImportedWorkflow { } export type ImportWorkflowResult = - | { success: true; workflow: ImportedWorkflow } + | { + success: true + workflow: ImportedWorkflow + /** + * One line per required workspace binding the payload carried empty — + * what the export cleared and this import could not restore. The + * workflow was created; it cannot run until these are set. + */ + warnings: string[] + } | { success: false; status: number; error: string; details?: unknown } +/** + * The import-side half of the export contract: export clears every + * workspace-scoped binding, so a round trip lands a workflow whose table and + * knowledge-base selections are empty. Saying so in the response is what keeps + * that from being discovered at the first failed run. + */ +export function describeStrippedWorkspaceBindings(state: WorkflowState): string[] { + return collectStrippedWorkspaceBindings(state).map( + ({ blockName, field }) => `${blockName}: ${field} was stripped by export; set it before running` + ) +} + /** * Caps a payload-derived string at `maxLength` *including* the ellipsis. * `truncate` appends its suffix after slicing, so passing the limit straight @@ -438,6 +460,7 @@ async function executeImportWorkflowIntoWorkspace( name: block.name, })), }, + warnings: describeStrippedWorkspaceBindings(workflowState), } } diff --git a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts index 98d05e14bfc..d7ecff40435 100644 --- a/apps/sim/lib/workflows/sanitization/json-sanitizer.ts +++ b/apps/sim/lib/workflows/sanitization/json-sanitizer.ts @@ -682,13 +682,23 @@ export function sanitizeForCopilot( } } +export interface ExportSanitizationOptions { + includeReferences?: boolean + /** + * Keep workspace-scoped resource bindings for a same-workspace round trip. + * Secrets and credentials are cleared either way; see + * `WorkflowSanitizationOptions.preserveWorkspaceBindings`. + */ + preserveWorkspaceBindings?: boolean +} + /** * Sanitize workflow state for export by removing secrets but keeping positions * Users need positions to restore the visual layout when importing */ export function sanitizeForExport( state: WorkflowState, - options: { includeReferences?: boolean } = {} + options: ExportSanitizationOptions = {} ): ExportWorkflowState { const canonicalLoops = generateLoopBlocks(state.blocks || {}) const canonicalParallels = generateParallelBlocks(state.blocks || {}) @@ -708,6 +718,7 @@ export function sanitizeForExport( preserveEnvVars: true, // Keep {{ENV_VAR}} references in exported workflows redactOpaqueCredentialInputs: true, preserveReferenceMetadata: options.includeReferences, + ...(options.preserveWorkspaceBindings ? { preserveWorkspaceBindings: true } : {}), }) as ExportWorkflowState['state'] return { diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index 681cf640889..c90ba4758ac 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -493,6 +493,21 @@ export interface BlockFieldIssues { inactiveModeValues: InactiveModeValue[] } +/** + * Which required-field policy {@link collectBlockFieldIssues} applies. + * + * `execution` mirrors `serializeBlock`: a required sub-block whose tool parameter + * the tool itself does not mark `required` + `user-only` is deferred to the tool's + * own late validation, because an agent invoking that tool may supply the value + * at call time. `lint` reports every required, visible sub-block the tool pass + * did not already check — a canvas block has no LLM to fill the field, so a + * missing knowledge base id is as fatal there as a missing table id, and the + * tool-level visibility of the parameter must not decide whether lint says so. + */ +export interface BlockFieldIssueOptions { + mode?: 'execution' | 'lint' +} + /** * Select the tool id for a block given its resolved params. */ @@ -680,7 +695,8 @@ function classifyCanonicalKind( export function collectBlockFieldIssues( block: BlockState, blockConfig: any, - params: Record + params: Record, + options: BlockFieldIssueOptions = {} ): BlockFieldIssues { // Disabled blocks and trigger-mode blocks are not validated (mirrors runtime). if (block.enabled === false) { @@ -712,9 +728,11 @@ export function collectBlockFieldIssues( // Validate tool parameters (for blocks with tools). // Lookup contract: a tool param's value lives under its own paramId in `params`. // Block subBlocks align via either `id === paramId` or `canonicalParamId === paramId`. + const checkedByToolPass = new Set() if (currentToolParams) { Object.entries(currentToolParams).forEach(([paramId, paramConfig]: [string, any]) => { if (paramConfig.required && paramConfig.visibility === 'user-only') { + checkedByToolPass.add(paramId) const matchingConfigs = blockConfig.subBlocks?.filter( (sb: any) => sb.id === paramId || sb.canonicalParamId === paramId @@ -768,8 +786,13 @@ export function collectBlockFieldIssues( }) } - // Validate required subBlocks not covered by tool params (e.g., blocks with empty tools.access) - const validatedByTool = new Set(currentToolParams ? Object.keys(currentToolParams) : []) + // Validate required subBlocks not covered by the tool pass. Execution treats every + // tool-declared param as the tool's to validate (see `BlockFieldIssueOptions`); + // lint only skips the ones the pass above actually checked. + const validatedByTool = + options.mode === 'lint' + ? checkedByToolPass + : new Set(currentToolParams ? Object.keys(currentToolParams) : []) blockConfig.subBlocks?.forEach((subBlockConfig: SubBlockConfig) => { if (validatedByTool.has(subBlockConfig.id)) { diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index e6f9a150d41..5cd39733bc8 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -5943,6 +5943,7 @@ type ImportWorkflowResponseRef1 = { failed: number } blocks: Array + warnings: Array } export type ImportWorkflowResponse = { @@ -6117,6 +6118,9 @@ export type ListChatDeploymentsResponse = { export type ListConnectorTypesQuery = { workspaceId: string search?: string + detail?: 'summary' | 'full' + limit?: number + cursor?: string } type ListConnectorTypesResponseRef0 = { @@ -6169,8 +6173,17 @@ type ListConnectorTypesResponseRef1 = { multi?: boolean } +type ListConnectorTypesResponseRef2 = { + connectorType: string + name: string + description: string + auth: { + mode: 'oauth' | 'apiKey' + } +} + export type ListConnectorTypesResponse = { - data: Array + data: Array nextCursor: string | null } @@ -7455,6 +7468,7 @@ type ListWorkflowMcpToolsResponseRef0 = { apiEndpoint: string createdAt: string updatedAt: string + status: 'active' | 'inactive' } export type ListWorkflowMcpToolsResponse = { @@ -8777,10 +8791,12 @@ export type RelocateFileFolderQuery = Record type RelocateFileFolderBodyRef0 = string +type RelocateFileFolderBodyRef1 = string + export type RelocateFileFolderBody = { workspaceId: string path: RelocateFileFolderBodyRef0 - destinationPath: RelocateFileFolderBodyRef0 + destinationPath: RelocateFileFolderBodyRef1 } type RelocateFileFolderResponseRef0 = { @@ -8800,10 +8816,12 @@ export type RelocateKnowledgeFolderQuery = Record type RelocateKnowledgeFolderBodyRef0 = string +type RelocateKnowledgeFolderBodyRef1 = string + export type RelocateKnowledgeFolderBody = { workspaceId: string path: RelocateKnowledgeFolderBodyRef0 - destinationPath: RelocateKnowledgeFolderBodyRef0 + destinationPath: RelocateKnowledgeFolderBodyRef1 } type RelocateKnowledgeFolderResponseRef0 = { @@ -8823,10 +8841,12 @@ export type RelocateTableFolderQuery = Record type RelocateTableFolderBodyRef0 = string +type RelocateTableFolderBodyRef1 = string + export type RelocateTableFolderBody = { workspaceId: string path: RelocateTableFolderBodyRef0 - destinationPath: RelocateTableFolderBodyRef0 + destinationPath: RelocateTableFolderBodyRef1 } type RelocateTableFolderResponseRef0 = { @@ -8846,10 +8866,12 @@ export type RelocateWorkflowFolderQuery = Record type RelocateWorkflowFolderBodyRef0 = string +type RelocateWorkflowFolderBodyRef1 = string + export type RelocateWorkflowFolderBody = { workspaceId: string path: RelocateWorkflowFolderBodyRef0 - destinationPath: RelocateWorkflowFolderBodyRef0 + destinationPath: RelocateWorkflowFolderBodyRef1 } type RelocateWorkflowFolderResponseRef0 = { @@ -9939,16 +9961,22 @@ type UndeployWorkflowResponseRef3 = { } type UndeployWorkflowResponseRef4 = { + serverId: string + toolName: string +} + +type UndeployWorkflowResponseRef5 = { id: string isDeployed: boolean deployedAt: string | null warnings: Array activeDeployment: UndeployWorkflowResponseRef0 | null latestDeploymentAttempt: UndeployWorkflowResponseRef1 | null + archivedMcpTools: Array } export type UndeployWorkflowResponse = { - data: UndeployWorkflowResponseRef4 + data: UndeployWorkflowResponseRef5 } /** `DELETE /api/v2/workflow-mcp-servers/[serverId]/tools/[workflowId]` */ @@ -14387,6 +14415,24 @@ export const V2_OPERATIONS = { kind: 'string', describe: 'Case-insensitive substring match against the connector name.', }, + detail: { + kind: 'enum', + values: ['summary', 'full'] as const, + default: 'summary', + describe: + 'Projection of each item. `summary` (the default) carries the identifier, name, description, and auth mode; `full` adds the version, the complete auth settings, the `sourceConfig` field schema, incremental-sync support, and tag definitions.', + }, + limit: { + kind: 'integer', + default: 25, + describe: + 'Maximum connector types to return per page. Must be a whole number from 1 to 100. Defaults to 25.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, }, }, listCredentialProviders: { @@ -16024,9 +16070,9 @@ export const V2_OPERATIONS = { }, limit: { kind: 'integer', - default: 50, + default: 25, describe: - 'Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + 'Maximum workspaces to return per page. Must be a whole number from 1 to 100. Defaults to 25.', }, cursor: { kind: 'string', @@ -16440,7 +16486,8 @@ export const V2_OPERATIONS = { destinationPath: { kind: 'string', required: true, - describe: 'New full path for the folder and its descendants.', + describe: + 'Where the folder lands, with `mv` semantics. A path naming an existing folder receives the source as a child under its current name; `/` moves it to the workspace root under its current name; any other path becomes the folder’s new full path (a rename, a relocation, or both).', }, }, }, @@ -16456,7 +16503,8 @@ export const V2_OPERATIONS = { destinationPath: { kind: 'string', required: true, - describe: 'New full path for the folder and its descendants.', + describe: + 'Where the folder lands, with `mv` semantics. A path naming an existing folder receives the source as a child under its current name; `/` moves it to the workspace root under its current name; any other path becomes the folder’s new full path (a rename, a relocation, or both).', }, }, }, @@ -16472,7 +16520,8 @@ export const V2_OPERATIONS = { destinationPath: { kind: 'string', required: true, - describe: 'New full path for the folder and its descendants.', + describe: + 'Where the folder lands, with `mv` semantics. A path naming an existing folder receives the source as a child under its current name; `/` moves it to the workspace root under its current name; any other path becomes the folder’s new full path (a rename, a relocation, or both).', }, }, }, @@ -16488,7 +16537,8 @@ export const V2_OPERATIONS = { destinationPath: { kind: 'string', required: true, - describe: 'New full path for the folder and its descendants.', + describe: + 'Where the folder lands, with `mv` semantics. A path naming an existing folder receives the source as a child under its current name; `/` moves it to the workspace root under its current name; any other path becomes the folder’s new full path (a rename, a relocation, or both).', }, }, }, From 296d6ae8420ff95864d91eaae96c5f8238ded4eb Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Wed, 2 Sep 2026 23:20:20 +0530 Subject: [PATCH 068/306] table groups default to the deployed version and refuse a dispatch without one; column rename reports unmigrated Table-block filters; lint checks table fields against the live schema; runCount counts every settled run; runs cancel is honest about no-ops; dry-run apply answers with previewBlockIds; conditionResult is the chosen test's boolean with selectedTitle; workflows run --select-output keys blockOutputs by the selector and rejects unknown heads; knowledge search exposes rankScore and rank; the multi-trigger error names workflows state get; the Knowledge block declares cost, tokens, model; media writes refuse to fork a moved folder; grep --in knowledge points at knowledge search --- apps/docs/openapi-v2-knowledge.json | 18 +- apps/docs/openapi-v2-tables.json | 186 +++++++++++++++++- apps/docs/openapi-v2-workflows.json | 62 +++++- .../app/api/copilot/tool-permission/route.ts | 2 - .../app/api/files/serve/[...path]/route.ts | 2 - apps/sim/app/api/v1/knowledge/search/route.ts | 7 +- .../app/api/v2/knowledge/search/route.test.ts | 6 + apps/sim/app/api/v2/lib/workflow-lint.ts | 5 + .../v2/tables/[tableId]/columns/route.test.ts | 30 ++- .../api/v2/tables/[tableId]/columns/route.ts | 4 +- .../v2/tables/[tableId]/groups/route.test.ts | 8 +- apps/sim/app/api/v2/tables/presenters.test.ts | 12 ++ apps/sim/app/api/v2/tables/presenters.ts | 18 +- .../[workflowId]/execute/route.test.ts | 27 +++ .../[workflowId]/operations/route.test.ts | 42 ++++ .../[workflowId]/operations/route.ts | 1 + .../runs/[runId]/cancel/route.test.ts | 23 ++- .../[workflowId]/runs/[runId]/cancel/route.ts | 8 +- .../[workflowId]/runs/[runId]/route.test.ts | 1 + .../[workflowId]/state/route.test.ts | 1 + .../resource-content/resource-content.tsx | 1 - .../tables/[tableId]/hooks/use-table.ts | 3 +- .../[workspaceId]/tables/[tableId]/table.tsx | 6 +- apps/sim/blocks/blocks/condition.ts | 11 +- apps/sim/blocks/blocks/knowledge.test.ts | 52 +++++ apps/sim/blocks/blocks/knowledge.ts | 14 ++ .../condition/condition-handler.test.ts | 86 +++++++- .../handlers/condition/condition-handler.ts | 32 ++- apps/sim/executor/types.ts | 1 + .../sim/lib/api/contracts/knowledge/search.ts | 12 +- apps/sim/lib/api/contracts/tables.ts | 2 +- .../v2/__tests__/workflow-graph.test.ts | 10 + apps/sim/lib/api/contracts/v2/knowledge.ts | 16 +- .../lib/api/contracts/v2/openapi/tables.ts | 38 +++- .../lib/api/contracts/v2/openapi/workflows.ts | 6 +- apps/sim/lib/api/contracts/v2/tables.ts | 47 ++++- apps/sim/lib/api/contracts/v2/workflows.ts | 47 +++-- .../lib/knowledge/application/search.test.ts | 96 +++++++++ apps/sim/lib/knowledge/application/search.ts | 16 +- apps/sim/lib/knowledge/search/queries.test.ts | 53 +++++ apps/sim/lib/knowledge/search/queries.ts | 29 ++- .../lib/mothership/agent-cli/engines.test.ts | 2 + .../agent-cli/engines/universal-grep.test.ts | 13 ++ .../agent-cli/engines/universal-grep.ts | 14 ++ .../mothership/vfs/resource-writer.test.ts | 92 +++++++++ .../sim/lib/mothership/vfs/resource-writer.ts | 58 +++++- .../sim/lib/table/application/columns.test.ts | 89 ++++++++- apps/sim/lib/table/application/columns.ts | 43 ++++ apps/sim/lib/table/application/groups.test.ts | 8 +- .../table/columns/workflow-references.test.ts | 155 +++++++++++++++ .../lib/table/columns/workflow-references.ts | 166 ++++++++++++++++ .../lib/table/query-builder/field-names.ts | 51 +++++ apps/sim/lib/table/query-builder/index.ts | 1 + apps/sim/lib/table/types.ts | 8 +- apps/sim/lib/table/workflow-columns.test.ts | 116 +++++++++++ apps/sim/lib/table/workflow-columns.ts | 75 ++++++- .../table/workflow-groups/deployment-mode.ts | 19 ++ .../lib/table/workflow-groups/service.test.ts | 68 +++++++ apps/sim/lib/table/workflow-groups/service.ts | 10 +- apps/sim/lib/workflows/api/route-policies.ts | 3 +- .../apply-workflow-operations.test.ts | 50 ++++- .../application/apply-workflow-operations.ts | 22 ++- .../lib/workflows/application/cancel-run.ts | 27 ++- .../run-workflow-from-copilot.test.ts | 59 ++++++ .../application/run-workflow-from-copilot.ts | 16 +- .../application/workflow-run-control.test.ts | 49 +++++ .../lib/workflows/editing/lint-report.test.ts | 79 ++++++++ apps/sim/lib/workflows/editing/lint-report.ts | 49 ++++- apps/sim/lib/workflows/editing/lint.test.ts | 147 +++++++++++++- apps/sim/lib/workflows/editing/lint.ts | 127 +++++++++++- .../executor/execute-service.test.ts | 7 +- .../lib/workflows/executor/execute-service.ts | 29 +-- .../workflows/executor/execution-core.test.ts | 36 +++- .../lib/workflows/executor/execution-core.ts | 43 ++-- .../resolve-output-selectors.test.ts | 38 +++- .../streaming/resolve-output-selectors.ts | 32 ++- apps/sim/lib/workflows/utils.test.ts | 34 ++++ apps/sim/lib/workflows/utils.ts | 22 ++- apps/sim/tools/knowledge/search.ts | 12 +- apps/sim/tools/knowledge/types.ts | 2 + packages/sim-cli/src/generated/v2-api.ts | 36 +++- 81 files changed, 2788 insertions(+), 160 deletions(-) create mode 100644 apps/sim/blocks/blocks/knowledge.test.ts create mode 100644 apps/sim/lib/table/columns/workflow-references.test.ts create mode 100644 apps/sim/lib/table/columns/workflow-references.ts create mode 100644 apps/sim/lib/table/query-builder/field-names.ts create mode 100644 apps/sim/lib/table/workflow-groups/deployment-mode.ts diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index baaba82d6bd..5e029f9adf6 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -6212,9 +6212,21 @@ }, "similarity": { "type": "number", - "description": "Similarity score for vector search; tag-only matches use 1.", + "description": "Cosine similarity between the query embedding and the chunk (1 - cosine distance), reported the same way in `vector` and `hybrid` mode; tag-only matches use 1. In `hybrid` mode results are not ordered by this value — see `rankScore`.", "examples": [0.8423] }, + "rankScore": { + "type": "number", + "description": "The score results are ordered by, descending. In `vector` mode it equals `similarity`; in `hybrid` mode it is the reciprocal-rank-fusion score (a sum of 1/(60 + rank) over the lexical and vector legs, so a chunk both legs ranked first scores 2/61); when a reranker ordered the results it is `rerankerScore`.", + "examples": [0.0328] + }, + "rank": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "1-based position in the returned order.", + "examples": [1] + }, "rerankerScore": { "description": "Relevance score assigned by the reranker, present only on results a reranker ordered. Results are ordered by this score when it is present, which is why it can disagree with `similarity`.", "examples": [0.9312], @@ -6229,7 +6241,9 @@ "content", "chunkIndex", "metadata", - "similarity" + "similarity", + "rankScore", + "rank" ], "additionalProperties": false, "title": "Knowledge search result", diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index c74343efdcb..3b01a472636 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -622,7 +622,7 @@ }, "responses": { "200": { - "description": "The updated table columns.", + "description": "The updated table columns, plus any unmigrated workflow Table blocks.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -637,7 +637,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2TableColumnsResponse" + "$ref": "#/components/schemas/V2UpdateTableColumnResponse" } } } @@ -5770,6 +5770,182 @@ } ] }, + "V2UnmigratedTableBlockReference": { + "type": "object", + "properties": { + "workflowId": { + "type": "string", + "description": "Workflow holding the Table block." + }, + "workflowName": { + "type": "string", + "description": "Display name of that workflow." + }, + "blockId": { + "type": "string", + "description": "Table block whose configuration still names the old column." + }, + "blockName": { + "type": "string", + "description": "Display name of that block." + }, + "fields": { + "type": "array", + "items": { + "type": "string", + "enum": ["filter", "order", "data"] + }, + "description": "Sub-block fields that still reference the old column name." + } + }, + "required": ["workflowId", "workflowName", "blockId", "blockName", "fields"], + "additionalProperties": false, + "title": "Unmigrated table block reference", + "description": "A workflow Table block still configured against a renamed column." + }, + "V2UpdateTableColumnData": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "description": "Stable server-assigned column identifier.", + "type": "string" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 50, + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "description": "Column name used as the public row-data key." + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "currency", + "boolean", + "date", + "ttl", + "json", + "select" + ], + "description": "Data type of values stored in the column." + }, + "required": { + "default": false, + "description": "Whether inserts require a value for this column.", + "type": "boolean" + }, + "unique": { + "default": false, + "description": "Whether values must be unique across table rows.", + "type": "boolean" + }, + "workflowGroupId": { + "description": "Workflow group whose output populates this column.", + "type": "string" + }, + "options": { + "description": "Options declared for a select column.", + "maxItems": 100, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Stable select-option identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Display name of the select option." + } + }, + "required": ["id", "name"], + "additionalProperties": false + } + }, + "multiple": { + "description": "Whether a select column accepts multiple options.", + "type": "boolean" + }, + "currencyCode": { + "description": "ISO 4217 code for a currency column, normalized to uppercase.", + "type": "string", + "pattern": "^[A-Za-z]{3}$" + } + }, + "required": ["name", "type", "required", "unique"], + "additionalProperties": false, + "description": "A typed column in a table schema." + }, + "description": "Current table columns." + }, + "unmigrated": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2UnmigratedTableBlockReference" + }, + "description": "Workflow Table blocks bound to this table whose `filter`, `order`, or `data` still name the column by its previous name. Only populated by a rename; empty otherwise. Workflow state is never rewritten by this endpoint — edit those blocks (`POST /api/v2/workflows/{workflowId}/operations`) or their next run fails on the old name." + } + }, + "required": ["columns", "unmigrated"], + "additionalProperties": false, + "title": "Update table column data", + "description": "The table column list after the update, plus any workflow Table blocks a rename left pointing at the old column name." + }, + "V2UpdateTableColumnResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2UpdateTableColumnData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update table column response", + "description": "The table column list after the update, plus workflow Table blocks a rename left on the old column name.", + "examples": [ + { + "data": { + "columns": [ + { + "id": "col_name", + "name": "name", + "type": "string", + "required": true, + "unique": false + }, + { + "id": "col_plan", + "name": "subscriptionPlan", + "type": "string", + "required": false, + "unique": false + } + ], + "unmigrated": [ + { + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workflowName": "Weekly digest", + "blockId": "blk_9c1d3f5a7e2b4068a0c2e4f6b8d0f193", + "blockName": "Query plans", + "fields": ["filter"] + } + ] + } + } + ] + }, "UpdateTableColumnRequest": { "type": "object", "properties": { @@ -7766,16 +7942,16 @@ } }, "deploymentMode": { - "description": "Workflow execution mode.", "type": "string", - "enum": ["live", "deployed"] + "enum": ["live", "deployed"], + "description": "Which workflow state per-cell runs execute against. `deployed` (the default when a group was created without one) runs the latest active deployment and refuses to run while the workflow is undeployed; `live` runs the editable draft." }, "autoRun": { "description": "Whether the group automatically runs for new rows.", "type": "boolean" } }, - "required": ["id", "workflowId", "outputs"], + "required": ["id", "workflowId", "outputs", "deploymentMode"], "additionalProperties": false, "title": "Table workflow group", "description": "A workflow or enrichment producer and the columns it populates." diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 749d82e42f6..731a55e6fc6 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -5757,7 +5757,7 @@ "type": "null" } ], - "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", + "description": "ISO 8601 timestamp of the latest settled run, whatever its outcome, or null when the workflow has never run.", "format": "date-time" }, "createdAt": { @@ -5929,7 +5929,7 @@ "type": "null" } ], - "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", + "description": "ISO 8601 timestamp of the latest settled run, whatever its outcome, or null when the workflow has never run.", "format": "date-time" }, "createdAt": { @@ -6940,6 +6940,51 @@ }, "description": "Credential, resource, tool, and skill references that do not resolve. These values are still persisted; they are reported, not dropped." }, + "tableFieldIssues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + }, + "field": { + "type": "string", + "description": "The filter or sort field that names no column." + }, + "tableName": { + "type": "string", + "description": "Display name of the table the block is bound to." + } + }, + "required": ["blockId", "blockName", "blockType", "field", "tableName"], + "additionalProperties": false + }, + "description": "Table block `filter` and `order` fields checked against the bound table's live schema that name no column (nor the implicit `id`, `createdAt`, `updatedAt`). Such a run fails inside the block's error edge. A filter holding a `` reference, or one that is not JSON, is not checked." + }, "notes": { "type": "array", "items": { @@ -6957,6 +7002,7 @@ "invalidConnectionTargets", "fieldIssues", "unresolvedReferences", + "tableFieldIssues", "notes" ], "additionalProperties": false, @@ -7022,6 +7068,7 @@ "invalidConnectionTargets": [], "fieldIssues": [], "unresolvedReferences": [], + "tableFieldIssues": [], "notes": [] } } @@ -7694,6 +7741,7 @@ } ], "unresolvedReferences": [], + "tableFieldIssues": [], "notes": [] }, "warnings": [], @@ -8793,7 +8841,7 @@ "type": "null" } ], - "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", + "description": "ISO 8601 timestamp of the latest settled run, whatever its outcome, or null when the workflow has never run.", "format": "date-time" }, "createdAt": { @@ -11626,7 +11674,7 @@ "type": "null" } ], - "description": "Outputs of the blocks named by `selectedOutputs`, keyed by those selector strings, or null when none were requested. Selectors whose block did not run or whose path is absent are omitted; failed runs include the outputs of the blocks that did run." + "description": "Outputs of the blocks named by `selectedOutputs`, keyed by those selector strings exactly as sent, or null when none were requested. `output` stays the final workflow output regardless. Selectors whose block did not run or whose path is absent are omitted (an unknown block is a `400` instead); failed runs include the outputs of the blocks that did run." }, "error": { "anyOf": [ @@ -11840,7 +11888,7 @@ "type": "boolean" }, "selectedOutputs": { - "description": "Block output references to include in the response. Use `.` for the executed workflow or `..` for a child workflow; block names are normalized workflow reference names, and selecting a child workflow applies to every invocation of it. On a sync request the named outputs come back in `blockOutputs`, keyed by these selector strings; on a stream they shape the streamed envelope. Selectors that resolve to no block or no value are omitted. Rejected when `async` is true — a queued run has produced nothing to select; narrow the finished run via the run resource instead.", + "description": "Block output references to include in the response. Use `.` for the executed workflow or `..` for a child workflow; block names are normalized workflow reference names, and selecting a child workflow applies to every invocation of it. On a sync request the named outputs come back in `blockOutputs`, keyed by these selector strings exactly as sent; on a stream they shape the streamed envelope. A selector whose block name or id matches no block in the workflow is rejected with `400` naming the available blocks, before the run starts. A selector whose block did not run or whose path is absent is omitted. Rejected when `async` is true — a queued run has produced nothing to select; narrow the finished run via the run resource instead.", "maxItems": 100, "type": "array", "items": { @@ -12506,7 +12554,7 @@ "properties": { "success": { "type": "boolean", - "description": "Whether cancellation was accepted." + "description": "Whether this request cancelled anything. `false` with an `already_*` reason means the run had already reached that terminal state, so there was nothing to cancel — still `200`, because a no-op is not an error. `false` with any other reason identifies a degraded or incomplete cancellation step." }, "runId": { "type": "string", @@ -12560,7 +12608,7 @@ ], "additionalProperties": false, "title": "Cancel workflow run result", - "description": "Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state succeeds with no effect, reported as `durablyRecorded: false` with an `already_*` reason naming the state observed." + "description": "Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state is a `200` no-op, reported as `success: false` and `durablyRecorded: false` with an `already_*` reason naming the state observed." }, "CancelWorkflowRunResponse": { "type": "object", diff --git a/apps/sim/app/api/copilot/tool-permission/route.ts b/apps/sim/app/api/copilot/tool-permission/route.ts index 565ec8e594f..5dd4afb127c 100644 --- a/apps/sim/app/api/copilot/tool-permission/route.ts +++ b/apps/sim/app/api/copilot/tool-permission/route.ts @@ -29,8 +29,6 @@ import { createUnauthorizedResponse, } from '@/lib/mothership/request/http' import { withIncomingGoSpan } from '@/lib/mothership/request/otel' -import { isCopilotToolPermissionsEnabled } from '@/lib/core/config/env-flags' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { isWorkspaceCapabilityWithheld } from '@/lib/permission-groups/capability-assertions' const logger = createLogger('CopilotToolPermissionAPI') diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 3f93ac16e95..4db82a99874 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -15,8 +15,6 @@ import { DocCompileUserError } from '@/lib/mothership/tools/server/files/doc-com import { asOrchestrationError } from '@/lib/core/orchestration/types' import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveServableDocBytes } from '@/lib/mothership/tools/server/files/doc-compile' -import { DocCompileUserError } from '@/lib/mothership/tools/server/files/doc-compile-error' import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads' import type { StorageContext } from '@/lib/uploads/config' import { readOrganizationAssistantImage } from '@/lib/uploads/contexts/organization-assistant/application' diff --git a/apps/sim/app/api/v1/knowledge/search/route.ts b/apps/sim/app/api/v1/knowledge/search/route.ts index 2321fa5ed07..6cb2a3dc808 100644 --- a/apps/sim/app/api/v1/knowledge/search/route.ts +++ b/apps/sim/app/api/v1/knowledge/search/route.ts @@ -320,7 +320,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, data: { - results: results.map((result) => { + results: results.map((result, index) => { const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {} const tags: Record = {} @@ -332,6 +332,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } }) + const similarity = hasQuery ? 1 - result.distance : 1 return { documentId: result.documentId, documentName: result.filename || undefined, @@ -339,7 +340,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { content: result.content, chunkIndex: result.chunkIndex, metadata: tags, - similarity: hasQuery ? 1 - result.distance : 1, + similarity, + rankScore: result.rankScore ?? similarity, + rank: index + 1, } }), query: query || '', diff --git a/apps/sim/app/api/v2/knowledge/search/route.test.ts b/apps/sim/app/api/v2/knowledge/search/route.test.ts index 1688466dcc6..65fee6d28de 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.test.ts @@ -59,6 +59,8 @@ describe('POST /api/v2/knowledge/search', () => { chunkIndex: 0, metadata: { category: 'billing' }, similarity: 0.9, + rankScore: 0.42, + rank: 1, rerankerScore: 0.42, }, ], @@ -130,6 +132,8 @@ describe('POST /api/v2/knowledge/search', () => { chunkIndex: 0, metadata: { category: 'billing' }, similarity: 0.9, + rankScore: 0.42, + rank: 1, rerankerScore: 0.42, }) }) @@ -206,6 +210,8 @@ describe('POST /api/v2/knowledge/search', () => { chunkIndex: 0, metadata: {}, similarity: 0.9, + rankScore: 0.9, + rank: 1, }, ], query: 'hello', diff --git a/apps/sim/app/api/v2/lib/workflow-lint.ts b/apps/sim/app/api/v2/lib/workflow-lint.ts index d00be819475..ff6b514adb7 100644 --- a/apps/sim/app/api/v2/lib/workflow-lint.ts +++ b/apps/sim/app/api/v2/lib/workflow-lint.ts @@ -57,6 +57,11 @@ export function presentWorkflowLint(lint: WorkflowLintReport) { kind: reference.kind, reason: reference.reason, })), + tableFieldIssues: lint.tableFieldIssues.map((issue) => ({ + ...blockRef(issue), + field: issue.field, + tableName: issue.tableName, + })), notes: lint.notes, } } diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts index 4b2b10291ec..af0db3df690 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.test.ts @@ -68,7 +68,7 @@ describe('/api/v2/tables/[tableId]/columns', () => { v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.add.mockResolvedValue({ table }) - mocks.update.mockResolvedValue({ table, changed: false }) + mocks.update.mockResolvedValue({ table, changed: false, unmigrated: [] }) mocks.remove.mockResolvedValue({ table }) }) @@ -123,6 +123,34 @@ describe('/api/v2/tables/[tableId]/columns', () => { ) }) + it('returns the workflow Table blocks a rename left on the old column name', async () => { + const unmigrated = [ + { + workflowId: 'wf-1', + workflowName: 'Alerts', + blockId: 'blk-1', + blockName: 'Query', + fields: ['filter', 'order'], + }, + ] + mocks.update.mockResolvedValue({ table, changed: true, unmigrated }) + + const response = await PATCH( + request('PATCH', { + workspaceId: WORKSPACE_ID, + columnName: 'Name', + updates: { name: 'FullName' }, + }), + context + ) + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual({ + columns: [{ id: 'col-1', name: 'Name', type: 'string', required: false, unique: false }], + unmigrated, + }) + }) + it('rejects an unrecognized key on the column delete body', async () => { const response = await DELETE( request('DELETE', { diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts index 4440d408b74..791b17c0645 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -40,7 +40,9 @@ export const PATCH = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableErrorPolicies.concealTableAuthorization, mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }), - present: presentColumns, + present: ({ table, unmigrated }) => ({ + data: { columns: table.schema.columns.map(normalizeColumn), unmigrated }, + }), }) export const DELETE = defineV2JsonRoute({ diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts index 92349b9d39b..83dd184f5ec 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.test.ts @@ -97,7 +97,13 @@ describe('/api/v2/tables/[tableId]/groups', () => { expect(response.status).toBe(200) expect(await response.json()).toEqual({ - data: [{ ...group, outputs: [{ ...group.outputs[0], columnName: 'Result' }] }], + data: [ + { + ...group, + outputs: [{ ...group.outputs[0], columnName: 'Result' }], + deploymentMode: 'deployed', + }, + ], nextCursor: null, }) expect(mocks.list).toHaveBeenCalledWith({ diff --git a/apps/sim/app/api/v2/tables/presenters.test.ts b/apps/sim/app/api/v2/tables/presenters.test.ts index 537944feda4..33021e86042 100644 --- a/apps/sim/app/api/v2/tables/presenters.test.ts +++ b/apps/sim/app/api/v2/tables/presenters.test.ts @@ -136,6 +136,18 @@ describe('presentV2WorkflowGroup', () => { expect(presentV2WorkflowGroup(orphaned, schema).outputs[0].columnName).toBe('col_deleted') }) + /** + * `deploymentMode: null` on the wire was read as "runs the draft", while a + * mode-less group has always run the deployment. The presenter publishes + * the effective mode so the response can never carry a third, absent state. + */ + it('presents the effective deployment mode, never an absent one', () => { + expect(presentV2WorkflowGroup(stored, schema).deploymentMode).toBe('deployed') + expect( + presentV2WorkflowGroup({ ...stored, deploymentMode: 'live' }, schema).deploymentMode + ).toBe('live') + }) + it('leaves a legacy name-keyed group alone', () => { const legacy = { ...stored, diff --git a/apps/sim/app/api/v2/tables/presenters.ts b/apps/sim/app/api/v2/tables/presenters.ts index d733b576d33..998e0f76460 100644 --- a/apps/sim/app/api/v2/tables/presenters.ts +++ b/apps/sim/app/api/v2/tables/presenters.ts @@ -8,7 +8,8 @@ import { toV2CreateTableImport, toV2TableImport, } from '@/lib/table/orchestration/import-resource' -import type { TableSchema, WorkflowGroup } from '@/lib/table/types' +import type { TableSchema, WorkflowGroup, WorkflowGroupDeploymentMode } from '@/lib/table/types' +import { resolveWorkflowGroupDeploymentMode } from '@/lib/table/workflow-groups/deployment-mode' export function presentV2CreateTableImport(result: CreateTableImportResult) { return { data: toV2CreateTableImport(result) } @@ -35,8 +36,19 @@ export function presentV2TableExport(record: TableExportRecord, queued = false) * driven by the inverse map; a ref naming no current column is left as-is, so a * legacy name-keyed group and a ref to a since-deleted column both survive. */ -export function presentV2WorkflowGroup(group: WorkflowGroup, schema: TableSchema): WorkflowGroup { - return remapGroupColumnRefs(group, buildNameById(schema)) +export function presentV2WorkflowGroup( + group: WorkflowGroup, + schema: TableSchema +): Omit & { deploymentMode: WorkflowGroupDeploymentMode } { + return { + ...remapGroupColumnRefs(group, buildNameById(schema)), + /** + * Always the effective mode. A group that predates the field ran the + * deployed version all along; publishing it as absent read as "no mode", + * which a caller took for the draft. + */ + deploymentMode: resolveWorkflowGroupDeploymentMode(group), + } } /** diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts index e05a22bb55e..6d835ed57a2 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts @@ -838,6 +838,33 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => { }) }) + it('rejects a selectedOutputs selector whose block is unknown before running, naming the available blocks', async () => { + const agentBlockId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const startBlockId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + mockLoadDeployedWorkflowState.mockResolvedValue({ + blocks: { + [agentBlockId]: { id: agentBlockId, name: 'Agent 1' }, + [startBlockId]: { id: startBlockId, name: 'Start' }, + }, + edges: [], + loops: {}, + parallels: {}, + variables: {}, + }) + + const res = await callExecute({ + input: {}, + selectedOutputs: ['Agent 1.content', 'Agent 2.content'], + }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.error.message).toBe( + 'Invalid selectedOutputs: Unknown block "Agent 2" in selector "Agent 2.content". Available blocks: Agent 1, Start' + ) + expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() + }) + it.each(['includeThinking', 'includeToolCalls'])( 'rejects %s unless stream is true before checking the protocol header', async (option) => { diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.test.ts index af519e1f92d..83d398657d1 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.test.ts @@ -55,6 +55,7 @@ const LINT = { invalidConnectionTargets: [], fieldIssues: [], unresolvedReferences: [], + tableFieldIssues: [], notes: [], } @@ -143,6 +144,47 @@ describe('/api/v2/workflows/[workflowId]/operations', () => { ) }) + /** + * A dry run's ids are decoys — the committed apply mints different UUIDs — + * so they are published as previews, apart from `mintedBlockIds`, and only + * when the use case reports them. + */ + it("publishes a dry run's provisional ids as previews", async () => { + mocks.applyWorkflowOperations.mockResolvedValue({ + workflowId: WORKFLOW_ID, + workflowName: 'Daily digest', + workspaceId: 'workspace-1', + graph: { blocks: {}, edges: [], loops: {}, parallels: {} }, + operationCount: 1, + applied: 1, + skipped: [], + deferred: [], + inputValidationErrors: [], + mintedBlockIds: {}, + previewBlockIds: { triage: 'a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77' }, + lint: LINT, + warnings: ['Dry run: block ids are previews'], + needsRedeployment: true, + dryRun: true, + }) + + const response = await POST( + new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}/operations?dryRun=true`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ operations: [ADD] }), + }), + routeContext + ) + + expect(response.status).toBe(200) + expect((await response.json()).data).toMatchObject({ + mintedBlockIds: {}, + previewBlockIds: { triage: 'a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77' }, + dryRun: true, + }) + }) + it('maps the setBlockEnabled flag onto the use case input', async () => { await POST( request({ diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.ts index a160f0ced08..9cdf2010fb4 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/operations/route.ts @@ -49,6 +49,7 @@ export const POST = defineV2JsonRoute({ error: error.error, })), mintedBlockIds: result.mintedBlockIds, + ...(result.previewBlockIds ? { previewBlockIds: result.previewBlockIds } : {}), lint: presentWorkflowLint(result.lint), warnings: result.warnings, needsRedeployment: result.needsRedeployment, diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.test.ts index 8de3f2cb24e..0d8beffc559 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.test.ts @@ -57,6 +57,7 @@ function serviceResult(overrides: Record) { redisAvailable: true, locallyAborted: false, pausedCancelled: false, + cancelled: true, workflowId: WORKFLOW_ID, workspaceId: WORKSPACE_ID, ...overrides, @@ -90,18 +91,30 @@ describe('POST /api/v2/workflows/[workflowId]/runs/[runId]/cancel', () => { }) }) - it('reports an already-cancelled run as an idempotent no-op', async () => { + /** + * `success: true` with every action flag false told a caller its cancel had + * done something. A run that was already terminal is a `200` no-op — not an + * error — answered with `success: false` and the `already_*` reason. + */ + it('answers an already-cancelled run as a no-op that cancelled nothing', async () => { mocks.cancel.mockResolvedValue( - serviceResult({ success: true, durablyRecorded: false, reason: 'already_cancelled' }) + serviceResult({ + success: true, + cancelled: false, + durablyRecorded: false, + reason: 'already_cancelled', + }) ) const response = await POST(request(), context) expect(response.status).toBe(200) expect((await response.json()).data).toMatchObject({ - success: true, + success: false, runId: RUN_ID, durablyRecorded: false, + locallyAborted: false, + pausedCancelled: false, reason: 'already_cancelled', }) }) @@ -110,7 +123,7 @@ describe('POST /api/v2/workflows/[workflowId]/runs/[runId]/cancel', () => { ['completed', 'already_completed'], ['failed', 'already_failed'], ] as const)( - 'preserves the v2 terminal no-op response when a standalone run is already %s', + 'answers a 200 no-op with success false when a standalone run is already %s', async (executionStatus, reason) => { mocks.cancel.mockRejectedValue( new WorkflowRunAlreadyTerminalError({ @@ -126,7 +139,7 @@ describe('POST /api/v2/workflows/[workflowId]/runs/[runId]/cancel', () => { expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ data: { - success: true, + success: false, runId: RUN_ID, redisAvailable: true, durablyRecorded: false, diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.ts index 6968b78bce3..5ac2e280e5a 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/cancel/route.ts @@ -17,7 +17,13 @@ export const POST = defineV2JsonRoute({ useCase: cancelWorkflowRun, present: (result) => ({ data: { - success: result.success, + /** + * `success` on this surface means "this request cancelled something". + * A run that was already terminal is a satisfied no-op — still `200`, + * not an error — but answering `true` with every action flag false told + * a caller its cancel had done something. + */ + success: result.success && result.cancelled, runId: result.executionId, redisAvailable: result.redisAvailable, durablyRecorded: result.durablyRecorded, diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts index bfd3758957a..107b1b01102 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts @@ -103,6 +103,7 @@ const successfulCancellation = { durablyRecorded: true, locallyAborted: false, pausedCancelled: false, + cancelled: true, reason: 'recorded', } diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/state/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/state/route.test.ts index 278a6e7b622..3e168206e6c 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/state/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/state/route.test.ts @@ -82,6 +82,7 @@ const EMPTY_LINT = { invalidConnectionTargets: [], fieldIssues: [], unresolvedReferences: [], + tableFieldIssues: [], notes: [], } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 1982487dec7..011b7fa4005 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -25,7 +25,6 @@ import { reportManualRunToolStop, } from '@/lib/mothership/tools/client/run-tool-execution' import { canonicalWorkspaceFilePath } from '@/lib/mothership/vfs/path-utils' -import { prefersInPlaceNavigation } from '@/lib/desktop' import { type FileDownloadSource, triggerFileDownload } from '@/lib/uploads/client/download' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts index be473ae71d3..f6333ae46f8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts @@ -11,6 +11,7 @@ import type { } from '@/lib/table' import { TABLE_LIMITS } from '@/lib/table/constants' import { prunePredicateForColumns } from '@/lib/table/query-builder/converters' +import { resolveWorkflowGroupDeploymentMode } from '@/lib/table/workflow-groups/deployment-mode' import type { FlattenOutputsBlockInput } from '@/lib/workflows/blocks/flatten-outputs' import { getBlock } from '@/blocks' import { @@ -239,7 +240,7 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) // `useWorkflowStates` only fetches the live draft, so we can only judge // "block missing" for live-mode groups. A deployed-mode group runs a // different graph we don't load client-side — don't risk a false badge. - const isLiveMode = group.deploymentMode !== 'deployed' + const isLiveMode = resolveWorkflowGroupDeploymentMode(group) === 'live' for (const out of group.outputs) { const block = blocks?.[out.blockId] const blockConfig = block?.type ? getBlock(block.type) : undefined diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 2f3a338ff5b..8177e903861 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -24,6 +24,7 @@ import type { } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' import { withCellValueFilter } from '@/lib/table/query-builder/cell-filter' +import { resolveWorkflowGroupDeploymentMode } from '@/lib/table/workflow-groups/deployment-mode' import { type BreadcrumbItem, type ColumnOption, @@ -977,13 +978,14 @@ export function Table({ if (mutateArgs.groupIds.length === 0) return if (mutateArgs.rowIds && mutateArgs.rowIds.length === 0) return runColumnMutate(mutateArgs) - // Derive the run's deployment mode from the targeted groups (default 'live' when unset). + // Derive the run's deployment mode from the targeted groups (effective mode, so an + // unset value resolves to the shared default rather than a third state). // 'mixed' when the targeted groups don't all agree. const targetGroupIds = new Set(mutateArgs.groupIds) const modes = new Set( tableWorkflowGroups .filter((g) => targetGroupIds.has(g.id)) - .map((g) => g.deploymentMode ?? 'live') + .map((g) => resolveWorkflowGroupDeploymentMode(g)) ) const deploymentMode = modes.size === 1 ? [...modes][0] : 'mixed' captureEvent(posthogRef.current, 'table_workflow_run', { diff --git a/apps/sim/blocks/blocks/condition.ts b/apps/sim/blocks/blocks/condition.ts index 7952b26b6fe..f60134f44da 100644 --- a/apps/sim/blocks/blocks/condition.ts +++ b/apps/sim/blocks/blocks/condition.ts @@ -39,8 +39,17 @@ export const ConditionBlock: BlockConfig = { }, inputs: {}, outputs: { - conditionResult: { type: 'boolean', description: 'Condition result' }, + conditionResult: { + type: 'boolean', + description: + "Whether the selected branch's own test passed: true when an if/else-if condition matched, false when the else branch or no branch fired", + }, selectedPath: { type: 'json', description: 'Selected execution path' }, selectedOption: { type: 'string', description: 'Selected condition option ID' }, + selectedTitle: { + type: 'string', + description: + 'Title of the selected branch (e.g. "if", "else if", "else"); null when no branch fired', + }, }, } diff --git a/apps/sim/blocks/blocks/knowledge.test.ts b/apps/sim/blocks/blocks/knowledge.test.ts new file mode 100644 index 00000000000..3c666a37d05 --- /dev/null +++ b/apps/sim/blocks/blocks/knowledge.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { KnowledgeBlock } from '@/blocks/blocks/knowledge' +import { knowledgeSearchTool } from '@/tools/knowledge/search' + +/** + * A search response with billing attached, shaped like `POST /api/knowledge/search` + * answers it. `transformResponse` lifts `tokens` and `model` out of `cost` onto the + * output, which is why the block has to declare all three. + */ +function billedSearchResponse(): Response { + return new Response( + JSON.stringify({ + success: true, + data: { + results: [], + query: 'refund policy', + totalResults: 0, + cost: { + input: 0.00001, + output: 0, + total: 0.00001, + tokens: { prompt: 3, completion: 0, total: 3 }, + model: 'text-embedding-3-small', + pricing: { input: 0.02, output: 0 }, + }, + }, + }), + { headers: { 'Content-Type': 'application/json' } } + ) +} + +describe('Knowledge block outputs', () => { + it('declares every top-level key the search tool emits, so `blocks get knowledge` is complete', async () => { + const transformed = await knowledgeSearchTool.transformResponse!(billedSearchResponse(), {}) + + const emitted = Object.keys(transformed.output).sort() + expect(emitted).toEqual(['cost', 'model', 'query', 'results', 'tokens', 'totalResults']) + for (const key of emitted) { + expect(KnowledgeBlock.outputs[key], `outputs.${key} is undeclared`).toBeDefined() + } + }) + + it('describes the billing outputs', () => { + expect(KnowledgeBlock.outputs.cost).toMatchObject({ type: 'json' }) + expect(KnowledgeBlock.outputs.tokens).toMatchObject({ type: 'json' }) + expect(KnowledgeBlock.outputs.model).toMatchObject({ type: 'string' }) + for (const key of ['cost', 'tokens', 'model'] as const) { + const definition = KnowledgeBlock.outputs[key] as { description?: string } + expect(definition.description, `outputs.${key} needs a description`).toBeTruthy() + } + }) +}) diff --git a/apps/sim/blocks/blocks/knowledge.ts b/apps/sim/blocks/blocks/knowledge.ts index 6d1fea3ac36..cdc6767f973 100644 --- a/apps/sim/blocks/blocks/knowledge.ts +++ b/apps/sim/blocks/blocks/knowledge.ts @@ -560,5 +560,19 @@ export const KnowledgeBlock: BlockConfig = { results: { type: 'json', description: 'Search results' }, query: { type: 'string', description: 'Query used' }, totalResults: { type: 'number', description: 'Total results count' }, + cost: { + type: 'json', + description: + 'Search cost breakdown: input, output, and total USD for the query embedding, plus rerankerCost, rerankerModel, and rerankerSearchUnits when a reranker ran. Absent when the search was not billed (e.g. tag-only search or BYOK).', + }, + tokens: { + type: 'json', + description: + 'Token usage of the query embedding: prompt, completion, and total. Present with cost.', + }, + model: { + type: 'string', + description: 'Embedding model that scored the query. Present with cost.', + }, }, } diff --git a/apps/sim/executor/handlers/condition/condition-handler.test.ts b/apps/sim/executor/handlers/condition/condition-handler.test.ts index 7ef19b5c938..8f131d3bee8 100644 --- a/apps/sim/executor/handlers/condition/condition-handler.test.ts +++ b/apps/sim/executor/handlers/condition/condition-handler.test.ts @@ -174,6 +174,7 @@ describe('ConditionBlockHandler', () => { blockTitle: 'Target Block 1', }, selectedOption: 'cond1', + selectedTitle: 'if', } const result = await handler.execute(mockContext, mockBlock, inputs) @@ -342,13 +343,14 @@ describe('ConditionBlockHandler', () => { const expectedOutput = { value: 10, text: 'hello', - conditionResult: true, + conditionResult: false, selectedPath: { blockId: mockTargetBlock2.id, blockType: 'target', blockTitle: 'Target Block 2', }, selectedOption: 'else1', + selectedTitle: 'else', } const result = await handler.execute(mockContext, mockBlock, inputs) @@ -357,6 +359,77 @@ describe('ConditionBlockHandler', () => { expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1') }) + describe("conditionResult is the chosen branch's own verdict", () => { + it('reports false when the else branch fires, so a downstream is not inverted', async () => { + mockExecuteTool.mockResolvedValueOnce(noMatch()) + + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.value < 0' }, + { id: 'else1', title: 'Else', value: '' }, + ] + + const result = (await handler.execute(mockContext, mockBlock, { + conditions: JSON.stringify(conditions), + })) as Record + + expect(result.conditionResult).toBe(false) + expect(result.selectedOption).toBe('else1') + expect(result.selectedTitle).toBe('Else') + expect(result.selectedPath).toEqual({ + blockId: mockTargetBlock2.id, + blockType: 'target', + blockTitle: 'Target Block 2', + }) + }) + + it('reports true with the matched branch title for an else-if match', async () => { + mockExecuteTool.mockResolvedValueOnce(matchedAt(1)) + + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.value < 0' }, + { id: 'cond2', title: 'else if', value: 'context.value > 5' }, + { id: 'else1', title: 'else', value: '' }, + ] + mockContext.workflow!.connections = [ + { source: mockSourceBlock.id, target: mockBlock.id }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond2' }, + { source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-else1' }, + ] + + const result = (await handler.execute(mockContext, mockBlock, { + conditions: JSON.stringify(conditions), + })) as Record + + expect(result.conditionResult).toBe(true) + expect(result.selectedOption).toBe('cond2') + expect(result.selectedTitle).toBe('else if') + }) + + it('keeps the verdict and title when the chosen branch has no outgoing connection', async () => { + mockExecuteTool.mockResolvedValueOnce(noMatch()) + + const conditions = [ + { id: 'cond1', title: 'if', value: 'context.value < 0' }, + { id: 'else1', title: 'else', value: '' }, + ] + mockContext.workflow!.connections = [ + { source: mockSourceBlock.id, target: mockBlock.id }, + { source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' }, + ] + + const result = (await handler.execute(mockContext, mockBlock, { + conditions: JSON.stringify(conditions), + })) as Record + + expect(result).toMatchObject({ + conditionResult: false, + selectedPath: null, + selectedOption: 'else1', + selectedTitle: 'else', + }) + }) + }) + it('recognizes legacy-capitalized else branches without evaluating them', async () => { const conditions = [{ id: 'else1', title: 'Else', value: '' }] const inputs = { conditions: JSON.stringify(conditions) } @@ -690,6 +763,7 @@ describe('ConditionBlockHandler', () => { expect((result as any).conditionResult).toBe(false) expect((result as any).selectedPath).toBeNull() expect((result as any).selectedOption).toBeNull() + expect((result as any).selectedTitle).toBeNull() expect(mockContext.decisions.condition.has(mockBlock.id)).toBe(false) }) @@ -993,7 +1067,7 @@ describe('ConditionBlockHandler', () => { const result = await handler.execute(mockContext, mockBlock, inputs) - expect((result as any).conditionResult).toBe(true) + expect((result as any).conditionResult).toBe(false) expect((result as any).selectedOption).toBe('else1') expect((result as any).selectedPath).toEqual({ blockId: mockTargetBlock1.id, @@ -1022,7 +1096,7 @@ describe('ConditionBlockHandler', () => { const result = await handler.execute(mockContext, mockBlock, inputs) - expect((result as any).conditionResult).toBe(true) + expect((result as any).conditionResult).toBe(false) expect((result as any).selectedOption).toBe('else1') expect((result as any).selectedPath?.blockId).toBe(mockTargetBlock1.id) }) @@ -1216,7 +1290,7 @@ describe('ConditionBlockHandler', () => { const result = await handler.execute(mockContext, mockBlock, inputs) - expect((result as any).conditionResult).toBe(true) + expect((result as any).conditionResult).toBe(false) expect((result as any).selectedPath).toBeNull() expect((result as any).selectedOption).toBe('else1') expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1') @@ -1239,7 +1313,7 @@ describe('ConditionBlockHandler', () => { const result = await handler.execute(mockContext, mockBlock, inputs) expect((result as any).selectedOption).toBe('else1') - expect((result as any).conditionResult).toBe(true) + expect((result as any).conditionResult).toBe(false) }) }) @@ -1584,7 +1658,7 @@ describe('ConditionBlockHandler', () => { const result = await handler.execute(parallelContext, parallelConditionBlock, inputs) - expect((result as any).conditionResult).toBe(true) + expect((result as any).conditionResult).toBe(false) expect((result as any).selectedOption).toBe('else1') expect((result as any).selectedPath.blockId).toBe('target-false') }) diff --git a/apps/sim/executor/handlers/condition/condition-handler.ts b/apps/sim/executor/handlers/condition/condition-handler.ts index 1db5a685e0e..5086cdd81a6 100644 --- a/apps/sim/executor/handlers/condition/condition-handler.ts +++ b/apps/sim/executor/handlers/condition/condition-handler.ts @@ -342,13 +342,14 @@ export class ConditionBlockHandler implements BlockHandler { (conn) => conn.source === baseBlockId ) - const { selectedConnection, selectedCondition } = await this.evaluateConditions( - conditions, - outgoingConnections || [], - evalContext, - ctx, - block.id - ) + const { selectedConnection, selectedCondition, conditionResult } = + await this.evaluateConditions( + conditions, + outgoingConnections || [], + evalContext, + ctx, + block.id + ) if (!selectedCondition) { return { @@ -356,6 +357,7 @@ export class ConditionBlockHandler implements BlockHandler { conditionResult: false, selectedPath: null, selectedOption: null, + selectedTitle: null, } } @@ -364,9 +366,10 @@ export class ConditionBlockHandler implements BlockHandler { ctx.decisions.condition.set(decisionKey, selectedCondition.id) return { ...((sourceOutput as any) || {}), - conditionResult: true, + conditionResult, selectedPath: null, selectedOption: selectedCondition.id, + selectedTitle: selectedCondition.title, } } @@ -380,13 +383,14 @@ export class ConditionBlockHandler implements BlockHandler { return { ...((sourceOutput as any) || {}), - conditionResult: true, + conditionResult, selectedPath: { blockId: targetBlock.id, blockType: targetBlock.metadata?.id || DEFAULTS.BLOCK_TYPE, blockTitle: targetBlock.metadata?.name || DEFAULTS.BLOCK_TITLE, }, selectedOption: selectedCondition.id, + selectedTitle: selectedCondition.title, } } @@ -446,6 +450,13 @@ export class ConditionBlockHandler implements BlockHandler { ): Promise<{ selectedConnection: { target: string; sourceHandle?: string } | null selectedCondition: ConditionEntry | null + /** + * The chosen branch's own test. Only a matched expression is a truthy test: + * the else branch fires precisely because every test was false, so reporting + * it as `true` inverted a downstream `` on the else + * path — and persisted the inversion into the trace. + */ + conditionResult: boolean }> { const elseIndex = conditions.findIndex((condition) => isElseConditionTitle(condition.title)) const testable = elseIndex === -1 ? conditions : conditions.slice(0, elseIndex) @@ -455,13 +466,14 @@ export class ConditionBlockHandler implements BlockHandler { const selectedCondition = matched ?? elseCondition if (!selectedCondition) { - return { selectedConnection: null, selectedCondition: null } + return { selectedConnection: null, selectedCondition: null, conditionResult: false } } return { selectedConnection: this.findConnectionForCondition(outgoingConnections, selectedCondition.id) ?? null, selectedCondition, + conditionResult: matched !== null, } } diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 580c816d935..b272ee17cf2 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -222,6 +222,7 @@ export interface NormalizedBlockOutput { blockTitle?: string } selectedOption?: string + selectedTitle?: string | null conditionResult?: boolean result?: any stdout?: string diff --git a/apps/sim/lib/api/contracts/knowledge/search.ts b/apps/sim/lib/api/contracts/knowledge/search.ts index 70807dbe008..19bedc2ed0a 100644 --- a/apps/sim/lib/api/contracts/knowledge/search.ts +++ b/apps/sim/lib/api/contracts/knowledge/search.ts @@ -110,7 +110,17 @@ export const internalKnowledgeSearchResultSchema = z.object({ content: z.string(), chunkIndex: z.number(), metadata: z.record(z.string(), z.unknown()), - similarity: z.number(), + similarity: z + .number() + .describe( + 'Cosine similarity between the query embedding and the chunk (1 - cosine distance), in every search mode; 1 for tag-only matches. In hybrid mode this is not the ordering key — see rankScore.' + ), + rankScore: z + .number() + .describe( + 'The score results are ordered by, descending: the reciprocal-rank-fusion score in hybrid mode (a sum of 1/(60 + rank) per retrieval leg), the cosine similarity in vector mode, or rerankerScore when a reranker ordered the results.' + ), + rank: z.number().int().positive().describe('1-based position in the returned order.'), rerankerScore: z.number().optional(), }) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 91b0409c2b2..44d7850fb3a 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -1635,7 +1635,7 @@ export const addWorkflowGroupBodySchema = z.object({ .array(workflowGroupInputMappingSchema) .optional() .describe('Workflow inputs mapped from table columns.'), - /** Which workflow state per-cell runs execute against. Defaults to `'live'`. */ + /** Which workflow state per-cell runs execute against. Defaults to `'deployed'`. */ deploymentMode: workflowGroupDeploymentModeSchema .optional() .describe('Workflow state used for cell runs.'), diff --git a/apps/sim/lib/api/contracts/v2/__tests__/workflow-graph.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/workflow-graph.test.ts index 9b2114581f3..56ec019af6a 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/workflow-graph.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/workflow-graph.test.ts @@ -123,6 +123,7 @@ const EMPTY_LINT = { invalidConnectionTargets: [], fieldIssues: [], unresolvedReferences: [], + tableFieldIssues: [], notes: [], } @@ -185,6 +186,15 @@ const FULL_LINT = { reason: 'Not accessible', }, ], + tableFieldIssues: [ + { + blockId: 'block-3', + blockName: 'Query leads', + blockType: 'table_v2', + field: 'score', + tableName: 'Leads', + }, + ], notes: ['lint note'], } diff --git a/apps/sim/lib/api/contracts/v2/knowledge.ts b/apps/sim/lib/api/contracts/v2/knowledge.ts index 1c539902810..1d192b23e5b 100644 --- a/apps/sim/lib/api/contracts/v2/knowledge.ts +++ b/apps/sim/lib/api/contracts/v2/knowledge.ts @@ -382,8 +382,22 @@ export const v2KnowledgeSearchResultSchema = z .meta({ examples: [{ category: 'billing', priority: 2 }] }), similarity: z .number() - .describe('Similarity score for vector search; tag-only matches use 1.') + .describe( + 'Cosine similarity between the query embedding and the chunk (1 - cosine distance), reported the same way in `vector` and `hybrid` mode; tag-only matches use 1. In `hybrid` mode results are not ordered by this value — see `rankScore`.' + ) .meta({ examples: [0.8423] }), + rankScore: z + .number() + .describe( + 'The retrieval score, or reranker score when reranked. In `vector` mode it equals `similarity`; in `hybrid` mode it is the reciprocal-rank-fusion score (a sum of 1/(60 + rank) over the lexical and vector legs, so a chunk both legs ranked first scores 2/61); when a reranker ordered the results it is `rerankerScore`. Recency boosting may reorder retrieval results without changing this score; `rank` always reflects returned order.' + ) + .meta({ examples: [0.0328] }), + rank: z + .number() + .int() + .positive() + .describe('1-based position in the returned order.') + .meta({ examples: [1] }), rerankerScore: z .number() .optional() diff --git a/apps/sim/lib/api/contracts/v2/openapi/tables.ts b/apps/sim/lib/api/contracts/v2/openapi/tables.ts index ce58d1c628b..4543014815c 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/tables.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/tables.ts @@ -334,9 +334,12 @@ const declaredRoutes = [ applicationOperation: tableOperations.updateColumn, operationId: 'updateTableColumn', summary: 'Update Column', - description: 'Update a column by name and return the complete resulting table schema.', + description: + 'Update a column by name and return the complete resulting table schema.\n\nA rename follows the column everywhere the table keys by column id — rows, views, workflow-group references. Workflow Table blocks are the exception: their `filter`, `order`, and `data` are authored JSON that names columns by name, and this endpoint never rewrites workflow state. Blocks bound to this table that still name the old column come back in `unmigrated`; edit them (`POST /api/v2/workflows/{workflowId}/operations`) or their next run fails on the old name.', errors: TABLE_MUTATION_ERRORS, - success: { description: 'The updated table columns.' }, + success: { + description: 'The updated table columns, plus any unmigrated workflow Table blocks.', + }, }), { query: v2UpdateTableColumnContract.query, @@ -355,9 +358,34 @@ const declaredRoutes = [ ), response: documentedSchema( v2UpdateTableColumnContract.response.schema, - 'V2TableColumnsResponse', - 'Table columns response', - 'The table column list after a schema mutation.' + 'V2UpdateTableColumnResponse', + 'Update table column response', + 'The table column list after the update, plus workflow Table blocks a rename left on the old column name.', + [ + { + data: { + columns: [ + { id: 'col_name', name: 'name', type: 'string', required: true, unique: false }, + { + id: 'col_plan', + name: 'subscriptionPlan', + type: 'string', + required: false, + unique: false, + }, + ], + unmigrated: [ + { + workflowId: WORKFLOW_ID, + workflowName: 'Weekly digest', + blockId: 'blk_9c1d3f5a7e2b4068a0c2e4f6b8d0f193', + blockName: 'Query plans', + fields: ['filter'], + }, + ], + }, + }, + ] ), } ), diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index 0076a0d11e1..c171728aab4 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -115,6 +115,7 @@ const EMPTY_LINT_EXAMPLE = { invalidConnectionTargets: [], fieldIssues: [], unresolvedReferences: [], + tableFieldIssues: [], notes: [], } as const @@ -335,7 +336,7 @@ const declaredRoutes = [ applicationOperation: workflowOperations.replaceState, operationId: 'replaceWorkflowState', summary: 'Replace Workflow State', - description: `Replace the draft graph atomically; concurrent writes are last-write-wins. Recompute containers from blocks and preserve omitted variables. Foreign IDs return \`409\`; lint is advisory. The live deployment is unchanged. \`dryRun=true\` validates without saving, auditing, or notifying; \`needsRedeployment\` describes the pre-write state. ${WORKSPACE_API_KEY_DENIED}`, + description: `Replace a workflow\u2019s editable draft graph wholesale. \`loops\` and \`parallels\` are accepted but ignored — both are recomputed from \`blocks\`. Omitting \`variables\` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state. Ids are the one conflict that is detected: block, edge, and subflow ids are globally unique, so a body carrying an id another workflow already owns is refused with \`409\` rather than written.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that \`needsRedeployment\` becomes true; \`POST /workflows/{workflowId}/deploy\` publishes the draft.\n\n\`lint\` is advisory and never blocks the write. \`lint.fieldIssues\` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and \`lint.unresolvedReferences\` names credential, resource, tool, and skill values that do not resolve. ${WORKSPACE_API_KEY_DENIED}\n\nSet \`?dryRun=true\` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and \`lint\` findings the committed write would, with \`dryRun: true\` — including the warnings the write\u2019s own preparation step raises, and the same \`409\` when an id is already owned by another workflow. Two things differ: \`needsRedeployment\` describes the state before the write, and block ids are previews — \`mintedBlockIds\` is empty and the provisional ids come back under \`previewBlockIds\` with a warning, because the real apply mints new ones.`, errors: RESOURCE_MUTATION_ERRORS, success: jsonSuccess('The draft graph was replaced.'), }), @@ -417,6 +418,7 @@ const declaredRoutes = [ }, ], unresolvedReferences: [], + tableFieldIssues: [], notes: [], }, warnings: [], @@ -1326,7 +1328,7 @@ const declaredRoutes = [ operationId: 'cancelRunV2', summary: 'Cancel Workflow Run', description: - 'Request cancellation of a running, queued, or paused workflow run. Terminal runs return successfully without changes. A table workflow-group run returns `409` if its cell can no longer accept cancellation.', + 'Request cancellation of a running, queued, or paused workflow run. Cancelling a run already in a terminal state is a `200` no-op answered with `success: false` and an `already_*` reason. A run produced by a table workflow group is a `409` when its cell can no longer accept the cancellation.', errors: RESOURCE_CONFLICT_ERRORS, success: jsonSuccess('The cancellation outcome.'), }), diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index 53fe943a2ab..aa3575f0938 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -793,6 +793,45 @@ export const v2AddTableColumnContract = defineRouteContract({ }, }) +/** + * A workflow Table block a column rename could not migrate. Rows, views, and + * workflow-group references key on the column's stable id and follow a rename; + * a Table block's authored `filter`, `order`, and `data` name columns by name + * and live in workflow state the rename does not rewrite. + */ +export const v2UnmigratedTableBlockReferenceSchema = z + .object({ + workflowId: z.string().describe('Workflow holding the Table block.'), + workflowName: z.string().describe('Display name of that workflow.'), + blockId: z.string().describe('Table block whose configuration still names the old column.'), + blockName: z.string().describe('Display name of that block.'), + fields: z + .array(z.enum(['filter', 'order', 'data'])) + .describe('Sub-block fields that still reference the old column name.'), + }) + .meta({ + id: 'V2UnmigratedTableBlockReference', + title: 'Unmigrated table block reference', + description: 'A workflow Table block still configured against a renamed column.', + }) +export type V2UnmigratedTableBlockReference = z.output + +export const v2UpdateTableColumnDataSchema = v2TableColumnsDataSchema + .extend({ + unmigrated: z + .array(v2UnmigratedTableBlockReferenceSchema) + .describe( + 'Workflow Table blocks bound to this table whose `filter`, `order`, or `data` still name the column by its previous name. Only populated by a rename; empty otherwise. Workflow state is never rewritten by this endpoint — edit those blocks (`POST /api/v2/workflows/{workflowId}/operations`) or their next run fails on the old name.' + ), + }) + .meta({ + id: 'V2UpdateTableColumnData', + title: 'Update table column data', + description: + 'The table column list after the update, plus any workflow Table blocks a rename left pointing at the old column name.', + }) +export type V2UpdateTableColumnData = z.output + export const v2UpdateTableColumnContract = defineRouteContract({ method: 'PATCH', path: '/api/v2/tables/[tableId]/columns', @@ -801,7 +840,7 @@ export const v2UpdateTableColumnContract = defineRouteContract({ body: v2UpdateTableColumnBodySchema, response: { mode: 'json', - schema: v2DataResponse(v2TableColumnsDataSchema), + schema: v2DataResponse(v2UpdateTableColumnDataSchema), }, }) @@ -1581,7 +1620,11 @@ export const v2WorkflowGroupSchema = z ) .optional() .describe('Workflow inputs mapped from table columns.'), - deploymentMode: z.enum(['live', 'deployed']).optional().describe('Workflow execution mode.'), + deploymentMode: z + .enum(['live', 'deployed']) + .describe( + 'Which workflow state per-cell runs execute against. `deployed` (the default when a group was created without one) runs the latest active deployment and refuses to run while the workflow is undeployed; `live` runs the editable draft.' + ), /** When `false` the group never auto-fires; it runs only on an explicit request. */ autoRun: z.boolean().optional().describe('Whether the group automatically runs for new rows.'), }) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index ba364f52d18..1b5260e7906 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -209,27 +209,24 @@ export const v2WorkflowListItemSchema = z /** * A monotonic column on the workflow row, not an aggregate over the run * list. `updateWorkflowRunCounts` is called from exactly one place — - * `executeWorkflowCore`'s post-execution hook, under - * `result.success && result.status !== 'paused'` — and nothing ever - * decrements it, so the two ways it disagrees with - * `GET /workflows/{workflowId}/runs` point in opposite directions and both are - * reachable at once. The description is what makes that legible; the - * counter itself is left alone because its stored values already carry the - * narrow meaning and no backfill can recover runs whose logs retention has - * already deleted. + * `executeWorkflowCore`'s finalization, for every settled outcome — and + * nothing ever decrements it, so it can only exceed the size of + * `GET /workflows/{workflowId}/runs` as runs age out of log retention. + * Runs that settled before failures and cancellations were counted are not + * backfilled; their logs may already be gone. */ runCount: z .number() .int() .nonnegative() .describe( - 'Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs.' + 'Settled runs — completed, failed, or cancelled — counted as each one finishes; a paused run is counted once it settles. The counter is never reduced when a run ages out of log retention, so it can exceed the size of `GET /api/v2/workflows/{workflowId}/runs`.' ), lastRunAt: z .string() .nullable() .describe( - 'ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.' + 'ISO 8601 timestamp of the latest settled run, whatever its outcome, or null when the workflow has never run.' ) .meta({ format: 'date-time' }), createdAt: z @@ -1357,7 +1354,7 @@ export const v2ExecuteWorkflowBodySchema = z .max(100) .optional() .describe( - 'Block output references to include in the response. Use `.` for the executed workflow or `..` for a child workflow; block names are normalized workflow reference names, and selecting a child workflow applies to every invocation of it. On a sync request the named outputs come back in `blockOutputs`, keyed by these selector strings; on a stream they shape the streamed envelope. Selectors that resolve to no block or no value are omitted. Rejected when `async` is true — a queued run has produced nothing to select; narrow the finished run via the run resource instead.' + 'Block output references to include in the response. Use `.` for the executed workflow or `..` for a child workflow; block names are normalized workflow reference names, and selecting a child workflow applies to every invocation of it. On a sync request the named outputs come back in `blockOutputs`, keyed by these selector strings exactly as sent; on a stream they shape the streamed envelope. A selector whose block name or id matches no block in the workflow is rejected with `400` naming the available blocks, before the run starts. A selector whose block did not run or whose path is absent is omitted. Rejected when `async` is true — a queued run has produced nothing to select; narrow the finished run via the run resource instead.' ), includeThinking: z .boolean() @@ -1433,7 +1430,7 @@ export const v2ExecuteWorkflowDataSchema = z .record(z.string(), z.unknown().describe('Output value produced by one workflow block.')) .nullable() .describe( - 'Outputs of the blocks named by `selectedOutputs`, keyed by those selector strings, or null when none were requested. Selectors whose block did not run or whose path is absent are omitted; failed runs include the outputs of the blocks that did run.' + 'Outputs of the blocks named by `selectedOutputs`, keyed by those selector strings exactly as sent, or null when none were requested. `output` stays the final workflow output regardless. Selectors whose block did not run or whose path is absent are omitted (an unknown block is a `400` instead); failed runs include the outputs of the blocks that did run.' ), error: v2ExecutionErrorSchema .nullable() @@ -1927,7 +1924,11 @@ export const v2GetWorkflowRunContract = defineRouteContract({ export const v2CancelWorkflowRunDataSchema = z .object({ - success: z.boolean().describe('Whether cancellation was accepted.'), + success: z + .boolean() + .describe( + 'Whether this request cancelled anything. `false` with an `already_*` reason means the run had already reached that terminal state, so there was nothing to cancel — still `200`, because a no-op is not an error. `false` with any other reason identifies a degraded or incomplete cancellation step.' + ), runId: v2WorkflowRunIdSchema, redisAvailable: z .boolean() @@ -1949,7 +1950,7 @@ export const v2CancelWorkflowRunDataSchema = z id: 'CancelWorkflowRunResult', title: 'Cancel workflow run result', description: - 'Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state succeeds with no effect, reported as `durablyRecorded: false` with an `already_*` reason naming the state observed.', + 'Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state is a `200` no-op, reported as `success: false` and `durablyRecorded: false` with an `already_*` reason naming the state observed.', }) export type V2CancelWorkflowRunData = z.output @@ -2897,6 +2898,16 @@ const v2WorkflowLintSchema = z .describe( 'Credential, resource, tool, and skill references that do not resolve. These values are still persisted; they are reported, not dropped.' ), + tableFieldIssues: z + .array( + v2WorkflowLintBlockRefSchema.extend({ + field: z.string().describe('The filter or sort field that names no column.'), + tableName: z.string().describe('Display name of the table the block is bound to.'), + }) + ) + .describe( + "Table block `filter` and `order` fields checked against the bound table's live schema that name no column (nor the implicit `id`, `createdAt`, `updatedAt`). Such a run fails inside the block's error edge. A filter holding a `` reference, or one that is not JSON, is not checked." + ), notes: z.array(z.string()).describe('Advisory notes about the report itself.'), }) .meta({ @@ -3546,7 +3557,13 @@ export const v2ApplyWorkflowOperationsDataSchema = v2WorkflowGraphWriteResultSch mintedBlockIds: z .record(z.string(), z.string().describe('The id the block was actually given.')) .describe( - 'Minted block ids keyed by requested `block_id`, present only when they differ. References within this batch are remapped automatically; later requests must use the minted id. Supply a UUID when the requested id must survive unchanged.' + 'The id each newly created block was actually given, keyed by the `block_id` you asked for, and present only for the ones that differ. A `block_id` on an `add` or `insert_into_subflow` that is not already a UUID is replaced with a minted one, so this is how you learn what to reference afterwards. Within a single batch you can keep using your own ids — references between operations are remapped for you — but a later request must use the minted id, so send your own UUIDs when you want an id you chose to survive. Always empty on a dry run, which reports its provisional ids under `previewBlockIds` instead.' + ), + previewBlockIds: z + .record(z.string(), z.string().describe('The provisional id the dry run assigned.')) + .optional() + .describe( + 'Dry run only: the provisional id the evaluation assigned to each block whose `block_id` was not already a UUID, keyed by the `block_id` you asked for. These are not the ids a committed apply produces — the real apply mints new ones — so never wire a later request against them. Wire edges by `block_id` within one batch, or by the `mintedBlockIds` the real apply returns.' ), lint: v2WorkflowLintSchema, dryRun: z diff --git a/apps/sim/lib/knowledge/application/search.test.ts b/apps/sim/lib/knowledge/application/search.test.ts index 715254c332f..1beb7ab8212 100644 --- a/apps/sim/lib/knowledge/application/search.test.ts +++ b/apps/sim/lib/knowledge/application/search.test.ts @@ -828,6 +828,102 @@ describe('knowledge search application use case', () => { }) }) + describe('rankScore and rank name the order results came back in', () => { + const row = (overrides: Record) => ({ + id: 'embedding-1', + documentId: 'document-1', + knowledgeBaseId: 'knowledge-1', + content: 'answer', + chunkIndex: 0, + distance: 0.2, + tag1: null, + tag2: null, + tag3: null, + tag4: null, + tag5: null, + tag6: null, + tag7: null, + number1: null, + number2: null, + number3: null, + number4: null, + number5: null, + date1: null, + date2: null, + boolean1: null, + boolean2: null, + boolean3: null, + ...overrides, + }) + const search = (input: Record = {}) => + searchKnowledge.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + query: 'answer', + topK: 5, + ...input, + }, + }) + + it('passes the hybrid fused score through as rankScore while similarity stays the cosine value', async () => { + mocks.executeSearch.mockResolvedValue([ + row({ id: 'embedding-1', distance: 0.2, rankScore: 1 / 61 + 1 / 62, rank: 1 }), + row({ + id: 'embedding-2', + documentId: 'document-1', + distance: 0.05, + rankScore: 1 / 62, + rank: 2, + }), + ]) + + const result = await search({ searchMode: 'hybrid' }) + + expect(result.results.map((item) => item.rank)).toEqual([1, 2]) + expect(result.results[0]).toMatchObject({ similarity: 0.8, rankScore: 1 / 61 + 1 / 62 }) + expect(result.results[1]).toMatchObject({ similarity: 0.95, rankScore: 1 / 62 }) + }) + + it('assigns contiguous ranks after unreadable documents are removed', async () => { + mocks.executeSearch.mockResolvedValue([ + row({ id: 'hidden', documentId: 'unreadable', rankScore: 0.9, rank: 1 }), + row({ id: 'visible', rankScore: 0.8, rank: 2 }), + ]) + + const result = await search() + + expect(result.results).toHaveLength(1) + expect(result.results[0]).toMatchObject({ embeddingId: 'visible', rankScore: 0.8, rank: 1 }) + }) + + it('reports the cosine similarity as rankScore in vector mode', async () => { + mocks.executeSearch.mockResolvedValue([row({ rankScore: 0.8, rank: 1 })]) + + const result = await search({ searchMode: 'vector' }) + + expect(result.results[0]).toMatchObject({ similarity: 0.8, rankScore: 0.8, rank: 1 }) + }) + + it('reports the reranker score as rankScore once a reranker has ordered the results', async () => { + mocks.executeSearch.mockResolvedValue([row({ rankScore: 0.8, rank: 1 })]) + mocks.rerank.mockResolvedValueOnce({ + results: [{ item: { id: 'embedding-1' }, relevanceScore: 0.93 }], + isBYOK: false, + }) + + const result = await search({ rerankerEnabled: true, rerankerModel: 'rerank-v4.0-pro' }) + + expect(result.results[0]).toMatchObject({ + similarity: 0.8, + rankScore: 0.93, + rerankerScore: 0.93, + rank: 1, + }) + }) + }) + describe('reranker outcome reporting', () => { const rerankedSearch = (rerankerEnabled?: boolean, query: string | undefined = 'answer') => searchKnowledge.execute({ diff --git a/apps/sim/lib/knowledge/application/search.ts b/apps/sim/lib/knowledge/application/search.ts index 9f050870807..706071b894b 100644 --- a/apps/sim/lib/knowledge/application/search.ts +++ b/apps/sim/lib/knowledge/application/search.ts @@ -135,7 +135,16 @@ export interface KnowledgeSearchItem { content: string chunkIndex: number metadata: Record + /** Cosine similarity to the query in every mode (1 for tag-only matches); not the ordering key in hybrid mode. */ similarity: number + /** + * Reranker score when reranked; otherwise the retrieval score (reciprocal-rank + * fusion in hybrid mode, cosine similarity in vector mode). Recency boosting + * may reorder retrieval results; `rank` always reflects the returned order. + */ + rankScore: number + /** 1-based position in the returned order. */ + rank: number rerankerScore?: number } @@ -664,7 +673,7 @@ export async function runKnowledgeSearch({ * card's modified time and connector type ride on the hydrated row, read under the same * predicate as the content. */ - const results = rows.map((row): KnowledgeSearchItem => { + const results = rows.map((row, index): KnowledgeSearchItem => { const metadata: Record = {} const tagMap = tagMaps.get(row.knowledgeBaseId) const provenanceDocument = provenanceSnapshot?.documentMetadata[row.documentId] @@ -677,6 +686,7 @@ export async function runKnowledgeSearch({ if (value !== null && value !== undefined) metadata[tagMap?.get(slot) ?? slot] = value } const rerankerScore = rerankerScores.get(row.id) + const similarity = hasQuery ? 1 - row.distance : 1 return { embeddingId: row.id, knowledgeBaseId: row.knowledgeBaseId, @@ -688,7 +698,9 @@ export async function runKnowledgeSearch({ content: row.content, chunkIndex: row.chunkIndex, metadata, - similarity: hasQuery ? 1 - row.distance : 1, + similarity, + rankScore: rerankerScore ?? row.rankScore ?? similarity, + rank: index + 1, ...(rerankerScore !== undefined ? { rerankerScore } : {}), } }) diff --git a/apps/sim/lib/knowledge/search/queries.test.ts b/apps/sim/lib/knowledge/search/queries.test.ts index 219f323d72e..cc8e48c136f 100644 --- a/apps/sim/lib/knowledge/search/queries.test.ts +++ b/apps/sim/lib/knowledge/search/queries.test.ts @@ -50,8 +50,11 @@ import { VECTOR_PROBE_DOCUMENT_LIMIT, vectorCandidatePoolLimit, visibleDocumentsQuery, + fuseByReciprocalRank, + type SearchResult, } from '@/lib/knowledge/search/queries' import { forgetIndexedVectorSources } from '@/lib/knowledge/search/source-vector-indexes' +import { RRF_K } from '@/lib/knowledge/search/recency' import type { StructuredFilter } from '@/lib/knowledge/types' /** @@ -2895,3 +2898,53 @@ describe('filters on a resolved scope', () => { expect(JSON.stringify(tin[0])).toContain('"type":"gte"') }) }) + +/** A retrieval row with only the fields fusion reads; the tag slots are irrelevant here. */ +function searchRow(id: string, distance: number): SearchResult { + return { + id, + content: id, + documentId: `doc-${id}`, + chunkIndex: 0, + tag1: null, + tag2: null, + tag3: null, + tag4: null, + tag5: null, + tag6: null, + tag7: null, + number1: null, + number2: null, + number3: null, + number4: null, + number5: null, + date1: null, + date2: null, + boolean1: null, + boolean2: null, + boolean3: null, + distance, + knowledgeBaseId: 'kb-1', + sourceModifiedAt: null, + } +} + +describe('fuseByReciprocalRank exposes the ordering key', () => { + it('stamps each row with the fused score it is ordered by and a 1-based rank, leaving similarity alone', () => { + const lexical = [searchRow('a', 0.5), searchRow('b', 0.2)] + const vector = [searchRow('b', 0.2), searchRow('c', 0.1)] + + const fused = fuseByReciprocalRank([lexical, vector], 3) + + expect(fused.map((row) => row.id)).toEqual(['b', 'a', 'c']) + expect(fused.map((row) => row.rank)).toEqual([1, 2, 3]) + expect(fused[0].rankScore).toBeCloseTo(1 / (RRF_K + 2) + 1 / (RRF_K + 1), 12) + expect(fused[1].rankScore).toBeCloseTo(1 / (RRF_K + 1), 12) + expect(fused[2].rankScore).toBeCloseTo(1 / (RRF_K + 2), 12) + /** The order follows rankScore, which the cosine distance alone would not explain: c is the nearest chunk yet ranks last. */ + expect(fused.map((row) => row.rankScore)).toEqual( + [...fused.map((row) => row.rankScore)].sort((x, y) => (y ?? 0) - (x ?? 0)) + ) + expect(fused.map((row) => row.distance)).toEqual([0.2, 0.5, 0.1]) + }) +}) diff --git a/apps/sim/lib/knowledge/search/queries.ts b/apps/sim/lib/knowledge/search/queries.ts index f2ff5bf066c..0dfca4eb747 100644 --- a/apps/sim/lib/knowledge/search/queries.ts +++ b/apps/sim/lib/knowledge/search/queries.ts @@ -310,6 +310,16 @@ export interface SearchResult { boolean1: boolean | null boolean2: boolean | null boolean3: boolean | null + /** + * The score this row's position in the returned list comes from: the + * reciprocal-rank-fusion score in hybrid mode, the cosine similarity + * (`1 - distance`) in vector mode, and 1 for a tag-only search. Stamped by + * retrieval on every row it returns; absent on rows straight from a single + * retrieval leg. Recency may reorder rows without changing this score. + */ + rankScore?: number + /** 1-based position in the returned order, stamped alongside `rankScore`. */ + rank?: number distance: number knowledgeBaseId: string /** When the source last changed the document; NULL for uploads and sources that do not say. */ @@ -587,6 +597,18 @@ const NARROW_KEYWORD_PAGE = 1000 */ const NARROW_KEYWORD_WINDOWS = [TIN_KEYWORD_WINDOWS[0], 20_000] as const +/** + * Stamps each row with the score its position came from and its 1-based rank. + * + * Hybrid results are ordered by a fused score the caller never saw, while the + * `similarity` reported beside them is the vector leg's cosine value — so the + * two modes answered with byte-identical `similarity` for orderings that could + * differ. Exposing the ordering key makes the order explainable in either mode. + */ +function rankResults(rows: SearchResult[], scoreOf: (row: SearchResult) => number): SearchResult[] { + return rows.map((row, index) => ({ ...row, rankScore: scoreOf(row), rank: index + 1 })) +} + /** * Row visibility predicates shared by every search leg: a chunk is only * retrievable when both it and its document are enabled, the document finished @@ -2522,7 +2544,7 @@ export function fuseByReciprocalRank(rankedLists: SearchResult[][], topK: number groupStart = groupEnd } - return fused + return rankResults(fused, (row) => scores.get(row.id) ?? 0) } export async function handleTagAndVectorSearch(params: SearchParams): Promise { @@ -2614,7 +2636,10 @@ export async function retrieveKnowledgeSearch( .filter((budget) => budget.timedOut) .map((budget) => budget.leg) return { - rows: boostRecency ? applyRecencyBoost(rows) : rows, + rows: rankResults( + boostRecency ? applyRecencyBoost(rows) : rows, + (row) => row.rankScore ?? (hasQuery ? 1 - row.distance : 1) + ), retrieval: { status: timedOutLegs.length ? 'partial' : 'complete', timedOutLegs }, } } diff --git a/apps/sim/lib/mothership/agent-cli/engines.test.ts b/apps/sim/lib/mothership/agent-cli/engines.test.ts index f3afce652aa..71a8a5db15d 100644 --- a/apps/sim/lib/mothership/agent-cli/engines.test.ts +++ b/apps/sim/lib/mothership/agent-cli/engines.test.ts @@ -20,6 +20,7 @@ const { buildWorkflowLintReport } = vi.hoisted(() => ({ }, ], unresolvedReferences: [], + tableFieldIssues: [], }), })) @@ -84,6 +85,7 @@ describe('workflows lint', () => { invalidConnectionTargets: [], fieldIssues: [], unresolvedReferences: [], + tableFieldIssues: [], }) const result = await runEngine( 'workflows lint', diff --git a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts index ba544341757..77809b1abb8 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts @@ -76,6 +76,19 @@ describe('universal grep', () => { expect(result.stderr).toContain('e.g. blocks/table_v2') }) + it.each(['knowledge', 'kb', 'KB', 'knowledge/kb-123'])( + 'redirects --in %s to semantic knowledge search before materializing any world', + async (selector) => { + /** An empty runtime throws on any request, so a clean refusal proves nothing was fetched. */ + const result = await runEngine('grep', ['invoice'], runtimeWith({}), { in: selector }) + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain(`Unknown --in selector ${JSON.stringify(selector)}`) + expect(result.stderr).toContain( + 'Knowledge bases are searched semantically — use knowledge search --kb --query "…"; grep covers workflows, blocks, tools, tables, files, integrations, skills, custom-tools, secrets, credentials.' + ) + } + ) + it('refuses an --in selector no resource in the searched worlds answers to', async () => { const bare = await runEngine('grep', ['id'], runtimeWith(CATALOG), { scope: 'blocks', diff --git a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts index ea060cec06f..f168b612885 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts @@ -243,6 +243,17 @@ function unknownWithin(selector: string): string { return `Unknown --in selector ${JSON.stringify(selector)}. Pass a world (workflows, blocks, …), a resource's bare id or name, or world/resource as a match line prints it (e.g. blocks/table_v2).` } +/** + * Heads a model reaches for when it wants to grep a knowledge base. Knowledge is not a + * grep world — chunks are retrieved semantically — so the refusal has to redirect rather + * than just list the worlds, or the next attempt is the same selector spelled differently. + */ +const KNOWLEDGE_SELECTOR_HEADS = new Set(['knowledge', 'kb']) + +function knowledgeWithin(selector: string): string { + return `${unknownWithin(selector)} Knowledge bases are searched semantically — use knowledge search --kb --query "…"; grep covers ${SCOPES.join(', ')}.` +} + function parseScopes(flags: AgentCliFlags): Scope[] | string { const raw = flags.scope if (raw === undefined || raw === true) return [...SCOPES] @@ -296,6 +307,9 @@ export const universalGrepCommand: AgentCliEngine = { // value is a resource id or name inside the searched worlds. const within = typeof flags.in === 'string' ? flags.in : undefined const [withinHead, ...withinRest] = within ? within.toLowerCase().split('/') : [] + if (within && withinHead && KNOWLEDGE_SELECTOR_HEADS.has(withinHead)) { + return agentCliFail(knowledgeWithin(within)) + } const withinScope = SCOPES.find((scope) => scope === withinHead) const withinResource = withinScope ? withinRest.join('/') : within?.toLowerCase() const searched: Scope[] = withinScope ? [withinScope] : scopes diff --git a/apps/sim/lib/mothership/vfs/resource-writer.test.ts b/apps/sim/lib/mothership/vfs/resource-writer.test.ts index c460fd07f2b..95bba495c02 100644 --- a/apps/sim/lib/mothership/vfs/resource-writer.test.ts +++ b/apps/sim/lib/mothership/vfs/resource-writer.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => { admitCreateWorkspaceFile: vi.fn(), ensureWorkspaceFileFolderPath: vi.fn(), findWorkspaceFileFolderIdByPath: vi.fn(), + listWorkspaceFileFolders: vi.fn(), normalizeWorkspaceFileItemName: vi.fn((name: string) => name.trim()), getWorkspaceFileByName: vi.fn(), resolveWorkspaceFileReference: vi.fn(), @@ -27,6 +28,7 @@ vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath: mocks.findWorkspaceFileFolderIdByPath, + listWorkspaceFileFolders: mocks.listWorkspaceFileFolders, normalizeWorkspaceFileItemName: mocks.normalizeWorkspaceFileItemName, })) @@ -58,6 +60,8 @@ describe('resource writer', () => { createdFolderIds: [], }) mocks.admitCreateWorkspaceFile.mockResolvedValue(undefined) + mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) + mocks.listWorkspaceFileFolders.mockResolvedValue([]) }) it('refuses to write into a workspace the acting user is not a member of', async () => { @@ -151,6 +155,94 @@ describe('resource writer', () => { }) }) + describe('a folder that moved is not recreated at its old path', () => { + const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + const movedFolder = { id: 'folder-xp', name: 'xp-files', path: 'fx-archive/xp-files' } + const staleTarget = { path: 'files/xp-files/xp-tiers.png', mode: 'create' as const } + + it('refuses the write and names the current location', async () => { + mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) + mocks.listWorkspaceFileFolders.mockResolvedValue([movedFolder]) + + await expect( + writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + principal, + target: staleTarget, + buffer: Buffer.from('png'), + inferredMimeType: 'image/png', + }) + ).rejects.toThrow( + 'Folder "xp-files" now lives at /fx-archive/xp-files; write to files/fx-archive/xp-files/xp-tiers.png or create the folder explicitly with files mkdir.' + ) + + expect(mocks.createWorkspaceFileBufferByPath.execute).not.toHaveBeenCalled() + expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + }) + + it('refuses the same target during read-only validation', async () => { + mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) + mocks.listWorkspaceFileFolders.mockResolvedValue([movedFolder]) + + await expect( + validateWorkspaceFileWriteTarget({ + workspaceId: 'workspace-1', + principal, + target: staleTarget, + }) + ).rejects.toThrow('Folder "xp-files" now lives at /fx-archive/xp-files') + }) + + it('still creates a genuinely new folder when no namesake exists elsewhere', async () => { + mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) + mocks.listWorkspaceFileFolders.mockResolvedValue([ + { id: 'folder-other', name: 'reports', path: 'reports' }, + ]) + mocks.createWorkspaceFileBufferByPath.execute.mockResolvedValue({ + id: 'file-xp', + name: 'xp-tiers.png', + size: 3, + contentType: 'image/png', + vfsPath: 'files/xp-files/xp-tiers.png', + mode: 'create', + }) + + const result = await writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + principal, + target: staleTarget, + buffer: Buffer.from('png'), + inferredMimeType: 'image/png', + }) + + expect(result).toMatchObject({ id: 'file-xp', vfsPath: 'files/xp-files/xp-tiers.png' }) + expect(mocks.createWorkspaceFileBufferByPath.execute).toHaveBeenCalledTimes(1) + }) + + it('does not consult other folders when the target path resolves', async () => { + mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue('folder-xp') + mocks.createWorkspaceFileBufferByPath.execute.mockResolvedValue({ + id: 'file-xp', + name: 'xp-tiers.png', + size: 3, + contentType: 'image/png', + vfsPath: 'files/fx-archive/xp-files/xp-tiers.png', + mode: 'create', + }) + + await writeWorkspaceFileByPath({ + workspaceId: 'workspace-1', + principal, + target: { path: 'files/fx-archive/xp-files/xp-tiers.png', mode: 'create' }, + buffer: Buffer.from('png'), + inferredMimeType: 'image/png', + }) + + expect(mocks.listWorkspaceFileFolders).not.toHaveBeenCalled() + expect(mocks.createWorkspaceFileBufferByPath.execute).toHaveBeenCalledTimes(1) + }) + }) + it('validates create targets read-only, resolving existing parent folders without creating', async () => { mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue('folder-nested') mocks.getWorkspaceFileByName.mockResolvedValue(null) diff --git a/apps/sim/lib/mothership/vfs/resource-writer.ts b/apps/sim/lib/mothership/vfs/resource-writer.ts index e8681692360..dac2374adc1 100644 --- a/apps/sim/lib/mothership/vfs/resource-writer.ts +++ b/apps/sim/lib/mothership/vfs/resource-writer.ts @@ -5,7 +5,10 @@ import { resolveCopilotFilePrincipal, } from '@/lib/mothership/auth/file-delegation' import { canonicalWorkspaceFilePath } from '@/lib/mothership/vfs/path-utils' -import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { + findWorkspaceFileFolderIdByPath, + listWorkspaceFileFolders, +} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFileByName, type WorkspaceFileRecord, @@ -58,6 +61,56 @@ export type WorkspaceFileWriteValidation = existingFileId: string } +/** + * Refuses to recreate a folder that has moved. + * + * A create into a folder path that no longer exists is normally fine — missing + * parents are created at write time — but when a folder of the same name lives + * elsewhere the path is almost always stale rather than new: a tool remembering + * `files/xp-files/…` after the user moved `xp-files` under `fx-archive`. + * Creating a fresh top-level `xp-files` then splits the user's files across two + * folders with one name. The error names where the folder went so the caller + * can write there, and leaves creating a genuine namesake to an explicit mkdir. + * + * Only the leaf folder is checked: it is the folder the caller targeted, and + * an ancestor with a namesake elsewhere is what a deliberate new tree looks like. + */ +async function assertTargetFolderNotMoved( + workspaceId: string, + parsed: { folderSegments: string[]; fileName: string } +): Promise { + const leafName = parsed.folderSegments.at(-1) + if (!leafName) return + const requestedFolderPath = parsed.folderSegments.join('/') + const relocated = (await listWorkspaceFileFolders(workspaceId)).filter( + (folder) => folder.name === leafName && folder.path !== requestedFolderPath + ) + if (relocated.length === 0) return + const locations = relocated.map((folder) => `/${folder.path}`).join(' or ') + const writeTarget = canonicalWorkspaceFilePath({ + folderPath: relocated[0].path, + name: parsed.fileName, + }) + throw new Error( + `Folder "${leafName}" now lives at ${locations}; write to ${writeTarget} or create the folder explicitly with files mkdir.` + ) +} + +/** + * Finds the create target's parent folder, or null when the chain does not exist + * yet and may be created at write time — never for a folder that merely moved. + */ +async function resolveCreateFolderId( + workspaceId: string, + parsed: { folderSegments: string[]; fileName: string } +): Promise { + if (parsed.folderSegments.length === 0) return null + const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments) + if (folderId) return folderId + await assertTargetFolderNotMoved(workspaceId, parsed) + return null +} + /** Resolves a create-mode target without mutating missing parent folders. */ async function resolveCreateTarget( workspaceId: string, @@ -66,7 +119,7 @@ async function resolveCreateTarget( const parsed = parseWorkspaceFileCreatePath(path) let folderId: string | null = null if (parsed.folderSegments.length > 0) { - folderId = await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments) + folderId = await resolveCreateFolderId(workspaceId, parsed) if (!folderId) { return { fileName: parsed.fileName, @@ -196,6 +249,7 @@ export async function writeWorkspaceFileByPath(args: { } await assertWorkspaceFileWriteAccess(args) + await resolveCreateFolderId(args.workspaceId, parseWorkspaceFileCreatePath(args.target.path)) const created = await createWorkspaceFileBufferByPath.execute({ principal: args.principal, diff --git a/apps/sim/lib/table/application/columns.test.ts b/apps/sim/lib/table/application/columns.test.ts index dfa9f41ccc5..9d5ac821afb 100644 --- a/apps/sim/lib/table/application/columns.test.ts +++ b/apps/sim/lib/table/application/columns.test.ts @@ -8,6 +8,8 @@ import type { TableDefinition } from '@/lib/table/types' const mocks = vi.hoisted(() => ({ audit: vi.fn(), deleteColumns: vi.fn(), + findUnmigrated: vi.fn(), + performUpdate: vi.fn(), resolveContext: vi.fn(), resolvePermission: vi.fn(), signal: vi.fn(), @@ -38,10 +40,16 @@ vi.mock('@/lib/table', () => ({ vi.mock('@/lib/table/application/context', () => ({ resolveActiveTableContext: mocks.resolveContext, })) +vi.mock('@/lib/table/columns/workflow-references', () => ({ + findUnmigratedTableBlockReferences: mocks.findUnmigrated, +})) vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mocks.signal })) -vi.mock('@/lib/table/orchestration', () => ({ performUpdateTableColumn: vi.fn() })) +vi.mock('@/lib/table/orchestration', () => ({ performUpdateTableColumn: mocks.performUpdate })) -import { deleteTableColumnsUseCase } from '@/lib/table/application/columns' +import { + deleteTableColumnsUseCase, + updateTableColumnUseCase, +} from '@/lib/table/application/columns' const table: TableDefinition = { id: 'table-1', @@ -187,3 +195,80 @@ describe('multi-column delete application use case', () => { expect(mocks.deleteColumns).not.toHaveBeenCalled() }) }) + +/** + * A rename leaves workflow Table blocks pointing at the old name — nothing + * rewrites workflow state, lint stays clean, and the next run fails inside an + * error edge. The use case reports those blocks so the caller can migrate them. + */ +describe('column rename application use case', () => { + const unmigrated = [ + { + workflowId: 'wf-1', + workflowName: 'Alerts', + blockId: 'blk-1', + blockName: 'Query', + fields: ['filter' as const], + }, + ] + + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveContext.mockResolvedValue({ + tableId: table.id, + table, + workspaceId: table.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + }) + mocks.findUnmigrated.mockResolvedValue(unmigrated) + }) + + function update(updates: { name?: string; required?: boolean }) { + mocks.performUpdate.mockResolvedValue({ + success: true, + table: { + ...table, + schema: { + columns: table.schema.columns.map((column) => + column.name === 'first' ? { ...column, ...updates } : column + ), + }, + }, + }) + return updateTableColumnUseCase.execute({ + principal, + input: { tableId: 'table-1', workspaceId: 'workspace-1', columnName: 'first', updates }, + }) + } + + it('reports the workflow Table blocks a rename did not migrate', async () => { + const result = await update({ name: 'given' }) + + expect(mocks.findUnmigrated).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + tableId: 'table-1', + columnName: 'first', + }) + expect(result.unmigrated).toEqual(unmigrated) + expect(result.changed).toBe(true) + }) + + it('does not scan when the update is not a rename', async () => { + const result = await update({ required: true }) + + expect(mocks.findUnmigrated).not.toHaveBeenCalled() + expect(result.unmigrated).toEqual([]) + }) + + it('reports nothing rather than failing a rename that already committed when the scan fails', async () => { + mocks.findUnmigrated.mockRejectedValue(new Error('workflow tables unavailable')) + + const result = await update({ name: 'given' }) + + expect(result.unmigrated).toEqual([]) + expect(result.table.schema.columns[1].name).toBe('given') + }) +}) diff --git a/apps/sim/lib/table/application/columns.ts b/apps/sim/lib/table/application/columns.ts index 4a56b2c1b4a..4752ac1c613 100644 --- a/apps/sim/lib/table/application/columns.ts +++ b/apps/sim/lib/table/application/columns.ts @@ -1,5 +1,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { @@ -17,9 +19,16 @@ import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized import { resolveActiveTableContext } from '@/lib/table/application/context' import { throwTableOperationFailure } from '@/lib/table/application/errors' import { tableOperations } from '@/lib/table/application/operations' +import { columnMatchesRef } from '@/lib/table/column-keys' +import { + findUnmigratedTableBlockReferences, + type UnmigratedTableBlockReference, +} from '@/lib/table/columns/workflow-references' import { signalTableSchemaChanged } from '@/lib/table/events' import { performUpdateTableColumn } from '@/lib/table/orchestration' +const logger = createLogger('TableColumnApplication') + interface TableColumnInput { tableId: string workspaceId: string @@ -108,6 +117,7 @@ export const updateTableColumnUseCase = defineAuthorizedTableUseCase({ changed: JSON.stringify(context.table.schema) !== JSON.stringify(outcome.table.schema) || JSON.stringify(context.table.metadata) !== JSON.stringify(outcome.table.metadata), + unmigrated: await findUnmigratedReferencesAfterRename(context, input), } }, projectAudit({ input, context, result }) { @@ -126,6 +136,39 @@ export const updateTableColumnUseCase = defineAuthorizedTableUseCase({ }, }) +/** + * Workflow Table blocks the rename left behind. A rename migrates everything + * keyed by column id — rows, views, workflow-group refs — but a Table block's + * `filter`/`order`/`data` name columns by name and live in workflow state this + * operation does not own, so they are reported for the caller to migrate. The + * rename has already committed by the time this runs; a failed scan is logged + * and reported as empty rather than failing a mutation that succeeded. + */ +async function findUnmigratedReferencesAfterRename( + context: { table: TableDefinition; workspaceId: string }, + input: UpdateTableColumnInput +): Promise { + const previous = context.table.schema.columns.find((column) => + columnMatchesRef(column, input.columnName) + ) + const newName = input.updates.name + if (!previous || newName === undefined || newName === previous.name) return [] + try { + return await findUnmigratedTableBlockReferences({ + workspaceId: context.workspaceId, + tableId: context.table.id, + columnName: previous.name, + }) + } catch (error) { + logger.warn('Could not scan workflows for references to a renamed column', { + tableId: context.table.id, + columnName: previous.name, + error: getErrorMessage(error), + }) + return [] + } +} + export interface DeleteTableColumnInput extends TableColumnInput { columnName: string } diff --git a/apps/sim/lib/table/application/groups.test.ts b/apps/sim/lib/table/application/groups.test.ts index dae4053d892..5f4b1c7f9a0 100644 --- a/apps/sim/lib/table/application/groups.test.ts +++ b/apps/sim/lib/table/application/groups.test.ts @@ -85,6 +85,7 @@ import { updateTableGroupUseCase, updateWorkflowTableGroup, } from '@/lib/table/application/groups' +import { resolveWorkflowGroupDeploymentMode } from '@/lib/table/workflow-groups/deployment-mode' const group: WorkflowGroup = { id: 'group-1', @@ -410,7 +411,12 @@ describe('workflow and enrichment Table application commands', () => { 'request-1' ) expect(result.group.workflowId).toBe('') - expect(v2WorkflowGroupSchema.safeParse(result.group).success).toBe(true) + expect( + v2WorkflowGroupSchema.safeParse({ + ...result.group, + deploymentMode: resolveWorkflowGroupDeploymentMode(result.group), + }).success + ).toBe(true) }) /** diff --git a/apps/sim/lib/table/columns/workflow-references.test.ts b/apps/sim/lib/table/columns/workflow-references.test.ts new file mode 100644 index 00000000000..0faf397c979 --- /dev/null +++ b/apps/sim/lib/table/columns/workflow-references.test.ts @@ -0,0 +1,155 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + collectTableBlockColumnReferences, + findUnmigratedTableBlockReferences, + isTableBlockBoundTo, +} from '@/lib/table/columns/workflow-references' + +function subBlocks(values: Record) { + return Object.fromEntries( + Object.entries(values).map(([id, value]) => [id, { id, type: 'short-input', value }]) + ) +} + +/** + * A rename migrates everything keyed by column id; a Table block's authored + * `filter`/`order`/`data` name columns by name and live in workflow state the + * rename never touches. These are what the rename response must point at. + */ +describe('collectTableBlockColumnReferences', () => { + it('finds the old name in a bare filter condition, a sort spec, and a row payload', () => { + const fields = collectTableBlockColumnReferences( + subBlocks({ + filter: '{"field":"wins","op":"gte","value":10}', + order: '[{"field":"wins","direction":"desc"}]', + data: '{"wins": 3, "name": ""}', + }), + 'wins' + ) + + expect(fields).toEqual(['filter', 'order', 'data']) + }) + + it('walks nested all/any groups and a record-shaped sort, matching case-insensitively', () => { + const fields = collectTableBlockColumnReferences( + subBlocks({ + filter: + '{"any":[{"field":"status","op":"eq","value":"open"},{"all":[{"field":"Wins","op":"gt","value":1}]}]}', + order: '{"WINS":"asc"}', + }), + 'wins' + ) + + expect(fields).toEqual(['filter', 'order']) + }) + + it('does not report a column that only appears as a value', () => { + expect( + collectTableBlockColumnReferences( + subBlocks({ filter: '{"field":"status","op":"eq","value":"wins"}' }), + 'wins' + ) + ).toEqual([]) + }) + + it('falls back to a quoted-token match when the text is not JSON', () => { + expect( + collectTableBlockColumnReferences( + subBlocks({ filter: '{"field":"wins","op":"eq","value":}' }), + 'wins' + ) + ).toEqual(['filter']) + expect( + collectTableBlockColumnReferences(subBlocks({ filter: '' }), 'wins') + ).toEqual([]) + }) + + it('ignores empty and absent sub-blocks', () => { + expect(collectTableBlockColumnReferences(subBlocks({ filter: ' ' }), 'wins')).toEqual([]) + expect(collectTableBlockColumnReferences({}, 'wins')).toEqual([]) + }) +}) + +describe('isTableBlockBoundTo', () => { + it('accepts the manual id, the selector, or a canonical tableId', () => { + expect(isTableBlockBoundTo(subBlocks({ manualTableId: 'tbl_1' }), 'tbl_1')).toBe(true) + expect(isTableBlockBoundTo(subBlocks({ tableSelector: 'tbl_1' }), 'tbl_1')).toBe(true) + expect(isTableBlockBoundTo(subBlocks({ tableId: ' tbl_1 ' }), 'tbl_1')).toBe(true) + expect(isTableBlockBoundTo(subBlocks({ manualTableId: 'tbl_2' }), 'tbl_1')).toBe(false) + }) +}) + +describe('findUnmigratedTableBlockReferences', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('reports only Table blocks bound to the table that still name the column', async () => { + queueTableRows(schemaMock.workflowBlocks, [ + { + workflowId: 'wf-2', + workflowName: 'Weekly digest', + blockId: 'blk-b', + blockName: 'Query wins', + subBlocks: subBlocks({ + manualTableId: 'tbl_1', + filter: '{"field":"wins","op":"gte","value":10}', + order: '[{"field":"name","direction":"asc"}]', + }), + }, + { + workflowId: 'wf-1', + workflowName: 'Alerts', + blockId: 'blk-a', + blockName: 'Insert', + subBlocks: subBlocks({ tableSelector: 'tbl_1', data: '{"wins": 1}' }), + }, + { + workflowId: 'wf-1', + workflowName: 'Alerts', + blockId: 'blk-other-table', + blockName: 'Other table', + subBlocks: subBlocks({ manualTableId: 'tbl_9', data: '{"wins": 1}' }), + }, + { + workflowId: 'wf-1', + workflowName: 'Alerts', + blockId: 'blk-clean', + blockName: 'Clean', + subBlocks: subBlocks({ + manualTableId: 'tbl_1', + filter: '{"field":"name","op":"eq","value":"x"}', + }), + }, + ]) + + await expect( + findUnmigratedTableBlockReferences({ + workspaceId: 'ws-1', + tableId: 'tbl_1', + columnName: 'wins', + }) + ).resolves.toEqual([ + { + workflowId: 'wf-1', + workflowName: 'Alerts', + blockId: 'blk-a', + blockName: 'Insert', + fields: ['data'], + }, + { + workflowId: 'wf-2', + workflowName: 'Weekly digest', + blockId: 'blk-b', + blockName: 'Query wins', + fields: ['filter'], + }, + ]) + expect(dbChainMockFns.innerJoin).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/lib/table/columns/workflow-references.ts b/apps/sim/lib/table/columns/workflow-references.ts new file mode 100644 index 00000000000..3d43e4abdc7 --- /dev/null +++ b/apps/sim/lib/table/columns/workflow-references.ts @@ -0,0 +1,166 @@ +/** + * Finds workflow Table blocks whose saved configuration still names a column by + * its old name after a rename. + * + * A rename is metadata-only inside the table — rows, views, and workflow-group + * refs key on the column's stable id — but a Table block's `filter`, `order`, + * and `data` are authored JSON that names columns by name. Nothing rewrites + * workflow state on a rename, so the next run of such a block fails inside its + * error edge with a lint that stayed clean. This module reports those blocks so + * the caller can migrate them; it never mutates workflow state. + */ + +import { db } from '@sim/db' +import { workflowBlocks, workflow as workflowTable } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { + collectPredicateFieldNames, + collectSortFieldNames, +} from '@/lib/table/query-builder/field-names' + +/** Authored-JSON sub-blocks that name columns. */ +const COLUMN_REFERENCE_SUB_BLOCKS = ['filter', 'order', 'data'] as const + +export type TableBlockColumnReferenceField = (typeof COLUMN_REFERENCE_SUB_BLOCKS)[number] + +export interface UnmigratedTableBlockReference { + workflowId: string + workflowName: string + blockId: string + blockName: string + /** Sub-block fields that still name the old column. */ + fields: TableBlockColumnReferenceField[] +} + +type SubBlockValues = Record + +/** Sub-blocks that bind a Table block to a table, in the order they are consulted. */ +const TABLE_ID_SUB_BLOCKS = ['manualTableId', 'tableSelector', 'tableId'] as const + +const TABLE_BLOCK_TYPE = 'table_v2' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function subBlockValue(subBlocks: SubBlockValues, id: string): unknown { + const entry = subBlocks[id] + return isRecord(entry) ? entry.value : undefined +} + +/** The raw text of a sub-block, or `undefined` when it holds nothing. */ +function subBlockText(subBlocks: SubBlockValues, id: string): string | undefined { + const value = subBlockValue(subBlocks, id) + if (value === undefined || value === null) return undefined + if (typeof value === 'string') return value.trim() === '' ? undefined : value + try { + return JSON.stringify(value) + } catch { + return undefined + } +} + +/** Whether a Table block's configuration binds it to `tableId`. */ +export function isTableBlockBoundTo(subBlocks: SubBlockValues, tableId: string): boolean { + return TABLE_ID_SUB_BLOCKS.some((id) => { + const value = subBlockValue(subBlocks, id) + return typeof value === 'string' && value.trim() === tableId + }) +} + +/** Every column a row payload names: the keys of the `{ column: value }` object. */ +function collectDataFieldNames(root: unknown): string[] { + return isRecord(root) ? Object.keys(root) : [] +} + +const FIELD_COLLECTORS: Record string[]> = { + filter: collectPredicateFieldNames, + order: collectSortFieldNames, + data: collectDataFieldNames, +} + +/** + * Whether one sub-block's text names `columnName`. Parsed JSON is matched on + * the column positions the runtime reads (case-insensitively, as the runtime + * resolves names). Text that is not JSON — a `` reference, a + * template — falls back to a quoted-token match so a reference-bearing filter + * is still reported rather than silently skipped. + */ +function textReferencesColumn( + text: string, + field: TableBlockColumnReferenceField, + columnName: string +): boolean { + const wanted = columnName.toLowerCase() + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + return text.toLowerCase().includes(JSON.stringify(wanted)) + } + return FIELD_COLLECTORS[field](parsed).some((name) => name.toLowerCase() === wanted) +} + +/** + * The `filter`/`order`/`data` sub-blocks of one Table block that still name + * `columnName`. Pure: the block's sub-block values are all it reads. + */ +export function collectTableBlockColumnReferences( + subBlocks: SubBlockValues, + columnName: string +): TableBlockColumnReferenceField[] { + const fields: TableBlockColumnReferenceField[] = [] + for (const field of COLUMN_REFERENCE_SUB_BLOCKS) { + const text = subBlockText(subBlocks, field) + if (text !== undefined && textReferencesColumn(text, field, columnName)) fields.push(field) + } + return fields +} + +/** + * Table blocks across the workspace's live workflow drafts that are bound to + * `tableId` and still name `columnName` in a filter, sort, or row payload. + * Read-only; ordered by workflow then block name so a response is stable. + */ +export async function findUnmigratedTableBlockReferences(input: { + workspaceId: string + tableId: string + columnName: string +}): Promise { + const rows = await db + .select({ + workflowId: workflowTable.id, + workflowName: workflowTable.name, + blockId: workflowBlocks.id, + blockName: workflowBlocks.name, + subBlocks: workflowBlocks.subBlocks, + }) + .from(workflowBlocks) + .innerJoin(workflowTable, eq(workflowBlocks.workflowId, workflowTable.id)) + .where( + and( + eq(workflowTable.workspaceId, input.workspaceId), + isNull(workflowTable.archivedAt), + eq(workflowBlocks.type, TABLE_BLOCK_TYPE) + ) + ) + + const unmigrated: UnmigratedTableBlockReference[] = [] + for (const row of rows) { + if (!isRecord(row.subBlocks)) continue + const subBlocks = row.subBlocks as SubBlockValues + if (!isTableBlockBoundTo(subBlocks, input.tableId)) continue + const fields = collectTableBlockColumnReferences(subBlocks, input.columnName) + if (fields.length === 0) continue + unmigrated.push({ + workflowId: row.workflowId, + workflowName: row.workflowName, + blockId: row.blockId, + blockName: row.blockName, + fields, + }) + } + return unmigrated.sort( + (a, b) => a.workflowName.localeCompare(b.workflowName) || a.blockName.localeCompare(b.blockName) + ) +} diff --git a/apps/sim/lib/table/query-builder/field-names.ts b/apps/sim/lib/table/query-builder/field-names.ts new file mode 100644 index 00000000000..d657ef99b29 --- /dev/null +++ b/apps/sim/lib/table/query-builder/field-names.ts @@ -0,0 +1,51 @@ +/** + * Column names an authored filter or sort names, read from untrusted JSON. + * + * Shared by the surfaces that check authored Table-block JSON against a real + * schema without executing it: workflow lint (does every field name a column?) + * and column rename (which blocks still name the old column?). Tolerant by + * design — a malformed node contributes nothing rather than throwing, since + * these are advisory readers of persisted block state. + */ + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Every `field` a predicate tree names, in document order: a bare + * `{ field, op, value }` condition, or nested `{ all | any: [...] }` groups. + * Iterative so a deep tree cannot overflow the stack. + */ +export function collectPredicateFieldNames(root: unknown): string[] { + const names: string[] = [] + const stack: unknown[] = [root] + const visitLater = (members: readonly unknown[]) => { + for (let index = members.length - 1; index >= 0; index -= 1) stack.push(members[index]) + } + while (stack.length > 0) { + const node = stack.pop() + if (Array.isArray(node)) { + visitLater(node) + continue + } + if (!isRecord(node)) continue + if (typeof node.field === 'string') names.push(node.field) + if (Array.isArray(node.any)) visitLater(node.any) + if (Array.isArray(node.all)) visitLater(node.all) + } + return names +} + +/** + * Every column a sort names: the `field` of each `[{ field, direction }]` + * entry, or the keys of a `{ column: direction }` record. + */ +export function collectSortFieldNames(root: unknown): string[] { + if (Array.isArray(root)) { + return root.flatMap((entry) => + isRecord(entry) && typeof entry.field === 'string' ? [entry.field] : [] + ) + } + return isRecord(root) ? Object.keys(root) : [] +} diff --git a/apps/sim/lib/table/query-builder/index.ts b/apps/sim/lib/table/query-builder/index.ts index 57d7aa8bce0..acd5379eed8 100644 --- a/apps/sim/lib/table/query-builder/index.ts +++ b/apps/sim/lib/table/query-builder/index.ts @@ -4,5 +4,6 @@ export * from '@/lib/table/query-builder/constants' export * from '@/lib/table/query-builder/converters' +export * from '@/lib/table/query-builder/field-names' export * from '@/lib/table/query-builder/predicate' export * from '@/lib/table/query-builder/use-query-builder' diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index 98747c75ed6..1980e763e13 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -152,9 +152,11 @@ export interface WorkflowGroup { */ inputMappings?: WorkflowGroupInputMapping[] /** - * Which workflow state per-cell runs execute against. Defaults to `'live'` - * (editable draft) when absent. `'deployed'` runs the workflow's latest - * active deployment. Only meaningful for `manual` groups. + * Which workflow state per-cell runs execute against. Defaults to + * `'deployed'` (the workflow's latest active deployment) when absent — + * resolve it through `resolveWorkflowGroupDeploymentMode`, never by reading + * the raw field. `'live'` runs the editable draft. Only meaningful for + * `manual` groups. */ deploymentMode?: WorkflowGroupDeploymentMode /** diff --git a/apps/sim/lib/table/workflow-columns.test.ts b/apps/sim/lib/table/workflow-columns.test.ts index a63ed47a7e8..92459934294 100644 --- a/apps/sim/lib/table/workflow-columns.test.ts +++ b/apps/sim/lib/table/workflow-columns.test.ts @@ -88,10 +88,12 @@ vi.mock('@/lib/table/service', () => ({ })) import { + assertWorkflowGroupsDeployable, buildEnqueueItems, cancelCellRunsByTags, cancelWorkflowGroupRuns, pickNextEligibleGroupForRow, + runWorkflowColumn, type WorkflowGroupCellPayload, } from '@/lib/table/workflow-columns' @@ -393,3 +395,117 @@ describe('cancelWorkflowGroupRuns deletion races', () => { await expect(cancelWorkflowGroupRuns(table.id, 'row1')).rejects.toBe(error) }) }) + +/** + * Groups run the deployed version, and a group that never said which mode it + * wanted is a deployed-mode group. A dispatch against an undeployed workflow + * used to be accepted and then wrote an error into every cell; the dispatcher + * now refuses it before anything is enqueued, naming the workflow. + */ +describe('assertWorkflowGroupsDeployable', () => { + const deployedGroup = makeGroup({ id: 'g-deployed', workflowId: 'wf-1' }) + const liveGroup = makeGroup({ id: 'g-live', workflowId: 'wf-2', deploymentMode: 'live' }) + const enrichmentGroup = makeGroup({ + id: 'g-enrich', + workflowId: '', + type: 'enrichment', + enrichmentId: 'company-domain', + }) + + it('refuses a manual run of a mode-less group whose workflow has no active deployment', async () => { + queueTableRows(schemaMock.workflow, [ + { workflowId: 'wf-1', workflowName: 'Enrich leads', deploymentId: null }, + ]) + + await expect( + assertWorkflowGroupsDeployable([deployedGroup], { isManualRun: true, requestId: 'req-1' }) + ).rejects.toThrow( + 'Workflow group "g-deployed" runs the deployed version of workflow "Enrich leads" (wf-1), which has no active deployment' + ) + }) + + it('passes a deployed-mode group whose workflow has an active deployment', async () => { + queueTableRows(schemaMock.workflow, [ + { workflowId: 'wf-1', workflowName: 'Enrich leads', deploymentId: 'dep-1' }, + ]) + + await expect( + assertWorkflowGroupsDeployable([deployedGroup], { isManualRun: true, requestId: 'req-1' }) + ).resolves.toEqual([deployedGroup]) + }) + + it('does not check live-mode or enrichment groups', async () => { + await expect( + assertWorkflowGroupsDeployable([liveGroup, enrichmentGroup], { + isManualRun: true, + requestId: 'req-1', + }) + ).resolves.toEqual([liveGroup, enrichmentGroup]) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) + + it('drops an undeployed group from an auto-fire instead of failing the row write', async () => { + queueTableRows(schemaMock.workflow, [ + { workflowId: 'wf-1', workflowName: 'Enrich leads', deploymentId: null }, + ]) + + await expect( + assertWorkflowGroupsDeployable([deployedGroup, liveGroup], { + isManualRun: false, + requestId: 'req-1', + }) + ).resolves.toEqual([liveGroup]) + }) +}) + +describe('runWorkflowColumn deployment gate', () => { + const table = { + id: 'table-1', + workspaceId: 'workspace-1', + schema: { + columns: [], + workflowGroups: [makeGroup({ id: 'g-deployed', workflowId: 'wf-1', name: 'Scoring' })], + }, + } as unknown as TableDefinition + + beforeEach(() => { + mockGetTableById.mockResolvedValue(table) + }) + + it('refuses the dispatch when the group workflow was undeployed', async () => { + queueTableRows(schemaMock.workflow, [ + { workflowId: 'wf-1', workflowName: 'Score', deploymentId: null }, + ]) + + await expect( + runWorkflowColumn({ + tableId: 'table-1', + workspaceId: 'workspace-1', + mode: 'all', + groupIds: ['g-deployed'], + requestId: 'req-1', + }) + ).rejects.toThrow( + 'Workflow group "Scoring" runs the deployed version of workflow "Score" (wf-1), which has no active deployment' + ) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('skips an auto-fire whose only group is undeployed without enqueueing anything', async () => { + queueTableRows(schemaMock.workflow, [ + { workflowId: 'wf-1', workflowName: 'Score', deploymentId: null }, + ]) + + await expect( + runWorkflowColumn({ + tableId: 'table-1', + workspaceId: 'workspace-1', + mode: 'new', + isManualRun: false, + rowIds: ['row-1'], + requestId: 'req-1', + }) + ).resolves.toEqual({ dispatchId: null, shouldSignalRowsChanged: false }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index 7fd9c6a640f..d96d2cb880f 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -10,6 +10,8 @@ import { pausedExecutions, tableRowExecutions, userTableRows as userTableRowsTable, + workflowDeploymentVersion, + workflow as workflowTable, } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { @@ -55,6 +57,7 @@ import { areGroupDepsSatisfied, areOutputsFilled, isExecInFlight } from '@/lib/t import { resolveTableDispatchConcurrency } from '@/lib/table/dispatch-concurrency' import type { DispatchLimit, DispatchMode } from '@/lib/table/dispatcher' import { buildFilterClause } from '@/lib/table/sql' +import { resolveWorkflowGroupDeploymentMode } from '@/lib/table/workflow-groups/deployment-mode' export { getUnmetGroupDeps, @@ -856,6 +859,69 @@ export async function cancelWorkflowGroupRuns( * completed cells; `mode: 'incomplete'` skips them. `groupIds` omitted = every * workflow group on the table. `rowIds` omitted = every row. */ +/** + * A `deployed`-mode group whose workflow has no active deployment has nothing + * to run: the cell runner loads the deployment, so every cell would fail with + * the same error. Refuse the dispatch up front, naming the workflow, rather than + * enqueueing a run that only writes error cells. Groups with no backing workflow + * (enrichments) and `live`-mode groups are not checked. + * + * Returns the groups that are runnable; an auto-fire caller drops the rest + * (with a warning, since nobody is there to receive an error) while a manual + * run refuses the whole request. + */ +export async function assertWorkflowGroupsDeployable( + groups: readonly WorkflowGroup[], + options: { isManualRun: boolean; requestId: string } +): Promise { + const checked = groups.filter( + (group) => group.workflowId && resolveWorkflowGroupDeploymentMode(group) === 'deployed' + ) + if (checked.length === 0) return [...groups] + + const workflowIds = [...new Set(checked.map((group) => group.workflowId))] + const rows = await db + .select({ + workflowId: workflowTable.id, + workflowName: workflowTable.name, + deploymentId: workflowDeploymentVersion.id, + }) + .from(workflowTable) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, workflowTable.id), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .where(inArray(workflowTable.id, workflowIds)) + + const nameById = new Map() + const deployed = new Set() + for (const row of rows) { + nameById.set(row.workflowId, row.workflowName) + if (row.deploymentId) deployed.add(row.workflowId) + } + + const undeployed = checked.filter((group) => !deployed.has(group.workflowId)) + if (undeployed.length === 0) return [...groups] + + const describe = (group: WorkflowGroup) => { + const name = nameById.get(group.workflowId) + const workflow = name ? `"${name}" (${group.workflowId})` : group.workflowId + return `Workflow group "${group.name ?? group.id}" runs the deployed version of workflow ${workflow}, which has no active deployment. Deploy the workflow, or switch the group to live mode, before running it.` + } + + if (options.isManualRun) { + throw new OrchestrationError('validation', describe(undeployed[0])) + } + for (const group of undeployed) { + logger.warn(`[${options.requestId}] Skipping auto-run: ${describe(group)}`) + } + const skipped = new Set(undeployed.map((group) => group.id)) + return groups.filter((group) => !skipped.has(group.id)) +} + export async function runWorkflowColumn(opts: { tableId: string workspaceId: string @@ -914,11 +980,18 @@ export async function runWorkflowColumn(opts: { throw new OrchestrationError('validation', 'Invalid workspace ID') const allGroups = table.schema.workflowGroups ?? [] - const targetGroups = groupIds ? allGroups.filter((g) => groupIds.includes(g.id)) : allGroups + const requestedGroups = groupIds ? allGroups.filter((g) => groupIds.includes(g.id)) : allGroups // Tables with no workflow groups are the majority. Auto-fire callers from // every row write would otherwise produce error-level log spam on every // PATCH/insert. Manual run-column callers always pass `groupIds` so they // can't reach here with an empty target. + if (requestedGroups.length === 0) { + return { dispatchId: null, shouldSignalRowsChanged: false } + } + const targetGroups = await assertWorkflowGroupsDeployable(requestedGroups, { + isManualRun, + requestId, + }) if (targetGroups.length === 0) { return { dispatchId: null, shouldSignalRowsChanged: false } } diff --git a/apps/sim/lib/table/workflow-groups/deployment-mode.ts b/apps/sim/lib/table/workflow-groups/deployment-mode.ts new file mode 100644 index 00000000000..a42bdf84e4b --- /dev/null +++ b/apps/sim/lib/table/workflow-groups/deployment-mode.ts @@ -0,0 +1,19 @@ +import type { WorkflowGroup, WorkflowGroupDeploymentMode } from '@/lib/table/types' + +/** + * The workflow state a group runs against when it does not say. Groups run the + * deployed version: a per-cell run is a headless execution, and the draft is + * whatever a collaborator happens to have on the canvas at that moment. + */ +export const DEFAULT_WORKFLOW_GROUP_DEPLOYMENT_MODE: WorkflowGroupDeploymentMode = 'deployed' + +/** + * The mode a group effectively runs in. A stored group may predate the field + * or omit it; every reader (dispatcher, presenter, UI) resolves it through + * here so an absent value can never be read as a third, undefined mode. + */ +export function resolveWorkflowGroupDeploymentMode( + group: Pick +): WorkflowGroupDeploymentMode { + return group.deploymentMode ?? DEFAULT_WORKFLOW_GROUP_DEPLOYMENT_MODE +} diff --git a/apps/sim/lib/table/workflow-groups/service.test.ts b/apps/sim/lib/table/workflow-groups/service.test.ts index e877d289d0e..10a12d15096 100644 --- a/apps/sim/lib/table/workflow-groups/service.test.ts +++ b/apps/sim/lib/table/workflow-groups/service.test.ts @@ -300,3 +300,71 @@ describe('addWorkflowGroup attaching existing columns', () => { ).rejects.toThrow('already exists with type "string"') }) }) + +/** + * Groups run the deployed version. A group stored without a mode used to be + * read back as `deploymentMode: null` and, by the old default, as the draft — + * while the cell runner had been loading the deployment all along. + */ +describe('addWorkflowGroup deployment mode', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAssertTableRowTtlEnabled.mockResolvedValue(undefined) + }) + + function add(group: WorkflowGroup) { + const set = vi.fn(() => ({ where: () => Promise.resolve() })) + mockWithLockedTable.mockImplementation( + async (_tableId: string, mutate: (t: TableDefinition, trx: unknown) => Promise) => + mutate(tableWithGroups(0), { update: () => ({ set }), execute: () => Promise.resolve() }) + ) + return async () => { + await addWorkflowGroup( + { + tableId: 'table-1', + workspaceId: 'workspace-1', + group, + outputColumns: [{ name: 'out', type: 'string', workflowGroupId: group.id }], + autoRun: false, + actorUserId: 'user-1', + } as Parameters[0], + 'request-1' + ) + const written = set.mock.calls[0][0] as { schema: TableSchema } + return written.schema.workflowGroups?.find((candidate) => candidate.id === group.id) + } + } + + it('stores a workflow-backed group without a mode as deployed', async () => { + const stored = await add({ + id: 'group-new', + workflowId: 'workflow-1', + outputs: [{ blockId: 'block-1', path: 'out', columnName: 'out' }], + } as WorkflowGroup)() + + expect(stored?.deploymentMode).toBe('deployed') + }) + + it('keeps an explicit live mode', async () => { + const stored = await add({ + id: 'group-new', + workflowId: 'workflow-1', + deploymentMode: 'live', + outputs: [{ blockId: 'block-1', path: 'out', columnName: 'out' }], + } as WorkflowGroup)() + + expect(stored?.deploymentMode).toBe('live') + }) + + it('leaves an enrichment group, which runs no workflow, without a mode', async () => { + const stored = await add({ + id: 'group-new', + workflowId: '', + type: 'enrichment', + enrichmentId: 'company-domain', + outputs: [{ blockId: '', path: '', outputId: 'domain', columnName: 'out' }], + } as WorkflowGroup)() + + expect(stored).not.toHaveProperty('deploymentMode') + }) +}) diff --git a/apps/sim/lib/table/workflow-groups/service.ts b/apps/sim/lib/table/workflow-groups/service.ts index d7e8f903055..93d86938fe3 100644 --- a/apps/sim/lib/table/workflow-groups/service.ts +++ b/apps/sim/lib/table/workflow-groups/service.ts @@ -41,6 +41,7 @@ import type { } from '@/lib/table/types' import { runWorkflowColumn } from '@/lib/table/workflow-columns' import { stripGroupDeps } from '@/lib/table/workflow-group-deps' +import { resolveWorkflowGroupDeploymentMode } from '@/lib/table/workflow-groups/deployment-mode' const logger = createLogger('TableWorkflowGroupsService') /** @@ -237,7 +238,14 @@ export async function addWorkflowGroup( // Rewrite the group's column refs from name → id. const idByName = new Map(updatedColumns.map((c) => [c.name, getColumnId(c)])) for (const [columnId, ref] of attached) idByName.set(ref, columnId) - const group = remapGroupColumnRefs(data.group, idByName) + // A workflow-backed group is stored with its effective mode so no later + // reader has to guess what an absent value meant at creation time. + const group = remapGroupColumnRefs( + data.group.workflowId + ? { ...data.group, deploymentMode: resolveWorkflowGroupDeploymentMode(data.group) } + : data.group, + idByName + ) const updatedSchema: TableSchema = { ...schema, diff --git a/apps/sim/lib/workflows/api/route-policies.ts b/apps/sim/lib/workflows/api/route-policies.ts index e29cc9ebd6e..c60e1fe7308 100644 --- a/apps/sim/lib/workflows/api/route-policies.ts +++ b/apps/sim/lib/workflows/api/route-policies.ts @@ -33,9 +33,10 @@ import { function v2CancelRunErrorResponse(error: unknown) { if (error instanceof WorkflowRunAlreadyTerminalError) { + // A terminal run is a `200` no-op: nothing was cancelled, so `success` is false. return v2Data( v2CancelWorkflowRunDataSchema.parse({ - success: true, + success: false, runId: error.executionId, redisAvailable: error.redisAvailable, durablyRecorded: false, diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts index 2270ed827b1..1d35eed5540 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts @@ -115,7 +115,10 @@ vi.mock('@/lib/workflows/autolayout', () => ({ import { ForbiddenOperationError } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { applyWorkflowOperations } from '@/lib/workflows/application/apply-workflow-operations' +import { + applyWorkflowOperations, + DRY_RUN_PREVIEW_BLOCK_IDS_WARNING, +} from '@/lib/workflows/application/apply-workflow-operations' import { WorkflowOperationsNotAppliedError } from '@/lib/workflows/application/workflow-operations-error' const BLOCK = { @@ -189,6 +192,7 @@ describe('applyWorkflowOperations', () => { state: graph(), validationErrors: [], skippedItems: [], + mintedBlockIds: {}, }) mocks.collectReferences.mockResolvedValue([]) mocks.collectToolReferences.mockResolvedValue([]) @@ -274,11 +278,54 @@ describe('applyWorkflowOperations', () => { orphanBlocks: [orphan], fieldIssues: [], unresolvedReferences: [], + tableFieldIssues: [], notes: ['No entry block: nothing can start this workflow.'], }) }) describe('dry run', () => { + /** + * The engine mints a UUID for every non-UUID `block_id` on each call, so a + * dry run's ids are never the ids the committed apply produces. Reporting + * them as `mintedBlockIds` made them look authoritative. + */ + it('reports minted ids as previews, with a warning, instead of as minted', async () => { + mocks.applyOperations.mockReturnValue({ + state: graph(), + validationErrors: [], + skippedItems: [], + mintedBlockIds: { triage: 'preview-uuid' }, + }) + + const dry = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations, dryRun: true }, + }) + + expect(dry.mintedBlockIds).toEqual({}) + expect(dry.previewBlockIds).toEqual({ triage: 'preview-uuid' }) + expect(dry.warnings).toContain(DRY_RUN_PREVIEW_BLOCK_IDS_WARNING) + + const committed = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations }, + }) + + expect(committed.mintedBlockIds).toEqual({ triage: 'preview-uuid' }) + expect(committed.previewBlockIds).toBeUndefined() + expect(committed.warnings).not.toContain(DRY_RUN_PREVIEW_BLOCK_IDS_WARNING) + }) + + it('raises no preview warning when the dry run minted nothing', async () => { + const dry = await applyWorkflowOperations.execute({ + principal: sessionPrincipal, + input: { workflowId: 'workflow-1', operations, dryRun: true }, + }) + + expect(dry.previewBlockIds).toEqual({}) + expect(dry.warnings).not.toContain(DRY_RUN_PREVIEW_BLOCK_IDS_WARNING) + }) + it('runs the whole engine and stops at the write', async () => { const result = await applyWorkflowOperations.execute({ principal: sessionPrincipal, @@ -347,6 +394,7 @@ describe('applyWorkflowOperations', () => { }, validationErrors: [], skippedItems: [], + mintedBlockIds: {}, }) mocks.validate.mockReturnValue({ valid: true, errors: [], warnings: ['validation note'] }) diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.ts index 08504768d7a..e1ca78e5124 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.ts @@ -108,8 +108,15 @@ export interface ApplyWorkflowOperationsResult { skipped: SkippedItem[] deferred: SkippedItem[] inputValidationErrors: ValidationError[] - /** Requested `block_id` -> the id the block was given, when they differ. */ + /** Requested `block_id` -> the id the block was given, when they differ. Empty on a dry run. */ mintedBlockIds: Record + /** + * Dry run only: requested `block_id` -> the provisional id the evaluation + * assigned. Reported apart from `mintedBlockIds` because they are not the + * ids a committed apply produces — the real apply mints new ones — and a + * caller that wired a later request against them would reference nothing. + */ + previewBlockIds?: Record lint: WorkflowLintReport warnings: string[] needsRedeployment: boolean @@ -117,6 +124,10 @@ export interface ApplyWorkflowOperationsResult { dryRun: boolean } +/** Raised on a dry run that assigned provisional block ids. */ +export const DRY_RUN_PREVIEW_BLOCK_IDS_WARNING = + 'Dry run: block ids are previews — the real apply mints new ones; wire edges by slug within one batch or by the ids the real apply returns.' + /** * The engine models a graph as an open record; the layout helpers want the * canonical shape. One conversion, named, rather than a cast at each call. @@ -409,9 +420,14 @@ export const applyWorkflowOperations = defineAuthorizedWorkflowUseCase({ skipped: genuineSkippedItems, deferred: deferredItems, inputValidationErrors: validationErrors, - mintedBlockIds, + mintedBlockIds: {}, + previewBlockIds: mintedBlockIds, lint, - warnings: [...validation.warnings, ...prepared.warnings], + warnings: [ + ...validation.warnings, + ...prepared.warnings, + ...(Object.keys(mintedBlockIds).length > 0 ? [DRY_RUN_PREVIEW_BLOCK_IDS_WARNING] : []), + ], needsRedeployment: await checkNeedsRedeployment(context.workflowId), dryRun: true, } diff --git a/apps/sim/lib/workflows/application/cancel-run.ts b/apps/sim/lib/workflows/application/cancel-run.ts index f946231c6c9..2b94e28507a 100644 --- a/apps/sim/lib/workflows/application/cancel-run.ts +++ b/apps/sim/lib/workflows/application/cancel-run.ts @@ -1,6 +1,7 @@ import { resolvePrincipalAttribution } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' import { + type CancelWorkflowExecutionResult, cancelWorkflowExecution, WorkflowExecutionNotFoundError, } from '@/lib/execution/cancel-workflow-execution' @@ -14,6 +15,23 @@ export interface CancelWorkflowRunInput { abortSignal?: AbortSignal } +/** + * Whether the request cancelled anything at all. A run already in a terminal + * state is "satisfied" — the end state the caller wanted holds — but nothing + * was recorded, aborted, or unpaused, and a surface that reads `success` as + * "my request did something" is misled by it. `queue_cancelled` is the one + * outcome that removes work without setting any of the three flags: the queued + * job is gone even though the record could not be written durably. + */ +function cancelledSomething(result: CancelWorkflowExecutionResult): boolean { + return ( + result.durablyRecorded || + result.locallyAborted || + result.pausedCancelled || + result.reason === 'queue_cancelled' + ) +} + export const cancelWorkflowRun = defineAuthorizedWorkflowUseCase({ operation: workflowOperations.cancelRun, resolveContext: ({ input }: { input: CancelWorkflowRunInput }) => @@ -32,7 +50,12 @@ export const cancelWorkflowRun = defineAuthorizedWorkflowUseCase({ workspaceId: context.workspaceId, abortSignal: input.abortSignal, }) - return { ...result, workflowId: context.workflowId, workspaceId: context.workspaceId } + return { + ...result, + cancelled: cancelledSomething(result), + workflowId: context.workflowId, + workspaceId: context.workspaceId, + } } catch (error) { if (error instanceof WorkflowExecutionNotFoundError) { throw new OrchestrationError('not_found', 'Run not found') @@ -41,7 +64,7 @@ export const cancelWorkflowRun = defineAuthorizedWorkflowUseCase({ } }, afterSuccess({ principal, context, result }) { - if (!result.success || result.reason === 'already_cancelled') return + if (!result.success || !result.cancelled) return const attribution = resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: context.billedAccountUserId, }) diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts index adb247e8375..7af4dae23ef 100644 --- a/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts @@ -151,6 +151,65 @@ describe('Copilot workflow run application commands', () => { ) }) + describe('trigger selection errors name only tools the agent surface has', () => { + const twoTriggers = [ + { triggerBlockId: 'start-1', blockName: 'Start', triggerType: 'start_trigger' }, + { triggerBlockId: 'hook-1', blockName: 'Slack hook', triggerType: 'slack_webhook' }, + ] + + it('lists every trigger as blockId → type/name and points at inputFormat in workflows state get', async () => { + mocks.resolveOptions.mockReturnValue(twoTriggers) + + await expect( + runWorkflowFromCopilot.execute({ + principal, + input: { + workflowId: 'workflow-1', + useDraftState: true, + lifecycle, + hasWorkflowInput: false, + useMockPayload: true, + }, + }) + ).rejects.toThrow( + 'This workflow has 2 triggers: pass triggerBlockId (start-1 → start_trigger/Start, hook-1 → slack_webhook/Slack hook). ' + + "Each trigger's input shape is its block's inputFormat in workflows state get." + ) + expect(mocks.executeWorkflow).not.toHaveBeenCalled() + }) + + it('never tells the agent to call get_workflow_run_options, which does not exist on its surface', async () => { + mocks.resolveOptions.mockReturnValue(twoTriggers) + + const messages: string[] = [] + for (const triggerBlockId of [undefined, 'not-a-trigger']) { + try { + await runWorkflowFromCopilot.execute({ + principal, + input: { + workflowId: 'workflow-1', + useDraftState: true, + lifecycle, + hasWorkflowInput: false, + useMockPayload: true, + triggerBlockId, + }, + }) + } catch (error) { + messages.push((error as Error).message) + } + } + + expect(messages).toHaveLength(2) + for (const message of messages) { + expect(message).not.toContain('get_workflow_run_options') + expect(message).toContain('inputFormat in workflows state get') + } + expect(messages[1]).toContain('triggerBlockId "not-a-trigger" is not a runnable trigger') + expect(messages[1]).toContain('start-1 → start_trigger/Start') + }) + }) + it('runs under a caller-claimed execution id and stamps its copilot correlation', async () => { // Set when the request handler wins the workflow-tool claim and runs the // tool server-side. The claimed id must BE the child execution id, and the diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts index 49eb9b020c3..e5828aef3d2 100644 --- a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts @@ -136,8 +136,18 @@ async function resolveTriggerExecution(params: { 'No runnable trigger found. Add a Start/API/Input/Chat trigger or an external (webhook/integration) trigger before running.' ) } + /** + * Names each trigger as `blockId → type/name` and points at the input shape + * where the agent surface actually exposes it. There is no run-options tool + * on that surface — the shape is the trigger block's `inputFormat`, read from + * `workflows state get`. + */ const listTriggers = () => - options.map((option) => `${option.triggerBlockId} (${option.blockName})`).join(', ') + options + .map((option) => `${option.triggerBlockId} → ${option.triggerType}/${option.blockName}`) + .join(', ') + const inputShapeHint = + "Each trigger's input shape is its block's inputFormat in workflows state get." let option = options[0] if (params.input.triggerBlockId) { const selected = options.find( @@ -146,14 +156,14 @@ async function resolveTriggerExecution(params: { if (!selected) { throw new OrchestrationError( 'validation', - `triggerBlockId "${params.input.triggerBlockId}" is not a runnable trigger in this workflow. Valid triggers: ${listTriggers()}. Call get_workflow_run_options to inspect them.` + `triggerBlockId "${params.input.triggerBlockId}" is not a runnable trigger in this workflow. Valid triggers: ${listTriggers()}. ${inputShapeHint}` ) } option = selected } else if (options.length > 1) { throw new OrchestrationError( 'validation', - `This workflow has multiple triggers — pass triggerBlockId to choose one: ${listTriggers()}. Call get_workflow_run_options for each trigger's input shape.` + `This workflow has ${options.length} triggers: pass triggerBlockId (${listTriggers()}). ${inputShapeHint}` ) } diff --git a/apps/sim/lib/workflows/application/workflow-run-control.test.ts b/apps/sim/lib/workflows/application/workflow-run-control.test.ts index a7440c3c14a..b920fbb755c 100644 --- a/apps/sim/lib/workflows/application/workflow-run-control.test.ts +++ b/apps/sim/lib/workflows/application/workflow-run-control.test.ts @@ -140,6 +140,55 @@ describe('workflow run-control application use cases', () => { } ) + /** + * A run that was already terminal is satisfied but nothing was cancelled. + * The use case says so explicitly, so a surface can answer `success: false` + * for the no-op without re-deriving it from three flags, and the analytics + * event for a cancellation is not captured for a cancel that did nothing. + */ + it.each(['already_completed', 'already_failed', 'already_cancelled'] as const)( + 'reports a %s run as satisfied but not cancelled', + async (reason) => { + mocks.cancel.mockResolvedValue({ + success: true, + executionId: 'parent-run-1', + redisAvailable: true, + durablyRecorded: false, + locallyAborted: false, + pausedCancelled: false, + reason, + }) + + const result = await cancelWorkflowRun.execute({ + principal: principals[0].principal, + input: { runId: 'parent-run-1' }, + }) + + expect(result).toMatchObject({ success: true, cancelled: false, reason }) + expect(mocks.capture).not.toHaveBeenCalled() + } + ) + + it('treats a queue-only cancellation as having cancelled the run', async () => { + mocks.cancel.mockResolvedValue({ + success: true, + executionId: 'parent-run-1', + redisAvailable: false, + durablyRecorded: false, + locallyAborted: false, + pausedCancelled: false, + reason: 'queue_cancelled', + }) + + const result = await cancelWorkflowRun.execute({ + principal: principals[0].principal, + input: { runId: 'parent-run-1' }, + }) + + expect(result).toMatchObject({ success: true, cancelled: true, reason: 'queue_cancelled' }) + expect(mocks.capture).toHaveBeenCalledTimes(1) + }) + it.each(principals)( 'authorizes $principal.kind resume and preserves the parent/new run distinction', async ({ principal, actorUserId }) => { diff --git a/apps/sim/lib/workflows/editing/lint-report.test.ts b/apps/sim/lib/workflows/editing/lint-report.test.ts index 509baa1c148..77aa699236d 100644 --- a/apps/sim/lib/workflows/editing/lint-report.test.ts +++ b/apps/sim/lib/workflows/editing/lint-report.test.ts @@ -6,8 +6,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ collectUnresolvedReferences: vi.fn(async () => []), collectUnresolvedAgentToolReferences: vi.fn(async () => []), + getTableById: vi.fn(async () => null), })) +vi.mock('@/lib/table/service', () => ({ getTableById: mocks.getTableById })) + vi.mock('@/lib/workflows/editing/validation', () => ({ collectUnresolvedReferences: mocks.collectUnresolvedReferences, collectUnresolvedAgentToolReferences: mocks.collectUnresolvedAgentToolReferences, @@ -111,3 +114,79 @@ describe('buildWorkflowLintReport notes', () => { expect(mocks.collectUnresolvedReferences).not.toHaveBeenCalled() }) }) + +/** + * The report builder is the one place with database access, so it resolves + * each Table block's filter and sort fields against the bound table's live + * schema — for every caller, since a table schema is workspace data rather + * than a human's grant. + */ +describe('buildWorkflowLintReport table fields', () => { + const leads = { + id: 'tbl_leads', + name: 'Leads', + workspaceId: 'workspace-1', + schema: { columns: [{ id: 'col_name', name: 'name', type: 'string' }] }, + } + + function tableBlock(id: string, values: Record) { + return { + ...block(id, 'table_v2'), + subBlocks: Object.fromEntries( + Object.entries(values).map(([key, value]) => [key, { id: key, type: 'code', value }]) + ), + } + } + + beforeEach(() => { + vi.clearAllMocks() + mocks.getTableById.mockImplementation(async (tableId: string) => + tableId === 'tbl_leads' ? leads : null + ) + }) + + it('reports a filter field the bound table has no column for', async () => { + const report = await buildWorkflowLintReport( + { + blocks: { + start: block('start', 'starter'), + query: tableBlock('query', { + manualTableId: 'tbl_leads', + filter: '{"field":"score","op":"gte","value":10}', + }), + }, + edges: [edge('start', 'query')], + } as never, + { ...scope, subjectUserId: null } + ) + + expect(mocks.getTableById).toHaveBeenCalledWith('tbl_leads') + expect(report.tableFieldIssues).toEqual([ + { + blockId: 'query', + blockName: 'query', + blockType: 'table_v2', + field: 'score', + tableName: 'Leads', + }, + ]) + }) + + it('skips a table outside the workspace and reports an empty finding when the lookup fails', async () => { + mocks.getTableById.mockResolvedValueOnce({ ...leads, workspaceId: 'workspace-2' }) + const graph = { + blocks: { + query: tableBlock('query', { + manualTableId: 'tbl_leads', + filter: '{"field":"score","op":"gte","value":10}', + }), + }, + edges: [], + } as never + + expect((await buildWorkflowLintReport(graph, scope)).tableFieldIssues).toEqual([]) + + mocks.getTableById.mockRejectedValueOnce(new Error('tables unavailable')) + expect((await buildWorkflowLintReport(graph, scope)).tableFieldIssues).toEqual([]) + }) +}) diff --git a/apps/sim/lib/workflows/editing/lint-report.ts b/apps/sim/lib/workflows/editing/lint-report.ts index dc5c930141f..d0e86b2654a 100644 --- a/apps/sim/lib/workflows/editing/lint-report.ts +++ b/apps/sim/lib/workflows/editing/lint-report.ts @@ -1,12 +1,17 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { WorkflowState } from '@sim/workflow-types/workflow' +import { getTableById } from '@/lib/table/service' import { collectDanglingBlockOutputReferences, + collectTableBlockFieldIssues, collectWorkflowFieldIssues, + collectWorkflowTableIds, hasWorkflowEntryBlock, lintEditedWorkflowState, type WorkflowLintReport, + type WorkflowLintTableFieldIssue, + type WorkflowLintTableSchema, type WorkflowLintUnresolvedReference, } from '@/lib/workflows/editing/lint' import { @@ -55,6 +60,30 @@ export interface WorkflowLintScope { subjectUserId: string | null } +/** + * The live schema of every table the graph's Table blocks are bound to, keyed + * by table id. A table outside the workspace is left out rather than reported + * on: the finding names the table, and a block bound to a table this workspace + * cannot read is a reference problem, not a field problem. + */ +async function loadTableSchemasForLint( + blocks: Pick['blocks'], + workspaceId: string +): Promise> { + const tables = new Map() + await Promise.all( + collectWorkflowTableIds(blocks).map(async (tableId) => { + const table = await getTableById(tableId) + if (!table || table.workspaceId !== workspaceId) return + tables.set(tableId, { + name: table.name, + columnNames: table.schema.columns.map((column) => column.name), + }) + }) + ) + return tables +} + /** * Builds the advisory report published by both graph writes. * @@ -101,6 +130,21 @@ export async function buildWorkflowLintReport( } } + // Every caller, like the dangling-reference pass: the table schema is + // workspace data, not a human's grant, so a workspace key can read it. + let tableFieldIssues: WorkflowLintTableFieldIssue[] = [] + try { + tableFieldIssues = collectTableBlockFieldIssues( + graph.blocks, + await loadTableSchemasForLint(graph.blocks, scope.workspaceId) + ) + } catch (error) { + logger.warn('Table field lint failed', { + workflowId: scope.workflowId, + error: getErrorMessage(error), + }) + } + const graphLint = lintEditedWorkflowState(graph) const notes: string[] = [] @@ -113,8 +157,8 @@ export async function buildWorkflowLintReport( } /** - * `fieldIssues`, `unresolvedReferences`, and `notes` are assigned after the - * graph-lint spread and that is safe: {@link lintEditedWorkflowState} returns + * `fieldIssues`, `unresolvedReferences`, `tableFieldIssues`, and `notes` are + * assigned after the graph-lint spread and that is safe: {@link lintEditedWorkflowState} returns * `WorkflowLintResult`, which declares none of them, so the assignment can * never discard a finding the linter made. */ @@ -122,6 +166,7 @@ export async function buildWorkflowLintReport( ...graphLint, fieldIssues: collectWorkflowFieldIssues(graph.blocks), unresolvedReferences, + tableFieldIssues, notes, } } diff --git a/apps/sim/lib/workflows/editing/lint.test.ts b/apps/sim/lib/workflows/editing/lint.test.ts index d6a9dc8e052..be948babb66 100644 --- a/apps/sim/lib/workflows/editing/lint.test.ts +++ b/apps/sim/lib/workflows/editing/lint.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it, vi } from 'vitest' -import { collectWorkflowFieldIssues, hasWorkflowLintIssues, lintEditedWorkflowState } from './lint' +import { + collectTableBlockFieldIssues, + collectWorkflowFieldIssues, + collectWorkflowTableIds, + formatWorkflowLintMessage, + hasWorkflowLintIssues, + lintEditedWorkflowState, +} from './lint' /** * A resource block shaped like `knowledge`: a picker/manual canonical pair for @@ -487,3 +494,141 @@ describe('collectWorkflowFieldIssues', () => { expect(issues).toEqual([]) }) }) + +/** + * A Table block's `filter`/`order` name columns by name and are only checked + * at run time, so a typo — or a column renamed under the block — lints clean + * and fails inside the block's error edge. Resolved here against the bound + * table's live schema. + */ +describe('collectTableBlockFieldIssues', () => { + const tables = new Map([ + ['tbl_leads', { name: 'Leads', columnNames: ['name', 'Wins', 'status'] }], + ]) + + function tableBlock(id: string, values: Record, type = 'table_v2') { + return { + id, + type, + name: id, + subBlocks: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, { value }])), + } + } + + it('reports filter and sort fields that are not columns of the bound table', () => { + const issues = collectTableBlockFieldIssues( + { + query: tableBlock('query', { + manualTableId: 'tbl_leads', + filter: + '{"any":[{"field":"score","op":"gte","value":10},{"all":[{"field":"wins","op":"gt","value":1},{"field":"region","op":"eq","value":"eu"}]}]}', + order: '[{"field":"createdAt","direction":"desc"},{"field":"tier","direction":"asc"}]', + }), + }, + tables + ) + + expect(issues).toEqual([ + { + blockId: 'query', + blockName: 'query', + blockType: 'table_v2', + field: 'score', + tableName: 'Leads', + }, + { + blockId: 'query', + blockName: 'query', + blockType: 'table_v2', + field: 'region', + tableName: 'Leads', + }, + { + blockId: 'query', + blockName: 'query', + blockType: 'table_v2', + field: 'tier', + tableName: 'Leads', + }, + ]) + }) + + it('accepts a bare condition, a record-shaped sort, and the implicit row fields', () => { + const issues = collectTableBlockFieldIssues( + { + query: tableBlock('query', { + tableSelector: 'tbl_leads', + filter: '{"field":"id","op":"eq","value":"row_1"}', + order: '{"updatedAt":"desc","status":"asc"}', + }), + }, + tables + ) + + expect(issues).toEqual([]) + }) + + it('skips a block whose table is unresolved, and a filter that is a reference or not JSON', () => { + const issues = collectTableBlockFieldIssues( + { + unbound: tableBlock('unbound', { + manualTableId: '', + filter: '{"field":"nope","op":"eq","value":1}', + }), + unknownTable: tableBlock('unknownTable', { + manualTableId: 'tbl_other', + filter: '{"field":"nope","op":"eq","value":1}', + }), + referenced: tableBlock('referenced', { + manualTableId: 'tbl_leads', + filter: '{"field":"nope","op":"eq","value":}', + }), + garbage: tableBlock('garbage', { manualTableId: 'tbl_leads', filter: 'not json' }), + notATable: tableBlock( + 'notATable', + { manualTableId: 'tbl_leads', filter: '{"field":"nope"}' }, + 'agent' + ), + }, + tables + ) + + expect(issues).toEqual([]) + }) + + it('surfaces the finding through the issue check and the summary', () => { + const lint = { + sources: [], + sinks: [], + orphanBlocks: [], + emptyOutgoingPorts: [], + invalidBranchPorts: [], + invalidConnectionTargets: [], + tableFieldIssues: [ + { blockId: 'query', blockName: 'Query leads', field: 'score', tableName: 'Leads' }, + ], + } + + expect(hasWorkflowLintIssues(lint)).toBe(true) + expect(formatWorkflowLintMessage(lint)).toContain( + 'Table filter/sort fields that are not columns of the referenced table (the run will fail in the block\'s error edge): "Query leads".score (table "Leads")' + ) + }) +}) + +describe('collectWorkflowTableIds', () => { + it('collects the statically bound table of every Table block once, preferring the manual id', () => { + const ids = collectWorkflowTableIds({ + a: { + id: 'a', + type: 'table_v2', + subBlocks: { manualTableId: { value: 'tbl_1' }, tableSelector: { value: 'tbl_2' } }, + }, + b: { id: 'b', type: 'table_v2', subBlocks: { tableSelector: { value: 'tbl_1' } } }, + c: { id: 'c', type: 'table_v2', subBlocks: { manualTableId: { value: '' } } }, + d: { id: 'd', type: 'agent', subBlocks: { manualTableId: { value: 'tbl_3' } } }, + }) + + expect(ids).toEqual(['tbl_1']) + }) +}) diff --git a/apps/sim/lib/workflows/editing/lint.ts b/apps/sim/lib/workflows/editing/lint.ts index 94b9ce92677..c0da8fa036f 100644 --- a/apps/sim/lib/workflows/editing/lint.ts +++ b/apps/sim/lib/workflows/editing/lint.ts @@ -1,4 +1,8 @@ import { findWorkflowReferenceTokens } from '@sim/utils/workflow-references' +import { + collectPredicateFieldNames, + collectSortFieldNames, +} from '@/lib/table/query-builder/field-names' import { getEffectiveBlockOutputs, getResponseFormatOutputs, @@ -84,6 +88,18 @@ export interface WorkflowLintUnresolvedReference extends WorkflowLintBlockRef { reason: string } +/** + * A Table block `filter`/`order` field that is not a column of the table the + * block is bound to. Lint stayed clean on these while the run failed inside + * the block's error edge, because the field name is only checked at run time. + */ +export interface WorkflowLintTableFieldIssue extends WorkflowLintBlockRef { + /** The filter or sort field that names no column. */ + field: string + /** Display name of the table the block is bound to. */ + tableName: string +} + /** * Aggregate lint report: the graph lint plus the config (Tier 1) and resolution * (Tier 2) checks. Returned in the edit_workflow result and written to lint.json. @@ -91,6 +107,7 @@ export interface WorkflowLintUnresolvedReference extends WorkflowLintBlockRef { export interface WorkflowLintReport extends WorkflowLintResult { fieldIssues: WorkflowLintFieldIssue[] unresolvedReferences: WorkflowLintUnresolvedReference[] + tableFieldIssues: WorkflowLintTableFieldIssue[] notes: string[] } @@ -297,9 +314,104 @@ export function collectWorkflowFieldIssues( return results } +/** The block type whose `filter`/`order` name table columns. */ +const TABLE_BLOCK_TYPE = 'table_v2' + +/** Sub-blocks that bind a Table block to a table: the manual id wins, then the picker. */ +const TABLE_ID_SUB_BLOCKS = ['manualTableId', 'tableSelector'] as const + +/** Fields every table row carries beyond its declared columns. */ +const IMPLICIT_TABLE_ROW_FIELDS = ['id', 'createdAt', 'updatedAt'] as const + +/** What the table field check needs to know about one table. */ +export interface WorkflowLintTableSchema { + name: string + columnNames: readonly string[] +} + +/** + * A sub-block string value that is literal at lint time: non-empty and holding + * no `` reference, which only resolves at run time. + */ +function literalSubBlockText(block: BlockState, id: string): string | undefined { + const value = block.subBlocks?.[id]?.value + if (typeof value !== 'string' || value.trim() === '' || value.includes('<')) return undefined + return value.trim() +} + +/** The table id a Table block is statically bound to, or `undefined` when it is a reference or unset. */ +export function tableBlockBoundTableId(block: BlockState): string | undefined { + if (block.type !== TABLE_BLOCK_TYPE) return undefined + for (const id of TABLE_ID_SUB_BLOCKS) { + const value = literalSubBlockText(block, id) + if (value) return value + } + return undefined +} + +/** Every table id the graph's Table blocks are statically bound to. */ +export function collectWorkflowTableIds( + blocks: WorkflowState['blocks'] | Record | undefined +): string[] { + const ids = new Set() + for (const block of Object.values(blocks || {})) { + const tableId = tableBlockBoundTableId(block as BlockState) + if (tableId) ids.add(tableId) + } + return [...ids] +} + +const TABLE_FIELD_SUB_BLOCKS: ReadonlyArray<[id: string, collect: (root: unknown) => string[]]> = [ + ['filter', collectPredicateFieldNames], + ['order', collectSortFieldNames], +] + +/** + * Table-block `filter`/`order` fields that name no column of the bound table. + * Resolved against the live schema the caller loaded (`tables`, keyed by table + * id); a block whose table is not in the map — unset, a reference, or not + * readable in this workspace — is skipped, as is a filter that holds a + * reference or is not JSON, since neither can be judged at lint time. + */ +export function collectTableBlockFieldIssues( + blocks: WorkflowState['blocks'] | Record | undefined, + tables: ReadonlyMap +): WorkflowLintTableFieldIssue[] { + const issues: WorkflowLintTableFieldIssue[] = [] + for (const [blockId, raw] of Object.entries(blocks || {})) { + const block = raw as BlockState + const tableId = tableBlockBoundTableId(block) + const table = tableId ? tables.get(tableId) : undefined + if (!table) continue + + const known = new Set( + [...table.columnNames, ...IMPLICIT_TABLE_ROW_FIELDS].map((name) => name.toLowerCase()) + ) + const reported = new Set() + for (const [subBlockId, collect] of TABLE_FIELD_SUB_BLOCKS) { + const text = literalSubBlockText(block, subBlockId) + if (!text) continue + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + continue + } + for (const field of collect(parsed)) { + const key = field.toLowerCase() + if (known.has(key) || reported.has(key)) continue + reported.add(key) + issues.push({ ...blockRef(blockId, block), field, tableName: table.name }) + } + } + } + return issues +} + type WorkflowLintIssueView = WorkflowLintResult & { fieldIssues?: WorkflowLintFieldIssue[] unresolvedReferences?: WorkflowLintUnresolvedReference[] + tableFieldIssues?: WorkflowLintTableFieldIssue[] } export function hasWorkflowLintIssues(lint: WorkflowLintIssueView) { @@ -309,7 +421,8 @@ export function hasWorkflowLintIssues(lint: WorkflowLintIssueView) { lint.invalidBranchPorts.length > 0 || lint.invalidConnectionTargets.length > 0 || (lint.fieldIssues?.length ?? 0) > 0 || - (lint.unresolvedReferences?.length ?? 0) > 0 + (lint.unresolvedReferences?.length ?? 0) > 0 || + (lint.tableFieldIssues?.length ?? 0) > 0 ) } @@ -415,6 +528,18 @@ export function formatWorkflowLintMessage(lint: WorkflowLintIssueView) { ) } + const tableFields = lint.tableFieldIssues ?? [] + if (tableFields.length > 0) { + parts.push( + `Table filter/sort fields that are not columns of the referenced table (the run will fail in the block's error edge): ${tableFields + .map( + (issue) => + `"${issue.blockName || issue.blockId}".${issue.field} (table "${issue.tableName}")` + ) + .join(', ')}` + ) + } + return `Workflow lint found issues. Fix these before continuing: ${parts.join('; ')}` } diff --git a/apps/sim/lib/workflows/executor/execute-service.test.ts b/apps/sim/lib/workflows/executor/execute-service.test.ts index 4f6b0c99ade..0ee5cf4b950 100644 --- a/apps/sim/lib/workflows/executor/execute-service.test.ts +++ b/apps/sim/lib/workflows/executor/execute-service.test.ts @@ -45,11 +45,10 @@ describe('pickRunBlockOutputs', () => { it('omits selectors for unknown blocks, unexecuted blocks, and absent paths', async () => { const logs = [log(AGENT_ID, { content: 'hi' })] - // An unknown block is a caller error and throws (the CLI turns it into - // `--select-output did not resolve to any block`); known-but-unexecuted blocks - // and absent paths are simply omitted. + // An unknown block is a caller error and throws, naming the blocks that exist; + // known-but-unexecuted blocks and absent paths are simply omitted. await expect(pickRunBlockOutputs(['Missing.content'], blocks, logs)).rejects.toThrow( - 'does not resolve' + 'Unknown block "Missing" in selector "Missing.content". Available blocks:' ) expect(await pickRunBlockOutputs(['Router.route', 'Agent 1.absent'], blocks, logs)).toEqual({}) }) diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts index c7a6d6b09d8..b1c33f6e945 100644 --- a/apps/sim/lib/workflows/executor/execute-service.ts +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -538,18 +538,25 @@ export async function executeWorkflowService( }) } + /** + * Validated before the run starts, for the sync path as much as the stream: + * a selector whose block does not exist is a caller mistake to answer with a + * 400 up front, not a run to execute and then answer with a silently emptier + * `blockOutputs`. + */ + let resolvedSelectedOutputs: string[] | undefined + try { + resolvedSelectedOutputs = await resolveOutputIds(selectedOutputs, workflowBlocks) + } catch (error) { + await releaseExecutionSlot(executionId) + return failure({ + kind: 'input', + message: `Invalid selectedOutputs: ${getErrorMessage(error)}`, + statusCode: 400, + }) + } + if (mode === 'stream') { - let resolvedSelectedOutputs: string[] | undefined - try { - resolvedSelectedOutputs = await resolveOutputIds(selectedOutputs, workflowBlocks) - } catch (error) { - await releaseExecutionSlot(executionId) - return failure({ - kind: 'input', - message: `Invalid selectedOutputs: ${getErrorMessage(error)}`, - statusCode: 400, - }) - } const streamWorkflow = { id: workflow.id, /** diff --git a/apps/sim/lib/workflows/executor/execution-core.test.ts b/apps/sim/lib/workflows/executor/execution-core.test.ts index 78e03f78577..a99f53a6fec 100644 --- a/apps/sim/lib/workflows/executor/execution-core.test.ts +++ b/apps/sim/lib/workflows/executor/execution-core.test.ts @@ -1598,6 +1598,8 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { loggingSession: loggingSession as any, }) + await loggingSession.setPostExecutionPromise.mock.calls[0][0] + expect(result.status).toBe('cancelled') expect(safeCompleteWithCancellationMock).toHaveBeenCalledTimes(1) expect(safeCompleteWithCancellationMock).toHaveBeenCalledWith( @@ -1609,7 +1611,7 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { ) expect(safeCompleteMock).not.toHaveBeenCalled() expect(safeCompleteWithPauseMock).not.toHaveBeenCalled() - expect(updateWorkflowRunCountsMock).not.toHaveBeenCalled() + expect(updateWorkflowRunCountsMock).toHaveBeenCalledWith('workflow-1') expect(clearExecutionCancellationMock).toHaveBeenCalledWith('execution-1') }) @@ -1650,12 +1652,12 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { }) /** - * The population `runCount` actually counts. Cancelled and paused runs are - * already pinned above; a plain failure is the case a caller is most likely to - * assume is included, and the workflow contract's `runCount` description is - * written against this. + * The population `runCount` actually counts: every settled run. A workflow + * whose only runs failed used to list `runCount: 0, lastRunAt: null`, which + * reads as "never ran". Cancelled runs are pinned above; paused runs are + * pinned below as the one outcome that is not yet settled. */ - it('leaves runCount untouched when the run fails', async () => { + it('counts a failed run, awaited from the finalization path', async () => { executorExecuteMock.mockResolvedValue({ success: false, status: 'failed', @@ -1673,7 +1675,27 @@ describe('executeWorkflowCore terminal finalization sequencing', () => { await loggingSession.setPostExecutionPromise.mock.calls[0][0] - expect(updateWorkflowRunCountsMock).not.toHaveBeenCalled() + expect(updateWorkflowRunCountsMock).toHaveBeenCalledWith('workflow-1') + }) + + it('counts a run whose engine threw, after the error was logged', async () => { + executorExecuteMock.mockRejectedValue(new Error('engine failed')) + + await expect( + executeWorkflowCore({ + snapshot: createSnapshot() as any, + callbacks: {}, + loggingSession: loggingSession as any, + }) + ).rejects.toThrow('engine failed') + + await loggingSession.setPostExecutionPromise.mock.calls[0][0] + + expect(safeCompleteWithErrorMock).toHaveBeenCalledTimes(1) + expect(updateWorkflowRunCountsMock).toHaveBeenCalledWith('workflow-1') + expect(safeCompleteWithErrorMock.mock.invocationCallOrder[0]).toBeLessThan( + updateWorkflowRunCountsMock.mock.invocationCallOrder[0] + ) }) it('routes paused executions through safeCompleteWithPause', async () => { diff --git a/apps/sim/lib/workflows/executor/execution-core.ts b/apps/sim/lib/workflows/executor/execution-core.ts index c83314e5f82..07abe72a11c 100644 --- a/apps/sim/lib/workflows/executor/execution-core.ts +++ b/apps/sim/lib/workflows/executor/execution-core.ts @@ -273,15 +273,31 @@ export function wasExecutionFinalizedByCore(error: unknown, executionId?: string ) } +/** + * Counts a settled run — completed, failed, or cancelled alike — on the + * workflow and stamps `lastRunAt`. Paused runs are not settled and are counted + * when they finish. Awaited from the finalization path rather than fired and + * forgotten, so a process reload after the log write cannot drop it. + */ +async function recordSettledRun(workflowId: string, requestId: string): Promise { + try { + await updateWorkflowRunCounts(workflowId) + } catch (error) { + logger.error(`[${requestId}] Failed to update run counts`, { error }) + } +} + async function finalizeExecutionOutcome(params: { result: ExecutionResult loggingSession: LoggingSession + workflowId: string executionId: string requestId: string workflowInput: unknown abortSignal?: AbortSignal }): Promise { - const { result, loggingSession, executionId, requestId, workflowInput, abortSignal } = params + const { result, loggingSession, workflowId, executionId, requestId, workflowInput, abortSignal } = + params const { traceSpans, totalDuration } = buildTraceSpans(result) const endedAt = new Date().toISOString() @@ -339,18 +355,23 @@ async function finalizeExecutionOutcome(params: { }) ) } + + // Every non-paused outcome above is a settled run; the paused branch returned. + await recordSettledRun(workflowId, requestId) } async function finalizeExecutionError(params: { error: unknown loggingSession: LoggingSession + workflowId: string executionId: string requestId: string }): Promise { - const { error, loggingSession, executionId, requestId } = params + const { error, loggingSession, workflowId, executionId, requestId } = params const executionResult = hasExecutionResult(error) ? error.executionResult : undefined const { traceSpans } = executionResult ? buildTraceSpans(executionResult) : { traceSpans: [] } + let finalized = false try { await loggingSession.safeCompleteWithError({ endedAt: new Date().toISOString(), @@ -363,18 +384,20 @@ async function finalizeExecutionError(params: { executionState: executionResult?.executionState, }) - const finalized = loggingSession.hasCompleted() + finalized = loggingSession.hasCompleted() if (finalized) { await clearExecutionCancellationSafely(executionId, requestId) } - return finalized } catch (postExecError) { logger.error( `[${requestId}] Post-execution error logging failed`, loggingSession.projectDiagnosticError(postExecError, { executionId }) ) - return false } + + // A run that threw after starting is a failed run, and failed runs count. + await recordSettledRun(workflowId, requestId) + return finalized } /** @@ -1119,19 +1142,12 @@ async function executeWorkflowCoreImpl( await finalizeExecutionOutcome({ result, loggingSession, + workflowId, executionId, requestId, workflowInput: processedInput, abortSignal, }) - - if (result.success && result.status !== 'paused') { - try { - await updateWorkflowRunCounts(workflowId) - } catch (runCountError) { - logger.error(`[${requestId}] Failed to update run counts`, { error: runCountError }) - } - } } catch (postExecError) { logger.error( `[${requestId}] Post-execution logging failed`, @@ -1180,6 +1196,7 @@ async function executeWorkflowCoreImpl( ? await finalizeExecutionError({ error, loggingSession, + workflowId, executionId, requestId, }) diff --git a/apps/sim/lib/workflows/streaming/resolve-output-selectors.test.ts b/apps/sim/lib/workflows/streaming/resolve-output-selectors.test.ts index 5e52d36c095..3fd0ccdc70b 100644 --- a/apps/sim/lib/workflows/streaming/resolve-output-selectors.test.ts +++ b/apps/sim/lib/workflows/streaming/resolve-output-selectors.test.ts @@ -59,6 +59,42 @@ describe('resolveOutputSelectors', () => { selectedOutputs: ['missing.result.text'], currentBlocks: { [ROOT_BLOCK_ID]: block(ROOT_BLOCK_ID, 'Root Agent') }, }) - ).toThrow('Selected output block does not resolve: missing') + ).toThrow( + 'Unknown block "missing" in selector "missing.result.text". Available blocks: Root Agent' + ) + }) + + it('names every available block when a selector head matches none, in either name form', () => { + const OTHER_BLOCK_ID = '33333333-3333-4333-8333-333333333333' + const currentBlocks = { + [ROOT_BLOCK_ID]: block(ROOT_BLOCK_ID, 'Root Agent'), + [OTHER_BLOCK_ID]: block(OTHER_BLOCK_ID, 'Start'), + } + + expect(() => + resolveOutputSelectors({ selectedOutputs: ['Agent 2.content'], currentBlocks }) + ).toThrow( + 'Unknown block "Agent 2" in selector "Agent 2.content". Available blocks: Root Agent, Start' + ) + expect( + resolveOutputSelectors({ + selectedOutputs: ['Root Agent.content', 'rootagent.content', OTHER_BLOCK_ID], + currentBlocks, + }) + ).toEqual([`${ROOT_BLOCK_ID}_content`, `${ROOT_BLOCK_ID}_content`, OTHER_BLOCK_ID]) + }) + + it('keeps the resolver error for an ambiguous name rather than calling it unknown', () => { + const OTHER_BLOCK_ID = '33333333-3333-4333-8333-333333333333' + + expect(() => + resolveOutputSelectors({ + selectedOutputs: ['agent.content'], + currentBlocks: { + [ROOT_BLOCK_ID]: block(ROOT_BLOCK_ID, 'Agent'), + [OTHER_BLOCK_ID]: block(OTHER_BLOCK_ID, 'agent'), + }, + }) + ).toThrow('Selected output block does not resolve: agent') }) }) diff --git a/apps/sim/lib/workflows/streaming/resolve-output-selectors.ts b/apps/sim/lib/workflows/streaming/resolve-output-selectors.ts index bb83bd40600..a59d6cad7b6 100644 --- a/apps/sim/lib/workflows/streaming/resolve-output-selectors.ts +++ b/apps/sim/lib/workflows/streaming/resolve-output-selectors.ts @@ -12,6 +12,36 @@ interface ResolveOutputSelectorsOptions { currentBlocks: Record } +/** + * Resolves a current-workflow block reference, naming the blocks the caller could + * have meant when it matches none. A silently empty `blockOutputs` made a typo in + * `--select-output` indistinguishable from a block that produced nothing; the + * available names are the fix, so they travel with the refusal. An ambiguous + * reference (two blocks normalizing to one name) keeps the resolver's own error. + */ +function resolveCurrentBlockId( + blockRef: string, + selector: string, + currentBlocks: Record +): string { + try { + return resolveOutputBlockRef(blockRef, currentBlocks) + } catch (error) { + const blocks = Object.values(currentBlocks) + const normalizedRef = normalizeName(blockRef) + const known = blocks.some( + (block) => block.id === blockRef || normalizeName(block.name || '') === normalizedRef + ) + if (known) throw error + const available = blocks + .map((block) => block.name?.trim() || block.id) + .sort((a, b) => a.localeCompare(b)) + throw new Error( + `Unknown block "${blockRef}" in selector "${selector}". Available blocks: ${available.join(', ')}` + ) + } +} + /** Resolves current-workflow names and leaves child names for its authorized loader. */ export function resolveOutputSelectors({ selectedOutputs, @@ -32,7 +62,7 @@ export function resolveOutputSelectors({ const parsed = parseStoredOutputSelector(selector, { currentBlockRefs, childWorkflowIds }) const blockId = parsed.workflowId ? parsed.blockId - : resolveOutputBlockRef(parsed.blockId, currentBlocks) + : resolveCurrentBlockId(parsed.blockId, selector, currentBlocks) return formatInternalOutputSelector(blockId, parsed.path, parsed.workflowId) }) } diff --git a/apps/sim/lib/workflows/utils.test.ts b/apps/sim/lib/workflows/utils.test.ts index 118cde9f663..d114e0355d3 100644 --- a/apps/sim/lib/workflows/utils.test.ts +++ b/apps/sim/lib/workflows/utils.test.ts @@ -11,6 +11,7 @@ import { authMockFns, createSession, createWorkflowRecord, + dbChainMockFns, expectWorkflowAccessDenied, expectWorkflowAccessGranted, queueTableRows, @@ -29,6 +30,7 @@ afterAll(() => { import { createHttpResponseFromBlock, deduplicateWorkflowName, + updateWorkflowRunCounts, validateWorkflowPermissions, } from '@/lib/workflows/utils' @@ -284,3 +286,35 @@ describe('createHttpResponseFromBlock', () => { await expect(response.json()).resolves.toEqual({ issues: [] }) }) }) + +describe('updateWorkflowRunCounts', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + /** + * The increment is done in SQL and the new total read back from the updated + * row, so two runs of one workflow settling together cannot overwrite each + * other's count the way a read-then-write did. + */ + it('increments in place and reports the stored total', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ runCount: 5 }]) + + await expect(updateWorkflowRunCounts('wf-1')).resolves.toEqual({ + success: true, + runsAdded: 1, + newTotal: 5, + }) + expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.workflow) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ lastRunAt: expect.any(Date) }) + ) + }) + + it('rejects when no workflow row was updated', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect(updateWorkflowRunCounts('missing')).rejects.toThrow('Workflow missing not found') + }) +}) diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index 6d0c8ea4ba7..6160e8dfefd 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -248,26 +248,30 @@ export async function resolveWorkflowIdForUser( } } +/** + * Adds settled runs to a workflow's `runCount` and stamps `lastRunAt`. The + * increment happens in SQL: concurrent runs of one workflow settle at the same + * time, and a read-then-write would let one overwrite the other's count. + */ export async function updateWorkflowRunCounts(workflowId: string, runs = 1) { try { - const workflow = await getWorkflowById(workflowId) - if (!workflow) { - logger.error(`Workflow ${workflowId} not found`) - throw new Error(`Workflow ${workflowId} not found`) - } - - await db + const [updated] = await db .update(workflowTable) .set({ - runCount: workflow.runCount + runs, + runCount: sql`${workflowTable.runCount} + ${runs}`, lastRunAt: new Date(), }) .where(eq(workflowTable.id, workflowId)) + .returning({ runCount: workflowTable.runCount }) + if (!updated) { + logger.error(`Workflow ${workflowId} not found`) + throw new Error(`Workflow ${workflowId} not found`) + } return { success: true, runsAdded: runs, - newTotal: workflow.runCount + runs, + newTotal: updated.runCount, } } catch (error) { logger.error(`Error updating workflow stats for ${workflowId}`, error) diff --git a/apps/sim/tools/knowledge/search.ts b/apps/sim/tools/knowledge/search.ts index ba3f680274b..5bbd5313be7 100644 --- a/apps/sim/tools/knowledge/search.ts +++ b/apps/sim/tools/knowledge/search.ts @@ -198,7 +198,17 @@ export const knowledgeSearchTool: InternalToolConfig similarity: number + rankScore: number + rank: number rerankerScore?: number } diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 5cd39733bc8..75088d7367e 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -274,7 +274,7 @@ type AddWorkflowGroupResponseRef0 = { inputName: string columnName: string }> - deploymentMode?: 'live' | 'deployed' + deploymentMode: 'live' | 'deployed' autoRun?: boolean } @@ -556,6 +556,13 @@ type ApplyWorkflowOperationsResponseRef2 = { kind: 'credential' | 'resource' | 'custom-tool' | 'mcp-tool' | 'skill' | 'block-output' reason: string }> + tableFieldIssues: Array<{ + blockId: string + blockName: string | null + blockType: string | null + field: string + tableName: string + }> notes: Array } @@ -568,6 +575,7 @@ type ApplyWorkflowOperationsResponseRef3 = { deferred: Array inputValidationErrors: Array mintedBlockIds: Record + previewBlockIds?: Record lint: ApplyWorkflowOperationsResponseRef2 dryRun: boolean } @@ -7415,7 +7423,7 @@ type ListWorkflowGroupsResponseRef0 = { inputName: string columnName: string }> - deploymentMode?: 'live' | 'deployed' + deploymentMode: 'live' | 'deployed' autoRun?: boolean } @@ -9145,6 +9153,13 @@ type ReplaceWorkflowStateResponseRef0 = { kind: 'credential' | 'resource' | 'custom-tool' | 'mcp-tool' | 'skill' | 'block-output' reason: string }> + tableFieldIssues: Array<{ + blockId: string + blockName: string | null + blockType: string | null + field: string + tableName: string + }> notes: Array } @@ -9716,6 +9731,8 @@ type SearchKnowledgeResponseRef0 = { chunkIndex: number metadata: Record similarity: number + rankScore: number + rank: number rerankerScore?: number } @@ -10777,6 +10794,14 @@ export type UpdateTableColumnBody = { } type UpdateTableColumnResponseRef0 = { + workflowId: string + workflowName: string + blockId: string + blockName: string + fields: Array<'filter' | 'order' | 'data'> +} + +type UpdateTableColumnResponseRef1 = { columns: Array<{ id?: string name: string @@ -10791,10 +10816,11 @@ type UpdateTableColumnResponseRef0 = { multiple?: boolean currencyCode?: string }> + unmigrated: Array } export type UpdateTableColumnResponse = { - data: UpdateTableColumnResponseRef0 + data: UpdateTableColumnResponseRef1 } /** `PATCH /api/v2/tables/[tableId]/rows/[rowId]` */ @@ -11080,7 +11106,7 @@ type UpdateWorkflowGroupResponseRef0 = { inputName: string columnName: string }> - deploymentMode?: 'live' | 'deployed' + deploymentMode: 'live' | 'deployed' autoRun?: boolean } @@ -13275,7 +13301,7 @@ export const V2_OPERATIONS = { selectedOutputs: { kind: 'array', describe: - 'Block output references to include in the response. Use `.` for the executed workflow or `..` for a child workflow; block names are normalized workflow reference names, and selecting a child workflow applies to every invocation of it. On a sync request the named outputs come back in `blockOutputs`, keyed by these selector strings; on a stream they shape the streamed envelope. Selectors that resolve to no block or no value are omitted. Rejected when `async` is true — a queued run has produced nothing to select; narrow the finished run via the run resource instead.', + 'Block output references to include in the response. Use `.` for the executed workflow or `..` for a child workflow; block names are normalized workflow reference names, and selecting a child workflow applies to every invocation of it. On a sync request the named outputs come back in `blockOutputs`, keyed by these selector strings exactly as sent; on a stream they shape the streamed envelope. A selector whose block name or id matches no block in the workflow is rejected with `400` naming the available blocks, before the run starts. A selector whose block did not run or whose path is absent is omitted. Rejected when `async` is true — a queued run has produced nothing to select; narrow the finished run via the run resource instead.', }, includeThinking: { kind: 'boolean', From b507b30b9dc3bebddc35698f4a3f90b3e60994a5 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 3 Sep 2026 02:14:30 +0530 Subject: [PATCH 069/306] agent cli grep: the searchable text leads with the resource's name and description --- .../agent-cli/engines/universal-grep.test.ts | 16 ++++++++++++++++ .../agent-cli/engines/universal-grep.ts | 12 +++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts index 77809b1abb8..a07cbeacd5d 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts @@ -133,6 +133,22 @@ describe('universal grep', () => { expect(result.stdout).not.toContain('sk-should-never-appear') }) + it('matches a resource by its own name, not only its body', async () => { + // `grep fx-` in an fx-* workspace found nothing: a workflow's state carries no name. + const result = await runEngine( + 'grep', + ['fx-etl'], + runtimeWith({ + '/api/v2/workflows': { data: [{ id: 'wf-1', name: 'fx-etl' }], nextCursor: null }, + '/api/v2/workflows/wf-1/state': { data: { blocks: {}, edges: [] } }, + }), + { scope: 'workflows' } + ) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('workflows/fx-etl (wf-1):') + expect(result.stdout).toContain('name: fx-etl') + }) + it('reports no matches honestly', async () => { const result = await runEngine('grep', ['zzz-nope'], runtimeWith(CATALOG), { scope: 'blocks' }) expect(result.exitCode).toBe(0) diff --git a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts index f168b612885..03a83e01f82 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts @@ -1,3 +1,4 @@ +import { isRecordLike } from '@sim/utils/object' import { LRUCache } from 'lru-cache' import type { ReadFileTextResponse } from 'sim/embed' import { @@ -95,8 +96,17 @@ async function mapConcurrent( return results } +/** + * The searchable text leads with the resource's own name and description: a workflow's + * state carries neither, so `grep fx-` in an `fx-*` workspace found nothing. + */ function render(scope: Scope, id: string, label: string, value: unknown): Materialized { - return { scope, id, label, text: JSON.stringify(value, null, 2) } + const description = + isRecordLike(value) && typeof (value as Record).description === 'string' + ? (value as Record).description + : '' + const header = `name: ${label}${description ? `\ndescription: ${description}` : ''}\n` + return { scope, id, label, text: `${header}${JSON.stringify(value, null, 2)}` } } /** One materializer per scope: the list, then each resource as its `get` returns it. */ From 24ee91c1b3eaabfc069031b5c15fcbfd8b5f6422 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 3 Sep 2026 07:36:41 +0530 Subject: [PATCH 070/306] Catalog hides sunset blocks unless includeSunset; enrichment get returns the group's run state and outputs; knowledge upload prints the document fields; docs chunks titled by page not nav link; logs carry hasHandledErrors and opt-in handledErrorRuns; outputTable receipt keeps stdout --- apps/docs/openapi-v2-logs.json | 32 ++++- apps/docs/openapi-v2-resources.json | 10 ++ apps/docs/openapi-v2-tables.json | 42 ++++++- apps/sim/app/api/v2/blocks/route.test.ts | 23 ++++ apps/sim/app/api/v2/blocks/route.ts | 1 + .../uploads/[uploadId]/complete/route.test.ts | 66 +++++----- apps/sim/app/api/v2/logs/route.test.ts | 79 ++++++++++++ apps/sim/app/api/v2/logs/route.ts | 7 ++ apps/sim/app/api/v2/logs/stats/route.test.ts | 35 ++++++ apps/sim/app/api/v2/logs/stats/route.ts | 1 + .../enrichment/[groupId]/route.test.ts | 118 ++++++++++++++++-- .../[rowId]/enrichment/[groupId]/route.ts | 19 ++- apps/sim/app/api/v2/tables/utils.ts | 71 +++++++++-- apps/sim/lib/api/contracts/logs.ts | 2 + .../v2/__tests__/list-pagination.test.ts | 4 + apps/sim/lib/api/contracts/v2/catalog.ts | 10 +- apps/sim/lib/api/contracts/v2/logs-stats.ts | 14 ++- apps/sim/lib/api/contracts/v2/logs.ts | 11 ++ apps/sim/lib/api/contracts/v2/openapi/logs.ts | 1 + .../lib/api/contracts/v2/openapi/tables.ts | 10 +- apps/sim/lib/api/contracts/v2/tables.ts | 40 +++++- .../catalog/application/catalog-context.ts | 18 ++- .../catalog/application/catalog-reads.test.ts | 58 +++++++++ .../lib/catalog/application/list-blocks.ts | 12 +- .../lib/catalog/projection/block-detail.ts | 26 +++- .../catalog/projection/catalog-sweep.test.ts | 18 +++ apps/sim/lib/chunkers/docs-chunker.test.ts | 67 +++++++++- apps/sim/lib/chunkers/docs-chunker.ts | 30 ++++- .../execute-request.test.ts | 50 ++++++++ .../sim/lib/logs/application/get-log-stats.ts | 9 +- apps/sim/lib/logs/handled-errors.ts | 23 ++++ apps/sim/lib/logs/public-filters.ts | 16 ++- apps/sim/lib/logs/public-queries.ts | 3 + apps/sim/lib/logs/stats-queries.ts | 18 ++- apps/sim/lib/logs/stats.test.ts | 32 +++++ apps/sim/lib/logs/stats.ts | 9 ++ .../lib/mothership/docs/docs-search.test.ts | 23 ++++ apps/sim/lib/mothership/docs/docs-search.ts | 23 +++- .../mothership/request/tools/tables.test.ts | 17 +++ .../lib/mothership/request/tools/tables.ts | 11 ++ apps/sim/lib/table/application/rows.test.ts | 34 ++++- apps/sim/lib/table/application/rows.ts | 39 ++++-- .../knowledge-document-upload.test.ts | 8 +- .../protocol/knowledge-document-upload.ts | 11 +- .../src/commands/protocol/logs-follow.test.ts | 1 + packages/sim-cli/src/generated/v2-api.ts | 47 ++++++- 46 files changed, 1094 insertions(+), 105 deletions(-) create mode 100644 apps/sim/lib/logs/handled-errors.ts diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 38d0382f567..979f65036d1 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -223,6 +223,16 @@ "minLength": 1 } }, + { + "name": "includeHandledErrors", + "in": "query", + "required": false, + "description": "Whether `level=error` also selects runs that finished at `info` after a block error was recovered by an error path. Off by default: such a run succeeded, so it is an error only to a caller auditing error handling. Every row reports `hasHandledErrors` whether or not this is set. Job runs carry no block trace, so the flag never widens that branch.", + "schema": { + "description": "Whether `level=error` also selects runs that finished at `info` after a block error was recovered by an error path. Off by default: such a run succeeded, so it is an error only to a caller auditing error handling. Every row reports `hasHandledErrors` whether or not this is set. Job runs carry no block trace, so the flag never widens that branch.", + "type": "boolean" + } + }, { "name": "status", "in": "query", @@ -486,6 +496,16 @@ "description": "Severity level to include." } }, + { + "name": "includeHandledErrors", + "in": "query", + "required": false, + "description": "Whether runs with a handled block error are counted as `handledErrorRuns`, and whether `level=error` also selects them. Off by default: counting them scans each run’s stored trace.", + "schema": { + "description": "Whether runs with a handled block error are counted as `handledErrorRuns`, and whether `level=error` also selects them. Off by default: counting them scans each run’s stored trace.", + "type": "boolean" + } + }, { "name": "startDate", "in": "query", @@ -1017,6 +1037,10 @@ ], "description": "Files the run produced, or null when none are recorded. Only the run's own output files appear; input attachments a caller supplied are addressed through the files API instead." }, + "hasHandledErrors": { + "type": "boolean", + "description": "Whether a block in the run errored and was recovered by an error path. Such a run keeps `level: info`, so this is the only place the handled error shows at run level; pass `includeHandledErrors=true` with `level=error` to list these runs. Always false for a job run." + }, "workflow": { "type": "object", "properties": { @@ -1078,7 +1102,8 @@ "endedAt", "totalDurationMs", "cost", - "files" + "files", + "hasHandledErrors" ], "additionalProperties": false, "title": "Execution log summary", @@ -1326,6 +1351,7 @@ "cost": { "total": 0.0032 }, + "hasHandledErrors": false, "files": [ { "id": "f1c3a7d0-4b52-4a8e-9f61-2d7c8b3e5a04", @@ -1864,6 +1890,10 @@ "type": "number", "description": "Runs in the window that errored." }, + "handledErrorRuns": { + "description": "Runs in the window in which a block errored and was recovered by an error path. Such runs count as successful in every other figure. Present only when `includeHandledErrors=true`.", + "type": "number" + }, "avgLatency": { "type": "number", "description": "Mean run duration in milliseconds across the window, weighted by run count." diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 523204bcb0b..66d2aeaec08 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -4169,6 +4169,16 @@ "enum": ["builtin", "custom"] } }, + { + "name": "includeSunset", + "in": "query", + "required": false, + "description": "Include `legacy` and `deprecated` blocks. Off by default: a sunset block keeps executing where it is already placed, but it is not offered for new authoring. Each returned entry carries `sunset.replacedBy`, the block to build with instead.", + "schema": { + "description": "Include `legacy` and `deprecated` blocks. Off by default: a sunset block keeps executing where it is already placed, but it is not offered for new authoring. Each returned entry carries `sunset.replacedBy`, the block to build with instead.", + "type": "boolean" + } + }, { "name": "sortBy", "in": "query", diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 3b01a472636..f7e97dca354 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -2753,7 +2753,7 @@ ], "responses": { "200": { - "description": "The enrichment run detail, or null when none was recorded.", + "description": "The run state, output cells, and provider cascade for the group on the row.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -10491,10 +10491,29 @@ "title": "Enrichment provider outcome", "description": "One provider's result within an enrichment cascade." }, - "V2RowEnrichmentResponse": { + "V2RowGroupEnrichment": { "type": "object", "properties": { - "data": { + "groupId": { + "type": "string", + "description": "Workflow or enrichment group this answers for." + }, + "runState": { + "anyOf": [ + { + "$ref": "#/components/schemas/V2TableRowRunState" + }, + { + "type": "null" + } + ], + "description": "Most recent run of this group on this row — the same shape `includeRunState` reports — or null when the group has never run for the row." + }, + "outputs": { + "description": "The group's output cells keyed by column name. A column the run has not populated is null.", + "$ref": "#/components/schemas/V2TableRowData" + }, + "cascade": { "anyOf": [ { "$ref": "#/components/schemas/V2EnrichmentRunDetail" @@ -10503,13 +10522,26 @@ "type": "null" } ], - "description": "Response data." + "description": "Provider cascade behind the cell, or null when none was recorded — a manual workflow group, a group that has not run, or a run predating the breakdown." + } + }, + "required": ["groupId", "runState", "outputs", "cascade"], + "additionalProperties": false, + "title": "Row group enrichment", + "description": "Run state, output cells, and provider cascade for one group on one row." + }, + "V2RowEnrichmentResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2RowGroupEnrichment" } }, "required": ["data"], "additionalProperties": false, "title": "Row enrichment response", - "description": "Provider cascade, cost, and timing for one enrichment cell." + "description": "Run state, output cells, and provider cascade for one group on one row." }, "V2TableRunDispatch": { "type": "object", diff --git a/apps/sim/app/api/v2/blocks/route.test.ts b/apps/sim/app/api/v2/blocks/route.test.ts index 489ddaa21fa..1d44e2f9125 100644 --- a/apps/sim/app/api/v2/blocks/route.test.ts +++ b/apps/sim/app/api/v2/blocks/route.test.ts @@ -71,6 +71,7 @@ function blockCursor({ category, capability, source, + includeSunset = false, sortBy = 'id', sortOrder = 'asc', }: { @@ -79,6 +80,7 @@ function blockCursor({ category?: string capability?: string source?: string + includeSunset?: boolean sortBy?: string sortOrder?: string }): string { @@ -90,6 +92,7 @@ function blockCursor({ category, capability, source, + includeSunset, }), offset ) @@ -123,6 +126,7 @@ describe('/api/v2/blocks', () => { category: undefined, capability: undefined, source: undefined, + includeSunset: false, sortBy: 'id', sortOrder: 'asc', limit: 50, @@ -133,6 +137,25 @@ describe('/api/v2/blocks', () => { }) }) + it('asks for sunset blocks only when includeSunset is set, and stamps it into the cursor', async () => { + const response = await GET( + request(`/api/v2/blocks?workspaceId=${WORKSPACE_ID}&includeSunset=true`) + ) + + expect(response.status).toBe(200) + expect(mocks.list).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ includeSunset: true }) }) + ) + + const cursor = blockCursor({ offset: 2 }) + const replayed = await GET( + request( + `/api/v2/blocks?workspaceId=${WORKSPACE_ID}&includeSunset=true&cursor=${encodeURIComponent(cursor)}` + ) + ) + expect(replayed.status).toBe(400) + }) + it('resumes from the offset cursor and mints the next one while pages remain', async () => { mocks.list.mockResolvedValue({ entries: [summary], hasMore: true, offset: 2, limit: 2 }) const cursor = blockCursor({ offset: 2 }) diff --git a/apps/sim/app/api/v2/blocks/route.ts b/apps/sim/app/api/v2/blocks/route.ts index 3c07c77cfd3..908e21362ae 100644 --- a/apps/sim/app/api/v2/blocks/route.ts +++ b/apps/sim/app/api/v2/blocks/route.ts @@ -17,6 +17,7 @@ function blockCursorFilters(query: V2ListBlocksQuery) { category: query.category, capability: query.capability, source: query.source, + includeSunset: query.includeSunset, }) } diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.test.ts index a4a5fc7ed72..f2672c6b978 100644 --- a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.test.ts @@ -41,33 +41,6 @@ vi.mock('@/lib/core/telemetry', () => ({ PlatformEvents: { knowledgeBaseDocumentsUploaded: mocks.platformEvent }, })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) -vi.mock('@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/utils', () => ({ - toV2KnowledgeDocumentUpload: (_session: unknown, document: { id: string } | null) => ({ - id: 'upload-1', - knowledgeBaseId: 'kb-1', - status: 'completed', - name: 'guide.pdf', - contentType: 'application/pdf', - size: 1024, - expiresAt: '2026-08-04T21:00:00.000Z', - error: null, - document: document - ? { - id: document.id, - knowledgeBaseId: 'kb-1', - filename: 'guide.pdf', - fileSize: 1024, - mimeType: 'application/pdf', - processingStatus: 'pending', - chunkCount: 0, - tokenCount: 0, - characterCount: 0, - enabled: true, - createdAt: '2026-08-03T21:01:00.000Z', - } - : null, - }), -})) import { POST } from '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route' @@ -84,8 +57,19 @@ const DOCUMENT = { enabled: true, uploadedAt: new Date('2026-08-03T21:01:00.000Z'), } +/** The session as the completion use case hands it back, in storage shape. */ +const SESSION = { + id: 'upload-1', + knowledgeBaseId: 'kb-1', + status: 'completed', + fileName: 'guide.pdf', + contentType: 'application/pdf', + fileSize: 1024, + expiresAt: new Date('2026-08-04T21:00:00.000Z'), + error: null, +} const RESULT = { - session: { id: 'upload-1' }, + session: SESSION, value: { document: DOCUMENT, created: true, knowledgeBaseName: 'Docs' }, alreadyCompleted: false, workspaceId: WORKSPACE_ID, @@ -160,6 +144,32 @@ describe('POST knowledge-document upload completion', () => { expect(await response.json()).toMatchObject({ data: { document: { id: 'upload-1' } } }) }) + /** + * `knowledge documents upload` answered `filename: null, processingStatus: null`: + * the created row must come back under its own names, freshly `pending` + * with no chunks yet, not only as a session receipt. + */ + it('publishes the created row with its filename, pending status, and zero chunks', async () => { + mocks.completeUpload.mockResolvedValue(RESULT) + + const response = await request().response + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).toMatchObject({ + id: 'upload-1', + status: 'completed', + name: 'guide.pdf', + document: { + id: 'upload-1', + filename: 'guide.pdf', + processingStatus: 'pending', + chunkCount: 0, + createdAt: '2026-08-03T21:01:00.000Z', + }, + }) + }) + it('does not duplicate analytics for an idempotent completion retry', async () => { mocks.completeUpload.mockResolvedValue({ ...RESULT, diff --git a/apps/sim/app/api/v2/logs/route.test.ts b/apps/sim/app/api/v2/logs/route.test.ts index 59898908099..2610487e275 100644 --- a/apps/sim/app/api/v2/logs/route.test.ts +++ b/apps/sim/app/api/v2/logs/route.test.ts @@ -97,6 +97,84 @@ describe('GET /api/v2/logs', () => { }) }) + it('publishes hasHandledErrors on every row and forwards includeHandledErrors as a filter', async () => { + mocks.execute.mockResolvedValueOnce({ + items: [ + { log: { ...log, hasHandledErrors: true } }, + { log: { ...log, executionId: 'run-2', hasHandledErrors: false } }, + { + log: { + kind: 'job', + id: 'job-1', + workspaceId: WORKSPACE_ID, + executionId: 'job-run-1', + level: 'info', + trigger: 'chat', + startedAt: new Date('2026-08-06T00:00:00Z'), + endedAt: new Date('2026-08-06T00:00:01Z'), + totalDurationMs: 1000, + cost: null, + }, + }, + ], + nextCursorKeys: null, + includeFullDetails: false, + includeFinalOutput: false, + includeTraceSpans: false, + }) + const request = new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&level=error&includeHandledErrors=true&includeJobRuns=true` + ) + + const response = await GET(request) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.map((row: { hasHandledErrors: boolean }) => row.hasHandledErrors)).toEqual([ + true, + false, + false, + ]) + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + filters: expect.objectContaining({ level: 'error', includeHandledErrors: true }), + }), + }) + ) + }) + + it('defaults includeHandledErrors off and leaves it out of an unfiltered cursor', async () => { + const request = new NextRequest(`http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}`) + await GET(request) + + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + filters: expect.objectContaining({ includeHandledErrors: false }), + }), + }) + ) + + /** A cursor minted with the flag on must not resume a walk with it off. */ + const cursor = encodeSortedCursor( + cursorSortKey('startedAt', 'desc'), + [log.startedAt.toISOString(), 'run-1'], + cursorScopeKey(cursorRoute(v2ListLogsContract), { + workspaceId: WORKSPACE_ID, + includeHandledErrors: true, + }) + ) + mocks.execute.mockClear() + const replayed = await GET( + new NextRequest( + `http://localhost:3000/api/v2/logs?workspaceId=${WORKSPACE_ID}&cursor=${encodeURIComponent(cursor)}` + ) + ) + expect(replayed.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + it('publishes durationMs on included trace spans from the stored duration', async () => { mocks.execute.mockResolvedValue({ items: [ @@ -590,6 +668,7 @@ describe('GET /api/v2/logs', () => { totalDurationMs: 2000, cost: { total: 0.5 }, files: null, + hasHandledErrors: false, }) }) diff --git a/apps/sim/app/api/v2/logs/route.ts b/apps/sim/app/api/v2/logs/route.ts index e00095dd5f9..65ba3835c51 100644 --- a/apps/sim/app/api/v2/logs/route.ts +++ b/apps/sim/app/api/v2/logs/route.ts @@ -64,6 +64,7 @@ function logCursorFilters(query: { status?: string workflowName?: string includeJobRuns?: boolean + includeHandledErrors?: boolean }) { return cursorScopeKey(cursorRoute(v2ListLogsContract), { workspaceId: query.workspaceId, @@ -88,6 +89,9 @@ function logCursorFilters(query: { // the field existed — including on unfiltered walks, which is precisely what // `folderScopeVersion` above is careful not to do. includeJobRuns: query.includeJobRuns || undefined, + // Same rule: it widens what `level=error` selects, so it is bound, and only + // stamped when on so cursors minted before it existed still decode. + includeHandledErrors: query.includeHandledErrors || undefined, }) } @@ -103,6 +107,7 @@ export const GET = defineV2JsonRoute({ workflowIds: parseUnorderedList(query.workflowIds), triggers: parseUnorderedList(query.triggers), level: query.level, + includeHandledErrors: query.includeHandledErrors, statuses: parseUnorderedList(query.status)?.filter(isPersistedWorkflowExecutionStatus), workflowName: query.workflowName, startDate: query.startDate ? new Date(query.startDate) : undefined, @@ -159,6 +164,7 @@ export const GET = defineV2JsonRoute({ totalDurationMs: log.totalDurationMs, cost: jobCostTotal(log.cost), files: null, + hasHandledErrors: false, } } @@ -175,6 +181,7 @@ export const GET = defineV2JsonRoute({ totalDurationMs: log.totalDurationMs, cost: log.costTotal != null ? { total: Number(log.costTotal) } : null, files: projectLogFiles(log), + hasHandledErrors: log.hasHandledErrors === true, } if (includeFullDetails) { item.workflow = { diff --git a/apps/sim/app/api/v2/logs/stats/route.test.ts b/apps/sim/app/api/v2/logs/stats/route.test.ts index 3fefb3d0998..92a13ed75df 100644 --- a/apps/sim/app/api/v2/logs/stats/route.test.ts +++ b/apps/sim/app/api/v2/logs/stats/route.test.ts @@ -147,6 +147,41 @@ describe('GET /api/v2/logs/stats', () => { ) }) + it('forwards includeHandledErrors and publishes the handled-error count when asked', async () => { + mocks.execute.mockResolvedValueOnce({ + stats: { ...stats, handledErrorRuns: 3 }, + workflowsTruncated: false, + }) + + const response = await GET(request('&level=error&includeHandledErrors=true')) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.handledErrorRuns).toBe(3) + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + filters: expect.objectContaining({ level: 'error', includeHandledErrors: true }), + }), + }) + ) + }) + + it('leaves handledErrorRuns out by default rather than publishing an uncounted zero', async () => { + const response = await GET(request()) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data).not.toHaveProperty('handledErrorRuns') + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + filters: expect.objectContaining({ includeHandledErrors: false }), + }), + }) + ) + }) + it('conceals a workspace the caller cannot reach', async () => { mocks.execute.mockRejectedValueOnce(new OrchestrationError('not_found', 'Workspace not found')) diff --git a/apps/sim/app/api/v2/logs/stats/route.ts b/apps/sim/app/api/v2/logs/stats/route.ts index 9409192d7f8..2631eb9193d 100644 --- a/apps/sim/app/api/v2/logs/stats/route.ts +++ b/apps/sim/app/api/v2/logs/stats/route.ts @@ -27,6 +27,7 @@ export const GET = defineV2JsonRoute({ workflowIds: parseUnorderedList(query.workflowIds), triggers: parseUnorderedList(query.triggers), level: query.level, + includeHandledErrors: query.includeHandledErrors, startDate: query.startDate ? new Date(query.startDate) : undefined, endDate: query.endDate ? new Date(query.endDate) : undefined, }, diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts index 01f7122b5a5..07bbca9f1be 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.test.ts @@ -154,6 +154,39 @@ describe('GET /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', () => ], } + const TABLE = { + id: 'table-1', + schema: { + columns: [ + { id: 'col-email', name: 'email', type: 'text' }, + { id: 'col-name', name: 'name', type: 'text' }, + { id: 'col-title', name: 'title', type: 'text' }, + ], + }, + } + const GROUP = { + id: 'group-1', + workflowId: 'workflow-1', + type: 'enrichment' as const, + outputs: [ + { blockId: 'b1', path: 'email', columnName: 'email' }, + { blockId: 'b1', path: 'title', columnName: 'title' }, + ], + } + const ROW = { + id: 'row-1', + data: { 'col-email': 'ada@example.com', 'col-name': 'Ada' }, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:02.000Z'), + } + const RUN_STATE = { + status: 'completed' as const, + executionId: 'exec-1', + jobId: null, + workflowId: 'workflow-1', + error: null, + } + function read() { const request = new NextRequest( `http://localhost/api/v2/tables/table-1/rows/row-1/enrichment/group-1?workspaceId=${WORKSPACE_ID}`, @@ -172,15 +205,37 @@ describe('GET /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', () => v2RouteMocks.authenticate.mockResolvedValue(AUTH) v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.readEnrichment.mockResolvedValue({ table: { id: 'table-1' }, detail: DETAIL }) + mocks.readEnrichment.mockResolvedValue({ + table: TABLE, + row: ROW, + group: GROUP, + runState: RUN_STATE, + detail: DETAIL, + }) }) - it('delegates the canonical row and group scope and publishes the cascade', async () => { + it('delegates the canonical row and group scope and publishes run state, outputs, and cascade', async () => { const invocation = read() const response = await invocation.response expect(response.status).toBe(200) - expect(await response.json()).toEqual({ data: DETAIL }) + expect(await response.json()).toEqual({ + data: { + groupId: 'group-1', + runState: { + status: 'completed', + executionId: 'exec-1', + workflowId: 'workflow-1', + error: null, + runningBlockIds: [], + blockErrors: {}, + canceledAt: null, + }, + /** Keyed by column name; the declared-but-unwritten output is null, not absent. */ + outputs: { email: 'ada@example.com', title: null }, + cascade: DETAIL, + }, + }) expect(mocks.readEnrichment).toHaveBeenCalledWith({ principal: PRINCIPAL, input: { @@ -193,14 +248,63 @@ describe('GET /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', () => }) }) - /** A cell that never ran is a real answer, not a missing resource. */ - it('answers null for a cell with no recorded run', async () => { - mocks.readEnrichment.mockResolvedValue({ table: { id: 'table-1' }, detail: null }) + /** A row that exists always answers; a group that never ran is `runState: null`, not a bare null. */ + it('answers the row with a null run state when the group has never run for it', async () => { + mocks.readEnrichment.mockResolvedValue({ + table: TABLE, + row: ROW, + group: GROUP, + runState: null, + detail: null, + }) const response = await read().response expect(response.status).toBe(200) - expect(await response.json()).toEqual({ data: null }) + expect(await response.json()).toEqual({ + data: { + groupId: 'group-1', + runState: null, + outputs: { email: 'ada@example.com', title: null }, + cascade: null, + }, + }) + }) + + it('publishes a failed run with its error and the canceled spelling', async () => { + mocks.readEnrichment.mockResolvedValue({ + table: TABLE, + row: ROW, + group: GROUP, + runState: { + ...RUN_STATE, + status: 'cancelled', + error: 'boom', + blockErrors: { b1: 'boom' }, + cancelledAt: '2026-01-01T00:00:03.000Z', + }, + detail: null, + }) + + const body = await (await read().response).json() + + expect(body.data.runState).toMatchObject({ + status: 'canceled', + error: 'boom', + blockErrors: { b1: 'boom' }, + canceledAt: '2026-01-01T00:00:03.000Z', + }) + }) + + it('answers not found for a row or group that does not exist', async () => { + mocks.readEnrichment.mockRejectedValue( + new OrchestrationError('not_found', 'Workflow group not found') + ) + + const response = await read().response + + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') }) it('rejects an unauthenticated read', async () => { diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts index d93d236c7d9..401890aaeec 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts @@ -7,19 +7,23 @@ import { v2TableRowsErrorPolicy } from '@/lib/table/api/row-route-policies' import { tableOperations } from '@/lib/table/application/operations' import { readTableRowEnrichmentDetail } from '@/lib/table/application/rows' import { startTableRun } from '@/lib/table/application/runs' -import { toApiEnrichmentDetail } from '@/app/api/v2/tables/utils' +import { namedRowMapper } from '@/lib/table/cell-format' +import { toApiRowGroupEnrichment } from '@/app/api/v2/tables/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 /** - * The provider cascade behind one enrichment cell: which providers ran, what - * each cost and took, and which produced the match. + * One group's outcome on one row: the run state `includeRunState` reports, the + * output cells that run populated, and — for an enrichment group — the provider + * cascade behind them: which providers ran, what each cost and took, and which + * produced the match. * * Deliberately its own read rather than a field on the row: the breakdown can * carry a dozen provider outcomes per cell, which is why the storage layer * keeps it off the grid read and why `includeRunState` on the row surfaces - * reports the cell's status without it. + * reports the cell's status without it. A row that exists always answers with + * the full shape — `{"data": null}` for a populated row told a caller nothing. * * A pure read, so the default `headSafe` stands. The `POST` on this same path * starts a run — if a future `GET` here ever acquires a side effect, that flag @@ -38,7 +42,12 @@ export const GET = defineV2JsonRoute({ assertedWorkspaceId: query.workspaceId, }), useCase: readTableRowEnrichmentDetail, - present: ({ detail }) => ({ data: toApiEnrichmentDetail(detail) }), + present: ({ table, row, group, runState, detail }) => ({ + data: toApiRowGroupEnrichment( + { row, group, runState, detail }, + namedRowMapper(table.schema.columns) + ), + }), }) export const POST = defineV2JsonRoute({ diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index 2f1e1af27ba..a08f396c87f 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -3,14 +3,21 @@ import type { V2ApiTable, V2EnrichmentProviderOutcome, V2EnrichmentRunDetail, + V2RowGroupEnrichment, V2RowRunState, } from '@/lib/api/contracts/v2/tables' import { getBaseUrl } from '@/lib/core/utils/urls' import { workspaceResourceWebUrl } from '@/lib/resources' -import type { RowData, TableDefinition, TableSchema } from '@/lib/table' +import type { RowData, TableDefinition, TableRowSummary, TableSchema } from '@/lib/table' import { getMaxRowsPerTable } from '@/lib/table/billing' import { buildColumnNameById, remapViewConfigColumnRefs } from '@/lib/table/column-keys' -import type { ColumnDefinition, EnrichmentRunDetail, RowExecutions } from '@/lib/table/types' +import type { + ColumnDefinition, + EnrichmentRunDetail, + RowExecutionMetadata, + RowExecutions, + WorkflowGroup, +} from '@/lib/table/types' import type { TableView } from '@/lib/table/views/service' import { normalizeColumn } from '@/lib/table/wire' import { getUserEmailsByIds, requireResolvedUserEmail } from '@/lib/users/queries' @@ -192,20 +199,60 @@ interface ApiRowInput { function toApiRunState(executions: RowExecutions): Record { const runState: Record = {} for (const [groupId, execution] of Object.entries(executions)) { - runState[groupId] = { - /** Stored as `cancelled`; published as `canceled`. See presentV2TableDispatch. */ - status: execution.status === 'cancelled' ? 'canceled' : execution.status, - executionId: execution.executionId, - workflowId: execution.workflowId, - error: execution.error, - runningBlockIds: execution.runningBlockIds ?? [], - blockErrors: execution.blockErrors ?? {}, - canceledAt: execution.cancelledAt ?? null, - } + runState[groupId] = toApiRunStateEntry(execution) } return runState } +/** One group's run state on one row, in the published shape. */ +function toApiRunStateEntry(execution: RowExecutionMetadata): V2RowRunState { + return { + /** Stored as `cancelled`; published as `canceled`. See presentV2TableDispatch. */ + status: execution.status === 'cancelled' ? 'canceled' : execution.status, + executionId: execution.executionId, + workflowId: execution.workflowId, + error: execution.error, + runningBlockIds: execution.runningBlockIds ?? [], + blockErrors: execution.blockErrors ?? {}, + canceledAt: execution.cancelledAt ?? null, + } +} + +/** The inputs to {@link toApiRowGroupEnrichment}, as the use case returns them. */ +export interface RowGroupEnrichmentInput { + row: TableRowSummary + group: WorkflowGroup + runState: RowExecutionMetadata | null + detail: EnrichmentRunDetail | null +} + +/** + * One group's outcome on one row: the run state `includeRunState` publishes, + * the group's output cells read out of the row, and the provider cascade. + * + * `outputs` is keyed by column NAME through the same `namedRowMapper` the row + * reads use, and every output column the group declares is present — `null` + * when the run has not written it — so a caller can tell "not populated" from + * "not an output of this group". `runState: null` is the group never having + * run for this row; the row itself always answers. + */ +export function toApiRowGroupEnrichment( + input: RowGroupEnrichmentInput, + toNamedRow: (data: RowData) => RowData +): V2RowGroupEnrichment { + const named = toNamedRow(input.row.data) + const outputs: RowData = {} + for (const output of input.group.outputs) { + outputs[output.columnName] = named[output.columnName] ?? null + } + return { + groupId: input.group.id, + runState: input.runState ? toApiRunStateEntry(input.runState) : null, + outputs, + cascade: toApiEnrichmentDetail(input.detail), + } +} + /** * Normalized public row shape: `{ id, data, createdAt, updatedAt }`, plus * `runState` when — and only when — the caller opted in. diff --git a/apps/sim/lib/api/contracts/logs.ts b/apps/sim/lib/api/contracts/logs.ts index 7f7aa2c92bc..6e73aac9c03 100644 --- a/apps/sim/lib/api/contracts/logs.ts +++ b/apps/sim/lib/api/contracts/logs.ts @@ -359,6 +359,8 @@ export const dashboardStatsResponseSchema = z.object({ aggregateSegments: z.array(segmentStatsSchema), totalRuns: z.number(), totalErrors: z.number(), + /** Runs holding a handled block error; present only when the read counted them. */ + handledErrorRuns: z.number().optional(), avgLatency: z.number(), timeBounds: z.object({ start: z.string(), diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index 89a8058b3b2..af888a8e389 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -161,6 +161,8 @@ const CURSOR_BINDINGS: Record = { 'GET /api/v2/blocks': [ 'workspaceId', 'search', + /** Admits sunset blocks into the sequence. */ + 'includeSunset', 'category', 'capability', 'source', @@ -225,6 +227,8 @@ const CURSOR_BINDINGS: Record = { 'workflowName', /** Decides whether the job-run branch is part of the sequence at all. */ 'includeJobRuns', + /** Widens what `level=error` selects, so it changes the sequence. */ + 'includeHandledErrors', ], 'GET /api/v2/mcp-servers': ['workspaceId', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/sandboxes': ['workspaceId', 'search', 'sortBy', 'sortOrder'], diff --git a/apps/sim/lib/api/contracts/v2/catalog.ts b/apps/sim/lib/api/contracts/v2/catalog.ts index bf7c9b07253..7fb70e78f16 100644 --- a/apps/sim/lib/api/contracts/v2/catalog.ts +++ b/apps/sim/lib/api/contracts/v2/catalog.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { booleanQueryFlagSchema, noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, @@ -771,7 +771,13 @@ export const v2ListBlocksQuerySchema = catalogWorkspaceQuerySchema source: z .enum(['builtin', 'custom']) .optional() - .describe("Restrict to built-in blocks or this workspace's deployed custom blocks."), + .describe('Restrict to shipped blocks or to this workspace’s deployed custom blocks.'), + includeSunset: booleanQueryFlagSchema + .optional() + .default(false) + .describe( + 'Include `legacy` and `deprecated` blocks. Off by default: a sunset block keeps executing where it is already placed, but it is not offered for new authoring. Each returned entry carries `sunset.replacedBy`, the block to build with instead.' + ), ...v2SortFields(v2BlockSortFields, { sortBy: 'id', sortOrder: 'asc' }), ...v2PaginationFields({ description: 'Maximum blocks to return per page.' }), }) diff --git a/apps/sim/lib/api/contracts/v2/logs-stats.ts b/apps/sim/lib/api/contracts/v2/logs-stats.ts index 57cfe35dbff..616fcd5e36e 100644 --- a/apps/sim/lib/api/contracts/v2/logs-stats.ts +++ b/apps/sim/lib/api/contracts/v2/logs-stats.ts @@ -1,6 +1,6 @@ import { z } from 'zod' import { MAX_STATS_SEGMENT_COUNT, MAX_STATS_WORKFLOWS } from '@/lib/api/contracts/logs' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { booleanQueryFlagSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { V2_FALSE_VALUES, @@ -121,6 +121,12 @@ export const v2LogStatsSchema = z ), totalRuns: z.number().describe('Runs in the window across the whole workspace.'), totalErrors: z.number().describe('Runs in the window that errored.'), + handledErrorRuns: z + .number() + .optional() + .describe( + 'Runs in the window in which a block errored and was recovered by an error path. Such runs count as successful in every other figure. Present only when `includeHandledErrors=true`.' + ), avgLatency: z .number() .describe('Mean run duration in milliseconds across the window, weighted by run count.'), @@ -208,6 +214,12 @@ export const v2LogStatsQuerySchema = z }) .optional(), level: z.enum(['info', 'error']).describe('Severity level to include.').optional(), + includeHandledErrors: booleanQueryFlagSchema + .describe( + 'Whether runs with a handled block error are counted as `handledErrorRuns`, and whether `level=error` also selects them. Off by default: counting them scans each run’s stored trace.' + ) + .optional() + .default(false), startDate: v2RunWindowBoundSchema('startDate').optional(), endDate: v2RunWindowBoundSchema('endDate').optional(), segmentCount: v2SegmentCountSchema, diff --git a/apps/sim/lib/api/contracts/v2/logs.ts b/apps/sim/lib/api/contracts/v2/logs.ts index 9915ecdd7f1..93b1ac4af05 100644 --- a/apps/sim/lib/api/contracts/v2/logs.ts +++ b/apps/sim/lib/api/contracts/v2/logs.ts @@ -208,6 +208,11 @@ export const v2LogListItemSchema = z .describe('Total execution duration in milliseconds, or null while unavailable.'), cost: v2LogCostSchema, files: v2LogFilesSchema, + hasHandledErrors: z + .boolean() + .describe( + 'Whether a block in the run errored and was recovered by an error path. Such a run keeps `level: info`, so this is the only place the handled error shows at run level; pass `includeHandledErrors=true` with `level=error` to list these runs. Always false for a job run.' + ), /** Present only when `details=full`. */ workflow: v2LogWorkflowSummarySchema .describe('Workflow summary for a full-detail result.') @@ -546,6 +551,12 @@ export const v2ListLogsQuerySchema = v1ListLogsQuerySchema V2_LOG_TRIGGERS_MAX ).optional(), level: z.enum(['info', 'error']).describe('Severity level to include.').optional(), + includeHandledErrors: booleanQueryFlagSchema + .describe( + 'Whether `level=error` also selects runs that finished at `info` after a block error was recovered by an error path. Off by default: such a run succeeded, so it is an error only to a caller auditing error handling. Every row reports `hasHandledErrors` whether or not this is set. Job runs carry no block trace, so the flag never widens that branch.' + ) + .optional() + .default(false), status: v2LogStatusFilterSchema.optional(), workflowName: v2WorkflowNameFilterSchema.optional(), includeJobRuns: booleanQueryFlagSchema diff --git a/apps/sim/lib/api/contracts/v2/openapi/logs.ts b/apps/sim/lib/api/contracts/v2/openapi/logs.ts index b737f4da880..feb79eb14f2 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/logs.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/logs.ts @@ -38,6 +38,7 @@ const LOG_LIST_EXAMPLE = { endedAt: '2026-01-15T10:30:01.250Z', totalDurationMs: 1250, cost: { total: 0.0032 }, + hasHandledErrors: false, files: [ { id: 'f1c3a7d0-4b52-4a8e-9f61-2d7c8b3e5a04', diff --git a/apps/sim/lib/api/contracts/v2/openapi/tables.ts b/apps/sim/lib/api/contracts/v2/openapi/tables.ts index 4543014815c..514db5db858 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/tables.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/tables.ts @@ -1787,11 +1787,13 @@ const declaredRoutes = [ tableOperation({ applicationOperation: tableOperations.readRow, operationId: 'getRowEnrichment', - summary: 'Get Enrichment Run Detail', + summary: 'Get Row Group Run', description: - "Get an enrichment cell's provider attempts, statuses, hosted-key costs, durations, and matching provider. Null means no run detail was recorded; `404` means the table, row, or group does not exist.", + "Retrieve one workflow or enrichment group's outcome on one row: the run state `includeRunState` reports on the row endpoints (`status`, `error`, `workflowId`, `executionId`, …), the group's output cells keyed by column name, and — for an enrichment group — the provider cascade behind them: every configured provider in cascade order, each one's status, hosted-key cost, and duration, plus which provider produced the match. A row that exists always answers; `runState: null` means the group has never run for it, and `cascade: null` that no provider breakdown was recorded. A `404` means the table, row, or group does not exist.", errors: RESOURCE_ERRORS, - success: { description: 'The enrichment run detail, or null when none was recorded.' }, + success: { + description: 'The run state, output cells, and provider cascade for the group on the row.', + }, }), { params: documentedSchema( @@ -1810,7 +1812,7 @@ const declaredRoutes = [ v2GetRowEnrichmentContract.response.schema, 'V2RowEnrichmentResponse', 'Row enrichment response', - 'Provider cascade, cost, and timing for one enrichment cell.' + 'Run state, output cells, and provider cascade for one group on one row.' ), } ), diff --git a/apps/sim/lib/api/contracts/v2/tables.ts b/apps/sim/lib/api/contracts/v2/tables.ts index aa3575f0938..b2a4c4e7874 100644 --- a/apps/sim/lib/api/contracts/v2/tables.ts +++ b/apps/sim/lib/api/contracts/v2/tables.ts @@ -1990,13 +1990,43 @@ export const v2EnrichmentRunDetailSchema = z }) export type V2EnrichmentRunDetail = z.output +/** + * One workflow/enrichment group's outcome on one row: the run state + * `includeRunState` reports on the row reads, the output cells that run + * populated, and — for an enrichment group — the provider cascade behind them. + * + * Never a bare `null` for a row that exists: an existing row and group always + * answer with this shape, and a group that has never run for the row reports + * `runState: null` with its output cells still present. A 404 means the table, + * row, or group does not exist. + */ +export const v2RowGroupEnrichmentSchema = z + .object({ + groupId: z.string().describe('Workflow or enrichment group this answers for.'), + runState: v2RowRunStateSchema + .nullable() + .describe( + 'Most recent run of this group on this row — the same shape `includeRunState` reports — or null when the group has never run for the row.' + ), + outputs: v2RowDataSchema.describe( + "The group's output cells keyed by column name. A column the run has not populated is null." + ), + cascade: v2EnrichmentRunDetailSchema + .nullable() + .describe( + 'Provider cascade behind the cell, or null when none was recorded — a manual workflow group, a group that has not run, or a run predating the breakdown.' + ), + }) + .meta({ + id: 'V2RowGroupEnrichment', + title: 'Row group enrichment', + description: 'Run state, output cells, and provider cascade for one group on one row.', + }) +export type V2RowGroupEnrichment = z.output + /** * The deep read deliberately kept off the paged row surface: `includeRunState` * on the row reads reports the cell's status, this reports how it got there. - * - * `null` is a real answer — the cell has never run, or it ran before the - * cascade breakdown was recorded — and is distinct from a 404, which means the - * table, row, or group does not exist. */ export const v2GetRowEnrichmentContract = defineRouteContract({ method: 'GET', @@ -2005,7 +2035,7 @@ export const v2GetRowEnrichmentContract = defineRouteContract({ query: v2TableWorkspaceQuerySchema, response: { mode: 'json', - schema: v2DataResponse(v2EnrichmentRunDetailSchema.nullable()), + schema: v2DataResponse(v2RowGroupEnrichmentSchema), }, }) diff --git a/apps/sim/lib/catalog/application/catalog-context.ts b/apps/sim/lib/catalog/application/catalog-context.ts index f06375a630e..eb0c588ef8c 100644 --- a/apps/sim/lib/catalog/application/catalog-context.ts +++ b/apps/sim/lib/catalog/application/catalog-context.ts @@ -57,6 +57,16 @@ export async function resolveCatalogGate( return { allowedIntegrations, visibility, customBlockRows } } +/** Per-read relaxations of the visibility predicate. */ +export interface BlockVisibilityOptions { + /** + * Admit a sunset (`legacy` / `deprecated`) block that is hidden from the + * toolbar. Only the lifecycle hide is lifted: an unrevealed preview block, a + * kill-switched one, and one the workspace excludes stay hidden. + */ + includeSunset?: boolean +} + /** * Whether this caller may see a block at all. * @@ -64,8 +74,12 @@ export async function resolveCatalogGate( * detail route 404s on it. Applying a weaker rule to the detail route would let * a caller enumerate unrevealed preview blocks one id at a time. */ -export function isBlockVisibleToCaller(block: BlockConfig, gate: CatalogGate): boolean { - if (block.hideFromToolbar) return false +export function isBlockVisibleToCaller( + block: BlockConfig, + gate: CatalogGate, + options: BlockVisibilityOptions = {} +): boolean { + if (block.hideFromToolbar && !(options.includeSunset && block.sunset)) return false if (isHiddenUnder(gate.visibility, block)) return false if (!isIntegrationDeploymentAvailableForVisibility(block.type, gate.visibility)) return false return isBlockTypeAllowed(block.type, gate) diff --git a/apps/sim/lib/catalog/application/catalog-reads.test.ts b/apps/sim/lib/catalog/application/catalog-reads.test.ts index 8e4b7d7faf8..57ea3e5e2d7 100644 --- a/apps/sim/lib/catalog/application/catalog-reads.test.ts +++ b/apps/sim/lib/catalog/application/catalog-reads.test.ts @@ -352,6 +352,64 @@ describe('catalog block and tool reads', () => { ).rejects.toMatchObject({ code: 'not_found', message: 'Block not found' }) }) + it('leaves a sunset block out of the list unless asked, while its detail leads with the state', async () => { + const legacyTable = block({ + type: 'table', + name: 'Table', + description: 'Read and write table rows.', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'table_v2' }, + }) + mocks.getAllBlocks.mockReturnValue([slackBlock, legacyTable]) + mocks.getBlock.mockImplementation((type: string) => + [slackBlock, legacyTable].find((entry) => entry.type === type) + ) + mocks.getLatestBlockForViewer.mockImplementation((type: string) => + resolveLatestForViewer(type, [slackBlock, legacyTable]) + ) + + const listed = await listCatalogBlocks.execute({ principal: session, input: listInput }) + expect(listed.entries.map((entry) => entry.id)).toEqual(['loop', 'parallel', 'slack']) + + const included = await listCatalogBlocks.execute({ + principal: session, + input: { ...listInput, includeSunset: true }, + }) + expect(included.entries.map((entry) => entry.id)).toEqual([ + 'loop', + 'parallel', + 'slack', + 'table', + ]) + + const table = included.entries.find((entry) => entry.id === 'table') + expect(table?.sunset).toEqual({ status: 'legacy', replacedBy: 'table_v2' }) + + /** The detail read applies the list's default gate: a hidden legacy block stays 404. */ + await expect( + getCatalogBlock.execute({ + principal: session, + input: { workspaceId: WORKSPACE_ID, blockId: 'table' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + it('keeps a kill-switched sunset block hidden even when sunset blocks are asked for', async () => { + const legacyTable = block({ + type: 'table', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'table_v2' }, + }) + mocks.getAllBlocks.mockReturnValue([slackBlock, legacyTable]) + setVisibility({ ...NOTHING_GATED, disabled: new Set(['table']) }) + + const included = await listCatalogBlocks.execute({ + principal: session, + input: { ...listInput, includeSunset: true }, + }) + expect(included.entries.map((entry) => entry.id)).toEqual(['loop', 'parallel', 'slack']) + }) + it('reveals a preview block once the visibility document names it', async () => { mocks.getAllBlocks.mockReturnValue([slackBlock, previewBlock]) setVisibility({ diff --git a/apps/sim/lib/catalog/application/list-blocks.ts b/apps/sim/lib/catalog/application/list-blocks.ts index 8e69bd23e68..73e04261be7 100644 --- a/apps/sim/lib/catalog/application/list-blocks.ts +++ b/apps/sim/lib/catalog/application/list-blocks.ts @@ -27,6 +27,8 @@ export interface ListCatalogBlocksInput { category?: 'blocks' | 'tools' | 'triggers' capability?: 'trigger' source?: 'builtin' | 'custom' + /** Whether `legacy` / `deprecated` blocks appear. Off by default. */ + includeSunset?: boolean sortBy: 'id' | 'name' | 'category' sortOrder: V2SortOrder offset: number @@ -48,6 +50,7 @@ function matchesFilters(block: CatalogBlockSummary, input: ListCatalogBlocksInpu if (input.category && block.category !== input.category) return false if (input.capability === 'trigger' && !block.triggerCapable) return false if (input.source && block.source !== input.source) return false + if (!input.includeSunset && block.sunset !== undefined) return false return true } @@ -59,6 +62,11 @@ function matchesFilters(block: CatalogBlockSummary, input: ListCatalogBlocksInpu * `source` field tells them apart, and `capability=trigger` narrows to the * blocks that can start a workflow rather than needing a second endpoint. * + * A sunset block (`legacy` or `deprecated`) is left out unless `includeSunset` + * is set: it stays readable by id and keeps executing where it is already + * placed, but a list of "what may I place?" must not offer a superseded block + * alongside its replacement. + * * No audit is projected — reading a catalog is not a semantic event, and no * shipped v2 read records one. */ @@ -73,7 +81,9 @@ export const listCatalogBlocks = defineAuthorizedWorkspaceUseCase({ const summaries = await withCatalogBlockScope(gate, async () => [ ...getAllBlocks() - .filter((block) => isBlockVisibleToCaller(block, gate)) + .filter((block) => + isBlockVisibleToCaller(block, gate, { includeSunset: input.includeSunset === true }) + ) .map(projectBlockSummary), // Containers are authorable types (add_block accepts them) that live outside // the registry — the catalog speaks the same vocabulary as authoring. diff --git a/apps/sim/lib/catalog/projection/block-detail.ts b/apps/sim/lib/catalog/projection/block-detail.ts index 3351b9603b6..208a4e614d6 100644 --- a/apps/sim/lib/catalog/projection/block-detail.ts +++ b/apps/sim/lib/catalog/projection/block-detail.ts @@ -374,6 +374,28 @@ export function projectBlockTriggers(block: BlockConfig): CatalogBlockTrigger[] return triggers } +/** + * The summary a detail is built on, with the block's lifecycle state made + * impossible to miss. + * + * A sunset block is still readable by id — a placed instance keeps executing — + * but `blocks get table` handed an agent a detail whose `sunset` sat below the + * operations and tools it had already read, and it built with the superseded + * block. So on a detail the `sunset` field comes first, and the description + * itself opens with the lifecycle state and the successor to migrate to. + */ +function summaryWithSunsetFirst(block: BlockConfig): CatalogBlockSummary { + const summary = projectBlockSummary(block) + if (!summary.sunset) return summary + const label = summary.sunset.status === 'deprecated' ? 'Deprecated' : 'Legacy' + const successor = summary.sunset.replacedBy ? ` — replaced by ${summary.sunset.replacedBy}` : '' + return { + sunset: summary.sunset, + ...summary, + description: `${label}${successor}. ${summary.description}`, + } +} + /** * A custom (deploy-as-block) block's detail. * @@ -386,7 +408,7 @@ function projectCustomBlockDetail(block: BlockConfig): CatalogBlockDetail { (subBlock) => !subBlock.hidden && !subBlock.hideFromCopilot ) return { - ...projectBlockSummary(block), + ...summaryWithSunsetFirst(block), inputSchema: visibleFields.map(projectSubBlock), operationInputSchema: {}, inputDefinitions: {}, @@ -453,7 +475,7 @@ export function projectBlockDetail( } return { - ...projectBlockSummary(block), + ...summaryWithSunsetFirst(block), inputSchema: commonFields, operationInputSchema: operationFields, inputDefinitions, diff --git a/apps/sim/lib/catalog/projection/catalog-sweep.test.ts b/apps/sim/lib/catalog/projection/catalog-sweep.test.ts index 30379b40607..70b429fc833 100644 --- a/apps/sim/lib/catalog/projection/catalog-sweep.test.ts +++ b/apps/sim/lib/catalog/projection/catalog-sweep.test.ts @@ -190,6 +190,24 @@ describe('block detail regressions', () => { } }) + /** + * `blocks get table` handed an agent a detail whose `sunset` sat below the + * operations it had already read, and it built with the superseded block. + */ + it('leads a sunset block’s detail with its lifecycle state and successor', () => { + const detail = projectBlockDetail(registered('table'), { deployment: HOSTED }) + expect(Object.keys(detail)[0]).toBe('sunset') + expect(detail.sunset).toEqual({ status: 'legacy', replacedBy: 'table_v2' }) + expect(detail.description.startsWith('Legacy — replaced by table_v2. ')).toBe(true) + expect(v2BlockDetailSchema.parse(JSON.parse(JSON.stringify(detail))).description).toBe( + detail.description + ) + + const current = projectBlockDetail(registered('table_v2'), { deployment: HOSTED }) + expect(current.sunset).toBeUndefined() + expect(current.description.startsWith('Legacy')).toBe(false) + }) + it('publishes a triggers-category block’s trigger-mode fields as its input schema', () => { const detail = projectBlockDetail(registered('schedule'), { deployment: HOSTED }) const ids = detail.inputSchema.map((field) => field.id) diff --git a/apps/sim/lib/chunkers/docs-chunker.test.ts b/apps/sim/lib/chunkers/docs-chunker.test.ts index 17ff1f33f4a..ffb9fc302d9 100644 --- a/apps/sim/lib/chunkers/docs-chunker.test.ts +++ b/apps/sim/lib/chunkers/docs-chunker.test.ts @@ -7,8 +7,11 @@ vi.mock('@/lib/knowledge/embeddings', () => ({ generateEmbeddings: vi.fn(async () => ({ embeddings: [] })), })) +import { mkdtemp, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' import { ChunkLimitExceededError } from '@/lib/chunkers/chunk-budget' -import { DocsChunker } from '@/lib/chunkers/docs-chunker' +import { DocsChunker, resolveDocumentTitle } from '@/lib/chunkers/docs-chunker' function cleanContent(content: string): string { const chunker = new DocsChunker() @@ -185,3 +188,65 @@ describe('DocsChunker output budget', () => { expect(() => enforceSizeLimit([`${longLine}\n${longLine}`])).toThrow(ChunkLimitExceededError) }) }) + +/** + * `docs search --path docs/tables` answered results titled "Next": every page + * ends on a nav link, so the last chunk sat under it and the indexer read the + * link as its header. A title comes from the frontmatter, else the first `#` + * heading, and never from a link-only line. + */ +describe('DocsChunker page title', () => { + const NAV_LINK = '[Next](/docs/tables/workflow-columns)' + const PARAGRAPH = + 'Tables store rows your workflows read and write. Columns are typed, and every write is validated against the schema before it lands, so a bad row never reaches a downstream block. ' + const BODY = `# Tables + +${PARAGRAPH.repeat(3)} + +## Querying rows + +Use the Table block to query, insert, or update rows from a workflow. ${PARAGRAPH.repeat(3)} + +## ${NAV_LINK} + +${NAV_LINK} +` + + async function chunkPage(content: string) { + const dir = await mkdtemp(join(tmpdir(), 'docs-chunker-')) + const file = join(dir, 'tables.mdx') + await writeFile(file, content) + return new DocsChunker({ chunkSize: 100, chunkOverlap: 0 }).chunkMdxFile(file, dir) + } + + it('titles every chunk by the frontmatter title and never by the trailing nav link', async () => { + const chunks = await chunkPage(`---\ntitle: Tables overview\n---\n${BODY}`) + + expect(chunks.length).toBeGreaterThan(1) + for (const chunk of chunks) { + expect(chunk.metadata.title).toBe('Tables overview') + expect(chunk.headerText).not.toBe(NAV_LINK) + expect(chunk.headerText).not.toBe('Next') + } + expect(chunks.at(-1)?.headerText).toBe('Querying rows') + }) + + it('falls back to the first # heading when there is no frontmatter title', async () => { + const chunks = await chunkPage(BODY) + + expect(chunks.length).toBeGreaterThan(0) + for (const chunk of chunks) expect(chunk.metadata.title).toBe('Tables') + }) + + it('never resolves a link-only heading as the title', () => { + expect( + resolveDocumentTitle({}, [{ level: 2, text: NAV_LINK, anchor: 'next', position: 0 }]) + ).toBeUndefined() + expect( + resolveDocumentTitle({ title: ' ' }, [ + { level: 2, text: 'Querying rows', anchor: 'querying-rows', position: 0 }, + { level: 1, text: 'Tables', anchor: 'tables', position: 10 }, + ]) + ).toBe('Tables') + }) +}) diff --git a/apps/sim/lib/chunkers/docs-chunker.ts b/apps/sim/lib/chunkers/docs-chunker.ts index 00f4918df6c..534d566c77f 100644 --- a/apps/sim/lib/chunkers/docs-chunker.ts +++ b/apps/sim/lib/chunkers/docs-chunker.ts @@ -24,6 +24,29 @@ interface Frontmatter { const logger = createLogger('DocsChunker') +/** + * A line that is nothing but one markdown link — `[Next](/docs/tables/…)`, + * `[← Back](…)`. Docs pages end on these, and they name the neighbour, not the + * page, so neither a title nor a section header may be read from one. + */ +const LINK_ONLY_LINE = /^\s*\[[^\]]*\]\([^)]*\)\s*$/ + +/** + * The page's own title: the frontmatter `title`, else the first `#` heading + * that is not a link-only line, else nothing. A chunk's `headerText` still + * names its section; this is what a search result is titled by when the + * section header would mislead — the last chunk of a page sits under its + * trailing nav, so it used to surface titled by that link. + */ +export function resolveDocumentTitle( + frontmatter: Frontmatter, + headers: readonly HeaderInfo[] +): string | undefined { + const declared = typeof frontmatter.title === 'string' ? frontmatter.title.trim() : '' + if (declared.length > 0) return declared + return headers.find((header) => header.level === 1)?.text +} + /** * One `{ question: "...", answer: "..." }` FAQ item, in either quote style and * with an optional trailing comma (`session-policies.mdx` uses single-quoted @@ -116,6 +139,7 @@ export class DocsChunker { const { chunks: textChunks, cleanedContent } = await this.splitContent(markdownContent) const headers = this.extractHeaders(cleanedContent) + const title = resolveDocumentTitle(frontmatter, headers) logger.info(`Generating embeddings for ${textChunks.length} chunks in ${relativePath}`) /** @@ -150,14 +174,14 @@ export class DocsChunker { tokenCount: estimateTokens(chunkText), sourceDocument: relativePath, headerLink: relevantHeader ? `${documentUrl}#${relevantHeader.anchor}` : documentUrl, - headerText: relevantHeader?.text || frontmatter.title || 'Document Root', + headerText: relevantHeader?.text || title || 'Document Root', headerLevel: relevantHeader?.level || 1, embedding: embeddings[i] || [], embeddingModel, metadata: { startIndex: chunkStart, endIndex: chunkEnd, - title: frontmatter.title, + title, }, } @@ -195,6 +219,8 @@ export class DocsChunker { while ((match = headerRegex.exec(content)) !== null) { const level = match[1].length const text = match[2].trim() + // A heading that is only a link (`## [Next](…)`) is navigation, not a section. + if (LINK_ONLY_LINE.test(text)) continue const anchor = this.generateAnchor(text) headers.push({ diff --git a/apps/sim/lib/function-execution/execute-request.test.ts b/apps/sim/lib/function-execution/execute-request.test.ts index 4a4ebfc557e..7b77a774ccd 100644 --- a/apps/sim/lib/function-execution/execute-request.test.ts +++ b/apps/sim/lib/function-execution/execute-request.test.ts @@ -1012,6 +1012,56 @@ describe('Function execution request', () => { expect(data.resources).toEqual([expect.objectContaining({ path: 'files/report.txt' })]) }) + it('keeps the export receipt and stdout beside returned rows on the JavaScript sandbox path', async () => { + envFlagsMock.isRemoteSandboxEnabled = true + const rows = [{ name: 'Ada' }, { name: 'Grace' }] + mockExecuteInSandbox.mockResolvedValueOnce({ + result: rows, + stdout: 'wrote 2 rows\n', + sandboxId: 'sandbox-123', + exportedFiles: { '/home/user/report.csv': 'name\nAda\nGrace\n' }, + }) + + const response = await POST( + createMockRequest('POST', { + code: 'console.log("wrote 2 rows"); return [{ name: "Ada" }, { name: "Grace" }]', + language: 'javascript', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/report.csv', + sandboxPath: '/home/user/report.csv', + mimeType: 'text/csv', + }, + ], + }, + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(mockExecuteInSandbox).toHaveBeenCalledWith( + expect.objectContaining({ + language: 'javascript', + outputSandboxPaths: ['/home/user/report.csv'], + }) + ) + // Both outputs survive: the table writer reads `result`, the receipt + // rides in `exported`, and stdout is the agent's only diagnostic. + expect(data.output.result).toEqual(rows) + expect(data.output.exported.files).toHaveLength(1) + expect(data.output.exported.files[0]).toEqual( + expect.objectContaining({ + vfsPath: 'files/report.csv', + sandboxPath: '/home/user/report.csv', + }) + ) + expect(data.output.stdout).toBe('wrote 2 rows') + expect(data.output.message).toBe(data.output.exported.message) + }) + it('atomically classifies text exports and acknowledges the durable v2 capability', async () => { envFlagsMock.isRemoteSandboxEnabled = true mockExecuteInSandbox.mockResolvedValueOnce({ diff --git a/apps/sim/lib/logs/application/get-log-stats.ts b/apps/sim/lib/logs/application/get-log-stats.ts index 4056c4a83c0..fd024c4a944 100644 --- a/apps/sim/lib/logs/application/get-log-stats.ts +++ b/apps/sim/lib/logs/application/get-log-stats.ts @@ -57,10 +57,17 @@ export const getLogStats = defineAuthorizedWorkspaceUseCase({ requestedStart: input.filters.startDate, requestedEnd: input.filters.endDate, }) - const rows = await readLogStatsSegments(where, window.startTime.toISOString(), window.segmentMs) + const includeHandledErrors = input.filters.includeHandledErrors === true + const rows = await readLogStatsSegments( + where, + window.startTime.toISOString(), + window.segmentMs, + { countHandledErrors: includeHandledErrors } + ) return buildDashboardStats(rows, window, input.segmentCount, { maxWorkflows: MAX_STATS_WORKFLOWS, includeEmpty: input.includeEmpty === true, + includeHandledErrors, }) }, }) diff --git a/apps/sim/lib/logs/handled-errors.ts b/apps/sim/lib/logs/handled-errors.ts new file mode 100644 index 00000000000..081927ef781 --- /dev/null +++ b/apps/sim/lib/logs/handled-errors.ts @@ -0,0 +1,23 @@ +import { workflowExecutionLogs } from '@sim/db/schema' +import { type SQL, sql } from 'drizzle-orm' + +/** + * Any span in the stored trace, at any depth, that errored and was recovered + * by an error path (`errorHandled: true`). A run with one keeps `level: info` + * because the workflow itself succeeded, which is why the run list cannot + * surface it through `level` alone. + */ +const HANDLED_ERROR_SPAN_PATH = '$.traceSpans.** ? (@.status == "error" && @.errorHandled == true)' + +/** + * Whether a run's stored execution data holds a handled block error. + * + * Reads the inline `execution_data` column only: a trace externalized to the + * blob store is slimmed to its marker keys in the row, so its spans are not + * visible to this predicate and such a run answers `false`. Null execution + * data answers `false` rather than null so the value can be published as a + * boolean and counted with `FILTER`. + */ +export function handledErrorSpanCondition(): SQL { + return sql`COALESCE(jsonb_path_exists(${workflowExecutionLogs.executionData}, ${HANDLED_ERROR_SPAN_PATH}::jsonpath), false)` +} diff --git a/apps/sim/lib/logs/public-filters.ts b/apps/sim/lib/logs/public-filters.ts index 69f22c91386..231842b9e85 100644 --- a/apps/sim/lib/logs/public-filters.ts +++ b/apps/sim/lib/logs/public-filters.ts @@ -1,6 +1,7 @@ import { jobExecutionLogs, workflow, workflowExecutionLogs } from '@sim/db/schema' -import { and, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm' +import { and, eq, gte, inArray, lte, or, type SQL, sql } from 'drizzle-orm' import { escapeLikePattern } from '@/lib/api/list-query' +import { handledErrorSpanCondition } from '@/lib/logs/handled-errors' import type { PersistedWorkflowExecutionStatus } from '@/lib/logs/types' /** Query filters shared by the v1 and v2 public log adapters. */ @@ -21,6 +22,12 @@ export interface LogFilters { */ triggers?: string[] level?: 'info' | 'error' + /** + * Whether `level: 'error'` also selects runs that finished at `info` after a + * block error was recovered by an error path. Off by default: those runs + * succeeded, so they are errors only to a caller auditing error handling. + */ + includeHandledErrors?: boolean /** * Persisted execution statuses to include, matched against the same column the * responses report. Deliberately not derived from `level` + `ended_at` the way @@ -74,7 +81,12 @@ export function buildLogFilters(filters: LogFilters): SQL { // Level filter if (filters.level) { - conditions.push(eq(workflowExecutionLogs.level, filters.level)) + const byLevel = eq(workflowExecutionLogs.level, filters.level) + conditions.push( + filters.level === 'error' && filters.includeHandledErrors + ? (or(byLevel, handledErrorSpanCondition()) ?? byLevel) + : byLevel + ) } if (filters.statuses && filters.statuses.length > 0) { diff --git a/apps/sim/lib/logs/public-queries.ts b/apps/sim/lib/logs/public-queries.ts index 291f23fc098..3b16f77322f 100644 --- a/apps/sim/lib/logs/public-queries.ts +++ b/apps/sim/lib/logs/public-queries.ts @@ -26,6 +26,7 @@ import { } from '@/lib/api/list-query' import { workflowExecutionOriginSql } from '@/lib/logs/execution-origin' import { folderScopeCondition, type LogFolderScope } from '@/lib/logs/folder-scope' +import { handledErrorSpanCondition } from '@/lib/logs/handled-errors' import { buildJobLogFilters, buildLogFilters, @@ -115,6 +116,8 @@ function workflowLogQuery(includeExecutionData: boolean) { totalDurationMs: workflowExecutionLogs.totalDurationMs, costTotal: workflowExecutionLogs.costTotal, files: workflowExecutionLogs.files, + /** Published on every row: a handled block error is invisible in `level`. */ + hasHandledErrors: handledErrorSpanCondition(), executionData: includeExecutionData ? workflowExecutionLogs.executionData : sql`null`, workflowName: workflow.name, workflowDescription: workflow.description, diff --git a/apps/sim/lib/logs/stats-queries.ts b/apps/sim/lib/logs/stats-queries.ts index 1c251c1d8ef..4d21a9868ca 100644 --- a/apps/sim/lib/logs/stats-queries.ts +++ b/apps/sim/lib/logs/stats-queries.ts @@ -1,6 +1,7 @@ import { dbReplica } from '@sim/db' import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { eq, type SQL, sql } from 'drizzle-orm' +import { handledErrorSpanCondition } from '@/lib/logs/handled-errors' /** Oldest and newest run start in the filtered set, or nulls when it is empty. */ export interface LogStatsBounds { @@ -16,6 +17,16 @@ export interface LogStatsSegmentRow { totalExecutions: number successfulExecutions: number avgDurationMs: number + /** Runs holding a handled block error. Always 0 unless the read counted them. */ + handledErrorRuns: number +} + +export interface ReadLogStatsSegmentsOptions { + /** + * Whether to count runs with a handled block error per bucket. Opt-in: it + * scans each run's stored execution data, so it is paid only when asked for. + */ + countHandledErrors?: boolean } /** @@ -54,10 +65,15 @@ export async function readLogStatsBounds(where: SQL | undefined): Promise { return dbReplica .select({ + handledErrorRuns: (options.countHandledErrors + ? sql`COUNT(*) FILTER (WHERE ${handledErrorSpanCondition()})` + : sql`0` + ).as('handled_error_runs'), workflowId: sql`COALESCE(${workflowExecutionLogs.workflowId}, 'deleted')`, workflowName: sql`COALESCE(${workflow.name}, 'Deleted Workflow')`, segmentIndex: diff --git a/apps/sim/lib/logs/stats.test.ts b/apps/sim/lib/logs/stats.test.ts index a92a6715e19..19e4958a86c 100644 --- a/apps/sim/lib/logs/stats.test.ts +++ b/apps/sim/lib/logs/stats.test.ts @@ -21,6 +21,7 @@ function row(overrides: Partial = {}): LogStatsSegmentRow { totalExecutions: 1, successfulExecutions: 1, avgDurationMs: 100, + handledErrorRuns: 0, ...overrides, } } @@ -328,3 +329,34 @@ describe('buildDashboardStats', () => { }) }) }) + +describe('buildDashboardStats handled errors', () => { + const window = { + startTime: new Date('2026-08-06T00:00:00.000Z'), + endTime: new Date('2026-08-06T02:00:00.000Z'), + segmentMs: 3_600_000, + } + + it('sums handled-error runs across every workflow and bucket when asked', () => { + const { stats } = buildDashboardStats( + [ + row({ handledErrorRuns: 2 }), + row({ segmentIndex: 1, handledErrorRuns: 1 }), + row({ workflowId: 'wf-2', workflowName: 'Beta', handledErrorRuns: 0 }), + ], + window, + 2, + { includeHandledErrors: true } + ) + + expect(stats.handledErrorRuns).toBe(3) + /** A handled error is still a successful run in every other figure. */ + expect(stats.totalErrors).toBe(0) + }) + + it('publishes no handled-error count unless the rows were counted', () => { + const { stats } = buildDashboardStats([row({ handledErrorRuns: 2 })], window, 2) + + expect(stats).not.toHaveProperty('handledErrorRuns') + }) +}) diff --git a/apps/sim/lib/logs/stats.ts b/apps/sim/lib/logs/stats.ts index 0e3c37ce142..ba489b0c2dd 100644 --- a/apps/sim/lib/logs/stats.ts +++ b/apps/sim/lib/logs/stats.ts @@ -78,6 +78,12 @@ export function resolveLogStatsWindow( } export interface BuildDashboardStatsOptions { + /** + * Whether `handledErrorRuns` is published. Only meaningful when the rows were + * read with the count; otherwise it would publish a zero that means "not + * counted", which reads as "none". + */ + includeHandledErrors?: boolean /** * Largest number of per-workflow series to return. Omitted means every * workflow, which is what the first-party dashboard reads. @@ -202,6 +208,8 @@ export function buildDashboardStats( let totalErrors = 0 let weightedLatencySum = 0 let latencyCount = 0 + /** Summed straight from the rows: it is a total, not a series. */ + const handledErrorRuns = rows.reduce((sum, row) => sum + Number(row.handledErrorRuns || 0), 0) for (let i = 0; i < segmentCount; i++) { let segTotal = 0 @@ -283,6 +291,7 @@ export function buildDashboardStats( aggregateSegments, totalRuns, totalErrors, + ...(options.includeHandledErrors ? { handledErrorRuns } : {}), avgLatency: latencyCount > 0 ? weightedLatencySum / latencyCount : 0, timeBounds: { start: startTime.toISOString(), end: endTime.toISOString() }, segmentMs, diff --git a/apps/sim/lib/mothership/docs/docs-search.test.ts b/apps/sim/lib/mothership/docs/docs-search.test.ts index d3368c2137f..fd322ae5568 100644 --- a/apps/sim/lib/mothership/docs/docs-search.test.ts +++ b/apps/sim/lib/mothership/docs/docs-search.test.ts @@ -136,6 +136,29 @@ describe('searchDocs results', () => { mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2] }) }) + it('titles a result by its page, with the section only when it adds something', async () => { + mockRows.value = [ + { + chunkText: 'faq', + sourceDocument: 'tables/index.mdx', + sourceLink: 'https://docs.sim.ai/tables#next', + headerText: 'Next', + metadata: { title: 'Tables' }, + similarity: 0.9, + }, + { + chunkText: 'intro', + sourceDocument: 'tables/index.mdx', + sourceLink: 'https://docs.sim.ai/tables', + headerText: 'Tables', + metadata: { title: 'Tables' }, + similarity: 0.8, + }, + ] + const { results } = await searchDocs('tables') + expect(results.map((result) => result.title)).toEqual(['Tables › Next', 'Tables']) + }) + it('returns the docs/ path to read next, folding index pages', async () => { mockRows.value = [ { diff --git a/apps/sim/lib/mothership/docs/docs-search.ts b/apps/sim/lib/mothership/docs/docs-search.ts index dddd6cd4f68..8ca3d5eca63 100644 --- a/apps/sim/lib/mothership/docs/docs-search.ts +++ b/apps/sim/lib/mothership/docs/docs-search.ts @@ -21,6 +21,26 @@ const SIMILARITY_THRESHOLD = 0.3 const DEFAULT_TOP_K = 5 const MAX_TOP_K = 25 +/** + * `Page › Section` when the chunk sits under a named section, else the page + * title. The section alone misled: a page's last chunk sits under its trailing + * `## Next` nav heading, so `docs search` answered results titled "Next" with + * nothing saying which page they were from. Rows indexed before the chunker + * recorded a page title fall back to the section header they always had. + */ +function resultTitle(metadata: unknown, headerText: string): string { + const pageTitle = + metadata && typeof metadata === 'object' && 'title' in metadata + ? (metadata as { title?: unknown }).title + : undefined + if (typeof pageTitle !== 'string' || pageTitle.trim().length === 0) return headerText + const section = headerText.trim() + if (section.length === 0 || section === pageTitle || section === 'Document Root') { + return pageTitle + } + return `${pageTitle} › ${section}` +} + export interface DocsSearchResult { /** The `docs/` VFS path this chunk came from — pass it to `read` for the full page. */ path: string @@ -169,6 +189,7 @@ export async function searchDocs( sourceDocument: docsEmbeddings.sourceDocument, sourceLink: docsEmbeddings.sourceLink, headerText: docsEmbeddings.headerText, + metadata: docsEmbeddings.metadata, similarity: sql`1 - (${docsEmbeddings.embedding} <=> ${queryVector}::vector)`, }) .from(docsEmbeddings) @@ -192,7 +213,7 @@ export async function searchDocs( results.push({ path, url: row.sourceLink, - title: row.headerText, + title: resultTitle(row.metadata, row.headerText), content: row.chunkText, similarity: row.similarity, }) diff --git a/apps/sim/lib/mothership/request/tools/tables.test.ts b/apps/sim/lib/mothership/request/tools/tables.test.ts index a54c76efa3d..20d2fabd8f5 100644 --- a/apps/sim/lib/mothership/request/tools/tables.test.ts +++ b/apps/sim/lib/mothership/request/tools/tables.test.ts @@ -270,6 +270,23 @@ describe('automatic Copilot tool-output table persistence', () => { expect(result.output).toBeUndefined() }) + it('keeps what the code printed beside the table write', async () => { + const context = buildContext() + const rows = [{ name: 'Ada' }] + + const result = await maybeWriteOutputToTable( + RunFunction.id, + { outputTable: 'table-1' }, + { success: true, output: { result: rows, stdout: 'fetched 1 record' } }, + context + ) + + expect(result.output).toMatchObject({ + message: 'Wrote 1 rows to table table-1', + stdout: 'fetched 1 record', + }) + }) + it('keeps the export receipt beside the table write when the run also exported files', async () => { const context = buildContext() const rows = [{ name: 'Ada' }, { name: 'Grace' }] diff --git a/apps/sim/lib/mothership/request/tools/tables.ts b/apps/sim/lib/mothership/request/tools/tables.ts index a4aea77f190..0b83ab0a85c 100644 --- a/apps/sim/lib/mothership/request/tools/tables.ts +++ b/apps/sim/lib/mothership/request/tools/tables.ts @@ -33,6 +33,14 @@ function exportedFiles(rawOutput: unknown): Record[] { return Array.isArray(files) ? files.filter(isRecordLike) : [] } +/** What the code printed, when it printed anything. */ +function printedStdout(rawOutput: unknown): string | undefined { + if (!isRecordLike(rawOutput)) return undefined + return typeof rawOutput.stdout === 'string' && rawOutput.stdout.length > 0 + ? rawOutput.stdout + : undefined +} + /** * Declared output files are written before the table step runs, so a table * failure after them is a partial success. The error result keeps the written @@ -172,6 +180,7 @@ export async function maybeWriteOutputToTable( * or the agent has to `files list` to confirm a write it already made. */ const exported = exportedFiles(rawOutput) + const stdout = printedStdout(rawOutput) const exportNote = exported.length > 0 ? ` and exported ${exported.length} ${exported.length === 1 ? 'file' : 'files'}: ${exported @@ -189,6 +198,8 @@ export async function maybeWriteOutputToTable( tableId: outputTable, rowCount: replaceResult.insertedCount, ...(exported.length > 0 ? { exported: { files: exported } } : {}), + /** What the code printed is the only diagnostic the agent has once rows replace the result. */ + ...(stdout !== undefined ? { stdout } : {}), }, } } catch (err) { diff --git a/apps/sim/lib/table/application/rows.test.ts b/apps/sim/lib/table/application/rows.test.ts index e8b7974d555..cde68df26ab 100644 --- a/apps/sim/lib/table/application/rows.test.ts +++ b/apps/sim/lib/table/application/rows.test.ts @@ -1742,6 +1742,7 @@ describe('enrichment detail id validation', () => { mockResolvePermission.mockResolvedValue('read') mockResolveContext.mockResolvedValue(contextFor(ENRICHED_TABLE)) mockLoadEnrichmentDetail.mockResolvedValue(null) + mockLoadExecutionsForRow.mockResolvedValue({}) }) it('404s on a row id the table does not have', async () => { @@ -1768,7 +1769,8 @@ describe('enrichment detail id validation', () => { expect(mockLoadEnrichmentDetail).not.toHaveBeenCalled() }) - it('still answers null for a real row and group with no recorded run', async () => { + /** A row that exists always answers; "never ran" is a null run state, not a null answer. */ + it('answers the row and group with a null run state when the group has never run', async () => { mockGetRowSummaryById.mockResolvedValue({ id: 'row-1', data: {} }) const result = await readTableRowEnrichmentDetail.execute({ @@ -1776,6 +1778,36 @@ describe('enrichment detail id validation', () => { input: { tableId: ENRICHED_TABLE.id, rowId: 'row-1', groupId: 'group-1' }, }) + expect(result.row).toEqual({ id: 'row-1', data: {} }) + expect(result.group.id).toBe('group-1') + expect(result.runState).toBeNull() expect(result.detail).toBeNull() }) + + it('answers the group’s own run state for a populated row', async () => { + mockGetRowSummaryById.mockResolvedValue({ id: 'row-1', data: { 'column-name': 'Ada' } }) + const groupRun = { + status: 'error', + executionId: 'exec-1', + jobId: null, + workflowId: 'workflow-1', + error: 'boom', + } + mockLoadExecutionsForRow.mockResolvedValue({ + 'group-1': groupRun, + 'group-2': { ...groupRun, status: 'completed' }, + }) + + const result = await readTableRowEnrichmentDetail.execute({ + principal: PRINCIPAL, + input: { tableId: ENRICHED_TABLE.id, rowId: 'row-1', groupId: 'group-1' }, + }) + + expect(result.runState).toEqual(groupRun) + expect(mockLoadExecutionsForRow).toHaveBeenCalledWith( + expect.anything(), + 'row-1', + expect.objectContaining({ budgetBytes: expect.any(Number) }) + ) + }) }) diff --git a/apps/sim/lib/table/application/rows.ts b/apps/sim/lib/table/application/rows.ts index 284e8a794ca..31d3dc8bb95 100644 --- a/apps/sim/lib/table/application/rows.ts +++ b/apps/sim/lib/table/application/rows.ts @@ -19,6 +19,7 @@ import type { Filter, ReplaceRowsResult, RowData, + RowExecutionMetadata, RowExecutions, Sort, SortSpec, @@ -27,6 +28,7 @@ import type { TableRow, TableRowSecretProvenanceWrite, TableRowsCursor, + WorkflowGroup, } from '@/lib/table' import { batchInsertRows, @@ -677,15 +679,23 @@ export interface ReadTableRowEnrichmentInput extends TableScopedInput { } export interface ReadTableRowEnrichmentResult extends TableResult { + /** The stored row, whose cells hold whatever the group's runs have written. */ + row: TableRowSummary + /** The group asked about, resolved from the table schema. */ + group: WorkflowGroup + /** The group's most recent run on this row, or null when it has never run. */ + runState: RowExecutionMetadata | null + /** The enrichment cascade breakdown, or null when none was recorded. */ detail: Awaited> } /** - * The enrichment cascade breakdown — provider outcomes, cost, timing — for one - * cell. Deliberately kept off the hot grid read and fetched on demand by the - * details panel; `null` for a cell with no recorded run, or a run predating the - * feature. The row id and group id are validated first so an unknown id 404s - * instead of being indistinguishable from "no enrichment run yet". + * One group's outcome on one row: its run state, the row it wrote into, and + * the enrichment cascade breakdown — provider outcomes, cost, timing — kept off + * the hot grid read and fetched on demand. The row id and group id are + * validated first so an unknown id 404s instead of being indistinguishable + * from "no run yet"; a row that exists always answers, with `runState: null` + * when the group has never run for it. * * Shares {@link tableOperations.readRow}: this is a projection of the same row, * under the same role, so it is not a second semantic operation. @@ -695,17 +705,24 @@ export const readTableRowEnrichmentDetail = defineAuthorizedTableUseCase({ resolveContext: ({ input }: { input: ReadTableRowEnrichmentInput }) => resolveActiveTableContext(input), async execute({ input, context }): Promise { - const rowExists = await getRowSummaryById(context.tableId, input.rowId, context.workspaceId) - if (!rowExists) throw new OrchestrationError('not_found', 'Row not found') - const groupExists = (context.table.schema.workflowGroups ?? []).some( - (group) => group.id === input.groupId + const row = await getRowSummaryById(context.tableId, input.rowId, context.workspaceId) + if (!row) throw new OrchestrationError('not_found', 'Row not found') + const group = (context.table.schema.workflowGroups ?? []).find( + (candidate) => candidate.id === input.groupId ) - if (!groupExists) { + if (!group) { throw new OrchestrationError('not_found', 'Workflow group not found') } + const [executions, detail] = await Promise.all([ + loadExecutionsForRow(db, input.rowId, { budgetBytes: TABLE_LIMITS.MAX_ROW_RUN_STATE_BYTES }), + loadEnrichmentDetail(db, context.tableId, input.rowId, input.groupId), + ]) return { table: context.table, - detail: await loadEnrichmentDetail(db, context.tableId, input.rowId, input.groupId), + row, + group, + runState: executions[input.groupId] ?? null, + detail, } }, }) diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts index 46dd1e39848..c940447e46f 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.test.ts @@ -179,9 +179,11 @@ describe('knowledge documents upload', () => { expect(JSON.parse(logged[0])).toEqual({ id: 'doc_1', knowledgeBaseId: 'kb_1', - name: 'notes.doc', - size: 5, - status: 'pending', + filename: 'notes.doc', + fileSize: 5, + mimeType: 'application/msword', + processingStatus: 'pending', + chunkCount: 0, }) expect(logged[0]).not.toContain('secret-token') }) diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts index 02a8af0a1d4..b93ad1e7e71 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts @@ -113,12 +113,17 @@ export function attachKnowledgeDocumentUpload(documents: Command): void { if (!completed.document) { throw new Error(`Knowledge upload ${session.id} completed without a document`) } + // The created row, under the names `knowledge documents get` and `list` + // publish: a caller reading `filename` / `processingStatus` off this + // result used to get nulls because it was printed as `name` / `status`. printProtocolResult(profile.output, { id: completed.document.id, knowledgeBaseId: completed.document.knowledgeBaseId, - name: completed.document.filename, - size: completed.document.fileSize, - status: completed.document.processingStatus, + filename: completed.document.filename, + fileSize: completed.document.fileSize, + mimeType: completed.document.mimeType, + processingStatus: completed.document.processingStatus, + chunkCount: completed.document.chunkCount, }) } ) diff --git a/packages/sim-cli/src/commands/protocol/logs-follow.test.ts b/packages/sim-cli/src/commands/protocol/logs-follow.test.ts index b727c0cd95e..bc50f48e4d9 100644 --- a/packages/sim-cli/src/commands/protocol/logs-follow.test.ts +++ b/packages/sim-cli/src/commands/protocol/logs-follow.test.ts @@ -51,6 +51,7 @@ function row(runId: string, startedAt: string): LogRow { totalDurationMs: 12, cost: { total: 0.5 }, files: null, + hasHandledErrors: false, workflow: { id: 'wf_1', name: 'Nightly sync', description: null, deleted: false }, } } diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 75088d7367e..943421bfca9 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -4634,6 +4634,7 @@ export type GetLogStatsQuery = { folderPaths?: string triggers?: string level?: 'info' | 'error' + includeHandledErrors?: boolean startDate?: string endDate?: string segmentCount?: number @@ -4674,6 +4675,7 @@ type GetLogStatsResponseRef2 = { aggregateSegments: Array totalRuns: number totalErrors: number + handledErrorRuns?: number avgLatency: number timeBounds: { start: string @@ -4769,16 +4771,28 @@ export type GetRowEnrichmentQuery = { } type GetRowEnrichmentResponseRef0 = { + status: string + executionId: string | null + workflowId: string + error: string | null + runningBlockIds: Array + blockErrors: Record + canceledAt: string | null +} + +type GetRowEnrichmentResponseRef1 = Record + +type GetRowEnrichmentResponseRef2 = { startedAt: string | null completedAt: string | null durationMs: number totalCost: number matchedProvider: string | null aborted: boolean - providers: Array + providers: Array } -type GetRowEnrichmentResponseRef1 = { +type GetRowEnrichmentResponseRef3 = { id: string label: string toolId: string @@ -4788,8 +4802,15 @@ type GetRowEnrichmentResponseRef1 = { error: string | null } +type GetRowEnrichmentResponseRef4 = { + groupId: string + runState: GetRowEnrichmentResponseRef0 | null + outputs: GetRowEnrichmentResponseRef1 + cascade: GetRowEnrichmentResponseRef2 | null +} + export type GetRowEnrichmentResponse = { - data: GetRowEnrichmentResponseRef0 | null + data: GetRowEnrichmentResponseRef4 } /** `GET /api/v2/sandboxes/[sandboxId]` */ @@ -6049,6 +6070,7 @@ export type ListBlocksQuery = { category?: 'blocks' | 'tools' | 'triggers' capability?: 'trigger' source?: 'builtin' | 'custom' + includeSunset?: boolean sortBy?: 'id' | 'name' | 'category' sortOrder?: 'asc' | 'desc' limit?: number @@ -6731,6 +6753,7 @@ export type ListLogsQuery = { includeFinalOutput?: boolean limit?: number cursor?: string + includeHandledErrors?: boolean status?: string workflowName?: string includeJobRuns?: boolean @@ -6755,6 +6778,7 @@ type ListLogsResponseRef0 = { total: number } | null files: Array | null + hasHandledErrors: boolean workflow?: { id: string | null name: string @@ -13638,6 +13662,11 @@ export const V2_OPERATIONS = { values: ['info', 'error'] as const, describe: 'Severity level to include.', }, + includeHandledErrors: { + kind: 'boolean', + describe: + 'Whether runs with a handled block error are counted as `handledErrorRuns`, and whether `level=error` also selects them. Off by default: counting them scans each run’s stored trace.', + }, startDate: { kind: 'string', describe: @@ -13711,7 +13740,7 @@ export const V2_OPERATIONS = { groupId: 'Workflow or enrichment group to run.', }, responseMode: 'json', - summary: 'Get Enrichment Run Detail', + summary: 'Get Row Group Run', query: { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the table.' }, }, @@ -14359,6 +14388,11 @@ export const V2_OPERATIONS = { values: ['builtin', 'custom'] as const, describe: "Restrict to built-in blocks or this workspace's deployed custom blocks.", }, + includeSunset: { + kind: 'boolean', + describe: + 'Include `legacy` and `deprecated` blocks. Off by default: a sunset block keeps executing where it is already placed, but it is not offered for new authoring. Each returned entry carries `sunset.replacedBy`, the block to build with instead.', + }, sortBy: { kind: 'enum', values: ['id', 'name', 'category'] as const, @@ -15137,6 +15171,11 @@ export const V2_OPERATIONS = { describe: 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', }, + includeHandledErrors: { + kind: 'boolean', + describe: + 'Whether `level=error` also selects runs that finished at `info` after a block error was recovered by an error path. Off by default: such a run succeeded, so it is an error only to a caller auditing error handling. Every row reports `hasHandledErrors` whether or not this is set. Job runs carry no block trace, so the flag never widens that branch.', + }, status: { kind: 'string', describe: From 26acb68c63de24065ef06bb6a677bea86ca26fc9 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 3 Sep 2026 08:36:12 +0530 Subject: [PATCH 071/306] Sandbox exports decode by the path rule the reader used: a .jpg declared without a format is written as image/jpeg bytes, not as its base64 text under the json format (thumbnails opened as raw text) --- .../sim/lib/execution/remote-sandbox/index.ts | 22 +---- .../remote-sandbox/sandbox-encoding.test.ts | 33 ++++++++ .../remote-sandbox/sandbox-encoding.ts | 29 +++++++ .../execute-request.test.ts | 83 +++++++++++++++++++ .../lib/function-execution/execute-request.ts | 17 +++- 5 files changed, 160 insertions(+), 24 deletions(-) create mode 100644 apps/sim/lib/execution/remote-sandbox/sandbox-encoding.test.ts create mode 100644 apps/sim/lib/execution/remote-sandbox/sandbox-encoding.ts diff --git a/apps/sim/lib/execution/remote-sandbox/index.ts b/apps/sim/lib/execution/remote-sandbox/index.ts index 591047a0acc..4e1795cfbf9 100644 --- a/apps/sim/lib/execution/remote-sandbox/index.ts +++ b/apps/sim/lib/execution/remote-sandbox/index.ts @@ -38,6 +38,7 @@ import { repairMissingSandboxImage, resolveWorkspaceSandbox, } from '@/lib/execution/remote-sandbox/resolve' +import { isBinarySandboxPath } from '@/lib/execution/remote-sandbox/sandbox-encoding' import { SANDBOX_OUTPUT_DIR_MAX_DEPTH, SANDBOX_OUTPUT_DIR_SENTINEL, @@ -593,25 +594,6 @@ const SIM_RESULT_CORRUPTED_ERROR = "Do not trust or persist this call's output. For large results, write the content to a " + 'file inside the sandbox and export it via outputs.files[].sandboxPath instead of returning it.' -function shouldReadSandboxPathAsBase64(outputSandboxPath: string): boolean { - const ext = outputSandboxPath.slice(outputSandboxPath.lastIndexOf('.')).toLowerCase() - const binaryExts = new Set([ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.pdf', - '.zip', - '.mp3', - '.mp4', - '.docx', - '.pptx', - '.xlsx', - ]) - return binaryExts.has(ext) -} - async function readSandboxOutputFile( sandbox: SandboxHandle, outputSandboxPath: string, @@ -621,7 +603,7 @@ async function readSandboxOutputFile( try { return await sandbox.readFileWithLimit(outputSandboxPath, { maxBytes, - encoding: shouldReadSandboxPathAsBase64(outputSandboxPath) ? 'base64' : 'utf8', + encoding: isBinarySandboxPath(outputSandboxPath) ? 'base64' : 'utf8', signal: options?.signal, }) } catch (error) { diff --git a/apps/sim/lib/execution/remote-sandbox/sandbox-encoding.test.ts b/apps/sim/lib/execution/remote-sandbox/sandbox-encoding.test.ts new file mode 100644 index 00000000000..fe794f07bec --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/sandbox-encoding.test.ts @@ -0,0 +1,33 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { isBinarySandboxPath } from '@/lib/execution/remote-sandbox/sandbox-encoding' + +describe('isBinarySandboxPath', () => { + it('treats image, document, and archive extensions as binary regardless of case', () => { + for (const path of [ + '/home/user/a.jpg', + '/tmp/B.PNG', + '/x/report.pdf', + '/x/deck.pptx', + '/x/data.xlsx', + '/x/a.zip', + ]) { + expect(isBinarySandboxPath(path)).toBe(true) + } + }) + + it('treats text formats and unknown extensions as text', () => { + for (const path of [ + '/home/user/a.json', + '/tmp/rows.csv', + '/x/notes.md', + '/x/script.py', + '/x/noext', + '/x/data.parquet', + ]) { + expect(isBinarySandboxPath(path)).toBe(false) + } + }) +}) diff --git a/apps/sim/lib/execution/remote-sandbox/sandbox-encoding.ts b/apps/sim/lib/execution/remote-sandbox/sandbox-encoding.ts new file mode 100644 index 00000000000..d6243ec4a27 --- /dev/null +++ b/apps/sim/lib/execution/remote-sandbox/sandbox-encoding.ts @@ -0,0 +1,29 @@ +/** + * How a declared sandbox output path is read back and, therefore, how its content must be + * decoded. The reader (remote-sandbox) and the exporter (function-execution) must agree + * on this per path: a `.jpg` read as base64 and then classified as text by its (unknown) + * output format was stored verbatim as base64 text under an `image/jpeg`-less name. + */ +const BINARY_EXTENSIONS: ReadonlySet = new Set([ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.webp', + '.pdf', + '.zip', + '.mp3', + '.mp4', + '.docx', + '.pptx', + '.xlsx', +]) + +/** + * True when a declared output path is read from the sandbox as base64 bytes rather than + * UTF-8 text. + */ +export function isBinarySandboxPath(sandboxPath: string): boolean { + const ext = sandboxPath.slice(sandboxPath.lastIndexOf('.')).toLowerCase() + return BINARY_EXTENSIONS.has(ext) +} diff --git a/apps/sim/lib/function-execution/execute-request.test.ts b/apps/sim/lib/function-execution/execute-request.test.ts index 7b77a774ccd..de9f2cded37 100644 --- a/apps/sim/lib/function-execution/execute-request.test.ts +++ b/apps/sim/lib/function-execution/execute-request.test.ts @@ -965,6 +965,89 @@ describe('Function execution request', () => { ]) }) + it('exports a .jpg declared without a format as image/jpeg bytes, never as base64 text', async () => { + // The sandbox reads a .jpg back as base64; the exporter used to classify it by its + // (unknown) output format, default to json, and store the base64 string verbatim. + envFlagsMock.isRemoteSandboxEnabled = true + const jpegBase64 = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]).toString('base64') + mockExecuteInSandbox.mockResolvedValueOnce({ + result: 'done', + stdout: 'ok', + sandboxId: 'sandbox-123', + cost: { input: 0, output: 0, total: 0.0001 }, + exportedFiles: { + '/home/user/thumbs/01.jpg': jpegBase64, + '/home/user/summary.json': '{"ok":true}', + }, + }) + + const req = createMockRequest('POST', { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + outputs: { + files: [ + { + path: 'files/thumbs/01.jpg', + mode: 'create', + sandboxPath: '/home/user/thumbs/01.jpg', + }, + { path: 'files/summary.json', mode: 'create', sandboxPath: '/home/user/summary.json' }, + ], + }, + }) + + const response = await POST(req) + expect(response.status).toBe(200) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(2) + const [jpgCall, jsonCall] = mockWriteWorkspaceFileByPath.mock.calls.map((call) => call[0]) + expect(jpgCall.target).toEqual(expect.objectContaining({ path: 'files/thumbs/01.jpg' })) + expect(jpgCall.inferredMimeType).toBe('image/jpeg') + expect(Buffer.from(jpgCall.buffer).equals(Buffer.from(jpegBase64, 'base64'))).toBe(true) + expect(jsonCall.target).toEqual(expect.objectContaining({ path: 'files/summary.json' })) + expect(jsonCall.inferredMimeType).toBe('application/json') + expect(Buffer.from(jsonCall.buffer).toString('utf-8')).toBe('{"ok":true}') + }) + + it('exports a single .jpg declared without a format as image/jpeg bytes (single-file path)', async () => { + envFlagsMock.isRemoteSandboxEnabled = true + const jpegBase64 = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]).toString('base64') + mockExecuteInSandbox.mockResolvedValueOnce({ + result: 'done', + stdout: 'ok', + sandboxId: 'sandbox-123', + cost: { input: 0, output: 0, total: 0.0001 }, + exportedFiles: { '/home/user/thumbs/02.jpg': jpegBase64 }, + }) + + const req = createMockRequest('POST', { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + executionId: 'execution-1', + outputs: { + files: [ + { + path: 'files/thumbs/02.jpg', + mode: 'create', + sandboxPath: '/home/user/thumbs/02.jpg', + }, + ], + }, + }) + + const response = await POST(req) + expect(response.status).toBe(200) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1) + const call = mockWriteWorkspaceFileByPath.mock.calls[0]?.[0] + expect(call.target).toEqual(expect.objectContaining({ path: 'files/thumbs/02.jpg' })) + expect(call.inferredMimeType).toBe('image/jpeg') + expect(Buffer.from(call.buffer).equals(Buffer.from(jpegBase64, 'base64'))).toBe(true) + }) + it("keeps the code's returned rows beside the sandbox export receipt", async () => { envFlagsMock.isRemoteSandboxEnabled = true const rows = [{ name: 'Ada' }, { name: 'Grace' }] diff --git a/apps/sim/lib/function-execution/execute-request.ts b/apps/sim/lib/function-execution/execute-request.ts index 7199f226bf3..80a75473604 100644 --- a/apps/sim/lib/function-execution/execute-request.ts +++ b/apps/sim/lib/function-execution/execute-request.ts @@ -74,6 +74,7 @@ import { MAX_SANDBOX_OUTPUT_BYTES, readTrustedSandboxOutputCost, } from '@/lib/execution/remote-sandbox/output-limits' +import { isBinarySandboxPath } from '@/lib/execution/remote-sandbox/sandbox-encoding' import { MAX_BLOCK_MOUNTED_FILES, SANDBOX_OUTPUT_DIR, @@ -1693,11 +1694,16 @@ async function maybeExportSandboxFileToWorkspace(args: { const fileName = normalizeOutputWorkspaceFileName(outputPath) + // Decode the way the sandbox read it (by path), never by guessing from the mime: a + // `.jpg` with no declared format resolved to the json text format and was stored as + // its base64 text (dev, 2026-09-03: thumbnails that opened as "raw text"). + const isBinary = isBinarySandboxPath(outputSandboxPath) const resolvedMimeType = outputMimeType || - FORMAT_TO_CONTENT_TYPE[resolveOutputFormat(fileName, outputFormat)] || + (isBinary + ? getMimeTypeFromExtension(getFileExtension(fileName)) + : FORMAT_TO_CONTENT_TYPE[resolveOutputFormat(fileName, outputFormat)]) || 'application/octet-stream' - const isBinary = !TEXT_OUTPUT_MIME_TYPES.has(resolvedMimeType) const outputBytes = Buffer.byteLength(exportedFileContent, isBinary ? 'base64' : 'utf-8') if (outputBytes > MAX_SANDBOX_OUTPUT_BYTES) { return exportFailure( @@ -1870,11 +1876,14 @@ async function maybeExportSandboxFilesToWorkspace(args: { } const outputPath = file.formatPath ?? file.path const fileName = normalizeOutputWorkspaceFileName(outputPath) + // Same rule as the single-file export: the sandbox path decides the encoding. + const isBinary = isBinarySandboxPath(sandboxPath) const resolvedMimeType = file.mimeType || - FORMAT_TO_CONTENT_TYPE[resolveOutputFormat(fileName, file.format)] || + (isBinary + ? getMimeTypeFromExtension(getFileExtension(fileName)) + : FORMAT_TO_CONTENT_TYPE[resolveOutputFormat(fileName, file.format)]) || 'application/octet-stream' - const isBinary = !TEXT_OUTPUT_MIME_TYPES.has(resolvedMimeType) const size = Buffer.byteLength(content, isBinary ? 'base64' : 'utf-8') totalOutputBytes += size if (totalOutputBytes > MAX_SANDBOX_OUTPUT_BYTES) { From 0b989176d941f299b8fe4dc69d5db5e4479b1cfc Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 3 Sep 2026 09:20:37 +0530 Subject: [PATCH 072/306] Chat uploads resolve by uploads/ for reads, sandbox mounts, and image references (never listings or writes); an unresolved reference image fails the call instead of rendering without it; copilot session-sandbox calls are priced like Function-block sandboxes and report the raw cost beside the billed one; CLI docs regenerated --- apps/docs/content/docs/cli/blocks.mdx | 4 +- .../docs/content/docs/cli/connector-types.mdx | 1 + apps/docs/content/docs/cli/files.mdx | 2 +- apps/docs/content/docs/cli/logs.mdx | 4 + apps/docs/content/docs/cli/reference.mdx | 13 +- apps/docs/content/docs/cli/tables.mdx | 2 +- apps/docs/openapi-v2-files-audit.json | 14 +- apps/docs/openapi-v2-knowledge.json | 2 +- apps/docs/openapi-v2-logs.json | 6 +- apps/docs/openapi-v2-resources.json | 8 +- apps/docs/openapi-v2-tables.json | 433 +----------------- apps/docs/openapi-v2-workflows.json | 58 ++- .../api/v2/files/[fileId]/text/route.test.ts | 38 +- .../app/api/v2/files/[fileId]/text/route.ts | 8 +- apps/sim/app/api/v2/files/utils.ts | 2 + apps/sim/lib/api/contracts/v2/files.ts | 25 +- .../api/contracts/v2/openapi/files-audit.ts | 2 +- .../lib/api/contracts/v2/openapi/workflows.ts | 7 +- .../remote-sandbox/conformance.test.ts | 32 +- .../sim/lib/execution/remote-sandbox/index.ts | 17 +- .../remote-sandbox/session-sandbox.test.ts | 43 ++ .../sim/lib/execution/remote-sandbox/types.ts | 7 + apps/sim/lib/mothership/chat/payload.test.ts | 2 + apps/sim/lib/mothership/chat/payload.ts | 1 + .../tools/handlers/function-execute.ts | 62 ++- .../tools/server/image/generate-image.test.ts | 225 +++++++++ .../tools/server/image/generate-image.ts | 105 +++-- apps/sim/lib/mothership/vfs/path-utils.ts | 6 +- .../workspace/workspace-file-manager.ts | 149 +++++- .../workspace-file-reference.test.ts | 253 ++++++++++ .../read-workspace-file-content.test.ts | 6 +- .../read-workspace-file-content.ts | 9 +- .../read-workspace-file-record.test.ts | 6 +- .../application/read-workspace-file-record.ts | 8 +- .../read-workspace-file-text.test.ts | 85 ++-- .../application/read-workspace-file-text.ts | 27 +- .../resolve-workspace-file-reference.test.ts | 80 ++++ .../resolve-workspace-file-reference.ts | 53 ++- .../application/workspace-file-context.ts | 6 + packages/sim-cli/src/generated/v2-api.ts | 50 +- .../sim-cli/src/telemetry/client-info.test.ts | 6 +- .../sim-cli/src/telemetry/invocation.test.ts | 6 +- packages/sim-cli/src/telemetry/invocation.ts | 6 +- packages/sim-cli/src/update/check.test.ts | 4 +- packages/sim-cli/src/update/check.ts | 6 +- packages/sim-cli/src/update/install.ts | 4 +- 46 files changed, 1264 insertions(+), 629 deletions(-) create mode 100644 apps/sim/lib/mothership/tools/server/image/generate-image.test.ts create mode 100644 apps/sim/lib/uploads/contexts/workspace/workspace-file-reference.test.ts diff --git a/apps/docs/content/docs/cli/blocks.mdx b/apps/docs/content/docs/cli/blocks.mdx index 96166c9fee2..93e6d3ce480 100644 --- a/apps/docs/content/docs/cli/blocks.mdx +++ b/apps/docs/content/docs/cli/blocks.mdx @@ -38,7 +38,9 @@ sim blocks list [options] | `--search ` | No | Case-insensitive substring match against the block id, name, and description. | | `--category ` | No | Restrict to one toolbar category. Accepted values: `blocks`, `tools`, `triggers`. | | `--capability ` | No | Restrict to blocks that can start a workflow — the `triggers` category, blocks declaring `triggerAllowed`, and blocks with trigger-mode fields. Accepted values: `trigger`. | -| `--source ` | No | Restrict to built-in blocks or this workspace's deployed custom blocks. Accepted values: `builtin`, `custom`. | +| `--source ` | No | Restrict to shipped blocks or to this workspace’s deployed custom blocks. Accepted values: `builtin`, `custom`. | +| `--include-sunset` | No | Include `legacy` and `deprecated` blocks. Off by default: a sunset block keeps executing where it is already placed, but it is not offered for new authoring. Each returned entry carries `sunset.replacedBy`, the block to build with instead. | +| `--no-include-sunset` | No | Send --include-sunset as false. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`, `category`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/connector-types.mdx b/apps/docs/content/docs/cli/connector-types.mdx index 587f5d0639c..88dd05a0cde 100644 --- a/apps/docs/content/docs/cli/connector-types.mdx +++ b/apps/docs/content/docs/cli/connector-types.mdx @@ -22,5 +22,6 @@ sim connector-types list [options] | `--search ` | No | Case-insensitive substring match against the connector name. | | `--detail ` | No | Projection of each item. `summary` (the default) carries the identifier, name, description, and auth mode; `full` adds the version, the complete auth settings, the `sourceConfig` field schema, incremental-sync support, and tag definitions. Accepted values: `summary`, `full`. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | diff --git a/apps/docs/content/docs/cli/files.mdx b/apps/docs/content/docs/cli/files.mdx index db168e20bfe..b9ff1776b2b 100644 --- a/apps/docs/content/docs/cli/files.mdx +++ b/apps/docs/content/docs/cli/files.mdx @@ -486,7 +486,7 @@ sim files read [options] | Argument | Required | Description | | --- | --- | --- | -| `fileId` | Yes | File identifier. | +| `fileId` | Yes | File identifier, or the file’s VFS path: `files/<folder>/<name>`, or `uploads/<name>` for a Chat upload. | diff --git a/apps/docs/content/docs/cli/logs.mdx b/apps/docs/content/docs/cli/logs.mdx index 339b678c585..4b35e0db28b 100644 --- a/apps/docs/content/docs/cli/logs.mdx +++ b/apps/docs/content/docs/cli/logs.mdx @@ -51,6 +51,8 @@ sim logs stats [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | +| `--include-handled-errors` | No | Whether runs with a handled block error are counted as `handledErrorRuns`, and whether `level=error` also selects them. Off by default: counting them scans each run’s stored trace. | +| `--no-include-handled-errors` | No | Send --include-handled-errors as false. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--segment-count ` | No | Number of equal time buckets to divide the window into, from 1 to 500. It is the ceiling on how many buckets a series carries: with `includeEmpty=true` exactly this many are returned, otherwise only the buckets holding at least one run. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty. | @@ -85,6 +87,8 @@ sim logs list [options] | `--include-final-output` | No | Include final output in JSON or YAML output (implies full detail). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | | `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--include-handled-errors` | No | Whether `level=error` also selects runs that finished at `info` after a block error was recovered by an error path. Off by default: such a run succeeded, so it is an error only to a caller auditing error handling. Every row reports `hasHandledErrors` whether or not this is set. Job runs carry no block trace, so the flag never widens that branch. | +| `--no-include-handled-errors` | No | Send --include-handled-errors as false. | | `--status ` | No | Comma-separated execution statuses to include, from `pending` \| `running` \| `paused` \| `redacting` \| `completed` \| `failed` \| `cancelled`. An empty entry is rejected. ANDed with `level`, which reports severity rather than lifecycle. | | `--workflow-name ` | No | Case-insensitive substring match against the run's workflow name. Runs whose workflow has been deleted match nothing, because the name is no longer joinable. | | `--include-job-runs` | No | Include Chat and Sim-agent jobs alongside workflow runs. Jobs use `kind: "job"` and have no workflow or cost ledger. Workflow, folder, model, or status filters exclude jobs. This option is valid only when sorting by `startedAt`. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index c3dcd299412..bcfe9893773 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -357,7 +357,9 @@ sim blocks list [options] | `--search ` | No | Case-insensitive substring match against the block id, name, and description. | | `--category ` | No | Restrict to one toolbar category. Accepted values: `blocks`, `tools`, `triggers`. | | `--capability ` | No | Restrict to blocks that can start a workflow — the `triggers` category, blocks declaring `triggerAllowed`, and blocks with trigger-mode fields. Accepted values: `trigger`. | -| `--source ` | No | Restrict to built-in blocks or this workspace's deployed custom blocks. Accepted values: `builtin`, `custom`. | +| `--source ` | No | Restrict to shipped blocks or to this workspace’s deployed custom blocks. Accepted values: `builtin`, `custom`. | +| `--include-sunset` | No | Include `legacy` and `deprecated` blocks. Off by default: a sunset block keeps executing where it is already placed, but it is not offered for new authoring. Each returned entry carries `sunset.replacedBy`, the block to build with instead. | +| `--no-include-sunset` | No | Send --include-sunset as false. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`, `category`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -408,6 +410,7 @@ sim connector-types list [options] | `--search ` | No | Case-insensitive substring match against the connector name. | | `--detail ` | No | Projection of each item. `summary` (the default) carries the identifier, name, description, and auth mode; `full` adds the version, the complete auth settings, the `sourceConfig` field schema, incremental-sync support, and tag definitions. Accepted values: `summary`, `full`. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | @@ -1247,7 +1250,7 @@ sim files read [options] | Argument | Required | Description | | --- | --- | --- | -| `fileId` | Yes | File identifier. | +| `fileId` | Yes | File identifier, or the file’s VFS path: `files/<folder>/<name>`, or `uploads/<name>` for a Chat upload. | @@ -2750,6 +2753,8 @@ sim logs stats [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | +| `--include-handled-errors` | No | Whether runs with a handled block error are counted as `handledErrorRuns`, and whether `level=error` also selects them. Off by default: counting them scans each run’s stored trace. | +| `--no-include-handled-errors` | No | Send --include-handled-errors as false. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--segment-count ` | No | Number of equal time buckets to divide the window into, from 1 to 500. It is the ceiling on how many buckets a series carries: with `includeEmpty=true` exactly this many are returned, otherwise only the buckets holding at least one run. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty. | @@ -2786,6 +2791,8 @@ sim logs list [options] | `--include-final-output` | No | Include final output in JSON or YAML output (implies full detail). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | | `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--include-handled-errors` | No | Whether `level=error` also selects runs that finished at `info` after a block error was recovered by an error path. Off by default: such a run succeeded, so it is an error only to a caller auditing error handling. Every row reports `hasHandledErrors` whether or not this is set. Job runs carry no block trace, so the flag never widens that branch. | +| `--no-include-handled-errors` | No | Send --include-handled-errors as false. | | `--status ` | No | Comma-separated execution statuses to include, from `pending` \| `running` \| `paused` \| `redacting` \| `completed` \| `failed` \| `cancelled`. An empty entry is rejected. ANDed with `level`, which reports severity rather than lifecycle. | | `--workflow-name ` | No | Case-insensitive substring match against the run's workflow name. Runs whose workflow has been deleted match nothing, because the name is no longer joinable. | | `--include-job-runs` | No | Include Chat and Sim-agent jobs alongside workflow runs. Jobs use `kind: "job"` and have no workflow or cost ledger. Workflow, folder, model, or status filters exclude jobs. This option is valid only when sorting by `startedAt`. | @@ -4589,7 +4596,7 @@ sim tables delete [options] ### sim tables enrichment get -Get Enrichment Run Detail +Get Row Group Run ```bash sim tables enrichment get diff --git a/apps/docs/content/docs/cli/tables.mdx b/apps/docs/content/docs/cli/tables.mdx index d7b5d9e6636..51164a51261 100644 --- a/apps/docs/content/docs/cli/tables.mdx +++ b/apps/docs/content/docs/cli/tables.mdx @@ -1043,7 +1043,7 @@ sim tables delete [options] -## Get enrichment run detail +## Get row group run ```bash sim tables enrichment get diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index f78ecfbf4ed..b023cc397f0 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -745,7 +745,7 @@ "get": { "operationId": "readFileText", "summary": "Read File Text", - "description": "Extract text without changing the file. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.", + "description": "Extract text without changing the file. Accepts its ID or canonical path (`files//` or `uploads/` for an unlisted chat upload); the response echoes the read path. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.", "x-sim-operation": "files.read_content", "x-oauth-scope": "api:read", "tags": ["Files"], @@ -754,13 +754,12 @@ "name": "fileId", "in": "path", "required": true, - "description": "File identifier.", + "description": "File identifier, or the file’s VFS path: `files//`, or `uploads/` for a Chat upload.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "maxLength": 4096, + "description": "File identifier, or the file’s VFS path: `files//`, or `uploads/` for a Chat upload." } }, { @@ -4443,6 +4442,10 @@ "type": "string", "description": "File name, including its extension." }, + "path": { + "type": "string", + "description": "Canonical VFS path of the file that was read: `files/…`, or `uploads/` for a Chat upload." + }, "type": { "type": "string", "description": "Stored MIME type of the source file." @@ -4516,6 +4519,7 @@ "required": [ "fileId", "name", + "path", "type", "text", "truncated", diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 5e029f9adf6..a6d53190039 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -6217,7 +6217,7 @@ }, "rankScore": { "type": "number", - "description": "The score results are ordered by, descending. In `vector` mode it equals `similarity`; in `hybrid` mode it is the reciprocal-rank-fusion score (a sum of 1/(60 + rank) over the lexical and vector legs, so a chunk both legs ranked first scores 2/61); when a reranker ordered the results it is `rerankerScore`.", + "description": "The retrieval score, or reranker score when reranked. In `vector` mode it equals `similarity`; in `hybrid` mode it is the reciprocal-rank-fusion score (a sum of 1/(60 + rank) over the lexical and vector legs, so a chunk both legs ranked first scores 2/61); when a reranker ordered the results it is `rerankerScore`. Recency boosting may reorder retrieval results without changing this score; `rank` always reflects returned order.", "examples": [0.0328] }, "rank": { diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 979f65036d1..16e20d17643 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -438,7 +438,7 @@ "get": { "operationId": "getLogStats", "summary": "Get Log Statistics", - "description": "Get run counts, success and error counts, and latency by workspace or workflow. Default bounds span recorded runs, or the last 24 hours when empty. Buckets may extend past the end. Folder filters include descendants; `workflowsTruncated` affects series, not totals. Expired runs are permanently deleted. Retention is 30 days from run start on Free, unlimited on Pro and Team, and configured per organization on Enterprise with workspace overrides. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "description": "Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans `startDate` through `endDate` when both are supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width. The window is divided into `segmentCount` equal buckets whose width is `max(60000, floor(windowMs / segmentCount))` milliseconds. Each series carries only the buckets that hold at least one run unless `includeEmpty` is set, in which case exactly `segmentCount` buckets are returned. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past `timeBounds.end` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and `workflowsTruncated` reports whether the cap applied; the workspace totals are always computed from every workflow. Expired runs are permanently deleted. Retention is 30 days from run start on Free, unlimited on Pro and Team, and configured per organization on Enterprise with workspace overrides. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", "x-sim-operation": "logs.read_stats", "x-oauth-scope": "api:read", "tags": ["Logs"], @@ -534,10 +534,10 @@ "name": "segmentCount", "in": "query", "required": false, - "description": "Number of time buckets, up to 500. Exactly this many are returned, each at least one minute wide. Short windows extend past the requested end and include empty trailing buckets.", + "description": "Number of equal time buckets to divide the window into, from 1 to 500. It is the ceiling on how many buckets a series carries: with `includeEmpty=true` exactly this many are returned, otherwise only the buckets holding at least one run. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty.", "schema": { "default": 72, - "description": "Number of time buckets, up to 500. Exactly this many are returned, each at least one minute wide. Short windows extend past the requested end and include empty trailing buckets.", + "description": "Number of equal time buckets to divide the window into, from 1 to 500. It is the ceiling on how many buckets a series carries: with `includeEmpty=true` exactly this many are returned, otherwise only the buckets holding at least one run. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty.", "type": "integer", "minimum": 1, "maximum": 500 diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 66d2aeaec08..cb1ddd3648a 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -3874,7 +3874,7 @@ "get": { "operationId": "listWorkflowMcpTools", "summary": "List Workflow MCP Tools", - "description": "List a server's published tools by name, including workflow IDs used to unpublish them. Returns up to 2,000 tools with `nextCursor: null`; `truncated` indicates an incomplete inventory that cannot be paginated. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "description": "Every tool a server publishes, tool-name ordered. The server list reports tool *names* only, so this is where a caller reads the `workflowId` that `DELETE /api/v2/workflow-mcp-servers/{serverId}/tools/{workflowId}` addresses. Registrations that undeploying their workflow archived are included with `status: \"inactive\"` rather than omitted; deploying the workflow again makes them `active`. Returned in one page rather than paged — so `nextCursor` is always null — and capped at 2,000 tools, `truncated` indicates an incomplete inventory that cannot be paginated. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", "x-sim-operation": "mcp_servers.workflow_deployments.list_tools", "x-oauth-scope": "api:read", "tags": ["MCP Servers"], @@ -4162,9 +4162,9 @@ "name": "source", "in": "query", "required": false, - "description": "Restrict to built-in blocks or this workspace's deployed custom blocks.", + "description": "Restrict to shipped blocks or to this workspace’s deployed custom blocks.", "schema": { - "description": "Restrict to built-in blocks or this workspace's deployed custom blocks.", + "description": "Restrict to shipped blocks or to this workspace’s deployed custom blocks.", "type": "string", "enum": ["builtin", "custom"] } @@ -4674,7 +4674,7 @@ "get": { "operationId": "listConnectorTypes", "summary": "List Connector Types", - "description": "List connector types and accepted source configuration. A field with `multi: true` stores `string[]`. `canonicalParamId` links picker and manual fields that write the same key; send exactly one, keyed by `canonicalParamId` rather than its own `id`. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.", + "description": "List knowledge-base connector types with opaque cursor pagination, 25 to a page by default. Each item is a summary — identifier, name, description, and auth mode — unless `detail=full` is sent, which adds the source configuration each type accepts. Two properties of a config field decide how its value is sent and are not inferable from the rest: a field with `multi: true` stores a `string[]` rather than a `string`, and a `canonicalParamId` links a picker field to a manual-entry field that write the SAME configuration key — send exactly one of the pair, keyed by `canonicalParamId` rather than by the field's own `id`.\n\nOAuth scope: `api:read`.", "x-sim-operation": "catalog.connector_types.list", "x-oauth-scope": "api:read", "tags": ["Catalog"], diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index f7e97dca354..e42cc112bf8 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -592,7 +592,7 @@ "patch": { "operationId": "updateTableColumn", "summary": "Update Column", - "description": "Update a column by name and return the complete resulting table schema.\n\nOAuth scope: `api:write`.", + "description": "Update a column by name and return the complete resulting table schema.\n\nA rename follows the column everywhere the table keys by column id — rows, views, workflow-group references. Workflow Table blocks are the exception: their `filter`, `order`, and `data` are authored JSON that names columns by name, and this endpoint never rewrites workflow state. Blocks bound to this table that still name the old column come back in `unmigrated`; edit them (`POST /api/v2/workflows/{workflowId}/operations`) or their next run fails on the old name.\n\nOAuth scope: `api:write`.", "x-sim-operation": "tables.columns.update", "x-oauth-scope": "api:write", "tags": ["Tables"], @@ -2519,7 +2519,7 @@ "get": { "operationId": "listTableDispatches", "summary": "List Run Dispatches", - "description": "List the run dispatches on one table, most recent first \u2014 settled dispatches (`complete`, `canceled`) alongside the ones still in flight, so a run that finished between two polls is still visible next to the `dispatchId` its create returned. Capped at the 100 most recent, so this list is unpaginated and `nextCursor` is always null.\n\nOAuth scope: `api:read`.", + "description": "List the run dispatches on one table, most recent first — settled dispatches (`complete`, `canceled`) alongside the ones still in flight, so a run that finished between two polls is still visible next to the `dispatchId` its create returned. Capped at the 100 most recent, so this list is unpaginated and `nextCursor` is always null.\n\nOAuth scope: `api:read`.", "x-sim-operation": "tables.runs.read", "x-oauth-scope": "api:read", "tags": ["Tables"], @@ -2700,8 +2700,8 @@ }, "get": { "operationId": "getRowEnrichment", - "summary": "Get Enrichment Run Detail", - "description": "Get an enrichment cell's provider attempts, statuses, hosted-key costs, durations, and matching provider. Null means no run detail was recorded; `404` means the table, row, or group does not exist.\n\nOAuth scope: `api:read`.", + "summary": "Get Row Group Run", + "description": "Retrieve one workflow or enrichment group's outcome on one row: the run state `includeRunState` reports on the row endpoints (`status`, `error`, `workflowId`, `executionId`, …), the group's output cells keyed by column name, and — for an enrichment group — the provider cascade behind them: every configured provider in cascade order, each one's status, hosted-key cost, and duration, plus which provider produced the match. A row that exists always answers; `runState: null` means the group has never run for it, and `cascade: null` that no provider breakdown was recorded. A `404` means the table, row, or group does not exist.\n\nOAuth scope: `api:read`.", "x-sim-operation": "tables.rows.read", "x-oauth-scope": "api:read", "tags": ["Tables"], @@ -5824,16 +5824,7 @@ }, "type": { "type": "string", - "enum": [ - "string", - "number", - "currency", - "boolean", - "date", - "ttl", - "json", - "select" - ], + "enum": ["string", "number", "currency", "boolean", "date", "json", "select"], "description": "Data type of values stored in the column." }, "required": { @@ -6379,420 +6370,6 @@ "title": "Update table rows response", "description": "Updated row count and identifiers." }, - "TablePredicate": { - "title": "Table predicate", - "description": "Recursive non-empty `all`/`any` groups containing groups or conditions; the root cannot be a condition. Limits: 100 members per group, 10 levels, and 500 nodes. The negating operators include nulls and absent cells, multi-select included; combine with `isNotNull` or `isNotEmpty` to exclude them. Pattern operators use `*` as the only wildcard; `%`, `_`, and backslash are literal. Select operators: single-select uses `eq`/`ne`/`in`/`nin`; multi-select uses `contains`/`ncontains`; option names resolve to IDs. Full operand rules are documented on `op`.", - "type": "object", - "oneOf": [ - { - "type": "object", - "description": "Matches a row when every member matches.", - "properties": { - "all": { - "type": "array", - "minItems": 1, - "maxItems": 100, - "description": "Members combined with AND. An empty group is rejected, because it would compile to no filter at all.", - "items": { - "description": "A nested group, or a single condition.", - "anyOf": [ - { - "$ref": "#/components/schemas/TablePredicate" - }, - { - "type": "object", - "title": "Predicate condition", - "description": "One column comparison.", - "properties": { - "field": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." - }, - "op": { - "type": "string", - "enum": [ - "eq", - "ne", - "gt", - "gte", - "lt", - "lte", - "in", - "nin", - "contains", - "ncontains", - "startsWith", - "endsWith", - "like", - "ilike", - "nlike", - "nilike", - "isEmpty", - "isNotEmpty", - "isNull", - "isNotNull" - ], - "description": "Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`; `in`/`nin` take arrays; `isEmpty`, `isNotEmpty`, `isNull`, and `isNotNull` take no operand. Text operators are `contains`, `ncontains`, `startsWith`, `endsWith`, `like`, `nlike`, `ilike`, and `nilike`. Contains variants are case-insensitive and literal; `like`/`nlike` are case-sensitive, while `ilike`/`nilike` are case-insensitive. `*` is the only wildcard; `%`, `_`, and backslash are literal. For `select` columns, single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names resolve to IDs." - }, - "value": { - "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." - } - }, - "required": ["field", "op"], - "additionalProperties": false - } - ] - } - } - }, - "required": ["all"], - "additionalProperties": false - }, - { - "type": "object", - "description": "Matches a row when at least one member matches.", - "properties": { - "any": { - "type": "array", - "minItems": 1, - "maxItems": 100, - "description": "Members combined with OR. An empty group is rejected, because it would compile to no filter at all.", - "items": { - "description": "A nested group, or a single condition.", - "anyOf": [ - { - "$ref": "#/components/schemas/TablePredicate" - }, - { - "type": "object", - "title": "Predicate condition", - "description": "One column comparison.", - "properties": { - "field": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Column name to compare, or one of the system fields `id`, `createdAt`, `updatedAt`." - }, - "op": { - "type": "string", - "enum": [ - "eq", - "ne", - "gt", - "gte", - "lt", - "lte", - "in", - "nin", - "contains", - "ncontains", - "startsWith", - "endsWith", - "like", - "ilike", - "nlike", - "nilike", - "isEmpty", - "isNotEmpty", - "isNull", - "isNotNull" - ], - "description": "Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`; `in`/`nin` take arrays; `isEmpty`, `isNotEmpty`, `isNull`, and `isNotNull` take no operand. Text operators are `contains`, `ncontains`, `startsWith`, `endsWith`, `like`, `nlike`, `ilike`, and `nilike`. Contains variants are case-insensitive and literal; `like`/`nlike` are case-sensitive, while `ilike`/`nilike` are case-insensitive. `*` is the only wildcard; `%`, `_`, and backslash are literal. For `select` columns, single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names resolve to IDs." - }, - "value": { - "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." - } - }, - "required": ["field", "op"], - "additionalProperties": false - } - ] - } - } - }, - "required": ["any"], - "additionalProperties": false - } - ] - }, - "UpdateTableRowsRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Unique workspace identifier." - }, - "filter": { - "$ref": "#/components/schemas/TablePredicate" - }, - "data": { - "description": "Row-data patch applied to every matching row.", - "$ref": "#/components/schemas/V2TableRowData" - }, - "limit": { - "description": "Maximum matching rows to update.", - "type": "integer", - "minimum": 1, - "maximum": 1000 - } - }, - "required": ["workspaceId", "filter", "data"], - "additionalProperties": false, - "title": "Update table rows request", - "description": "Workspace scope, typed predicate, and row-data patch." - }, - "V2DeleteRowsData": { - "type": "object", - "properties": { - "deletedCount": { - "type": "number", - "description": "Number of deleted rows." - }, - "deletedRowIds": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Identifiers of deleted rows." - }, - "requestedCount": { - "description": "Number of row identifiers requested.", - "type": "number" - }, - "missingRowIds": { - "description": "Requested row identifiers not found.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["deletedCount", "deletedRowIds"], - "additionalProperties": false, - "title": "Delete rows data", - "description": "Result of a bulk row deletion." - }, - "V2DeleteTableRowsResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2DeleteRowsData" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Delete table rows response", - "description": "Deleted row counts, identifiers, and optional missing identifiers." - }, - "DeleteTableRowsRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Unique workspace identifier." - }, - "filter": { - "$ref": "#/components/schemas/TablePredicate" - }, - "limit": { - "description": "Maximum matching rows to delete.", - "type": "integer", - "minimum": 1, - "maximum": 1000 - }, - "rowIds": { - "description": "Explicit row identifiers to delete.", - "minItems": 1, - "maxItems": 1000, - "type": "array", - "items": { - "type": "string", - "minLength": 1 - } - } - }, - "required": ["workspaceId"], - "additionalProperties": false, - "title": "Delete table rows request", - "description": "Workspace scope and exactly one of a predicate or row identifier list.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "rowIds": ["row_1f3e5d7c9b8a4c2d806e4a6b8d0f2e93"] - } - ] - }, - "V2TableRowResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2ApiTableRow" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Table row response", - "description": "A single table row." - }, - "UpdateTableRowRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Unique workspace identifier." - }, - "data": { - "description": "Partial row-data patch keyed by column name.", - "$ref": "#/components/schemas/V2TableRowData" - } - }, - "required": ["workspaceId", "data"], - "additionalProperties": false, - "title": "Update table row request", - "description": "Workspace scope and row-data patch keyed by column name.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "data": { - "status": "active" - } - } - ] - }, - "V2DeleteRowData": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Identifier of the deleted row." - }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Confirms that the row was deleted." - } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete row data", - "description": "Row deletion acknowledgement." - }, - "V2DeleteTableRowResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2DeleteRowData" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Delete table row response", - "description": "Row deletion acknowledgement." - }, - "V2UpsertRowData": { - "type": "object", - "properties": { - "row": { - "description": "The inserted or updated table row.", - "$ref": "#/components/schemas/V2ApiTableRow" - }, - "operation": { - "type": "string", - "enum": ["insert", "update"], - "description": "Whether the row was inserted or updated." - } - }, - "required": ["row", "operation"], - "additionalProperties": false, - "title": "Upsert row data", - "description": "Row returned by an upsert and the operation performed." - }, - "V2UpsertTableRowResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2UpsertRowData" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Upsert table row response", - "description": "The resulting row and whether it was inserted or updated." - }, - "UpsertTableRowRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Unique workspace identifier." - }, - "data": { - "description": "Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges.", - "$ref": "#/components/schemas/V2TableRowData" - }, - "conflictTarget": { - "description": "Unique column used to detect a conflict.", - "type": "string", - "minLength": 1 - } - }, - "required": ["workspaceId", "data"], - "additionalProperties": false, - "title": "Upsert table row request", - "description": "Workspace scope, row data, and optional unique-column conflict target.", - "examples": [ - { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "data": { - "email": "jane@example.com", - "status": "active" - }, - "conflictTarget": "email" - } - ] - }, - "V2QueryTableRowsResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2ApiTableRow" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "Query table rows response", - "description": "A cursor-paginated page of matching table rows." - }, "TablePredicateInput": { "title": "Table predicate input", "description": "One condition or a recursive `all`/`any` group, normalized to a grouped predicate. Limits: 100 members per group, 10 levels, and 500 nodes. The negating operators include nulls and absent cells, multi-select included; combine with `isNotNull` or `isNotEmpty` to exclude them. Pattern operators use `*` as the only wildcard; `%`, `_`, and backslash are literal. Select operators: single-select uses `eq`/`ne`/`in`/`nin`; multi-select uses `contains`/`ncontains`; option names resolve to IDs. Full operand rules are documented on `op`.", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 731a55e6fc6..d79e2870cf0 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -351,7 +351,7 @@ "put": { "operationId": "replaceWorkflowState", "summary": "Replace Workflow State", - "description": "Replace the draft graph atomically; concurrent writes are last-write-wins. Recompute containers from blocks and preserve omitted variables. Foreign IDs return `409`; lint is advisory. The live deployment is unchanged. `dryRun=true` validates without saving, auditing, or notifying; `needsRedeployment` describes the pre-write state. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "description": "Replace a workflow’s editable draft graph wholesale. `loops` and `parallels` are accepted but ignored — both are recomputed from `blocks`. Omitting `variables` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state. Ids are the one conflict that is detected: block, edge, and subflow ids are globally unique, so a body carrying an id another workflow already owns is refused with `409` rather than written.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that `needsRedeployment` becomes true; `POST /workflows/{workflowId}/deploy` publishes the draft.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — including the warnings the write’s own preparation step raises, and the same `409` when an id is already owned by another workflow. Two things differ: `needsRedeployment` describes the state before the write, and block ids are previews — `mintedBlockIds` is empty and the provisional ids come back under `previewBlockIds` with a warning, because the real apply mints new ones.\n\nOAuth scope: `api:write`.", "x-sim-operation": "workflows.state.replace", "x-oauth-scope": "api:write", "tags": ["Workflows"], @@ -1593,7 +1593,7 @@ "get": { "operationId": "getWorkflowDeployment", "summary": "Get Workflow Deployment", - "description": "Get the live version, latest deployment attempt, readiness, draft changes (`needsRedeployment`), and public API access. With `isPublicApi: true`, anyone with the execution URL can run the workflow and consume billed usage without an API key. Hosted chat is managed separately.\n\nOAuth scope: `api:read`.", + "description": "Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, whether the editable draft has since diverged from the live version, and the public delivery URL of every webhook the live version registered. This is the only operation that publishes `needsRedeployment`, `isPublicApi`, and `webhooks`.\n\n`webhooks` is where a caller learns the URL a webhook-triggered deploy started serving: the block's own URL field is computed in the editor and reads back empty through the API. Trigger blocks that receive events through a shared endpoint with no per-workflow URL are omitted.\n\n`isPublicApi` is the security-relevant one: while it is `true` the deployed workflow executes without an API key, so anyone holding the execution URL can run it — and consume the workspace’s billed usage — anonymously. It is set through `PATCH /workflows/{workflowId}/deployment`, and this read is the only way to audit whether it is on.\n\n`/workflows/{workflowId}/deployment` controls overall API executability; `/deployments/chat` controls only the hosted-chat surface. A workflow can remain deployed without a chat.\n\nOAuth scope: `api:read`.", "x-sim-operation": "workflows.read", "x-oauth-scope": "api:read", "tags": ["Workflows"], @@ -1998,7 +1998,7 @@ "get": { "operationId": "exportWorkflow", "summary": "Export Workflow", - "description": "Export a portable, secret-sanitized workflow; Set includeReferences=true to include non-secret source reference identities for mapped import; default exports keep their existing sanitized shape. Exporting records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "description": "Export a portable, secret-sanitized workflow. Use includeReferences=true for non-secret source identities and field occurrences used by mapped imports. Use includeWorkspaceBindings=true to retain non-secret workspace bindings for a same-workspace round trip; default exports clear those bindings. Credentials and secrets are cleared either way. Exporting records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", "x-sim-operation": "workflows.export", "x-oauth-scope": "api:read", "tags": ["Workflows"], @@ -2024,6 +2024,16 @@ "description": "Include non-secret resource identifiers and source field occurrences for mapped imports.", "type": "boolean" } + }, + { + "name": "includeWorkspaceBindings", + "in": "query", + "required": false, + "description": "Whether to keep workspace-scoped bindings — table, knowledge base, document, folder, channel, and other resource selectors — in the exported state. Defaults to false, the sharing-safe export in which those ids are cleared because they resolve nowhere else. Send true for a same-workspace round trip so the re-imported workflow can run without re-selecting them. Credentials, passwords, and table sub-block values are cleared either way.", + "schema": { + "description": "Whether to keep workspace-scoped bindings — table, knowledge base, document, folder, channel, and other resource selectors — in the exported state. Defaults to false, the sharing-safe export in which those ids are cleared because they resolve nowhere else. Send true for a same-workspace round trip so the re-imported workflow can run without re-selecting them. Credentials, passwords, and table sub-block values are cleared either way.", + "type": "boolean" + } } ], "responses": { @@ -3196,7 +3206,7 @@ "post": { "operationId": "cancelRunV2", "summary": "Cancel Workflow Run", - "description": "Request cancellation of a running, queued, or paused workflow run. Terminal runs return successfully without changes. A table workflow-group run returns `409` if its cell can no longer accept cancellation.\n\nOAuth scope: `api:write`.", + "description": "Request cancellation of a running, queued, or paused workflow run. Cancelling a run already in a terminal state is a `200` no-op answered with `success: false` and an `already_*` reason. A run produced by a table workflow group is a `409` when its cell can no longer accept the cancellation.\n\nOAuth scope: `api:write`.", "x-sim-operation": "workflows.runs.cancel", "x-oauth-scope": "api:write", "tags": ["Workflow Runs"], @@ -5746,7 +5756,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." + "description": "Settled runs — completed, failed, or cancelled — counted as each one finishes; a paused run is counted once it settles. The counter is never reduced when a run ages out of log retention, so it can exceed the size of `GET /api/v2/workflows/{workflowId}/runs`." }, "lastRunAt": { "anyOf": [ @@ -5918,7 +5928,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." + "description": "Settled runs — completed, failed, or cancelled — counted as each one finishes; a paused run is counted once it settles. The counter is never reduced when a run ages out of log retention, so it can exceed the size of `GET /api/v2/workflows/{workflowId}/runs`." }, "lastRunAt": { "anyOf": [ @@ -7675,7 +7685,18 @@ "type": "string", "description": "The id the block was actually given." }, - "description": "Minted block ids keyed by requested `block_id`, present only when they differ. References within this batch are remapped automatically; later requests must use the minted id. Supply a UUID when the requested id must survive unchanged." + "description": "The id each newly created block was actually given, keyed by the `block_id` you asked for, and present only for the ones that differ. A `block_id` on an `add` or `insert_into_subflow` that is not already a UUID is replaced with a minted one, so this is how you learn what to reference afterwards. Within a single batch you can keep using your own ids — references between operations are remapped for you — but a later request must use the minted id, so send your own UUIDs when you want an id you chose to survive. Always empty on a dry run, which reports its provisional ids under `previewBlockIds` instead." + }, + "previewBlockIds": { + "description": "Dry run only: the provisional id the evaluation assigned to each block whose `block_id` was not already a UUID, keyed by the `block_id` you asked for. These are not the ids a committed apply produces — the real apply mints new ones — so never wire a later request against them. Wire edges by `block_id` within one batch, or by the `mintedBlockIds` the real apply returns.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "description": "The provisional id the dry run assigned." + } }, "lint": { "$ref": "#/components/schemas/WorkflowLintReport" @@ -8830,7 +8851,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." + "description": "Settled runs — completed, failed, or cancelled — counted as each one finishes; a paused run is counted once it settles. The counter is never reduced when a run ages out of log retention, so it can exceed the size of `GET /api/v2/workflows/{workflowId}/runs`." }, "lastRunAt": { "anyOf": [ @@ -9836,6 +9857,13 @@ "isPublicApi": { "type": "boolean", "description": "Whether anyone with the execution URL can run the deployed workflow and consume billed usage without an API key. Change this with Update Workflow Public API Access." + }, + "webhooks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowDeploymentWebhook" + }, + "description": "Public delivery URL of every webhook the live version registered, one per trigger block. Empty while nothing is deployed, and omits trigger blocks that receive events through a shared endpoint with no per-workflow URL." } }, "required": [ @@ -10564,6 +10592,20 @@ "description": "ISO 8601 timestamp when the workflow was last updated.", "format": "date-time" }, + "blocks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImportedWorkflowBlock" + }, + "description": "Blocks the import created, in payload order. A summary only; the workflow state read returns the full graph." + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "One line per required workspace binding — a table, knowledge base, document, or other selector — that the payload carried empty, as `: was stripped by export; set it before running`. Export clears those bindings unless `includeWorkspaceBindings=true` was sent, so a round-tripped workflow arrives unable to run until they are set again. Empty when nothing is missing." + }, "operationId": { "type": "string", "minLength": 1, diff --git a/apps/sim/app/api/v2/files/[fileId]/text/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/text/route.test.ts index c469c8f586a..4fdc4c167c9 100644 --- a/apps/sim/app/api/v2/files/[fileId]/text/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/text/route.test.ts @@ -77,6 +77,7 @@ describe('GET /api/v2/files/[fileId]/text', () => { data: { fileId: FILE_ID, name: 'notes.txt', + path: 'files/notes.txt', type: 'text/plain', text: 'hello there!', truncated: false, @@ -133,11 +134,46 @@ describe('GET /api/v2/files/[fileId]/text', () => { expect(mocks.readText).toHaveBeenCalledWith( expect.objectContaining({ - input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, maxBytes: 4096 }, + input: { workspaceId: WORKSPACE_ID, reference: FILE_ID, maxBytes: 4096 }, }) ) }) + /** + * A Chat upload is absent from every listing, so the `uploads/` path its + * upload notice names is the only handle the model has. The path parameter + * therefore carries a VFS reference, not only an id, and the response echoes + * the canonical path that was read so the model sees the name it was told. + */ + it('accepts a VFS path as the file reference and echoes the path read', async () => { + mocks.readText.mockResolvedValueOnce( + result({ + file: { id: 'wf_upload', name: 'face (2).png', type: 'image/png', vfsNamespace: 'uploads' }, + }) + ) + + const response = await GET(textRequest(), { + params: Promise.resolve({ fileId: 'uploads/face%20(2).png' }), + }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(mocks.readText).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + workspaceId: WORKSPACE_ID, + reference: 'uploads/face%20(2).png', + maxBytes: undefined, + }, + }) + ) + expect(body.data).toMatchObject({ + fileId: 'wf_upload', + name: 'face (2).png', + path: 'uploads/face%20(2).png', + }) + }) + it('rejects a query with an undeclared key', async () => { const response = await GET(textRequest(`workspaceId=${WORKSPACE_ID}&format=html`), context) diff --git a/apps/sim/app/api/v2/files/[fileId]/text/route.ts b/apps/sim/app/api/v2/files/[fileId]/text/route.ts index 2e88aef2f18..b3480693e34 100644 --- a/apps/sim/app/api/v2/files/[fileId]/text/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/text/route.ts @@ -10,6 +10,10 @@ export const dynamic = 'force-dynamic' /** * GET /api/v2/files/[fileId]/text — extract a file's text. * + * `[fileId]` is a file id or the file's VFS path, so a Chat upload — absent from + * every listing — is readable by the `uploads/` path its upload notice + * names. The response echoes the canonical path that was read. + * * Runs on the existing `files.read_content` operation: extracting text reads * exactly the bytes that operation already authorizes. * @@ -33,8 +37,8 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, mapInput: ({ params, query }) => ({ - fileId: params.fileId, - assertedWorkspaceId: query.workspaceId, + workspaceId: query.workspaceId, + reference: params.fileId, maxBytes: query.maxBytes, offset: query.offset, limit: query.limit, diff --git a/apps/sim/app/api/v2/files/utils.ts b/apps/sim/app/api/v2/files/utils.ts index 124050edfc8..fcd312c54dc 100644 --- a/apps/sim/app/api/v2/files/utils.ts +++ b/apps/sim/app/api/v2/files/utils.ts @@ -1,3 +1,4 @@ +import { workspaceFileVfsPath } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import type { V2FileVersion } from '@/lib/api/contracts/v2/file-versions' import type { V2File, V2FileText } from '@/lib/api/contracts/v2/files' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -80,6 +81,7 @@ export function toV2FileText({ return { fileId: file.id, name: file.name, + path: workspaceFileVfsPath(file), type: file.type, text, truncated, diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 5c9b601dc9e..0fb3db56870 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -3,6 +3,7 @@ import { isCanonicalBase64, noInputSchema, versionNumberSchema, + requiredFieldSchema, workspaceFileIdSchema, workspaceFileNameSchema, workspaceIdSchema, @@ -40,7 +41,7 @@ import { v2UploadTokenHeadersSchema, v2UploadTransferSchema, } from '@/lib/api/contracts/v2/uploads' -import { MAX_FOLDER_PATH_SEGMENTS } from '@/lib/folders/paths' +import { MAX_FOLDER_PATH_BYTES, MAX_FOLDER_PATH_SEGMENTS } from '@/lib/folders/paths' import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' import { MAX_TEXT_EXTRACTION_BYTES } from '@/lib/uploads/utils/file-utils' import { MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits' @@ -286,6 +287,21 @@ export const v2FileParamsSchema = z.object({ export type V2FileParams = z.output +/** + * The text read also takes the file's VFS path, so a Chat upload — which no listing + * shows — is readable by the `uploads/` path its upload notice names, and any + * file by the `files/…` path `glob` prints, with no listing round-trip first. + */ +export const v2FileReferenceParamsSchema = z.object({ + fileId: requiredFieldSchema('File reference is required') + .max(MAX_FOLDER_PATH_BYTES, 'File reference is too long') + .describe( + 'File identifier, or the file’s VFS path: `files//`, or `uploads/` for a Chat upload.' + ), +}) + +export type V2FileReferenceParams = z.output + export const v2CreateFileBodySchema = z .object({ workspaceId: workspaceIdSchema.describe('Workspace in which to create the file.'), @@ -847,6 +863,11 @@ export const v2FileTextSchema = z .object({ fileId: workspaceFileIdSchema.describe('File the text was extracted from.'), name: z.string().describe('File name, including its extension.'), + path: z + .string() + .describe( + 'Canonical VFS path of the file that was read: `files/…`, or `uploads/` for a Chat upload.' + ), type: z.string().describe('Stored MIME type of the source file.'), text: z.string().describe('Extracted text.'), truncated: z @@ -903,7 +924,7 @@ export type V2FileText = z.output export const v2ReadFileTextContract = defineRouteContract({ method: 'GET', path: '/api/v2/files/[fileId]/text', - params: v2FileParamsSchema, + params: v2FileReferenceParamsSchema, query: v2ReadFileTextQuerySchema, response: { mode: 'json', diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index d3cd36d08a1..06119049261 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -415,7 +415,7 @@ const declaredRoutes = [ operationId: 'readFileText', summary: 'Read File Text', description: - 'Extract text without changing the file. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.', + 'Extract text without changing the file. Accepts its ID or canonical path (`files//` or `uploads/` for an unlisted chat upload); the response echoes the read path. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.', errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], success: { description: 'The extracted text and its extraction-quality flags.' }, }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts index c171728aab4..971fcd4237c 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/workflows.ts @@ -974,12 +974,7 @@ const declaredRoutes = [ success: jsonSuccess('The workflow export payload.'), }), { - query: documentedSchema( - v2ExportWorkflowContract.query, - 'ExportWorkflowQuery', - 'Export workflow query', - 'Export reference options.' - ), + query: v2ExportWorkflowContract.query, params: v2ExportWorkflowContract.params, response: documentedSchema( v2ExportWorkflowContract.response.schema, diff --git a/apps/sim/lib/execution/remote-sandbox/conformance.test.ts b/apps/sim/lib/execution/remote-sandbox/conformance.test.ts index e6f915f7dc3..3e596d1355f 100644 --- a/apps/sim/lib/execution/remote-sandbox/conformance.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/conformance.test.ts @@ -383,7 +383,12 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => { meterUsage: true, }) - expect(res.cost).toEqual({ input: 0, output: 0, total: expect.any(Number) }) + expect(res.cost).toEqual({ + input: 0, + output: 0, + total: expect.any(Number), + raw: expect.any(Number), + }) expect(res.cost?.total).toBeGreaterThan(0) } finally { nowSpy.mockRestore() @@ -403,7 +408,12 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => { meterUsage: true, }) - expect(res.cost).toEqual({ input: 0, output: 0, total: expect.any(Number) }) + expect(res.cost).toEqual({ + input: 0, + output: 0, + total: expect.any(Number), + raw: expect.any(Number), + }) expect(res.cost?.total).toBeGreaterThan(0) } finally { nowSpy.mockRestore() @@ -587,7 +597,12 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => { expect(res.error).toBe('ValueError: boom') expect(res.stdout).toContain('ValueError: boom') expect(res.result).toBeNull() - expect(res.cost).toEqual({ input: 0, output: 0, total: expect.any(Number) }) + expect(res.cost).toEqual({ + input: 0, + output: 0, + total: expect.any(Number), + raw: expect.any(Number), + }) }) it('normalizes Python code budget expiry to a typed timeout abort', async () => { @@ -739,6 +754,7 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => { input: 0, output: 0, total: expect.any(Number), + raw: expect.any(Number), }) }) @@ -1160,7 +1176,12 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => { expect(res.result).toBeNull() expect(res.error).toContain('boom detail') expect(res.stdout).toContain('boom detail') - expect(res.cost).toEqual({ input: 0, output: 0, total: expect.any(Number) }) + expect(res.cost).toEqual({ + input: 0, + output: 0, + total: expect.any(Number), + raw: expect.any(Number), + }) }) it('terminates shell execution when streamed process output exceeds the byte budget', async () => { @@ -1279,6 +1300,7 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => { input: 0, output: 0, total: expect.any(Number), + raw: expect.any(Number), }) expect(provider === 'e2b' ? mockE2BFilesRead : mockDownloadFileStream).not.toHaveBeenCalled() @@ -1345,6 +1367,7 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => { input: 0, output: 0, total: expect.any(Number), + raw: expect.any(Number), }) expect(provider === 'e2b' ? mockE2BFilesRead : mockDownloadFileStream).not.toHaveBeenCalled() }) @@ -1377,6 +1400,7 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => { input: 0, output: 0, total: expect.any(Number), + raw: expect.any(Number), }) } ) diff --git a/apps/sim/lib/execution/remote-sandbox/index.ts b/apps/sim/lib/execution/remote-sandbox/index.ts index 4e1795cfbf9..0b34747d132 100644 --- a/apps/sim/lib/execution/remote-sandbox/index.ts +++ b/apps/sim/lib/execution/remote-sandbox/index.ts @@ -167,8 +167,17 @@ async function leaseSandbox( }) if (existing) { await refresh(existing) + // Priced like a Function block's sandbox, over this call's wall time on the + // sandbox (acquire to release). The idle window between calls is the + // platform's overhead, not the user's. return { - created: { sandbox: existing, providerId: provider.id, startedAtMs: Date.now() }, + created: { + sandbox: existing, + providerId: provider.id, + startedAtMs: Date.now(), + effectiveLifetimeMs: provider.resolveLifetimeMs(SESSION_SANDBOX_IDLE_MS), + pricing: createSandboxPricing(provider.id), + }, session: 'reused', release: () => refresh(existing), } @@ -179,12 +188,14 @@ async function leaseSandbox( error: getErrorMessage(error), }) } + // Metered from the provider request, as a Function block's one-shot sandbox is: + // the first call of a chat pays for the boot it caused. const created = await createSelectedSandbox( kind, { ...options, lifetimeMs: SESSION_SANDBOX_IDLE_MS, sessionKey: session.key }, selected, signal, - false + true ) const bootstrapCommand = session.bootstrapCommand if (bootstrapCommand) { @@ -380,7 +391,7 @@ function calculateSandboxCost( cleanupStartedAtMs - created.startedAtMs, created.effectiveLifetimeMs ) - return { input: 0, output: 0, total: usage.billedCost } + return { input: 0, output: 0, total: usage.billedCost, raw: usage.rawCost } } /** diff --git a/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts b/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts index 7eb998aa599..d63c1c51c0e 100644 --- a/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts @@ -91,6 +91,15 @@ function fakeSandbox(id: string): { handle: SandboxHandle; calls: FakeSandboxCal return { handle, calls } } +/** A fake executes instantly; a priced call needs measurable wall time. */ +function slowDown(handle: SandboxHandle): void { + const original = handle.runCode.bind(handle) + handle.runCode = async (code, options) => { + await new Promise((resolve) => setTimeout(resolve, 25)) + return original(code, options) + } +} + const CODE_REQUEST = { code: 'print(1)', language: 'python' as never, @@ -142,6 +151,40 @@ describe('session sandbox lease', () => { expect(calls.extendLifetime.length).toBeGreaterThanOrEqual(2) }) + it('prices a reused session call like a Function block sandbox and reports the raw cost', async () => { + // The copilot bills sandbox time per call: acquire-to-release on the live sandbox, + // priced at the provider rates, with the raw (unmarked) amount beside the billed one + // so the worker's settlement applies its multiplier exactly once. + const { handle } = fakeSandbox('sb-priced') + slowDown(handle) + mockFindSessionSandbox.mockResolvedValue(handle) + const result = await executeInSandbox({ + ...CODE_REQUEST, + sandboxKind: 'mothership', + session: { key: 'mothership-chat:c1' }, + }) + expect(result.sandboxSession).toBe('reused') + expect(result.cost).toBeDefined() + expect(result.cost?.raw).toBeGreaterThan(0) + // The billed figure is the raw one times the platform multiplier, rounded; both + // are present and positive — the worker settles on `raw`, workflows on `total`. + expect(result.cost?.total).toBeGreaterThan(0) + }) + + it('prices a freshly created session sandbox from its provider request', async () => { + const { handle } = fakeSandbox('sb-priced-fresh') + slowDown(handle) + mockFindSessionSandbox.mockResolvedValue(null) + mockCreate.mockResolvedValue(handle) + const result = await executeInSandbox({ + ...CODE_REQUEST, + sandboxKind: 'mothership', + session: { key: 'mothership-chat:c2' }, + }) + expect(result.sandboxSession).toBe('created') + expect(result.cost?.raw).toBeGreaterThan(0) + }) + it('injects session envs into code executions', async () => { const { handle, calls } = fakeSandbox('sb-env') mockFindSessionSandbox.mockResolvedValue(handle) diff --git a/apps/sim/lib/execution/remote-sandbox/types.ts b/apps/sim/lib/execution/remote-sandbox/types.ts index dfaf1b54444..41fcc21290c 100644 --- a/apps/sim/lib/execution/remote-sandbox/types.ts +++ b/apps/sim/lib/execution/remote-sandbox/types.ts @@ -131,7 +131,14 @@ export interface SandboxShellExecutionRequest { export interface SandboxExecutionCost { input: number output: number + /** What the platform bills: the provider cost with the cost multiplier applied. */ total: number + /** + * The provider cost before the multiplier. The copilot settles through the worker, + * which applies its own platform multiplier to every raw charge (model tokens, web + * research, and now sandbox time), so it must receive the unmarked amount. + */ + raw?: number } /** diff --git a/apps/sim/lib/mothership/chat/payload.test.ts b/apps/sim/lib/mothership/chat/payload.test.ts index ea2db2091b1..a92cc64fd52 100644 --- a/apps/sim/lib/mothership/chat/payload.test.ts +++ b/apps/sim/lib/mothership/chat/payload.test.ts @@ -455,6 +455,7 @@ describe('buildCopilotRequestPayload', () => { content: [ 'File "payroll.xlsx" (application/octet-stream, 1 bytes) uploaded to workspace files.', 'Read it with: sim --output json files read "uploads/payroll.xlsx"', + 'Pass the same path "uploads/payroll.xlsx" as inputs.files[].path to mount it in run_code or use it as a reference image in generate_image.', ].join('\n'), }, ]) @@ -496,6 +497,7 @@ describe('buildCopilotRequestPayload', () => { content: [ 'File "photo.png" (image/png, 10 bytes) uploaded to workspace files.', 'Read it with: sim --output json files read "uploads/photo.png"', + 'Pass the same path "uploads/photo.png" as inputs.files[].path to mount it in run_code or use it as a reference image in generate_image.', ].join('\n'), }, ]) diff --git a/apps/sim/lib/mothership/chat/payload.ts b/apps/sim/lib/mothership/chat/payload.ts index 51fbc2d3a9a..78f3efe54b8 100644 --- a/apps/sim/lib/mothership/chat/payload.ts +++ b/apps/sim/lib/mothership/chat/payload.ts @@ -348,6 +348,7 @@ export async function buildCopilotRequestPayload( lines = [ `File "${displayName}" (${mediaType}, ${f.size} bytes) uploaded to workspace files.`, `Read it with: sim --output json files read "uploads/${encodedUploadName}"`, + `Pass the same path "uploads/${encodedUploadName}" as inputs.files[].path to mount it in run_code or use it as a reference image in generate_image.`, ] if (displayName.endsWith('.json')) { lines.push( diff --git a/apps/sim/lib/mothership/tools/handlers/function-execute.ts b/apps/sim/lib/mothership/tools/handlers/function-execute.ts index 23c03b8f03c..cedc0e8a2a0 100644 --- a/apps/sim/lib/mothership/tools/handlers/function-execute.ts +++ b/apps/sim/lib/mothership/tools/handlers/function-execute.ts @@ -2,6 +2,7 @@ import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { omit } from '@sim/utils/object' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import type { PrivateSecretProvenanceBundleV1 } from '@/lib/execution/model-input-provenance' import { @@ -39,6 +40,7 @@ import { getOrCreateTableSnapshot, SNAPSHOT_MAX_BYTES } from '@/lib/table/snapsh import { findWorkspaceFileRecord, getSandboxWorkspaceFilePath, + parseChatUploadReference, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { importWorkspaceFileSecretProvenanceForRuntime } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' @@ -50,8 +52,10 @@ import { import { isGeneratedDocumentSourceType } from '@/lib/uploads/utils/file-utils' import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer' import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' import { downloadWorkspaceFileRecord } from '@/lib/workspace-files/application/read-workspace-file-record' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { listWorkspaceFileFoldersOperation } from '@/lib/workspace-files/application/workspace-file-folders' import { buildWorkspaceFileFolderDisplayPath, @@ -191,7 +195,7 @@ function unmountableNamespaceReason(filePath: string): string | null { const path = `${filePath.replace(/^\/+|\/+$/g, '')}/` if (path.startsWith('uploads/')) { - return 'uploads/ files are not mountable into the sandbox. Use save_upload to save it to a files/... path first, then mount that canonical path.' + return 'uploads/ holds chat uploads addressed as "uploads/" with no folders beneath it. Copy the exact "uploads/" path from the upload notice.' } if (path.startsWith('internal/tool-results/')) { return 'tool-result artifacts are stored by the copilot backend, not in workspace storage, so read and grep reach them but the sandbox cannot. This path is correct — searching for a different one will not find anything. Either read or grep the artifact and inline the values you need in code, or re-run the tool that produced it with an output path under files/ (run_function: outputs.files[].path, user_table: outputPath) and mount that files/... path.' @@ -261,6 +265,46 @@ async function resolveTableRef( return tablePathLookup?.get(tableName) ?? null } +/** + * Locates one `inputs.files[].path`. Chat uploads are absent from the workspace listing + * by design, so an `uploads/` reference resolves through the read-content + * reference use case — the only path that may reach a chat upload — and a miss there + * is the honest not-found rather than a hint to go looking elsewhere. + */ +async function resolveMountableWorkspaceFile( + allFiles: WorkspaceFileRecord[], + filePath: string, + workspaceId: string, + principal: Principal +): Promise { + const listed = findWorkspaceFileRecord(allFiles, filePath) + if (listed) return listed + + if (parseChatUploadReference(filePath) !== null) { + try { + return await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.readContent, + workspaceId, + reference: filePath, + }) + } catch (error) { + if (!(error instanceof OrchestrationError && error.code === 'not_found')) throw error + throw new Error( + `Input file not found: "${filePath}". Copy the exact "uploads/" path from the upload notice.` + ) + } + } + + const unmountable = unmountableNamespaceReason(filePath) + if (unmountable) { + throw new Error(`Cannot mount "${filePath}": ${unmountable}`) + } + throw new Error( + `Input file not found: "${filePath}". Pass the exact canonical VFS path copied from glob/read (e.g. "files/Reports/data.csv").` + ) +} + export async function resolveInputFiles( workspaceId: string, inputFiles?: unknown[], @@ -288,16 +332,12 @@ export async function resolveInputFiles( for (const fileRef of inputFiles) { const filePath = refField(fileRef, 'path') if (!filePath) continue - const record = findWorkspaceFileRecord(allFiles, filePath) - if (!record) { - const unmountable = unmountableNamespaceReason(filePath) - if (unmountable) { - throw new Error(`Cannot mount "${filePath}": ${unmountable}`) - } - throw new Error( - `Input file not found: "${filePath}". Pass the exact canonical VFS path copied from glob/read (e.g. "files/Reports/data.csv").` - ) - } + const record = await resolveMountableWorkspaceFile( + allFiles, + filePath, + workspaceId, + filePrincipal + ) const mountPath = refField(fileRef, 'sandboxPath') ?? getSandboxWorkspaceFilePath(record) await pushWorkspaceFileMount( sandboxFiles, diff --git a/apps/sim/lib/mothership/tools/server/image/generate-image.test.ts b/apps/sim/lib/mothership/tools/server/image/generate-image.test.ts new file mode 100644 index 00000000000..5e91ef34465 --- /dev/null +++ b/apps/sim/lib/mothership/tools/server/image/generate-image.test.ts @@ -0,0 +1,225 @@ +/** + * @vitest-environment node + * + * Reference images are declared inputs. A path that does not load must fail the + * call rather than let the model render from whatever remained: the user who + * attached a face and got a "v4" without it, and without an error, is the defect + * these assertions pin. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGenerateContent, + mockIsOpaqueWorkspaceFileEgressSafe, + mockResolveWorkspaceFileReference, + mockReadWorkspaceFileContent, + mockWriteWorkspaceFileByPath, +} = vi.hoisted(() => ({ + mockGenerateContent: vi.fn(), + mockIsOpaqueWorkspaceFileEgressSafe: vi.fn(), + mockResolveWorkspaceFileReference: vi.fn(), + mockReadWorkspaceFileContent: vi.fn(), + mockWriteWorkspaceFileByPath: vi.fn(), +})) + +vi.mock('@google/genai', () => ({ + GoogleGenAI: class GoogleGenAI { + models = { generateContent: mockGenerateContent } + }, +})) +vi.mock('@/lib/core/config/api-keys', () => ({ getRotatingApiKey: vi.fn(() => 'api-key') })) +vi.mock('@/lib/mothership/vfs/resource-writer', () => ({ + writeCopilotWorkspaceFileByPath: mockWriteWorkspaceFileByPath, +})) +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, +})) +vi.mock('@/lib/workspace-files/application/read-workspace-file-content', async () => { + /** The Copilot adapter admits a use case only by its registered operation object. */ + const { fileOperations } = await import('@/lib/workspace-files/application/operations') + return { + readWorkspaceFileContent: { + operation: fileOperations.readContent, + execute: mockReadWorkspaceFileContent, + }, + } +}) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ + createWorkspaceFileSecretProvenanceFromRegistry: vi.fn(async () => ({ + safe: true, + provenance: { status: 'exact', entries: [] }, + })), + isOpaqueWorkspaceFileEgressSafe: mockIsOpaqueWorkspaceFileEgressSafe, + MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE: + 'File cannot be sent to a model because its secret provenance is unavailable', +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { ServerToolContext } from '@/lib/mothership/tools/server/base-tool' +import { generateImageServerTool } from '@/lib/mothership/tools/server/image/generate-image' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const WORKSPACE_ID = 'workspace-1' + +const chatUpload = { + id: 'wf_upload', + workspaceId: WORKSPACE_ID, + name: 'face.png', + key: `workspace/${WORKSPACE_ID}/1731000000000-ab12cd34-face.png`, + path: '/api/files/serve/face.png?context=mothership', + size: 10, + type: 'image/png', + uploadedBy: 'user-1', + uploadedAt: new Date('2026-09-01T00:00:00.000Z'), + updatedAt: new Date('2026-09-01T00:00:00.000Z'), + storageContext: 'mothership' as const, + vfsNamespace: 'uploads' as const, +} + +const workspaceFile = { + ...chatUpload, + id: 'wf_base', + name: 'base.png', + key: `workspace/${WORKSPACE_ID}/1731000000001-ab12cd35-base.png`, + storageContext: 'workspace' as const, + vfsNamespace: undefined, +} + +function context(): ServerToolContext { + return { + userId: 'user-1', + workspaceId: WORKSPACE_ID, + toolCallId: 'tool-1', + copilotToolExecution: true, + resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry([]), + } +} + +function generate(paths: string[]) { + return generateImageServerTool.execute( + { prompt: 'add this face to it', inputs: { files: paths.map((path) => ({ path })) } }, + context() + ) +} + +describe('generate_image reference images', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsOpaqueWorkspaceFileEgressSafe.mockResolvedValue(true) + mockResolveWorkspaceFileReference.mockResolvedValue(chatUpload) + mockReadWorkspaceFileContent.mockResolvedValue({ + file: chatUpload, + content: Buffer.from('face-bytes'), + }) + mockWriteWorkspaceFileByPath.mockResolvedValue({ + id: 'wf_out', + name: 'generated-image.png', + size: 5, + contentType: 'image/png', + vfsPath: 'files/generated-image.png', + mode: 'create', + downloadUrl: '/api/files/serve/out.png', + }) + mockGenerateContent.mockResolvedValue({ + candidates: [ + { content: { parts: [{ inlineData: { data: 'aW1hZ2U=', mimeType: 'image/png' } }] } }, + ], + }) + }) + + it('loads a chat upload reference through the read-content reference resolver', async () => { + const result = await generate(['uploads/face.png']) + + expect(result.success).toBe(true) + expect(mockResolveWorkspaceFileReference).toHaveBeenCalledWith( + expect.objectContaining({ + operation: fileOperations.readContent, + workspaceId: WORKSPACE_ID, + reference: 'uploads/face.png', + }) + ) + expect(mockGenerateContent).toHaveBeenCalledWith( + expect.objectContaining({ + contents: [ + expect.objectContaining({ + parts: expect.arrayContaining([ + { + inlineData: { + mimeType: 'image/png', + data: Buffer.from('face-bytes').toString('base64'), + }, + }, + ]), + }), + ], + }) + ) + }) + + it('fails the call, naming the path, when a reference image does not resolve', async () => { + mockResolveWorkspaceFileReference.mockRejectedValue( + new OrchestrationError('not_found', 'File not found') + ) + + const result = await generate(['uploads/image.png']) + + expect(result).toEqual( + expect.objectContaining({ + success: false, + message: expect.stringContaining('Reference image "uploads/image.png" was not found'), + }) + ) + expect(mockReadWorkspaceFileContent).not.toHaveBeenCalled() + expect(mockGenerateContent).not.toHaveBeenCalled() + expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() + }) + + it('never renders from a subset of the declared references', async () => { + mockResolveWorkspaceFileReference + .mockResolvedValueOnce(workspaceFile) + .mockRejectedValueOnce(new OrchestrationError('not_found', 'File not found')) + + const result = await generate(['files/base.png', 'uploads/face.png']) + + expect(result).toEqual( + expect.objectContaining({ + success: false, + message: expect.stringContaining('"uploads/face.png"'), + }) + ) + expect(mockGenerateContent).not.toHaveBeenCalled() + expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled() + }) + + it('fails the call, naming the path, when a reference image cannot be read', async () => { + mockReadWorkspaceFileContent.mockRejectedValue(new Error('storage unavailable')) + + const result = await generate(['uploads/face.png']) + + expect(result).toEqual( + expect.objectContaining({ + success: false, + message: expect.stringContaining( + 'Reference image "uploads/face.png" could not be read: storage unavailable' + ), + }) + ) + expect(mockGenerateContent).not.toHaveBeenCalled() + }) + + it('still refuses a model-unsafe reference with the safety message', async () => { + mockIsOpaqueWorkspaceFileEgressSafe.mockResolvedValue(false) + + const result = await generate(['uploads/face.png']) + + expect(result).toEqual( + expect.objectContaining({ + success: false, + message: expect.stringContaining('cannot be sent'), + }) + ) + expect(mockReadWorkspaceFileContent).not.toHaveBeenCalled() + expect(mockGenerateContent).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/mothership/tools/server/image/generate-image.ts b/apps/sim/lib/mothership/tools/server/image/generate-image.ts index c8ace4e677d..1977710a0db 100644 --- a/apps/sim/lib/mothership/tools/server/image/generate-image.ts +++ b/apps/sim/lib/mothership/tools/server/image/generate-image.ts @@ -1,7 +1,8 @@ import { GoogleGenAI, type Part } from '@google/genai' import { createLogger } from '@sim/logger' -import { getErrorMessage, toError } from '@sim/utils/errors' +import { getErrorMessage } from '@sim/utils/errors' import { getRotatingApiKey } from '@/lib/core/config/api-keys' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { MAX_MEDIA_BYTES } from '@/lib/media/falai' import { executeCopilotFileUseCase, @@ -13,11 +14,9 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/mothership/tools/server/base-tool' -import { - assertOpaqueWorkspaceFileModelSafe, - ServerToolModelInputError, -} from '@/lib/mothership/tools/server/model-input' +import { assertOpaqueWorkspaceFileModelSafe } from '@/lib/mothership/tools/server/model-input' import { writeCopilotWorkspaceFileByPath } from '@/lib/mothership/vfs/resource-writer' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { createWorkspaceFileSecretProvenanceFromRegistry } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' @@ -58,6 +57,51 @@ interface GenerateImageResult { _serviceCost?: { service: string; cost: number } } +/** + * Loads one declared reference image, or fails the whole call. Rendering from the + * remaining inputs would hand back an image silently missing what the user attached and + * report it as a success; a miss instead names the path so the model can correct it + * rather than hunt for the file. Model-safety refusals keep their own message. + */ +async function loadReferenceImage( + context: ServerToolContext, + workspaceId: string, + filePath: string +): Promise<{ file: WorkspaceFileRecord; buffer: Buffer }> { + let file: WorkspaceFileRecord + try { + file = await resolveCopilotWorkspaceFileReference(context, fileOperations.readContent, { + workspaceId, + reference: filePath, + }) + } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + throw new Error( + `Reference image "${filePath}" was not found. Pass the exact canonical VFS path copied from glob/read (e.g. "files/photo.png"), or the "uploads/" path from the upload notice.` + ) + } + throw error + } + await assertOpaqueWorkspaceFileModelSafe({ workspaceId, file }) + try { + const { content } = await executeCopilotFileUseCase( + context, + readWorkspaceFileContent, + { + fileId: file.id, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_MEDIA_BYTES, + }, + { fileId: file.id } + ) + return { file, buffer: content } + } catch (error) { + throw new Error( + `Reference image "${filePath}" could not be read: ${getErrorMessage(error, 'unknown error')}` + ) + } +} + export const generateImageServerTool: BaseServerTool = { name: GenerateImage.id, @@ -89,47 +133,16 @@ export const generateImageServerTool: BaseServerTool file.path) ?? [] - if (referencePaths.length) { - for (const filePath of referencePaths) { - try { - const fileRecord = await resolveCopilotWorkspaceFileReference( - context, - fileOperations.readContent, - { - workspaceId, - reference: filePath, - } - ) - await assertOpaqueWorkspaceFileModelSafe({ workspaceId, file: fileRecord }) - const { content: buffer } = await executeCopilotFileUseCase( - context, - readWorkspaceFileContent, - { - fileId: fileRecord.id, - assertedWorkspaceId: workspaceId, - maxBytes: MAX_MEDIA_BYTES, - }, - { fileId: fileRecord.id } - ) - const base64 = buffer.toString('base64') - const mime = fileRecord.type || 'image/png' - parts.push({ - inlineData: { mimeType: mime, data: base64 }, - }) - logger.info('Loaded reference image', { - filePath, - name: fileRecord.name, - size: buffer.length, - mimeType: mime, - }) - } catch (err) { - if (err instanceof ServerToolModelInputError) throw err - logger.warn('Failed to load reference image, skipping', { - filePath, - error: toError(err).message, - }) - } - } + for (const filePath of referencePaths) { + const { file, buffer } = await loadReferenceImage(context, workspaceId, filePath) + const mime = file.type || 'image/png' + parts.push({ inlineData: { mimeType: mime, data: buffer.toString('base64') } }) + logger.info('Loaded reference image', { + filePath, + name: file.name, + size: buffer.length, + mimeType: mime, + }) } const sizeInstruction = sizeHint diff --git a/apps/sim/lib/mothership/vfs/path-utils.ts b/apps/sim/lib/mothership/vfs/path-utils.ts index af379049132..730c4a41fa9 100644 --- a/apps/sim/lib/mothership/vfs/path-utils.ts +++ b/apps/sim/lib/mothership/vfs/path-utils.ts @@ -36,10 +36,14 @@ export function canonicalizeVfsPath(path: string): string { return canonicalizeNeutralVfsPath(path) } +/** + * Canonical, per-segment-encoded VFS path of a workspace file. `uploads` is the + * chat-upload namespace, which has no folders. + */ export function canonicalWorkspaceFilePath(parts: { folderPath?: string | null name: string - prefix?: 'files' | 'recently-deleted/files' + prefix?: 'files' | 'recently-deleted/files' | 'uploads' }): string { const prefix = parts.prefix ?? 'files' const folderSegments = parts.folderPath diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index fef7bbf4b44..474150841d3 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -16,7 +16,7 @@ import { } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { omit } from '@sim/utils/object' -import { and, eq, inArray, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm' +import { and, desc, eq, inArray, isNotNull, isNull, or, type SQL, sql } from 'drizzle-orm' import type { ShareRecord } from '@/lib/api/contracts/public-shares' import type { V2FileSortBy } from '@/lib/api/contracts/v2/files' import { @@ -162,6 +162,11 @@ export interface WorkspaceFileRecord { contentUpdatedAt?: Date | null /** Pass-through to `downloadFile` when not default `workspace` (e.g. chat mothership uploads). */ storageContext?: 'workspace' | 'mothership' + /** + * Set on chat uploads (`context = 'mothership'`), which the VFS addresses as + * `uploads/` rather than `files/…`; `name` then carries the upload's display name. + */ + vfsNamespace?: 'uploads' /** Public share state, attached at the API boundary. `null` when never shared. */ share?: ShareRecord | null } @@ -1175,10 +1180,30 @@ function mapUploadedWorkspaceFileRecord( } } +/** + * A chat upload keeps the collision-suffixed display name its upload notice told the + * model as `name`, sits under `uploads/` rather than in a folder, and reads through the + * `mothership` storage context its row is bound to. + */ +function mapChatUploadRecord(file: WorkspaceFileRow, workspaceId: string): WorkspaceFileRecord { + return { + ...mapWorkspaceFileRecord(file, workspaceId, new Map()), + name: file.displayName ?? file.originalName, + path: `${getServePathPrefix()}${encodeURIComponent(file.key)}?context=mothership`, + folderId: null, + folderPath: null, + storageContext: 'mothership', + vfsNamespace: 'uploads', + } +} + async function mapSingleWorkspaceFileRecord( file: WorkspaceFileRow, workspaceId: string ): Promise { + if (file.context === 'mothership') { + return mapChatUploadRecord(file, workspaceId) + } if (!file.folderId) { return mapWorkspaceFileRecord(file, workspaceId, new Map()) } @@ -1255,11 +1280,31 @@ export async function getWorkspaceFileByName( return mapSingleWorkspaceFileRecord(files[0], workspaceId) } +/** + * Chat uploads (`context = 'mothership'`) are hidden from every listing and closed to + * writes. A read may opt in to one by explicit reference only — its `uploads/` + * VFS path or its own id — which is what this option grants. + */ +export interface WorkspaceFileLookupOptions { + includeChatUploads?: boolean +} + +/** Row context a single-file lookup admits: workspace files, plus chat uploads on opt-in. */ +function workspaceFileContextCondition(includeChatUploads?: boolean) { + return includeChatUploads + ? inArray(workspaceFiles.context, ['workspace', 'mothership']) + : eq(workspaceFiles.context, 'workspace') +} + /** Workspace-file rows for one scope: live, Recently Deleted, or both. */ -function workspaceFileScopeCondition(workspaceId: string, scope: WorkspaceFileScope) { +function workspaceFileScopeCondition( + workspaceId: string, + scope: WorkspaceFileScope, + includeChatUploads?: boolean +) { const base = [ eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace'), + workspaceFileContextCondition(includeChatUploads), ] if (scope === 'all') return and(...base) return scope === 'archived' @@ -1490,13 +1535,68 @@ function normalizeWorkspaceFileReferenceSegments(fileReference: string): string[ return decodeVfsPathSegments(withoutDeletedPrefix) } +/** + * Canonical VFS path of a stored record: `uploads/` for a chat upload, else the + * `files/…` path of its folder and name. + */ +export function workspaceFileVfsPath( + file: Pick +): string { + return canonicalWorkspaceFilePath({ + folderPath: file.folderPath, + name: file.name, + prefix: file.vfsNamespace, + }) +} + /** * Canonical sandbox mount path for an existing workspace file. */ export function getSandboxWorkspaceFilePath( - file: Pick + file: Pick ): string { - return `/home/user/${canonicalWorkspaceFilePath({ folderPath: file.folderPath, name: file.name })}` + return `/home/user/${workspaceFileVfsPath(file)}` +} + +/** + * Display name addressed by an `uploads/` reference (percent-encoded per the VFS + * convention), or null for any other shape. Only the two-segment form names a chat + * upload: `files/uploads/…` is an ordinary folder path and keeps its meaning. + */ +export function parseChatUploadReference(fileReference: string): string | null { + const trimmed = fileReference.trim().replace(/^\/+/, '') + if (!trimmed.startsWith('uploads/')) return null + const segments = decodeVfsPathSegments(trimmed) + return segments.length === 2 ? segments[1] : null +} + +/** + * Newest active chat upload in the workspace whose display name is `name` (legacy rows + * without one match on their original name). Display names are unique per chat, not + * per workspace, so the latest upload wins — the one the model was told about last. + */ +async function getChatUploadByName( + workspaceId: string, + name: string +): Promise { + const [file] = await db + .select(workspaceFileColumns) + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'mothership'), + or( + eq(workspaceFiles.displayName, name), + and(isNull(workspaceFiles.displayName), eq(workspaceFiles.originalName, name)) + ), + isNull(workspaceFiles.deletedAt) + ) + ) + .orderBy(desc(workspaceFiles.uploadedAt)) + .limit(1) + + return file ? mapChatUploadRecord(file, workspaceId) : null } /** @@ -1550,15 +1650,29 @@ async function getWorkspaceFileByExactReference( * A reference that is already a file id resolves through the versioned read, so the record * carries the version of the very bytes it describes. The name and listing fallbacks return * records without one rather than pairing a row with a version a second query read later. + * With `includeChatUploads`, an `uploads/` path (or a chat upload's own id) reaches + * the chat upload it names; chat uploads are never found through the listing fallback. */ export async function resolveWorkspaceFileReference( workspaceId: string, - fileReference: string + fileReference: string, + options?: WorkspaceFileLookupOptions ): Promise { + const includeChatUploads = options?.includeChatUploads === true + if (includeChatUploads) { + const uploadName = parseChatUploadReference(fileReference) + if (uploadName !== null) { + const upload = await getChatUploadByName(workspaceId, uploadName) + if (upload) return upload + } + } + const referenceSegments = normalizeWorkspaceFileReferenceSegments(fileReference) const normalizedReference = referenceSegments.join('/') if (normalizedReference.startsWith('wf_')) { - const file = await getWorkspaceFileWithCurrentVersion(workspaceId, normalizedReference) + const file = await getWorkspaceFileWithCurrentVersion(workspaceId, normalizedReference, { + includeChatUploads, + }) if (file) return file } @@ -1572,10 +1686,11 @@ export async function resolveWorkspaceFileReference( /** * Load the canonical authorization context for an active workspace file by resource ID. * Database failures propagate so callers never confuse unavailable state with a missing file. + * Chat uploads are admitted only on explicit opt-in (see {@link WorkspaceFileLookupOptions}). */ export async function loadActiveWorkspaceFileContext( fileId: string, - options?: { includeDeleted?: boolean } + options?: WorkspaceFileLookupOptions & { includeDeleted?: boolean } ): Promise { const [context] = await db .select({ @@ -1590,7 +1705,7 @@ export async function loadActiveWorkspaceFileContext( .where( and( eq(workspaceFiles.id, fileId), - eq(workspaceFiles.context, 'workspace'), + workspaceFileContextCondition(options?.includeChatUploads), ...(options?.includeDeleted ? [] : [isNull(workspaceFiles.deletedAt)]), isNull(workspace.archivedAt) ) @@ -1656,11 +1771,13 @@ export async function loadActiveWorkspaceContext( * distinguish a genuinely-absent file (`null`) from a transient read failure (throws): the * collaborative-doc seed builder relies on this so a DB blip never looks like an empty file and gets * seeded as blank content over the real document. + * + * Chat uploads are admitted only on explicit opt-in (see {@link WorkspaceFileLookupOptions}). */ export async function getWorkspaceFile( workspaceId: string, fileId: string, - options?: { includeDeleted?: boolean; throwOnError?: boolean } + options?: WorkspaceFileLookupOptions & { includeDeleted?: boolean; throwOnError?: boolean } ): Promise { try { const { includeDeleted = false } = options ?? {} @@ -1670,7 +1787,9 @@ export async function getWorkspaceFile( .where( and( eq(workspaceFiles.id, fileId), - workspaceFileScopeCondition(workspaceId, includeDeleted ? 'all' : 'active') + eq(workspaceFiles.workspaceId, workspaceId), + workspaceFileContextCondition(options?.includeChatUploads), + ...(includeDeleted ? [] : [isNull(workspaceFiles.deletedAt)]) ) ) .limit(1) @@ -1692,7 +1811,7 @@ export async function getWorkspaceFile( export async function getWorkspaceFileWithCurrentVersion( workspaceId: string, fileId: string, - options?: { includeDeleted?: boolean } + options?: WorkspaceFileLookupOptions & { includeDeleted?: boolean } ): Promise { const [row] = await db .select({ @@ -1703,7 +1822,11 @@ export async function getWorkspaceFileWithCurrentVersion( .where( and( eq(workspaceFiles.id, fileId), - workspaceFileScopeCondition(workspaceId, options?.includeDeleted ? 'all' : 'active') + workspaceFileScopeCondition( + workspaceId, + options?.includeDeleted ? 'all' : 'active', + options?.includeChatUploads + ) ) ) .limit(1) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-reference.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-reference.test.ts new file mode 100644 index 00000000000..de5cd4e2533 --- /dev/null +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-reference.test.ts @@ -0,0 +1,253 @@ +/** + * @vitest-environment node + * + * `resolveWorkspaceFileReference` and the chat-upload namespace. Chat uploads + * (`context = 'mothership'`) are hidden from every listing on purpose, so the + * only way to one is an explicit `uploads/` reference (or its own id) + * under a read that opts in. These assertions pin both halves: the opt-in + * reaches the upload through its own query, and without it nothing does. + */ +import { + dbChainMockFns, + flattenMockConditions, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/billing/storage', () => ({ + decrementStorageUsageForBillingContextInTx: vi.fn(), + incrementStorageUsageForBillingContextInTx: vi.fn(), + maybeNotifyStorageLimitForBillingContext: vi.fn(), + resolveStorageBillingContext: vi.fn(), +})) + +vi.mock('@/lib/uploads', () => ({ + getServePathPrefix: vi.fn(() => '/api/files/serve/s3/'), +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + deleteFile: vi.fn(), + downloadFile: vi.fn(), + hasCloudStorage: vi.fn(() => false), + headObject: vi.fn(), + uploadFile: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ + assertWorkspaceFileFolderTarget: vi.fn(async () => null), + buildWorkspaceFileFolderPathMap: vi.fn(() => new Map()), + fileNameExistsInWorkspaceFolder: vi.fn(async () => false), + findWorkspaceFileFolderIdByPath: vi.fn(async () => null), + getWorkspaceFileFolderPath: vi.fn(), + listWorkspaceFileFolders: vi.fn(async () => []), + normalizeWorkspaceFileItemName: vi.fn((name: string) => name), + resolveWorkspaceFileFolderTarget: vi.fn(async () => null), +})) + +import { + getSandboxWorkspaceFilePath, + listWorkspaceFiles, + parseChatUploadReference, + resolveWorkspaceFileReference, + workspaceFileVfsPath, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +const WS = '22222222-2222-2222-2222-222222222222' +const UPLOAD_KEY = `workspace/${WS}/1731000000000-ab12cd34-face.png` + +function chatUploadRow(overrides: Record = {}) { + return { + id: 'wf_upload', + key: UPLOAD_KEY, + userId: 'user-1', + workspaceId: WS, + folderId: null, + context: 'mothership', + chatId: '11111111-1111-1111-1111-111111111111', + messageId: 'msg-1', + originalName: 'face.png', + displayName: 'face (2).png', + contentType: 'image/png', + size: 10, + sizeBytes: 10, + width: null, + height: null, + deletedAt: null, + uploadedAt: new Date('2026-09-01T00:00:00Z'), + updatedAt: new Date('2026-09-01T00:00:00Z'), + contentUpdatedAt: new Date('2026-09-01T00:00:00Z'), + secretProvenanceVersion: null, + ...overrides, + } +} + +const allConditions = () => + dbChainMockFns.where.mock.calls.flatMap(([condition]) => flattenMockConditions(condition)) + +const lastConditions = () => + flattenMockConditions(dbChainMockFns.where.mock.calls.at(-1)?.[0]).filter(Boolean) + +describe('parseChatUploadReference', () => { + it.each([ + ['uploads/face.png', 'face.png'], + ['/uploads/face%20(2).png', 'face (2).png'], + ['uploads/a/b.png', null], + ['files/uploads/face.png', null], + ['files/face.png', null], + ['wf_upload', null], + ])('%s → %s', (reference, expected) => { + expect(parseChatUploadReference(reference)).toBe(expected) + }) +}) + +describe('resolveWorkspaceFileReference', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('resolves uploads/ to the newest chat upload when a read opts in', async () => { + queueTableRows(schemaMock.workspaceFiles, [chatUploadRow()]) + + const record = await resolveWorkspaceFileReference(WS, 'uploads/face%20(2).png', { + includeChatUploads: true, + }) + + expect(record).toMatchObject({ + id: 'wf_upload', + name: 'face (2).png', + folderId: null, + folderPath: null, + storageContext: 'mothership', + vfsNamespace: 'uploads', + path: `/api/files/serve/s3/${encodeURIComponent(UPLOAD_KEY)}?context=mothership`, + }) + + const conditions = lastConditions() + expect(conditions).toContainEqual( + expect.objectContaining({ + type: 'eq', + left: schemaMock.workspaceFiles.context, + right: 'mothership', + }) + ) + expect(conditions).toContainEqual( + expect.objectContaining({ type: 'isNull', column: schemaMock.workspaceFiles.deletedAt }) + ) + const nameMatch = conditions.find((condition) => condition.type === 'or') + expect(nameMatch).toMatchObject({ + conditions: [ + { type: 'eq', left: schemaMock.workspaceFiles.displayName, right: 'face (2).png' }, + expect.anything(), + ], + }) + expect(dbChainMockFns.orderBy).toHaveBeenCalledWith({ + type: 'desc', + column: schemaMock.workspaceFiles.uploadedAt, + }) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(1) + /** Found by its own query: the listing fallback never ran. */ + expect(dbChainMockFns.from).toHaveBeenCalledTimes(1) + }) + + it('never consults chat uploads without the opt-in', async () => { + queueTableRows(schemaMock.workspaceFiles, []) + + await expect(resolveWorkspaceFileReference(WS, 'uploads/face.png')).resolves.toBeNull() + + const conditions = allConditions() + expect(conditions.some((condition) => condition.right === 'mothership')).toBe(false) + expect(conditions.some((condition) => condition.type === 'inArray')).toBe(false) + expect(conditions).toContainEqual( + expect.objectContaining({ + type: 'eq', + left: schemaMock.workspaceFiles.context, + right: 'workspace', + }) + ) + }) + + it('falls through to workspace files when no chat upload carries the name', async () => { + queueTableRows(schemaMock.workspaceFiles, []) + queueTableRows(schemaMock.workspaceFiles, []) + + await expect( + resolveWorkspaceFileReference(WS, 'uploads/report.csv', { includeChatUploads: true }) + ).resolves.toBeNull() + + expect(dbChainMockFns.from).toHaveBeenCalledTimes(2) + }) + + it('reaches a chat upload by its own id only on opt-in', async () => { + queueTableRows(schemaMock.workspaceFiles, [chatUploadRow()]) + + const record = await resolveWorkspaceFileReference(WS, 'wf_upload', { + includeChatUploads: true, + }) + + expect(record).toMatchObject({ id: 'wf_upload', name: 'face (2).png', vfsNamespace: 'uploads' }) + expect(lastConditions()).toContainEqual( + expect.objectContaining({ + type: 'inArray', + column: schemaMock.workspaceFiles.context, + values: ['workspace', 'mothership'], + }) + ) + }) + + it('keeps id lookups on workspace files by default', async () => { + queueTableRows(schemaMock.workspaceFiles, []) + queueTableRows(schemaMock.workspaceFiles, []) + + await resolveWorkspaceFileReference(WS, 'wf_upload') + + const conditions = allConditions() + expect(conditions.some((condition) => condition.type === 'inArray')).toBe(false) + expect(conditions).toContainEqual( + expect.objectContaining({ + type: 'eq', + left: schemaMock.workspaceFiles.context, + right: 'workspace', + }) + ) + }) +}) + +describe('listWorkspaceFiles', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('lists workspace files only, so a chat upload is never enumerable', async () => { + queueTableRows(schemaMock.workspaceFiles, []) + + await listWorkspaceFiles(WS) + + expect(lastConditions()).toContainEqual( + expect.objectContaining({ + type: 'eq', + left: schemaMock.workspaceFiles.context, + right: 'workspace', + }) + ) + }) +}) + +describe('workspace file VFS paths', () => { + it('addresses a chat upload under uploads/ and mounts it there', () => { + const upload = { folderPath: null, name: 'face (2).png', vfsNamespace: 'uploads' as const } + + expect(workspaceFileVfsPath(upload)).toBe('uploads/face%20(2).png') + expect(getSandboxWorkspaceFilePath(upload)).toBe('/home/user/uploads/face%20(2).png') + }) + + it('keeps workspace files under files/', () => { + const file = { folderPath: 'Reports', name: 'data.csv' } + + expect(workspaceFileVfsPath(file)).toBe('files/Reports/data.csv') + expect(getSandboxWorkspaceFilePath(file)).toBe('/home/user/files/Reports/data.csv') + }) +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content.test.ts index 1f1e595e039..2ddb1551940 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-content.test.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content.test.ts @@ -70,10 +70,14 @@ describe('readWorkspaceFileContent', () => { }) ).resolves.toEqual({ file, content: Buffer.from('source') }) - expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: true }) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { + includeDeleted: true, + includeChatUploads: true, + }) expect(mocks.getFile).toHaveBeenCalledWith('workspace-1', 'file-1', { includeDeleted: true, throwOnError: true, + includeChatUploads: true, }) expect(mocks.fetchBuffer).toHaveBeenCalledWith(file, { maxBytes: 512 }) expect(mocks.getSecretProvenance).not.toHaveBeenCalled() diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts index bfd1020bd50..e4216a7951f 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts @@ -42,6 +42,7 @@ async function executeReadWorkspaceFileContent({ const file = await getWorkspaceFile(context.workspaceId, context.fileId, { includeDeleted: input.includeDeleted, throwOnError: true, + includeChatUploads: true, }) if (!file) throw new OrchestrationError('not_found', 'File not found') const content = await fetchWorkspaceFileBuffer(file, { @@ -61,8 +62,14 @@ async function executeReadWorkspaceFileContent({ } } +/** + * A content read by id admits chat uploads: an id is an explicit reference, and the + * `uploads/` resolution that hands one to run_code or the image tools reads it back + * through here. Listings never surface chat uploads and writes never resolve them. + */ export const readWorkspaceFileContent = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.readContent, - resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + resolveContext: ({ input }) => + resolveActiveWorkspaceFileContext({ ...input, includeChatUploads: true }), execute: executeReadWorkspaceFileContent, }) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-record.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-record.test.ts index 4173a836a39..32fb3b87ec3 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-record.test.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-record.test.ts @@ -60,9 +60,13 @@ describe('workspace file record reads', () => { ).resolves.toEqual({ file }) expect(useCase.operation.id).toBe(operationId) - expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: undefined }) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { + includeDeleted: undefined, + includeChatUploads: true, + }) expect(mocks.getFile).toHaveBeenCalledWith('workspace-1', 'file-1', { throwOnError: true, + includeChatUploads: true, }) } ) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts index f9570ad679a..9ffde1a241c 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts @@ -18,11 +18,16 @@ export interface ReadWorkspaceFileRecordResult { file: WorkspaceFileRecord } +/** + * Record reads by id admit chat uploads: the sandbox mount reloads an `uploads/` + * upload through here before presigning it. Listings never surface chat uploads and + * writes never resolve them. + */ function createReadWorkspaceFileRecord(operation: O) { return defineAuthorizedWorkspaceFileUseCase({ operation, resolveContext: ({ input }: { input: ReadWorkspaceFileRecordInput }) => - resolveActiveWorkspaceFileContext(input), + resolveActiveWorkspaceFileContext({ ...input, includeChatUploads: true }), async execute({ context, }: AuthorizedWorkspaceUseCaseContext< @@ -32,6 +37,7 @@ function createReadWorkspaceFileRecord(opera >): Promise { const file = await getWorkspaceFile(context.workspaceId, context.fileId, { throwOnError: true, + includeChatUploads: true, }) if (!file) throw new OrchestrationError('not_found', 'File not found') return { file } diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts index 7ddaa195794..71a6e3b792a 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-text.test.ts @@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { FileParserError } from '@/lib/file-parsers/errors' const mocks = vi.hoisted(() => ({ - getFile: vi.fn(), fetchServable: vi.fn(), fetchBuffer: vi.fn(), parseBuffer: vi.fn(), @@ -24,13 +23,12 @@ vi.mock('@sim/platform-authz/workspace', () => ({ resolveEffectiveWorkspacePermission: mocks.resolvePermission, })) -vi.mock('@/lib/workspace-files/application/workspace-file-context', () => ({ - resolveActiveWorkspaceFileContext: mocks.resolveContext, +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveReferencedWorkspaceFileContext: mocks.resolveContext, })) vi.mock('@/lib/uploads/contexts/workspace', () => ({ fetchWorkspaceFileBuffer: mocks.fetchBuffer, - getWorkspaceFile: mocks.getFile, })) vi.mock('@/lib/workspace-files/application/fetch-servable-workspace-file-buffer', () => ({ @@ -43,6 +41,7 @@ vi.mock('@/lib/file-parsers', () => ({ parseBuffer: mocks.parseBuffer, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { DocCompileUserError } from '@/lib/mothership/tools/server/files/doc-compile-error' import { readWorkspaceFileText } from '@/lib/workspace-files/application/read-workspace-file-text' @@ -77,16 +76,20 @@ function fileRecord(overrides: Record = {}) { } } +/** The canonical context the reference resolver hands back, carrying the record it resolved. */ +function referenceContext(overrides: Record = {}) { + return { ...fileContext, file: fileRecord(overrides) } +} + function input(overrides: Record = {}) { - return { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, ...overrides } + return { workspaceId: WORKSPACE_ID, reference: FILE_ID, ...overrides } } describe('readWorkspaceFileText', () => { beforeEach(() => { vi.clearAllMocks() mocks.resolvePermission.mockResolvedValue('read') - mocks.resolveContext.mockResolvedValue(fileContext) - mocks.getFile.mockResolvedValue(fileRecord()) + mocks.resolveContext.mockResolvedValue(referenceContext()) mocks.fetchBuffer.mockResolvedValue(Buffer.from('hello there!')) mocks.fetchServable.mockResolvedValue({ buffer: Buffer.from('%PDF-1.7 rendered'), @@ -157,7 +160,6 @@ describe('readWorkspaceFileText', () => { request: { headers: new Headers(), signal: controller.signal }, }) ).rejects.toBe(controller.signal.reason) - expect(mocks.getFile).not.toHaveBeenCalled() expect(mocks.fetchBuffer).not.toHaveBeenCalled() expect(mocks.parseBuffer).not.toHaveBeenCalled() }) @@ -228,7 +230,34 @@ describe('readWorkspaceFileText', () => { readWorkspaceFileText.execute({ principal: principals[2], input: input() }) ).rejects.toMatchObject({ code: 'not_found' }) expect(mocks.resolvePermission).not.toHaveBeenCalled() - expect(mocks.getFile).not.toHaveBeenCalled() + expect(mocks.fetchBuffer).not.toHaveBeenCalled() + }) + + /** + * The reference is what makes a chat upload readable: no listing shows one, so + * the `uploads/` path from its upload notice must resolve with chat + * uploads admitted, and the record's display name is what comes back. + */ + it('resolves the reference with chat uploads admitted and reads the resolved record', async () => { + mocks.resolveContext.mockResolvedValueOnce( + referenceContext({ id: 'wf_upload', name: 'notes (2).txt', storageContext: 'mothership' }) + ) + + const result = await readWorkspaceFileText.execute({ + principal: principals[2], + input: input({ reference: 'uploads/notes%20(2).txt' }), + }) + + expect(mocks.resolveContext).toHaveBeenCalledWith( + { workspaceId: WORKSPACE_ID, reference: 'uploads/notes%20(2).txt' }, + { includeChatUploads: true } + ) + expect(result.file.name).toBe('notes (2).txt') + expect(mocks.fetchBuffer).toHaveBeenCalledWith( + expect.objectContaining({ id: 'wf_upload', storageContext: 'mothership' }), + expect.anything() + ) + expect(result.text).toBe('hello there!') }) /** @@ -237,7 +266,7 @@ describe('readWorkspaceFileText', () => { * reach the caller rather than being swallowed or turned into an error. */ it('surfaces a degraded legacy extraction with its reason', async () => { - mocks.getFile.mockResolvedValueOnce(fileRecord({ name: 'legacy.doc' })) + mocks.resolveContext.mockResolvedValueOnce(referenceContext({ name: 'legacy.doc' })) mocks.parseBuffer.mockResolvedValueOnce({ content: 'Unable to extract text from DOC file. Please convert to DOCX format.', metadata: { @@ -256,7 +285,7 @@ describe('readWorkspaceFileText', () => { }) it('surfaces a text-free deck as degraded', async () => { - mocks.getFile.mockResolvedValueOnce(fileRecord({ name: 'deck.pptx' })) + mocks.resolveContext.mockResolvedValueOnce(referenceContext({ name: 'deck.pptx' })) mocks.parseBuffer.mockResolvedValueOnce({ content: 'Unable to extract text from PowerPoint file.', metadata: { degraded: true, warning: 'Basic text extraction used' }, @@ -290,7 +319,7 @@ describe('readWorkspaceFileText', () => { * the remedy rather than an endpoint only one of those three can call. */ it('rejects an unsupported type and names the raw-bytes escape hatch', async () => { - mocks.getFile.mockResolvedValue(fileRecord({ name: 'photo.heic' })) + mocks.resolveContext.mockResolvedValue(referenceContext({ name: 'photo.heic' })) await expect( readWorkspaceFileText.execute({ principal: principals[2], input: input() }) @@ -305,7 +334,7 @@ describe('readWorkspaceFileText', () => { }) it('rejects a source above the extraction ceiling before reading bytes', async () => { - mocks.getFile.mockResolvedValueOnce(fileRecord({ size: 26 * 1024 * 1024 })) + mocks.resolveContext.mockResolvedValueOnce(referenceContext({ size: 26 * 1024 * 1024 })) await expect( readWorkspaceFileText.execute({ principal: principals[2], input: input() }) @@ -315,7 +344,7 @@ describe('readWorkspaceFileText', () => { /** A caller may lower the ceiling but must never raise it. */ it('clamps a caller maxBytes above the server ceiling', async () => { - mocks.getFile.mockResolvedValueOnce(fileRecord({ size: 26 * 1024 * 1024 })) + mocks.resolveContext.mockResolvedValueOnce(referenceContext({ size: 26 * 1024 * 1024 })) await expect( readWorkspaceFileText.execute({ @@ -326,7 +355,7 @@ describe('readWorkspaceFileText', () => { }) it('honours a caller maxBytes below the server ceiling', async () => { - mocks.getFile.mockResolvedValueOnce(fileRecord({ size: 2048 })) + mocks.resolveContext.mockResolvedValueOnce(referenceContext({ size: 2048 })) await expect( readWorkspaceFileText.execute({ principal: principals[2], input: input({ maxBytes: 1024 }) }) @@ -338,7 +367,7 @@ describe('readWorkspaceFileText', () => { * "0 Bytes" — leaving the caller unable to work out what to pass instead. */ it('names the real size and limit when both are under 1 KB', async () => { - mocks.getFile.mockResolvedValueOnce(fileRecord({ size: 28 })) + mocks.resolveContext.mockResolvedValueOnce(referenceContext({ size: 28 })) await expect( readWorkspaceFileText.execute({ principal: principals[2], input: input({ maxBytes: 27 }) }) @@ -350,7 +379,9 @@ describe('readWorkspaceFileText', () => { }) it('reports a missing file as not found', async () => { - mocks.getFile.mockResolvedValueOnce(null) + mocks.resolveContext.mockRejectedValueOnce( + new OrchestrationError('not_found', 'File not found') + ) await expect( readWorkspaceFileText.execute({ principal: principals[2], input: input() }) @@ -379,7 +410,7 @@ describe('readWorkspaceFileText', () => { ['deck.pptx', 'text/x-pptxgenjs'], ])('extracts %s from its compiled artifact, not its %s source', async (name, type) => { const controller = new AbortController() - mocks.getFile.mockResolvedValueOnce(fileRecord({ name, type, size: 900 })) + mocks.resolveContext.mockResolvedValueOnce(referenceContext({ name, type, size: 900 })) mocks.parseBuffer.mockResolvedValueOnce({ content: 'Quarterly results', metadata: {} }) const result = await readWorkspaceFileText.execute({ @@ -398,7 +429,7 @@ describe('readWorkspaceFileText', () => { it('preserves cancellation when an artifact read wraps the error', async () => { const controller = new AbortController() const reason = new Error('request cancelled during artifact read') - mocks.getFile.mockResolvedValueOnce(fileRecord({ name: 'report.pdf', type: 'text/x-pdflibjs' })) + mocks.resolveContext.mockResolvedValueOnce(referenceContext({ name: 'report.pdf', type: 'text/x-pdflibjs' })) mocks.fetchServable.mockImplementationOnce(async () => { controller.abort(reason) throw new DocCompileUserError('not ready', { pending: true }) @@ -415,8 +446,8 @@ describe('readWorkspaceFileText', () => { /** A genuinely uploaded PDF carries its real MIME and must keep reading its own bytes. */ it('reads an uploaded pdf from storage rather than an artifact', async () => { - mocks.getFile.mockResolvedValueOnce( - fileRecord({ name: 'scan.pdf', type: 'application/pdf', size: 900 }) + mocks.resolveContext.mockResolvedValueOnce( + referenceContext({ name: 'scan.pdf', type: 'application/pdf', size: 900 }) ) await readWorkspaceFileText.execute({ principal: principals[2], input: input() }) @@ -427,8 +458,8 @@ describe('readWorkspaceFileText', () => { /** An artifact still compiling is retryable, so it must not read as a fault. */ it('reports a still-compiling artifact as a conflict', async () => { - mocks.getFile.mockResolvedValueOnce( - fileRecord({ name: 'report.pdf', type: 'text/x-pdflibjs', size: 900 }) + mocks.resolveContext.mockResolvedValueOnce( + referenceContext({ name: 'report.pdf', type: 'text/x-pdflibjs', size: 900 }) ) mocks.fetchServable.mockRejectedValueOnce( new DocCompileUserError('not ready', { pending: true }) @@ -445,8 +476,8 @@ describe('readWorkspaceFileText', () => { * what decides, and the artifact carries its own ceiling. */ it('bounds a generated document by its artifact, not its source size', async () => { - mocks.getFile.mockResolvedValueOnce( - fileRecord({ name: 'report.pdf', type: 'text/x-pdflibjs', size: 900 }) + mocks.resolveContext.mockResolvedValueOnce( + referenceContext({ name: 'report.pdf', type: 'text/x-pdflibjs', size: 900 }) ) await readWorkspaceFileText.execute({ principal: principals[2], input: input() }) @@ -459,8 +490,8 @@ describe('readWorkspaceFileText', () => { * the same exact-byte formatting the source branch does. */ it('names a sub-1 KB artifact limit in bytes', async () => { - mocks.getFile.mockResolvedValueOnce( - fileRecord({ name: 'report.pdf', type: 'text/x-pdflibjs', size: 10 }) + mocks.resolveContext.mockResolvedValueOnce( + referenceContext({ name: 'report.pdf', type: 'text/x-pdflibjs', size: 10 }) ) mocks.fetchServable.mockRejectedValueOnce( new PayloadSizeLimitError({ label: 'artifact', maxBytes: 27 }) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts index fd3f29bcfc1..77453b1a7e4 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-text.ts @@ -6,9 +6,7 @@ import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { isSupportedFileType } from '@/lib/file-parsers' import { getFileParserErrorCode } from '@/lib/file-parsers/errors' import { - type ActiveWorkspaceFileContext, fetchWorkspaceFileBuffer, - getWorkspaceFile, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace' import { @@ -20,13 +18,18 @@ import { import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' import { resolveRenderedWorkspaceArtifact } from '@/lib/workspace-files/application/resolve-rendered-workspace-artifact' -import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' import { parseWorkspaceFileText } from '@/lib/workspace-files/text-extraction' import { sliceFileTextLines } from '@/lib/workspace-files/text-lines' +import { + type ReferencedWorkspaceFileContext, + resolveReferencedWorkspaceFileContext, +} from '@/lib/workspace-files/application/resolve-workspace-file-reference' export interface ReadWorkspaceFileTextInput { - fileId: string - assertedWorkspaceId?: string + /** Workspace the reference is resolved in. */ + workspaceId: string + /** File id, or its VFS path: `files//`, or `uploads/` for a chat upload. */ + reference: string maxBytes?: number /** First line to return, 1-based. Absent starts at the first line. */ offset?: number @@ -91,14 +94,11 @@ async function executeReadWorkspaceFileText({ }: AuthorizedWorkspaceUseCaseContext< typeof fileOperations.readContent, ReadWorkspaceFileTextInput, - ActiveWorkspaceFileContext + ReferencedWorkspaceFileContext >): Promise { const signal = request?.signal signal?.throwIfAborted() - const file = await getWorkspaceFile(context.workspaceId, context.fileId, { throwOnError: true }) - signal?.throwIfAborted() - if (!file) throw new OrchestrationError('not_found', 'File not found') - return extractWorkspaceFileRecordText(file, input, principal, signal) + return extractWorkspaceFileRecordText(context.file, input, principal, signal) } /** @@ -212,9 +212,14 @@ async function parseFileText( * Runs on `files.read_content` unchanged: extracting text reads exactly the * bytes that operation already authorizes, and turning them into text grants * no further reach. No audit is projected, matching the existing content read. + * + * The file is addressed by reference rather than id so a chat upload — which no + * listing shows — is readable by the `uploads/` path its upload notice + * names, and any file by the `files/…` path `glob` prints. */ export const readWorkspaceFileText = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.readContent, - resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + resolveContext: ({ input }) => + resolveReferencedWorkspaceFileContext(input, { includeChatUploads: true }), execute: executeReadWorkspaceFileText, }) diff --git a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts index a0bbac94fa6..1b49ee34240 100644 --- a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts +++ b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts @@ -67,10 +67,60 @@ describe('workspace file reference application service', () => { ).resolves.toBe(file) expect(mocks.resolveStoredReference).toHaveBeenCalledTimes(1) + expect(mocks.resolveStoredReference).toHaveBeenCalledWith( + 'workspace-1', + 'files/source.txt', + undefined + ) expect(mocks.loadContext).toHaveBeenCalledTimes(1) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', undefined) expect(mocks.resolvePermission).toHaveBeenCalledTimes(1) }) + /** + * Chat uploads are hidden from every listing, so an explicit `uploads/` + * reference is the one way to one — and only a read may take it. The opt-in + * rides both the stored lookup and the canonical context load, so a chat + * upload can neither be found nor authorized for anything but reading. + */ + it('lets a content read reach a chat upload by its uploads/ reference', async () => { + await expect( + resolveWorkspaceFileReference({ + principal, + operation: fileOperations.readContent, + workspaceId: 'workspace-1', + reference: 'uploads/photo.png', + }) + ).resolves.toBe(file) + + expect(mocks.resolveStoredReference).toHaveBeenCalledWith('workspace-1', 'uploads/photo.png', { + includeChatUploads: true, + }) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeChatUploads: true }) + }) + + it.each([ + fileOperations.rename, + fileOperations.updateContent, + fileOperations.move, + fileOperations.delete, + fileOperations.updateShare, + ])('never admits a chat upload for $id', async (operation) => { + await resolveWorkspaceFileReference({ + principal, + operation, + workspaceId: 'workspace-1', + reference: 'uploads/photo.png', + }) + + expect(mocks.resolveStoredReference).toHaveBeenCalledWith( + 'workspace-1', + 'uploads/photo.png', + undefined + ) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', undefined) + }) + it('reads a referenced file with one canonical load and authorization', async () => { await expect( readWorkspaceFileReference({ @@ -87,6 +137,36 @@ describe('workspace file reference application service', () => { expect(mocks.fetchBuffer).toHaveBeenCalledWith(file, { maxBytes: 512 }) }) + it('reads a chat upload by its uploads/ reference and returns its content', async () => { + const upload = { + ...file, + id: 'wf_upload', + name: 'photo (2).png', + storageContext: 'mothership' as const, + vfsNamespace: 'uploads' as const, + } + mocks.resolveStoredReference.mockResolvedValue(upload) + mocks.loadContext.mockResolvedValue({ ...context, fileId: upload.id }) + mocks.fetchBuffer.mockResolvedValue(Buffer.from('png-bytes')) + + await expect( + readWorkspaceFileReference({ + principal, + workspaceId: 'workspace-1', + reference: 'uploads/photo%20(2).png', + maxBytes: 512, + }) + ).resolves.toEqual({ file: upload, content: Buffer.from('png-bytes') }) + + expect(mocks.resolveStoredReference).toHaveBeenCalledWith( + 'workspace-1', + 'uploads/photo%20(2).png', + { includeChatUploads: true } + ) + expect(mocks.loadContext).toHaveBeenCalledWith('wf_upload', { includeChatUploads: true }) + expect(mocks.fetchBuffer).toHaveBeenCalledWith(upload, { maxBytes: 512 }) + }) + it('resolves an exact name directly inside a canonical folder id', async () => { await expect( resolveWorkspaceFileReference({ diff --git a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts index b502d347406..1f139bfab97 100644 --- a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts +++ b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts @@ -2,10 +2,12 @@ import type { Principal } from '@sim/auth/principal' import type { OperationUseCase, WorkspaceOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { + type ActiveWorkspaceFileContext, fetchWorkspaceFileBuffer, getWorkspaceFileByName, loadActiveWorkspaceFileContext, resolveWorkspaceFileReference as resolveStoredWorkspaceFileReference, + type WorkspaceFileLookupOptions, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' @@ -34,29 +36,45 @@ interface WorkspaceFileReferenceReadInput extends WorkspaceFileReferenceInput { maxBytes: number } -async function resolveWorkspaceFileReferenceContext({ - input, -}: { - input: WorkspaceFileReferenceInput -}) { - const file = - input.folderId === undefined - ? await resolveStoredWorkspaceFileReference(input.workspaceId, input.reference) - : await getWorkspaceFileByName(input.workspaceId, input.reference, { - folderId: input.folderId, - }) +/** Canonical file context plus the record the reference resolved to. */ +export interface ReferencedWorkspaceFileContext extends ActiveWorkspaceFileContext { + file: WorkspaceFileRecord +} + +/** + * Reads may reach a chat upload through its explicit `uploads/` reference (or its + * own id); every other file operation resolves workspace files only, so no write, move, + * rename, delete, or share can land on one. + */ +const CHAT_UPLOAD_LOOKUP: WorkspaceFileLookupOptions = { includeChatUploads: true } + +/** + * Resolves a VFS reference to its canonical authorization context, carrying the resolved + * record so the caller needs no second load. Chat uploads are reachable only on opt-in. + */ +export async function resolveReferencedWorkspaceFileContext( + input: WorkspaceFileReferenceInput, + options?: WorkspaceFileLookupOptions +): Promise { + const file = input.folderId === undefined + ? await resolveStoredWorkspaceFileReference(input.workspaceId, input.reference, options) + : await getWorkspaceFileByName(input.workspaceId, input.reference, { folderId: input.folderId }) if (!file) throw new OrchestrationError('not_found', 'File not found') - const canonical = await loadActiveWorkspaceFileContext(file.id) + const canonical = await loadActiveWorkspaceFileContext(file.id, options) if (!canonical || canonical.workspaceId !== input.workspaceId) { throw new OrchestrationError('not_found', 'File not found') } return { ...canonical, file } } -function defineWorkspaceFileReferenceUseCase(operation: O) { +function defineWorkspaceFileReferenceUseCase( + operation: O, + options?: WorkspaceFileLookupOptions +) { return defineAuthorizedWorkspaceFileUseCase({ operation, - resolveContext: resolveWorkspaceFileReferenceContext, + resolveContext: ({ input }: { input: WorkspaceFileReferenceInput }) => + resolveReferencedWorkspaceFileContext(input, options), async execute({ context }): Promise { return { file: context.file } }, @@ -70,7 +88,10 @@ type WorkspaceFileReferenceUseCase = OperationUseCase< > const workspaceFileReferenceUseCases = { - [fileOperations.readContent.id]: defineWorkspaceFileReferenceUseCase(fileOperations.readContent), + [fileOperations.readContent.id]: defineWorkspaceFileReferenceUseCase( + fileOperations.readContent, + CHAT_UPLOAD_LOOKUP + ), [fileOperations.create.id]: defineWorkspaceFileReferenceUseCase(fileOperations.create), [fileOperations.rename.id]: defineWorkspaceFileReferenceUseCase(fileOperations.rename), [fileOperations.updateContent.id]: defineWorkspaceFileReferenceUseCase( @@ -115,7 +136,7 @@ export interface ReadWorkspaceFileReferenceInput const readWorkspaceFileReferenceUseCase = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.readContent, resolveContext: ({ input }: { input: WorkspaceFileReferenceReadInput }) => - resolveWorkspaceFileReferenceContext({ input }), + resolveReferencedWorkspaceFileContext(input, CHAT_UPLOAD_LOOKUP), async execute({ input, context }): Promise<{ file: WorkspaceFileRecord; content: Buffer }> { return { file: context.file, diff --git a/apps/sim/lib/workspace-files/application/workspace-file-context.ts b/apps/sim/lib/workspace-files/application/workspace-file-context.ts index 4dba54c77e3..c8eaf651197 100644 --- a/apps/sim/lib/workspace-files/application/workspace-file-context.ts +++ b/apps/sim/lib/workspace-files/application/workspace-file-context.ts @@ -10,6 +10,11 @@ export interface WorkspaceFileContextInput { fileId: string assertedWorkspaceId?: string includeDeleted?: boolean + /** + * Admit a chat upload (`context = 'mothership'`) addressed by its own id. Only read + * use cases set this: chat uploads stay out of listings and closed to writes. + */ + includeChatUploads?: boolean } export async function resolveActiveWorkspaceFileContext( @@ -17,6 +22,7 @@ export async function resolveActiveWorkspaceFileContext( ): Promise { const canonical = await loadActiveWorkspaceFileContext(input.fileId, { includeDeleted: input.includeDeleted, + includeChatUploads: input.includeChatUploads, }) if ( !canonical || diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 943421bfca9..cd2cc951381 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -3812,6 +3812,7 @@ export type ExportWorkflowParams = { export type ExportWorkflowQuery = { includeReferences?: boolean + includeWorkspaceBindings?: boolean } type ExportWorkflowResponseRef0 = { @@ -5933,6 +5934,8 @@ type ImportWorkflowResponseRef1 = { folderPath: string createdAt: string updatedAt: string + blocks: Array + warnings: Array operationId?: string requestId?: string kind?: 'workflow_import' | 'workspace_fork' | 'workspace_push' | 'workspace_pull' @@ -5971,8 +5974,6 @@ type ImportWorkflowResponseRef1 = { copied: number failed: number } - blocks: Array - warnings: Array } export type ImportWorkflowResponse = { @@ -8763,6 +8764,7 @@ export type ReadFileTextQuery = { type ReadFileTextResponseRef0 = { fileId: string name: string + path: string type: string text: string truncated: boolean @@ -11805,7 +11807,7 @@ export const V2_OPERATIONS = { filter: { kind: 'unknown', describe: - 'Recursive non-empty `all`/`any` groups containing groups or conditions; the root cannot be a condition. Limits: 100 members per group, 10 levels, and 500 nodes. The negating operators include nulls and absent cells, multi-select included; combine with `isNotNull` or `isNotEmpty` to exclude them. Pattern operators use `*` as the only wildcard; `%`, `_`, and backslash are literal. Select operators: single-select uses `eq`/`ne`/`in`/`nin`; multi-select uses `contains`/`ncontains`; option names resolve to IDs. Full operand rules are documented on `op`.', + 'One condition or a recursive `all`/`any` group, normalized to a grouped predicate. Limits: 100 members per group, 10 levels, and 500 nodes. The negating operators include nulls and absent cells, multi-select included; combine with `isNotNull` or `isNotEmpty` to exclude them. Pattern operators use `*` as the only wildcard; `%`, `_`, and backslash are literal. Select operators: single-select uses `eq`/`ne`/`in`/`nin`; multi-select uses `contains`/`ncontains`; option names resolve to IDs. Full operand rules are documented on `op`.', }, excludeRowIds: { kind: 'array', describe: 'Rows excluded from an all-scope cancellation.' }, }, @@ -12492,7 +12494,7 @@ export const V2_OPERATIONS = { filter: { kind: 'unknown', describe: - 'Recursive non-empty `all`/`any` groups containing groups or conditions; the root cannot be a condition. Limits: 100 members per group, 10 levels, and 500 nodes. The negating operators include nulls and absent cells, multi-select included; combine with `isNotNull` or `isNotEmpty` to exclude them. Pattern operators use `*` as the only wildcard; `%`, `_`, and backslash are literal. Select operators: single-select uses `eq`/`ne`/`in`/`nin`; multi-select uses `contains`/`ncontains`; option names resolve to IDs. Full operand rules are documented on `op`.', + 'One condition or a recursive `all`/`any` group, normalized to a grouped predicate. Limits: 100 members per group, 10 levels, and 500 nodes. The negating operators include nulls and absent cells, multi-select included; combine with `isNotNull` or `isNotEmpty` to exclude them. Pattern operators use `*` as the only wildcard; `%`, `_`, and backslash are literal. Select operators: single-select uses `eq`/`ne`/`in`/`nin`; multi-select uses `contains`/`ncontains`; option names resolve to IDs. Full operand rules are documented on `op`.', }, excludeRowIds: { kind: 'array', describe: 'Rows excluded from a select-all run scope.' }, limit: { kind: 'object', describe: 'Optional cap on eligible rows to run.' }, @@ -13047,7 +13049,7 @@ export const V2_OPERATIONS = { filter: { kind: 'unknown', describe: - 'Recursive non-empty `all`/`any` groups containing groups or conditions; the root cannot be a condition. Limits: 100 members per group, 10 levels, and 500 nodes. The negating operators include nulls and absent cells, multi-select included; combine with `isNotNull` or `isNotEmpty` to exclude them. Pattern operators use `*` as the only wildcard; `%`, `_`, and backslash are literal. Select operators: single-select uses `eq`/`ne`/`in`/`nin`; multi-select uses `contains`/`ncontains`; option names resolve to IDs. Full operand rules are documented on `op`.', + 'One condition or a recursive `all`/`any` group, normalized to a grouped predicate. Limits: 100 members per group, 10 levels, and 500 nodes. The negating operators include nulls and absent cells, multi-select included; combine with `isNotNull` or `isNotEmpty` to exclude them. Pattern operators use `*` as the only wildcard; `%`, `_`, and backslash are literal. Select operators: single-select uses `eq`/`ne`/`in`/`nin`; multi-select uses `contains`/`ncontains`; option names resolve to IDs. Full operand rules are documented on `op`.', }, limit: { kind: 'integer', describe: 'Maximum matching rows to delete.' }, rowIds: { kind: 'array', describe: 'Explicit row identifiers to delete.' }, @@ -13395,6 +13397,11 @@ export const V2_OPERATIONS = { describe: 'Include non-secret resource identifiers and source field occurrences for mapped imports.', }, + includeWorkspaceBindings: { + kind: 'boolean', + describe: + 'Whether to keep workspace-scoped bindings — table, knowledge base, document, folder, channel, and other resource selectors — in the exported state. Defaults to false, the sharing-safe export in which those ids are cleared because they resolve nowhere else. Send true for a same-workspace round trip so the re-imported workflow can run without re-selecting them. Credentials, passwords, and table sub-block values are cleared either way.', + }, }, }, forkWorkspace: { @@ -13681,7 +13688,27 @@ export const V2_OPERATIONS = { kind: 'integer', default: 72, describe: - 'Number of time buckets, up to 500. Exactly this many are returned, each at least one minute wide. Short windows extend past the requested end and include empty trailing buckets.', + 'Number of equal time buckets to divide the window into, from 1 to 500. It is the ceiling on how many buckets a series carries: with `includeEmpty=true` exactly this many are returned, otherwise only the buckets holding at least one run. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty.', + }, + includeEmpty: { + kind: 'enum', + values: [ + 'true', + '1', + 'yes', + 'on', + 'y', + 'enabled', + 'false', + '0', + 'no', + 'off', + 'n', + 'disabled', + ] as const, + default: false, + describe: + 'Whether buckets with no runs are included in every series. Off by default, so each series carries only the buckets that hold at least one run; set it to publish exactly `segmentCount` buckets per series, empty ones included. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.', }, }, }, @@ -14386,7 +14413,7 @@ export const V2_OPERATIONS = { source: { kind: 'enum', values: ['builtin', 'custom'] as const, - describe: "Restrict to built-in blocks or this workspace's deployed custom blocks.", + describe: 'Restrict to shipped blocks or to this workspace’s deployed custom blocks.', }, includeSunset: { kind: 'boolean', @@ -16495,7 +16522,10 @@ export const V2_OPERATIONS = { method: 'GET', path: '/api/v2/files/[fileId]/text', pathParams: ['fileId'] as const, - pathParamDocs: { fileId: 'File identifier.' }, + pathParamDocs: { + fileId: + 'File identifier, or the file’s VFS path: `files//`, or `uploads/` for a Chat upload.', + }, responseMode: 'json', summary: 'Read File Text', query: { @@ -17029,7 +17059,7 @@ export const V2_OPERATIONS = { predicate: { kind: 'unknown', describe: - 'Recursive non-empty `all`/`any` groups containing groups or conditions; the root cannot be a condition. Limits: 100 members per group, 10 levels, and 500 nodes. The negating operators include nulls and absent cells, multi-select included; combine with `isNotNull` or `isNotEmpty` to exclude them. Pattern operators use `*` as the only wildcard; `%`, `_`, and backslash are literal. Select operators: single-select uses `eq`/`ne`/`in`/`nin`; multi-select uses `contains`/`ncontains`; option names resolve to IDs. Full operand rules are documented on `op`.', + 'One condition or a recursive `all`/`any` group, normalized to a grouped predicate. Limits: 100 members per group, 10 levels, and 500 nodes. The negating operators include nulls and absent cells, multi-select included; combine with `isNotNull` or `isNotEmpty` to exclude them. Pattern operators use `*` as the only wildcard; `%`, `_`, and backslash are literal. Select operators: single-select uses `eq`/`ne`/`in`/`nin`; multi-select uses `contains`/`ncontains`; option names resolve to IDs. Full operand rules are documented on `op`.', }, sort: { kind: 'array', describe: 'Ordered table-row sort specification.' }, }, @@ -17513,7 +17543,7 @@ export const V2_OPERATIONS = { kind: 'unknown', required: true, describe: - 'Recursive non-empty `all`/`any` groups containing groups or conditions; the root cannot be a condition. Limits: 100 members per group, 10 levels, and 500 nodes. The negating operators include nulls and absent cells, multi-select included; combine with `isNotNull` or `isNotEmpty` to exclude them. Pattern operators use `*` as the only wildcard; `%`, `_`, and backslash are literal. Select operators: single-select uses `eq`/`ne`/`in`/`nin`; multi-select uses `contains`/`ncontains`; option names resolve to IDs. Full operand rules are documented on `op`.', + 'One condition or a recursive `all`/`any` group, normalized to a grouped predicate. Limits: 100 members per group, 10 levels, and 500 nodes. The negating operators include nulls and absent cells, multi-select included; combine with `isNotNull` or `isNotEmpty` to exclude them. Pattern operators use `*` as the only wildcard; `%`, `_`, and backslash are literal. Select operators: single-select uses `eq`/`ne`/`in`/`nin`; multi-select uses `contains`/`ncontains`; option names resolve to IDs. Full operand rules are documented on `op`.', }, data: { kind: 'object', diff --git a/packages/sim-cli/src/telemetry/client-info.test.ts b/packages/sim-cli/src/telemetry/client-info.test.ts index e00b4ceb358..baf95105306 100644 --- a/packages/sim-cli/src/telemetry/client-info.test.ts +++ b/packages/sim-cli/src/telemetry/client-info.test.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { CLI_VERSION } from '../version' +import { cliVersion } from '#sim-cli/version' import { clientInfoHeader } from './client-info' let dir: string @@ -20,7 +20,7 @@ afterEach(() => { describe('clientInfoHeader', () => { it('names the CLI, its runtime, the platform, and the driving agent', () => { expect(clientInfoHeader({ CLAUDECODE: '1' })).toBe( - `cli/${CLI_VERSION}; node/${process.versions.node}; os/${process.platform}; arch/${process.arch}; agent/claude-code` + `cli/${cliVersion()}; node/${process.versions.node}; os/${process.platform}; arch/${process.arch}; agent/claude-code` ) }) @@ -31,6 +31,6 @@ describe('clientInfoHeader', () => { it('withholds the agent when usage reporting is opted out', () => { const header = clientInfoHeader({ CLAUDECODE: '1', DO_NOT_TRACK: '1' }) expect(header).not.toContain('agent/') - expect(header).toContain(`cli/${CLI_VERSION}`) + expect(header).toContain(`cli/${cliVersion()}`) }) }) diff --git a/packages/sim-cli/src/telemetry/invocation.test.ts b/packages/sim-cli/src/telemetry/invocation.test.ts index 56672a1b667..e7e35e95107 100644 --- a/packages/sim-cli/src/telemetry/invocation.test.ts +++ b/packages/sim-cli/src/telemetry/invocation.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { Command } from 'commander' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SimApiError } from '../http/client' -import { CLI_VERSION } from '../version' +import { cliVersion } from '#sim-cli/version' import { COMMAND_EVENT, type CommandEventProperties, @@ -117,7 +117,7 @@ describe('command telemetry', () => { expect(request.timestamp).toBe(NOW.toISOString()) expect(request.properties).toMatchObject({ $lib: 'sim-cli', - $lib_version: CLI_VERSION, + $lib_version: cliVersion(), $process_person_profile: false, session_sequence: 1, surface: 'cli', @@ -126,7 +126,7 @@ describe('command telemetry', () => { arg_count: 0, exit_code: 0, duration_ms: 1432, - cli_version: CLI_VERSION, + cli_version: cliVersion(), node_version: process.versions.node, os: process.platform, arch: process.arch, diff --git a/packages/sim-cli/src/telemetry/invocation.ts b/packages/sim-cli/src/telemetry/invocation.ts index de11504143c..5449e091000 100644 --- a/packages/sim-cli/src/telemetry/invocation.ts +++ b/packages/sim-cli/src/telemetry/invocation.ts @@ -2,7 +2,7 @@ import type { Command } from 'commander' import { profileFrom } from '../context' import { isCi } from '../environment' import { SimApiError } from '../http/client' -import { CLI_VERSION } from '../version' +import { cliVersion } from '#sim-cli/version' import { detectCodingAgent, NO_CODING_AGENT } from './coding-agent' import { telemetryStatus } from './policy' import { loadTelemetryState, nextSession, type TelemetryState, writeTelemetryState } from './state' @@ -245,7 +245,7 @@ export function createCommandTelemetry(options: CommandTelemetryOptions = {}): C const properties: CommandEventProperties = { $lib: LIBRARY_NAME, - $lib_version: CLI_VERSION, + $lib_version: cliVersion(), $process_person_profile: false, $session_id: session.id, session_sequence: session.sequence, @@ -256,7 +256,7 @@ export function createCommandTelemetry(options: CommandTelemetryOptions = {}): C exit_code: outcome.exitCode, duration_ms: Math.round(elapsed()), ...failureProperties(outcome.error), - cli_version: CLI_VERSION, + cli_version: cliVersion(), node_version: process.versions.node, os: process.platform, arch: process.arch, diff --git a/packages/sim-cli/src/update/check.test.ts b/packages/sim-cli/src/update/check.test.ts index b899dc50d7e..cd7a46fdf82 100644 --- a/packages/sim-cli/src/update/check.test.ts +++ b/packages/sim-cli/src/update/check.test.ts @@ -13,7 +13,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { CLI_VERSION } from '../version' +import { cliVersion } from '#sim-cli/version' import { announceUpdateIfAvailable, type UpdateCheckOptions, upgradeCommand } from './check' /** A global install, which is the only shape that gets advised at all. */ @@ -137,7 +137,7 @@ describe('announcing a newer release', () => { it('sends only its own version and gives the request a one-second deadline', async () => { await run() const headers = inits[0]?.headers - expect(headers['user-agent']).toBe(`sim-cli/${CLI_VERSION}`) + expect(headers['user-agent']).toBe(`sim-cli/${cliVersion()}`) expect(headers.accept).toBe('application/json') expect(headers.authorization).toBeUndefined() expect(inits[0]?.maxResponseBytes).toBe(64 * 1024) diff --git a/packages/sim-cli/src/update/check.ts b/packages/sim-cli/src/update/check.ts index fede0442035..194b2b260e2 100644 --- a/packages/sim-cli/src/update/check.ts +++ b/packages/sim-cli/src/update/check.ts @@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url' import { readJsonFile, writeJsonFile } from '../config/json-file' import { updateCachePath } from '../config/paths' import { childProcessEnv, isCi, isEnabled, proxyExecArgv } from '../environment' -import { CLI_VERSION } from '../version' +import { cliVersion } from '#sim-cli/version' /** How long a cached check suppresses another request. */ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 @@ -244,7 +244,7 @@ async function fetchDistTags( const url = registryUrl(env) if (!url) return null const text = await request(url, { - headers: { accept: 'application/json', 'user-agent': `${PACKAGE_NAME}-cli/${CLI_VERSION}` }, + headers: { accept: 'application/json', 'user-agent': `${PACKAGE_NAME}-cli/${cliVersion()}` }, maxResponseBytes: MAX_RESPONSE_BYTES, timeoutMs: REGISTRY_TIMEOUT_MS, }) @@ -341,7 +341,7 @@ export async function announceUpdateIfAvailable(options: UpdateCheckOptions = {} if (isCi(env)) return if (isUnadvisableInstall(modulePath, env, cwd)) return - const currentVersion = options.currentVersion ?? CLI_VERSION + const currentVersion = options.currentVersion ?? cliVersion() const current = parseStableVersion(currentVersion) if (!current) return diff --git a/packages/sim-cli/src/update/install.ts b/packages/sim-cli/src/update/install.ts index fa750e8950a..e602edc5f34 100644 --- a/packages/sim-cli/src/update/install.ts +++ b/packages/sim-cli/src/update/install.ts @@ -7,7 +7,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { omit } from '@sim/utils/object' import { lock } from 'proper-lockfile' import { upgradeCommand } from '#sim-cli/update/check' -import { CLI_VERSION } from '#sim-cli/version' +import { cliVersion } from '#sim-cli/version' export class CliUpdateError extends Error {} @@ -189,7 +189,7 @@ export async function installUpdate(options: InstallUpdateOptions = {}): Promise } const packageManager = manager as PackageManager const run = options.run ?? runPackageManager - const currentVersion = options.currentVersion ?? CLI_VERSION + const currentVersion = options.currentVersion ?? cliVersion() const current = parseReleaseVersion(currentVersion) const target = current.channel const write = options.write ?? ((message: string) => void process.stderr.write(message)) From acedce1c9968c987caa475b359cd70bc3993399e Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 3 Sep 2026 10:23:54 +0530 Subject: [PATCH 073/306] grep: resolve --in against listings, fetch only the named resources A bare --in selector (e.g. --in agent, --in file_v5) materialized every searched world to find one block: 65 block details plus every workflow state per call. On dev that took 18-34s per grep and tripped the per-user rate limit. Each world now has a cheap index (its listing) and a per-resource fetch; a --in search reads the indexes and fetches only the matches. Whole-world searches are unchanged. --- .../agent-cli/engines/universal-grep.test.ts | 32 +- .../agent-cli/engines/universal-grep.ts | 281 +++++++++++------- 2 files changed, 204 insertions(+), 109 deletions(-) diff --git a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts index a07cbeacd5d..5d65f941d7d 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts @@ -12,12 +12,16 @@ const SLACK_V2 = { operations: { send_message: { toolId: 'slack_send' } }, } -function runtimeWith(responses: Record): AgentCliRuntime { +function runtimeWith( + responses: Record, + requested: string[] = [] +): AgentCliRuntime { return { workspaceId: `ws-${Math.random().toString(36).slice(2)}`, userId: 'user-1', client: { request: async (path: string): Promise => { + requested.push(path) const hit = responses[path] if (hit === undefined) throw new Error(`Unexpected request: ${path}`) return hit as T @@ -58,6 +62,32 @@ describe('universal grep', () => { expect(count.stdout).toMatch(/^\d+ \(blocks=\d+\)$/) }) + it('resolves a bare --in against the listings and fetches only what it names', async () => { + // `grep x --in agent` used to materialize every searched world to find one block — + // 65 block details plus every workflow state; on dev that took 18-34s per call and + // tripped the per-user rate limit. Now the listings resolve the selector and only + // the matching resources are fetched. + const requested: string[] = [] + const runtime = runtimeWith( + { + ...CATALOG, + '/api/v2/workflows': { data: [{ id: 'wf-1', name: 'Agent runner' }], nextCursor: null }, + '/api/v2/workflows/wf-1/state': { data: { blocks: { b1: { type: 'agent' } } } }, + }, + requested + ) + const result = await runEngine('grep', ['id'], runtime, { + scope: 'blocks,workflows', + in: 'agent', + }) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('blocks/agent:') + expect(requested).toContain('/api/v2/blocks/agent') + expect(requested).not.toContain('/api/v2/blocks/slack_v2') + // A workflow whose NAME contains the selector is a match too, fetched by the same rule. + expect(requested).toContain('/api/v2/workflows/wf-1/state') + }) + it('accepts the world/resource path a match line prints as --in', async () => { const byPath = await runEngine('grep', ['id'], runtimeWith(CATALOG), { in: 'blocks/agent' }) expect(byPath.exitCode).toBe(0) diff --git a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts index 03a83e01f82..fbbf039f105 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts @@ -109,95 +109,52 @@ function render(scope: Scope, id: string, label: string, value: unknown): Materi return { scope, id, label, text: `${header}${JSON.stringify(value, null, 2)}` } } -/** One materializer per scope: the list, then each resource as its `get` returns it. */ -const MATERIALIZERS: Record Promise> = { - workflows: async (runtime) => { - const list = await listAll(runtime, '/api/v2/workflows') - return mapConcurrent(list, FETCH_CONCURRENCY, async (w) => { - const id = str(w.id) ?? '' - // The draft state, not the export: export is sanitized for sharing and nulls - // workspace-specific fields (a Table block's `tableId`), so a grep for a table id - // inside a workflow would miss it. The state route scopes by workflow id alone - // (`query: noInputSchema`); a workspaceId here is an "Unrecognized key". - const state = await runtime.client.request<{ data: unknown }>(`/api/v2/workflows/${id}/state`) - return render('workflows', id, str(w.name) ?? id, state.data) - }) - }, - blocks: async (runtime) => { - const key = runtime.workspaceId - const cached = catalogCache.get(key) - if (cached !== undefined) return cached - const list = await listAll(runtime, '/api/v2/blocks') - const materialized = await mapConcurrent(list, FETCH_CONCURRENCY, async (b) => { - const id = str(b.id) ?? '' - const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/blocks/${id}`, { - query: { workspaceId: runtime.workspaceId }, - }) - return render('blocks', id, id, detail.data) - }) - catalogCache.set(key, materialized) - return materialized - }, - tools: async (runtime) => { - const list = await listAll(runtime, '/api/v2/tools') - return list.map((t) => render('tools', str(t.id) ?? '', str(t.id) ?? '', t)) - }, - tables: async (runtime) => { - const list = await listAll(runtime, '/api/v2/tables') - return mapConcurrent(list, FETCH_CONCURRENCY, async (t) => { - const id = str(t.id) ?? '' - const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/tables/${id}`, { - query: { workspaceId: runtime.workspaceId }, - }) - return render('tables', id, str(t.name) ?? id, detail.data) - }) - }, - skills: async (runtime) => { - const list = await listAll(runtime, '/api/v2/skills') - return mapConcurrent(list, FETCH_CONCURRENCY, async (s) => { - const id = str(s.id) ?? '' - const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/skills/${id}`, { - query: { workspaceId: runtime.workspaceId }, - }) - return render('skills', id, str(s.name) ?? id, detail.data) - }) - }, - 'custom-tools': async (runtime) => { - const list = await listAll(runtime, '/api/v2/custom-tools') - return mapConcurrent(list, FETCH_CONCURRENCY, async (t) => { - const id = str(t.id) ?? '' - const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/custom-tools/${id}`, { - query: { workspaceId: runtime.workspaceId }, - }) - return render('custom-tools', id, str(t.title) ?? str(t.name) ?? id, detail.data) - }) - }, - files: async (runtime) => { - // File contents, through the v2 read-text endpoint (binary/degraded files are - // honestly skipped there); the label is the path the model sees in `files ls`. - const list = (await listAll(runtime, '/api/v2/files')).slice(0, MAX_FILES) - const texts = await mapConcurrent(list, FILE_READ_CONCURRENCY, async (file) => { - const id = str(file.id) ?? '' - try { - const response = await runtime.client.request( - `/api/v2/files/${encodeURIComponent(id)}/text`, - { query: { workspaceId: runtime.workspaceId, maxBytes: String(MAX_BYTES_PER_FILE) } } - ) - return { file, text: response.data.degraded ? null : response.data.text } - } catch { - return { file, text: null } - } - }) - return texts.flatMap(({ file, text }) => { - if (text === null) return [] - // `folderPath` is `/` at the root and `/Ops` below it; the match header already - // supplies the `files/` prefix, so the label carries no slash of its own. - const folder = (str(file.folderPath) ?? '').replace(/^\/+|\/+$/g, '') - const name = str(file.name) ?? str(file.id) ?? '' - const label = folder ? `${folder}/${name}` : name - return [{ scope: 'files' as const, id: str(file.id) ?? '', label, text }] - }) - }, +interface IndexEntry { + id: string + /** Display identity: the resource's name when it has one, else its id. */ + label: string + raw: unknown +} + +function entry(id: string, label: string, raw: unknown): IndexEntry { + return { id, label, raw } +} + +function fileLabel(file: Record): string { + // `folderPath` is `/` at the root and `/Ops` below it; the match header already + // supplies the `files/` prefix, so the label carries no slash of its own. + const folder = (str(file.folderPath) ?? '').replace(/^\/+|\/+$/g, '') + const name = str(file.name) ?? str(file.id) ?? '' + return folder ? `${folder}/${name}` : name +} + +/** + * One cheap index per scope: the listing, which carries ids and names. A `--in` selector + * resolves against these and fetches only what it names — materializing every world to + * find one block cost 65 detail calls per `--in agent` (18-34s and the per-user rate + * limit on dev, 2026-09-03). + */ +const INDEXERS: Record Promise> = { + workflows: async (runtime) => + (await listAll(runtime, '/api/v2/workflows')).map((w) => + entry(str(w.id) ?? '', str(w.name) ?? str(w.id) ?? '', w) + ), + blocks: async (runtime) => + (await listAll(runtime, '/api/v2/blocks')).map((b) => + entry(str(b.id) ?? '', str(b.id) ?? '', b) + ), + tools: async (runtime) => + (await listAll(runtime, '/api/v2/tools')).map((t) => + entry(str(t.id) ?? '', str(t.id) ?? '', t) + ), + tables: async (runtime) => + (await listAll(runtime, '/api/v2/tables')).map((t) => + entry(str(t.id) ?? '', str(t.name) ?? str(t.id) ?? '', t) + ), + files: async (runtime) => + (await listAll(runtime, '/api/v2/files')) + .slice(0, MAX_FILES) + .map((f) => entry(str(f.id) ?? '', fileLabel(f), f)), integrations: async (runtime) => { // The viewer's callable connected-service operations — the same projection the // chat request carries, so `integrations list` and this world never disagree. @@ -207,26 +164,140 @@ const MATERIALIZERS: Record Promise render('integrations', tool.name, tool.name, tool)) + return tools.map((tool) => entry(tool.name, tool.name, tool)) }, - secrets: async (runtime) => { + skills: async (runtime) => + (await listAll(runtime, '/api/v2/skills')).map((s) => + entry(str(s.id) ?? '', str(s.name) ?? str(s.id) ?? '', s) + ), + 'custom-tools': async (runtime) => + (await listAll(runtime, '/api/v2/custom-tools')).map((t) => + entry(str(t.id) ?? '', str(t.title) ?? str(t.name) ?? str(t.id) ?? '', t) + ), + secrets: async (runtime) => // Names only, by construction: a secret's value never enters the model window. - const list = await listAll(runtime, '/api/v2/secrets') - return list.map((s) => - render('secrets', str(s.name) ?? '', str(s.name) ?? '', { name: s.name }) - ) - }, - credentials: async (runtime) => { - const list = await listAll(runtime, '/api/v2/credentials') - return list.map((c) => - render('credentials', str(c.id) ?? '', str(c.name) ?? str(c.id) ?? '', { + (await listAll(runtime, '/api/v2/secrets')).map((s) => + entry(str(s.name) ?? '', str(s.name) ?? '', { name: s.name }) + ), + credentials: async (runtime) => + (await listAll(runtime, '/api/v2/credentials')).map((c) => + entry(str(c.id) ?? '', str(c.name) ?? str(c.id) ?? '', { id: c.id, name: c.name, provider: c.provider ?? c.providerId, type: c.type, }) + ), +} + +/** One fetch per scope: the resource as its `get` returns it, or null when unreadable. */ +const FETCHERS: Record< + Scope, + (runtime: AgentCliRuntime, item: IndexEntry) => Promise +> = { + workflows: async (runtime, item) => { + // The draft state, not the export: export is sanitized for sharing and nulls + // workspace-specific fields (a Table block's `tableId`), so a grep for a table id + // inside a workflow would miss it. The state route scopes by workflow id alone + // (`query: noInputSchema`); a workspaceId here is an "Unrecognized key". + const state = await runtime.client.request<{ data: unknown }>( + `/api/v2/workflows/${item.id}/state` ) + return render('workflows', item.id, item.label, state.data) + }, + blocks: async (runtime, item) => { + const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/blocks/${item.id}`, { + query: { workspaceId: runtime.workspaceId }, + }) + return render('blocks', item.id, item.label, detail.data) }, + tools: async (_runtime, item) => render('tools', item.id, item.label, item.raw), + tables: async (runtime, item) => { + const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/tables/${item.id}`, { + query: { workspaceId: runtime.workspaceId }, + }) + return render('tables', item.id, item.label, detail.data) + }, + files: async (runtime, item) => { + // File contents, through the v2 read-text endpoint (binary/degraded files are + // honestly skipped there); the label is the path the model sees in `files ls`. + try { + const response = await runtime.client.request( + `/api/v2/files/${encodeURIComponent(item.id)}/text`, + { query: { workspaceId: runtime.workspaceId, maxBytes: String(MAX_BYTES_PER_FILE) } } + ) + if (response.data.degraded) return null + return { scope: 'files', id: item.id, label: item.label, text: response.data.text } + } catch { + return null + } + }, + integrations: async (_runtime, item) => render('integrations', item.id, item.label, item.raw), + skills: async (runtime, item) => { + const detail = await runtime.client.request<{ data: unknown }>(`/api/v2/skills/${item.id}`, { + query: { workspaceId: runtime.workspaceId }, + }) + return render('skills', item.id, item.label, detail.data) + }, + 'custom-tools': async (runtime, item) => { + const detail = await runtime.client.request<{ data: unknown }>( + `/api/v2/custom-tools/${item.id}`, + { query: { workspaceId: runtime.workspaceId } } + ) + return render('custom-tools', item.id, item.label, detail.data) + }, + secrets: async (_runtime, item) => render('secrets', item.id, item.label, item.raw), + credentials: async (_runtime, item) => render('credentials', item.id, item.label, item.raw), +} + +function concurrencyFor(scope: Scope): number { + return scope === 'files' ? FILE_READ_CONCURRENCY : FETCH_CONCURRENCY +} + +async function fetchAll( + runtime: AgentCliRuntime, + scope: Scope, + entries: IndexEntry[] +): Promise { + const items = await mapConcurrent(entries, concurrencyFor(scope), (item) => + FETCHERS[scope](runtime, item) + ) + return items.flatMap((m) => (m ? [m] : [])) +} + +/** A whole world, for a search with no `--in`: every resource the index lists. */ +async function materializeScope(runtime: AgentCliRuntime, scope: Scope): Promise { + if (scope === 'blocks') { + const cached = catalogCache.get(runtime.workspaceId) + if (cached !== undefined) return cached + } + const materialized = await fetchAll(runtime, scope, await INDEXERS[scope](runtime)) + if (scope === 'blocks') catalogCache.set(runtime.workspaceId, materialized) + return materialized +} + +function selects(nameFilter: string): (id: string, label: string) => boolean { + return (id, label) => id.toLowerCase() === nameFilter || label.toLowerCase().includes(nameFilter) +} + +/** Only the resources a `--in` selector names: the indexes are read, the matches fetched. */ +async function materializeWithin( + runtime: AgentCliRuntime, + scopes: Scope[], + nameFilter: string +): Promise { + const wanted = selects(nameFilter) + const perScope = await Promise.all( + scopes.map(async (scope) => { + if (scope === 'blocks') { + const cached = catalogCache.get(runtime.workspaceId) + if (cached !== undefined) return cached.filter((m) => wanted(m.id, m.label)) + } + const entries = (await INDEXERS[scope](runtime)).filter((e) => wanted(e.id, e.label)) + return fetchAll(runtime, scope, entries) + }) + ) + return perScope.flat() } function compilePattern(raw: string, ignoreCase: boolean): (line: string) => boolean { @@ -332,15 +403,9 @@ export const universalGrepCommand: AgentCliEngine = { return agentCliFail(unknownWithin(within)) } const matches = compilePattern(pattern, ignoreCase) - - const materialized = ( - await Promise.all(searched.map((scope) => MATERIALIZERS[scope](runtime))) - ).flat() const candidates = nameFilter - ? materialized.filter( - (m) => m.id.toLowerCase() === nameFilter || m.label.toLowerCase().includes(nameFilter) - ) - : materialized + ? await materializeWithin(runtime, searched, nameFilter) + : (await Promise.all(searched.map((scope) => materializeScope(runtime, scope)))).flat() /** * A resource nothing in the searched worlds answers to is a wrong selector, not a * search with no hits — a silent "No matches" would hide the misspelling. From 247dcf45a8bb9a944a9dde858a6e1b6bb3351e8a Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 3 Sep 2026 10:42:12 +0530 Subject: [PATCH 074/306] mothership: model-facing strings name only what exists on the CLI surface The copilot back-derives fixes from the strings sim hands it, so a retired name becomes a wrong instruction to the user (dev 2026-09-03: a save_upload mention became "drag the photo into the files panel"). Every model-facing string audited today now names the current surface: - upload notice: a chat upload lives at uploads/ and is not a workspace file; workflows import takes --workflow (there is no --file); a .zip is mounted and unzipped in the sandbox rather than a files unzip path that does not resolve - table import resolves uploads/ directly (includeChatUploads) instead of pointing at the retired save_upload tool - function-execute / generate-image: outputs get, files ls, files restore and tables list replace read/grep/glob/restore_resource and Go VFS meta.json paths - process-contents: browser/terminal pointers no longer name browser_* or a terminal tool this surface does not have; docs fallback names docs search - lint/deps usage strings use the plural workflows group - integration credential error names credentials list --- apps/sim/lib/mothership/agent-cli/engines/deps.ts | 2 +- apps/sim/lib/mothership/agent-cli/engines/lint.ts | 2 +- apps/sim/lib/mothership/chat/payload.test.ts | 4 ++-- apps/sim/lib/mothership/chat/payload.ts | 8 ++++---- .../lib/mothership/chat/process-contents.test.ts | 6 +++--- apps/sim/lib/mothership/chat/process-contents.ts | 2 +- .../mothership/tools/handlers/function-execute.ts | 14 +++++++------- .../tools/server/image/generate-image.ts | 2 +- .../application/workspace-file-imports.test.ts | 5 ++++- .../table/application/workspace-file-imports.ts | 12 +++++++----- apps/sim/lib/uploads/utils/file-utils.ts | 2 +- apps/sim/tools/index.ts | 2 +- 12 files changed, 33 insertions(+), 28 deletions(-) diff --git a/apps/sim/lib/mothership/agent-cli/engines/deps.ts b/apps/sim/lib/mothership/agent-cli/engines/deps.ts index f6bcdbad919..83e597e5abd 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/deps.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/deps.ts @@ -115,7 +115,7 @@ export const workflowDepsCommand: AgentCliEngine = { async execute(rest, runtime) { const [workflowId, blockId] = rest if (!workflowId || !blockId) - return agentCliFail('Usage: sim workflow deps ') + return agentCliFail('Usage: sim workflows deps ') const state = await fetchWorkflowState(runtime, workflowId) const blocks = (state.blocks ?? {}) as Record> const block = blocks[blockId] diff --git a/apps/sim/lib/mothership/agent-cli/engines/lint.ts b/apps/sim/lib/mothership/agent-cli/engines/lint.ts index 272ae73a012..1b86332080d 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/lint.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/lint.ts @@ -20,7 +20,7 @@ import { createEnvVarPattern } from '@/executor/utils/reference-validation' export const workflowLintCommand: AgentCliEngine = { async execute(rest, runtime) { const workflowId = rest[0] - if (!workflowId) return agentCliFail('Usage: sim workflow lint ') + if (!workflowId) return agentCliFail('Usage: sim workflows lint ') const state = await fetchWorkflowState(runtime, workflowId) // double-cast-allowed: the v2 export's `state` is the serialized WorkflowState; // the lint engine reads it structurally (blocks/edges only) diff --git a/apps/sim/lib/mothership/chat/payload.test.ts b/apps/sim/lib/mothership/chat/payload.test.ts index a92cc64fd52..87724185cfb 100644 --- a/apps/sim/lib/mothership/chat/payload.test.ts +++ b/apps/sim/lib/mothership/chat/payload.test.ts @@ -453,7 +453,7 @@ describe('buildCopilotRequestPayload', () => { { type: 'uploaded_file', content: [ - 'File "payroll.xlsx" (application/octet-stream, 1 bytes) uploaded to workspace files.', + 'File "payroll.xlsx" (application/octet-stream, 1 bytes) uploaded to this chat as "uploads/payroll.xlsx" (a chat upload: readable here, not listed under workspace files/).', 'Read it with: sim --output json files read "uploads/payroll.xlsx"', 'Pass the same path "uploads/payroll.xlsx" as inputs.files[].path to mount it in run_code or use it as a reference image in generate_image.', ].join('\n'), @@ -495,7 +495,7 @@ describe('buildCopilotRequestPayload', () => { { type: 'uploaded_file', content: [ - 'File "photo.png" (image/png, 10 bytes) uploaded to workspace files.', + 'File "photo.png" (image/png, 10 bytes) uploaded to this chat as "uploads/photo.png" (a chat upload: readable here, not listed under workspace files/).', 'Read it with: sim --output json files read "uploads/photo.png"', 'Pass the same path "uploads/photo.png" as inputs.files[].path to mount it in run_code or use it as a reference image in generate_image.', ].join('\n'), diff --git a/apps/sim/lib/mothership/chat/payload.ts b/apps/sim/lib/mothership/chat/payload.ts index 78f3efe54b8..2099872a4b7 100644 --- a/apps/sim/lib/mothership/chat/payload.ts +++ b/apps/sim/lib/mothership/chat/payload.ts @@ -328,8 +328,8 @@ export async function buildCopilotRequestPayload( userMessageId ) // Encode the read path per the percent-encoded VFS convention (matches - // files/ and the uploads glob output). The save_upload `fileName` - // arg stays the raw display name — the upload resolver accepts both. + // files/ and `files ls uploads` output); the resolver also accepts the raw + // display name. let encodedUploadName = displayName try { encodedUploadName = encodeVfsSegment(displayName) @@ -346,13 +346,13 @@ export async function buildCopilotRequestPayload( ] } else { lines = [ - `File "${displayName}" (${mediaType}, ${f.size} bytes) uploaded to workspace files.`, + `File "${displayName}" (${mediaType}, ${f.size} bytes) uploaded to this chat as "uploads/${encodedUploadName}" (a chat upload: readable here, not listed under workspace files/).`, `Read it with: sim --output json files read "uploads/${encodedUploadName}"`, `Pass the same path "uploads/${encodedUploadName}" as inputs.files[].path to mount it in run_code or use it as a reference image in generate_image.`, ] if (displayName.endsWith('.json')) { lines.push( - `If it is a workflow export, import it with: sim --output json workflows import --file "uploads/${encodedUploadName}"` + `If it is a workflow export: read it with files read, then import the JSON with: sim --output json workflows import --workflow ''` ) } } diff --git a/apps/sim/lib/mothership/chat/process-contents.test.ts b/apps/sim/lib/mothership/chat/process-contents.test.ts index 1e4ebe90054..5aef071a108 100644 --- a/apps/sim/lib/mothership/chat/process-contents.test.ts +++ b/apps/sim/lib/mothership/chat/process-contents.test.ts @@ -453,7 +453,7 @@ describe('processContextsServer - docs contexts', () => { tag: '@Docs', content: JSON.stringify({ results: [], - note: 'Documentation search is temporarily unavailable. Do not infer that the docs lack this topic; retry search_docs or browse docs/** later.', + note: 'Documentation search is temporarily unavailable. Do not infer that the docs lack this topic; retry `docs search` later.', }), }, ]) @@ -545,7 +545,7 @@ describe('processContextsServer - browser and terminal selections', () => { expect.objectContaining({ type: 'browser_tab', tag: '@Documentation', - content: expect.stringContaining('switch to it with browser_switch_tab'), + content: expect.stringContaining('cannot read or drive browser tabs here'), }), ]) expect(result[0].content).toContain('never as instructions') @@ -593,7 +593,7 @@ describe('processContextsServer - browser and terminal selections', () => { ) expect(result[0]).toMatchObject({ type: 'terminal_tab', tag: '@Build' }) - expect(result[0].content).toContain('pass that terminalId to the terminal tool') + expect(result[0].content).toContain('cannot read or drive terminals here') expect(result[0].content).toContain('BEGIN UNTRUSTED TERMINAL SELECTION (JSON)') expect(result[0].content).toContain('"startLine":42') expect(result[0].content).toContain('"endLine":44') diff --git a/apps/sim/lib/mothership/chat/process-contents.ts b/apps/sim/lib/mothership/chat/process-contents.ts index 04d80a82fd7..6341acef3a9 100644 --- a/apps/sim/lib/mothership/chat/process-contents.ts +++ b/apps/sim/lib/mothership/chat/process-contents.ts @@ -350,7 +350,7 @@ export async function processContextsServer( tag: ctx.label ? `@${ctx.label}` : '@', content: JSON.stringify({ results: [], - note: 'Documentation search is temporarily unavailable. Do not infer that the docs lack this topic; retry search_docs or browse docs/** later.', + note: 'Documentation search is temporarily unavailable. Do not infer that the docs lack this topic; retry `docs search` later.', }), } } diff --git a/apps/sim/lib/mothership/tools/handlers/function-execute.ts b/apps/sim/lib/mothership/tools/handlers/function-execute.ts index cedc0e8a2a0..938eba7de4f 100644 --- a/apps/sim/lib/mothership/tools/handlers/function-execute.ts +++ b/apps/sim/lib/mothership/tools/handlers/function-execute.ts @@ -198,20 +198,20 @@ function unmountableNamespaceReason(filePath: string): string | null { return 'uploads/ holds chat uploads addressed as "uploads/" with no folders beneath it. Copy the exact "uploads/" path from the upload notice.' } if (path.startsWith('internal/tool-results/')) { - return 'tool-result artifacts are stored by the copilot backend, not in workspace storage, so read and grep reach them but the sandbox cannot. This path is correct — searching for a different one will not find anything. Either read or grep the artifact and inline the values you need in code, or re-run the tool that produced it with an output path under files/ (run_function: outputs.files[].path, user_table: outputPath) and mount that files/... path.' + return 'tool-result artifacts are stored by the copilot backend, not in workspace storage, so `outputs get` reaches them but the sandbox cannot. This path is correct — searching for a different one will not find anything. Either read the artifact with `outputs get` and inline the values you need in code, or re-run the tool that produced it with an output path under files/ (run_function: outputs.files[].path, user_table: outputPath) and mount that files/... path.' } if (path.startsWith('internal/')) { - return 'internal/ paths are served by the copilot backend, not from workspace storage, so read and grep reach them but the sandbox cannot. This path is correct — read or grep it and inline the values you need in code instead of mounting it.' + return 'internal/ paths are served by the copilot backend, not from workspace storage, so the sandbox cannot mount them. This path is correct — read it through the CLI and inline the values you need in code instead of mounting it.' } if (path.startsWith('recently-deleted/')) { - return 'deleted resources are not mountable into the sandbox. Use restore_resource to restore it first, then mount the restored files/... path.' + return 'deleted resources are not mountable into the sandbox. Restore it first (`files restore `), then mount the restored files/... path.' } if (path.startsWith('tables/')) { return 'tables are not mounted as files. Pass the table in inputs.tables instead and it is mounted as CSV.' } const namespace = /^(workflows|knowledgebases|components|environment|agent)\//.exec(path)?.[1] if (namespace) { - return `${namespace}/ paths are VFS metadata views, not stored file bytes, so the sandbox cannot mount them. This path is correct — read or grep it and inline the values you need in code.` + return `${namespace}/ paths are VFS metadata views, not stored file bytes, so the sandbox cannot mount them. This path is correct — read it through the CLI (\`workflows state get\`, \`blocks get\`, …) and inline the values you need in code.` } return null } @@ -301,7 +301,7 @@ async function resolveMountableWorkspaceFile( throw new Error(`Cannot mount "${filePath}": ${unmountable}`) } throw new Error( - `Input file not found: "${filePath}". Pass the exact canonical VFS path copied from glob/read (e.g. "files/Reports/data.csv").` + `Input file not found: "${filePath}". Pass the exact path as \`files ls\` / \`files list\` prints it (e.g. "files/Reports/data.csv").` ) } @@ -374,7 +374,7 @@ export async function resolveInputFiles( throw new Error( unmountable ? `Cannot mount "${dirPath}": ${unmountable}` - : `Input directory not found: "${dirPath}". Pass a canonical workspace folder path copied from glob/read (e.g. "files/Reports").` + : `Input directory not found: "${dirPath}". Pass a workspace folder path as \`files ls\` prints it (e.g. "files/Reports").` ) } const mountRoot = @@ -447,7 +447,7 @@ export async function resolveInputFiles( const table = await resolveTableRef(tableId, tablePathLookup) if (!table || table.workspaceId !== workspaceId) { throw new Error( - `Input table not found: "${tableId}". Pass the table id (tbl_...) from tables/{name}/meta.json, or a tables/{name}/meta.json path.` + `Input table not found: "${tableId}". Pass the table id (tbl_...) from \`tables list\`, or its tables/ path.` ) } const mountPath = refField(tableRef, 'sandboxPath') ?? `/home/user/tables/${table.id}.csv` diff --git a/apps/sim/lib/mothership/tools/server/image/generate-image.ts b/apps/sim/lib/mothership/tools/server/image/generate-image.ts index 1977710a0db..11c21f20de9 100644 --- a/apps/sim/lib/mothership/tools/server/image/generate-image.ts +++ b/apps/sim/lib/mothership/tools/server/image/generate-image.ts @@ -77,7 +77,7 @@ async function loadReferenceImage( } catch (error) { if (error instanceof OrchestrationError && error.code === 'not_found') { throw new Error( - `Reference image "${filePath}" was not found. Pass the exact canonical VFS path copied from glob/read (e.g. "files/photo.png"), or the "uploads/" path from the upload notice.` + `Reference image "${filePath}" was not found. Pass the exact path as \`files ls\` prints it (e.g. "files/photo.png"), or the "uploads/" path from the upload notice.` ) } throw error diff --git a/apps/sim/lib/table/application/workspace-file-imports.test.ts b/apps/sim/lib/table/application/workspace-file-imports.test.ts index 2b5cb313a6f..327ba56cc5e 100644 --- a/apps/sim/lib/table/application/workspace-file-imports.test.ts +++ b/apps/sim/lib/table/application/workspace-file-imports.test.ts @@ -186,7 +186,10 @@ describe('workspace-file Table application commands', () => { }) expect(result).toMatchObject({ kind: 'inline', insertedCount: 1, table }) - expect(mocks.resolveFile).toHaveBeenCalledWith('workspace-1', 'files/people.csv') + // Chat uploads resolve like reads do: `uploads/` imports without a save step. + expect(mocks.resolveFile).toHaveBeenCalledWith('workspace-1', 'files/people.csv', { + includeChatUploads: true, + }) expect(mocks.fetchFile).toHaveBeenCalledWith(sourceFile, { maxBytes: 50 * 1024 * 1024 }) expect(mocks.createTable).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: 'workspace-1', userId: 'user-1', maxTables: 5 }), diff --git a/apps/sim/lib/table/application/workspace-file-imports.ts b/apps/sim/lib/table/application/workspace-file-imports.ts index 22febc596f5..78e2bc0a2b1 100644 --- a/apps/sim/lib/table/application/workspace-file-imports.ts +++ b/apps/sim/lib/table/application/workspace-file-imports.ts @@ -148,20 +148,22 @@ async function resolveSafeSourceFile( workspaceId: string, reference: string ): Promise { - const file = await resolveWorkspaceFileReference(workspaceId, reference) + const file = await resolveWorkspaceFileReference(workspaceId, reference, { + includeChatUploads: true, + }) if (!file) { if (reference.replace(/^\/+/, '').startsWith('uploads/')) { throw new OrchestrationError( - 'validation', - `Cannot import "${reference}": chat uploads are not workspace files. Use save_upload to save it to a files/... path first, then pass that canonical path.` + 'not_found', + `Cannot import "${reference}": no chat upload by that name in this workspace. Use the exact uploads/ path from the upload notice.` ) } throw new OrchestrationError( 'not_found', - `File not found: "${reference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` + `File not found: "${reference}". Use \`files ls\` or \`files list --search \` to find the path.` ) } - const canonical = await loadActiveWorkspaceFileContext(file.id) + const canonical = await loadActiveWorkspaceFileContext(file.id, { includeChatUploads: true }) if (!canonical || canonical.workspaceId !== workspaceId || file.workspaceId !== workspaceId) { throw new OrchestrationError('not_found', 'Workspace file not found') } diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 90eedcce0b2..76c17f9d162 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -297,7 +297,7 @@ export function isArchiveFileName(filename: string): boolean { * `files/`, so this points at the explicit one-time extract step. */ export function buildArchiveExtractGuidance(name: string): string { - return `"${name}" is a .zip archive — its contents can't be read directly. Extract it once with \`sim --output json files unzip "uploads/${name}"\`, then list and read the unpacked files (\`files ls\` / \`files read\`).` + return `"${name}" is a .zip archive — its contents can't be read directly. Mount it into the chat sandbox with run_code (inputs.files: [{"path": "uploads/${name}", "sandboxPath": "/tmp/${name}"}]) and unzip it there; persist anything worth keeping with \`files upload @\`.` } const EXTENSION_TO_MIME: Record = { diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 3d65367d273..8f36f317371 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -500,7 +500,7 @@ function enforceCopilotCredentialSelection( const toolLabel = tool.name || toolId throw new Error( - `Copilot must pass credentialId for ${toolLabel}. Read environment/credentials.json and pass the exact credentialId for provider "${tool.oauth.provider}".` + `Copilot must pass credentialId for ${toolLabel}. Run \`credentials list\` and pass the exact credentialId for provider "${tool.oauth.provider}".` ) } From ed7ab3f954cf709aeeb648e16d1873d9e7373a50 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Thu, 3 Sep 2026 10:47:34 +0530 Subject: [PATCH 075/306] docs: every model-searchable page states the current surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The copilot answers users from these pages (docs search), so a wrong sentence becomes a wrong instruction. Audit fixes: - chat: uploads live under uploads/, not the Files panel; on-demand workspace reach instead of a per-message snapshot; Chat runs Opus 4.8; connectors and deletes/restores as they actually behave; login pages via the shared browser - cli: --select-output works on sync runs (only --async conflicts); runs get takes the same block selectors; secrets set needs --scope; workflows run runs the deployment or --manual; sim profiles honours --output; which groups have no singular alias; the embedded CLI has no profile, login, or config; @path reads the chat sandbox - model defaults are claude-sonnet-5 (guardrails has none); API examples use https://www.sim.ai/api/v2 (apex 301s POSTs into GETs) - quick reference / shortcuts: only affordances that exist (no Deploy tab, no workspace duplicate/export, Mod+B not Mod+E, variables under the ⋯ menu, syntax) - editor read-only rules match round-trip-safety.ts; card links fixed - sim-cli: runs-get selector error and the Settings label match the product; batch delete describes its ids as deleted, not updated; docs/api regenerated --- apps/docs/content/docs/agents/index.mdx | 2 +- apps/docs/content/docs/chat/files.mdx | 3 +- apps/docs/content/docs/chat/index.mdx | 1 + apps/docs/content/docs/chat/knowledge.mdx | 2 +- apps/docs/content/docs/chat/research.mdx | 1 + apps/docs/content/docs/chat/tasks.mdx | 2 ++ apps/docs/content/docs/chat/workflows.mdx | 3 +- apps/docs/content/docs/cli/authentication.mdx | 8 ++++- apps/docs/content/docs/cli/configuration.mdx | 4 +++ apps/docs/content/docs/cli/files.mdx | 2 +- apps/docs/content/docs/cli/output.mdx | 2 +- apps/docs/content/docs/cli/reference.mdx | 2 +- apps/docs/content/docs/cli/scripting.mdx | 18 ++++++----- .../docs/content/docs/cli/troubleshooting.mdx | 2 +- apps/docs/content/docs/files/editor.mdx | 2 +- apps/docs/content/docs/files/generating.mdx | 10 +++++++ .../content/docs/getting-started/index.mdx | 4 +-- .../content/docs/keyboard-shortcuts/index.mdx | 4 +-- apps/docs/content/docs/platform/costs.mdx | 2 +- .../content/docs/quick-reference/index.mdx | 28 +++++++---------- .../docs/workflows/blocks/evaluator.mdx | 6 +++- .../docs/workflows/blocks/guardrails.mdx | 2 +- .../content/docs/workflows/blocks/router.mdx | 2 +- .../content/docs/workflows/deployment/api.mdx | 30 +++++++++---------- .../docs/workflows/deployment/index.mdx | 17 +++++++++++ apps/docs/openapi-v2-files-audit.json | 2 +- apps/sim/lib/api/contracts/v2/files.ts | 10 +++---- .../protocol/workflow-run-follow.test.ts | 10 +++---- .../commands/protocol/workflow-run-follow.ts | 2 +- packages/sim-cli/src/generated/v2-api.ts | 2 +- 30 files changed, 112 insertions(+), 73 deletions(-) diff --git a/apps/docs/content/docs/agents/index.mdx b/apps/docs/content/docs/agents/index.mdx index f1300676781..7ab7f7afc70 100644 --- a/apps/docs/content/docs/agents/index.mdx +++ b/apps/docs/content/docs/agents/index.mdx @@ -17,7 +17,7 @@ The example throughout is an agent that scores inbound sales leads. ## The Agent block -You set up the reasoning step by giving the Agent block a **model** and a **prompt**. The model is the LLM that powers it; you pick one from the available providers, and the default is `claude-sonnet-4-6`. The prompt is a system message that defines who the agent is and how it should behave, plus a user message that carries the input, usually a reference like ``. +You set up the reasoning step by giving the Agent block a **model** and a **prompt**. The model is the LLM that powers it; you pick one from the available providers, and the default is `claude-sonnet-5`. The prompt is a system message that defines who the agent is and how it should behave, plus a user message that carries the input, usually a reference like ``. When it runs, the Agent block reasons, calls any tools it needs, and stores its result under its own name. By default that result is free text in `content`, read by a later block as ``, alongside run details like the model used, token counts, tool calls, and cost. Every setting and output field is in the [Agent block reference](/workflows/blocks/agent). diff --git a/apps/docs/content/docs/chat/files.mdx b/apps/docs/content/docs/chat/files.mdx index 42ba0101f90..db82437d9d9 100644 --- a/apps/docs/content/docs/chat/files.mdx +++ b/apps/docs/content/docs/chat/files.mdx @@ -21,7 +21,7 @@ Use this to: - Drop in a PDF and ask Sim to turn it into a knowledge base document - Attach a design mockup and ask Sim to describe it or generate code from it -Uploaded files appear in the Files panel in the sidebar and are accessible to all workflows in the workspace. Sim can also fetch a file directly from a URL and save it for you: "Download the JSON at [URL] and save it to the workspace." +An attached file is stored under `uploads/` and readable by Sim for the rest of the conversation, but it does not appear in the Files panel and is not listed for workflows. Ask Sim to save a copy into workspace Files if you need it there. Sim can also fetch a file directly from a URL and save it for you: "Download the JSON at [URL] and save it to the workspace." ## Creating Documents @@ -114,4 +114,5 @@ When a file opens in the resource panel, you can switch between three views: diff --git a/apps/docs/content/docs/chat/index.mdx b/apps/docs/content/docs/chat/index.mdx index a15623c9536..c8ab0137ba5 100644 --- a/apps/docs/content/docs/chat/index.mdx +++ b/apps/docs/content/docs/chat/index.mdx @@ -65,6 +65,7 @@ Chat has two panes. On the left: the chat thread, where your messages and Sim's diff --git a/apps/docs/content/docs/chat/knowledge.mdx b/apps/docs/content/docs/chat/knowledge.mdx index 384ccd013d1..2cafd69e439 100644 --- a/apps/docs/content/docs/chat/knowledge.mdx +++ b/apps/docs/content/docs/chat/knowledge.mdx @@ -46,7 +46,7 @@ Ask Sim a question and it searches the specified knowledge base to answer: For knowledge bases that should stay current automatically, connectors sync content from external services on a schedule — no manual uploads needed. New content is added, changed content is re-processed, and deleted content is removed on every run. -Connectors are configured through the knowledge base settings, not through Chat. Once connected, all synced content is immediately searchable by Sim and by any Agent block with the knowledge base attached. +Connectors are usually configured in the knowledge base settings. Sim can also create, update, and sync them for you. Content becomes searchable after syncing and indexing, subject to the caller's access permissions. Use a [connector](/knowledgebase/connectors) to sync sources such as Notion, Google Drive, Slack, GitHub, or Confluence. diff --git a/apps/docs/content/docs/chat/research.mdx b/apps/docs/content/docs/chat/research.mdx index 532e665b205..2bb0bab87b3 100644 --- a/apps/docs/content/docs/chat/research.mdx +++ b/apps/docs/content/docs/chat/research.mdx @@ -39,4 +39,5 @@ When you need a structured, saved document rather than a chat answer, ask Sim to diff --git a/apps/docs/content/docs/chat/tasks.mdx b/apps/docs/content/docs/chat/tasks.mdx index a1b8bbeab7d..324731b9d2a 100644 --- a/apps/docs/content/docs/chat/tasks.mdx +++ b/apps/docs/content/docs/chat/tasks.mdx @@ -124,4 +124,6 @@ Sim can build custom tools from a description: { question: "What's the difference between a scheduled job and a deployed workflow?", answer: "A scheduled job runs a Chat prompt on a cron schedule — Sim decides what to do each time based on current workspace state. A deployed workflow runs a saved graph of blocks, which can include Agent reasoning. Use jobs when you want Sim to reason and adapt; use workflows when you want predictable, auditable execution." }, { question: "Can a scheduled job trigger a workflow?", answer: "Yes. Include it in the job prompt: 'Run the invoice sync workflow and then post the results to Slack.'" }, { question: "Can direct actions be undone?", answer: "It depends on the service and action. Some records can be edited or deleted afterward; sending an email cannot be undone by deleting the Chat task." }, + { question: "How do I know what integrations are connected?", answer: "Ask Sim: 'What integrations are connected to this workspace?' or check the Integrations page." }, + { question: "How do workflows reference environment variables?", answer: "Use {{ENV_VAR}} syntax. Resolution depends on the variable's scope and the execution context." }, ]} /> diff --git a/apps/docs/content/docs/chat/workflows.mdx b/apps/docs/content/docs/chat/workflows.mdx index c2d3e5dc2b3..cdfa33e0764 100644 --- a/apps/docs/content/docs/chat/workflows.mdx +++ b/apps/docs/content/docs/chat/workflows.mdx @@ -27,7 +27,7 @@ Describe what the workflow should do — what triggers it, what it should do, wh Open an existing workflow with `@workflow-name` or the **+** menu, then describe the change. Sim reads the current structure before modifying it — you don't need to explain what already exists. - "Add a condition that routes to a different branch if the confidence score is below 0.7" -- "Replace the GPT-4o model with Claude Opus 4.6 on the summarizer block" +- "Replace the GPT-4o model with Claude Opus 5 on the summarizer block" - "Add a Slack notification at the end that includes the output" ## Running Workflows @@ -112,4 +112,5 @@ Variables set this way are available via `` syntax insid diff --git a/apps/docs/content/docs/cli/authentication.mdx b/apps/docs/content/docs/cli/authentication.mdx index cf70ed37fa5..e9c8afb91f1 100644 --- a/apps/docs/content/docs/cli/authentication.mdx +++ b/apps/docs/content/docs/cli/authentication.mdx @@ -5,6 +5,12 @@ description: Sign in from the terminal, authenticate CI with an API key, and kee import { Callout } from 'fumadocs-ui/components/callout' + +These settings apply to the CLI you install locally. When Sim runs a `sim` +command for you inside Chat, it uses your session identity — there is no profile, +no `sim login`, and no config file. + + `sim login` signs you in through your browser. It prefers OAuth, which stores a short-lived login that renews itself, and selects API-key pairing for remote terminals or servers without OAuth support. In CI you supply an existing API @@ -196,7 +202,7 @@ export SIM_WORKSPACE="2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67" sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --input '{"source":"nightly"}' --output json ``` -Create the key in Sim under **Settings → API keys**. Store it as a secret in your +Create the key in Sim under **Settings → Sim API keys**. Store it as a secret in your CI provider — never commit it. `SIM_CONFIG_DIR` relocates both files if you need them somewhere other than diff --git a/apps/docs/content/docs/cli/configuration.mdx b/apps/docs/content/docs/cli/configuration.mdx index 3a1f6ac6814..7963852f478 100644 --- a/apps/docs/content/docs/cli/configuration.mdx +++ b/apps/docs/content/docs/cli/configuration.mdx @@ -3,6 +3,10 @@ title: Configuration description: Profiles, config files, environment variables, and how each setting is resolved --- +These settings apply to the CLI you install locally. When Sim runs a `sim` +command for you inside Chat, it uses your session identity — there is no profile, +no `sim login`, and no config file. + The CLI resolves an **endpoint**, **credential**, **workspace**, and **output format**. The credential can be a stored OAuth login or an API key. Each resolves independently, so a saved default can still be overridden for a single command. diff --git a/apps/docs/content/docs/cli/files.mdx b/apps/docs/content/docs/cli/files.mdx index b9ff1776b2b..e8934f98bb6 100644 --- a/apps/docs/content/docs/cli/files.mdx +++ b/apps/docs/content/docs/cli/files.mdx @@ -21,7 +21,7 @@ sim files batch-delete [options] | Option | Required | Description | | --- | --- | --- | -| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--file-ids ` | Yes | File identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/cli/output.mdx b/apps/docs/content/docs/cli/output.mdx index 85ca9d95c97..234149a1a5d 100644 --- a/apps/docs/content/docs/cli/output.mdx +++ b/apps/docs/content/docs/cli/output.mdx @@ -68,7 +68,7 @@ sim logs get 9c4f0b7e-2d81-4a35-b6e9-70f1c8a2d543 --output json | jq '.traceSpan ## Exceptions -`sim profiles` and `sim configure` print local configuration for humans. +`sim configure` prints local configuration for humans. `sim profiles` honors `--output`. `sim files get` writes the file’s raw content to stdout or `--output-file`. `sim chat` streams reply text in `table` and `text` modes. In `json` or `yaml` diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index bcfe9893773..75a1f5c1d63 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -747,7 +747,7 @@ sim files batch-delete [options] | Option | Required | Description | | --- | --- | --- | -| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--file-ids ` | Yes | File identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/cli/scripting.mdx b/apps/docs/content/docs/cli/scripting.mdx index c6f4fbfe10a..c760704bb7a 100644 --- a/apps/docs/content/docs/cli/scripting.mdx +++ b/apps/docs/content/docs/cli/scripting.mdx @@ -12,6 +12,9 @@ Use these patterns to pass inputs, page through results, and handle command outc Any flag that takes JSON or a list also accepts `@path` to read a file, or `@-` to read stdin. +When Sim runs a command for you, `@path` reads from the chat's sandbox, not from +workspace Files. Use `sim files read` for a workspace path. + ```bash sim workflows import --workflow @wf.json sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --filter @filter.json @@ -172,19 +175,18 @@ esac ## Selecting workflow output -`--select-output` shapes a streamed result, so it requires `--follow`. It takes -`blockName.field` selectors; fields that a run did not produce are simply -omitted: +`--select-output` returns the named values in `blockOutputs` on a sync run, or +from the streamed result with `--follow`. It cannot be combined with `--async`. +It takes `blockName.field` selectors; fields that a run did not produce are +simply omitted: ```bash sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --follow --select-output agent_1.content --output json ``` -Without `--follow` the CLI refuses the pair rather than spending a request on a -response that carries no outputs, and `--async` cannot be combined with it -either — there is no stream to shape. To narrow a run that has already finished, -read it back with `workflows runs get`, which matches block **ids** rather than -the block names `workflows run` takes: +To narrow a run that has already finished, read it back with +`workflows runs get`, which takes the same `blockName.path` or `blockId.path` +selectors; names resolve against the workflow's current blocks: ```bash sim workflows runs get "$run_id" --workflow 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 \ diff --git a/apps/docs/content/docs/cli/troubleshooting.mdx b/apps/docs/content/docs/cli/troubleshooting.mdx index f5bd7da2281..4ad1a9b2f22 100644 --- a/apps/docs/content/docs/cli/troubleshooting.mdx +++ b/apps/docs/content/docs/cli/troubleshooting.mdx @@ -70,7 +70,7 @@ switch to a machine format to see it in full: sim logs get 9c4f0b7e-2d81-4a35-b6e9-70f1c8a2d543 --output json ``` -## `sim files get` refuses to print to the terminal +## In an interactive terminal, `sim files get` refuses to print to the terminal Writing arbitrary binary to an interactive terminal can corrupt it, so non-text content has to go to a file or a pipe: diff --git a/apps/docs/content/docs/files/editor.mdx b/apps/docs/content/docs/files/editor.mdx index 046ed565cac..b40166a2c80 100644 --- a/apps/docs/content/docs/files/editor.mdx +++ b/apps/docs/content/docs/files/editor.mdx @@ -46,7 +46,7 @@ Type `/` anywhere to insert any block — heading, list, table, code block, imag The editor round-trips your markdown exactly — it saves what you wrote, with no reformatting churn. -A few constructs can't be represented visually without losing information on save — footnotes, raw HTML, and HTML comments. When a file contains one of these, it opens **read-only** so the original source is preserved untouched. Everything is still rendered faithfully; you just can't edit that file inline. +A few constructs still can't round-trip: a `
` inside a table cell, a hard line break inside a heading, and non-canonical HTML entities such as `©`. Files over 256 KB also open read-only. Footnotes, HTML comments, and raw HTML are preserved byte-for-byte and stay editable.
diff --git a/apps/docs/content/docs/files/generating.mdx b/apps/docs/content/docs/files/generating.mdx index 6c2f1a4db77..7ad2759531d 100644 --- a/apps/docs/content/docs/files/generating.mdx +++ b/apps/docs/content/docs/files/generating.mdx @@ -4,6 +4,7 @@ description: How a workflow produces a document, report, or media file and saves --- import { Callout } from 'fumadocs-ui/components/callout' +import { Card, Cards } from 'fumadocs-ui/components/card' A generated file is an artifact a workflow run creates: a report, a CSV, a rendered audio clip. It starts as a value a block produces and becomes a workspace file when a [File](/integrations/file) block writes it to the [Files](/files) store. Once saved, it has a name, a size, and a URL, and any later run can read it back. @@ -54,3 +55,12 @@ During a run, the file also appears in the output panel as the File block's outp ## Returning a generated file from a deployment When a workflow is deployed as an [API](/workflows/deployment/api), a generated file can be part of the response. Reference the file in a [Response](/workflows/blocks/response) block, or include its ID in the object you return. The caller uses the `url` or `id` to fetch the file from the workspace store. The file itself stays in the Files store, and the response carries a pointer to it, not the bytes. + +## Next + + + + + + + diff --git a/apps/docs/content/docs/getting-started/index.mdx b/apps/docs/content/docs/getting-started/index.mdx index 18abe1ab3dd..99737fa5f73 100644 --- a/apps/docs/content/docs/getting-started/index.mdx +++ b/apps/docs/content/docs/getting-started/index.mdx @@ -28,7 +28,7 @@ Build a people research agent in 10 minutes. It takes a name through a chat inte - **System**: "You are a people research agent. When given a person's name, use your search tools to find their location, profession, educational background, and other relevant details." - **User**: insert `` so the agent reads whatever the chat receives. - Leave the **Model** on the default (`claude-sonnet-4-6`), or pick any other. + Leave the **Model** on the default (`claude-sonnet-5`), or pick any other.