From de6269e52e11db55deb7d3a427b9f5af3f3886e9 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 22 Aug 2026 16:15:21 -0700 Subject: [PATCH 1/4] improvement(perf): eight verified cuts to workspace cold-load JavaScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second round of load-time work, adversarially verified for strict behaviour preservation before implementation. Each item is an import-graph fix — none changes what renders, when it renders, or any data path: - knowledge/[id] imported one modal through the [documentId] components barrel, which also exports the chunk editor and therefore js-tiktoken (~2.5 MB gzip of BPE tables) on a route that never edits chunks. Deep import. - prepareBlockState moved out of stores/workflows/utils.ts into its own module. It is the only function there needing the block registry and the generated tool-outputs artifact (~476 KB gzip), and utils.ts is reached by the persistent shell — so every workspace route paid for a canvas-only helper, including a module-scope JSON.parse of a 5.4 MB string. - ExecutionSnapshot (the frozen-canvas modal) is now React.lazy behind its interaction gates, per the code-splitting procedure in sim-imports.md: deep import, dead barrel re-export deleted, sibling imports in log-details deepened to break the parent->child barrel cycle, local Suspense at both render sites. Takes ~7.6 MB of source off logs hydration. - The api contracts barrel no longer re-exports ./tools, ./selectors, ./v1, or ./demo-requests (~58 KB gzip of Zod schema construction on every route). Zero importers used the barrel path for any of them. - createCsvParser (streaming csv-parse, a Node Transform) moved to a server-only module so its stream polyfill leaves client bundles. Deliberately not re-exported from the lib/table barrel. - jszip is dynamically imported at both remaining static call sites (skill zip extraction, pptx parsing) — both already-async, user-triggered paths, mirroring the existing pattern in workflow import-export. - The desktop local-filesystem tool executor is dynamically imported in use-chat; a chunk-load failure now reports an error completion so the server-side tool call settles instead of hanging. Production build, JS downloaded before the load event, vs the previous release: /home 4.44 -> 3.87 MB /logs 4.44 -> 3.64 MB /knowledge 4.22 -> 3.68 MB /tables 4.17 -> 3.61 MB /files 4.68 -> 4.10 MB /w/[id] 4.80 -> 4.67 MB /home total after idle prefetch: 8.15 -> 5.52 MB The lazy snapshot was exercised end-to-end: its chunk loads when a log detail opens (off the route's cold path, warm before the View Snapshot click) and the modal renders without errors. Boundary baseline retightened. --- .../[workspaceId]/home/hooks/use-chat.ts | 30 +- .../[workspaceId]/knowledge/[id]/base.tsx | 8 +- .../[workspaceId]/logs/components/index.ts | 1 - .../components/execution-snapshot/index.ts | 1 - .../components/log-details/log-details.tsx | 51 ++- .../app/workspace/[workspaceId]/logs/logs.tsx | 34 +- .../[workspaceId]/skills/components/utils.ts | 7 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 3 +- apps/sim/lib/api/contracts/index.ts | 12 +- .../lib/pptx-renderer/parser/zip-parser.ts | 4 +- apps/sim/lib/table/import-runner.ts | 2 +- apps/sim/lib/table/import-stream.ts | 27 ++ apps/sim/lib/table/import.test.ts | 2 +- apps/sim/lib/table/import.ts | 14 +- apps/sim/lib/table/orchestration/import.ts | 2 +- .../stores/workflows/prepare-block-state.ts | 154 ++++++++ apps/sim/stores/workflows/utils.test.ts | 3 +- apps/sim/stores/workflows/utils.ts | 142 ------- ...check-tool-registry-boundary.baseline.json | 356 +++++++++--------- 19 files changed, 481 insertions(+), 372 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts create mode 100644 apps/sim/lib/table/import-stream.ts create mode 100644 apps/sim/stores/workflows/prepare-block-state.ts 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 b3e422d5328..a2e2fe7ffc6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -74,7 +74,6 @@ import { TERMINAL_SESSION_RESOURCE_ID, } from '@/lib/copilot/resources/types' import { executeBrowserToolOnClient } from '@/lib/copilot/tools/client/browser-tool-execution' -import { executeLocalFilesystemTool } from '@/lib/copilot/tools/client/local-filesystem' import { bindRunToolToExecution, cancelRunToolExecution, @@ -2009,11 +2008,36 @@ export function useChat( return } handledClientLocalFilesystemToolIdsRef.current.add(toolCallId) - executeLocalFilesystemTool(toolCallId, toolName, toolArgs, { + const options = { workspaceId, chatId: chatIdRef.current ?? selectedChatIdRef.current, signal: abortControllerRef.current?.signal, - }) + } + /** + * Dynamic on purpose: the local-filesystem executor only runs for desktop-local + * VFS tool calls, and a static import kept it in the shared chat chunk on every + * surface that mounts the composer. The guard, the dedupe add, and the option + * capture above stay synchronous, so re-entrancy behaviour is unchanged. If the + * chunk fails to load (deploy skew), the server-side tool call must still settle: + * report an error completion rather than leaving it hanging with the dedupe ref + * already marked handled. + */ + import('@/lib/copilot/tools/client/local-filesystem').then( + (m) => m.executeLocalFilesystemTool(toolCallId, toolName, toolArgs, options), + async (error) => { + logger.error('Failed to load local filesystem tool executor', { error }) + const [{ reportClientToolCompletion }, { ASYNC_TOOL_CONFIRMATION_STATUS }] = + await Promise.all([ + import('@/lib/copilot/tools/client/completion'), + import('@/lib/copilot/async-runs/lifecycle'), + ]) + await reportClientToolCompletion( + toolCallId, + ASYNC_TOOL_CONFIRMATION_STATUS.error, + 'Local filesystem tool failed to load' + ) + } + ) }, [workspaceId] ) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 32a08870988..7e5d1e45483 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -77,7 +77,13 @@ import { useFolderAncestors, } from '@/app/workspace/[workspaceId]/components/folders' import { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' -import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components' +/** + * Deep import on purpose: the `[documentId]/components` barrel also exports `ChunkEditor`, + * which needs exact token counts and therefore `js-tiktoken` (~2.5 MB gzip of BPE rank + * tables). Importing the modal through the barrel shipped the tokenizer to the document + * LIST route, which never edits chunks. + */ +import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal' import { ActionBar, AddConnectorModal, diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts index c8b8e357e15..b374e4f738c 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/index.ts @@ -1,6 +1,5 @@ export { Dashboard } from './dashboard' export { LogDetails, LogDetailsContent } from './log-details' -export { ExecutionSnapshot } from './log-details/components/execution-snapshot' export { FileCards } from './log-details/components/file-download' export { TraceView } from './log-details/components/trace-view' export { LogRowContextMenu } from './log-row-context-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts deleted file mode 100644 index a80bf4e337d..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ExecutionSnapshot } from './execution-snapshot' diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index 33adb2639ff..e87b9e8d57e 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -1,6 +1,16 @@ 'use client' -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { + lazy, + memo, + Suspense, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' import { Badge, Button, @@ -48,11 +58,13 @@ import { filterHiddenOutputKeys } from '@/lib/logs/execution/trace-spans/trace-s import type { TraceSpan } from '@/lib/logs/types' import { sendMothershipMessage } from '@/lib/mothership/events' import { DELETED_WORKFLOW_LABEL } from '@/lib/workflows/workflow-labels' -import { - ExecutionSnapshot, - FileCards, - TraceView, -} from '@/app/workspace/[workspaceId]/logs/components' +/** + * Deep imports on purpose: importing these back through the parent `logs/components` + * barrel forms a parent->child cycle that would keep the barrel edge to the snapshot + * alive and silently defeat the ExecutionSnapshot lazy split below. + */ +import { FileCards } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/file-download' +import { TraceView } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view' import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks' import { logDetailsTabParam, @@ -73,6 +85,17 @@ import { useLogDetailsUIStore } from '@/stores/logs/store' import { MAX_LOG_DETAILS_WIDTH_RATIO, MIN_LOG_DETAILS_WIDTH } from '@/stores/logs/utils' import type { ChatContext } from '@/stores/panel' +/** + * Lazy per the code-splitting rule in `sim-imports.md`: the snapshot renders the workflow + * preview canvas, whose graph is ~7.6 MB of source. Rendering is gated on the detail's + * open state, so the chunk is fetched on first use, never during SSR or hydration. + */ +const ExecutionSnapshot = lazy(() => + import( + '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot' + ).then((m) => ({ default: m.ExecutionSnapshot })) +) + /** * Renders an already-apportioned integer credit value. `dollars` is only used * to distinguish a genuine zero ("0 credits") from a sub-credit charge that @@ -679,13 +702,15 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP {/* Frozen Canvas Modal */} {log.executionId && ( - setIsExecutionSnapshotOpen(false)} - /> + + setIsExecutionSnapshotOpen(false)} + /> + )} ) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index d94f7de1b02..6707ab6b27a 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -1,6 +1,8 @@ 'use client' import { + lazy, + Suspense, useCallback, useEffect, useEffectEvent, @@ -93,7 +95,7 @@ import { useDebounce } from '@/hooks/use-debounce' import { useUrlSort } from '@/hooks/use-url-sort' import { useFilterStore } from '@/stores/logs/filters/store' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' -import { Dashboard, ExecutionSnapshot, LogDetails, LogRowContextMenu } from './components' +import { Dashboard, LogDetails, LogRowContextMenu } from './components' import { formatDate, getDisplayStatus, @@ -106,6 +108,20 @@ import { workflowEditorPath, } from './utils' +/** + * Lazy per the code-splitting rule in `sim-imports.md`: the snapshot renders the workflow + * preview canvas, whose graph is ~7.6 MB of source (the editor's sub-block components and + * the generated tool metadata). Both render sites are gated on client-only state (a preview + * selection / an opened detail), so the chunk is fetched on first use, never during SSR or + * hydration. The now-dead barrel re-export is deleted — with no `sideEffects: false`, a + * leftover re-export would silently defeat this split. + */ +const ExecutionSnapshot = lazy(() => + import( + '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot' + ).then((m) => ({ default: m.ExecutionSnapshot })) +) + const LOGS_PER_PAGE = 50 as const const REFRESH_SPINNER_DURATION_MS = 1000 as const const LIVE_REFRESH_INTERVAL_MS = 10_000 as const @@ -1259,13 +1275,15 @@ export default function Logs() { /> {previewLogId !== null && previewDetailQuery.data?.executionId && ( - + + + )} ) diff --git a/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts b/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts index bdb862bf939..68216e2a579 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/skills/components/utils.ts @@ -1,4 +1,3 @@ -import JSZip from 'jszip' import { isApiClientError } from '@/lib/api/client/errors' export interface ParsedSkill { @@ -91,6 +90,12 @@ function inferNameFromHeading(markdown: string): string { export async function extractSkillFromZip( data: File | Blob | ArrayBuffer | Uint8Array ): Promise { + /** + * Dynamic on purpose (mirrors `lib/workflows/operations/import-export.ts`): jszip is + * ~28 KB gzip and this user-triggered upload path is the only reason it would sit in + * the initial bundle of every route that links the skills surface. + */ + const { default: JSZip } = await import('jszip') const zip = await JSZip.loadAsync(data) const candidates: string[] = [] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 14bb7d539c5..c605d805cc3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -151,8 +151,9 @@ import { useUndoRedoStore } from '@/stores/undo-redo' import { useVariablesModalStore } from '@/stores/variables/modal' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowSearchReplaceStore } from '@/stores/workflow-search-replace/store' +import { prepareBlockState } from '@/stores/workflows/prepare-block-state' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { getUniqueBlockName, prepareBlockState } from '@/stores/workflows/utils' +import { getUniqueBlockName } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { BlockState } from '@/stores/workflows/workflow/types' diff --git a/apps/sim/lib/api/contracts/index.ts b/apps/sim/lib/api/contracts/index.ts index 3694225fb23..28bf25a3190 100644 --- a/apps/sim/lib/api/contracts/index.ts +++ b/apps/sim/lib/api/contracts/index.ts @@ -1,3 +1,11 @@ +/** + * Deliberately NOT re-exported from this barrel: `./tools` (per-integration tool + * contracts), `./selectors`, `./v1` (admin API), and `./demo-requests`. All of their + * consumers import those files directly, and re-exporting them here shipped their Zod + * schema construction (~60 KB gzip) to every route that touches any contract — schema + * objects are built at module scope, so `export *` defeats tree-shaking for them. + * Import from the specific contract file instead. + */ export * from './admin' export * from './api-keys' export * from './audit-logs' @@ -7,7 +15,6 @@ export * from './cli-auth' export * from './common' export * from './copilot' export * from './credentials' -export * from './demo-requests' export * from './desktop-auth' export * from './desktop-tool-authorization' export * from './environment' @@ -23,15 +30,12 @@ export * from './primitives' export * from './sandboxes' export * from './secret-mount-policy' export * from './secrets' -export * from './selectors' export * from './skills' export * from './storage-transfer' export * from './subscription' export * from './tool-primitives' -export * from './tools' export * from './types' export * from './user' -export * from './v1' export * from './workflows' export * from './workspace-file-folders' export * from './workspace-files' diff --git a/apps/sim/lib/pptx-renderer/parser/zip-parser.ts b/apps/sim/lib/pptx-renderer/parser/zip-parser.ts index 78243649c71..50e5fca9d16 100644 --- a/apps/sim/lib/pptx-renderer/parser/zip-parser.ts +++ b/apps/sim/lib/pptx-renderer/parser/zip-parser.ts @@ -4,7 +4,6 @@ */ import type { JSZipObject } from 'jszip' -import JSZip from 'jszip' export interface PptxFiles { contentTypes: string @@ -80,6 +79,9 @@ export async function parseZip( throwZipLimitExceeded(`maxConcurrency ${limits.maxConcurrency} must be an integer >= 1`) } + /** Dynamic on purpose — keeps jszip out of the initial bundle of routes that only + * *can* open a PPTX; the archive load below is already async. */ + const { default: JSZip } = await import('jszip') const zip = await JSZip.loadAsync(buffer) const entries = Object.entries(zip.files).filter(([, file]) => !file.dir) diff --git a/apps/sim/lib/table/import-runner.ts b/apps/sim/lib/table/import-runner.ts index 2ddf3ffe220..1a98496d408 100644 --- a/apps/sim/lib/table/import-runner.ts +++ b/apps/sim/lib/table/import-runner.ts @@ -12,7 +12,6 @@ import { CSV_SCHEMA_SAMPLE_SIZE, type CsvHeaderMapping, coerceRowsForTable, - createCsvParser, inferColumnType, inferSchemaFromCsv, sanitizeName, @@ -29,6 +28,7 @@ import { deleteAllTableRows, setTableSchemaForImport, } from '@/lib/table/import-data' +import { createCsvParser } from '@/lib/table/import-stream' import { markJobFailedInWorkspace, markJobReadyInWorkspace, diff --git a/apps/sim/lib/table/import-stream.ts b/apps/sim/lib/table/import-stream.ts new file mode 100644 index 00000000000..f629440b7a6 --- /dev/null +++ b/apps/sim/lib/table/import-stream.ts @@ -0,0 +1,27 @@ +/** + * Streaming CSV parsing — server-only. + * + * Split from `lib/table/import.ts` because the streaming `parse` export of + * `csv-parse` is a Node `Transform`, so a value import pins Next's stream + * polyfills (~35 KB gzip) into any client bundle that reaches it — and + * `lib/table/index.ts` re-exports `import.ts` to plenty of client code. This + * module is deliberately NOT re-exported from the `lib/table` barrel; the two + * consumers (the import runner and the import orchestration) are server-only + * and import it directly. The client CSV dialog keeps using the dynamic + * `csv-parse/sync` path and is unaffected. + */ + +import { type Parser, parse as parseCsvStream } from 'csv-parse' +import { csvParseOptions } from '@/lib/table/import' + +/** + * Returns a streaming `csv-parse` parser (a `Transform`/async-iterable). Pipe a + * file stream into it and iterate records with `for await`; backpressure flows + * back to the source while each record is processed. Use this for HTTP uploads + * so the file is never fully buffered in memory. + * + * `onHeaders` fires once, before the first record, with the full header row. + */ +export function createCsvParser(delimiter = ',', onHeaders?: (headers: string[]) => void): Parser { + return parseCsvStream(csvParseOptions(delimiter, onHeaders)) +} diff --git a/apps/sim/lib/table/import.test.ts b/apps/sim/lib/table/import.test.ts index f58565ee118..46904728f69 100644 --- a/apps/sim/lib/table/import.test.ts +++ b/apps/sim/lib/table/import.test.ts @@ -12,7 +12,6 @@ import { CsvImportValidationError, coerceRowsForTable, coerceValue, - createCsvParser, csvParseOptions, dedupeHeaders, detectCsvDelimiter, @@ -22,6 +21,7 @@ import { sanitizeName, validateMapping, } from '@/lib/table/import' +import { createCsvParser } from '@/lib/table/import-stream' import type { TableSchema } from '@/lib/table/types' describe('import', () => { diff --git a/apps/sim/lib/table/import.ts b/apps/sim/lib/table/import.ts index 44f857baf1a..e3707b74a99 100644 --- a/apps/sim/lib/table/import.ts +++ b/apps/sim/lib/table/import.ts @@ -11,7 +11,7 @@ * parsers share {@link csvParseOptions} so their behavior can't drift. */ -import { type Options as CsvParseOptions, type Parser, parse as parseCsvStream } from 'csv-parse' +import type { Options as CsvParseOptions } from 'csv-parse' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getColumnId } from '@/lib/table/column-keys' import type { ColumnType } from '@/lib/table/column-types' @@ -76,18 +76,6 @@ export function csvParseOptions( } } -/** - * Returns a streaming `csv-parse` parser (a `Transform`/async-iterable). Pipe a - * file stream into it and iterate records with `for await`; backpressure flows - * back to the source while each record is processed. Use this for HTTP uploads - * so the file is never fully buffered in memory. - * - * `onHeaders` fires once, before the first record, with the full header row. - */ -export function createCsvParser(delimiter = ',', onHeaders?: (headers: string[]) => void): Parser { - return parseCsvStream(csvParseOptions(delimiter, onHeaders)) -} - /** * Drops later exact-duplicate header names, preserving first-occurrence order. Mirrors how * `csv-parse` collapses duplicate column names into a single record key (last value wins), so diff --git a/apps/sim/lib/table/orchestration/import.ts b/apps/sim/lib/table/orchestration/import.ts index 7a799540458..04ec6a78540 100644 --- a/apps/sim/lib/table/orchestration/import.ts +++ b/apps/sim/lib/table/orchestration/import.ts @@ -21,13 +21,13 @@ import { type CsvHeaderMapping, CsvImportValidationError, coerceRowsForTable, - createCsvParser, inferColumnType, inferSchemaFromCsv, sanitizeName, validateMapping, } from '@/lib/table/import' import { importAppendRows, importReplaceRows } from '@/lib/table/import-data' +import { createCsvParser } from '@/lib/table/import-stream' import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' import { TableLockedError } from '@/lib/table/mutation-locks' import { createExactEmptyTableRowSecretProvenance } from '@/lib/table/rows/secret-provenance' diff --git a/apps/sim/stores/workflows/prepare-block-state.ts b/apps/sim/stores/workflows/prepare-block-state.ts new file mode 100644 index 00000000000..e039021a292 --- /dev/null +++ b/apps/sim/stores/workflows/prepare-block-state.ts @@ -0,0 +1,154 @@ +/** + * Seeds a new block's state from its registry config. + * + * Split out of `stores/workflows/utils.ts` because this is the only function there + * that needs the block registry and the generated tool-outputs artifact + * (`getBlock`, `getEffectiveBlockOutputs`). `utils.ts` is reached by the persistent + * workspace shell through the workflow list hooks, so housing this here kept + * `tools/generated/tool-outputs.ts` (~476 KB gzip) on the cold path of every + * workspace route for a function only the canvas drop handler calls. + */ + +import type { SeedValueGate } from '@/lib/permission-groups/operation-access' +import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs' +import { createDefaultInputFormatField } from '@/lib/workflows/input-format' +import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility' +import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' +import { getBlock } from '@/blocks' +import type { BlockState, Position, SubBlockState } from '@/stores/workflows/workflow/types' + +export interface PrepareBlockStateOptions { + id: string + type: string + name: string + position: Position + data?: Record + parentId?: string + extent?: 'parent' + triggerMode?: boolean + /** + * Vetoes a declared default that the creator's permission group denies — + * today the `operation` and `model` fields, both of which blocks pre-fill. + * + * A vetoed field is seeded with nothing rather than a substitute. The editor's + * own permission-aware pickers already resolve the right replacement (first + * allowed operation; preferred-then-first allowed model) and they only fill a + * field that is empty, so leaving it empty hands the choice to the one place + * that knows how to make it. Substituting here instead would also drift from + * `getDefaultBlockName`, which names the block after its *declared* default. + * + * Omit it entirely only where permission gating does not apply, in which case + * declared defaults are seeded unchanged. A caller that cannot yet answer — + * config still loading — vetoes rather than omitting, since a value written + * here is never revisited. + */ + isSeededValueAllowed?: SeedValueGate +} + +/** + * Prepares a BlockState object from block type and configuration. + * Generates subBlocks and outputs from the block registry. + */ +export function prepareBlockState(options: PrepareBlockStateOptions): BlockState { + const { + id, + type, + name, + position, + data, + parentId, + extent, + triggerMode = false, + isSeededValueAllowed, + } = options + + const blockConfig = getBlock(type) + + const blockData: Record = { ...(data || {}) } + if (parentId) blockData.parentId = parentId + if (extent) blockData.extent = extent + + if (!blockConfig) { + return { + id, + type, + name, + position, + data: blockData, + subBlocks: {}, + outputs: {}, + enabled: true, + horizontalHandles: true, + advancedMode: false, + triggerMode, + height: 0, + } + } + + const subBlocks: Record = {} + + if (blockConfig.subBlocks) { + blockConfig.subBlocks.forEach((subBlock) => { + let initialValue: unknown = null + + if (typeof subBlock.value === 'function') { + try { + initialValue = subBlock.value({}) + } catch { + initialValue = null + } + } else if (subBlock.defaultValue !== undefined) { + initialValue = subBlock.defaultValue + } else if (subBlock.type === 'input-format' || subBlock.type === 'response-format') { + initialValue = [createDefaultInputFormatField()] + } else if (subBlock.type === 'table') { + initialValue = [] + } + + if ( + isSeededValueAllowed && + typeof initialValue === 'string' && + initialValue !== '' && + !isSeededValueAllowed(subBlock.id, initialValue) + ) { + initialValue = null + } + + subBlocks[subBlock.id] = { + id: subBlock.id, + type: subBlock.type, + value: initialValue as SubBlockState['value'], + } + }) + } + + const isTriggerCapable = hasTriggerCapability(blockConfig) + const effectiveTriggerMode = Boolean(triggerMode && isTriggerCapable) + const outputs = getEffectiveBlockOutputs(type, subBlocks, { + triggerMode: effectiveTriggerMode, + preferToolOutputs: !effectiveTriggerMode, + }) + + if (blockConfig.subBlocks) { + const canonicalModes = buildDefaultCanonicalModes(blockConfig.subBlocks) + if (Object.keys(canonicalModes).length > 0) { + blockData.canonicalModes = canonicalModes + } + } + + return { + id, + type, + name, + position, + data: blockData, + subBlocks, + outputs, + enabled: true, + horizontalHandles: true, + advancedMode: false, + triggerMode, + height: 0, + locked: false, + } +} diff --git a/apps/sim/stores/workflows/utils.test.ts b/apps/sim/stores/workflows/utils.test.ts index 622feb67662..c01bf0200d7 100644 --- a/apps/sim/stores/workflows/utils.test.ts +++ b/apps/sim/stores/workflows/utils.test.ts @@ -10,7 +10,8 @@ import type { Edge } from 'reactflow' import { afterEach, describe, expect, it, vi } from 'vitest' import { getBlock } from '@/blocks/registry' import { normalizeName } from '@/executor/constants' -import { filterNewEdges, getUniqueBlockName, prepareBlockState, regenerateBlockIds } from './utils' +import { prepareBlockState } from './prepare-block-state' +import { filterNewEdges, getUniqueBlockName, regenerateBlockIds } from './utils' describe('normalizeName', () => { it.concurrent('should convert to lowercase', () => { diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index 34a5cc1a54a..e7226650716 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -2,15 +2,9 @@ import { generateId } from '@sim/utils/id' import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' import { filterUniqueWorkflowEdges } from '@sim/workflow-types/workflow' import type { Edge } from 'reactflow' -import type { SeedValueGate } from '@/lib/permission-groups/operation-access' import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants' -import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids' import { isDynamicHandleSubblock } from '@/lib/workflows/dynamic-handle-topology' -import { createDefaultInputFormatField } from '@/lib/workflows/input-format' -import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility' -import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' -import { getBlock } from '@/blocks' import { escapeRegExp, normalizeName } from '@/executor/constants' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -93,142 +87,6 @@ export function getUniqueBlockName(baseName: string, existingBlocks: Record - parentId?: string - extent?: 'parent' - triggerMode?: boolean - /** - * Vetoes a declared default that the creator's permission group denies — - * today the `operation` and `model` fields, both of which blocks pre-fill. - * - * A vetoed field is seeded with nothing rather than a substitute. The editor's - * own permission-aware pickers already resolve the right replacement (first - * allowed operation; preferred-then-first allowed model) and they only fill a - * field that is empty, so leaving it empty hands the choice to the one place - * that knows how to make it. Substituting here instead would also drift from - * `getDefaultBlockName`, which names the block after its *declared* default. - * - * Omit it entirely only where permission gating does not apply, in which case - * declared defaults are seeded unchanged. A caller that cannot yet answer — - * config still loading — vetoes rather than omitting, since a value written - * here is never revisited. - */ - isSeededValueAllowed?: SeedValueGate -} - -/** - * Prepares a BlockState object from block type and configuration. - * Generates subBlocks and outputs from the block registry. - */ -export function prepareBlockState(options: PrepareBlockStateOptions): BlockState { - const { - id, - type, - name, - position, - data, - parentId, - extent, - triggerMode = false, - isSeededValueAllowed, - } = options - - const blockConfig = getBlock(type) - - const blockData: Record = { ...(data || {}) } - if (parentId) blockData.parentId = parentId - if (extent) blockData.extent = extent - - if (!blockConfig) { - return { - id, - type, - name, - position, - data: blockData, - subBlocks: {}, - outputs: {}, - enabled: true, - horizontalHandles: true, - advancedMode: false, - triggerMode, - height: 0, - } - } - - const subBlocks: Record = {} - - if (blockConfig.subBlocks) { - blockConfig.subBlocks.forEach((subBlock) => { - let initialValue: unknown = null - - if (typeof subBlock.value === 'function') { - try { - initialValue = subBlock.value({}) - } catch { - initialValue = null - } - } else if (subBlock.defaultValue !== undefined) { - initialValue = subBlock.defaultValue - } else if (subBlock.type === 'input-format' || subBlock.type === 'response-format') { - initialValue = [createDefaultInputFormatField()] - } else if (subBlock.type === 'table') { - initialValue = [] - } - - if ( - isSeededValueAllowed && - typeof initialValue === 'string' && - initialValue !== '' && - !isSeededValueAllowed(subBlock.id, initialValue) - ) { - initialValue = null - } - - subBlocks[subBlock.id] = { - id: subBlock.id, - type: subBlock.type, - value: initialValue as SubBlockState['value'], - } - }) - } - - const isTriggerCapable = hasTriggerCapability(blockConfig) - const effectiveTriggerMode = Boolean(triggerMode && isTriggerCapable) - const outputs = getEffectiveBlockOutputs(type, subBlocks, { - triggerMode: effectiveTriggerMode, - preferToolOutputs: !effectiveTriggerMode, - }) - - if (blockConfig.subBlocks) { - const canonicalModes = buildDefaultCanonicalModes(blockConfig.subBlocks) - if (Object.keys(canonicalModes).length > 0) { - blockData.canonicalModes = canonicalModes - } - } - - return { - id, - type, - name, - position, - data: blockData, - subBlocks, - outputs, - enabled: true, - horizontalHandles: true, - advancedMode: false, - triggerMode, - height: 0, - locked: false, - } -} - /** * Merges workflow block states with the sub-block store's values while maintaining * block structure. Resolves the active workflow when no workflowId is given. diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index ded2d08310d..fa13ab72b6a 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -10,29 +10,29 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 3073, + "modules": 2994, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1383, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1033, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 894, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 891, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1388, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1034, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 895, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 892, "apps/sim/triggers/registry.ts": 472, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 334, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 343, "apps/sim/blocks/registry.ts": 315, "apps/sim/lib/auth/index.ts": 230 } }, "app/workspace/[workspaceId]/files/[fileId]/page.tsx": { - "modules": 2030, + "modules": 1947, "gateways": { "apps/sim/triggers/registry.ts": 472, "apps/sim/blocks/registry.ts": 341, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 273, - "apps/sim/lib/auth/index.ts": 236, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 151, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 134, - "apps/sim/lib/api/contracts/index.ts": 109, - "apps/sim/lib/webhooks/providers/index.ts": 109 + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 274, + "apps/sim/lib/auth/index.ts": 244, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 152, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 135, + "apps/sim/lib/webhooks/providers/index.ts": 109, + "apps/sim/lib/webhooks/providers/registry.ts": 107 } }, "app/workspace/[workspaceId]/files/[fileId]/view/page.tsx": { @@ -43,16 +43,16 @@ } }, "app/workspace/[workspaceId]/files/page.tsx": { - "modules": 2030, + "modules": 1947, "gateways": { "apps/sim/triggers/registry.ts": 472, "apps/sim/blocks/registry.ts": 341, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 275, - "apps/sim/lib/auth/index.ts": 236, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 151, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 134, - "apps/sim/lib/api/contracts/index.ts": 109, - "apps/sim/lib/webhooks/providers/index.ts": 109 + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 276, + "apps/sim/lib/auth/index.ts": 244, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 152, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 135, + "apps/sim/lib/webhooks/providers/index.ts": 109, + "apps/sim/lib/webhooks/providers/registry.ts": 107 } }, "app/workspace/[workspaceId]/home/layout.tsx": { @@ -60,120 +60,120 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 3073, + "modules": 2994, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1383, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1033, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 894, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 891, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1388, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1034, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 895, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 892, "apps/sim/triggers/registry.ts": 472, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 334, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 343, "apps/sim/blocks/registry.ts": 315, "apps/sim/lib/auth/index.ts": 230 } }, "app/workspace/[workspaceId]/integrations/[block]/page.tsx": { - "modules": 1350, + "modules": 1247, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1324, - "apps/sim/triggers/index.ts": 509, - "apps/sim/blocks/registry.ts": 447, - "apps/sim/lib/api/contracts/index.ts": 129, - "apps/sim/blocks/blocks/credential-group.ts": 106, - "apps/sim/stores/workflows/registry/store.ts": 81, - "apps/sim/hooks/queries/deployments.ts": 74, - "apps/sim/lib/workflows/comparison/compare.ts": 71 + "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1221, + "apps/sim/triggers/index.ts": 510, + "apps/sim/triggers/registry.ts": 508, + "apps/sim/blocks/registry.ts": 491, + "apps/sim/blocks/blocks/credential-group.ts": 145, + "apps/sim/stores/workflows/registry/store.ts": 128, + "apps/sim/hooks/queries/deployments.ts": 121, + "apps/sim/lib/workflows/comparison/describe.ts": 111 } }, "app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx": { - "modules": 1328, + "modules": 1225, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1327, + "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1224, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 348, - "apps/sim/lib/api/contracts/index.ts": 135, - "apps/sim/stores/workflows/registry/store.ts": 77, - "apps/sim/hooks/queries/deployments.ts": 74, - "apps/sim/lib/workflows/comparison/compare.ts": 71, - "apps/sim/lib/workflows/comparison/resolve-values.ts": 68 + "apps/sim/blocks/registry.ts": 349, + "apps/sim/stores/workflows/registry/store.ts": 124, + "apps/sim/hooks/queries/deployments.ts": 121, + "apps/sim/lib/workflows/comparison/describe.ts": 111, + "apps/sim/hooks/selectors/registry.ts": 106, + "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 54 } }, "app/workspace/[workspaceId]/integrations/page.tsx": { - "modules": 1335, + "modules": 1232, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1045, - "apps/sim/blocks/registry.ts": 961, - "apps/sim/triggers/index.ts": 509, - "apps/sim/lib/api/contracts/index.ts": 131, - "apps/sim/blocks/blocks/credential-group.ts": 107, - "apps/sim/stores/workflows/registry/store.ts": 81, - "apps/sim/hooks/queries/deployments.ts": 74, - "apps/sim/lib/workflows/comparison/compare.ts": 71 + "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1090, + "apps/sim/blocks/registry.ts": 1005, + "apps/sim/triggers/index.ts": 510, + "apps/sim/triggers/registry.ts": 508, + "apps/sim/blocks/blocks/credential-group.ts": 146, + "apps/sim/stores/workflows/registry/store.ts": 128, + "apps/sim/hooks/queries/deployments.ts": 121, + "apps/sim/lib/workflows/comparison/describe.ts": 111 } }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx": { - "modules": 1530, + "modules": 1432, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1237, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1287, "apps/sim/triggers/registry.ts": 508, "apps/sim/blocks/registry.ts": 344, "apps/sim/blocks/registry-maps.ts": 341, - "apps/sim/lib/api/contracts/index.ts": 129, + "apps/sim/hooks/selectors/registry.ts": 91, "apps/sim/connectors/registry.ts": 65, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 61, - "apps/sim/lib/api/contracts/tools/index.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 60, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 51 } }, "app/workspace/[workspaceId]/knowledge/[id]/page.tsx": { - "modules": 1542, + "modules": 1435, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1248, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1289, "apps/sim/triggers/registry.ts": 508, "apps/sim/blocks/registry.ts": 349, "apps/sim/blocks/registry-maps.ts": 346, - "apps/sim/lib/api/contracts/index.ts": 129, + "apps/sim/hooks/selectors/registry.ts": 91, "apps/sim/connectors/registry.ts": 65, - "apps/sim/app/workspace/[workspaceId]/components/index.ts": 61, - "apps/sim/lib/api/contracts/tools/index.ts": 60 + "apps/sim/app/workspace/[workspaceId]/components/index.ts": 60, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 44 } }, "app/workspace/[workspaceId]/knowledge/page.tsx": { - "modules": 2253, + "modules": 2170, "gateways": { "apps/sim/triggers/registry.ts": 472, "apps/sim/blocks/registry.ts": 339, "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 290, "apps/sim/lib/knowledge/application/knowledge-bases.ts": 234, - "apps/sim/lib/auth/index.ts": 183, + "apps/sim/lib/auth/index.ts": 192, "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 154, "apps/sim/lib/knowledge/orchestration/index.ts": 147, "apps/sim/lib/knowledge/orchestration/connectors.ts": 141 } }, "app/workspace/[workspaceId]/layout.tsx": { - "modules": 2106, + "modules": 2031, "gateways": { "apps/sim/triggers/registry.ts": 472, "apps/sim/blocks/registry.ts": 340, - "apps/sim/lib/auth/index.ts": 307, - "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 305, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 297, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 205, - "apps/sim/lib/api/contracts/index.ts": 109, - "apps/sim/lib/webhooks/providers/index.ts": 109 + "apps/sim/lib/auth/index.ts": 314, + "apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts": 310, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 305, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 210, + "apps/sim/lib/webhooks/providers/index.ts": 109, + "apps/sim/lib/webhooks/providers/registry.ts": 107 } }, "app/workspace/[workspaceId]/logs/page.tsx": { - "modules": 1769, + "modules": 1681, "gateways": { - "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1478, + "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1538, "apps/sim/triggers/registry.ts": 508, - "apps/sim/app/workspace/[workspaceId]/logs/components/index.ts": 399, - "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/index.ts": 345, + "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx": 360, "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 301, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 297, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 265 + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 314, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 308, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 270, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 259 } }, "app/workspace/[workspaceId]/page.tsx": { @@ -185,16 +185,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/page.tsx": { - "modules": 2125, + "modules": 2082, "gateways": { "apps/sim/triggers/registry.ts": 472, - "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 445, + "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 453, "apps/sim/blocks/registry.ts": 339, - "apps/sim/lib/auth/index.ts": 316, + "apps/sim/lib/auth/index.ts": 304, "apps/sim/lib/webhooks/providers/index.ts": 109, - "apps/sim/lib/api/contracts/index.ts": 107, "apps/sim/lib/webhooks/providers/registry.ts": 107, - "apps/sim/lib/api/contracts/tools/index.ts": 59 + "apps/sim/hooks/selectors/registry.ts": 71, + "apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx": 45 } }, "app/workspace/[workspaceId]/settings/billing/credit-usage/layout.tsx": { @@ -202,16 +202,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx": { - "modules": 1687, + "modules": 1601, "gateways": { - "apps/sim/lib/auth/index.ts": 1550, - "apps/sim/blocks/registry.ts": 616, - "apps/sim/blocks/registry-maps.ts": 613, - "apps/sim/triggers/index.ts": 473, - "apps/sim/blocks/blocks/credential-group.ts": 271, - "apps/sim/stores/workflows/registry/store.ts": 253, - "apps/sim/hooks/queries/deployments.ts": 245, - "apps/sim/lib/workflows/comparison/compare.ts": 240 + "apps/sim/lib/auth/index.ts": 1464, + "apps/sim/blocks/registry.ts": 530, + "apps/sim/blocks/registry-maps.ts": 527, + "apps/sim/triggers/index.ts": 474, + "apps/sim/triggers/registry.ts": 472, + "apps/sim/blocks/blocks/credential-group.ts": 185, + "apps/sim/stores/workflows/registry/store.ts": 168, + "apps/sim/hooks/queries/deployments.ts": 160 } }, "app/workspace/[workspaceId]/settings/layout.tsx": { @@ -223,159 +223,157 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx": { - "modules": 1375, + "modules": 1272, "gateways": { "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 350, - "apps/sim/blocks/registry-maps.ts": 347, - "apps/sim/lib/api/contracts/index.ts": 136, - "apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx": 77, - "apps/sim/stores/workflows/registry/store.ts": 76, - "apps/sim/hooks/queries/deployments.ts": 73, - "apps/sim/lib/workflows/comparison/compare.ts": 70 + "apps/sim/blocks/registry.ts": 351, + "apps/sim/blocks/registry-maps.ts": 348, + "apps/sim/stores/workflows/registry/store.ts": 123, + "apps/sim/hooks/queries/deployments.ts": 120, + "apps/sim/lib/workflows/comparison/describe.ts": 110, + "apps/sim/hooks/selectors/registry.ts": 105, + "apps/sim/app/workspace/[workspaceId]/settings/secrets/[credentialId]/secret-detail.tsx": 77 } }, "app/workspace/[workspaceId]/skills/[skillId]/page.tsx": { - "modules": 1451, + "modules": 1352, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1450, + "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1351, "apps/sim/triggers/registry.ts": 508, "apps/sim/blocks/registry.ts": 349, "apps/sim/blocks/registry-maps.ts": 347, - "apps/sim/lib/api/contracts/index.ts": 127, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 89, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 86, - "apps/sim/lib/api/contracts/tools/index.ts": 60 + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 93, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 90, + "apps/sim/hooks/queries/deployments.ts": 90, + "apps/sim/lib/workflows/comparison/describe.ts": 83 } }, "app/workspace/[workspaceId]/skills/new/page.tsx": { - "modules": 1449, + "modules": 1350, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1448, + "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1349, "apps/sim/triggers/registry.ts": 508, "apps/sim/blocks/registry.ts": 349, "apps/sim/blocks/registry-maps.ts": 347, - "apps/sim/lib/api/contracts/index.ts": 127, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 89, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 86, - "apps/sim/lib/api/contracts/tools/index.ts": 60 + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 93, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 90, + "apps/sim/hooks/queries/deployments.ts": 90, + "apps/sim/lib/workflows/comparison/describe.ts": 83 } }, "app/workspace/[workspaceId]/skills/page.tsx": { - "modules": 1318, + "modules": 1215, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 1028, - "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 1016, - "apps/sim/blocks/registry.ts": 1004, - "apps/sim/blocks/registry-maps.ts": 1002, - "apps/sim/triggers/index.ts": 509, - "apps/sim/lib/api/contracts/index.ts": 136, - "apps/sim/blocks/blocks/credential-group.ts": 120, - "apps/sim/stores/workflows/registry/store.ts": 92 + "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 1073, + "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 1061, + "apps/sim/blocks/registry.ts": 1049, + "apps/sim/blocks/registry-maps.ts": 1047, + "apps/sim/triggers/index.ts": 510, + "apps/sim/triggers/registry.ts": 508, + "apps/sim/blocks/blocks/credential-group.ts": 160, + "apps/sim/stores/workflows/registry/store.ts": 140 } }, "app/workspace/[workspaceId]/tables/[tableId]/page.tsx": { - "modules": 2298, + "modules": 2219, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 564, + "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 571, "apps/sim/triggers/registry.ts": 472, - "apps/sim/lib/auth/index.ts": 338, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 315, + "apps/sim/lib/auth/index.ts": 341, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 320, "apps/sim/blocks/registry.ts": 315, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 273, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 269, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 238 + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 275, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 271, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 239 } }, "app/workspace/[workspaceId]/tables/page.tsx": { - "modules": 1875, + "modules": 1789, "gateways": { "apps/sim/triggers/registry.ts": 472, - "apps/sim/blocks/registry.ts": 340, - "apps/sim/lib/auth/index.ts": 327, - "apps/sim/lib/api/contracts/index.ts": 116, + "apps/sim/blocks/registry.ts": 341, + "apps/sim/lib/auth/index.ts": 336, "apps/sim/lib/webhooks/providers/index.ts": 109, "apps/sim/lib/webhooks/providers/registry.ts": 107, "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 106, - "apps/sim/lib/api/contracts/tools/index.ts": 60 + "apps/sim/stores/workflows/registry/store.ts": 98, + "apps/sim/lib/workflows/comparison/describe.ts": 88 } }, "app/workspace/[workspaceId]/upgrade/page.tsx": { - "modules": 273, + "modules": 132, "gateways": { - "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 266, - "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 218, - "apps/sim/lib/billing/client/upgrade.ts": 210, - "apps/sim/hooks/queries/organization.ts": 206, - "apps/sim/hooks/queries/workspace.ts": 197, - "apps/sim/lib/api/contracts/index.ts": 195, - "apps/sim/lib/api/contracts/tools/index.ts": 61, - "apps/sim/lib/api/contracts/v1/index.ts": 38 + "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 125, + "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 77, + "apps/sim/lib/billing/client/upgrade.ts": 69, + "apps/sim/hooks/queries/organization.ts": 65, + "apps/sim/hooks/queries/workspace.ts": 56, + "apps/sim/lib/api/contracts/index.ts": 54 } }, "app/workspace/[workspaceId]/w/[workflowId]/layout.tsx": { - "modules": 2263, + "modules": 2192, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2262, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 548, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 2191, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 552, "apps/sim/triggers/registry.ts": 508, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 465, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 468, "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 290, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 182, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 151 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 293, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 188, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 157 } }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 2293, + "modules": 2223, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2292, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2222, "apps/sim/triggers/registry.ts": 508, "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 309, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 271, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 228, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 176, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 174 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 312, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 274, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 231, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 182, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 180 } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2263, + "modules": 2192, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 955, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 978, "apps/sim/triggers/registry.ts": 508, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 465, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 468, "apps/sim/blocks/registry.ts": 335, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 290, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 163, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 151, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 293, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/error/index.tsx": 169, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 157, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 146 } }, "app/workspace/layout.tsx": { - "modules": 1263, + "modules": 1160, "gateways": { - "apps/sim/app/workspace/providers/socket-provider.tsx": 1253, + "apps/sim/app/workspace/providers/socket-provider.tsx": 1150, "apps/sim/triggers/registry.ts": 508, - "apps/sim/blocks/registry.ts": 350, - "apps/sim/blocks/registry-maps.ts": 347, - "apps/sim/stores/workflows/registry/store.ts": 275, - "apps/sim/hooks/queries/deployments.ts": 272, - "apps/sim/lib/workflows/comparison/compare.ts": 266, - "apps/sim/lib/workflows/comparison/resolve-values.ts": 263 + "apps/sim/blocks/registry.ts": 351, + "apps/sim/blocks/registry-maps.ts": 348, + "apps/sim/stores/workflows/registry/store.ts": 180, + "apps/sim/hooks/queries/deployments.ts": 177, + "apps/sim/lib/workflows/comparison/describe.ts": 166, + "apps/sim/hooks/selectors/registry.ts": 106 } }, "app/workspace/page.tsx": { - "modules": 1258, + "modules": 1157, "gateways": { - "apps/sim/lib/auth/stale-session-recovery.ts": 1021, - "apps/sim/triggers/index.ts": 509, - "apps/sim/blocks/registry.ts": 350, - "apps/sim/blocks/registry-maps.ts": 347, - "apps/sim/lib/api/contracts/index.ts": 139, - "apps/sim/stores/workflows/registry/store.ts": 82, - "apps/sim/hooks/queries/deployments.ts": 75, - "apps/sim/lib/workflows/comparison/compare.ts": 72 + "apps/sim/lib/auth/stale-session-recovery.ts": 1068, + "apps/sim/triggers/index.ts": 510, + "apps/sim/triggers/registry.ts": 508, + "apps/sim/blocks/registry.ts": 351, + "apps/sim/blocks/registry-maps.ts": 348, + "apps/sim/stores/workflows/registry/store.ts": 130, + "apps/sim/hooks/queries/deployments.ts": 123, + "apps/sim/lib/workflows/comparison/describe.ts": 113 } } } From 8edeca6f1431c3482e9cee7c6750a37d333133de Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 22 Aug 2026 16:24:08 -0700 Subject: [PATCH 2/4] improvement(logs): contain snapshot chunk-load failures and settle the local-fs tool on recovery failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round: wrap both lazy ExecutionSnapshot render sites in a small error boundary (Suspense handles the lazy import's pending state, not its rejection — a failed chunk load would have unwound to the route boundary and replaced the logs page over an optional modal; mirrors PreviewErrorBoundary), and contain rejections inside the local-filesystem executor's load-failure recovery so a failed completion report degrades to a log instead of an unhandled rejection. --- .../[workspaceId]/home/hooks/use-chat.ts | 33 +++++++++---- .../execution-snapshot/snapshot-boundary.tsx | 49 +++++++++++++++++++ .../components/log-details/log-details.tsx | 21 ++++---- .../app/workspace/[workspaceId]/logs/logs.tsx | 21 ++++---- 4 files changed, 96 insertions(+), 28 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.tsx 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 a2e2fe7ffc6..b662036f2ce 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -2026,16 +2026,29 @@ export function useChat( (m) => m.executeLocalFilesystemTool(toolCallId, toolName, toolArgs, options), async (error) => { logger.error('Failed to load local filesystem tool executor', { error }) - const [{ reportClientToolCompletion }, { ASYNC_TOOL_CONFIRMATION_STATUS }] = - await Promise.all([ - import('@/lib/copilot/tools/client/completion'), - import('@/lib/copilot/async-runs/lifecycle'), - ]) - await reportClientToolCompletion( - toolCallId, - ASYNC_TOOL_CONFIRMATION_STATUS.error, - 'Local filesystem tool failed to load' - ) + /** + * The recovery itself can reject (the helper chunks or the completion POST can + * fail for the same reason the executor chunk did). Contain it: an unhandled + * rejection here would settle nothing and surface as a console error, exactly + * like the executor's own report-failure path, which also degrades to a log. + */ + try { + const [{ reportClientToolCompletion }, { ASYNC_TOOL_CONFIRMATION_STATUS }] = + await Promise.all([ + import('@/lib/copilot/tools/client/completion'), + import('@/lib/copilot/async-runs/lifecycle'), + ]) + await reportClientToolCompletion( + toolCallId, + ASYNC_TOOL_CONFIRMATION_STATUS.error, + 'Local filesystem tool failed to load' + ) + } catch (reportError) { + logger.error('Failed to report local filesystem tool load failure', { + toolCallId, + error: reportError, + }) + } } ) }, diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.tsx new file mode 100644 index 00000000000..a7c0d25e0bd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.tsx @@ -0,0 +1,49 @@ +'use client' + +import { Component, type ErrorInfo, type ReactNode } from 'react' +import { toast } from '@sim/emcn' +import { createLogger } from '@sim/logger' + +const logger = createLogger('ExecutionSnapshotBoundary') + +interface SnapshotBoundaryProps { + children: ReactNode +} + +interface SnapshotBoundaryState { + hasError: boolean +} + +/** + * Error boundary for the lazily loaded execution snapshot. + * + * `Suspense` handles the pending state of the lazy import but not its + * rejection — a failed chunk load (deploy skew, offline) would otherwise + * unwind to the route-level boundary and replace the whole logs page with an + * error view over an optional modal. Mirrors `PreviewErrorBoundary` in the + * file viewer: contain, log, degrade. The snapshot is an overlay, so the + * degraded state renders nothing and a toast explains why it didn't open. + * + * Callers must `key` this boundary by the snapshot's identity (execution id) + * — the error state resets only via remount, so a tripped boundary would + * otherwise stay stuck for every later log. + */ +export class SnapshotBoundary extends Component { + public state: SnapshotBoundaryState = { hasError: false } + + public static getDerivedStateFromError(): SnapshotBoundaryState { + return { hasError: true } + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + logger.error('Execution snapshot failed to load', { + error: error.message, + componentStack: errorInfo.componentStack, + }) + toast.error('Could not load the workflow snapshot. Refresh and try again.') + } + + public render() { + return this.state.hasError ? null : this.props.children + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx index e87b9e8d57e..1d118985ac4 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx @@ -63,6 +63,7 @@ import { DELETED_WORKFLOW_LABEL } from '@/lib/workflows/workflow-labels' * barrel forms a parent->child cycle that would keep the barrel edge to the snapshot * alive and silently defeat the ExecutionSnapshot lazy split below. */ +import { SnapshotBoundary } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary' import { FileCards } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/file-download' import { TraceView } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view' import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks' @@ -702,15 +703,17 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP {/* Frozen Canvas Modal */} {log.executionId && ( - - setIsExecutionSnapshotOpen(false)} - /> - + + + setIsExecutionSnapshotOpen(false)} + /> + + )} ) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 6707ab6b27a..c89d0bfeb04 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -66,6 +66,7 @@ import { type ResourceTableHandle, } from '@/app/workspace/[workspaceId]/components' import { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' +import { SnapshotBoundary } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary' import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters' import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state' import { @@ -1275,15 +1276,17 @@ export default function Logs() { /> {previewLogId !== null && previewDetailQuery.data?.executionId && ( - - - + + + + + )} ) From 357d6a2501fe9a019c361036fd5803b8ba551d6a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 22 Aug 2026 21:05:49 -0700 Subject: [PATCH 3/4] fix(logs): recover cleanly from snapshot chunk failures --- .../snapshot-boundary.test.tsx | 75 +++++++++++++++++++ .../execution-snapshot/snapshot-boundary.tsx | 33 +++++--- .../components/log-details/log-details.tsx | 6 +- .../app/workspace/[workspaceId]/logs/logs.tsx | 6 +- 4 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.test.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.test.tsx new file mode 100644 index 00000000000..433fffa3de0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.test.tsx @@ -0,0 +1,75 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockToastError } = vi.hoisted(() => ({ + mockToastError: vi.fn(), +})) + +vi.mock('@sim/emcn', () => ({ + toast: { error: mockToastError }, +})) + +import { SnapshotBoundary } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary' + +const LOAD_ERROR = new Error('snapshot chunk failed') + +function ThrowingSnapshot() { + throw LOAD_ERROR +} + +describe('SnapshotBoundary', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.clearAllMocks() + container = document.createElement('div') + document.body.appendChild(container) + act(() => { + root = createRoot(container) + }) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + it('contains a background pre-warm failure without notifying or closing', () => { + const onLoadError = vi.fn() + + act(() => { + root.render( + + + + ) + }) + + expect(container.childNodes).toHaveLength(0) + expect(mockToastError).not.toHaveBeenCalled() + expect(onLoadError).not.toHaveBeenCalled() + }) + + it('notifies and closes an explicitly opened snapshot after a load failure', () => { + const onLoadError = vi.fn() + + act(() => { + root.render( + + + + ) + }) + + expect(container.childNodes).toHaveLength(0) + expect(mockToastError).toHaveBeenCalledWith( + 'Could not load the workflow snapshot. Refresh and try again.' + ) + expect(onLoadError).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.tsx index a7c0d25e0bd..d13c3c9b2ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.tsx @@ -8,12 +8,16 @@ const logger = createLogger('ExecutionSnapshotBoundary') interface SnapshotBoundaryProps { children: ReactNode + isOpen: boolean + onLoadError: () => void } interface SnapshotBoundaryState { hasError: boolean } +const reportedErrors = new WeakSet() + /** * Error boundary for the lazily loaded execution snapshot. * @@ -22,11 +26,15 @@ interface SnapshotBoundaryState { * unwind to the route-level boundary and replace the whole logs page with an * error view over an optional modal. Mirrors `PreviewErrorBoundary` in the * file viewer: contain, log, degrade. The snapshot is an overlay, so the - * degraded state renders nothing and a toast explains why it didn't open. + * degraded state renders nothing. Closed snapshots are mounted to pre-warm + * their chunk and data, so a background failure is logged without interrupting + * the user. If the user actually opens a failed snapshot, the caller closes + * the modal state and a toast explains why it did not open. * - * Callers must `key` this boundary by the snapshot's identity (execution id) - * — the error state resets only via remount, so a tripped boundary would - * otherwise stay stuck for every later log. + * Callers must remount this boundary when the snapshot identity changes and + * when a pre-warmed snapshot is explicitly opened. Error boundaries reset only + * via remount; without both transitions, a failed pre-warm would leave the + * later open action stuck in the already-tripped state. */ export class SnapshotBoundary extends Component { public state: SnapshotBoundaryState = { hasError: false } @@ -36,11 +44,18 @@ export class SnapshotBoundary extends Component + setIsExecutionSnapshotOpen(false)} + > {previewLogId !== null && previewDetailQuery.data?.executionId && ( - + Date: Sat, 22 Aug 2026 21:18:44 -0700 Subject: [PATCH 4/4] fix(logs): preserve snapshot modal while loading --- .../snapshot-boundary.test.tsx | 45 ++++++++++++++++++- .../execution-snapshot/snapshot-boundary.tsx | 39 +++++++++++++++- .../components/log-details/log-details.tsx | 14 +++++- .../app/workspace/[workspaceId]/logs/logs.tsx | 7 ++- 4 files changed, 98 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.test.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.test.tsx index 433fffa3de0..221aa5892e4 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary.test.tsx @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { act } from 'react' +import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -10,10 +10,35 @@ const { mockToastError } = vi.hoisted(() => ({ })) vi.mock('@sim/emcn', () => ({ + Loader: () =>