diff --git a/apps/control-station/src/core/observation/useObservationLayout.ts b/apps/control-station/src/core/observation/useObservationLayout.ts index 460a7bd..22c13ac 100644 --- a/apps/control-station/src/core/observation/useObservationLayout.ts +++ b/apps/control-station/src/core/observation/useObservationLayout.ts @@ -96,6 +96,23 @@ export function livePresentationCloseFence( return acquisitionId ? JSON.stringify([source.id, acquisitionId]) : null; } +function acquisitionIdFromLivePresentationCloseFence(fence: string): string | null { + try { + const decoded: unknown = JSON.parse(fence); + if ( + !Array.isArray(decoded) + || decoded.length !== 2 + || typeof decoded[0] !== "string" + || typeof decoded[1] !== "string" + || !decoded[0].trim() + || !decoded[1].trim() + ) return null; + return decoded[1].trim(); + } catch { + return null; + } +} + export interface LiveDefaultPresentationAdmission { visibleIds: string[]; removedIds: string[]; @@ -132,9 +149,11 @@ export function admitLiveDefaultPresentations( /** * A restored workspace may hide cameras that the device plugin has not selected. * It must still admit the exact selected delivery in a fresh browser document, - * or a replacement delivery for a camera already presented in this acquisition. + * a replacement delivery for a camera already presented in this acquisition, + * or the first delivery of a later acquisition in the same browser document. * An explicit close remains a separate acquisition-scoped fence in - * `admitLiveDefaultPresentations` and de-selects the source in the plugin first. + * `admitLiveDefaultPresentations`; whether it may de-select the source remains + * the plugin's current activation-authority decision. */ export function restoredLayoutMayAdmitLiveDefault( sources: readonly ObservationSourceDescriptor[], @@ -145,7 +164,13 @@ export function restoredLayoutMayAdmitLiveDefault( if (presentedAcquisitionSources.size === 0) return true; return selectedSources.some((source) => { const lineage = livePresentationCloseFence(source); - return Boolean(lineage && presentedAcquisitionSources.has(lineage)); + if (!lineage) return false; + if (presentedAcquisitionSources.has(lineage)) return true; + const acquisitionId = source.binding.acquisitionId?.trim(); + if (!acquisitionId) return false; + return ![...presentedAcquisitionSources].some( + (presented) => acquisitionIdFromLivePresentationCloseFence(presented) === acquisitionId, + ); }); } diff --git a/apps/control-station/test/k1SupervisorPresentation.test.mjs b/apps/control-station/test/k1SupervisorPresentation.test.mjs index b26c255..4e2ce5d 100644 --- a/apps/control-station/test/k1SupervisorPresentation.test.mjs +++ b/apps/control-station/test/k1SupervisorPresentation.test.mjs @@ -5355,6 +5355,55 @@ test("an unresolved physical command has one explicit server-bound read-only rec /Результаты последнего Bluetooth-поиска|nearby-unrelated-device|>Выбрать<|>Применить { diff --git a/apps/control-station/test/workspaceLayout.test.mjs b/apps/control-station/test/workspaceLayout.test.mjs index 64a7bf3..f5a7c20 100644 --- a/apps/control-station/test/workspaceLayout.test.mjs +++ b/apps/control-station/test/workspaceLayout.test.mjs @@ -310,7 +310,7 @@ test("a sequential live acquisition re-arms the same camera without reopening a ]); }); -test("a restored layout admits a selected delivery after reload and only same-acquisition successors", () => { +test("a restored layout admits same-lineage replacements and the next acquisition", () => { const camera = (acquisitionId, deliveryId) => ({ id: "k1:sensor.camera.right", sourceId: "sensor.camera.right", @@ -344,7 +344,11 @@ test("a restored layout admits a selected delivery after reload and only same-ac assert.equal(restoredLayoutMayAdmitLiveDefault([original], new Set()), true); assert.equal(restoredLayoutMayAdmitLiveDefault([successor], presented), true); - assert.equal(restoredLayoutMayAdmitLiveDefault([unrelated], presented), false); + assert.equal( + restoredLayoutMayAdmitLiveDefault([unrelated], presented), + true, + "Bridge acquisition A must not suppress the first Quick Connect camera in acquisition B", + ); assert.equal(restoredLayoutMayAdmitLiveDefault([{ ...original, activation: { ...original.activation, selected: false }, diff --git a/plugins/xgrids-k1/frontend/src/api.ts b/plugins/xgrids-k1/frontend/src/api.ts index 0ac088a..4bfbe8e 100644 --- a/plugins/xgrids-k1/frontend/src/api.ts +++ b/plugins/xgrids-k1/frontend/src/api.ts @@ -1139,6 +1139,7 @@ interface ConnectionVerifyRequestBase { device_id: string; compatibility_attestation: CompatibilityAttestation; operation_id?: string; + expected_mode_revision: number; } export type ConnectionVerifyRequest = diff --git a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx index f91325a..c9d054d 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx @@ -819,11 +819,16 @@ export function localProvisioningDraftFenceKey({ function observationRequest( target: ReadOnlyConnectionObservationTarget, + desiredModeRevision: number | null | undefined, reconfigurationRevision: number, reconfigurationIntentId: string | null, ): ConnectionVerifyRequest | null { + if (!Number.isInteger(desiredModeRevision) || (desiredModeRevision ?? -1) < 0) { + return null; + } const commonRequest = { device_id: target.deviceId, + expected_mode_revision: desiredModeRevision as number, compatibility_attestation: profileSelectionForConnectionMode( target.connectionMode, ), @@ -1688,7 +1693,8 @@ export function K1ProvisioningPipeline({ currentReadOnlyReconnectPresentation?.kind === "connection" && currentReadOnlyReconnectPresentation.connectionMode === connectionMode; const physicalRecoveryVerificationPending = - currentReadOnlyReconnectPresentation?.kind === "physical"; + currentReadOnlyReconnectPresentation?.kind === "physical" + && currentReadOnlyReconnectPresentation.connectionMode === connectionMode; const provisioningMutationBusy = Boolean( livePreparationPending || presentedPendingAction @@ -1886,8 +1892,15 @@ export function K1ProvisioningPipeline({ const physicalRecoveryAuthorityKey = physicalRecoveryPresentationAuthorityKey( state, ); + const physicalRecoveryMatchesSelectedMode = Boolean( + physicalRecoveryTarget?.connectionMode === connectionMode + && state?.desired_connection_mode === connectionMode, + ); const physicalReadOnlyVerificationAvailable = Boolean( - physicalRecoveryTarget?.serverBound || physicalRecoveryVerificationPending, + ( + physicalRecoveryMatchesSelectedMode + && physicalRecoveryTarget?.serverBound + ) || physicalRecoveryVerificationPending, ); const presentedPhysicalRecoveryMode = physicalRecoveryBinding?.connectionMode ?? (physicalRecoveryVerificationPending @@ -2588,6 +2601,7 @@ export function K1ProvisioningPipeline({ const request = appliedNetworkRecoveryTarget ? observationRequest( appliedNetworkRecoveryTarget, + desiredModeRevision, reconfigurationRevision, reconfigurationIntentId, ) @@ -2644,6 +2658,7 @@ export function K1ProvisioningPipeline({ async (target) => { const request = observationRequest( target, + desiredModeRevision, reconfigurationRevision, reconfigurationIntentId, ); @@ -2709,12 +2724,23 @@ export function K1ProvisioningPipeline({ if ( !physicalRecoveryRequired || !physicalRecoveryTarget?.serverBound + || physicalRecoveryTarget.connectionMode !== connectionMode + || state?.desired_connection_mode !== connectionMode || !snapshotRuntimeId || !verificationKey || isBusy + || typeof getConnectionActionAuthority !== "function" ) return; + const modeAuthority = getConnectionActionAuthority(connectionMode); + if (!modeAuthority) { + setCandidateUnavailableMessage( + "Не удалось переподключиться: состояние K1 изменилось. Повторите проверку или подключите новый K1 вручную.", + ); + return; + } const request = observationRequest( physicalRecoveryTarget, + modeAuthority.desiredModeRevision, reconfigurationRevision, reconfigurationIntentId, ); @@ -2734,19 +2760,10 @@ export function K1ProvisioningPipeline({ }); setCandidateUnavailableMessage(null); try { - const modeAuthority = await commitDesiredModeForExplicitAction( - physicalRecoveryTarget.connectionMode, - ); if (!localScenarioActionEpochIsCurrent( actionEpoch, localScenarioActionEpoch.current, )) return; - if (!modeAuthority) { - setCandidateUnavailableMessage( - "Не удалось переподключиться: состояние K1 изменилось. Повторите проверку или подключите новый K1 вручную.", - ); - return; - } const actionFence = activateRuntimeActionFence(modeAuthority); if (!actionFence || !runtimeActionIsCurrent(actionFence)) return; try { diff --git a/scripts/check_k1_quick_connect_guardrails.py b/scripts/check_k1_quick_connect_guardrails.py index ab201b4..abf2592 100755 --- a/scripts/check_k1_quick_connect_guardrails.py +++ b/scripts/check_k1_quick_connect_guardrails.py @@ -120,6 +120,18 @@ PYTEST_TARGETS = ( "tests/test_xgrids_acquisition_lifecycle.py::" "test_quick_to_bridge_does_not_retrieve_old_handle_without_new_scan" ), + ( + "tests/test_xgrids_acquisition_lifecycle.py::" + "test_quick_verify_is_rejected_while_bridge_is_selected_before_any_io" + ), + ( + "tests/test_xgrids_acquisition_lifecycle.py::" + "test_physical_recovery_cannot_silently_change_bridge_draft_to_quick" + ), + ( + "tests/test_xgrids_acquisition_lifecycle.py::" + "test_public_quick_physical_recovery_rejoins_saved_wifi_after_live_ble_proof" + ), ) diff --git a/src/k1link/device_plugins/xgrids_k1/facade.py b/src/k1link/device_plugins/xgrids_k1/facade.py index c06c3a2..a754fdc 100644 --- a/src/k1link/device_plugins/xgrids_k1/facade.py +++ b/src/k1link/device_plugins/xgrids_k1/facade.py @@ -1076,6 +1076,10 @@ class ConnectionVerifyRequest(StrictRequest): ) compatibility_attestation: CompatibilityAttestationRequest | None = None operation_id: str | None = Field(default=None, min_length=1, max_length=128) + # New clients bind Verify to the exact mode draft rendered at the click. + # Optionality keeps the legacy server-resolved `{}` request and older + # local clients readable; mode equality remains mandatory either way. + expected_mode_revision: int | None = Field(default=None, ge=0) expected_discovery_generation: int | None = Field(default=None, ge=0) expected_reconfiguration_revision: int | None = Field(default=None, ge=0) expected_reconfiguration_intent_id: str | None = Field( @@ -8236,7 +8240,10 @@ class XgridsK1CompatibilityService: active_profile_id, device_session_id, camera_preview, - activation_admitted=camera_activation_admitted, + # Automatic acquisition camera ownership is deliberately not + # manual preview authority. The selected delivery remains + # visible, but source controls cannot detach evidence. + activation_admitted=(camera_activation_admitted and not acquisition_active), ), "device_calibration": device_calibration, "camera_preview": camera_preview, @@ -9400,6 +9407,22 @@ class XgridsK1CompatibilityService: runtime = self.runtime.snapshot() control = self._application_control_session.snapshot() physical = self._physical_command_coordinator.snapshot() + physical_recovery_requires_explicit_reset = bool( + physical.get("resolved_unclassified_stop_recovery_required") + is True + ) + if physical_recovery_requires_explicit_reset: + # A recovery card is bound to the mode recorded by the + # physical ledger. An old browser must never turn its + # action into a hidden mode change; only the visible + # scenario-reset control may retire that ownership. + raise NetworkProvisioningConflict( + "незавершённое физическое состояние K1 требует явного " + "сброса сценария перед сменой способа подключения", + reason_code=( + "connection-mode-selection-physical-recovery-reset-required" + ), + ) acquisition_state = acquisition.state if acquisition is not None else None selection_reasons = _connection_mode_selection_reason_codes( acquisition_state=acquisition_state, @@ -9694,6 +9717,39 @@ class XgridsK1CompatibilityService: reason_code="network-provision-discovery-generation-conflict", ) + def _require_connection_mode_draft_for_verify( + self, + requested_mode: ConnectionMode | None, + *, + expected_mode_revision: int | None, + ) -> None: + """Fence Verify before BLE, host Wi-Fi, MQTT or topology mutation.""" + + with self._lock: + desired = self._desired_connection_mode + revision = self._desired_connection_mode_revision + scenario_reset_pending = self._connection_scenario_reset_pending is not None + if scenario_reset_pending: + raise ConnectionVerificationError( + "сначала завершается явная смена сценария подключения", + reason_code="connection-mode-switch-pending", + ) + if requested_mode is None: + raise ConnectionVerificationError( + "Read-only проверка K1 не получила точный способ подключения", + reason_code="connection-verify-connection-missing", + ) + if expected_mode_revision is not None and expected_mode_revision != revision: + raise ConnectionVerificationError( + "способ подключения изменился до начала проверки", + reason_code="connection-mode-draft-revision-conflict", + ) + if requested_mode != desired: + raise ConnectionVerificationError( + "проверяемое подключение не совпадает с выбранным способом", + reason_code="connection-mode-draft-mismatch", + ) + def _require_current_connection_reconfiguration_target( self, *, @@ -15700,6 +15756,10 @@ class XgridsK1CompatibilityService: else None ) try: + self._require_connection_mode_draft_for_verify( + cast(ConnectionMode | None, requested_mode), + expected_mode_revision=request.expected_mode_revision, + ) self._require_current_connection_reconfiguration_target( expected_revision=request.expected_reconfiguration_revision, expected_intent_id=request.expected_reconfiguration_intent_id, @@ -15746,6 +15806,10 @@ class XgridsK1CompatibilityService: "фоновая проверка подключения не завершилась вовремя", reason_code="connection-verify-lifecycle-busy", ) + self._require_connection_mode_draft_for_verify( + cast(ConnectionMode | None, requested_mode), + expected_mode_revision=request.expected_mode_revision, + ) self._require_current_connection_reconfiguration_target( expected_revision=request.expected_reconfiguration_revision, expected_intent_id=request.expected_reconfiguration_intent_id, @@ -17944,6 +18008,7 @@ class XgridsK1CompatibilityService: if isinstance(item.get("device_id"), str) and str(item.get("device_id")).strip() } discovery_generation = self._ble_discovery_generation + desired_mode_revision = self._desired_connection_mode_revision selected_device_id = self._selected_device_id selected_connection_mode = self._connection_mode selected_device_session_id = self._device_session_id @@ -18026,6 +18091,11 @@ class XgridsK1CompatibilityService: verification="live-device-info", ), operation_id=request.operation_id, + expected_mode_revision=( + request.expected_mode_revision + if request.expected_mode_revision is not None + else desired_mode_revision + ), expected_discovery_generation=( discovery_generation if source == "fresh-scan" else None ), @@ -18054,7 +18124,11 @@ class XgridsK1CompatibilityService: "direct-lan": "bridge", "device-ap": "quick-connect", "controller-hotspot": "direct-connect", - }[request.compatibility_attestation.topology], + }[request.compatibility_attestation.topology], + ) + self._require_connection_mode_draft_for_verify( + requested_mode, + expected_mode_revision=request.expected_mode_revision, ) self._require_current_connection_reconfiguration_target( expected_revision=request.expected_reconfiguration_revision, @@ -18085,6 +18159,7 @@ class XgridsK1CompatibilityService: { "device_id": request.device_id, "source": request.source, + "expected_mode_revision": request.expected_mode_revision, "expected_discovery_generation": (request.expected_discovery_generation), "expected_reconfiguration_revision": (request.expected_reconfiguration_revision), "expected_reconfiguration_intent_id": (request.expected_reconfiguration_intent_id), @@ -18222,6 +18297,7 @@ class XgridsK1CompatibilityService: request.compatibility_attestation, verification_source=request.source, expected_discovery_generation=(request.expected_discovery_generation), + expected_mode_revision=request.expected_mode_revision, require_live_gatt_validation=(physical_recovery_target is not None), ) except asyncio.CancelledError: @@ -18339,9 +18415,14 @@ class XgridsK1CompatibilityService: *, transport_ref: str, target_ipv4: str, + expected_mode_revision: int | None, ) -> _SavedQuickConnectHostAssociation: """Restore the controller-side AP route without touching K1 over BLE.""" + self._require_connection_mode_draft_for_verify( + "quick-connect", + expected_mode_revision=expected_mode_revision, + ) binding = _recover_durable_quick_connect_host_binding( self.evidence_root, transport_ref=transport_ref, @@ -18401,6 +18482,13 @@ class XgridsK1CompatibilityService: ) try: if association_performed: + # Re-sample immediately before the only host-network mutation. + # A queued scenario reset or stale UI revision wins without + # invoking CoreWLAN. + self._require_connection_mode_draft_for_verify( + "quick-connect", + expected_mode_revision=expected_mode_revision, + ) association = await _run_blocking_operation_without_abandonment( associate_with_wifi_profile_once, self.repository_root @@ -18504,6 +18592,109 @@ class XgridsK1CompatibilityService: evidence_session_dir=session_dir, ) + async def _restore_saved_quick_connect_host_path( + self, + *, + transport_ref: str, + target_ipv4: str, + ble_operation_performed: bool, + expected_mode_revision: int | None, + ) -> tuple[ + _SavedQuickConnectHostAssociation, + _ConfiguredEndpointHostObservation, + int, + ]: + """Restore one exact saved AP route and prove its sole MQTT endpoint.""" + + association = await self._associate_saved_quick_connect_host( + transport_ref=transport_ref, + target_ipv4=target_ipv4, + expected_mode_revision=expected_mode_revision, + ) + supervisor_epoch_before = self._connection_supervisor.snapshot().host_path.epoch + try: + observation = await _run_blocking_operation_without_abandonment( + _probe_quick_connect_endpoint_after_route_settle, + target_ipv4, + path_probe=lambda target: self._sample_host_path( + target, + association_timeout_seconds=( + CONNECTION_MONITOR_ASSOCIATION_TIMEOUT_SECONDS + ), + ), + settle_timeout_seconds=( + DURABLE_QUICK_CONNECT_ROUTE_SETTLE_TIMEOUT_SECONDS + ), + settle_interval_seconds=( + DURABLE_QUICK_CONNECT_ROUTE_SETTLE_INTERVAL_SECONDS + ), + ) + except Exception as exc: + _write_quick_connect_host_network_proof( + association, + target_ipv4=target_ipv4, + observation=None, + outcome="probe-failed", + reason_code=( + getattr(exc, "reason_code", None) or "host-network-proof-failed" + ), + supervisor_host_path_epoch_before_admission=supervisor_epoch_before, + ble_operation_performed=ble_operation_performed, + ) + raise + if observation.reason_code == "host-route-changed-during-tcp-probe": + _write_quick_connect_host_network_proof( + association, + target_ipv4=target_ipv4, + observation=observation, + outcome="route-changed", + reason_code="connection-verify-lease-changed", + supervisor_host_path_epoch_before_admission=supervisor_epoch_before, + ble_operation_performed=ble_operation_performed, + ) + raise ConnectionVerificationError( + "Маршрут к K1 изменился во время Quick Connect проверки", + reason_code="connection-verify-lease-changed", + ) + if not observation.path.available or observation.path.route_class != "direct": + _write_quick_connect_host_network_proof( + association, + target_ipv4=target_ipv4, + observation=observation, + outcome="route-rejected", + reason_code="connection-verify-route-mismatch", + supervisor_host_path_epoch_before_admission=supervisor_epoch_before, + ble_operation_performed=ble_operation_performed, + ) + raise ConnectionVerificationError( + "После подключения Quick Connect прямой маршрут к K1 не подтверждён", + reason_code="connection-verify-route-mismatch", + ) + if not observation.reachable: + _write_quick_connect_host_network_proof( + association, + target_ipv4=target_ipv4, + observation=observation, + outcome="endpoint-unreachable", + reason_code="connection-verify-mqtt-unreachable", + supervisor_host_path_epoch_before_admission=supervisor_epoch_before, + ble_operation_performed=ble_operation_performed, + ) + raise ConnectionVerificationError( + "Прямой маршрут Quick Connect подтверждён, но MQTT endpoint K1 недоступен", + reason_code="connection-verify-mqtt-unreachable", + ) + _write_quick_connect_host_network_proof( + association, + target_ipv4=target_ipv4, + observation=observation, + outcome="direct-endpoint-reachable", + reason_code=None, + supervisor_host_path_epoch_before_admission=supervisor_epoch_before, + ble_operation_performed=ble_operation_performed, + ) + return association, observation, supervisor_epoch_before + async def _adopt_existing_lan_connection( self, device_id: str, @@ -18513,6 +18704,7 @@ class XgridsK1CompatibilityService: "fresh-scan", "retained-current-process", "durable-configured-state" ] = "fresh-scan", expected_discovery_generation: int | None = None, + expected_mode_revision: int | None = None, require_live_gatt_validation: bool = False, ) -> tuple[str, _ProvisionalFreshBridgeTopology | None]: """Create a process lease from live or persisted device topology evidence.""" @@ -18526,6 +18718,10 @@ class XgridsK1CompatibilityService: requested_mode = "direct-connect" else: raise ValueError("read-only K1 adoption received an unsupported topology") + self._require_connection_mode_draft_for_verify( + requested_mode, + expected_mode_revision=expected_mode_revision, + ) with self._lock: scanned_device = next( ( @@ -18877,6 +19073,10 @@ class XgridsK1CompatibilityService: # Publish the BLE verification fence before awaiting native I/O. # A scan cannot invalidate a capture or its validation handoff in # the post-GATT/pre-pin window. + self._require_connection_mode_draft_for_verify( + requested_mode, + expected_mode_revision=expected_mode_revision, + ) with self._lock: if self._provisioning_active: raise RuntimeError("настройка Wi-Fi началась во время проверки сети") @@ -18897,9 +19097,14 @@ class XgridsK1CompatibilityService: quick_host_association: _SavedQuickConnectHostAssociation | None = None quick_supervisor_epoch_before: int | None = None if requested_mode == "quick-connect": + self._require_connection_mode_draft_for_verify( + requested_mode, + expected_mode_revision=expected_mode_revision, + ) quick_host_association = await self._associate_saved_quick_connect_host( transport_ref=actual_transport_ref, target_ipv4=semantic_record.ipv4, + expected_mode_revision=expected_mode_revision, ) quick_supervisor_epoch_before = ( self._connection_supervisor.snapshot().host_path.epoch @@ -19132,6 +19337,10 @@ class XgridsK1CompatibilityService: transport_source == "durable-state" and require_live_gatt_validation ) + self._require_connection_mode_draft_for_verify( + requested_mode, + expected_mode_revision=expected_mode_revision, + ) status_read = await read_wifi_status_once( actual_transport_ref, timeout_seconds=20.0, @@ -19372,6 +19581,38 @@ class XgridsK1CompatibilityService: ) if retained_after_read["status"] != "retained": raise RuntimeError("process recovery token изменился после GATT validation") + live_quick_host_association: _SavedQuickConnectHostAssociation | None = None + live_quick_endpoint_observation: _ConfiguredEndpointHostObservation | None = None + live_quick_supervisor_epoch_before: int | None = None + if requested_mode == "quick-connect" and require_live_gatt_validation: + exact_saved_quick_topology = bool( + expected_reconciliation is None + and semantic_record is not None + and physical_transport_ref_comparison_key(semantic_record.transport_ref) + == physical_transport_ref_comparison_key(actual_transport_ref) + and semantic_record.connection_mode == "quick-connect" + and semantic_record.ipv4 == target + and semantic_record.compatibility_profile_id + == XGRIDS_K1_COMPATIBILITY_PROFILE_ID + and semantic_record.firmware_version + == compatibility_attestation.firmware_version + ) + if not exact_saved_quick_topology: + raise ConnectionVerificationError( + "Live Quick Connect recovery не имеет точной сохранённой topology " + "этого K1; подключение Wi-Fi Mac не выполнялось", + reason_code="connection-verify-quick-host-binding-unavailable", + ) + ( + live_quick_host_association, + live_quick_endpoint_observation, + live_quick_supervisor_epoch_before, + ) = await self._restore_saved_quick_connect_host_path( + transport_ref=actual_transport_ref, + target_ipv4=target, + ble_operation_performed=True, + expected_mode_revision=expected_mode_revision, + ) defer_fresh_bridge_semantic_commit = bool( verification_source == "fresh-scan" and requested_mode == "bridge" @@ -19538,11 +19779,17 @@ class XgridsK1CompatibilityService: raise RuntimeError( "K1 сообщил адрес другой сети; прямой локальный маршрут отсутствует" ) - control_endpoint_observation = await _run_blocking_operation_without_abandonment( - self._probe_control_endpoint, - target, - association_timeout_seconds=COMMAND_BOUND_ASSOCIATION_TIMEOUT_SECONDS, - ) + control_endpoint_observation = live_quick_endpoint_observation + if control_endpoint_observation is None: + control_endpoint_observation = ( + await _run_blocking_operation_without_abandonment( + self._probe_control_endpoint, + target, + association_timeout_seconds=( + COMMAND_BOUND_ASSOCIATION_TIMEOUT_SECONDS + ), + ) + ) if not control_endpoint_observation.reachable: self._update_applied_topology_reachability( connection_mode=requested_mode, @@ -19634,6 +19881,21 @@ class XgridsK1CompatibilityService: path=control_endpoint_observation.path, reachable=True, ) + if live_quick_host_association is not None: + _write_quick_connect_host_network_proof( + live_quick_host_association, + target_ipv4=target, + observation=control_endpoint_observation, + outcome="direct-endpoint-reachable", + reason_code=None, + supervisor_host_path_epoch_before_admission=( + live_quick_supervisor_epoch_before + ), + supervisor_host_path_epoch_after_admission=( + self._connection_supervisor.snapshot().host_path.epoch + ), + ble_operation_performed=True, + ) logger.info( "K1 existing direct-LAN connection adopted without provisioning", extra={ @@ -22906,6 +23168,7 @@ class XgridsK1CompatibilityService: def select_camera_preview(self, request: CameraPreviewSelectRequest) -> dict[str, Any]: camera_holder_acquired = False try: + self._require_camera_device_session(request.device_session_id) with self._lock: current_acquisition = self._acquisition current_out_dir = self._acquisition_out_dir @@ -22941,6 +23204,17 @@ class XgridsK1CompatibilityService: raise RuntimeError( "camera preview доступен после автоматического запуска правой камеры" ) + if acquisition_active_before_select: + if request.source_id != DEFAULT_ACQUISITION_CAMERA_SOURCE: + raise LocalAcquisitionLifecycleError( + "Во время активного приёма правая камера принадлежит evidence-сессии; " + "переключение видеоканала недоступно до завершения приёма.", + reason_code="acquisition-camera-evidence-owned", + ) + # The mandatory right camera is already selected and bound to + # this acquisition. Re-selecting it is a read-only no-op: a UI + # retry must never detach or replace the evidence producer. + return self.state() camera_holder_acquired = self._ensure_camera_preview_process_lease() target = self._camera_target_for_session(request.device_session_id) with self._lock: @@ -22975,6 +23249,18 @@ class XgridsK1CompatibilityService: @_serialized_k1_transition_access def stop_camera_preview(self, request: CameraPreviewStopRequest) -> dict[str, Any]: self._require_camera_device_session(request.device_session_id) + with self._lock: + acquisition = self._acquisition + acquisition_active = bool( + acquisition is not None + and acquisition.state not in TERMINAL_ACQUISITION_STATES + ) + if acquisition_active: + raise LocalAcquisitionLifecycleError( + "Во время активного приёма правая камера принадлежит evidence-сессии; " + "остановите весь приём штатной командой STOP.", + reason_code="acquisition-camera-evidence-owned", + ) self.camera_preview.stop(request.generation) self._release_camera_preview_process_lease() return self.state() @@ -31644,6 +31930,7 @@ def _plugin_execution_http_status(reason_code: object) -> int: "connection-mode-switch-lifecycle-busy", "connection-mode-switch-acquisition-changed", "connection-mode-selection-lifecycle-busy", + "connection-mode-selection-physical-recovery-reset-required", "connection-mode-selection-physical-state-unsafe", "connection-mode-selection-control-state-unsafe", "acquisition-start-lifecycle-busy", @@ -35098,6 +35385,7 @@ def _write_quick_connect_host_network_proof( reason_code: str | None, supervisor_host_path_epoch_before_admission: int | None, supervisor_host_path_epoch_after_admission: int | None = None, + ble_operation_performed: bool = False, ) -> None: """Persist one redacted CoreWLAN -> route -> TCP proof without K1 mutation.""" @@ -35161,7 +35449,7 @@ def _write_quick_connect_host_network_proof( "supervisor_host_path_epoch_after_admission": ( supervisor_host_path_epoch_after_admission ), - "ble_operation_performed": False, + "ble_operation_performed": ble_operation_performed, "device_write_performed": False, "automatic_retry": False, }, diff --git a/tests/test_xgrids_acquisition_lifecycle.py b/tests/test_xgrids_acquisition_lifecycle.py index 3a1e558..4d5542e 100644 --- a/tests/test_xgrids_acquisition_lifecycle.py +++ b/tests/test_xgrids_acquisition_lifecycle.py @@ -14120,6 +14120,7 @@ def test_confirmed_scanning_waits_for_first_authoritative_pcl_before_camera( assert pre_prepare_fence(lambda: True) is True events.append(("select", (source_id, target))) camera_state["active_source_id"] = source_id + camera_state["generation"] = 1 events.append(("record", session_dir)) camera_state["recording"] = { "active": True, @@ -14313,9 +14314,23 @@ def test_confirmed_scanning_waits_for_first_authoritative_pcl_before_camera( assert any( stream["source_id"] == "sensor.camera.right" and stream["activation"]["selected"] is True - and stream["activation"]["controllable"] is True + and stream["activation"]["controllable"] is False for stream in admitted_camera_streams ) + admitted_generation = admitted_state["camera_preview"]["generation"] + assert isinstance(admitted_generation, int) + with pytest.raises(facade_module.LocalAcquisitionLifecycleError) as stop_error: + service.stop_camera_preview( + facade_module.CameraPreviewStopRequest( + device_session_id=service._device_session_id, # type: ignore[arg-type] # noqa: SLF001 + generation=admitted_generation, + ) + ) + assert stop_error.value.reason_code == "acquisition-camera-evidence-owned" + after_rejected_stop = service.camera_preview.snapshot() + assert after_rejected_stop["active_source_id"] == "sensor.camera.right" + assert after_rejected_stop["recording"]["active"] is True + assert after_rejected_stop["generation"] == admitted_generation # Every later authoritative PCL for the same lineage is idempotent. physical_snapshot_calls_before = physical_snapshot_calls @@ -17570,7 +17585,7 @@ def test_abort_waits_for_start_handoff_then_stops_owned_producers( assert service._acquisition_session_lease is None # noqa: SLF001 -def test_camera_selection_finishes_before_serialized_acquisition_stop( +def test_active_acquisition_camera_selection_is_rejected_before_serialized_stop( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -17591,43 +17606,8 @@ def test_camera_selection_finishes_before_serialized_acquisition_stop( service._device_session_id = device_session_id # noqa: SLF001 out_dir = service._acquisition_out_dir # noqa: SLF001 assert out_dir is not None - entered_camera_arm = threading.Event() - release_camera_arm = threading.Event() - stop_finished = threading.Event() - worker_errors: list[BaseException] = [] events: list[str] = [] - def blocked_camera_arm(_out_dir: Path, *, require_session: bool = False) -> None: - assert require_session is True - events.append("arm") - entered_camera_arm.set() - if not release_camera_arm.wait(timeout=2): - raise TimeoutError("test did not release camera arm") - - def select_worker() -> None: - try: - service.select_camera_preview( - CameraPreviewSelectRequest( - source_id="sensor.camera.left", - device_session_id=device_session_id, - ) - ) - except BaseException as exc: # pragma: no cover - asserted below - worker_errors.append(exc) - - def stop_worker() -> None: - try: - service.stop_acquisition( - _stop_request( - acquisition_id=acquisition_id, - mode="capture-only", - ) - ) - except BaseException as exc: # pragma: no cover - asserted below - worker_errors.append(exc) - finally: - stop_finished.set() - camera_snapshot = service.camera_preview.snapshot() camera_snapshot.update( { @@ -17647,7 +17627,11 @@ def test_camera_selection_finishes_before_serialized_acquisition_stop( }, } ) - monkeypatch.setattr(service, "_arm_camera_recording", blocked_camera_arm) + monkeypatch.setattr( + service, + "_arm_camera_recording", + lambda *_args, **_kwargs: events.append("unexpected-arm"), + ) monkeypatch.setattr( service.camera_preview, "snapshot", @@ -17656,13 +17640,7 @@ def test_camera_selection_finishes_before_serialized_acquisition_stop( monkeypatch.setattr( service.camera_preview, "select", - lambda source_id, _target: ( - events.append("select") - or { - "active_source_id": source_id, - "recording": {"active": False}, - } - ), + lambda *_args, **_kwargs: events.append("unexpected-select"), ) monkeypatch.setattr( service.camera_preview, @@ -17670,18 +17648,24 @@ def test_camera_selection_finishes_before_serialized_acquisition_stop( lambda **_kwargs: events.append("camera-stop"), ) monkeypatch.setattr(runtime, "stop", lambda: events.append("runtime-stop")) - select_thread = threading.Thread(target=select_worker) - stop_thread = threading.Thread(target=stop_worker) - select_thread.start() - assert entered_camera_arm.wait(timeout=2) - stop_thread.start() - assert not stop_finished.wait(timeout=0.05) - release_camera_arm.set() - select_thread.join(timeout=2) - stop_thread.join(timeout=2) - assert worker_errors == [] - assert events == ["arm", "select", "camera-stop", "runtime-stop"] + with pytest.raises(facade_module.LocalAcquisitionLifecycleError) as selection_error: + service.select_camera_preview( + CameraPreviewSelectRequest( + source_id="sensor.camera.left", + device_session_id=device_session_id, + ) + ) + assert selection_error.value.reason_code == "acquisition-camera-evidence-owned" + assert events == [] + + service.stop_acquisition( + _stop_request( + acquisition_id=acquisition_id, + mode="capture-only", + ) + ) + assert events == ["camera-stop", "runtime-stop"] assert service.state()["acquisition"]["state"] == "completed" assert service._acquisition_session_lease is None # noqa: SLF001 @@ -21646,6 +21630,8 @@ def test_read_only_reconciliation_requires_exact_requested_mode_despite_shared_s service, intended_mode=intended_mode, ) + if requested_mode != "bridge": + _select_connection_mode(service, requested_mode) async def read_current_status(*_: object, **__: object) -> dict[str, Any]: raise AssertionError("mode mismatch must fail before a GATT read") @@ -21820,6 +21806,13 @@ def test_durable_restart_rejects_request_target_mismatch_before_gatt( transport_ref=DURABLE_K1_UUID, ) restarted, _ = service_with_fake_runtime(tmp_path) + requested_mode = { + "direct-lan": "bridge", + "device-ap": "quick-connect", + "controller-hotspot": "direct-connect", + }[attestation.topology] + if requested_mode != "bridge": + _select_connection_mode(restarted, requested_mode) reads: list[str] = [] async def forbidden_read(*_: object, **__: object) -> dict[str, Any]: @@ -21989,6 +21982,8 @@ def test_durable_restart_previous_topology_cannot_resolve_ambiguous_write( previous_connection=previous, ) restarted, _ = service_with_fake_runtime(tmp_path) + if intended_mode != "bridge": + _select_connection_mode(restarted, intended_mode) captured = _durable_status_capture() reads = 0 pin_calls: list[tuple[object, str]] = [] @@ -22123,13 +22118,15 @@ def test_durable_restart_failed_status_keeps_ambiguity_and_does_not_pin( def _seed_durable_quick_reconnect_evidence( service: XgridsK1CompatibilityService, + *, + transport_ref: str = DURABLE_K1_UUID, ) -> None: """Persist the exact secret-free Quick topology and AP activation proof.""" topology_store = service._semantic_topology_store # noqa: SLF001 assert topology_store is not None topology_store.commit( - transport_ref=DURABLE_K1_UUID, + transport_ref=transport_ref, connection_mode="quick-connect", ipv4="192.168.56.1", compatibility_profile_id=XGRIDS_K1_COMPATIBILITY_PROFILE_ID, @@ -22145,7 +22142,7 @@ def _seed_durable_quick_reconnect_evidence( json.dumps( { "operation": "single_reviewed_quick_connect_ap_activation", - "device_macos_uuid": DURABLE_K1_UUID, + "device_macos_uuid": transport_ref, "device_name": "XGR-A46BE7", "outcome": "ap_ready_observed", "ready_observed": True, @@ -22220,6 +22217,7 @@ def test_semantic_quick_restart_restores_saved_host_profile_before_endpoint_prob ) restarted, _ = service_with_fake_runtime(tmp_path) + _select_connection_mode(restarted, "quick-connect") calls: list[str] = [] association_calls: list[tuple[Path, str, str, float, float]] = [] route_samples: list[HostPathProbeResult] = [] @@ -22367,6 +22365,7 @@ def test_semantic_quick_restart_skips_wifi_mutation_when_direct_route_already_ex first, _ = service_with_fake_runtime(tmp_path) _seed_durable_quick_reconnect_evidence(first) restarted, _ = service_with_fake_runtime(tmp_path) + _select_connection_mode(restarted, "quick-connect") wifi_mutations: list[str] = [] tcp_calls: list[str] = [] @@ -22444,6 +22443,7 @@ def test_semantic_quick_restart_rejects_non_direct_route_without_tcp_or_ble( first, _ = service_with_fake_runtime(tmp_path) _seed_durable_quick_reconnect_evidence(first) restarted, _ = service_with_fake_runtime(tmp_path) + _select_connection_mode(restarted, "quick-connect") device_edges: list[str] = [] tcp_calls: list[str] = [] @@ -22528,6 +22528,7 @@ def test_semantic_quick_restart_tcp_timeout_fails_without_ble_fallback( first, _ = service_with_fake_runtime(tmp_path) _seed_durable_quick_reconnect_evidence(first) restarted, _ = service_with_fake_runtime(tmp_path) + _select_connection_mode(restarted, "quick-connect") device_edges: list[str] = [] tcp_calls: list[str] = [] @@ -22606,6 +22607,7 @@ def test_semantic_quick_restart_rejects_route_change_during_single_tcp_probe( first, _ = service_with_fake_runtime(tmp_path) _seed_durable_quick_reconnect_evidence(first) restarted, _ = service_with_fake_runtime(tmp_path) + _select_connection_mode(restarted, "quick-connect") device_edges: list[str] = [] tcp_calls: list[str] = [] initial_path = _direct_host_path("192.168.56.1") @@ -23014,6 +23016,240 @@ def test_semantic_durable_verify_rejects_association_change_during_tcp_probe( assert state["semantic_topology_store"]["record"]["revision"] == 1 +def test_quick_verify_is_rejected_while_bridge_is_selected_before_any_io( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + first, _ = service_with_fake_runtime(tmp_path) + _seed_durable_quick_reconnect_evidence(first) + restarted, _ = service_with_fake_runtime(tmp_path) + edges: list[str] = [] + + def forbidden_edge(*_: object, **__: object) -> object: + edges.append("io") + raise AssertionError("mode mismatch must fail before BLE, Wi-Fi or endpoint I/O") + + async def forbidden_async_edge(*_: object, **__: object) -> dict[str, Any]: + forbidden_edge() + return {} + + monkeypatch.setattr(facade_module, "read_wifi_status_once", forbidden_async_edge) + monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_async_edge) + monkeypatch.setattr( + facade_module, + "associate_with_wifi_profile_once", + forbidden_edge, + ) + monkeypatch.setattr(facade_module, "_inspect_host_path", forbidden_edge) + before = restarted.state() + assert before["desired_connection_mode"] == "bridge" + + with pytest.raises(facade_module.ConnectionVerificationError) as raised: + asyncio.run( + restarted.verify_connection( + ConnectionVerifyRequest( + device_id=DURABLE_K1_UUID, + source="durable-configured-state", + compatibility_attestation=QUICK_CONNECT_ATTESTATION, + expected_mode_revision=before[ + "desired_connection_mode_revision" + ], + ) + ) + ) + + assert raised.value.reason_code == "connection-mode-draft-mismatch" + assert edges == [] + after = restarted.state() + assert after["desired_connection_mode"] == "bridge" + assert after["operations"] == [] + + +def test_physical_recovery_cannot_silently_change_bridge_draft_to_quick( + tmp_path: Path, +) -> None: + original, _ = service_with_fake_runtime(tmp_path) + _persist_resolved_unclassified_stop_for_restart( + original, + connection_mode="quick-connect", + target_ipv4=facade_module.AP_FALLBACK_IPV4, + ) + _seed_durable_quick_reconnect_evidence( + original, + transport_ref="test-ble-transport", + ) + restarted, _ = service_with_fake_runtime(tmp_path) + before = restarted.state() + assert before["desired_connection_mode"] == "bridge" + assert before["connection_policy"]["actions"][ + "observe-configured-device-network" + ]["required_connection_mode"] == "quick-connect" + + with pytest.raises(facade_module.NetworkProvisioningConflict) as raised: + restarted.select_connection_mode( + DesiredConnectionModeRequest( + connection_mode="quick-connect", + expected_revision=before["desired_connection_mode_revision"], + ) + ) + + assert raised.value.reason_code == ( + "connection-mode-selection-physical-recovery-reset-required" + ) + after = restarted.state() + assert after["desired_connection_mode"] == "bridge" + assert after["desired_connection_mode_revision"] == before[ + "desired_connection_mode_revision" + ] + + +def test_public_quick_physical_recovery_rejoins_saved_wifi_after_live_ble_proof( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + original, _ = service_with_fake_runtime(tmp_path) + _persist_resolved_unclassified_stop_for_restart( + original, + connection_mode="quick-connect", + target_ipv4=facade_module.AP_FALLBACK_IPV4, + ) + _seed_durable_quick_reconnect_evidence( + original, + transport_ref="test-ble-transport", + ) + + restarted, runtime = service_with_fake_runtime(tmp_path) + # Model a process whose explicit Quick draft was already authoritative + # before the unresolved physical record became visible. The public UI may + # never create this authority as a side effect of the recovery button. + with restarted._lock: # noqa: SLF001 + restarted._desired_connection_mode = "quick-connect" # noqa: SLF001 + restarted._desired_connection_mode_revision = 1 # noqa: SLF001 + restarted._host_wifi_association_probe = FakeHostWifiAssociationProbe( # type: ignore[assignment] # noqa: SLF001 + "a" * 64 + ) + capture = _durable_status_capture(device_id="test-ble-transport") + ordered_edges: list[str] = [] + status_reads: list[tuple[str, bool, bool, float | None]] = [] + route_samples: list[HostPathProbeResult] = [] + network_writes: list[str] = [] + + async def read_current_quick_status( + device_id: str, + *, + allow_known_device_retrieval: bool = False, + rediscover: bool = False, + exact_scan_timeout_seconds: float | None = None, + on_gatt_validated: Callable[[object], None] | None = None, + **_: object, + ) -> dict[str, Any]: + ordered_edges.append("ble-status-read") + status_reads.append( + ( + device_id, + allow_known_device_retrieval, + rediscover, + exact_scan_timeout_seconds, + ) + ) + assert on_gatt_validated is not None + on_gatt_validated(capture) + result = _wifi_status_read(None, device_id=device_id) + result["status"] = _ap_ready_wifi_status() + return result + + def associate_saved_profile(*_: object, **__: object) -> dict[str, Any]: + ordered_edges.append("host-wifi-association") + return _successful_saved_quick_profile_association() + + def settling_quick_route(target: str) -> HostPathProbeResult: + path = ( + _tunnel_host_path(target) + if len(route_samples) < 2 + else _direct_host_path(target) + ) + route_samples.append(path) + return path + + def reachable_tcp(target: str) -> facade_module.TcpReachabilityProbeResult: + assert target == facade_module.AP_FALLBACK_IPV4 + ordered_edges.append("mqtt-endpoint-probe") + return facade_module.TcpReachabilityProbeResult(reachable=True) + + async def forbidden_network_write(*_: object, **__: object) -> dict[str, Any]: + network_writes.append("network-write") + raise AssertionError("saved Quick physical recovery must not write K1 network state") + + monkeypatch.setattr(facade_module, "read_wifi_status_once", read_current_quick_status) + monkeypatch.setattr( + facade_module, + "associate_with_wifi_profile_once", + associate_saved_profile, + ) + monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_network_write) + monkeypatch.setattr(facade_module, "pin_connected_device_handle", lambda *_a, **_k: None) + monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: False) + monkeypatch.setattr(facade_module, "_inspect_host_path", settling_quick_route) + monkeypatch.setattr(facade_module, "_probe_control_endpoint_socket", reachable_tcp) + monkeypatch.setattr( + facade_module, + "DURABLE_QUICK_CONNECT_ROUTE_SETTLE_INTERVAL_SECONDS", + 0.001, + ) + _install_real_coordinator_bootstrap( + restarted, + observed_session_state="ready", + ) + + before = restarted.state() + recovery = before["connection_policy"]["actions"][ + "observe-configured-device-network" + ] + assert recovery["allowed"] is True + assert recovery["required_connection_mode"] == "quick-connect" + assert recovery["requires_live_gatt_validation"] is True + + verified = asyncio.run(restarted.verify_connection(ConnectionVerifyRequest())) + + assert ordered_edges == [ + "ble-status-read", + "host-wifi-association", + "mqtt-endpoint-probe", + ] + assert status_reads == [ + ( + "test-ble-transport", + True, + True, + facade_module.CONNECTION_VERIFY_EXACT_UUID_SCAN_TIMEOUT_SECONDS, + ) + ] + assert network_writes == [] + assert runtime.start_calls == [] + assert runtime.stop_calls == 0 + assert verified["active_connection_mode"] == "quick-connect" + assert verified["k1_ip"] == facade_module.AP_FALLBACK_IPV4 + assert verified["last_operation"]["status"] == "succeeded" + assert verified["last_operation"]["result"]["physical_reconciliation"][ + "performed" + ] is True + reassociation_sessions = sorted( + restarted.evidence_root.glob("*viewer_k1_quick_connect_host_reassociation*") + ) + assert len(reassociation_sessions) == 1 + proof = json.loads( + (reassociation_sessions[0] / "host-network-proof.redacted.json").read_text( + encoding="utf-8" + ) + ) + assert proof["outcome"] == "direct-endpoint-reachable" + assert proof["route_after"]["route_class"] == "direct" + assert proof["tcp_probe_performed"] is True + assert proof["ble_operation_performed"] is True + assert proof["device_write_performed"] is False + assert proof["automatic_retry"] is False + + @pytest.mark.parametrize("observed_session_state", ["ready", "scanning"]) def test_public_physical_recovery_refreshes_stale_dhcp_then_classifies_without_writes( monkeypatch: pytest.MonkeyPatch, @@ -27491,9 +27727,9 @@ def test_select_device_handoff_is_local_cancel_invalidates_candidates_and_stale_ expected_reconfiguration_revision=fresh["connection_reconfiguration"]["revision"], expected_reconfiguration_intent_id=fresh["connection_reconfiguration"]["intent_id"], ) - with pytest.raises(facade_module.NetworkProvisioningConflict) as quick_isolated: + with pytest.raises(facade_module.ConnectionVerificationError) as quick_isolated: asyncio.run(service.verify_connection(quick_verify_request)) - assert quick_isolated.value.reason_code == "connection-reconfiguration-target-mismatch" + assert quick_isolated.value.reason_code == "connection-mode-draft-mismatch" cancel_request = _reconfiguration_request(fresh, "cancel") cancelled = asyncio.run(service.prepare_connection_reconfiguration(cancel_request)) @@ -30869,6 +31105,9 @@ def _persist_ambiguous_start_with_prepared_checkpoint( def _persist_resolved_active_start_for_restart( service: XgridsK1CompatibilityService, + *, + connection_mode: facade_module.ConnectionMode = "bridge", + target_ipv4: str = "192.168.68.52", ) -> None: """Persist one successful START without retaining process-local acquisition.""" @@ -30879,8 +31118,8 @@ def _persist_resolved_active_start_for_restart( connection = PhysicalCommandConnectionBinding( intent_id="old-start-intent", transport_ref="test-ble-transport", - connection_mode="bridge", - target_ipv4="192.168.68.52", + connection_mode=connection_mode, + target_ipv4=target_ipv4, target_port=facade_module.CONTROL_MQTT_PORT, host_path_epoch=1, control_session_id="old-start-control", @@ -31019,10 +31258,17 @@ def _persist_same_runtime_prepared_checkpoint_for_resolved_start( def _persist_resolved_unclassified_stop_for_restart( service: XgridsK1CompatibilityService, + *, + connection_mode: facade_module.ConnectionMode = "bridge", + target_ipv4: str = "192.168.68.52", ) -> tuple[str, int]: """Persist the exact legacy startup shape without process-local owners.""" - _persist_resolved_active_start_for_restart(service) + _persist_resolved_active_start_for_restart( + service, + connection_mode=connection_mode, + target_ipv4=target_ipv4, + ) ledger = service._physical_command_ledger # noqa: SLF001 start = ledger.snapshot().record assert start is not None