chore(k1): checkpoint connection recovery work

This commit is contained in:
DCCONSTRUCTIONS
2026-08-21 08:12:22 +03:00
parent a3138f3d71
commit c7843a3c7e
22 changed files with 969 additions and 293 deletions
@@ -27,6 +27,7 @@ import {
recoverableAcquisition,
requiresCanonicalStopAfterTerminalLocalFailure,
requiresReadOnlyPhysicalRecovery,
savedBridgeRequiresNetworkSetup,
sourceStatusLabel,
} from "./lifecycle";
import { phaseLabel, phaseTone } from "./presentation";
@@ -34,6 +35,7 @@ import {
useXgridsK1Controller,
type XgridsK1Controller,
} from "./runtimeContext";
import type { PendingAction } from "./useXgridsK1Runtime";
import type { XgridsK1State } from "./api";
import {
DEFAULT_CONNECTION_MODE,
@@ -59,6 +61,9 @@ export function physicalRecoveryConnectionDetail(
state: XgridsK1State | null | undefined,
): string | null {
if (!requiresReadOnlyPhysicalRecovery(state)) return null;
if (savedBridgeRequiresNetworkSetup(state)) {
return "K1 ответил по Bluetooth, но не подключён к сохранённой общей сети. Настройки устройства не изменялись; подключите K1 к общей сети заново.";
}
const retirementAvailable = Boolean(
unavailablePhysicalRetirementAuthority(state),
);
@@ -91,9 +96,11 @@ function connectionPhaseFallbackLabel(phase: string | null | undefined): string
*/
export function shouldRenderK1OperationalPanels(
state: XgridsK1State | null | undefined,
pendingAction: PendingAction | null = null,
): boolean {
return Boolean(
hasControlAuthority(state)
pendingAction === "live"
|| hasControlAuthority(state)
|| state?.source_mode === "live"
|| state?.source_mode === "replay"
|| recoverableAcquisition(state)
@@ -167,11 +174,13 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
const confirmedLive = isConfirmedLiveState(state);
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
const livePreparationPending = controller.pendingAction === "live";
const preparedAcquisition = recoverableAcquisition(state)?.state === "prepared";
const activeRecoveryPresentation = activeStreamRecoveryPresentation(state);
const sourceLabel = activeRecoveryPresentation?.title ?? sourceStatusLabel(state);
const releasedAcquisitionFailure = isReleasedTerminalAcquisitionFailure(state);
const physicalRecoveryRequired = requiresReadOnlyPhysicalRecovery(state);
const savedBridgeNetworkSetupRequired = savedBridgeRequiresNetworkSetup(state);
const physicalStopRecoverySettling = isPhysicalStopRecoverySettling(state);
const recoveredPhysicalScanning = physicalRecoveryRequired
&& state?.application_control_session?.state === "scanning"
@@ -265,7 +274,9 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
: sourceRuntimeBusy || preparedAcquisition
? "warning"
: "neutral";
const connectionPhaseLabel = sourceRuntimeBusy || preparedAcquisition
const connectionPhaseLabel = livePreparationPending
? "Подготовка приёма"
: sourceRuntimeBusy || preparedAcquisition
? sourceLabel
: activeRecoveryPresentation
? activeRecoveryPresentation.title
@@ -274,7 +285,9 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
: recoveredPhysicalScanning
? "Сканирование продолжается"
: physicalRecoveryRequired
? "Требуется действие"
? savedBridgeNetworkSetupRequired
? "Нужна настройка сети"
: "Требуется действие"
: projectedPhase === "error"
? connectionPhaseFallbackLabel(projectedPhase)
: connectionTopology?.status === "active"
@@ -288,7 +301,9 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
: connectionTopology?.source === "last-known"
? "Подключение отсутствует"
: connectionPhaseFallbackLabel(projectedPhase);
const connectionPhaseTone = sourceRuntimeBusy || preparedAcquisition
const connectionPhaseTone = livePreparationPending
? "warning"
: sourceRuntimeBusy || preparedAcquisition
? sourceTone
: activeRecoveryPresentation
? activeRecoveryPresentation.tone
@@ -301,7 +316,9 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
: connectionTopology?.status === "configured-unverified"
? "neutral"
: "neutral";
const connectionPhaseDetail = physicalStopRecoverySettling
const connectionPhaseDetail = livePreparationPending
? "Подготовка продолжается."
: physicalStopRecoverySettling
? "Команда остановки уже принята. Завершение выполняется без повторной команды."
: activeRecoveryPresentation
? activeRecoveryPresentation.detail
@@ -315,7 +332,10 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
: !sourceRuntimeBusy && connectionTopology?.status === "active"
? "Готово к новой сессии."
: "Ожидается состояние локального контура.";
const operationalPanelsVisible = shouldRenderK1OperationalPanels(state);
const operationalPanelsVisible = shouldRenderK1OperationalPanels(
state,
controller.pendingAction,
);
return (
<div className="device-workspace xgrids-k1-plugin">
@@ -293,9 +293,6 @@ export function K1AcquisitionPipeline({
projectNameValidation.value,
);
const physicalStartAllowed = connectionPolicyAllows(state, "start-acquisition");
const physicalStartGuidance = finalStartTarget && !physicalStartAllowed
? connectionPolicyOperatorGuidance(state, "start-acquisition")
: null;
const physicalStopGuidance = gracefulStopTarget
&& !physicalStopPresented
&& !terminalPhysicalStopObserved
@@ -317,8 +314,9 @@ export function K1AcquisitionPipeline({
? "Команда устройству недоступна в текущем подтверждённом состоянии. Завершите только локальный приём или выполните read-only восстановление."
: null;
const submitFinalStart = async () => {
const physicalAcceptance = operatorActionPhysicalAcceptance();
const submitFinalStart = async (
physicalAcceptance = operatorActionPhysicalAcceptance(),
) => {
await runAutomaticSpatialSourceStart(
() => startPreparedAcquisition(physicalAcceptance),
activateAutomaticSpatialSource,
@@ -342,6 +340,7 @@ export function K1AcquisitionPipeline({
return;
}
if (!draftPreparationTarget) return;
const physicalAcceptance = operatorActionPhysicalAcceptance();
const prepared = await prepareCanonicalAcquisition({
acquisition: {
project_name: projectNameValidation.value,
@@ -349,9 +348,10 @@ export function K1AcquisitionPipeline({
gnss_mode: SUPPORTED_GNSS_MODE,
compatibility_attestation: profileSelectionForConnectionMode(connectionMode),
},
physicalAcceptance,
});
if (!prepared) return;
await submitFinalStart();
await submitFinalStart(physicalAcceptance);
};
const submitReplay = async () => {
@@ -458,7 +458,6 @@ export function K1AcquisitionPipeline({
</div>
<TextField
label="Название проекта"
hint="Имя войдёт в единственный канонический START"
value={projectName}
onChange={(event) => {
setProjectName(event.target.value);
@@ -470,7 +469,7 @@ export function K1AcquisitionPipeline({
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
description={projectNameTouched && projectNameValidation.error
? projectNameValidation.error
: "Имя отправляется только при START; отдельной команды сохранения нет."}
: undefined}
placeholder="Например, TEST001"
/>
<Button
@@ -507,15 +506,6 @@ export function K1AcquisitionPipeline({
? "Продолжить запуск"
: "Запустить приём"}
</Button>
<p className="start-confirmation-note">
{modeSwitchRequired
? `Выбран другой способ связи. Сначала установите подключение через ${desiredConnectionMode === "bridge" ? "Bridge" : desiredConnectionMode === "quick-connect" ? "Quick Connect" : "Direct Connect"}.`
: !connectionConfigured
? "Сначала завершите подключение в выбранном режиме. START не используется для установки связи."
: physicalStartGuidance
? `${physicalStartGuidance.reason} ${physicalStartGuidance.nextAction}`
: "Одно нажатие выполняет каноническую подготовку и один START после подтверждённого READY. Автоматических повторов команд нет."}
</p>
{control?.control_socket_open && !activeAcquisition && !recoveredPhysicalStop && !isBusy ? (
<Button
variant="ghost"
@@ -525,21 +515,6 @@ export function K1AcquisitionPipeline({
Отменить запуск до START
</Button>
) : null}
<p className="live-instruction">
{controlPhase === "failed"
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручное действие"}`
: controlPhase === "connecting"
? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ."
: controlPhase === "workspace-requested"
? "После подтверждённых операций 1–6 выполняется вход в рабочее пространство."
: controlPhase === "project-requested"
? "Выполняются операции 8–10 и готовится локальный приём; имя ещё не отправляется."
: controlPhase === "start-requested" || controlPhase === "initializing"
? "Калибровка оборудования. Не перемещайте сканер; временных переходов и повторных команд нет."
: controlPhase === "scanning"
? "Режим сканирования и инициализация подтверждены. Остановка доступна в пространственной сцене."
: "Одна кнопка запускает весь процесс. Совместимость подключения подтверждается автоматически; каждый следующий этап начинается только после подтверждения результата."}
</p>
</div>
) : (
<div className="session-form session-form--replay">
@@ -59,6 +59,7 @@ import {
requiresReadOnlyPhysicalRecovery,
reopenedPhysicalReconciliationMatches,
retiredPhysicalReopenAuthority,
savedBridgeRequiresNetworkSetup,
serverBoundAppliedNetworkObservationTarget,
transportRefEquivalenceKey,
trustedConnectionBinding,
@@ -358,12 +359,11 @@ export function connectionAttemptOwnsAppliedNetworkRecovery(
|| attempt.phase !== "network_applied"
|| attempt.control_state === "ready"
) return false;
if (["accepted", "running"].includes(attempt.status)) return true;
return [
"continue-with-control-verification",
"verify-control-read-only",
"manual-recovery-required",
].includes(attempt.safe_next_action);
// Only an operation that is still executing owns the screen. A terminal
// network-applied record is audit history: the backend may continue to use
// it as a command fence, but it must never replace the operator's two normal
// choices (verify the saved K1 or start a fresh Bluetooth connection).
return ["accepted", "running"].includes(attempt.status);
}
export async function dispatchUnavailablePhysicalRetirementForCurrentRuntime<T>(
@@ -1537,6 +1537,7 @@ export function K1ProvisioningPipeline({
typeof isSnapshotRuntimeCurrent !== "function"
|| runtimeActionIsCurrent(currentRuntimeActionFence)
) ? pendingAction : null;
const livePreparationPending = pendingAction === "live";
const reconfiguration = activeConnectionReconfiguration(state);
const reconfigurationRevision = state?.connection_reconfiguration?.revision ?? 0;
const reconfigurationIntentId = reconfiguration?.intent_id ?? null;
@@ -1660,7 +1661,8 @@ export function K1ProvisioningPipeline({
const physicalRecoveryVerificationPending =
currentReadOnlyReconnectPresentation?.kind === "physical";
const provisioningMutationBusy = Boolean(
presentedPendingAction
livePreparationPending
|| presentedPendingAction
|| modeResetPending !== null
|| currentPreparingReconfigurationRequest
|| searchDisplayActive
@@ -1731,6 +1733,11 @@ export function K1ProvisioningPipeline({
const appliedNetworkAttempt = state?.connection_attempt?.phase === "network_applied"
? state.connection_attempt
: null;
const terminalAppliedRecoveryAttempt = appliedNetworkAttempt
&& appliedNetworkAttempt.control_state !== "ready"
&& !["accepted", "running"].includes(appliedNetworkAttempt.status)
? appliedNetworkAttempt
: null;
const correlatedConnectionAttempt = connectionAttemptForRuntimeError(
errorCorrelation,
state,
@@ -1741,9 +1748,11 @@ export function K1ProvisioningPipeline({
? state.connection_attempt
: null;
const connectionRecoveryAttempt = correlatedConnectionAttempt
?? unknownNetworkOutcomeAttempt;
?? unknownNetworkOutcomeAttempt
?? terminalAppliedRecoveryAttempt;
const connectionRecoveryObservationAllowed = Boolean(
!connectionRecoveryAttempt
|| connectionRecoveryAttempt.phase === "network_applied"
|| [
"continue-with-control-verification",
"verify-control-read-only",
@@ -1780,15 +1789,14 @@ export function K1ProvisioningPipeline({
const physicalRecoveryRequired = requiresReadOnlyPhysicalRecovery(state);
const networkRecoveryRequired = unresolvedAppliedAttempt !== null
&& !physicalRecoveryRequired;
// Scan keeps the reset-owned clean draft intact, while the first later
// Connect/Verify operation releases it. This hides only pre-reset saved
// history and never masks a new post-reset failure.
// A settled reset owns the clean new-device draft, but it must not erase the
// separately durable fast-reconnect offer. The operator can either verify
// the last confirmed K1 without writes or start a fresh Bluetooth search.
const scenarioResetOwnsCleanDraft = scenarioResetOwnsCleanConnectionDraft(
state,
connectionMode,
);
const connectionRecoveryTarget = !scenarioResetOwnsCleanDraft
&& !physicalRecoveryRequired
const connectionRecoveryTarget = !physicalRecoveryRequired
&& !unresolvedAppliedAttempt
&& !selectedModeConnected
&& !reconfigurationActive
@@ -1805,10 +1813,12 @@ export function K1ProvisioningPipeline({
connectionRecoveryKey,
escapedConnectionRecoveryKey,
) && !(searchRequested && !searchDisplayActive);
const presentedConnectionRecoveryRequired = connectionRecoveryRequired
|| connectionRecoveryVerificationPending;
const presentedConnectionRecoveryRequired = !livePreparationPending && (
connectionRecoveryRequired || connectionRecoveryVerificationPending
);
const presentedPhysicalRecoveryRequired = physicalRecoveryRequired
|| physicalRecoveryVerificationPending;
const savedBridgeNetworkSetupRequired = savedBridgeRequiresNetworkSetup(state);
const appliedControlSettlementPending = Boolean(
unresolvedAppliedAttempt
&& ["accepted", "running"].includes(unresolvedAppliedAttempt.status)
@@ -1893,10 +1903,12 @@ export function K1ProvisioningPipeline({
&& !physicalStopRecoverySettling,
);
const trustedBinding = trustedConnectionBinding(state);
const connectedEndpoint = selectedModeConnected
const connectionPresentationEstablished = selectedModeConnected
|| livePreparationPending;
const connectedEndpoint = connectionPresentationEstablished
? selectedModeTopology?.endpoint?.trim() || null
: null;
const connectedDeviceIdentity = selectedModeConnected
const connectedDeviceIdentity = connectionPresentationEstablished
? selectedTarget?.label
|| retainedSessionLabel
|| (trustedBinding?.connectionMode === connectionMode
@@ -1951,7 +1963,7 @@ export function K1ProvisioningPipeline({
!presentedPhysicalRecoveryRequired
&& !presentedConnectionRecoveryRequired
&& (
selectedModeConnected
connectionPresentationEstablished
|| explicitProvisioningDraftRetained
|| explicitProvisioningDraftContextRetained
|| unresolvedAppliedAttempt
@@ -2619,10 +2631,13 @@ export function K1ProvisioningPipeline({
) return;
if (!dispatched.result.succeeded) {
setCandidateUnavailableMessage(
"Не удалось переподключиться к прежнему K1. Проверьте питание и сеть, затем повторите проверку.",
dispatched.result.reasonCode === "connection-verify-address-unavailable"
? "K1 ответил по Bluetooth, но не подключён к сохранённой общей сети. Старый Bridge использовать нельзя; подключите K1 к общей сети заново — настройки устройства не менялись."
: "Не удалось переподключиться к прежнему K1. Проверьте питание и сеть, затем повторите проверку.",
);
return;
}
clearError();
setCandidateUnavailableMessage(null);
} finally {
setReadOnlyReconnectPresentation((current) => (
@@ -2699,14 +2714,20 @@ export function K1ProvisioningPipeline({
const actionFence = activateRuntimeActionFence(modeAuthority);
if (!actionFence || !runtimeActionIsCurrent(actionFence)) return;
try {
const result = await verifyConnection(request);
const result = await verifyConnection(request, { surfaceErrors: false });
if (!runtimeClickIsCurrent(actionFence)) return;
if (!result.succeeded) {
setCandidateUnavailableMessage(
"Не удалось переподключиться к прежнему K1. Проверьте питание и сеть, затем повторите проверку.",
result.reasonCode === "connection-verify-address-unavailable"
? "K1 ответил по Bluetooth, но не подключён к сохранённой общей сети. Старый Bridge использовать нельзя; подключите K1 к общей сети заново — настройки устройства не менялись."
: "Не удалось переподключиться к прежнему K1. Проверьте питание и сеть, затем повторите проверку.",
);
return;
}
// A successful authoritative Verify supersedes any earlier local
// connection banner, including a failure that settled immediately
// before the recovered state reached this browser.
clearError();
if (
result.observedState
&& isRecoveredPhysicalScanning(
@@ -2882,7 +2903,7 @@ export function K1ProvisioningPipeline({
// Only an explicit public search owns the Step-1 Bluetooth loader.
const searchActive = searchDisplayActive;
const connectionEstablished = selectedModeConnected;
const connectionEstablished = connectionPresentationEstablished;
const connectionAttemptOwnsDraft = connectionAttemptSettling
|| (
connectionAttemptFailed
@@ -3103,19 +3124,23 @@ export function K1ProvisioningPipeline({
: "Переподключиться"}
</Button>
) : null}
<Button
width="full"
variant={
connectionRecoveryTarget || connectionRecoveryVerificationPending
? "secondary"
: "primary"
}
icon={<Icon name="search" />}
disabled={isBusy || modeResetInFlight}
onClick={() => void changeDesiredConnectionMode(connectionMode)}
>
Подключить новый K1
</Button>
{!scenarioResetOwnsCleanDraft ? (
<Button
width="full"
variant={
connectionRecoveryTarget || connectionRecoveryVerificationPending
? "secondary"
: "primary"
}
icon={<Icon name="search" />}
disabled={isBusy || modeResetInFlight}
onClick={() => void changeDesiredConnectionMode(connectionMode)}
>
{savedBridgeNetworkSetupRequired
? "Подключить K1 к общей сети"
: "Подключить новый K1"}
</Button>
) : null}
</>
) : null;
@@ -3164,7 +3189,9 @@ export function K1ProvisioningPipeline({
: presentedPhysicalRecoveryRequired
? verificationActionPending
? "Переподключение…"
: "Нужно выбрать действие"
: savedBridgeNetworkSetupRequired
? "Нужна настройка сети"
: "Нужно выбрать действие"
: networkRecoveryRequired
? appliedControlSettlementPending
? "Подтверждение управления"
@@ -3173,8 +3200,8 @@ export function K1ProvisioningPipeline({
? connectionReconnectPending
? "Переподключение…"
: connectionRecoveryTarget
? "Нужна проверка"
: "Нужен новый поиск"
? "Можно переподключиться"
: "Подключить заново"
: connectionEstablished
? "Подключение установлено"
: explicitProvisioningDraftStale
@@ -3200,7 +3227,6 @@ export function K1ProvisioningPipeline({
? "success"
: networkRecoveryRequired
|| presentedPhysicalRecoveryRequired
|| presentedConnectionRecoveryRequired
|| explicitProvisioningDraftStale
? "warning"
: "neutral"
@@ -3239,15 +3265,20 @@ export function K1ProvisioningPipeline({
aria-busy={physicalReconnectPending || undefined}
>
<div className="connection-summary" role="status">
<span>Прежнее подключение не подтверждено</span>
<span>
{savedBridgeNetworkSetupRequired
? "K1 не подключён к сохранённой общей сети"
: "Прежнее подключение не подтверждено"}
</span>
<strong>{physicalRecoveryModeLabel}</strong>
<small>
{presentedPhysicalRecoveryDeviceId ?? "Прежний K1"}
</small>
</div>
<p className="safety-note">
Переподключитесь к прежнему K1 или начните чистое подключение
нового устройства.
{savedBridgeNetworkSetupRequired
? "K1 ответил по Bluetooth, но не сообщил адрес Bridge. Настройки устройства не изменялись; требуется новое подключение к общей сети."
: "Переподключитесь к прежнему K1 или начните чистое подключение нового устройства."}
</p>
{physicalReadOnlyVerificationAvailable ? (
<Button
@@ -3276,7 +3307,9 @@ export function K1ProvisioningPipeline({
disabled={isBusy || modeResetInFlight}
onClick={() => void changeDesiredConnectionMode(connectionMode)}
>
Подключить новый K1
{savedBridgeNetworkSetupRequired
? "Подключить K1 к общей сети"
: "Подключить новый K1"}
</Button>
{candidateUnavailableMessage ? (
<p className="safety-note" role="status">
@@ -3289,7 +3322,8 @@ export function K1ProvisioningPipeline({
className="field-stack"
aria-busy={connectionReconnectPending || undefined}
>
{correlatedConnectionAttempt && error ? (
{correlatedConnectionAttempt && error
&& !savedBridgeNetworkSetupRequired ? (
<K1OperatorError
message={error}
diagnostic={errorDiagnostic}
@@ -3305,9 +3339,9 @@ export function K1ProvisioningPipeline({
<>
<div className="connection-summary" role="status">
<span>
{unknownNetworkOutcomeAttempt
? "Результат применения сети не подтверждён"
: "Сохранённое подключение требует проверки"}
{savedBridgeNetworkSetupRequired
? "K1 не подключён к сохранённой общей сети"
: "Сохранённое подключение"}
</span>
<strong>
{connectionRecoveryModeLabel
@@ -3319,13 +3353,24 @@ export function K1ProvisioningPipeline({
) : null}
</div>
<p className="safety-note">
{unknownNetworkOutcomeAttempt
? "Итог прежней попытки неизвестен. Автоматического повтора не было: сначала проверьте сохранённое подключение без изменений либо начните отдельный новый поиск."
: роверка читает состояние прежнего K1 и не отправляет настройки сети, START или STOP. Новый поиск — отдельное явное действие для выбора другого устройства."}
{savedBridgeNetworkSetupRequired
? "K1 ответил по Bluetooth, но не сообщил адрес Bridge. Настройки устройства не изменялись; требуется новое подключение к общей сети."
: ереподключение использует сохранённый K1 без изменения сети. Новое подключение запускается отдельно через Bluetooth."}
</p>
{connectionRecoveryActions}
</>
)}
{scenarioResetOwnsCleanDraft ? (
<Button
width="full"
variant="secondary"
icon={<Icon name="search" />}
disabled={!canScan}
onClick={() => void repeatDeviceScan()}
>
Найти по Bluetooth
</Button>
) : null}
{candidateUnavailableMessage ? (
<p className="safety-note" role="status">
{candidateUnavailableMessage}
@@ -3444,7 +3489,7 @@ export function K1ProvisioningPipeline({
? "Настройка…"
: explicitProvisioningDraftStale
? "Результат устарел"
: selectedModeConnected
: connectionPresentationEstablished
? "Готово"
: "Ожидает настройки"
}
@@ -3459,7 +3504,7 @@ export function K1ProvisioningPipeline({
? "accent"
: networkRecoveryRequired || explicitProvisioningDraftStale
? "warning"
: selectedModeConnected
: connectionPresentationEstablished
? "success"
: "neutral"
}
@@ -3558,7 +3603,7 @@ export function K1ProvisioningPipeline({
<ActivityIndicator />
<strong>Настройка сети</strong>
</div>
) : selectedModeConnected && !changeNetworkDialogue ? (
) : connectionPresentationEstablished && !changeNetworkDialogue ? (
<div className="field-stack">
<div className="connection-summary">
<span>Подключение установлено</span>
+34 -16
View File
@@ -812,6 +812,26 @@ export function readOnlyFailureShowsNetworkUnavailable(
&& READ_ONLY_NETWORK_UNAVAILABLE_REASON_CODES.has(reasonCode);
}
/**
* Preserve the stronger BLE observation across refreshes and later scan
* failures: this saved Bridge needs network setup, not another old-address
* reconnect attempt.
*/
export function savedBridgeRequiresNetworkSetup(
state: XgridsK1State | null | undefined,
): boolean {
if (
state?.connection_verification?.reason_code
=== "connection-verify-address-unavailable"
) return true;
const operation = state?.last_operation;
return Boolean(
operation?.action === "connection.verify"
&& operation.status === "failed"
&& operation.error?.code === "connection-verify-address-unavailable",
);
}
export function requiresReadOnlyPhysicalRecovery(
state: XgridsK1State | null | undefined,
): boolean {
@@ -1201,24 +1221,15 @@ ReadonlyArray<ReadOnlyNetworkObservationAction> = [
/**
* Resolve a recovery Verify target from the public policy, never from a
* browser selection. The backend recommendation wins when it names an exact
* allowed observation; older compatible projections fall back in the same
* current -> configured -> fresh order used by the supervisor.
* browser selection. A reconnect must prefer already bound or durable state
* over a projected fresh scan: a browser projection cannot prove that the
* native BLEDevice is still retained by the backend process. The backend
* policy still decides which exact actions are allowed; this helper only
* chooses the safest allowed source in current -> configured -> fresh order.
*/
export function recommendedConnectionRecoveryObservationTarget(
state: XgridsK1State | null | undefined,
): ReadOnlyConnectionObservationTarget | null {
const recommended = state?.connection_policy?.recommended_action;
const orderedActions = SERVER_BOUND_RECOVERY_OBSERVATION_PRIORITY.includes(
recommended as ReadOnlyNetworkObservationAction,
)
? [
recommended as ReadOnlyNetworkObservationAction,
...SERVER_BOUND_RECOVERY_OBSERVATION_PRIORITY.filter(
(action) => action !== recommended,
),
]
: SERVER_BOUND_RECOVERY_OBSERVATION_PRIORITY;
const sources: Record<
ReadOnlyNetworkObservationAction,
ReadOnlyConnectionObservationSource
@@ -1227,7 +1238,7 @@ export function recommendedConnectionRecoveryObservationTarget(
"observe-configured-device-network": "durable-configured-state",
"observe-fresh-device-network": "fresh-scan",
};
for (const action of orderedActions) {
for (const action of SERVER_BOUND_RECOVERY_OBSERVATION_PRIORITY) {
const target = exactPolicyObservationTarget(state, action, sources[action]);
if (target?.serverBound) return target;
}
@@ -1282,6 +1293,13 @@ export function readOnlyConnectionObservationTarget(
selectedDeviceId = "",
selectedConnectionMode: XgridsConnectionMode | null = null,
): ReadOnlyConnectionObservationTarget | null {
// An unresolved physical command must first use the exact durable
// DeviceInfo path when the supervisor authorizes it. A projected fresh BLE
// row can outlive the backend's native BLEDevice and therefore cannot
// outrank this read-only reconciliation route.
const exactControl = exactControlVerificationTarget(state);
if (exactControl) return exactControl;
const exactFresh = exactPolicyObservationTarget(
state,
"observe-fresh-device-network",
@@ -1327,7 +1345,7 @@ export function readOnlyConnectionObservationTarget(
state,
"observe-configured-device-network",
"durable-configured-state",
) ?? exactControlVerificationTarget(state);
);
}
/**
@@ -145,6 +145,12 @@ export type AcquisitionPreparationDraft = Omit<
export interface CanonicalLivePreparationRequest {
acquisition: AcquisitionPreparationDraft;
physicalAcceptance: OperatorPresenceConfirmation;
}
function runtimeTimezoneName(): string {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return typeof timezone === "string" && timezone.trim() ? timezone : "UTC";
}
export interface ProvisioningSubmitResult {
@@ -677,7 +683,7 @@ export function connectionVerificationFailureMessage(
"connection-verify-exact-uuid-scan-timeout":
"Mission Core не получил объявление точного сохранённого CoreBluetooth UUID в отведённое окно. Команды K1 не отправлялись; это не является выводом о состоянии устройства.",
"connection-verify-address-unavailable":
"K1 ответил, но не сообщил адрес в общей сети. Bridge пока не подключён; настройки устройства не менялись.",
"K1 ответил по Bluetooth, но не подключён к сохранённой общей сети. Старый Bridge использовать нельзя; подключите K1 к общей сети заново — настройки устройства не менялись.",
"connection-verify-target-not-distinguishable-from-baseline":
"K1 ответил, но приложение не смогло подтвердить, что прежние настройки сети были применены. Автоматического повтора и новой записи не было.",
"connection-verify-route-mismatch":
@@ -1732,6 +1738,27 @@ export function useXgridsK1Runtime(enabled: boolean) {
}
continue;
}
if (nextState.application_control_session?.inspection_only === true) {
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.openApplicationControlSession({
...request.physicalAcceptance,
timezone_name: runtimeTimezoneName(),
expected_snapshot_runtime_id: actionSnapshotRuntimeId,
}),
);
acceptState(nextState);
assertOperatorIntentCurrent();
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"connection-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue;
}
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => {