chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,836 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user