// flow-live-status — pushes the flow runner's live progress into the sim it // is driving, over the same ws bridge the steps execute on. the engine's // flow-run-store renders it in the shell devtools "test" tab and replies // with the pause intent, which the runner honors between steps. // // pushes are best-effort: an engine without the `flowStatus` handler (an // older shell) throws "unknown command type" once and the reporter disables // itself for the rest of the run. live status must never fail a test. import type { WsBridge } from './ws-bridge' export type LiveStepStatus = 'running' | 'success' | 'failure' | 'skipped' type PlanStep = { index: number; name: string; target?: string } const PAUSE_POLL_MS = 400 function replyPaused(reply: unknown) { if (typeof reply !== 'object' || reply == null || !('control' in reply)) return false const control = reply.control return ( typeof control === 'object' && control != null && 'paused' in control && control.paused === true ) } export class FlowLiveStatusReporter { private disabled = false private paused = false private lastSimId: string | undefined private plannedSteps: PlanStep[] | null = null private readonly completedSteps = new Map< number, { index: number; status: LiveStepStatus; durationMs?: number; error?: string } >() private readonly runId = `flr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}` constructor( private bridge: WsBridge, private flowName: string | null, private flowSource: string, private getSimId?: () => string | undefined, ) {} private async send( payload: Record, simId: string | undefined, ): Promise { const reply: unknown = await this.bridge.send({ type: 'flowStatus', simId, status: { runId: this.runId, flowName: this.flowName, ...payload }, }) this.paused = replyPaused(reply) return reply } private async push(payload: Record): Promise { if (this.disabled) return undefined try { const simId = this.getSimId?.() if ( simId !== this.lastSimId && this.lastSimId != null && this.plannedSteps && payload.phase !== 'plan' ) { await this.send( { phase: 'plan', state: 'running', steps: this.plannedSteps, source: this.flowSource, }, simId, ) for (const step of this.completedSteps.values()) { await this.send({ phase: 'step', state: 'running', step }, simId) } } this.lastSimId = simId return await this.send(payload, simId) } catch (error) { this.disabled = true console.log( `[flow] live status unavailable (${error instanceof Error ? error.message.slice(0, 120) : error}) — continuing without the devtools rail`, ) return undefined } } async plan(steps: PlanStep[], options: { dryRun?: boolean } = {}): Promise { this.plannedSteps = steps.map((step) => ({ ...step })) this.completedSteps.clear() await this.push({ phase: 'plan', state: options.dryRun ? 'idle' : 'running', dryRun: options.dryRun, steps, source: this.flowSource, }) } async waitForStart(): Promise { if (this.disabled) return while (!this.disabled) { await new Promise((resolve) => setTimeout(resolve, 200)) const reply = await this.push({ phase: 'heartbeat', state: 'idle' }) if ( reply && typeof reply === 'object' && 'control' in reply && typeof (reply as { control?: unknown }).control === 'object' ) { const ctrl = (reply as { control: { start?: boolean } }).control if (ctrl?.start === true) { return } } } } async waitForResetOrRerun(): Promise<'reset' | 'start' | 'exit'> { if (this.disabled) return 'exit' while (!this.disabled) { await new Promise((resolve) => setTimeout(resolve, 200)) const reply = await this.push({ phase: 'heartbeat' }) if ( reply && typeof reply === 'object' && 'control' in reply && typeof (reply as { control?: unknown }).control === 'object' ) { const ctrl = (reply as { control: { reset?: boolean; start?: boolean } }).control if (ctrl?.reset === true) { return 'reset' } if (ctrl?.start === true) { return 'start' } } } return 'exit' } async step( index: number, status: LiveStepStatus, extra?: { durationMs?: number; error?: string }, ): Promise { const step = { index, status, ...extra } await this.push({ phase: 'step', state: 'running', step, }) this.completedSteps.set(index, step) } async end(state: 'passed' | 'failed'): Promise { await this.push({ phase: 'end', state }) } // park while the devtools pause toggle is on. checked between top-level // steps only, so a pause never interrupts a step mid-gesture. async waitWhilePaused(): Promise { if (this.disabled) return const reply = await this.push({ phase: 'heartbeat' }) if (replyPaused(reply)) { this.paused = true } while (this.paused && !this.disabled) { console.log('[flow] paused from devtools — waiting…') while (this.paused && !this.disabled) { await new Promise((resolve) => setTimeout(resolve, 200)) await this.push({ phase: 'heartbeat', state: 'paused' }) } if (!this.disabled) console.log('[flow] resumed') } } }