import { toast } from '@sim/emcn' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { useShallow } from 'zustand/react/shallow' import { redactApiKeys } from '@/lib/core/security/redaction' import { sendMothershipMessage } from '@/lib/mothership/events' import { getQueryClient } from '@/app/_shell/providers/query-provider' import type { NormalizedBlockOutput } from '@/executor/types' import { type GeneralSettings, generalSettingsKeys } from '@/hooks/queries/general-settings' import { useExecutionStore } from '@/stores/execution' import { consolePersistence, loadConsoleData } from '@/stores/terminal/console/storage' import type { ConsoleEntry, ConsoleEntryLocation, ConsoleStore, ConsoleUpdate, } from '@/stores/terminal/console/types' import { normalizeConsoleError, normalizeConsoleInput, normalizeConsoleOutput, safeConsoleStringify, trimWorkflowConsoleEntries, } from '@/stores/terminal/console/utils' const logger = createLogger('TerminalConsoleStore') const EMPTY_CONSOLE_ENTRIES: ConsoleEntry[] = [] const updateBlockOutput = ( existingOutput: NormalizedBlockOutput | undefined, contentUpdate: string ): NormalizedBlockOutput => { return { ...(existingOutput || {}), content: contentUpdate, } } const isStreamingOutput = (output: any): boolean => { if (typeof ReadableStream !== 'undefined' && output instanceof ReadableStream) { return true } if (typeof output !== 'object' || !output) { return false } return ( output.isStreaming === true || ('executionData' in output && typeof output.executionData === 'object' && output.executionData?.isStreaming === true) || 'stream' in output ) } const shouldSkipEntry = (output: any): boolean => { if (typeof output !== 'object' || !output) { return false } if ('stream' in output && 'executionData' in output) { return true } if ('stream' in output && 'execution' in output) { return true } return false } const getBlockExecutionKey = (blockId: string, executionId?: string): string => `${executionId ?? 'no-execution'}:${blockId}` const matchesEntryForUpdate = ( entry: ConsoleEntry, blockId: string, executionId: string | undefined, update: string | ConsoleUpdate ): boolean => { if (entry.blockId !== blockId || entry.executionId !== executionId) { return false } if (typeof update !== 'object') { return true } if (update.executionOrder !== undefined && entry.executionOrder !== update.executionOrder) { return false } if (update.iterationCurrent !== undefined && entry.iterationCurrent !== update.iterationCurrent) { return false } if ( update.iterationContainerId !== undefined && entry.iterationContainerId !== update.iterationContainerId ) { return false } if ( update.childWorkflowBlockId !== undefined && entry.childWorkflowBlockId !== update.childWorkflowBlockId ) { return false } if ( update.childWorkflowInstanceId !== undefined && entry.childWorkflowInstanceId !== undefined && entry.childWorkflowInstanceId !== update.childWorkflowInstanceId ) { return false } return true } function cloneWorkflowEntries( workflowEntries: Record ): Record { return { ...workflowEntries } } function removeWorkflowIndexes( workflowId: string, entries: ConsoleEntry[], entryIdsByBlockExecution: Record, entryLocationById: Record ): void { for (const entry of entries) { delete entryLocationById[entry.id] const blockExecutionKey = getBlockExecutionKey(entry.blockId, entry.executionId) const existingIds = entryIdsByBlockExecution[blockExecutionKey] if (!existingIds) { continue } const nextIds = existingIds.filter((entryId) => entryId !== entry.id) if (nextIds.length === 0) { delete entryIdsByBlockExecution[blockExecutionKey] } else { entryIdsByBlockExecution[blockExecutionKey] = nextIds } } } function indexWorkflowEntries( workflowId: string, entries: ConsoleEntry[], entryIdsByBlockExecution: Record, entryLocationById: Record ): void { entries.forEach((entry, index) => { entryLocationById[entry.id] = { workflowId, index } const blockExecutionKey = getBlockExecutionKey(entry.blockId, entry.executionId) const existingIds = entryIdsByBlockExecution[blockExecutionKey] if (existingIds) { entryIdsByBlockExecution[blockExecutionKey] = [...existingIds, entry.id] } else { entryIdsByBlockExecution[blockExecutionKey] = [entry.id] } }) } function rebuildWorkflowStateMaps(workflowEntries: Record) { const entryIdsByBlockExecution: Record = {} const entryLocationById: Record = {} Object.entries(workflowEntries).forEach(([workflowId, entries]) => { indexWorkflowEntries(workflowId, entries, entryIdsByBlockExecution, entryLocationById) }) return { entryIdsByBlockExecution, entryLocationById } } function replaceWorkflowEntries( state: ConsoleStore, workflowId: string, nextEntries: ConsoleEntry[] ): Pick { const workflowEntries = cloneWorkflowEntries(state.workflowEntries) const entryIdsByBlockExecution = { ...state.entryIdsByBlockExecution } const entryLocationById = { ...state.entryLocationById } const previousEntries = workflowEntries[workflowId] ?? EMPTY_CONSOLE_ENTRIES removeWorkflowIndexes(workflowId, previousEntries, entryIdsByBlockExecution, entryLocationById) if (nextEntries.length === 0) { delete workflowEntries[workflowId] } else { workflowEntries[workflowId] = nextEntries indexWorkflowEntries(workflowId, nextEntries, entryIdsByBlockExecution, entryLocationById) } return { workflowEntries, entryIdsByBlockExecution, entryLocationById } } function appendWorkflowEntry( state: ConsoleStore, workflowId: string, newEntry: ConsoleEntry, trimmedEntries: ConsoleEntry[] ): Pick { const workflowEntries = cloneWorkflowEntries(state.workflowEntries) const previousEntries = workflowEntries[workflowId] ?? EMPTY_CONSOLE_ENTRIES workflowEntries[workflowId] = trimmedEntries const entryLocationById = { ...state.entryLocationById } const entryIdsByBlockExecution = { ...state.entryIdsByBlockExecution } const survivingIds = new Set(trimmedEntries.map((e) => e.id)) const droppedEntries = previousEntries.filter((e) => !survivingIds.has(e.id)) if (droppedEntries.length > 0) { removeWorkflowIndexes(workflowId, droppedEntries, entryIdsByBlockExecution, entryLocationById) } trimmedEntries.forEach((entry, index) => { entryLocationById[entry.id] = { workflowId, index } }) const blockExecutionKey = getBlockExecutionKey(newEntry.blockId, newEntry.executionId) const existingIds = entryIdsByBlockExecution[blockExecutionKey] if (existingIds) { if (!existingIds.includes(newEntry.id)) { entryIdsByBlockExecution[blockExecutionKey] = [...existingIds, newEntry.id] } } else { entryIdsByBlockExecution[blockExecutionKey] = [newEntry.id] } return { workflowEntries, entryIdsByBlockExecution, entryLocationById } } interface NotifyBlockErrorParams { error: unknown blockName: string blockId: string executionId?: string logContext: Record } /** * A single block failure surfaces through both `addConsole` (initial entry) * and `updateConsole` (streaming/finalize), so the same logical error asks to * toast twice within the same tick. Collapse them inside a short window. The * key is scoped to the block execution (not just block + message), so a re-run * or a different execution still toasts — even within the window, and even * after the stack was cleared on navigation. */ const NOTIFY_DEDUP_WINDOW_MS = 1500 const recentErrorNotifications = new Map() const notifyBlockError = ({ error, blockName, blockId, executionId, logContext, }: NotifyBlockErrorParams) => { const settings = getQueryClient().getQueryData(generalSettingsKeys.settings()) const isErrorNotificationsEnabled = settings?.errorNotificationsEnabled ?? true if (!isErrorNotificationsEnabled) return try { const errorMessage = normalizeConsoleError(error) ?? String(error) const displayName = blockName || 'Unknown Block' const copilotMessage = `${errorMessage}\n\nError in ${displayName}.\n\nPlease fix this.` const now = Date.now() for (const [key, shownAt] of recentErrorNotifications) { if (now - shownAt >= NOTIFY_DEDUP_WINDOW_MS) recentErrorNotifications.delete(key) } const dedupKey = `${getBlockExecutionKey(blockId, executionId)}: ${errorMessage}` const lastShownAt = recentErrorNotifications.get(dedupKey) if (lastShownAt !== undefined && now - lastShownAt < NOTIFY_DEDUP_WINDOW_MS) return recentErrorNotifications.set(dedupKey, now) toast.error(displayName, { description: errorMessage, action: { label: 'Fix in Chat', onClick: () => sendMothershipMessage(copilotMessage), }, }) } catch (notificationError) { logger.error('Failed to create block error notification', { ...logContext, error: notificationError, }) } } export const useTerminalConsoleStore = create()( devtools((set, get) => ({ workflowEntries: {}, entryIdsByBlockExecution: {}, entryLocationById: {}, isOpen: false, _hasHydrated: false, addConsole: (entry: Omit) => { if (shouldSkipEntry(entry.output)) { return get().getWorkflowEntries(entry.workflowId)[0] as ConsoleEntry | undefined } const redactedEntry = { ...entry } if ( !isStreamingOutput(entry.output) && redactedEntry.output && typeof redactedEntry.output === 'object' ) { redactedEntry.output = redactApiKeys(redactedEntry.output) } if (redactedEntry.input && typeof redactedEntry.input === 'object') { redactedEntry.input = redactApiKeys(redactedEntry.input) } const createdEntry: ConsoleEntry = { ...redactedEntry, id: generateId(), timestamp: new Date().toISOString(), input: normalizeConsoleInput(redactedEntry.input), output: normalizeConsoleOutput(redactedEntry.output), error: normalizeConsoleError(redactedEntry.error), warning: typeof redactedEntry.warning === 'string' ? (normalizeConsoleError(redactedEntry.warning) ?? undefined) : redactedEntry.warning, } set((state) => { const workflowEntries = state.workflowEntries[entry.workflowId] ?? EMPTY_CONSOLE_ENTRIES const nextWorkflowEntries = trimWorkflowConsoleEntries([createdEntry, ...workflowEntries]) return appendWorkflowEntry(state, entry.workflowId, createdEntry, nextWorkflowEntries) }) if (createdEntry.isRunning) { consolePersistence.onRunningEntryAdded() } if (createdEntry.error && createdEntry.blockType !== 'cancelled') { notifyBlockError({ error: createdEntry.error, blockName: createdEntry.blockName || 'Unknown Block', blockId: createdEntry.blockId, executionId: createdEntry.executionId, logContext: { entryId: createdEntry.id }, }) } return createdEntry }, clearWorkflowConsole: (workflowId: string) => { set((state) => replaceWorkflowEntries(state, workflowId, EMPTY_CONSOLE_ENTRIES)) useExecutionStore.getState().clearRunPath(workflowId) consolePersistence.persist({ merge: false }) }, clearExecutionEntries: (executionId: string) => set((state) => { const nextWorkflowEntries = cloneWorkflowEntries(state.workflowEntries) let didChange = false Object.entries(nextWorkflowEntries).forEach(([workflowId, entries]) => { const filteredEntries = entries.filter((entry) => entry.executionId !== executionId) if (filteredEntries.length !== entries.length) { nextWorkflowEntries[workflowId] = filteredEntries didChange = true } }) if (!didChange) { return state } const normalizedEntries = Object.fromEntries( Object.entries(nextWorkflowEntries).filter(([, entries]) => entries.length > 0) ) return { workflowEntries: normalizedEntries, ...rebuildWorkflowStateMaps(normalizedEntries), } }), exportConsoleCSV: (workflowId: string) => { const entries = get().getWorkflowEntries(workflowId) if (entries.length === 0) { return } const formatCSVValue = (value: any): string => { if (value === null || value === undefined) { return '' } let stringValue = typeof value === 'object' ? safeConsoleStringify(value) : String(value) if (stringValue.includes('"') || stringValue.includes(',') || stringValue.includes('\n')) { stringValue = `"${stringValue.replace(/"/g, '""')}"` } return stringValue } const headers = [ 'timestamp', 'blockName', 'blockType', 'startedAt', 'endedAt', 'durationMs', 'success', 'input', 'output', 'error', 'warning', ] const csvRows = [ headers.join(','), ...entries.map((entry) => [ formatCSVValue(entry.timestamp), formatCSVValue(entry.blockName), formatCSVValue(entry.blockType), formatCSVValue(entry.startedAt), formatCSVValue(entry.endedAt), formatCSVValue(entry.durationMs), formatCSVValue(entry.success), formatCSVValue(entry.input), formatCSVValue(entry.output), formatCSVValue(entry.error), formatCSVValue(entry.warning), ].join(',') ), ] const csvContent = csvRows.join('\n') const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) const filename = `terminal-console-${workflowId}-${timestamp}.csv` const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) const link = document.createElement('a') if (link.download !== undefined) { const url = URL.createObjectURL(blob) link.setAttribute('href', url) link.setAttribute('download', filename) link.style.visibility = 'hidden' document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(url) } }, getWorkflowEntries: (workflowId) => { return get().workflowEntries[workflowId] ?? EMPTY_CONSOLE_ENTRIES }, toggleConsole: () => { set((state) => ({ isOpen: !state.isOpen })) }, updateConsole: (blockId: string, update: string | ConsoleUpdate, executionId?: string) => { set((state) => { const candidateIds = state.entryIdsByBlockExecution[getBlockExecutionKey(blockId, executionId)] ?? [] if (candidateIds.length === 0) { return state } const workflowId = state.entryLocationById[candidateIds[0]]?.workflowId if (!workflowId) { return state } const currentEntries = state.workflowEntries[workflowId] ?? EMPTY_CONSOLE_ENTRIES let nextEntries: ConsoleEntry[] | null = null for (const candidateId of candidateIds) { const location = state.entryLocationById[candidateId] if (!location || location.workflowId !== workflowId) continue const source = nextEntries ?? currentEntries const entry = source[location.index] if (!entry || entry.id !== candidateId) continue if (!matchesEntryForUpdate(entry, blockId, executionId, update)) continue if (!nextEntries) { nextEntries = [...currentEntries] } if (typeof update === 'string') { const newOutput = normalizeConsoleOutput(updateBlockOutput(entry.output, update)) nextEntries[location.index] = { ...entry, output: newOutput } continue } const updatedEntry = { ...entry } if (update.content !== undefined) { updatedEntry.output = normalizeConsoleOutput( updateBlockOutput(entry.output, update.content) ) } if (update.replaceOutput !== undefined) { const redactedOutput = typeof update.replaceOutput === 'object' && update.replaceOutput !== null ? redactApiKeys(update.replaceOutput) : update.replaceOutput updatedEntry.output = normalizeConsoleOutput(redactedOutput) } else if (update.output !== undefined) { const mergedOutput = { ...(entry.output || {}), ...update.output, } updatedEntry.output = typeof mergedOutput === 'object' ? normalizeConsoleOutput(redactApiKeys(mergedOutput)) : normalizeConsoleOutput(mergedOutput) } if (update.blockName !== undefined) { updatedEntry.blockName = update.blockName } if (update.blockType !== undefined) { updatedEntry.blockType = update.blockType } if (update.error !== undefined) { updatedEntry.error = normalizeConsoleError(update.error) } if (update.warning !== undefined) { updatedEntry.warning = normalizeConsoleError(update.warning) ?? undefined } if (update.success !== undefined) { updatedEntry.success = update.success } if (update.startedAt !== undefined) { updatedEntry.startedAt = update.startedAt } if (update.endedAt !== undefined) { updatedEntry.endedAt = update.endedAt } if (update.durationMs !== undefined) { updatedEntry.durationMs = update.durationMs } if (update.input !== undefined) { updatedEntry.input = typeof update.input === 'object' && update.input !== null ? normalizeConsoleInput(redactApiKeys(update.input)) : normalizeConsoleInput(update.input) } if (update.isRunning !== undefined) { updatedEntry.isRunning = update.isRunning } if (update.isCanceled !== undefined) { updatedEntry.isCanceled = update.isCanceled } if (update.iterationCurrent !== undefined) { updatedEntry.iterationCurrent = update.iterationCurrent } if (update.iterationTotal !== undefined) { updatedEntry.iterationTotal = update.iterationTotal } if (update.iterationType !== undefined) { updatedEntry.iterationType = update.iterationType } if (update.iterationContainerId !== undefined) { updatedEntry.iterationContainerId = update.iterationContainerId } if (update.parentIterations !== undefined) { updatedEntry.parentIterations = update.parentIterations } if (update.childWorkflowBlockId !== undefined) { updatedEntry.childWorkflowBlockId = update.childWorkflowBlockId } if (update.childWorkflowName !== undefined) { updatedEntry.childWorkflowName = update.childWorkflowName } if (update.childWorkflowInstanceId !== undefined) { updatedEntry.childWorkflowInstanceId = update.childWorkflowInstanceId } nextEntries[location.index] = updatedEntry } if (!nextEntries) { return state } const workflowEntriesClone = cloneWorkflowEntries(state.workflowEntries) workflowEntriesClone[workflowId] = nextEntries return { workflowEntries: workflowEntriesClone, entryIdsByBlockExecution: state.entryIdsByBlockExecution, entryLocationById: state.entryLocationById, } }) if (typeof update === 'object' && update.error) { const matchingEntry = get() .getWorkflowEntries( get().entryLocationById[ (get().entryIdsByBlockExecution[getBlockExecutionKey(blockId, executionId)] ?? [])[0] ?? '' ]?.workflowId ?? '' ) .find((entry) => matchesEntryForUpdate(entry, blockId, executionId, update)) notifyBlockError({ error: update.error, blockName: update.blockName || matchingEntry?.blockName || 'Unknown Block', blockId, executionId, logContext: { blockId }, }) } }, cancelRunningEntries: (workflowId: string, executionId?: string) => { set((state) => { const now = new Date() const workflowEntries = state.workflowEntries[workflowId] ?? EMPTY_CONSOLE_ENTRIES let didChange = false const updatedEntries = workflowEntries.map((entry) => { if ( entry.workflowId === workflowId && entry.isRunning && (executionId === undefined || entry.executionId === executionId) ) { didChange = true const durationMs = entry.startedAt ? now.getTime() - new Date(entry.startedAt).getTime() : entry.durationMs return { ...entry, isRunning: false, isCanceled: true, endedAt: now.toISOString(), durationMs, } } return entry }) if (!didChange) { return state } return replaceWorkflowEntries(state, workflowId, updatedEntries) }) }, finishRunningEntries: (workflowId: string, executionId?: string) => { set((state) => { const now = new Date() const workflowEntries = state.workflowEntries[workflowId] ?? EMPTY_CONSOLE_ENTRIES let didChange = false const updatedEntries = workflowEntries.map((entry) => { if ( entry.workflowId === workflowId && entry.isRunning && (executionId === undefined || entry.executionId === executionId) ) { didChange = true const durationMs = entry.startedAt ? now.getTime() - new Date(entry.startedAt).getTime() : entry.durationMs return { ...entry, isRunning: false, isCanceled: false, endedAt: now.toISOString(), durationMs, } } return entry }) if (!didChange) { return state } return replaceWorkflowEntries(state, workflowId, updatedEntries) }) }, })) ) /** * Hydrates the console store from IndexedDB on startup. * Applies the same normalization and trimming as the old persist merge. */ async function hydrateConsoleStore(): Promise { try { const data = await loadConsoleData() if (!data) { useTerminalConsoleStore.setState({ _hasHydrated: true }) return } const oneHourAgo = Date.now() - 60 * 60 * 1000 const workflowEntries = Object.fromEntries( Object.entries(data.workflowEntries).map(([workflowId, entries]) => [ workflowId, trimWorkflowConsoleEntries( entries.map((entry, index) => { let updated = entry if (entry.executionOrder === undefined) { updated = { ...updated, executionOrder: index + 1 } } if ( entry.isRunning && entry.startedAt && new Date(entry.startedAt).getTime() < oneHourAgo ) { updated = { ...updated, isRunning: false } } updated = { ...updated, input: normalizeConsoleInput(updated.input), output: normalizeConsoleOutput(updated.output), error: normalizeConsoleError(updated.error), warning: typeof updated.warning === 'string' ? (normalizeConsoleError(updated.warning) ?? undefined) : updated.warning, } return updated }) ), ]) ) const currentState = useTerminalConsoleStore.getState() const mergedWorkflowEntries = { ...workflowEntries } for (const [wfId, currentEntries] of Object.entries(currentState.workflowEntries)) { if (currentEntries.length > 0) { const persistedEntries = mergedWorkflowEntries[wfId] ?? [] const persistedIds = new Set(persistedEntries.map((e) => e.id)) const newEntries = currentEntries.filter((e) => !persistedIds.has(e.id)) if (newEntries.length > 0) { mergedWorkflowEntries[wfId] = trimWorkflowConsoleEntries([ ...newEntries, ...persistedEntries, ]) } } } useTerminalConsoleStore.setState({ workflowEntries: mergedWorkflowEntries, ...rebuildWorkflowStateMaps(mergedWorkflowEntries), isOpen: data.isOpen, _hasHydrated: true, }) } catch (error) { logger.error('Failed to hydrate console store', { error }) useTerminalConsoleStore.setState({ _hasHydrated: true }) } } if (typeof window !== 'undefined') { consolePersistence.bind(() => { const state = useTerminalConsoleStore.getState() return { workflowEntries: state.workflowEntries, isOpen: state.isOpen, } }) hydrateConsoleStore() window.addEventListener('pagehide', () => consolePersistence.persist()) } export function useWorkflowConsoleEntries(workflowId?: string): ConsoleEntry[] { return useTerminalConsoleStore( useShallow((state) => { if (!workflowId) { return EMPTY_CONSOLE_ENTRIES } return state.workflowEntries[workflowId] ?? EMPTY_CONSOLE_ENTRIES }) ) } export function useConsoleEntry(entryId?: string | null): ConsoleEntry | null { return useTerminalConsoleStore((state) => { if (!entryId) { return null } const location = state.entryLocationById[entryId] if (!location) { return null } const entry = state.workflowEntries[location.workflowId]?.[location.index] if (!entry || entry.id !== entryId) { return null } return entry }) }