Skip to content

Commit 28801d9

Browse files
committed
make persistence execution scoped not debounce
1 parent cda1222 commit 28801d9

File tree

9 files changed

+143
-68
lines changed

9 files changed

+143
-68
lines changed

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { useExecutionStream } from '@/hooks/use-execution-stream'
3333
import { useExecutionStore } from '@/stores/execution/store'
3434
import { useFolderStore } from '@/stores/folders/store'
3535
import type { ChatContext } from '@/stores/panel'
36-
import { useTerminalConsoleStore } from '@/stores/terminal'
36+
import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal'
3737
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
3838
import type {
3939
ChatMessage,
@@ -1512,6 +1512,7 @@ export function useChat(
15121512
})
15131513

15141514
executionStream.cancel(workflowId)
1515+
consolePersistence.executionEnded()
15151516
execState.setIsExecuting(workflowId, false)
15161517
execState.setIsDebugging(workflowId, false)
15171518
execState.setActiveBlocks(workflowId, new Set())

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import { useCurrentWorkflowExecution, useExecutionStore } from '@/stores/executi
3636
import { useNotificationStore } from '@/stores/notifications'
3737
import { useVariablesStore } from '@/stores/panel'
3838
import { useEnvironmentStore } from '@/stores/settings/environment'
39-
import { useTerminalConsoleStore } from '@/stores/terminal'
39+
import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal'
4040
import { useWorkflowDiffStore } from '@/stores/workflow-diff'
4141
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
4242
import { mergeSubblockState } from '@/stores/workflows/utils'
@@ -123,7 +123,19 @@ export function useWorkflowExecution() {
123123
useCurrentWorkflowExecution()
124124
const setCurrentExecutionId = useExecutionStore((s) => s.setCurrentExecutionId)
125125
const getCurrentExecutionId = useExecutionStore((s) => s.getCurrentExecutionId)
126-
const setIsExecuting = useExecutionStore((s) => s.setIsExecuting)
126+
const rawSetIsExecuting = useExecutionStore((s) => s.setIsExecuting)
127+
128+
const setIsExecuting = useCallback(
129+
(workflowId: string, executing: boolean) => {
130+
if (executing) {
131+
consolePersistence.executionStarted()
132+
} else {
133+
consolePersistence.executionEnded()
134+
}
135+
rawSetIsExecuting(workflowId, executing)
136+
},
137+
[rawSetIsExecuting]
138+
)
127139
const setIsDebugging = useExecutionStore((s) => s.setIsDebugging)
128140
const setPendingBlocks = useExecutionStore((s) => s.setPendingBlocks)
129141
const setExecutor = useExecutionStore((s) => s.setExecutor)
@@ -1804,6 +1816,7 @@ export function useWorkflowExecution() {
18041816
)
18051817
if (otherExecutionIds.size > 0) {
18061818
cancelRunningEntries(activeWorkflowId)
1819+
consolePersistence.persist()
18071820
}
18081821

18091822
setCurrentExecutionId(activeWorkflowId, executionId)
@@ -1965,6 +1978,7 @@ export function useWorkflowExecution() {
19651978
for (const entry of originalEntries) {
19661979
addConsole(entry)
19671980
}
1981+
consolePersistence.persist()
19681982
}
19691983
}
19701984
// eslint-disable-next-line react-hooks/exhaustive-deps

apps/sim/lib/copilot/client-sse/run-tool-execution.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants'
44
import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-display-registry'
55
import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils'
66
import { useExecutionStore } from '@/stores/execution/store'
7+
import { consolePersistence } from '@/stores/terminal'
78
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
89

910
const logger = createLogger('CopilotRunToolExecution')
@@ -148,6 +149,7 @@ async function doExecuteRunTool(
148149
const abortController = new AbortController()
149150
activeRunAbortByWorkflowId.set(targetWorkflowId, abortController)
150151

152+
consolePersistence.executionStarted()
151153
setIsExecuting(targetWorkflowId, true)
152154
const executionId = uuidv4()
153155
setCurrentExecutionId(targetWorkflowId, executionId)
@@ -241,6 +243,7 @@ async function doExecuteRunTool(
241243
}
242244
const { setCurrentExecutionId: clearExecId } = useExecutionStore.getState()
243245
clearExecId(targetWorkflowId, null)
246+
consolePersistence.executionEnded()
244247
setIsExecuting(targetWorkflowId, false)
245248
}
246249
}

apps/sim/stores/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createLogger } from '@sim/logger'
55
import { useExecutionStore } from '@/stores/execution'
66
import { useVariablesStore } from '@/stores/panel'
77
import { useEnvironmentStore } from '@/stores/settings/environment'
8-
import { useTerminalConsoleStore } from '@/stores/terminal'
8+
import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal'
99
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
1010
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
1111
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -217,7 +217,13 @@ export const resetAllStores = () => {
217217
useSubBlockStore.getState().clear()
218218
useEnvironmentStore.getState().reset()
219219
useExecutionStore.getState().reset()
220-
useTerminalConsoleStore.setState({ entries: [], isOpen: false })
220+
useTerminalConsoleStore.setState({
221+
workflowEntries: {},
222+
entryIdsByBlockExecution: {},
223+
entryLocationById: {},
224+
isOpen: false,
225+
})
226+
consolePersistence.persist()
221227
// Custom tools are managed by React Query cache, not a Zustand store
222228
// Variables store has no tracking to reset; registry hydrates
223229
}

apps/sim/stores/terminal/console/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { consolePersistence } from './storage'
12
export { useConsoleEntry, useTerminalConsoleStore, useWorkflowConsoleEntries } from './store'
23
export type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './types'
34
export {

apps/sim/stores/terminal/console/storage.ts

Lines changed: 84 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { createLogger } from '@sim/logger'
2-
import { del, get, set } from 'idb-keyval'
2+
import { get, set } from 'idb-keyval'
33
import type { ConsoleEntry } from './types'
44

55
const logger = createLogger('ConsoleStorage')
66

77
const STORE_KEY = 'terminal-console-store'
88
const MIGRATION_KEY = 'terminal-console-store-migrated'
9-
const WRITE_DEBOUNCE_MS = 750
9+
10+
/**
11+
* Safety-net interval for persisting during very long executions.
12+
* Only fires while an execution is active. Much longer than a debounce
13+
* because intermediate writes during execution are low-value.
14+
*/
15+
const LONG_EXECUTION_PERSIST_INTERVAL_MS = 30_000
1016

1117
/**
1218
* Shape of terminal console data persisted to IndexedDB.
@@ -88,63 +94,95 @@ export async function loadConsoleData(): Promise<PersistedConsoleData | null> {
8894
}
8995
}
9096

91-
let pendingData: PersistedConsoleData | null = null
92-
let writeTimer: ReturnType<typeof setTimeout> | null = null
97+
let writeSequence = 0
98+
let activeWrite: Promise<void> | null = null
9399

94-
function executeWrite(): void {
95-
writeTimer = null
96-
const data = pendingData
97-
pendingData = null
98-
if (!data) return
100+
function writeToIndexedDB(data: PersistedConsoleData): void {
101+
const seq = ++writeSequence
99102

100-
try {
101-
const serialized = JSON.stringify(data)
102-
set(STORE_KEY, serialized).catch((error) => {
103+
const doWrite = async () => {
104+
try {
105+
const serialized = JSON.stringify(data)
106+
if (seq !== writeSequence) return
107+
await set(STORE_KEY, serialized)
108+
} catch (error) {
103109
logger.warn('IndexedDB write failed', { error })
104-
})
105-
} catch (error) {
106-
logger.warn('Failed to serialize console data for persistence', { error })
110+
}
107111
}
108-
}
109112

110-
/**
111-
* Schedules a debounced write of console data to IndexedDB.
112-
* Only stores a reference until the timer fires, so no serialization
113-
* happens on the calling thread.
114-
*/
115-
export function scheduleConsolePersist(data: PersistedConsoleData): void {
116-
if (typeof window === 'undefined') return
117-
pendingData = data
118-
if (writeTimer !== null) return
119-
writeTimer = setTimeout(executeWrite, WRITE_DEBOUNCE_MS)
113+
activeWrite = (activeWrite ?? Promise.resolve()).then(doWrite)
120114
}
121115

122116
/**
123-
* Immediately flushes any pending console data to IndexedDB.
124-
* Used on page hide to avoid data loss.
117+
* Execution-aware persistence manager for the terminal console store.
118+
*
119+
* Writes happen only at meaningful lifecycle boundaries:
120+
* - When an execution ends (success, error, cancel)
121+
* - On explicit user actions (clear console)
122+
* - On page hide (crash safety)
123+
* - Every 30s during very long active executions (safety net)
124+
*
125+
* During normal execution, no serialization or IndexedDB writes occur,
126+
* keeping the hot path completely free of persistence overhead.
125127
*/
126-
export function flushConsolePersist(): void {
127-
if (writeTimer !== null) {
128-
clearTimeout(writeTimer)
128+
class ConsolePersistenceManager {
129+
private dataProvider: (() => PersistedConsoleData) | null = null
130+
private safetyTimer: ReturnType<typeof setTimeout> | null = null
131+
private activeExecutions = 0
132+
133+
/**
134+
* Binds the data provider function used to snapshot current state.
135+
* Called once during store initialization.
136+
*/
137+
bind(provider: () => PersistedConsoleData): void {
138+
this.dataProvider = provider
129139
}
130-
executeWrite()
131-
}
132140

133-
/**
134-
* Removes all persisted console data from IndexedDB.
135-
*/
136-
export async function clearPersistedConsoleData(): Promise<void> {
137-
if (typeof window === 'undefined') return
141+
/**
142+
* Signals that a workflow execution has started.
143+
* Starts the long-execution safety-net timer if this is the first active execution.
144+
*/
145+
executionStarted(): void {
146+
this.activeExecutions++
147+
if (this.activeExecutions === 1) {
148+
this.startSafetyTimer()
149+
}
150+
}
138151

139-
if (writeTimer !== null) {
140-
clearTimeout(writeTimer)
141-
writeTimer = null
152+
/**
153+
* Signals that a workflow execution has ended (success, error, or cancel).
154+
* Triggers an immediate persist and stops the safety timer if no executions remain.
155+
*/
156+
executionEnded(): void {
157+
this.activeExecutions = Math.max(0, this.activeExecutions - 1)
158+
this.persist()
159+
if (this.activeExecutions === 0) {
160+
this.stopSafetyTimer()
161+
}
142162
}
143-
pendingData = null
144163

145-
try {
146-
await del(STORE_KEY)
147-
} catch (error) {
148-
logger.warn('IndexedDB delete failed', { error })
164+
/**
165+
* Triggers an immediate persist. Used for explicit user actions
166+
* like clearing the console, and for page-hide durability.
167+
*/
168+
persist(): void {
169+
if (!this.dataProvider) return
170+
writeToIndexedDB(this.dataProvider())
171+
}
172+
173+
private startSafetyTimer(): void {
174+
this.stopSafetyTimer()
175+
this.safetyTimer = setInterval(() => {
176+
this.persist()
177+
}, LONG_EXECUTION_PERSIST_INTERVAL_MS)
178+
}
179+
180+
private stopSafetyTimer(): void {
181+
if (this.safetyTimer !== null) {
182+
clearInterval(this.safetyTimer)
183+
this.safetyTimer = null
184+
}
149185
}
150186
}
187+
188+
export const consolePersistence = new ConsolePersistenceManager()

apps/sim/stores/terminal/console/store.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,7 @@ import type { NormalizedBlockOutput } from '@/executor/types'
88
import { type GeneralSettings, generalSettingsKeys } from '@/hooks/queries/general-settings'
99
import { useExecutionStore } from '@/stores/execution'
1010
import { useNotificationStore } from '@/stores/notifications'
11-
import {
12-
flushConsolePersist,
13-
loadConsoleData,
14-
scheduleConsolePersist,
15-
} from '@/stores/terminal/console/storage'
11+
import { consolePersistence, loadConsoleData } from '@/stores/terminal/console/storage'
1612
import type {
1713
ConsoleEntry,
1814
ConsoleEntryLocation,
@@ -294,8 +290,6 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
294290
isOpen: false,
295291
_hasHydrated: false,
296292

297-
setHasHydrated: (hasHydrated) => set({ _hasHydrated: hasHydrated }),
298-
299293
addConsole: (entry: Omit<ConsoleEntry, 'id' | 'timestamp'>) => {
300294
if (shouldSkipEntry(entry.output)) {
301295
return get().getWorkflowEntries(entry.workflowId)[0] as ConsoleEntry
@@ -347,6 +341,7 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
347341
clearWorkflowConsole: (workflowId: string) => {
348342
set((state) => replaceWorkflowEntries(state, workflowId, EMPTY_CONSOLE_ENTRIES))
349343
useExecutionStore.getState().clearRunPath(workflowId)
344+
consolePersistence.persist()
350345
},
351346

352347
clearExecutionEntries: (executionId: string) =>
@@ -685,9 +680,26 @@ async function hydrateConsoleStore(): Promise<void> {
685680
])
686681
)
687682

683+
const currentState = useTerminalConsoleStore.getState()
684+
const mergedWorkflowEntries = { ...workflowEntries }
685+
686+
for (const [wfId, currentEntries] of Object.entries(currentState.workflowEntries)) {
687+
if (currentEntries.length > 0) {
688+
const persistedEntries = mergedWorkflowEntries[wfId] ?? []
689+
const persistedIds = new Set(persistedEntries.map((e) => e.id))
690+
const newEntries = currentEntries.filter((e) => !persistedIds.has(e.id))
691+
if (newEntries.length > 0) {
692+
mergedWorkflowEntries[wfId] = trimWorkflowConsoleEntries([
693+
...newEntries,
694+
...persistedEntries,
695+
])
696+
}
697+
}
698+
}
699+
688700
useTerminalConsoleStore.setState({
689-
workflowEntries,
690-
...rebuildWorkflowStateMaps(workflowEntries),
701+
workflowEntries: mergedWorkflowEntries,
702+
...rebuildWorkflowStateMaps(mergedWorkflowEntries),
691703
isOpen: data.isOpen,
692704
_hasHydrated: true,
693705
})
@@ -698,17 +710,17 @@ async function hydrateConsoleStore(): Promise<void> {
698710
}
699711

700712
if (typeof window !== 'undefined') {
701-
hydrateConsoleStore()
702-
703-
useTerminalConsoleStore.subscribe((state) => {
704-
if (!state._hasHydrated) return
705-
scheduleConsolePersist({
713+
consolePersistence.bind(() => {
714+
const state = useTerminalConsoleStore.getState()
715+
return {
706716
workflowEntries: state.workflowEntries,
707717
isOpen: state.isOpen,
708-
})
718+
}
709719
})
710720

711-
window.addEventListener('pagehide', flushConsolePersist)
721+
hydrateConsoleStore()
722+
723+
window.addEventListener('pagehide', () => consolePersistence.persist())
712724
}
713725

714726
export function useWorkflowConsoleEntries(workflowId?: string): ConsoleEntry[] {

apps/sim/stores/terminal/console/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,5 +77,4 @@ export interface ConsoleStore {
7777
updateConsole: (blockId: string, update: string | ConsoleUpdate, executionId?: string) => void
7878
cancelRunningEntries: (workflowId: string) => void
7979
_hasHydrated: boolean
80-
setHasHydrated: (hasHydrated: boolean) => void
8180
}

apps/sim/stores/terminal/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './console'
22
export {
3+
consolePersistence,
34
normalizeConsoleError,
45
normalizeConsoleInput,
56
normalizeConsoleOutput,

0 commit comments

Comments
 (0)