From c7843a3c7ec1902ea4b569324c81822bb87a3437 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 21 Aug 2026 08:12:22 +0300 Subject: [PATCH] chore(k1): checkpoint connection recovery work --- .../src/components/RerunViewport.tsx | 29 ++- .../src/workspaces/Workspaces.tsx | 9 +- .../test/devicePluginContracts.test.mjs | 9 +- .../devicePluginFrontendBoundary.test.mjs | 10 +- .../test/k1SupervisorPresentation.test.mjs | 193 +++++++++++++++--- .../rerunViewportAtomicAdmission.test.mjs | 27 ++- docs/20_K1_CONNECTION_SUPERVISION_CANON.md | 33 +-- docs/runbooks/K1_CONNECTION_RECOVERY.md | 55 +++-- .../frontend/src/XgridsK1Connection.tsx | 32 ++- .../src/components/K1AcquisitionPipeline.tsx | 39 +--- .../src/components/K1ProvisioningPipeline.tsx | 151 +++++++++----- plugins/xgrids-k1/frontend/src/lifecycle.ts | 50 +++-- .../frontend/src/useXgridsK1Runtime.ts | 29 ++- .../active_acquisition_recovery_checkpoint.py | 104 ++++++++-- src/k1link/device_plugins/xgrids_k1/facade.py | 164 +++++++++++---- .../xgrids_k1/protocol/application_session.py | 24 +-- tests/test_frontend_plugin_boundaries.py | 8 +- tests/test_xgrids_acquisition_lifecycle.py | 74 +++++-- ...tive_acquisition_checkpoint_integration.py | 9 +- ..._active_acquisition_recovery_checkpoint.py | 99 ++++++++- ..._active_acquisition_restart_rehydration.py | 96 +++++++++ tests/test_xgrids_application_session.py | 18 +- 22 files changed, 969 insertions(+), 293 deletions(-) diff --git a/apps/control-station/src/components/RerunViewport.tsx b/apps/control-station/src/components/RerunViewport.tsx index 9162b02..6e7729e 100644 --- a/apps/control-station/src/components/RerunViewport.tsx +++ b/apps/control-station/src/components/RerunViewport.tsx @@ -292,6 +292,21 @@ export function isLiveRerunPresentationReady( isUsableRecordedPlaybackRange(rangeNs); } +/** + * `recording_open` can arrive before Rerun has registered the live timeline. + * Selecting it at that point is a silent no-op, so keep retrying only until + * the exact live timeline is both available and active. + */ +export function liveTimelineNeedsSynchronization( + followLive: boolean, + activeTimeline: string | null | undefined, + rangeNs: { min: number; max: number } | null, +): boolean { + return followLive && + isUsableRecordedPlaybackRange(rangeNs) && + activeTimeline !== "stream_time"; +} + /** * Key the native receiver to its data-plane binding. Recovery authority is a * retry fence projected from changing supervisor snapshots; it must not tear @@ -1435,6 +1450,7 @@ export function RerunViewport({ let rangeNs = viewer.get_time_range(event.recording_id, timeline); let currentNs = viewer.get_current_time(event.recording_id, timeline); let playing = viewer.get_playing(event.recording_id); + let liveTimelineSynchronized = !followLive; if (!followLive && playing) { try { viewer.set_playing(event.recording_id, false); @@ -1496,6 +1512,17 @@ export function RerunViewport({ if (disposed || !playbackState) return; try { rangeNs = viewer.get_time_range(event.recording_id, timeline); + if (followLive && !liveTimelineSynchronized) { + let activeTimeline = viewer.get_active_timeline(event.recording_id); + if (liveTimelineNeedsSynchronization(followLive, activeTimeline, rangeNs)) { + // The first selection above may have raced timeline + // creation. Once a real range exists this retry is safe + // and preserves Rerun's native live-following cursor. + viewer.set_active_timeline(event.recording_id, timeline); + activeTimeline = viewer.get_active_timeline(event.recording_id); + } + liveTimelineSynchronized = activeTimeline === timeline; + } currentNs = viewer.get_current_time(event.recording_id, timeline); playing = viewer.get_playing(event.recording_id); } catch { @@ -1511,7 +1538,7 @@ export function RerunViewport({ setRecordingBufferProgress(recordedBuffer.bufferProgress); } const readyToRender = followLive - ? isLiveRerunPresentationReady( + ? liveTimelineSynchronized && isLiveRerunPresentationReady( viewerStartResolved, rangeNs, liveActivitySequenceRef.current, diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index 5fad69a..e779c59 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -157,6 +157,7 @@ function SpatialWorkspace({ const [showSegmentation, setShowSegmentation] = useState(false); const [showCuboids3d, setShowCuboids3d] = useState(false); const recordedSource = Boolean(recordedReplay) || /\.rrd(?:$|[?#])/i.test(sourceUrl); + const liveRerunSource = !recordedSource && /^rerun\+https?:\/\//i.test(sourceUrl.trim()); const recordedSessionGate: RecordedAdmissionPhase = recordedSource ? recordedSessionAdmission?.phase ?? "loading" : "ready"; @@ -165,6 +166,10 @@ function SpatialWorkspace({ isRecordedPlaybackPresentationReady(viewerStatus, playbackState)); const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay"; const metrics = streamActive ? state?.metrics : undefined; + // An explicit manual gRPC source has no Mission Core metrics producer. Its + // native Rerun range is therefore the only available activity proof. + const livePresentationActivitySequence = metrics?.publishedFrameCount ?? + (liveRerunSource && !streamActive ? 1 : null); const latency = pipelineLatency(metrics); const frameRate = finiteMetric(metrics?.frameRateHz); const points = finiteMetric(metrics?.pointCount); @@ -495,8 +500,8 @@ function SpatialWorkspace({ { assert.match(connection, /

Подключение \{model\.displayName\}<\/h2>/); assert.match( connection, - /const operationalPanelsVisible = shouldRenderK1OperationalPanels\(state\)/, + /const operationalPanelsVisible = shouldRenderK1OperationalPanels\(\s*state,\s*controller\.pendingAction,\s*\)/, ); assert.match( connection, @@ -395,7 +395,7 @@ test("K1 START renders an in-button spinner for the complete live orchestration" assert.doesNotMatch(acquisition, /readOnlyConnectionObservationTarget\(state\)/); assert.match(acquisition, /appliedTopology\?\.status === "active"\s*&& desiredModeMatchesActive/); assert.match(acquisition, /if \(!draftPreparationTarget\) return;/); - assert.match(acquisition, /START не используется для установки связи/); + assert.doesNotMatch(acquisition, /START не используется для установки связи/); assert.match(acquisition, /Синхронизация…/); }); @@ -502,7 +502,7 @@ test("K1 mode reset is explicit while Scan and Apply keep exact backend CAS", () assert.match(acquisition, /state\?\.active_connection_mode/); assert.match(acquisition, /desiredSelectionCommitted/); assert.match(acquisition, /configuredConnectionMode !== desiredConnectionMode/); - assert.match(acquisition, /Выбран другой способ связи/); + assert.doesNotMatch(acquisition, /Выбран другой способ связи/); }); test("top-right device utility is an explicit pending-aware K1 scenario reset", async () => { @@ -990,7 +990,7 @@ test("every K1 connection and acquisition action is fenced to the accepted backe [...runtime.matchAll( /expected_snapshot_runtime_id: actionSnapshotRuntimeId/g, )].length, - 3, + 4, ); assert.match( runtime, @@ -1064,7 +1064,7 @@ test("K1 orchestration accepts backend recovery but still requires exact topolog ); assert.match(acquisition, /desiredModeMatchesActive/); assert.match(acquisition, /modeSwitchRequired/); - assert.match(acquisition, /Выбран другой способ связи/); + assert.doesNotMatch(acquisition, /Выбран другой способ связи/); assert.match(acquisition, /prepareCanonicalAcquisition/); assert.match(acquisition, /startPreparedAcquisition/); assert.match(acquisition, /operatorActionPhysicalAcceptance\(\)/); diff --git a/apps/control-station/test/k1SupervisorPresentation.test.mjs b/apps/control-station/test/k1SupervisorPresentation.test.mjs index 56af9c9..8723657 100644 --- a/apps/control-station/test/k1SupervisorPresentation.test.mjs +++ b/apps/control-station/test/k1SupervisorPresentation.test.mjs @@ -65,6 +65,7 @@ let shellPresentation; let reconfigurationAllowsFreshDevice; let readOnlyObservationShowsNetworkUnavailable; let readOnlyFailureShowsNetworkUnavailable; +let savedBridgeRequiresNetworkSetup; let provisioningFailureRequiresFreshCandidate; let trustedConnectionBinding; let transportRefEquivalenceKey; @@ -183,6 +184,7 @@ before(async () => { reconfigurationAllowsFreshDevice, readOnlyObservationShowsNetworkUnavailable, readOnlyFailureShowsNetworkUnavailable, + savedBridgeRequiresNetworkSetup, provisioningFailureRequiresFreshCandidate, trustedConnectionBinding, transportRefEquivalenceKey, @@ -1057,12 +1059,15 @@ function connectionPipelinesController(state) { }; } -function renderConnectionPipelines(state) { +function renderConnectionPipelines(state, pendingAction = null) { return renderToStaticMarkup(createElement(K1ConnectionPipelines, { - controller: connectionPipelinesController(state), + controller: { + ...connectionPipelinesController(state), + pendingAction, + }, desiredConnectionMode: "bridge", onDesiredConnectionModeChange() {}, - operationalPanelsVisible: shouldRenderK1OperationalPanels(state), + operationalPanelsVisible: shouldRenderK1OperationalPanels(state, pendingAction), openSpatialScene() {}, activateAutomaticSpatialSource() {}, sourceLabel: "Ожидание", @@ -2872,7 +2877,7 @@ test("runtime errors borrow diagnostics only from their exact Connect attempt", ); }); -test("connection recovery observation follows recommended exact policy then safe priority", () => { +test("connection recovery keeps current then durable priority over a stale fresh recommendation", () => { const state = durableTopologyState(); state.snapshot_runtime_id = "runtime-recovery-priority"; state.devices = [{ @@ -2893,7 +2898,7 @@ test("connection recovery observation follows recommended exact policy then safe state.connection_policy = { schema_version: "missioncore.xgrids-k1-connection-policy/v1", facts: { retained_context_is_presence: false }, - recommended_action: "observe-configured-device-network", + recommended_action: "observe-fresh-device-network", allowed_actions: [ "observe-current-device-network", "observe-configured-device-network", @@ -2913,10 +2918,21 @@ test("connection recovery observation follows recommended exact policy then safe }; const recommended = recommendedConnectionRecoveryObservationTarget(state); - assert.equal(recommended?.action, "observe-configured-device-network"); - assert.equal(recommended?.deviceId, "configured-k1"); + assert.equal(recommended?.action, "observe-current-device-network"); + assert.equal(recommended?.deviceId, "current-k1"); + + state.connection_policy.actions["observe-current-device-network"].allowed = false; + const durable = recommendedConnectionRecoveryObservationTarget(state); + assert.equal(durable?.action, "observe-configured-device-network"); + assert.equal(durable?.deviceId, "configured-k1"); + + state.connection_policy.actions["observe-configured-device-network"].allowed = false; + const fresh = recommendedConnectionRecoveryObservationTarget(state); + assert.equal(fresh?.action, "observe-fresh-device-network"); + assert.equal(fresh?.deviceId, "fresh-k1"); state.connection_policy.recommended_action = "scan-ble"; + state.connection_policy.actions["observe-current-device-network"].allowed = true; const fallback = recommendedConnectionRecoveryObservationTarget(state); assert.equal(fallback?.action, "observe-current-device-network"); assert.equal(fallback?.deviceId, "current-k1"); @@ -3109,6 +3125,19 @@ test("cold disconnected connection SSR hides every operational panel", () => { assert.doesNotMatch(unresolvedHistoryMarkup, /class="device-workspace__side/); }); +test("live preparation keeps operational panels mounted across a control handoff", () => { + const state = durableTopologyState(); + const markup = renderConnectionPipelines(state, "live"); + + assert.equal(shouldRenderK1OperationalPanels(state, "live"), true); + assert.match(markup, /class="metrics-grid/); + assert.match(markup, /class="[^"]*\bsession-panel\b/); + assert.match(markup, /class="diagnostics-grid/); + assert.match(markup, /class="device-workspace__side/); + assert.match(markup, /Подключение установлено/); + assert.doesNotMatch(markup, /Переподключиться|Подключить заново/); +}); + test("connected, active, replay and recovery SSR retain operational panels", () => { const connected = runtimeState(); connected.phase = "connected"; @@ -3911,6 +3940,34 @@ test("explicit read-only recovery keeps its network-unavailable classification", } }); +test("a BLE-proven missing Bridge address requires network setup across later retries", () => { + const state = { + connection_verification: { + status: "unreachable", + reason_code: "connection-verify-address-unavailable", + }, + last_operation: { + action: "connection.verify", + status: "failed", + error: { code: "connection-verify-device-not-rediscovered" }, + }, + }; + assert.equal(savedBridgeRequiresNetworkSetup(state), true); + assert.equal(savedBridgeRequiresNetworkSetup({ + last_operation: { + action: "connection.verify", + status: "failed", + error: { code: "connection-verify-address-unavailable" }, + }, + }), true); + assert.equal(savedBridgeRequiresNetworkSetup({ + connection_verification: { + status: "unreachable", + reason_code: "connection-verify-device-not-rediscovered", + }, + }), false); +}); + test("pre-write candidate loss is classified stale without granting UI continuation", () => { for (const reasonCode of [ "BleakDeviceNotFoundError", @@ -4256,7 +4313,7 @@ test("fresh Scan keeps retired audit rows selectable without recovery I/O", () = assertCanonicalConnectionCopy(historicalAuditMarkup); }); -test("only current network recovery safe-next states retain a historical applied attempt", () => { +test("only an active network operation owns an applied-attempt screen", () => { const attempt = { schema_version: "missioncore.xgrids-k1-connection-attempt/v1", attempt_id: "attempt-network-recovery-ownership", @@ -4283,7 +4340,7 @@ test("only current network recovery safe-next states retain a historical applied ...attempt, safe_next_action: safeNextAction, }), - true, + false, safeNextAction, ); } @@ -5205,6 +5262,51 @@ test("an unresolved physical command has one explicit server-bound read-only rec /Результаты последнего Bluetooth-поиска|nearby-unrelated-device|>Выбрать<|>Применить)/); + const networkSetupAction = buttonMarkupWithText( + networkSetupMarkup, + "Подключить K1 к общей сети", + ); + assert.equal(networkSetupAction.length, 1); + assert.doesNotMatch(networkSetupAction[0], /\bdisabled(?:=|\s|>)/); + assert.match( + networkSetupMarkup, + /K1 не подключён к сохранённой общей сети/, + ); + assert.match(networkSetupMarkup, /не сообщил адрес Bridge/); + assert.doesNotMatch( + networkSetupMarkup, + /Локальная операция завершилась ошибкой|Проверьте питание и сеть/, + ); + assert.match( + physicalRecoveryConnectionDetail(networkSetupState), + /K1 ответил по Bluetooth, но не подключён к сохранённой общей сети/, + ); + const policyDeniedState = structuredClone(state); policyDeniedState.connection_policy.allowed_actions = ["scan-ble"]; delete policyDeniedState.connection_policy.actions["observe-configured-device-network"]; @@ -5258,9 +5360,10 @@ test("an unresolved physical command has one explicit server-bound read-only rec /scanWithResult\(|connect\(|retireUnavailable|reopenRetired|reconcilePhysicalCommand/, ); assert.match(physicalRecovery, /physicalRecoveryTarget\?\.serverBound/); + assert.match(physicalRecovery, /surfaceErrors: false/); }); -test("supervisor DeviceInfo Verify keeps the exact physical reconnect visible", () => { +test("supervisor DeviceInfo Verify outranks a stale exact fresh row for physical recovery", () => { const state = durableTopologyState(); const recoveryTransport = "F89438FA-55ED-85AD-EED7-734AC84746D8"; state.snapshot_runtime_id = "runtime-terminal-control-physical-recovery"; @@ -5299,11 +5402,20 @@ test("supervisor DeviceInfo Verify keeps the exact physical reconnect visible", recommended_action: "scan-ble", allowed_actions: [ "scan-ble", + "observe-fresh-device-network", "inspect-configured-endpoint", "verify-control-device-info", ], actions: { - "observe-fresh-device-network": deniedObservation("fresh-scan"), + "observe-fresh-device-network": { + allowed: true, + reason_codes: [], + target_source: "fresh-scan", + required_transport_ref: recoveryTransport, + required_connection_mode: "bridge", + requires_live_gatt_validation: true, + automatic_retry: false, + }, "observe-current-device-network": deniedObservation( "retained-current-process", ), @@ -5320,6 +5432,13 @@ test("supervisor DeviceInfo Verify keeps the exact physical reconnect visible", }, }, }; + state.devices = [{ + device_id: recoveryTransport, + name: "Projected K1", + connectable: true, + likely_k1: true, + }]; + state.ble_discovery_generation = 2; assert.deepEqual(readOnlyConnectionObservationTarget(state), { action: "verify-control-device-info", @@ -5341,6 +5460,7 @@ test("supervisor DeviceInfo Verify keeps the exact physical reconnect visible", const mismatched = structuredClone(state); mismatched.semantic_topology_store.record.transport_ref = "another-k1"; + mismatched.connection_policy.actions["observe-fresh-device-network"].allowed = false; assert.equal(readOnlyConnectionObservationTarget(mismatched), null); }); @@ -5929,8 +6049,16 @@ test("terminal Apply recovery is bounded and never claims that the network is re controller: provisioningController(state), desiredMode: "bridge", }, presentation); - assert.match(markup, /Управление не подтверждено/); - assert.match(markup, /Попытка настройки завершена/); + assert.match(markup, /Сохранённое подключение/); + assert.match(markup, /Подключить заново/); + assert.equal( + buttonMarkupWithText(markup, "Подключить новый K1").length, + 1, + ); + assert.doesNotMatch( + markup, + /Управление не подтверждено|выбор заблокирован|Попытка настройки завершена/, + ); assert.doesNotMatch(markup, /Сеть (?:готова|настроена)|Сетевые настройки уже применены/); assert.doesNotMatch(markup, /Пароль передан|FIELD-NET|>Применить { @@ -6294,7 +6422,7 @@ test("read-only reconnect keeps its card and loader across a transient projectio assert.match(pendingReconnect[0], /disabled/); assert.match(pendingReconnect[0], /aria-busy="true"/); assert.match(pendingReconnect[0], /nodedc-activity-indicator/); - assert.match(pendingMarkup, /Сохранённое подключение требует проверки/); + assert.match(pendingMarkup, /Сохранённое подключение/); assert.doesNotMatch(pendingMarkup, /connection-action-progress/); assert.equal( harness.stateValue(emptyReadOnlyReconnectPresentation)?.kind, @@ -6320,7 +6448,7 @@ test("read-only reconnect keeps its card and loader across a transient projectio } }); -test("cold saved new-device path resets once, performs zero Scan, and survives reload", async () => { +test("cold saved target keeps fast reconnect after a reset and reload", async () => { const state = terminalConnectionRecoveryState(); state.connection_attempt = null; const target = recommendedConnectionRecoveryObservationTarget(state); @@ -6424,14 +6552,18 @@ test("cold saved new-device path resets once, performs zero Scan, and survives r }, desiredMode: "bridge", }); - assert.equal(buttonMarkupWithText(reloadedMarkup, "Переподключиться").length, 0); + const reloadedReconnect = buttonMarkupWithText( + reloadedMarkup, + "Переподключиться", + ); + assert.equal(reloadedReconnect.length, 1); + assert.doesNotMatch(reloadedReconnect[0], /\bdisabled(?:=|\s|>)/); const cleanScan = buttonMarkupWithText(reloadedMarkup, "Найти по Bluetooth"); assert.equal(cleanScan.length, 1); assert.doesNotMatch(cleanScan[0], /\bdisabled(?:=|\s|>)/); - assert.doesNotMatch( - reloadedMarkup, - /Нужна проверка|Прежнее подключение не подтверждено|Проверяем прежний K1/, - ); + assert.match(reloadedMarkup, /Сохранённое подключение/); + assert.match(reloadedMarkup, /Можно переподключиться/); + assert.doesNotMatch(reloadedMarkup, /Подключить новый K1/); }); test("a current post-reset failed or unknown attempt outranks the durable reset marker after reload", () => { @@ -6478,7 +6610,7 @@ test("a current post-reset failed or unknown attempt outranks the durable reset }, desiredMode: "bridge", }); - assert.match(unknownReloadMarkup, /Результат применения сети не подтверждён/); + assert.match(unknownReloadMarkup, /Сохранённое подключение/); assert.equal( buttonMarkupWithText(unknownReloadMarkup, "Переподключиться").length, 1, @@ -6501,9 +6633,20 @@ test("a current post-reset failed or unknown attempt outranks the durable reset controller: provisioningController(failed), desiredMode: "bridge", }); - assert.match(failedReloadMarkup, /Управление не подтверждено/); - assert.match(failedReloadMarkup, /Новый выбор временно заблокирован/); - assert.doesNotMatch(failedReloadMarkup, /Совпадений нет|Ожидает/); + assert.match(failedReloadMarkup, /Сохранённое подключение/); + assert.match(failedReloadMarkup, /Можно переподключиться/); + assert.equal( + buttonMarkupWithText(failedReloadMarkup, "Переподключиться").length, + 1, + ); + assert.equal( + buttonMarkupWithText(failedReloadMarkup, "Подключить новый K1").length, + 1, + ); + assert.doesNotMatch( + failedReloadMarkup, + /Управление не подтверждено|выбор заблокирован|Новый выбор временно заблокирован/, + ); }); test("Apply never owns hidden refresh or an automatic continuation", () => { diff --git a/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs b/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs index 1d837f2..91bd782 100644 --- a/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs +++ b/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs @@ -9,6 +9,7 @@ let claimExclusiveLiveViewer; let createRecordedOpenWatchdog; let createReentrantViewerDisposer; let isLiveRerunPresentationReady; +let liveTimelineNeedsSynchronization; let liveRerunReceiverBindingIdentity; let recordedOpenWatchdogTimeoutMs; let rerunViewerInitialSource; @@ -26,6 +27,7 @@ before(async () => { createRecordedOpenWatchdog, createReentrantViewerDisposer, isLiveRerunPresentationReady, + liveTimelineNeedsSynchronization, liveRerunReceiverBindingIdentity, recordedOpenWatchdogTimeoutMs, rerunViewerInitialSource, @@ -82,6 +84,14 @@ test("live presentation waits for the exact receiver to expose a usable range", assert.equal(isLiveRerunPresentationReady(true, { min: 1, max: 1 }, 1), true); }); +test("live timeline is retried only after stream_time exists and until it is active", () => { + assert.equal(liveTimelineNeedsSynchronization(false, undefined, { min: 1, max: 2 }), false); + assert.equal(liveTimelineNeedsSynchronization(true, undefined, null), false); + assert.equal(liveTimelineNeedsSynchronization(true, undefined, { min: 1, max: 2 }), true); + assert.equal(liveTimelineNeedsSynchronization(true, "log_time", { min: 1, max: 2 }), true); + assert.equal(liveTimelineNeedsSynchronization(true, "stream_time", { min: 1, max: 2 }), false); +}); + test("live receiver binding stays stable across recovery authority projections", () => { const sourceUrl = "rerun+http://127.0.0.1:9877/proxy"; const streamId = "acq-001"; @@ -217,7 +227,11 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn ); assert.match( source, - /const readyToRender = followLive\s*\? isLiveRerunPresentationReady\(/, + /const readyToRender = followLive\s*\? liveTimelineSynchronized && isLiveRerunPresentationReady\(/, + ); + assert.match( + source, + /viewer\.get_active_timeline\(event\.recording_id\)[\s\S]*viewer\.set_active_timeline\(event\.recording_id, timeline\)/, ); assert.match( source, @@ -257,7 +271,16 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan" new URL("../src/workspaces/Workspaces.tsx", import.meta.url), "utf8", ); - assert.match(source, /followLive=\{!recordedReplay && streamActive\}/); + assert.match( + source, + /const liveRerunSource = !recordedSource && \/\^rerun\\\+https\?:\\\/\\\//, + ); + assert.match( + source, + /const livePresentationActivitySequence = metrics\?\.publishedFrameCount \?\?[\s\S]*liveRerunSource && !streamActive \? 1 : null/, + ); + assert.match(source, /followLive=\{liveRerunSource\}/); + assert.match(source, /liveActivitySequence=\{livePresentationActivitySequence\}/); assert.match( source, /sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/, diff --git a/docs/20_K1_CONNECTION_SUPERVISION_CANON.md b/docs/20_K1_CONNECTION_SUPERVISION_CANON.md index 3b08e9e..cdd2f63 100644 --- a/docs/20_K1_CONNECTION_SUPERVISION_CANON.md +++ b/docs/20_K1_CONNECTION_SUPERVISION_CANON.md @@ -1,6 +1,6 @@ # K1 connection supervision canon -Status: canonical target and product acceptance contract, updated 2026-08-11; +Status: canonical target and product acceptance contract, updated 2026-08-20; implementation/hardware conformance remains tracked by the acceptance manifest. Operator recovery procedure: [`runbooks/K1_CONNECTION_RECOVERY.md`](runbooks/K1_CONNECTION_RECOVERY.md). @@ -16,10 +16,12 @@ point packet means that the device is connected. The product goal is a simple operator-owned lifecycle: every app start, disconnect, explicit stop, or committed network-mode transition ends the old -connection session. A normal connection has one discovery intent and one -application intent: the operator explicitly searches, selects one fresh result, -enters the network data immediately, and presses **Применить** once. Opening the -UI, selecting a result and editing credentials are presentation inputs only. +live connection authority. The last exactly confirmed K1 and topology remain a +durable read-only fast-reconnect target until the operator changes the device or +network. A normal new connection has one discovery intent and one application +intent: the operator explicitly searches, selects one fresh result, enters the +network data immediately, and presses **Применить** once. Opening the UI, +selecting a result and editing credentials are presentation inputs only. Changing mode is one explicit local scenario-reset operation; it performs no device or host I/O and starts no discovery. Historical attempts are audit evidence only. They never @@ -109,9 +111,14 @@ terminology, physical-ledger state and protocol recovery names are not operator ceremonies. 1. A cold plugin section shows the connection-mode selector and Step 01 - **Подключение** with its explicit Bluetooth search action. Historical K1 - identity never adds a reconnect prompt on cold entry. Opening or polling the - surface performs no action. + **Подключение**. When an exact last confirmed K1 and unchanged topology are + available, it shows the saved target with **Переподключиться** plus the + separate **Найти по Bluetooth** path. The reconnect action performs only a + server-pinned read-only observation; it sends no network settings, START or + STOP. A browser-cache reset or local scenario reset closes live authority but + does not erase this durable offer. Audit-only historical identities do not + create it. Without an eligible durable target, Step 01 shows only the explicit + Bluetooth search action. Opening or polling the surface performs no action. Each selector/escape gesture sends one explicit CAS-fenced `reset_scenario`; it may seal only old local receiver/camera/control owners and retire their audit lineage, but performs no BLE, device/host Wi-Fi, @@ -173,9 +180,11 @@ reopened record from fresh DeviceInfo plus non-retained DeviceStatus: READY settles standby, while identity-bound initialized SCANNING settles active without replaying START and materializes only a STOP-only control shell. A reset-owned settlement never restarts the retired receiver, camera, evidence writer or acquisition. -Exact operator-facing physical recovery keeps its backend CAS and -transport pinning, but appears only inside an established session after an -actual interruption, never in cold entry or Bluetooth results. +Exact operator-facing physical recovery keeps its backend CAS and transport +pinning and remains distinct from cold fast reconnect. Fast reconnect only +re-proves an unchanged saved target; unresolved physical START/STOP state still +uses the stricter recovery path. Neither action appears inside Bluetooth result +rows. Outside that reset-owned STOP-only settlement, fresh non-retained READY proof resolves physical ambiguity as standby; fresh exact same-project SCANNING resolves it as active and exposes only the @@ -248,7 +257,7 @@ not fabricate the state of another row. | Boundary that can fail | Typical real scenario | Required response | | --- | --- | --- | -| Browser ↔ local API | app refresh/restart, closed tab, suspended renderer, lost HTTP response | App restart/close ends its operator connection session without sending a device command; a single lost response never repeats an action. The next app session starts with discovery and selection. | +| Browser ↔ local API | app refresh/restart, closed tab, suspended renderer, lost HTTP response | App restart/close ends live operator authority without sending a device command; a single lost response never repeats an action. When an exact last confirmed K1/topology remains durable, the next app session offers an explicit read-only fast reconnect alongside fresh discovery. Otherwise it starts with discovery and selection. | | Mission Core process | crash, upgrade, second process, stale worker thread | Close the active connection session on restart; retain terminal audit only; stable process locks prevent a second controller; old runtime generations cannot publish evidence into a new session. | | CoreBluetooth discovery/GATT | K1 powers off, Mac sleeps, disconnect callback arrives, scan callback arrives late | Close and clear the selected session on proven disconnect. A later operator scan and selection establish a new GATT connection. Unselected rows remain stable for the latest admitted scan generation and are invalidated only by an explicit successor/reset, runtime-owner teardown or proven exact-target GATT failure. A row never grants mutation authority without exact-handle capture and live GATT validation. | | Mac Wi-Fi association | Quick AP is left manually, Bridge router changes, same IP is reused by another AP | Rotate host epoch using an opaque OS association identity in addition to route/interface/source IP. Rebuild TCP and protocol proof. | diff --git a/docs/runbooks/K1_CONNECTION_RECOVERY.md b/docs/runbooks/K1_CONNECTION_RECOVERY.md index ff516d3..b1454c9 100644 --- a/docs/runbooks/K1_CONNECTION_RECOVERY.md +++ b/docs/runbooks/K1_CONNECTION_RECOVERY.md @@ -12,16 +12,19 @@ the wizard the two step names are exactly **Подключение** and **Се ### Cold entry -On a clean cold entry show only: +On a clean cold entry show: - the connection-mode selector; -- Step 01 **Подключение** with the explicit Bluetooth search action. +- Step 01 **Подключение**; +- when an exact last confirmed K1 and unchanged topology are durable, + **Переподключиться** and the separate **Найти по Bluetooth** path; +- otherwise only the explicit Bluetooth search action. -Historical K1 identity never adds a reconnect choice to cold entry. If local -session ownership or an older connection scenario exists, one explicit -`reset_scenario` CAS first closes only that local scenario. Only after the reset -is accepted does clean Step 01 expose **Найти по Bluetooth** as a separate -click; reset never starts Scan itself. +**Переподключиться** is a read-only fast connection to the server-pinned last +K1. It does not send network settings, START or STOP. Historical audit identity +alone never creates this choice. A browser-cache reset or accepted +`reset_scenario` closes the old live/local authority but does not erase the +last confirmed target. Reset never starts reconnect or Scan itself. Do not render Step 02 yet and do not start discovery automatically. The mode selector and same-mode **Подключить новый K1** escape remain available through @@ -42,12 +45,14 @@ the request owns the current action it reads **Сбрасываем подклю does not dispatch a second reset until that bounded request settles. It remains available to supersede any other local action. The accepted revision always returns the new local scenario and even a dirty browser selector to canonical -**Bridge**, clears the old browser/backend presentation and source owners, and -leaves Scan as a separate click. It performs no hidden Scan, Verify, Connect, +**Bridge**, clears old browser drafts and source owners, and leaves both an +eligible durable fast reconnect and Scan as separate clicks. It performs no +hidden Scan, Verify, Connect, START, STOP, BLE or network write and never substitutes a passive `state.read` -for the reset mutation. After settlement the product surface contains no prior -UUID, result count, **Повторить поиск**, reconnect error, selected device, -credentials or recovery card; it returns to **Найти по Bluetooth**. Late +for the reset mutation. After settlement the product surface contains no stale +result count, **Повторить поиск**, reconnect error, selected draft or +credentials. It retains only the exact durable fast-reconnect card when one is +eligible, alongside **Найти по Bluetooth**. Late Scan/Verify settlements from the retired scenario cannot repopulate it. The retained reset marker fences only work that belonged to the retired scenario. A newly correlated post-reset network attempt that fails or has an @@ -196,8 +201,10 @@ CoreBluetooth object and live GATT validation before any write. The selected session ends on proven disconnect, explicit lifecycle stop, a committed network transition, selection of another device, backend restart or -proven native cleanup. A later connection always requires an explicit search -and **Выбрать**. Polling can update presentation but starts neither operation. +proven native cleanup. A later new-device connection requires an explicit search +and **Выбрать**. An unchanged exact last confirmed K1 may instead use the +explicit read-only fast reconnect. Polling can update presentation but starts +neither operation. Bridge, Quick Connect and Direct Connect are separate topologies. In any state, changing the mode or choosing another K1 in the same mode sends one @@ -260,7 +267,8 @@ fenced. | Event | Product result | Operator path | | --- | --- | --- | -| Cold entry | Mode plus Step 01 and explicit Scan; zero device I/O before Scan | Start search explicitly | +| Cold entry with an exact last confirmed K1/topology | Mode plus Step 01, read-only **Переподключиться**, and separate explicit Scan; zero device I/O before a click | Fast reconnect or start search explicitly | +| Cold entry without an eligible durable target | Mode plus Step 01 and explicit Scan; zero device I/O before Scan | Start search explicitly | | Disconnected/idle mode or same-mode new-device request with unresolved durable physical history | Local session/audit lineage is retired under one reset CAS; zero device/host I/O and no automatic Scan | Start the clean Step 01 search explicitly; the old physical outcome remains auditable | | Mode reset while live, reconnecting or terminal cleanup still owns local sources | Reset supersedes recovery and locally seals receiver/camera/control; previous K1 may still scan | Wait for the bounded local cleanup or retry the same reset if local sealing fails | | Search running | Step 01 spinner and visible countdown | Wait or let the bounded search end | @@ -280,7 +288,8 @@ fenced. | A physical START/STOP edge is unresolved | Mutation stays fenced; no technical wizard ceremony | Search/select remains explicit; backend admits only a safe exact path | | Exact actively retired UUID is present after committed reset and successor Scan | The row exposes the same enabled **Выбрать** as every candidate | Select locally; Apply remains exact-handle/live-GATT gated and may append one internal local settlement checkpoint before its sole write; audit remains append-only and START/STOP stay denied until fresh read-only classification | | Another candidate is selected while old authority is unavailable and no reset-owned new scenario exists | Selection stays local and Apply remains denied | Start an explicit new connection scenario, then Scan and select again | -| Browser refresh or backend restart | No automatic operation and no restored live selection | Begin from the cold progressive wizard | +| Browser refresh or cache reset | No automatic operation and no restored live authority; exact durable last target remains available for read-only fast reconnect | Reconnect explicitly or begin a fresh Bluetooth search | +| Backend restart without an eligible durable target | No automatic operation and no restored live selection | Begin from the cold progressive wizard | ## Physical START/STOP safety remains separate @@ -336,15 +345,19 @@ loader belongs to the explicit action that created it and ends with it. Software tests do not replace a real K1/macOS/router run. Accept sequentially: -1. Open cold and prove mode plus Step 01 and its explicit Scan action are - visible, while Step 02 is absent and no discovery starts automatically. With both empty and +1. Open cold and prove mode plus Step 01 are visible, while Step 02 is absent + and no discovery starts automatically. With an exact last confirmed K1 and + unchanged topology, prove **Переподключиться** and **Найти по Bluetooth** are + both available after reload and browser-cache reset; reconnect performs only + read-only verification. With no eligible durable target, prove only explicit + Scan is available. With both empty and unresolved durable physical history, change the mode and prove one local reset CAS, zero device/host calls and no automatic Scan. Repeat from active, reconnecting and terminal `cleanup_pending` states; prove local sources are sealed, the old K1 is not claimed stopped, and a local cleanup failure leaves - the exact reset retryable. With an exact prior connection, prove cold entry - contains no historical reconnect prompt; after one reset CAS and zero Scan, - a separate clean **Найти по Bluetooth** action remains clean after reload. + the exact reset retryable. With an exact prior connection, prove one reset + CAS and zero Scan preserve its fast-reconnect card and a separate clean + **Найти по Bluetooth** action after reload. 2. Start discovery and prove the spinner and seconds countdown remain visible for the bounded search, then the exact result count appears. 3. With multiple advertisements, prove every ordinary connectable row keeps diff --git a/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx b/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx index aa13fe9..8d09b56 100644 --- a/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx +++ b/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx @@ -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 (
diff --git a/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx index 3a8ee86..345a69e 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx @@ -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({
{ 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" /> ) : null} - + {!scenarioResetOwnsCleanDraft ? ( + + ) : 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} >
- Прежнее подключение не подтверждено + + {savedBridgeNetworkSetupRequired + ? "K1 не подключён к сохранённой общей сети" + : "Прежнее подключение не подтверждено"} + {physicalRecoveryModeLabel} {presentedPhysicalRecoveryDeviceId ?? "Прежний K1"}

- Переподключитесь к прежнему K1 или начните чистое подключение - нового устройства. + {savedBridgeNetworkSetupRequired + ? "K1 ответил по Bluetooth, но не сообщил адрес Bridge. Настройки устройства не изменялись; требуется новое подключение к общей сети." + : "Переподключитесь к прежнему K1 или начните чистое подключение нового устройства."}

{physicalReadOnlyVerificationAvailable ? ( {candidateUnavailableMessage ? (

@@ -3289,7 +3322,8 @@ export function K1ProvisioningPipeline({ className="field-stack" aria-busy={connectionReconnectPending || undefined} > - {correlatedConnectionAttempt && error ? ( + {correlatedConnectionAttempt && error + && !savedBridgeNetworkSetupRequired ? (

- {unknownNetworkOutcomeAttempt - ? "Результат применения сети не подтверждён" - : "Сохранённое подключение требует проверки"} + {savedBridgeNetworkSetupRequired + ? "K1 не подключён к сохранённой общей сети" + : "Сохранённое подключение"} {connectionRecoveryModeLabel @@ -3319,13 +3353,24 @@ export function K1ProvisioningPipeline({ ) : null}

- {unknownNetworkOutcomeAttempt - ? "Итог прежней попытки неизвестен. Автоматического повтора не было: сначала проверьте сохранённое подключение без изменений либо начните отдельный новый поиск." - : "Проверка читает состояние прежнего K1 и не отправляет настройки сети, START или STOP. Новый поиск — отдельное явное действие для выбора другого устройства."} + {savedBridgeNetworkSetupRequired + ? "K1 ответил по Bluetooth, но не сообщил адрес Bridge. Настройки устройства не изменялись; требуется новое подключение к общей сети." + : "Переподключение использует сохранённый K1 без изменения сети. Новое подключение запускается отдельно через Bluetooth."}

{connectionRecoveryActions} )} + {scenarioResetOwnsCleanDraft ? ( + + ) : null} {candidateUnavailableMessage ? (

{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({ Настройка сети… - ) : selectedModeConnected && !changeNetworkDialogue ? ( + ) : connectionPresentationEstablished && !changeNetworkDialogue ? (

Подключение установлено diff --git a/plugins/xgrids-k1/frontend/src/lifecycle.ts b/plugins/xgrids-k1/frontend/src/lifecycle.ts index a188085..adf72fe 100644 --- a/plugins/xgrids-k1/frontend/src/lifecycle.ts +++ b/plugins/xgrids-k1/frontend/src/lifecycle.ts @@ -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 = [ /** * 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); + ); } /** diff --git a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts index f0f432a..f1c4b9e 100644 --- a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts +++ b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts @@ -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, () => { diff --git a/src/k1link/device_plugins/xgrids_k1/active_acquisition_recovery_checkpoint.py b/src/k1link/device_plugins/xgrids_k1/active_acquisition_recovery_checkpoint.py index 2eb6a4a..64c9d70 100644 --- a/src/k1link/device_plugins/xgrids_k1/active_acquisition_recovery_checkpoint.py +++ b/src/k1link/device_plugins/xgrids_k1/active_acquisition_recovery_checkpoint.py @@ -1989,9 +1989,11 @@ class ActiveAcquisitionRecoveryCheckpointStore: raise ActiveAcquisitionRecoveryCheckpointTransitionError( "only a prepared or active checkpoint can cease" ) - _require_physical_lineage_base(current, physical_proof) transport_revision = current.transport_revision current_binding = current.current_binding + gap_started_at = current.last_gap_started_at_utc + gap_started_revision = current.last_gap_started_transport_revision + gap_failed_binding = current.last_gap_failed_binding gap_recovered_at = current.last_gap_recovered_at_utc gap_recovered_revision = current.last_gap_recovered_transport_revision prepared_resolution: ActiveAcquisitionRecoveryPhysicalLineageProof | None = None @@ -2003,7 +2005,21 @@ class ActiveAcquisitionRecoveryCheckpointStore: ) prepared_resolution = physical_proof current_binding = physical_proof.binding + if ( + physical_proof.binding.target_ipv4 + != current.connection.target_ipv4 + ): + transport_revision = _next_transport_revision( + current.transport_revision + ) + gap_started_at = current.updated_at_utc + gap_started_revision = current.transport_revision + gap_failed_binding = current.prepared_binding + assert status_proof is not None + gap_recovered_at = status_proof.observed_at_utc + gap_recovered_revision = transport_revision else: + _require_physical_lineage_base(current, physical_proof) assert status_proof is not None _require_active_cessation( current, @@ -2054,6 +2070,9 @@ class ActiveAcquisitionRecoveryCheckpointStore: ), updated_at_utc=now, ceased_at_utc=ceased_at, + last_gap_started_at_utc=gap_started_at, + last_gap_started_transport_revision=gap_started_revision, + last_gap_failed_binding=gap_failed_binding, last_gap_recovered_at_utc=gap_recovered_at, last_gap_recovered_transport_revision=gap_recovered_revision, cessation_status_proof=status_proof, @@ -2594,6 +2613,7 @@ def _require_binding_matches_checkpoint_values( connection: ActiveAcquisitionRecoveryConnection, compatibility_profile_id: str, binding: ActiveAcquisitionRecoveryTransportBinding, + allow_target_ipv4_change: bool = False, ) -> None: if ( binding.logical_device_id != identity.logical_device_id @@ -2602,7 +2622,10 @@ def _require_binding_matches_checkpoint_values( or binding.compatibility_profile_id != compatibility_profile_id or binding.transport_ref != connection.transport_ref or binding.connection_mode != connection.connection_mode - or binding.target_ipv4 != connection.target_ipv4 + or ( + not allow_target_ipv4_change + and binding.target_ipv4 != connection.target_ipv4 + ) or binding.target_port != connection.target_port ): raise ActiveAcquisitionRecoveryCheckpointTransitionError( @@ -2619,6 +2642,11 @@ def _require_status_binding( connection=checkpoint.connection, compatibility_profile_id=checkpoint.compatibility_profile_id, binding=proof.binding, + allow_target_ipv4_change=( + checkpoint.state != "prepared" + and checkpoint.last_gap_recovered_at_utc is not None + and proof.binding == checkpoint.current_binding + ), ) @@ -2654,6 +2682,8 @@ def _require_gap_cessation_evidence_session( def _require_physical_lineage_base( checkpoint: ActiveAcquisitionRecoveryCheckpoint, proof: ActiveAcquisitionRecoveryPhysicalLineageProof, + *, + allow_target_ipv4_change: bool = False, ) -> None: if ( proof.acquisition_id != checkpoint.acquisition_id @@ -2668,6 +2698,14 @@ def _require_physical_lineage_base( connection=checkpoint.connection, compatibility_profile_id=checkpoint.compatibility_profile_id, binding=proof.binding, + allow_target_ipv4_change=( + allow_target_ipv4_change + or ( + checkpoint.state != "prepared" + and checkpoint.last_gap_recovered_at_utc is not None + and proof.binding == checkpoint.current_binding + ) + ), ) @@ -3103,6 +3141,14 @@ def _require_prepared_cessation( status_proof: ActiveAcquisitionRecoveryStatusProof | None, physical_proof: ActiveAcquisitionRecoveryPhysicalLineageProof, ) -> None: + target_ipv4_changed = ( + physical_proof.binding.target_ipv4 != checkpoint.connection.target_ipv4 + ) + _require_physical_lineage_base( + checkpoint, + physical_proof, + allow_target_ipv4_change=target_ipv4_changed, + ) if ( physical_proof.operation_id != checkpoint.original_start_operation_id or physical_proof.action != "start" @@ -3138,7 +3184,26 @@ def _require_prepared_cessation( raise ActiveAcquisitionRecoveryCheckpointTransitionError( "prepared terminal reconciliation requires fresh READY/SCAN_OVER" ) - _require_status_binding(checkpoint, status_proof) + _require_binding_matches_checkpoint_values( + identity=checkpoint.identity, + connection=checkpoint.connection, + compatibility_profile_id=checkpoint.compatibility_profile_id, + binding=status_proof.binding, + allow_target_ipv4_change=target_ipv4_changed, + ) + if target_ipv4_changed and ( + status_proof.source != "explicit-read-only-reconciliation" + or status_proof.binding.runtime_instance_id + == checkpoint.prepared_binding.runtime_instance_id + or status_proof.binding.control_session_id + == checkpoint.prepared_binding.control_session_id + or status_proof.evidence_session_id + == checkpoint.original_evidence_session_id + ): + raise ActiveAcquisitionRecoveryCheckpointTransitionError( + "prepared target change requires fresh DeviceInfo-bound runtime, " + "control and evidence sessions" + ) _require_matching_observed_proofs( status_proof=status_proof, physical_proof=physical_proof, @@ -3331,16 +3396,13 @@ def _require_active_reconciled_standby_shape( == failed_evidence_session_id or ( same_runtime - and ( - recovery_binding.host_path_epoch == failed_binding.host_path_epoch - or recovery_binding.producer_generation - == failed_binding.producer_generation - ) + and recovery_binding.producer_generation + == failed_binding.producer_generation ) ): raise ActiveAcquisitionRecoveryCheckpointTransitionError( "standby settlement requires a new control/evidence boundary; " - "same-runtime recovery also requires a new host path and producer generation" + "same-runtime recovery also requires a new producer generation" ) gap_started = _validated_timestamp( @@ -4565,19 +4627,29 @@ def _validate_checkpoint_semantics( _validate_evidence_policy(checkpoint.evidence_policy) _validate_mount_type(checkpoint.mount_type) _validate_gnss_mode(checkpoint.gnss_mode) - for binding in (checkpoint.prepared_binding, checkpoint.current_binding): - _require_binding_matches_checkpoint_values( - identity=checkpoint.identity, - connection=checkpoint.connection, - compatibility_profile_id=checkpoint.compatibility_profile_id, - binding=binding, - ) + _require_binding_matches_checkpoint_values( + identity=checkpoint.identity, + connection=checkpoint.connection, + compatibility_profile_id=checkpoint.compatibility_profile_id, + binding=checkpoint.prepared_binding, + ) + _require_binding_matches_checkpoint_values( + identity=checkpoint.identity, + connection=checkpoint.connection, + compatibility_profile_id=checkpoint.compatibility_profile_id, + binding=checkpoint.current_binding, + allow_target_ipv4_change=( + checkpoint.state != "prepared" + and checkpoint.last_gap_recovered_at_utc is not None + ), + ) if checkpoint.last_gap_failed_binding is not None: _require_binding_matches_checkpoint_values( identity=checkpoint.identity, connection=checkpoint.connection, compatibility_profile_id=checkpoint.compatibility_profile_id, binding=checkpoint.last_gap_failed_binding, + allow_target_ipv4_change=(checkpoint.state != "prepared"), ) _canonical_timestamp_value(checkpoint.created_at_utc, field_name="created_at_utc") _canonical_timestamp_value(checkpoint.updated_at_utc, field_name="updated_at_utc") diff --git a/src/k1link/device_plugins/xgrids_k1/facade.py b/src/k1link/device_plugins/xgrids_k1/facade.py index be49053..46f5b92 100644 --- a/src/k1link/device_plugins/xgrids_k1/facade.py +++ b/src/k1link/device_plugins/xgrids_k1/facade.py @@ -3555,6 +3555,66 @@ class XgridsK1CompatibilityService: self._application_control_session.snapshot() ) + def _retire_verified_inspection_for_operator_dialogue( + self, + inspection: Mapping[str, Any], + ) -> None: + """Close Verify's ordinal-1 socket before a canonical operator dialogue. + + Verify is intentionally read-only and may remain open while the Park UI + waits for the next explicit action. The captured LixelGO dialogue does + not resume that old socket: ordinals 1-6 are one contiguous connection + stage on a fresh socket. Retiring the inspection here is local-only; + it cannot publish START, STOP, Wi-Fi credentials, or another K1 request. + """ + + generation = inspection.get("session_generation") + revision = inspection.get("state_revision") + if not isinstance(generation, int) or not isinstance(revision, int): + raise ApplicationAcceptanceError( + "read-only inspection checkpoint is unavailable" + ) + if not ( + inspection.get("state") == "connection-ready" + and inspection.get("inspection_only") is True + ): + raise ApplicationAcceptanceError( + "only a connection-ready inspection may be replaced" + ) + + self._application_control_session.close_prestart( + expected_session_generation=generation, + expected_state_revision=revision, + ) + deadline = time.monotonic() + CONTROL_LOCAL_RETIREMENT_TIMEOUT_SECONDS + while True: + terminal = dict(self._application_control_session.snapshot()) + if terminal.get("can_open") is True: + break + if time.monotonic() >= deadline: + # Bounded local socket teardown only. ``close`` never invents + # a physical STOP for a pre-START session. + self._application_control_session.close() + terminal = dict(self._application_control_session.snapshot()) + if terminal.get("can_open") is not True: + raise ApplicationAcceptanceError( + "read-only inspection worker is still retiring" + ) + break + time.sleep(0.05) + + self._retire_application_control_for_network_change( + allow_terminal_failure=True, + ) + logger.info( + "K1 read-only inspection retired before canonical operator dialogue", + extra={ + "event_code": "k1_control_inspection_retired_before_operator_dialogue", + "device_write_performed": False, + "automatic_retry": False, + }, + ) + def _retry_pending_local_control_retirement(self) -> bool: """Finish local socket retirement after its worker actually exits.""" @@ -4400,32 +4460,6 @@ class XgridsK1CompatibilityService: allow_receiver_rehydrate=False, ) ) - if ( - physical_reconciliation.get("resolution") - != "physical-active-observed" - ): - inspected = self._application_control_session.snapshot() - if ( - inspected.get("state") == "connection-ready" - and inspected.get("inspection_only") is True - and inspected.get("inspection_promotion_allowed") is not True - ): - generation = inspected.get("session_generation") - revision = inspected.get("state_revision") - if not isinstance(generation, int) or not isinstance( - revision, - int, - ): - raise ConnectionVerificationError( - "read-only continuation lost its inspection checkpoint", - reason_code=( - "control-bootstrap-inspection-checkpoint-invalid" - ), - ) - self._application_control_session.release_inspection_for_operator_dialogue( - expected_session_generation=generation, - expected_state_revision=revision, - ) if ( physical_reconciliation.get("resolution") == "physical-active-observed" @@ -14816,23 +14850,6 @@ class XgridsK1CompatibilityService: physical_reconciliation = await self._reconcile_physical_command_after_verify_owned( verify_operation_id=verify_operation_id, ) - verified_session = dict(self._application_control_session.snapshot()) - if ( - verified_session.get("state") == "connection-ready" - and verified_session.get("inspection_only") is True - and verified_session.get("inspection_promotion_allowed") is not True - ): - generation = verified_session.get("session_generation") - revision = verified_session.get("state_revision") - if not isinstance(generation, int) or not isinstance(revision, int): - raise ConnectionVerificationError( - "Verify не получил точный checkpoint inspection-сессии", - reason_code="connection-verify-control-checkpoint-invalid", - ) - self._application_control_session.release_inspection_for_operator_dialogue( - expected_session_generation=generation, - expected_state_revision=revision, - ) if provisional_topology is not None: control_stage = "semantic-topology-commit" self._commit_provisional_fresh_bridge_topology(provisional_topology) @@ -15023,6 +15040,59 @@ class XgridsK1CompatibilityService: or resolved_active_recovery_required or resolved_scan_over_recovery_required ): + # The physical-ledger reconciliation may have committed before + # an older process failed to persist the matching checkpoint + # cessation. A later explicit Verify must close that durable + # fsync gap from the immutable latest READY/SCAN_OVER proof; + # otherwise the UI can look connected while the next START is + # still fail-closed by a stale PREPARED checkpoint. This is a + # local projection only and never publishes a K1 command. + ledger_snapshot = self._physical_command_ledger.snapshot() + record = ledger_snapshot.record + latest_reconciliation = ( + record.reconciliations[-1] + if record is not None and record.reconciliations + else None + ) + checkpoint_settlement_required = bool( + checkpoint_trust_token is not None + and ledger_snapshot.status == "resolved" + and record is not None + and latest_reconciliation is not None + and latest_reconciliation.resolution + == "physical-standby-observed" + and latest_reconciliation.kind + in { + "ambiguous-outcome", + "prepared-stop-classification", + "resolved-active-cessation", + } + and latest_reconciliation.observation.source + == "explicit-read-only-reconciliation" + and latest_reconciliation.observation.session_state + in {"ready", "scan_over"} + and not latest_reconciliation.observation.project_bound + and not latest_reconciliation.observation.init_ready + and not latest_reconciliation.observation.mqtt_retained + ) + if checkpoint_settlement_required: + assert record is not None + assert latest_reconciliation is not None + settled = self._settle_restart_checkpoint_after_verified_standby( + token=checkpoint_trust_token, + reconciliation_id=( + latest_reconciliation.reconciliation_id + ), + reconciled_record=record.as_dict(), + ) + if not settled: + raise ConnectionVerificationError( + "Подтверждённый READY не закрыл старую локальную START-сессию", + reason_code=( + self._active_acquisition_checkpoint_reason + or "restart-standby-checkpoint-settlement-failed" + ), + ) return { "performed": False, "resolution": None, @@ -19052,6 +19122,14 @@ class XgridsK1CompatibilityService: if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES: raise RuntimeError("control-сессия должна быть открыта до подготовки acquisition") existing_control = self._application_control_session.snapshot() + if ( + existing_control.get("state") == "connection-ready" + and existing_control.get("inspection_only") is True + ): + self._retire_verified_inspection_for_operator_dialogue( + existing_control, + ) + existing_control = self._application_control_session.snapshot() if existing_control.get("state") in { "connection-ready", "workspace-ready", diff --git a/src/k1link/device_plugins/xgrids_k1/protocol/application_session.py b/src/k1link/device_plugins/xgrids_k1/protocol/application_session.py index 44282d1..7cb49db 100644 --- a/src/k1link/device_plugins/xgrids_k1/protocol/application_session.py +++ b/src/k1link/device_plugins/xgrids_k1/protocol/application_session.py @@ -265,7 +265,8 @@ class InteractiveApplicationControlSession: # Opening the MQTT dialogue begins with only the read-only DeviceInfo # bootstrap. Durable identity admission must accept that proof before # a normal session may continue its pre-START preparation; inspection - # sessions remain read-only until explicitly promoted. Physical + # sessions remain read-only and must retire before a normal dialogue. + # Physical # acceptance belongs to the exact START/STOP dispatch boundaries below; # a connection intent must never fabricate those confirmations merely # to prove that the selected K1 is ready. @@ -375,27 +376,6 @@ class InteractiveApplicationControlSession: self._workspace_requested.set() return self.snapshot() - def release_inspection_for_operator_dialogue( - self, - *, - expected_session_generation: int, - expected_state_revision: int, - ) -> dict[str, object]: - """End Verify's read-only boundary without sending another request.""" - - with self._lock: - self._require_checkpoint_locked( - expected_session_generation=expected_session_generation, - expected_state_revision=expected_state_revision, - ) - self._require_phase_locked("connection-ready") - if not self._inspection_only: - return self.snapshot() - if not self._inspection_promotion_allowed: - self._inspection_promotion_allowed = True - self._state_revision += 1 - return self.snapshot() - def validate_connection_binding(self) -> None: """Fail closed when the DeviceInfo-bound route lost command authority.""" diff --git a/tests/test_frontend_plugin_boundaries.py b/tests/test_frontend_plugin_boundaries.py index 80227c2..5ed4156 100644 --- a/tests/test_frontend_plugin_boundaries.py +++ b/tests/test_frontend_plugin_boundaries.py @@ -132,16 +132,16 @@ def test_xgrids_live_copy_exposes_response_gated_prepare_and_one_physical_start( assert "Запустить приём" in connection_source assert "Запустить K1" not in connection_source - assert "Одно нажатие выполняет каноническую подготовку и один START" in ( + assert "Одно нажатие выполняет каноническую подготовку и один START" not in ( connection_source ) assert "Проверить условия и отправить START" not in connection_source assert "Физический START отправляется только после отдельного финального окна" not in ( connection_source ) - assert "Имя войдёт в единственный канонический START" in connection_source - assert "один START после подтверждённого READY" in connection_source - assert "Автоматических повторов команд нет" in connection_source + assert "Имя войдёт в единственный канонический START" not in connection_source + assert "один START после подтверждённого READY" not in connection_source + assert "Автоматических повторов команд нет" not in connection_source assert "Подключить управление K1" not in connection_source assert "Открыть рабочее пространство K1" not in connection_source assert "Сохранить проект и подготовить локальный приём" not in connection_source diff --git a/tests/test_xgrids_acquisition_lifecycle.py b/tests/test_xgrids_acquisition_lifecycle.py index 3f0b59a..b2cbd41 100644 --- a/tests/test_xgrids_acquisition_lifecycle.py +++ b/tests/test_xgrids_acquisition_lifecycle.py @@ -675,20 +675,6 @@ class FakeInteractiveControlSession: self.state_revision += 1 return self.snapshot() - def release_inspection_for_operator_dialogue( - self, - *, - expected_session_generation: int, - expected_state_revision: int, - ) -> dict[str, object]: - self._accept_checkpoint( - expected_session_generation=expected_session_generation, - expected_state_revision=expected_state_revision, - ) - assert self.state == "connection-ready" - self.inspection_promotion_allowed = True - return self.snapshot() - def adopt_reconciled_scanning( self, *, @@ -6553,6 +6539,64 @@ def test_control_session_reuses_reachable_process_owned_connection_lease( service._release_application_control_process_lease() # noqa: SLF001 +def test_operator_control_open_replaces_verify_inspection_with_fresh_dialogue( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + service, _ = service_with_fake_runtime(tmp_path) + transitions: list[str] = [] + + class RecordingControlSession(FakeInteractiveControlSession): + def close_prestart(self, **kwargs: object) -> dict[str, object]: + transitions.append("inspection-closed") + return super().close_prestart(**kwargs) + + def retire_for_network_change(self, **kwargs: object) -> dict[str, object]: + transitions.append("inspection-retired") + return super().retire_for_network_change(**kwargs) + + def open(self, *, inspection_only: bool = False, **kwargs: object) -> dict[str, object]: + transitions.append( + "inspection-opened" if inspection_only else "canonical-opened" + ) + self.session_generation += 1 + return super().open(inspection_only=inspection_only, **kwargs) + + control = RecordingControlSession(initial_state="connection-ready") + control.session_generation = 7 + control.state_revision = 11 + control.inspection_only = True + control.inspection_promotion_allowed = False + service._application_control_session = control # type: ignore[assignment] # noqa: SLF001 + binding = _seed_supervised_connection(service) + control.verified_control = _verified_control_for_binding(binding) + service._acquire_application_control_process_lease() # noqa: SLF001 + + monkeypatch.setattr(facade_module, "_control_endpoint_reachable", lambda _target: True) + monkeypatch.setattr(facade_module, "_inspect_host_path", _direct_host_path) + + state = service.open_application_control_session( + OpenApplicationControlSessionRequest( + operator_present=True, + owner_controlled_device=True, + lixelgo_closed=True, + battery_storage_confirmed=True, + expected_physical_state_confirmed=True, + timezone_name="Europe/Moscow", + ) + ) + + assert transitions == [ + "inspection-closed", + "inspection-retired", + "canonical-opened", + ] + assert state["application_control_session"]["state"] == "connection-ready" + assert state["application_control_session"]["inspection_only"] is False + assert state["application_control_session"]["session_generation"] == 8 + service._release_application_control_process_lease() # noqa: SLF001 + + def test_reachable_connection_lease_supports_repeated_independent_control_sessions( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -27705,7 +27749,7 @@ def test_reset_retired_apply_continuation_settles_without_receiver_rehydrate( else: assert control["state"] == "connection-ready" assert control["inspection_only"] is True - assert control["inspection_promotion_allowed"] is True + assert control["inspection_promotion_allowed"] is False assert settled["connection_attempt"]["safe_next_action"] == ( "start-acquisition" ) diff --git a/tests/test_xgrids_active_acquisition_checkpoint_integration.py b/tests/test_xgrids_active_acquisition_checkpoint_integration.py index c4b4f3f..72673b5 100644 --- a/tests/test_xgrids_active_acquisition_checkpoint_integration.py +++ b/tests/test_xgrids_active_acquisition_checkpoint_integration.py @@ -521,7 +521,11 @@ def test_ready_after_dispatched_stop_ceases_active_checkpoint( ) restarted = _connection( control_session_id="checkpoint-dispatched-stop-restarted-control", - host_path_epoch=2, + # A read-only reconnect on the same Wi-Fi route legitimately keeps the + # host-path epoch. Fresh control/evidence and producer generations are + # the replay fences; requiring a route change strands a confirmed READY + # behind an ACTIVE checkpoint, as observed in the live Bridge flow. + host_path_epoch=(original.host_path_epoch if same_process else 2), producer_generation=2, ) coordinator = service._physical_command_coordinator # noqa: SLF001 @@ -593,7 +597,8 @@ def test_ready_after_dispatched_stop_ceases_active_checkpoint( # The physical ledger fsync is durable while checkpoint cessation did not # commit. Recovery may happen after a process restart or, as observed in - # production, later in the same process under a fresh control/path epoch. + # production, later in the same process under a fresh control epoch while + # the host route itself remains unchanged. if same_process: monkeypatch.setattr( service, diff --git a/tests/test_xgrids_active_acquisition_recovery_checkpoint.py b/tests/test_xgrids_active_acquisition_recovery_checkpoint.py index 2248b73..be3a949 100644 --- a/tests/test_xgrids_active_acquisition_recovery_checkpoint.py +++ b/tests/test_xgrids_active_acquisition_recovery_checkpoint.py @@ -87,13 +87,14 @@ def _binding( host_path_epoch: int = 10, control_session_id: str = "control-A", producer_generation: int = 7, + target_ipv4: str = CONNECTION.target_ipv4, ) -> ActiveAcquisitionRecoveryTransportBinding: return ActiveAcquisitionRecoveryTransportBinding( runtime_instance_id=runtime_instance_id, intent_id="intent-001", transport_ref=CONNECTION.transport_ref, connection_mode=CONNECTION.connection_mode, - target_ipv4=CONNECTION.target_ipv4, + target_ipv4=target_ipv4, target_port=CONNECTION.target_port, host_path_epoch=host_path_epoch, control_session_id=control_session_id, @@ -1313,6 +1314,7 @@ def test_cease_active_reconciled_standby_rejects_inexact_restart_proofs( status.binding, runtime_instance_id=binding.runtime_instance_id, host_path_epoch=binding.host_path_epoch, + producer_generation=binding.producer_generation, ), replace( status.binding, @@ -2211,6 +2213,101 @@ def test_prepared_crash_cannot_cease_on_status_alone( assert ceased.prepared_resolution_proof is not None +def test_prepared_ambiguous_start_standby_accepts_verified_dhcp_target_change( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + prepared_binding = _binding() + store = _store(tmp_path, monkeypatch) + prepared = _prepare(store, prepared_binding) + recovered_binding = _binding( + runtime_instance_id="runtime-recovered-new-address", + host_path_epoch=1, + control_session_id="control-recovered-new-address", + producer_generation=1, + target_ipv4="192.168.43.54", + ) + ready = _status( + "ready", + binding=recovered_binding, + evidence_session_id="evidence-recovered-new-address", + observed_at="2026-08-13T12:01:00.000Z", + ) + physical = _physical( + "physical-standby-observed", + binding=recovered_binding, + proof_id="physical-ambiguous-ready-new-address", + observed_at=ready.observed_at_utc, + ) + + ceased = store.cease( + transition_id="transition-cease-ambiguous-new-address", + expected_revision=prepared.revision, + expected_acquisition_id=ACQUISITION_ID, + expected_start_operation_id=START_OPERATION_ID, + status_proof=ready, + physical_proof=physical, + ) + + assert ceased.state == "ceased" + assert ceased.transport_revision == prepared.transport_revision + 1 + assert ceased.prepared_binding == prepared_binding + assert ceased.current_binding == recovered_binding + assert ceased.last_gap_failed_binding == prepared_binding + assert ceased.last_gap_started_transport_revision == prepared.transport_revision + assert ceased.last_gap_recovered_transport_revision == ceased.transport_revision + assert ceased.cessation_status_proof == ready + assert ceased.cessation_physical_proof == physical + assert ( + ActiveAcquisitionRecoveryCheckpointStore(tmp_path / "repository") + .snapshot() + .checkpoint + == ceased + ) + + +def test_prepared_dhcp_target_change_rejects_stale_runtime_proof( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + prepared_binding = _binding() + store = _store(tmp_path, monkeypatch) + prepared = _prepare(store, prepared_binding) + stale_binding = _binding( + runtime_instance_id=prepared_binding.runtime_instance_id, + host_path_epoch=prepared_binding.host_path_epoch + 1, + control_session_id=prepared_binding.control_session_id, + producer_generation=prepared_binding.producer_generation + 1, + target_ipv4="192.168.43.54", + ) + ready = _status( + "ready", + binding=stale_binding, + evidence_session_id="evidence-recovered-new-address", + observed_at="2026-08-13T12:01:00.000Z", + ) + + with pytest.raises( + ActiveAcquisitionRecoveryCheckpointTransitionError, + match="target change requires fresh DeviceInfo-bound runtime", + ): + store.cease( + transition_id="transition-reject-stale-new-address", + expected_revision=prepared.revision, + expected_acquisition_id=ACQUISITION_ID, + expected_start_operation_id=START_OPERATION_ID, + status_proof=ready, + physical_proof=_physical( + "physical-standby-observed", + binding=stale_binding, + proof_id="physical-stale-ready-new-address", + observed_at=ready.observed_at_utc, + ), + ) + + assert store.snapshot().checkpoint == prepared + + def test_active_cease_requires_exact_current_binding_and_physical_stop( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_xgrids_active_acquisition_restart_rehydration.py b/tests/test_xgrids_active_acquisition_restart_rehydration.py index 666da31..d996e08 100644 --- a/tests/test_xgrids_active_acquisition_restart_rehydration.py +++ b/tests/test_xgrids_active_acquisition_restart_rehydration.py @@ -828,6 +828,102 @@ def test_ambiguous_prepared_restart_waits_for_first_pcl_before_activation( assert ledger_record.resolution == "physical-active-observed" +def test_ambiguous_prepared_restart_ready_settles_after_dhcp_target_change( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + first = restart_support._service(tmp_path, monkeypatch) + store = restart_support._seed_matching_prepared_start(first) + ledger = first._physical_command_ledger # noqa: SLF001 + original = restart_support._prepared_connection() + ledger.mark_dispatching(restart_support.START_OPERATION_ID) + ledger.mark_observing( + restart_support.START_OPERATION_ID, + publish_call_returned=True, + packet_id=41, + ) + ledger.mark_qos2_completed(restart_support.START_OPERATION_ID, packet_id=41) + ledger.record_application_response( + restart_support.START_OPERATION_ID, + restart_support.PhysicalCommandApplicationResponse( + operation_id=restart_support.START_OPERATION_ID, + action="start", + control_session_id=original.control_session_id, + host_path_epoch=original.host_path_epoch, + producer_generation=original.producer_generation, + result_code=restart_support.PHYSICAL_COMMAND_APPLICATION_SUCCESS_CODE, + success=True, + payload_sha256="f" * 64, + observed_at_utc="2026-08-13T12:00:01.000Z", + ), + ) + + restarted = restart_support._service(tmp_path, monkeypatch) + token = restarted._validate_active_acquisition_checkpoint_lineage() # noqa: SLF001 + assert token is not None + recovered = restart_support.PhysicalCommandConnectionBinding( + intent_id=original.intent_id, + transport_ref=original.transport_ref, + connection_mode=original.connection_mode, + target_ipv4="192.168.68.54", + target_port=original.target_port, + host_path_epoch=2, + control_session_id="control-restart-safety-new-address", + producer_generation=2, + ) + ready = restart_support.PhysicalCommandStatusEvidence( + source="explicit-read-only-reconciliation", + vendor_device_id_sha256=restart_support.VENDOR_SHA256, + device_serial_sha256=restart_support.SERIAL_SHA256, + control_session_id=recovered.control_session_id, + host_path_epoch=recovered.host_path_epoch, + producer_generation=recovered.producer_generation, + session_state="ready", + session_state_code=300, + project_bound=False, + project_id_sha256=None, + init_ready=False, + status_message_sha256="9" * 64, + mqtt_retained=False, + observed_at_utc="2026-08-13T12:20:01.000Z", + ) + record = restarted._physical_command_ledger.reconcile_ambiguous( # noqa: SLF001 + restart_support.START_OPERATION_ID, + reconciliation_id="reconciliation-ready-new-dhcp-address", + resolution="physical-standby-observed", + verified_binding=restart_support.PhysicalCommandVerifiedBinding( + verification_id="verification-ready-new-dhcp-address", + identity=restart_support.PhysicalCommandIdentity( + vendor_device_id_sha256=restart_support.VENDOR_SHA256, + device_serial_sha256=restart_support.SERIAL_SHA256, + ), + connection=recovered, + device_info_message_sha256="8" * 64, + verified_at_utc="2026-08-13T12:20:00.000Z", + ), + observation=ready, + ) + + assert ( + restarted._physical_command_coordinator.snapshot()[ # noqa: SLF001 + "recovery_requirement" + ] + is None + ) + result = asyncio.run( + restarted._reconcile_physical_command_after_verify_owned( # noqa: SLF001 + verify_operation_id="verify-catches-up-checkpoint-new-address", + ) + ) + assert result["performed"] is False + checkpoint = store.snapshot().checkpoint + assert checkpoint is not None + assert checkpoint.state == "ceased" + assert checkpoint.prepared_binding.target_ipv4 == "192.168.68.52" + assert checkpoint.current_binding.target_ipv4 == "192.168.68.54" + assert checkpoint.transport_revision == 2 + + def test_composite_prepared_restart_uses_exact_origin_and_zero_start_replay( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_xgrids_application_session.py b/tests/test_xgrids_application_session.py index 8834a69..0aca0fb 100644 --- a/tests/test_xgrids_application_session.py +++ b/tests/test_xgrids_application_session.py @@ -527,6 +527,7 @@ def test_read_only_device_info_open_does_not_require_physical_acceptance( ready = _wait_phase(session, "connection-ready") assert ready["verified_control"] is not None + assert FakeExecutor.records[:2] == ["inspection:1", "connection:2-6"] assert coordinator.prepares == [] assert "start:11-14" not in FakeExecutor.records assert "stop" not in FakeExecutor.records @@ -536,7 +537,7 @@ def test_read_only_device_info_open_does_not_require_physical_acceptance( ) -def test_inspection_session_rejects_workspace_until_verify_releases_boundary( +def test_inspection_session_rejects_workspace_and_closes_without_connection_stage( monkeypatch: pytest.MonkeyPatch, ) -> None: FakeExecutor.records = [] @@ -561,21 +562,12 @@ def test_inspection_session_rejects_workspace_until_verify_releases_boundary( ) assert FakeExecutor.records == ["inspection:1", "wait:workspace-entered"] - released = session.release_inspection_for_operator_dialogue( + session.close_prestart( expected_session_generation=ready["session_generation"], # type: ignore[arg-type] expected_state_revision=ready["state_revision"], # type: ignore[arg-type] ) - session.enter_workspace( - expected_session_generation=released["session_generation"], # type: ignore[arg-type] - expected_state_revision=released["state_revision"], # type: ignore[arg-type] - ) - _wait_phase(session, "workspace-ready") - assert FakeExecutor.records[:4] == [ - "inspection:1", - "wait:workspace-entered", - "connection:2-6", - "workspace:7", - ] + _wait_phase(session, "closed") + assert FakeExecutor.records == ["inspection:1", "wait:workspace-entered"] @pytest.mark.parametrize(