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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,9 @@
export {
clearExecutionPointer,
consolePersistence,
loadExecutionPointer,
saveExecutionPointer,
} from './storage'
export { useConsoleEntry, useTerminalConsoleStore, useWorkflowConsoleEntries } from './store'
export type { ConsoleEntry, ConsoleUpdate } from './types'
export { safeConsoleStringify } from './utils'
+320
View File
@@ -0,0 +1,320 @@
import { createLogger } from '@sim/logger'
import { get, set } from 'idb-keyval'
import type { ConsoleEntry } from './types'
const logger = createLogger('ConsoleStorage')
const STORE_KEY = 'terminal-console-store'
const MIGRATION_KEY = 'terminal-console-store-migrated'
/**
* Interval for persisting terminal state during active executions.
* Kept short enough that a hard refresh during execution still has
* recent rows available once the tab-local reconnect pointer resumes.
*/
const EXECUTION_PERSIST_INTERVAL_MS = 5_000
/**
* Shape of terminal console data persisted to IndexedDB.
*/
export interface PersistedConsoleData {
workflowEntries: Record<string, ConsoleEntry[]>
isOpen: boolean
}
let migrationPromise: Promise<void> | null = null
async function migrateFromLocalStorage(): Promise<void> {
if (typeof window === 'undefined') return
try {
const migrated = await get<boolean>(MIGRATION_KEY)
if (migrated) return
const localData = localStorage.getItem(STORE_KEY)
if (localData) {
await set(STORE_KEY, localData)
localStorage.removeItem(STORE_KEY)
logger.info('Migrated console store to IndexedDB')
}
await set(MIGRATION_KEY, true)
} catch (error) {
logger.warn('Migration from localStorage failed', { error })
}
}
if (typeof window !== 'undefined') {
migrationPromise = migrateFromLocalStorage().finally(() => {
migrationPromise = null
})
}
/**
* Loads persisted console data from IndexedDB.
* Handles three historical storage formats:
* 1. Zustand persist wrapper: `{ state: { entries: [...] }, version }` (original flat format)
* 2. Zustand persist wrapper: `{ state: { workflowEntries: {...} }, version }` (refactored format)
* 3. Raw data: `{ workflowEntries: {...}, isOpen }` (current format)
*/
export async function loadConsoleData(): Promise<PersistedConsoleData | null> {
if (typeof window === 'undefined') return null
if (migrationPromise) {
await migrationPromise
}
try {
const raw = await get<string>(STORE_KEY)
if (!raw) return null
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
if (!parsed || typeof parsed !== 'object') return null
const data = parsed.state ?? parsed
if (Array.isArray(data.entries) && !data.workflowEntries) {
const workflowEntries: Record<string, ConsoleEntry[]> = {}
for (const entry of data.entries) {
if (!entry?.workflowId) continue
const wfId = entry.workflowId
if (!workflowEntries[wfId]) workflowEntries[wfId] = []
workflowEntries[wfId].push(entry)
}
return { workflowEntries, isOpen: Boolean(data.isOpen) }
}
return {
workflowEntries: data.workflowEntries ?? {},
isOpen: Boolean(data.isOpen),
}
} catch (error) {
logger.warn('Failed to load console data from IndexedDB', { error })
return null
}
}
let activeWrite: Promise<void> | null = null
interface PersistOptions {
merge?: boolean
}
function entryTimestamp(entry: ConsoleEntry): number {
return Date.parse(entry.endedAt ?? entry.startedAt ?? entry.timestamp)
}
function shouldReplaceEntry(existing: ConsoleEntry, incoming: ConsoleEntry): boolean {
if (existing.isRunning && !incoming.isRunning) return true
if (!existing.isRunning && incoming.isRunning) return false
return entryTimestamp(incoming) >= entryTimestamp(existing)
}
function mergeEntries(
existingEntries: ConsoleEntry[] = [],
incomingEntries: ConsoleEntry[] = []
): ConsoleEntry[] {
const entriesById = new Map<string, ConsoleEntry>()
const orderedIds: string[] = []
for (const entry of existingEntries) {
entriesById.set(entry.id, entry)
orderedIds.push(entry.id)
}
for (const entry of incomingEntries) {
const existing = entriesById.get(entry.id)
if (!existing) {
entriesById.set(entry.id, entry)
orderedIds.push(entry.id)
continue
}
if (shouldReplaceEntry(existing, entry)) {
entriesById.set(entry.id, entry)
}
}
return orderedIds
.map((id) => entriesById.get(id))
.filter((entry): entry is ConsoleEntry => !!entry)
}
function mergePersistedConsoleData(
existing: PersistedConsoleData | null,
incoming: PersistedConsoleData
): PersistedConsoleData {
if (!existing) return incoming
const workflowIds = new Set([
...Object.keys(existing.workflowEntries),
...Object.keys(incoming.workflowEntries),
])
const workflowEntries: Record<string, ConsoleEntry[]> = {}
for (const workflowId of workflowIds) {
const entries = mergeEntries(
existing.workflowEntries[workflowId],
incoming.workflowEntries[workflowId]
)
if (entries.length > 0) workflowEntries[workflowId] = entries
}
return {
workflowEntries,
isOpen: incoming.isOpen,
}
}
function writeToIndexedDB(
data: PersistedConsoleData,
{ merge = true }: PersistOptions = {}
): Promise<void> {
const doWrite = async () => {
try {
const nextData = merge ? mergePersistedConsoleData(await loadConsoleData(), data) : data
const serialized = JSON.stringify(nextData)
await set(STORE_KEY, serialized)
} catch (error) {
logger.warn('IndexedDB write failed', { error })
}
}
activeWrite = (activeWrite ?? Promise.resolve()).then(doWrite)
return activeWrite
}
/**
* Execution-aware persistence manager for the terminal console store.
*
* Writes happen only at meaningful lifecycle boundaries:
* - When an execution ends (success, error, cancel)
* - On explicit user actions (clear console)
* - On page hide (crash safety)
* - Every 30s during very long active executions (safety net)
*
* During normal execution, no serialization or IndexedDB writes occur,
* keeping the hot path completely free of persistence overhead.
*/
class ConsolePersistenceManager {
private dataProvider: (() => PersistedConsoleData) | null = null
private safetyTimer: ReturnType<typeof setTimeout> | null = null
private activeExecutions = 0
private needsInitialPersist = false
/**
* Binds the data provider function used to snapshot current state.
* Called once during store initialization.
*/
bind(provider: () => PersistedConsoleData): void {
this.dataProvider = provider
}
/**
* Signals that a workflow execution has started.
* Starts the long-execution safety-net timer if this is the first active execution.
*/
executionStarted(): void {
this.activeExecutions++
this.needsInitialPersist = true
if (this.activeExecutions === 1) {
this.startSafetyTimer()
}
}
/**
* Called by the store when a running entry is added during an active execution.
* Triggers one immediate persist so refreshes can hydrate visible terminal rows,
* then disables until the next execution starts.
*/
onRunningEntryAdded(): void {
if (!this.needsInitialPersist) return
this.needsInitialPersist = false
this.persist()
}
/**
* Signals that a workflow execution has ended (success, error, or cancel).
* Triggers an immediate persist and stops the safety timer if no executions remain.
*/
executionEnded(): void {
this.activeExecutions = Math.max(0, this.activeExecutions - 1)
this.persist()
if (this.activeExecutions === 0) {
this.stopSafetyTimer()
}
}
/**
* Triggers an immediate persist. Used for explicit user actions
* like clearing the console, and for page-hide durability.
*/
persist(options?: PersistOptions): Promise<void> {
if (!this.dataProvider) return Promise.resolve()
return writeToIndexedDB(this.dataProvider(), options)
}
private startSafetyTimer(): void {
this.stopSafetyTimer()
this.safetyTimer = setInterval(() => {
this.persist()
}, EXECUTION_PERSIST_INTERVAL_MS)
}
private stopSafetyTimer(): void {
if (this.safetyTimer !== null) {
clearInterval(this.safetyTimer)
this.safetyTimer = null
}
}
}
export const consolePersistence = new ConsolePersistenceManager()
const EXEC_POINTER_PREFIX = 'terminal-active-execution:'
/**
* Lightweight pointer to an in-flight execution, persisted immediately on
* execution start so the reconnect flow can find it even if no console
* entries have been written yet. Stored in sessionStorage so ownership stays
* scoped to the browser tab that started the run.
*/
export interface ExecutionPointer {
workflowId: string
executionId: string
lastEventId: number
}
export async function loadExecutionPointer(workflowId: string): Promise<ExecutionPointer | null> {
if (typeof window === 'undefined') return null
try {
const raw = window.sessionStorage.getItem(`${EXEC_POINTER_PREFIX}${workflowId}`)
if (!raw) return null
const parsed = JSON.parse(raw)
if (!parsed?.executionId) return null
return parsed as ExecutionPointer
} catch {
return null
}
}
export function saveExecutionPointer(pointer: ExecutionPointer): Promise<void> {
if (typeof window === 'undefined') return Promise.resolve()
try {
window.sessionStorage.setItem(
`${EXEC_POINTER_PREFIX}${pointer.workflowId}`,
JSON.stringify(pointer)
)
} catch {
return Promise.resolve()
}
return Promise.resolve()
}
export function clearExecutionPointer(workflowId: string): Promise<void> {
if (typeof window === 'undefined') return Promise.resolve()
try {
window.sessionStorage.removeItem(`${EXEC_POINTER_PREFIX}${workflowId}`)
} catch {
return Promise.resolve()
}
return Promise.resolve()
}
@@ -0,0 +1,194 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.unmock('@/stores/terminal')
vi.unmock('@/stores/terminal/console/store')
import { useTerminalConsoleStore } from '@/stores/terminal/console/store'
describe('terminal console store', () => {
beforeEach(() => {
useTerminalConsoleStore.setState({
workflowEntries: {},
entryIdsByBlockExecution: {},
entryLocationById: {},
isOpen: false,
_hasHydrated: true,
})
})
it('normalizes oversized payloads when adding console entries', () => {
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-1',
blockName: 'Function',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 1,
output: {
a: 'x'.repeat(100_000),
b: 'y'.repeat(100_000),
c: 'z'.repeat(100_000),
d: 'q'.repeat(100_000),
e: 'r'.repeat(100_000),
f: 's'.repeat(100_000),
},
})
const [entry] = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1')
expect(entry.output).toMatchObject({
__simTruncated: true,
})
})
it('normalizes oversized replaceOutput updates', () => {
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-1',
blockName: 'Function',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 1,
output: { ok: true },
})
useTerminalConsoleStore.getState().updateConsole(
'block-1',
{
executionOrder: 1,
replaceOutput: {
a: 'x'.repeat(100_000),
b: 'y'.repeat(100_000),
c: 'z'.repeat(100_000),
d: 'q'.repeat(100_000),
e: 'r'.repeat(100_000),
f: 's'.repeat(100_000),
},
},
'exec-1'
)
const [entry] = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1')
expect(entry.output).toMatchObject({
__simTruncated: true,
})
})
it('updates one workflow without replacing unrelated workflow arrays', () => {
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-1',
blockName: 'Function',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 1,
output: { ok: true },
})
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-2',
blockId: 'block-2',
blockName: 'Function',
blockType: 'function',
executionId: 'exec-2',
executionOrder: 1,
output: { ok: true },
})
const before = useTerminalConsoleStore.getState()
const workflowTwoEntries = before.workflowEntries['wf-2']
useTerminalConsoleStore.getState().updateConsole(
'block-1',
{
executionOrder: 1,
replaceOutput: { status: 'updated' },
},
'exec-1'
)
const after = useTerminalConsoleStore.getState()
expect(after.workflowEntries['wf-2']).toBe(workflowTwoEntries)
expect(after.getWorkflowEntries('wf-1')[0].output).toMatchObject({ status: 'updated' })
})
describe('cancelRunningEntries', () => {
it('flips a plain running entry to canceled', () => {
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-1',
blockName: 'Function',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 1,
isRunning: true,
startedAt: new Date(Date.now() - 1000).toISOString(),
})
useTerminalConsoleStore.getState().cancelRunningEntries('wf-1')
const [entry] = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1')
expect(entry.isCanceled).toBe(true)
expect(entry.isRunning).toBe(false)
})
it('only cancels running entries for the requested execution when provided', () => {
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-1',
blockName: 'Function 1',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 1,
isRunning: true,
})
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-2',
blockName: 'Function 2',
blockType: 'function',
executionId: 'exec-2',
executionOrder: 2,
isRunning: true,
})
useTerminalConsoleStore.getState().cancelRunningEntries('wf-1', 'exec-1')
const entries = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1')
expect(entries.find((entry) => entry.executionId === 'exec-1')).toMatchObject({
isCanceled: true,
isRunning: false,
})
expect(entries.find((entry) => entry.executionId === 'exec-2')).toMatchObject({
isRunning: true,
})
})
})
describe('finishRunningEntries', () => {
it('settles running entries without marking them canceled', () => {
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-1',
blockName: 'Function',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 1,
isRunning: true,
startedAt: new Date(Date.now() - 1000).toISOString(),
})
useTerminalConsoleStore.getState().finishRunningEntries('wf-1', 'exec-1')
const [entry] = useTerminalConsoleStore.getState().getWorkflowEntries('wf-1')
expect(entry.isCanceled).toBe(false)
expect(entry.isRunning).toBe(false)
expect(entry.endedAt).toBeDefined()
})
})
})
+836
View File
@@ -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
})
}
+83
View File
@@ -0,0 +1,83 @@
import type { ParentIteration } from '@/executor/execution/types'
import type { NormalizedBlockOutput } from '@/executor/types'
import type { SubflowType } from '@/stores/workflows/workflow/types'
export interface ConsoleEntry {
id: string
timestamp: string
workflowId: string
blockId: string
blockName: string
blockType: string
executionId?: string
startedAt?: string
executionOrder: number
endedAt?: string
durationMs?: number
success?: boolean
input?: any
output?: NormalizedBlockOutput
error?: string | Error | null
warning?: string
iterationCurrent?: number
iterationTotal?: number
iterationType?: SubflowType
iterationContainerId?: string
parentIterations?: ParentIteration[]
isRunning?: boolean
isCanceled?: boolean
/** ID of the workflow block in the parent execution that spawned this child block */
childWorkflowBlockId?: string
/** Display name of the child workflow this block belongs to */
childWorkflowName?: string
/** Per-invocation unique ID linking this workflow block to its child block events */
childWorkflowInstanceId?: string
}
export interface ConsoleUpdate {
content?: string
output?: Partial<NormalizedBlockOutput>
replaceOutput?: NormalizedBlockOutput
blockName?: string
blockType?: string
executionOrder?: number
error?: string | Error | null
warning?: string
success?: boolean
startedAt?: string
endedAt?: string
durationMs?: number
input?: any
isRunning?: boolean
isCanceled?: boolean
iterationCurrent?: number
iterationTotal?: number
iterationType?: SubflowType
iterationContainerId?: string
parentIterations?: ParentIteration[]
childWorkflowBlockId?: string
childWorkflowName?: string
childWorkflowInstanceId?: string
}
export interface ConsoleEntryLocation {
workflowId: string
index: number
}
export interface ConsoleStore {
workflowEntries: Record<string, ConsoleEntry[]>
entryIdsByBlockExecution: Record<string, string[]>
entryLocationById: Record<string, ConsoleEntryLocation>
isOpen: boolean
addConsole: (entry: Omit<ConsoleEntry, 'id' | 'timestamp'>) => ConsoleEntry | undefined
clearWorkflowConsole: (workflowId: string) => void
clearExecutionEntries: (executionId: string) => void
exportConsoleCSV: (workflowId: string) => void
getWorkflowEntries: (workflowId: string) => ConsoleEntry[]
toggleConsole: () => void
updateConsole: (blockId: string, update: string | ConsoleUpdate, executionId?: string) => void
cancelRunningEntries: (workflowId: string, executionId?: string) => void
finishRunningEntries: (workflowId: string, executionId?: string) => void
_hasHydrated: boolean
}
@@ -0,0 +1,144 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { ConsoleEntry } from './types'
import {
normalizeConsoleOutput,
safeConsoleStringify,
TERMINAL_CONSOLE_LIMITS,
trimConsoleEntries,
} from './utils'
function makeEntry(id: string, executionId: string, workflowId = 'wf-1'): ConsoleEntry {
return {
id,
executionId,
workflowId,
blockId: `block-${id}`,
blockName: `Block ${id}`,
blockType: 'function',
executionOrder: Number.parseInt(id.replace(/\D/g, ''), 10) || 0,
timestamp: '2025-01-01T00:00:00.000Z',
}
}
describe('terminal console utils', () => {
it('safely stringifies circular values', () => {
const circular: { name: string; self?: unknown } = { name: 'root' }
circular.self = circular
const result = safeConsoleStringify(circular)
expect(result).toContain('[Circular]')
expect(result).toContain('"name": "root"')
})
it('preserves small objects nested at the agent tool-call depth', () => {
const output = normalizeConsoleOutput({
toolCalls: {
list: [
{
name: 'table_query_rows',
result: {
rows: [{ data: { deal_id: 'DEAL-001', client_name: 'Jennifer Martinez' } }],
},
},
],
},
}) as {
toolCalls: { list: Array<{ result: { rows: Array<{ data: Record<string, unknown> }> } }> }
}
const row = output.toolCalls.list[0].result.rows[0]
expect(row).not.toBe('[Truncated object]')
expect(row.data.deal_id).toBe('DEAL-001')
expect(row.data.client_name).toBe('Jennifer Martinez')
})
it('resolves true circular references without infinite recursion', () => {
const circular: { name: string; self?: unknown } = { name: 'root' }
circular.self = circular
const output = normalizeConsoleOutput(circular) as { name: string; self: unknown }
expect(output.name).toBe('root')
expect(output.self).toBe('[Circular]')
})
it('renders a value shared across sibling positions fully (not circular)', () => {
const shared = { x: 1 }
const output = normalizeConsoleOutput({ a: shared, b: shared }) as {
a: { x: number }
b: { x: number }
}
expect(output.a).toEqual({ x: 1 })
expect(output.b).toEqual({ x: 1 })
})
it('truncates structures nested beyond MAX_DEPTH as a backstop', () => {
let deep: Record<string, unknown> = { value: 'leaf' }
for (let i = 0; i < TERMINAL_CONSOLE_LIMITS.MAX_DEPTH + 2; i++) {
deep = { nested: deep }
}
const serialized = safeConsoleStringify(normalizeConsoleOutput(deep))
expect(serialized).toContain('[Truncated object]')
expect(serialized).not.toContain('leaf')
})
it('truncates oversized nested strings in console output', () => {
const output = normalizeConsoleOutput({
stdout: 'x'.repeat(TERMINAL_CONSOLE_LIMITS.MAX_STRING_LENGTH + 100),
})
expect(output?.stdout).toContain('[truncated 100 chars]')
})
it('caps oversized normalized payloads with a preview object', () => {
const output = normalizeConsoleOutput({
a: 'x'.repeat(100_000),
b: 'y'.repeat(100_000),
c: 'z'.repeat(100_000),
d: 'q'.repeat(100_000),
e: 'r'.repeat(100_000),
f: 's'.repeat(100_000),
}) as Record<string, unknown>
expect(output.__simTruncated).toBe(true)
expect(typeof output.__simPreview).toBe('string')
expect(typeof output.__simByteLength).toBe('number')
})
it('preserves the newest oversized execution by trimming within it first', () => {
const newestEntries = Array.from({ length: 5_100 }, (_, index) =>
makeEntry(`new-${index}`, 'exec-new')
)
const olderEntries = Array.from({ length: 25 }, (_, index) =>
makeEntry(`old-${index}`, 'exec-old')
)
const trimmed = trimConsoleEntries([...newestEntries, ...olderEntries])
expect(trimmed).toHaveLength(TERMINAL_CONSOLE_LIMITS.MAX_ENTRIES_PER_WORKFLOW)
expect(trimmed.every((entry) => entry.executionId === 'exec-new')).toBe(true)
expect(trimmed[0].id).toBe('new-0')
expect(trimmed.at(-1)?.id).toBe(`new-${TERMINAL_CONSOLE_LIMITS.MAX_ENTRIES_PER_WORKFLOW - 1}`)
})
it('keeps older whole executions when they still fit after the newest run', () => {
const newestEntries = Array.from({ length: 4_990 }, (_, index) =>
makeEntry(`new-${index}`, 'exec-new')
)
const olderEntries = Array.from({ length: 10 }, (_, index) =>
makeEntry(`old-${index}`, 'exec-old')
)
const trimmed = trimConsoleEntries([...newestEntries, ...olderEntries])
expect(trimmed).toHaveLength(5_000)
expect(trimmed.filter((entry) => entry.executionId === 'exec-new')).toHaveLength(4_990)
expect(trimmed.filter((entry) => entry.executionId === 'exec-old')).toHaveLength(10)
})
})
+319
View File
@@ -0,0 +1,319 @@
import type { NormalizedBlockOutput } from '@/executor/types'
import type { ConsoleEntry } from './types'
/**
* Terminal console safety limits used to bound persisted debug data.
*/
export const TERMINAL_CONSOLE_LIMITS = {
MAX_ENTRIES_PER_WORKFLOW: 5000,
MAX_STRING_LENGTH: 50_000,
MAX_OBJECT_KEYS: 100,
MAX_ARRAY_ITEMS: 100,
MAX_DEPTH: 12,
MAX_SERIALIZED_BYTES: 256 * 1024,
MAX_SERIALIZED_PREVIEW_LENGTH: 10_000,
} as const
const textEncoder = new TextEncoder()
/**
* Returns the UTF-8 byte length of a string.
*/
function getByteLength(value: string): number {
return textEncoder.encode(value).length
}
/**
* Truncates a string while preserving a short explanation.
*/
function truncateString(
value: string,
maxLength: number = TERMINAL_CONSOLE_LIMITS.MAX_STRING_LENGTH
): string {
if (value.length <= maxLength) {
return value
}
return `${value.slice(0, maxLength)}... [truncated ${value.length - maxLength} chars]`
}
/**
* Safely stringifies terminal data without throwing on circular or non-JSON-safe values.
*/
export function safeConsoleStringify(value: unknown): string {
const seen = new WeakSet<object>()
try {
return (
JSON.stringify(
value,
(_key, currentValue) => {
if (typeof currentValue === 'bigint') {
return `${currentValue.toString()}n`
}
if (currentValue instanceof Error) {
return {
name: currentValue.name,
message: currentValue.message,
stack: currentValue.stack,
}
}
if (typeof currentValue === 'function') {
return `[Function ${currentValue.name || 'anonymous'}]`
}
if (typeof currentValue === 'symbol') {
return currentValue.toString()
}
if (typeof currentValue === 'object' && currentValue !== null) {
if (seen.has(currentValue)) {
return '[Circular]'
}
seen.add(currentValue)
}
return currentValue
},
2
) ?? ''
)
} catch {
try {
return String(value)
} catch {
return '[Unserializable value]'
}
}
}
/**
* Produces a terminal-safe representation of any value.
*
* Recursion is bounded by two independent guards: `seen` tracks the current
* ancestor chain so true circular references resolve to `[Circular]` (a value
* reused across sibling positions is not a cycle and renders fully), and
* `MAX_DEPTH` is a pathological-nesting backstop. Actual payload size is bounded
* downstream by `truncateString` and `capNormalizedValue`, not by depth.
*/
export function normalizeConsoleValue(
value: unknown,
depth = 0,
seen: WeakSet<object> = new WeakSet()
): unknown {
if (value === null || value === undefined) {
return value
}
if (typeof value === 'string') {
return truncateString(value)
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value
}
if (typeof value === 'bigint') {
return `${value.toString()}n`
}
if (typeof value === 'function') {
return `[Function ${value.name || 'anonymous'}]`
}
if (typeof value === 'symbol') {
return value.toString()
}
if (value instanceof Error) {
return {
name: value.name,
message: truncateString(value.message),
stack: value.stack ? truncateString(value.stack) : undefined,
}
}
if (depth >= TERMINAL_CONSOLE_LIMITS.MAX_DEPTH) {
return `[Truncated ${Array.isArray(value) ? 'array' : 'object'}]`
}
const objectValue = value as object
if (seen.has(objectValue)) {
return '[Circular]'
}
seen.add(objectValue)
try {
if (Array.isArray(value)) {
const normalizedItems = value
.slice(0, TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS)
.map((item) => normalizeConsoleValue(item, depth + 1, seen))
if (value.length > TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS) {
normalizedItems.push(
`[... truncated ${value.length - TERMINAL_CONSOLE_LIMITS.MAX_ARRAY_ITEMS} items]`
)
}
return normalizedItems
}
const objectEntries = Object.entries(value as Record<string, unknown>)
const normalizedObject: Record<string, unknown> = {}
for (const [key, entryValue] of objectEntries.slice(
0,
TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS
)) {
normalizedObject[key] = normalizeConsoleValue(entryValue, depth + 1, seen)
}
if (objectEntries.length > TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS) {
normalizedObject.__simTruncatedKeys =
objectEntries.length - TERMINAL_CONSOLE_LIMITS.MAX_OBJECT_KEYS
}
return normalizedObject
} finally {
seen.delete(objectValue)
}
}
/**
* Applies a final serialized-size cap after recursive normalization.
*/
function capNormalizedValue(value: unknown): unknown {
if (value === null || value === undefined) {
return value
}
const serialized = safeConsoleStringify(value)
const serializedBytes = getByteLength(serialized)
if (serializedBytes <= TERMINAL_CONSOLE_LIMITS.MAX_SERIALIZED_BYTES) {
return value
}
return {
__simTruncated: true,
__simByteLength: serializedBytes,
__simPreview: truncateString(serialized, TERMINAL_CONSOLE_LIMITS.MAX_SERIALIZED_PREVIEW_LENGTH),
}
}
/**
* Normalizes terminal input data before it is stored.
*/
export function normalizeConsoleInput(input: unknown): unknown {
return capNormalizedValue(normalizeConsoleValue(input))
}
/**
* Normalizes terminal output data before it is stored.
*/
export function normalizeConsoleOutput(output: unknown): NormalizedBlockOutput | undefined {
if (output === undefined) {
return undefined
}
return capNormalizedValue(normalizeConsoleValue(output)) as NormalizedBlockOutput
}
/**
* Normalizes terminal error data before it is stored.
*/
export function normalizeConsoleError(error: unknown): string | null | undefined {
if (error === undefined) {
return undefined
}
if (error === null) {
return null
}
return truncateString(
typeof error === 'string' ? error : safeConsoleStringify(normalizeConsoleValue(error))
)
}
/**
* Returns a workflow's entries trimmed to the configured cap.
*/
export function trimWorkflowConsoleEntries(entries: ConsoleEntry[]): ConsoleEntry[] {
if (entries.length <= TERMINAL_CONSOLE_LIMITS.MAX_ENTRIES_PER_WORKFLOW) {
return entries
}
const executionGroups = new Map<string, ConsoleEntry[]>()
for (const entry of entries) {
const executionId = entry.executionId ?? entry.id
const group = executionGroups.get(executionId)
if (group) {
group.push(entry)
} else {
executionGroups.set(executionId, [entry])
}
}
const executionIds = [...executionGroups.keys()]
const newestExecutionId = executionIds[0]
if (!newestExecutionId) {
return entries.slice(0, TERMINAL_CONSOLE_LIMITS.MAX_ENTRIES_PER_WORKFLOW)
}
const keptEntryIds = new Set<string>()
let remainingSlots = TERMINAL_CONSOLE_LIMITS.MAX_ENTRIES_PER_WORKFLOW
const newestExecutionEntries = executionGroups.get(newestExecutionId) ?? []
const newestExecutionToKeep = newestExecutionEntries.slice(0, remainingSlots)
newestExecutionToKeep.forEach((entry) => keptEntryIds.add(entry.id))
remainingSlots -= newestExecutionToKeep.length
for (const executionId of executionIds.slice(1)) {
const executionEntries = executionGroups.get(executionId) ?? []
if (executionEntries.length > remainingSlots) {
continue
}
executionEntries.forEach((entry) => keptEntryIds.add(entry.id))
remainingSlots -= executionEntries.length
if (remainingSlots === 0) {
break
}
}
return entries.filter((entry) => keptEntryIds.has(entry.id))
}
/**
* Applies workflow-level trimming while preserving newest-first order.
*/
export function trimConsoleEntries(entries: ConsoleEntry[]): ConsoleEntry[] {
const workflowGroups = new Map<string, ConsoleEntry[]>()
for (const entry of entries) {
const workflowEntries = workflowGroups.get(entry.workflowId)
if (workflowEntries) {
workflowEntries.push(entry)
} else {
workflowGroups.set(entry.workflowId, [entry])
}
}
const keptEntryIds = new Set<string>()
for (const workflowEntries of workflowGroups.values()) {
trimWorkflowConsoleEntries(workflowEntries).forEach((entry) => keptEntryIds.add(entry.id))
}
return entries.filter((entry) => keptEntryIds.has(entry.id))
}
+12
View File
@@ -0,0 +1,12 @@
export type { ConsoleEntry, ConsoleUpdate } from './console'
export {
clearExecutionPointer,
consolePersistence,
loadExecutionPointer,
safeConsoleStringify,
saveExecutionPointer,
useConsoleEntry,
useTerminalConsoleStore,
useWorkflowConsoleEntries,
} from './console'
export { useTerminalStore } from './store'
+123
View File
@@ -0,0 +1,123 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { OUTPUT_PANEL_WIDTH, TERMINAL_HEIGHT } from '@/stores/constants'
import type { TerminalState } from './types'
export const useTerminalStore = create<TerminalState>()(
persist(
(set) => ({
terminalHeight: TERMINAL_HEIGHT.DEFAULT,
lastExpandedHeight: TERMINAL_HEIGHT.DEFAULT,
isResizing: false,
/**
* Updates the terminal height and synchronizes the CSS custom property.
*
* @remarks
* - Enforces a minimum height to keep the resize handle usable.
* - Persists {@link TerminalState.lastExpandedHeight} only when the
* height is expanded above the minimum.
*
* @param height - Desired terminal height in pixels.
*/
setTerminalHeight: (height) => {
const clampedHeight = Math.max(TERMINAL_HEIGHT.MIN, height)
set((state) => ({
terminalHeight: clampedHeight,
lastExpandedHeight:
clampedHeight > TERMINAL_HEIGHT.MIN ? clampedHeight : state.lastExpandedHeight,
}))
// Update CSS variable for immediate visual feedback
if (typeof window !== 'undefined') {
document.documentElement.style.setProperty('--terminal-height', `${clampedHeight}px`)
}
},
/**
* Updates the terminal resize state used to coordinate layout transitions.
*
* @param isResizing - True while the terminal is being resized via mouse drag.
*/
setIsResizing: (isResizing) => {
set({ isResizing })
},
outputPanelWidth: OUTPUT_PANEL_WIDTH.DEFAULT,
/**
* Updates the output panel width, enforcing the minimum constraint.
*
* @param width - Desired width in pixels for the output panel.
*/
setOutputPanelWidth: (width) => {
const clampedWidth = Math.max(OUTPUT_PANEL_WIDTH.MIN, width)
set({ outputPanelWidth: clampedWidth })
},
openOnRun: true,
/**
* Enables or disables automatic terminal opening when new entries are added.
*
* @param open - Whether the terminal should open on new console entries.
*/
setOpenOnRun: (open) => {
set({ openOnRun: open })
},
wrapText: true,
/**
* Enables or disables text wrapping in the output panel.
*
* @param wrap - Whether output text should wrap.
*/
setWrapText: (wrap) => {
set({ wrapText: wrap })
},
structuredView: true,
/**
* Enables or disables structured view mode in the output panel.
*
* @param structured - Whether output should be displayed as nested blocks.
*/
setStructuredView: (structured) => {
set({ structuredView: structured })
},
/**
* Indicates whether the terminal store has finished client-side hydration.
*/
_hasHydrated: false,
/**
* Marks the store as hydrated on the client.
*
* @param hasHydrated - True when client-side hydration is complete.
*/
setHasHydrated: (hasHydrated) => {
set({ _hasHydrated: hasHydrated })
},
}),
{
name: 'terminal-state',
/**
* Persist only the durable terminal UI preferences. The transient
* `isResizing` drag flag and the `_hasHydrated` hydration marker are
* excluded so they always start fresh on load.
*/
partialize: (state) => ({
terminalHeight: state.terminalHeight,
lastExpandedHeight: state.lastExpandedHeight,
outputPanelWidth: state.outputPanelWidth,
openOnRun: state.openOnRun,
wrapText: state.wrapText,
structuredView: state.structuredView,
}),
/**
* Synchronizes the `--terminal-height` CSS custom property with the
* persisted store value after client-side rehydration.
*/
onRehydrateStorage: () => (state) => {
if (state && typeof window !== 'undefined') {
document.documentElement.style.setProperty(
'--terminal-height',
`${state.terminalHeight}px`
)
}
},
}
)
)
+41
View File
@@ -0,0 +1,41 @@
/**
* Display mode type for terminal output.
*
* @remarks
* Currently unused but kept for future customization of terminal rendering.
*/
// export type DisplayMode = 'raw' | 'prettier'
/**
* Terminal state persisted across workspace sessions.
*/
export interface TerminalState {
terminalHeight: number
setTerminalHeight: (height: number) => void
lastExpandedHeight: number
outputPanelWidth: number
setOutputPanelWidth: (width: number) => void
openOnRun: boolean
setOpenOnRun: (open: boolean) => void
wrapText: boolean
setWrapText: (wrap: boolean) => void
structuredView: boolean
setStructuredView: (structured: boolean) => void
/**
* Indicates whether the terminal is currently being resized via mouse drag.
*
* @remarks
* This flag is used by other workspace UI elements (e.g. notifications,
* diff controls) to temporarily disable position transitions while the
* terminal height is actively changing, avoiding janky animations.
*/
isResizing: boolean
/**
* Updates the {@link TerminalState.isResizing} flag.
*
* @param isResizing - True while the terminal is being resized.
*/
setIsResizing: (isResizing: boolean) => void
_hasHydrated: boolean
setHasHydrated: (hasHydrated: boolean) => void
}