Skip to content

Commit 23331cc

Browse files
committed
improvement(logs): contain snapshot chunk-load failures and settle the local-fs tool on recovery failure
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.
1 parent 3a6a346 commit 23331cc

4 files changed

Lines changed: 96 additions & 28 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2026,16 +2026,29 @@ export function useChat(
20262026
(m) => m.executeLocalFilesystemTool(toolCallId, toolName, toolArgs, options),
20272027
async (error) => {
20282028
logger.error('Failed to load local filesystem tool executor', { error })
2029-
const [{ reportClientToolCompletion }, { ASYNC_TOOL_CONFIRMATION_STATUS }] =
2030-
await Promise.all([
2031-
import('@/lib/copilot/tools/client/completion'),
2032-
import('@/lib/copilot/async-runs/lifecycle'),
2033-
])
2034-
await reportClientToolCompletion(
2035-
toolCallId,
2036-
ASYNC_TOOL_CONFIRMATION_STATUS.error,
2037-
'Local filesystem tool failed to load'
2038-
)
2029+
/**
2030+
* The recovery itself can reject (the helper chunks or the completion POST can
2031+
* fail for the same reason the executor chunk did). Contain it: an unhandled
2032+
* rejection here would settle nothing and surface as a console error, exactly
2033+
* like the executor's own report-failure path, which also degrades to a log.
2034+
*/
2035+
try {
2036+
const [{ reportClientToolCompletion }, { ASYNC_TOOL_CONFIRMATION_STATUS }] =
2037+
await Promise.all([
2038+
import('@/lib/copilot/tools/client/completion'),
2039+
import('@/lib/copilot/async-runs/lifecycle'),
2040+
])
2041+
await reportClientToolCompletion(
2042+
toolCallId,
2043+
ASYNC_TOOL_CONFIRMATION_STATUS.error,
2044+
'Local filesystem tool failed to load'
2045+
)
2046+
} catch (reportError) {
2047+
logger.error('Failed to report local filesystem tool load failure', {
2048+
toolCallId,
2049+
error: reportError,
2050+
})
2051+
}
20392052
}
20402053
)
20412054
},
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
'use client'
2+
3+
import { Component, type ErrorInfo, type ReactNode } from 'react'
4+
import { toast } from '@sim/emcn'
5+
import { createLogger } from '@sim/logger'
6+
7+
const logger = createLogger('ExecutionSnapshotBoundary')
8+
9+
interface SnapshotBoundaryProps {
10+
children: ReactNode
11+
}
12+
13+
interface SnapshotBoundaryState {
14+
hasError: boolean
15+
}
16+
17+
/**
18+
* Error boundary for the lazily loaded execution snapshot.
19+
*
20+
* `Suspense` handles the pending state of the lazy import but not its
21+
* rejection — a failed chunk load (deploy skew, offline) would otherwise
22+
* unwind to the route-level boundary and replace the whole logs page with an
23+
* error view over an optional modal. Mirrors `PreviewErrorBoundary` in the
24+
* file viewer: contain, log, degrade. The snapshot is an overlay, so the
25+
* degraded state renders nothing and a toast explains why it didn't open.
26+
*
27+
* Callers must `key` this boundary by the snapshot's identity (execution id)
28+
* — the error state resets only via remount, so a tripped boundary would
29+
* otherwise stay stuck for every later log.
30+
*/
31+
export class SnapshotBoundary extends Component<SnapshotBoundaryProps, SnapshotBoundaryState> {
32+
public state: SnapshotBoundaryState = { hasError: false }
33+
34+
public static getDerivedStateFromError(): SnapshotBoundaryState {
35+
return { hasError: true }
36+
}
37+
38+
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
39+
logger.error('Execution snapshot failed to load', {
40+
error: error.message,
41+
componentStack: errorInfo.componentStack,
42+
})
43+
toast.error('Could not load the workflow snapshot. Refresh and try again.')
44+
}
45+
46+
public render() {
47+
return this.state.hasError ? null : this.props.children
48+
}
49+
}

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import { DELETED_WORKFLOW_LABEL } from '@/lib/workflows/workflow-labels'
6363
* barrel forms a parent->child cycle that would keep the barrel edge to the snapshot
6464
* alive and silently defeat the ExecutionSnapshot lazy split below.
6565
*/
66+
import { SnapshotBoundary } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary'
6667
import { FileCards } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/file-download'
6768
import { TraceView } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/trace-view'
6869
import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks'
@@ -702,15 +703,17 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
702703

703704
{/* Frozen Canvas Modal */}
704705
{log.executionId && (
705-
<Suspense fallback={null}>
706-
<ExecutionSnapshot
707-
executionId={log.executionId}
708-
traceSpans={traceSpans}
709-
isModal
710-
isOpen={isExecutionSnapshotOpen}
711-
onClose={() => setIsExecutionSnapshotOpen(false)}
712-
/>
713-
</Suspense>
706+
<SnapshotBoundary key={log.executionId}>
707+
<Suspense fallback={null}>
708+
<ExecutionSnapshot
709+
executionId={log.executionId}
710+
traceSpans={traceSpans}
711+
isModal
712+
isOpen={isExecutionSnapshotOpen}
713+
onClose={() => setIsExecutionSnapshotOpen(false)}
714+
/>
715+
</Suspense>
716+
</SnapshotBoundary>
714717
)}
715718
</>
716719
)

apps/sim/app/workspace/[workspaceId]/logs/logs.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import {
6666
type ResourceTableHandle,
6767
} from '@/app/workspace/[workspaceId]/components'
6868
import { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
69+
import { SnapshotBoundary } from '@/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/snapshot-boundary'
6970
import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters'
7071
import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state'
7172
import {
@@ -1275,15 +1276,17 @@ export default function Logs() {
12751276
/>
12761277

12771278
{previewLogId !== null && previewDetailQuery.data?.executionId && (
1278-
<Suspense fallback={null}>
1279-
<ExecutionSnapshot
1280-
executionId={previewDetailQuery.data.executionId}
1281-
traceSpans={previewDetailQuery.data.executionData?.traceSpans}
1282-
isModal
1283-
isOpen={previewLogId !== null}
1284-
onClose={handleClosePreview}
1285-
/>
1286-
</Suspense>
1279+
<SnapshotBoundary key={previewDetailQuery.data.executionId}>
1280+
<Suspense fallback={null}>
1281+
<ExecutionSnapshot
1282+
executionId={previewDetailQuery.data.executionId}
1283+
traceSpans={previewDetailQuery.data.executionData?.traceSpans}
1284+
isModal
1285+
isOpen={previewLogId !== null}
1286+
onClose={handleClosePreview}
1287+
/>
1288+
</Suspense>
1289+
</SnapshotBoundary>
12871290
)}
12881291
</>
12891292
)

0 commit comments

Comments
 (0)