import { IOverlaySyncPort, OverlayAction, ActionAck } from '../ports/IOverlaySyncPort' import { IAutomationEventPublisher, AutomationEvent } from '../ports/IAutomationEventPublisher' import { IAutomationLifecycleEmitter, LifecycleCallback } from '../../infrastructure/adapters/IAutomationLifecycleEmitter' import { ILogger } from '../ports/ILogger' type ConstructorArgs = { lifecycleEmitter: IAutomationLifecycleEmitter publisher: IAutomationEventPublisher logger: ILogger initialPanelWaitMs?: number maxPanelRetries?: number backoffFactor?: number defaultTimeoutMs?: number } export class OverlaySyncService implements IOverlaySyncPort { private lifecycleEmitter: IAutomationLifecycleEmitter private publisher: IAutomationEventPublisher private logger: ILogger private initialPanelWaitMs: number private maxPanelRetries: number private backoffFactor: number private defaultTimeoutMs: number constructor(args: ConstructorArgs) { this.lifecycleEmitter = args.lifecycleEmitter this.publisher = args.publisher this.logger = args.logger this.initialPanelWaitMs = args.initialPanelWaitMs ?? 500 this.maxPanelRetries = args.maxPanelRetries ?? 3 this.backoffFactor = args.backoffFactor ?? 2 this.defaultTimeoutMs = args.defaultTimeoutMs ?? 5000 } async startAction(action: OverlayAction): Promise { const timeoutMs = action.timeoutMs ?? this.defaultTimeoutMs const seenEvents: AutomationEvent[] = [] let settled = false const cb: LifecycleCallback = async (ev) => { seenEvents.push(ev) if (ev.type === 'action-started' && ev.actionId === action.id) { if (!settled) { settled = true cleanup() resolveAck({ id: action.id, status: 'confirmed' }) } } } const cleanup = () => { try { this.lifecycleEmitter.offLifecycle(cb) } catch { // ignore } } let resolveAck: (ack: ActionAck) => void = () => {} const promise = new Promise((resolve) => { resolveAck = resolve try { this.lifecycleEmitter.onLifecycle(cb) } catch (e) { const error = e instanceof Error ? e : new Error(String(e)) this.logger?.error?.('OverlaySyncService: failed to subscribe to lifecycleEmitter', error, { actionId: action.id, }) } }) try { void this.publisher.publish({ type: 'modal-opened', timestamp: Date.now(), payload: { actionId: action.id, label: action.label }, actionId: action.id, } as AutomationEvent) } catch (e) { const error = e instanceof Error ? e : new Error(String(e)) this.logger?.warn?.('OverlaySyncService: publisher.publish failed', { actionId: action.id, error, }) } const timeoutPromise = new Promise((res) => { setTimeout(() => { if (!settled) { settled = true cleanup() this.logger?.info?.('OverlaySyncService: timeout waiting for confirmation', { actionId: action.id, timeoutMs, }) const lastEvents = seenEvents.slice(-10) this.logger?.debug?.('OverlaySyncService: recent lifecycle events', { actionId: action.id, events: lastEvents, }) res({ id: action.id, status: 'tentative', reason: 'timeout' }) } }, timeoutMs) }) return Promise.race([promise, timeoutPromise]) } async cancelAction(actionId: string): Promise { try { await this.publisher.publish({ type: 'panel-missing', timestamp: Date.now(), actionId, } as AutomationEvent) } catch (e) { const error = e instanceof Error ? e : new Error(String(e)) this.logger?.warn?.('OverlaySyncService: cancelAction publish failed', { actionId, error, }) } } }