131 lines
4.0 KiB
TypeScript
131 lines
4.0 KiB
TypeScript
|
|
import { AutomationEventPublisherPort, AutomationEvent } from '../ports/AutomationEventPublisherPort';
|
|
import { AutomationLifecycleEmitterPort, LifecycleCallback } from '../ports/AutomationLifecycleEmitterPort';
|
|
import { LoggerPort } from '../ports/LoggerPort';
|
|
import type { IAsyncApplicationService } from '@core/shared/application';
|
|
|
|
type ConstructorArgs = {
|
|
lifecycleEmitter: AutomationLifecycleEmitterPort
|
|
publisher: AutomationEventPublisherPort
|
|
logger: LoggerPort
|
|
initialPanelWaitMs?: number
|
|
maxPanelRetries?: number
|
|
backoffFactor?: number
|
|
defaultTimeoutMs?: number
|
|
}
|
|
|
|
export class OverlaySyncService
|
|
implements OverlaySyncPort, IAsyncApplicationService<OverlayAction, ActionAck>
|
|
{
|
|
private lifecycleEmitter: AutomationLifecycleEmitterPort
|
|
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<ActionAck> {
|
|
return this.startAction(action)
|
|
}
|
|
|
|
async startAction(action: OverlayAction): Promise<ActionAck> {
|
|
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<ActionAck>((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<ActionAck>((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<void> {
|
|
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,
|
|
})
|
|
}
|
|
}
|
|
} |