import { OverlaySyncPort, OverlayAction, ActionAck } from '../ports/OverlaySyncPort'; import { AutomationEventPublisherPort, AutomationEvent } from '../ports/AutomationEventPublisherPort'; import { IAutomationLifecycleEmitter, LifecycleCallback } from '../../infrastructure/adapters/IAutomationLifecycleEmitter'; import { LoggerPort } from '../ports/LoggerPort'; import type { IAsyncApplicationService } from '@gridpilot/shared/application'; type ConstructorArgs = { lifecycleEmitter: IAutomationLifecycleEmitter publisher: AutomationEventPublisherPort logger: LoggerPort initialPanelWaitMs?: number maxPanelRetries?: number backoffFactor?: number defaultTimeoutMs?: number } export class OverlaySyncService implements OverlaySyncPort, IAsyncApplicationService { private lifecycleEmitter: IAutomationLifecycleEmitter private publisher: AutomationEventPublisherPort private logger: LoggerPort 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 execute(action: OverlayAction): Promise { return this.startAction(action) } 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, }) } } }