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

321 lines
9.3 KiB
TypeScript

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()
}