Skip to content

Commit 4f04b1e

Browse files
authored
feat(terminal): migrate from zustand for console terminal logs to indexedDb, incr limit from 5mb to ~GBs (#2812)
* feat(terminal): migrate from zustand for console terminal logs to indexedDb, incr limit from 5mb to ~GBs * ack PR comments
1 parent 258e96d commit 4f04b1e

File tree

8 files changed

+141
-27
lines changed

8 files changed

+141
-27
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,9 @@ export function Chat() {
221221
exportChatCSV,
222222
} = useChatStore()
223223

224-
const { entries } = useTerminalConsoleStore()
224+
const hasConsoleHydrated = useTerminalConsoleStore((state) => state._hasHydrated)
225+
const entriesFromStore = useTerminalConsoleStore((state) => state.entries)
226+
const entries = hasConsoleHydrated ? entriesFromStore : []
225227
const { isExecuting } = useExecutionStore()
226228
const { handleRunWorkflow, handleCancelExecution } = useWorkflowExecution()
227229
const { data: session } = useSession()

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/terminal.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,12 +320,14 @@ export function Terminal() {
320320
} = useTerminalStore()
321321
const isExpanded = useTerminalStore((state) => state.terminalHeight > NEAR_MIN_THRESHOLD)
322322
const { activeWorkflowId } = useWorkflowRegistry()
323+
const hasConsoleHydrated = useTerminalConsoleStore((state) => state._hasHydrated)
323324
const workflowEntriesSelector = useCallback(
324325
(state: { entries: ConsoleEntry[] }) =>
325326
state.entries.filter((entry) => entry.workflowId === activeWorkflowId),
326327
[activeWorkflowId]
327328
)
328-
const entries = useTerminalConsoleStore(useShallow(workflowEntriesSelector))
329+
const entriesFromStore = useTerminalConsoleStore(useShallow(workflowEntriesSelector))
330+
const entries = hasConsoleHydrated ? entriesFromStore : []
329331
const clearWorkflowConsole = useTerminalConsoleStore((state) => state.clearWorkflowConsole)
330332
const exportConsoleCSV = useTerminalConsoleStore((state) => state.exportConsoleCSV)
331333
const [selectedEntry, setSelectedEntry] = useState<ConsoleEntry | null>(null)

apps/sim/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
"groq-sdk": "^0.15.0",
105105
"html-to-image": "1.11.13",
106106
"html-to-text": "^9.0.5",
107+
"idb-keyval": "6.2.2",
107108
"imapflow": "1.2.4",
108109
"input-otp": "^1.4.2",
109110
"ioredis": "^5.6.0",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
export { indexedDBStorage } from './storage'
12
export { useTerminalConsoleStore } from './store'
23
export type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from './types'
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { createLogger } from '@sim/logger'
2+
import { del, get, set } from 'idb-keyval'
3+
import type { StateStorage } from 'zustand/middleware'
4+
5+
const logger = createLogger('ConsoleStorage')
6+
7+
const STORE_KEY = 'terminal-console-store'
8+
const MIGRATION_KEY = 'terminal-console-store-migrated'
9+
10+
/**
11+
* Promise that resolves when migration is complete.
12+
* Used to ensure getItem waits for migration before reading.
13+
*/
14+
let migrationPromise: Promise<void> | null = null
15+
16+
/**
17+
* Migrates existing console data from localStorage to IndexedDB.
18+
* Runs once on first load, then marks migration as complete.
19+
*/
20+
async function migrateFromLocalStorage(): Promise<void> {
21+
if (typeof window === 'undefined') return
22+
23+
try {
24+
const migrated = await get<boolean>(MIGRATION_KEY)
25+
if (migrated) return
26+
27+
const localData = localStorage.getItem(STORE_KEY)
28+
if (localData) {
29+
await set(STORE_KEY, localData)
30+
localStorage.removeItem(STORE_KEY)
31+
logger.info('Migrated console store to IndexedDB')
32+
}
33+
34+
await set(MIGRATION_KEY, true)
35+
} catch (error) {
36+
logger.warn('Migration from localStorage failed', { error })
37+
}
38+
}
39+
40+
if (typeof window !== 'undefined') {
41+
migrationPromise = migrateFromLocalStorage().finally(() => {
42+
migrationPromise = null
43+
})
44+
}
45+
46+
export const indexedDBStorage: StateStorage = {
47+
getItem: async (name: string): Promise<string | null> => {
48+
if (typeof window === 'undefined') return null
49+
50+
// Ensure migration completes before reading
51+
if (migrationPromise) {
52+
await migrationPromise
53+
}
54+
55+
try {
56+
const value = await get<string>(name)
57+
return value ?? null
58+
} catch (error) {
59+
logger.warn('IndexedDB read failed', { name, error })
60+
return null
61+
}
62+
},
63+
64+
setItem: async (name: string, value: string): Promise<void> => {
65+
if (typeof window === 'undefined') return
66+
try {
67+
await set(name, value)
68+
} catch (error) {
69+
logger.warn('IndexedDB write failed', { name, error })
70+
}
71+
},
72+
73+
removeItem: async (name: string): Promise<void> => {
74+
if (typeof window === 'undefined') return
75+
try {
76+
await del(name)
77+
} catch (error) {
78+
logger.warn('IndexedDB delete failed', { name, error })
79+
}
80+
},
81+
}

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

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
import { createLogger } from '@sim/logger'
22
import { create } from 'zustand'
3-
import { devtools, persist } from 'zustand/middleware'
3+
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
44
import { redactApiKeys } from '@/lib/core/security/redaction'
55
import type { NormalizedBlockOutput } from '@/executor/types'
66
import { useExecutionStore } from '@/stores/execution'
77
import { useNotificationStore } from '@/stores/notifications'
88
import { useGeneralStore } from '@/stores/settings/general'
9+
import { indexedDBStorage } from '@/stores/terminal/console/storage'
910
import type { ConsoleEntry, ConsoleStore, ConsoleUpdate } from '@/stores/terminal/console/types'
1011

1112
const logger = createLogger('TerminalConsoleStore')
1213

1314
/**
14-
* Updates a NormalizedBlockOutput with new content
15+
* Maximum number of console entries to keep per workflow.
16+
* Keeps the stored data size reasonable and improves performance.
1517
*/
18+
const MAX_ENTRIES_PER_WORKFLOW = 500
19+
1620
const updateBlockOutput = (
1721
existingOutput: NormalizedBlockOutput | undefined,
1822
contentUpdate: string
@@ -23,9 +27,6 @@ const updateBlockOutput = (
2327
}
2428
}
2529

26-
/**
27-
* Checks if output represents a streaming object that should be skipped
28-
*/
2930
const isStreamingOutput = (output: any): boolean => {
3031
if (typeof ReadableStream !== 'undefined' && output instanceof ReadableStream) {
3132
return true
@@ -44,9 +45,6 @@ const isStreamingOutput = (output: any): boolean => {
4445
)
4546
}
4647

47-
/**
48-
* Checks if entry should be skipped to prevent duplicates
49-
*/
5048
const shouldSkipEntry = (output: any): boolean => {
5149
if (typeof output !== 'object' || !output) {
5250
return false
@@ -69,6 +67,9 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
6967
(set, get) => ({
7068
entries: [],
7169
isOpen: false,
70+
_hasHydrated: false,
71+
72+
setHasHydrated: (hasHydrated) => set({ _hasHydrated: hasHydrated }),
7273

7374
addConsole: (entry: Omit<ConsoleEntry, 'id' | 'timestamp'>) => {
7475
set((state) => {
@@ -94,7 +95,15 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
9495
timestamp: new Date().toISOString(),
9596
}
9697

97-
return { entries: [newEntry, ...state.entries] }
98+
const newEntries = [newEntry, ...state.entries]
99+
const workflowCounts = new Map<string, number>()
100+
const trimmedEntries = newEntries.filter((entry) => {
101+
const count = workflowCounts.get(entry.workflowId) || 0
102+
if (count >= MAX_ENTRIES_PER_WORKFLOW) return false
103+
workflowCounts.set(entry.workflowId, count + 1)
104+
return true
105+
})
106+
return { entries: trimmedEntries }
98107
})
99108

100109
const newEntry = get().entries[0]
@@ -130,10 +139,6 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
130139
return newEntry
131140
},
132141

133-
/**
134-
* Clears console entries for a specific workflow and clears the run path
135-
* @param workflowId - The workflow ID to clear entries for
136-
*/
137142
clearWorkflowConsole: (workflowId: string) => {
138143
set((state) => ({
139144
entries: state.entries.filter((entry) => entry.workflowId !== workflowId),
@@ -148,9 +153,6 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
148153
return
149154
}
150155

151-
/**
152-
* Formats a value for CSV export
153-
*/
154156
const formatCSVValue = (value: any): string => {
155157
if (value === null || value === undefined) {
156158
return ''
@@ -297,7 +299,35 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
297299
}),
298300
{
299301
name: 'terminal-console-store',
302+
storage: createJSONStorage(() => indexedDBStorage),
303+
partialize: (state) => ({
304+
entries: state.entries,
305+
isOpen: state.isOpen,
306+
}),
307+
onRehydrateStorage: () => (_state, error) => {
308+
if (error) {
309+
logger.error('Failed to rehydrate console store', { error })
310+
}
311+
},
312+
merge: (persistedState, currentState) => {
313+
const persisted = persistedState as Partial<ConsoleStore> | undefined
314+
return {
315+
...currentState,
316+
entries: persisted?.entries ?? currentState.entries,
317+
isOpen: persisted?.isOpen ?? currentState.isOpen,
318+
}
319+
},
300320
}
301321
)
302322
)
303323
)
324+
325+
if (typeof window !== 'undefined') {
326+
useTerminalConsoleStore.persist.onFinishHydration(() => {
327+
useTerminalConsoleStore.setState({ _hasHydrated: true })
328+
})
329+
330+
if (useTerminalConsoleStore.persist.hasHydrated()) {
331+
useTerminalConsoleStore.setState({ _hasHydrated: true })
332+
}
333+
}

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

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import type { NormalizedBlockOutput } from '@/executor/types'
22
import type { SubflowType } from '@/stores/workflows/workflow/types'
33

4-
/**
5-
* Console entry for terminal logs
6-
*/
74
export interface ConsoleEntry {
85
id: string
96
timestamp: string
@@ -25,9 +22,6 @@ export interface ConsoleEntry {
2522
iterationType?: SubflowType
2623
}
2724

28-
/**
29-
* Console update payload for partial updates
30-
*/
3125
export interface ConsoleUpdate {
3226
content?: string
3327
output?: Partial<NormalizedBlockOutput>
@@ -40,9 +34,6 @@ export interface ConsoleUpdate {
4034
input?: any
4135
}
4236

43-
/**
44-
* Console store state and actions
45-
*/
4637
export interface ConsoleStore {
4738
entries: ConsoleEntry[]
4839
isOpen: boolean
@@ -52,4 +43,6 @@ export interface ConsoleStore {
5243
getWorkflowEntries: (workflowId: string) => ConsoleEntry[]
5344
toggleConsole: () => void
5445
updateConsole: (blockId: string, update: string | ConsoleUpdate, executionId?: string) => void
46+
_hasHydrated: boolean
47+
setHasHydrated: (hasHydrated: boolean) => void
5548
}

bun.lock

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"lockfileVersion": 1,
3+
"configVersion": 0,
34
"workspaces": {
45
"": {
56
"name": "simstudio",
@@ -133,6 +134,7 @@
133134
"groq-sdk": "^0.15.0",
134135
"html-to-image": "1.11.13",
135136
"html-to-text": "^9.0.5",
137+
"idb-keyval": "6.2.2",
136138
"imapflow": "1.2.4",
137139
"input-otp": "^1.4.2",
138140
"ioredis": "^5.6.0",
@@ -2310,6 +2312,8 @@
23102312

23112313
"iconv-lite": ["[email protected]", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
23122314

2315+
"idb-keyval": ["[email protected]", "", {}, "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg=="],
2316+
23132317
"ieee754": ["[email protected]", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
23142318

23152319
"image-size": ["[email protected]", "", { "bin": { "image-size": "bin/image-size.js" } }, "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w=="],

0 commit comments

Comments
 (0)