wip(k1): checkpoint connection recovery rewrite
Capture the current unreleased K1 connection, recovery, lifecycle, viewer, and test work as a single known-bad baseline for subsequent fixes.
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user