import { isXgridsActiveStreamRecovery, type XgridsActiveStreamRecovery, type XgridsK1State, } from "./api"; export interface ActiveStreamRecoveryLineage { snapshotRuntimeId: string; acquisitionId: string; acquisitionStateRevision: number; recoveryGeneration: number; runtimeProducerGeneration: number; recovery: XgridsActiveStreamRecovery; } export type ActiveStreamForceFinishAuthority = ActiveStreamRecoveryLineage; export type ActiveStreamRecoveryPresentationAuthority = ActiveStreamRecoveryLineage; export type ActiveStreamRecoveryVisibleState = | "reconnecting" | "blocked" | "standby" | "fault"; export interface ActiveStreamRecoveryPresentation { state: ActiveStreamRecoveryVisibleState; eyebrow: string; title: string; statusLabel: string; tone: "neutral" | "warning" | "danger"; detail: string; progressLabel: string | null; showSpinner: boolean; forceFinishAvailable: boolean; } function positiveInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) > 0; } /** * Resolve one exact active-stream lineage from the public runtime snapshot. * * A recovery-shaped object alone is not authority. The browser also requires * the current runtime id, the same acquisition id and the exact producer * generation on both sides of the projection. This keeps a late recovery * update from an older producer out of both presentation and mutation gates. */ export function exactActiveStreamRecoveryLineage( state: XgridsK1State | null | undefined, ): ActiveStreamRecoveryLineage | null { const recovery = state?.connection_recovery; const acquisition = state?.acquisition; const snapshotRuntimeId = state?.snapshot_runtime_id?.trim() || null; const producerGeneration = state?.producer_generation; const acquisitionId = acquisition?.acquisition_id?.trim() || null; const recoveryAcquisitionId = recovery?.acquisition_id?.trim() || null; if ( !snapshotRuntimeId || !isXgridsActiveStreamRecovery(recovery) || !acquisition || !acquisitionId || recoveryAcquisitionId !== acquisitionId || !positiveInteger(acquisition.state_revision) || !positiveInteger(recovery.generation) || !positiveInteger(producerGeneration) || recovery.runtime_producer_generation !== producerGeneration || recovery.automatic_read_only_rebind !== true ) return null; return { snapshotRuntimeId, acquisitionId, acquisitionStateRevision: acquisition.state_revision, recoveryGeneration: recovery.generation, runtimeProducerGeneration: producerGeneration, recovery, }; } /** Exact, current and backend-policy-admitted authority for local-only finish. */ export function activeStreamForceFinishAuthority( state: XgridsK1State | null | undefined, ): ActiveStreamForceFinishAuthority | null { const lineage = exactActiveStreamRecoveryLineage(state); if ( !lineage || !["reconnecting", "blocked"].includes(lineage.recovery.state) || lineage.recovery.force_finish_allowed !== true || state?.phase !== "reconnecting" || state.source_mode !== "live" || ![ "starting", "awaiting_external_start", "acquiring", ].includes(state.acquisition?.state ?? "") ) return null; return lineage; } /** * Exact authority for retaining browser presentation while the backend owns a * read-only reconnect. This is deliberately narrower than the recovery card: * terminal/blocked recovery states and an inactive acquisition cannot retain * a prior spatial or camera transport. */ export function activeStreamRecoveryPresentationAuthority( state: XgridsK1State | null | undefined, ): ActiveStreamRecoveryPresentationAuthority | null { const lineage = exactActiveStreamRecoveryLineage(state); if ( !lineage || lineage.recovery.state !== "reconnecting" || state?.phase !== "reconnecting" || state.source_mode !== "live" || ![ "starting", "awaiting_external_start", "acquiring", ].includes(state.acquisition?.state ?? "") ) return null; return lineage; } /** * Keep the exact recovered lineage available to disposable browser receivers * after the recovery card has disappeared. Spatial admission can complete on * the first authoritative PCL before the acquisition-owned camera produces * its first playable frame. This authority carries only the no-write * presentation lease: it grants neither force-finish nor START/STOP policy. */ export function activeStreamRecoveredBrowserAuthority( state: XgridsK1State | null | undefined, ): ActiveStreamRecoveryPresentationAuthority | null { const lineage = exactActiveStreamRecoveryLineage(state); if ( !lineage || lineage.recovery.state !== "recovered" || lineage.recovery.camera_recovery !== "owned" || state?.phase !== "live" || state.source_mode !== "live" || state.acquisition?.state !== "acquiring" ) return null; return lineage; } /** * While a validated recovery contract is active it owns the presentation * decision. Ordinary supervisor data flags may be stale across the network * gap, so only an exact reconnect lease can retain browser transports. */ export function activeStreamRecoveryOwnsPresentationDecision( state: XgridsK1State | null | undefined, ): boolean { const recovery = state?.connection_recovery; return Boolean( isXgridsActiveStreamRecovery(recovery) && !["inactive", "recovered"].includes(recovery.state), ); } export function activeStreamForceFinishAuthorityMatches( expected: ActiveStreamForceFinishAuthority, state: XgridsK1State | null | undefined, ): boolean { const current = activeStreamForceFinishAuthority(state); return Boolean( current && current.snapshotRuntimeId === expected.snapshotRuntimeId && current.acquisitionId === expected.acquisitionId && current.acquisitionStateRevision === expected.acquisitionStateRevision && current.recoveryGeneration === expected.recoveryGeneration && current.runtimeProducerGeneration === expected.runtimeProducerGeneration, ); } export function formatActiveStreamRecoveryElapsed( elapsedMs: number | null, ): string | null { if (!Number.isFinite(elapsedMs) || elapsedMs === null || elapsedMs < 0) return null; const elapsedSeconds = Math.floor(elapsedMs / 1_000); if (elapsedSeconds < 60) return `${elapsedSeconds} с`; const minutes = Math.floor(elapsedSeconds / 60); const seconds = elapsedSeconds % 60; return seconds > 0 ? `${minutes} мин ${seconds} с` : `${minutes} мин`; } function recoveryProgressLabel( recovery: XgridsActiveStreamRecovery, ): string | null { const elapsed = formatActiveStreamRecoveryElapsed(recovery.elapsed_ms); const attempt = recovery.attempt > 0 ? `Попытка ${recovery.attempt}` : "Подготовка проверки"; return elapsed ? `${attempt} · ${elapsed}` : attempt; } /** * Present only an exact current lineage. `recovered` deliberately returns * null so the ordinary confirmed live UI resumes without a transitional card. */ export function activeStreamRecoveryPresentation( state: XgridsK1State | null | undefined, ): ActiveStreamRecoveryPresentation | null { const lineage = exactActiveStreamRecoveryLineage(state); if (!lineage) return null; const recovery = lineage.recovery; if (recovery.state === "reconnecting") { return { state: "reconnecting", eyebrow: "СВЯЗЬ · АКТИВНЫЙ ПРИЁМ", title: "Восстанавливаем соединение", statusLabel: "Восстановление связи", tone: "neutral", detail: "Проверка прежнего активного контура только для чтения. START, STOP и настройки сети не отправляются.", progressLabel: recoveryProgressLabel(recovery), showSpinner: true, forceFinishAvailable: activeStreamForceFinishAuthority(state) !== null, }; } if (recovery.state === "blocked") { return { state: "blocked", eyebrow: "СВЯЗЬ · ТРЕБУЕТСЯ ДЕЙСТВИЕ", title: recovery.camera_recovery === "blocked" ? "Видеопоток не восстановлен" : "Связь не восстановлена", statusLabel: "Восстановление остановлено", tone: "warning", detail: recovery.camera_recovery === "blocked" ? "Связь с K1 проверена, но камера не возобновила передачу. Можно завершить только локальный приём." : "Автоматическая проверка остановлена. Можно завершить только локальный приём; команда устройству не отправится.", progressLabel: recoveryProgressLabel(recovery), showSpinner: false, forceFinishAvailable: activeStreamForceFinishAuthority(state) !== null, }; } if (recovery.state === "standby") { return { state: "standby", eyebrow: "СВЯЗЬ · СОСТОЯНИЕ ПРОВЕРЕНО", title: "Устройство перешло в ожидание", statusLabel: "Приём завершён", tone: "neutral", detail: "K1 сообщил, что активное сканирование уже завершено. Локальный приём закрывается без команды STOP.", progressLabel: recoveryProgressLabel(recovery), showSpinner: false, forceFinishAvailable: false, }; } if (recovery.state === "fault") { return { state: "fault", eyebrow: "СВЯЗЬ · СОСТОЯНИЕ ПРОВЕРЕНО", title: "K1 сообщил об ошибке", statusLabel: "Восстановление невозможно", tone: "danger", detail: "Безопасная проверка обнаружила ошибку устройства. Автоматических команд и повторов нет.", progressLabel: recoveryProgressLabel(recovery), showSpinner: false, forceFinishAvailable: false, }; } return null; } /** * Only an exact, still-active background reconnect may hide the generic red * error banner. A failed explicit local finish is operator-facing evidence and * must remain visible even while the last accepted snapshot says reconnecting. */ export function suppressGenericErrorDuringActiveStreamRecovery( state: XgridsK1State | null | undefined, errorAction?: string | null, ): boolean { if (errorAction === "force-finish") return false; return activeStreamRecoveryPresentationAuthority(state) !== null; }