move automation out of core

This commit is contained in:
2025-12-16 14:31:43 +01:00
parent 29dc11deb9
commit 29410708c8
145 changed files with 378 additions and 1532 deletions

View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from 'vitest'
import { OverlayAction } from 'apps/companion/main/automation/application/ports/IOverlaySyncPort'
import { IAutomationLifecycleEmitter, LifecycleCallback } from '@core/automation/infrastructure//IAutomationLifecycleEmitter'
import { OverlaySyncService } from 'apps/companion/main/automation/application/services/OverlaySyncService'
class MockLifecycleEmitter implements IAutomationLifecycleEmitter {
private callbacks: Set<LifecycleCallback> = new Set()
onLifecycle(cb: LifecycleCallback): void {
this.callbacks.add(cb)
}
offLifecycle(cb: LifecycleCallback): void {
this.callbacks.delete(cb)
}
async emit(event: AutomationEvent) {
for (const cb of Array.from(this.callbacks)) {
// fire without awaiting to simulate async emitter
cb(event)
}
}
}
describe('OverlaySyncService (unit)', () => {
test('startAction resolves as confirmed only after action-started event is emitted', async () => {
const emitter = new MockLifecycleEmitter()
// create service wiring: pass emitter as dependency (constructor shape expected)
const svc = new OverlaySyncService({
lifecycleEmitter: emitter,
logger: console as unknown,
publisher: { publish: async () => {} },
})
const action: OverlayAction = { id: 'add-car', label: 'Adding...' }
// start the action but don't emit event yet
const promise = svc.startAction(action)
// wait a small tick to ensure promise hasn't resolved prematurely
await new Promise((r) => setTimeout(r, 10))
let resolved = false
promise.then(() => (resolved = true))
expect(resolved).toBe(false)
// now emit action-started
await emitter.emit({ type: 'action-started', actionId: 'add-car', timestamp: Date.now() })
const ack = await promise
expect(ack.status).toBe('confirmed')
expect(ack.id).toBe('add-car')
})
})

View File

@@ -0,0 +1,133 @@
import { AutomationEventPublisherPort, AutomationEvent } from '../ports/AutomationEventPublisherPort';
import { AutomationLifecycleEmitterPort, LifecycleCallback } from '../ports/AutomationLifecycleEmitterPort';
import { LoggerPort } from '../ports/LoggerPort';
import type { IAsyncApplicationService } from '@core/shared/application';
import { OverlayAction, OverlaySyncPort } from '../ports/OverlaySyncPort';
import { ActionAck } from '../ports/IOverlaySyncPort';
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,
})
}
}
}