Files
simstudioai--sim/apps/sim/stores/terminal/console/store.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

837 lines
26 KiB
TypeScript

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<string, ConsoleEntry[]>
): Record<string, ConsoleEntry[]> {
return { ...workflowEntries }
}
function removeWorkflowIndexes(
workflowId: string,
entries: ConsoleEntry[],
entryIdsByBlockExecution: Record<string, string[]>,
entryLocationById: Record<string, ConsoleEntryLocation>
): 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<string, string[]>,
entryLocationById: Record<string, ConsoleEntryLocation>
): 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<string, ConsoleEntry[]>) {
const entryIdsByBlockExecution: Record<string, string[]> = {}
const entryLocationById: Record<string, ConsoleEntryLocation> = {}
Object.entries(workflowEntries).forEach(([workflowId, entries]) => {
indexWorkflowEntries(workflowId, entries, entryIdsByBlockExecution, entryLocationById)
})
return { entryIdsByBlockExecution, entryLocationById }
}
function replaceWorkflowEntries(
state: ConsoleStore,
workflowId: string,
nextEntries: ConsoleEntry[]
): Pick<ConsoleStore, 'workflowEntries' | 'entryIdsByBlockExecution' | 'entryLocationById'> {
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<ConsoleStore, 'workflowEntries' | 'entryIdsByBlockExecution' | 'entryLocationById'> {
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<string, unknown>
}
/**
* 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<string, number>()
const notifyBlockError = ({
error,
blockName,
blockId,
executionId,
logContext,
}: NotifyBlockErrorParams) => {
const settings = getQueryClient().getQueryData<GeneralSettings>(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<ConsoleStore>()(
devtools((set, get) => ({
workflowEntries: {},
entryIdsByBlockExecution: {},
entryLocationById: {},
isOpen: false,
_hasHydrated: false,
addConsole: (entry: Omit<ConsoleEntry, 'id' | 'timestamp'>) => {
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<void> {
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
})
}