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,9 @@
|
||||
export {
|
||||
clearExecutionPointer,
|
||||
consolePersistence,
|
||||
loadExecutionPointer,
|
||||
saveExecutionPointer,
|
||||
} from './storage'
|
||||
export { useConsoleEntry, useTerminalConsoleStore, useWorkflowConsoleEntries } from './store'
|
||||
export type { ConsoleEntry, ConsoleUpdate } from './types'
|
||||
export { safeConsoleStringify } from './utils'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user