import { assertApprovedActionPageSize, type ApprovedActionDispatchCursor, } from '../domain/approvedActionDispatchExecution'; import { assertApprovedActionRecoveryPageSize, type ApprovedActionRecoveryCursor, } from '../domain/approvedActionRecovery'; import type { ApprovedActionDispatchBatchSummary, ApprovedActionDispatcher, } from './approvedActionDispatcher'; import type { ApprovedActionRecoveryBatchSummary, ApprovedActionRecoveryReconciler, } from './approvedActionRecoveryReconciler'; export const MAX_APPROVED_ACTION_RUNTIME_PAGES_PER_PHASE = 64; export type ApprovedActionRuntimePhaseStopReason = | 'complete' | 'page_limit' | 'cursor_stalled'; export interface ApprovedActionDispatchCycleOptions { cursor?: ApprovedActionDispatchCursor; pageSize?: number; maxPages?: number; } export interface ApprovedActionRecoveryCycleOptions { cursor?: ApprovedActionRecoveryCursor; pageSize?: number; maxPages?: number; } export interface ApprovedActionRuntimeCycleOptions { dispatch?: ApprovedActionDispatchCycleOptions; recovery?: ApprovedActionRecoveryCycleOptions; } export interface ApprovedActionDispatchCycleSummary extends Omit { pages: number; stopReason: ApprovedActionRuntimePhaseStopReason; remaining: boolean; nextCursor?: Readonly; } export interface ApprovedActionRecoveryCycleSummary extends Omit { pages: number; stopReason: ApprovedActionRuntimePhaseStopReason; remaining: boolean; nextCursor?: Readonly; } export interface ApprovedActionRuntimeCycleSummary { recovery: Readonly; dispatch: Readonly; } function exactKeys(value: object, expected: readonly string[]): boolean { const keys = Object.keys(value).sort(); const canonical = [...expected].sort(); return ( keys.length === canonical.length && keys.every((key, index) => key === canonical[index]) ); } function assertOptionsObject( name: string, value: unknown, ): asserts value is object { if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new TypeError(`${name} must be an object`); } } function assertMaxPages(value: number): void { if ( !Number.isSafeInteger(value) || value < 1 || value > MAX_APPROVED_ACTION_RUNTIME_PAGES_PER_PHASE ) { throw new RangeError( 'maxPages must be between 1 and MAX_APPROVED_ACTION_RUNTIME_PAGES_PER_PHASE', ); } } function sameDispatchCursor( left: ApprovedActionDispatchCursor | undefined, right: Readonly, ): boolean { return ( left !== undefined && left.eligibleAtMs === right.eligibleAtMs && left.dispatchId === right.dispatchId ); } function sameRecoveryCursor( left: ApprovedActionRecoveryCursor | undefined, right: Readonly, ): boolean { return ( left !== undefined && left.nextScanAtMs === right.nextScanAtMs && left.dispatchId === right.dispatchId ); } function normalizeDispatchOptions( value: ApprovedActionDispatchCycleOptions = {}, ): Required> & Pick { assertOptionsObject('dispatch cycle options', value); const expectedKeys = [ ...(value.cursor === undefined ? [] : ['cursor']), ...(value.pageSize === undefined ? [] : ['pageSize']), ...(value.maxPages === undefined ? [] : ['maxPages']), ]; if (!exactKeys(value, expectedKeys)) { throw new TypeError('dispatch cycle options shape is invalid'); } const pageSize = value.pageSize ?? 16; const maxPages = value.maxPages ?? 4; assertApprovedActionPageSize(pageSize); assertMaxPages(maxPages); return { pageSize, maxPages, ...(value.cursor === undefined ? {} : { cursor: { ...value.cursor } }), }; } function normalizeRecoveryOptions( value: ApprovedActionRecoveryCycleOptions = {}, ): Required> & Pick { assertOptionsObject('recovery cycle options', value); const expectedKeys = [ ...(value.cursor === undefined ? [] : ['cursor']), ...(value.pageSize === undefined ? [] : ['pageSize']), ...(value.maxPages === undefined ? [] : ['maxPages']), ]; if (!exactKeys(value, expectedKeys)) { throw new TypeError('recovery cycle options shape is invalid'); } const pageSize = value.pageSize ?? 16; const maxPages = value.maxPages ?? 4; assertApprovedActionRecoveryPageSize(pageSize); assertMaxPages(maxPages); return { pageSize, maxPages, ...(value.cursor === undefined ? {} : { cursor: { ...value.cursor } }), }; } /** * Runs one bounded SQLite control-plane cycle. Recovery is deliberately first: * if the recovery index cannot be read, the cycle does not create more action * side effects. The class owns no timer and is safe to embed in other profiles. */ export class ApprovedActionRuntimeSupervisor { constructor( private readonly dispatcher: Pick< ApprovedActionDispatcher, 'dispatchBatch' >, private readonly reconciler: Pick< ApprovedActionRecoveryReconciler, 'reconcileBatch' >, ) {} async runCycle( options: ApprovedActionRuntimeCycleOptions = {}, ): Promise> { assertOptionsObject('approved action runtime options', options); const expectedKeys = [ ...(options.dispatch === undefined ? [] : ['dispatch']), ...(options.recovery === undefined ? [] : ['recovery']), ]; if (!exactKeys(options, expectedKeys)) { throw new TypeError('approved action runtime options shape is invalid'); } const recoveryOptions = normalizeRecoveryOptions(options.recovery); const dispatchOptions = normalizeDispatchOptions(options.dispatch); const recovery = await this.runRecovery(recoveryOptions); const dispatch = await this.runDispatch(dispatchOptions); return Object.freeze({ recovery, dispatch }); } private async runDispatch( options: ReturnType, ): Promise> { const total: ApprovedActionDispatchCycleSummary = { pages: 0, scanned: 0, claimed: 0, started: 0, succeeded: 0, failed: 0, blocked: 0, retrying: 0, deferred: 0, recoveryRequired: 0, alreadyTerminal: 0, unavailable: 0, stopReason: 'complete', remaining: false, }; let cursor = options.cursor; for (let pageNumber = 0; pageNumber < options.maxPages; pageNumber += 1) { const page = await this.dispatcher.dispatchBatch({ ...(cursor === undefined ? {} : { cursor }), limit: options.pageSize, }); total.pages += 1; total.scanned += page.scanned; total.claimed += page.claimed; total.started += page.started; total.succeeded += page.succeeded; total.failed += page.failed; total.blocked += page.blocked; total.retrying += page.retrying; total.deferred += page.deferred; total.recoveryRequired += page.recoveryRequired; total.alreadyTerminal += page.alreadyTerminal; total.unavailable += page.unavailable; if (!page.truncated) return Object.freeze(total); if (!page.nextCursor || sameDispatchCursor(cursor, page.nextCursor)) { total.stopReason = 'cursor_stalled'; total.remaining = true; if (page.nextCursor) total.nextCursor = { ...page.nextCursor }; return Object.freeze(total); } cursor = { ...page.nextCursor }; if (pageNumber === options.maxPages - 1) { total.stopReason = 'page_limit'; total.remaining = true; total.nextCursor = cursor; return Object.freeze(total); } } return Object.freeze(total); } private async runRecovery( options: ReturnType, ): Promise> { const total: ApprovedActionRecoveryCycleSummary = { pages: 0, scanned: 0, claimed: 0, verifiedSucceeded: 0, verifiedFailed: 0, deferred: 0, manualRequired: 0, executionActive: 0, alreadyResolved: 0, unavailable: 0, stopReason: 'complete', remaining: false, }; let cursor = options.cursor; for (let pageNumber = 0; pageNumber < options.maxPages; pageNumber += 1) { const page = await this.reconciler.reconcileBatch({ ...(cursor === undefined ? {} : { cursor }), limit: options.pageSize, }); total.pages += 1; total.scanned += page.scanned; total.claimed += page.claimed; total.verifiedSucceeded += page.verifiedSucceeded; total.verifiedFailed += page.verifiedFailed; total.deferred += page.deferred; total.manualRequired += page.manualRequired; total.executionActive += page.executionActive; total.alreadyResolved += page.alreadyResolved; total.unavailable += page.unavailable; if (!page.truncated) return Object.freeze(total); if (!page.nextCursor || sameRecoveryCursor(cursor, page.nextCursor)) { total.stopReason = 'cursor_stalled'; total.remaining = true; if (page.nextCursor) total.nextCursor = { ...page.nextCursor }; return Object.freeze(total); } cursor = { ...page.nextCursor }; if (pageNumber === options.maxPages - 1) { total.stopReason = 'page_limit'; total.remaining = true; total.nextCursor = cursor; return Object.freeze(total); } } return Object.freeze(total); } }