"),
+ "recovered SCANNING must render the STOP-only branch before project/START controls",
+ );
+});
+
test("K1 provisioning mutations require the operator's fresh BLE candidate", () => {
const backendLease = {
selected_device_id: "stale-backend-lease-device",
@@ -662,46 +1917,1280 @@ test("K1 provisioning mutations require the operator's fresh BLE candidate", ()
assert.equal(lifecycle.canSubmitProvisioningMutation({
devices: backendLease.devices,
selectedDeviceId: "expired-scan-candidate",
- powerConfirmed: true,
credentialsReady: true,
isBusy: false,
}), false);
assert.equal(lifecycle.canSubmitProvisioningMutation({
devices: backendLease.devices,
selectedDeviceId: "fresh-scan-candidate",
- powerConfirmed: true,
credentialsReady: true,
isBusy: false,
}), true);
});
test("K1 connection status is green only for a reachable matching lease", () => {
- const unreachableLease = {
+ const legacyReachableLease = {
k1_ip: "192.168.68.50",
connection_mode: "bridge",
- connection_verification: {
- lease_state: "disconnected",
- network_reachability: "unreachable",
- },
- };
- const reachableLease = {
- ...unreachableLease,
connection_verification: {
lease_state: "reachable",
network_reachability: "reachable",
},
};
+ const reachableLease = supervisedConnectionState();
- assert.equal(lifecycle.isReachableConnectionLease(unreachableLease, "bridge"), false);
+ assert.equal(lifecycle.isReachableConnectionLease(legacyReachableLease, "bridge"), false);
assert.equal(lifecycle.isReachableConnectionLease(reachableLease, "bridge"), true);
assert.equal(lifecycle.isReachableConnectionLease(reachableLease, "quick-connect"), false);
assert.equal(lifecycle.isReachableConnectionLease({
k1_ip: "192.168.68.50",
connection_mode: "bridge",
}, "bridge"), false);
+ assert.equal(lifecycle.isReachableConnectionLease({
+ k1_ip: "192.168.56.1",
+ connection_mode: "quick-connect",
+ connection_verification: {
+ status: "control-transport-lost",
+ lease_state: "disconnected",
+ network_reachability: "unreachable",
+ },
+ }, "quick-connect"), false);
+
+ for (const mutate of [
+ (state) => { state.connection_supervisor.observed.device_network.state = "unconfigured"; },
+ (state) => { state.connection_supervisor.observed.device_network.intent_id = "stale-intent"; },
+ (state) => { state.connection_supervisor.observed.device_network.transport_ref = null; },
+ (state) => {
+ state.connection_supervisor.observed.device_network.target = {
+ ...state.connection_supervisor.observed.device_network.target,
+ ipv4: "192.168.68.99",
+ };
+ },
+ (state) => { state.connection_supervisor.observed.endpoint.intent_id = "stale-intent"; },
+ (state) => { state.connection_supervisor.observed.endpoint.host_path_epoch = 2; },
+ (state) => { state.connection_supervisor.observed.host_path.route_class = "default"; },
+ (state) => { state.connection_supervisor.observed.device_identity.intent_id = "stale-intent"; },
+ (state) => { state.connection_supervisor.observed.device_identity.host_path_epoch = 2; },
+ (state) => { state.connection_supervisor.observed.control_plane.host_path_epoch = 2; },
+ (state) => { state.connection_supervisor.observed.control_plane.session_id = null; },
+ (state) => {
+ state.connection_supervisor.observed.endpoint.target = {
+ ...state.connection_supervisor.observed.endpoint.target,
+ ipv4: "192.168.68.99",
+ };
+ },
+ ]) {
+ const stale = structuredClone(reachableLease);
+ mutate(stale);
+ assert.equal(lifecycle.isReachableConnectionLease(stale, "bridge"), false);
+ }
});
-test("provisioning intent keeps one idempotency key and exposes unsafe outcomes", () => {
+test("frontend connection authority fails closed without the backend lifecycle projection", () => {
+ const reachable = supervisedConnectionState();
+ assert.equal(lifecycle.isReachableConnectionLease(reachable, "bridge"), true);
+
+ const omitted = structuredClone(reachable);
+ delete omitted.connection_lifecycle;
+ assert.equal(lifecycle.isReachableConnectionLease(omitted, "bridge"), false);
+ assert.equal(
+ lifecycle.currentAppliedConnectionTopology(omitted, "bridge")?.status,
+ "configured-unverified",
+ );
+
+ const drifted = structuredClone(reachable);
+ drifted.connection_lifecycle.active_mode = "quick-connect";
+ assert.equal(lifecycle.isReachableConnectionLease(drifted, "bridge"), false);
+});
+
+test("backend topology distinguishes reachable authority from last-known address", () => {
+ const target = { ipv4: "192.168.56.1", port: 1883 };
+ const lastKnown = supervisedConnectionState({
+ mode: "quick-connect",
+ target,
+ leaseState: "lost",
+ controlAllowed: false,
+ deviceNetworkState: "unconfigured",
+ lastKnown: {
+ connection_mode: "quick-connect",
+ target,
+ logical_device_id: "device-k1-001",
+ compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
+ verified_at: "2026-08-06T11:59:00Z",
+ },
+ });
+ const configured = supervisedConnectionState({
+ mode: "quick-connect",
+ target,
+ leaseState: "configured-unverified",
+ controlAllowed: false,
+ });
+ const active = supervisedConnectionState({ mode: "quick-connect", target });
+
+ assert.deepEqual(lifecycle.backendConnectionTopology(lastKnown), {
+ connectionMode: "quick-connect",
+ status: "configured-offline",
+ source: "last-known",
+ endpoint: "192.168.56.1",
+ });
+ assert.deepEqual(lifecycle.backendConnectionTopology(configured), {
+ connectionMode: "quick-connect",
+ status: "configured-unverified",
+ source: "applied",
+ endpoint: "192.168.56.1",
+ });
+ assert.deepEqual(lifecycle.backendConnectionTopology(active), {
+ connectionMode: "quick-connect",
+ status: "active",
+ source: "applied",
+ endpoint: "192.168.56.1",
+ });
+ assert.equal(lifecycle.activeConnectionEndpointLabel(lastKnown), null);
+ assert.equal(
+ lifecycle.activeConnectionEndpointLabel(active),
+ "192.168.56.1",
+ );
+ assert.equal(lifecycle.backendConnectionTopology({}), null);
+});
+
+test("current BLE topology remains visible offline and supersedes durable history", () => {
+ const currentOffline = supervisedConnectionState({
+ mode: "bridge",
+ leaseState: "lost",
+ controlAllowed: false,
+ hostAvailable: false,
+ endpointState: "unreachable",
+ });
+ currentOffline.semantic_topology_store = {
+ status: "available",
+ configured_offline_evidence: true,
+ live_connection_authority: false,
+ reason_code: null,
+ record: {
+ schema_version: "missioncore.xgrids-k1-semantic-topology/v1",
+ revision: 2,
+ transport_ref: "ble-k1-old",
+ connection_mode: "quick-connect",
+ ipv4: "192.168.56.1",
+ compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
+ firmware_version: "3.0.2",
+ source: "ble-read-only-status",
+ observed_at_utc: "2026-08-06T11:00:00Z",
+ },
+ };
+
+ assert.deepEqual(lifecycle.backendConnectionTopology(currentOffline), {
+ connectionMode: "bridge",
+ status: "configured-offline",
+ source: "applied",
+ endpoint: "192.168.68.50",
+ });
+ assert.equal(lifecycle.backendConnectionTopology(currentOffline, "quick-connect"), null);
+ assert.equal(lifecycle.isReachableConnectionLease(currentOffline, "bridge"), false);
+ assert.equal(lifecycle.isConfiguredConnectionLease(currentOffline, "bridge"), true);
+ assert.equal(lifecycle.hasControlAuthority(currentOffline), false);
+ assert.equal(
+ lifecycle.normalizeRuntimePhase({ ...currentOffline, phase: "connected" }),
+ "configuring",
+ );
+ assert.equal(lifecycle.canonicalDeviceConnectivity(currentOffline), "offline");
+});
+
+test("durable semantic topology is configured-offline evidence, never authority", () => {
+ const durable = {
+ semantic_topology_store: {
+ status: "available",
+ configured_offline_evidence: true,
+ live_connection_authority: false,
+ reason_code: null,
+ record: {
+ schema_version: "missioncore.xgrids-k1-semantic-topology/v1",
+ revision: 4,
+ transport_ref: "ble-k1-001",
+ connection_mode: "quick-connect",
+ ipv4: "192.168.56.1",
+ compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
+ firmware_version: "3.0.2",
+ source: "ble-post-write-status",
+ observed_at_utc: "2026-08-06T12:00:00Z",
+ },
+ },
+ };
+ assert.deepEqual(lifecycle.backendConnectionTopology(durable), {
+ connectionMode: "quick-connect",
+ status: "configured-offline",
+ source: "durable",
+ endpoint: "192.168.56.1",
+ });
+ assert.equal(lifecycle.isConfiguredConnectionLease(durable, "quick-connect"), true);
+ assert.equal(lifecycle.isReachableConnectionLease(durable, "quick-connect"), false);
+ assert.equal(lifecycle.hasControlAuthority(durable), false);
+ assert.equal(lifecycle.normalizeRuntimePhase({ ...durable, phase: "connected" }), "configuring");
+ assert.equal(lifecycle.canonicalDeviceConnectivity(durable), "offline");
+
+ const hostProbed = structuredClone(durable);
+ hostProbed.configured_endpoint_probe = {
+ schema_version: "missioncore.xgrids-k1-configured-endpoint-probe/v1",
+ status: "reachable",
+ target_source: "durable-semantic-topology",
+ connection_mode: "quick-connect",
+ endpoint: "192.168.56.1",
+ transport_ref: "ble-k1-001",
+ intent_id: null,
+ semantic_revision: 4,
+ host_route_available: true,
+ host_route_class: "direct",
+ tcp_reachable: true,
+ identity_validation: "not-performed",
+ control_authority_granted: false,
+ ble_operation_performed: false,
+ network_mutation_performed: false,
+ automatic_retry: false,
+ observed_at: "2026-08-08T12:30:19Z",
+ reason_code: null,
+ };
+ assert.deepEqual(lifecycle.backendConnectionTopology(hostProbed), {
+ connectionMode: "quick-connect",
+ status: "configured-unverified",
+ source: "durable",
+ endpoint: "192.168.56.1",
+ });
+ assert.equal(lifecycle.isReachableConnectionLease(hostProbed, "quick-connect"), false);
+ assert.equal(lifecycle.hasControlAuthority(hostProbed), false);
+
+ for (const status of ["empty", "corrupt"]) {
+ const unavailable = structuredClone(durable);
+ unavailable.semantic_topology_store.status = status;
+ unavailable.semantic_topology_store.record = null;
+ unavailable.semantic_topology_store.configured_offline_evidence = false;
+ assert.equal(lifecycle.backendConnectionTopology(unavailable), null);
+ }
+});
+
+test("unresolved or corrupt durable mutation state remains a provisioning barrier", () => {
+ assert.equal(lifecycle.hasUnresolvedNetworkMutation({}), false);
+ assert.equal(lifecycle.hasUnresolvedNetworkMutation({ network_write_reconciliation: {} }), true);
+ assert.equal(lifecycle.hasUnresolvedNetworkMutation({
+ network_mutation_ledger: { status: "unresolved", mutation_allowed: false },
+ }), true);
+ assert.equal(lifecycle.hasUnresolvedNetworkMutation({
+ network_mutation_ledger: { status: "corrupt", mutation_allowed: false },
+ }), true);
+ assert.equal(lifecycle.hasUnresolvedNetworkMutation({
+ network_mutation_ledger: { status: "resolved", mutation_allowed: true },
+ }), false);
+});
+
+test("retained BLE context is never presence but can carry one backend-authorized recovery", () => {
+ const activeQuick = {
+ current_device_recovery: {
+ transport_ref: "exact-session-handle",
+ connection_mode: "quick-connect",
+ handle_available: true,
+ handle_retained: true,
+ advertised_now: false,
+ gatt_validated_recently: true,
+ },
+ connection_policy: {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ allowed_actions: ["recover-current-device-network"],
+ actions: {
+ "recover-current-device-network": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "retained-current-process",
+ required_transport_ref: "exact-session-handle",
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ },
+ facts: {
+ retained_context_is_presence: false,
+ },
+ },
+ };
+
+ assert.equal(
+ lifecycle.currentDeviceTransportRef(activeQuick),
+ "exact-session-handle",
+ );
+ assert.deepEqual(lifecycle.retainedBleRecoveryTarget(activeQuick), {
+ transportRef: "exact-session-handle",
+ connectionMode: "quick-connect",
+ gattValidatedRecently: true,
+ });
+ assert.equal(
+ lifecycle.provisioningCandidateById([], "exact-session-handle"),
+ null,
+ );
+ assert.equal(
+ lifecycle.canSubmitProvisioningMutation({
+ devices: [],
+ selectedDeviceId: "exact-session-handle",
+ credentialsReady: true,
+ isBusy: false,
+ }),
+ false,
+ );
+ assert.equal(
+ lifecycle.connectionPolicyAllows(
+ activeQuick,
+ "recover-current-device-network",
+ ),
+ true,
+ );
+ assert.equal(
+ lifecycle.connectionPolicyDecision(
+ activeQuick,
+ "recover-current-device-network",
+ ).requires_live_gatt_validation,
+ true,
+ );
+
+ const freshlyObserved = {
+ device_id: "exact-session-handle",
+ name: "Lixel K1",
+ connectable: true,
+ };
+ assert.equal(
+ lifecycle.provisioningCandidateById(
+ [freshlyObserved],
+ "exact-session-handle",
+ ),
+ freshlyObserved,
+ );
+ assert.equal(
+ lifecycle.canSubmitProvisioningMutation({
+ devices: [freshlyObserved],
+ selectedDeviceId: "exact-session-handle",
+ credentialsReady: true,
+ isBusy: false,
+ }),
+ true,
+ );
+ assert.equal(lifecycle.retainedBleRecoveryTarget({
+ current_device_recovery: {
+ ...activeQuick.current_device_recovery,
+ advertised_now: true,
+ },
+ }), null);
+});
+
+test("browser refresh never adopts an existing backend K1 session as selection", () => {
+ const backendSession = {
+ selected_device_id: "ble-k1-001",
+ connection_mode: "bridge",
+ device_session: {
+ device_session_id: "device-session-001",
+ device_id: "logical-k1-001",
+ },
+ current_device_recovery: {
+ transport_ref: "ble-k1-001",
+ connection_mode: "bridge",
+ handle_retained: true,
+ },
+ };
+
+ assert.equal(
+ lifecycle.locallyInitiatedBleSessionTarget(backendSession, null, "bridge"),
+ null,
+ );
+ assert.equal(
+ lifecycle.locallyInitiatedBleSessionTarget(
+ backendSession,
+ "another-device",
+ "bridge",
+ ),
+ null,
+ );
+ assert.equal(
+ lifecycle.locallyInitiatedBleSessionTarget(
+ backendSession,
+ "ble-k1-001",
+ "quick-connect",
+ ),
+ null,
+ );
+});
+
+test("a pending local connect cannot bind the old matching backend session", () => {
+ const oldBackendSession = {
+ selected_device_id: "ble-k1-001",
+ connection_mode: "bridge",
+ device_session: {
+ device_session_id: "device-session-A",
+ device_id: "logical-k1-001",
+ },
+ current_device_recovery: {
+ transport_ref: "ble-k1-001",
+ connection_mode: "bridge",
+ handle_retained: true,
+ },
+ };
+
+ const oldTarget = lifecycle.bleSessionTargetForTransport(
+ oldBackendSession,
+ "ble-k1-001",
+ "bridge",
+ );
+ assert.equal(oldTarget?.key, "device-session-A:bridge:ble-k1-001");
+ assert.equal(
+ lifecycle.locallyInitiatedBleSessionTarget(
+ oldBackendSession,
+ null,
+ "bridge",
+ ),
+ null,
+ "pending is not a successful local intent and grants no bind authority",
+ );
+ assert.equal(
+ lifecycle.acceptedBleSessionKeyAfterConnect(
+ oldBackendSession,
+ "ble-k1-001",
+ "bridge",
+ oldTarget.key,
+ ),
+ null,
+ "the session that predated the click is not an accepted result",
+ );
+});
+
+test("a successful local connect binds only the new matching backend session", () => {
+ const backendSession = {
+ selected_device_id: "ble-k1-001",
+ connection_mode: "bridge",
+ device_session: {
+ device_session_id: "device-session-B",
+ device_id: "logical-k1-001",
+ },
+ current_device_recovery: {
+ transport_ref: "ble-k1-001",
+ connection_mode: "bridge",
+ handle_retained: true,
+ },
+ };
+
+ const acceptedSessionKey = lifecycle.acceptedBleSessionKeyAfterConnect(
+ backendSession,
+ "ble-k1-001",
+ "bridge",
+ "device-session-A:bridge:ble-k1-001",
+ );
+ assert.equal(acceptedSessionKey, "device-session-B:bridge:ble-k1-001");
+
+ assert.deepEqual(
+ lifecycle.locallyInitiatedBleSessionTarget(
+ backendSession,
+ "ble-k1-001",
+ "bridge",
+ { requiredSessionKey: acceptedSessionKey },
+ ),
+ {
+ transportRef: "ble-k1-001",
+ connectionMode: "bridge",
+ deviceSessionId: "device-session-B",
+ key: "device-session-B:bridge:ble-k1-001",
+ },
+ );
+ assert.equal(
+ lifecycle.locallyInitiatedBleSessionTarget(
+ {
+ ...backendSession,
+ device_session: {
+ ...backendSession.device_session,
+ device_session_id: "device-session-C",
+ },
+ },
+ "ble-k1-001",
+ "bridge",
+ { requiredSessionKey: acceptedSessionKey },
+ ),
+ null,
+ "a later session cannot silently replace the accepted session B",
+ );
+ assert.equal(
+ lifecycle.locallyInitiatedBleSessionTarget(
+ { ...backendSession, device_session: null },
+ "ble-k1-001",
+ "bridge",
+ { requiredSessionKey: acceptedSessionKey },
+ ),
+ null,
+ );
+ assert.equal(
+ lifecycle.acceptedBleSessionKeyAfterConnect(
+ { ...backendSession, device_session: null },
+ "ble-k1-001",
+ "bridge",
+ "device-session-A:bridge:ble-k1-001",
+ ),
+ null,
+ "success without an exact returned session key is a clean reset",
+ );
+ assert.equal(
+ lifecycle.canAdmitProvisioningConnection({
+ policyAllowed: true,
+ targetSource: "fresh-scan",
+ hasSuccessfulLocalConnect: false,
+ localPrerequisitesReady: true,
+ }),
+ true,
+ "after the reset a fresh explicit scan selection can connect",
+ );
+});
+
+test("an arbitrary BLE result set never selects or connects a device", () => {
+ const devices = Array.from({ length: 20 }, (_, index) => ({
+ device_id: `ble-device-${String(index + 1).padStart(2, "0")}`,
+ name: `BLE device ${index + 1}`,
+ connectable: true,
+ }));
+
+ assert.equal(lifecycle.provisioningCandidateById(devices, ""), null);
+ assert.equal(
+ lifecycle.canSubmitProvisioningMutation({
+ devices,
+ selectedDeviceId: "",
+ credentialsReady: true,
+ isBusy: false,
+ }),
+ false,
+ );
+});
+
+test("connection policy remains authoritative when historical operations are present", () => {
+ const state = {
+ operations: [{
+ action: "network.provision",
+ status: "running",
+ operation_id: "historical-operation",
+ }],
+ connection_policy: {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: ["provision-fresh-device"],
+ actions: {
+ "provision-fresh-device": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "fresh-scan",
+ required_transport_ref: null,
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ },
+ },
+ };
+
+ assert.equal(
+ lifecycle.connectionPolicyAllows(state, "provision-fresh-device"),
+ true,
+ );
+ assert.equal(
+ lifecycle.canAdmitProvisioningConnection({
+ policyAllowed: true,
+ targetSource: "fresh-scan",
+ hasSuccessfulLocalConnect: false,
+ localPrerequisitesReady: true,
+ }),
+ true,
+ );
+});
+
+test("legacy backend recovery evidence keeps exact UUID and mode outside UI admission", () => {
+ const decision = (allowed, target_source, required_transport_ref, required_connection_mode) => ({
+ allowed,
+ reason_codes: allowed ? [] : ["not-selected"],
+ target_source,
+ required_transport_ref,
+ required_connection_mode,
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ });
+ const state = {
+ ble_discovery_generation: 7,
+ devices: [{
+ device_id: "fresh-policy-k1",
+ name: "Lixel K1",
+ connectable: true,
+ }],
+ network_mutation_ledger: {
+ status: "unresolved",
+ mutation_allowed: false,
+ },
+ connection_policy: {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ allowed_actions: [
+ "observe-fresh-device-network",
+ "observe-current-device-network",
+ "observe-configured-device-network",
+ ],
+ actions: {
+ "observe-fresh-device-network": decision(
+ true,
+ "fresh-scan",
+ "fresh-policy-k1",
+ "direct-connect",
+ ),
+ "observe-current-device-network": decision(
+ true,
+ "retained-current-process",
+ "retained-policy-k1",
+ "quick-connect",
+ ),
+ "observe-configured-device-network": decision(
+ true,
+ "durable-configured-state",
+ "durable-policy-k1",
+ "bridge",
+ ),
+ },
+ facts: { retained_context_is_presence: false },
+ },
+ };
+
+ assert.deepEqual(
+ lifecycle.readOnlyConnectionObservationTarget(
+ state,
+ "different-browser-selection",
+ "bridge",
+ ),
+ {
+ action: "observe-fresh-device-network",
+ deviceId: "fresh-policy-k1",
+ connectionMode: "direct-connect",
+ source: "fresh-scan",
+ serverBound: true,
+ expectedDiscoveryGeneration: 7,
+ },
+ );
+
+ state.devices = [];
+ state.connection_policy.allowed_actions = [
+ "observe-current-device-network",
+ "observe-configured-device-network",
+ ];
+ state.connection_policy.actions["observe-fresh-device-network"] = decision(
+ false,
+ "fresh-scan",
+ "fresh-policy-k1",
+ "direct-connect",
+ );
+ assert.deepEqual(
+ lifecycle.readOnlyConnectionObservationTarget(
+ state,
+ "stale-browser-selection",
+ "direct-connect",
+ ),
+ {
+ action: "observe-current-device-network",
+ deviceId: "retained-policy-k1",
+ connectionMode: "quick-connect",
+ source: "retained-current-process",
+ serverBound: true,
+ expectedDiscoveryGeneration: null,
+ },
+ );
+ assert.deepEqual(
+ lifecycle.serverBoundAppliedNetworkObservationTarget(state, "quick-connect"),
+ {
+ action: "observe-current-device-network",
+ deviceId: "retained-policy-k1",
+ connectionMode: "quick-connect",
+ source: "retained-current-process",
+ serverBound: true,
+ expectedDiscoveryGeneration: null,
+ },
+ );
+
+ state.network_mutation_ledger = {
+ status: "resolved",
+ mutation_allowed: true,
+ };
+ state.devices = [{
+ device_id: "durable-policy-k1",
+ name: "Lixel K1",
+ connectable: true,
+ }];
+ state.connection_policy.allowed_actions = [
+ "observe-fresh-device-network",
+ "observe-configured-device-network",
+ ];
+ state.connection_policy.actions["observe-fresh-device-network"] = decision(
+ true,
+ "fresh-scan",
+ null,
+ null,
+ );
+ state.connection_policy.actions["observe-current-device-network"] = decision(
+ false,
+ "retained-current-process",
+ "retained-policy-k1",
+ "quick-connect",
+ );
+ assert.deepEqual(
+ lifecycle.readOnlyConnectionObservationTarget(
+ state,
+ "stale-browser-selection",
+ "direct-connect",
+ ),
+ {
+ action: "observe-configured-device-network",
+ deviceId: "durable-policy-k1",
+ connectionMode: "bridge",
+ source: "durable-configured-state",
+ serverBound: true,
+ expectedDiscoveryGeneration: null,
+ },
+ );
+ assert.deepEqual(
+ lifecycle.serverBoundAppliedNetworkObservationTarget(state, "bridge"),
+ {
+ action: "observe-configured-device-network",
+ deviceId: "durable-policy-k1",
+ connectionMode: "bridge",
+ source: "durable-configured-state",
+ serverBound: true,
+ expectedDiscoveryGeneration: null,
+ },
+ );
+});
+
+test("legacy unresolved backend evidence never falls back to stale browser state", () => {
+ const state = {
+ devices: [],
+ network_mutation_ledger: {
+ status: "unresolved",
+ mutation_allowed: false,
+ },
+ connection_policy: {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ allowed_actions: ["observe-configured-device-network"],
+ actions: {
+ "observe-configured-device-network": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "durable-configured-state",
+ required_transport_ref: "durable-policy-k1",
+ required_connection_mode: null,
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ },
+ facts: { retained_context_is_presence: false },
+ },
+ };
+
+ assert.equal(
+ lifecycle.readOnlyConnectionObservationTarget(
+ state,
+ "stale-browser-k1",
+ "quick-connect",
+ ),
+ null,
+ );
+});
+
+test("local receiver cleanup never targets a replay session's retained acquisition", () => {
+ assert.deepEqual(lifecycle.localReceiverStopPlan({
+ source_mode: "replay",
+ acquisition: {
+ acquisition_id: "terminal-live-acquisition",
+ state: "completed",
+ cleanup_pending: false,
+ },
+ }), { kind: "compatibility" });
+
+ assert.deepEqual(lifecycle.localReceiverStopPlan({
+ source_mode: "idle",
+ acquisition: {
+ acquisition_id: " exact-cleanup-target ",
+ state: "failed",
+ cleanup_pending: true,
+ },
+ }), {
+ kind: "acquisition",
+ acquisitionId: "exact-cleanup-target",
+ });
+
+ assert.deepEqual(lifecycle.localReceiverStopPlan({
+ source_mode: "idle",
+ acquisition: {
+ acquisition_id: "released-terminal-acquisition",
+ state: "failed",
+ cleanup_pending: false,
+ },
+ }), { kind: "compatibility" });
+});
+
+test("local receiver policy denial distinguishes proven idle from active cleanup", () => {
+ assert.equal(lifecycle.isProvenLocalReceiverInactive({
+ source_mode: "idle",
+ acquisition: null,
+ }), true);
+ assert.equal(lifecycle.isProvenLocalReceiverInactive({
+ source_mode: "idle",
+ acquisition: {
+ state: "failed",
+ cleanup_pending: false,
+ },
+ }), true);
+ assert.equal(lifecycle.isProvenLocalReceiverInactive({
+ source_mode: "idle",
+ acquisition: {
+ state: "failed",
+ cleanup_pending: true,
+ },
+ }), false);
+ assert.equal(lifecycle.isProvenLocalReceiverInactive({
+ source_mode: "live",
+ acquisition: {
+ state: "acquiring",
+ cleanup_pending: false,
+ },
+ }), false);
+ assert.equal(lifecycle.isProvenLocalReceiverInactive({
+ source_mode: "replay",
+ acquisition: {
+ state: "completed",
+ cleanup_pending: false,
+ },
+ }), false);
+});
+
+test("trusted K1 recovery uses only one exact backend-owned device and mode", () => {
+ const state = {
+ physical_command: {
+ status: "unresolved",
+ requires_reconciliation: true,
+ resolved_active_recovery_required: false,
+ },
+ connection_policy: {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ allowed_actions: ["observe-configured-device-network"],
+ actions: {
+ "provision-fresh-device": {
+ allowed: false,
+ reason_codes: ["physical-command-reconciliation-required"],
+ target_source: "fresh-scan",
+ required_transport_ref: "physical-k1",
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ "observe-configured-device-network": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "durable-configured-state",
+ required_transport_ref: "physical-k1",
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ },
+ facts: { retained_context_is_presence: false },
+ },
+ semantic_topology_store: {
+ record: {
+ transport_ref: "semantic-k1",
+ connection_mode: "quick-connect",
+ },
+ },
+ current_device_recovery: {
+ transport_ref: "process-k1",
+ connection_mode: "direct-connect",
+ },
+ };
+
+ assert.deepEqual(lifecycle.trustedConnectionBinding(state), {
+ deviceId: "physical-k1",
+ connectionMode: "bridge",
+ });
+
+ delete state.connection_policy.actions["provision-fresh-device"];
+ state.physical_command.requires_reconciliation = false;
+ assert.deepEqual(lifecycle.trustedConnectionBinding(state), {
+ deviceId: "semantic-k1",
+ connectionMode: "quick-connect",
+ });
+
+ state.semantic_topology_store.record.connection_mode = null;
+ assert.deepEqual(lifecycle.trustedConnectionBinding(state), {
+ deviceId: "process-k1",
+ connectionMode: "direct-connect",
+ });
+
+ state.current_device_recovery.connection_mode = null;
+ assert.equal(lifecycle.trustedConnectionBinding(state), null);
+});
+
+test("only an unresolved durable STOP blocks connection controls during backend cleanup", () => {
+ const unresolvedStop = {
+ source_mode: "live",
+ acquisition: {
+ state: "stopping",
+ cleanup_pending: true,
+ },
+ application_control_session: {
+ state: "awaiting-standby-confirmation",
+ physical_command: {
+ status: "unresolved",
+ requires_reconciliation: true,
+ record: {
+ action: "stop",
+ stage: "requested",
+ resolution: null,
+ },
+ },
+ },
+ };
+ assert.equal(lifecycle.isPhysicalStopRecoverySettling(unresolvedStop), true);
+
+ const boundedTimeout = structuredClone(unresolvedStop);
+ boundedTimeout.source_mode = "idle";
+ boundedTimeout.acquisition.state = "failed";
+ boundedTimeout.acquisition.cleanup_pending = false;
+ boundedTimeout.operations = [{
+ action: "acquisition.stop",
+ status: "timed_out",
+ }];
+ // The durable physical ledger remains intentionally unresolved, but all
+ // local work is released. The connection screen must leave the STOP loader
+ // and may start the separate bounded connection recovery path.
+ assert.equal(lifecycle.isPhysicalStopRecoverySettling(boundedTimeout), false);
+
+ const backendObservedStandby = structuredClone(unresolvedStop);
+ backendObservedStandby.application_control_session.state = "idle";
+ backendObservedStandby.application_control_session.physical_command.status = "resolved";
+ backendObservedStandby.application_control_session.physical_command.requires_reconciliation = false;
+ backendObservedStandby.application_control_session.physical_command.record.stage = "resolved";
+ backendObservedStandby.application_control_session.physical_command.record.resolution =
+ "stop-standby-observed";
+ assert.equal(
+ lifecycle.isPhysicalStopRecoverySettling(backendObservedStandby),
+ false,
+ );
+
+ const unresolvedStart = structuredClone(unresolvedStop);
+ unresolvedStart.application_control_session.physical_command.record.action = "start";
+ assert.equal(lifecycle.isPhysicalStopRecoverySettling(unresolvedStart), false);
+});
+
+test("physical recovery stays pinned to the original K1 across a multi-device BLE scan", () => {
+ const state = {
+ physical_command: {
+ status: "unresolved",
+ requires_reconciliation: true,
+ resolved_active_recovery_required: false,
+ },
+ ble_discovery_generation: 9,
+ devices: [
+ { device_id: "nearby-other-k1", name: "Nearby K1", connectable: true },
+ { device_id: "original-k1", name: "Original K1", connectable: true },
+ ],
+ connection_policy: {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ allowed_actions: ["scan-ble", "observe-fresh-device-network"],
+ actions: {
+ "provision-fresh-device": {
+ allowed: false,
+ reason_codes: ["physical-device-already-active"],
+ target_source: "fresh-scan",
+ required_transport_ref: "original-k1",
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ "observe-fresh-device-network": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "fresh-scan",
+ required_transport_ref: "original-k1",
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ },
+ facts: { retained_context_is_presence: false },
+ },
+ };
+
+ assert.equal(lifecycle.requiresReadOnlyPhysicalRecovery(state), true);
+ assert.deepEqual(lifecycle.readOnlyPhysicalRecoveryBinding(state), {
+ deviceId: "original-k1",
+ connectionMode: "bridge",
+ });
+ assert.deepEqual(
+ lifecycle.readOnlyConnectionObservationTarget(
+ state,
+ "nearby-other-k1",
+ "quick-connect",
+ ),
+ {
+ action: "observe-fresh-device-network",
+ deviceId: "original-k1",
+ connectionMode: "bridge",
+ source: "fresh-scan",
+ serverBound: true,
+ expectedDiscoveryGeneration: 9,
+ },
+ );
+
+ state.devices = [{ device_id: "nearby-other-k1", name: "Nearby K1", connectable: true }];
+ assert.equal(
+ lifecycle.readOnlyConnectionObservationTarget(
+ state,
+ "nearby-other-k1",
+ "quick-connect",
+ ),
+ null,
+ "another nearby K1 must not become a fallback recovery target",
+ );
+});
+
+test("READY-classified STOP exits recovery UI while START follows backend authority", () => {
+ const readyPhysical = {
+ status: "resolved",
+ reason_code: null,
+ requires_reconciliation: false,
+ resolved_active_recovery_required: false,
+ observed_session_state: "ready",
+ record: {
+ action: "stop",
+ stage: "resolved",
+ resolution: "not-dispatched",
+ reconciled_physical_state: "standby",
+ },
+ };
+ const readySuccessor = {
+ physical_command: readyPhysical,
+ application_control_session: {
+ physical_command: readyPhysical,
+ },
+ connection_policy: {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ allowed_actions: ["start-acquisition"],
+ actions: {
+ "start-acquisition": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "durable-physical-command",
+ required_transport_ref: "original-k1",
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ "provision-fresh-device": {
+ allowed: false,
+ reason_codes: ["physical-command-reconciliation-required"],
+ target_source: "fresh-scan",
+ required_transport_ref: "original-k1",
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ },
+ facts: { retained_context_is_presence: false },
+ },
+ connection_lifecycle: {
+ schema_version: "missioncore.xgrids-k1-connection-lifecycle/v1",
+ mode_selection: {
+ allowed: false,
+ reason_codes: ["connection-mode-selection-physical-state-unsafe"],
+ automatic_retry: false,
+ },
+ allowed_actions: ["start-acquisition"],
+ },
+ };
+
+ assert.equal(lifecycle.requiresReadOnlyPhysicalRecovery(readySuccessor), false);
+ assert.equal(lifecycle.connectionPolicyAllows(readySuccessor, "start-acquisition"), true);
+ assert.equal(
+ lifecycle.connectionPolicyAllows(readySuccessor, "provision-fresh-device"),
+ false,
+ "the exact successor binding remains pinned against network mutation",
+ );
+ assert.equal(
+ lifecycle.canSelectConnectionMode(readySuccessor),
+ false,
+ "the exact successor binding remains pinned against mode changes",
+ );
+
+ const unresolved = structuredClone(readySuccessor);
+ unresolved.application_control_session.physical_command.requires_reconciliation = true;
+ assert.equal(lifecycle.requiresReadOnlyPhysicalRecovery(unresolved), true);
+
+ const scanOver = structuredClone(readySuccessor);
+ scanOver.application_control_session.physical_command.requires_reconciliation = true;
+ scanOver.application_control_session.physical_command.resolved_scan_over_recovery_required = true;
+ assert.equal(lifecycle.requiresReadOnlyPhysicalRecovery(scanOver), true);
+
+ const reconciledActive = structuredClone(readySuccessor);
+ reconciledActive.application_control_session.physical_command.resolved_active_recovery_required = true;
+ reconciledActive.application_control_session.physical_command.observed_session_state = "scanning";
+ assert.equal(lifecycle.requiresReadOnlyPhysicalRecovery(reconciledActive), true);
+
+ const reopened = structuredClone(readySuccessor);
+ reopened.application_control_session.physical_command.resolved_active_recovery_required = true;
+ reopened.application_control_session.physical_command.reopened_physical_state_recovery_required = true;
+ assert.equal(lifecycle.requiresReadOnlyPhysicalRecovery(reopened), true);
+});
+
+test("only a newer authoritative reachable lease resolves connection errors", () => {
+ const error = {
+ action: "connect",
+ runtimeId: "runtime-a",
+ leaseGeneration: 4,
+ };
+ const reachableBridge = supervisedConnectionState({ generation: 5 });
+
+ assert.equal(
+ lifecycle.authoritativeReachableLeaseSupersedesError(error, reachableBridge),
+ true,
+ );
+ assert.equal(
+ lifecycle.authoritativeReachableLeaseSupersedesError(
+ { ...error, action: "scan" },
+ reachableBridge,
+ ),
+ false,
+ );
+ assert.equal(
+ lifecycle.authoritativeReachableLeaseSupersedesError(
+ error,
+ supervisedConnectionState({ generation: 4 }),
+ ),
+ false,
+ );
+ assert.equal(
+ lifecycle.authoritativeReachableLeaseSupersedesError(
+ error,
+ { ...reachableBridge, network_write_reconciliation: {} },
+ ),
+ false,
+ );
+ assert.equal(
+ lifecycle.authoritativeReachableLeaseSupersedesError(
+ error,
+ { ...reachableBridge, snapshot_runtime_id: "runtime-b" },
+ ),
+ false,
+ );
+});
+
+test("connection-mode reset is explicit and CAS-fenced before the next flow", async () => {
+ const pipelineSource = await readFile(
+ new URL(
+ "../../../plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx",
+ import.meta.url,
+ ),
+ "utf8",
+ );
+ const connectionSource = await readFile(
+ new URL(
+ "../../../plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx",
+ import.meta.url,
+ ),
+ "utf8",
+ );
+
+ assert.doesNotMatch(pipelineSource, /nextReachableConnectionModeSynchronization/);
+ assert.doesNotMatch(pipelineSource, /synchronizedLeaseKeyRef/);
+ assert.doesNotMatch(pipelineSource, /setConnectionMode\(/);
+ assert.match(connectionSource, /state\.desired_connection_mode/);
+ assert.doesNotMatch(connectionSource, /selectConnectionMode\(\{/);
+ assert.match(connectionSource, /desiredModeLocallyDirty\.current = mode !== state\?\.desired_connection_mode/);
+ assert.match(connectionSource, /setDesiredConnectionMode\(mode\)/);
+ const localModeChange = pipelineSource.slice(
+ pipelineSource.indexOf("const changeDesiredConnectionMode"),
+ pipelineSource.indexOf("const selectFreshDevice"),
+ );
+ const explicitModeCommit = pipelineSource.slice(
+ pipelineSource.indexOf("const commitDesiredModeForExplicitAction"),
+ pipelineSource.indexOf("const currentPreparingReconfigurationRequest"),
+ );
+ assert.match(localModeChange, /await selectConnectionMode\(\{/);
+ assert.match(localModeChange, /expected_revision: expectedRevision as number/);
+ assert.match(localModeChange, /reset_scenario: true/);
+ assert.match(localModeChange, /reset_id: resetId/);
+ assert.doesNotMatch(
+ localModeChange,
+ /scanWithResult\(|connect\(|verifyConnection\(|prepareConnection/,
+ );
+ assert.match(explicitModeCommit, /selectConnectionMode\(\{/);
+ assert.match(explicitModeCommit, /expected_revision: expectedRevision as number/);
+ assert.match(
+ pipelineSource,
+ /const repeatDeviceScan[\s\S]*?await commitDesiredModeForExplicitAction\(\)/,
+ );
+ assert.match(
+ pipelineSource,
+ /const submitConnect[\s\S]*?await commitDesiredModeForExplicitAction\(\)/,
+ );
+});
+
+test("connection-mode selector follows the authoritative backend admission", () => {
+ const ready = supervisedConnectionState({ connectionReady: true });
+ assert.equal(lifecycle.canSelectConnectionMode(ready), true);
+
+ const prepared = structuredClone(ready);
+ prepared.acquisition = {
+ acquisition_id: "acq-prepared",
+ state: "prepared",
+ project_name: "TEST001",
+ };
+ assert.equal(lifecycle.canSelectConnectionMode(prepared), true);
+
+ for (const reasonCode of [
+ "connection-mode-selection-control-state-unsafe",
+ "connection-mode-selection-acquisition-active",
+ "connection-mode-selection-physical-state-unsafe",
+ ]) {
+ const blocked = structuredClone(ready);
+ blocked.connection_lifecycle.mode_selection = {
+ allowed: false,
+ reason_codes: [reasonCode],
+ automatic_retry: false,
+ };
+ blocked.connection_lifecycle.allowed_actions = ["start-acquisition"];
+ assert.equal(lifecycle.canSelectConnectionMode(blocked), false);
+ }
+
+ const drifted = structuredClone(ready);
+ drifted.connection_lifecycle.allowed_actions = ["start-acquisition"];
+ assert.equal(lifecycle.canSelectConnectionMode(drifted), false);
+ assert.equal(lifecycle.canSelectConnectionMode({}), false);
+});
+
+test("prepared project survives a mode draft and its cancellation", () => {
+ assert.equal(projectName.shouldHydratePreparedProject({
+ acquisitionId: "acq-prepared",
+ hydratedAcquisitionId: null,
+ modeSwitchRequired: false,
+ }), true);
+ let displayedProject = projectName.projectNameAfterConnectionModeSelection("TEST001");
+ assert.equal(displayedProject, "TEST001");
+ assert.equal(projectName.validateProjectName(displayedProject).error, null);
+
+ assert.equal(projectName.shouldHydratePreparedProject({
+ acquisitionId: "acq-prepared",
+ hydratedAcquisitionId: "acq-prepared",
+ modeSwitchRequired: true,
+ }), false);
+ displayedProject = projectName.projectNameAfterConnectionModeSelection("TEST001");
+ assert.equal(displayedProject, "TEST001");
+ assert.equal(projectName.validateProjectName(displayedProject).error, null);
+
+ assert.equal(projectName.shouldHydratePreparedProject({
+ acquisitionId: "acq-prepared",
+ hydratedAcquisitionId: "acq-prepared",
+ modeSwitchRequired: false,
+ }), true);
+
+ assert.equal(projectName.projectNameAfterConnectionModeSelection(null), "");
+});
+
+test("one in-flight provisioning call keeps its idempotency key internally", () => {
let created = 0;
const createUuid = () => {
created += 1;
@@ -711,13 +3200,184 @@ test("provisioning intent keeps one idempotency key and exposes unsafe outcomes"
const repeated = lifecycle.provisioningIntentKey(first, createUuid);
const failedOperation = {
status: "failed",
- error: { safe_to_retry: false },
+ error: { safe_to_retry: false, side_effect_status: "unknown" },
+ };
+ const safePreWriteFailure = {
+ status: "failed",
+ error: { safe_to_retry: true, side_effect_status: "none" },
};
assert.equal(first, "network-provision:11111111-1111-4111-8111-111111111111");
assert.equal(repeated, first);
assert.equal(created, 1);
assert.equal(lifecycle.operationNeedsReconciliation(failedOperation), true);
+ assert.equal(
+ lifecycle.operationAllowsFreshProvisioningIntent(safePreWriteFailure),
+ true,
+ );
+ assert.equal(
+ lifecycle.operationAllowsFreshProvisioningIntent(failedOperation),
+ false,
+ );
+});
+
+test("read-only verification releases only a durably resolved matching write fence", () => {
+ const fence = {
+ operation_id: "op-ambiguous",
+ transport_ref: "k1-a",
+ };
+ const before = {
+ snapshot_runtime_id: "runtime-a",
+ network_write_reconciliation: fence,
+ };
+ const resolvedLedger = {
+ status: "resolved",
+ mutation_allowed: true,
+ operation_id: "op-ambiguous",
+ stage: "resolved",
+ resolution: "target-observed",
+ };
+
+ assert.equal(
+ lifecycle.readOnlyVerificationClearedReconciliation(
+ before,
+ {
+ snapshot_runtime_id: "runtime-restarted",
+ network_write_reconciliation: null,
+ network_mutation_ledger: resolvedLedger,
+ },
+ "k1-a",
+ ),
+ true,
+ );
+ assert.equal(
+ lifecycle.readOnlyVerificationClearedReconciliation(
+ before,
+ {
+ network_write_reconciliation: null,
+ network_mutation_ledger: {
+ status: "empty",
+ mutation_allowed: true,
+ operation_id: null,
+ stage: null,
+ resolution: null,
+ },
+ },
+ "k1-a",
+ ),
+ true,
+ );
+ assert.equal(
+ lifecycle.readOnlyVerificationClearedReconciliation(before, {}, "k1-a"),
+ false,
+ );
+ assert.equal(
+ lifecycle.readOnlyVerificationClearedReconciliation(
+ before,
+ {
+ network_write_reconciliation: fence,
+ network_mutation_ledger: {
+ ...resolvedLedger,
+ status: "unresolved",
+ mutation_allowed: false,
+ stage: "observing",
+ resolution: null,
+ },
+ },
+ "k1-a",
+ ),
+ false,
+ );
+ assert.equal(
+ lifecycle.readOnlyVerificationClearedReconciliation(
+ before,
+ {
+ network_write_reconciliation: null,
+ network_mutation_ledger: resolvedLedger,
+ },
+ "k1-b",
+ ),
+ false,
+ );
+ assert.equal(
+ lifecycle.readOnlyVerificationClearedReconciliation(
+ before,
+ {
+ network_write_reconciliation: null,
+ network_mutation_ledger: {
+ ...resolvedLedger,
+ operation_id: "op-different",
+ },
+ },
+ "k1-a",
+ ),
+ false,
+ );
+ assert.equal(
+ lifecycle.readOnlyVerificationClearedReconciliation(
+ before,
+ {
+ network_write_reconciliation: null,
+ network_mutation_ledger: {
+ ...resolvedLedger,
+ resolution: null,
+ },
+ },
+ "k1-a",
+ ),
+ false,
+ );
+});
+
+test("each terminal explicit provisioning click starts a fresh operation identity", async () => {
+ const hookSource = await readFile(
+ new URL(
+ "../../../plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts",
+ import.meta.url,
+ ),
+ "utf8",
+ );
+ const pipelineSource = await readFile(
+ new URL(
+ "../../../plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx",
+ import.meta.url,
+ ),
+ "utf8",
+ );
+ const connectRecovery = hookSource.slice(
+ hookSource.indexOf("const connect = useCallback"),
+ hookSource.indexOf("const verifyConnection = useCallback"),
+ );
+ const submitConnect = pipelineSource.slice(
+ pipelineSource.indexOf("const submitConnect = async"),
+ pipelineSource.indexOf("return (", pipelineSource.indexOf("const submitConnect = async")),
+ );
+
+ assert.match(
+ connectRecovery,
+ /failedOperation\?\.status === "succeeded"[\s\S]*?!exactAppliedNetworkIntentCompleted\([\s\S]*?&& !hasExactConnectionReady/,
+ );
+ assert.match(
+ connectRecovery,
+ /const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted\([\s\S]*?nextState = acceptSuccessfulConnectState\(nextState\)/,
+ );
+ assert.match(connectRecovery, /acceptedBleSessionKeyAfterConnect\(/);
+ assert.match(
+ connectRecovery,
+ /acceptedSessionKey:\s*networkIntentCompleted \? acceptedSessionKey : null/,
+ );
+ assert.match(submitConnect, /provisioningIntentKey\(null\)/);
+ assert.doesNotMatch(submitConnect, /provisioningIntentRef/);
+ assert.match(
+ submitConnect,
+ /const freshStartAllowed = result\.intentDisposition === "release"/,
+ );
+ assert.match(
+ submitConnect,
+ /setExplicitProvisioningDraft\(null\);[\s\S]*?setPassword\(""\);[\s\S]*?await connect\(/,
+ );
+ assert.match(submitConnect, /setSelectedDeviceSnapshot\(null\)/);
+ assert.doesNotMatch(pipelineSource, /Проверить K1 без записи/);
});
test("device mutations send explicit nested compatibility attestation", async () => {
@@ -739,12 +3399,15 @@ test("device mutations send explicit nested compatibility attestation", async ()
try {
await xgridsK1Api.connect({
+ expected_snapshot_runtime_id: "snapshot-runtime-test",
device_id: "ble-device",
ssid: "lab-network",
password: syntheticCredential,
connection_mode: "bridge",
compatibility_attestation: attestation,
idempotency_key: "network-provision:test",
+ expected_mode_revision: 4,
+ expected_discovery_generation: 9,
});
await xgridsK1Api.prepareAcquisition({
project_name: "Mission 01",
@@ -761,11 +3424,17 @@ test("device mutations send explicit nested compatibility attestation", async ()
const prepare = JSON.parse(calls[1].init.body);
assert.deepEqual(provisioning.input.compatibility_attestation, attestation);
assert.equal(provisioning.input.idempotency_key, "network-provision:test");
+ assert.equal(provisioning.input.expected_mode_revision, 4);
+ assert.equal(provisioning.input.expected_discovery_generation, 9);
+ assert.equal(
+ provisioning.input.expected_snapshot_runtime_id,
+ "snapshot-runtime-test",
+ );
assert.deepEqual(prepare.input.compatibility_attestation, attestation);
assert.equal(prepare.input.project_name, "Mission 01");
});
-test("connection verification supports refresh and explicit read-only adoption", async () => {
+test("endpoint probing is separate from BLE refresh and read-only adoption", async () => {
const originalFetch = globalThis.fetch;
const calls = [];
globalThis.fetch = async (path, init) => {
@@ -782,30 +3451,451 @@ test("connection verification supports refresh and explicit read-only adoption",
};
try {
- await xgridsK1Api.verifyConnection();
await xgridsK1Api.verifyConnection({
+ expected_snapshot_runtime_id: "snapshot-runtime-test",
device_id: "fresh-ble-device",
+ source: "fresh-scan",
compatibility_attestation: attestation,
+ expected_discovery_generation: 11,
+ });
+ await xgridsK1Api.verifyConnection({
+ expected_snapshot_runtime_id: "snapshot-runtime-test",
+ device_id: "durable-ble-device",
+ source: "durable-configured-state",
+ compatibility_attestation: attestation,
+ });
+ await xgridsK1Api.probeConfiguredEndpoint({
+ expected_snapshot_runtime_id: "snapshot-runtime-test",
});
} finally {
globalThis.fetch = originalFetch;
}
- assert.equal(calls.length, 2);
+ assert.equal(calls.length, 3);
assert.match(String(calls[0].path), /actions\/connection\.verify$/);
- assert.match(String(calls[1].path), /actions\/connection\.verify$/);
- assert.deepEqual(JSON.parse(calls[0].init.body), { input: {} });
- const adoption = JSON.parse(calls[1].init.body).input;
+ const adoption = JSON.parse(calls[0].init.body).input;
assert.deepEqual(adoption, {
+ expected_snapshot_runtime_id: "snapshot-runtime-test",
device_id: "fresh-ble-device",
+ source: "fresh-scan",
compatibility_attestation: attestation,
+ expected_discovery_generation: 11,
});
assert.equal("ssid" in adoption, false);
assert.equal("password" in adoption, false);
assert.equal("connection_mode" in adoption, false);
+ const durableAdoption = JSON.parse(calls[1].init.body).input;
+ assert.deepEqual(durableAdoption, {
+ expected_snapshot_runtime_id: "snapshot-runtime-test",
+ device_id: "durable-ble-device",
+ source: "durable-configured-state",
+ compatibility_attestation: attestation,
+ });
+ assert.equal("expected_discovery_generation" in durableAdoption, false);
+ assert.match(String(calls[2].path), /actions\/connection\.endpoint-probe$/);
+ assert.deepEqual(JSON.parse(calls[2].init.body), {
+ input: { expected_snapshot_runtime_id: "snapshot-runtime-test" },
+ });
});
-test("rejected network-profile writes use safe operator copy", () => {
+test("connection verification accepts only the backend literal status and lease contract", () => {
+ const statuses = [
+ "not-probed",
+ "device-network-applied",
+ "device-network-applied-host-failed",
+ "adopted",
+ "host-route-mismatch",
+ "endpoint-unreachable",
+ "tcp-reachable-device-info-unverified",
+ "reachable",
+ "recovered",
+ "control-transport-lost",
+ "unreachable",
+ ];
+ const leaseStates = ["disconnected", "configured-unverified", "reachable"];
+ assert.deepEqual([...XGRIDS_CONNECTION_VERIFICATION_STATUSES], statuses);
+ assert.deepEqual([...XGRIDS_CONNECTION_VERIFICATION_LEASE_STATES], leaseStates);
+
+ const exact = {
+ status: "not-probed",
+ lease_state: "disconnected",
+ lease_generation: 0,
+ supervisor_revision: 1,
+ endpoint_validation: "not-performed",
+ network_reachability: "unknown",
+ observed_at: null,
+ };
+ for (const status of statuses) {
+ assert.equal(isXgridsConnectionVerification({ ...exact, status }), true, status);
+ }
+ for (const lease_state of leaseStates) {
+ assert.equal(
+ isXgridsConnectionVerification({ ...exact, lease_state }),
+ true,
+ lease_state,
+ );
+ }
+
+ for (const malformed of [
+ { ...exact, status: "configured" },
+ { ...exact, lease_state: "lost" },
+ { ...exact, lease_generation: -1 },
+ { ...exact, supervisor_revision: 1.25 },
+ { ...exact, network_reachability: "degraded" },
+ { ...exact, observed_at: 123 },
+ Object.fromEntries(Object.entries(exact).filter(([key]) => key !== "status")),
+ Object.fromEntries(Object.entries(exact).filter(([key]) => key !== "lease_state")),
+ ]) {
+ assert.equal(isXgridsConnectionVerification(malformed), false);
+ }
+});
+
+test("connection policy accepts the exact restart-recovery actions, sources and modes", () => {
+ assert.deepEqual([...XGRIDS_CONNECTION_POLICY_ACTIONS], [
+ "scan-ble",
+ "provision-fresh-device",
+ "prepare-select-device",
+ "prepare-change-network",
+ "cancel-reconfiguration",
+ "recover-current-device-network",
+ "observe-fresh-device-network",
+ "observe-current-device-network",
+ "observe-configured-device-network",
+ "inspect-configured-endpoint",
+ "inspect-host-network",
+ "probe-endpoint",
+ "verify-control-device-info",
+ "start-acquisition",
+ "stop-acquisition",
+ "stop-local-receiver",
+ "retire-unavailable-physical-target",
+ "acknowledge-data-loss",
+ ]);
+ assert.deepEqual([...XGRIDS_CONNECTION_POLICY_TARGET_SOURCES], [
+ "none",
+ "fresh-scan",
+ "retained-current-process",
+ "durable-configured-state",
+ "configured-topology",
+ "connection-supervisor",
+ "local-runtime",
+ "local-prestart-handoff",
+ "local-reconfiguration-intent",
+ "durable-physical-command",
+ ]);
+
+ const retainedDecision = {
+ allowed: true,
+ reason_codes: [],
+ target_source: "retained-current-process",
+ required_transport_ref: "9AE978F2-37A8-4B4B-9BCD-BD7010BB20D1",
+ required_connection_mode: "quick-connect",
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ };
+ const durableDecision = {
+ ...retainedDecision,
+ target_source: "durable-configured-state",
+ required_transport_ref: "0CA8AB68-E37B-46A2-B09F-C34B5C49428C",
+ required_connection_mode: "bridge",
+ };
+ const policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ supervisor_revision: 17,
+ network_ledger_revision: 4,
+ recommended_action: "observe-current-device-network",
+ allowed_actions: [
+ "observe-current-device-network",
+ "observe-configured-device-network",
+ ],
+ actions: {
+ "observe-current-device-network": retainedDecision,
+ "observe-configured-device-network": durableDecision,
+ },
+ facts: { retained_context_is_presence: false },
+ };
+
+ assert.equal(isXgridsConnectionPolicyDecision(retainedDecision), true);
+ assert.equal(isXgridsConnectionPolicy(policy), true);
+ const retirementDecision = {
+ allowed: true,
+ reason_codes: [],
+ target_source: "durable-physical-command",
+ required_transport_ref: "0CA8AB68-E37B-46A2-B09F-C34B5C49428C",
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: false,
+ physical_command_allowed: false,
+ physical_outcome: "unknown",
+ device_write_performed: false,
+ automatic_retry: false,
+ };
+ assert.equal(isXgridsConnectionPolicyDecision(retirementDecision), true);
+ assert.equal(isXgridsConnectionPolicy({
+ ...policy,
+ recommended_action: "retire-unavailable-physical-target",
+ allowed_actions: ["retire-unavailable-physical-target"],
+ actions: {
+ "retire-unavailable-physical-target": retirementDecision,
+ },
+ }), true);
+ for (const malformedRetirement of [
+ { ...retirementDecision, target_source: "durable-configured-state" },
+ { ...retirementDecision, physical_command_allowed: undefined },
+ { ...retirementDecision, physical_outcome: undefined },
+ { ...retirementDecision, device_write_performed: undefined },
+ { ...retirementDecision, requires_live_gatt_validation: true },
+ ]) {
+ assert.equal(isXgridsConnectionPolicy({
+ ...policy,
+ recommended_action: "retire-unavailable-physical-target",
+ allowed_actions: ["retire-unavailable-physical-target"],
+ actions: {
+ "retire-unavailable-physical-target": malformedRetirement,
+ },
+ }), false);
+ }
+ assert.equal(
+ isXgridsConnectionPolicyDecision({
+ ...retainedDecision,
+ required_connection_mode: undefined,
+ }),
+ true,
+ "older decisions may omit the optional mode field",
+ );
+
+ for (const malformed of [
+ { ...retainedDecision, target_source: "server-cache" },
+ { ...retainedDecision, required_connection_mode: "automatic" },
+ { ...retainedDecision, automatic_retry: true },
+ ]) {
+ assert.equal(isXgridsConnectionPolicyDecision(malformed), false);
+ }
+ assert.equal(isXgridsConnectionPolicy({
+ ...policy,
+ actions: {
+ ...policy.actions,
+ "observe-current-device-network": {
+ ...retainedDecision,
+ required_connection_mode: undefined,
+ },
+ },
+ }), false, "an allowed retained recovery must pin its mode");
+ assert.equal(isXgridsConnectionPolicy({
+ ...policy,
+ actions: {
+ ...policy.actions,
+ "observe-configured-device-network": {
+ ...durableDecision,
+ target_source: "configured-topology",
+ },
+ },
+ }), false, "durable recovery cannot drift to an endpoint-only source");
+});
+
+test("connection reconfiguration accepts only the exact resumable v1 projection", () => {
+ const exact = {
+ schema_version: "missioncore.xgrids-k1-connection-reconfiguration/v1",
+ revision: 4,
+ intent_id: "connection-reconfigure-001",
+ intent: "change-network",
+ status: "fresh-scan-completed",
+ required_transport_ref: "BLE-DEVICE-001",
+ required_connection_mode: "bridge",
+ minimum_discovery_generation: 10,
+ fresh_discovery_generation: 10,
+ required_transport_observed: true,
+ prepared_at: "2026-08-10T12:00:00Z",
+ automatic_retry: false,
+ };
+ assert.equal(isXgridsConnectionReconfiguration(exact), true);
+ assert.equal(isXgridsConnectionReconfiguration({
+ ...exact,
+ status: "idle",
+ intent: null,
+ intent_id: null,
+ required_transport_ref: null,
+ required_connection_mode: null,
+ minimum_discovery_generation: null,
+ fresh_discovery_generation: null,
+ required_transport_observed: null,
+ prepared_at: null,
+ }), true);
+ for (const malformed of [
+ { ...exact, revision: -1 },
+ { ...exact, intent: "cancel" },
+ { ...exact, status: "scanning" },
+ { ...exact, required_connection_mode: "bluetooth" },
+ { ...exact, automatic_retry: true },
+ { ...exact, intent_id: null },
+ ]) {
+ assert.equal(isXgridsConnectionReconfiguration(malformed), false);
+ }
+});
+
+test("connection attempt phase distinguishes no write from an unknown write outcome", async () => {
+ assert.deepEqual([...XGRIDS_CONNECTION_ATTEMPT_PHASES], [
+ "network_applied",
+ "network_not_applied",
+ "network_outcome_unknown",
+ ]);
+ for (const phase of XGRIDS_CONNECTION_ATTEMPT_PHASES) {
+ assert.equal(isXgridsConnectionAttemptPhase(phase), true, phase);
+ }
+ for (const phase of [
+ "network_write_failed",
+ "network_not_confirmed",
+ "outcome_unknown",
+ null,
+ ]) {
+ assert.equal(isXgridsConnectionAttemptPhase(phase), false, String(phase));
+ }
+
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async () => new Response(JSON.stringify({
+ state: {
+ source_mode: "idle",
+ connection_attempt: {
+ schema_version: "missioncore.xgrids-k1-connection-attempt/v1",
+ phase: "network_outcome_unknown",
+ automatic_retry: false,
+ },
+ },
+ }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+
+ try {
+ const state = await xgridsK1Api.getState();
+ assert.equal(state.connection_attempt.phase, "network_outcome_unknown");
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test("state API rejects an unreviewed connection attempt phase", async () => {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async () => new Response(JSON.stringify({
+ state: {
+ source_mode: "idle",
+ connection_attempt: {
+ schema_version: "missioncore.xgrids-k1-connection-attempt/v1",
+ phase: "network_write_failed",
+ automatic_retry: false,
+ },
+ },
+ }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+
+ try {
+ await assert.rejects(
+ xgridsK1Api.getState(),
+ (error) => error instanceof ApiError
+ && error.message === "Локальный сервер вернул некорректное состояние.",
+ );
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test("state API rejects a drifted connection verification object at runtime", async () => {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async () => new Response(JSON.stringify({
+ state: {
+ source_mode: "idle",
+ connection_verification: {
+ status: "configured",
+ lease_state: "lost",
+ lease_generation: 1,
+ supervisor_revision: 2,
+ endpoint_validation: "not-performed",
+ network_reachability: "degraded",
+ observed_at: null,
+ },
+ },
+ }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+
+ try {
+ await assert.rejects(
+ xgridsK1Api.getState(),
+ (error) => error instanceof ApiError
+ && error.message === "Локальный сервер вернул некорректное состояние.",
+ );
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test("state API rejects a drifted restart-recovery policy at runtime", async () => {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async () => new Response(JSON.stringify({
+ state: {
+ source_mode: "idle",
+ connection_policy: {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ supervisor_revision: 5,
+ network_ledger_revision: 3,
+ recommended_action: "observe-configured-device-network",
+ allowed_actions: ["observe-configured-device-network"],
+ actions: {
+ "observe-configured-device-network": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "durable-configured-state",
+ required_transport_ref: "exact-k1",
+ required_connection_mode: "automatic",
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ },
+ facts: { retained_context_is_presence: false },
+ },
+ },
+ }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+
+ try {
+ await assert.rejects(
+ xgridsK1Api.getState(),
+ (error) => error instanceof ApiError
+ && error.message === "Локальный сервер вернул некорректное состояние.",
+ );
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test("host diagnostic guard rejects every unreviewed literal dimension", () => {
+ const exact = {
+ schema_version: "missioncore.host-failure-diagnostic/v1",
+ code: "host.keychain.interaction-required",
+ domain: "keychain",
+ impact: "control",
+ operator_action: "unlock-or-authorize-keychain",
+ automatic_retry: false,
+ redacted: true,
+ };
+ assert.equal(isXgridsHostFailureDiagnostic(exact), true);
+ for (const malformed of [
+ { ...exact, code: "PermissionError: private" },
+ { ...exact, domain: "python-runtime" },
+ { ...exact, impact: "unknown-impact" },
+ { ...exact, operator_action: "run-private-shell-command" },
+ { ...exact, automatic_retry: true },
+ { ...exact, redacted: false },
+ ]) {
+ assert.equal(isXgridsHostFailureDiagnostic(malformed), false);
+ }
+});
+
+test("failed network-profile writes close the UI session with a fresh explicit next step", () => {
const message = networkProvisionFailureMessage({
status: "failed",
error: { code: "BleakGATTProtocolError" },
@@ -813,36 +3903,237 @@ test("rejected network-profile writes use safe operator copy", () => {
assert.equal(
message,
- "Сканер отклонил запись сетевого профиля. Результат изменения сети неизвестен; автоматический повтор запрещён. Проверьте текущее состояние K1 или подхватите существующее подключение без изменения настроек Wi‑Fi.",
+ "Bluetooth-периферия завершила сетевую операцию ошибкой. Команда могла быть принята K1; итог текущей попытки не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
);
assert.doesNotMatch(message, /Bleak|GATT|ATT/i);
+
+ const attMessage = networkProvisionFailureMessage({
+ action: "network.provision",
+ status: "failed",
+ error: {
+ code: "BleakGATTProtocolError",
+ ble_att_error_code: 4,
+ ble_att_error_name: "INVALID_PDU",
+ },
+ });
+ assert.match(attMessage, /ATT 4 INVALID_PDU/);
+ assert.match(attMessage, /Автоматический повтор команды K1 не отправлялся/);
+ assert.match(attMessage, /Сессия подключения в интерфейсе сброшена/);
+ assert.match(attMessage, /новый поиск Bluetooth, выберите K1/);
+
+ const preWriteAttMessage = networkProvisionFailureMessage({
+ action: "network.provision",
+ status: "failed",
+ error: {
+ code: "BleakGATTProtocolError",
+ operation_stage: "baseline-read",
+ device_write_attempted: false,
+ side_effect_status: "none",
+ safe_to_retry: true,
+ ble_att_error_code: 4,
+ ble_att_error_name: "INVALID_PDU",
+ },
+ });
+ assert.match(preWriteAttMessage, /до команды изменения сети/);
+ assert.match(preWriteAttMessage, /Запись сетевого профиля не выполнялась/);
+ assert.doesNotMatch(preWriteAttMessage, /результат изменения сети неизвестен/i);
});
-test("host Wi-Fi helper failures do not fabricate a missing-password diagnosis", () => {
+test("post-dispatch ambiguity resets the UI session without an automatic retry", () => {
+ const message = networkProvisionFailureMessage({
+ action: "network.provision",
+ status: "failed",
+ error: {
+ code: "network-provision-target-not-distinguishable-from-baseline",
+ device_write_attempted: true,
+ side_effect_status: "unknown",
+ safe_to_retry: false,
+ },
+ });
+
+ assert.equal(
+ message,
+ "После BLE-команды K1 вернул сетевой статус, неотличимый от исходного; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
+ );
+ assert.doesNotMatch(message, /ручн|read-only|защитный барьер/i);
+});
+
+test("Bluetooth scan failures explain whether a device command was sent", () => {
+ const busy = discoveryScanFailureMessage({
+ action: "discovery.scan",
+ status: "failed",
+ error: { code: "ble-runtime-busy" },
+ });
+ const cleanupPending = discoveryScanFailureMessage({
+ action: "discovery.scan",
+ status: "failed",
+ error: { code: "ble-runtime-cleanup-pending" },
+ });
+ const timedOut = discoveryScanFailureMessage({
+ action: "discovery.scan",
+ status: "failed",
+ error: { code: "ble-discovery-timeout" },
+ });
+
+ assert.match(busy, /занят другой локальной операцией/);
+ assert.match(cleanupPending, /подтверждает отключение/);
+ assert.match(timedOut, /принудительно остановлен/);
+ assert.match(timedOut, /Команды K1 не отправлялись/);
+});
+
+test("connection recovery exposes safe operator-facing failure classes", () => {
+ const addressUnavailable = connectionVerificationFailureMessage({
+ action: "connection.verify",
+ status: "failed",
+ error: {
+ code: "connection-verify-address-unavailable",
+ side_effect_status: "none",
+ safe_to_retry: true,
+ },
+ });
+ const missingAdvertisement = connectionVerificationFailureMessage({
+ action: "connection.verify",
+ status: "failed",
+ error: { code: "connection-verify-device-not-rediscovered" },
+ });
+ const statusReadFailed = connectionVerificationFailureMessage({
+ action: "connection.verify",
+ status: "failed",
+ error: {
+ code: "connection-verify-status-read-failed",
+ operation_stage: "exact-uuid-scan",
+ side_effect_status: "none",
+ },
+ });
+ const exactUuidScanTimedOut = connectionVerificationFailureMessage({
+ action: "connection.verify",
+ status: "failed",
+ error: {
+ code: "connection-verify-exact-uuid-scan-timeout",
+ operation_stage: "exact-uuid-scan",
+ side_effect_status: "none",
+ },
+ });
+ const indistinguishableFromBaseline = connectionVerificationFailureMessage({
+ action: "connection.verify",
+ status: "failed",
+ error: {
+ code: "connection-verify-target-not-distinguishable-from-baseline",
+ side_effect_status: "none",
+ safe_to_retry: false,
+ },
+ });
+ const staleEndpoint = connectionVerificationFailureMessage({
+ action: "connection.verify",
+ status: "failed",
+ error: {
+ code: "connection-verify-mqtt-unreachable",
+ side_effect_status: "none",
+ safe_to_retry: true,
+ },
+ });
+ const bindingChanged = connectionVerificationFailureMessage({
+ action: "connection.verify",
+ status: "failed",
+ error: {
+ code: "application-connection-binding-lost",
+ side_effect_status: "none",
+ safe_to_retry: true,
+ },
+ });
+ const physicalProofTimedOut = connectionVerificationFailureMessage({
+ action: "connection.verify",
+ status: "failed",
+ error: {
+ code: "physical-command-reconciliation-proof-timeout",
+ side_effect_status: "none",
+ safe_to_retry: true,
+ },
+ });
+
+ assert.match(addressUnavailable, /K1 ответил/);
+ assert.match(addressUnavailable, /настройки устройства не менялись/);
+ assert.match(missingAdvertisement, /Mission Core не получил объявление/);
+ assert.match(missingAdvertisement, /Команды K1 не отправлялись/);
+ assert.doesNotMatch(missingAdvertisement, /питани|перезапуст|не работает|пропал/i);
+ assert.match(statusReadFailed, /Mission Core не завершил/);
+ assert.match(statusReadFailed, /команды не отправлялись/);
+ assert.doesNotMatch(statusReadFailed, /питани|перезапуст|не работает|K1 не ответил/i);
+ assert.match(exactUuidScanTimedOut, /точного сохранённого CoreBluetooth UUID/);
+ assert.match(exactUuidScanTimedOut, /не является выводом о состоянии устройства/);
+ assert.doesNotMatch(exactUuidScanTimedOut, /питани|перезапуст|не работает|K1 не ответил/i);
+ assert.match(staleEndpoint, /можно заново применить настройки общей сети/);
+ assert.match(staleEndpoint, /Команда Wi-Fi не отправлялась/);
+ assert.doesNotMatch(staleEndpoint, /новый поиск|read-only/i);
+ assert.match(bindingChanged, /нажмите «Подключиться заново»/);
+ assert.doesNotMatch(bindingChanged, /поиск Bluetooth/i);
+ assert.match(physicalProofTimedOut, /START и STOP не отправлялись/);
+ assert.match(physicalProofTimedOut, /нажмите «Подключиться заново»/);
+ assert.doesNotMatch(physicalProofTimedOut, /поиск Bluetooth/i);
+ assert.equal(
+ indistinguishableFromBaseline,
+ "K1 ответил, но приложение не смогло подтвердить, что прежние настройки сети были применены. Автоматического повтора и новой записи не было.",
+ );
+ assert.doesNotMatch(indistinguishableFromBaseline, /поиск Bluetooth|повторите запись/i);
+});
+
+test("scan and verify errors correlate only with the requested operation id", () => {
+ const state = {
+ operations: [
+ {
+ operation_id: "op-requested",
+ action: "discovery.scan",
+ status: "failed",
+ error: { code: "ble-discovery-timeout" },
+ },
+ {
+ operation_id: "op-other-tab",
+ action: "discovery.scan",
+ status: "failed",
+ error: { code: "ble-discovery-already-running" },
+ },
+ ],
+ last_operation: {
+ operation_id: "op-other-tab",
+ action: "discovery.scan",
+ status: "failed",
+ },
+ };
+
+ assert.equal(
+ operationById(state, "discovery.scan", "op-requested")?.error?.code,
+ "ble-discovery-timeout",
+ );
+ assert.equal(operationById(state, "discovery.scan", "op-missing"), null);
+});
+
+test("host Wi-Fi failures close the attempt and require a fresh explicit connection", () => {
const operationTimeout = networkProvisionFailureMessage({
status: "failed",
error: { code: "host-wifi-operation-timeout" },
});
- const buildTimeout = networkProvisionFailureMessage({
+ const missingNetwork = networkProvisionFailureMessage({
status: "failed",
error: {
- code: "host-wifi-helper-build-timeout",
+ code: "network-not-found",
+ side_effect_status: "confirmed",
+ safe_to_retry: false,
+ scan_attempt_count: 13,
+ scan_elapsed_ms: 17524,
+ },
+ });
+ const preWriteKeychain = networkProvisionFailureMessage({
+ status: "failed",
+ error: {
+ code: "keychain-authorization-required",
side_effect_status: "none",
safe_to_retry: true,
},
});
- const buildFailed = networkProvisionFailureMessage({
+ const postWriteKeychain = networkProvisionFailureMessage({
status: "failed",
error: {
- code: "host-wifi-helper-build-failed",
- side_effect_status: "none",
- safe_to_retry: true,
- },
- });
- const postWriteBuildFailed = networkProvisionFailureMessage({
- status: "failed",
- error: {
- code: "host-wifi-helper-build-failed",
+ code: "keychain-authorization-denied",
side_effect_status: "confirmed",
safe_to_retry: false,
},
@@ -850,19 +4141,12 @@ test("host Wi-Fi helper failures do not fabricate a missing-password diagnosis",
assert.equal(
operationTimeout,
- "Локальная операция подготовки Wi‑Fi не завершилась вовремя. Это могло произойти до изменения состояния K1; наличие сохранённого пароля этим кодом не подтверждается и не опровергается. Проверьте состояние K1 и повторите подключение отдельным действием.",
+ "Локальная операция подготовки Wi‑Fi не завершилась вовремя; итог текущей попытки подключения не подтверждён. Автоматический повтор команды K1 не отправлялся. Сессия подключения в интерфейсе сброшена. Выполните новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз.",
);
assert.doesNotMatch(operationTimeout, /получите пароль|пароль отсутствует/i);
- assert.equal(
- buildTimeout,
- "Локальный компонент Wi‑Fi не успел собраться за отведённое время. Команда K1 не отправлялась; подготовьте локальный компонент и повторите подключение отдельным действием.",
- );
- assert.equal(
- buildFailed,
- "Локальный компонент Wi‑Fi не удалось собрать. Команда K1 не отправлялась; подготовьте локальный компонент и повторите подключение отдельным действием.",
- );
- assert.equal(
- postWriteBuildFailed,
- "Локальный компонент Wi‑Fi не удалось собрать уже после начала операции с K1. Состояние устройства нельзя выводить из этой локальной ошибки; автоматического повтора команды не было. Выполните read-only проверку K1 перед новым подключением.",
- );
+ assert.match(missingNetwork, /K1 принял команду Quick Connect/);
+ assert.match(missingNetwork, /13 проверок за 17\.5 с/);
+ assert.match(missingNetwork, /автоматического повтора не было/);
+ assert.match(preWriteKeychain, /Команда устройству не отправлялась/);
+ assert.match(postWriteKeychain, /Дополнительный пароль не запрашивался/);
});
diff --git a/apps/control-station/test/devicePluginFrontendBoundary.test.mjs b/apps/control-station/test/devicePluginFrontendBoundary.test.mjs
index 712cb43..a656d02 100644
--- a/apps/control-station/test/devicePluginFrontendBoundary.test.mjs
+++ b/apps/control-station/test/devicePluginFrontendBoundary.test.mjs
@@ -105,6 +105,35 @@ test("each device plugin contributes its own connection pipeline component", ()
);
});
+test("selected-model shell leaves the model name to the connection heading", () => {
+ const workspace = readFileSync(
+ join(coreSourceRoot, "workspaces/DeviceWorkspace.tsx"),
+ "utf8",
+ );
+ const app = readFileSync(join(coreSourceRoot, "App.tsx"), "utf8");
+
+ const selectedSlot = workspace.slice(workspace.indexOf("const ConnectionView ="));
+ assert.match(workspace, /
\{model\.displayName\}<\/h3>/);
+ assert.match(selectedSlot, /СЦЕНАРИЙ ПОДКЛЮЧЕНИЯ/);
+ assert.match(selectedSlot, /Модель выбрана<\/strong>/);
+ assert.doesNotMatch(selectedSlot, /selection\.model\.displayName/);
+ assert.doesNotMatch(
+ selectedSlot,
+ /selection\.plugin\.manifest\.metadata\.displayName/,
+ );
+
+ const localContour = app.slice(
+ app.indexOf('id: "local-contour"'),
+ app.indexOf("items={rootWorkspaces", app.indexOf('id: "local-contour"')),
+ );
+ assert.match(
+ localContour,
+ /description: selection \? "Подключение" : "Модель не выбрана"/,
+ );
+ assert.doesNotMatch(localContour, /activeDevice\?\.endpointLabel/);
+ assert.doesNotMatch(localContour, /selection\?\.model\.displayName/);
+});
+
test("registry exposes an optional model-scoped spatial controls contribution", () => {
const connectionView = () => null;
const spatialControlsView = () => null;
@@ -146,6 +175,7 @@ test("XGRIDS frontend is physically plugin-owned and split by operator pipeline"
"components/K1AcquisitionPipeline.tsx",
"components/K1SpatialControls.tsx",
"components/K1Diagnostics.tsx",
+ "physicalCommandConfirmation.ts",
"projectName.ts",
]) {
assert.equal(existsSync(join(pluginFrontendRoot, relativePath)), true, relativePath);
@@ -159,24 +189,523 @@ test("XGRIDS frontend is physically plugin-owned and split by operator pipeline"
assert.match(spatialControls, /cleanup_pending/);
assert.match(spatialControls, /spatialActionFailure/);
assert.match(spatialControls, /role="alert"/);
- assert.match(spatialControls, /Повторить остановку/);
+ assert.doesNotMatch(spatialControls, /Повторить остановку/);
+ assert.match(spatialControls, /stopLocalReceiver/);
+ assert.match(spatialControls, / {
+test("K1 connection surface enforces one scan, local selection, and one Apply", () => {
const provisioning = readFileSync(
join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
"utf8",
);
- assert.match(provisioning, /connectionMode === "bridge"/);
- assert.match(provisioning, /Подхватить существующее подключение/);
- assert.match(provisioning, /pendingAction === "verify"/);
+ const lifecycle = readFileSync(join(pluginFrontendRoot, "lifecycle.ts"), "utf8");
+ const submitPrerequisites = lifecycle.slice(
+ lifecycle.indexOf("export function canSubmitProvisioningMutation"),
+ lifecycle.indexOf("export function canAdmitProvisioningConnection"),
+ );
+ assert.match(provisioning, /bridge:\s*\{/);
assert.match(
provisioning,
- /compatibility_attestation: profileSelectionForConnectionMode\("bridge"\)/,
+ /compatibility_attestation: profileSelectionForConnectionMode\([\s\S]*attemptedConnectionMode/,
);
- assert.match(provisioning, /без изменения настроек Wi‑Fi/);
- assert.match(provisioning, /deviceSummary !== null/);
- assert.match(provisioning, /device_id: deviceSummary\.device_id/);
+ assert.match(provisioning, /scanSecondsRemaining/);
+ assert.match(provisioning, /setInterval\(updateCountdown, 250\)/);
+ assert.match(provisioning, /Поиск Bluetooth · \{scanSecondsRemaining \?\? 6\} с/);
+ assert.match(provisioning, /scanWithResult\(\{[^}]*durationSeconds:\s*6/);
+ assert.doesNotMatch(provisioning, /K1 уже доступен|Сетевой адрес K1 доступен/);
+ assert.doesNotMatch(
+ provisioning,
+ /automaticRecovery|automaticDiscovery|reconnectFallbackAction/,
+ );
+ assert.match(provisioning, /Переподключиться/);
+ assert.match(provisioning, /Подключить новый K1/);
+ assert.doesNotMatch(provisioning, /Вернуть прежний K1 и проверить/);
+ assert.match(provisioning, /explicitProvisioningDraftMatches/);
+ assert.doesNotMatch(
+ provisioning,
+ /powerConfirmed|powerConfirmationEpoch|resetPowerConfirmation|Питание включено|title="Питание"/,
+ );
+ assert.doesNotMatch(submitPrerequisites, /power|питани/i);
+ assert.match(provisioning, /ПОДКЛЮЧЕНИЕ · ШАГИ 01–02/);
+ assert.match(
+ provisioning,
+ /number="01"[\s\S]*?title="Подключение"/,
+ );
+ assert.match(provisioning, /number="02"[\s\S]*?title="Сеть"/);
+ assert.doesNotMatch(provisioning, /number="03"|showDeviceStep/);
+ assert.match(
+ provisioning,
+ /const backendScanAllowed = scanAllowedByPolicy\s*&& !isBusy\s*&& !networkRecoveryRequired/,
+ );
+ assert.match(provisioning, /const showNetworkStep = Boolean\([\s\S]*explicitProvisioningDraftRetained/);
+ assert.match(
+ provisioning,
+ /if \(result\.networkIntentCompleted\)[\s\S]*setExplicitProvisioningDraft\(null\)/,
+ );
+ assert.equal(
+ (provisioning.match(/buttonLabel:\s*"Применить"/g) ?? []).length,
+ 3,
+ );
+ assert.doesNotMatch(provisioning, /<(?:button|input|select|textarea)\b/);
+ assert.doesNotMatch(provisioning, /allow_host_wifi_switch/);
+ assert.doesNotMatch(provisioning, /(?:color|background(?:-color)?):\s*(?:#[0-9a-f]{3,8}|rgba?\()/i);
+ for (const sharedControl of ["Button", "IconButton", "TextField", "ActivityIndicator", "StatusBadge"]) {
+ assert.match(provisioning, new RegExp(`<${sharedControl}\\b`), sharedControl);
+ }
+ assert.doesNotMatch(
+ provisioning,
+ /Подключиться к сохранённому|Исходный K1|Проверить связь с K1|Проверить прежнее подключение/,
+ );
+});
+
+test("K1 click-owned actions are fenced without hidden frontend continuations", () => {
+ const provisioning = readFileSync(
+ join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
+ "utf8",
+ );
+ const runtime = readFileSync(
+ join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
+ "utf8",
+ );
+ const search = provisioning.slice(
+ provisioning.indexOf("const repeatDeviceScan"),
+ provisioning.indexOf("const submitConnect"),
+ );
+ const apply = provisioning.slice(
+ provisioning.indexOf("const submitConnect"),
+ provisioning.indexOf("const verifyAppliedNetwork"),
+ );
+ assert.equal((search.match(/scanWithResult\(/g) ?? []).length, 1);
+ assert.match(search, /durationSeconds:\s*6/);
+ assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
+ assert.doesNotMatch(
+ apply,
+ /scanWithResult\(|verifyConnection\(|candidateRefresh|void submitConnect/,
+ );
+ assert.match(runtime, /class SnapshotRuntimeActionArbiter/);
+ assert.match(runtime, /runtimeActionArbiter\.current\.isCurrent\(actionToken\)/);
+ assert.match(runtime, /runtimeActionArbiter\.current\.settle\(actionToken\)/);
+});
+
+test("connected presentation uses canonical process copy", () => {
+ const connection = readFileSync(
+ join(pluginFrontendRoot, "XgridsK1Connection.tsx"),
+ "utf8",
+ );
+ assert.match(
+ connection,
+ /connectionTopology\?\.status === "active"\s*\? "Подключение установлено"/,
+ );
+ assert.match(connection, /"Готово к новой сессии\."/);
+ assert.match(connection, /Подключение \{model\.displayName\}<\/h2>/);
+ assert.match(
+ connection,
+ /const operationalPanelsVisible = shouldRenderK1OperationalPanels\(state\)/,
+ );
+ assert.match(
+ connection,
+ /operationalPanelsVisible \? : null/,
+ );
+ assert.match(
+ connection,
+ / {
+ const app = readFileSync(join(coreSourceRoot, "App.tsx"), "utf8");
+ const shellPresentation = readFileSync(
+ join(coreSourceRoot, "presentation.ts"),
+ "utf8",
+ );
+ const connection = readFileSync(
+ join(pluginFrontendRoot, "XgridsK1Connection.tsx"),
+ "utf8",
+ );
+ const operatorError = readFileSync(
+ join(pluginFrontendRoot, "components/K1OperatorError.tsx"),
+ "utf8",
+ );
+ const acquisition = readFileSync(
+ join(pluginFrontendRoot, "components/K1AcquisitionPipeline.tsx"),
+ "utf8",
+ );
+ const metrics = readFileSync(
+ join(pluginFrontendRoot, "components/K1Metrics.tsx"),
+ "utf8",
+ );
+
+ assert.match(connection, /Подключение \{model\.displayName\}<\/h2>/);
+ assert.doesNotMatch(connection, /:\s*message\}/);
+ assert.match(
+ operatorError,
+ /Подключение не завершено\. Автоматического повтора не было/,
+ );
+
+ const deviceHeader = app.slice(
+ app.indexOf('activeDefinition.kind === "device" ? ('),
+ app.indexOf('activeDefinition.kind === "spatial"', app.indexOf('activeDefinition.kind === "device" ? (')),
+ );
+ assert.match(deviceHeader, /localConnectionPhaseLabel\(runtime\.state\?\.phase\)/);
+ assert.doesNotMatch(deviceHeader, /phaseLabel\(runtime\.state\?\.phase\)/);
+ assert.match(
+ shellPresentation,
+ /phase === "configuring"\) return "Подключение"/,
+ );
+ assert.match(
+ shellPresentation,
+ /phase === "connected"\) return "Подключение установлено"/,
+ );
+ assert.match(shellPresentation, /configuring: "Настройка устройства"/);
+ assert.match(shellPresentation, /connected: "Устройство подключено"/);
+
+ assert.match(acquisition, /hint="Локальный файл записи"/);
+ assert.match(acquisition, /Состояние сканирования остаётся неизвестным/);
+ assert.doesNotMatch(
+ acquisition,
+ /Локальный файл исходных данных|Физическое состояние сканера/,
+ );
+ assert.match(metrics, /Данные потока при этом сохраняются/);
+ assert.doesNotMatch(metrics, /Исходные данные при этом сохраняются/);
+});
+
+test("K1 START renders an in-button spinner for the complete live orchestration", () => {
+ const acquisition = readFileSync(
+ join(pluginFrontendRoot, "components/K1AcquisitionPipeline.tsx"),
+ "utf8",
+ );
+
+ assert.match(acquisition, /pendingAction === "live"[\s\S]* {
+ const runtime = readFileSync(
+ join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
+ "utf8",
+ );
+ const provisioning = readFileSync(
+ join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
+ "utf8",
+ );
+ const exactReady = runtime.slice(
+ runtime.indexOf("function hasExactConnectionReady"),
+ runtime.indexOf("async function waitForPhysicalReconciliationProof"),
+ );
+ const connectFlow = runtime.slice(
+ runtime.indexOf("const connect = useCallback"),
+ runtime.indexOf("const verifyConnection = useCallback"),
+ );
+ const appliedProof = runtime.slice(
+ runtime.indexOf("export function exactAppliedNetworkIntentCompleted"),
+ runtime.indexOf("function requireExactReadOnlyVerificationOutcome"),
+ );
+
+ assert.match(exactReady, /state\.desired_connection_mode === connectionMode/);
+ assert.match(exactReady, /state\.active_connection_mode === connectionMode/);
+ assert.match(exactReady, /application_control_session\?\.state === "connection-ready"/);
+ assert.match(exactReady, /currentAppliedConnectionTopology\(state, connectionMode\)\?\.status === "active"/);
+ assert.doesNotMatch(connectFlow, /openApplicationControlSession|waitForControlPhase/);
+ assert.doesNotMatch(connectFlow, /verifyConnection\(|scanWithResult\(/);
+ assert.doesNotMatch(connectFlow, /startAcquisition|startPreparedAcquisition|START acquisition/);
+ assert.match(connectFlow, /networkIntentCompleted/);
+ assert.match(appliedProof, /attempt\.phase === "network_applied"/);
+ assert.match(appliedProof, /operation\.status === "succeeded"/);
+ assert.match(appliedProof, /operationPhase === "network_applied"/);
+ assert.match(appliedProof, /ledger\.resolution === "target-observed"/);
+ assert.match(appliedProof, /deviceNetwork\?\.state === "applied"/);
+ assert.doesNotMatch(appliedProof, /control_state/);
+ assert.match(
+ connectFlow,
+ /const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted\([\s\S]*?if \(\s*!exactNetworkIntentCompleted\s*&& !hasExactConnectionReady\([\s\S]*?return requireExactConnectionReady\([\s\S]*?nextState = acceptSuccessfulConnectState\(nextState\)/,
+ );
+ assert.match(provisioning, /Подключение установлено/);
+ assert.doesNotMatch(
+ provisioning,
+ /Подключиться к сохранённому|Проверить связь с K1|Проверить прежнее подключение/,
+ );
+ assert.match(provisioning, /Переподключиться/);
+ assert.match(provisioning, /Подключить новый K1/);
+ assert.match(provisioning, /expected_mode_revision: modeAuthority\.desiredModeRevision/);
+ assert.match(
+ provisioning,
+ /expected_discovery_generation: modeAuthority\.discoveryGeneration/,
+ );
+ assert.doesNotMatch(provisioning, /Настройки сети применены/);
+});
+
+test("K1 mode reset is explicit while Scan and Apply keep exact backend CAS", () => {
+ const api = readFileSync(join(pluginFrontendRoot, "api.ts"), "utf8");
+ const manifest = readFileSync(join(pluginFrontendRoot, "manifest.ts"), "utf8");
+ const connection = readFileSync(join(pluginFrontendRoot, "XgridsK1Connection.tsx"), "utf8");
+ const runtime = readFileSync(join(pluginFrontendRoot, "useXgridsK1Runtime.ts"), "utf8");
+ const acquisition = readFileSync(
+ join(pluginFrontendRoot, "components/K1AcquisitionPipeline.tsx"),
+ "utf8",
+ );
+ const provisioning = readFileSync(
+ join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
+ "utf8",
+ );
+
+ assert.match(manifest, /connectionModeSelect:[\s\S]*"connection\.mode\.select"/);
+ assert.match(api, /interface SelectConnectionModeRequest/);
+ assert.match(api, /expected_revision: number/);
+ assert.match(api, /reset_scenario\?: true/);
+ assert.match(api, /reset_id\?: string/);
+ assert.match(api, /expected_mode_revision: number/);
+ assert.match(api, /expected_discovery_generation: number/);
+ const localModeHandler = connection.slice(
+ connection.indexOf("const updateDesiredConnectionMode"),
+ connection.indexOf("const sourceTone"),
+ );
+ assert.match(localModeHandler, /setDesiredConnectionMode\(mode\)/);
+ assert.doesNotMatch(localModeHandler, /await|selectConnectionMode\(|refresh\(|connect\(/);
+ assert.match(provisioning, /const commitDesiredModeForExplicitAction = useCallback\(async/);
+ assert.match(provisioning, /await selectConnectionMode\(\{/);
+ assert.match(provisioning, /expected_revision: expectedRevision as number/);
+ assert.match(provisioning, /reset_scenario: true/);
+ assert.match(provisioning, /const resetId = newOperationId\(\)/);
+ assert.match(provisioning, /reset_id: resetId/);
+ assert.match(provisioning, /Подключить новый K1/);
+ const configurationAnchor = provisioning.slice(
+ provisioning.indexOf(''),
+ provisioning.indexOf('
'),
+ );
+ assert.doesNotMatch(configurationAnchor, /Подключить новый K1/);
+ assert.match(provisioning, /value=\{connectionMode\}/);
+ assert.doesNotMatch(
+ provisioning,
+ /disabled=\{\s*isBusy\s*\|\|\s*networkRecoveryRequired\s*\|\|\s*physicalRecoveryRequired\s*\|\|\s*connectionRecoveryRequired/,
+ );
+ assert.match(runtime, /catch \(selectionError\)[\s\S]*xgridsK1Api\.getState\(\)/);
+ assert.match(acquisition, /state\?\.active_connection_mode/);
+ assert.match(acquisition, /desiredSelectionCommitted/);
+ assert.match(acquisition, /configuredConnectionMode !== desiredConnectionMode/);
+ assert.match(acquisition, /Выбран другой способ связи/);
+});
+
+test("top-right device utility is an explicit pending-aware K1 scenario reset", async () => {
+ const { deviceRuntimeUtilityAction } = await server.ssrLoadModule(
+ "/src/components/useApplicationPanelActions.ts",
+ );
+ let resetCalls = 0;
+ let refreshCalls = 0;
+ const reset = deviceRuntimeUtilityAction({
+ refreshRuntime: () => {
+ refreshCalls += 1;
+ },
+ resetConnectionScenario: async () => {
+ resetCalls += 1;
+ return true;
+ },
+ connectionScenarioResetting: false,
+ });
+
+ assert.equal(reset.label, "Сбросить подключение");
+ assert.equal(reset.icon, "refresh");
+ assert.equal(reset.disabled, undefined);
+ reset.onClick();
+ await Promise.resolve();
+ assert.equal(resetCalls, 1);
+ assert.equal(refreshCalls, 0);
+
+ const pending = deviceRuntimeUtilityAction({
+ refreshRuntime: () => {
+ refreshCalls += 1;
+ },
+ resetConnectionScenario: async () => true,
+ connectionScenarioResetting: true,
+ });
+ assert.equal(pending.label, "Сбрасываем подключение");
+ assert.equal(pending.icon, "activity");
+ assert.equal(pending.disabled, true);
+ pending.onClick();
+ await Promise.resolve();
+ assert.equal(resetCalls, 1);
+ assert.equal(refreshCalls, 0);
+
+ const app = readFileSync(join(coreSourceRoot, "App.tsx"), "utf8");
+ const contracts = readFileSync(
+ join(coreSourceRoot, "core/runtime/contracts.ts"),
+ "utf8",
+ );
+ const runtimeContext = readFileSync(
+ join(pluginFrontendRoot, "runtimeContext.tsx"),
+ "utf8",
+ );
+ const runtime = readFileSync(
+ join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
+ "utf8",
+ );
+ const provisioning = readFileSync(
+ join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
+ "utf8",
+ );
+ const connection = readFileSync(
+ join(pluginFrontendRoot, "XgridsK1Connection.tsx"),
+ "utf8",
+ );
+
+ assert.match(contracts, /resetConnectionScenario\?: \(\) => Promise
/);
+ assert.match(app, /resetConnectionScenario: runtime\.resetConnectionScenario/);
+ assert.match(app, /connectionScenarioResetting: runtime\.pendingAction === "mode"/);
+ assert.match(
+ runtimeContext,
+ /resetConnectionScenario: controller\.resetConnectionScenario/,
+ );
+
+ const resetStart = runtime.indexOf("const resetConnectionScenario");
+ const resetEnd = runtime.indexOf(
+ "const prepareConnectionReconfigurationWithResult",
+ resetStart,
+ );
+ assert.notEqual(resetStart, -1);
+ assert.notEqual(resetEnd, -1);
+ const resetFlow = runtime.slice(resetStart, resetEnd);
+ assert.equal((resetFlow.match(/selectConnectionMode\(/g) ?? []).length, 1);
+ assert.match(resetFlow, /connection_mode: DEFAULT_CONNECTION_MODE/);
+ assert.match(resetFlow, /expected_revision: expectedRevision as number/);
+ assert.match(resetFlow, /reset_scenario: true/);
+ assert.match(resetFlow, /reset_id: newOperationId\(\)/);
+ assert.doesNotMatch(
+ resetFlow,
+ /refresh\(|getState\(|scan|verify|connect\(|prepare|start|stop|camera/i,
+ );
+
+ const selectStart = runtime.indexOf("const selectConnectionMode");
+ const selectEnd = runtime.indexOf("const resetConnectionScenario", selectStart);
+ const selectFlow = runtime.slice(selectStart, selectEnd);
+ assert.match(selectFlow, /expected_snapshot_runtime_id: expectedSnapshotRuntimeId\(\)/);
+ assert.match(selectFlow, /supersedePending: request\.reset_scenario === true/);
+
+ const runStart = runtime.indexOf("const run = useCallback");
+ const runEnd = runtime.indexOf("const scanWithResult", runStart);
+ const runFlow = runtime.slice(runStart, runEnd);
+ assert.match(runFlow, /setPendingAction\(action\)/);
+ assert.match(runFlow, /setPresentedErrorCorrelation\(null\)/);
+ assert.match(runFlow, /setError\(null\)/);
+ assert.match(runFlow, /setErrorDiagnostic\(null\)/);
+ assert.match(runFlow, /setPendingAction\(null\)/);
+
+ const draftFenceStart = provisioning.indexOf("const nextFence = localProvisioningDraftFenceKey");
+ assert.notEqual(draftFenceStart, -1);
+ const draftFence = provisioning.slice(draftFenceStart, draftFenceStart + 2_700);
+ assert.match(draftFence, /reconfigurationRevision/);
+ assert.match(draftFence, /setSelectedDeviceId\(""\)/);
+ assert.match(draftFence, /setSelectedDeviceSnapshot\(null\)/);
+ assert.match(draftFence, /setExplicitProvisioningDraft\(null\)/);
+ assert.match(draftFence, /setSsid\(""\)/);
+ assert.match(draftFence, /setPassword\(""\)/);
+ assert.match(draftFence, /setCandidateUnavailableMessage\(null\)/);
+ assert.match(draftFence, /resetSearchPresentation\(\)/);
+ assert.match(
+ provisioning,
+ /const hydratedScenarioResetPresentationKey = useRef\(null\)/,
+ );
+ assert.match(
+ provisioning,
+ /hydratedScenarioResetPresentationKey\.current = scenarioResetPresentationKey/,
+ );
+ assert.match(provisioning, /setConnectionAttemptPresentation\(null\)/);
+ assert.match(
+ provisioning,
+ /const modeResetInFlight = pendingAction === "mode" \|\| modeResetPending !== null/,
+ );
+ assert.match(provisioning, /if \(modeResetInFlight\) return/);
+ assert.equal((provisioning.match(/disabled=\{modeResetInFlight\}/g) ?? []).length, 2);
+ assert.match(provisioning, /disabled=\{isBusy \|\| modeResetInFlight\}/);
+
+ assert.match(connection, /const hydratedScenarioResetKey = useRef\(null\)/);
+ assert.match(
+ connection,
+ /scenarioReset\.revision === state\?\.desired_connection_mode_revision/,
+ );
+ assert.match(connection, /scenarioReset\.desired_mode === backendDesiredMode/);
+ assert.match(connection, /desiredModeLocallyDirty\.current = false/);
+ assert.match(connection, /setDesiredConnectionMode\(backendDesiredMode\)/);
+});
+
+test("background polling stays read-only while backend state reconciliation retires terminal control", () => {
+ const runtime = readFileSync(
+ join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
+ "utf8",
+ );
+
+ const refreshFlow = runtime.slice(
+ runtime.indexOf("const refresh = useCallback"),
+ runtime.indexOf("const run = useCallback"),
+ );
+ assert.match(runtime, /refresh\(false\)/);
+ assert.match(runtime, /refresh\(true\)/);
+ assert.doesNotMatch(runtime, /terminalControlCleanupInFlight/);
+ assert.doesNotMatch(
+ refreshFlow,
+ /closeApplicationControlSession|startAcquisition|stopAcquisition|networkProvision/,
+ );
+ assert.match(refreshFlow, /xgridsK1Api\.getState\(\)/);
+});
+
+test("automatic K1 live start opens the selected delivered camera despite an older saved layout", () => {
+ const app = readFileSync(join(coreSourceRoot, "App.tsx"), "utf8");
+ const layout = readFileSync(
+ join(coreSourceRoot, "core/observation/useObservationLayout.ts"),
+ "utf8",
+ );
+
+ assert.match(app, /observationLayout\.activateAutomaticDefaults\(\)/);
+ assert.match(layout, /const activateAutomaticDefaults = useCallback/);
+ assert.match(layout, /restoredLayoutAuthorityRef\.current = false/);
+ assert.match(layout, /sources\.filter\(canOpenByDefault\)/);
+ assert.match(layout, /source\.capabilities\.overlay/);
+});
+
+test("K1 connect errors reset the UI session without exposing reconciliation ceremony", () => {
+ const runtime = readFileSync(
+ join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
+ "utf8",
+ );
+ const connectStart = runtime.indexOf("const connect = useCallback(");
+ const connectEnd = runtime.indexOf("const verifyConnection = useCallback(", connectStart);
+ assert.notEqual(connectStart, -1);
+ assert.notEqual(connectEnd, -1);
+ const connectFlow = runtime.slice(connectStart, connectEnd);
+
+ assert.match(connectFlow, /operationByIdempotencyKey\(/);
+ assert.match(connectFlow, /failedOperation\?\.status === "succeeded"/);
+ assert.match(connectFlow, /resetConnectSessionMessage\(/);
+ assert.doesNotMatch(
+ connectFlow,
+ /требует ручной проверки|измените параметры только после проверки устройства|Сохранён тот же ключ/,
+ );
+ assert.match(runtime, /Сессия подключения в интерфейсе сброшена/);
+ assert.match(runtime, /новый поиск Bluetooth, выберите K1 и запустите подключение ещё раз/);
+ const verifyStart = runtime.indexOf("const verifyConnection = useCallback(");
+ const verifyEnd = runtime.indexOf("const probeConfiguredEndpoint = useCallback(", verifyStart);
+ const verifyFlow = runtime.slice(verifyStart, verifyEnd);
+ assert.match(verifyFlow, /failedOperation\?\.status === "succeeded"/);
+ assert.doesNotMatch(verifyFlow, /xgridsK1Api\.verifyConnection\([^)]*\)[\s\S]*xgridsK1Api\.verifyConnection/);
+ assert.doesNotMatch(runtime, /automatic.?retry\s*:\s*true/);
});
test("K1 provisioning keeps the operator draft separate from the backend lease", () => {
@@ -184,14 +713,432 @@ test("K1 provisioning keeps the operator draft separate from the backend lease",
join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
"utf8",
);
+ const selection = provisioning.slice(
+ provisioning.indexOf("const selectFreshDevice"),
+ provisioning.indexOf("const chooseAnother"),
+ );
+ const apply = provisioning.slice(
+ provisioning.indexOf("const submitConnect"),
+ provisioning.indexOf("const verifyAppliedNetwork"),
+ );
+ assert.match(provisioning, /explicitProvisioningDraftRetained/);
+ assert.match(provisioning, /localProvisioningDraftFenceKey/);
+ assert.match(provisioning, /provisioningIntentKey\(null\)/);
+ assert.match(provisioning, /const \[selectedDeviceSnapshot, setSelectedDeviceSnapshot\]/);
+ assert.doesNotMatch(selection, /await|scanWithResult\(|verifyConnection\(|connect\(/);
+ assert.match(selection, /requestExplicitProvisioning\(device\.device_id/);
+ assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
+ assert.doesNotMatch(apply, /scanWithResult\(|verifyConnection\(|candidateRefresh/);
+ assert.match(provisioning, /scanWithResult\(\{ durationSeconds: 6 \}\)/);
+ assert.match(
+ provisioning,
+ /onChange=\{\(event\) => setPassword\(event\.target\.value\)\}/,
+ );
+});
- assert.doesNotMatch(provisioning, /setSelectedDeviceId\(state\.selected_device_id\)/);
- assert.doesNotMatch(provisioning, /setConnectionMode\(state\.connection_mode\)/);
- assert.match(provisioning, /canSubmitProvisioningMutation\(\{/);
- assert.match(provisioning, /isReachableConnectionLease\(state, connectionMode\)/);
- assert.doesNotMatch(provisioning, /else if \(connectionMode === "quick-connect"\)/);
- assert.match(provisioning, /const succeeded = await verifyConnection\(\{/);
- assert.match(provisioning, /if \(succeeded\) \{\s*provisioningIntentRef\.current = null;/);
+test("K1 plugin layout follows its contribution width and contains long topology text", () => {
+ const styles = readFileSync(join(pluginFrontendRoot, "styles.css"), "utf8");
+ const baseGrid = styles.slice(
+ styles.indexOf(".device-workspace__grid"),
+ styles.indexOf(".device-workspace__side"),
+ );
+ const splitThreshold = styles.match(
+ /@container xgrids-k1 \(min-width:\s*([0-9.]+)rem\)/,
+ );
+ const splitColumns = styles.match(
+ /grid-template-columns:\s*minmax\(([0-9.]+)rem,\s*0\.8fr\)\s*minmax\(([0-9.]+)rem,\s*1\.2fr\)/,
+ );
+
+ assert.match(styles, /container:\s*xgrids-k1\s*\/\s*inline-size/);
+ assert.match(styles, /container:\s*k1-connection-panel\s*\/\s*inline-size/);
+ assert.match(styles, /container:\s*k1-session-panel\s*\/\s*inline-size/);
+ assert.match(styles, /@container xgrids-k1 \(min-width:\s*78rem\)/);
+ assert.match(
+ styles,
+ /grid-template-columns:\s*minmax\(32rem,\s*0\.8fr\)\s*minmax\(38rem,\s*1\.2fr\)/,
+ );
+ assert.match(styles, /@container k1-connection-panel \(max-width:\s*48rem\)/);
+ assert.match(styles, /@container k1-session-panel \(max-width:\s*48rem\)/);
+ assert.match(styles, /@container xgrids-k1 \(max-width:\s*48rem\)/);
+ assert.match(styles, /overflow-wrap:\s*anywhere/);
+ assert.match(baseGrid, /grid-template-columns:\s*minmax\(0,\s*1fr\)/);
+ assert.ok(splitThreshold);
+ assert.ok(splitColumns);
+ const splitThresholdPixels = Number(splitThreshold[1]) * 16;
+ const minimumSplitPixels = (Number(splitColumns[1]) + Number(splitColumns[2]) + 0.85) * 16;
+ assert.equal(splitThresholdPixels, 1248);
+ assert.ok(minimumSplitPixels < splitThresholdPixels);
+ assert.ok(390 < splitThresholdPixels);
+ assert.ok(760 < splitThresholdPixels);
+ assert.ok(1280 > splitThresholdPixels);
+ assert.match(
+ styles,
+ /\.wizard-list,[\s\S]*?\.wizard-step,[\s\S]*?\.session-form,[\s\S]*?\.device-row,[\s\S]*?\.detail-list\s*\{[^}]*min-width:\s*0[^}]*max-width:\s*100%/,
+ );
+ assert.match(
+ styles,
+ /> \*\s*\{[^}]*min-width:\s*0[^}]*max-width:\s*100%/,
+ );
+ assert.match(
+ styles,
+ /\.metrics-grid > \*,[\s\S]*?\.device-workspace__grid > \*,[\s\S]*?\.diagnostics-grid > \*\s*\{[^}]*min-width:\s*0[^}]*max-width:\s*100%/,
+ );
+ assert.match(
+ styles,
+ /\.device-row code,[\s\S]*?\.detail-row code\s*\{[^}]*overflow-wrap:\s*anywhere[^}]*white-space:\s*normal/,
+ );
+ assert.match(
+ styles,
+ /\.detail-row dd\s*\{[^}]*overflow-wrap:\s*anywhere[^}]*text-overflow:\s*clip[^}]*white-space:\s*normal/,
+ );
+ assert.match(
+ styles,
+ /\.error-banner__actions > \.nodedc-button\s*\{[^}]*min-width:\s*0[^}]*max-width:\s*100%[^}]*overflow-wrap:\s*anywhere/,
+ );
+ assert.doesNotMatch(
+ styles,
+ /\.connection-panel,[\s\S]*?\.session-panel\s*\{[^}]*overflow:\s*(?:clip|hidden)/,
+ );
+ assert.match(
+ styles,
+ /@container k1-connection-panel \(max-width:\s*48rem\)[\s\S]*?\.wizard-step__content > header[^}]*flex-wrap:\s*wrap/,
+ );
+ assert.match(
+ styles,
+ /@container k1-connection-panel \(max-width:\s*48rem\)[\s\S]*?\.retained-recovery-target > div[^}]*flex-direction:\s*column/,
+ );
+ assert.match(
+ styles,
+ /@container k1-session-panel \(max-width:\s*48rem\)[\s\S]*?\.panel-heading[^}]*flex-wrap:\s*wrap/,
+ );
+ assert.match(
+ styles,
+ /\.connection-summary__value > \.nodedc-status,[\s\S]*?white-space:\s*normal/,
+ );
+ assert.doesNotMatch(styles, /\.nodedc-checker(?:__copy|__label)?\s*\{/);
+ assert.match(
+ styles,
+ /\.workspace-lead__status > span,[\s\S]*?\.nodedc-field__description,[\s\S]*?\.retained-recovery-target small,[\s\S]*?\.session-footer p\s*\{[^}]*overflow-wrap:\s*anywhere/,
+ );
+ assert.match(
+ styles,
+ /@container xgrids-k1 \(max-width:\s*32rem\)[\s\S]*?\.error-banner__actions\s*\{[^}]*align-items:\s*stretch[^}]*flex-direction:\s*column/,
+ );
+ assert.doesNotMatch(styles, /@media \(max-width:\s*(?:1280|1480)px\)/);
+ assert.doesNotMatch(styles, /device-recovery-choice/);
+});
+
+test("Mission Core shell protects the device workspace before the shared mobile breakpoint", () => {
+ const responsive = readFileSync(join(coreSourceRoot, "styles/responsive.css"), "utf8");
+
+ assert.match(
+ responsive,
+ /@media \(min-width:\s*761px\) and \(max-width:\s*929px\)/,
+ );
+ assert.match(
+ responsive,
+ /\.nodedc-app-shell__navigation,\s*\.nodedc-app-shell__content\s*\{[^}]*left:\s*var\(--nodedc-app-page-pad\)[^}]*width:\s*auto/s,
+ );
+ assert.match(
+ responsive,
+ /\[data-content-open="true"\] \.nodedc-app-shell__navigation\s*\{[^}]*opacity:\s*0[^}]*pointer-events:\s*none/s,
+ );
+ assert.match(
+ responsive,
+ /@media \(max-width:\s*760px\)[\s\S]*?\.nodedc-application-panel__head,\s*\.nodedc-application-panel__body\s*\{[^}]*width:\s*auto[^}]*min-width:\s*0[^}]*max-width:\s*100%/,
+ );
+ assert.match(
+ responsive,
+ /\.nodedc-application-panel__head\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s*auto/,
+ );
+});
+
+test("K1 frontend state models durable mutation and connection supervision facts", () => {
+ const api = readFileSync(join(pluginFrontendRoot, "api.ts"), "utf8");
+ const lifecycle = readFileSync(join(pluginFrontendRoot, "lifecycle.ts"), "utf8");
+
+ assert.match(api, /interface XgridsNetworkMutationLedger/);
+ assert.match(api, /scope:\s*"durable-ledger"/);
+ assert.match(api, /network_mutation_ledger\?: XgridsNetworkMutationLedger \| null/);
+ assert.match(api, /handle_retained\?: boolean/);
+ assert.match(api, /gatt_validated_recently\?: boolean/);
+ assert.match(api, /interface XgridsConnectionSupervisor/);
+ assert.match(api, /interface XgridsConnectionSupervisorDeviceNetwork/);
+ assert.match(api, /device_network: XgridsConnectionSupervisorDeviceNetwork/);
+ assert.match(api, /missioncore\.k1-connection-supervisor\/v1/);
+ assert.match(api, /connection_supervisor\?: XgridsConnectionSupervisor \| null/);
+ assert.match(api, /interface XgridsConnectionPolicy/);
+ assert.match(api, /missioncore\.xgrids-k1-connection-policy\/v1/);
+ assert.match(api, /connection_policy\?: XgridsConnectionPolicy \| null/);
+ assert.match(api, /interface XgridsSemanticTopologyStore/);
+ assert.match(api, /configured_offline_evidence: boolean/);
+ assert.match(api, /live_connection_authority: false/);
+ assert.match(api, /semantic_topology_store\?: XgridsSemanticTopologyStore \| null/);
+
+ const reconciliation = lifecycle.slice(
+ lifecycle.indexOf("export function readOnlyVerificationClearedReconciliation"),
+ lifecycle.indexOf("export function provisioningCandidateById"),
+ );
+ assert.match(reconciliation, /nextState\?\.network_mutation_ledger/);
+ assert.match(reconciliation, /nextLedger\.status === "resolved"/);
+ assert.match(reconciliation, /nextLedger\.operation_id === previousOperationId/);
+ assert.doesNotMatch(reconciliation, /snapshot_runtime_id/);
+});
+
+test("Bridge device and network reconfiguration stays backend-owned and CAS-fenced", () => {
+ const api = readFileSync(join(pluginFrontendRoot, "api.ts"), "utf8");
+ const manifest = readFileSync(join(pluginFrontendRoot, "manifest.ts"), "utf8");
+ const lifecycle = readFileSync(join(pluginFrontendRoot, "lifecycle.ts"), "utf8");
+ const runtime = readFileSync(
+ join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
+ "utf8",
+ );
+ const provisioning = readFileSync(
+ join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
+ "utf8",
+ );
+ const prepare = provisioning.slice(
+ provisioning.indexOf("const prepareReconfiguration"),
+ provisioning.indexOf("const changeDesiredConnectionMode"),
+ );
+ const selection = provisioning.slice(
+ provisioning.indexOf("const selectFreshDevice"),
+ provisioning.indexOf("const chooseAnother"),
+ );
+ assert.match(manifest, /connectionReconfigurePrepare:[\s\S]*"connection\.reconfigure\.prepare"/);
+ assert.match(api, /expected_reconfiguration_revision: number/);
+ assert.match(api, /expected_reconfiguration_intent_id/);
+ assert.match(prepare, /prepareConnectionReconfigurationWithResult\(\{/);
+ assert.doesNotMatch(prepare, /scanWithResult\(|verifyConnection\(|connect\(/);
+ assert.doesNotMatch(selection, /await|scanWithResult\(|verifyConnection\(|connect\(/);
+ assert.match(provisioning, /showNetworkStep = Boolean\([\s\S]*changeNetworkDialogue/);
+ assert.match(provisioning, /localProvisioningDraftFenceKey/);
+ assert.match(lifecycle, /reconfigurationAllowsFreshDevice/);
+ assert.match(runtime, /prepareConnectionReconfiguration/);
+});
+
+test("shared runtime exposes only a reachable K1 endpoint as active", () => {
+ const runtimeContext = readFileSync(
+ join(pluginFrontendRoot, "runtimeContext.tsx"),
+ "utf8",
+ );
+
+ assert.match(
+ runtimeContext,
+ /endpointLabel: activeConnectionEndpointLabel\(state\)/,
+ );
+ assert.doesNotMatch(runtimeContext, /endpointLabel: state\.k1_ip/);
+});
+
+test("every K1 connection and acquisition action is fenced to the accepted backend runtime", () => {
+ const runtime = readFileSync(
+ join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
+ "utf8",
+ );
+
+ assert.match(runtime, /latestState\.current\?\.snapshot_runtime_id/);
+ assert.equal(
+ [...runtime.matchAll(
+ /expected_snapshot_runtime_id: expectedSnapshotRuntimeId\(\)/g,
+ )].length,
+ 14,
+ );
+ assert.match(
+ runtime,
+ /mode:\s*"graceful",[\s\S]*?expected_snapshot_runtime_id: checkpoint\.snapshotRuntimeId/,
+ );
+ assert.match(
+ runtime,
+ /xgridsK1Api\.scanBle\([\s\S]*?expected_snapshot_runtime_id/,
+ );
+ assert.match(
+ runtime,
+ /xgridsK1Api\.verifyConnection\([\s\S]*?expected_snapshot_runtime_id/,
+ );
+ assert.match(
+ runtime,
+ /xgridsK1Api\.connect\([\s\S]*?expected_snapshot_runtime_id/,
+ );
+ assert.match(
+ runtime,
+ /xgridsK1Api\.retireUnavailablePhysicalCommand\([\s\S]*?expected_snapshot_runtime_id:\s*exactSnapshotRuntimeId/,
+ );
+ assert.match(
+ runtime,
+ /run\("retire",[\s\S]*?surfaceErrors: false[\s\S]*?await refresh\(false\)/,
+ );
+ assert.match(
+ runtime,
+ /const exactSnapshotRuntimeId = actionSnapshotRuntimeId\.trim\(\)[\s\S]*?isSnapshotRuntimeCurrent\(exactSnapshotRuntimeId\)[\s\S]*?xgridsK1Api\.reopenRetiredPhysicalReconciliation\([\s\S]*?expected_snapshot_runtime_id: exactSnapshotRuntimeId/,
+ );
+ for (const action of [
+ "openApplicationControlSession",
+ "enterApplicationWorkspace",
+ "closeApplicationControlSession",
+ "prepareAcquisition",
+ "startAcquisition",
+ "abortAcquisition",
+ "reconcilePhysicalCommand",
+ ]) {
+ assert.match(
+ runtime,
+ new RegExp(`xgridsK1Api\\.${action}\\([\\s\\S]*?expected_snapshot_runtime_id`),
+ action,
+ );
+ }
+ assert.equal(
+ [...runtime.matchAll(
+ /expected_snapshot_runtime_id: actionSnapshotRuntimeId/g,
+ )].length,
+ 3,
+ );
+ assert.match(
+ runtime,
+ /stopSessionCompatibility\(\{[\s\S]*?expected_snapshot_runtime_id/,
+ );
+ assert.match(
+ runtime,
+ /run\([\s\S]*?"reopen"[\s\S]*?surfaceErrors: false, supersedePending: true[\s\S]*?await refresh\(false\)/,
+ );
+ assert.match(
+ runtime,
+ /const actionSnapshotRuntimeId =\s*options\.expectedSnapshotRuntimeId\?\.trim\(\) \|\| null[\s\S]*?expected_snapshot_runtime_id:\s*actionSnapshotRuntimeId \?\? expectedSnapshotRuntimeId\(\)/,
+ );
+});
+
+test("fresh Scan treats an exact prior K1 as one local Select action", () => {
+ const provisioning = readFileSync(
+ join(pluginFrontendRoot, "components/K1ProvisioningPipeline.tsx"),
+ "utf8",
+ );
+ const lifecycle = readFileSync(join(pluginFrontendRoot, "lifecycle.ts"), "utf8");
+ const runtime = readFileSync(
+ join(pluginFrontendRoot, "useXgridsK1Runtime.ts"),
+ "utf8",
+ );
+ const selection = provisioning.slice(
+ provisioning.indexOf("const selectFreshDevice"),
+ provisioning.indexOf("const chooseAnother"),
+ );
+ const apply = provisioning.slice(
+ provisioning.indexOf("const submitConnect"),
+ provisioning.indexOf("const verifyAppliedNetwork"),
+ );
+ assert.match(selection, /requestExplicitProvisioning\(device\.device_id/);
+ assert.match(selection, /const actionableDevices = devices\.filter\(candidateSelectionAllowed\)/);
+ assert.doesNotMatch(
+ selection,
+ /physicallyRetired|retiredPhysical|await|verifyConnection\(|retireUnavailable|reopenRetired/,
+ );
+ const resultRows = provisioning.slice(
+ provisioning.indexOf(''),
+ provisioning.indexOf('
'),
+ );
+ assert.match(resultRows, /actionLabel="Выбрать"/);
+ assert.match(resultRows, /onSelect=\{\(\) => selectCandidate\(device\)\}/);
+ assert.doesNotMatch(resultRows, /Переподключиться|reopen|verifyConnection/);
+ assert.doesNotMatch(provisioning, /recoverRetiredPhysicalCandidate/);
+ assert.doesNotMatch(apply, /verifyConnection\(|retireUnavailable|reopenRetired/);
+ assert.match(provisioning, /Переподключиться/);
+ assert.match(provisioning, /Подключить новый K1/);
+ assert.doesNotMatch(provisioning, /Вернуть прежний K1 и проверить/);
+ assert.match(runtime, /reopenRetiredPhysicalReconciliation/);
+ assert.match(runtime, /retireUnavailablePhysicalCommand/);
+ assert.match(lifecycle, /retiredPhysicalReopenAuthority/);
+});
+
+test("K1 orchestration accepts backend recovery but still requires exact topology before physical START", () => {
+ const acquisition = readFileSync(
+ join(pluginFrontendRoot, "components/K1AcquisitionPipeline.tsx"),
+ "utf8",
+ );
+ const diagnostics = readFileSync(
+ join(pluginFrontendRoot, "components/K1Diagnostics.tsx"),
+ "utf8",
+ );
+
+ assert.match(acquisition, /currentAppliedConnectionTopology\(state\)/);
+ assert.ok(
+ [...acquisition.matchAll(/!connectionConfigured/g)].length >= 2,
+ "handler and button must both reject absent or configured-offline topology",
+ );
+ assert.match(acquisition, /desiredModeMatchesActive/);
+ assert.match(acquisition, /modeSwitchRequired/);
+ assert.match(acquisition, /Выбран другой способ связи/);
+ assert.match(acquisition, /prepareCanonicalAcquisition/);
+ assert.match(acquisition, /startPreparedAcquisition/);
+ assert.match(acquisition, /operatorActionPhysicalAcceptance\(\)/);
+ assert.doesNotMatch(acquisition, /K1PhysicalCommandConfirmation/);
+ assert.match(acquisition, /connectionPolicyAllows\(state, "start-acquisition"\)/);
+ assert.match(acquisition, /canIssueCanonicalStop\(state, physicalStopIntentSpent\)/);
+ assert.match(acquisition, /physicalStopInFlight \|\| physicalStopExecutable/);
+ assert.match(acquisition, /connectionPolicyAllows\(state, "stop-local-receiver"\)/);
+ assert.match(acquisition, /if \(finalStartTarget\)/);
+ assert.match(acquisition, /if \(physicalStopExecutable\)/);
+ assert.match(acquisition, /if \(!physicalStartAllowed\) return;/);
+ assert.doesNotMatch(acquisition, /physicalStopAllowed/);
+ const replaySubmit = acquisition.slice(
+ acquisition.indexOf("const submitReplay ="),
+ acquisition.indexOf("return (", acquisition.indexOf("const submitReplay =")),
+ );
+ assert.doesNotMatch(
+ replaySubmit,
+ /connectionPolicyAllows|physicalStartAllowed|physicalStopExecutable/,
+ );
+ assert.match(acquisition, /void stopLocalReceiver\(\);/);
+ assert.match(acquisition, /onClick=\{\(\) => void abort\(\)\}/);
+ assert.doesNotMatch(acquisition, /acknowledge-data-loss/);
+ assert.doesNotMatch(acquisition, /PHYSICAL_ACCEPTANCE/);
+ assert.doesNotMatch(acquisition, /!state\?\.k1_ip/);
+ assert.match(diagnostics, /activeConnectionEndpointLabel\(state\)/);
+ assert.match(diagnostics, /Адрес конфигурации/);
+ assert.match(diagnostics, /связь не подтверждена/);
+ assert.doesNotMatch(diagnostics, /state\?\.k1_ip/);
+});
+
+test("one explicit K1 action performs START or STOP without a redundant checklist modal", () => {
+ const acquisition = readFileSync(
+ join(pluginFrontendRoot, "components/K1AcquisitionPipeline.tsx"),
+ "utf8",
+ );
+ const spatial = readFileSync(
+ join(pluginFrontendRoot, "components/K1SpatialControls.tsx"),
+ "utf8",
+ );
+ const confirmation = readFileSync(
+ join(pluginFrontendRoot, "physicalCommandConfirmation.ts"),
+ "utf8",
+ );
+ const styles = readFileSync(join(pluginFrontendRoot, "styles.css"), "utf8");
+
+ assert.equal(
+ existsSync(join(pluginFrontendRoot, "components/K1PhysicalCommandConfirmation.tsx")),
+ false,
+ );
+ assert.doesNotMatch(acquisition, /K1PhysicalCommandConfirmation|ConfirmationModal/);
+ assert.doesNotMatch(spatial, /K1PhysicalCommandConfirmation|ConfirmationModal/);
+ assert.match(acquisition, /operatorActionPhysicalAcceptance\(\)/);
+ assert.match(acquisition, /await submitFinalStart\(\)/);
+ assert.match(spatial, /stop\(operatorActionPhysicalAcceptance\(\)\)/);
+ assert.match(spatial, /physicalStopExecutable/);
+ assert.match(spatial, /canIssueCanonicalStop\(state, physicalStopIntentSpent\)/);
+ assert.match(spatial, /physicalStopInFlight \|\| physicalStopExecutable/);
+ assert.match(spatial, /connectionPolicyAllows\(state, "stop-local-receiver"\)/);
+ assert.doesNotMatch(spatial, /Физическая остановка K1 недоступна/);
+ assert.match(spatial, /onClick=\{\(\) => void stopLocalReceiver\(\)\}/);
+ assert.match(spatial, /Завершить локальный приём/);
+ assert.doesNotMatch(spatial, /Повторить остановку/);
+ assert.match(acquisition, /canIssueCanonicalStop\(state, physicalStopIntentSpent\)/);
+ assert.match(acquisition, /stopLocalReceiver/);
+ assert.match(acquisition, /Повторная команда устройству не отправляется/);
+ assert.doesNotMatch(spatial, /acknowledge-data-loss/);
+ assert.match(confirmation, /operatorActionPhysicalAcceptance/);
+ assert.match(confirmation, /operator_present:\s*true/);
+ assert.match(confirmation, /acquisition\.state !== "prepared"/);
+ assert.match(confirmation, /control\.state !== "project-ready"/);
+ assert.match(confirmation, /latest_device_session_state/);
+ assert.match(confirmation, /acquisition_start_allowed !== true/);
+ assert.doesNotMatch(styles, /xgrids-k1-physical-confirmation/);
});
test("generic Control Station has one composition import and no K1 implementation knowledge", () => {
diff --git a/apps/control-station/test/devicePluginHostPersistence.test.mjs b/apps/control-station/test/devicePluginHostPersistence.test.mjs
new file mode 100644
index 0000000..7b7b54d
--- /dev/null
+++ b/apps/control-station/test/devicePluginHostPersistence.test.mjs
@@ -0,0 +1,219 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { after, before, test } from "node:test";
+
+import React, { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { createServer } from "vite";
+
+let server;
+let DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY;
+let DevicePluginHostProvider;
+let commitPersistedDeviceModelId;
+let restorePersistedDeviceModelId;
+
+const hostSourceUrl = new URL(
+ "../src/core/device-plugins/DevicePluginHost.tsx",
+ import.meta.url,
+);
+
+before(async () => {
+ server = await createServer({
+ appType: "custom",
+ logLevel: "silent",
+ server: { middlewareMode: true },
+ });
+ ({
+ DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY,
+ DevicePluginHostProvider,
+ commitPersistedDeviceModelId,
+ restorePersistedDeviceModelId,
+ } = await server.ssrLoadModule("/src/core/device-plugins/DevicePluginHost.tsx"));
+});
+
+after(async () => {
+ await server?.close();
+});
+
+function memoryStorage(seed = {}) {
+ const values = new Map(Object.entries(seed));
+ const calls = [];
+ return {
+ calls,
+ getItem(key) {
+ calls.push(["get", key]);
+ return values.get(key) ?? null;
+ },
+ setItem(key, value) {
+ calls.push(["set", key, value]);
+ values.set(key, value);
+ },
+ removeItem(key) {
+ calls.push(["remove", key]);
+ values.delete(key);
+ },
+ value(key) {
+ return values.get(key) ?? null;
+ },
+ };
+}
+
+function registryWith(...modelIds) {
+ const registered = new Set(modelIds);
+ return {
+ resolveModel(modelId) {
+ return registered.has(modelId) ? { model: { id: modelId } } : null;
+ },
+ };
+}
+
+function fakePlugin(modelId) {
+ function RuntimeProvider({ activeModel, children }) {
+ return createElement(
+ "div",
+ { "data-active-model": activeModel?.id ?? "" },
+ children,
+ );
+ }
+ function ConnectionView() {
+ return null;
+ }
+ return {
+ manifest: {
+ apiVersion: "missioncore.nodedc/v1alpha1",
+ kind: "DevicePlugin",
+ metadata: {
+ id: "test.device.plugin",
+ version: "1.0.0",
+ displayName: "Test device",
+ },
+ spec: {
+ hostApiRange: "v1alpha1",
+ runtime: {
+ backendEntrypoint: "test.device:plugin",
+ isolation: "transitional-in-process",
+ },
+ permissions: [],
+ actions: [{ id: "state.read", mutating: false, secretFields: [] }],
+ models: [{
+ id: modelId,
+ vendor: "Test",
+ displayName: "Test model",
+ category: "test",
+ description: "test",
+ verified: true,
+ capabilities: [],
+ ui: {
+ slot: "device.connection",
+ componentKey: "test.connection",
+ },
+ }],
+ },
+ },
+ RuntimeProvider,
+ connectionViews: { "test.connection": ConnectionView },
+ };
+}
+
+test("persisted model restore admits only an id in the current plugin registry", () => {
+ const key = DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY;
+ const registry = registryWith("xgrids.lixelkity-k1");
+ const valid = memoryStorage({ [key]: " xgrids.lixelkity-k1 " });
+
+ assert.equal(
+ restorePersistedDeviceModelId(registry, valid),
+ "xgrids.lixelkity-k1",
+ );
+ assert.equal(valid.calls.some(([operation]) => operation === "remove"), false);
+
+ for (const staleValue of ["removed.model", " "]) {
+ const stale = memoryStorage({ [key]: staleValue });
+ assert.equal(restorePersistedDeviceModelId(registry, stale), null);
+ assert.equal(stale.value(key), null, "a stale model id must be cleared");
+ }
+ assert.equal(restorePersistedDeviceModelId(registry, null), null);
+});
+
+test("fresh provider mount immediately activates the registry-validated persisted model", () => {
+ const modelId = "test.model.one";
+ const storage = memoryStorage({
+ [DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY]: modelId,
+ });
+ const previousWindow = globalThis.window;
+ globalThis.window = { localStorage: storage };
+ try {
+ const markup = renderToStaticMarkup(createElement(
+ DevicePluginHostProvider,
+ { plugins: [fakePlugin(modelId)] },
+ createElement("span", null, "runtime child"),
+ ));
+ assert.match(markup, /data-active-model="test\.model\.one"/);
+ } finally {
+ if (previousWindow === undefined) delete globalThis.window;
+ else globalThis.window = previousWindow;
+ }
+});
+
+test("successful selection commits and explicit clear removes the same durable key", () => {
+ const storage = memoryStorage();
+ commitPersistedDeviceModelId("xgrids.lixelkity-k1", storage);
+ assert.equal(
+ storage.value(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY),
+ "xgrids.lixelkity-k1",
+ );
+
+ commitPersistedDeviceModelId(null, storage);
+ assert.equal(storage.value(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY), null);
+ assert.deepEqual(
+ storage.calls.slice(-1)[0],
+ ["remove", DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY],
+ );
+});
+
+test("unavailable browser storage fails closed without blocking host state", () => {
+ const denied = {
+ getItem() {
+ throw new Error("storage denied");
+ },
+ setItem() {
+ throw new Error("storage denied");
+ },
+ removeItem() {
+ throw new Error("storage denied");
+ },
+ };
+ assert.equal(
+ restorePersistedDeviceModelId(registryWith("xgrids.lixelkity-k1"), denied),
+ null,
+ );
+ assert.doesNotThrow(() =>
+ commitPersistedDeviceModelId("xgrids.lixelkity-k1", denied)
+ );
+ assert.doesNotThrow(() => commitPersistedDeviceModelId(null, denied));
+});
+
+test("host writes persistence only after plugin deactivation succeeds", () => {
+ const source = readFileSync(hostSourceUrl, "utf8");
+ const failedDeactivation = source.indexOf("if (!(await deactivate()))");
+ const admittedState = source.indexOf("setSelectedModelId(nextModelId);");
+ const durableCommit = source.indexOf(
+ "commitPersistedDeviceModelId(nextModelId, selectionStorage);",
+ admittedState,
+ );
+
+ assert.ok(failedDeactivation >= 0);
+ assert.ok(
+ failedDeactivation < admittedState && admittedState < durableCommit,
+ "failed deactivation branches must return before in-memory and durable commit",
+ );
+ assert.match(
+ source,
+ /if \(nextModelId === selectedModelId\) \{[\s\S]*?commitPersistedDeviceModelId\(nextModelId, selectionStorage\);[\s\S]*?return true;/,
+ "explicit clear must remove stale persistence even from an already-empty host",
+ );
+ assert.match(
+ source,
+ /useState
\(\(\) =>\s*restorePersistedDeviceModelId\(registry, selectionStorage\)/,
+ "a fresh provider mount must restore before runtime providers receive activeModel",
+ );
+});
diff --git a/apps/control-station/test/k1ActiveStreamRecovery.test.mjs b/apps/control-station/test/k1ActiveStreamRecovery.test.mjs
new file mode 100644
index 0000000..2a08c54
--- /dev/null
+++ b/apps/control-station/test/k1ActiveStreamRecovery.test.mjs
@@ -0,0 +1,977 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { after, before, test } from "node:test";
+
+import React, { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { createServer } from "vite";
+
+let server;
+let activeStreamForceFinishAuthority;
+let activeStreamForceFinishAuthorityMatches;
+let activeStreamRecoveredBrowserAuthority;
+let activeStreamRecoveryPresentation;
+let activeStreamRecoveryPresentationAuthority;
+let activeStreamRecoveryOwnsPresentationDecision;
+let exactActiveStreamRecoveryLineage;
+let formatActiveStreamRecoveryElapsed;
+let suppressGenericErrorDuringActiveStreamRecovery;
+let isXgridsActiveStreamRecovery;
+let K1AcquisitionPipeline;
+let K1SpatialControlsView;
+let runSpatialActiveStreamForceFinish;
+let shouldRenderK1GenericRuntimeError;
+let shouldRenderK1OperationalPanels;
+let xgridsK1Actions;
+let xgridsK1Api;
+
+const hookSourceUrl = new URL(
+ "../../../plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts",
+ import.meta.url,
+);
+const acquisitionSourceUrl = new URL(
+ "../../../plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx",
+ import.meta.url,
+);
+const recoverySurfaceSourceUrl = new URL(
+ "../../../plugins/xgrids-k1/frontend/src/components/ActiveStreamRecoverySurface.tsx",
+ import.meta.url,
+);
+const spatialControlsSourceUrl = new URL(
+ "../../../plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx",
+ import.meta.url,
+);
+const connectionSourceUrl = new URL(
+ "../../../plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx",
+ import.meta.url,
+);
+
+before(async () => {
+ server = await createServer({
+ appType: "custom",
+ logLevel: "silent",
+ server: { middlewareMode: true },
+ });
+ ({
+ activeStreamForceFinishAuthority,
+ activeStreamForceFinishAuthorityMatches,
+ activeStreamRecoveredBrowserAuthority,
+ activeStreamRecoveryPresentation,
+ activeStreamRecoveryPresentationAuthority,
+ activeStreamRecoveryOwnsPresentationDecision,
+ exactActiveStreamRecoveryLineage,
+ formatActiveStreamRecoveryElapsed,
+ suppressGenericErrorDuringActiveStreamRecovery,
+ } = await server.ssrLoadModule("@xgrids-k1/frontend/activeStreamRecovery.ts"));
+ ({ isXgridsActiveStreamRecovery, xgridsK1Api } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/api.ts",
+ ));
+ ({ xgridsK1Actions } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/manifest.ts",
+ ));
+ ({ K1AcquisitionPipeline } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/components/K1AcquisitionPipeline.tsx",
+ ));
+ ({
+ K1SpatialControlsView,
+ runSpatialActiveStreamForceFinish,
+ } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/components/K1SpatialControls.tsx",
+ ));
+ ({
+ shouldRenderK1GenericRuntimeError,
+ shouldRenderK1OperationalPanels,
+ } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/XgridsK1Connection.tsx",
+ ));
+});
+
+after(async () => {
+ await server?.close();
+});
+
+function recoveryContract(overrides = {}) {
+ return {
+ schema_version: "missioncore.xgrids-k1-active-stream-recovery/v1",
+ state: "reconnecting",
+ generation: 7,
+ acquisition_id: "acquisition-recovery-001",
+ attempt: 3,
+ started_at_utc: "2026-08-11T19:31:00Z",
+ elapsed_ms: 12_400,
+ reason_code: "read-only-rebind-in-progress",
+ force_finish_allowed: true,
+ automatic_read_only_rebind: true,
+ automatic_command_retry: false,
+ start_performed: false,
+ stop_performed: false,
+ ble_operation_performed: false,
+ network_mutation_performed: false,
+ runtime_producer_generation: 11,
+ camera_recovery: "owned",
+ camera_media_state: "pending-first-media",
+ camera_media_ready: false,
+ camera_epoch: {
+ generation: 7,
+ init_committed: true,
+ init_committed_age_ms: 250,
+ first_media_committed: false,
+ first_media_committed_age_ms: null,
+ committed_media_segment_count: 0,
+ last_media_segment_age_ms: null,
+ },
+ ...overrides,
+ };
+}
+
+function recoveryState(recoveryOverrides = {}, stateOverrides = {}) {
+ return {
+ snapshot_runtime_id: "snapshot-runtime-recovery-001",
+ snapshot_revision: 43,
+ producer_generation: 11,
+ phase: "reconnecting",
+ source_mode: "live",
+ acquisition: {
+ acquisition_id: "acquisition-recovery-001",
+ device_id: "device-k1-001",
+ device_session_id: "device-session-001",
+ compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
+ control_mode: "plugin-commanded",
+ requested_streams: ["spatial.point-cloud.live"],
+ target_host: "127.0.0.1",
+ duration_seconds: 0,
+ evidence_policy: "required",
+ state: "acquiring",
+ state_revision: 9,
+ cleanup_pending: false,
+ },
+ connection_recovery: recoveryContract(recoveryOverrides),
+ ...stateOverrides,
+ };
+}
+
+function coldRestartPrePclState() {
+ const state = recoveryState({
+ generation: 1,
+ acquisition_id: "acquisition-before-backend-restart-001",
+ attempt: 0,
+ started_at_utc: "2026-08-14T00:31:00Z",
+ elapsed_ms: 450,
+ reason_code: "restart-receiver-awaiting-first-pcl",
+ runtime_producer_generation: 1,
+ camera_recovery: "inactive",
+ camera_media_state: "inactive",
+ camera_media_ready: false,
+ camera_epoch: null,
+ }, {
+ snapshot_runtime_id: "snapshot-runtime-after-cold-restart-001",
+ snapshot_revision: 2,
+ producer_generation: 1,
+ acquisition: {
+ ...recoveryState().acquisition,
+ acquisition_id: "acquisition-before-backend-restart-001",
+ state: "awaiting_external_start",
+ state_revision: 4,
+ },
+ camera_preview: {
+ activation_admission: {
+ state: "waiting-for-first-authoritative-pcl",
+ basis: "post-rerun-publish-pcl-frame",
+ runtime_producer_generation: 1,
+ device_command_sent: false,
+ },
+ },
+ });
+ return state;
+}
+
+function coldRestartRecoveredState() {
+ const state = coldRestartPrePclState();
+ return {
+ ...state,
+ snapshot_revision: state.snapshot_revision + 1,
+ phase: "live",
+ acquisition: {
+ ...state.acquisition,
+ state: "acquiring",
+ state_revision: state.acquisition.state_revision + 1,
+ },
+ connection_recovery: {
+ ...state.connection_recovery,
+ state: "recovered",
+ elapsed_ms: null,
+ reason_code: null,
+ force_finish_allowed: false,
+ camera_recovery: "owned",
+ camera_media_state: "pending-epoch",
+ camera_media_ready: false,
+ camera_epoch: null,
+ },
+ camera_preview: {
+ activation_admission: {
+ state: "activating",
+ basis: "post-rerun-publish-pcl-frame",
+ runtime_producer_generation: 1,
+ device_command_sent: false,
+ },
+ },
+ };
+}
+
+function controller(state, overrides = {}) {
+ return {
+ state,
+ pendingAction: null,
+ error: null,
+ physicalStopIntentSpent: false,
+ physicalStopInFlight: false,
+ closeApplicationControlSession: async () => false,
+ prepareCanonicalAcquisition: async () => false,
+ startPreparedAcquisition: async () => false,
+ startReplay: async () => false,
+ stop: async () => false,
+ stopLocalReceiver: async () => false,
+ forceFinishActiveStreamLocally: async () => false,
+ abort: async () => false,
+ ...overrides,
+ };
+}
+
+function renderPipeline(state, overrides = {}) {
+ return renderToStaticMarkup(createElement(K1AcquisitionPipeline, {
+ controller: controller(state, overrides),
+ desiredConnectionMode: "bridge",
+ openSpatialScene() {},
+ activateAutomaticSpatialSource() {},
+ }));
+}
+
+function renderSpatialControls(state, overrides = {}) {
+ return renderToStaticMarkup(createElement(K1SpatialControlsView, {
+ controller: controller(state, overrides),
+ }));
+}
+
+function buttonsWithText(markup, text) {
+ return (markup.match(//g) ?? [])
+ .filter((button) => button.includes(text));
+}
+
+function sourceSlice(source, startMarker, endMarker) {
+ const start = source.indexOf(startMarker);
+ const end = source.indexOf(endMarker, start + startMarker.length);
+ assert.notEqual(start, -1, `missing source marker: ${startMarker}`);
+ assert.notEqual(end, -1, `missing source marker: ${endMarker}`);
+ return source.slice(start, end);
+}
+
+test("active recovery contract is strict about all no-write invariants", () => {
+ assert.equal(isXgridsActiveStreamRecovery(recoveryContract()), true);
+ for (const field of [
+ "automatic_command_retry",
+ "start_performed",
+ "stop_performed",
+ "ble_operation_performed",
+ "network_mutation_performed",
+ ]) {
+ assert.equal(
+ isXgridsActiveStreamRecovery(recoveryContract({ [field]: true })),
+ false,
+ field,
+ );
+ }
+ assert.equal(
+ isXgridsActiveStreamRecovery(recoveryContract({ state: "retrying-command" })),
+ false,
+ );
+ assert.equal(
+ isXgridsActiveStreamRecovery(recoveryContract({ elapsed_ms: -1 })),
+ false,
+ );
+ const missingMediaState = recoveryContract();
+ delete missingMediaState.camera_media_state;
+ assert.equal(isXgridsActiveStreamRecovery(missingMediaState), false);
+ assert.equal(
+ isXgridsActiveStreamRecovery(recoveryContract({ camera_media_ready: true })),
+ false,
+ );
+ assert.equal(
+ isXgridsActiveStreamRecovery(recoveryContract({
+ camera_media_state: "ready",
+ camera_media_ready: true,
+ camera_epoch: {
+ ...recoveryContract().camera_epoch,
+ generation: 0,
+ first_media_committed: true,
+ first_media_committed_age_ms: 1,
+ committed_media_segment_count: 1,
+ last_media_segment_age_ms: 1,
+ },
+ })),
+ false,
+ );
+});
+
+test("cold restart before first PCL owns exact reconnect UI without camera or physical actions", () => {
+ const state = coldRestartPrePclState();
+ assert.equal(isXgridsActiveStreamRecovery(state.connection_recovery), true);
+
+ const lineage = exactActiveStreamRecoveryLineage(state);
+ assert.deepEqual(lineage && {
+ runtime: lineage.snapshotRuntimeId,
+ acquisition: lineage.acquisitionId,
+ revision: lineage.acquisitionStateRevision,
+ recovery: lineage.recoveryGeneration,
+ producer: lineage.runtimeProducerGeneration,
+ }, {
+ runtime: "snapshot-runtime-after-cold-restart-001",
+ acquisition: "acquisition-before-backend-restart-001",
+ revision: 4,
+ recovery: 1,
+ producer: 1,
+ });
+ assert.notEqual(activeStreamRecoveryPresentationAuthority(state), null);
+ assert.notEqual(activeStreamForceFinishAuthority(state), null);
+ assert.equal(activeStreamRecoveredBrowserAuthority(state), null);
+ assert.equal(state.connection_recovery.camera_recovery, "inactive");
+ assert.equal(state.connection_recovery.camera_media_state, "inactive");
+ assert.equal(state.connection_recovery.camera_media_ready, false);
+ assert.equal(state.connection_recovery.camera_epoch, null);
+ assert.deepEqual(state.camera_preview.activation_admission, {
+ state: "waiting-for-first-authoritative-pcl",
+ basis: "post-rerun-publish-pcl-frame",
+ runtime_producer_generation: 1,
+ device_command_sent: false,
+ });
+
+ const pipeline = renderPipeline(state);
+ assert.match(pipeline, /Восстанавливаем соединение/);
+ assert.match(pipeline, /START, STOP, Bluetooth и настройки устройства не отправляются/);
+ assert.equal(buttonsWithText(pipeline, "Прервать соединение").length, 1);
+ assert.equal(buttonsWithText(pipeline, "Запустить приём").length, 0);
+ assert.equal(buttonsWithText(pipeline, "Остановить устройство и запись").length, 0);
+ assert.equal(buttonsWithText(pipeline, "Остановить сканирование").length, 0);
+ assert.doesNotMatch(
+ pipeline,
+ /СВЯЗЬ ВОССТАНОВЛЕНА|Связь восстановлена · приём продолжается|Продолжаем тот же приём/,
+ );
+
+ const spatial = renderSpatialControls(state);
+ assert.match(spatial, /Восстанавливаем соединение/);
+ assert.equal(buttonsWithText(spatial, "Прервать соединение").length, 1);
+ assert.doesNotMatch(
+ spatial,
+ /Остановить устройство|Остановить K1|Завершить локальный приём/,
+ );
+});
+
+test("cold restart first PCL preserves lineage and renders recovered continuation copy", () => {
+ const beforePcl = coldRestartPrePclState();
+ const state = coldRestartRecoveredState();
+ assert.equal(isXgridsActiveStreamRecovery(state.connection_recovery), true);
+ assert.equal(
+ state.connection_recovery.acquisition_id,
+ beforePcl.connection_recovery.acquisition_id,
+ );
+ assert.equal(
+ state.connection_recovery.generation,
+ beforePcl.connection_recovery.generation,
+ );
+ assert.equal(
+ state.connection_recovery.runtime_producer_generation,
+ beforePcl.connection_recovery.runtime_producer_generation,
+ );
+
+ const lineage = exactActiveStreamRecoveryLineage(state);
+ const browserAuthority = activeStreamRecoveredBrowserAuthority(state);
+ assert.deepEqual(lineage && {
+ runtime: lineage.snapshotRuntimeId,
+ acquisition: lineage.acquisitionId,
+ revision: lineage.acquisitionStateRevision,
+ recovery: lineage.recoveryGeneration,
+ producer: lineage.runtimeProducerGeneration,
+ }, {
+ runtime: "snapshot-runtime-after-cold-restart-001",
+ acquisition: "acquisition-before-backend-restart-001",
+ revision: 5,
+ recovery: 1,
+ producer: 1,
+ });
+ assert.deepEqual(browserAuthority, lineage);
+ assert.equal(activeStreamRecoveryPresentationAuthority(state), null);
+ assert.equal(activeStreamForceFinishAuthority(state), null);
+ assert.equal(state.connection_recovery.camera_recovery, "owned");
+ assert.equal(state.camera_preview.activation_admission.device_command_sent, false);
+
+ const pipeline = renderPipeline(state);
+ assert.doesNotMatch(pipeline, /Восстанавливаем соединение|Прервать соединение/);
+ assert.match(pipeline, /СВЯЗЬ ВОССТАНОВЛЕНА · АКТИВНЫЙ ПРИЁМ/);
+ assert.match(pipeline, /Связь восстановлена · приём продолжается/);
+ assert.match(pipeline, /Продолжаем тот же приём без нового START/);
+ assert.doesNotMatch(pipeline, /Назовите проект и запустите приём|Запустить приём/);
+});
+
+test("force-finish authority requires exact runtime, acquisition, revision and generations", () => {
+ const state = recoveryState();
+ const authority = activeStreamForceFinishAuthority(state);
+ assert.deepEqual(authority && {
+ snapshotRuntimeId: authority.snapshotRuntimeId,
+ acquisitionId: authority.acquisitionId,
+ acquisitionStateRevision: authority.acquisitionStateRevision,
+ recoveryGeneration: authority.recoveryGeneration,
+ runtimeProducerGeneration: authority.runtimeProducerGeneration,
+ }, {
+ snapshotRuntimeId: "snapshot-runtime-recovery-001",
+ acquisitionId: "acquisition-recovery-001",
+ acquisitionStateRevision: 9,
+ recoveryGeneration: 7,
+ runtimeProducerGeneration: 11,
+ });
+ assert.equal(activeStreamForceFinishAuthorityMatches(authority, state), true);
+
+ const staleCases = [
+ (() => {
+ const value = structuredClone(state);
+ value.snapshot_runtime_id = "snapshot-runtime-recovery-002";
+ return value;
+ })(),
+ (() => {
+ const value = structuredClone(state);
+ value.acquisition.acquisition_id = "acquisition-recovery-002";
+ return value;
+ })(),
+ (() => {
+ const value = structuredClone(state);
+ value.acquisition.state_revision += 1;
+ return value;
+ })(),
+ (() => {
+ const value = structuredClone(state);
+ value.connection_recovery.generation += 1;
+ return value;
+ })(),
+ (() => {
+ const value = structuredClone(state);
+ value.producer_generation += 1;
+ return value;
+ })(),
+ ];
+ for (const stale of staleCases) {
+ assert.equal(activeStreamForceFinishAuthorityMatches(authority, stale), false);
+ }
+
+ assert.equal(
+ activeStreamForceFinishAuthority(recoveryState({ state: "recovered", force_finish_allowed: false })),
+ null,
+ );
+ assert.equal(
+ activeStreamForceFinishAuthority(recoveryState({ force_finish_allowed: false })),
+ null,
+ );
+});
+
+test("reconnecting presentation is exact and owns stale supervisor projection", () => {
+ const state = recoveryState({ force_finish_allowed: false });
+ const authority = activeStreamRecoveryPresentationAuthority(state);
+ assert.deepEqual(authority && {
+ runtime: authority.snapshotRuntimeId,
+ acquisition: authority.acquisitionId,
+ producer: authority.runtimeProducerGeneration,
+ recovery: authority.recoveryGeneration,
+ }, {
+ runtime: "snapshot-runtime-recovery-001",
+ acquisition: "acquisition-recovery-001",
+ producer: 11,
+ recovery: 7,
+ });
+ assert.equal(activeStreamRecoveryOwnsPresentationDecision(state), true);
+
+ const wrongProducer = structuredClone(state);
+ wrongProducer.producer_generation += 1;
+ assert.equal(activeStreamRecoveryPresentationAuthority(wrongProducer), null);
+ assert.equal(
+ activeStreamRecoveryOwnsPresentationDecision(wrongProducer),
+ true,
+ "a valid reconnect contract must block stale ordinary data projection",
+ );
+
+ for (const recoveryStateName of [
+ "blocked",
+ "standby",
+ "fault",
+ "force-finishing",
+ "force-finished",
+ ]) {
+ const terminal = recoveryState({ state: recoveryStateName });
+ assert.equal(activeStreamRecoveryPresentationAuthority(terminal), null);
+ assert.equal(activeStreamRecoveryOwnsPresentationDecision(terminal), true);
+ }
+ assert.equal(
+ activeStreamRecoveryOwnsPresentationDecision(recoveryState({ state: "recovered" })),
+ false,
+ );
+});
+
+test("recovered browser carryover keeps exact lineage without restoring recovery controls", () => {
+ const state = recoveryState({
+ state: "recovered",
+ force_finish_allowed: false,
+ elapsed_ms: null,
+ reason_code: null,
+ }, {
+ phase: "live",
+ });
+ const authority = activeStreamRecoveredBrowserAuthority(state);
+ assert.deepEqual(authority && {
+ runtime: authority.snapshotRuntimeId,
+ acquisition: authority.acquisitionId,
+ revision: authority.acquisitionStateRevision,
+ producer: authority.runtimeProducerGeneration,
+ recovery: authority.recoveryGeneration,
+ }, {
+ runtime: "snapshot-runtime-recovery-001",
+ acquisition: "acquisition-recovery-001",
+ revision: 9,
+ producer: 11,
+ recovery: 7,
+ });
+ assert.equal(activeStreamRecoveryPresentation(state), null);
+ assert.equal(activeStreamForceFinishAuthority(state), null);
+
+ const staleProducer = structuredClone(state);
+ staleProducer.producer_generation += 1;
+ assert.equal(activeStreamRecoveredBrowserAuthority(staleProducer), null);
+ assert.equal(
+ activeStreamRecoveredBrowserAuthority(recoveryState({
+ state: "recovered",
+ camera_recovery: "blocked",
+ force_finish_allowed: false,
+ }, { phase: "live" })),
+ null,
+ );
+});
+
+test("only an exact reconnecting lineage suppresses the generic red error", () => {
+ const state = recoveryState();
+ assert.equal(suppressGenericErrorDuringActiveStreamRecovery(state, null), true);
+ assert.equal(
+ shouldRenderK1GenericRuntimeError("Ошибка локального приёмника", false, state, null),
+ false,
+ );
+ assert.equal(
+ suppressGenericErrorDuringActiveStreamRecovery(state, "force-finish"),
+ false,
+ );
+ assert.equal(
+ shouldRenderK1GenericRuntimeError(
+ "Локальное завершение не выполнено",
+ false,
+ state,
+ "force-finish",
+ ),
+ true,
+ "a failed explicit local finish must keep the generic error banner visible",
+ );
+
+ const wrongProducer = structuredClone(state);
+ wrongProducer.producer_generation += 1;
+ assert.equal(suppressGenericErrorDuringActiveStreamRecovery(wrongProducer), false);
+
+ const wrongAcquisition = structuredClone(state);
+ wrongAcquisition.connection_recovery.acquisition_id = "acquisition-stale";
+ assert.equal(suppressGenericErrorDuringActiveStreamRecovery(wrongAcquisition), false);
+
+ const missingRuntime = structuredClone(state);
+ delete missingRuntime.snapshot_runtime_id;
+ assert.equal(suppressGenericErrorDuringActiveStreamRecovery(missingRuntime), false);
+
+ const blocked = recoveryState({ state: "blocked" });
+ assert.equal(suppressGenericErrorDuringActiveStreamRecovery(blocked), false);
+
+ const nonOwned = recoveryState({ automatic_read_only_rebind: false });
+ assert.equal(suppressGenericErrorDuringActiveStreamRecovery(nonOwned), false);
+
+ const inactiveCases = [
+ recoveryState({}, { phase: "error" }),
+ recoveryState({}, { source_mode: "idle" }),
+ recoveryState({}, {
+ acquisition: {
+ ...state.acquisition,
+ state: "failed",
+ },
+ }),
+ ];
+ for (const inactive of inactiveCases) {
+ assert.equal(
+ suppressGenericErrorDuringActiveStreamRecovery(inactive, null),
+ false,
+ "stale recovery projection must fail open to the error banner",
+ );
+ }
+});
+
+test("reconnecting presentation is neutral, timed and exposes explicit local finish", () => {
+ const state = recoveryState();
+ const presentation = activeStreamRecoveryPresentation(state);
+ assert.equal(presentation?.state, "reconnecting");
+ assert.equal(presentation?.tone, "neutral");
+ assert.equal(presentation?.progressLabel, "Попытка 3 · 12 с");
+ assert.equal(presentation?.forceFinishAvailable, true);
+
+ const markup = renderPipeline(state);
+ assert.match(markup, /Восстанавливаем соединение/);
+ assert.match(markup, /Попытка 3 · 12 с/);
+ assert.match(markup, /class="nodedc-activity-indicator/);
+ assert.equal(buttonsWithText(markup, "Прервать соединение").length, 1);
+ assert.match(markup, /START, STOP, Bluetooth и настройки устройства не отправляются/);
+ assert.doesNotMatch(markup, /Ошибка локального приёмника/);
+ assert.equal(shouldRenderK1OperationalPanels(state), true);
+});
+
+test("spatial scene owns the same recovery spinner and explicit local finish", () => {
+ const markup = renderSpatialControls(recoveryState());
+ assert.match(markup, /Восстанавливаем соединение/);
+ assert.match(markup, /Попытка 3 · 12 с/);
+ assert.match(markup, /class="nodedc-activity-indicator/);
+ assert.match(markup, /data-recovery-state="reconnecting"/);
+ assert.equal(buttonsWithText(markup, "Прервать соединение").length, 1);
+ assert.match(markup, /локальный front\/back-приём/);
+ assert.doesNotMatch(
+ markup,
+ /Остановить устройство|Остановить K1|Завершить локальный приём/,
+ "recovery must not expose canonical STOP or the generic receiver stop",
+ );
+});
+
+test("spatial blocked and fault recovery copy is terminal and truthful", () => {
+ const blocked = renderSpatialControls(recoveryState({
+ state: "blocked",
+ reason_code: "exact-binding-changed",
+ }));
+ assert.match(blocked, /Связь не восстановлена/);
+ assert.match(blocked, /Восстановление остановлено/);
+ assert.doesNotMatch(blocked, /class="nodedc-activity-indicator/);
+ assert.equal(buttonsWithText(blocked, "Прервать соединение").length, 1);
+
+ const fault = renderSpatialControls(recoveryState({
+ state: "fault",
+ force_finish_allowed: false,
+ reason_code: "active-stream-recovery-system-error",
+ }));
+ assert.match(fault, /K1 сообщил об ошибке/);
+ assert.match(fault, /Автоматических команд и повторов нет/);
+ assert.equal(buttonsWithText(fault, "Прервать соединение").length, 0);
+});
+
+test("spatial recovery interaction routes only to exact local force-finish", async () => {
+ let forceFinishCalls = 0;
+ const current = recoveryState();
+ const invoked = await runSpatialActiveStreamForceFinish({
+ state: current,
+ forceFinishActiveStreamLocally: async () => {
+ forceFinishCalls += 1;
+ return true;
+ },
+ });
+ assert.equal(invoked, true);
+ assert.equal(forceFinishCalls, 1);
+
+ const stale = structuredClone(current);
+ stale.connection_recovery.runtime_producer_generation += 1;
+ const rejected = await runSpatialActiveStreamForceFinish({
+ state: stale,
+ forceFinishActiveStreamLocally: async () => {
+ forceFinishCalls += 1;
+ return true;
+ },
+ });
+ assert.equal(rejected, false);
+ assert.equal(forceFinishCalls, 1, "stale lineage must not dispatch any action");
+});
+
+test("spatial force-finish pending owns the surface without a second action", () => {
+ const markup = renderSpatialControls(
+ recoveryState({
+ state: "force-finishing",
+ acquisition_id: null,
+ force_finish_allowed: false,
+ automatic_read_only_rebind: false,
+ camera_recovery: "inactive",
+ }),
+ { pendingAction: "force-finish" },
+ );
+ assert.match(markup, /Завершаем локальный приём/);
+ assert.match(markup, /data-recovery-state="force-finishing"/);
+ assert.match(markup, /Команда STOP устройству не отправляется/);
+ assert.equal(buttonsWithText(markup, "Прервать соединение").length, 0);
+});
+
+test("blocked, camera-blocked, standby and fault copy stay truthful", () => {
+ const blocked = recoveryState({ state: "blocked", reason_code: "exact-binding-changed" });
+ const blockedMarkup = renderPipeline(blocked);
+ assert.match(blockedMarkup, /Связь не восстановлена/);
+ assert.match(blockedMarkup, /Восстановление остановлено/);
+ assert.doesNotMatch(blockedMarkup, /class="nodedc-activity-indicator/);
+ assert.equal(buttonsWithText(blockedMarkup, "Прервать соединение").length, 1);
+
+ const cameraBlocked = recoveryState({
+ state: "blocked",
+ camera_recovery: "blocked",
+ reason_code: "camera-recovery-failed",
+ });
+ assert.match(renderPipeline(cameraBlocked), /Видеопоток не восстановлен/);
+
+ const standby = recoveryState({
+ state: "standby",
+ force_finish_allowed: false,
+ reason_code: "device-reported-standby",
+ });
+ const standbyMarkup = renderPipeline(standby);
+ assert.match(standbyMarkup, /Устройство перешло в ожидание/);
+ assert.match(standbyMarkup, /без команды STOP/);
+ assert.equal(buttonsWithText(standbyMarkup, "Прервать соединение").length, 0);
+ assert.equal(shouldRenderK1OperationalPanels(standby), true);
+
+ const fault = recoveryState({
+ state: "fault",
+ force_finish_allowed: false,
+ reason_code: "active-stream-recovery-system-error",
+ });
+ const faultMarkup = renderPipeline(fault);
+ assert.match(faultMarkup, /K1 сообщил об ошибке/);
+ assert.match(faultMarkup, /Автоматических команд и повторов нет/);
+ assert.equal(buttonsWithText(faultMarkup, "Прервать соединение").length, 0);
+ assert.equal(shouldRenderK1OperationalPanels(fault), true);
+});
+
+test("recovered active lineage renders the continued session and one exact STOP", () => {
+ const state = recoveryState({
+ state: "recovered",
+ force_finish_allowed: false,
+ elapsed_ms: null,
+ reason_code: null,
+ }, {
+ phase: "live",
+ compatibility: {
+ vendor_writes_enabled: true,
+ permitted_mode: "active-control",
+ },
+ application_control_session: {
+ session_generation: 5,
+ state_revision: 8,
+ state: "scanning",
+ can_stop: true,
+ control_socket_open: true,
+ },
+ connection_policy: {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: ["stop-acquisition"],
+ actions: {
+ "stop-acquisition": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "connection-supervisor",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ },
+ },
+ });
+ assert.equal(activeStreamRecoveryPresentation(state), null);
+ const markup = renderPipeline(state);
+ assert.doesNotMatch(markup, /Восстанавливаем соединение|Прервать соединение/);
+ assert.match(markup, /СВЯЗЬ ВОССТАНОВЛЕНА · АКТИВНЫЙ ПРИЁМ/);
+ assert.match(markup, /Связь восстановлена · приём продолжается/);
+ assert.match(markup, /Продолжаем тот же приём без нового START/);
+ assert.doesNotMatch(markup, /Назовите проект и запустите приём|Запустить приём/);
+
+ const stopButtons = buttonsWithText(markup, "Остановить устройство и запись");
+ assert.equal(stopButtons.length, 1);
+ assert.doesNotMatch(stopButtons[0], /\bdisabled(?:=|\s|>)/);
+});
+
+test("stale recovered marker on an idle released runtime fails closed to idle UI", () => {
+ const state = recoveryState({
+ state: "recovered",
+ force_finish_allowed: false,
+ elapsed_ms: null,
+ reason_code: null,
+ }, {
+ phase: "idle",
+ source_mode: "idle",
+ acquisition: {
+ ...recoveryState().acquisition,
+ state: "completed",
+ cleanup_pending: false,
+ },
+ application_control_session: {
+ session_generation: 5,
+ state_revision: 9,
+ state: "completed",
+ can_stop: false,
+ control_socket_open: false,
+ },
+ });
+ assert.equal(activeStreamRecoveryPresentation(state), null);
+ const markup = renderPipeline(state);
+ assert.doesNotMatch(
+ markup,
+ /СВЯЗЬ ВОССТАНОВЛЕНА|Связь восстановлена · приём продолжается|Продолжаем тот же приём/,
+ );
+ assert.match(markup, /Назовите проект и запустите приём/);
+ assert.equal(buttonsWithText(markup, "Остановить устройство и запись").length, 0);
+ assert.equal(buttonsWithText(markup, "Остановить сканирование").length, 0);
+});
+
+test("force-finishing shows one local-only pending owner and no second action", () => {
+ const state = recoveryState({
+ state: "force-finishing",
+ acquisition_id: null,
+ force_finish_allowed: false,
+ automatic_read_only_rebind: false,
+ runtime_producer_generation: 11,
+ camera_recovery: "inactive",
+ });
+ const markup = renderPipeline(state, { pendingAction: "force-finish" });
+ assert.match(markup, /Завершаем локальный приём/);
+ assert.match(markup, /Команда STOP устройству не отправляется/);
+ assert.match(markup, /class="nodedc-activity-indicator/);
+ assert.equal(buttonsWithText(markup, "Прервать соединение").length, 0);
+});
+
+test("elapsed presentation is deterministic", () => {
+ assert.equal(formatActiveStreamRecoveryElapsed(null), null);
+ assert.equal(formatActiveStreamRecoveryElapsed(-1), null);
+ assert.equal(formatActiveStreamRecoveryElapsed(999), "0 с");
+ assert.equal(formatActiveStreamRecoveryElapsed(59_999), "59 с");
+ assert.equal(formatActiveStreamRecoveryElapsed(60_000), "1 мин");
+ assert.equal(formatActiveStreamRecoveryElapsed(125_900), "2 мин 5 с");
+});
+
+test("force-finish manifest/API sends the exact fenced local-only request", async () => {
+ assert.equal(
+ xgridsK1Actions.acquisitionForceFinishLocal,
+ "acquisition.force-finish-local",
+ );
+ const request = {
+ expected_snapshot_runtime_id: "snapshot-runtime-recovery-001",
+ acquisition_id: "acquisition-recovery-001",
+ expected_state_revision: 9,
+ expected_recovery_generation: 7,
+ operator_confirmed: true,
+ operation_id: "op-00000000-0000-4000-8000-000000000321",
+ idempotency_key:
+ "acquisition.force-finish-local:op-00000000-0000-4000-8000-000000000321",
+ deadline_seconds: 30,
+ };
+ let capturedUrl = null;
+ let capturedInit = null;
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async (input, init) => {
+ capturedUrl = String(input);
+ capturedInit = init;
+ return new Response(JSON.stringify({ state: recoveryState() }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ };
+ try {
+ await xgridsK1Api.forceFinishAcquisitionLocally(request);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ assert.match(
+ capturedUrl,
+ /\/actions\/acquisition\.force-finish-local$/,
+ );
+ assert.equal(capturedInit.method, "POST");
+ assert.deepEqual(JSON.parse(capturedInit.body), { input: request });
+});
+
+test("state API rejects a drifted active recovery contract", async () => {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = async () => new Response(JSON.stringify({
+ state: recoveryState({ stop_performed: true }),
+ }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ try {
+ await assert.rejects(
+ () => xgridsK1Api.getState(),
+ /некорректное состояние/,
+ );
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+});
+
+test("frontend boundary keeps recovery automatic work read-only and local finish explicit", () => {
+ const hookSource = readFileSync(hookSourceUrl, "utf8");
+ const forceFinish = sourceSlice(
+ hookSource,
+ "const forceFinishActiveStreamLocally",
+ "const abort",
+ );
+ assert.match(forceFinish, /run\("force-finish"/);
+ assert.match(forceFinish, /activeStreamForceFinishAuthority\(latestState\.current\)/);
+ assert.match(forceFinish, /expected_snapshot_runtime_id:\s*authority\.snapshotRuntimeId/);
+ assert.match(forceFinish, /acquisition_id:\s*authority\.acquisitionId/);
+ assert.match(forceFinish, /expected_state_revision:\s*authority\.acquisitionStateRevision/);
+ assert.match(forceFinish, /expected_recovery_generation:\s*authority\.recoveryGeneration/);
+ assert.match(forceFinish, /operator_confirmed:\s*true/);
+ assert.match(
+ forceFinish,
+ /newMutationContext\("acquisition\.force-finish-local"\)/,
+ );
+ assert.equal(
+ (forceFinish.match(/forceFinishAcquisitionLocally\(/g) ?? []).length,
+ 1,
+ );
+ assert.doesNotMatch(
+ forceFinish,
+ /startAcquisition|stopAcquisition|scanBle|selectCameraPreview|connect\(/,
+ );
+
+ const acquisitionSource = readFileSync(acquisitionSourceUrl, "utf8");
+ assert.equal(
+ (acquisitionSource.match(/forceFinishActiveStreamLocally\(\)/g) ?? []).length,
+ 1,
+ "the explicit recovery button is the only frontend caller",
+ );
+ const recoverySurface = readFileSync(recoverySurfaceSourceUrl, "utf8");
+ assert.match(recoverySurface, / {
+ server = await createServer({
+ appType: "custom",
+ logLevel: "silent",
+ server: { middlewareMode: true },
+ });
+ ({ normalizeXgridsK1MissionState } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/runtimeContext.tsx",
+ ));
+ ({
+ SnapshotRuntimeActionArbiter,
+ connectionActionAuthoritySnapshot,
+ exactAppliedNetworkIntentCompleted,
+ } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/useXgridsK1Runtime.ts",
+ ));
+ ({ selectMonotonicXgridsState } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/stateOrdering.ts",
+ ));
+ ({ xgridsK1Manifest } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/manifest.ts",
+ ));
+ ({ workspaces } = await server.ssrLoadModule("/src/productModel.ts"));
+ ({ connectionModeOptions } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/configuration.ts",
+ ));
+ ({ K1Metrics } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/components/K1Metrics.tsx",
+ ));
+ ({
+ K1ProvisioningPipeline,
+ RuntimeActionFenceTestContext,
+ emptySearchPresentation,
+ emptyProvisioningAttemptPresentation,
+ provisioningAttemptViewState,
+ unavailablePhysicalRetirementAuthority,
+ connectionAttemptOwnsAppliedNetworkRecovery,
+ connectionRecoveryObservationTargetMatches,
+ connectionRecoveryEscapeKey,
+ connectionRecoveryEscapeAfterScan,
+ connectionRecoveryIsRequired,
+ clearConnectionFailureAfterSuccessfulRefresh,
+ dispatchConnectionRecoveryObservationForCurrentRuntime,
+ dispatchUnavailablePhysicalRetirementForCurrentRuntime,
+ dispatchRetiredPhysicalReconciliationForCurrentRuntime,
+ retiredPhysicalReopenVerificationContext,
+ admitPhysicalReopenPresentation,
+ physicalReopenClickAuthority,
+ physicalReopenPresentationIsCurrent,
+ physicalReopenSettlementIsCurrent,
+ scenarioResetPresentationBoundary,
+ localScenarioActionEpochIsCurrent,
+ explicitProvisioningDraftMatches,
+ localProvisioningDraftFenceKey,
+ exactChangeNetworkCandidate,
+ connectionActionAuthorityMatches,
+ reconfigurationContinuationAuthority,
+ observedConnectionAuthorityAllowsTarget,
+ connectContinuationAuthority,
+ runtimeActionFenceMatches,
+ currentRuntimeActionRequest,
+ connectedReconfigurationActionApplicable,
+ } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/components/K1ProvisioningPipeline.tsx",
+ ));
+ ({ K1AcquisitionPipeline } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/components/K1AcquisitionPipeline.tsx",
+ ));
+ ({ K1Diagnostics } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/components/K1Diagnostics.tsx",
+ ));
+ ({
+ K1SpatialControlsView,
+ k1SpatialAuthorityState,
+ k1SpatialPhasePresentation,
+ } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/components/K1SpatialControls.tsx",
+ ));
+ ({
+ connectionModeSelectionGuidance,
+ connectionPolicyOperatorGuidance,
+ physicalRetirementGuidance,
+ physicalReopenGuidance,
+ } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/presentation.ts",
+ ));
+ ({
+ reconfigurationAllowsFreshDevice,
+ readOnlyObservationShowsNetworkUnavailable,
+ readOnlyFailureShowsNetworkUnavailable,
+ provisioningFailureRequiresFreshCandidate,
+ trustedConnectionBinding,
+ transportRefEquivalenceKey,
+ shouldRevealProvisioningNetworkStep,
+ retiredPhysicalReopenAuthority,
+ canIssueCanonicalStop,
+ physicalStopIntentCheckpoint,
+ authoritativeStateSupersedesPhysicalStopIntent,
+ connectionAttemptForRuntimeError,
+ recommendedConnectionRecoveryObservationTarget,
+ readOnlyConnectionObservationTarget,
+ } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/lifecycle.ts",
+ ));
+ ({
+ K1ConnectionPipelines,
+ physicalRecoveryConnectionDetail,
+ shouldRenderK1OperationalPanels,
+ } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/XgridsK1Connection.tsx",
+ ));
+ ({
+ K1OperatorError,
+ attemptNetworkPhaseLabel,
+ } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/components/K1OperatorError.tsx",
+ ));
+ ({ hostFailureDiagnosticPresentation } = await server.ssrLoadModule(
+ "@xgrids-k1/frontend/hostDiagnosticPresentation.ts",
+ ));
+ shellPresentation = await server.ssrLoadModule("/src/presentation.ts");
+ ({ contourRuntimeAuthorityPresentation } = await server.ssrLoadModule(
+ "/src/workspaces/ContourHealthWorkspace.tsx",
+ ));
+});
+
+after(async () => {
+ await server?.close();
+});
+
+function sourceSlice(source, startMarker, endMarker) {
+ const start = source.indexOf(startMarker);
+ const end = source.indexOf(endMarker, start + startMarker.length);
+ assert.notEqual(start, -1, `missing source marker: ${startMarker}`);
+ assert.notEqual(end, -1, `missing source marker: ${endMarker}`);
+ return source.slice(start, end);
+}
+
+test("K1 one-intent source contract makes mode reset explicit and keeps device I/O separate", () => {
+ const source = readFileSync(provisioningSourceUrl, "utf8");
+ const search = sourceSlice(source, "const repeatDeviceScan", "const submitConnect");
+ const apply = sourceSlice(source, "const submitConnect", "const verifyAppliedNetwork");
+ const mode = sourceSlice(source, "const changeDesiredConnectionMode", "const selectFreshDevice");
+ const selection = sourceSlice(
+ source,
+ "const selectFreshDevice",
+ "const chooseAnother",
+ );
+
+ assert.match(search, /scanWithResult\(\{[^}]*durationSeconds:\s*6/);
+ assert.equal((search.match(/scanWithResult\(/g) ?? []).length, 1);
+ assert.doesNotMatch(search, /\b(?:connect|verifyConnection|submitConnect)\s*\(/);
+
+ assert.match(mode, /await selectConnectionMode\(\{/);
+ assert.match(mode, /reset_scenario: true/);
+ assert.match(mode, /reset_id: resetId/);
+ assert.doesNotMatch(
+ mode,
+ /scanWithResult\(|verifyConnection\(|connect\(|prepareConnection/,
+ );
+ assert.doesNotMatch(
+ selection,
+ /await|scanWithResult\(|verifyConnection\(|connect\(|retireUnavailablePhysicalCommand|reopenRetiredPhysicalReconciliation/,
+ );
+ assert.match(selection, /requestExplicitProvisioning\(device\.device_id/);
+ assert.match(selection, /const actionableDevices = devices\.filter\(candidateSelectionAllowed\)/);
+ assert.doesNotMatch(selection, /physicallyRetired|reopenAuthority/);
+ const resultRows = sourceSlice(
+ source,
+ '',
+ '
',
+ );
+ assert.match(resultRows, /actionLabel="Выбрать"/);
+ assert.match(resultRows, /onSelect=\{\(\) => selectCandidate\(device\)\}/);
+ assert.doesNotMatch(resultRows, /Переподключиться|reopen|verifyConnection/);
+ assert.match(source, /onChange=\{\(event\) => setSsid\(event\.target\.value\)\}/);
+ assert.match(source, /onChange=\{\(event\) => setPassword\(event\.target\.value\)\}/);
+
+ assert.equal((apply.match(/await connect\(/g) ?? []).length, 1);
+ assert.doesNotMatch(
+ apply,
+ /scanWithResult\(|verifyConnection\(|retireUnavailablePhysicalCommand|reopenRetiredPhysicalReconciliation|candidateRefreshRequest|void submitConnect/,
+ );
+ assert.doesNotMatch(source, /allow_host_wifi_switch/);
+ assert.doesNotMatch(source, /<(?:button|input|select|textarea)\b/);
+ assert.doesNotMatch(
+ source,
+ /(?:color|background(?:-color)?):\s*(?:#[0-9a-f]{3,8}|rgba?\()/i,
+ );
+ for (const sharedControl of ["Button", "IconButton", "TextField", "ActivityIndicator", "StatusBadge"]) {
+ assert.match(source, new RegExp(`<${sharedControl}\\b`), sharedControl);
+ }
+ assert.equal((source.match(/buttonLabel:\s*"Применить"/g) ?? []).length, 3);
+ assert.match(source, /устарел|stale/i);
+ assert.match(source, /неизвест|outcome-unknown|safe_to_retry/i);
+});
+
+test("exact network-applied REST proof completes frontend Apply before control is ready", () => {
+ const request = {
+ device_id: "BLE-K1-ONE",
+ connection_mode: "bridge",
+ idempotency_key: "apply-intent-one",
+ };
+ const operation = {
+ operation_id: "network-operation-one",
+ action: "network.provision",
+ status: "succeeded",
+ idempotency_key: request.idempotency_key,
+ context: { connection_mode: request.connection_mode },
+ result: { phase: "network_applied" },
+ };
+ const exactState = (controlState) => ({
+ connection_attempt: {
+ schema_version: "missioncore.xgrids-k1-connection-attempt/v1",
+ attempt_id: operation.operation_id,
+ phase: "network_applied",
+ connection_mode: request.connection_mode,
+ control_state: controlState,
+ },
+ network_mutation_ledger: {
+ status: "resolved",
+ mutation_allowed: true,
+ operation_id: operation.operation_id,
+ intended_mode: request.connection_mode,
+ resolution: "target-observed",
+ transport_ref: request.device_id,
+ },
+ connection_supervisor: {
+ observed: {
+ device_network: {
+ state: "applied",
+ connection_mode: request.connection_mode,
+ transport_ref: request.device_id.toLowerCase(),
+ },
+ },
+ },
+ });
+
+ assert.equal(
+ exactAppliedNetworkIntentCompleted(
+ exactState("control_not_ready"),
+ request,
+ operation,
+ ),
+ true,
+ );
+ assert.equal(
+ exactAppliedNetworkIntentCompleted(exactState("unknown"), request, operation),
+ true,
+ );
+ assert.equal(
+ exactAppliedNetworkIntentCompleted({
+ ...exactState("control_not_ready"),
+ connection_attempt: {
+ ...exactState("control_not_ready").connection_attempt,
+ attempt_id: "another-operation",
+ },
+ }, request, operation),
+ false,
+ );
+
+ const runtime = readFileSync(new URL(
+ "../../../plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts",
+ import.meta.url,
+ ), "utf8");
+ const connectFlow = sourceSlice(
+ runtime,
+ "const connect = useCallback",
+ "const verifyConnection = useCallback",
+ );
+ assert.match(
+ connectFlow,
+ /const exactNetworkIntentCompleted = exactAppliedNetworkIntentCompleted\([\s\S]*?if \(\s*!exactNetworkIntentCompleted\s*&& !hasExactConnectionReady\([\s\S]*?return requireExactConnectionReady\([\s\S]*?nextState = acceptSuccessfulConnectState\(nextState\)/,
+ );
+ assert.match(
+ connectFlow,
+ /failedOperation\?\.status === "succeeded"[\s\S]*?!exactAppliedNetworkIntentCompleted\([\s\S]*?&& !hasExactConnectionReady/,
+ );
+ assert.doesNotMatch(
+ connectFlow,
+ /openApplicationControlSession|waitForControlPhase|verifyConnection\(|scanWithResult\(/,
+ );
+});
+
+function model() {
+ const activeModel = xgridsK1Manifest.spec.models[0];
+ assert.ok(activeModel);
+ return activeModel;
+}
+
+function supervisor({
+ control = true,
+ data = false,
+ dataPlaneState = "idle",
+} = {}) {
+ const target = { ipv4: "192.168.68.52", port: 1883 };
+ return {
+ schema_version: "missioncore.k1-connection-supervisor/v1",
+ revision: 9,
+ closed: false,
+ intent: {
+ intent_id: "intent-001",
+ requested_mode: "bridge",
+ expected_device_id: "device-k1-001",
+ requested_at: OBSERVED_AT,
+ },
+ observed: {
+ device_network: {
+ state: "applied",
+ intent_id: "intent-001",
+ transport_ref: "ble-k1-001",
+ connection_mode: "bridge",
+ target,
+ source: "ble-read-only-status",
+ observed_at: OBSERVED_AT,
+ },
+ host_path: {
+ epoch: 5,
+ available: true,
+ fingerprint: "en0:192.168.68.10",
+ interface: "en0",
+ source_ipv4: "192.168.68.10",
+ route_class: "direct",
+ reason_code: null,
+ observed_at: OBSERVED_AT,
+ },
+ endpoint: {
+ target,
+ tcp_state: "reachable",
+ intent_id: "intent-001",
+ host_path_epoch: 5,
+ reason_code: null,
+ observed_at: OBSERVED_AT,
+ },
+ device_identity: {
+ state: control ? "verified" : "stale",
+ intent_id: "intent-001",
+ logical_device_id: "device-k1-001",
+ compatibility_profile_id: PROFILE_ID,
+ connection_mode: "bridge",
+ source: "mqtt-device-info",
+ host_path_epoch: 5,
+ observed_at: OBSERVED_AT,
+ },
+ control_plane: {
+ state: control ? "healthy" : "lost",
+ session_id: control ? "control-session-001" : null,
+ host_path_epoch: control ? 5 : null,
+ reason_code: control ? null : "control-heartbeat-lost",
+ observed_at: OBSERVED_AT,
+ },
+ data_plane: {
+ state: data ? "healthy" : dataPlaneState,
+ session_id: data || dataPlaneState !== "idle" ? "data-session-001" : null,
+ host_path_epoch: data || dataPlaneState !== "idle" ? 5 : null,
+ reason_code: dataPlaneState === "lost" ? "data-heartbeat-lost" : null,
+ observed_at: data || dataPlaneState !== "idle" ? OBSERVED_AT : null,
+ },
+ },
+ lease: {
+ state: "reachable",
+ generation: 6,
+ intent_id: "intent-001",
+ host_path_epoch: 5,
+ connection_mode: "bridge",
+ target,
+ logical_device_id: "device-k1-001",
+ reason_code: null,
+ observed_at: OBSERVED_AT,
+ },
+ authority: {
+ network_mutation_allowed: false,
+ control_allowed: control,
+ acquisition_start_allowed: control,
+ data_ingest_authoritative: control && data,
+ physical_motion_allowed: false,
+ reason_codes: control ? [] : ["control-heartbeat-lost"],
+ },
+ last_known: null,
+ allowed_actions: control ? ["stop-acquisition"] : ["probe-endpoint"],
+ };
+}
+
+function connectionLifecycle({
+ control = true,
+ mode = "bridge",
+ revision = 9,
+} = {}) {
+ return {
+ schema_version: "missioncore.xgrids-k1-connection-lifecycle/v1",
+ revision,
+ desired_mode: mode,
+ configured_mode: mode,
+ active_mode: control ? mode : null,
+ mode_change: {
+ state: control ? "ready" : "awaiting-control",
+ from: mode,
+ to: mode,
+ },
+ mode_selection: {
+ allowed: true,
+ reason_codes: [],
+ automatic_retry: false,
+ },
+ active_binding_key: control ? `binding-intent-001-${mode}` : null,
+ active_binding: control
+ ? {
+ binding_key: `binding-intent-001-${mode}`,
+ intent_id: "intent-001",
+ transport_ref: "ble-k1-001",
+ connection_mode: mode,
+ target_ipv4: "192.168.68.52",
+ target_port: 1883,
+ host_path_epoch: 5,
+ control_session_id: "control-session-001",
+ logical_device_id: "device-k1-001",
+ compatibility_profile_id: PROFILE_ID,
+ }
+ : null,
+ connection_ready: control,
+ ready_to_start: control,
+ operation: null,
+ allowed_actions: control
+ ? ["start-acquisition", "select-connection-mode"]
+ : ["select-connection-mode", "verify-control-read-only"],
+ automatic_retry: false,
+ };
+}
+
+function runtimeState() {
+ return {
+ phase: "live",
+ source_mode: "live",
+ ble_discovery_generation: 4,
+ connection_mode: "bridge",
+ configured_connection_mode: "bridge",
+ active_connection_mode: "bridge",
+ desired_connection_mode: "bridge",
+ desired_connection_mode_revision: 0,
+ k1_ip: "192.168.68.52",
+ rerun_grpc_url: "rerun+http://127.0.0.1:9877/proxy",
+ compatibility: {
+ profile_id: PROFILE_ID,
+ vendor_writes_enabled: true,
+ permitted_mode: "active-control",
+ },
+ device_ref: {
+ device_id: "device-k1-001",
+ model_id: model().id,
+ identity_stability: "stable",
+ identity_basis: "hardware-identifier",
+ },
+ device_session: {
+ device_session_id: "device-session-001",
+ device_id: "device-k1-001",
+ compatibility_profile_id: PROFILE_ID,
+ connectivity: "connected",
+ },
+ acquisition: {
+ acquisition_id: "acquisition-001",
+ device_id: "device-k1-001",
+ device_session_id: "device-session-001",
+ compatibility_profile_id: PROFILE_ID,
+ control_mode: "plugin-commanded",
+ requested_streams: ["spatial.point-cloud.live"],
+ target_host: "127.0.0.1",
+ duration_seconds: 0,
+ evidence_policy: "required",
+ state: "acquiring",
+ state_revision: 4,
+ },
+ sensor_catalog: {
+ schema_version: "missioncore.sensor-catalog/v1alpha2",
+ revision: "runtime-test",
+ streams: [{
+ stream_id: "spatial.point-cloud.live",
+ modality: "point-cloud",
+ availability: "streaming",
+ }],
+ },
+ metrics: {
+ pcl_frames: 10,
+ pose_frames: 5,
+ pipeline_ms: 12.5,
+ frame_rate_hz: 8,
+ point_count: 42_000,
+ ai_frame_rate_hz: 5,
+ device_elapsed_seconds: 30,
+ },
+ connection_supervisor: supervisor({ control: true, data: true }),
+ connection_lifecycle: connectionLifecycle(),
+ };
+}
+
+function activeRecoveryRuntimeState(recoveryOverrides = {}, stateOverrides = {}) {
+ const state = runtimeState();
+ return {
+ ...state,
+ snapshot_runtime_id: "runtime-active-recovery-001",
+ snapshot_revision: 51,
+ producer_generation: 13,
+ phase: "reconnecting",
+ connection_supervisor: supervisor({
+ control: false,
+ data: false,
+ dataPlaneState: "lost",
+ }),
+ connection_lifecycle: connectionLifecycle({ control: false }),
+ connection_recovery: {
+ schema_version: "missioncore.xgrids-k1-active-stream-recovery/v1",
+ state: "reconnecting",
+ generation: 5,
+ acquisition_id: state.acquisition.acquisition_id,
+ attempt: 1,
+ started_at_utc: "2026-08-11T21:19:59Z",
+ elapsed_ms: 16_869,
+ reason_code: "host-route-unavailable",
+ force_finish_allowed: true,
+ automatic_read_only_rebind: true,
+ automatic_command_retry: false,
+ start_performed: false,
+ stop_performed: false,
+ ble_operation_performed: false,
+ network_mutation_performed: false,
+ runtime_producer_generation: 13,
+ camera_recovery: "owned",
+ camera_media_state: "pending-epoch",
+ camera_media_ready: false,
+ camera_epoch: null,
+ ...recoveryOverrides,
+ },
+ ...stateOverrides,
+ };
+}
+
+function normalize(state) {
+ return normalizeXgridsK1MissionState({ state }, model());
+}
+
+function durableTopologyState() {
+ return {
+ phase: "idle",
+ source_mode: "idle",
+ ble_discovery_generation: 0,
+ connection_mode: "bridge",
+ configured_connection_mode: "bridge",
+ active_connection_mode: null,
+ desired_connection_mode: "bridge",
+ desired_connection_mode_revision: 0,
+ devices: [],
+ operations: [],
+ network_write_reconciliation: null,
+ network_mutation_ledger: {
+ status: "empty",
+ mutation_allowed: true,
+ reason_code: null,
+ operation_id: null,
+ transport_ref: null,
+ intended_mode: null,
+ stage: null,
+ revision: null,
+ resolution: null,
+ updated_at_utc: null,
+ },
+ semantic_topology_store: {
+ status: "available",
+ configured_offline_evidence: true,
+ live_connection_authority: false,
+ reason_code: null,
+ record: {
+ schema_version: "missioncore.xgrids-k1-semantic-topology/v1",
+ revision: 3,
+ transport_ref: "ble-k1-001",
+ connection_mode: "bridge",
+ ipv4: "192.168.68.52",
+ compatibility_profile_id: PROFILE_ID,
+ firmware_version: "3.0.2",
+ source: "ble-read-only-status",
+ observed_at_utc: OBSERVED_AT,
+ },
+ },
+ };
+}
+
+function provisioningController(state) {
+ return {
+ state,
+ pendingAction: null,
+ scan: async () => undefined,
+ connect: async () => ({
+ succeeded: false,
+ networkIntentCompleted: false,
+ intentDisposition: "retain",
+ acceptedSessionKey: null,
+ }),
+ verifyConnection: async () => ({
+ succeeded: false,
+ reconciliationCompleted: false,
+ observedState: state,
+ }),
+ retireUnavailablePhysicalCommand: async () => false,
+ retireUnavailablePhysicalCommandWithResult: async () => ({
+ succeeded: false,
+ observedState: state,
+ }),
+ reopenRetiredPhysicalReconciliation: async () => ({
+ succeeded: false,
+ observedState: state,
+ }),
+ prepareConnectionReconfiguration: async () => false,
+ probeConfiguredEndpoint: async () => undefined,
+ };
+}
+
+function provisioningAttemptPresentation(overrides = {}) {
+ return {
+ snapshotRuntimeId: "runtime-network-action",
+ connectionMode: "bridge",
+ idempotencyKey: "apply-intent-one",
+ attemptId: null,
+ deviceId: "f89438fa-55ed-85ad-eed7-734ac84746d8",
+ label: "XGR-A46BE7",
+ rssi: -51,
+ ssid: "FIELD-NET",
+ localPhase: "submitting",
+ failureMessage: null,
+ freshStartAllowed: false,
+ ...overrides,
+ };
+}
+
+function terminalConnectionRecoveryState({
+ phase = "network_outcome_unknown",
+ safeNextAction = "verify-control-read-only",
+ includeConfiguredTarget = true,
+ includeScan = true,
+} = {}) {
+ const state = durableTopologyState();
+ state.snapshot_runtime_id = "runtime-terminal-connection-recovery";
+ state.snapshot_runtime_started_at_utc = "2026-08-11T18:00:00Z";
+ state.snapshot_revision = 27;
+ state.connection_attempt = {
+ schema_version: "missioncore.xgrids-k1-connection-attempt/v1",
+ attempt_id: "op-b7ea404d-b0a2-460a-80bd-6922e85a2df5",
+ connection_mode: "bridge",
+ status: "failed",
+ phase,
+ control_state: "unknown",
+ stage: "network-operation-failed",
+ public_error_code: null,
+ side_effect_status: phase === "network_outcome_unknown" ? "unknown" : "none",
+ safe_next_action: safeNextAction,
+ automatic_retry: false,
+ accepted_at: OBSERVED_AT,
+ completed_at: OBSERVED_AT,
+ timeline: [],
+ diagnostic_bundle: {
+ schema_version: "missioncore.xgrids-k1-connection-diagnostic/v1",
+ redacted: true,
+ generated_at_utc: OBSERVED_AT,
+ snapshot_runtime_id: "runtime-terminal-connection-recovery",
+ attempt: {},
+ network_mutation_ledger: {},
+ connection_supervisor: {},
+ automatic_retry: false,
+ },
+ };
+ const allowedActions = [];
+ const actions = {};
+ if (includeConfiguredTarget) {
+ allowedActions.push("observe-configured-device-network");
+ actions["observe-configured-device-network"] = {
+ allowed: true,
+ reason_codes: [],
+ target_source: "durable-configured-state",
+ required_transport_ref: "F89438FA-55ED-85AD-EED7-734AC84746D8",
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ };
+ }
+ if (includeScan) {
+ allowedActions.push("scan-ble");
+ actions["scan-ble"] = {
+ allowed: true,
+ reason_codes: [],
+ target_source: "none",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ };
+ }
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ recommended_action: includeConfiguredTarget
+ ? "observe-configured-device-network"
+ : includeScan
+ ? "scan-ble"
+ : "manual-recovery-required",
+ allowed_actions: allowedActions,
+ actions,
+ };
+ return state;
+}
+
+function reopenedPhysicalState({
+ stage = "observing",
+ advertisedRef = "f89438fa-55ed-85ad-eed7-734ac84746d8",
+ backendRef = "F89438FA-55ED-85AD-EED7-734AC84746D8",
+ reopeningId = "reopening-returned-k1",
+ discoveryGeneration = 13,
+ snapshotRevision = 20,
+} = {}) {
+ const state = durableTopologyState();
+ state.snapshot_runtime_id = "runtime-reopened-k1";
+ state.snapshot_runtime_started_at_utc = "2026-08-10T12:00:00Z";
+ state.snapshot_revision = snapshotRevision;
+ state.desired_connection_mode = "bridge";
+ state.desired_connection_mode_revision = 7;
+ state.ble_discovery_generation = discoveryGeneration;
+ state.semantic_topology_store = {
+ status: "empty",
+ configured_offline_evidence: false,
+ live_connection_authority: false,
+ reason_code: null,
+ record: null,
+ };
+ state.devices = [{
+ device_id: advertisedRef,
+ name: "XGR-A46BE7",
+ rssi: -61,
+ connectable: true,
+ likely_k1: true,
+ }];
+ state.physical_command = {
+ status: "unresolved",
+ reason_code: "physical-command-reconciliation-required",
+ requires_reconciliation: true,
+ automatic_replay_allowed: false,
+ normal_session_recovery_supported: false,
+ runtime_bound: false,
+ reconciliation_ready: true,
+ record: {
+ revision: 282,
+ operation_id: "old-stop-operation",
+ action: "stop",
+ stage,
+ resolution: null,
+ connection: { transport_ref: backendRef },
+ operator_retirements: [{
+ retirement_id: "retirement-old-stop",
+ retired_transport_ref: backendRef,
+ }],
+ operator_reconciliation_reopens: [{
+ reopening_id: reopeningId,
+ retirement_id: "retirement-old-stop",
+ retired_record_revision: 281,
+ reopened_transport_ref: advertisedRef,
+ discovery_generation: discoveryGeneration,
+ reason: "device-returned-for-explicit-reconciliation",
+ }],
+ },
+ };
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: {
+ retained_context_is_presence: false,
+ retired_transport_refs: [],
+ },
+ allowed_actions: ["observe-fresh-device-network", "scan-ble"],
+ actions: {
+ "observe-fresh-device-network": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "fresh-scan",
+ required_transport_ref: backendRef,
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ "scan-ble": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "none",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ "provision-fresh-device": {
+ allowed: false,
+ reason_codes: ["physical-command-reconciliation-required"],
+ target_source: "fresh-scan",
+ required_transport_ref: backendRef,
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ },
+ };
+ return state;
+}
+
+function retiredPhysicalReopenReadyState({
+ advertisedRef = "f89438fa-55ed-85ad-eed7-734ac84746d8",
+ backendRef = "F89438FA-55ED-85AD-EED7-734AC84746D8",
+ discoveryGeneration = 13,
+ snapshotRuntimeId = "runtime-reopened-k1",
+} = {}) {
+ const state = reopenedPhysicalState({
+ advertisedRef,
+ backendRef,
+ discoveryGeneration,
+ });
+ state.snapshot_runtime_id = snapshotRuntimeId;
+ state.physical_command = {
+ status: "resolved",
+ reason_code: null,
+ requires_reconciliation: false,
+ automatic_replay_allowed: false,
+ normal_session_recovery_supported: false,
+ runtime_bound: false,
+ reconciliation_ready: false,
+ operator_reconciliation_reopen: {
+ allowed: true,
+ reason_codes: [],
+ expected_revision: 281,
+ expected_retirement_id: "retirement-old-stop",
+ expected_transport_ref: backendRef,
+ expected_discovery_generation: discoveryGeneration,
+ expected_desired_mode: "bridge",
+ expected_desired_mode_revision: 7,
+ device_io_performed: false,
+ automatic_retry: false,
+ },
+ record: {
+ revision: 281,
+ operation_id: "old-stop-operation",
+ action: "stop",
+ stage: "resolved",
+ resolution: "operator-retired-outcome-unknown",
+ connection: {
+ transport_ref: backendRef,
+ connection_mode: "bridge",
+ },
+ operator_retirements: [{
+ retirement_id: "retirement-old-stop",
+ retired_transport_ref: backendRef,
+ }],
+ operator_reconciliation_reopens: [],
+ },
+ };
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: {
+ retained_context_is_presence: false,
+ retired_transport_refs: [backendRef],
+ },
+ allowed_actions: ["scan-ble"],
+ actions: {
+ "scan-ble": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "none",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ "provision-fresh-device": {
+ allowed: false,
+ reason_codes: ["physical-command-target-retired"],
+ target_source: "fresh-scan",
+ required_transport_ref: backendRef,
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ },
+ };
+ return state;
+}
+
+
+function reopenedPhysicalAuthority({ discoveryGeneration = 13 } = {}) {
+ return {
+ snapshotRuntimeId: "runtime-reopened-k1",
+ connectionMode: "bridge",
+ desiredModeRevision: 7,
+ reconfigurationRevision: 0,
+ reconfigurationIntentId: null,
+ activeBindingKey: null,
+ discoveryGeneration,
+ };
+}
+
+function connectionReconfiguration({
+ intent = null,
+ status = intent ? "awaiting-fresh-scan" : "idle",
+ revision = 0,
+ intentId = intent ? `reconfigure-${revision}` : null,
+ requiredTransportRef = null,
+ requiredConnectionMode = null,
+ discoveryGeneration = null,
+ requiredTransportObserved = null,
+} = {}) {
+ return {
+ schema_version: "missioncore.xgrids-k1-connection-reconfiguration/v1",
+ revision,
+ intent_id: intentId,
+ intent,
+ status,
+ required_transport_ref: requiredTransportRef,
+ required_connection_mode: requiredConnectionMode,
+ minimum_discovery_generation: intent ? (discoveryGeneration ?? 1) : null,
+ fresh_discovery_generation: status === "fresh-scan-completed"
+ ? discoveryGeneration
+ : null,
+ required_transport_observed: requiredTransportObserved,
+ prepared_at: intent ? OBSERVED_AT : null,
+ automatic_retry: false,
+ };
+}
+
+function acquisitionController(state) {
+ return {
+ state,
+ pendingAction: null,
+ error: null,
+ physicalStopIntentSpent: false,
+ physicalStopInFlight: false,
+ closeApplicationControlSession: async () => false,
+ prepareCanonicalAcquisition: async () => false,
+ startPreparedAcquisition: async () => false,
+ startReplay: async () => false,
+ stop: async () => false,
+ stopLocalReceiver: async () => false,
+ abort: async () => false,
+ };
+}
+
+function connectionPipelinesController(state) {
+ return {
+ ...provisioningController(state),
+ ...acquisitionController(state),
+ backendStatus: "offline",
+ eventStatus: "disconnected",
+ latencyHistory: [],
+ scanWithResult: async () => ({
+ observedState: state,
+ discoveryGeneration: state?.ble_discovery_generation ?? 0,
+ }),
+ prepareConnectionReconfigurationWithResult: async () => ({
+ succeeded: false,
+ observedState: state,
+ }),
+ getConnectionActionAuthority: () => null,
+ isSnapshotRuntimeCurrent: () => true,
+ isConnectionActionAuthorityCurrent: () => true,
+ };
+}
+
+function renderConnectionPipelines(state) {
+ return renderToStaticMarkup(createElement(K1ConnectionPipelines, {
+ controller: connectionPipelinesController(state),
+ desiredConnectionMode: "bridge",
+ onDesiredConnectionModeChange() {},
+ operationalPanelsVisible: shouldRenderK1OperationalPanels(state),
+ openSpatialScene() {},
+ activateAutomaticSpatialSource() {},
+ sourceLabel: "Ожидание",
+ }));
+}
+
+function buttonMarkupWithText(markup, text) {
+ return (markup.match(/
/g) ?? [])
+ .filter((button) => button.includes(text));
+}
+
+function assertCanonicalConnectionCopy(markup) {
+ assert.doesNotMatch(
+ markup,
+ /[Ии]сходн|[Сс]охранён|[Пп]редыдущ|[Фф]изическ|[Пп]одключаемся/u,
+ );
+}
+
+function deviceRowOpeningTag(markup, deviceId) {
+ const deviceOffset = markup.indexOf(`${deviceId}`);
+ assert.notEqual(deviceOffset, -1, `${deviceId} must be rendered`);
+ const rowOffset = markup.lastIndexOf('", rowOffset) + 1);
+}
+
+function deviceRowActionButton(markup, deviceId) {
+ const deviceOffset = markup.indexOf(`
${deviceId}`);
+ assert.notEqual(deviceOffset, -1, `${deviceId} must be rendered`);
+ const buttonOffset = markup.indexOf("
", buttonOffset);
+ assert.notEqual(buttonEnd, -1, `${deviceId} action must be complete`);
+ return markup.slice(buttonOffset, buttonEnd + " ".length);
+}
+
+function renderProvisioning(props) {
+ return renderToStaticMarkup(createElement(K1ProvisioningPipeline, props));
+}
+
+function captureProvisioningTree(props) {
+ let capturedTree = null;
+
+ function CaptureHarness() {
+ capturedTree = K1ProvisioningPipeline(props);
+ return capturedTree;
+ }
+
+ renderToStaticMarkup(createElement(CaptureHarness));
+ assert.ok(capturedTree);
+ return capturedTree;
+}
+
+function captureProvisioningTreeAfterSearch(
+ props,
+ { completedDiscoveryGeneration } = {},
+) {
+ let capturedTree = null;
+
+ function SearchCompletedCaptureHarness() {
+ const internals =
+ React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
+ const dispatcher = internals?.H;
+ assert.equal(typeof dispatcher?.useState, "function");
+ const originalUseState = dispatcher.useState;
+ dispatcher.useState = (initialState) => {
+ if (initialState === emptySearchPresentation) {
+ const state = props.controller.state;
+ return [{
+ sequence: 1,
+ snapshotRuntimeId: state.snapshot_runtime_id ?? null,
+ connectionMode: props.desiredMode,
+ desiredModeRevision: state.desired_connection_mode_revision ?? null,
+ active: false,
+ completedDiscoveryGeneration: completedDiscoveryGeneration
+ ?? state.ble_discovery_generation
+ ?? null,
+ }, () => undefined];
+ }
+ return originalUseState(initialState);
+ };
+ try {
+ capturedTree = K1ProvisioningPipeline(props);
+ return capturedTree;
+ } finally {
+ dispatcher.useState = originalUseState;
+ }
+ }
+
+ renderToStaticMarkup(createElement(SearchCompletedCaptureHarness));
+ assert.ok(capturedTree);
+ return capturedTree;
+}
+
+function elementByProp(node, propName, expectedValue) {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = elementByProp(child, propName, expectedValue);
+ if (found) return found;
+ }
+ return null;
+ }
+ if (!React.isValidElement(node)) return null;
+ if (node.props[propName] === expectedValue) return node;
+ return elementByProp(node.props.children, propName, expectedValue);
+}
+
+function createStatefulProvisioningHarness(initialProps) {
+ const hookSlots = [];
+ let currentProps = initialProps;
+ let capturedTree = null;
+ let pendingEffects = [];
+
+ const dependenciesMatch = (left, right) => Boolean(
+ left
+ && right
+ && left.length === right.length
+ && left.every((value, index) => Object.is(value, right[index])),
+ );
+
+ const render = (nextProps = currentProps) => {
+ currentProps = nextProps;
+ pendingEffects = [];
+
+ function StatefulCaptureHarness() {
+ const internals =
+ React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
+ const dispatcher = internals?.H;
+ assert.equal(typeof dispatcher?.useState, "function");
+ const originals = {
+ useState: dispatcher.useState,
+ useRef: dispatcher.useRef,
+ useMemo: dispatcher.useMemo,
+ useCallback: dispatcher.useCallback,
+ useEffect: dispatcher.useEffect,
+ };
+ let hookIndex = 0;
+
+ dispatcher.useState = (initialState) => {
+ const index = hookIndex;
+ hookIndex += 1;
+ if (!hookSlots[index]) {
+ hookSlots[index] = {
+ kind: "state",
+ initializer: initialState,
+ value: typeof initialState === "function"
+ ? initialState()
+ : initialState,
+ };
+ }
+ const slot = hookSlots[index];
+ assert.equal(slot.kind, "state");
+ const setValue = (nextValue) => {
+ slot.value = typeof nextValue === "function"
+ ? nextValue(slot.value)
+ : nextValue;
+ };
+ return [slot.value, setValue];
+ };
+ dispatcher.useRef = (initialValue) => {
+ const index = hookIndex;
+ hookIndex += 1;
+ if (!hookSlots[index]) {
+ hookSlots[index] = {
+ kind: "ref",
+ value: { current: initialValue },
+ };
+ }
+ const slot = hookSlots[index];
+ assert.equal(slot.kind, "ref");
+ return slot.value;
+ };
+ dispatcher.useMemo = (factory, dependencies) => {
+ const index = hookIndex;
+ hookIndex += 1;
+ const previous = hookSlots[index];
+ if (
+ !previous
+ || previous.kind !== "memo"
+ || !dependenciesMatch(previous.dependencies, dependencies)
+ ) {
+ hookSlots[index] = {
+ kind: "memo",
+ dependencies,
+ value: factory(),
+ };
+ }
+ return hookSlots[index].value;
+ };
+ dispatcher.useCallback = (callback, dependencies) => {
+ const index = hookIndex;
+ hookIndex += 1;
+ const previous = hookSlots[index];
+ if (
+ !previous
+ || previous.kind !== "callback"
+ || !dependenciesMatch(previous.dependencies, dependencies)
+ ) {
+ hookSlots[index] = {
+ kind: "callback",
+ dependencies,
+ value: callback,
+ };
+ }
+ return hookSlots[index].value;
+ };
+ dispatcher.useEffect = (effect, dependencies) => {
+ const index = hookIndex;
+ hookIndex += 1;
+ const previous = hookSlots[index];
+ const changed = !previous
+ || previous.kind !== "effect"
+ || !dependenciesMatch(previous.dependencies, dependencies);
+ hookSlots[index] = {
+ kind: "effect",
+ dependencies,
+ cleanup: previous?.kind === "effect" ? previous.cleanup : undefined,
+ };
+ if (changed) pendingEffects.push({ index, effect });
+ };
+
+ try {
+ capturedTree = K1ProvisioningPipeline(currentProps);
+ } finally {
+ dispatcher.useState = originals.useState;
+ dispatcher.useRef = originals.useRef;
+ dispatcher.useMemo = originals.useMemo;
+ dispatcher.useCallback = originals.useCallback;
+ dispatcher.useEffect = originals.useEffect;
+ }
+ return null;
+ }
+
+ renderToStaticMarkup(createElement(StatefulCaptureHarness));
+ assert.ok(capturedTree);
+ return capturedTree;
+ };
+
+ const flushEffects = () => {
+ const effects = pendingEffects;
+ pendingEffects = [];
+ for (const { index, effect } of effects) {
+ const slot = hookSlots[index];
+ slot.cleanup?.();
+ const cleanup = effect();
+ slot.cleanup = typeof cleanup === "function" ? cleanup : undefined;
+ }
+ };
+
+ const stateValue = (initializer) => hookSlots.find(
+ (slot) => slot?.kind === "state" && slot.initializer === initializer,
+ )?.value;
+
+ const dispose = () => {
+ for (const slot of hookSlots) {
+ if (slot?.kind === "effect") slot.cleanup?.();
+ }
+ };
+
+ return { dispose, flushEffects, render, stateValue };
+}
+
+function reactNodeText(node) {
+ if (typeof node === "string" || typeof node === "number") return String(node);
+ if (Array.isArray(node)) return node.map(reactNodeText).join("");
+ if (!React.isValidElement(node)) return "";
+ return reactNodeText(node.props.children);
+}
+
+function actionByLabel(node, label) {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = actionByLabel(child, label);
+ if (found) return found;
+ }
+ return null;
+ }
+ if (!React.isValidElement(node)) return null;
+ if (
+ typeof node.props.onClick === "function"
+ && reactNodeText(node.props.children).includes(label)
+ ) return node;
+ return actionByLabel(node.props.children, label);
+}
+
+function renderProvisioningWithAttempt(props, presentation) {
+ function AttemptHarness() {
+ const internals =
+ React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
+ const dispatcher = internals?.H;
+ assert.equal(typeof dispatcher?.useState, "function");
+ const originalUseState = dispatcher.useState;
+ dispatcher.useState = (initialState) => {
+ if (initialState === emptyProvisioningAttemptPresentation) {
+ return [presentation, () => undefined];
+ }
+ return originalUseState(initialState);
+ };
+ try {
+ return K1ProvisioningPipeline(props);
+ } finally {
+ dispatcher.useState = originalUseState;
+ }
+ }
+
+ return renderToStaticMarkup(createElement(AttemptHarness));
+}
+
+function captureProvisioningTreeWithAttempt(props, presentation) {
+ let capturedTree = null;
+
+ function AttemptCaptureHarness() {
+ const internals =
+ React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
+ const dispatcher = internals?.H;
+ assert.equal(typeof dispatcher?.useState, "function");
+ const originalUseState = dispatcher.useState;
+ dispatcher.useState = (initialState) => {
+ if (initialState === emptyProvisioningAttemptPresentation) {
+ return [presentation, () => undefined];
+ }
+ return originalUseState(initialState);
+ };
+ try {
+ capturedTree = K1ProvisioningPipeline(props);
+ return capturedTree;
+ } finally {
+ dispatcher.useState = originalUseState;
+ }
+ }
+
+ renderToStaticMarkup(createElement(AttemptCaptureHarness));
+ assert.ok(capturedTree);
+ return capturedTree;
+}
+
+function renderProvisioningAfterSearch(
+ props,
+ { completedDiscoveryGeneration } = {},
+) {
+ function SearchCompletedHarness() {
+ const internals =
+ React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
+ const dispatcher = internals?.H;
+ assert.equal(typeof dispatcher?.useState, "function");
+ const originalUseState = dispatcher.useState;
+ dispatcher.useState = (initialState) => {
+ if (initialState === emptySearchPresentation) {
+ const state = props.controller.state;
+ return [{
+ sequence: 1,
+ snapshotRuntimeId: state.snapshot_runtime_id ?? null,
+ connectionMode: props.desiredMode,
+ desiredModeRevision: state.desired_connection_mode_revision ?? null,
+ active: false,
+ completedDiscoveryGeneration: completedDiscoveryGeneration
+ ?? state.ble_discovery_generation
+ ?? null,
+ }, () => undefined];
+ }
+ return originalUseState(initialState);
+ };
+ try {
+ return K1ProvisioningPipeline(props);
+ } finally {
+ dispatcher.useState = originalUseState;
+ }
+ }
+
+ return renderToStaticMarkup(createElement(SearchCompletedHarness));
+}
+
+
+function renderProvisioningWithCurrentPendingAction(
+ props,
+ { publicSearch = true } = {},
+) {
+ function CurrentPendingActionHarness() {
+ const state = props.controller.state;
+ const fence = {
+ snapshotRuntimeId: state.snapshot_runtime_id,
+ connectionMode: props.desiredMode,
+ desiredModeRevision: state.desired_connection_mode_revision,
+ reconfigurationRevision: state.connection_reconfiguration?.revision ?? 0,
+ reconfigurationIntentId: state.connection_reconfiguration?.intent_id ?? null,
+ activeBindingKey: state.connection_lifecycle?.active_binding_key ?? null,
+ discoveryGeneration: state.ble_discovery_generation,
+ clickToken: 1,
+ };
+ return createElement(
+ RuntimeActionFenceTestContext.Provider,
+ { value: fence },
+ publicSearch
+ ? createElement(SearchCompletedPendingHarness, { props })
+ : createElement(K1ProvisioningPipeline, props),
+ );
+ }
+
+ function SearchCompletedPendingHarness({ props: childProps }) {
+ const internals =
+ React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
+ const dispatcher = internals?.H;
+ assert.equal(typeof dispatcher?.useState, "function");
+ const originalUseState = dispatcher.useState;
+ dispatcher.useState = (initialState) => {
+ if (initialState === emptySearchPresentation) {
+ const state = childProps.controller.state;
+ return [{
+ sequence: 1,
+ snapshotRuntimeId: state.snapshot_runtime_id ?? null,
+ connectionMode: childProps.desiredMode,
+ desiredModeRevision: state.desired_connection_mode_revision ?? null,
+ active: true,
+ completedDiscoveryGeneration: null,
+ }, () => undefined];
+ }
+ return originalUseState(initialState);
+ };
+ try {
+ return K1ProvisioningPipeline(childProps);
+ } finally {
+ dispatcher.useState = originalUseState;
+ }
+ }
+
+ return renderToStaticMarkup(createElement(CurrentPendingActionHarness));
+}
+
+
+function renderProvisioningWithRetainedDraft(
+ props,
+ { device, draft, ssid, password },
+) {
+ function RetainedDraftHarness() {
+ const internals =
+ React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
+ const dispatcher = internals?.H;
+ assert.equal(typeof dispatcher?.useState, "function");
+ const originalUseState = dispatcher.useState;
+ let emptyStringStateIndex = 0;
+ let nullStateIndex = 0;
+ dispatcher.useState = (initialState) => {
+ if (initialState === "") {
+ emptyStringStateIndex += 1;
+ const seededValue = emptyStringStateIndex === 1
+ ? device.device_id
+ : emptyStringStateIndex === 2
+ ? ssid
+ : emptyStringStateIndex === 3
+ ? password
+ : initialState;
+ return [seededValue, () => undefined];
+ }
+ if (initialState === null) {
+ nullStateIndex += 1;
+ if (nullStateIndex === 1) return [device, () => undefined];
+ if (nullStateIndex === 8) return [draft, () => undefined];
+ }
+ return originalUseState(initialState);
+ };
+ try {
+ return K1ProvisioningPipeline(props);
+ } finally {
+ dispatcher.useState = originalUseState;
+ }
+ }
+
+ return renderToStaticMarkup(createElement(RetainedDraftHarness));
+}
+
+
+test("K1 provisioning password authority is scoped to one exact operator draft", () => {
+ const draft = {
+ snapshotRuntimeId: "runtime-a",
+ deviceId: "ble-k1-001",
+ connectionMode: "bridge",
+ desiredModeRevision: 7,
+ discoveryGeneration: 13,
+ reconfigurationRevision: 5,
+ reconfigurationIntentId: "reconfigure-001",
+ activeBindingKey: null,
+ requiredTransportRef: "ble-k1-001",
+ requiredConnectionMode: "bridge",
+ };
+ const current = {
+ snapshotRuntimeId: "runtime-a",
+ deviceId: "ble-k1-001",
+ connectionMode: "bridge",
+ desiredModeRevision: 7,
+ discoveryGeneration: 13,
+ reconfigurationRevision: 5,
+ reconfigurationIntentId: "reconfigure-001",
+ activeBindingKey: null,
+ requiredTransportRef: "ble-k1-001",
+ requiredConnectionMode: "bridge",
+ };
+
+ assert.equal(explicitProvisioningDraftMatches(draft, current), true);
+ for (const drifted of [
+ { ...current, snapshotRuntimeId: "runtime-b" },
+ { ...current, deviceId: "another-k1" },
+ { ...current, connectionMode: "direct-connect" },
+ { ...current, desiredModeRevision: 8 },
+ { ...current, discoveryGeneration: 14 },
+ { ...current, reconfigurationRevision: 6 },
+ { ...current, reconfigurationIntentId: "reconfigure-002" },
+ { ...current, activeBindingKey: "binding-new" },
+ { ...current, requiredTransportRef: "ble-k1-002" },
+ { ...current, requiredConnectionMode: "direct-connect" },
+ { ...current, deviceId: null },
+ ]) {
+ assert.equal(explicitProvisioningDraftMatches(draft, drifted), false);
+ }
+});
+
+test("K1 provisioning local credential fence changes with backend runtime authority", () => {
+ const fence = {
+ snapshotRuntimeId: "runtime-a",
+ reconfigurationRevision: 5,
+ reconfigurationIntentId: "reconfigure-001",
+ activeBindingKey: null,
+ requiredTransportRef: "ble-k1-001",
+ requiredConnectionMode: "bridge",
+ };
+ assert.notEqual(
+ localProvisioningDraftFenceKey(fence),
+ localProvisioningDraftFenceKey({
+ ...fence,
+ snapshotRuntimeId: "runtime-b",
+ }),
+ );
+});
+
+test("K1 click-owned action requests disappear across runtime and click interleavings", () => {
+ const authorityA = {
+ snapshotRuntimeId: "runtime-a",
+ connectionMode: "bridge",
+ desiredModeRevision: 7,
+ reconfigurationRevision: 10,
+ reconfigurationIntentId: "reconfigure-a",
+ activeBindingKey: "binding-a",
+ discoveryGeneration: 5,
+ };
+ const firstClick = {
+ ...authorityA,
+ clickToken: 17,
+ };
+ const awaitingScan = {
+ ...firstClick,
+ deviceId: "ble-k1-001",
+ previousDiscoveryGeneration: 5,
+ expectedDiscoveryGeneration: null,
+ scanTransportRefs: [],
+ stage: "awaiting-scan",
+ };
+ let latestAuthority = authorityA;
+ const latestAuthorityMatches = (fence) =>
+ connectionActionAuthorityMatches(fence, latestAuthority);
+
+ assert.equal(
+ runtimeActionFenceMatches(
+ firstClick,
+ "runtime-a",
+ firstClick,
+ latestAuthorityMatches,
+ ),
+ true,
+ );
+ assert.equal(
+ currentRuntimeActionRequest(
+ awaitingScan,
+ "runtime-a",
+ firstClick,
+ latestAuthorityMatches,
+ ),
+ awaitingScan,
+ );
+
+ // Same-runtime authority drift must invalidate the old click before the new
+ // React render commits. A late Quick candidate cannot re-arm or auto-submit.
+ latestAuthority = { ...authorityA, discoveryGeneration: 6 };
+ assert.equal(
+ runtimeActionFenceMatches(
+ firstClick,
+ "runtime-a",
+ firstClick,
+ latestAuthorityMatches,
+ ),
+ false,
+ );
+ assert.equal(
+ currentRuntimeActionRequest(
+ awaitingScan,
+ "runtime-a",
+ firstClick,
+ latestAuthorityMatches,
+ ),
+ null,
+ );
+
+ // Simulate acceptState(runtime-b) updating latestState.current before React
+ // commits the runtime-b render.
+ latestAuthority = { ...authorityA, snapshotRuntimeId: "runtime-b" };
+
+ const secondClick = {
+ ...latestAuthority,
+ clickToken: 18,
+ };
+ assert.equal(
+ runtimeActionFenceMatches(
+ firstClick,
+ "runtime-b",
+ secondClick,
+ latestAuthorityMatches,
+ ),
+ false,
+ );
+ assert.equal(
+ runtimeActionFenceMatches(
+ secondClick,
+ "runtime-b",
+ secondClick,
+ latestAuthorityMatches,
+ ),
+ true,
+ );
+});
+
+
+test("K1 reconfiguration continuation requires exact correlated +1 revisions", () => {
+ const fence = {
+ snapshotRuntimeId: "runtime-a",
+ connectionMode: "bridge",
+ desiredModeRevision: 7,
+ reconfigurationRevision: 10,
+ reconfigurationIntentId: null,
+ activeBindingKey: "binding-a",
+ discoveryGeneration: 5,
+ };
+ const preparedState = {
+ snapshot_runtime_id: "runtime-a",
+ desired_connection_mode: "bridge",
+ desired_connection_mode_revision: 7,
+ ble_discovery_generation: 6,
+ connection_reconfiguration: connectionReconfiguration({
+ intent: "select-device",
+ status: "awaiting-fresh-scan",
+ revision: 11,
+ intentId: "reconfigure-b",
+ discoveryGeneration: 6,
+ }),
+ connection_lifecycle: {
+ active_binding_key: null,
+ active_binding: null,
+ },
+ };
+ const preparedAuthority = {
+ ...fence,
+ reconfigurationRevision: 11,
+ reconfigurationIntentId: "reconfigure-b",
+ activeBindingKey: null,
+ discoveryGeneration: 6,
+ };
+ assert.deepEqual(
+ reconfigurationContinuationAuthority(
+ fence,
+ preparedState,
+ "select-device",
+ preparedAuthority,
+ ),
+ preparedAuthority,
+ );
+
+ for (const drifted of [
+ { ...preparedAuthority, discoveryGeneration: 7 },
+ { ...preparedAuthority, reconfigurationRevision: 12 },
+ { ...preparedAuthority, desiredModeRevision: 8 },
+ { ...preparedAuthority, activeBindingKey: "foreign-binding" },
+ ]) {
+ assert.equal(
+ reconfigurationContinuationAuthority(
+ fence,
+ preparedState,
+ "select-device",
+ drifted,
+ ),
+ null,
+ );
+ }
+ assert.equal(
+ reconfigurationContinuationAuthority(
+ fence,
+ preparedState,
+ "change-network",
+ preparedAuthority,
+ ),
+ null,
+ );
+ const contaminatedPreparedState = {
+ ...preparedState,
+ connection_lifecycle: {
+ active_binding_key: "binding-foreign",
+ active_binding: {
+ binding_key: "binding-foreign",
+ transport_ref: "ble-k1-foreign",
+ connection_mode: "bridge",
+ },
+ },
+ };
+ assert.equal(
+ reconfigurationContinuationAuthority(
+ fence,
+ contaminatedPreparedState,
+ "select-device",
+ { ...preparedAuthority, activeBindingKey: "binding-foreign" },
+ ),
+ null,
+ );
+
+ const cancelFence = preparedAuthority;
+ const cancelledState = {
+ ...preparedState,
+ ble_discovery_generation: 7,
+ connection_reconfiguration: connectionReconfiguration({ revision: 12 }),
+ };
+ const cancelledAuthority = {
+ ...cancelFence,
+ reconfigurationRevision: 12,
+ reconfigurationIntentId: null,
+ discoveryGeneration: 7,
+ };
+ assert.deepEqual(
+ reconfigurationContinuationAuthority(
+ cancelFence,
+ cancelledState,
+ "cancel",
+ cancelledAuthority,
+ ),
+ cancelledAuthority,
+ );
+});
+
+test("separately explicit Verify rejects a foreign same-runtime binding", () => {
+ const exact = {
+ selected_device_id: "ble-k1-001",
+ connection_mode: "bridge",
+ connection_lifecycle: {
+ active_binding_key: "binding-a",
+ active_binding: {
+ binding_key: "binding-a",
+ transport_ref: "ble-k1-001",
+ connection_mode: "bridge",
+ },
+ },
+ };
+ assert.equal(
+ observedConnectionAuthorityAllowsTarget(
+ exact,
+ "ble-k1-001",
+ "bridge",
+ ),
+ true,
+ );
+ assert.equal(
+ observedConnectionAuthorityAllowsTarget(
+ {
+ ...exact,
+ selected_device_id: "ble-k1-foreign",
+ connection_lifecycle: {
+ active_binding_key: "binding-b",
+ active_binding: {
+ binding_key: "binding-b",
+ transport_ref: "ble-k1-foreign",
+ connection_mode: "bridge",
+ },
+ },
+ },
+ "ble-k1-001",
+ "bridge",
+ { allowUnbound: true },
+ ),
+ false,
+ );
+ assert.equal(
+ observedConnectionAuthorityAllowsTarget(
+ {
+ selected_device_id: null,
+ connection_mode: null,
+ connection_lifecycle: {
+ active_binding_key: null,
+ active_binding: null,
+ },
+ },
+ "ble-k1-001",
+ "bridge",
+ { allowUnbound: true },
+ ),
+ true,
+ );
+});
+
+test("K1 Connect continuation accepts only unchanged or exactly consumed authority", () => {
+ const ordinaryFence = {
+ snapshotRuntimeId: "runtime-a",
+ connectionMode: "bridge",
+ desiredModeRevision: 7,
+ reconfigurationRevision: 10,
+ reconfigurationIntentId: null,
+ activeBindingKey: null,
+ discoveryGeneration: 5,
+ };
+ const connectedState = ({
+ deviceId = "ble-k1-001",
+ reconfigurationRevision = 10,
+ discoveryGeneration = 5,
+ } = {}) => ({
+ snapshot_runtime_id: "runtime-a",
+ desired_connection_mode: "bridge",
+ desired_connection_mode_revision: 7,
+ ble_discovery_generation: discoveryGeneration,
+ selected_device_id: deviceId,
+ connection_mode: "bridge",
+ connection_reconfiguration: connectionReconfiguration({
+ revision: reconfigurationRevision,
+ }),
+ connection_lifecycle: {
+ active_binding_key: `binding-${deviceId}`,
+ active_binding: {
+ binding_key: `binding-${deviceId}`,
+ transport_ref: deviceId,
+ connection_mode: "bridge",
+ },
+ },
+ });
+ const ordinaryState = connectedState();
+ const ordinaryCurrent = {
+ ...ordinaryFence,
+ activeBindingKey: "binding-ble-k1-001",
+ };
+ assert.deepEqual(
+ connectContinuationAuthority(
+ ordinaryFence,
+ ordinaryState,
+ ordinaryCurrent,
+ "ble-k1-001",
+ "bridge",
+ ),
+ ordinaryCurrent,
+ );
+
+ const reconfigurationFence = {
+ ...ordinaryFence,
+ reconfigurationIntentId: "reconfigure-a",
+ };
+ const consumedState = connectedState({
+ reconfigurationRevision: 11,
+ discoveryGeneration: 6,
+ });
+ const consumedCurrent = {
+ ...reconfigurationFence,
+ reconfigurationRevision: 11,
+ reconfigurationIntentId: null,
+ activeBindingKey: "binding-ble-k1-001",
+ discoveryGeneration: 6,
+ };
+ assert.deepEqual(
+ connectContinuationAuthority(
+ reconfigurationFence,
+ consumedState,
+ consumedCurrent,
+ "ble-k1-001",
+ "bridge",
+ ),
+ consumedCurrent,
+ );
+
+ const skippedState = connectedState({
+ reconfigurationRevision: 12,
+ discoveryGeneration: 7,
+ });
+ const skippedCurrent = {
+ ...consumedCurrent,
+ reconfigurationRevision: 12,
+ discoveryGeneration: 7,
+ };
+ assert.equal(
+ connectContinuationAuthority(
+ reconfigurationFence,
+ skippedState,
+ skippedCurrent,
+ "ble-k1-001",
+ "bridge",
+ ),
+ null,
+ );
+ const foreignState = connectedState({ deviceId: "ble-k1-foreign" });
+ assert.equal(
+ connectContinuationAuthority(
+ ordinaryFence,
+ foreignState,
+ {
+ ...ordinaryCurrent,
+ activeBindingKey: "binding-ble-k1-foreign",
+ },
+ "ble-k1-001",
+ "bridge",
+ ),
+ null,
+ );
+});
+
+test("K1 runtime replacement retires A and isolates a new B action from late settlement", () => {
+ const arbiter = new SnapshotRuntimeActionArbiter();
+ const actionA = arbiter.begin(4);
+ assert.ok(actionA);
+ let pendingAction = "verify";
+ let acceptedState = "runtime-a";
+ let surfacedError = null;
+
+ assert.equal(
+ arbiter.retireForSnapshotChange("runtime-a", "runtime-b"),
+ true,
+ );
+ pendingAction = null;
+ acceptedState = "runtime-b";
+ assert.equal(pendingAction, null, "runtime-b controls are enabled without A loader");
+
+ const actionB = arbiter.begin(4);
+ assert.ok(actionB, "runtime-b click is admitted before action A settles");
+ pendingAction = "scan";
+
+ const acceptLateResult = (token, nextState) => {
+ if (!arbiter.isCurrent(token)) return false;
+ acceptedState = nextState;
+ return true;
+ };
+ const surfaceLateFailure = (token, message) => {
+ if (!arbiter.isCurrent(token)) return false;
+ surfacedError = message;
+ return true;
+ };
+ const finish = (token) => {
+ if (!arbiter.settle(token)) return false;
+ pendingAction = null;
+ return true;
+ };
+
+ assert.equal(acceptLateResult(actionA, "runtime-a-late"), false);
+ assert.equal(surfaceLateFailure(actionA, "late A error"), false);
+ assert.equal(finish(actionA), false);
+ assert.equal(acceptedState, "runtime-b");
+ assert.equal(surfacedError, null);
+ assert.equal(pendingAction, "scan", "late A finally cannot clear B pending state");
+
+ assert.equal(finish(actionB), true);
+ assert.equal(pendingAction, null);
+});
+
+test("explicit scenario reset B supersedes pending callback A in one runtime", () => {
+ const arbiter = new SnapshotRuntimeActionArbiter();
+ const actionA = arbiter.begin(12);
+ assert.ok(actionA);
+
+ const actionB = arbiter.begin(12, true);
+ assert.ok(actionB, "reset B is admitted while A is still pending");
+ assert.equal(arbiter.isCurrent(actionA), false);
+ assert.equal(arbiter.settle(actionA), false, "late A cannot clear B loader");
+ assert.equal(arbiter.isCurrent(actionB), true);
+ assert.equal(arbiter.settle(actionB), true);
+});
+
+test("acquisition and spatial STOP surfaces share one runtime action slot", () => {
+ const arbiter = new SnapshotRuntimeActionArbiter();
+ let physicalStopCallCount = 0;
+ const clickPhysicalStop = () => {
+ const token = arbiter.begin(7);
+ if (!token) return null;
+ physicalStopCallCount += 1;
+ return token;
+ };
+
+ const acquisitionSurface = clickPhysicalStop();
+ const spatialSurface = clickPhysicalStop();
+ assert.ok(acquisitionSurface);
+ assert.equal(spatialSurface, null);
+ assert.equal(physicalStopCallCount, 1);
+ assert.equal(arbiter.settle(acquisitionSurface), true);
+});
+
+test("change-network exact lookup never substitutes a neighboring UUID", () => {
+ const reconfiguration = connectionReconfiguration({
+ intent: "change-network",
+ status: "fresh-scan-completed",
+ revision: 8,
+ requiredTransportRef: "ble-k1-original",
+ requiredConnectionMode: "bridge",
+ discoveryGeneration: 11,
+ requiredTransportObserved: true,
+ });
+ const devices = [
+ { device_id: "ble-k1-foreign", name: "Nearby", connectable: true },
+ { device_id: "ble-k1-original", name: "Original", connectable: true },
+ ];
+ assert.equal(
+ exactChangeNetworkCandidate(reconfiguration, devices, 11)?.device_id,
+ "ble-k1-original",
+ );
+ assert.equal(exactChangeNetworkCandidate(reconfiguration, devices, 12), null);
+ assert.equal(
+ exactChangeNetworkCandidate(
+ { ...reconfiguration, required_transport_observed: false },
+ devices,
+ 11,
+ ),
+ null,
+ );
+});
+
+test("cold connection workflow exposes the mode selector and explicit Scan immediately", () => {
+ const state = durableTopologyState();
+ state.semantic_topology_store = {
+ status: "empty",
+ configured_offline_evidence: false,
+ live_connection_authority: false,
+ reason_code: null,
+ record: null,
+ };
+ state.devices = [{
+ device_id: "ble-k1-001",
+ name: "XGR-A46BE7",
+ rssi: -45,
+ connectable: true,
+ likely_k1: true,
+ }];
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: ["scan-ble", "provision-fresh-device"],
+ actions: {
+ "scan-ble": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "none",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ "provision-fresh-device": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "fresh-scan",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ "prepare-select-device": {
+ allowed: false,
+ reason_codes: ["acquisition-active"],
+ target_source: "local-prestart-handoff",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ device_write_performed: false,
+ automatic_retry: false,
+ },
+ "prepare-change-network": {
+ allowed: false,
+ reason_codes: ["acquisition-active"],
+ target_source: "local-prestart-handoff",
+ required_transport_ref: "ble-k1-001",
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: false,
+ device_write_performed: false,
+ automatic_retry: false,
+ },
+ },
+ };
+
+ const markup = renderToStaticMarkup(createElement(K1ProvisioningPipeline, {
+ controller: provisioningController(state),
+ desiredMode: "bridge",
+ }));
+ assert.equal(physicalRecoveryConnectionDetail(state), null);
+ assert.match(markup, /ПОДКЛЮЧЕНИЕ · ШАГИ 01–02/);
+ assert.match(markup, /
01<\/span>/);
+ assert.match(markup, /Подключение<\/h3>/);
+ assert.doesNotMatch(markup, /02<\/span>|03<\/span>/);
+ assert.doesNotMatch(markup, /Питание|индикатор горит/);
+ assert.doesNotMatch(markup, /Название общей сети Wi‑Fi|Пароль Wi‑Fi/);
+ assert.doesNotMatch(markup, /class="nodedc-activity-indicator"/);
+ const scanButton = buttonMarkupWithText(markup, "Найти по Bluetooth")[0];
+ assert.ok(scanButton);
+ assert.doesNotMatch(scanButton, /\bdisabled(?:=|\s|>)/);
+ assert.equal(
+ buttonMarkupWithText(markup, "Подключить новый K1").length,
+ 0,
+ );
+ const modeToggle = markup.match(
+ /]*aria-label="Способ подключения"[^>]*>/,
+ )?.[0];
+ assert.ok(modeToggle);
+ assert.doesNotMatch(modeToggle, /\bdisabled(?:=|\s|>)/);
+ assertCanonicalConnectionCopy(markup);
+});
+
+test("cold Scan click dispatches from the live runtime fence and releases it for retry", async () => {
+ const state = durableTopologyState();
+ state.snapshot_runtime_id = "runtime-live-scan-click";
+ state.snapshot_revision = 1;
+ state.semantic_topology_store = {
+ status: "empty",
+ configured_offline_evidence: false,
+ live_connection_authority: false,
+ reason_code: null,
+ record: null,
+ };
+ state.connection_reconfiguration = connectionReconfiguration();
+ state.connection_lifecycle = connectionLifecycle({ control: false });
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: ["scan-ble"],
+ actions: {
+ "scan-ble": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "none",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ },
+ };
+ const authority = connectionActionAuthoritySnapshot(state, "bridge");
+ assert.ok(authority);
+ let scanCalls = 0;
+ let scanDispatched = null;
+ const controller = {
+ ...provisioningController(state),
+ scanWithResult: async () => {
+ scanCalls += 1;
+ scanDispatched?.();
+ return {
+ succeeded: true,
+ snapshotRuntimeId: state.snapshot_runtime_id,
+ discoveryGeneration: state.ble_discovery_generation + scanCalls,
+ transportRefs: [],
+ };
+ },
+ selectConnectionMode: async () => true,
+ getConnectionActionAuthority: () => authority,
+ isSnapshotRuntimeCurrent: (runtimeId) => runtimeId === state.snapshot_runtime_id,
+ isConnectionActionAuthorityCurrent: (candidate) =>
+ connectionActionAuthorityMatches(candidate, authority),
+ isConnectionPolicyActionAllowedCurrent: (action) => action === "scan-ble",
+ };
+ const tree = captureProvisioningTree({
+ controller,
+ desiredMode: "bridge",
+ });
+ const scanButton = actionByLabel(tree, "Найти по Bluetooth");
+ assert.ok(scanButton);
+
+ const clickAndWaitForDispatch = async () => {
+ const dispatched = new Promise((resolve) => {
+ scanDispatched = resolve;
+ });
+ scanButton.props.onClick();
+ await Promise.race([
+ dispatched,
+ new Promise((_, reject) => setTimeout(
+ () => reject(new Error("Scan click did not reach scanWithResult")),
+ 100,
+ )),
+ ]);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ };
+
+ await clickAndWaitForDispatch();
+ await clickAndWaitForDispatch();
+ assert.equal(scanCalls, 2);
+});
+
+test("scenario reset epoch rejects every late settlement from the old local scenario", () => {
+ assert.equal(localScenarioActionEpochIsCurrent(4, 4), true);
+ assert.equal(localScenarioActionEpochIsCurrent(4, 5), false);
+
+ const source = readFileSync(provisioningSourceUrl, "utf8");
+ const search = sourceSlice(source, "const repeatDeviceScan", "const submitConnect");
+ const afterScan = search.indexOf("scanResult = await scanWithResult");
+ const epochGuard = search.indexOf(
+ "localScenarioActionEpochIsCurrent",
+ afterScan,
+ );
+ const firstSettlement = search.indexOf("setEscapedAppliedAttemptKey", afterScan);
+ assert.ok(afterScan >= 0);
+ assert.ok(epochGuard > afterScan);
+ assert.ok(firstSettlement > epochGuard);
+
+ const toolbarResetBoundary = sourceSlice(
+ source,
+ "if (\n scenarioResetPresentationKey",
+ "const currentRuntimeActionFence",
+ );
+ assert.match(toolbarResetBoundary, /localScenarioActionEpoch\.current \+= 1/);
+ assert.match(toolbarResetBoundary, /activeRuntimeActionFence\.current = null/);
+ assert.match(toolbarResetBoundary, /activeConnectIntent\.current = null/);
+
+ const resetEffect = sourceSlice(
+ source,
+ "useEffect(() => {\n if (\n !scenarioResetPresentationKey",
+ "const searchPresentationIsCurrent",
+ );
+ assert.match(resetEffect, /setConnectionAttemptPresentation\(null\)/);
+ assert.match(resetEffect, /setPreparingReconfigurationRequest\(null\)/);
+ assert.match(resetEffect, /setCandidateUnavailableMessage\(null\)/);
+ assert.doesNotMatch(source, /physicalReopenInFlight|setPhysicalReopenPresentation/);
+});
+
+test("toolbar reset in one runtime rejects late Scan A and admits exactly one Scan B", async () => {
+ const initialState = durableTopologyState();
+ initialState.snapshot_runtime_id = "runtime-toolbar-reset-during-scan";
+ initialState.snapshot_revision = 1;
+ initialState.semantic_topology_store = {
+ status: "empty",
+ configured_offline_evidence: false,
+ live_connection_authority: false,
+ reason_code: null,
+ record: null,
+ };
+ initialState.connection_reconfiguration = connectionReconfiguration();
+ initialState.connection_lifecycle = connectionLifecycle({ control: false });
+ initialState.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: ["scan-ble"],
+ actions: {
+ "scan-ble": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "none",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ },
+ };
+
+ let currentState = initialState;
+ let resolveScanA;
+ let markScanADispatched;
+ let markScanBDispatched;
+ const scanAResult = new Promise((resolve) => {
+ resolveScanA = resolve;
+ });
+ const scanADispatched = new Promise((resolve) => {
+ markScanADispatched = resolve;
+ });
+ const scanBDispatched = new Promise((resolve) => {
+ markScanBDispatched = resolve;
+ });
+ let scanCalls = 0;
+ let scanBCalls = 0;
+
+ const controller = {
+ ...provisioningController(initialState),
+ selectConnectionMode: async () => true,
+ scanWithResult: async () => {
+ scanCalls += 1;
+ if (scanCalls === 1) {
+ markScanADispatched();
+ return scanAResult;
+ }
+ scanBCalls += 1;
+ markScanBDispatched();
+ return {
+ succeeded: true,
+ snapshotRuntimeId: currentState.snapshot_runtime_id,
+ discoveryGeneration: currentState.ble_discovery_generation + 1,
+ transportRefs: [],
+ };
+ },
+ getCurrentState: () => currentState,
+ getConnectionActionAuthority: (mode) =>
+ connectionActionAuthoritySnapshot(currentState, mode),
+ isSnapshotRuntimeCurrent: (runtimeId) =>
+ runtimeId === currentState.snapshot_runtime_id,
+ isConnectionActionAuthorityCurrent: (candidate) =>
+ connectionActionAuthorityMatches(
+ candidate,
+ connectionActionAuthoritySnapshot(
+ currentState,
+ candidate.connectionMode,
+ ),
+ ),
+ isConnectionPolicyActionAllowedCurrent: (action) =>
+ currentState.connection_policy.allowed_actions.includes(action),
+ };
+ const props = () => ({
+ controller: { ...controller, state: currentState },
+ desiredMode: "bridge",
+ });
+ const harness = createStatefulProvisioningHarness(props());
+
+ try {
+ let tree = harness.render(props());
+ harness.flushEffects();
+ tree = harness.render(props());
+ harness.flushEffects();
+ const scanAButton = actionByLabel(tree, "Найти по Bluetooth");
+ assert.ok(scanAButton);
+ scanAButton.props.onClick();
+ await Promise.race([
+ scanADispatched,
+ new Promise((_, reject) => setTimeout(
+ () => reject(new Error("Scan A did not dispatch")),
+ 100,
+ )),
+ ]);
+
+ const resetState = structuredClone(initialState);
+ resetState.snapshot_revision = 2;
+ resetState.desired_connection_mode_revision = 1;
+ resetState.connection_scenario_reset = {
+ reset_id: "toolbar-reset-same-runtime",
+ request_revision: 0,
+ revision: 1,
+ desired_mode: "bridge",
+ active: false,
+ settled_by_discovery_generation: 0,
+ local_session_closed: true,
+ previous_device_may_continue_scanning: false,
+ physical_disposition: null,
+ network_disposition: null,
+ device_command_performed: false,
+ network_write_performed: false,
+ automatic_scan: false,
+ operation_sequence: 2,
+ };
+ currentState = resetState;
+
+ tree = harness.render(props());
+ harness.flushEffects();
+ tree = harness.render(props());
+ harness.flushEffects();
+ assert.equal(harness.stateValue(emptySearchPresentation), null);
+ assert.ok(actionByLabel(tree, "Найти по Bluetooth"));
+ assert.equal(actionByLabel(tree, "Повторить поиск Bluetooth"), null);
+ assert.equal(actionByLabel(tree, "Применить"), null);
+
+ resolveScanA({
+ succeeded: true,
+ snapshotRuntimeId: initialState.snapshot_runtime_id,
+ discoveryGeneration: 1,
+ transportRefs: ["stale-device-from-scan-a"],
+ });
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ tree = harness.render(props());
+ harness.flushEffects();
+ tree = harness.render(props());
+ harness.flushEffects();
+ assert.equal(
+ harness.stateValue(emptySearchPresentation),
+ null,
+ "late Scan A must not repopulate the reset search latch",
+ );
+ assert.ok(actionByLabel(tree, "Найти по Bluetooth"));
+ assert.equal(actionByLabel(tree, "Повторить поиск Bluetooth"), null);
+ assert.equal(actionByLabel(tree, "Применить"), null);
+
+ const scanBButton = actionByLabel(tree, "Найти по Bluetooth");
+ scanBButton.props.onClick();
+ await Promise.race([
+ scanBDispatched,
+ new Promise((_, reject) => setTimeout(
+ () => reject(new Error("Scan B did not dispatch")),
+ 100,
+ )),
+ ]);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ assert.equal(scanCalls, 2);
+ assert.equal(scanBCalls, 1);
+ } finally {
+ harness.dispose();
+ }
+});
+
+test("local connection navigation and selector copy use process nouns", () => {
+ const localConnection = workspaces.find((workspace) => workspace.id === "local-device");
+ assert.equal(localConnection?.label, "Подключение");
+ assert.equal(localConnection?.title, "Подключение");
+ assert.equal(model().displayName, "XGRIDS LixelKity K1");
+ assert.deepEqual(
+ connectionModeOptions.map(({ label }) => label),
+ [
+ "Общая сеть · Bridge",
+ "Локальная сеть · Quick Connect",
+ "Хотспот контроллера · Direct Connect",
+ ],
+ );
+ assertCanonicalConnectionCopy(
+ connectionModeOptions
+ .map(({ label, description }) => `${label} ${description ?? ""}`)
+ .join(" "),
+ );
+});
+
+test("cold adjacent connection panels do not repeat the model or device noun", () => {
+ const state = durableTopologyState();
+ state.semantic_topology_store = {
+ status: "empty",
+ configured_offline_evidence: false,
+ live_connection_authority: false,
+ reason_code: null,
+ record: null,
+ };
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: ["scan-ble", "provision-fresh-device"],
+ actions: {
+ "scan-ble": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "none",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ },
+ };
+ const provisioningMarkup = renderToStaticMarkup(createElement(
+ K1ProvisioningPipeline,
+ { controller: provisioningController(state), desiredMode: "bridge" },
+ ));
+ const acquisitionMarkup = renderToStaticMarkup(createElement(
+ K1AcquisitionPipeline,
+ {
+ controller: acquisitionController(state),
+ desiredConnectionMode: "bridge",
+ openSpatialScene() {},
+ activateAutomaticSpatialSource() {},
+ },
+ ));
+ const diagnosticsMarkup = renderToStaticMarkup(createElement(
+ K1Diagnostics,
+ {
+ controller: {
+ state,
+ backendStatus: "offline",
+ eventStatus: "disconnected",
+ latencyHistory: [],
+ },
+ sourceLabel: "Ожидание",
+ },
+ ));
+ assertCanonicalConnectionCopy(
+ `${provisioningMarkup}${acquisitionMarkup}${diagnosticsMarkup}`,
+ );
+});
+
+test("legacy live fields do not create device, stream or metrics authority", () => {
+ const state = runtimeState();
+ delete state.connection_supervisor;
+
+ const normalized = normalize(state);
+ assert.ok(normalized);
+ assert.equal(normalized.activeDevice, null);
+ assert.equal(normalized.deviceSession.connectivity, "unknown");
+ assert.equal(normalized.sourceMode, "idle");
+ assert.equal(normalized.phase, "starting");
+ assert.equal(normalized.spatialSource, null);
+ assert.equal(normalized.metrics.latencyMs, null);
+ assert.equal(normalized.metrics.frameRateHz, null);
+ assert.equal(normalized.metrics.aiFrameRateHz, null);
+ assert.equal(normalized.observationSources[0].availability, "unverified");
+ assert.equal(normalized.observationSources[0].binding.deviceId, null);
+});
+
+test("control authority exposes the session but not unconfirmed data", () => {
+ const state = runtimeState();
+ state.connection_supervisor = supervisor({ control: true, data: false });
+
+ const normalized = normalize(state);
+ assert.ok(normalized?.activeDevice);
+ assert.equal(normalized.activeDevice.endpointLabel, "192.168.68.52");
+ assert.equal(normalized.deviceSession.connectivity, "connected");
+ assert.equal(normalized.sourceMode, "idle");
+ assert.equal(normalized.phase, "starting");
+ assert.equal(normalized.spatialSource, null);
+ assert.equal(normalized.metrics.pointCount, null);
+ assert.equal(normalized.observationSources[0].availability, "available");
+ assert.equal(normalized.observationSources[0].previewUrl, null);
+});
+
+test("control plus data authority is the only live streaming presentation", () => {
+ const normalized = normalize(runtimeState());
+ assert.ok(normalized?.activeDevice);
+ assert.equal(normalized.sourceMode, "live");
+ assert.equal(normalized.phase, "streaming");
+ assert.equal(normalized.deviceSession.connectivity, "connected");
+ assert.equal(normalized.spatialSource.url, "rerun+http://127.0.0.1:9877/proxy");
+ assert.equal(normalized.metrics.publishedFrameCount, 15);
+ assert.equal(normalized.metrics.latencyMs, 12.5);
+ assert.equal(normalized.metrics.aiFrameRateHz, 5);
+ assert.equal(normalized.observationSources[0].availability, "streaming");
+});
+
+test("successful K1 scanning state stays a success message", () => {
+ const state = runtimeState();
+ state.message = "K1 подтвердил режим сканирования; приём и запись активны.";
+
+ const normalized = normalize(state);
+ assert.equal(normalized.message, state.message);
+ assert.notEqual(
+ normalized.message,
+ "Операция не выполнена. Технические подробности сохранены в журнале сервера.",
+ );
+});
+
+test("data loss degrades the controlled device and withdraws stale stream data", () => {
+ const state = runtimeState();
+ state.connection_supervisor = supervisor({
+ control: true,
+ data: false,
+ dataPlaneState: "lost",
+ });
+
+ const normalized = normalize(state);
+ assert.ok(normalized?.activeDevice);
+ assert.equal(normalized.deviceSession.connectivity, "degraded");
+ assert.equal(normalized.sourceMode, "idle");
+ assert.equal(normalized.phase, "starting");
+ assert.equal(normalized.spatialSource, null);
+ assert.equal(normalized.metrics.frameRateHz, null);
+ assert.equal(normalized.observationSources[0].availability, "degraded");
+ assert.equal(normalized.observationSources[0].previewUrl, null);
+});
+
+test("exact reconnecting lineage retains only the current spatial presentation lease", () => {
+ const healthy = normalize(runtimeState());
+ const recoveryState = activeRecoveryRuntimeState();
+ const recovering = normalize(recoveryState);
+ assert.ok(healthy?.spatialSource && recovering?.spatialSource);
+ assert.equal(recovering.sourceMode, "live");
+ assert.equal(recovering.phase, "starting");
+ assert.equal(recovering.spatialSource.id, healthy.spatialSource.id);
+ assert.equal(recovering.spatialSource.url, healthy.spatialSource.url);
+ assert.equal(recovering.activeDevice, null);
+ assert.equal(recovering.metrics.publishedFrameCount, null);
+ assert.equal(recovering.metrics.latencyMs, null);
+ assert.equal(recovering.observationSources[0].availability, "connecting");
+ assert.equal(recovering.observationSources[0].previewUrl, healthy.spatialSource.url);
+ assert.deepEqual(recovering.observationSources[0].presentationLease, {
+ kind: "active-stream-recovery",
+ runtimeId: "runtime-active-recovery-001",
+ acquisitionId: "acquisition-001",
+ acquisitionStateRevision: 4,
+ producerGeneration: 13,
+ recoveryGeneration: 5,
+ });
+
+ const noCurrentUrl = structuredClone(recoveryState);
+ delete noCurrentUrl.rerun_grpc_url;
+ assert.equal(normalize(noCurrentUrl).spatialSource, null);
+ assert.equal(normalize(noCurrentUrl).observationSources[0].previewUrl, null);
+});
+
+test("stale or terminal recovery lineage cannot retain a spatial source", () => {
+ const staleProducer = activeRecoveryRuntimeState();
+ staleProducer.producer_generation += 1;
+
+ const differentAcquisition = activeRecoveryRuntimeState({
+ acquisition_id: "acquisition-from-old-runtime",
+ });
+
+ for (const state of [
+ staleProducer,
+ differentAcquisition,
+ activeRecoveryRuntimeState({ state: "blocked" }),
+ activeRecoveryRuntimeState({ state: "standby" }),
+ activeRecoveryRuntimeState({ state: "fault" }),
+ ]) {
+ const normalized = normalize(state);
+ assert.equal(normalized.sourceMode, "idle");
+ assert.equal(normalized.spatialSource, null);
+ assert.equal(normalized.metrics.publishedFrameCount, null);
+ assert.equal(normalized.observationSources[0].previewUrl, null);
+ assert.equal(normalized.observationSources[0].presentationLease, null);
+ }
+});
+
+test("control loss turns a selected device back into an unverified snapshot", () => {
+ const state = runtimeState();
+ state.connection_supervisor = supervisor({ control: false, data: false });
+
+ const normalized = normalize(state);
+ assert.ok(normalized);
+ assert.equal(normalized.activeDevice, null);
+ assert.equal(normalized.deviceSession.connectivity, "connecting");
+ assert.equal(normalized.sourceMode, "idle");
+ assert.equal(normalized.spatialSource, null);
+ assert.equal(normalized.observationSources[0].availability, "unverified");
+ assert.equal(normalized.observationSources[0].binding.deviceId, null);
+});
+
+test("operator banner renders all safe host diagnostics and never the raw exception", () => {
+ const diagnostic = {
+ schema_version: "missioncore.host-failure-diagnostic/v1",
+ code: "host.keychain.interaction-required",
+ domain: "keychain",
+ impact: "control",
+ operator_action: "unlock-or-authorize-keychain",
+ automatic_retry: false,
+ redacted: true,
+ };
+ const presentation = hostFailureDiagnosticPresentation(diagnostic);
+ assert.ok(presentation);
+ assert.equal(
+ presentation.operatorActionLabel,
+ "Разблокируйте связку ключей macOS и подтвердите доступ Mission Core к профилю подключения.",
+ );
+
+ const privateException = "SecurityError: account=private-user password=never-render-this";
+ const attempt = {
+ schema_version: "missioncore.xgrids-k1-connection-attempt/v1",
+ attempt_id: "op-00000000-0000-4000-8000-000000000123",
+ connection_mode: "bridge",
+ status: "failed",
+ stage: "status-observing-failed",
+ public_error_code: "network-session-interrupted",
+ side_effect_status: "unknown",
+ safe_next_action: "scan-select-connect",
+ automatic_retry: false,
+ accepted_at: OBSERVED_AT,
+ completed_at: OBSERVED_AT,
+ timeline: [],
+ diagnostic_bundle: {
+ schema_version: "missioncore.xgrids-k1-connection-diagnostic/v1",
+ redacted: true,
+ generated_at_utc: OBSERVED_AT,
+ snapshot_runtime_id: "runtime-test",
+ attempt: {},
+ network_mutation_ledger: {},
+ connection_supervisor: {},
+ automatic_retry: false,
+ },
+ };
+ const markup = renderToStaticMarkup(createElement(K1OperatorError, {
+ message: privateException,
+ diagnostic,
+ attempt,
+ onRefresh() {},
+ onClear() {},
+ }));
+
+ assert.match(markup, /Причина/);
+ assert.match(markup, /Связка ключей требует явного подтверждения оператора/);
+ assert.match(markup, /Системный контур/);
+ assert.match(markup, /Связка ключей macOS/);
+ assert.match(markup, /Управляющая связь не установлена/);
+ assert.match(markup, /Разблокируйте связку ключей macOS/);
+ assert.match(markup, /Автоматического повтора не было/);
+ assert.match(markup, /op-00000000-0000-4000-8000-000000000123/);
+ assert.match(markup, /Ожидание ответа/);
+ assert.match(markup, /Результат команды не подтверждён/);
+ assert.match(markup, /новый поиск и выбрать результат/);
+ assert.match(markup, /Скопировать диагностику/);
+ assert.match(markup, //);
+ assert.doesNotMatch(markup, /]*\sopen/);
+ assert.match(markup, /Проверить состояние/);
+ assert.doesNotMatch(markup, /SecurityError|private-user|never-render-this/);
+ assert.doesNotMatch(markup, /host\.keychain\.interaction-required/);
+});
+
+test("operator banner presents every canonical connection next action without fallback drift", () => {
+ const labels = new Map([
+ ["wait-for-current-attempt", "Дождаться завершения текущей попытки"],
+ ["continue-with-control-verification", "Продолжить текущее подключение"],
+ ["verify-control-read-only", "Проверить управление без изменения сети"],
+ ["start-acquisition", "Готово к запуску приёма"],
+ ["stop-local-receiver", "Завершить только локальный приём"],
+ [
+ "retire-unavailable-physical-target",
+ "Исключить недоступный прежний K1 и выбрать другой",
+ ],
+ ["scan-select-connect", "Выполнить новый поиск и выбрать результат"],
+ ["manual-recovery-required", "Требуется ручное восстановление"],
+ ]);
+ for (const [safeNextAction, expectedLabel] of labels) {
+ const attempt = {
+ schema_version: "missioncore.xgrids-k1-connection-attempt/v1",
+ attempt_id: `attempt-${safeNextAction}`,
+ connection_mode: "bridge",
+ status: "failed",
+ stage: "device-info-failed",
+ public_error_code: "connection-not-ready",
+ side_effect_status: "none",
+ safe_next_action: safeNextAction,
+ automatic_retry: false,
+ accepted_at: OBSERVED_AT,
+ completed_at: OBSERVED_AT,
+ timeline: [],
+ };
+ const markup = renderToStaticMarkup(createElement(K1OperatorError, {
+ message: "Связь не подтверждена",
+ attempt,
+ onRefresh() {},
+ onClear() {},
+ }));
+ assert.match(markup, new RegExp(expectedLabel));
+ }
+});
+
+test("operator banner never renders an unstructured legacy connection error", () => {
+ const markup = renderToStaticMarkup(createElement(K1OperatorError, {
+ message:
+ "K1: исходное устройство не найдено; подключаемся к сохранённому устройству",
+ onRefresh() {},
+ onClear() {},
+ }));
+
+ assert.match(
+ markup,
+ /Подключение не завершено\. Автоматического повтора не было/,
+ );
+ assertCanonicalConnectionCopy(markup);
+});
+
+test("compact connection error can expose only its contextual recovery path", () => {
+ const markup = renderToStaticMarkup(createElement(K1OperatorError, {
+ message: "hidden transport error",
+ compact: true,
+ showDefaultActions: false,
+ recoveryActions: createElement("button", null, "Подключить новый K1"),
+ onRefresh() {},
+ onClear() {},
+ }));
+
+ assert.match(markup, /Подключение не завершено/);
+ assert.match(markup, />Подключить новый K1<\/button>/);
+ assert.doesNotMatch(markup, /Проверить состояние|>Закрыть<\/button>/);
+});
+
+test("connection attempt network phase copy distinguishes absent, applied and unknown outcomes", () => {
+ assert.equal(
+ attemptNetworkPhaseLabel("network_not_applied"),
+ "Настройки сети не применены",
+ );
+ assert.equal(
+ attemptNetworkPhaseLabel("network_applied"),
+ "Настройки сети применены",
+ );
+ assert.equal(
+ attemptNetworkPhaseLabel("network_outcome_unknown"),
+ "Результат применения настроек сети не подтверждён",
+ );
+});
+
+test("runtime errors borrow diagnostics only from their exact Connect attempt", () => {
+ const state = durableTopologyState();
+ state.snapshot_runtime_id = "runtime-correlated-error";
+ state.connection_attempt = {
+ schema_version: "missioncore.xgrids-k1-connection-attempt/v1",
+ attempt_id: "op-exact-connect",
+ connection_mode: "bridge",
+ status: "failed",
+ phase: "network_outcome_unknown",
+ control_state: "unknown",
+ stage: "ble-write-dispatched",
+ public_error_code: null,
+ side_effect_status: "unknown",
+ safe_next_action: "verify-control-read-only",
+ automatic_retry: false,
+ accepted_at: OBSERVED_AT,
+ completed_at: OBSERVED_AT,
+ timeline: [],
+ };
+ const exact = {
+ action: "connect",
+ runtimeId: state.snapshot_runtime_id,
+ leaseGeneration: 0,
+ connectionAttemptId: state.connection_attempt.attempt_id,
+ };
+
+ assert.equal(connectionAttemptForRuntimeError(exact, state), state.connection_attempt);
+ for (const mismatch of [
+ { ...exact, action: "scan" },
+ { ...exact, runtimeId: "runtime-replaced" },
+ { ...exact, connectionAttemptId: "op-another-connect" },
+ { ...exact, connectionAttemptId: null },
+ ]) {
+ assert.equal(connectionAttemptForRuntimeError(mismatch, state), null);
+ }
+ assert.equal(
+ connectionAttemptForRuntimeError(exact, {
+ ...state,
+ connection_attempt: { ...state.connection_attempt, status: "running" },
+ }),
+ null,
+ );
+});
+
+test("connection recovery observation follows recommended exact policy then safe priority", () => {
+ const state = durableTopologyState();
+ state.snapshot_runtime_id = "runtime-recovery-priority";
+ state.devices = [{
+ device_id: "fresh-k1",
+ name: "Fresh K1",
+ connectable: true,
+ likely_k1: true,
+ }];
+ const decision = (target_source, required_transport_ref) => ({
+ allowed: true,
+ reason_codes: [],
+ target_source,
+ required_transport_ref,
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: target_source === "fresh-scan",
+ automatic_retry: false,
+ });
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ recommended_action: "observe-configured-device-network",
+ allowed_actions: [
+ "observe-current-device-network",
+ "observe-configured-device-network",
+ "observe-fresh-device-network",
+ ],
+ actions: {
+ "observe-current-device-network": decision(
+ "retained-current-process",
+ "current-k1",
+ ),
+ "observe-configured-device-network": decision(
+ "durable-configured-state",
+ "configured-k1",
+ ),
+ "observe-fresh-device-network": decision("fresh-scan", "fresh-k1"),
+ },
+ };
+
+ const recommended = recommendedConnectionRecoveryObservationTarget(state);
+ assert.equal(recommended?.action, "observe-configured-device-network");
+ assert.equal(recommended?.deviceId, "configured-k1");
+
+ state.connection_policy.recommended_action = "scan-ble";
+ const fallback = recommendedConnectionRecoveryObservationTarget(state);
+ assert.equal(fallback?.action, "observe-current-device-network");
+ assert.equal(fallback?.deviceId, "current-k1");
+ assert.equal(connectionRecoveryObservationTargetMatches(fallback, fallback), true);
+ assert.equal(
+ connectionRecoveryObservationTargetMatches(fallback, {
+ ...fallback,
+ deviceId: "foreign-k1",
+ }),
+ false,
+ );
+});
+
+test("cold Quick recovery dispatches exact Verify without borrowing the mode draft", async () => {
+ const target = {
+ action: "observe-configured-device-network",
+ deviceId: "configured-quick-k1",
+ connectionMode: "quick-connect",
+ source: "durable-configured-state",
+ serverBound: true,
+ expectedDiscoveryGeneration: null,
+ };
+ let calls = 0;
+ const dispatched = await dispatchConnectionRecoveryObservationForCurrentRuntime(
+ "runtime-cold-quick",
+ target,
+ (runtimeId) => runtimeId === "runtime-cold-quick",
+ () => ({ ...target }),
+ async (exactTarget) => {
+ calls += 1;
+ assert.equal(exactTarget.connectionMode, "quick-connect");
+ return "verified";
+ },
+ );
+ assert.deepEqual(dispatched, { dispatched: true, result: "verified" });
+ assert.equal(calls, 1);
+
+ for (const [runtimeCurrent, currentTarget] of [
+ [false, target],
+ [true, { ...target, deviceId: "replacement-k1" }],
+ ]) {
+ const blocked = await dispatchConnectionRecoveryObservationForCurrentRuntime(
+ "runtime-cold-quick",
+ target,
+ () => runtimeCurrent,
+ () => currentTarget,
+ async () => {
+ calls += 1;
+ return "must-not-dispatch";
+ },
+ );
+ assert.deepEqual(blocked, { dispatched: false, result: null });
+ }
+ assert.equal(calls, 1);
+});
+
+test("successful recovery Refresh clears only the compact connection failure", async () => {
+ let clearCalls = 0;
+ const refreshed = await clearConnectionFailureAfterSuccessfulRefresh(
+ async () => ({ snapshot_runtime_id: "runtime-refreshed" }),
+ () => {
+ clearCalls += 1;
+ },
+ );
+ assert.equal(refreshed, true);
+ assert.equal(clearCalls, 1);
+
+ const unavailable = await clearConnectionFailureAfterSuccessfulRefresh(
+ async () => null,
+ () => {
+ clearCalls += 1;
+ },
+ );
+ assert.equal(unavailable, false);
+ assert.equal(clearCalls, 1);
+});
+
+test("successful recovery Scan survives configured-to-fresh promotion for the same K1", () => {
+ const attempt = terminalConnectionRecoveryState().connection_attempt;
+ const configuredTarget = {
+ action: "observe-configured-device-network",
+ deviceId: "F89438FA-55ED-85AD-EED7-734AC84746D8",
+ connectionMode: "bridge",
+ source: "durable-configured-state",
+ serverBound: true,
+ expectedDiscoveryGeneration: null,
+ };
+ const freshTarget = {
+ ...configuredTarget,
+ action: "observe-fresh-device-network",
+ deviceId: "f89438fa-55ed-85ad-eed7-734ac84746d8",
+ source: "fresh-scan",
+ expectedDiscoveryGeneration: 28,
+ };
+ const beforeScan = connectionRecoveryEscapeKey({
+ snapshotRuntimeId: "runtime-recovery-scan",
+ attempt,
+ target: configuredTarget,
+ });
+ const afterSuccessfulScan = connectionRecoveryEscapeKey({
+ snapshotRuntimeId: "runtime-recovery-scan",
+ attempt,
+ target: freshTarget,
+ });
+
+ assert.equal(afterSuccessfulScan, beforeScan);
+ const successfulEscape = connectionRecoveryEscapeAfterScan(true, beforeScan);
+ // Success stores the pre-Scan key, so the promoted fresh results own Step 01.
+ assert.equal(
+ connectionRecoveryIsRequired(afterSuccessfulScan, successfulEscape),
+ false,
+ );
+ // A failed Scan stores no escape and leaves the recovery card authoritative.
+ const failedEscape = connectionRecoveryEscapeAfterScan(false, beforeScan);
+ assert.equal(failedEscape, null);
+ assert.equal(connectionRecoveryIsRequired(afterSuccessfulScan, failedEscape), true);
+
+ for (const changed of [
+ {
+ snapshotRuntimeId: "runtime-replaced",
+ attempt,
+ target: freshTarget,
+ },
+ {
+ snapshotRuntimeId: "runtime-recovery-scan",
+ attempt: { ...attempt, attempt_id: "op-new-attempt" },
+ target: freshTarget,
+ },
+ {
+ snapshotRuntimeId: "runtime-recovery-scan",
+ attempt,
+ target: { ...freshTarget, deviceId: "replacement-k1" },
+ },
+ {
+ snapshotRuntimeId: "runtime-recovery-scan",
+ attempt,
+ target: { ...freshTarget, connectionMode: "quick-connect" },
+ },
+ ]) {
+ assert.equal(
+ connectionRecoveryIsRequired(
+ connectionRecoveryEscapeKey(changed),
+ beforeScan,
+ ),
+ true,
+ );
+ }
+});
+
+test("metric cards render values only for replay or supervisor-authoritative live data", () => {
+ const legacy = runtimeState();
+ delete legacy.connection_supervisor;
+ const legacyMarkup = renderToStaticMarkup(createElement(K1Metrics, {
+ controller: { state: legacy },
+ }));
+ assert.doesNotMatch(legacyMarkup, /12,5/);
+ assert.match(legacyMarkup, /ДО ПУБЛИКАЦИИ/);
+
+ const authoritativeMarkup = renderToStaticMarkup(createElement(K1Metrics, {
+ controller: { state: runtimeState() },
+ }));
+ assert.match(authoritativeMarkup, /12,5/);
+ assert.match(authoritativeMarkup, /42[\s ]000/);
+ assert.match(authoritativeMarkup, /Данные потока при этом сохраняются/);
+ assert.doesNotMatch(authoritativeMarkup, /Исходные данные/);
+ assertCanonicalConnectionCopy(authoritativeMarkup);
+});
+
+test("cold disconnected connection SSR hides every operational panel", () => {
+ const state = durableTopologyState();
+ const markup = renderConnectionPipelines(state);
+ const unresolvedHistory = reopenedPhysicalState();
+ const unresolvedHistoryMarkup = renderConnectionPipelines(unresolvedHistory);
+
+ assert.equal(shouldRenderK1OperationalPanels(state), false);
+ assert.equal(
+ shouldRenderK1OperationalPanels(unresolvedHistory),
+ false,
+ "unresolved durable physical history is handled inside the connection pipeline",
+ );
+ assert.match(markup, /class="[^"]*\bconnection-panel\b/);
+ assert.match(markup, /ПОДКЛЮЧЕНИЕ · ШАГИ 01–02/);
+ assert.doesNotMatch(markup, /class="metrics-grid/);
+ assert.doesNotMatch(markup, /class="[^"]*\bsession-panel\b/);
+ assert.doesNotMatch(markup, /class="diagnostics-grid/);
+ assert.doesNotMatch(markup, /class="device-workspace__side/);
+ assert.doesNotMatch(unresolvedHistoryMarkup, /class="metrics-grid/);
+ assert.doesNotMatch(unresolvedHistoryMarkup, /class="[^"]*\bsession-panel\b/);
+ assert.doesNotMatch(unresolvedHistoryMarkup, /class="diagnostics-grid/);
+ assert.doesNotMatch(unresolvedHistoryMarkup, /class="device-workspace__side/);
+});
+
+test("connected, active, replay and recovery SSR retain operational panels", () => {
+ const connected = runtimeState();
+ connected.phase = "connected";
+ connected.source_mode = "idle";
+ connected.acquisition = null;
+
+ const active = runtimeState();
+ delete active.connection_supervisor;
+
+ const replay = runtimeState();
+ replay.phase = "replay";
+ replay.source_mode = "replay";
+ replay.acquisition = null;
+ delete replay.connection_supervisor;
+
+ const recovery = durableTopologyState();
+ recovery.acquisition = {
+ acquisition_id: "acquisition-recovery",
+ device_id: "device-recovery",
+ device_session_id: "session-recovery",
+ compatibility_profile_id: PROFILE_ID,
+ control_mode: "plugin-commanded",
+ requested_streams: ["spatial.point-cloud.live"],
+ target_host: "127.0.0.1",
+ duration_seconds: 0,
+ evidence_policy: "required",
+ state: "failed",
+ state_revision: 8,
+ cleanup_pending: true,
+ };
+
+ for (const [name, state] of [
+ ["connected", connected],
+ ["active", active],
+ ["replay", replay],
+ ["recovery", recovery],
+ ]) {
+ assert.equal(
+ shouldRenderK1OperationalPanels(state),
+ true,
+ `${name} must keep operational controls visible`,
+ );
+ const markup = renderConnectionPipelines(state);
+ assert.match(markup, /class="metrics-grid/, name);
+ assert.match(markup, /class="[^"]*\bsession-panel\b/, name);
+ assert.match(markup, /class="diagnostics-grid/, name);
+ assert.match(markup, /class="device-workspace__side/, name);
+ }
+});
+
+test("local connection shell scopes process-only configuring and connected labels", () => {
+ assert.equal(shellPresentation.localConnectionPhaseLabel("configuring"), "Подключение");
+ assert.equal(
+ shellPresentation.localConnectionPhaseLabel("connected"),
+ "Подключение установлено",
+ );
+ assert.equal(
+ shellPresentation.phaseLabel("configuring"),
+ "Настройка устройства",
+ "other workspaces keep the global runtime label",
+ );
+ assert.equal(
+ shellPresentation.phaseLabel("connected"),
+ "Устройство подключено",
+ "other workspaces keep the global runtime label",
+ );
+});
+
+test("replay and local-only stop branches use bounded process copy", () => {
+ const replayState = runtimeState();
+ replayState.phase = "replay";
+ replayState.source_mode = "replay";
+ const replayMarkup = renderToStaticMarkup(createElement(K1AcquisitionPipeline, {
+ controller: acquisitionController(replayState),
+ desiredConnectionMode: "bridge",
+ openSpatialScene() {},
+ activateAutomaticSpatialSource() {},
+ }));
+ assert.match(replayMarkup, /Локальный файл записи/);
+ assert.doesNotMatch(replayMarkup, /Локальный файл исходных данных/);
+ assertCanonicalConnectionCopy(replayMarkup);
+
+ const stopState = runtimeState();
+ stopState.compatibility.vendor_writes_enabled = false;
+ stopState.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: ["stop-local-receiver"],
+ actions: {
+ "stop-local-receiver": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "local-runtime",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ },
+ };
+ const stopMarkup = renderToStaticMarkup(createElement(K1AcquisitionPipeline, {
+ controller: acquisitionController(stopState),
+ desiredConnectionMode: "bridge",
+ openSpatialScene() {},
+ activateAutomaticSpatialSource() {},
+ }));
+ assert.match(stopMarkup, /Состояние сканирования остаётся неизвестным/);
+ assert.doesNotMatch(stopMarkup, /Физическое состояние сканера/);
+ assertCanonicalConnectionCopy(stopMarkup);
+});
+
+test("physical STOP is fail-closed and both acquisition surfaces fall back to local cleanup", () => {
+ const state = runtimeState();
+ state.snapshot_runtime_id = "runtime-stop-001";
+ state.snapshot_revision = 40;
+ state.application_control_session = {
+ session_generation: 5,
+ state_revision: 8,
+ state: "scanning",
+ can_stop: true,
+ control_socket_open: true,
+ };
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: ["stop-acquisition", "stop-local-receiver"],
+ actions: {
+ "stop-acquisition": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "connection-supervisor",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ "stop-local-receiver": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "local-runtime",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ },
+ };
+
+ assert.equal(canIssueCanonicalStop(state, false), true);
+ assert.equal(canIssueCanonicalStop(state, true), false);
+ const missingRuntimeIdentity = structuredClone(state);
+ delete missingRuntimeIdentity.snapshot_runtime_id;
+ assert.equal(physicalStopIntentCheckpoint(missingRuntimeIdentity), null);
+ assert.equal(canIssueCanonicalStop(missingRuntimeIdentity, false), false);
+ const zeroGeneration = structuredClone(state);
+ zeroGeneration.application_control_session.session_generation = 0;
+ assert.equal(physicalStopIntentCheckpoint(zeroGeneration), null);
+ const zeroRevision = structuredClone(state);
+ zeroRevision.application_control_session.state_revision = 0;
+ assert.equal(physicalStopIntentCheckpoint(zeroRevision), null);
+ const spentCheckpoint = physicalStopIntentCheckpoint(state);
+ assert.ok(spentCheckpoint);
+ const sameCasPoll = structuredClone(state);
+ sameCasPoll.snapshot_revision += 1;
+ assert.equal(
+ authoritativeStateSupersedesPhysicalStopIntent(spentCheckpoint, sameCasPoll),
+ false,
+ "a newer polling snapshot with the same control CAS must stay spent",
+ );
+ const nextControlTransition = structuredClone(sameCasPoll);
+ nextControlTransition.application_control_session.state_revision += 1;
+ assert.equal(
+ authoritativeStateSupersedesPhysicalStopIntent(
+ spentCheckpoint,
+ nextControlTransition,
+ ),
+ true,
+ "a newer atomic snapshot plus a newer exact control CAS is fresh authority",
+ );
+ const sameTargetReplacement = structuredClone(state);
+ sameTargetReplacement.snapshot_runtime_id = "runtime-stop-002";
+ sameTargetReplacement.snapshot_revision = 1;
+ assert.equal(
+ authoritativeStateSupersedesPhysicalStopIntent(
+ spentCheckpoint,
+ sameTargetReplacement,
+ ),
+ true,
+ "a newer accepted runtime with an exact STOP gate is fresh authority",
+ );
+ const nonAuthoritativeReplacement = structuredClone(sameTargetReplacement);
+ nonAuthoritativeReplacement.application_control_session.can_stop = false;
+ assert.equal(
+ authoritativeStateSupersedesPhysicalStopIntent(
+ spentCheckpoint,
+ nonAuthoritativeReplacement,
+ ),
+ false,
+ "runtime replacement without an exact current STOP gate stays spent",
+ );
+ const distinctAcquisition = structuredClone(sameCasPoll);
+ distinctAcquisition.acquisition.acquisition_id = "acquisition-002";
+ assert.equal(
+ authoritativeStateSupersedesPhysicalStopIntent(
+ spentCheckpoint,
+ distinctAcquisition,
+ ),
+ true,
+ "a newer accepted snapshot may admit a distinct acquisition target",
+ );
+
+ state.application_control_session.state = "failed";
+ assert.equal(canIssueCanonicalStop(state, false), false);
+ state.application_control_session.state = "scanning";
+ state.application_control_session.can_stop = false;
+ assert.equal(canIssueCanonicalStop(state, false), false);
+ state.application_control_session.can_stop = true;
+ state.connection_policy.actions["stop-acquisition"].allowed = false;
+ state.connection_policy.actions["stop-acquisition"].reason_codes = [
+ "physical-control-authority-unavailable",
+ ];
+ state.connection_policy.allowed_actions = ["stop-local-receiver"];
+ assert.equal(canIssueCanonicalStop(state, false), false);
+
+ state.connection_policy.actions["stop-acquisition"].allowed = true;
+ state.connection_policy.actions["stop-acquisition"].reason_codes = [];
+ state.connection_policy.allowed_actions = [
+ "stop-acquisition",
+ "stop-local-receiver",
+ ];
+ const physicalMarkup = renderToStaticMarkup(createElement(K1AcquisitionPipeline, {
+ controller: acquisitionController(state),
+ desiredConnectionMode: "bridge",
+ openSpatialScene() {},
+ activateAutomaticSpatialSource() {},
+ }));
+ const physicalButtons = buttonMarkupWithText(
+ physicalMarkup,
+ "Остановить устройство и запись",
+ );
+ assert.equal(physicalButtons.length, 1);
+ assert.doesNotMatch(physicalButtons[0], /\bdisabled(?:=|\s|>)/);
+ assert.doesNotMatch(physicalMarkup, /Завершить локальный приём/);
+
+ const unrelatedErrorMarkup = renderToStaticMarkup(createElement(
+ K1AcquisitionPipeline,
+ {
+ controller: {
+ ...acquisitionController(state),
+ error: "unrelated local viewer error",
+ },
+ desiredConnectionMode: "bridge",
+ openSpatialScene() {},
+ activateAutomaticSpatialSource() {},
+ },
+ ));
+ assert.equal(
+ buttonMarkupWithText(
+ unrelatedErrorMarkup,
+ "Остановить устройство и запись",
+ ).length,
+ 1,
+ "a presentation error that did not spend physical STOP must not hide it",
+ );
+
+ const dismissedFailureMarkup = renderToStaticMarkup(createElement(
+ K1AcquisitionPipeline,
+ {
+ controller: {
+ ...acquisitionController(state),
+ // Closing a presentation banner clears `error`; the independent spent
+ // checkpoint must continue to suppress the same physical mutation.
+ error: null,
+ physicalStopIntentSpent: true,
+ },
+ desiredConnectionMode: "bridge",
+ openSpatialScene() {},
+ activateAutomaticSpatialSource() {},
+ },
+ ));
+ assert.doesNotMatch(dismissedFailureMarkup, /Остановить устройство и запись/);
+ assert.match(dismissedFailureMarkup, /Повторная команда устройству не отправляется/);
+ const localButtons = buttonMarkupWithText(
+ dismissedFailureMarkup,
+ "Завершить локальный приём",
+ );
+ assert.equal(localButtons.length, 1);
+ assert.doesNotMatch(localButtons[0], /\bdisabled(?:=|\s|>)/);
+ assertCanonicalConnectionCopy(dismissedFailureMarkup);
+
+ const classifiedStopState = structuredClone(state);
+ classifiedStopState.acquisition.state = "awaiting_external_stop";
+ classifiedStopState.connection_policy.actions["stop-acquisition"].allowed = false;
+ classifiedStopState.connection_policy.allowed_actions = ["stop-local-receiver"];
+ const classifiedStopMarkup = renderToStaticMarkup(createElement(
+ K1SpatialControlsView,
+ {
+ controller: {
+ ...acquisitionController(classifiedStopState),
+ error: "STOP не был отправлен; требуется read-only восстановление",
+ physicalStopIntentSpent: true,
+ },
+ },
+ ));
+ assert.equal(
+ buttonMarkupWithText(classifiedStopMarkup, "Завершить локальный приём").length,
+ 0,
+ "a classified STOP must not leave a permanently disabled local action in the scene",
+ );
+
+ const localCleanupPendingMarkup = renderToStaticMarkup(createElement(
+ K1AcquisitionPipeline,
+ {
+ controller: {
+ ...acquisitionController(state),
+ pendingAction: "stop",
+ error: null,
+ physicalStopIntentSpent: true,
+ },
+ desiredConnectionMode: "bridge",
+ openSpatialScene() {},
+ activateAutomaticSpatialSource() {},
+ },
+ ));
+ assert.match(localCleanupPendingMarkup, /Завершаем локальный приём…/);
+ assert.doesNotMatch(localCleanupPendingMarkup, /Останавливаем устройство…/);
+
+ const physicalStopPendingMarkup = renderToStaticMarkup(createElement(
+ K1AcquisitionPipeline,
+ {
+ controller: {
+ ...acquisitionController(state),
+ pendingAction: "stop",
+ physicalStopIntentSpent: true,
+ physicalStopInFlight: true,
+ },
+ desiredConnectionMode: "bridge",
+ openSpatialScene() {},
+ activateAutomaticSpatialSource() {},
+ },
+ ));
+ assert.match(physicalStopPendingMarkup, /Останавливаем устройство…/);
+ assert.doesNotMatch(physicalStopPendingMarkup, /Завершаем локальный приём…/);
+
+ const terminalDeniedState = structuredClone(state);
+ terminalDeniedState.phase = "error";
+ terminalDeniedState.source_mode = "idle";
+ terminalDeniedState.acquisition.state = "failed";
+ terminalDeniedState.acquisition.cleanup_pending = true;
+ terminalDeniedState.connection_policy.actions["stop-acquisition"].allowed = false;
+ terminalDeniedState.connection_policy.actions["stop-acquisition"].reason_codes = [
+ "physical-control-authority-unavailable",
+ ];
+ terminalDeniedState.connection_policy.allowed_actions = ["stop-local-receiver"];
+ const terminalDeniedMarkup = renderToStaticMarkup(createElement(
+ K1AcquisitionPipeline,
+ {
+ controller: acquisitionController(terminalDeniedState),
+ desiredConnectionMode: "bridge",
+ openSpatialScene() {},
+ activateAutomaticSpatialSource() {},
+ },
+ ));
+ assert.match(terminalDeniedMarkup, /ВОССТАНОВЛЕНИЕ · ЛОКАЛЬНЫЙ КОНТУР/);
+ assert.match(terminalDeniedMarkup, /Доступно локальное завершение приёма/);
+ assert.doesNotMatch(terminalDeniedMarkup, /Локальная запись завершена/);
+ assert.doesNotMatch(
+ terminalDeniedMarkup,
+ /явный STOP|Остановить сканирование|Сканирование продолжается|Требуется остановка/,
+ );
+
+ const terminalSpentState = structuredClone(terminalDeniedState);
+ terminalSpentState.connection_policy.actions["stop-acquisition"].allowed = true;
+ terminalSpentState.connection_policy.actions["stop-acquisition"].reason_codes = [];
+ terminalSpentState.connection_policy.allowed_actions = [
+ "stop-acquisition",
+ "stop-local-receiver",
+ ];
+ const terminalReadyMarkup = renderToStaticMarkup(createElement(
+ K1AcquisitionPipeline,
+ {
+ controller: acquisitionController(terminalSpentState),
+ desiredConnectionMode: "bridge",
+ openSpatialScene() {},
+ activateAutomaticSpatialSource() {},
+ },
+ ));
+ assert.match(terminalReadyMarkup, /Локальный приём ещё требует завершения/);
+ assert.doesNotMatch(terminalReadyMarkup, /Локальная запись (?:уже )?завершена/);
+ const terminalSpentMarkup = renderToStaticMarkup(createElement(
+ K1AcquisitionPipeline,
+ {
+ controller: {
+ ...acquisitionController(terminalSpentState),
+ error: null,
+ physicalStopIntentSpent: true,
+ },
+ desiredConnectionMode: "bridge",
+ openSpatialScene() {},
+ activateAutomaticSpatialSource() {},
+ },
+ ));
+ assert.match(terminalSpentMarkup, /ВОССТАНОВЛЕНИЕ · ЛОКАЛЬНЫЙ КОНТУР/);
+ assert.match(terminalSpentMarkup, /Повторная команда K1 не отправляется/);
+ assert.doesNotMatch(
+ terminalSpentMarkup,
+ /явный STOP|Остановить сканирование|Сканирование продолжается|Требуется остановка/,
+ );
+
+ const terminalInFlightMarkup = renderToStaticMarkup(createElement(
+ K1AcquisitionPipeline,
+ {
+ controller: {
+ ...acquisitionController(terminalSpentState),
+ pendingAction: "stop",
+ physicalStopIntentSpent: true,
+ physicalStopInFlight: true,
+ },
+ desiredConnectionMode: "bridge",
+ openSpatialScene() {},
+ activateAutomaticSpatialSource() {},
+ },
+ ));
+ assert.match(terminalInFlightMarkup, /ВОССТАНОВЛЕНИЕ · КОМАНДА ОТПРАВЛЕНА/);
+ assert.match(terminalInFlightMarkup, /Команда остановки устройства уже отправлена/);
+ assert.match(terminalInFlightMarkup, /Останавливаем устройство…/);
+ assert.doesNotMatch(
+ terminalInFlightMarkup,
+ /требуется явный STOP|Нажмите «Остановить сканирование»/,
+ );
+
+ const noStopActionState = structuredClone(state);
+ noStopActionState.connection_policy.actions["stop-acquisition"].allowed = false;
+ noStopActionState.connection_policy.actions["stop-acquisition"].reason_codes = [
+ "physical-control-authority-unavailable",
+ ];
+ noStopActionState.connection_policy.actions["stop-local-receiver"].allowed = false;
+ noStopActionState.connection_policy.actions["stop-local-receiver"].reason_codes = [
+ "local-acquisition-receiver-not-active",
+ ];
+ noStopActionState.connection_policy.allowed_actions = [];
+ noStopActionState.connection_policy.recommended_action = "wait-for-operation";
+ noStopActionState.phase = "error";
+ noStopActionState.source_mode = "idle";
+ noStopActionState.acquisition.state = "failed";
+ noStopActionState.acquisition.cleanup_pending = true;
+ const noStopActionMarkup = renderToStaticMarkup(createElement(
+ K1AcquisitionPipeline,
+ {
+ controller: acquisitionController(noStopActionState),
+ desiredConnectionMode: "bridge",
+ openSpatialScene() {},
+ activateAutomaticSpatialSource() {},
+ },
+ ));
+ assert.equal(
+ buttonMarkupWithText(noStopActionMarkup, "Завершить локальный приём").length,
+ 0,
+ );
+ assert.equal(
+ buttonMarkupWithText(noStopActionMarkup, "Остановить устройство и запись").length,
+ 0,
+ );
+ assert.match(
+ noStopActionMarkup,
+ /ВОССТАНОВЛЕНИЕ · ТОЛЬКО ЧТЕНИЕ/,
+ );
+ assert.match(noStopActionMarkup, /Доступно только read-only восстановление/);
+ assert.doesNotMatch(
+ noStopActionMarkup,
+ /Доступно локальное завершение|Завершите локальный приём|явный STOP|Остановить сканирование/,
+ );
+});
+
+test("recovered physical SCANNING overrides replay with one explicit K1 STOP", () => {
+ const state = runtimeState();
+ state.snapshot_runtime_id = "runtime-recovered-stop";
+ state.snapshot_revision = 12;
+ state.phase = "replay";
+ state.source_mode = "replay";
+ state.acquisition = {
+ ...state.acquisition,
+ state: "failed",
+ state_revision: 9,
+ cleanup_pending: false,
+ };
+ state.application_control_session = {
+ session_generation: 4,
+ state_revision: 11,
+ state: "scanning",
+ can_stop: true,
+ control_socket_open: true,
+ physical_command: {
+ requires_reconciliation: false,
+ resolved_active_recovery_required: true,
+ observed_session_state: "scanning",
+ },
+ };
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: ["stop-acquisition"],
+ actions: {
+ "stop-acquisition": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "connection-supervisor",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ },
+ };
+
+ const markup = renderToStaticMarkup(createElement(K1AcquisitionPipeline, {
+ controller: acquisitionController(state),
+ desiredConnectionMode: "bridge",
+ openSpatialScene() {},
+ activateAutomaticSpatialSource() {},
+ }));
+
+ assert.match(markup, /ВОССТАНОВЛЕНИЕ · ОСТАНОВКА/);
+ assert.match(markup, /Сканирование продолжается/);
+ assert.match(markup, /Требуется остановка/);
+ assert.match(markup, /Сканирование подтверждено; требуется явный STOP/);
+ assert.doesNotMatch(markup, /Назовите проект и запустите приём/);
+ assert.doesNotMatch(markup, /Путь к записи|Запустить повтор записи/);
+ assert.doesNotMatch(markup, /Подготовить проект|Запустить сканирование/);
+
+ const stopButtons = buttonMarkupWithText(markup, "Остановить сканирование");
+ assert.equal(stopButtons.length, 1);
+ assert.doesNotMatch(stopButtons[0], /\bdisabled(?:=|\s|>)/);
+ assertCanonicalConnectionCopy(markup);
+});
+
+
+
+
+
+
+test("authoritative Bridge renders all completed steps with canonical process copy", () => {
+ const state = runtimeState();
+ state.phase = "connected";
+ state.source_mode = "idle";
+ state.acquisition = null;
+ state.devices = [];
+ state.message = "Готово. Включите устройство и начните с поиска по Bluetooth.";
+ state.connection_supervisor = supervisor({ control: true, data: false });
+ state.connection_lifecycle = connectionLifecycle({ control: true });
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: ["start-acquisition"],
+ actions: {
+ "scan-ble": {
+ allowed: false,
+ reason_codes: ["acquisition-active"],
+ target_source: "none",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ },
+ recommended_action: "start-acquisition",
+ };
+
+ const markup = renderToStaticMarkup(createElement(K1ProvisioningPipeline, {
+ controller: provisioningController(state),
+ desiredMode: "bridge",
+ }));
+
+ assert.doesNotMatch(markup, /Питание|aria-checked=/);
+ assert.match(markup, /01<\/span>/);
+ assert.match(markup, /02<\/span>/);
+ assert.match(markup, /Подключение<\/h3>/);
+ assert.match(markup, /Сеть<\/h3>/);
+ assert.match(markup, /Подключение установлено/);
+ assert.match(markup, /ble-k1-001<\/strong>/);
+ assert.match(markup, /Общая сеть · Bridge<\/strong>/);
+ assert.match(markup, /192\.168\.68\.52<\/small>/);
+ assert.equal(buttonMarkupWithText(markup, "Выбрать другое").length, 0);
+ assert.equal(buttonMarkupWithText(markup, "Изменить сеть").length, 0);
+ assert.doesNotMatch(markup, /class="nodedc-activity-indicator"/);
+ assert.doesNotMatch(
+ markup,
+ /Найти устройство Bluetooth|Повторить поиск Bluetooth|Устройства пока не найдены/,
+ );
+ assertCanonicalConnectionCopy(markup);
+});
+
+test("connected Bridge action slots survive transient lifecycle contention A to B to A", () => {
+ const actionDecision = (reasonCodes) => ({
+ allowed: reasonCodes.length === 0,
+ reason_codes: reasonCodes,
+ target_source: "local-prestart-handoff",
+ required_transport_ref: "ble-k1-001",
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: false,
+ device_write_performed: false,
+ automatic_retry: false,
+ });
+ const connectedState = (snapshotRevision, reasonCodes = []) => {
+ const state = runtimeState();
+ state.snapshot_runtime_id = "runtime-connected-actions";
+ state.snapshot_runtime_started_at_utc = "2026-08-11T06:00:00Z";
+ state.snapshot_revision = snapshotRevision;
+ state.phase = "connected";
+ state.source_mode = "idle";
+ state.snapshot_runtime_id = "runtime-ready-pending";
+ state.acquisition = null;
+ state.devices = [];
+ state.connection_supervisor = supervisor({ control: true, data: false });
+ state.connection_lifecycle = connectionLifecycle({ control: true });
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: reasonCodes.length === 0
+ ? ["prepare-select-device", "prepare-change-network"]
+ : [],
+ actions: {
+ "prepare-select-device": actionDecision(reasonCodes),
+ "prepare-change-network": actionDecision(reasonCodes),
+ },
+ };
+ return state;
+ };
+ const frames = [
+ connectedState(40),
+ connectedState(41, [
+ "connection-reconfiguration-lifecycle-busy",
+ "k1-lifecycle-process-lease-network-owned",
+ ]),
+ connectedState(42),
+ ];
+
+ for (const reason of [
+ "connection-reconfiguration-lifecycle-busy",
+ "k1-lifecycle-process-lease-network-owned",
+ ]) {
+ const singlyContended = connectedState(39, [reason]);
+ assert.equal(
+ connectedReconfigurationActionApplicable(
+ singlyContended,
+ "prepare-select-device",
+ ),
+ true,
+ );
+ assert.equal(
+ connectedReconfigurationActionApplicable(
+ singlyContended,
+ "prepare-change-network",
+ ),
+ true,
+ );
+ }
+
+ for (const [index, state] of frames.entries()) {
+ assert.equal(
+ connectedReconfigurationActionApplicable(state, "prepare-select-device"),
+ true,
+ );
+ assert.equal(
+ connectedReconfigurationActionApplicable(state, "prepare-change-network"),
+ true,
+ );
+ const markup = renderToStaticMarkup(createElement(K1ProvisioningPipeline, {
+ controller: provisioningController(state),
+ desiredMode: "bridge",
+ }));
+ assert.match(markup, /Подключение установлено/);
+ const selectAction = buttonMarkupWithText(markup, "Выбрать другое");
+ const networkAction = buttonMarkupWithText(markup, "Изменить сеть");
+ assert.equal(selectAction.length, 1, `frame ${index} keeps select action slot`);
+ assert.equal(networkAction.length, 1, `frame ${index} keeps network action slot`);
+ if (index === 1) {
+ assert.match(selectAction[0], /\bdisabled(?:=|\s|>)/);
+ assert.match(networkAction[0], /\bdisabled(?:=|\s|>)/);
+ } else {
+ assert.doesNotMatch(selectAction[0], /\bdisabled(?:=|\s|>)/);
+ assert.doesNotMatch(networkAction[0], /\bdisabled(?:=|\s|>)/);
+ }
+ }
+
+ const durableBlocker = connectedState(43, ["acquisition-active"]);
+ assert.equal(
+ connectedReconfigurationActionApplicable(
+ durableBlocker,
+ "prepare-select-device",
+ ),
+ false,
+ );
+ assert.equal(
+ connectedReconfigurationActionApplicable(
+ durableBlocker,
+ "prepare-change-network",
+ ),
+ false,
+ );
+ const durableMarkup = renderToStaticMarkup(createElement(K1ProvisioningPipeline, {
+ controller: provisioningController(durableBlocker),
+ desiredMode: "bridge",
+ }));
+ assert.equal(buttonMarkupWithText(durableMarkup, "Выбрать другое").length, 0);
+ assert.equal(buttonMarkupWithText(durableMarkup, "Изменить сеть").length, 0);
+});
+
+for (const pendingAction of ["connect", "verify"]) {
+ test(`authoritative Ready with pending ${pendingAction} keeps one canonical progress surface`, () => {
+ const state = runtimeState();
+ state.phase = "connected";
+ state.source_mode = "idle";
+ state.snapshot_runtime_id = "runtime-ready-pending";
+ state.acquisition = null;
+ state.devices = [];
+ state.connection_supervisor = supervisor({ control: true, data: false });
+ state.connection_lifecycle = connectionLifecycle({ control: true });
+
+ const markup = renderProvisioningWithCurrentPendingAction({
+ controller: {
+ ...provisioningController(state),
+ pendingAction,
+ isSnapshotRuntimeCurrent: () => true,
+ isConnectionActionAuthorityCurrent: () => true,
+ },
+ desiredMode: "bridge",
+ }, { publicSearch: false });
+
+ assert.equal(
+ (markup.match(/class="nodedc-activity-indicator"/g) ?? []).length,
+ 1,
+ );
+ assert.match(markup, /ble-k1-001<\/strong>/);
+ if (pendingAction === "connect") {
+ assert.match(markup, /Сеть<\/h3>/);
+ assert.match(markup, /Настройка сети…/);
+ } else {
+ assert.match(markup, /Сеть<\/h3>/);
+ assert.match(markup, /Проверяем подключение без изменения сети…/);
+ assert.match(markup, /02<\/span>/);
+ }
+ assertCanonicalConnectionCopy(markup);
+ });
+}
+
+
+
+
+
+
+
+
+test("explicit read-only recovery keeps its network-unavailable classification", () => {
+ const base = {
+ connection_verification: {
+ status: "endpoint-unreachable",
+ lease_state: "configured-unverified",
+ network_reachability: "unreachable",
+ },
+ };
+ assert.equal(readOnlyObservationShowsNetworkUnavailable(base), true);
+ assert.equal(readOnlyObservationShowsNetworkUnavailable({
+ connection_verification: {
+ ...base.connection_verification,
+ status: "tcp-reachable-device-info-unverified",
+ network_reachability: "reachable",
+ },
+ }), false);
+ assert.equal(readOnlyObservationShowsNetworkUnavailable(null), false);
+ assert.equal(readOnlyObservationShowsNetworkUnavailable({
+ connection_verification: {
+ status: "device-network-applied",
+ lease_state: "configured-unverified",
+ network_reachability: "unknown",
+ reason_code: "endpoint-target-unconfigured",
+ },
+ }), true);
+ for (const reasonCode of [
+ "connection-verify-address-unavailable",
+ "connection-verify-connection-missing",
+ "connection-verify-route-mismatch",
+ "connection-verify-mqtt-unreachable",
+ "configured-endpoint-unavailable",
+ "endpoint-target-unconfigured",
+ ]) {
+ assert.equal(readOnlyFailureShowsNetworkUnavailable(reasonCode), true);
+ }
+ for (const reasonCode of [
+ "connection-verify-device-not-rediscovered",
+ "connection-verify-status-read-invalid",
+ "device-identity-pin-mismatch",
+ "connection-verify-discovery-generation-conflict",
+ "connection-verify-lease-changed",
+ "connection-reconfiguration-revision-conflict",
+ "connection-verify-busy",
+ null,
+ ]) {
+ assert.equal(readOnlyFailureShowsNetworkUnavailable(reasonCode), false);
+ }
+});
+
+test("pre-write candidate loss is classified stale without granting UI continuation", () => {
+ for (const reasonCode of [
+ "BleakDeviceNotFoundError",
+ "network-provision-candidate-not-fresh",
+ "network-provision-candidate-changed",
+ "network-provision-discovery-generation-conflict",
+ ]) {
+ assert.equal(provisioningFailureRequiresFreshCandidate(reasonCode), true);
+ }
+ for (const reasonCode of [
+ "BleakGATTProtocolError",
+ "network-provision-target-not-distinguishable-from-baseline",
+ "network-provision-lifecycle-busy",
+ "host-wifi-operation-timeout",
+ null,
+ ]) {
+ assert.equal(provisioningFailureRequiresFreshCandidate(reasonCode), false);
+ }
+});
+
+test("stale old-device reachability cannot own a fresh selection outcome", () => {
+ const staleOldDeviceProjection = {
+ connection_verification: {
+ status: "endpoint-unreachable",
+ lease_state: "configured-unverified",
+ network_reachability: "unreachable",
+ transport_ref: "ble-k1-old",
+ },
+ };
+
+ // The global projection is deliberately stale and unavailable, but the
+ // correlated fresh-device operation failed identity validation. The normal
+ // one-intent UI renders this only as a stale/safety outcome; it never scans,
+ // verifies or submits automatically.
+ assert.equal(
+ readOnlyObservationShowsNetworkUnavailable(staleOldDeviceProjection),
+ true,
+ );
+ assert.equal(
+ readOnlyFailureShowsNetworkUnavailable("connection-verify-status-read-invalid"),
+ false,
+ );
+ assert.equal(
+ readOnlyFailureShowsNetworkUnavailable("device-identity-pin-mismatch"),
+ false,
+ );
+ assert.equal(
+ readOnlyFailureShowsNetworkUnavailable("connection-reconfiguration-revision-conflict"),
+ false,
+ );
+ assert.equal(
+ readOnlyFailureShowsNetworkUnavailable("BleakGATTProtocolError"),
+ false,
+ );
+});
+
+test("Quick Connect does not expose Bridge reconfiguration actions", () => {
+ const state = runtimeState();
+ state.phase = "connected";
+ state.source_mode = "idle";
+ state.acquisition = null;
+ state.connection_mode = "quick-connect";
+ state.configured_connection_mode = "quick-connect";
+ state.active_connection_mode = "quick-connect";
+ state.desired_connection_mode = "quick-connect";
+ state.connection_supervisor = supervisor({ control: true, data: false });
+ state.connection_supervisor.intent.requested_mode = "quick-connect";
+ state.connection_supervisor.observed.device_network.connection_mode = "quick-connect";
+ state.connection_supervisor.observed.device_identity.connection_mode = "quick-connect";
+ state.connection_supervisor.lease.connection_mode = "quick-connect";
+ state.connection_lifecycle = connectionLifecycle({ mode: "quick-connect" });
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: [],
+ actions: {},
+ };
+
+ const markup = renderProvisioning({
+ controller: provisioningController(state),
+ desiredMode: "quick-connect",
+ });
+ assert.doesNotMatch(markup, /Выбрать другое устройство|Изменить сеть/);
+});
+
+test("a verify without current click authority does not invent network step 02", () => {
+ const state = durableTopologyState();
+ state.semantic_topology_store = {
+ status: "empty",
+ configured_offline_evidence: false,
+ live_connection_authority: false,
+ reason_code: null,
+ record: null,
+ };
+ state.connection_supervisor = supervisor({ control: false, data: false });
+ state.connection_lifecycle = connectionLifecycle({ control: false });
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: [],
+ actions: {},
+ };
+
+ const markup = renderProvisioning({
+ controller: {
+ ...provisioningController(state),
+ pendingAction: "verify",
+ isSnapshotRuntimeCurrent: () => true,
+ isConnectionActionAuthorityCurrent: () => false,
+ },
+ desiredMode: "bridge",
+ });
+
+ assert.match(markup, /01<\/span>/);
+ assert.doesNotMatch(markup, /02<\/span>|03<\/span>/);
+ assert.doesNotMatch(markup, /aria-busy="true"/);
+ assert.equal((markup.match(/class="nodedc-activity-indicator"/g) ?? []).length, 0);
+ assertCanonicalConnectionCopy(markup);
+});
+
+
+
+
+test("a blocked local-only device escape explains backend safety reasons", () => {
+ const state = durableTopologyState();
+ state.physical_command = {
+ operator_retirement: {
+ allowed: false,
+ reason_codes: ["physical-command-retirement-operation-conflict"],
+ expected_operation_id: "old-stop-operation",
+ expected_revision: 280,
+ expected_transport_ref: "ble-k1-001",
+ physical_outcome: "unknown",
+ device_io_performed: false,
+ automatic_retry: false,
+ },
+ };
+ assert.match(
+ physicalRetirementGuidance(state),
+ /завершается другая операция с физическим состоянием устройства/,
+ );
+
+ for (const [reasonCode, expectedCopy] of [
+ ["physical-command-retirement-state-unsafe", /Состояние предыдущей физической команды изменилось/],
+ ["physical-command-retirement-not-required", /больше не удерживает выбор устройства/],
+ ["physical-command-target-retired", /уже выведено из текущего контура/],
+ ["acquisition-active", /Сканирование ещё активно/],
+ ["control-session-state-unsafe", /Управляющая сессия устройства ещё не завершена/],
+ ["ble-runtime-busy", /Bluetooth занят другой операцией устройства/],
+ ["ble-runtime-cleanup-pending", /Bluetooth ещё завершает предыдущую операцию/],
+ ]) {
+ state.physical_command.operator_retirement.reason_codes = [reasonCode];
+ assert.match(physicalRetirementGuidance(state), expectedCopy);
+ }
+});
+
+test("a blocked exact retired-device check explains safe next actions", () => {
+ const state = durableTopologyState();
+ state.physical_command = {
+ operator_reconciliation_reopen: {
+ allowed: false,
+ reason_codes: ["physical-command-reconciliation-reopen-target-not-observed"],
+ expected_revision: 281,
+ expected_retirement_id: "retirement-old-stop",
+ expected_transport_ref: "ble-k1-001",
+ expected_discovery_generation: 13,
+ expected_desired_mode: "bridge",
+ expected_desired_mode_revision: 0,
+ device_io_performed: false,
+ automatic_retry: false,
+ },
+ };
+ assert.match(physicalReopenGuidance(state), /не найдено в последнем Bluetooth-поиске/);
+ state.physical_command.operator_reconciliation_reopen.reason_codes = [
+ "physical-command-reconciliation-reopen-target-not-connectable",
+ ];
+ assert.match(physicalReopenGuidance(state), /не принимает Bluetooth-подключение/);
+ state.physical_command.operator_reconciliation_reopen.reason_codes = [
+ "physical-command-reconciliation-reopen-candidate-ambiguous",
+ ];
+ assert.match(physicalReopenGuidance(state), /не подтвердил один точный экземпляр/);
+ state.physical_command.operator_reconciliation_reopen.reason_codes = [
+ "device-calibration-read-active",
+ ];
+ assert.match(physicalReopenGuidance(state), /читается калибровка/);
+});
+
+test("fresh Scan keeps retired audit rows selectable without recovery I/O", () => {
+ const state = durableTopologyState();
+ state.current_device_recovery = {
+ transport_ref: "ble-k1-001",
+ connection_mode: "bridge",
+ handle_retained: false,
+ advertised_now: false,
+ };
+ state.physical_command = {
+ status: "resolved",
+ reason_code: null,
+ requires_reconciliation: false,
+ automatic_replay_allowed: false,
+ normal_session_recovery_supported: false,
+ runtime_bound: false,
+ reconciliation_ready: false,
+ record: {
+ revision: 281,
+ operation_id: "old-stop-operation",
+ action: "stop",
+ stage: "resolved",
+ resolution: "operator-retired-outcome-unknown",
+ connection: { transport_ref: "ble-k1-001" },
+ operator_retirements: [{ retired_transport_ref: "ble-k1-001" }],
+ },
+ };
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: {
+ retained_context_is_presence: false,
+ retired_transport_refs: ["ble-k1-001"],
+ },
+ allowed_actions: ["scan-ble", "provision-fresh-device"],
+ actions: {
+ "scan-ble": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "none",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ },
+ };
+ state.connection_attempt = {
+ schema_version: "missioncore.xgrids-k1-connection-attempt/v1",
+ attempt_id: "historical-network-attempt-before-retirement",
+ connection_mode: "bridge",
+ status: "failed",
+ phase: "network_applied",
+ control_state: "unknown",
+ stage: "host-route-and-control-endpoint",
+ public_error_code: "control-bootstrap-failed",
+ side_effect_status: "network-applied",
+ safe_next_action: "scan-select-connect",
+ automatic_retry: false,
+ accepted_at: OBSERVED_AT,
+ completed_at: OBSERVED_AT,
+ timeline: [],
+ };
+ state.devices = [
+ {
+ device_id: "ble-k1-001",
+ name: "Retired old device",
+ rssi: -39,
+ connectable: true,
+ likely_k1: true,
+ },
+ {
+ device_id: "ble-k1-new",
+ name: "Replacement device",
+ rssi: -52,
+ connectable: true,
+ likely_k1: true,
+ },
+ ];
+
+ assert.equal(trustedConnectionBinding(state), null);
+ assert.equal(
+ connectionAttemptOwnsAppliedNetworkRecovery(state.connection_attempt),
+ false,
+ );
+ const markup = renderProvisioningAfterSearch({
+ controller: provisioningController(state),
+ desiredMode: "bridge",
+ });
+ assert.match(markup, /Результатов: 2/);
+ assert.match(markup, /Повторить поиск Bluetooth/);
+ assert.doesNotMatch(markup, /old-stop-operation/);
+ const priorAction = deviceRowActionButton(markup, "ble-k1-001");
+ assert.match(priorAction, />Выбрать<\/button>/);
+ assert.doesNotMatch(priorAction, /\bdisabled(?:=|\s|>)/);
+ const replacementAction = deviceRowActionButton(markup, "ble-k1-new");
+ assert.match(replacementAction, />Выбрать<\/button>/);
+ assert.doesNotMatch(replacementAction, /\bdisabled(?:=|\s|>)/);
+ assert.equal(
+ buttonMarkupWithText(markup, "Подключить новый K1").length,
+ 0,
+ );
+ assert.doesNotMatch(markup, /]*disabled[^>]*>Недоступно<\/button>/);
+ assert.doesNotMatch(
+ markup,
+ /Попытка настройки завершена|Проверить подключение без изменения сети|Управление не подтверждено/,
+ );
+ assert.doesNotMatch(markup, /Название сети Wi‑Fi|Пароль Wi‑Fi|>Применить);
+ assertCanonicalConnectionCopy(markup);
+
+ const onlyPrior = structuredClone(state);
+ onlyPrior.devices = [state.devices[0]];
+ const onlyPriorMarkup = renderProvisioningAfterSearch({
+ controller: provisioningController(onlyPrior),
+ desiredMode: "bridge",
+ });
+ assert.equal(
+ buttonMarkupWithText(onlyPriorMarkup, "Подключить новый K1").length,
+ 0,
+ );
+ assert.doesNotMatch(
+ onlyPriorMarkup,
+ /Подходящих K1 не найдено\. Повторите поиск\./,
+ );
+ const onlyPriorAction = deviceRowActionButton(onlyPriorMarkup, "ble-k1-001");
+ assert.match(onlyPriorAction, />Выбрать<\/button>/);
+ assert.doesNotMatch(onlyPriorAction, /\bdisabled(?:=|\s|>)/);
+ assert.match(onlyPriorMarkup, /Результатов: 1/);
+ assert.doesNotMatch(onlyPriorMarkup, />Недоступно|Переподключиться/);
+
+ // Historical audit data is not an active deny once the authoritative policy
+ // projection removes the reference. The same advertised row then behaves as
+ // an ordinary fresh result, without a saved/reopen branch in the UI.
+ state.connection_policy.facts.retired_transport_refs = [];
+ state.connection_lifecycle = connectionLifecycle({ control: false });
+ state.connection_supervisor = supervisor({ control: false, data: false });
+ state.semantic_topology_store = {
+ status: "empty",
+ configured_offline_evidence: false,
+ live_connection_authority: false,
+ reason_code: null,
+ record: null,
+ };
+ state.current_device_recovery = null;
+ const historicalAuditMarkup = renderProvisioningAfterSearch({
+ controller: provisioningController(state),
+ desiredMode: "bridge",
+ });
+ const historicallyRetiredAction = deviceRowActionButton(
+ historicalAuditMarkup,
+ "ble-k1-001",
+ );
+ assert.match(historicallyRetiredAction, />Выбрать<\/button>/);
+ assert.doesNotMatch(
+ historicallyRetiredAction,
+ /\bdisabled(?:=|\s|>)/,
+ );
+ assertCanonicalConnectionCopy(historicalAuditMarkup);
+});
+
+test("only current network recovery safe-next states retain a historical applied attempt", () => {
+ const attempt = {
+ schema_version: "missioncore.xgrids-k1-connection-attempt/v1",
+ attempt_id: "attempt-network-recovery-ownership",
+ connection_mode: "bridge",
+ status: "failed",
+ phase: "network_applied",
+ control_state: "control_not_ready",
+ stage: "host-route-and-control-endpoint",
+ public_error_code: "control-bootstrap-failed",
+ side_effect_status: "network-applied",
+ safe_next_action: "verify-control-read-only",
+ automatic_retry: false,
+ accepted_at: OBSERVED_AT,
+ completed_at: OBSERVED_AT,
+ timeline: [],
+ };
+ for (const safeNextAction of [
+ "continue-with-control-verification",
+ "verify-control-read-only",
+ "manual-recovery-required",
+ ]) {
+ assert.equal(
+ connectionAttemptOwnsAppliedNetworkRecovery({
+ ...attempt,
+ safe_next_action: safeNextAction,
+ }),
+ true,
+ safeNextAction,
+ );
+ }
+ for (const safeNextAction of [
+ "scan-select-connect",
+ "stop-local-receiver",
+ "retire-unavailable-physical-target",
+ "start-acquisition",
+ ]) {
+ assert.equal(
+ connectionAttemptOwnsAppliedNetworkRecovery({
+ ...attempt,
+ safe_next_action: safeNextAction,
+ }),
+ false,
+ safeNextAction,
+ );
+ }
+ assert.equal(
+ connectionAttemptOwnsAppliedNetworkRecovery({
+ ...attempt,
+ status: "running",
+ safe_next_action: "wait-for-current-attempt",
+ }),
+ true,
+ );
+ assert.equal(
+ connectionAttemptOwnsAppliedNetworkRecovery({
+ ...attempt,
+ control_state: "ready",
+ }),
+ false,
+ );
+});
+
+test("an exact prior CoreBluetooth UUID remains a normal fresh selection across casing", () => {
+ const state = retiredPhysicalReopenReadyState({
+ advertisedRef: "f89438fa-55ed-85ad-eed7-734ac84746d8",
+ backendRef: "F89438FA-55ED-85AD-EED7-734AC84746D8",
+ });
+
+ assert.equal(
+ transportRefEquivalenceKey(" F89438FA-55ED-85AD-EED7-734AC84746D8 "),
+ "f89438fa-55ed-85ad-eed7-734ac84746d8",
+ );
+ assert.equal(trustedConnectionBinding(state), null);
+ const markup = renderProvisioningAfterSearch({
+ controller: provisioningController(state),
+ desiredMode: "bridge",
+ });
+ const priorDeviceAction = deviceRowActionButton(
+ markup,
+ "f89438fa-55ed-85ad-eed7-734ac84746d8",
+ );
+ assert.match(priorDeviceAction, />Выбрать<\/button>/);
+ assert.doesNotMatch(priorDeviceAction, /\bdisabled(?:=|\s|>)/);
+ assert.equal(buttonMarkupWithText(markup, "Переподключиться").length, 0);
+ assertCanonicalConnectionCopy(markup);
+});
+
+test("an exact fresh prior row exposes one enabled Select without reconnect", () => {
+ const state = retiredPhysicalReopenReadyState();
+ assert.ok(retiredPhysicalReopenAuthority(
+ state,
+ state.devices[0].device_id,
+ "bridge",
+ ));
+ const markup = renderProvisioningAfterSearch({
+ controller: provisioningController(state),
+ desiredMode: "bridge",
+ });
+ const selectActions = buttonMarkupWithText(markup, "Выбрать");
+ assert.equal(selectActions.length, 1);
+ assert.doesNotMatch(selectActions[0], /\bdisabled(?:=|\s|>)/);
+ assert.equal(buttonMarkupWithText(markup, "Переподключиться").length, 0);
+ assert.doesNotMatch(markup, /Вернуть прежний K1 и проверить/);
+ assert.doesNotMatch(markup, /Название общей сети Wi‑Fi|Пароль Wi‑Fi|>Применить);
+});
+
+test("post-reset Scan selects the exact prior UUID locally and opens network fields", async () => {
+ const initialState = retiredPhysicalReopenReadyState();
+ initialState.devices.push({
+ device_id: "A1619D95-C352-1069-D430-5FB0BC13F7F9",
+ name: "dcCONSTRUCTIONS",
+ rssi: -55,
+ connectable: true,
+ likely_k1: false,
+ });
+ initialState.connection_scenario_reset = {
+ reset_id: "reset-before-retired-reconnect",
+ request_revision: 6,
+ revision: 7,
+ desired_mode: "bridge",
+ active: false,
+ settled_by_discovery_generation: initialState.ble_discovery_generation,
+ local_session_closed: true,
+ previous_device_may_continue_scanning: false,
+ physical_disposition: "retired",
+ network_disposition: null,
+ device_command_performed: false,
+ network_write_performed: false,
+ automatic_scan: false,
+ operation_sequence: 21,
+ };
+ let currentState = initialState;
+ let scanCalls = 0;
+ let reopenCalls = 0;
+ let verifyCalls = 0;
+ let connectCalls = 0;
+ let resetCalls = 0;
+ let prepareCalls = 0;
+ let markScanDispatched;
+ const scanDispatched = new Promise((resolve) => {
+ markScanDispatched = resolve;
+ });
+ const controller = {
+ ...provisioningController(initialState),
+ scanWithResult: async () => {
+ scanCalls += 1;
+ markScanDispatched();
+ return {
+ succeeded: true,
+ snapshotRuntimeId: currentState.snapshot_runtime_id,
+ discoveryGeneration: currentState.ble_discovery_generation,
+ transportRefs: currentState.devices.map((device) => device.device_id),
+ };
+ },
+ reopenRetiredPhysicalReconciliation: async () => {
+ reopenCalls += 1;
+ return { succeeded: false, observedState: currentState };
+ },
+ verifyConnection: async () => {
+ verifyCalls += 1;
+ return {
+ succeeded: false,
+ reconciliationCompleted: false,
+ observedState: currentState,
+ };
+ },
+ connect: async () => {
+ connectCalls += 1;
+ return {
+ succeeded: false,
+ networkIntentCompleted: false,
+ intentDisposition: "retain",
+ acceptedSessionKey: null,
+ };
+ },
+ selectConnectionMode: async () => {
+ resetCalls += 1;
+ return true;
+ },
+ prepareConnectionReconfigurationWithResult: async () => {
+ prepareCalls += 1;
+ return { succeeded: false, observedState: currentState };
+ },
+ getCurrentState: () => currentState,
+ getConnectionRecoveryObservationTarget: () =>
+ recommendedConnectionRecoveryObservationTarget(currentState),
+ getConnectionActionAuthority: (mode) =>
+ connectionActionAuthoritySnapshot(currentState, mode),
+ isSnapshotRuntimeCurrent: (runtimeId) =>
+ runtimeId === currentState.snapshot_runtime_id,
+ isConnectionActionAuthorityCurrent: (authority) =>
+ connectionActionAuthorityMatches(
+ authority,
+ connectionActionAuthoritySnapshot(
+ currentState,
+ authority.connectionMode,
+ ),
+ ),
+ isConnectionPolicyActionAllowedCurrent: (action) =>
+ currentState.connection_policy.allowed_actions.includes(action),
+ };
+ const props = () => ({
+ controller: { ...controller, state: currentState },
+ desiredMode: "bridge",
+ });
+ const harness = createStatefulProvisioningHarness(props());
+
+ try {
+ let tree = harness.render(props());
+ harness.flushEffects();
+ tree = harness.render(props());
+ harness.flushEffects();
+ const scan = actionByLabel(tree, "Найти по Bluetooth");
+ assert.ok(scan);
+ scan.props.onClick();
+ await Promise.race([
+ scanDispatched,
+ new Promise((_, reject) => setTimeout(
+ () => reject(new Error("post-reset Scan did not dispatch")),
+ 100,
+ )),
+ ]);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ tree = harness.render(props());
+ harness.flushEffects();
+ const priorRow = elementByProp(tree, "actionLabel", "Выбрать");
+ assert.ok(priorRow);
+ assert.equal(priorRow.props.device.device_id, initialState.devices[0].device_id);
+ assert.equal(priorRow.props.selectionDisabled, false);
+ assert.equal(actionByLabel(tree, "Переподключиться"), null);
+ priorRow.props.onSelect();
+
+ tree = harness.render(props());
+ harness.flushEffects();
+ tree = harness.render(props());
+ const selectedMarkup = renderToStaticMarkup(tree);
+ assert.equal(scanCalls, 1);
+ assert.equal(reopenCalls, 0);
+ assert.equal(verifyCalls, 0);
+ assert.equal(connectCalls, 0);
+ assert.equal(resetCalls, 0);
+ assert.equal(prepareCalls, 0);
+ assert.match(selectedMarkup, /02<\/span>/);
+ assert.match(selectedMarkup, /Название общей сети Wi‑Fi/);
+ assert.match(selectedMarkup, /Пароль Wi‑Fi/);
+ assert.doesNotMatch(selectedMarkup, /Переподключиться/);
+ assert.equal(
+ harness.stateValue(emptyProvisioningAttemptPresentation),
+ null,
+ "local selection must not manufacture a backend Apply attempt",
+ );
+
+ const passwordField = elementByProp(tree, "label", "Пароль Wi‑Fi");
+ assert.ok(passwordField);
+ assert.equal(passwordField.props.type, "password");
+ passwordField.props.onChange({ target: { value: "test-only-password" } });
+ tree = harness.render(props());
+ harness.flushEffects();
+ const showPassword = elementByProp(tree, "label", "Показать пароль");
+ assert.ok(showPassword);
+ assert.equal(showPassword.props.disabled, false);
+ showPassword.props.onClick();
+ tree = harness.render(props());
+ harness.flushEffects();
+ assert.equal(
+ elementByProp(tree, "label", "Пароль Wi‑Fi").props.type,
+ "text",
+ );
+ assert.ok(elementByProp(tree, "label", "Скрыть пароль"));
+
+ const chooseAnother = actionByLabel(tree, "Выбрать другое");
+ assert.ok(chooseAnother);
+ chooseAnother.props.onClick();
+
+ tree = harness.render(props());
+ harness.flushEffects();
+ tree = harness.render(props());
+ const returnedMarkup = renderToStaticMarkup(tree);
+ const returnedRows = buttonMarkupWithText(returnedMarkup, "Выбрать");
+ assert.equal(returnedRows.length, initialState.devices.length);
+ assert.ok(returnedRows.every((row) => !/\bdisabled(?:=|\s|>)/.test(row)));
+ assert.match(returnedMarkup, /Повторить поиск Bluetooth/);
+ assert.doesNotMatch(returnedMarkup, />Найти по Bluetooth);
+ assert.doesNotMatch(
+ returnedMarkup,
+ /Название общей сети Wi‑Fi|Пароль Wi‑Fi|02<\/span>/,
+ );
+ assert.equal(scanCalls, 1);
+ assert.equal(reopenCalls, 0);
+ assert.equal(verifyCalls, 0);
+ assert.equal(connectCalls, 0);
+ assert.equal(resetCalls, 0);
+ assert.equal(prepareCalls, 0);
+ } finally {
+ harness.dispose();
+ }
+});
+
+test("a rendered prior-device selection never dispatches reopen or Verify", async () => {
+ const initialState = retiredPhysicalReopenReadyState();
+ let currentState = initialState;
+ let reopenCalls = 0;
+ let verifyCalls = 0;
+ let reopenedRequest = null;
+ let verifiedRequest = null;
+ let verifiedOptions = null;
+ let markVerifyDispatched;
+ const verifyDispatched = new Promise((resolve) => {
+ markVerifyDispatched = resolve;
+ });
+
+ const controller = {
+ ...provisioningController(initialState),
+ getCurrentState: () => currentState,
+ getConnectionRecoveryObservationTarget: () =>
+ recommendedConnectionRecoveryObservationTarget(currentState),
+ getConnectionActionAuthority: (mode) =>
+ connectionActionAuthoritySnapshot(currentState, mode),
+ isSnapshotRuntimeCurrent: (runtimeId) =>
+ runtimeId === currentState.snapshot_runtime_id,
+ reopenRetiredPhysicalReconciliation: async (request, runtimeId) => {
+ reopenCalls += 1;
+ reopenedRequest = request;
+ assert.equal(runtimeId, initialState.snapshot_runtime_id);
+ const committed = reopenedPhysicalState({
+ reopeningId: request.reopening_id,
+ discoveryGeneration: request.expected_discovery_generation,
+ });
+ committed.connection_policy.recommended_action = "select-connection-intent";
+ committed.connection_policy.allowed_actions = [
+ "observe-fresh-device-network",
+ "observe-configured-device-network",
+ "scan-ble",
+ ];
+ committed.connection_policy.actions["observe-configured-device-network"] = {
+ allowed: true,
+ reason_codes: [],
+ target_source: "durable-configured-state",
+ required_transport_ref: request.expected_transport_ref,
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ };
+ currentState = committed;
+ assert.equal(
+ recommendedConnectionRecoveryObservationTarget(currentState).source,
+ "durable-configured-state",
+ "the generic selector must reproduce the competing configured target",
+ );
+ assert.equal(
+ readOnlyConnectionObservationTarget(currentState).source,
+ "fresh-scan",
+ );
+ return { succeeded: true, observedState: currentState };
+ },
+ verifyConnection: async (request, options) => {
+ verifyCalls += 1;
+ verifiedRequest = request;
+ verifiedOptions = options;
+ markVerifyDispatched();
+ return {
+ succeeded: true,
+ reconciliationCompleted: true,
+ observedState: currentState,
+ };
+ },
+ };
+ const tree = captureProvisioningTreeAfterSearch({
+ controller,
+ desiredMode: "bridge",
+ });
+ const priorDeviceRow = elementByProp(
+ tree,
+ "actionLabel",
+ "Выбрать",
+ );
+ assert.ok(priorDeviceRow);
+ assert.equal(typeof priorDeviceRow.props.onSelect, "function");
+ assert.equal(actionByLabel(tree, "Переподключиться"), null);
+
+ priorDeviceRow.props.onSelect();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ assert.equal(reopenCalls, 0);
+ assert.equal(verifyCalls, 0);
+ assert.equal(reopenedRequest, null);
+ assert.equal(verifiedRequest, null);
+ assert.equal(verifiedOptions, null);
+});
+
+test("retired physical reopen proof uses the backend target, not the visible row", () => {
+ const request = {
+ reopening_id: "reopening-returned-k1",
+ expected_revision: 281,
+ expected_retirement_id: "retirement-old-stop",
+ expected_transport_ref: "F89438FA-55ED-85AD-EED7-734AC84746D8",
+ expected_discovery_generation: 13,
+ expected_desired_mode: "bridge",
+ expected_desired_mode_revision: 7,
+ operator_confirmed: true,
+ reason: "device-returned-for-explicit-reconciliation",
+ };
+ const state = reopenedPhysicalState();
+ const target = recommendedConnectionRecoveryObservationTarget(state);
+ const authority = connectionActionAuthoritySnapshot(state, "bridge");
+ const context = retiredPhysicalReopenVerificationContext(
+ state,
+ request,
+ "runtime-reopened-k1",
+ target,
+ authority,
+ );
+ assert.ok(context);
+ assert.equal(context.target.deviceId, request.expected_transport_ref);
+ assert.equal(context.target.serverBound, true);
+ assert.equal(
+ context.target.deviceId,
+ state.connection_policy.actions["observe-fresh-device-network"]
+ .required_transport_ref,
+ );
+ assert.notEqual(context.target.deviceId, state.devices[0].device_id);
+ assert.equal(
+ transportRefEquivalenceKey(context.target.deviceId),
+ transportRefEquivalenceKey(state.devices[0].device_id),
+ );
+
+ const wrongTarget = {
+ ...target,
+ deviceId: "visible-row-but-not-server-target",
+ };
+ assert.equal(retiredPhysicalReopenVerificationContext(
+ state,
+ request,
+ "runtime-reopened-k1",
+ wrongTarget,
+ authority,
+ ), null);
+});
+
+test("exact lost reopen response continues once to read-only Verify", async () => {
+ const request = {
+ reopening_id: "reopening-returned-k1",
+ expected_revision: 281,
+ expected_retirement_id: "retirement-old-stop",
+ expected_transport_ref: "F89438FA-55ED-85AD-EED7-734AC84746D8",
+ expected_discovery_generation: 13,
+ expected_desired_mode: "bridge",
+ expected_desired_mode_revision: 7,
+ operator_confirmed: true,
+ reason: "device-returned-for-explicit-reconciliation",
+ };
+ const committed = reopenedPhysicalState();
+ let verifyCalls = 0;
+ let verifiedTarget = null;
+ const result = await dispatchRetiredPhysicalReconciliationForCurrentRuntime({
+ snapshotRuntimeId: "runtime-reopened-k1",
+ request,
+ isSnapshotRuntimeCurrent: (runtimeId) => runtimeId === "runtime-reopened-k1",
+ reopen: async () => ({ succeeded: false, observedState: committed }),
+ getCurrentState: () => committed,
+ getCurrentTarget: () => recommendedConnectionRecoveryObservationTarget(committed),
+ getCurrentAuthority: (mode) => connectionActionAuthoritySnapshot(committed, mode),
+ expectedAuthority: connectionActionAuthoritySnapshot(committed, "bridge"),
+ verify: async (context, runtimeId) => {
+ verifyCalls += 1;
+ verifiedTarget = context.target;
+ assert.equal(runtimeId, "runtime-reopened-k1");
+ return { succeeded: true };
+ },
+ });
+ assert.equal(result.reopenDispatched, true);
+ assert.equal(result.reopenResult.succeeded, false);
+ assert.equal(result.verifyDispatched, true);
+ assert.equal(verifyCalls, 1);
+ assert.equal(verifiedTarget.deviceId, request.expected_transport_ref);
+});
+
+test("retired combined recovery keeps the exact fresh target when configured observation is also allowed", async () => {
+ const request = {
+ reopening_id: "reopening-combined-live",
+ expected_revision: 281,
+ expected_retirement_id: "retirement-old-stop",
+ expected_transport_ref: "F89438FA-55ED-85AD-EED7-734AC84746D8",
+ expected_discovery_generation: 13,
+ expected_desired_mode: "bridge",
+ expected_desired_mode_revision: 7,
+ operator_confirmed: true,
+ reason: "device-returned-for-explicit-reconciliation",
+ };
+ const committed = reopenedPhysicalState({
+ reopeningId: request.reopening_id,
+ });
+ committed.connection_policy.recommended_action = "select-connection-intent";
+ committed.connection_policy.allowed_actions = [
+ "observe-fresh-device-network",
+ "observe-configured-device-network",
+ "scan-ble",
+ ];
+ committed.connection_policy.actions["observe-configured-device-network"] = {
+ allowed: true,
+ reason_codes: [],
+ target_source: "durable-configured-state",
+ required_transport_ref: request.expected_transport_ref,
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ };
+
+ const genericTarget = recommendedConnectionRecoveryObservationTarget(committed);
+ const exactFreshTarget = readOnlyConnectionObservationTarget(committed);
+ assert.equal(genericTarget.action, "observe-configured-device-network");
+ assert.equal(exactFreshTarget.action, "observe-fresh-device-network");
+ assert.equal(exactFreshTarget.source, "fresh-scan");
+ assert.equal(exactFreshTarget.expectedDiscoveryGeneration, 13);
+
+ let verifyCalls = 0;
+ const result = await dispatchRetiredPhysicalReconciliationForCurrentRuntime({
+ snapshotRuntimeId: committed.snapshot_runtime_id,
+ request,
+ isSnapshotRuntimeCurrent: () => true,
+ reopen: async () => ({ succeeded: true, observedState: committed }),
+ getCurrentState: () => committed,
+ getCurrentTarget: () => readOnlyConnectionObservationTarget(committed),
+ getCurrentAuthority: (mode) => connectionActionAuthoritySnapshot(committed, mode),
+ expectedAuthority: connectionActionAuthoritySnapshot(committed, "bridge"),
+ verify: async ({ target }) => {
+ verifyCalls += 1;
+ assert.equal(target.action, "observe-fresh-device-network");
+ assert.equal(target.source, "fresh-scan");
+ assert.equal(target.deviceId, request.expected_transport_ref);
+ return { succeeded: true };
+ },
+ });
+ assert.equal(result.verifyDispatched, true);
+ assert.equal(verifyCalls, 1);
+});
+
+test("runtime replacement and same-runtime re-retirement suppress post-reopen Verify", async () => {
+ const request = {
+ reopening_id: "reopening-returned-k1",
+ expected_revision: 281,
+ expected_retirement_id: "retirement-old-stop",
+ expected_transport_ref: "F89438FA-55ED-85AD-EED7-734AC84746D8",
+ expected_discovery_generation: 13,
+ expected_desired_mode: "bridge",
+ expected_desired_mode_revision: 7,
+ operator_confirmed: true,
+ reason: "device-returned-for-explicit-reconciliation",
+ };
+ const committed = reopenedPhysicalState();
+ let runtimeId = "runtime-B";
+ let reopenCalls = 0;
+ let verifyCalls = 0;
+ const staleBeforeDispatch = await dispatchRetiredPhysicalReconciliationForCurrentRuntime({
+ snapshotRuntimeId: "runtime-A",
+ request,
+ isSnapshotRuntimeCurrent: (expected) => expected === runtimeId,
+ reopen: async () => {
+ reopenCalls += 1;
+ return { succeeded: true, observedState: committed };
+ },
+ getCurrentState: () => committed,
+ getCurrentTarget: () => recommendedConnectionRecoveryObservationTarget(committed),
+ getCurrentAuthority: (mode) => connectionActionAuthoritySnapshot(committed, mode),
+ expectedAuthority: connectionActionAuthoritySnapshot(committed, "bridge"),
+ verify: async () => {
+ verifyCalls += 1;
+ return { succeeded: true };
+ },
+ });
+ assert.equal(staleBeforeDispatch.reopenDispatched, false);
+ assert.equal(reopenCalls, 0);
+ assert.equal(verifyCalls, 0);
+
+ runtimeId = "runtime-reopened-k1";
+ const reretired = retiredPhysicalReopenReadyState();
+ let currentState = committed;
+ const changedAfterSuccess = await dispatchRetiredPhysicalReconciliationForCurrentRuntime({
+ snapshotRuntimeId: runtimeId,
+ request,
+ isSnapshotRuntimeCurrent: (expected) => expected === runtimeId,
+ reopen: async () => {
+ currentState = reretired;
+ return { succeeded: true, observedState: committed };
+ },
+ getCurrentState: () => currentState,
+ getCurrentTarget: () => recommendedConnectionRecoveryObservationTarget(currentState),
+ getCurrentAuthority: (mode) => connectionActionAuthoritySnapshot(currentState, mode),
+ expectedAuthority: connectionActionAuthoritySnapshot(committed, "bridge"),
+ verify: async () => {
+ verifyCalls += 1;
+ return { succeeded: true };
+ },
+ });
+ assert.equal(changedAfterSuccess.reopenDispatched, true);
+ assert.equal(changedAfterSuccess.verifyDispatched, false);
+ assert.equal(verifyCalls, 0);
+});
+
+test("retired reopen click and settlement retain the complete authority fence", () => {
+ const rendered = retiredPhysicalReopenReadyState();
+ rendered.connection_reconfiguration = connectionReconfiguration();
+ rendered.connection_lifecycle = { active_binding_key: "binding-A" };
+ const reopenAuthority = retiredPhysicalReopenAuthority(
+ rendered,
+ rendered.devices[0].device_id,
+ "bridge",
+ );
+ const authorityA = connectionActionAuthoritySnapshot(rendered, "bridge");
+ assert.ok(authorityA);
+ assert.deepEqual(
+ physicalReopenClickAuthority(rendered, "bridge", authorityA, reopenAuthority),
+ authorityA,
+ );
+
+ const reconfigured = structuredClone(rendered);
+ reconfigured.connection_reconfiguration = connectionReconfiguration({
+ intent: "select-device",
+ revision: 1,
+ intentId: "reconfigure-B",
+ });
+ const authorityR1 = connectionActionAuthoritySnapshot(reconfigured, "bridge");
+ assert.equal(
+ physicalReopenClickAuthority(rendered, "bridge", authorityR1, reopenAuthority),
+ null,
+ "a rendered R0 CTA cannot borrow latest R1 before POST",
+ );
+
+ const rebound = structuredClone(rendered);
+ rebound.connection_lifecycle.active_binding_key = "binding-B";
+ const authorityB = connectionActionAuthoritySnapshot(rebound, "bridge");
+ assert.equal(
+ physicalReopenClickAuthority(rendered, "bridge", authorityB, reopenAuthority),
+ null,
+ "a rendered binding A CTA cannot borrow latest binding B before POST",
+ );
+
+ const requestA = {
+ reopening_id: "reopening-A",
+ expected_revision: reopenAuthority.expectedRevision,
+ expected_retirement_id: reopenAuthority.expectedRetirementId,
+ expected_transport_ref: reopenAuthority.expectedTransportRef,
+ expected_discovery_generation: reopenAuthority.expectedDiscoveryGeneration,
+ expected_desired_mode: reopenAuthority.expectedDesiredMode,
+ expected_desired_mode_revision: reopenAuthority.expectedDesiredModeRevision,
+ operator_confirmed: true,
+ reason: "device-returned-for-explicit-reconciliation",
+ };
+ const presentationA = {
+ key: "runtime-A:reopening-A:device",
+ snapshotRuntimeId: authorityA.snapshotRuntimeId,
+ request: requestA,
+ authority: authorityA,
+ };
+ assert.equal(
+ admitPhysicalReopenPresentation(null, presentationA, authorityA),
+ presentationA,
+ );
+ assert.equal(
+ admitPhysicalReopenPresentation(presentationA, presentationA, authorityA),
+ null,
+ "a double click is coalesced while A owns the action",
+ );
+
+ const authorityRuntimeB = {
+ ...authorityA,
+ snapshotRuntimeId: "runtime-B",
+ };
+ const presentationB = {
+ ...presentationA,
+ key: "runtime-B:reopening-B:device",
+ snapshotRuntimeId: "runtime-B",
+ request: { ...requestA, reopening_id: "reopening-B" },
+ authority: authorityRuntimeB,
+ };
+ assert.equal(physicalReopenPresentationIsCurrent(
+ presentationA,
+ authorityRuntimeB,
+ ), false);
+ assert.equal(
+ admitPhysicalReopenPresentation(
+ presentationA,
+ presentationB,
+ authorityRuntimeB,
+ ),
+ presentationB,
+ "runtime B synchronously replaces stale A without waiting for an effect",
+ );
+ assert.equal(physicalReopenSettlementIsCurrent(
+ presentationB,
+ presentationA.key,
+ authorityRuntimeB,
+ ), false, "late A cannot message or clear B");
+
+ for (const [label, driftedAuthority] of [
+ ["new reset revision", {
+ ...authorityA,
+ desiredModeRevision: authorityA.desiredModeRevision + 1,
+ }],
+ ["new discovery generation", {
+ ...authorityA,
+ discoveryGeneration: authorityA.discoveryGeneration + 1,
+ }],
+ ["new reconfiguration", authorityR1],
+ ["new binding", authorityB],
+ ]) {
+ assert.equal(physicalReopenPresentationIsCurrent(
+ presentationA,
+ driftedAuthority,
+ ), false);
+ const replacement = {
+ ...presentationA,
+ key: `runtime-A:reopening-B:${label}`,
+ request: {
+ ...requestA,
+ reopening_id: `reopening-B-${label}`,
+ expected_desired_mode_revision: driftedAuthority.desiredModeRevision,
+ expected_discovery_generation: driftedAuthority.discoveryGeneration,
+ },
+ authority: driftedAuthority,
+ };
+ assert.equal(
+ admitPhysicalReopenPresentation(
+ presentationA,
+ replacement,
+ driftedAuthority,
+ ),
+ replacement,
+ `${label} releases A synchronously so a current B click can start`,
+ );
+ }
+});
+
+test("same-runtime reconfiguration or binding drift after reopen suppresses Verify", async () => {
+ const request = {
+ reopening_id: "reopening-returned-k1",
+ expected_revision: 281,
+ expected_retirement_id: "retirement-old-stop",
+ expected_transport_ref: "F89438FA-55ED-85AD-EED7-734AC84746D8",
+ expected_discovery_generation: 13,
+ expected_desired_mode: "bridge",
+ expected_desired_mode_revision: 7,
+ operator_confirmed: true,
+ reason: "device-returned-for-explicit-reconciliation",
+ };
+ const committed = reopenedPhysicalState();
+ committed.connection_reconfiguration = connectionReconfiguration();
+ committed.connection_lifecycle = { active_binding_key: "binding-A" };
+ const expectedAuthority = connectionActionAuthoritySnapshot(committed, "bridge");
+ for (const drift of ["reconfiguration", "binding"]) {
+ const current = structuredClone(committed);
+ if (drift === "reconfiguration") {
+ current.connection_reconfiguration = connectionReconfiguration({
+ intent: "select-device",
+ revision: 1,
+ intentId: "reconfiguration-B",
+ });
+ } else {
+ current.connection_lifecycle.active_binding_key = "binding-B";
+ }
+ let verifyCalls = 0;
+ const result = await dispatchRetiredPhysicalReconciliationForCurrentRuntime({
+ snapshotRuntimeId: committed.snapshot_runtime_id,
+ request,
+ isSnapshotRuntimeCurrent: () => true,
+ reopen: async () => ({ succeeded: true, observedState: committed }),
+ getCurrentState: () => current,
+ getCurrentTarget: () => recommendedConnectionRecoveryObservationTarget(current),
+ getCurrentAuthority: (mode) => connectionActionAuthoritySnapshot(current, mode),
+ expectedAuthority,
+ verify: async () => {
+ verifyCalls += 1;
+ return { succeeded: true };
+ },
+ });
+ assert.equal(result.verifyDispatched, false, drift);
+ assert.equal(verifyCalls, 0, drift);
+ }
+});
+
+test("a settled reset marker keeps the exact prior UUID as an ordinary Select row", () => {
+ const state = retiredPhysicalReopenReadyState();
+ state.connection_scenario_reset = {
+ reset_id: "reset-settled",
+ request_revision: 6,
+ revision: 7,
+ desired_mode: "bridge",
+ active: false,
+ settled_by_discovery_generation: 13,
+ local_session_closed: true,
+ previous_device_may_continue_scanning: false,
+ physical_disposition: "retired",
+ network_disposition: null,
+ device_command_performed: false,
+ network_write_performed: false,
+ automatic_scan: false,
+ operation_sequence: 21,
+ };
+ assert.deepEqual(scenarioResetPresentationBoundary(state, "bridge"), {
+ current: true,
+ active: false,
+ key: "runtime-reopened-k1:7",
+ });
+ const markup = renderProvisioningAfterSearch({
+ controller: provisioningController(state),
+ desiredMode: "bridge",
+ });
+ assert.doesNotMatch(markup, /Прежний локальный сеанс закрыт/);
+ const select = buttonMarkupWithText(markup, "Выбрать");
+ assert.equal(select.length, 1);
+ assert.doesNotMatch(select[0], /\bdisabled(?:=|\s|>)/);
+ assert.equal(buttonMarkupWithText(markup, "Переподключиться").length, 0);
+ assert.doesNotMatch(markup, /Вернуть прежний K1 и проверить/);
+});
+
+test("an unresolved physical command has one explicit server-bound read-only recovery surface", () => {
+ const state = durableTopologyState();
+ state.snapshot_runtime_id = "runtime-physical-read-only";
+ const recoveryTransport = "F89438FA-55ED-85AD-EED7-734AC84746D8";
+ state.physical_command = {
+ status: "unresolved",
+ reason_code: "physical-command-reconciliation-required",
+ requires_reconciliation: true,
+ resolved_active_recovery_required: false,
+ automatic_replay_allowed: false,
+ normal_session_recovery_supported: false,
+ runtime_bound: true,
+ reconciliation_ready: true,
+ observed_session_state: "scan_stopping",
+ active_operation_id: "old-stop-operation",
+ record: {
+ revision: 306,
+ operation_id: "old-stop-operation",
+ action: "stop",
+ stage: "observing",
+ resolution: null,
+ connection: {
+ transport_ref: recoveryTransport,
+ connection_mode: "bridge",
+ target_ipv4: "192.168.68.51",
+ },
+ },
+ };
+ state.connection_attempt = {
+ schema_version: "missioncore.xgrids-k1-connection-attempt/v1",
+ attempt_id: "historical-network-attempt-after-powered-off-start",
+ connection_mode: "bridge",
+ status: "failed",
+ phase: "network_applied",
+ control_state: "control_not_ready",
+ stage: "host-route-and-control-endpoint",
+ public_error_code: "control-bootstrap-failed",
+ side_effect_status: "network-applied",
+ safe_next_action: "manual-recovery-required",
+ automatic_retry: false,
+ accepted_at: OBSERVED_AT,
+ completed_at: OBSERVED_AT,
+ timeline: [],
+ };
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false, retired_transport_refs: [] },
+ allowed_actions: ["scan-ble", "observe-configured-device-network"],
+ actions: {
+ "scan-ble": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "none",
+ required_transport_ref: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ "provision-fresh-device": {
+ allowed: false,
+ reason_codes: ["physical-command-reconciliation-required"],
+ target_source: "fresh-scan",
+ required_transport_ref: null,
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ "observe-configured-device-network": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "durable-configured-state",
+ required_transport_ref: recoveryTransport,
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ },
+ };
+ state.devices = [
+ {
+ device_id: recoveryTransport.toLowerCase(),
+ name: "XGR-A46BE7",
+ rssi: -42,
+ connectable: true,
+ likely_k1: true,
+ },
+ {
+ device_id: "nearby-unrelated-device",
+ name: "Nearby unrelated device",
+ rssi: -48,
+ connectable: true,
+ likely_k1: false,
+ },
+ ];
+
+ const markup = renderToStaticMarkup(createElement(K1ProvisioningPipeline, {
+ controller: provisioningController(state),
+ desiredMode: "bridge",
+ }));
+ const phaseDetail = physicalRecoveryConnectionDetail(state);
+ assert.match(phaseDetail, /Проверьте состояние прежнего K1/);
+ assert.match(phaseDetail, /сохранённое системой подключение/);
+ assert.match(phaseDetail, /не отправляет START, STOP или настройки сети/);
+ assert.doesNotMatch(phaseDetail, /read-only/);
+ assert.doesNotMatch(phaseDetail, /серверн.*цел/i);
+ assert.doesNotMatch(markup, /read-only|серверн.*цел/i);
+ assert.doesNotMatch(phaseDetail, /[Пп]одтвердите питание|локально исключить/);
+ const reconnect = buttonMarkupWithText(markup, "Переподключиться");
+ const connectNew = buttonMarkupWithText(markup, "Подключить новый K1");
+ assert.equal(reconnect.length, 1);
+ assert.equal(connectNew.length, 1);
+ assert.doesNotMatch(reconnect[0], /\bdisabled(?:=|\s|>)/);
+ assert.doesNotMatch(connectNew[0], /\bdisabled(?:=|\s|>)/);
+ assert.doesNotMatch(
+ markup,
+ /Предыдущая команда завершилась|Проверить прежний K1 без изменений|Выберите способ восстановления|Подтверждаю:|Питание|индикатор горит/,
+ );
+ assert.doesNotMatch(markup, /02<\/span>|03<\/span>/);
+ assert.doesNotMatch(markup, /Попытка настройки завершена|Проверить подключение без изменения сети/);
+ assert.doesNotMatch(markup, /nearby-unrelated-device/);
+ assert.doesNotMatch(markup, />Применить<|>Выбрать<|Обновить Bluetooth-поиск/);
+ assert.doesNotMatch(markup, /old-stop-operation/);
+
+ const searchedMarkup = renderProvisioningAfterSearch({
+ controller: provisioningController(state),
+ desiredMode: "bridge",
+ });
+ assert.equal(buttonMarkupWithText(searchedMarkup, "Переподключиться").length, 1);
+ assert.equal(buttonMarkupWithText(searchedMarkup, "Подключить новый K1").length, 1);
+ assert.doesNotMatch(
+ searchedMarkup,
+ /Результаты последнего Bluetooth-поиска|nearby-unrelated-device|>Выбрать<|>Применить,
+ );
+
+ const policyDeniedState = structuredClone(state);
+ policyDeniedState.connection_policy.allowed_actions = ["scan-ble"];
+ delete policyDeniedState.connection_policy.actions["observe-configured-device-network"];
+ const policyDeniedMarkup = renderProvisioningAfterSearch({
+ controller: provisioningController(policyDeniedState),
+ desiredMode: "bridge",
+ });
+ assert.equal(buttonMarkupWithText(policyDeniedMarkup, "Переподключиться").length, 0);
+ const deniedNew = buttonMarkupWithText(policyDeniedMarkup, "Подключить новый K1");
+ assert.equal(deniedNew.length, 1);
+ assert.doesNotMatch(deniedNew[0], /\bdisabled(?:=|\s|>)/);
+ assert.doesNotMatch(
+ policyDeniedMarkup,
+ /nearby-unrelated-device|Старый K1 недоступен|Подтверждаю:|>Применить,
+ );
+
+ const pendingMarkup = renderProvisioningWithCurrentPendingAction({
+ controller: {
+ ...provisioningController(state),
+ pendingAction: "verify",
+ isSnapshotRuntimeCurrent: () => true,
+ isConnectionActionAuthorityCurrent: () => true,
+ },
+ desiredMode: "bridge",
+ }, { publicSearch: false });
+ assert.equal(
+ (pendingMarkup.match(/class="connection-action-progress"/g) ?? []).length,
+ 1,
+ );
+ assert.doesNotMatch(pendingMarkup, /Переподключиться|Подключить новый K1|Подтверждаю:/);
+
+ const source = readFileSync(provisioningSourceUrl, "utf8");
+ const physicalRecovery = sourceSlice(
+ source,
+ "const verifyPhysicalRecovery",
+ "const prepareReconfiguration",
+ );
+ assert.equal((physicalRecovery.match(/await verifyConnection\(/g) ?? []).length, 1);
+ assert.doesNotMatch(
+ physicalRecovery,
+ /scanWithResult\(|connect\(|retireUnavailable|reopenRetired|reconcilePhysicalCommand/,
+ );
+ assert.match(physicalRecovery, /physicalRecoveryTarget\?\.serverBound/);
+});
+
+test("unavailable physical replacement stays CAS-protected behind the simple new-device path", () => {
+ const state = durableTopologyState();
+ const transportRef = "F89438FA-55ED-85AD-EED7-734AC84746D8";
+ state.snapshot_runtime_id = "runtime-retire-old-k1";
+ state.physical_command = {
+ status: "unresolved",
+ reason_code: "physical-command-reconciliation-required",
+ requires_reconciliation: true,
+ resolved_active_recovery_required: false,
+ automatic_replay_allowed: false,
+ normal_session_recovery_supported: false,
+ runtime_bound: false,
+ reconciliation_ready: true,
+ observed_session_state: "unknown",
+ active_operation_id: "old-start-operation",
+ operator_retirement: {
+ allowed: true,
+ reason_codes: [],
+ expected_operation_id: "old-start-operation",
+ expected_revision: 41,
+ expected_transport_ref: transportRef,
+ physical_outcome: "unknown",
+ device_io_performed: false,
+ automatic_retry: false,
+ },
+ record: {
+ revision: 41,
+ operation_id: "old-start-operation",
+ action: "start",
+ stage: "observing",
+ resolution: null,
+ connection: {
+ transport_ref: transportRef,
+ connection_mode: "bridge",
+ },
+ },
+ };
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false, retired_transport_refs: [] },
+ allowed_actions: ["retire-unavailable-physical-target"],
+ actions: {
+ "provision-fresh-device": {
+ allowed: false,
+ reason_codes: ["physical-command-reconciliation-required"],
+ target_source: "fresh-scan",
+ required_transport_ref: null,
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ "retire-unavailable-physical-target": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "durable-physical-command",
+ required_transport_ref: transportRef,
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: false,
+ physical_command_allowed: false,
+ physical_outcome: "unknown",
+ device_write_performed: false,
+ automatic_retry: false,
+ },
+ },
+ };
+
+ assert.deepEqual(unavailablePhysicalRetirementAuthority(state), {
+ expectedOperationId: "old-start-operation",
+ expectedRevision: 41,
+ expectedTransportRef: transportRef,
+ });
+ const markup = renderToStaticMarkup(createElement(K1ProvisioningPipeline, {
+ controller: provisioningController(state),
+ desiredMode: "bridge",
+ }));
+ const connectNew = buttonMarkupWithText(markup, "Подключить новый K1");
+ assert.equal(connectNew.length, 1);
+ assert.doesNotMatch(connectNew[0], /\bdisabled(?:=|\s|>)/);
+ assert.equal(buttonMarkupWithText(markup, "Переподключиться").length, 0);
+ assert.doesNotMatch(markup, /02<\/span>|03<\/span>/);
+ assert.doesNotMatch(
+ markup,
+ /Старый K1 недоступен|Подтверждаю:|Выберите способ восстановления|Проверить прежний K1 без изменений|[Пп]итани/,
+ );
+ const phaseDetail = physicalRecoveryConnectionDetail(state);
+ assert.match(phaseDetail, /можно локально исключить без связи с устройством/);
+ assert.match(phaseDetail, /не отправляет START, STOP или настройки сети/);
+ assert.doesNotMatch(phaseDetail, /[Пп]итани|read-only/);
+
+ const denied = structuredClone(state);
+ denied.connection_policy.allowed_actions = [];
+ denied.connection_policy.actions["retire-unavailable-physical-target"].allowed = false;
+ denied.connection_policy.actions["retire-unavailable-physical-target"].reason_codes = [
+ "physical-command-retirement-state-unsafe",
+ ];
+ assert.equal(unavailablePhysicalRetirementAuthority(denied), null);
+ const deniedMarkup = renderProvisioning({
+ controller: provisioningController(denied),
+ desiredMode: "bridge",
+ });
+ const deniedNew = buttonMarkupWithText(deniedMarkup, "Подключить новый K1");
+ assert.equal(deniedNew.length, 1);
+ assert.doesNotMatch(deniedNew[0], /\bdisabled(?:=|\s|>)/);
+ assert.equal(buttonMarkupWithText(deniedMarkup, "Переподключиться").length, 0);
+ assert.doesNotMatch(deniedMarkup, /Старый K1 недоступен|Подтверждаю:/);
+ assert.doesNotMatch(
+ physicalRecoveryConnectionDetail(denied),
+ /можно локально исключить/,
+ );
+
+ const resolvedActive = structuredClone(state);
+ resolvedActive.physical_command.status = "resolved";
+ resolvedActive.physical_command.requires_reconciliation = false;
+ resolvedActive.physical_command.resolved_active_recovery_required = true;
+ resolvedActive.physical_command.observed_session_state = "scanning";
+ resolvedActive.physical_command.record.stage = "resolved";
+ resolvedActive.physical_command.record.resolution = "start-active-observed";
+ resolvedActive.connection_policy.actions["provision-fresh-device"].reason_codes = [
+ "physical-device-already-active",
+ ];
+ const resolvedActiveMarkup = renderToStaticMarkup(createElement(
+ K1ProvisioningPipeline,
+ {
+ controller: provisioningController(resolvedActive),
+ desiredMode: "bridge",
+ },
+ ));
+ const resolvedNew = buttonMarkupWithText(
+ resolvedActiveMarkup,
+ "Подключить новый K1",
+ );
+ assert.equal(resolvedNew.length, 1);
+ assert.doesNotMatch(resolvedNew[0], /\bdisabled(?:=|\s|>)/);
+ assert.equal(
+ buttonMarkupWithText(resolvedActiveMarkup, "Переподключиться").length,
+ 0,
+ );
+ assert.doesNotMatch(resolvedActiveMarkup, /[Пп]итани/);
+ assert.doesNotMatch(resolvedActiveMarkup, /02<\/span>|03<\/span>/);
+
+ const bothAllowed = structuredClone(state);
+ bothAllowed.semantic_topology_store.record.transport_ref = transportRef;
+ bothAllowed.connection_policy.allowed_actions = [
+ "observe-configured-device-network",
+ "retire-unavailable-physical-target",
+ ];
+ bothAllowed.connection_policy.actions["observe-configured-device-network"] = {
+ allowed: true,
+ reason_codes: [],
+ target_source: "durable-configured-state",
+ required_transport_ref: transportRef,
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ };
+ const bothMarkup = renderToStaticMarkup(createElement(
+ K1ProvisioningPipeline,
+ {
+ controller: provisioningController(bothAllowed),
+ desiredMode: "bridge",
+ },
+ ));
+ const bothReconnect = buttonMarkupWithText(bothMarkup, "Переподключиться");
+ const bothNew = buttonMarkupWithText(bothMarkup, "Подключить новый K1");
+ assert.equal(bothReconnect.length, 1);
+ assert.equal(bothNew.length, 1);
+ assert.doesNotMatch(bothReconnect[0], /\bdisabled(?:=|\s|>)/);
+ assert.doesNotMatch(bothNew[0], /\bdisabled(?:=|\s|>)/);
+ assert.ok(
+ bothMarkup.indexOf("Переподключиться")
+ < bothMarkup.indexOf("Подключить новый K1"),
+ "read-only reconnect must be first",
+ );
+ assert.doesNotMatch(
+ bothMarkup,
+ /Выберите способ восстановления|Если прежний K1 снова доступен|Если прежний K1 недоступен постоянно|Подтверждаю:/,
+ );
+ assert.doesNotMatch(bothMarkup, /[Пп]итани|индикатор горит/);
+ assert.doesNotMatch(bothMarkup, /02<\/span>|03<\/span>/);
+ const bothPhaseDetail = physicalRecoveryConnectionDetail(bothAllowed);
+ assert.match(bothPhaseDetail, /Если прежний K1 снова доступен/);
+ assert.match(bothPhaseDetail, /недоступен постоянно или заменён/);
+ assert.match(bothPhaseDetail, /не отправляет START, STOP или настройки сети/);
+ assert.match(bothPhaseDetail, /без связи с устройством/);
+ assert.doesNotMatch(bothPhaseDetail, /read-only/);
+ assert.doesNotMatch(bothMarkup, /read-only/);
+
+ const bothPendingMarkup = renderProvisioningWithCurrentPendingAction({
+ controller: {
+ ...provisioningController(bothAllowed),
+ pendingAction: "verify",
+ isSnapshotRuntimeCurrent: () => true,
+ isConnectionActionAuthorityCurrent: () => true,
+ },
+ desiredMode: "bridge",
+ }, { publicSearch: false });
+ assert.match(
+ bothPendingMarkup,
+ /Проверяем прежний K1 без повторения START, STOP или настроек сети/,
+ );
+ assert.equal(
+ (bothPendingMarkup.match(/class="connection-action-progress"/g) ?? []).length,
+ 1,
+ );
+ assert.doesNotMatch(
+ bothPendingMarkup,
+ /Переподключиться|Подключить новый K1|Подтверждаю:/,
+ );
+
+ const bothResolvedActive = structuredClone(bothAllowed);
+ bothResolvedActive.physical_command.status = "resolved";
+ bothResolvedActive.physical_command.requires_reconciliation = false;
+ bothResolvedActive.physical_command.resolved_active_recovery_required = true;
+ bothResolvedActive.physical_command.observed_session_state = "scanning";
+ bothResolvedActive.physical_command.record.stage = "resolved";
+ bothResolvedActive.physical_command.record.resolution = "start-active-observed";
+ bothResolvedActive.connection_policy.actions[
+ "provision-fresh-device"
+ ].reason_codes = ["physical-device-already-active"];
+ const bothResolvedActiveMarkup = renderToStaticMarkup(createElement(
+ K1ProvisioningPipeline,
+ {
+ controller: provisioningController(bothResolvedActive),
+ desiredMode: "bridge",
+ },
+ ));
+ assert.equal(
+ buttonMarkupWithText(bothResolvedActiveMarkup, "Переподключиться").length,
+ 1,
+ );
+ assert.equal(
+ buttonMarkupWithText(bothResolvedActiveMarkup, "Подключить новый K1").length,
+ 1,
+ );
+ assert.doesNotMatch(
+ bothResolvedActiveMarkup,
+ /02<\/span>|03<\/span>/,
+ );
+});
+
+test("physical recovery new-device action performs only an explicit scenario reset", async () => {
+ const state = reopenedPhysicalState();
+ const resetRequests = [];
+ let desiredModeChanges = 0;
+ let scanCalls = 0;
+ let connectCalls = 0;
+ let verifyCalls = 0;
+ let reopenCalls = 0;
+ let markResetDispatched;
+ const resetDispatched = new Promise((resolve) => {
+ markResetDispatched = resolve;
+ });
+ const controller = {
+ ...provisioningController(state),
+ selectConnectionMode: async (request) => {
+ resetRequests.push(request);
+ markResetDispatched();
+ return true;
+ },
+ scanWithResult: async () => {
+ scanCalls += 1;
+ return { succeeded: false };
+ },
+ connect: async () => {
+ connectCalls += 1;
+ return { succeeded: false };
+ },
+ verifyConnection: async () => {
+ verifyCalls += 1;
+ return { succeeded: false };
+ },
+ reopenRetiredPhysicalReconciliation: async () => {
+ reopenCalls += 1;
+ return { succeeded: false };
+ },
+ };
+ const props = {
+ controller,
+ desiredMode: "bridge",
+ onDesiredModeChange: async (mode) => {
+ assert.equal(mode, "bridge");
+ desiredModeChanges += 1;
+ },
+ };
+ const markup = renderProvisioning(props);
+ const resetButtons = buttonMarkupWithText(markup, "Подключить новый K1");
+ assert.equal(resetButtons.length, 1);
+ assert.doesNotMatch(resetButtons[0], /\bdisabled(?:=|\s|>)/);
+
+ const tree = captureProvisioningTree(props);
+ const connectNew = actionByLabel(tree, "Подключить новый K1");
+ assert.ok(connectNew);
+ connectNew.props.onClick();
+ await Promise.race([
+ resetDispatched,
+ new Promise((_, reject) => setTimeout(
+ () => reject(new Error("new-device action did not dispatch scenario reset")),
+ 100,
+ )),
+ ]);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ assert.equal(resetRequests.length, 1);
+ assert.equal(resetRequests[0].connection_mode, "bridge");
+ assert.equal(
+ resetRequests[0].expected_revision,
+ state.desired_connection_mode_revision,
+ );
+ assert.equal(resetRequests[0].reset_scenario, true);
+ assert.equal(typeof resetRequests[0].reset_id, "string");
+ assert.ok(resetRequests[0].reset_id.length > 0);
+ assert.equal(desiredModeChanges, 1);
+ assert.equal(scanCalls, 0);
+ assert.equal(connectCalls, 0);
+ assert.equal(verifyCalls, 0);
+ assert.equal(reopenCalls, 0);
+});
+
+test("physical retirement dispatch refuses a rendered runtime A after runtime B is current", async () => {
+ let currentRuntimeId = "runtime-B";
+ let mutationRequests = 0;
+ const stale = await dispatchUnavailablePhysicalRetirementForCurrentRuntime(
+ "runtime-A",
+ (expectedSnapshotRuntimeId) => expectedSnapshotRuntimeId === currentRuntimeId,
+ async () => {
+ mutationRequests += 1;
+ return { succeeded: true };
+ },
+ );
+ assert.deepEqual(stale, { dispatched: false, result: null });
+ assert.equal(mutationRequests, 0);
+
+ currentRuntimeId = "runtime-A";
+ const current = await dispatchUnavailablePhysicalRetirementForCurrentRuntime(
+ "runtime-A",
+ (expectedSnapshotRuntimeId) => expectedSnapshotRuntimeId === currentRuntimeId,
+ async (expectedSnapshotRuntimeId) => {
+ mutationRequests += 1;
+ return { expectedSnapshotRuntimeId };
+ },
+ );
+ assert.deepEqual(current, {
+ dispatched: true,
+ result: { expectedSnapshotRuntimeId: "runtime-A" },
+ });
+ assert.equal(mutationRequests, 1);
+
+ const runtimeSource = readFileSync(new URL(
+ "../../../plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts",
+ import.meta.url,
+ ), "utf8");
+ const retirementHook = sourceSlice(
+ runtimeSource,
+ "const retireUnavailablePhysicalCommandWithResult",
+ "const retireUnavailablePhysicalCommand = useCallback",
+ );
+ assert.match(
+ retirementHook,
+ /!isSnapshotRuntimeCurrent\(exactSnapshotRuntimeId\)[\s\S]*?expected_snapshot_runtime_id:\s*exactSnapshotRuntimeId/,
+ );
+ assert.doesNotMatch(
+ retirementHook,
+ /expected_snapshot_runtime_id:\s*expectedSnapshotRuntimeId\(\)/,
+ );
+});
+
+
+
+test("an explicit network action keeps one disabled Step-2 form with an in-place loader", () => {
+ const state = durableTopologyState();
+ state.snapshot_runtime_id = "runtime-network-action";
+ state.desired_connection_mode_revision = 7;
+ state.ble_discovery_generation = 13;
+ state.semantic_topology_store = {
+ status: "empty",
+ configured_offline_evidence: false,
+ live_connection_authority: false,
+ reason_code: null,
+ record: null,
+ };
+ const device = {
+ device_id: "f89438fa-55ed-85ad-eed7-734ac84746d8",
+ name: "XGR-A46BE7",
+ rssi: -51,
+ connectable: true,
+ likely_k1: true,
+ };
+ state.devices = [device];
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: ["provision-fresh-device"],
+ actions: {
+ "provision-fresh-device": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "fresh-scan",
+ required_transport_ref: null,
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ },
+ };
+ const presentation = provisioningAttemptPresentation({
+ snapshotRuntimeId: state.snapshot_runtime_id,
+ deviceId: device.device_id,
+ });
+ const markup = renderProvisioningWithAttempt({
+ controller: provisioningController(state),
+ desiredMode: "bridge",
+ }, presentation);
+
+ assert.doesNotMatch(markup, /class="connection-action-progress"/);
+ assert.match(markup, /aria-busy="true"/);
+ assert.equal((markup.match(/class="nodedc-activity-indicator"/g) ?? []).length, 1);
+ assert.match(markup, /Устройство выбрано/);
+ assert.match(markup, /XGR-A46BE7/);
+ assert.match(markup, /02<\/span>/);
+ assert.match(markup, /Применяем настройки…/);
+ assert.match(markup, /FIELD-NET/);
+ assert.match(markup, /Пароль передан/);
+ assert.doesNotMatch(markup, /secret/);
+ assert.equal((markup.match(/ ]*disabled=""/g) ?? []).length, 2);
+ assert.doesNotMatch(markup, />Применить<\/button>/);
+ assertCanonicalConnectionCopy(markup);
+});
+
+test("Apply presentation stays correlated through ACK, bootstrap, terminal and connected projections", () => {
+ const presentation = provisioningAttemptPresentation({
+ deviceId: "ble-k1-001",
+ });
+ const initialState = {
+ ...durableTopologyState(),
+ snapshot_runtime_id: presentation.snapshotRuntimeId,
+ };
+ assert.equal(
+ provisioningAttemptViewState(presentation, initialState),
+ "submitting",
+ );
+
+ const operation = {
+ operation_id: "network-operation-one",
+ action: "network.provision",
+ status: "succeeded",
+ idempotency_key: presentation.idempotencyKey,
+ };
+ assert.equal(
+ provisioningAttemptViewState({
+ ...presentation,
+ localPhase: "failed",
+ }, {
+ ...initialState,
+ operations: [{ ...operation, status: "running" }],
+ }),
+ "settling",
+ "an authoritative running operation supersedes a presentation error",
+ );
+ assert.equal(
+ provisioningAttemptViewState(presentation, {
+ ...initialState,
+ operations: [operation],
+ }),
+ "settling",
+ "a fast network ACK is not yet a connected result",
+ );
+
+ const correlated = {
+ ...presentation,
+ attemptId: operation.operation_id,
+ localPhase: "settling",
+ };
+ const attempt = {
+ attempt_id: operation.operation_id,
+ connection_mode: "bridge",
+ status: "running",
+ phase: "network_applied",
+ control_state: "unknown",
+ safe_next_action: "wait-for-current-attempt",
+ };
+ assert.equal(
+ provisioningAttemptViewState(correlated, {
+ ...initialState,
+ operations: [operation],
+ connection_attempt: attempt,
+ }),
+ "settling",
+ );
+ assert.equal(
+ provisioningAttemptViewState(correlated, {
+ ...initialState,
+ operations: [operation],
+ connection_attempt: {
+ ...attempt,
+ status: "succeeded",
+ control_state: "ready",
+ safe_next_action: "start-acquisition",
+ },
+ }),
+ "settling",
+ "ready child success waits for the exact reachable lease projection",
+ );
+ assert.equal(
+ provisioningAttemptViewState(correlated, {
+ ...initialState,
+ operations: [operation],
+ connection_attempt: {
+ ...attempt,
+ status: "failed",
+ control_state: "control_not_ready",
+ safe_next_action: "manual-recovery-required",
+ },
+ }),
+ "failed",
+ );
+
+ const connectedState = runtimeState();
+ connectedState.snapshot_runtime_id = presentation.snapshotRuntimeId;
+ assert.equal(
+ provisioningAttemptViewState(correlated, connectedState),
+ "connected",
+ );
+ assert.equal(
+ provisioningAttemptViewState(correlated, {
+ ...initialState,
+ snapshot_runtime_id: "replacement-runtime",
+ }),
+ "retired",
+ );
+});
+
+test("Apply spends secret state before I/O and never stores a password in its presentation latch", () => {
+ const source = readFileSync(provisioningSourceUrl, "utf8");
+ const presentationType = sourceSlice(
+ source,
+ "export interface ProvisioningAttemptPresentation",
+ "export function emptyProvisioningAttemptPresentation",
+ );
+ const apply = sourceSlice(source, "const submitConnect", "const verifyAppliedNetwork");
+ assert.doesNotMatch(presentationType, /password\s*:/i);
+ assert.ok(apply.indexOf('setPassword("")') < apply.indexOf("await connect("));
+ assert.ok(apply.indexOf("setExplicitProvisioningDraft(null)") < apply.indexOf("await connect("));
+ assert.match(apply, /const attemptedPassword = password/);
+ assert.match(apply, /value|password/);
+ assert.match(source, /\|\| connectionAttemptSettling[\s\S]*?\|\| connectionAttemptFailed/);
+ assert.match(source, /icon=\{ \}[\s\S]*?disabled/);
+});
+
+test("terminal Apply recovery is bounded and never claims that the network is ready", () => {
+ const presentation = provisioningAttemptPresentation({
+ localPhase: "settling",
+ attemptId: "network-operation-one",
+ });
+ const state = durableTopologyState();
+ state.snapshot_runtime_id = presentation.snapshotRuntimeId;
+ state.connection_attempt = {
+ schema_version: "missioncore.xgrids-k1-connection-attempt/v1",
+ attempt_id: presentation.attemptId,
+ connection_mode: "bridge",
+ status: "failed",
+ phase: "network_applied",
+ control_state: "control_not_ready",
+ stage: "host-route-and-control-endpoint",
+ public_error_code: "control-bootstrap-failed",
+ side_effect_status: "network-applied",
+ safe_next_action: "manual-recovery-required",
+ automatic_retry: false,
+ accepted_at: OBSERVED_AT,
+ completed_at: OBSERVED_AT,
+ timeline: [],
+ };
+ state.operations = [{
+ operation_id: presentation.attemptId,
+ action: "network.provision",
+ status: "failed",
+ idempotency_key: presentation.idempotencyKey,
+ }];
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: [],
+ actions: {},
+ };
+
+ const markup = renderProvisioningWithAttempt({
+ controller: provisioningController(state),
+ desiredMode: "bridge",
+ }, presentation);
+ assert.match(markup, /Управление не подтверждено/);
+ assert.match(markup, /Попытка настройки завершена/);
+ assert.doesNotMatch(markup, /Сеть (?:готова|настроена)|Сетевые настройки уже применены/);
+ assert.doesNotMatch(markup, /Пароль передан|FIELD-NET|>Применить);
+ assert.doesNotMatch(markup, /aria-busy="true"/);
+});
+
+test("correlated terminal recovery resets before exposing a separate clean Scan", async () => {
+ const state = terminalConnectionRecoveryState({
+ phase: "network_not_applied",
+ safeNextAction: "scan-select-connect",
+ includeConfiguredTarget: true,
+ });
+ // A proven pre-dispatch failure must follow its exact Scan recovery instead
+ // of borrowing an older durable K1 observation that is still policy-visible.
+ state.connection_policy.recommended_action = "scan-ble";
+ const presentation = provisioningAttemptPresentation({
+ snapshotRuntimeId: state.snapshot_runtime_id,
+ attemptId: state.connection_attempt.attempt_id,
+ localPhase: "failed",
+ failureMessage: "Результат отправки неизвестен.",
+ freshStartAllowed: false,
+ });
+ const exactCorrelation = {
+ action: "connect",
+ runtimeId: state.snapshot_runtime_id,
+ leaseGeneration: 0,
+ connectionAttemptId: state.connection_attempt.attempt_id,
+ };
+ let resetCalls = 0;
+ let scanCalls = 0;
+ let resetRequest = null;
+ let markResetDispatched;
+ const resetDispatched = new Promise((resolve) => {
+ markResetDispatched = resolve;
+ });
+ const controller = {
+ ...provisioningController(state),
+ error: "private transport exception must stay hidden",
+ errorDiagnostic: null,
+ errorCorrelation: exactCorrelation,
+ refresh: async () => state,
+ clearError() {},
+ selectConnectionMode: async (request) => {
+ resetCalls += 1;
+ resetRequest = request;
+ markResetDispatched();
+ return true;
+ },
+ scanWithResult: async () => {
+ scanCalls += 1;
+ return { succeeded: false };
+ },
+ };
+ const markup = renderProvisioningWithAttempt({
+ controller,
+ desiredMode: "bridge",
+ }, presentation);
+
+ assert.match(markup, /Подключение не завершено/);
+ assert.match(markup, //);
+ assert.doesNotMatch(markup, /]*\sopen/);
+ assert.equal(
+ buttonMarkupWithText(markup, "Подключить новый K1").length,
+ 1,
+ );
+ assert.equal(
+ buttonMarkupWithText(markup, "Проверить прежнее подключение").length,
+ 0,
+ );
+ assert.doesNotMatch(markup, /Проверить состояние|>Закрыть<\/button>/);
+ assert.doesNotMatch(markup, /Выбрать другое|Пароль Wi‑Fi|Пароль передан|>Применить);
+ assert.doesNotMatch(markup, /02<\/span>|private transport exception/);
+
+ const busyMarkup = renderProvisioningWithAttempt({
+ controller: {
+ ...controller,
+ pendingAction: "mode",
+ },
+ desiredMode: "bridge",
+ }, presentation);
+ const busyReset = buttonMarkupWithText(
+ busyMarkup,
+ "Подключить новый K1",
+ )[0];
+ assert.ok(busyReset);
+ assert.match(busyReset, /\bdisabled(?:=|\s|>)/);
+
+ const tree = captureProvisioningTreeWithAttempt({
+ controller,
+ desiredMode: "bridge",
+ }, presentation);
+ const errorSurface = elementByProp(
+ tree,
+ "title",
+ "Подключение не завершено",
+ );
+ assert.ok(errorSurface);
+ const resetAction = actionByLabel(
+ errorSurface.props.recoveryActions,
+ "Подключить новый K1",
+ );
+ assert.ok(resetAction);
+ resetAction.props.onClick();
+ await Promise.race([
+ resetDispatched,
+ new Promise((_, reject) => setTimeout(
+ () => reject(new Error("terminal recovery did not dispatch scenario reset")),
+ 100,
+ )),
+ ]);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ assert.equal(resetCalls, 1);
+ assert.deepEqual(
+ {
+ connection_mode: resetRequest.connection_mode,
+ expected_revision: resetRequest.expected_revision,
+ reset_scenario: resetRequest.reset_scenario,
+ },
+ {
+ connection_mode: "bridge",
+ expected_revision: state.desired_connection_mode_revision,
+ reset_scenario: true,
+ },
+ );
+ assert.equal(typeof resetRequest.reset_id, "string");
+ assert.ok(resetRequest.reset_id.length > 0);
+ assert.equal(scanCalls, 0);
+
+ const cleanState = structuredClone(state);
+ cleanState.connection_attempt = null;
+ cleanState.operations = [];
+ cleanState.desired_connection_mode_revision += 1;
+ cleanState.connection_scenario_reset = {
+ reset_id: resetRequest.reset_id,
+ request_revision: state.desired_connection_mode_revision,
+ revision: cleanState.desired_connection_mode_revision,
+ desired_mode: "bridge",
+ active: false,
+ settled_by_discovery_generation: cleanState.ble_discovery_generation,
+ local_session_closed: true,
+ previous_device_may_continue_scanning: false,
+ physical_disposition: null,
+ network_disposition: null,
+ device_command_performed: false,
+ network_write_performed: false,
+ automatic_scan: false,
+ operation_sequence: 9,
+ };
+ const cleanMarkup = renderProvisioning({
+ controller: provisioningController(cleanState),
+ desiredMode: "bridge",
+ });
+ const cleanScan = buttonMarkupWithText(cleanMarkup, "Найти по Bluetooth");
+ assert.equal(cleanScan.length, 1);
+ assert.doesNotMatch(cleanScan[0], /\bdisabled(?:=|\s|>)/);
+ assert.equal(
+ buttonMarkupWithText(cleanMarkup, "Подключить новый K1").length,
+ 0,
+ );
+ assert.equal(scanCalls, 0);
+
+ const deniedState = terminalConnectionRecoveryState({
+ phase: "network_not_applied",
+ safeNextAction: "scan-select-connect",
+ includeConfiguredTarget: false,
+ includeScan: false,
+ });
+ const deniedMarkup = renderProvisioningWithAttempt({
+ controller: {
+ ...provisioningController(deniedState),
+ error: "denied",
+ errorCorrelation: {
+ ...exactCorrelation,
+ runtimeId: deniedState.snapshot_runtime_id,
+ connectionAttemptId: deniedState.connection_attempt.attempt_id,
+ },
+ refresh: async () => deniedState,
+ clearError() {},
+ },
+ desiredMode: "bridge",
+ }, {
+ ...presentation,
+ snapshotRuntimeId: deniedState.snapshot_runtime_id,
+ attemptId: deniedState.connection_attempt.attempt_id,
+ });
+ assert.equal(
+ buttonMarkupWithText(deniedMarkup, "Подключить новый K1").length,
+ 1,
+ );
+
+ const source = readFileSync(provisioningSourceUrl, "utf8");
+ const search = sourceSlice(source, "const repeatDeviceScan", "const submitConnect");
+ const actions = sourceSlice(
+ source,
+ "const connectionRecoveryActions",
+ "return (",
+ );
+ assert.equal((search.match(/scanWithResult\(/g) ?? []).length, 1);
+ assert.match(
+ search,
+ /isConnectionPolicyActionAllowedCurrent\("scan-ble"\)/,
+ );
+ assert.match(
+ actions,
+ /changeDesiredConnectionMode\(connectionMode\)/,
+ );
+ assert.doesNotMatch(
+ actions,
+ /repeatDeviceScan\(|scanWithResult\(|xgridsK1Api|\.scanBle\(/,
+ );
+});
+
+test("unknown and cold durable recovery offer one reconnect and one new-device path", () => {
+ const state = terminalConnectionRecoveryState();
+ const target = recommendedConnectionRecoveryObservationTarget(state);
+ assert.equal(target?.action, "observe-configured-device-network");
+ const presentation = provisioningAttemptPresentation({
+ snapshotRuntimeId: state.snapshot_runtime_id,
+ attemptId: state.connection_attempt.attempt_id,
+ localPhase: "failed",
+ failureMessage: "Результат отправки неизвестен.",
+ freshStartAllowed: false,
+ });
+ const controller = {
+ ...provisioningController(state),
+ error: "hidden",
+ errorDiagnostic: null,
+ errorCorrelation: {
+ action: "connect",
+ runtimeId: state.snapshot_runtime_id,
+ leaseGeneration: 0,
+ connectionAttemptId: state.connection_attempt.attempt_id,
+ },
+ refresh: async () => state,
+ clearError() {},
+ getConnectionRecoveryObservationTarget: () => target,
+ };
+ const markup = renderProvisioningWithAttempt({
+ controller,
+ desiredMode: "bridge",
+ }, presentation);
+ const reconnectOffset = markup.indexOf("Переподключиться");
+ const newDeviceOffset = markup.indexOf("Подключить новый K1");
+ assert.ok(reconnectOffset > 0);
+ assert.ok(newDeviceOffset > reconnectOffset);
+ assert.match(markup, /Результат применения настроек сети не подтверждён/);
+ assert.doesNotMatch(markup, /Настройки сети не применены/);
+ assert.doesNotMatch(markup, /Сеть (?:готова|настроена)|>Применить);
+ assert.doesNotMatch(markup, /02<\/span>/);
+
+ const coldState = structuredClone(state);
+ coldState.connection_attempt = null;
+ const coldTarget = recommendedConnectionRecoveryObservationTarget(coldState);
+ const coldMarkup = renderProvisioning({
+ controller: {
+ ...provisioningController(coldState),
+ getConnectionRecoveryObservationTarget: () => coldTarget,
+ },
+ desiredMode: "bridge",
+ });
+ const coldReconnectOffset = coldMarkup.indexOf("Переподключиться");
+ const coldNewDeviceOffset = coldMarkup.indexOf("Подключить новый K1");
+ assert.ok(coldReconnectOffset > 0);
+ assert.ok(coldNewDeviceOffset > coldReconnectOffset);
+ assert.doesNotMatch(coldMarkup, /Локальная операция завершилась ошибкой/);
+ assert.doesNotMatch(coldMarkup, /02<\/span>|>Применить);
+
+ const coldQuickState = structuredClone(coldState);
+ coldQuickState.connection_policy.actions[
+ "observe-configured-device-network"
+ ].required_connection_mode = "quick-connect";
+ const coldQuickTarget = recommendedConnectionRecoveryObservationTarget(
+ coldQuickState,
+ );
+ const coldQuickMarkup = renderProvisioning({
+ controller: {
+ ...provisioningController(coldQuickState),
+ getConnectionRecoveryObservationTarget: () => coldQuickTarget,
+ },
+ desiredMode: "bridge",
+ });
+ const coldQuickReconnect = buttonMarkupWithText(
+ coldQuickMarkup,
+ "Переподключиться",
+ )[0];
+ assert.ok(coldQuickReconnect);
+ assert.doesNotMatch(coldQuickReconnect, /\bdisabled(?:=|\s|>)/);
+ assert.equal(
+ buttonMarkupWithText(coldQuickMarkup, "Подключить новый K1").length,
+ 1,
+ );
+ assert.match(coldQuickMarkup, /Quick Connect/);
+
+ const unrelatedMarkup = renderProvisioningWithAttempt({
+ controller: {
+ ...controller,
+ errorCorrelation: { ...controller.errorCorrelation, action: "scan" },
+ },
+ desiredMode: "bridge",
+ }, presentation);
+ assert.doesNotMatch(unrelatedMarkup, /op-b7ea404d/);
+ assert.match(unrelatedMarkup, /Результат применения сети не подтверждён/);
+});
+
+test("cold saved new-device path resets once, performs zero Scan, and survives reload", async () => {
+ const state = terminalConnectionRecoveryState();
+ state.connection_attempt = null;
+ const target = recommendedConnectionRecoveryObservationTarget(state);
+ assert.ok(target);
+ const resetRequests = [];
+ let scanCalls = 0;
+ let verifyCalls = 0;
+ let connectCalls = 0;
+ let markResetDispatched;
+ const resetDispatched = new Promise((resolve) => {
+ markResetDispatched = resolve;
+ });
+ const controller = {
+ ...provisioningController(state),
+ getConnectionRecoveryObservationTarget: () => target,
+ selectConnectionMode: async (request) => {
+ resetRequests.push(request);
+ markResetDispatched();
+ return true;
+ },
+ scanWithResult: async () => {
+ scanCalls += 1;
+ return { succeeded: false };
+ },
+ verifyConnection: async () => {
+ verifyCalls += 1;
+ return { succeeded: false };
+ },
+ connect: async () => {
+ connectCalls += 1;
+ return { succeeded: false };
+ },
+ };
+ const props = {
+ controller,
+ desiredMode: "bridge",
+ };
+ const markup = renderProvisioning(props);
+ const reconnect = buttonMarkupWithText(markup, "Переподключиться");
+ const connectNew = buttonMarkupWithText(markup, "Подключить новый K1");
+ assert.equal(reconnect.length, 1);
+ assert.equal(connectNew.length, 1);
+ assert.doesNotMatch(reconnect[0], /\bdisabled(?:=|\s|>)/);
+ assert.doesNotMatch(connectNew[0], /\bdisabled(?:=|\s|>)/);
+
+ const tree = captureProvisioningTree(props);
+ const resetAction = actionByLabel(tree, "Подключить новый K1");
+ assert.ok(resetAction);
+ resetAction.props.onClick();
+ await Promise.race([
+ resetDispatched,
+ new Promise((_, reject) => setTimeout(
+ () => reject(new Error("cold new-device path did not dispatch reset")),
+ 100,
+ )),
+ ]);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ assert.equal(resetRequests.length, 1);
+ assert.deepEqual(
+ {
+ connection_mode: resetRequests[0].connection_mode,
+ expected_revision: resetRequests[0].expected_revision,
+ reset_scenario: resetRequests[0].reset_scenario,
+ },
+ {
+ connection_mode: "bridge",
+ expected_revision: state.desired_connection_mode_revision,
+ reset_scenario: true,
+ },
+ );
+ assert.equal(typeof resetRequests[0].reset_id, "string");
+ assert.ok(resetRequests[0].reset_id.length > 0);
+ assert.equal(scanCalls, 0);
+ assert.equal(verifyCalls, 0);
+ assert.equal(connectCalls, 0);
+
+ const reloaded = structuredClone(state);
+ reloaded.desired_connection_mode_revision += 1;
+ reloaded.connection_scenario_reset = {
+ reset_id: resetRequests[0].reset_id,
+ request_revision: state.desired_connection_mode_revision,
+ revision: reloaded.desired_connection_mode_revision,
+ desired_mode: "bridge",
+ active: false,
+ settled_by_discovery_generation: reloaded.ble_discovery_generation,
+ local_session_closed: true,
+ previous_device_may_continue_scanning: false,
+ physical_disposition: null,
+ network_disposition: null,
+ device_command_performed: false,
+ network_write_performed: false,
+ automatic_scan: false,
+ operation_sequence: 31,
+ };
+ const reloadedMarkup = renderProvisioning({
+ controller: {
+ ...provisioningController(reloaded),
+ getConnectionRecoveryObservationTarget: () =>
+ recommendedConnectionRecoveryObservationTarget(reloaded),
+ },
+ desiredMode: "bridge",
+ });
+ assert.equal(buttonMarkupWithText(reloadedMarkup, "Переподключиться").length, 0);
+ const cleanScan = buttonMarkupWithText(reloadedMarkup, "Найти по Bluetooth");
+ assert.equal(cleanScan.length, 1);
+ assert.doesNotMatch(cleanScan[0], /\bdisabled(?:=|\s|>)/);
+ assert.doesNotMatch(
+ reloadedMarkup,
+ /Нужна проверка|Прежнее подключение не подтверждено|Проверяем прежний K1/,
+ );
+});
+
+test("a current post-reset failed or unknown attempt outranks the durable reset marker after reload", () => {
+ const withSettledReset = (state) => {
+ state.desired_connection_mode_revision = 1;
+ state.connection_scenario_reset = {
+ reset_id: "reset-before-new-attempt",
+ request_revision: 0,
+ revision: 1,
+ desired_mode: "bridge",
+ active: false,
+ settled_by_discovery_generation: state.ble_discovery_generation,
+ local_session_closed: true,
+ previous_device_may_continue_scanning: false,
+ physical_disposition: null,
+ network_disposition: null,
+ device_command_performed: false,
+ network_write_performed: false,
+ automatic_scan: false,
+ // Operation sequence is per operation, not a global chronology. The new
+ // post-reset operation below can therefore legitimately have 3 < 6.
+ operation_sequence: 6,
+ };
+ state.operations = [{
+ operation_id: state.connection_attempt.attempt_id,
+ action: "network.provision",
+ status: "failed",
+ sequence: 3,
+ idempotency_key: "post-reset-new-network-attempt",
+ }];
+ return state;
+ };
+
+ const unknown = withSettledReset(terminalConnectionRecoveryState({
+ phase: "network_outcome_unknown",
+ safeNextAction: "verify-control-read-only",
+ }));
+ const unknownTarget = recommendedConnectionRecoveryObservationTarget(unknown);
+ assert.ok(unknownTarget);
+ const unknownReloadMarkup = renderProvisioning({
+ controller: {
+ ...provisioningController(unknown),
+ getConnectionRecoveryObservationTarget: () => unknownTarget,
+ },
+ desiredMode: "bridge",
+ });
+ assert.match(unknownReloadMarkup, /Результат применения сети не подтверждён/);
+ assert.equal(
+ buttonMarkupWithText(unknownReloadMarkup, "Переподключиться").length,
+ 1,
+ );
+ assert.equal(
+ buttonMarkupWithText(unknownReloadMarkup, "Подключить новый K1").length,
+ 1,
+ );
+ assert.equal(
+ buttonMarkupWithText(unknownReloadMarkup, "Найти по Bluetooth").length,
+ 0,
+ );
+
+ const failed = withSettledReset(terminalConnectionRecoveryState({
+ phase: "network_applied",
+ safeNextAction: "manual-recovery-required",
+ }));
+ failed.connection_attempt.control_state = "control_not_ready";
+ const failedReloadMarkup = renderProvisioning({
+ controller: provisioningController(failed),
+ desiredMode: "bridge",
+ });
+ assert.match(failedReloadMarkup, /Управление не подтверждено/);
+ assert.match(failedReloadMarkup, /Новый выбор временно заблокирован/);
+ assert.doesNotMatch(failedReloadMarkup, /Совпадений нет|Ожидает/);
+});
+
+test("Apply never owns hidden refresh or an automatic continuation", () => {
+ const source = readFileSync(provisioningSourceUrl, "utf8");
+ const apply = sourceSlice(source, "const submitConnect", "const verifyAppliedNetwork");
+ assert.doesNotMatch(
+ apply,
+ /scanWithResult\(|candidateRefresh|verifyConnection\(|void submitConnect/,
+ );
+});
+
+test("stale authority is explicit and never settles a hidden refresh", () => {
+ const source = readFileSync(provisioningSourceUrl, "utf8");
+ const apply = sourceSlice(source, "const submitConnect", "const verifyAppliedNetwork");
+ assert.doesNotMatch(apply, /candidateRefresh|scanWithResult\(/);
+ assert.match(source, /устарел|stale/i);
+});
+
+test("a same-runtime authority drift keeps one neutral settling surface", () => {
+ const state = durableTopologyState();
+ const markup = renderProvisioning({
+ controller: {
+ ...provisioningController(state),
+ pendingAction: "verify",
+ // A synchronous authority read has already moved beyond the click-owned
+ // fence while the controller is still settling its old I/O.
+ isSnapshotRuntimeCurrent: () => true,
+ isConnectionActionAuthorityCurrent: () => false,
+ },
+ desiredMode: "bridge",
+ });
+
+ assert.match(markup, /01<\/span>/);
+ assert.doesNotMatch(markup, /02<\/span>|03<\/span>/);
+ assert.doesNotMatch(markup, /Завершаем ранее начатое действие…/);
+ assert.equal((markup.match(/class="nodedc-activity-indicator"/g) ?? []).length, 0);
+ assert.doesNotMatch(markup, /Проверяем сохранённое подключение…/);
+ assert.doesNotMatch(markup, /Подключаемся к сохранённому K1…/);
+});
+
+test("a stale reopen settlement cannot own the current wizard surface", () => {
+ const state = durableTopologyState();
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: ["scan-ble"],
+ actions: {
+ "scan-ble": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "none",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ },
+ };
+ const markup = renderProvisioning({
+ controller: {
+ ...provisioningController(state),
+ pendingAction: "reopen",
+ isSnapshotRuntimeCurrent: () => true,
+ isConnectionActionAuthorityCurrent: () => false,
+ },
+ desiredMode: "bridge",
+ });
+
+ assert.match(markup, /01<\/span>/);
+ assert.doesNotMatch(markup, /Подключение…|aria-busy="true"/);
+ assert.doesNotMatch(markup, /02<\/span>|03<\/span>/);
+ assert.match(markup, />Найти по Bluetooth<\/button>/);
+ const modeToggle = markup.match(
+ /]*aria-label="Способ подключения"[^>]*>/,
+ )?.[0];
+ assert.ok(modeToggle);
+ assert.doesNotMatch(modeToggle, /\bdisabled(?:=|\s|>)/);
+ assertCanonicalConnectionCopy(markup);
+});
+
+test("network step 02 is disclosed only by a selected device or a real network intent", () => {
+ assert.equal(shouldRevealProvisioningNetworkStep({
+ deviceExplicitlySelectedOrAdmitted: false,
+ networkIntentStarted: false,
+ }), false);
+ assert.equal(shouldRevealProvisioningNetworkStep({
+ deviceExplicitlySelectedOrAdmitted: true,
+ networkIntentStarted: false,
+ }), true);
+ assert.equal(shouldRevealProvisioningNetworkStep({
+ deviceExplicitlySelectedOrAdmitted: false,
+ networkIntentStarted: true,
+ }), true);
+});
+
+test("a current-authority Bluetooth scan owns Step 01 and no network step", () => {
+ const state = reopenedPhysicalState();
+ state.physical_command = null;
+ state.connection_policy.actions["provision-fresh-device"] = {
+ allowed: true,
+ reason_codes: [],
+ target_source: "fresh-scan",
+ required_transport_ref: null,
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ };
+ state.connection_policy.allowed_actions.push("provision-fresh-device");
+ const authority = reopenedPhysicalAuthority();
+ const markup = renderProvisioningWithCurrentPendingAction({
+ controller: {
+ ...provisioningController(state),
+ pendingAction: "scan",
+ isSnapshotRuntimeCurrent: () => true,
+ isConnectionActionAuthorityCurrent: (candidate) =>
+ connectionActionAuthorityMatches(candidate, authority),
+ },
+ desiredMode: "bridge",
+ });
+
+ assert.match(markup, /01<\/span>/);
+ assert.match(markup, /Поиск Bluetooth · 6 с/);
+ assert.equal(
+ (markup.match(/class="nodedc-activity-indicator"/g) ?? []).length,
+ 1,
+ );
+ assert.doesNotMatch(markup, /Найдено: 0|Совпадений нет|Результатов:/);
+ assert.equal(
+ buttonMarkupWithText(markup, "Подключить новый K1").length,
+ 0,
+ );
+ assert.doesNotMatch(markup, /02<\/span>|03<\/span>/);
+ assert.doesNotMatch(markup, /Настройка сети|Название общей сети Wi‑Fi/);
+ assertCanonicalConnectionCopy(markup);
+});
+
+test("stale raw Bluetooth results stay hidden until an explicit search", () => {
+ const state = durableTopologyState();
+ state.semantic_topology_store = {
+ ...state.semantic_topology_store,
+ status: "empty",
+ configured_offline_evidence: false,
+ record: null,
+ };
+ state.devices = [{
+ device_id: "replacement-k1",
+ name: "Replacement K1",
+ rssi: -44,
+ connectable: true,
+ likely_k1: true,
+ }];
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: ["scan-ble", "provision-fresh-device"],
+ actions: {
+ "scan-ble": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "none",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ "provision-fresh-device": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "fresh-scan",
+ required_transport_ref: null,
+ required_connection_mode: "bridge",
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ },
+ };
+
+ const markup = renderProvisioning({
+ controller: {
+ ...provisioningController(state),
+ // The raw promise is still settling, but its click authority has already
+ // been retired by the completed discovery-generation transition.
+ pendingAction: "scan",
+ isSnapshotRuntimeCurrent: () => true,
+ isConnectionActionAuthorityCurrent: () => false,
+ },
+ desiredMode: "bridge",
+ });
+
+ assert.match(markup, /01<\/span>/);
+ assert.doesNotMatch(markup, /Replacement K1/);
+ assert.match(markup, />Найти по Bluetooth<\/button>/);
+ assert.doesNotMatch(markup, /aria-busy="true"/);
+ assert.doesNotMatch(markup, /02<\/span>|03<\/span>/);
+ assert.doesNotMatch(markup, /Завершаем предыдущее действие|Завершаем ранее начатое действие/);
+ assert.doesNotMatch(markup, /Настройка сети|Сначала найдите и выберите K1/);
+ assertCanonicalConnectionCopy(markup);
+});
+
+test("a completed search never adopts another tab's discovery generation", () => {
+ const state = durableTopologyState();
+ state.ble_discovery_generation = 8;
+ state.semantic_topology_store = {
+ status: "empty",
+ configured_offline_evidence: false,
+ live_connection_authority: false,
+ reason_code: null,
+ record: null,
+ };
+ state.devices = [{
+ device_id: "generation-eight-result",
+ name: "Generation eight",
+ rssi: -46,
+ connectable: true,
+ likely_k1: true,
+ }];
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: ["scan-ble"],
+ actions: {
+ "scan-ble": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "none",
+ required_transport_ref: null,
+ required_connection_mode: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ },
+ };
+
+ const markup = renderProvisioningAfterSearch({
+ controller: provisioningController(state),
+ desiredMode: "bridge",
+ }, { completedDiscoveryGeneration: 7 });
+
+ assert.doesNotMatch(markup, /generation-eight-result|Generation eight/);
+ assert.match(markup, /Найти по Bluetooth/);
+ assert.doesNotMatch(markup, /Результатов:|Совпадений нет/);
+ assertCanonicalConnectionCopy(markup);
+});
+
+test("change-network opens credentials immediately and Apply does not refresh", () => {
+ const source = readFileSync(provisioningSourceUrl, "utf8");
+ const apply = sourceSlice(source, "const submitConnect", "const verifyAppliedNetwork");
+ assert.doesNotMatch(apply, /scanWithResult\(|verifyConnection\(|candidateRefresh/);
+ assert.equal((source.match(/buttonLabel:\s*"Применить"/g) ?? []).length, 3);
+});
+
+test("Step-2 credentials and Apply share one canonical field stack", () => {
+ const source = readFileSync(provisioningSourceUrl, "utf8");
+ const styles = readFileSync(provisioningStylesUrl, "utf8");
+ const form = sourceSlice(
+ source,
+ "const provisioningDraftContent = (",
+ "return (",
+ );
+
+ assert.match(
+ form,
+ /:\s*\(\s*/,
+ );
+ assert.match(form, /disabled=\{provisioningFieldsDisabled\}/);
+ assert.match(form, /value=\{connectionAttemptOwnsDraft \? "" : password\}/);
+ assert.match(
+ styles,
+ /\.field-stack,\s*\.session-form\s*\{\s*display:\s*grid;\s*gap:\s*0\.85rem;/,
+ );
+});
+
+test("applied-network recovery spends the old intent and admits only explicit server-authorized paths", () => {
+ const source = readFileSync(provisioningSourceUrl, "utf8");
+ const apply = sourceSlice(source, "const submitConnect", "const verifyAppliedNetwork");
+ const recoveryState = sourceSlice(
+ source,
+ "const appliedNetworkAttempt",
+ "const localProvisioningPrerequisitesReady",
+ );
+ const appliedRecoveryTarget = sourceSlice(
+ source,
+ "const appliedNetworkRecoveryTarget",
+ "const networkRecoveryModeLabel",
+ );
+ const connectAuthority = sourceSlice(
+ source,
+ "const canConnect",
+ "const scanAllowedByPolicy",
+ );
+ const scanAuthority = sourceSlice(
+ source,
+ "const backendScanAllowed",
+ "const physicalStopRecoverySettling",
+ );
+ const search = sourceSlice(source, "const repeatDeviceScan", "const submitConnect");
+ const recovery = sourceSlice(
+ source,
+ "const verifyAppliedNetwork",
+ "const verifyConnectionRecoveryTarget",
+ );
+ const prepare = sourceSlice(
+ source,
+ "const prepareReconfiguration",
+ "const changeDesiredConnectionMode",
+ );
+ const mode = sourceSlice(
+ source,
+ "const changeDesiredConnectionMode",
+ "const selectFreshDevice",
+ );
+ const selection = sourceSlice(
+ source,
+ "const selectFreshDevice",
+ "// Only an explicit public search",
+ );
+ const recoveryEscape = sourceSlice(
+ source,
+ "const recoverBySelectingAnotherDevice",
+ "const attemptButtonLabel",
+ );
+
+ assert.doesNotMatch(apply, /verifyConnection\(/);
+ assert.match(recoveryState, /state\?\.connection_attempt\?\.phase === "network_applied"/);
+ assert.match(
+ recoveryState,
+ /connectionAttemptOwnsAppliedNetworkRecovery\(appliedNetworkAttempt\)/,
+ );
+ assert.match(
+ recoveryState,
+ /`\$\{snapshotRuntimeId\}:\$\{appliedNetworkAttempt\.attempt_id\}`/,
+ );
+ assert.match(
+ recoveryState,
+ /escapedAppliedAttemptKey === appliedAttemptRecoveryKey[\s\S]*?\|\| appliedRecoveryReconfigurationPrepared/,
+ );
+ assert.match(
+ recoveryState,
+ /connectionAttemptOwnsAppliedNetworkRecovery\(appliedNetworkAttempt\)[\s\S]*?&& !appliedRecoveryExplicitlyEscaped/,
+ );
+ assert.match(recoveryState, /const networkRecoveryRequired = unresolvedAppliedAttempt !== null/);
+ assert.match(
+ recoveryState,
+ /const appliedControlSettlementPending = Boolean\([\s\S]*?\["accepted", "running"\]\.includes\(unresolvedAppliedAttempt\.status\)[\s\S]*?safe_next_action === "wait-for-current-attempt"/,
+ );
+ assert.doesNotMatch(
+ appliedRecoveryTarget,
+ /\bconnectionMode\b|selectedDeviceId|selectedTarget/,
+ );
+ assert.match(
+ appliedRecoveryTarget,
+ /serverBoundAppliedNetworkObservationTarget\(\s*state,\s*unresolvedAppliedAttempt\.connection_mode/,
+ );
+
+ assert.match(connectAuthority, /!unresolvedAppliedAttempt/);
+ assert.match(scanAuthority, /!networkRecoveryRequired/);
+ assert.match(apply, /\|\| unresolvedAppliedAttempt/);
+ assert.match(apply, /provisioningIntentKey\(null\)/);
+ assert.match(mode, /if \(modeResetInFlight\) return/);
+ assert.match(mode, /reset_scenario: true/);
+ assert.match(selection, /networkRecoveryRequired\s*\|\|/);
+ assert.doesNotMatch(
+ source,
+ /powerConfirmed|powerConfirmationEpoch|resetPowerConfirmation|Питание включено|title="Питание"/,
+ );
+ assert.match(source, /value=\{connectionMode\}[\s\S]*?onChange=\{\(value\) => void changeDesiredConnectionMode\(value\)\}/);
+ assert.match(
+ sourceSlice(source, "
void repeatDeviceScan\(\{\s*appliedRecoveryEscape: true/,
+ );
+ assert.match(source, /Начать новый поиск Bluetooth/);
+ assert.match(source, /Подтверждаем управляющее подключение/);
+ assert.match(source, /BLE-команда не повторяется/);
+ assert.match(
+ source,
+ /status=\{\s*searchActive[\s\S]*?: physicalRecoveryRequired[\s\S]*?: networkRecoveryRequired[\s\S]*?>\s*\{searchActive \? \([\s\S]*?Поиск Bluetooth · \{scanSecondsRemaining \?\? 6\} с[\s\S]*?: networkRecoveryRequired && !connectionAttemptSettling \? \(/,
+ );
+
+ assert.match(
+ source,
+ /if \(!appliedNetworkAttempt\) return;[\s\S]*?setExplicitProvisioningDraft\(null\);[\s\S]*?setSsid\(""\);[\s\S]*?setPassword\(""\)/,
+ );
+ const appliedAttemptEffect = sourceSlice(
+ source,
+ "if (!appliedNetworkAttempt) return;",
+ "if (connectionAttemptView !== \"retired\") return;",
+ );
+ assert.doesNotMatch(appliedAttemptEffect, /setConnectionAttemptPresentation\(null\)/);
+ assert.doesNotMatch(apply, /void submitConnect|repeatDeviceScan\(/);
+
+ const settlingState = durableTopologyState();
+ settlingState.connection_attempt = {
+ schema_version: "missioncore.xgrids-k1-connection-attempt/v1",
+ attempt_id: "network-apply-settling",
+ connection_mode: "bridge",
+ status: "running",
+ phase: "network_applied",
+ control_state: "unknown",
+ stage: "host-route-and-control-endpoint",
+ public_error_code: null,
+ side_effect_status: "none",
+ safe_next_action: "wait-for-current-attempt",
+ automatic_retry: false,
+ accepted_at: OBSERVED_AT,
+ completed_at: null,
+ recovery_operation_id: null,
+ timeline: [],
+ };
+ const settlingMarkup = renderProvisioning({
+ controller: provisioningController(settlingState),
+ desiredMode: "bridge",
+ });
+ assert.match(settlingMarkup, /Подтверждаем управляющее подключение/);
+ assert.match(settlingMarkup, /BLE-команда не повторяется/);
+ assert.doesNotMatch(settlingMarkup, /Сеть (?:готова|настроена)|Сетевые настройки уже применены/);
+ assert.equal(
+ (settlingMarkup.match(/class="connection-action-progress"/g) ?? []).length,
+ 1,
+ );
+ assert.doesNotMatch(
+ settlingMarkup,
+ /Проверить подключение без изменения сети|Начать выбор другого K1|Начать новый поиск Bluetooth/,
+ );
+});
+
+
+test("incomplete historical recovery policy stays outside the normal connection form", () => {
+ const state = durableTopologyState();
+ state.network_mutation_ledger = {
+ ...state.network_mutation_ledger,
+ status: "unresolved",
+ mutation_allowed: false,
+ operation_id: "operation-002",
+ transport_ref: "ble-k1-001",
+ intended_mode: "bridge",
+ stage: "observing",
+ revision: 6,
+ };
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: ["observe-configured-device-network"],
+ actions: {
+ "observe-configured-device-network": {
+ allowed: true,
+ reason_codes: [],
+ target_source: "durable-configured-state",
+ required_transport_ref: "ble-k1-001",
+ required_connection_mode: null,
+ requires_live_gatt_validation: true,
+ automatic_retry: false,
+ },
+ },
+ recommended_action: "observe-configured-device-network",
+ };
+
+ const markup = renderProvisioning({
+ controller: provisioningController(state),
+ desiredMode: "bridge",
+ });
+
+ assert.doesNotMatch(markup, /Сервер пока не выдал одну точную цель/);
+ assert.doesNotMatch(markup, /не подменяют UUID и режим незавершённой операции/);
+ assert.doesNotMatch(markup, /Проверить .*без записи/);
+ assert.equal(
+ buttonMarkupWithText(markup, "Подключиться к сохранённому устройству").length,
+ 0,
+ );
+ assert.doesNotMatch(markup, /class="connection-action-progress"/);
+ assert.doesNotMatch(markup, /Проверить связь/);
+ assert.doesNotMatch(markup, /Подключить K1 к общей сети/);
+});
+
+
+test("configured endpoint policy explains missing durable evidence without implying BLE", () => {
+ const state = durableTopologyState();
+ state.semantic_topology_store = {
+ status: "empty",
+ configured_offline_evidence: false,
+ live_connection_authority: false,
+ reason_code: null,
+ record: null,
+ };
+ state.connection_policy = {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1",
+ facts: { retained_context_is_presence: false },
+ allowed_actions: [],
+ actions: {
+ "inspect-configured-endpoint": {
+ allowed: false,
+ reason_codes: ["configured-endpoint-unavailable"],
+ target_source: "configured-topology",
+ required_transport_ref: null,
+ requires_live_gatt_validation: false,
+ automatic_retry: false,
+ },
+ },
+ recommended_action: "inspect-host-network",
+ };
+
+ const guidance = connectionPolicyOperatorGuidance(
+ state,
+ "inspect-configured-endpoint",
+ );
+ assert.match(guidance.reason, /Нет подтверждённого сохранённого адреса K1/);
+ assert.match(guidance.nextAction, /Проверьте активную локальную сеть/);
+ assert.doesNotMatch(`${guidance.reason} ${guidance.nextAction}`, /Bluetooth|BLE/);
+});
+
+test("spatial controls distinguish control authority from data authority", () => {
+ const live = runtimeState();
+ assert.deepEqual(k1SpatialAuthorityState(live), {
+ controlAuthoritative: true,
+ dataAuthoritative: true,
+ softwareCommanded: true,
+ authorityFailure: null,
+ });
+
+ live.connection_supervisor = supervisor({ control: true, data: false });
+ const waiting = k1SpatialAuthorityState(live);
+ assert.equal(waiting.controlAuthoritative, true);
+ assert.equal(waiting.dataAuthoritative, false);
+ assert.equal(waiting.softwareCommanded, true);
+ assert.match(waiting.authorityFailure, /Телеметрия скрыта/);
+
+ live.connection_supervisor = supervisor({ control: false, data: false });
+ const lost = k1SpatialAuthorityState(live);
+ assert.equal(lost.controlAuthoritative, false);
+ assert.equal(lost.dataAuthoritative, false);
+ assert.equal(lost.softwareCommanded, false);
+ assert.match(lost.authorityFailure, /команды устройству запрещены/);
+});
+
+test("spatial controls explain the bounded K1 calibration wait before point data", () => {
+ const acquisition = runtimeState().acquisition;
+ assert.ok(acquisition);
+ for (const state of ["awaiting_external_start", "starting"]) {
+ const phase = k1SpatialPhasePresentation({ ...acquisition, state }, true);
+ assert.equal(phase.busy, true);
+ assert.match(phase.label, /калибруется.*облако точек/i);
+ assert.match(phase.detail, /десятки секунд/i);
+ assert.match(phase.detail, /не перемещайте/i);
+ assert.doesNotMatch(phase.detail, /30\s*с/i);
+ }
+});
+
+test("contour health never promotes selection or replay metrics to live authority", () => {
+ const selectedOnly = {
+ phase: "starting",
+ sourceMode: "idle",
+ activeDevice: {
+ pluginId: "xgrids-k1",
+ modelId: "lixelkity-k1",
+ displayName: "XGRIDS LixelKity K1",
+ instanceId: "device-k1-001",
+ endpointLabel: "192.168.68.52",
+ },
+ deviceSession: {
+ sessionId: "device-session-001",
+ deviceId: "device-k1-001",
+ compatibilityProfileId: PROFILE_ID,
+ connectivity: "unknown",
+ },
+ metrics: { aiFrameRateHz: 8 },
+ };
+ const unverified = contourRuntimeAuthorityPresentation(selectedOnly);
+ assert.equal(unverified.controlledDevice, null);
+ assert.equal(unverified.aiActive, false);
+
+ const live = contourRuntimeAuthorityPresentation({
+ ...selectedOnly,
+ phase: "streaming",
+ sourceMode: "live",
+ deviceSession: { ...selectedOnly.deviceSession, connectivity: "connected" },
+ });
+ assert.equal(live.controlledDevice.instanceId, "device-k1-001");
+ assert.equal(live.aiActive, true);
+
+ const replay = contourRuntimeAuthorityPresentation({
+ ...selectedOnly,
+ phase: "replaying",
+ sourceMode: "replay",
+ deviceSession: { ...selectedOnly.deviceSession, connectivity: "connected" },
+ });
+ assert.equal(replay.aiActive, false);
+});
diff --git a/apps/control-station/test/liveReceiverWatchdog.test.mjs b/apps/control-station/test/liveReceiverWatchdog.test.mjs
index e0cf274..d13a14f 100644
--- a/apps/control-station/test/liveReceiverWatchdog.test.mjs
+++ b/apps/control-station/test/liveReceiverWatchdog.test.mjs
@@ -4,9 +4,14 @@ import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
+let advanceLiveReceiverOpenWatchdog;
let advanceLiveReceiverWatchdog;
+let initialLiveReceiverOpenWatchdogState;
let initialLiveReceiverWatchdogState;
let initialLiveReceiverRecoveryState;
+let liveReceiverRecoveryAuthorityIsCurrent;
+let liveReceiverRecoveryRetryDelay;
+let liveRerunRecoveryAuthorityIdentity;
let requestLiveReceiverRecovery;
before(async () => {
@@ -16,9 +21,14 @@ before(async () => {
server: { middlewareMode: true },
});
({
+ advanceLiveReceiverOpenWatchdog,
advanceLiveReceiverWatchdog,
+ initialLiveReceiverOpenWatchdogState,
initialLiveReceiverRecoveryState,
initialLiveReceiverWatchdogState,
+ liveReceiverRecoveryAuthorityIsCurrent,
+ liveReceiverRecoveryRetryDelay,
+ liveRerunRecoveryAuthorityIdentity,
requestLiveReceiverRecovery,
} = await server.ssrLoadModule("/src/core/observation/liveReceiverWatchdog.ts"));
});
@@ -92,3 +102,217 @@ test("startup failures request only three bounded viewer restarts", () => {
assert.equal(exhausted.attempt, 3);
assert.equal(exhausted.state.awaitingRecovery, false);
});
+
+function livePointCloudDescriptor(overrides = {}) {
+ return {
+ id: "xgrids-k1:lixelkity-k1:sensor.lidar.primary",
+ sourceId: "sensor.lidar.primary",
+ semanticChannelId: "spatial.point-cloud.live",
+ label: "K1 point cloud",
+ description: "live",
+ modality: "point-cloud",
+ role: "primary",
+ availability: "streaming",
+ transport: "rerun-grpc",
+ endpointLabel: "Rerun gRPC",
+ previewUrl: "rerun+http://127.0.0.1:9877/proxy",
+ delivery: null,
+ activation: null,
+ presentationLease: null,
+ provider: {
+ pluginId: "xgrids-k1",
+ pluginVersion: "0.1.0",
+ modelId: "lixelkity-k1",
+ compatibilityProfileId: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
+ },
+ binding: {
+ deviceId: "device-k1-001",
+ deviceSessionId: "device-session-001",
+ acquisitionId: "acquisition-001",
+ },
+ capabilities: {
+ overlay: false,
+ fullscreen: true,
+ resizable: false,
+ defaultVisible: true,
+ timelineMode: "live-only",
+ seekable: false,
+ sessionRecording: false,
+ clockId: "acquisition-001",
+ spatialRegistration: "native",
+ },
+ ...overrides,
+ };
+}
+
+const liveSpatialSource = {
+ id: "acquisition-001",
+ url: "rerun+http://127.0.0.1:9877/proxy",
+ label: "Live",
+ kind: "rerun-grpc",
+};
+
+test("exact live Rerun authority gets durable retries with capped delay", () => {
+ const authority = liveRerunRecoveryAuthorityIdentity(
+ livePointCloudDescriptor(),
+ liveSpatialSource,
+ );
+ assert.ok(authority);
+ assert.equal(liveReceiverRecoveryAuthorityIsCurrent(authority, authority), true);
+
+ let state = initialLiveReceiverRecoveryState();
+ const delays = [];
+ for (let attempt = 1; attempt <= 8; attempt += 1) {
+ const recovery = requestLiveReceiverRecovery(state, {
+ activeAuthorityIdentity: authority,
+ expectedAuthorityIdentity: authority,
+ });
+ assert.equal(recovery.signal, "retry");
+ assert.equal(recovery.attempt, attempt);
+ delays.push(recovery.delayMs);
+ state = recovery.state;
+ }
+ assert.deepEqual(delays, [400, 1_000, 2_000, 5_000, 5_000, 5_000, 5_000, 5_000]);
+ assert.equal(state.attempts, 8);
+ assert.equal(liveReceiverRecoveryRetryDelay(100), 5_000);
+});
+
+test("Rerun durable retry fails closed when exact authority is replaced", () => {
+ const authority = liveRerunRecoveryAuthorityIdentity(
+ livePointCloudDescriptor(),
+ liveSpatialSource,
+ );
+ assert.ok(authority);
+ const replacement = liveRerunRecoveryAuthorityIdentity(
+ livePointCloudDescriptor({
+ binding: {
+ deviceId: "device-k1-001",
+ deviceSessionId: "device-session-002",
+ acquisitionId: "acquisition-002",
+ },
+ capabilities: {
+ ...livePointCloudDescriptor().capabilities,
+ clockId: "acquisition-002",
+ },
+ }),
+ { ...liveSpatialSource, id: "acquisition-002" },
+ );
+ assert.ok(replacement);
+
+ const stale = requestLiveReceiverRecovery(initialLiveReceiverRecoveryState(), {
+ activeAuthorityIdentity: replacement,
+ expectedAuthorityIdentity: authority,
+ });
+ assert.equal(stale.signal, "stale");
+ assert.equal(stale.delayMs, null);
+ assert.deepEqual(stale.state, initialLiveReceiverRecoveryState());
+});
+
+test("connecting Rerun authority requires an exact recovery generation lease", () => {
+ const recoveryLease = {
+ kind: "active-stream-recovery",
+ runtimeId: "runtime-recovery-001",
+ acquisitionId: "acquisition-001",
+ acquisitionStateRevision: 4,
+ producerGeneration: 17,
+ recoveryGeneration: 6,
+ };
+ const recoveredAuthority = liveRerunRecoveryAuthorityIdentity(
+ livePointCloudDescriptor({
+ availability: "connecting",
+ presentationLease: recoveryLease,
+ }),
+ liveSpatialSource,
+ );
+ assert.ok(recoveredAuthority);
+ assert.equal(
+ liveRerunRecoveryAuthorityIdentity(
+ livePointCloudDescriptor({ availability: "connecting" }),
+ liveSpatialSource,
+ ),
+ null,
+ );
+ assert.equal(
+ liveRerunRecoveryAuthorityIdentity(
+ livePointCloudDescriptor({
+ availability: "connecting",
+ presentationLease: { ...recoveryLease, producerGeneration: 0 },
+ }),
+ liveSpatialSource,
+ ),
+ null,
+ );
+});
+
+test("opening receiver gets bounded rolling patience while backend publication advances", () => {
+ let openState = initialLiveReceiverOpenWatchdogState(0, 0);
+ let recoveryState = initialLiveReceiverRecoveryState();
+
+ const samples = [
+ [134, 3_999, "wait-for-store"],
+ [266, 4_000, "refresh-receiver"],
+ ];
+ for (const [backendActivitySequence, nowMs, expectedSignal] of samples) {
+ const observed = advanceLiveReceiverOpenWatchdog(
+ openState,
+ recoveryState,
+ backendActivitySequence,
+ nowMs,
+ );
+ assert.equal(observed.signal, expectedSignal);
+ assert.equal(observed.state.lastBackendActivitySequence, backendActivitySequence);
+ assert.deepEqual(observed.recoveryState, {
+ attempts: 0,
+ awaitingRecovery: false,
+ });
+ openState = observed.state;
+ recoveryState = observed.recoveryState;
+ }
+});
+
+test("unchanged opening sequence delegates to bounded receiver restart", () => {
+ const openState = initialLiveReceiverOpenWatchdogState(486);
+ const recoveryState = initialLiveReceiverRecoveryState();
+ const unchanged = advanceLiveReceiverOpenWatchdog(
+ openState,
+ recoveryState,
+ 486,
+ );
+
+ assert.equal(unchanged.signal, "restart-receiver");
+ const restart = requestLiveReceiverRecovery(unchanged.recoveryState);
+ assert.equal(restart.signal, "retry");
+ assert.equal(restart.attempt, 1);
+});
+
+test("fresh backend progress preserves earlier restart debt until viewer admission", () => {
+ const openState = initialLiveReceiverOpenWatchdogState(486, 0);
+ const consumedRestart = requestLiveReceiverRecovery(initialLiveReceiverRecoveryState());
+ const observed = advanceLiveReceiverOpenWatchdog(
+ openState,
+ consumedRestart.state,
+ 600,
+ 3_999,
+ );
+
+ assert.equal(observed.signal, "wait-for-store");
+ assert.deepEqual(observed.recoveryState, {
+ attempts: 1,
+ awaitingRecovery: true,
+ });
+});
+
+test("aged active receiver refresh does not spend or erase restart debt", () => {
+ const openState = initialLiveReceiverOpenWatchdogState(486, 0);
+ const consumedRestart = requestLiveReceiverRecovery(initialLiveReceiverRecoveryState());
+ const observed = advanceLiveReceiverOpenWatchdog(
+ openState,
+ consumedRestart.state,
+ 900,
+ 4_000,
+ );
+
+ assert.equal(observed.signal, "refresh-receiver");
+ assert.deepEqual(observed.recoveryState, consumedRestart.state);
+ assert.equal(observed.openForMs, 4_000);
+});
diff --git a/apps/control-station/test/liveViewerDiagnostics.test.mjs b/apps/control-station/test/liveViewerDiagnostics.test.mjs
new file mode 100644
index 0000000..0447951
--- /dev/null
+++ b/apps/control-station/test/liveViewerDiagnostics.test.mjs
@@ -0,0 +1,216 @@
+import assert from "node:assert/strict";
+import { after, before, test } from "node:test";
+
+import { createServer } from "vite";
+
+let createLiveViewerDiagnosticLifecycle;
+let createAbortFencedBuildVerifier;
+let createUiBuildStaleCoordinator;
+let liveViewerDiagnosticBody;
+let server;
+let uiBuildIdFromModuleScripts;
+
+before(async () => {
+ server = await createServer({
+ appType: "custom",
+ logLevel: "silent",
+ server: { middlewareMode: true },
+ });
+ ({
+ createAbortFencedBuildVerifier,
+ createLiveViewerDiagnosticLifecycle,
+ createUiBuildStaleCoordinator,
+ liveViewerDiagnosticBody,
+ uiBuildIdFromModuleScripts,
+ } = await server.ssrLoadModule("/src/core/observation/liveViewerDiagnostics.ts"));
+});
+
+after(async () => {
+ await server?.close();
+});
+
+function createFakeScheduler() {
+ let now = 0;
+ let nextHandle = 1;
+ const jobs = new Map();
+ const schedule = (callback, delay, interval) => {
+ const handle = nextHandle;
+ nextHandle += 1;
+ jobs.set(handle, { callback, due: now + delay, interval });
+ return handle;
+ };
+ const clear = (handle) => jobs.delete(handle);
+ return {
+ scheduler: {
+ setTimeout: (callback, delay) => schedule(callback, delay, null),
+ clearTimeout: clear,
+ setInterval: (callback, delay) => schedule(callback, delay, delay),
+ clearInterval: clear,
+ },
+ advance(milliseconds) {
+ const target = now + milliseconds;
+ while (true) {
+ const next = [...jobs.entries()]
+ .filter(([, job]) => job.due <= target)
+ .sort((left, right) => left[1].due - right[1].due)[0];
+ if (!next) break;
+ const [handle, job] = next;
+ now = job.due;
+ if (job.interval === null) jobs.delete(handle);
+ else job.due += job.interval;
+ job.callback();
+ }
+ now = target;
+ },
+ pending: () => jobs.size,
+ };
+}
+
+const lineage = (viewerInstanceId, lifecycleGeneration = 1) => ({
+ uiBuildId: "/assets/index-abcdefgh.js",
+ documentInstanceId: "00000000-0000-4000-8000-000000000001",
+ viewerInstanceId,
+ lifecycleGeneration,
+});
+
+test("mounted viewer admission terminally fences 60 seconds of stale timers", () => {
+ const clock = createFakeScheduler();
+ const callbacks = [];
+ const posts = [];
+ const lifecycle = createLiveViewerDiagnosticLifecycle({
+ lineage: lineage("00000000-0000-4000-8000-000000000011"),
+ scheduler: clock.scheduler,
+ diagnosticPoster: (event, eventLineage) => posts.push({ event, eventLineage }),
+ buildVerifier: () => undefined,
+ });
+
+ lifecycle.armAdmissionTimeout(() => callbacks.push("timeout"), 12_000);
+ lifecycle.armAdmissionInterval(() => callbacks.push("discovery"), 100);
+ lifecycle.markAdmitted();
+ lifecycle.post({ eventCode: "live_receiver_active_store_admitted" });
+ clock.advance(60_000);
+
+ assert.deepEqual(callbacks, []);
+ assert.equal(clock.pending(), 0);
+ assert.equal(posts.length, 1);
+ assert.equal(posts[0].eventLineage.lifecycleGeneration, 1);
+});
+
+test("two mounted viewers keep timer and diagnostic lineage isolated", () => {
+ const clock = createFakeScheduler();
+ const posts = [];
+ const first = createLiveViewerDiagnosticLifecycle({
+ lineage: lineage("00000000-0000-4000-8000-000000000021"),
+ scheduler: clock.scheduler,
+ diagnosticPoster: (event, eventLineage) => posts.push({ event, eventLineage }),
+ buildVerifier: () => undefined,
+ });
+ const second = createLiveViewerDiagnosticLifecycle({
+ lineage: lineage("00000000-0000-4000-8000-000000000022", 7),
+ scheduler: clock.scheduler,
+ diagnosticPoster: (event, eventLineage) => posts.push({ event, eventLineage }),
+ buildVerifier: () => undefined,
+ });
+
+ first.armAdmissionTimeout(() => {
+ first.post({ eventCode: "live_receiver_error" });
+ }, 12_000);
+ second.armAdmissionTimeout(() => {
+ second.post({ eventCode: "live_receiver_error" });
+ }, 12_000);
+ first.markAdmitted();
+ clock.advance(12_000);
+
+ assert.equal(posts.length, 1);
+ assert.equal(
+ posts[0].eventLineage.viewerInstanceId,
+ "00000000-0000-4000-8000-000000000022",
+ );
+ assert.equal(posts[0].eventLineage.lifecycleGeneration, 7);
+});
+
+test("stale-build and unmount fence callbacks before one reload", () => {
+ const clock = createFakeScheduler();
+ const order = [];
+ const posts = [];
+ const lifecycle = createLiveViewerDiagnosticLifecycle({
+ lineage: lineage("00000000-0000-4000-8000-000000000031"),
+ scheduler: clock.scheduler,
+ diagnosticPoster: (event) => posts.push(event),
+ buildVerifier: () => undefined,
+ });
+ lifecycle.armAdmissionTimeout(() => {
+ lifecycle.post({ eventCode: "live_receiver_error" });
+ }, 12_000);
+ const coordinator = createUiBuildStaleCoordinator({
+ scheduleReload: (callback, delay) => {
+ order.push(`scheduled:${delay}`);
+ clock.scheduler.setTimeout(callback, delay);
+ },
+ reload: () => order.push("reload"),
+ });
+ coordinator.subscribe(() => {
+ order.push("local-transports-closed");
+ lifecycle.dispose();
+ });
+
+ coordinator.report({
+ loadedUiBuildId: "/assets/index-abcdefgh.js",
+ expectedUiBuildId: "/assets/index-ijklmnop.js",
+ });
+ coordinator.report({
+ loadedUiBuildId: "/assets/index-abcdefgh.js",
+ expectedUiBuildId: "/assets/index-qrstuvwx.js",
+ });
+ clock.advance(60_000);
+ lifecycle.post({ eventCode: "live_receiver_error" });
+
+ assert.deepEqual(order, ["local-transports-closed", "scheduled:50", "reload"]);
+ assert.deepEqual(posts, []);
+ assert.equal(lifecycle.active(), false);
+});
+
+test("last unsubscribe fences an already queued build verification callback", () => {
+ const controller = new AbortController();
+ const observedSignals = [];
+ const queuedVerify = createAbortFencedBuildVerifier(
+ controller.signal,
+ (signal) => observedSignals.push(signal),
+ );
+
+ queuedVerify();
+ // stopBuildMonitor aborts the locally captured controller when the last
+ // mounted viewer unsubscribes. A browser callback already queued before the
+ // interval/listener removal can still run once, but cannot start a fetch.
+ controller.abort();
+ queuedVerify();
+
+ assert.deepEqual(observedSignals, [controller.signal]);
+ assert.equal(observedSignals[0].aborted, true);
+});
+
+test("diagnostic body and build id retain exact document/viewer/build lineage", () => {
+ const eventLineage = lineage("00000000-0000-4000-8000-000000000041", 9);
+ assert.deepEqual(
+ liveViewerDiagnosticBody(
+ { eventCode: "live_receiver_recovered", streamId: "acquisition-42" },
+ eventLineage,
+ ),
+ {
+ schema_version: "missioncore.live-viewer-diagnostic/v2",
+ event_code: "live_receiver_recovered",
+ ui_build_id: "/assets/index-abcdefgh.js",
+ document_instance_id: "00000000-0000-4000-8000-000000000001",
+ viewer_instance_id: "00000000-0000-4000-8000-000000000041",
+ lifecycle_generation: 9,
+ stream_id: "acquisition-42",
+ },
+ );
+ assert.equal(
+ uiBuildIdFromModuleScripts(
+ ["https://mission.local/assets/index-dT7dN-y4.js"],
+ "https://mission.local/park",
+ ),
+ "/assets/index-dT7dN-y4.js",
+ );
+});
diff --git a/apps/control-station/test/observationSources.test.mjs b/apps/control-station/test/observationSources.test.mjs
index f9f960b..c0f39d2 100644
--- a/apps/control-station/test/observationSources.test.mjs
+++ b/apps/control-station/test/observationSources.test.mjs
@@ -12,6 +12,20 @@ let openObservationSource;
let shouldRestartObservationSource;
let consumeCameraLeaseRetry;
let resetCameraLeaseRetryBudget;
+let cameraTransportRecoveryIsCurrent;
+let cameraTransportCanOpen;
+let cameraPendingQueueCanAccept;
+let cameraTransportCloseRecoveryMessage;
+let createCameraStartupWatchdog;
+let cameraStartupWatchdogRecoveryMessage;
+let CAMERA_FIRST_MEDIA_TIMEOUT_MS;
+let CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS;
+let liveCameraPlaybackAuthorityIdentity;
+let initialCameraPlaybackRecoveryState;
+let reduceCameraPlaybackRecovery;
+let cameraTransportCallbackIsCurrent;
+let cameraBrowserTransportIdentity;
+let liveRerunRecoveryAuthorityIdentity;
let initialObservationWindowRect;
let ObservationTimeline;
let shouldCaptureWorkspacePointer;
@@ -48,12 +62,33 @@ before(async () => {
({ openObservationSource, shouldRestartObservationSource } = await server.ssrLoadModule(
"/src/core/observation/layoutPolicy.ts",
));
- ({ consumeCameraLeaseRetry, resetCameraLeaseRetryBudget } = await server.ssrLoadModule(
- "/src/components/MseFmp4WebSocketPlayer.tsx",
+ ({
+ consumeCameraLeaseRetry,
+ resetCameraLeaseRetryBudget,
+ cameraTransportRecoveryIsCurrent,
+ cameraTransportCanOpen,
+ cameraPendingQueueCanAccept,
+ cameraTransportCloseRecoveryMessage,
+ createCameraStartupWatchdog,
+ cameraStartupWatchdogRecoveryMessage,
+ CAMERA_FIRST_MEDIA_TIMEOUT_MS,
+ CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS,
+ } = await server.ssrLoadModule("/src/components/MseFmp4WebSocketPlayer.tsx"));
+ ({
+ liveCameraPlaybackAuthorityIdentity,
+ initialCameraPlaybackRecoveryState,
+ reduceCameraPlaybackRecovery,
+ cameraTransportCallbackIsCurrent,
+ cameraBrowserTransportIdentity,
+ } = await server.ssrLoadModule(
+ "/src/core/observation/liveCameraRecovery.ts",
));
({ initialObservationWindowRect, shouldCaptureWorkspacePointer } = await server.ssrLoadModule(
"/src/components/FloatingObservationWindow.tsx",
));
+ ({ liveRerunRecoveryAuthorityIdentity } = await server.ssrLoadModule(
+ "/src/core/observation/liveReceiverWatchdog.ts",
+ ));
({
ObservationTimeline,
normalizeTimelineRange,
@@ -574,6 +609,134 @@ function cameraRow(sourceId, label) {
};
}
+function connectionSupervisor({ dataAuthoritative = false, dataPlaneState = "idle" } = {}) {
+ const observedAt = "2026-08-06T12:00:00Z";
+ const target = { ipv4: "192.168.68.52", port: 1883 };
+ return {
+ schema_version: "missioncore.k1-connection-supervisor/v1",
+ revision: 7,
+ closed: false,
+ intent: {
+ intent_id: "intent-001",
+ requested_mode: "bridge",
+ expected_device_id: "device-k1-001",
+ requested_at: observedAt,
+ },
+ observed: {
+ device_network: {
+ state: "applied",
+ intent_id: "intent-001",
+ transport_ref: "ble-k1-001",
+ connection_mode: "bridge",
+ target,
+ source: "ble-read-only-status",
+ observed_at: observedAt,
+ },
+ host_path: {
+ epoch: 3,
+ available: true,
+ fingerprint: "en0:192.168.68.10",
+ interface: "en0",
+ source_ipv4: "192.168.68.10",
+ route_class: "direct",
+ reason_code: null,
+ observed_at: observedAt,
+ },
+ endpoint: {
+ target,
+ tcp_state: "reachable",
+ intent_id: "intent-001",
+ host_path_epoch: 3,
+ reason_code: null,
+ observed_at: observedAt,
+ },
+ device_identity: {
+ state: "verified",
+ intent_id: "intent-001",
+ logical_device_id: "device-k1-001",
+ compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
+ connection_mode: "bridge",
+ source: "mqtt-device-info",
+ host_path_epoch: 3,
+ observed_at: observedAt,
+ },
+ control_plane: {
+ state: "healthy",
+ session_id: "control-session-001",
+ host_path_epoch: 3,
+ reason_code: null,
+ observed_at: observedAt,
+ },
+ data_plane: {
+ state: dataAuthoritative ? "healthy" : dataPlaneState,
+ session_id: dataAuthoritative || dataPlaneState !== "idle" ? "data-session-001" : null,
+ host_path_epoch: dataAuthoritative || dataPlaneState !== "idle" ? 3 : null,
+ reason_code: null,
+ observed_at: dataAuthoritative || dataPlaneState !== "idle" ? observedAt : null,
+ },
+ },
+ lease: {
+ state: "reachable",
+ generation: 4,
+ intent_id: "intent-001",
+ host_path_epoch: 3,
+ connection_mode: "bridge",
+ target,
+ logical_device_id: "device-k1-001",
+ reason_code: null,
+ observed_at: observedAt,
+ },
+ authority: {
+ network_mutation_allowed: false,
+ control_allowed: true,
+ acquisition_start_allowed: true,
+ data_ingest_authoritative: dataAuthoritative,
+ physical_motion_allowed: false,
+ reason_codes: [],
+ },
+ last_known: null,
+ allowed_actions: ["stop-acquisition"],
+ };
+}
+
+function connectionLifecycle() {
+ return {
+ schema_version: "missioncore.xgrids-k1-connection-lifecycle/v1",
+ revision: 8,
+ desired_mode: "bridge",
+ configured_mode: "bridge",
+ active_mode: "bridge",
+ mode_change: {
+ state: "ready",
+ from: "bridge",
+ to: "bridge",
+ },
+ mode_selection: {
+ allowed: false,
+ reason_codes: ["connection-mode-selection-acquisition-active"],
+ automatic_retry: false,
+ },
+ active_binding_key: "binding-intent-001-bridge",
+ active_binding: {
+ binding_key: "binding-intent-001-bridge",
+ intent_id: "intent-001",
+ transport_ref: "ble-k1-001",
+ connection_mode: "bridge",
+ target_ipv4: "192.168.68.52",
+ target_port: 1883,
+ host_path_epoch: 3,
+ control_session_id: "control-session-001",
+ logical_device_id: "device-k1-001",
+ compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
+ },
+ connection_ready: true,
+ ready_to_start: true,
+ operation: null,
+ allowed_actions: ["stop-acquisition"],
+ automatic_retry: false,
+ };
+}
+
function declaredState(cameraRows = [
cameraRow("sensor.camera.left", "K1 · камера слева"),
cameraRow("sensor.camera.right", "K1 · камера справа"),
@@ -582,6 +745,7 @@ function declaredState(cameraRows = [
return {
phase: "connected",
source_mode: "idle",
+ connection_mode: "bridge",
k1_ip: "192.168.7.10",
foxglove_ws_url: "ws://192.168.7.10:8765",
foxglove_viewer_url: "http://192.168.7.10:8765/vendor-viewer",
@@ -601,6 +765,16 @@ function declaredState(cameraRows = [
compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
connectivity: "connected",
},
+ connection_supervisor: connectionSupervisor(),
+ connection_lifecycle: connectionLifecycle(),
+ application_control_session: {
+ verified_control: {
+ logical_device_id: "device-k1-001",
+ compatibility_profile_id: "xgrids.lixelkity-k1.fw-3.0.2.local-network.v2",
+ control_session_id: "control-session-001",
+ source: "mqtt-device-info",
+ },
+ },
sensor_catalog: {
schema_version: "missioncore.sensor-catalog/v1alpha2",
revision: "test-profile",
@@ -620,10 +794,12 @@ function declaredState(cameraRows = [
}
function pointCloudStreamingState() {
+ const state = declaredState();
return {
- ...declaredState(),
+ ...state,
phase: "streaming",
source_mode: "live",
+ connection_supervisor: connectionSupervisor({ dataAuthoritative: true }),
rerun_grpc_url: "rerun+http://127.0.0.1:9877/proxy",
acquisition: {
acquisition_id: "acquisition-001",
@@ -668,9 +844,83 @@ function cameraStreamingState(sourceId) {
active_source_id: sourceId,
delivery,
};
+ state.connection_recovery = {
+ schema_version: "missioncore.xgrids-k1-active-stream-recovery/v1",
+ state: "inactive",
+ generation: 0,
+ acquisition_id: null,
+ attempt: 0,
+ started_at_utc: null,
+ elapsed_ms: null,
+ reason_code: null,
+ force_finish_allowed: false,
+ automatic_read_only_rebind: false,
+ automatic_command_retry: false,
+ start_performed: false,
+ stop_performed: false,
+ ble_operation_performed: false,
+ network_mutation_performed: false,
+ runtime_producer_generation: null,
+ camera_recovery: "inactive",
+ camera_media_state: "ready",
+ camera_media_ready: true,
+ camera_epoch: {
+ generation: 7,
+ init_committed: true,
+ init_committed_age_ms: 100,
+ first_media_committed: true,
+ first_media_committed_age_ms: 80,
+ committed_media_segment_count: 2,
+ last_media_segment_age_ms: 20,
+ },
+ };
return state;
}
+function cameraRecoveringState(sourceId, recoveryOverrides = {}, stateOverrides = {}) {
+ const state = cameraStreamingState(sourceId);
+ return {
+ ...state,
+ snapshot_runtime_id: "runtime-camera-recovery-001",
+ snapshot_revision: 29,
+ producer_generation: 17,
+ phase: "reconnecting",
+ connection_supervisor: connectionSupervisor({ dataPlaneState: "lost" }),
+ connection_recovery: {
+ schema_version: "missioncore.xgrids-k1-active-stream-recovery/v1",
+ state: "reconnecting",
+ generation: 6,
+ acquisition_id: state.acquisition.acquisition_id,
+ attempt: 2,
+ started_at_utc: "2026-08-11T21:19:59Z",
+ elapsed_ms: 16_869,
+ reason_code: "host-route-unavailable",
+ force_finish_allowed: true,
+ automatic_read_only_rebind: true,
+ automatic_command_retry: false,
+ start_performed: false,
+ stop_performed: false,
+ ble_operation_performed: false,
+ network_mutation_performed: false,
+ runtime_producer_generation: 17,
+ camera_recovery: "owned",
+ camera_media_state: "pending-first-media",
+ camera_media_ready: false,
+ camera_epoch: {
+ generation: 7,
+ init_committed: true,
+ init_committed_age_ms: 250,
+ first_media_committed: false,
+ first_media_committed_age_ms: null,
+ committed_media_segment_count: 0,
+ last_media_segment_age_ms: null,
+ },
+ ...recoveryOverrides,
+ },
+ ...stateOverrides,
+ };
+}
+
function collectUrlLikeStrings(value, found = []) {
if (typeof value === "string") {
if (value.includes("://")) found.push(value);
@@ -719,6 +969,41 @@ test("descriptor ids remain stable while point cloud and selected camera start s
assert.equal(streaming[0].previewUrl, "rerun+http://127.0.0.1:9877/proxy");
});
+test("legacy selected-device fields cannot attest or stream observation sources", () => {
+ const state = cameraStreamingState("sensor.camera.left");
+ delete state.connection_supervisor;
+ delete state.application_control_session;
+
+ const sources = xgridsK1ObservationSources(state, model());
+ const pointCloud = sources.find(({ modality }) => modality === "point-cloud");
+ const left = sources.find(({ sourceId }) => sourceId === "sensor.camera.left");
+ assert.ok(pointCloud && left);
+ assert.equal(pointCloud.availability, "unverified");
+ assert.equal(pointCloud.previewUrl, null);
+ assert.equal(pointCloud.binding.deviceId, null);
+ assert.equal(pointCloud.binding.deviceSessionId, null);
+ assert.equal(left.availability, "unverified");
+ assert.equal(left.activation.selected, false);
+ assert.equal(left.activation.controllable, false);
+ assert.equal(left.delivery, null);
+});
+
+test("data-plane loss keeps control identity but withdraws all live deliveries", () => {
+ const state = cameraStreamingState("sensor.camera.left");
+ state.connection_supervisor = connectionSupervisor({ dataPlaneState: "lost" });
+
+ const sources = xgridsK1ObservationSources(state, model());
+ const pointCloud = sources.find(({ modality }) => modality === "point-cloud");
+ const left = sources.find(({ sourceId }) => sourceId === "sensor.camera.left");
+ assert.ok(pointCloud && left);
+ assert.equal(pointCloud.availability, "degraded");
+ assert.equal(pointCloud.previewUrl, null);
+ assert.equal(pointCloud.binding.deviceId, "device-k1-001");
+ assert.equal(left.activation.selected, true);
+ assert.equal(left.availability, "degraded");
+ assert.equal(left.delivery, null);
+});
+
test("only the authoritative selected camera receives browser delivery", () => {
const sources = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
@@ -740,6 +1025,43 @@ test("only the authoritative selected camera receives browser delivery", () => {
assert.match(left.activation.groupId, /device-session-001/);
});
+test("ordinary camera delivery requires ready media facts from the exact current epoch", () => {
+ const pending = cameraStreamingState("sensor.camera.left");
+ pending.connection_recovery = {
+ ...pending.connection_recovery,
+ camera_media_state: "pending-first-media",
+ camera_media_ready: false,
+ camera_epoch: {
+ generation: 7,
+ init_committed: true,
+ init_committed_age_ms: 20,
+ first_media_committed: false,
+ first_media_committed_age_ms: null,
+ committed_media_segment_count: 0,
+ last_media_segment_age_ms: null,
+ },
+ };
+ const staleEpoch = cameraStreamingState("sensor.camera.left");
+ staleEpoch.connection_recovery = {
+ ...staleEpoch.connection_recovery,
+ camera_epoch: {
+ ...staleEpoch.connection_recovery.camera_epoch,
+ generation: 6,
+ },
+ };
+
+ for (const state of [pending, staleEpoch]) {
+ const camera = xgridsK1ObservationSources(state, model()).find(
+ ({ sourceId }) => sourceId === "sensor.camera.left",
+ );
+ assert.ok(camera);
+ assert.equal(camera.activation.selected, false);
+ assert.equal(camera.delivery, null);
+ assert.equal(camera.availability, "available");
+ assert.equal(liveCameraPlaybackAuthorityIdentity(camera), null);
+ }
+});
+
test("switching left to right keeps ids stable and never exposes both deliveries", () => {
const left = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
@@ -781,6 +1103,8 @@ test("unattested, duplicate and unsafe camera entries fail closed", () => {
const state = cameraStreamingState("sensor.camera.left");
state.compatibility.profile_id = null;
state.device_session.compatibility_profile_id = null;
+ state.connection_supervisor.observed.device_identity.state = "mismatch";
+ state.application_control_session.verified_control = null;
state.sensor_catalog.streams.push(duplicate);
state.camera_preview.delivery = {
...state.camera_preview.delivery,
@@ -825,14 +1149,14 @@ test("camera delivery rejects literal and encoded endpoint or credential leaks",
}
});
-test("manual camera reconnect restores an exhausted lease retry budget", () => {
+test("authoritative camera transport retries indefinitely with a capped backoff", () => {
let budget = resetCameraLeaseRetryBudget("delivery-7");
- for (const expectedDelay of [400, 1_000, 2_000]) {
+ for (const expectedDelay of [400, 1_000, 2_000, 5_000, 5_000, 5_000]) {
const retry = consumeCameraLeaseRetry(budget, "delivery-7");
assert.equal(retry.delay, expectedDelay);
budget = retry.budget;
}
- assert.equal(consumeCameraLeaseRetry(budget, "delivery-7").delay, null);
+ assert.equal(budget.count, 4);
budget = resetCameraLeaseRetryBudget("delivery-7");
const retryAfterManualReset = consumeCameraLeaseRetry(budget, "delivery-7");
@@ -840,6 +1164,592 @@ test("manual camera reconnect restores an exhausted lease retry budget", () => {
assert.equal(retryAfterManualReset.budget.count, 1);
});
+test("camera startup watchdog replaces an open-but-silent MSE or WebSocket", () => {
+ const scheduled = new Map();
+ const timeouts = [];
+ let nextHandle = 1;
+ const watchdog = createCameraStartupWatchdog({
+ schedule(callback, timeoutMs) {
+ const handle = nextHandle;
+ nextHandle += 1;
+ scheduled.set(handle, { callback, timeoutMs });
+ return handle;
+ },
+ cancel(handle) {
+ scheduled.delete(handle);
+ },
+ onTimeout(stage) {
+ timeouts.push(stage);
+ },
+ });
+
+ watchdog.armFirstMedia();
+ watchdog.armFirstMedia();
+ assert.equal(watchdog.pendingStage(), "first-media");
+ assert.equal(scheduled.size, 1);
+ const [{ callback, timeoutMs }] = scheduled.values();
+ assert.equal(timeoutMs, CAMERA_FIRST_MEDIA_TIMEOUT_MS);
+ callback();
+
+ assert.deepEqual(timeouts, ["first-media"]);
+ assert.equal(watchdog.pendingStage(), null);
+ assert.match(
+ cameraStartupWatchdogRecoveryMessage("first-media"),
+ /не передаёт медиаданные; восстанавливаем/,
+ );
+});
+
+test("first media starts one non-sliding first-playable-frame deadline", () => {
+ const scheduled = new Map();
+ const cancelled = [];
+ const timeouts = [];
+ let nextHandle = 10;
+ const watchdog = createCameraStartupWatchdog({
+ schedule(callback, timeoutMs) {
+ const handle = nextHandle;
+ nextHandle += 1;
+ scheduled.set(handle, { callback, timeoutMs });
+ return handle;
+ },
+ cancel(handle) {
+ cancelled.push(handle);
+ scheduled.delete(handle);
+ },
+ onTimeout(stage) {
+ timeouts.push(stage);
+ },
+ });
+
+ watchdog.armFirstMedia();
+ watchdog.markMediaReceived();
+ const playableHandle = nextHandle - 1;
+ assert.deepEqual(cancelled, [10]);
+ assert.equal(watchdog.pendingStage(), "first-playable-frame");
+ assert.equal(scheduled.get(playableHandle).timeoutMs, CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS);
+
+ watchdog.markMediaReceived();
+ assert.equal(nextHandle - 1, playableHandle, "later fragments must not extend the deadline");
+ scheduled.get(playableHandle).callback();
+ assert.deepEqual(timeouts, ["first-playable-frame"]);
+ assert.match(
+ cameraStartupWatchdogRecoveryMessage("first-playable-frame"),
+ /первый кадр не воспроизводится; пересоздаём decoder/,
+ );
+});
+
+test("playing clears the camera startup watchdog", () => {
+ const scheduled = new Map();
+ const cancelled = [];
+ const watchdog = createCameraStartupWatchdog({
+ schedule(callback) {
+ scheduled.set(21, callback);
+ return 21;
+ },
+ cancel(handle) {
+ cancelled.push(handle);
+ scheduled.delete(handle);
+ },
+ onTimeout() {
+ assert.fail("a playing transport must not time out");
+ },
+ });
+
+ watchdog.armFirstMedia();
+ watchdog.markMediaReceived();
+ watchdog.markPlaying();
+ assert.equal(watchdog.pendingStage(), null);
+ assert.deepEqual(cancelled, [21, 21]);
+ assert.equal(scheduled.size, 0);
+});
+
+test("browser append queue absorbs the complete bounded server backlog", () => {
+ assert.equal(
+ cameraPendingQueueCanAccept(8 * 1024 * 1024, 64, 1024 * 1024),
+ true,
+ );
+ assert.equal(
+ cameraPendingQueueCanAccept(11 * 1024 * 1024, 95, 1024 * 1024),
+ true,
+ );
+ assert.equal(
+ cameraPendingQueueCanAccept(12 * 1024 * 1024, 64, 1),
+ false,
+ );
+ assert.equal(
+ cameraPendingQueueCanAccept(8 * 1024 * 1024, 96, 1),
+ false,
+ );
+});
+
+test("server slow-reader close is an automatic browser-only camera recovery", () => {
+ assert.match(
+ cameraTransportCloseRecoveryMessage(4_008),
+ /отстал от эфира; восстанавливаем текущую камеру/,
+ );
+ assert.match(cameraTransportCloseRecoveryMessage(1_008), /переподключаемся/);
+ assert.match(cameraTransportCloseRecoveryMessage(1_000), /восстанавливаем/);
+ assert.match(cameraTransportCloseRecoveryMessage(1_011), /восстанавливаем/);
+});
+
+test("a laptop sleep gap reopens only the exact authoritative live camera transport", () => {
+ const source = xgridsK1ObservationSources(
+ cameraStreamingState("sensor.camera.right"),
+ model(),
+ ).find(({ sourceId }) => sourceId === "sensor.camera.right");
+ assert.ok(source);
+ const authority = liveCameraPlaybackAuthorityIdentity(source);
+ assert.ok(authority);
+
+ const initial = initialCameraPlaybackRecoveryState(authority, 1_000);
+ const wake = reduceCameraPlaybackRecovery(initial, { type: "heartbeat" }, {
+ activeAuthorityIdentity: authority,
+ now: 7_000,
+ documentVisible: true,
+ networkOnline: true,
+ });
+ assert.equal(wake.reopen, true);
+ assert.equal(wake.state.lastReopenAt, 7_000);
+
+ const staleSource = reduceCameraPlaybackRecovery(initial, { type: "heartbeat" }, {
+ activeAuthorityIdentity: `${authority}:replaced`,
+ now: 7_000,
+ documentVisible: true,
+ networkOnline: true,
+ });
+ assert.equal(staleSource.reopen, false);
+});
+
+test("visibility, pageshow and online wake burst owns one replacement decoder", () => {
+ const authority = "camera-authority-acquisition-1";
+ let state = initialCameraPlaybackRecoveryState(authority, 1_000);
+ ({ state } = reduceCameraPlaybackRecovery(state, { type: "document-hidden" }, {
+ activeAuthorityIdentity: authority,
+ now: 2_000,
+ documentVisible: false,
+ networkOnline: true,
+ }));
+
+ const visible = reduceCameraPlaybackRecovery(state, { type: "document-visible" }, {
+ activeAuthorityIdentity: authority,
+ now: 8_000,
+ documentVisible: true,
+ networkOnline: true,
+ });
+ assert.equal(visible.reopen, true);
+ state = visible.state;
+
+ for (const event of [
+ { type: "page-restore", persisted: true },
+ { type: "network-online" },
+ { type: "heartbeat" },
+ ]) {
+ const duplicate = reduceCameraPlaybackRecovery(state, event, {
+ activeAuthorityIdentity: authority,
+ now: 8_100,
+ documentVisible: true,
+ networkOnline: true,
+ });
+ assert.equal(duplicate.reopen, false);
+ state = duplicate.state;
+ }
+
+ ({ state } = reduceCameraPlaybackRecovery(state, { type: "document-hidden" }, {
+ activeAuthorityIdentity: authority,
+ now: 12_000,
+ documentVisible: false,
+ networkOnline: true,
+ }));
+ const secondWake = reduceCameraPlaybackRecovery(state, { type: "document-visible" }, {
+ activeAuthorityIdentity: authority,
+ now: 18_000,
+ documentVisible: true,
+ networkOnline: true,
+ });
+ assert.equal(secondWake.reopen, true);
+ assert.equal(secondWake.state.lastReopenAt, 18_000);
+});
+
+test("healthy visible camera heartbeats do not churn its WebSocket or decoder", () => {
+ const authority = "camera-authority-acquisition-healthy";
+ let state = initialCameraPlaybackRecoveryState(authority, 1_000);
+ for (const now of [2_000, 3_000, 4_000, 5_000]) {
+ const heartbeat = reduceCameraPlaybackRecovery(state, { type: "heartbeat" }, {
+ activeAuthorityIdentity: authority,
+ now,
+ documentVisible: true,
+ networkOnline: true,
+ });
+ assert.equal(heartbeat.reopen, false);
+ state = heartbeat.state;
+ }
+ assert.equal(state.lastReopenAt, null);
+});
+
+test("callbacks from a replaced camera WebSocket and SourceBuffer are fenced", () => {
+ assert.equal(cameraTransportCallbackIsCurrent(4, 4, false), true);
+ assert.equal(cameraTransportCallbackIsCurrent(5, 4, false), false);
+ assert.equal(cameraTransportCallbackIsCurrent(4, 4, true), false);
+});
+
+test("an already-stale document opens zero camera transports on mount", () => {
+ let transportOpenCount = 0;
+ const uiBuildStaleRef = { current: false };
+ const subscribeToAlreadyStaleCoordinator = (listener) => {
+ listener();
+ return () => undefined;
+ };
+
+ subscribeToAlreadyStaleCoordinator(() => {
+ uiBuildStaleRef.current = true;
+ });
+ if (cameraTransportCanOpen(uiBuildStaleRef.current)) transportOpenCount += 1;
+
+ assert.equal(uiBuildStaleRef.current, true);
+ assert.equal(transportOpenCount, 0);
+});
+
+test("only the current acquisition authority may schedule a replacement decoder", () => {
+ const authority = "camera-authority-acquisition-current";
+ assert.equal(
+ cameraTransportRecoveryIsCurrent(authority, authority, 7, 7, false),
+ true,
+ );
+ assert.equal(
+ cameraTransportRecoveryIsCurrent(`${authority}:replaced`, authority, 7, 7, false),
+ false,
+ );
+ assert.equal(
+ cameraTransportRecoveryIsCurrent(authority, authority, 8, 7, false),
+ false,
+ );
+ assert.equal(
+ cameraTransportRecoveryIsCurrent(authority, authority, 7, 7, true),
+ false,
+ );
+ assert.equal(
+ cameraTransportRecoveryIsCurrent(null, authority, 7, 7, false),
+ false,
+ );
+});
+
+test("same camera delivery gets a new browser transport owner for a new acquisition", () => {
+ const camera = xgridsK1ObservationSources(
+ cameraStreamingState("sensor.camera.right"),
+ model(),
+ ).find(({ sourceId }) => sourceId === "sensor.camera.right");
+ assert.ok(camera?.delivery && camera.delivery.kind === "mse-fmp4-websocket");
+ const previousAuthority = liveCameraPlaybackAuthorityIdentity(camera);
+ const nextAuthority = liveCameraPlaybackAuthorityIdentity({
+ ...camera,
+ binding: {
+ ...camera.binding,
+ deviceSessionId: "device-session-002",
+ acquisitionId: "acquisition-002",
+ },
+ });
+ assert.ok(previousAuthority && nextAuthority);
+ assert.notEqual(nextAuthority, previousAuthority);
+ assert.notEqual(
+ cameraBrowserTransportIdentity(camera.delivery, nextAuthority),
+ cameraBrowserTransportIdentity(camera.delivery, previousAuthority),
+ );
+});
+
+test("malformed camera exclusivity or media descriptors cannot own wake recovery", () => {
+ const camera = xgridsK1ObservationSources(
+ cameraStreamingState("sensor.camera.left"),
+ model(),
+ ).find(({ sourceId }) => sourceId === "sensor.camera.left");
+ assert.ok(camera?.activation && camera.delivery?.kind === "mse-fmp4-websocket");
+
+ for (const malformed of [
+ { ...camera, activation: { ...camera.activation, groupId: " " } },
+ { ...camera, activation: { ...camera.activation, groupId: null } },
+ { ...camera, activation: { ...camera.activation, maxActive: 2 } },
+ { ...camera, delivery: { ...camera.delivery, mediaType: " " } },
+ { ...camera, delivery: { ...camera.delivery, mediaType: null } },
+ { ...camera, delivery: { ...camera.delivery, mediaType: "video/webm" } },
+ ]) {
+ assert.equal(liveCameraPlaybackAuthorityIdentity(malformed), null);
+ }
+});
+
+test("point-cloud recovery cannot revive a dead or stale camera delivery", () => {
+ const sources = xgridsK1ObservationSources(
+ cameraStreamingState("sensor.camera.left"),
+ model(),
+ );
+ const pointCloud = sources.find(({ modality }) => modality === "point-cloud");
+ const camera = sources.find(({ sourceId }) => sourceId === "sensor.camera.left");
+ assert.ok(pointCloud && camera);
+ assert.equal(pointCloud.availability, "streaming");
+ assert.ok(liveCameraPlaybackAuthorityIdentity(camera));
+
+ assert.equal(liveCameraPlaybackAuthorityIdentity({
+ ...camera,
+ availability: "error",
+ }), null);
+ assert.equal(liveCameraPlaybackAuthorityIdentity({
+ ...camera,
+ binding: { ...camera.binding, acquisitionId: "acquisition-replaced" },
+ activation: { ...camera.activation, selected: false },
+ }), null);
+});
+
+test("exact active recovery retains the same camera delivery without controls", () => {
+ const healthy = xgridsK1ObservationSources(
+ cameraStreamingState("sensor.camera.left"),
+ model(),
+ );
+ const recovering = xgridsK1ObservationSources(
+ cameraRecoveringState("sensor.camera.left"),
+ model(),
+ );
+ const healthyPointCloud = healthy.find(({ modality }) => modality === "point-cloud");
+ const recoveryPointCloud = recovering.find(({ modality }) => modality === "point-cloud");
+ const healthyCamera = healthy.find(({ sourceId }) => sourceId === "sensor.camera.left");
+ const recoveryCamera = recovering.find(({ sourceId }) => sourceId === "sensor.camera.left");
+ assert.ok(healthyPointCloud && recoveryPointCloud && healthyCamera && recoveryCamera);
+
+ assert.equal(recoveryPointCloud.id, healthyPointCloud.id);
+ assert.equal(recoveryPointCloud.previewUrl, healthyPointCloud.previewUrl);
+ assert.equal(recoveryPointCloud.availability, "connecting");
+ assert.equal(recoveryCamera.id, healthyCamera.id);
+ assert.deepEqual(recoveryCamera.delivery, healthyCamera.delivery);
+ assert.equal(recoveryCamera.activation.selected, true);
+ assert.equal(recoveryCamera.activation.controllable, false);
+ assert.equal(recoveryCamera.availability, "connecting");
+ assert.deepEqual(recoveryCamera.presentationLease, {
+ kind: "active-stream-recovery",
+ runtimeId: "runtime-camera-recovery-001",
+ acquisitionId: "acquisition-001",
+ acquisitionStateRevision: 3,
+ producerGeneration: 17,
+ recoveryGeneration: 6,
+ });
+
+ const healthyAuthority = liveCameraPlaybackAuthorityIdentity(healthyCamera);
+ const recoveryAuthority = liveCameraPlaybackAuthorityIdentity(recoveryCamera);
+ assert.ok(healthyAuthority && recoveryAuthority);
+ assert.notEqual(recoveryAuthority, healthyAuthority);
+ assert.equal(
+ liveCameraPlaybackAuthorityIdentity(
+ xgridsK1ObservationSources(
+ cameraRecoveringState("sensor.camera.left"),
+ model(),
+ ).find(({ sourceId }) => sourceId === "sensor.camera.left"),
+ ),
+ recoveryAuthority,
+ "repeated snapshots of the same recovery generation keep one browser owner",
+ );
+});
+
+test("camera recovery stays connecting until the current epoch has durable first media", () => {
+ const pending = xgridsK1ObservationSources(
+ cameraRecoveringState("sensor.camera.left"),
+ model(),
+ ).find(({ sourceId }) => sourceId === "sensor.camera.left");
+ const ready = xgridsK1ObservationSources(
+ cameraRecoveringState("sensor.camera.left", {
+ camera_media_state: "ready",
+ camera_media_ready: true,
+ camera_epoch: {
+ generation: 7,
+ init_committed: true,
+ init_committed_age_ms: 500,
+ first_media_committed: true,
+ first_media_committed_age_ms: 100,
+ committed_media_segment_count: 1,
+ last_media_segment_age_ms: 100,
+ },
+ }),
+ model(),
+ ).find(({ sourceId }) => sourceId === "sensor.camera.left");
+
+ assert.ok(pending?.delivery && ready?.delivery);
+ assert.equal(pending.activation.selected, true);
+ assert.equal(pending.availability, "connecting");
+ assert.ok(pending.presentationLease);
+ assert.equal(ready.activation.selected, true);
+ assert.equal(ready.availability, "streaming");
+ assert.ok(ready.presentationLease);
+});
+
+test("first recovered PCL keeps the exact camera authority until browser media is playable", () => {
+ const reconnectingState = cameraRecoveringState("sensor.camera.left");
+ const recoveredState = cameraRecoveringState(
+ "sensor.camera.left",
+ {
+ state: "recovered",
+ force_finish_allowed: false,
+ elapsed_ms: null,
+ reason_code: null,
+ },
+ {
+ phase: "live",
+ connection_supervisor: connectionSupervisor({ dataAuthoritative: true }),
+ },
+ );
+ recoveredState.camera_preview = {
+ ...recoveredState.camera_preview,
+ phase: "connecting",
+ };
+ recoveredState.sensor_catalog = {
+ ...recoveredState.sensor_catalog,
+ streams: recoveredState.sensor_catalog.streams.map((stream) =>
+ stream.source_id === "sensor.camera.left"
+ ? { ...stream, availability: "connecting" }
+ : stream),
+ };
+
+ const reconnecting = xgridsK1ObservationSources(reconnectingState, model());
+ const recovered = xgridsK1ObservationSources(recoveredState, model());
+ const reconnectingCamera = reconnecting.find(
+ ({ sourceId }) => sourceId === "sensor.camera.left",
+ );
+ const recoveredCamera = recovered.find(
+ ({ sourceId }) => sourceId === "sensor.camera.left",
+ );
+ const reconnectingPointCloud = reconnecting.find(
+ ({ modality }) => modality === "point-cloud",
+ );
+ const recoveredPointCloud = recovered.find(
+ ({ modality }) => modality === "point-cloud",
+ );
+ assert.ok(
+ reconnectingCamera
+ && recoveredCamera
+ && reconnectingPointCloud
+ && recoveredPointCloud,
+ );
+
+ assert.equal(recoveredCamera.availability, "connecting");
+ assert.equal(recoveredCamera.activation.selected, true);
+ assert.equal(recoveredCamera.activation.controllable, true);
+ assert.deepEqual(recoveredCamera.delivery, reconnectingCamera.delivery);
+ assert.deepEqual(recoveredCamera.presentationLease, reconnectingCamera.presentationLease);
+ assert.deepEqual(
+ recoveredPointCloud.presentationLease,
+ reconnectingPointCloud.presentationLease,
+ );
+ assert.equal(
+ liveCameraPlaybackAuthorityIdentity(recoveredCamera),
+ liveCameraPlaybackAuthorityIdentity(reconnectingCamera),
+ "reconnecting→recovered must not retire the decoder before first playable frame",
+ );
+ const spatialSource = {
+ id: "acquisition-001",
+ url: reconnectingPointCloud.previewUrl,
+ label: "Live",
+ kind: "rerun-grpc",
+ };
+ assert.equal(
+ liveRerunRecoveryAuthorityIdentity(recoveredPointCloud, spatialSource),
+ liveRerunRecoveryAuthorityIdentity(reconnectingPointCloud, spatialSource),
+ "the first recovered PCL keeps one exact Rerun browser authority",
+ );
+});
+
+test("camera recovery fails closed while the exact spatial lease may continue", () => {
+ const cameraStreamError = cameraRecoveringState("sensor.camera.left");
+ cameraStreamError.sensor_catalog.streams = cameraStreamError.sensor_catalog.streams.map(
+ (stream) => stream.source_id === "sensor.camera.left"
+ ? { ...stream, availability: "error" }
+ : stream,
+ );
+ const mismatchedDelivery = cameraRecoveringState("sensor.camera.left");
+ mismatchedDelivery.camera_preview.delivery = {
+ ...mismatchedDelivery.camera_preview.delivery,
+ id: "preview-generation-replaced:sensor.camera.left",
+ };
+ const missingDelivery = cameraRecoveringState("sensor.camera.left");
+ missingDelivery.camera_preview.delivery = null;
+ missingDelivery.sensor_catalog.streams = missingDelivery.sensor_catalog.streams.map(
+ (stream) => stream.source_id === "sensor.camera.left"
+ ? { ...stream, delivery: null }
+ : stream,
+ );
+ const cases = [
+ cameraRecoveringState("sensor.camera.left", { camera_recovery: "blocked" }),
+ cameraRecoveringState("sensor.camera.left", {}, {
+ device_session: {
+ ...cameraStreamingState("sensor.camera.left").device_session,
+ device_session_id: "device-session-replaced",
+ },
+ }),
+ cameraRecoveringState("sensor.camera.left", {}, {
+ camera_preview: {
+ ...cameraStreamingState("sensor.camera.left").camera_preview,
+ phase: "error",
+ },
+ }),
+ cameraStreamError,
+ mismatchedDelivery,
+ missingDelivery,
+ ];
+
+ for (const state of cases) {
+ const sources = xgridsK1ObservationSources(state, model());
+ const pointCloud = sources.find(({ modality }) => modality === "point-cloud");
+ const camera = sources.find(({ sourceId }) => sourceId === "sensor.camera.left");
+ assert.ok(pointCloud && camera);
+ assert.equal(pointCloud.previewUrl, "rerun+http://127.0.0.1:9877/proxy");
+ assert.equal(pointCloud.availability, "connecting");
+ assert.equal(camera.activation.selected, false);
+ assert.equal(camera.activation.controllable, false);
+ assert.equal(camera.delivery, null);
+ assert.equal(camera.presentationLease, null);
+ assert.equal(liveCameraPlaybackAuthorityIdentity(camera), null);
+ }
+});
+
+test("stale and terminal recovery states withdraw both retained transports", () => {
+ const staleProducer = cameraRecoveringState("sensor.camera.left");
+ staleProducer.producer_generation += 1;
+ const differentAcquisition = cameraRecoveringState("sensor.camera.left", {
+ acquisition_id: "acquisition-stale",
+ });
+ for (const state of [
+ staleProducer,
+ differentAcquisition,
+ cameraRecoveringState("sensor.camera.left", { state: "blocked" }),
+ cameraRecoveringState("sensor.camera.left", { state: "standby" }),
+ cameraRecoveringState("sensor.camera.left", { state: "fault" }),
+ ]) {
+ const sources = xgridsK1ObservationSources(state, model());
+ const pointCloud = sources.find(({ modality }) => modality === "point-cloud");
+ const camera = sources.find(({ sourceId }) => sourceId === "sensor.camera.left");
+ assert.ok(pointCloud && camera);
+ assert.equal(pointCloud.previewUrl, null);
+ assert.equal(pointCloud.presentationLease, null);
+ assert.equal(camera.activation.selected, false);
+ assert.equal(camera.delivery, null);
+ assert.equal(camera.presentationLease, null);
+ }
+});
+
+test("malformed recovery leases cannot own a camera WebSocket or decoder", () => {
+ const camera = xgridsK1ObservationSources(
+ cameraRecoveringState("sensor.camera.left"),
+ model(),
+ ).find(({ sourceId }) => sourceId === "sensor.camera.left");
+ assert.ok(camera?.presentationLease);
+
+ for (const presentationLease of [
+ null,
+ { ...camera.presentationLease, runtimeId: " " },
+ { ...camera.presentationLease, acquisitionId: "acquisition-stale" },
+ { ...camera.presentationLease, acquisitionStateRevision: 0 },
+ { ...camera.presentationLease, producerGeneration: 0 },
+ { ...camera.presentationLease, recoveryGeneration: 0 },
+ ]) {
+ assert.equal(
+ liveCameraPlaybackAuthorityIdentity({ ...camera, presentationLease }),
+ null,
+ );
+ }
+});
+
test("layout policy evicts only exclusive camera peers", () => {
const sources = xgridsK1ObservationSources(
cameraStreamingState("sensor.camera.left"),
diff --git a/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs b/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs
index 807625e..bb7e5e1 100644
--- a/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs
+++ b/apps/control-station/test/rerunViewportAtomicAdmission.test.mjs
@@ -5,7 +5,9 @@ import { after, before, test } from "node:test";
import { createServer } from "vite";
let server;
+let claimExclusiveLiveViewer;
let createRecordedOpenWatchdog;
+let createReentrantViewerDisposer;
let recordedOpenWatchdogTimeoutMs;
let rerunViewerInitialSource;
let resolveRecordedViewerSourceUrl;
@@ -17,7 +19,9 @@ before(async () => {
server: { middlewareMode: true },
});
({
+ claimExclusiveLiveViewer,
createRecordedOpenWatchdog,
+ createReentrantViewerDisposer,
recordedOpenWatchdogTimeoutMs,
rerunViewerInitialSource,
resolveRecordedViewerSourceUrl,
@@ -118,6 +122,45 @@ test("complete recorded admission clears its watchdog", () => {
assert.deepEqual(cancelled, [23]);
});
+test("deferred viewer start cannot reopen after stale unmount", async () => {
+ let resolveStart;
+ const start = new Promise((resolve) => {
+ resolveStart = resolve;
+ });
+ let disposed = false;
+ let cleanupCount = 0;
+ let closeCount = 0;
+ let stopCount = 0;
+ const diagnostics = [];
+ const disposeViewer = createReentrantViewerDisposer(
+ () => {
+ cleanupCount += 1;
+ },
+ () => {
+ closeCount += 1;
+ stopCount += 1;
+ },
+ );
+ const pendingMount = (async () => {
+ await start;
+ if (disposed) {
+ disposeViewer();
+ return;
+ }
+ diagnostics.push("admitted");
+ })();
+
+ disposed = true;
+ disposeViewer();
+ resolveStart();
+ await pendingMount;
+
+ assert.equal(cleanupCount, 1);
+ assert.equal(closeCount, 2);
+ assert.equal(stopCount, 2);
+ assert.deepEqual(diagnostics, []);
+});
+
test("recorded RRD bytes are never split across LogChannel.send_rrd calls", async () => {
const source = await readFile(
new URL("../src/components/RerunViewport.tsx", import.meta.url),
@@ -129,7 +172,7 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
assert.match(
source,
- /recordingOpened = true;[\s\S]*clearLiveRecordingOpenTimer\(\);[\s\S]*clearLiveRecordingDiscoveryTimer\(\);/,
+ /recordingOpened = true;[\s\S]*diagnosticLifecycle\.markAdmitted\(\);/,
);
assert.match(
source,
@@ -145,12 +188,31 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
);
});
+test("one live document owns one native Rerun receiver", async () => {
+ const releases = [];
+ const releaseFirstClaim = claimExclusiveLiveViewer(() => releases.push("first"));
+ const releaseSecondClaim = claimExclusiveLiveViewer(() => releases.push("second"));
+
+ assert.deepEqual(releases, ["first"]);
+ releaseFirstClaim();
+ assert.deepEqual(releases, ["first"]);
+ releaseSecondClaim();
+
+ const releaseThirdClaim = claimExclusiveLiveViewer(() => releases.push("third"));
+ assert.deepEqual(releases, ["first"]);
+ releaseThirdClaim();
+});
+
test("raw replay exercises the same streaming receiver lifecycle as a live scan", async () => {
const source = await readFile(
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
"utf8",
);
assert.match(source, /followLive=\{!recordedReplay && streamActive\}/);
+ assert.match(
+ source,
+ /sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/,
+ );
});
test("the complete vendor canvas host is hidden during partial and failed admission", async () => {
diff --git a/apps/control-station/test/runtimeStateOrdering.test.mjs b/apps/control-station/test/runtimeStateOrdering.test.mjs
index 29d728a..d65871a 100644
--- a/apps/control-station/test/runtimeStateOrdering.test.mjs
+++ b/apps/control-station/test/runtimeStateOrdering.test.mjs
@@ -42,6 +42,113 @@ function snapshot(revision, generation, phase = "streaming", sessionId = "device
};
}
+function stampedSnapshot({
+ runtimeStartedAt = "2026-08-06T10:00:00Z",
+ runtimeStartedMonotonicNs = "1000000",
+ runtimeId = "runtime-a",
+ snapshotRevision = 1,
+ cameraRevision = snapshotRevision,
+ generation = 1,
+} = {}) {
+ return {
+ ...snapshot(cameraRevision, generation),
+ snapshot_runtime_started_at_utc: runtimeStartedAt,
+ snapshot_runtime_started_monotonic_ns: runtimeStartedMonotonicNs,
+ snapshot_runtime_id: runtimeId,
+ snapshot_revision: snapshotRevision,
+ };
+}
+
+test("uses the process snapshot revision before camera-local counters", () => {
+ const current = stampedSnapshot({ snapshotRevision: 8, cameraRevision: 2 });
+ const stale = stampedSnapshot({ snapshotRevision: 7, cameraRevision: 99 });
+ const newer = stampedSnapshot({ snapshotRevision: 9, cameraRevision: 1 });
+
+ assert.equal(selectMonotonicXgridsState(current, stale), current);
+ assert.equal(selectMonotonicXgridsState(current, newer), newer);
+});
+
+test("accepts a newer runtime and rejects a delayed snapshot from the old runtime", () => {
+ const oldRuntime = stampedSnapshot({
+ runtimeStartedAt: "2026-08-06T10:00:00Z",
+ runtimeStartedMonotonicNs: "1000000",
+ runtimeId: "runtime-old",
+ snapshotRevision: 300,
+ });
+ const newRuntime = stampedSnapshot({
+ runtimeStartedAt: "2026-08-06T10:05:00Z",
+ runtimeStartedMonotonicNs: "2000000",
+ runtimeId: "runtime-new",
+ snapshotRevision: 1,
+ });
+ const delayedOldRuntime = stampedSnapshot({
+ runtimeStartedAt: "2026-08-06T10:00:00Z",
+ runtimeStartedMonotonicNs: "1000000",
+ runtimeId: "runtime-old",
+ snapshotRevision: 301,
+ });
+
+ assert.equal(selectMonotonicXgridsState(oldRuntime, newRuntime), newRuntime);
+ assert.equal(
+ selectMonotonicXgridsState(newRuntime, delayedOldRuntime),
+ newRuntime,
+ );
+});
+
+test("orders restarts by monotonic time even when UTC moves backwards", () => {
+ const oldRuntime = stampedSnapshot({
+ runtimeStartedAt: "2026-08-06T10:05:00Z",
+ runtimeStartedMonotonicNs: "2000000",
+ runtimeId: "runtime-old",
+ snapshotRevision: 900,
+ });
+ const newRuntime = stampedSnapshot({
+ runtimeStartedAt: "2026-08-06T09:55:00Z",
+ runtimeStartedMonotonicNs: "3000000",
+ runtimeId: "runtime-new",
+ snapshotRevision: 1,
+ });
+
+ assert.equal(selectMonotonicXgridsState(oldRuntime, newRuntime), newRuntime);
+ assert.equal(selectMonotonicXgridsState(newRuntime, oldRuntime), newRuntime);
+});
+
+test("orders two runtimes sharing the same UTC millisecond", () => {
+ const first = stampedSnapshot({
+ runtimeStartedMonotonicNs: "4000000",
+ runtimeId: "runtime-first",
+ });
+ const second = stampedSnapshot({
+ runtimeStartedMonotonicNs: "4000001",
+ runtimeId: "runtime-second",
+ });
+
+ assert.equal(selectMonotonicXgridsState(first, second), second);
+});
+
+test("a malformed monotonic stamp cannot replace valid runtime authority", () => {
+ const current = stampedSnapshot({
+ runtimeStartedMonotonicNs: "5000000",
+ runtimeId: "runtime-current",
+ });
+ const malformed = stampedSnapshot({
+ runtimeStartedAt: "2026-08-06T11:00:00Z",
+ runtimeStartedMonotonicNs: "not-a-number",
+ runtimeId: "runtime-malformed",
+ snapshotRevision: 9999,
+ });
+
+ assert.equal(selectMonotonicXgridsState(current, malformed), current);
+});
+
+test("does not let an unstamped legacy response replace stamped authority", () => {
+ const current = stampedSnapshot({ snapshotRevision: 8 });
+ const legacy = snapshot(99, 99);
+
+ assert.equal(selectMonotonicXgridsState(current, legacy), current);
+ assert.equal(selectMonotonicXgridsState(legacy, current), current);
+});
+
test("accepts the first camera preview snapshot", () => {
const incoming = snapshot(1, 1);
assert.equal(selectMonotonicXgridsState(null, incoming), incoming);
diff --git a/apps/control-station/test/workspaceLayout.test.mjs b/apps/control-station/test/workspaceLayout.test.mjs
index b67ae86..1341afa 100644
--- a/apps/control-station/test/workspaceLayout.test.mjs
+++ b/apps/control-station/test/workspaceLayout.test.mjs
@@ -13,6 +13,9 @@ let projectObservationLayoutSnapshot;
let saveObservationWorkspaceLayoutProfile;
let WorkspaceLayoutApiError;
let WorkspaceLayoutContractError;
+let admitLiveDefaultPresentations;
+let automaticLivePresentationIdentity;
+let livePresentationCloseFence;
let observationPresentationSourceAfterLayoutApply;
let visibleSourceIdsAfterRecordedCatalogActivation;
@@ -33,6 +36,9 @@ before(async () => {
WorkspaceLayoutContractError,
} = await server.ssrLoadModule("/src/core/observation/workspaceLayout.ts"));
({
+ admitLiveDefaultPresentations,
+ automaticLivePresentationIdentity,
+ livePresentationCloseFence,
observationPresentationSourceAfterLayoutApply,
visibleSourceIdsAfterRecordedCatalogActivation,
} = await server.ssrLoadModule("/src/core/observation/useObservationLayout.ts"));
@@ -213,6 +219,95 @@ test("opening a recorded catalog reveals its sealed cameras beside the point clo
);
});
+test("a sequential live acquisition re-arms the same camera without reopening a deliberate close", () => {
+ const pointCloud = {
+ id: "k1:sensor.lidar.primary",
+ sourceId: "sensor.lidar.primary",
+ modality: "point-cloud",
+ availability: "streaming",
+ transport: "rerun-grpc",
+ previewUrl: "grpc://127.0.0.1:9876/proxy",
+ delivery: null,
+ activation: null,
+ binding: {
+ deviceId: "k1-a",
+ deviceSessionId: "device-session-reused",
+ acquisitionId: "acquisition-a",
+ },
+ capabilities: { defaultVisible: true, overlay: false },
+ };
+ const camera = (acquisitionId, deliveryId) => ({
+ id: "k1:sensor.camera.right",
+ sourceId: "sensor.camera.right",
+ modality: "video",
+ availability: "streaming",
+ transport: "websocket",
+ previewUrl: null,
+ delivery: {
+ id: deliveryId,
+ kind: "mse-fmp4-websocket",
+ url: "/camera-preview/reused",
+ mediaType: 'video/mp4; codecs="avc1.641028"',
+ },
+ activation: {
+ groupId: "k1:device-session-reused:camera.preview.decoder",
+ maxActive: 1,
+ selected: true,
+ controllable: true,
+ },
+ binding: {
+ deviceId: "k1-a",
+ deviceSessionId: "device-session-reused",
+ acquisitionId,
+ },
+ capabilities: { defaultVisible: true, overlay: true },
+ });
+
+ const firstCamera = camera("acquisition-a", "camera-preview-2");
+ const first = admitLiveDefaultPresentations(
+ [pointCloud.id],
+ [pointCloud, firstCamera],
+ new Set(),
+ new Set(),
+ );
+ assert.deepEqual(first.visibleIds, [pointCloud.id, firstCamera.id]);
+ assert.deepEqual(first.admittedIdentities, [
+ automaticLivePresentationIdentity(firstCamera),
+ ]);
+
+ const sameAcquisitionNewDelivery = camera("acquisition-a", "camera-preview-3");
+ const closedInFirstAcquisition = new Set([
+ livePresentationCloseFence(firstCamera),
+ ]);
+ const afterDeliberateClose = admitLiveDefaultPresentations(
+ [pointCloud.id],
+ [pointCloud, sameAcquisitionNewDelivery],
+ new Set(first.admittedIdentities),
+ closedInFirstAcquisition,
+ );
+ assert.deepEqual(afterDeliberateClose.visibleIds, [pointCloud.id]);
+ assert.deepEqual(afterDeliberateClose.admittedIdentities, []);
+
+ const nextAcquisitionSameDelivery = camera("acquisition-b", "camera-preview-3");
+ assert.notEqual(
+ automaticLivePresentationIdentity(nextAcquisitionSameDelivery),
+ automaticLivePresentationIdentity(sameAcquisitionNewDelivery),
+ );
+ const second = admitLiveDefaultPresentations(
+ [pointCloud.id],
+ [
+ { ...pointCloud, binding: { ...pointCloud.binding, acquisitionId: "acquisition-b" } },
+ nextAcquisitionSameDelivery,
+ ],
+ new Set(first.admittedIdentities),
+ closedInFirstAcquisition,
+ );
+ assert.deepEqual(second.visibleIds, [pointCloud.id, nextAcquisitionSameDelivery.id]);
+ assert.deepEqual(second.admittedIdentities, [
+ automaticLivePresentationIdentity(nextAcquisitionSameDelivery),
+ ]);
+});
+
test("workspace layout API uses the canonical endpoint and optimistic revision", async () => {
const calls = [];
const current = decodeObservationWorkspaceLayoutProfile(wireProfile());
diff --git a/docs/04_K1_WIFI_PROVISIONING_PROFILE.md b/docs/04_K1_WIFI_PROVISIONING_PROFILE.md
index 1eff7bb..fa95b66 100644
--- a/docs/04_K1_WIFI_PROVISIONING_PROFILE.md
+++ b/docs/04_K1_WIFI_PROVISIONING_PROFILE.md
@@ -111,6 +111,27 @@ handle from the operator's scan and connects that exact selected handle in the
following network action. A fallback lookup remains only for non-UI callers
that did not perform discovery first.
+The public advertisement cache is deliberately separate from an admitted
+device session. Rows from the latest explicit scan generation remain stable
+without wall-clock expiry while the operator completes the form. They still
+grant no mutation authority without exact retained-handle capture and live
+GATT validation. An admitted selected session ends only on proven disconnect,
+explicit stop, app/backend restart, selection of another K1, or connection-mode
+switch. A later scan may replace unselected candidates but never auto-connects
+any of them.
+
+An explicit Quick Connect to Bridge request for that same device can therefore
+continue when K1 no longer advertises after AP activation. The preconditions
+are: no active acquisition, no pending evidence cleanup, no active local source,
+and a terminal/released control session. A mode switch closes the old selected
+session first; the operator then scans, selects the K1, and creates a clean new
+GATT session for Bridge. Before the station command the code performs the
+normal internal `7f02` baseline read and allows exactly one 99-byte `7f01`
+write. A powered-off or unreachable peripheral ends that attempt. An
+unobserved post-write result is recorded as terminal `outcome-unknown`; it is
+never retried automatically and never blocks a later distinct explicit
+scan-select-connect attempt.
+
The 2026-07-20 prepared-host acceptance installed the exact firmware provider,
found one expected K1 candidate, emitted one AP-enable write, observed AP-ready
and completed one CoreWLAN association without an iPhone or manual credential.
@@ -136,12 +157,19 @@ mode. It never fragments or retries the payload automatically.
A completed GATT write only proves transport completion. It does not prove that
the K1 joined Wi-Fi or began beaconing. The application polls `7f02`; the
-observed response frame contains a fixed-width mode slot, an address slot, a
-status byte at offset 50 and the AP-ready flag at offset 51. The stale AP
+observed response frame contains a fixed-width text slot, an address slot, a
+status byte at offset 50 and the AP-ready flag at offset 51. The text slot is
+not a uniform mode enum: AP state uses the `WIFI_AP` control literal, while the
+2026-08-08 FW 3.0.2 Bridge observation returned the joined network name. The stale AP
baseline reports `WIFI_AP / 192.168.56.1 / byte51=0`; the physically observed
ready transition reports the same mode/address with `byte51=1`.
-For Bridge/Direct Connect, acceptance requires at least one of:
+For Bridge/Direct, acceptance requires the post-write `7f02` text slot to match
+the exact requested network name and the address slot to contain a valid
+non-AP private IPv4. This proves the desired target even when the K1 was
+already joined to the same network before the explicit idempotent command. A
+legacy literal-only `WIFI_CLIENT` observation retains the older conservative
+cross-family rules and requires at least one of:
1. `7f02` reports a non-AP IPv4 address;
2. the same address appears as a new router/ARP client after the write;
@@ -150,6 +178,15 @@ For Bridge/Direct Connect, acceptance requires at least one of:
Do not infer success from a write callback alone.
+An interrupted attempt with no exact post-write network-name observation is not
+made successful by a write callback, changed DHCP address, router/ARP row or
+reachable endpoint. Likewise, an already AP-ready baseline alone cannot prove
+the outcome of an interrupted Quick-to-Quick attempt. Such an attempt remains
+`outcome-unknown` in historical audit and is never replayed automatically. It
+does not create a permanent mutation barrier: after the old active operation
+and cleanup have terminated, a later explicit operator scan, selection, and
+connect is a distinct session with its own single reviewed write.
+
The Bridge/Direct Connect address is a DHCP lease, not configuration and not
device identity. Mission Core re-reads `7f02` without writing before every new
LAN control session, implicit-host acquisition and factory-calibration read.
@@ -157,6 +194,10 @@ If the value changes, it rotates `device_session_id`; it never retargets an
active acquisition. A correlated MQTT `DeviceInfo` response supplies the live
model/firmware/serial identity barrier.
+The joined network name is used only for exact in-process comparison with the
+current explicit request. Durable network audit stores the normalized semantic
+family and never stores or publishes the raw network name.
+
The 2026-07-20 reboot/power-cycle check observed the startup race directly:
one read returned the earlier `.54` lease while that exact address had no ARP or
application endpoint; a later read returned `.52`, where exact probes found
@@ -166,7 +207,9 @@ and why a BLE lease observation alone is not reported as live DeviceInfo.
For Quick Connect, host association is not admitted until the canonical
byte-51 ready flag is observed. CoreWLAN then searches only for the exact
-device-profile SSID for at most 15 seconds and performs at most one association.
+device-profile SSID for at most 30 seconds and performs at most one association.
+AP-ready is a device-state barrier, not proof that the host has already observed
+the RF beacon; a retained successful run required 18.142 seconds of discovery.
## Safety, recovery and stop conditions
@@ -176,9 +219,17 @@ device-profile SSID for at most 15 seconds and performs at most one association.
secure store. Missing or mismatched firmware material fails before the AP
write. Never extrapolate this provider to another firmware or model.
- The macOS adapter materializes a device-scoped Keychain item from the exact
- firmware source, then performs one association. Standard Wi-Fi Keychain and
- native prompt paths remain compatibility fallbacks, not the reviewed
- zero-touch path. It never asks the browser for a password.
+ firmware source before the BLE write, then performs one association using
+ only that exact profile. Standard Wi-Fi Keychain lookup, native password
+ prompts and post-write profile rewrites are prohibited. It never asks the
+ browser for a password. Preflight reads are non-interactive and validate the
+ exact SSID/source inside the helper before K1 changes network state.
+- The prepared-host laboratory adapter launches the reviewed Swift source only
+ through `/usr/bin/xcrun swift`. Runtime `swiftc` compilation to an ad-hoc
+ executable is prohibited because its unstable process identity regressed
+ Keychain ACL and CoreWLAN behavior. Product packaging still requires a
+ prebuilt, properly signed helper with a stable designated identity and
+ explicit CoreWLAN authorization.
- Do not alter Deco settings, scan the subnet, or guess any credential.
- If the status does not change, do not retry automatically.
- If the supplied credentials are wrong, reconnect over BLE and overwrite them
diff --git a/docs/06_K1_LIVE_VIEWER.md b/docs/06_K1_LIVE_VIEWER.md
index d20d73d..b20002a 100644
--- a/docs/06_K1_LIVE_VIEWER.md
+++ b/docs/06_K1_LIVE_VIEWER.md
@@ -110,12 +110,11 @@ directly and use their sibling metadata receive timestamps when present.
## Connect and stream live
1. Power K1 to its normal steady-green standby state.
-2. Confirm the manual power checklist in **Парк → Локальное устройство**.
-3. Run the real six-second BLE scan and select the intended device from the
+2. Run the real six-second BLE scan and select the intended device from the
complete visible-device list.
-4. Enter the existing router SSID/password and explicitly authorize the reviewed
+3. Enter the existing router SSID/password and explicitly authorize the reviewed
provisioning write. The backend does not retry the write automatically.
-5. Enter the required project name, confirm operator presence, closed LixelGO,
+4. Enter the required project name, confirm operator presence, closed LixelGO,
storage/power and steady green, then choose **Запустить сканирование и
локальный приём** once.
6. Mission Core emits operations 1–6, waits for their correlated device
diff --git a/docs/20_K1_CONNECTION_SUPERVISION_CANON.md b/docs/20_K1_CONNECTION_SUPERVISION_CANON.md
new file mode 100644
index 0000000..3b08e9e
--- /dev/null
+++ b/docs/20_K1_CONNECTION_SUPERVISION_CANON.md
@@ -0,0 +1,1141 @@
+# K1 connection supervision canon
+
+Status: canonical target and product acceptance contract, updated 2026-08-11;
+implementation/hardware conformance remains tracked by the acceptance manifest.
+
+Operator recovery procedure: [`runbooks/K1_CONNECTION_RECOVERY.md`](runbooks/K1_CONNECTION_RECOVERY.md).
+Production macOS association-observer boundary:
+[`adr/0014-k1-macos-association-observer.md`](adr/0014-k1-macos-association-observer.md).
+Physical-state loss and recovery boundary:
+[`adr/0015-k1-physical-state-recovery.md`](adr/0015-k1-physical-state-recovery.md).
+
+This document defines the connection state model for the local Mission Core
+runtime. It replaces the earlier assumption that one remembered K1 IPv4
+address, one retained CoreBluetooth object, one open TCP port, or one incoming
+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.
+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
+change a row label or make an available K1 look connected. Durable physical
+START/STOP ambiguity remains a separate safety barrier: it can deny Apply and
+START until an explicit recovery action reconciles the exact target, but it may
+not turn mode, selection or input into hidden recovery work.
+
+## Product boundary
+
+### Product-surface brief
+
+The operator connects the selected K1 inside the existing device plugin section.
+The surrounding job/entity/lifecycle model is unchanged. This interaction is
+novelty A: a focused improvement to the existing connection surface, not a new
+product area. The chosen surface is the existing plugin section; a separate
+wizard, modal workflow or mandatory preflight/recovery screen is rejected.
+
+The surface reuses the canonical shared `Button`, `TextField`,
+`ActivityIndicator` and `StatusBadge` components. It introduces no shared
+entity, job type or lifecycle state, and it does not implement raw local
+`button`/`input` controls or literal local status colors.
+
+- Bridge/shared-LAN is the product path.
+- Quick Connect/device-AP is a prepared-host laboratory and recovery path.
+- Direct Connect/controller-hotspot remains a distinct topology and is never
+ silently treated as Bridge merely because both can expose a private IPv4.
+- The browser does not own Bluetooth, Wi-Fi, MQTT, or device state. It renders
+ the backend snapshot and invokes explicit operator actions.
+- Operator errors are secret-free. The UI maps an allowlisted public error code
+ to canonical copy; an unknown exception/message uses a canonical fallback.
+ Raw backend messages, credentials, SSIDs, payloads and stack traces are never
+ rendered.
+- No ambient background monitor or passive poll may originate Verify, Scan,
+ selection, reconnect, BLE writes, Wi-Fi provisioning, START, STOP, or repeat
+ an operator mutation. Backend events and bounded state polling may passively
+ refresh already available state; they create no operator progress state and
+ no new device operation. The sole declared exception is a supervised
+ read-only control bootstrap already owned by the exact active Apply intent;
+ after the durable ACK it may finish evidence collection but cannot mutate,
+ retry, create a UI action or outlive that intent's authority.
+- Discovery starts only from one explicit operator action, runs for six seconds
+ per click, and never auto-connects or auto-selects a device. Every ordinary
+ connectable result has the same one enabled **Выбрать** action, including an
+ exact UUID seen in an earlier connection scenario. Selection is a local draft
+ transition: it immediately reveals the selected card and applicable network
+ inputs and performs no Scan, GATT connect, Verify, retirement/reopen or other
+ backend I/O. Fresh discovery results never expose **Переподключиться** or
+ route through physical-recovery UI. Reconnect belongs only to a previously
+ established session after a proven interruption.
+- One explicit **Применить** click owns the resulting connection intent. It uses
+ only the selected transport captured by the completed scan, performs no
+ hidden discovery or read-only preflight, and crosses at most one reviewed
+ device-mutation boundary. An ended or ambiguous intent is never replayed.
+- Normal Bridge Apply never opts into changing the controlling Mac's Wi-Fi
+ association. Host-network switching is a separate future consequential
+ operator action, not a hidden part of Apply. Successful K1 provisioning may
+ therefore end as `network_applied` with `control_not_ready`.
+- The exact REST response to Apply is authoritative for that mutation boundary.
+ When `connection_attempt.phase=network_applied`, the controller accepts the
+ network intent as completed even when `control_state` is
+ `control_not_ready` or `unknown` and returns that durable ACK immediately; it
+ does not wait for WebSocket/poll convergence or full connection-ready. The
+ service may continue the same intent's supervised control bootstrap after the
+ ACK, but that continuation is read-only: no BLE/host mutation, mutation retry,
+ new UI action or second Apply. It is not a hidden frontend Scan or Verify.
+ Full exact readiness remains a separate gate for control and physical START.
+ The old Apply and its credentials are spent, but `connection_attempt` is a
+ read model rather than permanent lifecycle authority: a later new intent may
+ be admitted only by current server policy and a new explicit operator path.
+- App/backend restart, proven BLE disconnect, explicit stop, and a committed
+ Bridge/Quick network transition close that active session. Passive BLE Scan
+ is discovery only; it does not by itself provision, switch topology or
+ resolve a physical command.
+- Loss of Mission Core control does not prove that K1 stopped recording. It
+ revokes local authority and makes the physical device state unknown.
+- Physical motion authority is always false in this layer. A mobile platform
+ requires its own local watchdog and fail-safe stop independent of Mission
+ Core, the Mac, the router, and the browser.
+
+### One-intent connection surface
+
+The existing plugin section uses one progressive two-part surface. Its only
+model-bearing heading is **Подключение XGRIDS LixelKity K1**. The parts are
+**Подключение** and **Сеть**; saved/original/previous device
+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.
+ 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,
+ MQTT publish, Verify, reconnect, provisioning, START, STOP or automatic
+ Scan.
+2. Bluetooth discovery starts only from the explicit search action;
+ while it runs the same step shows an activity indicator and visible seconds
+ countdown. Every completed connectable result keeps one enabled **Выбрать**
+ action, including an exact prior UUID. No found row renders a reconnect CTA,
+ a disabled primary action or a historical-device decision. The completed
+ explicit search is authoritative for this new connection draft; a projected
+ historical recovery fact cannot hijack it and require a second search.
+3. One **Выбрать** click updates browser-local selection only. It keeps the
+ selected row/card visible and immediately reveals Step 02 **Сеть** for Bridge
+ and Direct Connect. SSID/password editing is also local-only. Quick Connect
+ immediately shows its selected-device Apply summary without credential
+ fields. Selection starts no loader and calls no controller method.
+4. One explicit **Применить** click creates the only device connection/mutation
+ intent. The desired mode already belongs to the acknowledged scenario-reset
+ revision; Apply may perform at most one reviewed K1 mutation. Its frontend
+ handler never performs a hidden
+ Scan or Verify,
+ reconnect, retirement/reopen, candidate substitution or retry. The supervisor
+ and ledgers remain the authority before admission and after dispatch.
+
+The successful Apply response latches its post-mutation state immediately. An
+exact REST snapshot whose `connection_attempt.phase` is `network_applied` ends
+and spends the old Apply intent even if control is not ready or is unknown.
+While its exact service-owned child is `accepted` or `running` with
+`safe_next_action=wait-for-current-attempt`, ordinary controls and recovery
+actions remain locked behind one passive **Сеть настроена · подтверждаем
+управление** indicator. Once that child is terminal, an unready/unknown result
+enters the applied-network recovery choice. Delayed WebSocket/poll convergence
+cannot turn the accepted mutation into an error or replay the same intent.
+Verify is then the recommended read-only choice, not a prerequisite imposed by
+the read model. Any new connection path has a new idempotency identity and must
+be explicitly admitted from current backend policy.
+
+The service-owned same-intent bootstrap may still publish a later ready/unready
+snapshot after the REST ACK. It owns no blocking Apply loader and cannot call
+the browser's Scan/Verify/Apply handlers; the frontend may render only its
+passive exact-child settling indicator. Failure simply leaves the spent attempt
+in the explicit recovery-choice presentation; it never replays the network
+write.
+
+The ordinary **Выбрать** action does not weaken backend safety because it grants
+no authority. An admitted fresh candidate exposes inputs immediately. After an
+explicit scenario reset, only a successfully completed successor Scan may make
+an exact previously retired transport eligible for this ordinary draft. Apply
+still requires that exact current-generation CoreBluetooth handle, live GATT
+baseline validation and the existing network-mutation admission; it performs
+at most one write. Immediately before that sole write, the backend may append
+one exact local `reset-network-intent-read-only-settlement` reopen checkpoint,
+bound to the reset, successor Scan, immutable retirement and network request.
+This preserves the retirement and original unknown outcome as append-only
+audit, grants no START/STOP authority and is never exposed as a reconnect step.
+After an applied network result, the same service-owned intent may classify the
+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.
+
+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
+separately guarded stop path outside the connection surface. Identity, GATT,
+CAS, route/control or policy failure leaves physical outcome unknown. No
+password, hidden START/STOP, provisioning or automatic retry bypasses it.
+
+`physical-command.retire-unavailable` remains a local durable backend primitive
+for an unresolved target that is truly unavailable or replaced. It requires a
+separate explicit recovery confirmation and exact runtime/operation/revision/
+transport CAS. It performs zero device I/O, starts no scan, preserves the
+unknown historical outcome and activates the exact transport deny. Selecting a
+row or pressing Apply never invokes it implicitly.
+
+FW 3.0.2 BLE `7f02` does not expose the stable DeviceInfo identity. Therefore a
+physical unit that reappears under a new CoreBluetooth UUID cannot currently be
+recognized as the retired identity before provisioning; this is an explicit
+protocol/field-acceptance gap, not a closed pre-provision identity invariant.
+
+Cancel is an exact revision/intent CAS and is local-only: it sends no BLE,
+Wi-Fi, MQTT, START or STOP command and does not resurrect an old socket. It is
+available only while no scan, Verify, Connect or other lifecycle winner is in
+flight. A stale browser tab cannot cancel or complete a newer dialogue.
+
+The browser may prefill a currently observable SSID when the operating system
+provides it, but it must not claim that macOS can silently disclose the Wi-Fi
+password. Bridge credentials are explicit operator input, retained only in the
+current browser operation memory, and fenced by backend runtime, device,
+connection mode, discovery generation and reconfiguration revision/intent.
+Any draft-fence change clears the password and submit
+authority; a backend runtime or reconfiguration-fence change also clears the
+SSID.
+
+## One connection is a chain of independent evidence
+
+The word `connected` is reserved for the final, current binding. The following
+facts must not be collapsed into one boolean.
+
+| Fact | What it proves | What it does **not** prove |
+| --- | --- | --- |
+| Operator `intent` | Which device and topology the current operation requests | Device presence, a completed mutation, host association, or authority |
+| Fresh BLE advertisement | A transport UUID was observed by the current explicit scan | Selection, a connected GATT session, that K1 is on a network, or that MQTT works |
+| Active selected BLE session | The operator selected one discovered transport and Mission Core established the current reviewed GATT connection | That K1 is on the requested network, that MQTT works, or that the session survives disconnect/restart/stop/mode switch |
+| Live GATT baseline/status | The reviewed K1 GATT contract answered during this explicit operation | That a write is safe to repeat or that the Mac followed K1 to its network |
+| Semantic device topology (`device_network`) | K1 reported the exact requested AP/STA result and, for LAN modes, a valid target address | That the Mac has a route, TCP is open, DeviceInfo matches, or data is fresh |
+| Host association (`host_network`) | The Mac currently has a particular interface, source address, route fingerprint, and host epoch toward the target | That the target is K1 or that port 1883 is reachable |
+| TCP endpoint reachability (`endpoint`) | Port 1883 answered for the exact target in the current host epoch | K1 identity, MQTT application dialogue, control authority, or live sensor data |
+| DeviceInfo/control proof (`control`) | Current MQTT dialogue returned the expected K1 DeviceInfo for the exact intent, mode, target, and host epoch | Fresh point/camera data or that a physical recording stopped after control loss |
+| Live data evidence (`data`) | Point, camera, or telemetry evidence arrived for a bounded acquisition session | Control authority, K1 identity by itself, or permission to start another action |
+| Network-attempt audit | What the previous explicit attempt requested, whether its single write boundary was crossed, and how the attempt ended | Presence, a live session, permission for automatic retry, or a veto over a later explicit session |
+| `last_known` | Historical troubleshooting context from a previously admitted connection | Presence, reachability, selection, lease, or command authority |
+
+The authoritative control binding is the exact tuple:
+
+```text
+(intent_id, transport_ref, connection_mode, target_ipv4:port,
+ host_path_epoch, logical_device_id, compatibility_profile_id,
+ control_session_id)
+```
+
+Evidence for a different intent, transport, mode, target, device identity, or
+host epoch cannot complete this tuple. "Same mode" is not "same connection";
+Bridge to a different K1 is a new intent and must earn new evidence.
+
+## Infrastructure failure domains
+
+The implementation treats every boundary below as independently fallible. A
+failure in one row must revoke only the authority that depended on it and must
+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. |
+| 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. |
+| IP route ↔ TCP probe | route changes while port 1883 connect is in flight | Admit reachability only for one correlated `route A → TCP → route B` sample where A and B are identical. |
+| MQTT control | socket half-open, broker disappears, wrong service answers 1883, late DeviceInfo | Revoke control lease. Require exact current intent, BLE transport, target, host epoch, durable device pin and DeviceInfo before commands. |
+| MQTT/data producers | old capture thread drains late, points continue after control loss, new acquisition starts | Stamp every message with an immutable producer generation; stale generations remain non-authoritative and cannot revive the new session. |
+| Router/phone hotspot | power loss, LTE/Wi-Fi reset, DHCP reassignment | Preserve only the semantically proven K1 topology. Host/TCP/control/data become offline independently; no provisioning retry follows. |
+| Physical K1 | battery removal before/during/after a BLE network write or START/STOP | End the network attempt as interrupted/unknown and allow a later explicit clean network session without replay. For physical START/STOP, preserve the separate durable ambiguity barrier and never infer STOP. |
+
+This is also the design boundary for a future vehicle. Mission Core may report
+loss quickly, but a drive controller must independently enter a locally safe
+state when its sensor/control heartbeat expires. A laptop, router, MQTT broker
+or UI can never be the only motion watchdog.
+
+## Product state and authority matrix
+
+These rows are ordered by proof, not by UI optimism. `Data` is intentionally
+shown independently from `Control`.
+
+| Product state | Device topology | Host path | TCP | DeviceInfo/control | Data | Authority and required presentation |
+| --- | --- | --- | --- | --- | --- | --- |
+| Not selected | none | any/unknown | unknown | absent | idle | No device or network authority. Offer device discovery/selection. |
+| Intent pending | previous only as `last_known` | unknown/current | unknown | revoked | previous stream non-authoritative at most | Show requested topology separately; do not call it connected. |
+| Active connection attempt | previous topology is historical only | unknown/current | unknown | revoked | previous stream non-authoritative at most | One selected K1, one mode, one active GATT session and at most one network write for this explicit intent. |
+| Attempt ended/interrupted | last observation is audit only | unknown/stale | unknown | revoked | evidence-only at most | Clear the active selection/session. Offer the normal scan-select-connect flow; no dedicated recovery action and no automatic retry. |
+| Configured offline | exact intended topology semantically proven | unavailable, wrong, or stale | unknown/unreachable | absent/lost | idle or evidence-only | Preserve the K1 topology fact. Show offline/degraded transport and a read-only host/endpoint verification action. |
+| Reachable unverified | exact current topology | fresh exact host epoch | reachable | absent, stale, or mismatch | independent | `degraded`, never `connected`. TCP alone grants no command or acquisition-start authority. |
+| Connected/controlled | exact current topology | fresh exact host epoch | reachable and fresh | exact current binding, fresh | idle/healthy/stalled/lost | Control allowed. Acquisition start allowed only when its separate lifecycle preconditions also pass. |
+| Control lost, data alive | exact topology remains last proven | current or changing | failed/stale | lost | fresh evidence still arriving | Revoke commands and start authority immediately. Keep data visibly labelled evidence-only; do not send STOP automatically. |
+| Standby unconfirmed | exact topology remains last proven | unavailable/stale | unknown/unreachable | STOP acknowledged and SCAN_STOPPING observed, but READY not observed | local receiver closed/evidence-only | Show `standby-unconfirmed`. After cleanup offer explicit bounded exact-target read-only recovery; deny provisioning and START. The always-available scenario reset may retire only the old local/audit lineage and start a clean Step 01, but does not prove standby or replay STOP. |
+| Standby unknown after STOP deadline | exact topology remains last proven | unavailable/stale | unknown/unreachable | STOP accepted, but neither READY nor SCAN_STOPPING arrived before the backend operation deadline | local receiver/camera/ingress closed locally; evidence-only archive retained | Terminalize the operation as `timed_out` and present product state `standby-unknown`, with `side_effect_status=unknown`, `safe_to_retry=false`; keep the physical ledger unresolved. Allow passive BLE/read-only recovery after successful cleanup, but deny every mutation, START and STOP until exact reconciliation. |
+| Recovery active rebind | exact topology and physical `transport_ref` match | fresh exact epoch | reachable | exactly re-proven DeviceInfo plus fresh same-project SCANNING | old data non-authoritative | Allow passive Scan and one single-use explicit STOP on the still-open recovery binding. An explicit scenario reset supersedes the read-only rebind, locally seals its receiver/camera/control owners and retires the old lineage without a device command; otherwise deny provisioning, START and automatic commands. |
+| Control alive, data stalled | exact current topology | fresh exact host epoch | reachable | exact current binding | stalled/lost | Control may remain available; data is not healthy. Offer stop/diagnosis according to backend `allowed_actions`. |
+| Closed supervisor | historical facts only | stale/unknown | stale/unknown | revoked | non-authoritative | No actions except reopening the product runtime through its normal lifecycle. |
+
+`connected` therefore requires all of the following at once:
+
+1. the current intent exists;
+2. K1's exact semantic topology matches that intent;
+3. the Mac host-path observation is fresh and belongs to the current epoch;
+4. TCP reachability is fresh for the exact target in that epoch;
+5. DeviceInfo and MQTT control proof match the exact binding;
+6. the proof has not expired and no route/sleep/network transition has revoked
+ it.
+
+Live data is not a seventh condition for control connectivity, because data and
+control may fail independently. It is a separate condition for declaring an
+acquisition healthy or its evidence authoritative.
+
+## Non-negotiable invariants
+
+- Ordinary discovery and selection are explicit and never auto-connect. One
+ search click owns exactly one six-second discovery. The operator selects
+ exactly one candidate; **Выбрать** is local-only and merely binds the form to
+ that transport. It starts no backend chain and grants no mutation authority.
+- Candidate presentation and active-session liveness are different. The latest
+ admitted scan generation remains visible without a wall-clock expiry while
+ the operator reads or completes the form. A successor Scan, explicit scenario
+ reset, runtime-owner teardown or proven exact-target GATT failure invalidates
+ that generation. A visible row is never mutation authority: Apply still
+ requires its exact captured CoreBluetooth object and live GATT validation.
+ Proven disconnect, explicit stop, app/backend restart, or a committed network
+ transition ends an admitted session.
+- A disconnect callback closes the active session and clears its live
+ selection. Rediscovery never reconnects the network automatically. UI entry,
+ row selection and form input perform no I/O. Mode/new-device choice performs
+ only its explicit local scenario reset and no device/host I/O. The
+ operator explicitly searches, selects, enters data and presses **Применить**.
+- Passive `scan-ble` means non-mutating with respect to K1; CoreBluetooth Scan
+ is still active discovery, not RF sniffing. It sends no K1 GATT/network or
+ physical command. After local
+ receiver/control cleanup has completed and the BLE runtime lease is free, an
+ unknown or proven-active physical record must **not** disable passive Scan.
+ It still blocks provisioning and START on the old lineage; scenario reset may
+ retire that lineage without resolving it. A discovered row is presence
+ evidence only and grants none of those actions.
+- Physical-state recovery is pinned to the durable physical record's exact
+ `transport_ref`. A fresh candidate for another K1 may be displayed, but it
+ cannot replace the recovery target, change semantic topology, reconcile the
+ ledger or authorize any command. Selecting or probing the wrong K1 is a
+ fail-closed, no-topology-change outcome.
+- `physical-command.retire-unavailable` is the only exception for an exact
+ physical target that the operator confirms is permanently unavailable or
+ replaced. It is a local ledger/authority transition, not physical
+ reconciliation and not device takeover: the original outcome remains
+ unknown, no device I/O or automatic Scan occurs, and the exact retired
+ transport receives an active deny for provisioning, Verify/adoption,
+ control, calibration, physical commands and network writes. The retirement
+ and unknown original outcome remain permanent historical audit even if that
+ active deny is later reopened. Explicit passive Scan/GATT metadata/MQTT
+ capture is non-authoritative evidence and cannot by itself restore topology
+ or control. Only a fresh exact same-UUID row plus the explicit exact-CAS
+ `physical-command.reopen-retired-reconciliation` transition may remove that
+ one active retirement deny for read-only reconciliation. Reopen performs no
+ device I/O. It is normally invoked only by a separate explicit recovery
+ action. The sole cold-flow exception is the exact local reopen checkpoint
+ inside a reset-owned, successor-Scan-owned Apply, durably ordered after the
+ network PREPARED record and before the one write-dispatch edge. Row selection
+ never invokes either path and no reconnect UI is rendered. A retired durable
+ identity, once DeviceInfo makes it observable, remains denied control and
+ successor START outside that exact reopened reconciliation. Because BLE
+ `7f02` has no stable device identity, a new UUID for the same hardware remains
+ a pre-provision detection gap.
+- Bridge to Quick, Quick to Bridge, same-mode reconnect, and selecting another
+ K1 all close the old connection session before starting the new one. Old
+ host, endpoint, control, data and BLE ownership cannot cross that boundary.
+- Every explicit network intent creates a new connect, validates the reviewed
+ GATT contract, and may read internal baseline `7f02` before exactly one
+ reviewed write. The baseline is internal protocol validation, not a mandatory
+ operator recovery step or separate product button.
+- A fresh Bridge `7f02` observation is provisional when it comes from the
+ explicit Apply-owned network operation.
+ It may be projected in memory for route/TCP/DeviceInfo checks, but it does
+ not replace durable semantic topology until the exact MQTT DeviceInfo has
+ matched or created the immutable transport/profile identity pin. Identity
+ mismatch, cancellation, route/control failure or process death leaves the
+ previous durable device A unchanged, or leaves the store empty on first
+ contact, and retires only the provisional B session. A previously dispatched
+ unresolved network write keeps its separate durable-first reconciliation
+ semantics.
+- FW 3.0.2 `7f02` uses its first text slot as `WIFI_AP` in AP state and as the
+ joined network name in station state. Bridge/Direct admission compares that
+ post-write name exactly with the current explicit request and a valid non-AP
+ private IPv4. The parser publishes only the normalized `WIFI_CLIENT` family;
+ raw network names remain inside the sensitive operation evidence and never
+ enter the public API or durable secret-free audit.
+- A timeout, disconnect, cancellation, browser loss, or process death never
+ triggers an automatic network write retry. The attempt becomes terminal audit
+ (`not-dispatched`, `completed`, `failed`, `interrupted`, or `outcome-unknown`)
+ and releases its right to block a later distinct explicit session.
+- Repeating an explicit operator action creates a new intent and may perform
+ its own one reviewed write after ordinary current-session preconditions pass.
+ The previous attempt is not replayed and its payload/credentials are not
+ reused automatically.
+- Network-attempt audit is secret-free, atomically recorded, and useful for
+ diagnosis. It is not durable connection authority and cannot require a
+ read-only reconciliation UI before another explicit connect.
+- Active concurrency remains fail-closed: a process-wide BLE owner and stable
+ cross-process lease admit at most one discovery/GATT/network operation at a
+ time. A new operator intent does not bypass an operation that is still
+ actively executing or native cleanup whose completion is unproved. Once that
+ active work terminates, historical audit cannot keep the lease fenced.
+- Route or MQTT failure after a proven device topology does not make the BLE
+ write unknown. It produces configured-offline/degraded state with no control
+ authority.
+- Bridge provisioning does not silently move the Mac to another Wi-Fi network.
+ The ordinary UI sends no host-switch opt-in. `network_applied` is a successful
+ terminal K1 mutation even when control is not ready; the UI must not repeat
+ that intent's BLE write. Recommended separately explicit read-only Verify may
+ establish host route, endpoint and control proof for the applied topology; a
+ server-policy-admitted new intent follows its own fresh path. Any future
+ host-switch action requires its own consequential confirmation and intent.
+- `network_applied` plus `control_not_ready` or unknown outcome spends the old
+ Apply. An exact accepted/running service child first keeps ordinary controls
+ and recovery actions behind a passive settling indicator. After that child is
+ terminal, the UI presents an explicit recovery choice, independent of the
+ browser-local mode. It never authorizes an automatic or same-intent replay.
+ Recommended Verify accepts only the backend `serverBound` current/configured
+ transport and mode; browser selection, remembered rows and local mode can
+ never substitute its target.
+- A new intent is nevertheless possible when current server policy admits an
+ explicit path. Bridge uses the distinct `prepare-select-device` local-only
+ CAS with zero device/host I/O; only after success may the operator start a
+ fresh six-second scan. Quick and Direct use an explicit policy-gated
+ `scan-ble`, then the latest fresh row and a new idempotency Apply. A mode or
+ same-mode new-device request instead uses the local `reset_scenario` CAS,
+ available across every non-reset owner, and is followed by a fresh explicit
+ scan. None of these
+ choices selects a row, scans, Verify, provisions
+ or continues Apply behind another click; the browser grants no authority.
+- A pre-write failure records no network side effect and ends the new attempt.
+ Previous topology may remain historical context, but no old active session is
+ resurrected.
+- Selecting any mode, including the current one through **Подключить другой
+ K1**, sends one explicit idempotent `reset_scenario` with exact desired-mode
+ revision. The first reset supersedes an older non-reset local action; while it
+ owns `mode`, the visible reset controls dispatch no second intent. It seals
+ retained receiver/camera/control owners, invalidates BLE candidates,
+ credentials and topology authority, and durably retires ambiguous old
+ physical lineage without resolving it. It performs no device command, BLE,
+ host-network write or automatic Scan. The resulting Step 01 starts clean and
+ search remains a separate click. Apply remains the only boundary that creates
+ a new network intent and may cross the reviewed device-write edge.
+- Host route/interface epoch changes, manual Wi-Fi switches, sleep/wake,
+ endpoint loss, MQTT failure, or stale protocol evidence revoke their live
+ authority immediately. Re-admission is explicit and never writes
+ automatically.
+- A host epoch includes an opaque, process-scoped association token supplied by
+ the OS helper. Raw SSID/BSSID values never enter API state or logs. Missing
+ association evidence remains explicitly `unavailable` and never becomes
+ association proof. The laboratory helper keeps one fallback token only for
+ the same process/interface/failure scope so an unchanged kernel route is not
+ falsely rotated every second. Its proven association token is derived from
+ interface+BSSID, not SSID visibility, so the same BSSID remains stable across
+ `ssid+bssid` and `bssid-only` observations. For an already exact healthy
+ DeviceInfo/control binding only, an unproven helper observation with the same
+ raw kernel fingerprint/interface/source retains the preceding proven host
+ fingerprint while the monitor still performs TCP and a final raw-route
+ recheck. This refreshes route/TCP liveness, not association or control proof.
+ Interface/source/route loss, a proven different BSSID, endpoint/control loss,
+ proof expiry, intent/target change, or process restart still revokes. An
+ unverified path remains bounded and fail-closed. Authority still requires
+ fresh TCP plus exact DeviceInfo/control evidence; the fallback token alone
+ grants nothing.
+- A TCP success is admissible only when host route/association observations
+ taken immediately before and after the connect are identical. A success
+ attached to a different or unknown epoch is discarded.
+- TCP port 1883 being open is diagnostic only. Correlated K1 protocol evidence
+ is required for device identity and control authority.
+- `connection.endpoint-probe` is the only configured/offline host-only action.
+ It selects either the exact coherent current supervisor target or the latest
+ valid durable semantic-topology record on the server, performs one bounded
+ association-bound host-route/TCP:1883/association-bound host-route sample
+ through the same process-owned macOS association observer used by the
+ supervisor, without mutating supervisor evidence, and publishes
+ `missioncore.xgrids-k1-configured-endpoint-probe/v1`. It never enters the BLE
+ runtime, never reads DeviceInfo, never changes K1 networking, never retries
+ automatically and never grants
+ control authority. The result carries the exact transport plus current intent
+ or durable semantic revision, so a result from an older target is not rendered
+ as evidence for a replacement topology. It is diagnostics, not a prerequisite
+ for the normal scan-select-connect lifecycle.
+- The first exact MQTT DeviceInfo accepted for one BLE `transport_ref` creates
+ a private durable identity pin. A later logical-device/profile mismatch is a
+ hard identity conflict and cannot overwrite that pin automatically.
+- Exactly one local Mission Core process may own the application-control lease.
+ Process death releases the OS lock, but a second live process cannot open a
+ competing control dialogue or emit START/STOP.
+- The same stable OS lease is acquired at the lowest supported CoreBluetooth
+ boundary for discovery, status/calibration reads, GATT inspection, AP enable
+ and Wi-Fi provisioning. A higher-level network transaction may explicitly
+ borrow its already-held lease, but no service, CLI or read-only helper may
+ bypass it or acquire a second lock file.
+- Caller timeout/cancellation does not release that OS lease. Ownership moves
+ to the detached native cleanup and is released only when CoreBluetooth has
+ actually completed. An unproved cleanup poisons the BLE runtime and retains
+ the lease until process exit; a new loop or request cannot guess that the
+ adapter is idle.
+- Control and data freshness are independent. Either can fail without
+ inventing the state of the other.
+- No command is automatically replayed after timeout, reconnect, wake, browser
+ refresh, process restart, or network change.
+- There is one narrowly scoped service-owned exception to the general
+ no-background-reconnect rule: an already running, plugin-commanded
+ acquisition with a composite-confirmed START may rebind its **read-only data
+ and inspection transports** after transient host-path loss. This exception is
+ defined below. It is not an operation retry and grants no command or network
+ mutation authority.
+- UI entry, mode, selection and credential input never
+ start recovery work. Apply is the only normal connection intent. It performs
+ no hidden discovery, browser Verify, candidate substitution or command
+ replay. The exact post-reset retired-UUID exception defined above may append
+ one request-bound local reopen checkpoint inside Apply and settle it through
+ the service-owned read-only continuation; it is not a UI recovery step and
+ grants no extra write or START/STOP. There is no loop, timer-driven retry, similar-device fallback,
+ continuation after completion or background resumption. A later Apply creates
+ a new bounded intent; the ended one never restarts itself. Exact target
+ authority comes only from the captured fresh discovery, backend policy,
+ semantic topology and supervisor/ledger fences; browser memory cannot
+ manufacture it.
+- The read-only observation may open a non-reconnecting pre-START control
+ generation for the exact target. Outbound ordinal 1 is exactly one canonical
+ DeviceInfo request; it publishes nothing else and then, when physical
+ classification is required, waits passively for a fresh, non-retained
+ DeviceStatus received after the DeviceInfo barrier. Each requested read-only
+ observation emits zero BLE writes, Wi-Fi provisioning writes, DeviceConfig,
+ ModelingStatus/status solicitations, time sync, workspace, project, START or
+ STOP. It is not a network reconnect and grants no mutation authority.
+- A denied Apply terminates before device I/O with an explicit stale or
+ safety-blocked outcome. An Apply whose response is lost after dispatch is
+ explicitly outcome-unknown and `safe_to_retry=false`; it never reuses the
+ password or restarts itself. Step 02 remains visible with the selected card so
+ the operator can understand the result and deliberately start a new scan or
+ intent. Physical recovery remains pinned to its durable `transport_ref`, so
+ another K1 cannot be substituted by client inference.
+- Fresh canonical READY records cessation/standby without inventing a
+ successful STOP. Fresh initialized SCANNING for the exact same project may
+ rebind the successful or ambiguous physical START to the exact new control
+ generation and expose one single-use, explicitly confirmed STOP. It never
+ replays START, never sends STOP automatically and never grants a second STOP
+ from the same confirmation/checkpoint.
+- A STOP application acknowledgement is not a READY observation. If
+ SCAN_STOPPING was observed and Wi-Fi or MQTT is then lost before READY, local
+ receiver/control cleanup may finish and the product state becomes
+ `standby-unconfirmed`. Explicit passive Scan and explicit bounded exact-target
+ read-only recovery remain available; provisioning and START remain blocked on
+ that lineage. Scenario reset may retire it without claiming READY.
+- If STOP was accepted but neither READY nor SCAN_STOPPING arrives before the
+ backend-owned operation deadline, Mission Core performs local-only
+ receiver/camera/perception-ingress cleanup, terminalizes the operation as
+ `timed_out`, and presents the local result as `standby-unknown`, with
+ `side_effect_status=unknown` and
+ `safe_to_retry=false`. The durable physical ledger stays unresolved. After
+ cleanup, passive BLE and read-only observation are allowed; provisioning,
+ START and STOP remain denied on that lineage until exact reconciliation.
+ Scenario reset remains available as a local retirement escape. A cleanup
+ failure retains the local lease/fence and retries
+ only host cleanup; it never repeats the physical STOP.
+- Every runtime message is bound to the acquisition producer generation that
+ created it. A callback from an older generation is rejected before it can
+ update data freshness, perception ingress, metrics, or authority.
+- In particular, **no automatic BLE network write or retry is allowed after an
+ ambiguous dispatch**. A later explicit operator attempt is a new session and
+ is allowed after the old active operation and cleanup have terminated.
+- The network-attempt journal and semantic topology store are deliberately
+ separate. The journal is historical audit; the topology store answers only
+ what an exact current observation last proved. Neither restores BLE presence,
+ host, endpoint, DeviceInfo, control or data authority after restart.
+- Legacy unresolved network records are terminalized as historical
+ `abandoned-by-restart`/`outcome-unknown` audit during migration. They are not
+ silently declared successful and do not authorize replay, but they also do
+ not veto a new explicit operator session.
+
+### Sole automatic active-stream read-only rebind exception
+
+This exception exists only while one process still owns the exact active
+acquisition. Admission requires every fact below at the instant data transport
+reports connection loss:
+
+- the physical ledger has one resolved, composite-confirmed START with publish
+ return, QoS2 completion, successful application response and fresh
+ non-retained initialized same-project SCANNING;
+- the current acquisition id, device id/session, evidence session, snapshot
+ runtime id, runtime producer generation, connection intent/mode/target,
+ transport ref and physical operation id all match that START lineage;
+- the acquisition-owned right-camera recording root still matches the same
+ evidence session; and
+- no explicit mode/reset/force-finish owner has superseded the lineage.
+
+When admitted, the runtime remains neutral `reconnecting`; a short route loss
+does not terminalize the acquisition. Retry cadence is bounded backoff
+`0.5s, 1s, 2s, 4s`, then `5s` capped. There is no automatic terminal timeout:
+the owner remains reconnecting/blocked until exact recovery, truthful physical
+standby/fault evidence, or explicit local force-finish. A late failure from the
+old application-control socket is classified as superseded transport evidence
+and cannot raise a global terminal operation while this recovery generation is
+current.
+
+Local force-finish invalidates that recovery generation before closing any
+socket or producer. A receiver, camera or archive cleanup failure therefore
+cannot resurrect the old acquisition: it is a visible retryable **local-only**
+finalization failure, retains the evidence cleanup fence, and leaves the
+physical START proof untouched. Only an explicit local cleanup retry or exact
+connection-scenario reset may finish those retained host resources; neither
+path may publish START/STOP, enter BLE, or write device/host network state.
+
+Each attempt may perform only:
+
+1. exact host-route/association and TCP observation for the frozen target;
+2. one inspection-only DeviceInfo bootstrap on a new globally fresh control
+ generation; and
+3. passive receipt of a fresh, non-retained DeviceStatus after that DeviceInfo
+ barrier.
+
+It performs zero BLE discovery/write, Wi-Fi association/provisioning,
+DeviceConfig, time sync, workspace/project mutation, START or STOP. The raw
+capture evidence writer is retained across MQTT client replacement. A durable
+read-only reconciliation may advance the physical record revision; a failed
+first MQTT SUBACK may retry only while every immutable lineage field is equal
+and that revision advanced monotonically for the same operation.
+
+Recovery outcomes are fail-closed:
+
+- exact same identity and initialized same-project `SCANNING` append a durable
+ active rebind, adopt the fresh control generation and resubscribe data without
+ replaying START;
+- fresh unbound `READY` appends durable cessation and interrupts/seals local
+ acquisition owners without STOP;
+- fresh `SCAN_OVER` is also durably classified as cessation, but leaves a
+ `scan-over-awaiting-ready` read-only fence. It is no longer modeled as an
+ active START, yet a later START remains denied until another fresh exact
+ `READY` reconciliation;
+- foreign identity/same-IP substitution, changed target/session/runtime or
+ camera lineage blocks recovery for explicit operator handling; and
+- device/system fault or unsafe status terminalizes locally as fault without
+ STOP or retry.
+
+If the acquisition-owned FFmpeg process exited or has emitted no complete
+segment for the stall threshold, recovery may CAS-restart only the same right
+camera/source/target/recording session and exact source-generation/active-epoch.
+The old archive epoch is sealed `interrupted`, a new epoch is opened once, and
+late callbacks from the old epoch are generation-rejected. This is local
+producer recovery, not public camera selection or a device command.
+
+`acquisition.force-finish-local` is the explicit escape hatch for permanent
+loss. The request is fenced by snapshot runtime, acquisition id/state revision,
+recovery generation and runtime producer generation. The backend invalidates
+and cancels the recovery owner before closing local control, receiver, camera
+and perception ingress; it preserves physical START evidence and sends no
+STOP. A queued mode reset uses the same idempotent helper. Whichever wins the
+local lifecycle gate supersedes the other, and no late recovery success may
+revive the old acquisition.
+
+## Network-attempt lifecycle and audit
+
+The live operation may pass through `prepared`, `dispatching`, and `observing`.
+Those stages fence concurrent work only while that explicit operation or its
+native cleanup is active. The durable record is an audit trail, not a
+cross-session mutation barrier.
+
+| Stage/outcome | Meaning | Next explicit session |
+| --- | --- | --- |
+| `prepared` | Current selected session passed preconditions; no write crossed the dispatch boundary | If interrupted, record `not-dispatched`, release active ownership, and allow a new scan-select-connect attempt. |
+| `dispatching` | Audit was persisted immediately before the one reviewed BLE write | If the process/transport ends without a result, record `outcome-unknown`; never replay automatically. |
+| `observing` | The one write returned and bounded status observation is running | Record the observed outcome or `outcome-unknown`, then release active ownership. |
+| `completed` | Current attempt observed its intended topology | Rebuild host/TCP/DeviceInfo for this session; later explicit intents still start cleanly. |
+| `failed` / `interrupted` / `outcome-unknown` | Current attempt ended without a usable connected session | Clear selected/live session. The normal operator flow may start a distinct new attempt; no special recovery control is required. |
+| Corrupt audit | Historical data cannot be trusted | Quarantine/report the audit record without manufacturing success or replay. If the active process lease is free, corrupt history alone does not make the physical K1 permanently unusable. |
+
+An unknown result remains honestly unknown in history. Mission Core does not
+claim that the command was applied or not applied. That uncertainty prohibits
+automatic replay of the old attempt; it does not prohibit a later fresh,
+explicit operator intent from sending its own single reviewed command.
+
+## Transition rules
+
+### Switching Quick Connect and Bridge
+
+1. A mode choice or same-mode **Подключить новый K1** click is one explicit,
+ idempotent `reset_scenario` under desired-mode CAS. It remains available
+ during active, reconnecting, cleanup-pending and unknown-physical states.
+ Mission Core locally seals/retire receiver, camera and control ownership,
+ invalidates candidates/drafts/credentials and retains truthful audit that
+ the previous K1 may still be scanning. It sends no device command, BLE,
+ host-network write or automatic Scan.
+ The top-right refresh-shaped utility is also this explicit reset, not
+ `state.read`: **Сбросить подключение** sends one reset to the canonical
+ Bridge default. While pending its label is **Сбрасываем подключение** and it
+ is non-dispatchable until that bounded reset settles, preventing a second
+ browser token from abandoning the accepted result. It still supersedes any
+ other pending local action. The committed revision overwrites even a dirty
+ browser mode draft with canonical Bridge.
+2. Choosing the original mode is a new-device reset, not cancellation of a
+ browser-local draft. A duplicate `reset_id` replays the committed result;
+ a newer reset supersedes an older waiting reset before teardown.
+3. Explicit Scan is a separate later action and runs one six-second bounded
+ passive discovery. Mission Core proves that
+ local runtime/native cleanup has completed and the BLE discovery lease is
+ free. It may close only a safe pre-START local control session and locally
+ abort an exactly prepared/no-receiver acquisition without STOP. An unknown
+ or active physical record does not block this Scan. The committed reset
+ marker remains `active=true` until a successfully admitted explicit Scan
+ completes, including an empty successful result. The Scan captures the
+ exact reset id/revision/mode at action entry, then stores its admitted
+ discovery generation and sets only that unchanged marker inactive. Failure,
+ cancellation, generation invalidation or a different
+ newer pending reset leaves it active. The inactive marker remains available
+ for exact `reset_id` replay and old-operation suppression. It continues to
+ fence the saved/reconnect presentation of the retired scenario, including
+ after reload, but never hides a newly correlated connection attempt admitted
+ after the reset revision. A new failed or outcome-unknown attempt renders
+ its current recovery/error surface immediately.
+4. Scan does not revoke or replace semantic topology and starts no connect or
+ Verify. Physical recovery remains pinned to the exact `transport_ref`.
+5. The operator selects one ordinary fresh K1 and immediately enters applicable
+ credentials. Selection is local-only. Apply proves physical standby, exact
+ mode/discovery/runtime CAS and the selected transport. Physical unknown/
+ active blocks Apply and START while leaving separately explicit recovery.
+6. Apply retires old live authority, creates the new connection
+ intent, opens a new GATT session for that selected transport, may read
+ internal baseline `7f02`, and performs at most one reviewed network write.
+ It is never retried automatically.
+7. Success admits the observed topology. Normal Bridge Apply does not switch Mac
+ Wi-Fi. Host/TCP/DeviceInfo may therefore remain unready and require explicit
+ read-only Verify. Failure/disconnect ends the new session; the next operator
+ attempt starts with explicit discovery and selection.
+
+Quick to Bridge and Bridge to Quick obey the same transition. Neither path may
+carry an old Mac route, TCP result, DeviceInfo response, control session, data
+session, or UI `connected` label across the intent boundary.
+
+### Losing K1, the router, or the Mac route
+
+- Loss before dispatch records `not-dispatched`; loss at/after dispatch records
+ `interrupted`/`outcome-unknown`. Both end the active connection session and
+ permit a later distinct explicit attempt. Neither automatically retries.
+- Loss after the target was semantically proven preserves that K1 topology but
+ revokes host, endpoint, DeviceInfo, control, and acquisition-start authority.
+- During acquisition, fresh data that survives control loss is evidence-only.
+ Mission Core must not infer that recording stopped, nor issue an automatic
+ STOP over a newly recovered connection.
+- Once local receiver/control/native cleanup is complete, physical unknown or
+ active state leaves passive BLE Scan available. It continues to deny Apply
+ and START on the old lineage. Explicit read-only recovery targets only the
+ durable physical record's `transport_ref`; alternatively, scenario reset may
+ retire that old lineage without resolving it and open a clean new Step 01.
+- If that exact target is permanently unavailable, retirement/reopen requires a
+ separate explicit recovery confirmation with exact CAS. Neither **Выбрать**
+ nor **Применить** owns that local mutation or subsequent Verify.
+- If STOP reached SCAN_STOPPING and the Mac loses Wi-Fi before a fresh READY
+ status, the local operation is `standby-unconfirmed`, not a generic connected
+ state and not a proven STOP. If STOP was accepted but neither READY nor
+ SCAN_STOPPING arrived before the backend operation deadline, local-only
+ cleanup produces terminal `standby-unknown`, while the physical ledger stays
+ unresolved. In both cases only exact read-only recovery may classify READY or
+ same-project SCANNING; no command is replayed automatically.
+- A manual Mac Wi-Fi change and an unplanned router/hotspot failure have the
+ same authority consequence: host epoch changes or expires, so all evidence
+ bound to the old epoch is rejected.
+
+### Browser, sleep, wake, and backend process lifecycle
+
+- Browser app refresh/close emits no device command but ends that operator
+ connection session. The next page load starts with no selected live K1 and
+ renders the cold mode + Step 01 **Подключение** surface. Backend-owned history
+ stays internal; only explicit search, Apply, or a separately rendered exact
+ recovery action may create I/O. Uniform ordinary **Выбрать** creates only a
+ browser-local draft.
+- Mac sleep/wake always invalidates the old host epoch and lease, even if the
+ interface name and IPv4 appear unchanged after wake.
+- Backend restart closes every old network connection session. A live network
+ attempt is terminalized as `not-dispatched` when dispatch was not crossed or
+ `abandoned-by-restart`/`outcome-unknown` otherwise. Backend startup does not
+ verify, scan, reconnect, provision, START or STOP automatically. A later
+ operator action may invoke one bounded exact-target read-only attempt.
+- A resolved target may restore semantic topology after restart, but it starts
+ configured/offline. Host path, TCP and DeviceInfo must be observed afresh.
+- Restart discards every in-memory CoreBluetooth object, selection and session
+ token. The UI starts with no selected live K1. Explicit search plus ordinary
+ **Выбрать** creates only the new local candidate draft; it never internally
+ re-observes a target. A distinct exact recovery action may perform the one
+ backend-authorized read-only observation when policy requires it.
+ Old network-attempt audit has no role in admission. Host association, TCP
+ reachability, DeviceInfo/control and data remain separate proofs.
+- Service shutdown cancels and owns an exact control-bootstrap child while its
+ owner event loop is running. If synchronous shutdown begins only after that
+ loop has already stopped or become unavailable, Python cannot physically
+ join the now-unrunnable Task. The service must still terminalize the durable
+ child fail-closed and set a closing fence before releasing ownership, so a
+ retained Task reference cannot later publish success, perform I/O or grant
+ control authority; the reference may remain until loop/service destruction.
+
+## Required acceptance scenarios
+
+Each scenario must assert backend state, durable ledger state, authority,
+allowed next action, and product presentation. Passing only the HTTP request or
+showing a green lamp is insufficient.
+
+### Topology transitions and device identity
+
+| ID | Scenario | Required acceptance |
+| --- | --- | --- |
+| CONN-01 | Quick Connect to Bridge succeeds | Quick session closes; the operator starts a new Bridge scan, local selection and Apply; one Bridge write at most; exact STA/LAN status commits Bridge; host/TCP/DeviceInfo rebuild; `connected` appears only after the exact new binding. |
+| CONN-02 | Bridge to Quick Connect succeeds | Same guarantees in reverse; old Bridge session closes before new Quick discovery, local selection and Apply; AP status commits Quick even before the Mac joins it; old Bridge route/DeviceInfo cannot authorize Quick. |
+| CONN-03 | Quick to Bridge fails before dispatch | New attempt terminalizes `not-dispatched`; old Quick facts are historical only, no live session is restored; no BLE side effect, no automatic retry, and a later explicit attempt is allowed. |
+| CONN-04 | Bridge to Quick fails before dispatch | New attempt terminalizes `not-dispatched`; old Bridge facts are historical only; active ownership releases and a later explicit attempt is allowed. |
+| CONN-05 | Bridge to Bridge, same mode but different K1 | Different transport/device creates a new intent. Old K1 TCP/DeviceInfo is rejected; the new K1 must pass exact discovery, topology, target, identity and control proof. |
+| CONN-06 | Same UUID is rediscovered after the prior session ended | It remains a candidate until the operator presses the same **Выбрать** action used by every row. That click updates only the local form and immediately exposes applicable inputs. It performs no Scan, connect, Verify, reopen, network mutation or command. |
+| CONN-07 | Wall-clock time passes beyond the legacy candidate TTL after one explicit Scan or admitted Apply | Unselected rows remain stable for the current scan generation; the active GATT session also remains usable. A successor Scan/reset/runtime-owner teardown or proven exact-target GATT failure invalidates the applicable candidate/session, while every Apply still requires exact-handle capture and live GATT validation. |
+| CONN-08 | Backend/app restarts after any network-attempt stage | No in-memory CoreBluetooth handle, selection or active network session survives. The previous attempt becomes terminal audit and never causes automatic discovery/write or blocks the normal new scan-select-Apply flow. |
+
+### Hard K1 power loss by operation phase
+
+| ID | Fault point | Required acceptance |
+| --- | --- | --- |
+| CONN-10 | Power removed before `prepared` | No write. Active selection/session clears; previous state is historical only; operator may power K1, scan, select and Apply again. |
+| CONN-11 | Power removed after `prepared`, before `dispatching` | Attempt terminalizes `not-dispatched`; no previous live session is restored; active ownership releases and a later explicit attempt is allowed. |
+| CONN-12 | Power removed after `dispatching`, before write result | Attempt is terminal `outcome-unknown`, no automatic retry occurs, active selection/session clears, and a later explicit operator scan-select-Apply intent is allowed after current safety gates. Physical START/STOP ambiguity rules are unaffected. |
+| CONN-13 | Power removed during `observing` | Last bounded observation remains audit; attempt ends and active ownership releases. No automatic BLE retry and no mandatory read-only recovery UI. |
+| CONN-14 | Power removed after target topology was semantically applied, before host association | New device topology stays committed; state is configured-offline, not mutation-unknown and not connected. |
+| CONN-15 | Power removed while connected but idle | Host/TCP/control lease expires; topology becomes last proven context; no device command is inferred or replayed. |
+| CONN-16 | Power removed during acquisition | Control and data transition independently to lost/stale. A process-owned composite START may enter the sole read-only active-stream recovery generation, but a powered-off K1 cannot satisfy route/TCP/DeviceInfo and remains reconnecting/blocked until explicit local force-finish. Physical recording stays unknown/last-proven-active; no automatic STOP/START or network write occurs. |
+| CONN-17 | Process crashes at any START/STOP publish boundary | Durable physical-command state distinguishes `prepared` from `dispatching/observing`. Only `prepared` can become `not-dispatched`; every later ambiguous stage blocks replay and automatic START/STOP after restart. |
+| CONN-18 | A proven START survives, but its original MQTT process/socket is lost while the same process-owned acquisition and project are still SCANNING | The sole service-owned active-stream exception retries exact host/TCP plus inspection-only DeviceInfo and passively receives a fresh non-retained initialized SCANNING status for the frozen lineage. The original START remains `succeeded`; the raw writer and producer generation are preserved, START is not replayed, and data resubscribe resumes only after durable same-lineage rebind. |
+| CONN-18A | Active-stream recovery observes fresh SCAN_OVER | Append a durable cessation audit for the same START, interrupt/seal local owners without STOP, clear active physical classification and retain a `scan-over-awaiting-ready` read-only fence. A fresh START remains denied until another exact fresh READY reconciliation. |
+| CONN-19 | A proven START survives, then K1 is power-cycled and a fresh explicit observation sees READY | The bounded exact-target attempt publishes one DeviceInfo request and passively observes fresh non-retained READY. Record physical cessation without inventing a successful STOP; the original START remains `succeeded` and effective physical state becomes standby. A later START still requires a fresh normal control session and READY baseline on the socket that will publish it. |
+
+### Host network continuity
+
+| ID | Scenario | Required acceptance |
+| --- | --- | --- |
+| CONN-20 | Shared router or phone hotspot disappears | Host epoch/endpoint/control revoke; Bridge topology remains last semantically proven. The plugin section returns to its ordinary cold progressive flow, not provisioning success, a stale connected state or a technical recovery ceremony. |
+| CONN-21 | Router returns with the same SSID/IP | Similar-looking network values do not resurrect the old lease. Fresh host observation, TCP probe and exact DeviceInfo/control proof are required. |
+| CONN-22 | Operator manually switches Mac Wi-Fi during Bridge | Epoch changes immediately; in-flight old-epoch TCP/DeviceInfo results are rejected; no automatic BLE write follows the switch. |
+| CONN-23 | Operator manually leaves K1 AP during Quick Connect | Quick topology remains the device fact, while host/endpoint/control become offline; returning to the AP requires read-only re-verification. |
+| CONN-24 | Mac sleeps and wakes on the apparently same network | Old epoch, endpoint and DeviceInfo proof are stale; reacquire them read-only before control returns. |
+| CONN-25 | Mac route changes between host probe and TCP result | The result for the earlier epoch is discarded and cannot create reachable or connected state. |
+| CONN-26 | TCP 1883 is open on a wrong/non-K1 target | Endpoint may show reachable diagnostic only; missing/mismatched DeviceInfo keeps connectivity degraded and authority false. |
+| CONN-27 | MQTT socket is half-open or heartbeat/control proof stops advancing | Route and TCP alone do not preserve authority. Exact-session control proof expires on monotonic and suspend-aware TTL; control/start revoke without sending a command. |
+| CONN-28 | Physical-state recovery is required after an ambiguous/interrupted command or a resolved active START whose control socket was lost | Normal row selection stays local and Apply stays denied. A separately explicit recovery action for the exact physical record `transport_ref` may own at most one read-only Verify; another row cannot substitute for it. Each observation generation publishes exactly one canonical DeviceInfo request and passively accepts only a fresh non-retained DeviceStatus after its DeviceInfo barrier. READY records standby/cessation. Exact same-project initialized SCANNING records an active rebind and exposes one single-use explicit STOP outside connection setup. No recovery action scans, provisions, retries or replays a command. |
+| CONN-29 | K1 is already SCANNING but there is no matching durable Mission Core START chain, or a different K1 is discovered during recovery | Treat it as external/foreign state, not as our successful START. The foreign candidate may remain visible as passive discovery evidence, but it cannot replace the physical record's `transport_ref`, mutate topology, reconcile the ledger or authorize START/STOP. No automatic STOP is sent; takeover requires a separate explicit durable policy. |
+| CONN-29A | Mac Wi-Fi/route disappears for longer than the old control keepalive while one composite-confirmed acquisition remains owned, then returns | Keep acquisition neutral `reconnecting`; retire the late old-control failure as superseded transport. Exact route/TCP/DeviceInfo plus fresh same-lineage SCANNING resumes the receiver. Wrong identity, runtime/session replacement or changed physical operation fails closed. |
+| CONN-29B | Operator chooses `Завершить локально` while recovery is pending, concurrently with a mode reset or late recovery success | One lifecycle-gated idempotent cleanup wins, invalidates recovery generation first, seals receiver/camera/control locally and preserves physical evidence. No STOP/START/BLE/network mutation is sent and no late callback revives acquisition. |
+
+### Durable restart and concurrency
+
+| ID | Restart/concurrency point | Required acceptance |
+| --- | --- | --- |
+| CONN-30 | Browser/app refresh or close during a network connection attempt | End the operator connection session without automatic command. Reload starts with no selected live K1 and offers the normal discovery flow; prior attempt remains audit only. |
+| CONN-31 | Server restarts with no active operation | No automatic device action and no restored live selection. Historical topology may display as historical/offline context only. |
+| CONN-32 | Server restarts at `prepared` | Terminalize as `not-dispatched`, release active ownership, and allow a later explicit new session. |
+| CONN-33 | Server restarts at `dispatching` | Terminalize as `abandoned-by-restart`/`outcome-unknown`; never replay automatically; do not block a later explicit new session. |
+| CONN-34 | Server restarts at `observing` | Preserve the last observation as audit, terminalize the attempt, release ownership, and allow a later explicit new session. |
+| CONN-35 | Server restarts after a completed target observation | Historical topology may display configured/offline, never as live reachability, selection or authority. |
+| CONN-36 | Server restarts after `not-dispatched` | Preserve audit/context only; no old active session is restored. |
+| CONN-37 | Network-attempt audit JSON is corrupt, oversized, wrong-schema, wrong-permission or symlinked | Quarantine/report the historical record and never invent success/replay. Once active process ownership is known free, corrupt audit alone does not permanently block a fresh explicit session. |
+| CONN-38 | Two local server processes request mutations concurrently | Cross-process lease serializes live admission. At most one process owns the selected GATT/write operation; the other performs no BLE write. |
+| CONN-39 | A stale process tries to update another operation ID/revision | CAS rejects the stale audit transition and cannot alter the current active operation or emit a BLE write. |
+
+### Control and data plane independence
+
+| ID | Scenario | Required acceptance |
+| --- | --- | --- |
+| CONN-40 | TCP remains open but MQTT DeviceInfo/control dialogue fails | Connectivity is degraded/unverified; control and acquisition-start authority are false. |
+| CONN-41 | MQTT control fails while points continue | Control is lost immediately; points may remain visible only as evidence for the current bounded session; no command is issued automatically. |
+| CONN-42 | Point/camera stream stalls while control remains healthy | Data state becomes stalled/lost without inventing control loss; operator may stop or diagnose according to `allowed_actions`. |
+| CONN-43 | Old acquisition packets arrive after intent/host epoch change | Packets cannot become authoritative for the new binding/session and cannot make the device connected. |
+| CONN-44 | DeviceInfo response from old target/epoch arrives late | Exact binding mismatch rejects it; no lease or green connected state is created. |
+| CONN-45 | Read-only control observation succeeds after an acquisition interruption | DeviceInfo proves only current identity/control. Physical recovery also requires a fresh non-retained same-generation DeviceStatus after the DeviceInfo barrier. Mission Core does not infer previous physical recording state and does not replay START or STOP; exact same-project SCANNING may expose one explicit single-use STOP only through the durable rebind. |
+| CONN-46 | A message from producer generation N arrives after generation N+1 starts | It is rejected before data freshness/perception admission; it cannot mark the new acquisition healthy. |
+| CONN-47 | A second local process opens application control while the first is alive | Stable OS lock denies the second process before MQTT commands; no competing START/STOP is emitted. |
+| CONN-48 | Camera producer generation N drains after N+1 is bound | The archived N fragment remains evidence, but the live perception ingress rejects it before the new acquisition queue. |
+| CONN-49 | Read-only connection monitor raises an unexpected exception | Supervisor revokes authority for that observation, the monitor remains supervised with bounded backoff, and later passive polls can refresh presentation without a process restart. Polling starts no Verify/Scan/reconnect and creates no operator progress state. |
+
+### Freshness, identity and presentation
+
+| ID | Scenario | Required acceptance |
+| --- | --- | --- |
+| CONN-50 | Candidate wall-clock age advances, or Mac sleeps/wakes | Wall-clock age alone removes no row from the latest scan generation and grants no mutation authority. Sleep/wake or an actual disconnect revokes live host/control authority; runtime-owner teardown also invalidates captured handles, so a later Apply must fail cleanly or require explicit rediscovery rather than use UUID memory as authority. |
+| CONN-51 | Same route/interface/source IP appears after association changed | Opaque association token changes host epoch; old TCP/DeviceInfo/control proof is rejected. |
+| CONN-52 | Logical K1 DeviceInfo changes for a durably pinned BLE transport | Identity conflict is shown; control/start remain blocked; the pin is not overwritten automatically. |
+| CONN-53 | Restart with a valid semantic topology record and no live network | UI shows configured/offline historical evidence. It does not show a fresh BLE candidate, reachable endpoint, green control state or live telemetry. |
+| CONN-54 | An old interrupted/unknown network-attempt audit and valid older semantic topology coexist | Both may be displayed as historical context. Neither restores a live session, triggers replay, requires a recovery button, or blocks a distinct explicit scan-select-connect attempt. |
+| CONN-55 | Plugin container crosses any acceptance width while an error panel is open | Blocks stack without overlap or clipping; the factual state and normal safe next action remain visible; resize fires no device action. |
+| CONN-56 | Long translated copy or an unknown future reason code exceeds a panel width | Content reflows and stays reachable. Panels do not use clipping/hidden overflow as an acceptance mechanism; geometry tests detect every escaped interactive element. |
+| CONN-57 | The physical K1 is replaced after the previous command chain reached an effective standby state | Identity/profile history is never overwritten in place. Supporting replacement of a resolved-standby identity requires a distinct operator-confirmed archive/rotation transaction; `physical-command.retire-unavailable` applies only to an unresolved unavailable target and is not that transaction. Until resolved-standby rotation exists, the new identity is rejected and no physical command is emitted. |
+| CONN-58 | A direct service/CLI/read-only BLE entrypoint runs while another process owns K1 or while native cleanup is detached | The call is rejected before CoreBluetooth admission. Every BLE entrypoint uses the same OS lock; timeout retains it through proven cleanup, and cleanup failure requires process restart. |
+| CONN-59 | Network-attempt audit admission succeeds but the in-memory operation journal cannot admit the request | The record is terminalized as failed with `side_effect_status=none`; no BLE call occurs, active ownership releases, and no ghost blocks a later explicit attempt. |
+| CONN-60 | A physical START/STOP confirmation is open while topology, device, acquisition or control CAS changes | The modal-open fence becomes stale, all confirmations are discarded and no request is sent. A confirm action may use only the exact immutable checkpoint displayed when the modal opened. |
+
+### Cross-layer recovery and executable UI policy
+
+| ID | Scenario | Required acceptance |
+| --- | --- | --- |
+| CONN-61 | A legacy server left a network record at `dispatching` or `observing` | Under the lifecycle lease, migration terminalizes it as secret-free `abandoned-by-restart`/`outcome-unknown` audit. It never touches BLE or retries the old write, and the terminal historical record does not block a new explicit session. |
+| CONN-62 | The browser closes or refreshes while the event WebSocket is active | A proven WebSocket disconnect or closed transport finishes that browser stream quietly and changes no device/backend state. Only the exact closed-send exceptions are normalized; an unrelated runtime failure is still logged/test-visible. No device operation is retried. |
+| CONN-63 | One live process/runtime authority blocks an action that the supervisor alone would allow | The composite policy intersects supervisor evidence with active BLE/process ownership, acquisition/runtime state, cleanup, identity pins and the separate physical-command ledger. Active contention is denied; historical network audit is not. The UI does not recreate authority from mode labels or remembered addresses. |
+| CONN-64 | Acquisition data is lost while control is healthy, or control and data are both lost | With healthy exact control, a physical STOP remains available only through the durable physical-command/CAS gates. With control lost, physical STOP is denied; local receiver cleanup remains available, the physical K1 state is labelled unknown and manual device follow-up is shown. A nonexistent acknowledgement action never replaces either executable path. |
+| CONN-65 | Only durable configured/offline topology remains after restart | A diagnostic endpoint probe may use that exact historical target without entering BLE or mutating K1, but it is optional diagnostics and grants no control. Cold UI entry and polling start no work. A mode/same-mode reset performs one local-only CAS, retires the old lineage and exposes clean Step 01 without automatic Scan or device/host I/O. One explicit six-second scan produces candidates. **Выбрать** is local-only and immediately reveals the applicable Step 02 inputs. Only Apply may create a connection intent; there is no background Verify, discovery, reconnect or mutation. |
+| CONN-66 | The one-second connection monitor observes macOS association continuity for a long session | Association identity comes from a bounded compiled or long-lived observer. The monitor does not compile/launch the Swift source twice per poll, cannot hold its shared observation lock for tens of seconds, and revokes freshness while the observer is unavailable instead of delaying the whole control lifecycle. It never invokes Verify, BLE discovery, selection or reconnect and never owns a UI action loader. |
+| CONN-67 | A Quick-to-Quick, Bridge-to-Bridge, or cross-mode attempt ends after dispatch with an unknown result | Record the old attempt as terminal `outcome-unknown`, clear its active selected session, and never replay automatically. A later explicit operator scan, selection and Apply is a distinct intent and may perform its own single reviewed write after safety admission; no historical deadlock and no mandatory preflight. Real-K1 repeated reconnect/mode-switch acceptance remains required. |
+| CONN-68 | STOP is acknowledged, K1 enters SCAN_STOPPING, and Mac Wi-Fi is lost before fresh READY | Complete local receiver/control cleanup and present `standby-unconfirmed`, not a generic global error and not proven standby. Passive BLE Scan remains allowed after cleanup but starts only from an operator action. An explicit scenario reset may retire the old local/audit lineage and expose clean Step 01 without resolving standby or sending a command; provisioning and START stay denied on the old lineage. Exact read-only recovery may classify fresh non-retained READY or same-project SCANNING; no automatic retry or command. |
+| CONN-69 | K1 is hard-powered off during Bridge acquisition, then rebooted while Mission Core remains open | The lost acquisition becomes terminal locally without cache/backend reset; stale control/data cannot revive it. Session recovery remains a separate exact-target surface and passive BLE Scan runs only when requested. A new connection scenario requires explicit reset, then a successor Scan; every admitted result uses **Выбрать**, including the prior UUID. READY records cessation; fresh non-retained identity-bound SCANNING establishes only read-only control proof and exposes one explicit STOP outside connection setup. It never restarts the retired receiver, camera, writer or acquisition. Bridge field acceptance is required; Quick Connect was not exercised by this live incident. |
+| CONN-70 | UI enters a disconnected/configured K1 | UI entry, passive polling, row selection and input run no Verify, BLE discovery, connect or mutation. A mode/same-mode gesture owns one visible local scenario-reset action, then clean Step 01 is visible; it never starts discovery automatically. Search runs exactly once for six seconds per explicit click. Every result has **Выбрать**; selection retains the card and immediately reveals applicable Step 02 inputs. One Apply creates one connection intent with at most one K1 mutation and no hidden Scan/Verify/replay. |
+| CONN-71 | STOP is accepted, but neither READY nor SCAN_STOPPING arrives before the backend operation deadline | Automatically close only host-owned receiver, camera, perception ingress and control resources. Terminalize the operation as `timed_out` and present `standby-unknown`, with `side_effect_status=unknown`, `safe_to_retry=false`; keep the durable physical record unresolved. After successful cleanup allow a separately explicit exact bounded read-only recovery. An explicit scenario reset instead retires the old local lineage and exposes clean Step 01 while preserving the auditable physical outcome. Its successor Scan uses uniform **Выбрать** rows and never enters recovery from the candidate list. Repeated state polls are idempotent and never replay STOP or start recovery; local cleanup failure retains the local fence and retries cleanup only. |
+| CONN-72 | A connected or previously configured Bridge surface is opened | Render the existing plugin section, not a separate wizard or recovery dashboard. Mode, one explicit six-second scan, uniform results, local selection, immediate credentials and one Apply form one predictable path. Backend revision, random intent ID, desired-mode revision, supervisor and ledger CAS remain internal. Only Scan and Apply show truthful action-specific progress. |
+| CONN-73 | Apply for fresh cold Bridge candidate B returns a provisional post-write topology before MQTT identity is known | Record the single write exactly once and keep control unready. Commit identity/control authority only after exact DeviceInfo and durable transport/profile pin success. On mismatch or control bootstrap failure, preserve durable A where required, retain B's network-attempt audit, return `network_applied/control_not_ready`, and never repeat the same intent. Recommended read-only Verify may establish control. Alternatively, current server policy may admit an explicitly new intent; the historical attempt itself grants no authority. |
+| CONN-74 | The operator chooses **Изменить сеть** while neighboring BLE devices may be advertising | Pin the exact authoritative Bridge transport/mode and reveal credentials immediately. Opening and editing perform no I/O. Apply performs no hidden discovery or Verify and crosses at most one reviewed K1 write-dispatch edge. Reject stale-tab, stale candidate, missing exact device and changed-binding requests before device I/O with an explicit stale outcome. A post-dispatch ambiguous result is explicit outcome-unknown and never replayed. |
+| CONN-75 | A safe pre-START holder, active/reconnecting/cleanup-pending acquisition, unresolved durable physical history or stale runtime overlaps a mode/new-device request | The selector, same-mode escape and top-right **Сбросить подключение** utility send one idempotent `reset_scenario` with exact runtime/mode revision; the utility targets canonical Bridge and is never passive `state.read`. The reset queues behind and supersedes old local lifecycle ownership without deadlock, then locally seals receiver/camera/control resources, cancels late recovery generations, invalidates candidates/drafts/credentials and retires the old physical lineage truthfully. It performs zero START/STOP, BLE, device-network, host-network or automatic-Scan I/O. Terminal cleanup failure remains visible and retryable without committing the mode revision. On success the clean Step 01 requires a separate explicit Scan. |
+| CONN-76 | An unresolved physical START/STOP target is unavailable or has been replaced while the operator selects a fresh candidate | Selection remains local and cannot retire authority. Without an explicit committed reset plus its successor Scan, Apply is denied before device I/O. `physical-command.retire-unavailable` retains stable idempotency identity and exact runtime/operation/revision/transport CAS, preserves unknown-outcome audit, performs zero device I/O and starts no Scan. Product UI exposes no checker, confirmation ceremony or reconnect action in Bluetooth results. |
+| CONN-77 | A fresh post-reset scan returns candidates, including the exact actively retired UUID | Search runs once for six seconds. A successful admitted generation settles only the exact active reset marker captured at Scan entry; a Scan started before a later reset, failed/cancelled Scan or a newer pending reset does not. Every connectable row has one enabled **Выбрать**. Selection immediately exposes the local network draft and performs zero I/O. The explicit reset plus exact successor generation may admit one new network Apply to that rediscovered transport, still gated by exact handle capture and live GATT baseline and limited to one reviewed write. Before dispatch the backend may append one request-bound local reopen checkpoint; it preserves retirement/original-outcome audit and grants no START/STOP. A fresh service-owned DeviceInfo + non-retained READY/SCANNING observation settles that checkpoint without a UI Verify or command replay. SCANNING creates only explicit STOP authority and never resurrects reset-owned receiver/camera/acquisition state. Any other post-reset network dispatch consumes the exception. |
+| CONN-78 | One-intent UI contract | A mode, same-mode new-device or top-right **Сбросить подключение** gesture invokes exactly one visible local-only `reset_scenario`; the top-right action changes to **Сбрасываем подключение** while pending and never calls passive refresh instead. While that reset owns `mode`, every reset entry point dispatches no B intent. Its authoritative revision clears the dirty selector and all old click-owned attempt presentation. Row selection and every SSID/password keystroke still invoke no controller I/O. The reset has a stable idempotency identity, sends no device/host command and starts no Scan. One explicit search invokes exactly one six-second scan. One Apply invokes one connect request, no frontend Scan/Verify/reopen and at most one device mutation. Pre-dispatch authority drift is rendered explicitly stale; post-dispatch uncertainty is rendered outcome-unknown and never replayed. `network_applied` plus unready/unknown control spends the old Apply and gates ordinary controls behind an explicit recovery choice. Recommended Verify accepts only the backend server-bound target. Every new Apply has a new idempotency identity, and no new-intent path runs as a hidden frontend or mutating continuation. The already-declared service-owned same-intent read-only bootstrap after the durable ACK is the sole continuation exception; it creates no UI action and cannot mutate or retry. The plugin reuses canonical `Button`, `TextField`, `ActivityIndicator` and `StatusBadge`, with no raw local controls, literal status colors, new shared entities or lifecycle changes. |
+
+## Composite action policy
+
+`connection_supervisor.allowed_actions` covers only host, endpoint, identity,
+control and data evidence. It is deliberately insufficient to authorize a BLE
+or physical mutation. The API therefore publishes
+`missioncore.xgrids-k1-connection-policy/v1`, composed from:
+
+1. the current supervisor revision and exact evidence epochs;
+2. the current explicit network intent and active-operation generation;
+3. the network-attempt audit/idempotency record, which prevents automatic
+ replay inside that attempt but is not a cross-session veto;
+4. the durable physical-command ledger (highest precedence for START/STOP and
+ deliberately unaffected by the simpler network lifecycle);
+5. the process-wide BLE runtime/quarantine state and the stable cross-process
+ lifecycle lease;
+6. current acquisition/runtime/cleanup state;
+7. fresh unselected BLE candidates and, separately, the one admitted active
+ selected session;
+8. the exact `connection_reconfiguration` revision, random intent ID, required
+ transport/mode and minimum fresh discovery generation;
+9. semantic topology and identity-pin store health.
+
+Every action carries an `allowed` bit, reason codes, target source, required
+transport reference and whether a new live GATT validation is mandatory. The
+browser may add form-local prerequisites, such as an entered password or an
+exact fresh local selection. It may not turn a denied/missing backend
+decision into an enabled action. There is no product-level
+`recover-current-device-network` prerequisite. Latest-generation candidate rows
+and an active selected session do not disappear because wall-clock time elapsed;
+generation/runtime/GATT fences and the connection lifecycle clear them at their
+respective authority boundaries.
+
+`scan-ble` is evaluated separately from every mutation. Once local/native
+cleanup and the BLE discovery lease are clear, physical unknown/active does not
+deny passive Scan. The same physical fact must deny
+`provision-fresh-device` and acquisition START on that lineage, but cannot deny
+the local-only scenario-reset escape.
+Every recovery observation and any recovery STOP are additionally pinned to
+the physical record's exact `transport_ref`; a browser-selected or newly
+discovered foreign transport cannot substitute for it or change topology.
+Recovery is a separately explicit action. Normal row selection remains a local
+draft and never admits observation or mutation.
+
+`physical_command.operator_retirement` is a separate backend projection. Its
+`allowed` decision is true only for one exact unresolved post-dispatch target
+after acquisition, runtime, control, BLE/native cleanup and cross-process
+lifecycle owners are safe. The browser may invoke
+`physical-command.retire-unavailable` only from that projection and must echo
+the exact backend runtime, operation, revision and transport checkpoint. This
+local durable mutation neither inherits `scan-ble` permission nor invokes it.
+Success clears only current authority for the retired target and creates an
+active exact-transport deny at every adopting or mutating entrypoint, including
+provisioning, Verify/adoption, control, calibration, physical commands and
+network writes. It applies the retired durable identity fence once DeviceInfo
+makes that identity observable. The retirement record and unknown original
+outcome remain permanent audit.
+
+`physical_command.operator_reconciliation_reopen` is a separate backend
+projection for exact recovery of a previously established session. The browser
+may invoke `physical-command.reopen-retired-reconciliation` only from that
+session-recovery projection, never from a cold Bluetooth result row, and must
+echo the exact backend runtime, ledger revision, active retirement ID,
+transport and discovery generation, plus a stable reopening ID and explicit
+operator confirmation. The local durable transition appends a
+reopen audit, restores only the retired record's original unresolved stage and
+removes only that retirement's active deny. It performs zero BLE, Wi-Fi, MQTT,
+DeviceConfig, ModelingStatus, workspace, project, START or STOP I/O and starts
+no Scan. The separately explicit recovery intent may then perform one exact
+read-only Verify under its original runtime/action/candidate fences; it grants
+no provisioning or command authority until fresh READY or exact same-project
+SCANNING proof reconciles the record. It never continues into Apply.
+Explicit passive Scan/GATT metadata/MQTT capture outside this transition remains
+a non-authoritative diagnostic surface and cannot restore topology/control. The
+policy does not claim pre-provision identity detection across a changed
+CoreBluetooth UUID.
+
+Physical device commands are fail-closed when the policy is absent, malformed
+or denied. Purely local risk-reduction actions are different: stopping or
+aborting a local receiver/replay remains possible even when the K1 physical
+state is unknown. Such cleanup never claims that K1 received STOP, and the UI
+must present the required manual device follow-up.
+
+## Adaptive UI contract
+
+The UI is a projection of backend evidence, not a second connection state
+machine.
+
+- Layout responds to the plugin container width, not only the viewport width.
+- At every width, connection/topology, project/intake, status, metrics, and
+ error surfaces either fit side by side or stack in document order. They never
+ overlap, escape their panel, rely on horizontal clipping, or cover primary
+ actions.
+- Wide composition may use two columns. Narrow composition stacks the network
+ connection block before project/acquisition controls; fields and actions use
+ the full available width.
+- Requested topology, semantically observed K1 topology, current Mac
+ association, endpoint reachability, DeviceInfo/control state, and data state
+ are visually distinct. A single green lamp cannot stand in for all planes.
+- `last_known` and stale observations are labelled as
+ historical/non-authoritative and are never rendered as a live selectable or
+ connected device.
+- The mode selector, same-mode **Подключить новый K1** action and top utility
+ remain available through every non-reset lifecycle owner and send one explicit
+ local-only scenario-reset CAS. The first reset may supersede another pending
+ local action. While that bounded reset owns `mode`, all reset entry points
+ report cleanup and dispatch no B intent. On success the authoritative reset
+ revision clears old click-owned presentation and a dirty selector, then shows
+ clean Step 01 plus truthful warning when the previous K1 may still scan;
+ search never starts automatically. Provisioning and START retain separate
+ backend gates, and Apply remains the normal topology/device-mutation boundary.
+ The reset marker suppresses only the retired scenario: a later correlated
+ failed or outcome-unknown attempt must render immediately and survive reload
+ without reviving the old reconnect prompt.
+- The only model-bearing plugin-section heading is **Подключение XGRIDS LixelKity K1**.
+ Below it, copy uses the neutral step names **Подключение** and **Сеть**. It
+ does not label devices as saved, original, previous or retired,
+ and it does not surface physical-ledger terminology.
+- Progressive disclosure is intent-oriented. A cold surface shows mode and
+ Step 01 **Подключение** with its explicit Scan action. A completed explicit
+ scan shows candidates. Selecting one immediately retains its card and reveals
+ Step 02 inputs (or the Quick Connect summary) without an operation.
+- Step 01 search begins only when pressed, runs exactly six seconds, and shows
+ an `ActivityIndicator` plus a visible seconds countdown. After completion,
+ every connectable result row has one enabled **Выбрать** action and the base
+ anatomy, including an exact UUID from an earlier scenario. Selection has no
+ loader and invokes no controller method. A transport without safe current
+ draft admission is explicit unavailable evidence, not a disabled primary
+ affordance or a reconnect CTA.
+- Selection retains the candidate card and immediately exposes applicable
+ fields. Form input invokes no I/O. Candidate/runtime/intent drift produces an
+ explicit stale state and an explicit search action; it never triggers a scan
+ or clears the result as a hidden continuation.
+- **Изменить сеть** retains one exact device card and reveals credentials
+ without I/O. **Применить** names the one network operation, performs no hidden
+ exact-UUID refresh/Verify and crosses at most one device-write boundary.
+- Apply accepts the exact REST `network_applied` snapshot immediately for
+ `control_not_ready` and `unknown`. It does not await WebSocket/poll proof or
+ full connection-ready. It spends the old Apply and credentials. While the
+ exact service child is accepted/running, one passive settling indicator is
+ shown and no recovery action is enabled; terminal unready/unknown state then
+ presents explicit recovery choices. Verify is recommended and server-bound,
+ not mandatory. A new server-policy-admitted intent uses Bridge
+ `prepare-select-device`, Quick/Direct explicit `scan-ble`, or an explicit
+ scenario reset; every route still requires a later fresh scan and a new
+ idempotency Apply, with no hidden frontend or mutating continuation. The
+ declared service-owned same-intent read-only bootstrap is the only post-ACK
+ continuation and creates no UI action.
+- After that fast ACK, the service may finish a supervised read-only control
+ bootstrap for the same intent. It performs no BLE/host mutation or retry and
+ creates no frontend Scan/Verify/new-Apply action or blocking Apply loader. Its
+ exact accepted/running state may own one passive settling indicator only.
+- Selecting a fresh Bridge or Direct device reveals SSID/password immediately.
+ Quick Connect reveals no credential fields. A stale global error from device
+ A cannot own device B's form, and a backend runtime/fence change invalidates
+ credentials and renders a clear stale outcome rather than scanning or
+ replaying automatically.
+- The plugin surface uses shared `Button`, `TextField`, `ActivityIndicator` and
+ `StatusBadge`. Raw local HTML controls and literal local status colors are not
+ accepted; no new shared entities are introduced.
+- During an actively executing `prepared`, `dispatching`, or `observing`
+ operation, controls that could create a competing mutation are replaced by a
+ truthful action-specific progress row. When that attempt terminates, its audit
+ cannot keep the next explicit connect unavailable. An unresolved/active
+ physical record may hide mutation controls, but it does not deny an explicit
+ passive BLE Scan after local cleanup. Once that explicit scan completes, Step
+ 01 shows its complete result set, including unrelated BLE devices, as passive
+ discovery evidence. Ordinary connectable rows keep one enabled **Выбрать**
+ action when current backend policy admits a fresh draft; an exact prior UUID
+ is not special in this list. Another row cannot replace the durable target or
+ reveal Apply while admission remains denied. A
+ configured-but-unverified topology or physical recovery target starts no
+ operation from UI entry or polling. Only explicit search
+ and Apply create I/O.
+ Physical recovery stays pinned to the durable `transport_ref`;
+ each requested observation generation publishes exactly one DeviceInfo
+ request and passively waits for fresh non-retained status.
+- Backend events and bounded state polling converge the presentation after
+ disconnect, cleanup, read-only recovery success/failure, and power-cycle
+ recovery. Polling is passive presentation convergence: it starts no device
+ action, shows no recovery spinner by itself and cannot extend an ended action.
+ Clearing browser cache/local storage, restarting the backend, or pressing a
+ generic reset is never a required recovery procedure. Optional state reads
+ and endpoint diagnostics are read-only and cannot manufacture authority.
+- A human-readable error states which product fact failed (discovery, device
+ topology, Mac association, endpoint, identity/control, data, or active
+ lifecycle contention). A released local acquisition after acknowledged STOP
+ plus Wi-Fi loss is rendered as `standby-unconfirmed` with plain recovery
+ guidance, not as an unexplained global `Ошибка`. A STOP acceptance whose
+ READY/SCAN_STOPPING deadline expires is rendered as terminal
+ `standby-unknown`; after local cleanup it offers read-only recovery, while all
+ mutations remain fenced. Internal stack traces and reason taxonomies remain
+ in engineering logs, not in the primary product surface.
+- Every idle/error state exposes only relevant actions derived from backend
+ `allowed_actions`. Historical recovery never runs behind **Выбрать** or
+ **Применить**; UI heuristics do not manufacture authority, loop, or
+ reinterpret legacy convenience fields.
+- Telemetry and acquisition metrics disappear or become explicitly unavailable
+ when their data source is stale. The UI does not preserve old numbers as live
+ measurements.
+- Browser refresh never invokes provisioning and starts a fresh operator
+ session with no restored live selection. Resizing and panel expansion do not
+ change the current session or topology.
+
+Acceptance widths are container widths `1948`, `1680`, `1481`, `1281`, `1280`,
+`1024`, `761`, `760`, and `390` px. At each width, acceptance requires:
+
+1. no overlap or clipped content;
+2. readable requested/observed/authoritative distinctions;
+3. reachable primary action and error recovery action;
+4. the latest-generation BLE rows remain stable across wall-clock age, are not
+ presented as mutation authority without exact-handle/live-GATT proof, and no
+ admitted active session is cleared merely because wall-clock time elapsed;
+5. no green connected/control state without the exact backend binding;
+6. no stale telemetry presented as current;
+7. no action fired by layout change, refresh, or hydration.
+
+## Operational acceptance rule
+
+A scenario is complete only when its test evidence covers all applicable
+layers:
+
+1. network-attempt audit transition and active cross-process exclusion;
+2. supervisor evidence planes and host epoch handling;
+3. exact DeviceInfo/control binding and independent data state;
+4. API snapshot and `allowed_actions`;
+5. adaptive product presentation at the boundary widths;
+6. for real K1 acceptance, one explicitly operated hardware run with redacted
+ logs and no automatic mutation retry.
+
+The 2026-08-09 live incidents exercised Bridge only: STOP acknowledgement plus
+Mac Wi-Fi loss before READY, and K1 hard power loss/reboot during acquisition.
+They are field evidence for `CONN-68`/`CONN-69`, not Quick Connect acceptance.
+Quick Connect recovery remains unaccepted until it is exercised separately.
+
+Synthetic/unit acceptance can prove the state machine and regressions. It does
+not by itself claim that a real K1, router, CoreBluetooth stack, or macOS sleep
+transition has been physically validated.
diff --git a/docs/adr/0003-device-plugin-ui-and-runtime-boundary.md b/docs/adr/0003-device-plugin-ui-and-runtime-boundary.md
index 857cfce..b1135b0 100644
--- a/docs/adr/0003-device-plugin-ui-and-runtime-boundary.md
+++ b/docs/adr/0003-device-plugin-ui-and-runtime-boundary.md
@@ -136,7 +136,7 @@ The current XGRIDS contribution maps its proven internal workflow into those
platform states without changing the wire protocol:
```text
-confirm power -> scan BLE -> select candidate -> enter Wi-Fi
+scan BLE -> select candidate -> enter Wi-Fi
-> provision once -> receive LAN address -> start source
-> wait for first point frame -> streaming
```
diff --git a/docs/adr/0013-k1-local-connection-matrix.md b/docs/adr/0013-k1-local-connection-matrix.md
index 3269e93..d8f84b7 100644
--- a/docs/adr/0013-k1-local-connection-matrix.md
+++ b/docs/adr/0013-k1-local-connection-matrix.md
@@ -1,6 +1,6 @@
# ADR 0013: explicit K1 local connection matrix
-- Status: amended 2026-07-20; Bridge is the product path, Quick Connect retained as a prepared-host laboratory path
+- Status: amended 2026-08-08; Bridge is the product path, Quick Connect retained as a prepared-host laboratory path
- Date: 2026-07-19
- Extends: ADR 0004, ADR 0005 and ADR 0012
@@ -47,6 +47,23 @@ for the previous session. An active acquisition is never retargeted in place.
The later correlated MQTT `DeviceInfo` response supplies model, firmware,
serial and vendor identity; IP equality alone cannot identify a K1.
+The 2026-08-08 physical Bridge trace corrected the earlier field model: the
+first `7f02` text slot is `WIFI_AP` in AP state but contains the joined network
+name in FW 3.0.2 station state. Mission Core therefore normalizes that station
+response to `WIFI_CLIENT` internally and admits Bridge/Direct only when the raw
+post-write name exactly matches the current explicit request plus a valid
+non-AP private address. The raw name is not persisted in the secret-free
+network audit or published through API state.
+
+The limitation still applies when recording an interrupted attempt that has no
+exact post-write network-name observation. A changed private DHCP address alone
+cannot identify the selected network. An already AP-ready baseline likewise
+cannot prove the outcome of an interrupted Quick-to-Quick attempt. Mission Core
+therefore records that attempt as terminal `outcome-unknown` and never replays it automatically. The
+historical uncertainty is not a permanent barrier: after the old active
+operation and cleanup have ended, a later explicit operator scan, selection,
+and connect is a distinct session with its own one reviewed write.
+
Product decision on 2026-07-20: Bridge/direct-LAN is the continuing route.
Quick Connect remains visible and executable on an already prepared host, but
is not a deployment dependency or portability claim.
@@ -86,12 +103,65 @@ and the following cold Swift/CoreWLAN process missed the beacon. The corrected
implementation holds the selected `BleakClient` open through bounded native
SSID discovery and the single association call.
-BLE discovery and the selected device action form one host session. A physical
-run proved that immediately rediscovering the same K1 by its CoreBluetooth UUID
-can fail even though the preceding scan exposed it. Mission Core retains the
-non-serializable `BLEDevice` handle process-locally and uses that exact handle
-for the next selected network action; it never exposes the handle through API
-state or treats the macOS UUID as durable device identity.
+One explicit six-second BLE discovery and the later Apply action form one
+operator intent without a second discovery. A physical run proved that
+immediately rediscovering the same K1 by its CoreBluetooth UUID can fail even
+though the preceding scan exposed it. Mission Core retains the non-serializable
+`BLEDevice` handle process-locally and uses that exact handle for Apply; it never
+exposes the handle through API state or treats the macOS UUID as durable device
+identity. UI row selection itself performs no GATT or backend I/O.
+
+The operator-visible candidate list, local selected draft and admitted active
+session are separate contracts. Every explicit scan replaces the candidate
+set. Selection only binds a local form to one result from the latest admitted
+generation. Wall-clock age does not remove that generation while the operator
+completes the form. Apply admits only its exact retained handle and live GATT
+validation may create the active session; a remembered UUID is never mutation
+authority. Proven disconnect, explicit stop, app/backend restart, another
+explicit Scan, or a committed mode transition revokes the applicable candidate
+or live session. Rediscovery never auto-connects.
+
+Quick Connect to Bridge is an explicit topology transition, not another scan
+heuristic. Selecting Bridge — or choosing another K1 while Bridge is already
+selected — sends one idempotent local `reset_scenario` CAS. It seals retained
+receiver/camera/control ownership, invalidates candidates and credentials and
+retires old physical lineage truthfully, while sending no device command, BLE,
+host-network write or automatic Scan. The next explicit Scan starts the clean
+discovery flow, while Apply remains the topology and device-mutation boundary.
+The operator selects one discovered K1, sees the Bridge credentials immediately
+and submits once. The new GATT
+session reads internal baseline `7f02` and emits exactly one reviewed 99-byte
+station write. A connect failure ends that attempt. An ambiguous post-write
+failure is terminal `outcome-unknown` audit, not a permanent cross-session
+fence. There is no automatic BLE or network-write retry.
+
+Likewise, an exact REST `network_applied` result with unready/unknown control
+spends that Apply and its credentials without making the read-model attempt a
+permanent topology lock. Recommended Verify is pinned to the backend
+current/configured target. A separately explicit new intent still requires
+current server policy: Bridge prepares `select-device` before a later fresh
+scan; Quick and Direct run an admitted fresh scan, select only its latest row
+and create a new idempotency Apply; a mode or same-mode new-device transition
+uses one idempotent local-only `reset_scenario` before that fresh scan. None of
+these new-intent UI paths reuses the old
+intent or runs as a hidden frontend/mutating continuation. The service-owned
+same-intent read-only bootstrap declared below is the sole post-ACK exception.
+
+The Apply REST call returns as soon as the exact durable
+`network_applied` proof is available. The service may continue the same
+intent's supervised control bootstrap read-only after that ACK. This performs
+no BLE/host mutation or retry and creates no frontend Verify, Scan, Apply or
+blocking Apply loader. While the exact child is accepted/running, the UI may
+show only a passive **Сеть настроена · подтверждаем управление** indicator and
+must keep every recovery action disabled. Later control state arrives only as
+backend presentation convergence; terminal unready/unknown state then exposes
+the explicit server-policy recovery choices.
+
+Bridge and Quick Connect were physically accepted as separate paths before
+this amendment. The combined Quick Connect to Bridge transition has automated
+contract coverage but remains a distinct physical acceptance gate; it must not
+be reported as field-accepted until one redacted live run records both sides of
+the transition.
The corrected host boundary derives a non-secret, device-scoped profile ID from
the selected SSID. The reviewed client contains per-device `WiFiAP_SSID` and
@@ -110,13 +180,40 @@ that opaque source before any BLE write. A missing provider fails closed. The
browser, API, argv, logs, manifests and evidence never receive the secret; the
importer's short-lived mutable buffer is zeroized after the Keychain handoff.
+The 2026-08-06 field regression established that process identity is part of
+this prepared-host contract. Runtime `swiftc` compilation produced an ad-hoc
+helper with an unstable designated identity. macOS then requested Keychain
+authorization repeatedly and the same process context failed to expose the
+exact K1 SSID through CoreWLAN even after K1 had acknowledged AP-ready. That
+runtime-compiled route is rejected. The laboratory adapter uses the previously
+physically accepted Apple-signed interpreter path,
+`/usr/bin/xcrun swift `, and validates the source path before
+launch. A portable product implementation still requires a packaged,
+precompiled and properly signed helper with a stable bundle identifier,
+designated requirement, Location/CoreWLAN authorization and Keychain ACL; the
+current prepared-host path does not claim that packaging work is complete.
+
+Before any BLE write, the helper's preflight is non-interactive. It first checks
+Keychain item existence through metadata, then validates the selected profile's
+SSID and `exact-firmware-profile` provenance inside the helper without returning
+secret data. Provider material is also read with interaction disabled if a
+missing device profile must be materialized. The association phase accepts only
+that already materialized exact profile. It never
+falls back to the system Wi-Fi Keychain, rewrites a profile opportunistically,
+or opens a password/authorization dialog after K1 has changed network state.
+An unavailable or unauthorized profile therefore fails closed with a precise
+reason code and no automatic device retry.
+
The host-network boundary, rather than the XGRIDS frontend, owns platform
association. Browsers expose no Wi-Fi join API, and Apple's iOS
`NEHotspotConfiguration` consent flow is unavailable on macOS. The current
implementation therefore uses a short-lived Swift/CoreWLAN + macOS Keychain
helper; Windows Credential Manager and Linux Secret Service adapters remain
separate platform work. The helper performs repeated read-only exact-SSID scans
-inside one 15-second discovery window and at most one association. It never
+inside one 30-second discovery window and at most one association. The larger
+window covers the physically observed 18.142-second beacon-discovery case;
+AP-ready confirms K1 state but does not prove that macOS has already observed
+the RF beacon. It never
repeats the BLE command, guesses a password or treats `7f01` as a credential-read
command. The credential-bearing 99-byte station-provisioning frame and fixed
100-byte AP-enable frame are separate reviewed payloads.
@@ -128,12 +225,13 @@ The owner also observed no explicit device/account pairing in the normal
LixelGo onboarding flow; this is consistent with a firmware-defined AP secret,
but does not establish account-wide authorization for arbitrary scanners.
-Connection verification refreshes the session-scoped lease with the same
-read-only BLE status operation. It does not write a characteristic, re-provision
+Apply may read BLE baseline internally while establishing a new selected
+session. Selection never does so, and the normal flow has no mandatory or hidden
+"verify without write" recovery step. The baseline read does not re-provision
Wi-Fi, scan the subnet, change a host route, or touch VPN configuration. The
-later canonical MQTT session supplies the real data-plane connection and live
-`DeviceInfo` identity check. A BLE lease observation is therefore not by itself
-a claim that MQTT/RTSP is reachable.
+later canonical MQTT
+session supplies the real data-plane connection and live `DeviceInfo` identity
+check; BLE status alone is not a claim that MQTT/RTSP is reachable.
## Consequences
@@ -148,6 +246,10 @@ a claim that MQTT/RTSP is reachable.
therefore not scheduled for this Quick Connect path.
- Direct Connect requires an already-running hotspot and a controller route;
Mission Core does not create or manage that hotspot.
+- Discovery never auto-connects devices. Mode, selection and input are local
+ only. App restart, disconnect, explicit stop and mode transition require a
+ fresh explicit scan-select-Apply session. Apply performs no hidden rescan or
+ Verify and may cross at most one device-mutation boundary.
- The application-control, START/STOP and raw-first acquisition protocol is
unchanged after a target address is admitted.
- Direct Connect remains explicitly pending one owner-operated physical
diff --git a/docs/adr/0014-k1-macos-association-observer.md b/docs/adr/0014-k1-macos-association-observer.md
new file mode 100644
index 0000000..d5d705d
--- /dev/null
+++ b/docs/adr/0014-k1-macos-association-observer.md
@@ -0,0 +1,177 @@
+# ADR 0014: long-lived macOS host-association observer
+
+Status: planned production boundary; software contract may be developed behind
+a disabled feature flag.
+
+Related acceptance item: `CONN-66` in
+[`../20_K1_CONNECTION_SUPERVISION_CANON.md`](../20_K1_CONNECTION_SUPERVISION_CANON.md).
+
+## Context
+
+Mission Core must distinguish a K1 that is configured for a network from a Mac
+that is currently attached to the same network. Route, TCP, DeviceInfo,
+control and data evidence are bound to a host-path epoch; a Wi-Fi switch,
+sleep/wake cycle or observer restart must invalidate that epoch before any late
+TCP/MQTT result can restore command authority.
+
+The current laboratory implementation is fail-closed but not a production
+observer. One normal connection-monitor poll samples the host path before and
+after its TCP probe. Each sample synchronously invokes:
+
+```text
+/usr/bin/xcrun swift plugins/xgrids-k1/macos/associate_wifi.swift
+```
+
+under one process-local lock with a 30-second timeout. At the one-second
+monitor interval this can launch two Swift processes per second. A failed
+cycle can occupy the lock for roughly sixty seconds, and cancellation of the
+Python `asyncio.to_thread()` waiter does not terminate the native process or
+thread. Physical-command validation shares this observation path. Shorter
+timeouts, cached shell output or automatic fallback would hide rather than
+remove the lifecycle defect.
+
+## Decision
+
+Production host-association evidence will come from one signed, long-lived,
+read-only agent in the user's macOS login session.
+
+- The agent owns one `CWWiFiClient` for its process lifetime.
+- It observes CoreWLAN link/association/power events and macOS sleep/wake.
+- It never scans BLE, changes Wi-Fi, reads K1 credentials, reconnects MQTT or
+ sends START/STOP.
+- It is packaged in a minimal container app and registered with `SMAppService`;
+ it is not a `LaunchDaemon` and is not launched through `xcrun` at runtime.
+- The required Wi-Fi event entitlement and Location authorization are checked
+ before K1 network mutation is offered. Missing authorization produces
+ explicit unavailable evidence, not a crash loop or guessed association.
+- The existing Wi-Fi mutator remains a separate component under the exclusive
+ network process lease. Observer authority and mutation authority are never
+ combined.
+
+The backend communicates with the observer through bounded local IPC. Each
+backend session supplies a random HMAC key. SSID and BSSID remain inside the
+agent; only an opaque continuity token is returned and it cannot be correlated
+between backend processes. The token material is interface plus BSSID; SSID is
+used only to report evidence quality. This keeps one AP identity stable when
+macOS alternates between `ssid+bssid` and `bssid-only` disclosure.
+
+## Observer contract
+
+```text
+schema_version: missioncore.macos-host-association/v2
+agent_instance_id: random 128-bit process instance
+sequence: uint64
+association_epoch: uint64
+interface_name: string | null
+wifi_interface: true | false | null
+state: associated | not-associated | inactive | not-wifi | unavailable
+evidence_quality: ssid+bssid | bssid-only | not-wifi | unavailable
+continuity_token: 64 lowercase hex | null
+reason_code: string | null
+observed_monotonic_ns: uint64
+sample_age_ms: uint32
+cause: initial | link-change | association-change | power-change |
+ permission-change | will-sleep | did-wake | poll-correction |
+ observer-restart
+```
+
+`sequence` changes for every event or heartbeat. `association_epoch` changes
+when interface, power, state, SSID or BSSID changes. Sleep and wake each create
+a barrier even if the visible network looks unchanged afterward. A new agent
+instance, IPC reconnect, sequence rollback/gap, malformed frame or timeout is
+also a discontinuity.
+
+The backend adds its own `observer_session_epoch`; the effective host-route
+fingerprint includes the agent instance, observer session, association epoch
+and opaque token. A response from an old session or sequence is discarded.
+Unknown schema/state or incomplete evidence is `unavailable` and immediately
+revokes host authority.
+
+## Timing and failure semantics
+
+- Heartbeat: 1 second.
+- Maximum cached-snapshot age: 750 ms.
+- Snapshot RPC deadline: 250 ms.
+- Initial handshake deadline: 2 seconds.
+- Two missed heartbeats or one invalid IPC frame revoke authority immediately.
+- Reconnect backoff: 250 ms, 500 ms, 1 s, 2 s, then at most 5 s.
+- There is no automatic fallback to the Swift source runner.
+- Agent loss affects only read-only host evidence. It never triggers a K1
+ network write, MQTT reconnect or physical command.
+- A discontinuity first marks the supervisor host path unavailable and rotates
+ its epoch. Recovery then requires fresh route, TCP and DeviceInfo/control
+ evidence in that order.
+
+## Delivery phases
+
+Phase A is safe without signing or a physical K1:
+
+1. Define the Python observer protocol and validate the v2 schema.
+2. Add a fake/in-memory transport and backend session/sequence validator.
+3. Implement immediate epoch invalidation and bounded cached lookup.
+4. Inject the observer into the monitor behind a disabled feature flag.
+5. Implement the Swift reducer and local transport as a testable Swift package.
+6. Test sleep/wake, timeout, event gaps, delayed replies, crash/restart and
+ manual network changes.
+7. Expose secret-free observer health and next action to the UI.
+8. Prove 10,000 samples launch no child process and cause no lock starvation.
+
+Phase B requires the actual Mac signing and permission environment:
+
+1. Package and register the user-session agent.
+2. Obtain the Wi-Fi events entitlement and complete Location onboarding.
+3. Run the observer in shadow mode beside the current fail-closed probe.
+4. Cut over only after the physical fault matrix and an eight-hour soak show no
+ unexplained divergence.
+
+## Acceptance gate
+
+- No `xcrun`, `swift` or `swiftc` occurs on the observer path.
+- One agent and one CoreWLAN client serve one login session.
+- Snapshot p99 is below 50 ms, hard deadline 250 ms, monitor-cycle p99 below
+ 1.5 seconds.
+- No mutex is held across native or IPC calls.
+- Sleep, wake, agent restart, sequence gap and timeout always invalidate the
+ effective host epoch.
+- Late TCP/DeviceInfo evidence from an old epoch is rejected.
+- SSID, BSSID and credentials never enter IPC logs, API state or artifacts.
+- Quick-to-Bridge, Bridge-to-Quick, manual Wi-Fi switch, Wi-Fi off/on,
+ router loss/return with the same SSID/IP, backend restart and Location denial
+ all revoke control authority within two seconds and recover only through
+ fresh route, TCP and DeviceInfo evidence.
+
+Until this gate passes, the current association probe remains explicitly a
+laboratory implementation and `CONN-66` remains open.
+
+## Laboratory containment while Location evidence is hidden
+
+The source-runner helper can return `association-identity-unavailable` on a
+connected Mac when macOS privacy rules hide SSID and BSSID from the CLI child
+process. Rotating a random fallback token on every one-second poll made a
+stable route and a successful TCP probe mutually impossible: every following
+sample revoked the preceding endpoint result as a fictitious network switch.
+
+Until the signed observer above replaces the source runner, the laboratory
+probe uses one random, process-scoped token for the same interface and
+unavailable-evidence scope. This is not promoted to association evidence:
+
+- the public evidence quality remains `unavailable`;
+- interface, source address, kernel route, availability, a proven different
+ BSSID and process restart remain epoch barriers;
+- endpoint reachability alone remains `configured-unverified`;
+- only fresh exact DeviceInfo/control evidence can grant control authority;
+- `CONN-66`, sleep/wake and same-subnet network-switch acceptance remain open.
+
+For an already reachable lease whose exact DeviceInfo identity and control
+session remain healthy, a temporary helper timeout or privacy-limited
+association sample may retain the preceding proven association fingerprint
+only while the kernel route fingerprint, interface, source, intent and target
+are unchanged. That retained sample still performs TCP contact and a second
+kernel-route check, refreshing only route/TCP observation TTLs. Endpoint loss,
+control loss, control-proof expiry, target/intent change, a proven association
+identity change or any raw route change revokes immediately. A
+`configured-unverified` path does not receive this bridge and remains bounded
+by the existing technical-failure debounce and transport TTL.
+
+This containment removes the false per-poll epoch churn observed on the field
+Mac without claiming that the planned production observer has been delivered.
diff --git a/docs/adr/0015-k1-physical-state-recovery.md b/docs/adr/0015-k1-physical-state-recovery.md
new file mode 100644
index 0000000..b2c7da9
--- /dev/null
+++ b/docs/adr/0015-k1-physical-state-recovery.md
@@ -0,0 +1,309 @@
+# ADR 0015: explicit K1 recovery beside the one-intent connection flow
+
+Status: accepted product, recovery and presentation contract; executable
+coverage and remaining hardware acceptance are tracked in
+`docs/k1-connection-acceptance.manifest.json`.
+
+Related acceptance items: `CONN-16` through `CONN-19`, `CONN-28`, `CONN-29`,
+`CONN-65`, and `CONN-68` through `CONN-78` in
+[`../20_K1_CONNECTION_SUPERVISION_CANON.md`](../20_K1_CONNECTION_SUPERVISION_CANON.md).
+
+## Problem
+
+Loss of K1 power, the router, Mac Wi-Fi, MQTT control or the backend does not
+prove whether K1 is physically scanning. Retained points, an open TCP port and a
+historical START are insufficient. Replaying START or STOP after an ambiguous
+dispatch boundary can create a second physical edge.
+
+The durable physical-command ledger, exact read-only classification and
+fail-closed supervisor must remain. They must not make ordinary connection slow
+or surprising. In particular, selecting a device must not secretly connect,
+Verify, retire/reopen history or delay network credentials.
+
+## Decision
+
+### Existing product surface
+
+K1 connection stays in the existing device plugin section headed
+**Подключение XGRIDS LixelKity K1**. The surrounding job, entity and lifecycle
+models do not change. This is novelty A: an improvement to an existing product
+surface. A separate wizard, modal flow and mandatory preflight/recovery surface
+are rejected.
+
+The section reuses canonical shared `Button`, `TextField`, `ActivityIndicator`
+and `StatusBadge`. It creates no shared entity and uses no raw local HTML
+controls or literal local status colors.
+
+### One-intent normal flow
+
+The normal flow is:
+
+1. choose Bridge, Direct Connect or Quick Connect locally;
+2. press the explicit Bluetooth search action;
+3. wait for exactly one six-second discovery;
+4. press **Выбрать** on one result;
+5. enter Bridge/Direct credentials immediately, or review the Quick Connect
+ summary;
+6. press **Применить** once.
+
+Opening the section, changing mode, selecting a row and every
+SSID/password keystroke perform zero browser-controller, device or host I/O.
+They create no backend operation and show no operation loader. An admitted fresh
+selection retains the selected card and exposes applicable inputs immediately.
+A candidate without current draft authority is omitted or presented only as
+non-actionable evidence; it never receives a misleading disabled primary.
+
+Each explicit search owns exactly one bounded six-second discovery. It performs
+no connect, Verify, selection or mutation. Results are never auto-selected.
+
+One Apply owns the normal connection intent. It may commit the local desired-mode
+draft under backend CAS and may cross at most one reviewed K1 mutation boundary.
+Its frontend handler performs no hidden Scan, Verify, reconnect, retirement,
+reopen, candidate substitution or retry. Quick, Bridge and Direct use the same single primary
+**Применить** action; credentials are required only for Bridge and Direct.
+
+Ordinary Bridge Apply never opts into changing the controlling Mac's Wi-Fi
+association. Host switching is a separate future consequential operator action,
+not an Apply substep. K1 provisioning can therefore succeed as
+`network_applied` while control is `control_not_ready`. That result must not
+repeat that intent's BLE write. Recommended separately explicit read-only
+Verify/recovery may establish route, endpoint and DeviceInfo/control evidence
+for the applied topology; a new intent remains separately policy-gated.
+
+The exact REST response owns completion of the Apply mutation. A snapshot with
+`connection_attempt.phase=network_applied` is accepted immediately when
+`control_state` is `control_not_ready` or `unknown`; the controller does not
+wait for WebSocket/poll convergence or call the full connection-ready
+requirement. The service may continue supervised same-intent control bootstrap
+after this fast durable ACK, but only read-only: no BLE/host mutation, mutation
+retry, new UI action or second Apply. This is not a hidden frontend Scan or
+Verify. Exact connection-ready remains mandatory before control or physical
+START. This separation spends the old intent before a delayed state channel
+could invite its duplicate replay. `connection_attempt` is a read model, not
+permanent lifecycle authority; current server policy may admit a separately
+explicit new intent.
+
+While the exact service-owned bootstrap child is `accepted` or `running` and
+projects `safe_next_action=wait-for-current-attempt`, the UI shows only one
+passive **Сеть настроена · подтверждаем управление** indicator. It enables no
+Verify, mode change, Scan, row or Apply action. Terminal unready/unknown child
+state then exposes the separately explicit policy-gated recovery choices.
+
+`network_applied` plus unready or unknown control spends the old Apply and gates
+ordinary mode change, Scan, row selection and Apply. It first waits passively
+for an exact active service child; after terminal settlement it presents an
+explicit recovery choice, regardless of browser-local mode. It never authorizes
+automatic or same-intent replay. Recommended Verify is pinned to the backend
+`serverBound` current/configured transport and mode; it never falls back to a
+selected browser row and is not a prerequisite for every new intent.
+
+Current server policy may admit a distinct, explicit new-intent path. Bridge
+uses `prepare-select-device`, a local-only CAS with zero device/host I/O; only
+after its success may the operator initiate a fresh six-second Scan. Quick and
+Direct use explicit policy-gated `scan-ble`, then the latest fresh row and a new
+idempotency Apply. A mode change requires backend `mode_selection` authority and
+then a fresh explicit Scan. No recovery choice performs hidden Scan, selection,
+Verify, provisioning or continuation of the old Apply, and the browser never
+manufactures authority.
+
+### Freshness and outcome semantics
+
+Apply is admitted only for the exact selected transport, completed discovery
+generation, backend runtime, desired-mode revision, reconfiguration intent and
+policy snapshot. Authority drift before dispatch is a terminal, zero-device-I/O
+`stale` result. The UI keeps the result understandable, labels it explicitly and
+offers a new explicit six-second search. It never starts that search itself.
+
+A failure before the reviewed mutation boundary is `not-dispatched` or
+`failed`, with zero K1 mutation. A lost response, timeout, power failure or
+process death after dispatch is `outcome-unknown`, with
+`safe_to_retry=false`. The durable network-attempt ledger prevents replay.
+Credentials are never reused automatically. A later operator Apply is a new
+intent and must pass all current gates.
+
+## Physical safety remains separate
+
+Network attempts are disposable; physical START/STOP ambiguity is durable:
+
+- START and STOP never replay automatically;
+- control loss does not prove scanning stopped;
+- local receiver/camera/ingress cleanup is not physical STOP;
+- a wrong K1/transport/profile/project cannot reconcile the record;
+- READY records cessation without rewriting historical command outcome;
+- exact same-project SCANNING may mint one single-use confirmed STOP permit on
+ the still-open exact control binding;
+- accepted STOP without READY or SCAN_STOPPING by the backend deadline closes
+ only host-owned resources, records `timed_out` / `standby-unknown`, preserves
+ the unresolved ledger and keeps every mutation fenced.
+
+The composite supervisor and physical-command ledger can disable Apply before
+device I/O. Their denial does not turn mode, selection or input into recovery.
+
+### Explicit read-only recovery
+
+Recovery is a distinct, explicitly requested exceptional action. It is never a
+continuation of row selection or Apply. The browser supplies neither endpoint,
+substitute transport nor ledger authority. The backend pins the durable record's
+exact transport, identity/profile, operation/revision, acquisition/project,
+topology revision and host epoch.
+
+The non-reconnecting observation is:
+
+```text
+topology-probed
+ -> pre-start-control-opened
+ -> device-info-requested (ordinal 1; exactly one publish)
+ -> device-info-verified
+ -> awaiting-passive-fresh-status
+ -> cessation | active-same-project | foreign-active | inconclusive | failed
+```
+
+It publishes exactly one canonical DeviceInfo request and then accepts only a
+fresh non-retained DeviceStatus from the same socket generation after that
+barrier. It publishes no status solicitation, DeviceConfig, time sync,
+workspace, project, START or STOP; it never scans, reconnects, provisions or
+continues into Apply.
+
+Canonical READY records cessation/standby. Initialized SCANNING may rebind only
+when operation/acquisition, identity/profile, transport, host epoch and project
+all match; it exposes one separate single-use confirmed STOP checkpoint.
+Foreign, stale or inconclusive evidence changes no topology or authority.
+
+### Explicit retirement and reopen
+
+`physical-command.retire-unavailable` is a separately confirmed local durable
+recovery action for one unresolved target that is truly unavailable or replaced.
+Admission requires stable idempotency identity and exact backend runtime,
+operation, ledger revision and transport CAS plus safe lifecycle ownership. It
+preserves the original unknown outcome, activates the exact-transport deny,
+performs zero device/host I/O and starts no discovery.
+
+`physical-command.reopen-retired-reconciliation` is also separately confirmed.
+It requires an exact fresh same-transport candidate, stable `reopening_id`, exact
+runtime/revision/retirement/transport/discovery CAS and safe lifecycle ownership.
+It preserves retirement audit, removes only that retirement's active deny and
+performs zero device/host I/O. The explicit recovery intent may then run one
+exact read-only observation. **Выбрать** never invokes retirement, reopen or
+Verify. The only Apply exception is an internal, request-bound local reopen
+checkpoint for an explicit scenario reset plus its exact successor Scan. It is
+ordered after network PREPARED and before the sole dispatch edge, remains
+invisible in the wizard and grants no command authority. The same applied
+intent may then settle it read-only from fresh DeviceInfo plus non-retained
+READY/SCANNING evidence.
+
+FW 3.0.2 BLE `7f02` contains no stable DeviceInfo identity. Mission Core cannot
+prove during BLE-only discovery that the same physical unit has a new
+CoreBluetooth UUID. This remains an explicit protocol/hardware gap.
+
+### Bounded durable audit rollover
+
+An explicit local scenario reset must not become unavailable merely because
+closed retire/reopen history filled the 64 KiB hot ledger. Before a transition
+would exceed that bound, Mission Core durably publishes the complete previous
+ledger as a private, owner-only, content-addressed archive segment and then
+atomically publishes a compact v4 main record. The main record retains every
+active retirement deny, the newest lost-response retire/reopen checkpoint, and
+all reconciliation/confirmation proof required by the current physical
+operation. Compaction never changes a device outcome and performs no device,
+network or host I/O.
+
+Archive segments form a predecessor hash chain with exact sequence and byte
+accounting. Reload verifies directory and file ownership/mode, rejects symlink
+traversal, bounds total segments and bytes, reparses every embedded ledger and
+fails closed for a missing, replayed, reordered or tampered segment. Operation,
+reconciliation, verification, confirmation, retirement and reopening identities
+remain globally one-use across the hot record and archive. The archive segment
+is fsynced before the main-file replace: a crash may leave only an inert orphan,
+while retry of the same CAS reuses identical bytes and cannot duplicate the
+referenced chain.
+
+Scenario reset asks the ledger to build the exact prospective retirement or
+prepared→not-dispatched plan before closing any local receiver, camera,
+control-session or network ownership. That shared planner applies the same hot
+serialization, compaction, segment, count and total-byte bounds as commit. When
+rollover is required, preflight may idempotently prepublish only the immutable
+content-addressed predecessor; the main revision/CAS and physical disposition
+remain unchanged. This also proves owner/mode, symlink and content-collision
+conditions before teardown.
+
+Archive publication is restart-safe at the hard-link boundary. A process death
+after destination link and directory fsync but before temporary-name unlink may
+leave exactly two private names for one inode. Retry removes only a strictly
+named, owner-only temporary alias whose bytes and inode exactly match the
+expected destination and whose link count is exactly two, fsyncs that cleanup,
+then reuses the destination. Any unrelated hard link, extra temporary, symlink,
+metadata mismatch or byte mismatch remains a fail-closed corruption condition.
+
+## Failure and restart semantics
+
+- UI entry, mode, selection, input, polling, refresh and layout changes
+ start no device operation.
+- Search starts only when pressed, runs once for six seconds and terminalizes.
+- Apply starts only when pressed, uses one exact fresh candidate and may perform
+ at most one K1 mutation.
+- Candidate/runtime/intent drift is explicit stale, never hidden rescan.
+- Post-dispatch uncertainty is explicit outcome-unknown, never automatic replay.
+- K1 power loss revokes the active session without inventing standby.
+- Wi-Fi loss and WAN loss are distinct: local LAN control may survive WAN loss;
+ route/association loss revokes only dependent host/control evidence.
+- Browser refresh restores no live local selection and causes no I/O.
+- Backend restart restores durable audit and safety ledgers, but no live BLE,
+ control or operator intent.
+- Mac sleep/restart rotates host/runtime authority and rejects late work.
+
+## Acceptance
+
+- Mode, selection and input result in zero controller calls.
+- Each Search click issues exactly one scan with duration `6`; no effect, timer,
+ selection or Apply path calls Scan.
+- Every result keeps the same ordinary **Выбрать** action. Selection retains
+ the card, shows applicable inputs immediately, shows no loader and calls no
+ controller. After an explicit committed scenario reset and its successfully
+ completed successor Scan, this includes the exact UUID used by the retired
+ prior scenario; the row never exposes a reconnect/reopen/Verify CTA.
+- During unresolved physical recovery, a completed explicit Scan still renders
+ passive BLE evidence but cannot substitute a foreign target for the durable
+ recovery record. Exact recovery remains a separate established-session
+ action outside the cold result list; ordinary **Выбрать** never invokes its
+ reopen or read-only Verify. A new network flow first requires explicit reset
+ and a successor Scan.
+- Bridge/Direct show SSID/password; Quick Connect does not.
+- Exactly one primary **Применить** owns the connection request. Its frontend
+ handler calls no Scan/Verify/reopen helper and it permits at most one device
+ mutation. For an exact reset-owned retired UUID, the backend may append only
+ the internal local settlement checkpoint described above before dispatch.
+ A later SCANNING settlement grants only explicit STOP authority and never
+ restarts the reset-owned receiver, camera, writer or acquisition.
+- Stale/pre-dispatch and unknown/post-dispatch outcomes are visibly distinct.
+- Applied-but-unready/unknown spends the old Apply and gates ordinary mode,
+ Scan, selection and Apply behind an explicit recovery choice; recommended
+ Verify has only a server-bound backend target and no browser fallback.
+- A new intent remains possible only through current backend policy. Bridge
+ uses explicit local-only `prepare-select-device`; Quick/Direct use an explicit
+ admitted Scan and latest fresh row; mode change requires `mode_selection`.
+ Each route starts no hidden frontend or mutating continuation and ends in a
+ later fresh Scan/new idempotency Apply. The declared service-owned
+ same-intent read-only bootstrap after the durable ACK is the sole continuation
+ exception and creates no UI action.
+- The exact Apply REST snapshot with `phase=network_applied` completes the
+ network intent for both `control_not_ready` and `unknown`, without requiring
+ connection-ready or waiting for WebSocket/poll convergence.
+- A service-owned supervised control bootstrap may continue read-only after
+ that ACK. It performs no BLE/host mutation or retry and creates no frontend
+ Scan/Verify/new-Apply action or blocking Apply loader. Its exact
+ accepted/running state may own one passive settling indicator only.
+- Operator error copy comes only from an allowlisted public error-code mapping;
+ unknown/raw messages use a canonical secret-free fallback and never render
+ credentials, SSIDs, payloads or stack traces.
+- No timeout, disconnect, refresh, restart or state update starts a continuation
+ or replays an ended action.
+- Supervisor, identity pin, network-attempt ledger, physical-command ledger,
+ process/BLE lease and one-use recovery STOP remain authoritative.
+- The plugin uses shared `Button`, `TextField`, `ActivityIndicator` and
+ `StatusBadge`; contract tests reject raw local controls and literal colors.
+- Geometry and long-copy tests keep all actions reachable without overlap.
+- Bridge and Quick Connect retain separate real-hardware acceptance.
+
+This ADR does not itself declare hardware coverage. The manifest may mark a
+scenario software-covered only when named executable tests cover the software
+invariant; remaining K1/macOS/router and Quick Connect gaps stay explicit.
diff --git a/docs/k1-connection-acceptance.manifest.json b/docs/k1-connection-acceptance.manifest.json
new file mode 100644
index 0000000..a81a35f
--- /dev/null
+++ b/docs/k1-connection-acceptance.manifest.json
@@ -0,0 +1,93 @@
+{
+ "schema_version": "missioncore.k1-connection-acceptance/v1",
+ "canonical_document": "docs/20_K1_CONNECTION_SUPERVISION_CANON.md",
+ "meaning": {
+ "software-covered": "The listed automated tests cover the software invariant; this is not hardware acceptance.",
+ "partial": "At least one software layer is covered and an explicit remaining gap is listed.",
+ "planned": "The scenario is specified but does not yet have adequate executable coverage."
+ },
+ "scenarios": [
+ {"id":"CONN-01","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs"],"remaining":["real K1 Quick-to-Bridge evidence"]},
+ {"id":"CONN-02","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs"],"remaining":["real K1 Bridge-to-Quick evidence"]},
+ {"id":"CONN-03","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["hardware pre-dispatch fault injection"]},
+ {"id":"CONN-04","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["hardware pre-dispatch fault injection"]},
+ {"id":"CONN-05","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_device_identity_pin_store.py"],"remaining":["two-K1 hardware evidence"]},
+ {"id":"CONN-06","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_ble_scanner.py","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["CoreBluetooth hardware evidence"]},
+ {"id":"CONN-07","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real K1 wait-beyond-TTL acceptance"]},
+ {"id":"CONN-08","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real app/backend restart reconnect acceptance"]},
+
+ {"id":"CONN-10","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["hardware pre-prepare power-loss fault injection"]},
+ {"id":"CONN-11","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["process-kill acceptance at the prepared boundary"]},
+ {"id":"CONN-12","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real K1 post-dispatch power-loss acceptance"]},
+ {"id":"CONN-13","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real K1 observation-loss evidence"]},
+ {"id":"CONN-14","status":"software-covered","test_files":["tests/test_xgrids_semantic_topology_store.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["hardware host-association loss"]},
+ {"id":"CONN-15","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["hard-power hardware evidence"]},
+ {"id":"CONN-16","status":"partial","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_physical_command_ledger.py"],"remaining":["end-to-end passive Scan policy after acquisition power loss","exact-target read-only recovery/rebind integration","Bridge hardware power-loss acceptance","Quick Connect recovery not exercised"]},
+ {"id":"CONN-17","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py"],"remaining":["transport dispatch integration","restart acceptance"]},
+ {"id":"CONN-18","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_application_mqtt.py"],"remaining":["facade exact-target resolved-active rebind","single-use explicit recovery STOP presentation/action integration","same-project Bridge hardware acceptance","Quick Connect recovery not exercised"]},
+ {"id":"CONN-19","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_application_mqtt.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["facade exact-target resolved-active READY cessation integration","Bridge reboot hardware acceptance","Quick Connect recovery not exercised"]},
+
+ {"id":"CONN-20","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_macos_wifi.py"],"remaining":["router-loss hardware evidence"]},
+ {"id":"CONN-21","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_macos_wifi.py"],"remaining":["same-SSID router-return evidence"]},
+ {"id":"CONN-22","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_macos_wifi.py"],"remaining":["manual macOS switch evidence"]},
+ {"id":"CONN-23","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real Quick AP leave/return"]},
+ {"id":"CONN-24","status":"partial","test_files":["tests/test_connection_supervisor.py","tests/test_ble_scanner.py"],"remaining":["macOS sleep/wake hardware acceptance"]},
+ {"id":"CONN-25","status":"software-covered","test_files":["tests/test_connection_supervisor.py"],"remaining":["route-race integration evidence"]},
+ {"id":"CONN-26","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["wrong-service integration evidence"]},
+ {"id":"CONN-27","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_application_mqtt.py","tests/test_xgrids_application_session.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["half-open MQTT hardware acceptance"]},
+ {"id":"CONN-28","status":"partial","test_files":["tests/test_xgrids_application_mqtt.py","tests/test_xgrids_physical_command_coordinator.py","tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["full facade policy for passive Scan with unknown/active physical state","physical-record transport_ref pinning across bounded observation and wrong-K1 no-topology-change","resolved-active SCANNING one-STOP integration","real K1 passive READY/SCANNING DeviceStatus acceptance"]},
+ {"id":"CONN-29","status":"planned","test_files":[],"remaining":["durable external-active takeover contract","operator-confirmed same-binding STOP"]},
+
+ {"id":"CONN-30","status":"planned","test_files":[],"remaining":["browser/app close clean-session acceptance at every stage"]},
+ {"id":"CONN-31","status":"software-covered","test_files":["tests/test_xgrids_network_mutation_ledger.py","tests/test_xgrids_semantic_topology_store.py"],"remaining":["restart integration acceptance"]},
+ {"id":"CONN-32","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real backend restart acceptance from a prepared network mutation"]},
+ {"id":"CONN-33","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real backend restart acceptance from a dispatching network mutation"]},
+ {"id":"CONN-34","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real backend restart acceptance from an observing network mutation"]},
+ {"id":"CONN-35","status":"software-covered","test_files":["tests/test_xgrids_network_mutation_ledger.py","tests/test_xgrids_semantic_topology_store.py"],"remaining":["restart integration acceptance"]},
+ {"id":"CONN-36","status":"planned","test_files":[],"remaining":["restart acceptance proving no old live session restoration"]},
+ {"id":"CONN-37","status":"planned","test_files":[],"remaining":["corrupt historical audit quarantine without permanent K1 block","operator diagnosis UI"]},
+ {"id":"CONN-38","status":"software-covered","test_files":["tests/test_xgrids_network_mutation_ledger.py","tests/test_xgrids_ble_runtime_arbiter.py"],"remaining":["two-service integration acceptance"]},
+ {"id":"CONN-39","status":"software-covered","test_files":["tests/test_xgrids_network_mutation_ledger.py"],"remaining":[]},
+
+ {"id":"CONN-40","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_application_session.py"],"remaining":["real wrong/failed DeviceInfo evidence"]},
+ {"id":"CONN-41","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["MQTT fault-injection integration"]},
+ {"id":"CONN-42","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["camera and point stall integration"]},
+ {"id":"CONN-43","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["late packet integration evidence"]},
+ {"id":"CONN-44","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_application_session.py"],"remaining":["late DeviceInfo integration evidence"]},
+ {"id":"CONN-45","status":"partial","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_application_control_process_lease.py"],"remaining":["durable physical-command integration"]},
+ {"id":"CONN-46","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_camera_gateway.py"],"remaining":["combined MQTT/camera late-producer integration"]},
+ {"id":"CONN-47","status":"software-covered","test_files":["tests/test_xgrids_application_control_process_lease.py"],"remaining":["two-service integration acceptance"]},
+ {"id":"CONN-48","status":"software-covered","test_files":["tests/test_xgrids_camera_gateway.py"],"remaining":["drain-timeout integration evidence"]},
+ {"id":"CONN-49","status":"software-covered","test_files":["tests/test_connection_supervisor.py"],"remaining":["long-running fault-injection acceptance"]},
+
+ {"id":"CONN-50","status":"partial","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["macOS sleep/wake hardware acceptance"]},
+ {"id":"CONN-51","status":"software-covered","test_files":["tests/test_xgrids_macos_wifi.py","tests/test_connection_supervisor.py"],"remaining":["compiled association observer"]},
+ {"id":"CONN-52","status":"software-covered","test_files":["tests/test_xgrids_device_identity_pin_store.py","tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["two-identity hardware evidence"]},
+ {"id":"CONN-53","status":"software-covered","test_files":["tests/test_xgrids_semantic_topology_store.py","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["restart browser acceptance"]},
+ {"id":"CONN-54","status":"planned","test_files":[],"remaining":["historical unknown audit does not block fresh explicit connect","restart browser acceptance"]},
+ {"id":"CONN-55","status":"partial","test_files":["apps/control-station/test/devicePluginFrontendBoundary.test.mjs"],"remaining":["automated browser geometry matrix"]},
+ {"id":"CONN-56","status":"partial","test_files":["apps/control-station/test/devicePluginFrontendBoundary.test.mjs"],"remaining":["automated long-copy browser geometry"]},
+ {"id":"CONN-57","status":"planned","test_files":[],"remaining":["operator-confirmed physical-ledger archive and identity rotation"]},
+ {"id":"CONN-58","status":"software-covered","test_files":["tests/test_xgrids_ble_runtime_arbiter.py","tests/test_xgrids_application_control_process_lease.py","tests/test_ble_scanner.py","tests/test_wifi_provisioning.py","tests/test_xgrids_ap_activation.py"],"remaining":["two-service CoreBluetooth hardware acceptance","native cleanup fault injection on macOS"]},
+ {"id":"CONN-59","status":"partial","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_network_provisioning_idempotency_journal.py"],"remaining":["prove failed audit admission releases active ownership for a new explicit attempt"]},
+ {"id":"CONN-60","status":"software-covered","test_files":["apps/control-station/test/devicePluginContracts.test.mjs"],"remaining":["manual browser confirmation-dismissal acceptance"]},
+ {"id":"CONN-61","status":"planned","test_files":[],"remaining":["legacy unresolved record terminalization without BLE or cross-session block","process-kill acceptance"]},
+ {"id":"CONN-62","status":"software-covered","test_files":["tests/test_web_validation_security.py"],"remaining":["manual browser refresh/close acceptance"]},
+ {"id":"CONN-63","status":"planned","test_files":[],"remaining":["composite policy denies active contention but ignores terminal historical network audit","manual policy presentation acceptance"]},
+ {"id":"CONN-64","status":"software-covered","test_files":["tests/test_connection_supervisor.py","tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs"],"remaining":["real K1 control/data loss acceptance"]},
+ {"id":"CONN-65","status":"partial","test_files":["tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["resolved-active same-project one-STOP UI action acceptance","real restart/browser host-route and passive DeviceStatus acceptance","Quick Connect recovery not exercised"]},
+ {"id":"CONN-66","status":"planned","test_files":[],"remaining":["compiled or long-lived macOS association observer","long-running monitor latency/fault acceptance"]},
+ {"id":"CONN-67","status":"partial","test_files":["tests/test_xgrids_acquisition_lifecycle.py"],"remaining":["real K1 repeated same-mode and cross-mode reconnect acceptance"]},
+ {"id":"CONN-68","status":"partial","test_files":["tests/test_xgrids_application_session.py","tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["Bridge STOP-ack plus Wi-Fi-loss hardware rerun","Quick Connect recovery not exercised"]},
+ {"id":"CONN-69","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["resolved-active same-project one explicit STOP browser acceptance","Bridge hardware rerun with redacted evidence","Quick Connect recovery not exercised"]},
+ {"id":"CONN-70","status":"software-covered","test_files":["apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real Bridge one-scan/select/immediate-credentials/Apply acceptance","Quick Connect recovery not exercised"]},
+ {"id":"CONN-71","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real Bridge STOP-deadline fault injection","Quick Connect recovery not exercised"]},
+ {"id":"CONN-72","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_plugin_runtime.py","apps/control-station/test/devicePluginContracts.test.mjs","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real Bridge existing-plugin-section acceptance","manual two-tab browser acceptance","Quick Connect live acceptance remains separate"]},
+ {"id":"CONN-73","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_semantic_topology_store.py","tests/test_xgrids_device_identity_pin_store.py"],"remaining":["real cold Bridge and two-K1 identity-mismatch/restart evidence","Quick Connect live acceptance remains separate"]},
+ {"id":"CONN-74","status":"software-covered","test_files":["tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_network_mutation_ledger.py","tests/test_xgrids_network_provisioning_idempotency_journal.py","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real Bridge Apply with no hidden discovery/Verify","post-dispatch hardware fault injection","Quick Connect live acceptance remains separate"]},
+ {"id":"CONN-75","status":"software-covered","test_files":["tests/test_xgrids_connection_scenario_reset.py","tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_application_control_process_lease.py","apps/control-station/test/devicePluginFrontendBoundary.test.mjs"],"remaining":["real disconnected/idle desired-mode draft no-I/O acceptance with unresolved durable physical history plus live-owner denial","real pre-START orphan and backend-runtime credential invalidation acceptance","manual top-right emergency-reset acceptance","Quick Connect live acceptance remains separate"]},
+ {"id":"CONN-76","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_physical_command_coordinator.py","tests/test_xgrids_acquisition_lifecycle.py","tests/test_xgrids_application_control_process_lease.py","tests/test_xgrids_ble_runtime_arbiter.py","tests/test_xgrids_camera_gateway.py","tests/test_cli.py","tests/test_plugin_runtime.py","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real separate explicit retirement confirmation while selection and Apply remain mutation-free","same-hardware/new-CoreBluetooth-UUID cannot be identified before provisioning because FW 3.0.2 BLE 7f02 exposes no stable DeviceInfo identity","Quick Connect live acceptance remains separate"]},
+ {"id":"CONN-77","status":"partial","test_files":["tests/test_xgrids_physical_command_ledger.py","tests/test_xgrids_physical_command_coordinator.py","tests/test_xgrids_acquisition_lifecycle.py","tests/test_plugin_runtime.py","apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["real retired exact-UUID selection remains local and Apply denied","real separately explicit reopen followed by READY and same-project SCANNING outcomes","Quick Connect live acceptance remains separate"]},
+ {"id":"CONN-78","status":"software-covered","test_files":["apps/control-station/test/devicePluginFrontendBoundary.test.mjs","apps/control-station/test/k1SupervisorPresentation.test.mjs"],"remaining":["manual Bridge and Quick one-intent timing acceptance","manual top-right idle/pending accessible-label acceptance","real stale-before-dispatch and unknown-after-dispatch fault injection","real fast REST network_applied plus delayed service-owned read-only control-bootstrap convergence","real Bridge prepare-select-device and Quick/Direct scan-new-intent recovery acceptance","manual canonical shared-control visual acceptance"]}
+ ]
+}
diff --git a/docs/runbooks/K1_CONNECTION_RECOVERY.md b/docs/runbooks/K1_CONNECTION_RECOVERY.md
new file mode 100644
index 0000000..ff516d3
--- /dev/null
+++ b/docs/runbooks/K1_CONNECTION_RECOVERY.md
@@ -0,0 +1,393 @@
+# K1 connection lifecycle and recovery runbook
+
+Canonical model: [`../20_K1_CONNECTION_SUPERVISION_CANON.md`](../20_K1_CONNECTION_SUPERVISION_CANON.md).
+This runbook is the operator-facing projection of that model. Technical terms in
+the internal-safety sections are engineering evidence; they are not wizard copy.
+
+## One operator wizard
+
+The connection surface is one progressive wizard, not a recovery dashboard.
+Its only model-bearing heading is **Подключение XGRIDS LixelKity K1**. Inside
+the wizard the two step names are exactly **Подключение** and **Сеть**.
+
+### Cold entry
+
+On a clean cold entry show only:
+
+- the connection-mode selector;
+- Step 01 **Подключение** with 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.
+
+Do not render Step 02 yet and do not start discovery automatically. The mode
+selector and same-mode **Подключить новый K1** escape remain available through
+every other lifecycle state. The first gesture may supersede another pending
+local action. While that one bounded scenario reset itself owns `mode`, all
+three reset entry points show pending and dispatch no B intent. Each accepted
+gesture sends one idempotent local `reset_scenario` CAS: it queues behind old
+local lifecycle ownership, seals retained receiver/camera/control resources,
+invalidates candidates/drafts/credentials and retires the old physical lineage
+without resolving its outcome. It sends no BLE, device or host network command,
+MQTT publish, Verify, provisioning, START, STOP or automatic Scan. Opening and
+polling the surface still do nothing. Scan, Verify, provisioning and START
+retain separate backend gates.
+
+The top-right refresh-shaped utility is the same explicit emergency reset, not
+a passive state refresh. Its accessible label is **Сбросить подключение**; while
+the request owns the current action it reads **Сбрасываем подключение** and
+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,
+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
+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
+unknown outcome must immediately render its current recovery/error surface;
+reload must neither hide that new failure nor resurrect the prior prompt.
+
+### Step 01 — Подключение
+
+Step 01 **Подключение** is visible immediately.
+
+1. Bluetooth discovery starts only after the operator presses the search
+ action.
+2. For the full bounded search, show an activity indicator and a visible
+ seconds countdown in the same step.
+3. After search completes, show the result count or the empty result. Every
+ connectable result row keeps the same one enabled **Выбрать** action,
+ including an exact UUID used in an earlier scenario. That action is
+ local-only and never invokes reopen or Verify. Fresh results never render a
+ reconnect CTA, a disabled competing primary or an old/new-device decision.
+ A successful admitted Scan first settles only the reset marker whose
+ id/revision/mode it captured at action entry, recording the later admitted
+ discovery generation while retaining the marker for idempotent reset replay.
+ Failed/cancelled Scan and an older Scan racing a newer pending reset leave
+ the marker active and do not expose stale recovery authority.
+4. After ordinary **Выбрать**, retain the chosen device card and reveal only
+ the applicable local draft inputs. Selection itself has no loader and makes
+ no controller call.
+5. Only an authoritative successful connection outcome renders Step 01 green
+ as **Подключение установлено** and advances the normal connection flow.
+
+The wizard never labels a candidate as saved, original, retired or physically
+ambiguous and never exposes ledger, CAS, retirement, reopen or reconnect
+terminology in the search list. Exact recovery belongs only to a previously
+established session after an actual interruption.
+
+### Step 02 — Сеть
+
+Step 02 **Сеть** exists only after Step 01 has a confirmed green connection.
+
+- If the selected device is already usable on the chosen connection path,
+ show the network result without asking for credentials.
+- If backend policy has safely admitted explicit network setup, show the exact
+ retained device context and SSID/password fields here, never beside the
+ candidate list.
+- One explicit submit owns any exact hidden revalidation and at most one
+ reviewed network write.
+- **Изменить сеть** belongs only to this step and starts no Bluetooth search
+ when its form opens.
+- A stale tab, changed runtime/binding or policy denial fails before a write and
+ never exposes a foreign candidate.
+
+## Ordinary selection
+
+The ordinary **Выбрать** action is presentation simplification, not relaxed
+safety. It creates only a browser-local candidate draft and performs no
+controller I/O. It never selects an internal recovery path:
+
+- an ordinary fresh candidate becomes only the selected local draft;
+- an exact prior candidate uses the same **Выбрать** action as every row;
+- no candidate selection retires old authority, reopens a ledger record, calls
+ Verify, connects GATT, scans again or changes topology or the device;
+- a foreign, stale, non-connectable or policy-denied candidate remains
+ unavailable and changes no topology, ledger or device.
+
+After an explicit committed scenario reset, only its successfully completed
+successor Scan may make an exact previously retired transport eligible for a
+new network draft. Apply still captures that exact current-generation handle,
+validates the live GATT baseline and crosses at most one reviewed write edge.
+Selection performs no recovery action. At the final Apply boundary, the backend
+may append one exact request-bound local reopen checkpoint after network
+PREPARED and before write dispatch. That checkpoint preserves physical
+retirement/original-outcome audit, performs no device I/O and authorizes no
+START or STOP. The same applied intent then uses fresh DeviceInfo plus a
+non-retained DeviceStatus to settle READY as standby or identity-bound SCANNING as
+active, without a visible Verify step or command replay. In the SCANNING case
+it materializes only explicit STOP authority; it does not restart the retired
+receiver, camera, evidence writer or acquisition.
+
+### Exact internal recovery for an established session
+
+For an exact target belonging to a previously established session with one
+active retirement,
+`physical-command.reopen-retired-reconciliation` requires:
+
+- `operator_confirmed=true`, bound to a separately explicit session-recovery
+ action outside cold entry and the Bluetooth result list;
+- a stable `reopening_id` and reason
+ `device-returned-for-explicit-reconciliation`;
+- exact `expected_snapshot_runtime_id`, `expected_revision`,
+ `expected_retirement_id`, `expected_transport_ref` and
+ `expected_discovery_generation` CAS;
+- a current connectable candidate and safe lifecycle/process ownership.
+
+The transaction changes only the local durable ledger. It appends reopen audit,
+preserves the retirement and unknown command outcome as history, restores the
+original unresolved `dispatching` or `observing` stage and removes only that
+retirement's active deny. It performs zero BLE, Wi-Fi, MQTT, DeviceConfig,
+ModelingStatus, workspace, project, START or STOP I/O and starts no Scan.
+
+The same still-current session-recovery action may then own one exact read-only
+Verify. Ordinary **Выбрать** never invokes either half. Recovery never replays
+historical START/STOP and never silently provisions:
+
+- fresh non-retained READY resolves standby;
+- fresh exact same-project SCANNING resolves active and permits only the
+ separately guarded stop path;
+- identity, GATT, CAS, route/control or policy failure leaves the outcome
+ unknown and ends the established-session recovery without entering the new
+ connection wizard.
+
+If the action response is lost, refreshed state may continue the same click
+only when it proves that exact `reopening_id` audit was committed and every
+original runtime, candidate and authority fence still matches. A second tab,
+new discovery generation, new retirement or different reopening identity cannot
+inherit the continuation.
+
+### Internal retirement
+
+`physical-command.retire-unavailable` is a local durable primitive for an
+unresolved target that is truly unavailable or replaced. It may run only from
+its separately confirmed recovery/reset path, never from ordinary candidate
+selection, UI entry, polling or a timer. Admission requires explicit
+confirmation, stable `retirement_id` and exact backend runtime,
+operation, revision and transport CAS while every local owner is safe.
+
+Retirement preserves the complete old attempt and unknown command outcome,
+activates an exact-transport deny and performs zero device I/O or automatic
+discovery. Retirement history remains durable even if an exact later recovery
+action uses the reopen transaction. The wizard exposes no retirement
+transaction or historical label. Any plain-language exact recovery CTA belongs
+only to the established-session surface when backend authority permits it.
+
+Current FW 3.0.2 BLE `7f02` does not expose stable DeviceInfo identity. The same
+hardware under a new CoreBluetooth UUID cannot be recognized before DeviceInfo
+becomes available. This remains an explicit protocol/hardware acceptance gap;
+the wizard must not speculate.
+
+## Session and freshness rules
+
+A scan result is an unselected presence candidate owned by the latest explicit
+scan generation. Wall-clock age does not remove its row while the operator is
+reading or completing the form. A successor Scan, explicit scenario reset,
+runtime-owner teardown or proven exact-target GATT failure invalidates it. The
+row itself is never network authority: Apply still requires the exact captured
+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.
+
+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
+local scenario-reset CAS. It can wait for and supersede live/recovery ownership,
+seal retained local producers and retire unresolved old lineage, but performs
+zero device/host I/O and starts no Scan. Scan, Verify, provisioning and START
+remain independently fenced until an explicit candidate intent crosses its
+reviewed transition. No old host route, endpoint, control, data or BLE authority
+crosses a committed reset boundary.
+
+## Active scanning: transient host-path recovery
+
+This is the sole automatic read-only rebind exception. It exists only after
+Mission Core itself has a composite-confirmed START and still owns the exact
+acquisition/runtime/device/connection/evidence lineage. It does not apply on a
+cold connection screen, after backend restart, to an external SCANNING K1 or to
+an unresolved/foreign START.
+
+When the Mac loses Wi-Fi/route or the data socket while that acquisition is
+running, the active scanning pane shows a neutral spinner and
+**Восстанавливаем соединение** with attempt/elapsed time. Do not show a red
+terminal operation banner for the expected late failure of the superseded old
+control socket. Keep the acquisition and evidence session owned while the
+backend retries exact route/TCP and inspection-only DeviceInfo/status proof.
+
+The recovery loop never sends BLE, changes Wi-Fi, writes DeviceConfig, repeats
+START or sends STOP. Outcomes are:
+
+- exact same-device/same-project initialized `SCANNING`: silently resume the
+ point stream/control binding and, when necessary, CAS-restart the dead or
+ stalled acquisition-owned right-camera FFmpeg epoch;
+- fresh `READY`: interrupt/seal host-owned acquisition resources truthfully,
+ without STOP;
+- fresh `SCAN_OVER`: persist cessation, interrupt/seal locally and retain a
+ read-only `awaiting READY` fence that denies a new START;
+- wrong identity/same IP, changed lineage or failed camera CAS: remain blocked
+ for explicit operator handling; and
+- device/system fault or unsafe status: show a truthful terminal fault, with no
+ command retry.
+
+While state is `reconnecting` or `blocked`, expose **Завершить локально**. The
+action `acquisition.force-finish-local` requires the current snapshot runtime,
+acquisition id/state revision, recovery generation, producer generation match,
+an idempotency key and explicit confirmation. It cancels recovery first, then
+seals only local receiver/camera/control/perception owners. It preserves the
+physical START ledger and sends no STOP. If a connection-mode reset races this
+action, the shared lifecycle gate makes cleanup idempotent; the loser cannot
+overwrite the new mode or revive the old acquisition.
+
+If receiver, camera or evidence sealing fails, the recovery generation is
+still irrevocably cancelled first. The force-finish operation ends with a
+visible `local-cleanup-failed` result whose retryability applies only to local
+finalization; the terminal acquisition retains `cleanup_pending` and blocks a
+replacement session. A later explicit local stop or exact connection-scenario
+reset may retry those host resources. It must not retry START, STOP, BLE or a
+network write, and a late success from the retired recovery generation remains
+fenced.
+
+## Failure matrix
+
+| Event | Product result | Operator path |
+| --- | --- | --- |
+| Cold entry | 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 |
+| Search finds no candidates | Step 01 reports no matches | Repeat search explicitly |
+| Search finds one or many candidates | Every connectable row has one enabled **Выбрать**, including the exact prior UUID | Select one row; no reconnect or recovery action appears in search results |
+| Wall-clock time passes after Scan before selection | Latest-generation rows remain stable; no operation starts | Select normally; exact capture and live GATT will gate Apply |
+| A new Scan/reset/runtime teardown or exact-target GATT failure invalidates the generation | Old rows disappear or the attempted action fails cleanly before mutation | Run one explicit new search if needed |
+| Selection is rejected by identity, GATT, CAS, lifecycle or safety policy | Loader ends; nothing changed; Step 02 remains absent | **Повторить** or **Выбрать другое** |
+| Selection completes exact device connection | Step 01 turns green | Continue in Step 02 **Сеть** |
+| Network setup is safely required | Credentials appear only in Step 02 | Submit once |
+| Network write becomes ambiguous after dispatch | Attempt ends unknown; no replay | Wait for cleanup, then create a distinct explicit attempt |
+| Device powers off or BLE disconnects | Live selection and authority revoke after proof | Search and select explicitly after the device is available |
+| Router, Mac Wi-Fi or MQTT control is lost while idle/pre-START | Host/control authority revokes; data may remain evidence only | Restore reachability, then use the same wizard flow |
+| Mac Wi-Fi/route is briefly lost during one composite-confirmed owned acquisition | Active pane remains neutral **Восстанавливаем соединение**; no START/STOP/network retry | Wait for exact automatic read-only rebind or press **Завершить локально** |
+| Active recovery returns READY or SCAN_OVER | Local receiver/camera seal without STOP; SCAN_OVER remains fenced until fresh READY | Start another scenario only after backend policy reports it safe |
+| Active recovery sees another K1 on the same IP or changed lineage | Recovery blocks fail-closed; no camera/data resurrection | Finish locally or explicitly choose/reset connection scenario |
+| 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 |
+
+## Physical START/STOP safety remains separate
+
+The simplified wizard never weakens physical-command safety:
+
+- loss of control does not prove that K1 stopped recording;
+- START and STOP are never replayed automatically;
+- local receiver/camera/ingress cleanup is not physical STOP;
+- an ambiguous post-dispatch command remains unknown until exact fresh proof;
+- read-only recovery is pinned to the durable transport, identity/profile,
+ host epoch and project;
+- each observation publishes exactly one DeviceInfo request and may classify
+ only a fresh non-retained DeviceStatus after that barrier;
+- READY records cessation without inventing a successful STOP;
+- SCAN_OVER records cessation without inventing STOP, but keeps a durable
+ read-only fence until a later fresh unbound READY observation;
+- exact same-project SCANNING may mint one single-use, separately confirmed STOP
+ checkpoint; it does not send STOP automatically;
+- a wrong transport/device/project changes no topology or ledger state;
+- accepted STOP without READY or SCAN_STOPPING by the backend deadline closes
+ only host-owned resources, yields `timed_out` / `standby-unknown`, preserves
+ the unresolved ledger and keeps mutation fenced.
+
+Engineering logs and state APIs retain these distinctions. The connection
+wizard projects only the ordinary progressive flow and a non-technical terminal
+selection result.
+
+## No automatic action rule
+
+None of these events may scan, select, reconnect, Verify, provision, START or
+STOP:
+
+- opening or resizing the connection surface;
+- backend event delivery or state polling;
+- an acknowledged scenario reset (it may perform only its explicit local
+ retirement, never any listed device/network action or automatic Scan);
+- candidate list refresh after an ended search;
+- browser refresh, sleep/wake or backend restart;
+- timeout, disconnect or a historical audit record.
+
+The only exception is the service-owned active-stream read-only rebind above.
+It is triggered by the already-owned receiver's transport loss, not UI entry or
+polling, and is limited to route/TCP, DeviceInfo/status inspection, receiver
+resubscribe and exact local camera-epoch restart. It never performs discovery,
+provisioning, START, STOP or any device/network write.
+
+Only the currently pressed search, distinct exact recovery CTA, network submit
+or separately guarded acquisition control may own corresponding I/O. Ordinary
+**Выбрать** owns only a browser-local draft and never owns a loader. Every
+loader belongs to the explicit action that created it and ends with it.
+
+## Hardware acceptance order
+
+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
+ 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.
+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
+ exactly one enabled **Выбрать** action and none auto-selects or auto-connects.
+ Repeat with the exact prior UUID after reset and prove it has the same
+ **Выбрать** action, with no reconnect/reopen/Verify path.
+4. Select a Bridge device, including that prior UUID, and prove the card remains visible through
+ **Подключение…**, then Step 01 turns green before Step 02 **Сеть** appears.
+5. Prove network fields never coexist with candidate rows, and one explicit
+ submit owns at most one write.
+6. Wait beyond the legacy candidate TTL and prove both the latest-generation
+ unselected rows and an admitted selected session remain stable; then prove a
+ missing exact handle/live GATT failure blocks Apply before any write.
+7. Exercise identity, GATT, stale-CAS, lifecycle-busy, disconnect and power-off
+ failures; each ends the loader, leaves Step 02 absent and offers only ordinary
+ retry/choose-another copy.
+8. Retire an unresolved target in controlled fault injection, perform one
+ scenario reset and rediscover its exact UUID in the successor Scan. Prove its
+ sole action is **Выбрать**, selection performs no I/O and Step 02 appears
+ immediately. Apply once and prove exact current-generation handle capture,
+ live GATT baseline, exactly one request-bound append-only physical reopen
+ checkpoint and at most one network write. The original retirement/outcome
+ audit remains immutable; the service-owned continuation uses only DeviceInfo
+ and non-retained status, with zero START/STOP and no browser Verify. Inject
+ failed and outcome-unknown network results; each current error/recovery
+ surface remains visible after reload.
+9. Try a different device while old authority is unavailable and prove its
+ ordinary selection triggers no hidden retirement/reopen/Verify and cannot
+ bypass the durable target.
+10. Prove no row labels a device saved/original/retired, says
+ **Переподключиться**, or exposes physical-state/ledger terminology. The model name
+ appears only in the top heading; step names remain **Подключение / Сеть**.
+11. During a composite-confirmed live acquisition, remove host Wi-Fi for longer
+ than the old control keepalive and restore it. Prove neutral reconnecting,
+ same-lineage SCANNING resume, raw-writer continuity, exact camera epoch
+ restart when stalled, and zero START/STOP/BLE/network mutation. Repeat with
+ READY, SCAN_OVER, wrong identity and permanent loss plus
+ **Завершить локально**.
+12. Repeat idle/pre-START Bridge network loss, Mac Wi-Fi switch, sleep/wake,
+ hard K1 power loss, STOP deadline and backend restart; prove zero automatic
+ command or retry outside the sole active-stream exception.
+13. Repeat the entire acceptance separately for Quick Connect before claiming
+ Quick coverage.
+
+The current software contract is not real-hardware acceptance. The acceptance
+manifest lists executable coverage and the remaining Bridge/Quick field gaps.
diff --git a/experiments/perception/worker/run_e15_shadow_inference.py b/experiments/perception/worker/run_e15_shadow_inference.py
index a1d41e9..5007558 100644
--- a/experiments/perception/worker/run_e15_shadow_inference.py
+++ b/experiments/perception/worker/run_e15_shadow_inference.py
@@ -254,13 +254,11 @@ def read_live_profile(path: Path) -> tuple[dict[str, Any], str]:
}
if (
not isinstance(replay_source, dict)
- or replay_source.get("session_id")
- != "20260720T065719Z_viewer_live"
+ or replay_source.get("session_id") != "20260720T065719Z_viewer_live"
or replay_source.get("display_name") != "RAVNOVES00"
or replay_source.get("selection") != "complete-recording"
or float(replay_source.get("speed", 0)) != 1.0
- or float(replay_source.get("minimum_source_span_seconds", 0))
- < 450
+ or float(replay_source.get("minimum_source_span_seconds", 0)) < 450
or replay_source.get("look_ahead") is not False
or any(
not isinstance(replay_source.get(key), int)
@@ -269,9 +267,7 @@ def read_live_profile(path: Path) -> tuple[dict[str, Any], str]:
for key, expected in replay_integer_contract.items()
)
):
- raise RuntimeError(
- "LAB E28 complete-recording worker replay contract is invalid"
- )
+ raise RuntimeError("LAB E28 complete-recording worker replay contract is invalid")
elif replay_source is not None:
raise RuntimeError("LAB E15 non-replay profile carries replay source state")
if local_surface is not None:
@@ -280,9 +276,7 @@ def read_live_profile(path: Path) -> tuple[dict[str, Any], str]:
)
local_acceptance = (
- local_surface.get("acceptance")
- if isinstance(local_surface, dict)
- else None
+ local_surface.get("acceptance") if isinstance(local_surface, dict) else None
)
expected_profile_sha256 = hashlib.sha256(
canonical_json(DEFAULT_K1_LOCAL_SURFACE_PROFILE.to_dict())
@@ -293,29 +287,20 @@ def read_live_profile(path: Path) -> tuple[dict[str, Any], str]:
"maximum_runtime_drop_fraction",
)
point_capacity = (
- local_surface.get("point_queue_capacity")
- if isinstance(local_surface, dict)
- else None
+ local_surface.get("point_queue_capacity") if isinstance(local_surface, dict) else None
)
pose_capacity = (
- local_surface.get("pose_buffer_capacity")
- if isinstance(local_surface, dict)
- else None
+ local_surface.get("pose_buffer_capacity") if isinstance(local_surface, dict) else None
)
result_capacity = (
- local_surface.get("result_capacity")
- if isinstance(local_surface, dict)
- else None
+ local_surface.get("result_capacity") if isinstance(local_surface, dict) else None
)
if (
- profile.get("mode")
- not in {"worker-replay-gate", "physical-shadow-gate"}
+ profile.get("mode") not in {"worker-replay-gate", "physical-shadow-gate"}
or not isinstance(local_surface, dict)
or local_surface.get("enabled") is not True
- or local_surface.get("profile_id")
- != DEFAULT_K1_LOCAL_SURFACE_PROFILE.profile_id
- or local_surface.get("profile_sha256")
- != expected_profile_sha256
+ or local_surface.get("profile_id") != DEFAULT_K1_LOCAL_SURFACE_PROFILE.profile_id
+ or local_surface.get("profile_sha256") != expected_profile_sha256
or not isinstance(point_capacity, int)
or isinstance(point_capacity, bool)
or point_capacity not in range(1, 9)
@@ -328,27 +313,16 @@ def read_live_profile(path: Path) -> tuple[dict[str, Any], str]:
or not 0
<= float(local_surface.get("future_pose_wait_ms", -1))
<= DEFAULT_K1_LOCAL_SURFACE_PROFILE.maximum_pose_binding_ms
- or not 0.1
- <= float(local_surface.get("retention_seconds", 0))
- <= 30
+ or not 0.1 <= float(local_surface.get("retention_seconds", 0)) <= 30
or float(temporal.get("maximum_pose_point_delta_ms", 0))
!= DEFAULT_K1_LOCAL_SURFACE_PROFILE.maximum_pose_binding_ms
or not isinstance(local_acceptance, dict)
or int(local_acceptance.get("minimum_bound_frames", 0)) < 2
- or any(
- not 0 <= float(local_acceptance.get(key, -1)) <= 1
- for key in local_fractions
- )
- or float(
- local_acceptance.get("maximum_p95_result_age_ms", 0)
- )
- <= 0
- or float(local_acceptance.get("minimum_effective_fps", 0))
- <= 0
+ or any(not 0 <= float(local_acceptance.get(key, -1)) <= 1 for key in local_fractions)
+ or float(local_acceptance.get("maximum_p95_result_age_ms", 0)) <= 0
+ or float(local_acceptance.get("minimum_effective_fps", 0)) <= 0
):
- raise RuntimeError(
- "LAB E28 worker local-surface profile contract is invalid"
- )
+ raise RuntimeError("LAB E28 worker local-surface profile contract is invalid")
fractions = (
"detector_maximum_drop_fraction",
"semantic_maximum_drop_fraction",
@@ -429,64 +403,45 @@ def _local_surface_acceptance_checks(
return {
"local_surface_session_initialized": bool(runtime),
- "local_surface_closed": snapshot.get("closed") is True
- and runtime.get("closed") is True,
+ "local_surface_closed": snapshot.get("closed") is True and runtime.get("closed") is True,
"local_surface_minimum_bound_frames": point_bound
>= int(acceptance["minimum_bound_frames"]),
- "local_surface_binder_accounting": point_bound
- + point_missed
- + point_dropped
- + point_depth
+ "local_surface_binder_accounting": point_bound + point_missed + point_dropped + point_depth
== point_published,
- "local_surface_binder_to_runtime_accounting": point_bound
- == runtime_published,
+ "local_surface_binder_to_runtime_accounting": point_bound == runtime_published,
"local_surface_point_buffer_bound": (
int(points.get("capacity", 0)) == int(config["point_queue_capacity"])
- and int(points.get("maximum_depth", 0))
- <= int(points.get("capacity", 0))
+ and int(points.get("maximum_depth", 0)) <= int(points.get("capacity", 0))
and point_depth == 0
),
"local_surface_pose_buffer_bound": (
int(poses.get("capacity", 0)) == int(config["pose_buffer_capacity"])
- and int(poses.get("maximum_depth", 0))
- <= int(poses.get("capacity", 0))
+ and int(poses.get("maximum_depth", 0)) <= int(poses.get("capacity", 0))
),
- "local_surface_maximum_pose_miss_fraction": point_missed
- / max(1, point_published)
+ "local_surface_maximum_pose_miss_fraction": point_missed / max(1, point_published)
<= float(acceptance["maximum_pose_miss_fraction"]),
- "local_surface_maximum_point_drop_fraction": point_dropped
- / max(1, point_published)
+ "local_surface_maximum_point_drop_fraction": point_dropped / max(1, point_published)
<= float(acceptance["maximum_point_drop_fraction"]),
- "local_surface_runtime_accounting": runtime_consumed
- + runtime_dropped
- + runtime_depth
+ "local_surface_runtime_accounting": runtime_consumed + runtime_dropped + runtime_depth
== runtime_published,
- "local_surface_runtime_result_accounting": result_published
- + result_failed
+ "local_surface_runtime_result_accounting": result_published + result_failed
== runtime_consumed,
"local_surface_runtime_queue_bound": (
int(queue_state.get("capacity", 0)) == int(config["point_queue_capacity"])
- and int(queue_state.get("maximum_depth", 0))
- <= int(queue_state.get("capacity", 0))
+ and int(queue_state.get("maximum_depth", 0)) <= int(queue_state.get("capacity", 0))
and runtime_depth == 0
),
- "local_surface_maximum_runtime_drop_fraction": runtime_dropped
- / max(1, runtime_published)
+ "local_surface_maximum_runtime_drop_fraction": runtime_dropped / max(1, runtime_published)
<= float(acceptance["maximum_runtime_drop_fraction"]),
- "local_surface_minimum_effective_fps": float(
- delivery.get("effective_fps", 0)
- )
+ "local_surface_minimum_effective_fps": float(delivery.get("effective_fps", 0))
>= float(acceptance["minimum_effective_fps"]),
"local_surface_zero_runtime_failures": result_failed == 0,
"local_surface_maximum_p95_result_age_ms": (
isinstance(p95_result_age, (int, float))
and not isinstance(p95_result_age, bool)
- and float(p95_result_age)
- <= float(acceptance["maximum_p95_result_age_ms"])
- ),
- "local_surface_profile_pinned": (
- runtime_profile.get("profile_id") == config["profile_id"]
+ and float(p95_result_age) <= float(acceptance["maximum_p95_result_age_ms"])
),
+ "local_surface_profile_pinned": (runtime_profile.get("profile_id") == config["profile_id"]),
"local_surface_shadow_authority_only": (
snapshot.get("authority")
== {
@@ -622,6 +577,7 @@ class _TransportState:
camera_sequence_gaps: int = 0
last_camera_source_sequence: int | None = None
session_id: str | None = None
+ session_generation: int | None = None
session_end_seen: bool = False
timed_out: bool = False
results_published: int = 0
@@ -956,8 +912,7 @@ class _StageExecutionTelemetry:
self._last_frame_by_stage.get(stage_id),
)
for stage_id in self._stage_ids
- if stage_id in self._native_started
- and stage_id not in self._native_failed
+ if stage_id in self._native_started and stage_id not in self._native_failed
]
for stage_id, elapsed_seconds, activations, frame_index in rows:
self._emit_native(
@@ -1012,9 +967,7 @@ class _StageExecutionTelemetry:
"elapsed_seconds": round(elapsed[stage_id], 6),
"activations": self._activations[stage_id],
"share_percent": (
- round(elapsed[stage_id] / total * 100, 6)
- if total > 0
- else None
+ round(elapsed[stage_id] / total * 100, 6) if total > 0 else None
),
}
for stage_id in self._stage_ids
@@ -1127,11 +1080,20 @@ def _receiver(
state.first_ingress_sequence = sequence
state.last_ingress_sequence = sequence
session_id = str(header["session_id"])
+ session_generation_value = header["session_generation"]
+ if (
+ not isinstance(session_generation_value, int)
+ or isinstance(session_generation_value, bool)
+ or session_generation_value < 1
+ ):
+ raise ShadowRuntimeError("shadow session generation is invalid")
+ session_generation = session_generation_value
if state.session_id is None:
state.session_id = session_id
+ state.session_generation = session_generation
if local_surface is not None:
local_surface.begin_session(session_id)
- elif state.session_id != session_id:
+ elif state.session_id != session_id or state.session_generation != session_generation:
raise ShadowRuntimeError("shadow session identity changed")
modality = str(header["modality"])
state.counts[modality] += 1
@@ -1272,9 +1234,7 @@ def _common(args: argparse.Namespace) -> dict[str, Any]:
"k1link/ground_segmentation.py",
}
if not required_surface_sources <= worker_sources:
- raise RuntimeError(
- "LAB E28 worker package lacks local-surface runtime"
- )
+ raise RuntimeError("LAB E28 worker package lacks local-surface runtime")
stability = None
stability_sha256 = None
if args.stability_profile is not None:
@@ -1400,11 +1360,7 @@ def run(
if not token or len(token) < 40:
raise RuntimeError("LAB E15 shadow token is missing")
- stage_telemetry = (
- runtime_state.get("_stage_telemetry")
- if runtime_state is not None
- else None
- )
+ stage_telemetry = runtime_state.get("_stage_telemetry") if runtime_state is not None else None
if not isinstance(stage_telemetry, _StageExecutionTelemetry):
stage_telemetry = _StageExecutionTelemetry()
if runtime_state is not None:
@@ -1461,9 +1417,7 @@ def run(
local_surface = K1LocalSurfaceShadowCoordinator(
point_capacity=int(local_surface_config["point_queue_capacity"]),
pose_capacity=int(local_surface_config["pose_buffer_capacity"]),
- future_pose_wait_ms=float(
- local_surface_config["future_pose_wait_ms"]
- ),
+ future_pose_wait_ms=float(local_surface_config["future_pose_wait_ms"]),
retention_seconds=float(local_surface_config["retention_seconds"]),
result_capacity=int(local_surface_config["result_capacity"]),
)
@@ -1890,7 +1844,11 @@ def run(
optimize=False,
)
with stage_telemetry.measure("result-publication", envelope.frame_index):
+ if transport.session_id is None or transport.session_generation is None:
+ raise ShadowRuntimeError("shadow result session identity is unavailable")
live_result = encode_live_perception_result(
+ session_id=transport.session_id,
+ session_generation=transport.session_generation,
frame_index=envelope.frame_index,
source_frame_index=int(envelope.timeline["source_frame_index"]),
session_seconds=frame_seconds,
@@ -1969,9 +1927,7 @@ def run(
temporal_semantic_summary = (
None if semantic_stabilizer is None else semantic_stabilizer.snapshot()
)
- local_surface_snapshot = (
- None if local_surface is None else local_surface.snapshot()
- )
+ local_surface_snapshot = None if local_surface is None else local_surface.snapshot()
acceptance = live["acceptance"]
checks = {
"minimum_camera_frames": decoded_frame_count >= int(acceptance["minimum_camera_frames"]),
@@ -2356,9 +2312,7 @@ def _persistent_run_telemetry_identity(
if isinstance(stability, dict) and isinstance(stability.get("profile_id"), str)
else "lab-e15-shadow-inference-v1"
)
- method_id = (
- INLINE_TEMPORAL_PIPELINE_ID if isinstance(stability, dict) else PIPELINE_ID
- )
+ method_id = INLINE_TEMPORAL_PIPELINE_ID if isinstance(stability, dict) else PIPELINE_ID
return PipelineTelemetryIdentity(
contour_id=telemetry.get("contour_id"),
agent_id=telemetry.get("agent_id"),
@@ -2545,9 +2499,7 @@ def serve(args: argparse.Namespace) -> int:
state["last_run_outcome"] = {
"request_id": request_id,
"state": "failed",
- "duration_ms": (
- round(duration_ms, 6) if duration_ms is not None else None
- ),
+ "duration_ms": (round(duration_ms, 6) if duration_ms is not None else None),
"exit_code": None,
"error_type": type(exc).__name__,
}
diff --git a/plugins/xgrids-k1/README.md b/plugins/xgrids-k1/README.md
index aae6079..d7df916 100644
--- a/plugins/xgrids-k1/README.md
+++ b/plugins/xgrids-k1/README.md
@@ -95,6 +95,14 @@ Validate the current exact-match profile without device I/O with:
uv run python plugins/xgrids-k1/profile_loader.py
```
+Plugin v0.7.0 adds the backend-owned supervised connection lifecycle. Operator
+mode choice is a CAS-fenced draft; an explicit Scan commits a safe pre-START
+mode switch, while Connect reaches Ready only after the exact current
+`DeviceInfo` authority is confirmed. Configured, active and desired modes are
+separate facts. Terminal pre-START failures and purely local prepared sessions
+self-retire without a device command, and an applied network configuration is
+recovered through a separate read-only Verify instead of replaying Wi-Fi.
+
Plugin v0.6.0 retains the physically accepted v0.5.0 control transport and adds
the connection matrix behind the existing explicit `network.provision` action.
Bridge remains the default. Direct Connect sends the same single reviewed
@@ -102,13 +110,19 @@ Bridge remains the default. Direct Connect sends the same single reviewed
Connect accepts no browser/API credential: it sends one reviewed fixed 100-byte
AP-enable frame to the selected K1, waits up to 15 seconds for the canonical
byte-51 AP-ready flag, and keeps that BLE session alive while the macOS adapter
-performs bounded exact-SSID CoreWLAN discovery and one association. Credentials
+performs up to 30 seconds of exact-SSID CoreWLAN discovery and one association.
+AP-ready does not imply that macOS has already observed the RF beacon. Credentials
are resolved by a preinstalled exact `3.0.2` firmware provider. Its optional laboratory importer
validates the reviewed official archive, extracts the single AP declaration and
installs firmware-scoped material in the OS secure store. The macOS helper then
materializes the selected device profile entirely inside Keychain before any
BLE write. The secret never enters the browser, API, argv, logs or evidence;
the importer's short-lived mutable buffer is zeroized after the stdin handoff.
+The prepared-host adapter uses the accepted Apple-signed
+`/usr/bin/xcrun swift` runner. It does not runtime-compile an ad-hoc executable,
+query the standard Wi-Fi Keychain or open a password dialog after the K1 write.
+Production portability still requires a packaged, properly signed helper with
+a stable designated identity and explicit CoreWLAN authorization.
There is no automatic BLE-write or association retry. A clean host cannot
obtain the provider from BLE and the product does not download firmware during
connection. Windows/Linux Quick Connect adapters are not planned while that
diff --git a/plugins/xgrids-k1/frontend/README.md b/plugins/xgrids-k1/frontend/README.md
index 8dc7c95..1cc1ca8 100644
--- a/plugins/xgrids-k1/frontend/README.md
+++ b/plugins/xgrids-k1/frontend/README.md
@@ -6,17 +6,23 @@ generic application source tree.
The contribution contains:
-- `K1ProvisioningPipeline` for power confirmation, BLE discovery and the three
- explicit local connection directions: Bridge, Quick Connect and Direct
- Connect;
+- `K1ProvisioningPipeline` for explicit BLE discovery and the three local
+ connection directions: Bridge, Quick Connect and Direct Connect;
- `K1AcquisitionPipeline` for explicit canonical connection/workspace/project/
START checkpoints, local receiver preparation and compatibility file replay;
- `K1SpatialControls` for an explicit no-retry STOP followed by the separate
READY plus steady-green completion gate;
- plugin-local diagnostics, metrics, API state, lifecycle mapping,
observation-source mapping and scoped styles;
-- typed v0.6.0 local-network and interactive application-control state plus legacy shadow
+- typed v0.7.0 supervised connection lifecycle and interactive application-control state plus legacy shadow
inspection contracts;
+- a click-correlated, non-secret provisioning presentation latch: after Apply,
+ Steps 01–02 keep their selected-device/form anatomy with disabled controls
+ until the exact connection attempt becomes reachable or reaches bounded
+ recovery; the Wi-Fi password is cleared before asynchronous dispatch;
+- policy-gated retirement of an unavailable historical K1 as an explicit
+ local ledger action; it never emits a device command and never bypasses the
+ public `retire-unavailable-physical-target` decision;
- `plugin.ts`, which binds the manifest `device.connection` component key to
the runtime provider and connection view.
diff --git a/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx b/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx
index 3036792..aa13fe9 100644
--- a/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx
+++ b/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx
@@ -1,31 +1,264 @@
-import { Button, StatusBadge, type StatusTone } from "@nodedc/ui-react";
+import { useEffect, useRef, useState } from "react";
+import { StatusBadge, type StatusTone } from "@nodedc/ui-react";
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
+import {
+ activeStreamRecoveryPresentation,
+ suppressGenericErrorDuringActiveStreamRecovery,
+} from "./activeStreamRecovery";
import { K1AcquisitionPipeline } from "./components/K1AcquisitionPipeline";
import { K1Diagnostics } from "./components/K1Diagnostics";
import { K1Metrics } from "./components/K1Metrics";
-import { K1ProvisioningPipeline } from "./components/K1ProvisioningPipeline";
+import { K1OperatorError } from "./components/K1OperatorError";
import {
+ K1ProvisioningPipeline,
+ unavailablePhysicalRetirementAuthority,
+} from "./components/K1ProvisioningPipeline";
+import {
+ backendConnectionTopology,
+ connectionAttemptForRuntimeError,
+ hasControlAuthority,
isConfirmedLiveState,
+ isPhysicalStopRecoverySettling,
+ isRecoveredPhysicalScanning,
+ isReleasedTerminalAcquisitionFailure,
isSourceRuntimeBusy,
+ readOnlyConnectionObservationTarget,
recoverableAcquisition,
+ requiresCanonicalStopAfterTerminalLocalFailure,
+ requiresReadOnlyPhysicalRecovery,
sourceStatusLabel,
} from "./lifecycle";
-import { localizeRuntimeMessage } from "./messages";
import { phaseLabel, phaseTone } from "./presentation";
-import { useXgridsK1Controller } from "./runtimeContext";
+import {
+ useXgridsK1Controller,
+ type XgridsK1Controller,
+} from "./runtimeContext";
+import type { XgridsK1State } from "./api";
+import {
+ DEFAULT_CONNECTION_MODE,
+ type ConnectionMode,
+} from "./configuration";
+
+export { K1OperatorError };
+
+export function shouldRenderK1GenericRuntimeError(
+ error: string | null | undefined,
+ hasCorrelatedConnectionAttempt: boolean,
+ state: XgridsK1State | null | undefined,
+ errorAction?: string | null,
+): boolean {
+ return Boolean(
+ error
+ && !hasCorrelatedConnectionAttempt
+ && !suppressGenericErrorDuringActiveStreamRecovery(state, errorAction),
+ );
+}
+
+export function physicalRecoveryConnectionDetail(
+ state: XgridsK1State | null | undefined,
+): string | null {
+ if (!requiresReadOnlyPhysicalRecovery(state)) return null;
+ const retirementAvailable = Boolean(
+ unavailablePhysicalRetirementAuthority(state),
+ );
+ const readOnlyVerificationAvailable = Boolean(
+ readOnlyConnectionObservationTarget(state)?.serverBound,
+ );
+ if (retirementAvailable && readOnlyVerificationAvailable) {
+ return "Если прежний K1 снова доступен, проверьте его состояние без изменений: проверка читает состояние и не отправляет START, STOP или настройки сети. Если K1 недоступен постоянно или заменён, его можно локально исключить без связи с устройством.";
+ }
+ if (readOnlyVerificationAvailable) {
+ return "Проверьте состояние прежнего K1 без изменений устройства. Проверка использует сохранённое системой подключение и не отправляет START, STOP или настройки сети.";
+ }
+ if (retirementAvailable) {
+ return "Прежний K1 можно локально исключить без связи с устройством: действие не отправляет START, STOP или настройки сети. После этого можно отдельно выбрать другой K1.";
+ }
+ return "Безопасная сверка прежнего K1 сейчас недоступна. Обновите состояние; новые команды устройству заблокированы.";
+}
+
+function connectionPhaseFallbackLabel(phase: string | null | undefined): string {
+ if (phase === "device_selected") return "Выбор выполнен";
+ if (phase === "connected") return "Сетевой адрес получен";
+ return phaseLabel(phase);
+}
+
+/**
+ * Keep the disconnected connection job focused on its progressive pipeline.
+ * Persisted topology is evidence, not live control authority. Operational
+ * panels return only when they are actionable or required to finish an
+ * already-started lifecycle, especially STOP and recovery.
+ */
+export function shouldRenderK1OperationalPanels(
+ state: XgridsK1State | null | undefined,
+): boolean {
+ return Boolean(
+ hasControlAuthority(state)
+ || state?.source_mode === "live"
+ || state?.source_mode === "replay"
+ || recoverableAcquisition(state)
+ || state?.acquisition?.cleanup_pending === true
+ || requiresCanonicalStopAfterTerminalLocalFailure(state)
+ || isRecoveredPhysicalScanning(state)
+ || isPhysicalStopRecoverySettling(state)
+ || activeStreamRecoveryPresentation(state) !== null
+ );
+}
+
+export function K1ConnectionPipelines({
+ controller,
+ desiredConnectionMode,
+ onDesiredConnectionModeChange,
+ operationalPanelsVisible,
+ openSpatialScene,
+ activateAutomaticSpatialSource,
+ sourceLabel,
+}: {
+ controller: XgridsK1Controller;
+ desiredConnectionMode: ConnectionMode;
+ onDesiredConnectionModeChange: (mode: ConnectionMode) => void | Promise;
+ operationalPanelsVisible: boolean;
+ openSpatialScene: () => void;
+ activateAutomaticSpatialSource: () => void;
+ sourceLabel: string;
+}) {
+ return (
+ <>
+ {operationalPanelsVisible ? : null}
+
+
+
+ {operationalPanelsVisible ? (
+
+
+
+
+ ) : null}
+
+ >
+ );
+}
export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) {
const controller = useXgridsK1Controller();
- const { state, error, refresh, clearError } = controller;
+ const {
+ state,
+ error,
+ errorDiagnostic,
+ errorCorrelation,
+ refresh,
+ clearError,
+ } = controller;
+ const [desiredConnectionMode, setDesiredConnectionMode] = useState(
+ DEFAULT_CONNECTION_MODE,
+ );
+ const desiredModeInitialized = useRef(false);
+ const desiredModeLocallyDirty = useRef(false);
+ const hydratedScenarioResetKey = useRef(null);
const confirmedLive = isConfirmedLiveState(state);
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
const preparedAcquisition = recoverableAcquisition(state)?.state === "prepared";
- const sourceLabel = sourceStatusLabel(state);
- const relevantAcquisitionFailed = state?.source_mode !== "replay" && state?.acquisition?.state === "failed";
+ const activeRecoveryPresentation = activeStreamRecoveryPresentation(state);
+ const sourceLabel = activeRecoveryPresentation?.title ?? sourceStatusLabel(state);
+ const releasedAcquisitionFailure = isReleasedTerminalAcquisitionFailure(state);
+ const physicalRecoveryRequired = requiresReadOnlyPhysicalRecovery(state);
+ const physicalStopRecoverySettling = isPhysicalStopRecoverySettling(state);
+ const recoveredPhysicalScanning = physicalRecoveryRequired
+ && state?.application_control_session?.state === "scanning"
+ && state.application_control_session.can_stop === true;
+ const physicalRecoveryDetail = physicalRecoveryConnectionDetail(state);
+ const correlatedConnectionAttempt = connectionAttemptForRuntimeError(
+ errorCorrelation,
+ state,
+ );
+ const showGenericRuntimeError = shouldRenderK1GenericRuntimeError(
+ error,
+ Boolean(correlatedConnectionAttempt),
+ state,
+ errorCorrelation?.action,
+ );
+ const relevantAcquisitionFailed = state?.source_mode !== "replay"
+ && state?.acquisition?.state === "failed"
+ && !releasedAcquisitionFailure;
+ const projectedPhase = releasedAcquisitionFailure && state?.phase === "error"
+ ? "idle"
+ : state?.phase;
+ const connectionTopology = backendConnectionTopology(state);
+ const effectiveDesiredConnectionMode = desiredModeInitialized.current
+ ? desiredConnectionMode
+ : state?.desired_connection_mode
+ ?? (connectionTopology?.status === "active"
+ ? connectionTopology.connectionMode
+ : DEFAULT_CONNECTION_MODE);
+
+ useEffect(() => {
+ if (!state || desiredModeInitialized.current) return;
+ desiredModeInitialized.current = true;
+ setDesiredConnectionMode(
+ state.desired_connection_mode
+ ?? (connectionTopology?.status === "active"
+ ? connectionTopology.connectionMode
+ : DEFAULT_CONNECTION_MODE),
+ );
+ }, [connectionTopology, state]);
+
+ useEffect(() => {
+ if (!desiredModeInitialized.current) return;
+ const backendDesiredMode = state?.desired_connection_mode;
+ if (!backendDesiredMode) return;
+ const scenarioReset = state?.connection_scenario_reset;
+ const scenarioResetKey = scenarioReset
+ && scenarioReset.revision === state?.desired_connection_mode_revision
+ && scenarioReset.desired_mode === backendDesiredMode
+ && state?.snapshot_runtime_id?.trim()
+ ? `${state.snapshot_runtime_id}:${scenarioReset.revision}`
+ : null;
+ if (scenarioResetKey && hydratedScenarioResetKey.current !== scenarioResetKey) {
+ // The shell emergency reset is an authoritative new backend revision.
+ // It must retire a locally dirty selector too; an older dirty browser
+ // draft cannot keep showing Quick/Direct after canonical Bridge won.
+ hydratedScenarioResetKey.current = scenarioResetKey;
+ desiredModeLocallyDirty.current = false;
+ setDesiredConnectionMode(backendDesiredMode);
+ return;
+ }
+ if (backendDesiredMode === desiredConnectionMode) {
+ desiredModeLocallyDirty.current = false;
+ return;
+ }
+ // Every dropdown gesture is now an explicit backend scenario-reset CAS.
+ // The callback may publish its accepted mode one render before the hook's
+ // authoritative snapshot arrives, so passive polling must not overwrite
+ // that in-flight acknowledgement. Once the backend echoes the exact mode
+ // above, the dirty fence clears and later authoritative changes hydrate it.
+ if (desiredModeLocallyDirty.current) return;
+ setDesiredConnectionMode(backendDesiredMode);
+ }, [
+ desiredConnectionMode,
+ state?.connection_scenario_reset,
+ state?.desired_connection_mode,
+ state?.desired_connection_mode_revision,
+ state?.snapshot_runtime_id,
+ ]);
+
+ const updateDesiredConnectionMode = (mode: ConnectionMode) => {
+ desiredModeLocallyDirty.current = mode !== state?.desired_connection_mode;
+ setDesiredConnectionMode(mode);
+ };
const sourceTone: StatusTone =
- state?.phase === "error" || relevantAcquisitionFailed
+ activeRecoveryPresentation
+ ? activeRecoveryPresentation.tone
+ : projectedPhase === "error" || relevantAcquisitionFailed
? "danger"
: confirmedLive || state?.source_mode === "replay"
? "success"
@@ -34,56 +267,88 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
: "neutral";
const connectionPhaseLabel = sourceRuntimeBusy || preparedAcquisition
? sourceLabel
- : phaseLabel(state?.phase);
+ : activeRecoveryPresentation
+ ? activeRecoveryPresentation.title
+ : physicalStopRecoverySettling
+ ? "Завершение остановки"
+ : recoveredPhysicalScanning
+ ? "Сканирование продолжается"
+ : physicalRecoveryRequired
+ ? "Требуется действие"
+ : projectedPhase === "error"
+ ? connectionPhaseFallbackLabel(projectedPhase)
+ : connectionTopology?.status === "active"
+ ? "Подключение установлено"
+ : connectionTopology?.status === "configured-unverified"
+ ? "Подключение отсутствует"
+ : connectionTopology?.source === "durable"
+ ? "Подключение отсутствует"
+ : connectionTopology?.source === "applied"
+ ? "Подключение отсутствует"
+ : connectionTopology?.source === "last-known"
+ ? "Подключение отсутствует"
+ : connectionPhaseFallbackLabel(projectedPhase);
const connectionPhaseTone = sourceRuntimeBusy || preparedAcquisition
? sourceTone
- : phaseTone(state?.phase);
+ : activeRecoveryPresentation
+ ? activeRecoveryPresentation.tone
+ : physicalRecoveryRequired
+ ? "warning"
+ : projectedPhase === "error"
+ ? phaseTone(projectedPhase)
+ : connectionTopology?.status === "active"
+ ? "success"
+ : connectionTopology?.status === "configured-unverified"
+ ? "neutral"
+ : "neutral";
+ const connectionPhaseDetail = physicalStopRecoverySettling
+ ? "Команда остановки уже принята. Завершение выполняется без повторной команды."
+ : activeRecoveryPresentation
+ ? activeRecoveryPresentation.detail
+ : recoveredPhysicalScanning
+ ? "Локальная запись остановлена, но сканирование ещё продолжается."
+ : physicalRecoveryRequired
+ ? physicalRecoveryDetail
+ ?? "Безопасное восстановление прежнего K1 сейчас недоступно."
+ : !sourceRuntimeBusy && connectionTopology?.status === "configured-unverified"
+ ? "Начните новое подключение."
+ : !sourceRuntimeBusy && connectionTopology?.status === "active"
+ ? "Готово к новой сессии."
+ : "Ожидается состояние локального контура.";
+ const operationalPanelsVisible = shouldRenderK1OperationalPanels(state);
return (
- {error ? (
-
+ {showGenericRuntimeError && error ? (
+
void refresh()}
+ onClear={clearError}
+ />
) : null}
-
XGRIDS K1 · PLUGIN UI
+
ЛОКАЛЬНОЕ ПОДКЛЮЧЕНИЕ
Подключение {model.displayName}
-
BLE/Wi‑Fi provisioning и acquisition pipeline принадлежат этому device plugin; Control Station предоставляет только host slot и переход в пространственную сцену.
+
Выберите способ связи и последовательно установите подключение.
{connectionPhaseLabel}
- {localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}
+ {connectionPhaseDetail}
-
-
-
+
);
}
diff --git a/plugins/xgrids-k1/frontend/src/activeStreamRecovery.ts b/plugins/xgrids-k1/frontend/src/activeStreamRecovery.ts
new file mode 100644
index 0000000..e56c7a3
--- /dev/null
+++ b/plugins/xgrids-k1/frontend/src/activeStreamRecovery.ts
@@ -0,0 +1,278 @@
+import {
+ isXgridsActiveStreamRecovery,
+ type XgridsActiveStreamRecovery,
+ type XgridsK1State,
+} from "./api";
+
+export interface ActiveStreamRecoveryLineage {
+ snapshotRuntimeId: string;
+ acquisitionId: string;
+ acquisitionStateRevision: number;
+ recoveryGeneration: number;
+ runtimeProducerGeneration: number;
+ recovery: XgridsActiveStreamRecovery;
+}
+
+export type ActiveStreamForceFinishAuthority = ActiveStreamRecoveryLineage;
+
+export type ActiveStreamRecoveryPresentationAuthority = ActiveStreamRecoveryLineage;
+
+export type ActiveStreamRecoveryVisibleState =
+ | "reconnecting"
+ | "blocked"
+ | "standby"
+ | "fault";
+
+export interface ActiveStreamRecoveryPresentation {
+ state: ActiveStreamRecoveryVisibleState;
+ eyebrow: string;
+ title: string;
+ statusLabel: string;
+ tone: "neutral" | "warning" | "danger";
+ detail: string;
+ progressLabel: string | null;
+ showSpinner: boolean;
+ forceFinishAvailable: boolean;
+}
+
+function positiveInteger(value: unknown): value is number {
+ return Number.isInteger(value) && (value as number) > 0;
+}
+
+/**
+ * Resolve one exact active-stream lineage from the public runtime snapshot.
+ *
+ * A recovery-shaped object alone is not authority. The browser also requires
+ * the current runtime id, the same acquisition id and the exact producer
+ * generation on both sides of the projection. This keeps a late recovery
+ * update from an older producer out of both presentation and mutation gates.
+ */
+export function exactActiveStreamRecoveryLineage(
+ state: XgridsK1State | null | undefined,
+): ActiveStreamRecoveryLineage | null {
+ const recovery = state?.connection_recovery;
+ const acquisition = state?.acquisition;
+ const snapshotRuntimeId = state?.snapshot_runtime_id?.trim() || null;
+ const producerGeneration = state?.producer_generation;
+ const acquisitionId = acquisition?.acquisition_id?.trim() || null;
+ const recoveryAcquisitionId = recovery?.acquisition_id?.trim() || null;
+ if (
+ !snapshotRuntimeId
+ || !isXgridsActiveStreamRecovery(recovery)
+ || !acquisition
+ || !acquisitionId
+ || recoveryAcquisitionId !== acquisitionId
+ || !positiveInteger(acquisition.state_revision)
+ || !positiveInteger(recovery.generation)
+ || !positiveInteger(producerGeneration)
+ || recovery.runtime_producer_generation !== producerGeneration
+ || recovery.automatic_read_only_rebind !== true
+ ) return null;
+ return {
+ snapshotRuntimeId,
+ acquisitionId,
+ acquisitionStateRevision: acquisition.state_revision,
+ recoveryGeneration: recovery.generation,
+ runtimeProducerGeneration: producerGeneration,
+ recovery,
+ };
+}
+
+/** Exact, current and backend-policy-admitted authority for local-only finish. */
+export function activeStreamForceFinishAuthority(
+ state: XgridsK1State | null | undefined,
+): ActiveStreamForceFinishAuthority | null {
+ const lineage = exactActiveStreamRecoveryLineage(state);
+ if (
+ !lineage
+ || !["reconnecting", "blocked"].includes(lineage.recovery.state)
+ || lineage.recovery.force_finish_allowed !== true
+ || state?.phase !== "reconnecting"
+ || state.source_mode !== "live"
+ || ![
+ "starting",
+ "awaiting_external_start",
+ "acquiring",
+ ].includes(state.acquisition?.state ?? "")
+ ) return null;
+ return lineage;
+}
+
+/**
+ * Exact authority for retaining browser presentation while the backend owns a
+ * read-only reconnect. This is deliberately narrower than the recovery card:
+ * terminal/blocked recovery states and an inactive acquisition cannot retain
+ * a prior spatial or camera transport.
+ */
+export function activeStreamRecoveryPresentationAuthority(
+ state: XgridsK1State | null | undefined,
+): ActiveStreamRecoveryPresentationAuthority | null {
+ const lineage = exactActiveStreamRecoveryLineage(state);
+ if (
+ !lineage
+ || lineage.recovery.state !== "reconnecting"
+ || state?.phase !== "reconnecting"
+ || state.source_mode !== "live"
+ || ![
+ "starting",
+ "awaiting_external_start",
+ "acquiring",
+ ].includes(state.acquisition?.state ?? "")
+ ) return null;
+ return lineage;
+}
+
+/**
+ * Keep the exact recovered lineage available to disposable browser receivers
+ * after the recovery card has disappeared. Spatial admission can complete on
+ * the first authoritative PCL before the acquisition-owned camera produces
+ * its first playable frame. This authority carries only the no-write
+ * presentation lease: it grants neither force-finish nor START/STOP policy.
+ */
+export function activeStreamRecoveredBrowserAuthority(
+ state: XgridsK1State | null | undefined,
+): ActiveStreamRecoveryPresentationAuthority | null {
+ const lineage = exactActiveStreamRecoveryLineage(state);
+ if (
+ !lineage
+ || lineage.recovery.state !== "recovered"
+ || lineage.recovery.camera_recovery !== "owned"
+ || state?.phase !== "live"
+ || state.source_mode !== "live"
+ || state.acquisition?.state !== "acquiring"
+ ) return null;
+ return lineage;
+}
+
+/**
+ * While a validated recovery contract is active it owns the presentation
+ * decision. Ordinary supervisor data flags may be stale across the network
+ * gap, so only an exact reconnect lease can retain browser transports.
+ */
+export function activeStreamRecoveryOwnsPresentationDecision(
+ state: XgridsK1State | null | undefined,
+): boolean {
+ const recovery = state?.connection_recovery;
+ return Boolean(
+ isXgridsActiveStreamRecovery(recovery)
+ && !["inactive", "recovered"].includes(recovery.state),
+ );
+}
+
+export function activeStreamForceFinishAuthorityMatches(
+ expected: ActiveStreamForceFinishAuthority,
+ state: XgridsK1State | null | undefined,
+): boolean {
+ const current = activeStreamForceFinishAuthority(state);
+ return Boolean(
+ current
+ && current.snapshotRuntimeId === expected.snapshotRuntimeId
+ && current.acquisitionId === expected.acquisitionId
+ && current.acquisitionStateRevision === expected.acquisitionStateRevision
+ && current.recoveryGeneration === expected.recoveryGeneration
+ && current.runtimeProducerGeneration === expected.runtimeProducerGeneration,
+ );
+}
+
+export function formatActiveStreamRecoveryElapsed(
+ elapsedMs: number | null,
+): string | null {
+ if (!Number.isFinite(elapsedMs) || elapsedMs === null || elapsedMs < 0) return null;
+ const elapsedSeconds = Math.floor(elapsedMs / 1_000);
+ if (elapsedSeconds < 60) return `${elapsedSeconds} с`;
+ const minutes = Math.floor(elapsedSeconds / 60);
+ const seconds = elapsedSeconds % 60;
+ return seconds > 0 ? `${minutes} мин ${seconds} с` : `${minutes} мин`;
+}
+
+function recoveryProgressLabel(
+ recovery: XgridsActiveStreamRecovery,
+): string | null {
+ const elapsed = formatActiveStreamRecoveryElapsed(recovery.elapsed_ms);
+ const attempt = recovery.attempt > 0
+ ? `Попытка ${recovery.attempt}`
+ : "Подготовка проверки";
+ return elapsed ? `${attempt} · ${elapsed}` : attempt;
+}
+
+/**
+ * Present only an exact current lineage. `recovered` deliberately returns
+ * null so the ordinary confirmed live UI resumes without a transitional card.
+ */
+export function activeStreamRecoveryPresentation(
+ state: XgridsK1State | null | undefined,
+): ActiveStreamRecoveryPresentation | null {
+ const lineage = exactActiveStreamRecoveryLineage(state);
+ if (!lineage) return null;
+ const recovery = lineage.recovery;
+ if (recovery.state === "reconnecting") {
+ return {
+ state: "reconnecting",
+ eyebrow: "СВЯЗЬ · АКТИВНЫЙ ПРИЁМ",
+ title: "Восстанавливаем соединение",
+ statusLabel: "Восстановление связи",
+ tone: "neutral",
+ detail: "Проверяем прежний активный контур только для чтения. START, STOP и настройки сети не отправляются.",
+ progressLabel: recoveryProgressLabel(recovery),
+ showSpinner: true,
+ forceFinishAvailable: activeStreamForceFinishAuthority(state) !== null,
+ };
+ }
+ if (recovery.state === "blocked") {
+ return {
+ state: "blocked",
+ eyebrow: "СВЯЗЬ · ТРЕБУЕТСЯ ДЕЙСТВИЕ",
+ title: recovery.camera_recovery === "blocked"
+ ? "Видеопоток не восстановлен"
+ : "Связь не восстановлена",
+ statusLabel: "Восстановление остановлено",
+ tone: "warning",
+ detail: recovery.camera_recovery === "blocked"
+ ? "Связь с K1 проверена, но камера не возобновила передачу. Можно завершить только локальный приём."
+ : "Автоматическая проверка остановлена. Можно завершить только локальный приём; команда устройству не отправится.",
+ progressLabel: recoveryProgressLabel(recovery),
+ showSpinner: false,
+ forceFinishAvailable: activeStreamForceFinishAuthority(state) !== null,
+ };
+ }
+ if (recovery.state === "standby") {
+ return {
+ state: "standby",
+ eyebrow: "СВЯЗЬ · СОСТОЯНИЕ ПРОВЕРЕНО",
+ title: "Устройство перешло в ожидание",
+ statusLabel: "Приём завершён",
+ tone: "neutral",
+ detail: "K1 сообщил, что активное сканирование уже завершено. Локальный приём закрывается без команды STOP.",
+ progressLabel: recoveryProgressLabel(recovery),
+ showSpinner: false,
+ forceFinishAvailable: false,
+ };
+ }
+ if (recovery.state === "fault") {
+ return {
+ state: "fault",
+ eyebrow: "СВЯЗЬ · СОСТОЯНИЕ ПРОВЕРЕНО",
+ title: "K1 сообщил об ошибке",
+ statusLabel: "Восстановление невозможно",
+ tone: "danger",
+ detail: "Безопасная проверка обнаружила ошибку устройства. Автоматических команд и повторов нет.",
+ progressLabel: recoveryProgressLabel(recovery),
+ showSpinner: false,
+ forceFinishAvailable: false,
+ };
+ }
+ return null;
+}
+
+/**
+ * Only an exact, still-active background reconnect may hide the generic red
+ * error banner. A failed explicit local finish is operator-facing evidence and
+ * must remain visible even while the last accepted snapshot says reconnecting.
+ */
+export function suppressGenericErrorDuringActiveStreamRecovery(
+ state: XgridsK1State | null | undefined,
+ errorAction?: string | null,
+): boolean {
+ if (errorAction === "force-finish") return false;
+ return activeStreamRecoveryPresentationAuthority(state) !== null;
+}
diff --git a/plugins/xgrids-k1/frontend/src/api.ts b/plugins/xgrids-k1/frontend/src/api.ts
index 639cc98..96a55e8 100644
--- a/plugins/xgrids-k1/frontend/src/api.ts
+++ b/plugins/xgrids-k1/frontend/src/api.ts
@@ -13,6 +13,115 @@ export interface BleDevice {
}
export type SourceMode = "idle" | "live" | "replay";
+export const XGRIDS_ACTIVE_STREAM_RECOVERY_STATES = [
+ "inactive",
+ "reconnecting",
+ "blocked",
+ "recovered",
+ "standby",
+ "fault",
+ "force-finishing",
+ "force-finished",
+] as const;
+export type XgridsActiveStreamRecoveryState =
+ typeof XGRIDS_ACTIVE_STREAM_RECOVERY_STATES[number];
+export const XGRIDS_CAMERA_RECOVERY_STATES = [
+ "inactive",
+ "owned",
+ "blocked",
+] as const;
+export type XgridsCameraRecoveryState =
+ typeof XGRIDS_CAMERA_RECOVERY_STATES[number];
+export const XGRIDS_CAMERA_MEDIA_STATES = [
+ "inactive",
+ "pending-epoch",
+ "pending-init",
+ "pending-first-media",
+ "ready",
+] as const;
+export type XgridsCameraMediaState =
+ typeof XGRIDS_CAMERA_MEDIA_STATES[number];
+export const XGRIDS_CONNECTION_MODES = [
+ "bridge",
+ "quick-connect",
+ "direct-connect",
+] as const;
+export type XgridsConnectionMode = typeof XGRIDS_CONNECTION_MODES[number];
+
+export const XGRIDS_HOST_DIAGNOSTIC_CODES = [
+ "host.bluetooth.permission-denied",
+ "host.bluetooth.adapter-powered-off",
+ "host.bluetooth.adapter-unavailable",
+ "host.bluetooth.runtime-unavailable",
+ "host.bluetooth.operation-timeout",
+ "host.wifi.permission-denied",
+ "host.wifi.adapter-powered-off",
+ "host.wifi.interface-unavailable",
+ "host.wifi.ssid-unavailable",
+ "host.wifi.operation-timeout",
+ "host.wifi.association-failed",
+ "host.keychain.interaction-required",
+ "host.keychain.permission-denied",
+ "host.keychain.unavailable",
+ "host.route.unavailable",
+ "host.tcp.connection-refused",
+ "host.tcp.connection-timeout",
+ "host.tcp.endpoint-unavailable",
+ "host.mqtt.connection-timeout",
+ "host.mqtt.connection-refused",
+ "host.mqtt.transport-unavailable",
+ "host.filesystem.permission-denied",
+ "host.filesystem.ledger-unavailable",
+] as const;
+
+export const XGRIDS_HOST_DIAGNOSTIC_DOMAINS = [
+ "corebluetooth",
+ "corewlan",
+ "keychain",
+ "route",
+ "tcp",
+ "mqtt",
+ "filesystem",
+] as const;
+
+export const XGRIDS_HOST_DIAGNOSTIC_IMPACTS = [
+ "discovery",
+ "host-network",
+ "control",
+ "durable-safety",
+] as const;
+
+export const XGRIDS_HOST_DIAGNOSTIC_ACTIONS = [
+ "grant-bluetooth-permission",
+ "power-on-bluetooth",
+ "restore-bluetooth-adapter",
+ "grant-wifi-permission",
+ "power-on-wifi",
+ "restore-wifi-interface",
+ "unlock-or-authorize-keychain",
+ "review-keychain-access",
+ "join-expected-network",
+ "inspect-host-route",
+ "verify-broker-endpoint",
+ "inspect-local-storage",
+ "restart-local-service",
+ "explicit-retry",
+] as const;
+
+export type XgridsHostDiagnosticCode = typeof XGRIDS_HOST_DIAGNOSTIC_CODES[number];
+export type XgridsHostDiagnosticDomain = typeof XGRIDS_HOST_DIAGNOSTIC_DOMAINS[number];
+export type XgridsHostDiagnosticImpact = typeof XGRIDS_HOST_DIAGNOSTIC_IMPACTS[number];
+export type XgridsHostDiagnosticAction = typeof XGRIDS_HOST_DIAGNOSTIC_ACTIONS[number];
+
+export interface XgridsHostFailureDiagnostic {
+ schema_version: "missioncore.host-failure-diagnostic/v1";
+ code: XgridsHostDiagnosticCode;
+ domain: XgridsHostDiagnosticDomain;
+ impact: XgridsHostDiagnosticImpact;
+ operator_action: XgridsHostDiagnosticAction;
+ automatic_retry: false;
+ redacted: true;
+}
export type AcquisitionState =
| "preparing"
@@ -58,43 +167,385 @@ export interface XgridsDeviceSession {
connectivity?: "unknown" | "offline" | "connecting" | "connected" | "degraded";
}
+export const XGRIDS_CONNECTION_VERIFICATION_STATUSES = [
+ "not-probed",
+ "device-network-applied",
+ "device-network-applied-host-failed",
+ "adopted",
+ "host-route-mismatch",
+ "endpoint-unreachable",
+ "tcp-reachable-device-info-unverified",
+ "reachable",
+ "recovered",
+ "control-transport-lost",
+ "unreachable",
+] as const;
+
+export const XGRIDS_CONNECTION_VERIFICATION_LEASE_STATES = [
+ "disconnected",
+ "configured-unverified",
+ "reachable",
+] as const;
+
+export const XGRIDS_CONNECTION_NETWORK_REACHABILITY = [
+ "unknown",
+ "reachable",
+ "unreachable",
+] as const;
+
+export type XgridsConnectionVerificationStatus =
+ typeof XGRIDS_CONNECTION_VERIFICATION_STATUSES[number];
+export type XgridsConnectionVerificationLeaseState =
+ typeof XGRIDS_CONNECTION_VERIFICATION_LEASE_STATES[number];
+export type XgridsConnectionNetworkReachability =
+ typeof XGRIDS_CONNECTION_NETWORK_REACHABILITY[number];
+
export interface XgridsConnectionVerification {
- status?:
- | "not-probed"
- | "configured"
- | "live-address-observed"
- | "reachable"
- | "recovered"
- | "unreachable";
- lease_state?: "disconnected" | "configured" | "reachable";
- lease_generation?: number;
- endpoint_validation?: string | null;
- network_reachability?:
- | "unknown"
- | "not-probed"
- | "reachable"
- | "degraded"
- | "unreachable";
+ status: XgridsConnectionVerificationStatus;
+ lease_state: XgridsConnectionVerificationLeaseState;
+ lease_generation: number;
+ supervisor_revision: number;
+ endpoint_validation: string | null;
+ network_reachability: XgridsConnectionNetworkReachability;
+ host_route_class?: string | null;
+ address_source?: string | null;
+ connection_origin?: string | null;
+ admission_source?: string | null;
address_changed?: boolean;
previous_address_present?: boolean;
write_performed?: boolean;
- observed_at?: string | null;
+ observed_at: string | null;
+ last_known_reachable_at?: string | null;
reason_code?: string | null;
}
+export interface XgridsConfiguredEndpointProbe {
+ schema_version: "missioncore.xgrids-k1-configured-endpoint-probe/v1";
+ status: "not-probed" | "reachable" | "endpoint-unreachable" | "host-route-unavailable";
+ target_source: "current-supervisor" | "durable-semantic-topology" | null;
+ connection_mode: XgridsConnectionMode | null;
+ endpoint: string | null;
+ transport_ref: string | null;
+ intent_id: string | null;
+ semantic_revision: number | null;
+ host_route_available: boolean | null;
+ host_route_class: string | null;
+ tcp_reachable: boolean | null;
+ identity_validation: "not-performed";
+ control_authority_granted: false;
+ ble_operation_performed: false;
+ network_mutation_performed: false;
+ automatic_retry: false;
+ observed_at: string | null;
+ reason_code: string | null;
+}
+
export interface XgridsNetworkWriteReconciliation {
- status: "device-state-unknown-after-write";
+ status:
+ | "prepared-before-dispatch"
+ | "device-state-unknown-after-write"
+ | "durable-ledger-corrupt";
operation_id: string;
transport_ref: string;
- connection_mode: "bridge" | "quick-connect" | "direct-connect";
- operation_stage: string;
+ connection_mode: XgridsConnectionMode;
+ operation_stage: "prepared" | "dispatching" | "observing" | "ledger-corrupt";
reason_code: string;
device_write_confirmed: boolean;
- required_action: "explicit-read-only-ble-status-observation";
- scope: "process-runtime";
+ required_action:
+ | "restart-service-to-resolve-prepared"
+ | "explicit-read-only-ble-status-observation"
+ | "operator-ledger-diagnosis";
+ scope: "durable-ledger";
+ ledger_revision: number | null;
+ observed_at: string | null;
+}
+
+export interface XgridsNetworkMutationLedger {
+ status: "empty" | "unresolved" | "resolved" | "corrupt";
+ mutation_allowed: boolean;
+ reason_code: string | null;
+ operation_id: string | null;
+ transport_ref: string | null;
+ intended_mode: XgridsConnectionMode | null;
+ stage: "prepared" | "dispatching" | "observing" | "resolved" | null;
+ revision: number | null;
+ resolution:
+ | "not-dispatched"
+ | "target-observed"
+ | "interrupted"
+ | "superseded"
+ | null;
+ updated_at_utc: string | null;
+ diagnostic?: XgridsHostFailureDiagnostic | null;
+}
+
+export interface XgridsCurrentDeviceRecovery {
+ transport_ref?: string | null;
+ connection_mode?: XgridsConnectionMode | null;
+ handle_available?: boolean;
+ handle_retained?: boolean;
+ advertised_now?: boolean;
+ gatt_validated_recently?: boolean;
+ observed_at?: string | null;
+}
+
+export const XGRIDS_CONNECTION_POLICY_ACTIONS = [
+ "scan-ble",
+ "provision-fresh-device",
+ "prepare-select-device",
+ "prepare-change-network",
+ "cancel-reconfiguration",
+ "recover-current-device-network",
+ "observe-fresh-device-network",
+ "observe-current-device-network",
+ "observe-configured-device-network",
+ "inspect-configured-endpoint",
+ "inspect-host-network",
+ "probe-endpoint",
+ "verify-control-device-info",
+ "start-acquisition",
+ "stop-acquisition",
+ "stop-local-receiver",
+ "retire-unavailable-physical-target",
+ "acknowledge-data-loss",
+] as const;
+export type XgridsConnectionPolicyAction =
+ typeof XGRIDS_CONNECTION_POLICY_ACTIONS[number];
+
+export const XGRIDS_CONNECTION_POLICY_TARGET_SOURCES = [
+ "none",
+ "fresh-scan",
+ "retained-current-process",
+ "durable-configured-state",
+ "configured-topology",
+ "connection-supervisor",
+ "local-runtime",
+ "local-prestart-handoff",
+ "local-reconfiguration-intent",
+ "durable-physical-command",
+] as const;
+export type XgridsConnectionPolicyTargetSource =
+ typeof XGRIDS_CONNECTION_POLICY_TARGET_SOURCES[number];
+
+export interface XgridsConnectionPolicyDecision {
+ allowed: boolean;
+ reason_codes: string[];
+ target_source: XgridsConnectionPolicyTargetSource;
+ required_transport_ref: string | null;
+ required_connection_mode?: XgridsConnectionMode | null;
+ requires_live_gatt_validation: boolean;
+ automatic_retry: false;
+ execution_mode?: "capture-only";
+ physical_command_allowed?: false;
+ physical_outcome?: "unknown";
+ operator_follow_up?: "manual-device-stop-required";
+ device_write_performed?: false;
+}
+
+export interface XgridsConnectionPolicy {
+ schema_version: "missioncore.xgrids-k1-connection-policy/v1";
+ supervisor_revision: number;
+ network_ledger_revision: number | null;
+ recommended_action: string;
+ allowed_actions: XgridsConnectionPolicyAction[];
+ actions: Partial>;
+ facts: {
+ fresh_transport_refs: string[];
+ retained_transport_ref: string | null;
+ retained_context_is_presence: false;
+ network_mutation_status: "empty" | "unresolved" | "resolved" | "corrupt";
+ network_provisioning_idempotency_status: "empty" | "ready" | "blocked" | "corrupt";
+ network_provisioning_idempotency_available: boolean;
+ network_provisioning_active_operation_id: string | null;
+ network_provisioning_active_operation_matches_ledger: boolean;
+ semantic_topology_store_status: "empty" | "available" | "corrupt";
+ device_identity_pin_store_status: "empty" | "available" | "corrupt";
+ physical_command_status: "empty" | "unresolved" | "resolved" | "corrupt";
+ physical_command_requires_reconciliation: boolean;
+ retired_transport_refs?: string[];
+ eligible_fresh_transport_refs?: string[];
+ ble_runtime: {
+ active_operation_kind: string | null;
+ cleanup_pending: boolean;
+ poisoned: boolean;
+ };
+ lifecycle_process_lease_holders: Array<"control" | "network">;
+ control_plane_state: "idle" | "healthy" | "stalled" | "lost";
+ data_plane_state: "idle" | "healthy" | "stalled" | "lost";
+ physical_network_state: "unknown" | "not-disputed";
+ };
+}
+
+export const XGRIDS_CONNECTION_RECONFIGURATION_INTENTS = [
+ "select-device",
+ "change-network",
+] as const;
+export type XgridsConnectionReconfigurationIntent =
+ typeof XGRIDS_CONNECTION_RECONFIGURATION_INTENTS[number];
+
+export const XGRIDS_CONNECTION_RECONFIGURATION_STATUSES = [
+ "idle",
+ "awaiting-fresh-scan",
+ "fresh-scan-completed",
+] as const;
+export type XgridsConnectionReconfigurationStatus =
+ typeof XGRIDS_CONNECTION_RECONFIGURATION_STATUSES[number];
+
+export interface XgridsConnectionReconfiguration {
+ schema_version: "missioncore.xgrids-k1-connection-reconfiguration/v1";
+ revision: number;
+ intent_id: string | null;
+ intent: XgridsConnectionReconfigurationIntent | null;
+ status: XgridsConnectionReconfigurationStatus;
+ required_transport_ref: string | null;
+ required_connection_mode: XgridsConnectionMode | null;
+ minimum_discovery_generation: number | null;
+ fresh_discovery_generation: number | null;
+ required_transport_observed: boolean | null;
+ prepared_at: string | null;
+ automatic_retry: false;
+}
+
+export interface XgridsConnectionSupervisorTarget {
+ ipv4: string;
+ port: number;
+}
+
+export interface XgridsConnectionSupervisorIntent {
+ intent_id: string;
+ requested_mode: XgridsConnectionMode;
+ expected_device_id: string | null;
+ requested_at: string;
+}
+
+export interface XgridsConnectionSupervisorHostPath {
+ epoch: number;
+ available: boolean;
+ fingerprint: string | null;
+ interface: string | null;
+ source_ipv4: string | null;
+ route_class: "direct" | "default" | "tunnel" | "unavailable" | "unknown";
+ reason_code: string | null;
observed_at: string;
}
+export interface XgridsConnectionSupervisorEndpoint {
+ target: XgridsConnectionSupervisorTarget | null;
+ tcp_state: "unknown" | "reachable" | "unreachable";
+ intent_id: string | null;
+ host_path_epoch: number | null;
+ reason_code: string | null;
+ observed_at: string | null;
+}
+
+export interface XgridsConnectionSupervisorDeviceNetwork {
+ state: "unconfigured" | "applied";
+ intent_id: string | null;
+ transport_ref: string | null;
+ connection_mode: XgridsConnectionMode | null;
+ target: XgridsConnectionSupervisorTarget | null;
+ source: "ble-post-write-status" | "ble-read-only-status" | null;
+ observed_at: string | null;
+}
+
+export interface XgridsConnectionSupervisorDeviceIdentity {
+ state: "unverified" | "verified" | "stale" | "mismatch";
+ intent_id: string | null;
+ logical_device_id: string | null;
+ compatibility_profile_id: string | null;
+ connection_mode: XgridsConnectionMode | null;
+ source: "mqtt-device-info" | null;
+ host_path_epoch: number | null;
+ observed_at: string | null;
+}
+
+export interface XgridsConnectionSupervisorPlane {
+ state: "idle" | "healthy" | "stalled" | "lost";
+ session_id: string | null;
+ host_path_epoch: number | null;
+ reason_code: string | null;
+ observed_at: string | null;
+}
+
+export interface XgridsConnectionSupervisorLease {
+ state: "absent" | "configured-unverified" | "reachable" | "lost";
+ generation: number;
+ intent_id: string | null;
+ host_path_epoch: number | null;
+ connection_mode: XgridsConnectionMode | null;
+ target: XgridsConnectionSupervisorTarget | null;
+ logical_device_id: string | null;
+ reason_code: string | null;
+ observed_at: string | null;
+}
+
+export interface XgridsConnectionSupervisorAuthority {
+ network_mutation_allowed: boolean;
+ control_allowed: boolean;
+ acquisition_start_allowed: boolean;
+ data_ingest_authoritative: boolean;
+ physical_motion_allowed: false;
+ reason_codes: string[];
+}
+
+export interface XgridsConnectionSupervisorLastKnown {
+ connection_mode: XgridsConnectionMode;
+ target: XgridsConnectionSupervisorTarget;
+ logical_device_id: string;
+ compatibility_profile_id: string;
+ verified_at: string;
+}
+
+export type XgridsConnectionSupervisorAllowedAction =
+ | "select-connection-intent"
+ | "inspect-host-network"
+ | "probe-endpoint"
+ | "verify-control-device-info"
+ | "start-acquisition"
+ | "stop-acquisition"
+ | "stop-local-receiver"
+ | "acknowledge-data-loss";
+
+export interface XgridsConnectionSupervisor {
+ schema_version: "missioncore.k1-connection-supervisor/v1";
+ revision: number;
+ closed: boolean;
+ intent: XgridsConnectionSupervisorIntent | null;
+ observed: {
+ device_network: XgridsConnectionSupervisorDeviceNetwork;
+ host_path: XgridsConnectionSupervisorHostPath;
+ endpoint: XgridsConnectionSupervisorEndpoint;
+ device_identity: XgridsConnectionSupervisorDeviceIdentity;
+ control_plane: XgridsConnectionSupervisorPlane;
+ data_plane: XgridsConnectionSupervisorPlane;
+ };
+ lease: XgridsConnectionSupervisorLease;
+ authority: XgridsConnectionSupervisorAuthority;
+ last_known: XgridsConnectionSupervisorLastKnown | null;
+ diagnostics?: XgridsHostFailureDiagnostic[];
+ allowed_actions: XgridsConnectionSupervisorAllowedAction[];
+}
+
+export interface XgridsSemanticTopologyRecord {
+ schema_version: "missioncore.xgrids-k1-semantic-topology/v1";
+ revision: number;
+ transport_ref: string;
+ connection_mode: XgridsConnectionMode;
+ ipv4: string;
+ compatibility_profile_id: string;
+ firmware_version: string;
+ source: "ble-post-write-status" | "ble-read-only-status";
+ observed_at_utc: string;
+}
+
+export interface XgridsSemanticTopologyStore {
+ status: "empty" | "available" | "corrupt";
+ configured_offline_evidence: boolean;
+ live_connection_authority: false;
+ reason_code: string | null;
+ record: XgridsSemanticTopologyRecord | null;
+}
+
export interface XgridsCompatibilityState {
profile_id?: string | null;
decision?: "compatible" | "limited" | "unknown" | "incompatible";
@@ -142,10 +593,54 @@ export interface XgridsApplicationControlExecution {
can_emit_requests: false;
}
+export interface XgridsPhysicalCommandState {
+ status: "empty" | "unresolved" | "resolved" | "corrupt";
+ reason_code: string | null;
+ requires_reconciliation: boolean;
+ resolved_active_recovery_required?: boolean;
+ automatic_replay_allowed: false;
+ normal_session_recovery_supported: false;
+ recovery_requirement?: string | null;
+ runtime_bound: boolean;
+ reconciliation_ready: boolean;
+ observed_session_state?: "ready" | "scanning" | string | null;
+ active_operation_id?: string | null;
+ operator_retirement?: {
+ allowed: boolean;
+ reason_codes: string[];
+ expected_operation_id: string | null;
+ expected_revision: number | null;
+ expected_transport_ref: string | null;
+ physical_outcome: "unknown";
+ device_io_performed: false;
+ automatic_retry: false;
+ } | null;
+ operator_retirement_allowed?: boolean;
+ operator_retirement_reason_codes?: string[];
+ operator_reconciliation_reopen?: {
+ allowed: boolean;
+ reason_codes: string[];
+ expected_revision: number | null;
+ expected_retirement_id: string | null;
+ expected_transport_ref: string | null;
+ expected_discovery_generation: number | null;
+ expected_desired_mode: XgridsConnectionMode;
+ expected_desired_mode_revision: number;
+ device_io_performed: false;
+ automatic_retry: false;
+ } | null;
+ record?: (Record & {
+ action?: "start" | "stop";
+ stage?: string;
+ resolution?: string | null;
+ }) | null;
+}
+
export type XgridsApplicationControlPhase =
| "idle"
| "connecting"
| "connection-ready"
+ | "active-recovery-requested"
| "workspace-requested"
| "workspace-ready"
| "project-requested"
@@ -162,6 +657,10 @@ export type XgridsApplicationControlPhase =
export interface XgridsApplicationControlSession {
mode: "interactive-canonical";
+ inspection_only?: boolean;
+ inspection_promotion_allowed?: boolean;
+ session_generation: number;
+ state_revision: number;
state: XgridsApplicationControlPhase;
control_socket_open: boolean;
can_open: boolean;
@@ -222,9 +721,27 @@ export interface XgridsApplicationControlSession {
automatic_retry?: false;
} | null;
safe_to_retry?: boolean;
+ host_diagnostic?: XgridsHostFailureDiagnostic;
} | null;
dialogue?: Record | null;
transport?: Record | null;
+ verified_control?: {
+ logical_device_id: string;
+ compatibility_profile_id: string;
+ control_session_id: string;
+ source: "mqtt-device-info";
+ intent_id: string;
+ transport_ref: string;
+ host_path_epoch: number;
+ target_ipv4: string;
+ target_port: number;
+ connection_mode: XgridsConnectionMode;
+ control_proof_revision: number;
+ control_proof_source: string;
+ control_proof_fresh: boolean;
+ control_proof_age_seconds?: number | null;
+ } | null;
+ physical_command?: XgridsPhysicalCommandState | null;
}
export interface XgridsAcquisition {
@@ -269,8 +786,70 @@ export interface XgridsOperation {
cancellable?: boolean;
cancel_requested?: boolean;
result?: Record | null;
- error?: Record | null;
+ error?: (Record & {
+ host_diagnostic?: XgridsHostFailureDiagnostic;
+ }) | null;
evidence_refs?: string[];
+ context?: Record;
+ events?: XgridsOperationEvent[];
+}
+
+export interface XgridsOperationEvent {
+ schema_version: "missioncore.operation-event/v1";
+ sequence: number;
+ status: OperationStatus;
+ stage_code: string;
+ message_code: string;
+ observed_at: string;
+ side_effect_status: string | null;
+ error_code: string | null;
+ safe_to_retry: boolean | null;
+ automatic_retry: false;
+}
+
+export interface XgridsConnectionDiagnosticBundle {
+ schema_version: "missioncore.xgrids-k1-connection-diagnostic/v1";
+ redacted: true;
+ generated_at_utc: string;
+ snapshot_runtime_id: string;
+ attempt: Omit;
+ network_mutation_ledger: XgridsNetworkMutationLedger;
+ connection_supervisor: Record;
+ automatic_retry: false;
+}
+
+export const XGRIDS_CONNECTION_ATTEMPT_PHASES = [
+ "network_applied",
+ "network_not_applied",
+ "network_outcome_unknown",
+] as const;
+export type XgridsConnectionAttemptPhase =
+ typeof XGRIDS_CONNECTION_ATTEMPT_PHASES[number];
+
+export interface XgridsConnectionAttempt {
+ schema_version: "missioncore.xgrids-k1-connection-attempt/v1";
+ attempt_id: string;
+ connection_mode: XgridsConnectionMode;
+ status: OperationStatus;
+ stage: string;
+ public_error_code: string | null;
+ side_effect_status: string;
+ phase: XgridsConnectionAttemptPhase;
+ control_state: "ready" | "control_not_ready" | "unknown";
+ safe_next_action:
+ | "wait-for-current-attempt"
+ | "continue-with-control-verification"
+ | "verify-control-read-only"
+ | "start-acquisition"
+ | "stop-local-receiver"
+ | "retire-unavailable-physical-target"
+ | "scan-select-connect"
+ | "manual-recovery-required";
+ automatic_retry: false;
+ accepted_at: string | null;
+ completed_at: string | null;
+ timeline: XgridsOperationEvent[];
+ diagnostic_bundle?: XgridsConnectionDiagnosticBundle;
}
export interface XgridsK1Metrics {
@@ -344,14 +923,129 @@ export interface XgridsSensorCatalog {
streams?: XgridsSensorCatalogStream[];
}
+export interface XgridsConnectionLifecycleBinding {
+ binding_key: string;
+ intent_id: string;
+ transport_ref: string;
+ connection_mode: XgridsConnectionMode;
+ target_ipv4: string;
+ target_port: number;
+ host_path_epoch: number;
+ control_session_id: string;
+ logical_device_id?: string | null;
+ compatibility_profile_id?: string | null;
+ control_proof_source?: string | null;
+ control_proof_revision?: number | null;
+}
+
+export interface XgridsConnectionLifecycle {
+ schema_version: "missioncore.xgrids-k1-connection-lifecycle/v1";
+ revision: number;
+ desired_mode: XgridsConnectionMode;
+ configured_mode: XgridsConnectionMode | null;
+ active_mode: XgridsConnectionMode | null;
+ mode_change: {
+ state:
+ | "disconnected"
+ | "awaiting-control"
+ | "ready"
+ | "switch-selected"
+ | "connecting";
+ from: XgridsConnectionMode | null;
+ to: XgridsConnectionMode;
+ };
+ mode_selection: {
+ allowed: boolean;
+ reason_codes: string[];
+ automatic_retry: false;
+ };
+ active_binding_key: string | null;
+ active_binding: XgridsConnectionLifecycleBinding | null;
+ connection_ready: boolean;
+ ready_to_start: boolean;
+ operation: XgridsConnectionAttempt | null;
+ allowed_actions: string[];
+ automatic_retry: false;
+}
+
+export interface XgridsActiveStreamRecovery {
+ schema_version: "missioncore.xgrids-k1-active-stream-recovery/v1";
+ state: XgridsActiveStreamRecoveryState;
+ generation: number;
+ acquisition_id: string | null;
+ attempt: number;
+ started_at_utc: string | null;
+ elapsed_ms: number | null;
+ reason_code: string | null;
+ force_finish_allowed: boolean;
+ automatic_read_only_rebind: boolean;
+ automatic_command_retry: false;
+ start_performed: false;
+ stop_performed: false;
+ ble_operation_performed: false;
+ network_mutation_performed: false;
+ runtime_producer_generation: number | null;
+ camera_recovery: XgridsCameraRecoveryState;
+ camera_media_state: XgridsCameraMediaState;
+ camera_media_ready: boolean;
+ camera_epoch: XgridsCameraMediaEpoch | null;
+}
+
+export interface XgridsCameraMediaEpoch {
+ generation: number;
+ init_committed: boolean;
+ init_committed_age_ms: number | null;
+ first_media_committed: boolean;
+ first_media_committed_age_ms: number | null;
+ committed_media_segment_count: number;
+ last_media_segment_age_ms: number | null;
+}
+
export interface XgridsK1State {
contract_version?: string | null;
+ snapshot_runtime_started_at_utc?: string | null;
+ snapshot_runtime_started_monotonic_ns?: string | null;
+ snapshot_runtime_id?: string | null;
+ snapshot_revision?: number | null;
+ producer_generation?: number | null;
phase?: string | null;
message?: string | null;
devices?: BleDevice[];
selected_device_id?: string | null;
+ ble_discovery_generation?: number;
k1_ip?: string | null;
- connection_mode?: "bridge" | "quick-connect" | "direct-connect" | null;
+ /** Legacy alias for the last configured mode. */
+ connection_mode?: XgridsConnectionMode | null;
+ configured_connection_mode?: XgridsConnectionMode | null;
+ active_connection_mode?: XgridsConnectionMode | null;
+ desired_connection_mode?: XgridsConnectionMode;
+ desired_connection_mode_revision?: number;
+ connection_scenario_reset?: {
+ reset_id: string;
+ request_revision: number;
+ revision: number;
+ desired_mode: XgridsConnectionMode;
+ /** Missing is treated as active while rolling out the marker lifecycle. */
+ active?: boolean;
+ settled_by_discovery_generation?: number | null;
+ local_session_closed: true;
+ previous_device_may_continue_scanning: boolean;
+ physical_disposition: string;
+ network_disposition: string | null;
+ device_command_performed: false;
+ network_write_performed: false;
+ automatic_scan: false;
+ operation_sequence: number;
+ } | null;
+ connection_scenario_reset_pending?: {
+ reset_id: string;
+ desired_mode: XgridsConnectionMode;
+ expected_revision: number;
+ status: "waiting-for-local-lifecycle" | "retiring-local-session";
+ device_command_performed: false;
+ network_write_performed: false;
+ automatic_scan: false;
+ } | null;
foxglove_ws_url?: string | null;
foxglove_viewer_url?: string | null;
rerun_grpc_url?: string | null;
@@ -362,13 +1056,24 @@ export interface XgridsK1State {
modeling_control_safety?: XgridsModelingControlSafety | null;
application_control_execution?: XgridsApplicationControlExecution | null;
application_control_session?: XgridsApplicationControlSession | null;
+ physical_command?: XgridsPhysicalCommandState | null;
device_ref?: XgridsDeviceRef | null;
device_session?: XgridsDeviceSession | null;
connection_verification?: XgridsConnectionVerification | null;
+ configured_endpoint_probe?: XgridsConfiguredEndpointProbe | null;
network_write_reconciliation?: XgridsNetworkWriteReconciliation | null;
+ network_mutation_ledger?: XgridsNetworkMutationLedger | null;
+ current_device_recovery?: XgridsCurrentDeviceRecovery | null;
+ connection_supervisor?: XgridsConnectionSupervisor | null;
+ connection_lifecycle?: XgridsConnectionLifecycle | null;
+ connection_recovery?: XgridsActiveStreamRecovery | null;
+ connection_reconfiguration?: XgridsConnectionReconfiguration | null;
+ connection_policy?: XgridsConnectionPolicy | null;
+ semantic_topology_store?: XgridsSemanticTopologyStore | null;
acquisition?: XgridsAcquisition | null;
operations?: XgridsOperation[];
last_operation?: XgridsOperation | null;
+ connection_attempt?: XgridsConnectionAttempt | null;
sensor_catalog?: XgridsSensorCatalog | null;
camera_preview?: XgridsCameraPreviewState | null;
}
@@ -382,6 +1087,7 @@ export interface HealthResponse {
export interface ScanRequest {
duration_seconds?: number;
+ operation_id?: string;
}
export interface CompatibilityAttestation {
@@ -397,14 +1103,81 @@ export interface ConnectRequest {
connection_mode: "bridge" | "quick-connect" | "direct-connect";
compatibility_attestation: CompatibilityAttestation;
operation_id?: string;
- idempotency_key?: string;
+ idempotency_key: string;
+ expected_mode_revision: number;
+ expected_discovery_generation: number;
+ expected_reconfiguration_revision: number;
+ expected_reconfiguration_intent_id?: string;
}
-export interface ConnectionVerifyRequest {
+export interface SelectConnectionModeRequest {
+ connection_mode: XgridsConnectionMode;
+ expected_revision: number;
+ reset_scenario?: true;
+ reset_id?: string;
+}
+
+export interface PrepareConnectionReconfigurationRequest {
+ intent: XgridsConnectionReconfigurationIntent | "cancel";
+ expected_reconfiguration_revision: number;
+ expected_reconfiguration_intent_id: string | null;
+ expected_desired_mode_revision: number;
+ expected_active_binding_key: string | null;
+}
+
+interface ConnectionVerifyRequestBase {
device_id: string;
compatibility_attestation: CompatibilityAttestation;
+ operation_id?: string;
}
+export type ConnectionVerifyRequest =
+ | (ConnectionVerifyRequestBase & {
+ source: "fresh-scan";
+ expected_discovery_generation: number;
+ expected_reconfiguration_revision: number;
+ expected_reconfiguration_intent_id?: string;
+ })
+ | (ConnectionVerifyRequestBase & {
+ source: Extract<
+ XgridsConnectionPolicyTargetSource,
+ "retained-current-process" | "durable-configured-state"
+ >;
+ expected_discovery_generation?: never;
+ });
+
+export interface ConfiguredEndpointProbeRequest {
+ operation_id?: string;
+}
+
+export interface RetireUnavailablePhysicalCommandRequest {
+ retirement_id: string;
+ expected_operation_id: string;
+ expected_revision: number;
+ expected_transport_ref: string;
+ operator_confirmed: true;
+ reason: "device-permanently-unavailable-or-replaced";
+}
+
+export interface ReopenRetiredPhysicalReconciliationRequest {
+ reopening_id: string;
+ expected_revision: number;
+ expected_retirement_id: string;
+ expected_transport_ref: string;
+ expected_discovery_generation: number;
+ expected_desired_mode: XgridsConnectionMode;
+ expected_desired_mode_revision: number;
+ operator_confirmed: true;
+ reason: "device-returned-for-explicit-reconciliation";
+}
+
+export interface SnapshotRuntimeFenceRequest {
+ expected_snapshot_runtime_id: string;
+}
+
+export type SnapshotRuntimeFencedRequest =
+ TRequest & SnapshotRuntimeFenceRequest;
+
export interface PrepareAcquisitionRequest {
project_name: string;
mount_type: "handheld";
@@ -415,8 +1188,10 @@ export interface PrepareAcquisitionRequest {
evidence_policy?: "required" | "best-effort" | "disabled";
compatibility_attestation: CompatibilityAttestation;
operation_id?: string;
- idempotency_key?: string;
+ idempotency_key: string;
deadline_seconds?: number;
+ expected_control_session_generation?: number;
+ expected_control_state_revision?: number;
}
export type RequestedStreamId =
@@ -429,26 +1204,51 @@ export type RequestedStreamId =
export interface StartAcquisitionRequest {
acquisition_id: string;
expected_state_revision?: number;
+ expected_control_session_generation: number;
+ expected_control_state_revision: number;
operation_id?: string;
- idempotency_key?: string;
+ idempotency_key: string;
deadline_seconds?: number;
physical_acceptance?: OperatorPresenceConfirmation;
}
-export interface StopAcquisitionRequest {
+interface StopAcquisitionRequestBase {
acquisition_id: string;
- mode: "capture-only" | "graceful";
operator_confirmed?: boolean;
operation_id?: string;
- idempotency_key?: string;
+ idempotency_key: string;
deadline_seconds?: number;
physical_acceptance?: OperatorPresenceConfirmation;
}
+export type StopAcquisitionRequest =
+ | (StopAcquisitionRequestBase & {
+ mode: "capture-only";
+ expected_control_session_generation?: never;
+ expected_control_state_revision?: never;
+ })
+ | (StopAcquisitionRequestBase & {
+ mode: "graceful";
+ expected_control_session_generation: number;
+ expected_control_state_revision: number;
+ });
+
export interface AbortAcquisitionRequest {
acquisition_id: string;
operation_id?: string;
- idempotency_key?: string;
+ idempotency_key: string;
+ deadline_seconds?: number;
+ expected_control_session_generation?: number;
+ expected_control_state_revision?: number;
+}
+
+export interface ForceFinishAcquisitionRequest {
+ acquisition_id: string;
+ expected_state_revision: number;
+ expected_recovery_generation: number;
+ operator_confirmed: true;
+ operation_id?: string;
+ idempotency_key: string;
deadline_seconds?: number;
}
@@ -496,86 +1296,568 @@ export interface OpenApplicationControlSessionRequest
export interface EnterApplicationWorkspaceRequest {
operator_confirmed: true;
+ expected_session_generation: number;
+ expected_state_revision: number;
+}
+
+export interface CloseApplicationControlSessionRequest {
+ expected_session_generation: number;
+ expected_state_revision: number;
+}
+
+export interface ReconcilePhysicalCommandRequest {
+ reconciliation_id: string;
+ expected_session_generation: number;
+ expected_state_revision: number;
}
export class ApiError extends Error {
readonly status: number;
readonly transportUnavailable: boolean;
+ readonly hostDiagnostic: XgridsHostFailureDiagnostic | null;
- constructor(message: string, status = 0, transportUnavailable = false) {
+ constructor(
+ message: string,
+ status = 0,
+ transportUnavailable = false,
+ hostDiagnostic: unknown = null,
+ ) {
super(message);
this.name = "ApiError";
this.status = status;
this.transportUnavailable = transportUnavailable;
+ this.hostDiagnostic = isXgridsHostFailureDiagnostic(hostDiagnostic)
+ ? hostDiagnostic
+ : null;
}
}
+export class ApiRequestTimeoutError extends ApiError {
+ readonly code = "request_timeout_outcome_unknown";
+ readonly outcomeUnknown = true;
+ readonly automaticRetry = false;
+ readonly timeoutMs: number;
+ readonly operationLabel: string;
+
+ constructor(operationLabel: string, timeoutMs: number) {
+ super(
+ `Локальный запрос «${operationLabel}» не завершился за ${Math.ceil(timeoutMs / 1000)} с. Результат операции неизвестен; автоматический повтор запрещён. Обновите состояние перед отдельным ручным действием.`,
+ );
+ this.name = "ApiRequestTimeoutError";
+ this.timeoutMs = timeoutMs;
+ this.operationLabel = operationLabel;
+ }
+}
+
+const DEFAULT_REQUEST_TIMEOUT_MS = 8_000;
+const ACTION_REQUEST_TIMEOUT_MS: Readonly> = {
+ [xgridsK1Actions.stateRead]: 8_000,
+ [xgridsK1Actions.discoveryScan]: 70_000,
+ // Explicit scenario reset queues behind a bounded in-flight lifecycle and
+ // then performs local-only retirement. Keep the browser request alive long
+ // enough to observe that single committed intent.
+ [xgridsK1Actions.connectionModeSelect]: 360_000,
+ [xgridsK1Actions.connectionReconfigurePrepare]: 8_000,
+ // Bridge and Quick are composite operations: exact BLE baseline/write,
+ // bounded post-write observation and, for Quick, a native CoreWLAN handoff.
+ // The backend journals a 240-second operation fence. The browser deadline
+ // must outlive it; aborting the HTTP request earlier cannot cancel the
+ // native/device work and would present an avoidable ambiguous outcome.
+ [xgridsK1Actions.networkProvision]: 300_000,
+ // A canonical verification performs a bounded fresh CoreBluetooth connect
+ // and 7f02 status read before any route/TCP interpretation. The browser
+ // must not abandon that read at the generic eight-second HTTP deadline and
+ // accidentally invite a second operator attempt while native cleanup still
+ // owns the BLE lifecycle.
+ [xgridsK1Actions.connectionVerify]: 150_000,
+ // This is the separate host-only route -> TCP -> route diagnostic. It does
+ // not enter CoreBluetooth and has a much smaller bounded server deadline.
+ [xgridsK1Actions.configuredEndpointProbe]: 15_000,
+ [xgridsK1Actions.applicationControlSessionOpen]: 120_000,
+ [xgridsK1Actions.applicationControlWorkspaceEnter]: 120_000,
+ [xgridsK1Actions.applicationControlSessionClose]: 120_000,
+ [xgridsK1Actions.physicalCommandReconcile]: 120_000,
+ [xgridsK1Actions.physicalCommandRetireUnavailable]: 8_000,
+ [xgridsK1Actions.physicalCommandReopenRetiredReconciliation]: 8_000,
+ [xgridsK1Actions.acquisitionPrepare]: 120_000,
+ [xgridsK1Actions.acquisitionStart]: 120_000,
+ [xgridsK1Actions.acquisitionStop]: 120_000,
+ [xgridsK1Actions.acquisitionForceFinishLocal]: 45_000,
+};
+
+export function requestTimeoutMsForAction(actionId: string): number {
+ return ACTION_REQUEST_TIMEOUT_MS[actionId] ?? DEFAULT_REQUEST_TIMEOUT_MS;
+}
+
function isRecord(value: unknown): value is Record {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+function isLiteralValue(
+ value: unknown,
+ values: Values,
+): value is Values[number] {
+ return typeof value === "string" && values.some((candidate) => candidate === value);
+}
+
+function isOptionalString(value: unknown): value is string | null | undefined {
+ return value === undefined || value === null || typeof value === "string";
+}
+
+function isOptionalBoolean(value: unknown): value is boolean | undefined {
+ return value === undefined || typeof value === "boolean";
+}
+
+function isNonNegativeInteger(value: unknown): value is number {
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
+}
+
+function isStringOrNull(value: unknown): value is string | null {
+ return value === null || typeof value === "string";
+}
+
+function isNonNegativeIntegerOrNull(value: unknown): value is number | null {
+ return value === null || isNonNegativeInteger(value);
+}
+
+function isStringArray(value: unknown): value is string[] {
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
+}
+
+export function isXgridsConnectionAttemptPhase(
+ value: unknown,
+): value is XgridsConnectionAttemptPhase {
+ return isLiteralValue(value, XGRIDS_CONNECTION_ATTEMPT_PHASES);
+}
+
+export function isXgridsHostFailureDiagnostic(
+ value: unknown,
+): value is XgridsHostFailureDiagnostic {
+ return isRecord(value)
+ && value.schema_version === "missioncore.host-failure-diagnostic/v1"
+ && isLiteralValue(value.code, XGRIDS_HOST_DIAGNOSTIC_CODES)
+ && isLiteralValue(value.domain, XGRIDS_HOST_DIAGNOSTIC_DOMAINS)
+ && isLiteralValue(value.impact, XGRIDS_HOST_DIAGNOSTIC_IMPACTS)
+ && isLiteralValue(value.operator_action, XGRIDS_HOST_DIAGNOSTIC_ACTIONS)
+ && value.automatic_retry === false
+ && value.redacted === true;
+}
+
+export function isXgridsActiveStreamRecovery(
+ value: unknown,
+): value is XgridsActiveStreamRecovery {
+ if (!isRecord(value)) return false;
+ return value.schema_version
+ === "missioncore.xgrids-k1-active-stream-recovery/v1"
+ && isLiteralValue(value.state, XGRIDS_ACTIVE_STREAM_RECOVERY_STATES)
+ && isNonNegativeInteger(value.generation)
+ && isStringOrNull(value.acquisition_id)
+ && isNonNegativeInteger(value.attempt)
+ && isStringOrNull(value.started_at_utc)
+ && isNonNegativeIntegerOrNull(value.elapsed_ms)
+ && isStringOrNull(value.reason_code)
+ && typeof value.force_finish_allowed === "boolean"
+ && typeof value.automatic_read_only_rebind === "boolean"
+ && value.automatic_command_retry === false
+ && value.start_performed === false
+ && value.stop_performed === false
+ && value.ble_operation_performed === false
+ && value.network_mutation_performed === false
+ && isNonNegativeIntegerOrNull(value.runtime_producer_generation)
+ && isLiteralValue(value.camera_recovery, XGRIDS_CAMERA_RECOVERY_STATES)
+ && isXgridsCameraMediaProjection(value);
+}
+
+function isXgridsCameraMediaEpoch(value: unknown): value is XgridsCameraMediaEpoch {
+ return isRecord(value)
+ && isNonNegativeInteger(value.generation)
+ && value.generation > 0
+ && typeof value.init_committed === "boolean"
+ && isNonNegativeIntegerOrNull(value.init_committed_age_ms)
+ && typeof value.first_media_committed === "boolean"
+ && isNonNegativeIntegerOrNull(value.first_media_committed_age_ms)
+ && isNonNegativeInteger(value.committed_media_segment_count)
+ && isNonNegativeIntegerOrNull(value.last_media_segment_age_ms);
+}
+
+function isXgridsCameraMediaProjection(
+ value: Record,
+): boolean {
+ if (
+ !isLiteralValue(value.camera_media_state, XGRIDS_CAMERA_MEDIA_STATES)
+ || typeof value.camera_media_ready !== "boolean"
+ ) return false;
+ const state = value.camera_media_state;
+ const epoch = value.camera_epoch;
+ if (state === "inactive" || state === "pending-epoch") {
+ return value.camera_media_ready === false && epoch === null;
+ }
+ if (!isXgridsCameraMediaEpoch(epoch) || value.camera_media_ready !== (state === "ready")) {
+ return false;
+ }
+ if (state === "pending-init") {
+ return epoch.init_committed === false
+ && epoch.init_committed_age_ms === null
+ && epoch.first_media_committed === false
+ && epoch.first_media_committed_age_ms === null
+ && epoch.committed_media_segment_count === 0
+ && epoch.last_media_segment_age_ms === null;
+ }
+ if (state === "pending-first-media") {
+ return epoch.init_committed === true
+ && isNonNegativeInteger(epoch.init_committed_age_ms)
+ && epoch.first_media_committed === false
+ && epoch.first_media_committed_age_ms === null
+ && epoch.committed_media_segment_count === 0
+ && epoch.last_media_segment_age_ms === null;
+ }
+ return epoch.init_committed === true
+ && isNonNegativeInteger(epoch.init_committed_age_ms)
+ && epoch.first_media_committed === true
+ && isNonNegativeInteger(epoch.first_media_committed_age_ms)
+ && epoch.committed_media_segment_count > 0
+ && isNonNegativeInteger(epoch.last_media_segment_age_ms);
+}
+
+export function isXgridsConnectionVerification(
+ value: unknown,
+): value is XgridsConnectionVerification {
+ if (!isRecord(value)) return false;
+ return (
+ isLiteralValue(value.status, XGRIDS_CONNECTION_VERIFICATION_STATUSES)
+ && isLiteralValue(
+ value.lease_state,
+ XGRIDS_CONNECTION_VERIFICATION_LEASE_STATES,
+ )
+ && isNonNegativeInteger(value.lease_generation)
+ && isNonNegativeInteger(value.supervisor_revision)
+ && isStringOrNull(value.endpoint_validation)
+ && isLiteralValue(
+ value.network_reachability,
+ XGRIDS_CONNECTION_NETWORK_REACHABILITY,
+ )
+ && isOptionalString(value.host_route_class)
+ && isOptionalString(value.address_source)
+ && isOptionalString(value.connection_origin)
+ && isOptionalString(value.admission_source)
+ && isOptionalBoolean(value.address_changed)
+ && isOptionalBoolean(value.previous_address_present)
+ && isOptionalBoolean(value.write_performed)
+ && isStringOrNull(value.observed_at)
+ && isOptionalString(value.last_known_reachable_at)
+ && isOptionalString(value.reason_code)
+ );
+}
+
+export function isXgridsConnectionReconfiguration(
+ value: unknown,
+): value is XgridsConnectionReconfiguration {
+ if (!isRecord(value)) return false;
+ const intent = value.intent;
+ const intentId = value.intent_id;
+ const status = value.status;
+ const requiredTransportRef = value.required_transport_ref;
+ const requiredMode = value.required_connection_mode;
+ const observed = value.required_transport_observed;
+ return Boolean(
+ value.schema_version
+ === "missioncore.xgrids-k1-connection-reconfiguration/v1"
+ && isNonNegativeInteger(value.revision)
+ && isStringOrNull(intentId)
+ && (
+ intent === null
+ || isLiteralValue(intent, XGRIDS_CONNECTION_RECONFIGURATION_INTENTS)
+ )
+ && isLiteralValue(
+ status,
+ XGRIDS_CONNECTION_RECONFIGURATION_STATUSES,
+ )
+ && isStringOrNull(requiredTransportRef)
+ && (
+ requiredMode === null
+ || isLiteralValue(requiredMode, XGRIDS_CONNECTION_MODES)
+ )
+ && isNonNegativeIntegerOrNull(value.minimum_discovery_generation)
+ && isNonNegativeIntegerOrNull(value.fresh_discovery_generation)
+ && (observed === null || typeof observed === "boolean")
+ && isStringOrNull(value.prepared_at)
+ && value.automatic_retry === false
+ && (
+ status === "idle"
+ ? intent === null
+ && intentId === null
+ && requiredTransportRef === null
+ && requiredMode === null
+ && value.minimum_discovery_generation === null
+ && value.fresh_discovery_generation === null
+ && observed === null
+ && value.prepared_at === null
+ : intent !== null
+ && Boolean(intentId?.trim())
+ && requiredMode === "bridge"
+ && isNonNegativeInteger(value.minimum_discovery_generation)
+ && Boolean(value.prepared_at?.trim())
+ && (
+ intent === "select-device"
+ ? requiredTransportRef === null
+ : Boolean(requiredTransportRef?.trim())
+ )
+ && (
+ status === "awaiting-fresh-scan"
+ ? value.fresh_discovery_generation === null
+ && observed === null
+ : isNonNegativeInteger(value.fresh_discovery_generation)
+ && value.fresh_discovery_generation
+ >= value.minimum_discovery_generation
+ && (
+ intent === "change-network"
+ ? typeof observed === "boolean"
+ : observed === null
+ )
+ )
+ )
+ );
+}
+
+export function isXgridsConnectionPolicyDecision(
+ value: unknown,
+): value is XgridsConnectionPolicyDecision {
+ if (!isRecord(value)) return false;
+ const requiredMode = value.required_connection_mode;
+ return (
+ typeof value.allowed === "boolean"
+ && isStringArray(value.reason_codes)
+ && isLiteralValue(value.target_source, XGRIDS_CONNECTION_POLICY_TARGET_SOURCES)
+ && isStringOrNull(value.required_transport_ref)
+ && (
+ requiredMode === undefined
+ || requiredMode === null
+ || isLiteralValue(requiredMode, XGRIDS_CONNECTION_MODES)
+ )
+ && typeof value.requires_live_gatt_validation === "boolean"
+ && value.automatic_retry === false
+ && (
+ value.execution_mode === undefined
+ || value.execution_mode === "capture-only"
+ )
+ && (
+ value.physical_command_allowed === undefined
+ || value.physical_command_allowed === false
+ )
+ && (
+ value.physical_outcome === undefined
+ || value.physical_outcome === "unknown"
+ )
+ && (
+ value.operator_follow_up === undefined
+ || value.operator_follow_up === "manual-device-stop-required"
+ )
+ && (
+ value.device_write_performed === undefined
+ || value.device_write_performed === false
+ )
+ );
+}
+
+export function isXgridsConnectionPolicy(
+ value: unknown,
+): value is XgridsConnectionPolicy {
+ if (
+ !isRecord(value)
+ || value.schema_version !== "missioncore.xgrids-k1-connection-policy/v1"
+ || !isNonNegativeInteger(value.supervisor_revision)
+ || !isNonNegativeIntegerOrNull(value.network_ledger_revision)
+ || typeof value.recommended_action !== "string"
+ || !Array.isArray(value.allowed_actions)
+ || !isRecord(value.actions)
+ || !isRecord(value.facts)
+ || value.facts.retained_context_is_presence !== false
+ ) {
+ return false;
+ }
+
+ const allowedActions: XgridsConnectionPolicyAction[] = [];
+ const policyActions = value.actions;
+ for (const action of value.allowed_actions) {
+ if (!isLiteralValue(action, XGRIDS_CONNECTION_POLICY_ACTIONS)) return false;
+ allowedActions.push(action);
+ }
+
+ for (const [action, decision] of Object.entries(policyActions)) {
+ if (
+ !isLiteralValue(action, XGRIDS_CONNECTION_POLICY_ACTIONS)
+ || !isXgridsConnectionPolicyDecision(decision)
+ ) {
+ return false;
+ }
+ if (decision.allowed !== allowedActions.includes(action)) return false;
+
+ if (
+ action === "observe-current-device-network"
+ && decision.target_source !== "retained-current-process"
+ ) {
+ return false;
+ }
+ if (
+ action === "observe-configured-device-network"
+ && decision.target_source !== "durable-configured-state"
+ ) {
+ return false;
+ }
+ if (
+ action === "retire-unavailable-physical-target"
+ && (
+ decision.target_source !== "durable-physical-command"
+ || decision.physical_command_allowed !== false
+ || decision.physical_outcome !== "unknown"
+ || decision.device_write_performed !== false
+ || decision.requires_live_gatt_validation !== false
+ )
+ ) {
+ return false;
+ }
+ if (
+ decision.allowed
+ && (action === "observe-current-device-network"
+ || action === "observe-configured-device-network")
+ && (
+ !decision.required_transport_ref?.trim()
+ || decision.required_connection_mode == null
+ )
+ ) {
+ return false;
+ }
+ }
+
+ return allowedActions.every((action) => {
+ const decision = policyActions[action];
+ return isRecord(decision) && decision.allowed === true;
+ });
+}
+
+function hasValidRuntimeContracts(
+ value: unknown,
+): value is XgridsK1State {
+ if (!isRecord(value)) return false;
+ const verification = value.connection_verification;
+ const reconfiguration = value.connection_reconfiguration;
+ const policy = value.connection_policy;
+ const attempt = value.connection_attempt;
+ const activeStreamRecovery = value.connection_recovery;
+ const verificationValid = verification === undefined
+ || verification === null
+ || isXgridsConnectionVerification(verification);
+ const policyValid = policy === undefined
+ || policy === null
+ || isXgridsConnectionPolicy(policy);
+ const reconfigurationValid = reconfiguration === undefined
+ || reconfiguration === null
+ || isXgridsConnectionReconfiguration(reconfiguration);
+ const attemptValid = attempt === undefined
+ || attempt === null
+ || (
+ isRecord(attempt)
+ && attempt.schema_version === "missioncore.xgrids-k1-connection-attempt/v1"
+ && isXgridsConnectionAttemptPhase(attempt.phase)
+ && attempt.automatic_retry === false
+ );
+ const activeStreamRecoveryValid = activeStreamRecovery === undefined
+ || activeStreamRecovery === null
+ || isXgridsActiveStreamRecovery(activeStreamRecovery);
+ const producerGenerationValid = value.producer_generation === undefined
+ || value.producer_generation === null
+ || isNonNegativeInteger(value.producer_generation);
+ return verificationValid
+ && reconfigurationValid
+ && policyValid
+ && attemptValid
+ && activeStreamRecoveryValid
+ && producerGenerationValid;
+}
+
function unwrapState(payload: unknown): XgridsK1State {
const value = isRecord(payload) && isRecord(payload.state) ? payload.state : payload;
- if (!isRecord(value)) {
+ if (!hasValidRuntimeContracts(value)) {
throw new ApiError("Локальный сервер вернул некорректное состояние.");
}
- return value as XgridsK1State;
+ return value;
}
-async function requestJson(path: string, init?: RequestInit): Promise {
- let response: Response;
+async function requestJson(
+ path: string,
+ init: RequestInit | undefined,
+ operationLabel: string,
+ timeoutMs: number,
+): Promise {
+ const controller = new AbortController();
+ let timedOut = false;
+ const timeout = setTimeout(() => {
+ timedOut = true;
+ controller.abort();
+ }, timeoutMs);
try {
- response = await fetch(path, {
+ const response = await fetch(path, {
...init,
+ signal: controller.signal,
headers: {
Accept: "application/json",
...(init?.body ? { "Content-Type": "application/json" } : {}),
...init?.headers,
},
});
- } catch {
+ const bodyText = await response.text();
+ let body: unknown;
+
+ if (bodyText) {
+ try {
+ body = JSON.parse(bodyText) as unknown;
+ } catch {
+ body = bodyText;
+ }
+ }
+
+ if (!response.ok) {
+ const detail =
+ isRecord(body) && typeof body.detail === "string"
+ ? body.detail
+ : typeof body === "string" && body.trim()
+ ? body.trim()
+ : `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`;
+ throw new ApiError(
+ detail || `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`,
+ response.status,
+ );
+ }
+
+ return body;
+ } catch (error) {
+ if (error instanceof ApiError) throw error;
+ if (timedOut) {
+ throw new ApiRequestTimeoutError(operationLabel, timeoutMs);
+ }
throw new ApiError(
"Не удалось подключиться к локальному сервису устройства.",
0,
true,
);
+ } finally {
+ clearTimeout(timeout);
}
-
- const bodyText = await response.text();
- let body: unknown;
-
- if (bodyText) {
- try {
- body = JSON.parse(bodyText) as unknown;
- } catch {
- body = bodyText;
- }
- }
-
- if (!response.ok) {
- const detail =
- isRecord(body) && typeof body.detail === "string"
- ? body.detail
- : typeof body === "string" && body.trim()
- ? body.trim()
- : `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`;
- throw new ApiError(
- detail || `Запрос к API устройства завершился ошибкой HTTP ${response.status}.`,
- response.status,
- );
- }
-
- return body;
}
-async function postState(path: string, body?: object): Promise {
+async function postState(
+ path: string,
+ body: object | undefined,
+ operationLabel: string,
+ timeoutMs: number,
+): Promise {
const payload = await requestJson(path, {
method: "POST",
body: body ? JSON.stringify(body) : undefined,
- });
+ }, operationLabel, timeoutMs);
if (payload === undefined) {
return xgridsK1Api.getState();
@@ -588,12 +1870,19 @@ function invokeState(actionId: string, input: object = {}): Promise {
- const payload = await requestJson("/api/health");
+ const payload = await requestJson(
+ "/api/health",
+ undefined,
+ "health.read",
+ DEFAULT_REQUEST_TIMEOUT_MS,
+ );
if (!isRecord(payload)) {
throw new ApiError("Локальный сервер вернул некорректный ответ проверки.");
}
@@ -604,34 +1893,87 @@ export const xgridsK1Api = {
return invokeState(xgridsK1Actions.stateRead);
},
- scanBle(body: ScanRequest = {}): Promise {
+ scanBle(
+ body: SnapshotRuntimeFencedRequest,
+ ): Promise {
return invokeState(xgridsK1Actions.discoveryScan, body);
},
- connect(body: ConnectRequest): Promise {
+ selectConnectionMode(
+ body: SnapshotRuntimeFencedRequest,
+ ): Promise {
+ return invokeState(xgridsK1Actions.connectionModeSelect, body);
+ },
+
+ prepareConnectionReconfiguration(
+ body: SnapshotRuntimeFencedRequest,
+ ): Promise {
+ return invokeState(xgridsK1Actions.connectionReconfigurePrepare, body);
+ },
+
+ connect(
+ body: SnapshotRuntimeFencedRequest,
+ ): Promise {
return invokeState(xgridsK1Actions.networkProvision, body);
},
- verifyConnection(body?: ConnectionVerifyRequest): Promise {
+ verifyConnection(
+ body: SnapshotRuntimeFencedRequest,
+ ): Promise {
return invokeState(xgridsK1Actions.connectionVerify, body);
},
- prepareAcquisition(body: PrepareAcquisitionRequest): Promise {
+ probeConfiguredEndpoint(
+ body: SnapshotRuntimeFencedRequest,
+ ): Promise {
+ return invokeState(xgridsK1Actions.configuredEndpointProbe, body);
+ },
+
+ retireUnavailablePhysicalCommand(
+ body: SnapshotRuntimeFencedRequest,
+ ): Promise {
+ return invokeState(xgridsK1Actions.physicalCommandRetireUnavailable, body);
+ },
+
+ reopenRetiredPhysicalReconciliation(
+ body: SnapshotRuntimeFencedRequest,
+ ): Promise {
+ return invokeState(
+ xgridsK1Actions.physicalCommandReopenRetiredReconciliation,
+ body,
+ );
+ },
+
+ prepareAcquisition(
+ body: SnapshotRuntimeFencedRequest,
+ ): Promise {
return invokeState(xgridsK1Actions.acquisitionPrepare, body);
},
- startAcquisition(body: StartAcquisitionRequest): Promise {
+ startAcquisition(
+ body: SnapshotRuntimeFencedRequest,
+ ): Promise {
return invokeState(xgridsK1Actions.acquisitionStart, body);
},
- stopAcquisition(body: StopAcquisitionRequest): Promise {
+ stopAcquisition(
+ body: SnapshotRuntimeFencedRequest,
+ ): Promise {
return invokeState(xgridsK1Actions.acquisitionStop, body);
},
- abortAcquisition(body: AbortAcquisitionRequest): Promise {
+ abortAcquisition(
+ body: SnapshotRuntimeFencedRequest,
+ ): Promise {
return invokeState(xgridsK1Actions.acquisitionAbort, body);
},
+ forceFinishAcquisitionLocally(
+ body: SnapshotRuntimeFencedRequest,
+ ): Promise {
+ return invokeState(xgridsK1Actions.acquisitionForceFinishLocal, body);
+ },
+
startReplay(body: ReplayRequest): Promise {
return invokeState(xgridsK1Actions.streamStartReplay, body);
},
@@ -640,8 +1982,10 @@ export const xgridsK1Api = {
return invokeState(xgridsK1Actions.compatibilityStreamStartLive, body);
},
- stopSessionCompatibility(): Promise {
- return invokeState(xgridsK1Actions.compatibilityStreamStop);
+ stopSessionCompatibility(
+ body: SnapshotRuntimeFenceRequest,
+ ): Promise {
+ return invokeState(xgridsK1Actions.compatibilityStreamStop, body);
},
selectCameraPreview(body: SelectCameraPreviewRequest): Promise {
@@ -671,19 +2015,27 @@ export const xgridsK1Api = {
},
openApplicationControlSession(
- body: OpenApplicationControlSessionRequest,
+ body: SnapshotRuntimeFencedRequest,
): Promise {
return invokeState(xgridsK1Actions.applicationControlSessionOpen, body);
},
enterApplicationWorkspace(
- body: EnterApplicationWorkspaceRequest,
+ body: SnapshotRuntimeFencedRequest,
): Promise {
return invokeState(xgridsK1Actions.applicationControlWorkspaceEnter, body);
},
- closeApplicationControlSession(): Promise {
- return invokeState(xgridsK1Actions.applicationControlSessionClose);
+ closeApplicationControlSession(
+ body: SnapshotRuntimeFencedRequest,
+ ): Promise {
+ return invokeState(xgridsK1Actions.applicationControlSessionClose, body);
+ },
+
+ reconcilePhysicalCommand(
+ body: SnapshotRuntimeFencedRequest,
+ ): Promise {
+ return invokeState(xgridsK1Actions.physicalCommandReconcile, body);
},
};
diff --git a/plugins/xgrids-k1/frontend/src/components/ActiveStreamRecoverySurface.tsx b/plugins/xgrids-k1/frontend/src/components/ActiveStreamRecoverySurface.tsx
new file mode 100644
index 0000000..846a39c
--- /dev/null
+++ b/plugins/xgrids-k1/frontend/src/components/ActiveStreamRecoverySurface.tsx
@@ -0,0 +1,172 @@
+import {
+ ActivityIndicator,
+ Button,
+ GlassSurface,
+ StatusBadge,
+} from "@nodedc/ui-react";
+
+import type { ActiveStreamRecoveryPresentation } from "../activeStreamRecovery";
+
+export type ActiveStreamRecoverySurfaceVariant = "panel" | "compact";
+
+export interface ActiveStreamRecoverySurfaceProps {
+ presentation: ActiveStreamRecoveryPresentation | null;
+ forceFinishing: boolean;
+ actionBusy: boolean;
+ onForceFinish: () => void;
+ variant?: ActiveStreamRecoverySurfaceVariant;
+}
+
+interface ActiveStreamRecoverySurfaceCopy {
+ eyebrow: string;
+ title: string;
+ statusLabel: string;
+ detail: string;
+ showSpinner: boolean;
+ forceFinishAvailable: boolean;
+}
+
+function surfaceCopy({
+ presentation,
+ forceFinishing,
+}: Pick<
+ ActiveStreamRecoverySurfaceProps,
+ "presentation" | "forceFinishing"
+>): ActiveStreamRecoverySurfaceCopy {
+ return {
+ eyebrow: forceFinishing
+ ? "СВЯЗЬ · ЛОКАЛЬНОЕ ЗАВЕРШЕНИЕ"
+ : presentation?.eyebrow ?? "СВЯЗЬ · АКТИВНЫЙ ПРИЁМ",
+ title: forceFinishing
+ ? "Завершаем локальный приём"
+ : presentation?.title ?? "Восстанавливаем соединение",
+ statusLabel: forceFinishing
+ ? "Локальное завершение"
+ : presentation?.statusLabel ?? "Восстановление связи",
+ detail: forceFinishing
+ ? "Закрываем только локальный приём и сохранение. Команда STOP устройству не отправляется."
+ : presentation?.detail ?? "Проверяем состояние активного приёма.",
+ showSpinner: forceFinishing || presentation?.showSpinner === true,
+ forceFinishAvailable:
+ !forceFinishing && presentation?.forceFinishAvailable === true,
+ };
+}
+
+function RecoveryState({
+ presentation,
+ copy,
+}: {
+ presentation: ActiveStreamRecoveryPresentation | null;
+ copy: ActiveStreamRecoverySurfaceCopy;
+}) {
+ const stateClassName = copy.showSpinner
+ ? "active-stream-recovery__state"
+ : "active-stream-recovery__state active-stream-recovery__state--static";
+ return (
+
+ {copy.showSpinner ?
: null}
+
+ {copy.title}
+ {copy.detail}
+ {presentation?.progressLabel ? (
+ {presentation.progressLabel}
+ ) : null}
+
+
+ );
+}
+
+function RecoveryAction({
+ visible,
+ actionBusy,
+ compact,
+ onForceFinish,
+}: {
+ visible: boolean;
+ actionBusy: boolean;
+ compact: boolean;
+ onForceFinish: () => void;
+}) {
+ if (!visible) return null;
+ return (
+
+
+ Прервать соединение
+
+
+ Завершит только локальный front/back-приём и сохранение. START, STOP,
+ Bluetooth и настройки устройства не отправляются.
+
+
+ );
+}
+
+/**
+ * One shared recovery owner for the connection and spatial workspaces.
+ *
+ * The surface never chooses a mutation by itself: its sole callback is the
+ * explicitly fenced local force-finish action supplied by the K1 controller.
+ */
+export function ActiveStreamRecoverySurface({
+ presentation,
+ forceFinishing,
+ actionBusy,
+ onForceFinish,
+ variant = "panel",
+}: ActiveStreamRecoverySurfaceProps) {
+ const copy = surfaceCopy({ presentation, forceFinishing });
+ const tone = forceFinishing ? "neutral" : presentation?.tone ?? "neutral";
+ const content = (
+ <>
+
+
+ >
+ );
+
+ if (variant === "compact") {
+ return (
+
+
+ {copy.eyebrow}
+ {copy.statusLabel}
+
+
+ {content}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {content}
+
+
+ );
+}
diff --git a/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx
index b08aacd..a6469dc 100644
--- a/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx
+++ b/plugins/xgrids-k1/frontend/src/components/K1AcquisitionPipeline.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
+ ActivityIndicator,
Button,
Checker,
GlassSurface,
@@ -12,6 +13,11 @@ import {
} from "@nodedc/ui-react";
import { profileSelectionForConnectionMode } from "../compatibility";
+import {
+ activeStreamForceFinishAuthority,
+ activeStreamRecoveryPresentation,
+ exactActiveStreamRecoveryLineage,
+} from "../activeStreamRecovery";
import {
SUPPORTED_GNSS_MODE,
SUPPORTED_MOUNT_TYPE,
@@ -22,48 +28,63 @@ import {
} from "../configuration";
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
import {
+ canIssueCanonicalStop,
+ connectionPolicyAllows,
isConfirmedLiveState,
- isSoftwareCommandedAcquisition,
+ isReleasedTerminalAcquisitionFailure,
+ currentAppliedConnectionTopology,
isSourceRuntimeBusy,
- isVendorWriteCapable,
+ isTerminalAcquisitionState,
recoverableAcquisition,
+ requiresCanonicalStopAfterTerminalLocalFailure,
sourceStatusLabel,
} from "../lifecycle";
-import { normalizeProjectName, validateProjectName } from "../projectName";
+import { connectionPolicyOperatorGuidance } from "../presentation";
+import {
+ normalizeProjectName,
+ projectNameAfterConnectionModeSelection,
+ shouldHydratePreparedProject,
+ validateProjectName,
+} from "../projectName";
import type { XgridsK1Controller } from "../runtimeContext";
-import type { OperatorPresenceConfirmation } from "../api";
+import {
+ activeStopTarget,
+ operatorActionPhysicalAcceptance,
+ preparationTarget,
+ preparedStartTarget,
+} from "../physicalCommandConfirmation";
+import { ActiveStreamRecoverySurface } from "./ActiveStreamRecoverySurface";
type SessionIntent = "live" | "replay";
const sessionItems = [
- { value: "live", label: "Реальное устройство" },
+ { value: "live", label: "Прямой приём" },
{ value: "replay", label: "Повтор записи" },
] satisfies Array<{ value: SessionIntent; label: string }>;
-const PHYSICAL_ACCEPTANCE = {
- operator_present: true,
- owner_controlled_device: true,
- lixelgo_closed: true,
- battery_storage_confirmed: true,
- expected_physical_state_confirmed: true,
-} satisfies OperatorPresenceConfirmation;
-
export function K1AcquisitionPipeline({
controller,
+ desiredConnectionMode = "bridge",
openSpatialScene,
activateAutomaticSpatialSource,
}: {
controller: XgridsK1Controller;
+ desiredConnectionMode?: "bridge" | "quick-connect" | "direct-connect";
openSpatialScene: () => void;
activateAutomaticSpatialSource: () => void;
}) {
const {
state,
pendingAction,
+ physicalStopIntentSpent,
+ physicalStopInFlight,
closeApplicationControlSession,
- startCanonicalAcquisition,
+ prepareCanonicalAcquisition,
+ startPreparedAcquisition,
startReplay,
stop,
+ stopLocalReceiver,
+ forceFinishActiveStreamLocally,
abort,
} = controller;
const [sessionIntent, setSessionIntent] = useState("live");
@@ -75,13 +96,59 @@ export function K1AcquisitionPipeline({
const [mountType, setMountType] = useState(SUPPORTED_MOUNT_TYPE);
const [gnssMode, setGnssMode] = useState(SUPPORTED_GNSS_MODE);
const hydratedAcquisitionId = useRef(null);
+ const previousDesiredConnectionMode = useRef(desiredConnectionMode);
const activeAcquisition = recoverableAcquisition(state);
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
const projectNameValidation = validateProjectName(projectName);
- const vendorWriteCapable = isVendorWriteCapable(state);
const control = state?.application_control_session;
const controlPhase = control?.state ?? "idle";
+ const appliedTopology = currentAppliedConnectionTopology(state);
+ const connectionMode = desiredConnectionMode;
+ const backendDesiredConnectionMode = state?.desired_connection_mode
+ ?? desiredConnectionMode;
+ const configuredConnectionMode = state?.configured_connection_mode
+ ?? state?.connection_mode
+ ?? null;
+ const activeConnectionMode = state?.active_connection_mode
+ ?? (appliedTopology?.status === "active" ? appliedTopology.connectionMode : null);
+ const desiredSelectionCommitted = backendDesiredConnectionMode
+ === desiredConnectionMode;
+ const desiredModeMatchesActive = desiredSelectionCommitted
+ && activeConnectionMode === desiredConnectionMode;
+ const modeSwitchRequired = Boolean(
+ !desiredSelectionCommitted
+ || (activeConnectionMode && !desiredModeMatchesActive)
+ || (configuredConnectionMode && configuredConnectionMode !== desiredConnectionMode),
+ );
+ const connectionConfigured = Boolean(
+ appliedTopology?.status === "active"
+ && desiredModeMatchesActive
+ && state?.connection_lifecycle?.ready_to_start === true,
+ );
+
+ useEffect(() => {
+ if (previousDesiredConnectionMode.current === desiredConnectionMode) return;
+ previousDesiredConnectionMode.current = desiredConnectionMode;
+ const projectNameAfterSelection = projectNameAfterConnectionModeSelection(
+ preparedAcquisition?.project_name,
+ );
+ // A prepared acquisition is immutable backend state, not a draft owned by
+ // this selector. Preserve its project while the operator previews another
+ // mode so selecting the active mode again can resume START immediately.
+ if (preparedAcquisition) {
+ setProjectName(projectNameAfterSelection);
+ setProjectNameTouched(false);
+ return;
+ }
+ // Draft project fields belong to the previously selected transport. The
+ // dropdown sends no physical command; Connect performs the later bounded
+ // mode transaction, while START remains fenced in the meantime.
+ setProjectName(projectNameAfterSelection);
+ setProjectNameTouched(false);
+ setMountType(SUPPORTED_MOUNT_TYPE);
+ setGnssMode(SUPPORTED_GNSS_MODE);
+ }, [desiredConnectionMode, preparedAcquisition]);
useEffect(() => {
if (state?.source_mode === "live" || state?.source_mode === "replay") {
@@ -93,11 +160,35 @@ export function K1AcquisitionPipeline({
useEffect(() => {
const acquisitionId = preparedAcquisition?.acquisition_id ?? null;
- if (!acquisitionId || hydratedAcquisitionId.current === acquisitionId) return;
+ if (!shouldHydratePreparedProject({
+ acquisitionId,
+ hydratedAcquisitionId: hydratedAcquisitionId.current,
+ modeSwitchRequired,
+ })) return;
hydratedAcquisitionId.current = acquisitionId;
setProjectName(preparedAcquisition?.project_name ?? "");
setProjectNameTouched(false);
- }, [preparedAcquisition?.acquisition_id, preparedAcquisition?.project_name]);
+ }, [
+ modeSwitchRequired,
+ preparedAcquisition?.acquisition_id,
+ preparedAcquisition?.project_name,
+ ]);
+
+ useEffect(() => {
+ const acquisition = state?.acquisition;
+ if (
+ hydratedAcquisitionId.current === null
+ || !acquisition
+ || acquisition.acquisition_id !== hydratedAcquisitionId.current
+ || !isTerminalAcquisitionState(acquisition.state)
+ || state?.source_mode !== "idle"
+ ) return;
+ hydratedAcquisitionId.current = null;
+ setProjectName("");
+ setProjectNameTouched(false);
+ setMountType(SUPPORTED_MOUNT_TYPE);
+ setGnssMode(SUPPORTED_GNSS_MODE);
+ }, [state?.acquisition, state?.source_mode]);
const isBusy = pendingAction !== null;
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
@@ -108,10 +199,50 @@ export function K1AcquisitionPipeline({
: activeAcquisition
? "live"
: sessionIntent;
- const sourceLabel = sourceStatusLabel(state);
- const relevantAcquisitionFailed = state?.source_mode !== "replay" && state?.acquisition?.state === "failed";
+ const gracefulStopTarget = activeStopTarget(state);
+ const terminalPhysicalStopObserved =
+ requiresCanonicalStopAfterTerminalLocalFailure(state);
+ const physicalStopExecutable = Boolean(
+ gracefulStopTarget
+ && canIssueCanonicalStop(state, physicalStopIntentSpent),
+ );
+ const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
+ const localReceiverStopExecutable = Boolean(
+ connectionPolicyAllows(state, "stop-local-receiver")
+ && preparedAcquisition === null,
+ );
+ const terminalPhysicalStopPending = terminalPhysicalStopObserved
+ && physicalStopInFlight;
+ const recoveredPhysicalStop = terminalPhysicalStopObserved
+ && physicalStopExecutable
+ && !physicalStopInFlight;
+ const terminalLocalRecovery = terminalPhysicalStopObserved
+ && !physicalStopPresented
+ && localReceiverStopExecutable;
+ const terminalReadOnlyRecovery = terminalPhysicalStopObserved
+ && !physicalStopPresented
+ && !localReceiverStopExecutable;
+ const terminalLocalCapturePending = Boolean(
+ state?.acquisition?.cleanup_pending === true
+ || state?.source_mode === "live",
+ );
+ const sourceLabel = terminalPhysicalStopPending
+ ? "Команда отправлена"
+ : recoveredPhysicalStop
+ ? "Требуется остановка"
+ : terminalLocalRecovery
+ ? "Локальное завершение доступно"
+ : terminalReadOnlyRecovery
+ ? "Действия заблокированы"
+ : sourceStatusLabel(state);
+ const releasedAcquisitionFailure = isReleasedTerminalAcquisitionFailure(state);
+ const relevantAcquisitionFailed = state?.source_mode !== "replay"
+ && state?.acquisition?.state === "failed"
+ && !releasedAcquisitionFailure;
const sourceTone: StatusTone =
- state?.phase === "error" || relevantAcquisitionFailed
+ terminalPhysicalStopObserved
+ ? "warning"
+ : (state?.phase === "error" && !releasedAcquisitionFailure) || relevantAcquisitionFailed
? "danger"
: isConfirmedLiveState(state) || state?.source_mode === "replay"
? "success"
@@ -122,42 +253,105 @@ export function K1AcquisitionPipeline({
() => sessionItems.map((item) => ({ ...item, disabled: sessionLocked })),
[sessionLocked],
);
+ const activeRecoveryPresentation = activeStreamRecoveryPresentation(state);
+ const activeRecoveryForceFinishAuthority = activeStreamForceFinishAuthority(state);
+ const activeRecoveryLineage = exactActiveStreamRecoveryLineage(state);
+ const recoveredActiveSession = Boolean(
+ activeRecoveryLineage?.recovery.state === "recovered"
+ && state?.phase === "live"
+ && state.source_mode === "live"
+ && activeAcquisition?.state === "acquiring"
+ && activeRecoveryLineage.acquisitionId === activeAcquisition.acquisition_id,
+ );
+ const recoveredActiveSessionLabel = activeAcquisition?.project_name?.trim()
+ || activeAcquisition?.acquisition_id
+ || "текущая сессия";
+ const localForceFinishPending = pendingAction === "force-finish";
+
+ if (activeRecoveryPresentation || localForceFinishPending) {
+ return (
+ {
+ if (!activeRecoveryForceFinishAuthority) return;
+ void forceFinishActiveStreamLocally();
+ }}
+ />
+ );
+ }
const preparedCanonicalLaunch =
preparedAcquisition?.control_mode === "plugin-commanded";
const launchBlockedByAcquisition =
activeAcquisition !== null && !preparedCanonicalLaunch;
const controlRetryBlocked =
controlPhase === "failed" && control?.can_open !== true;
+ const finalStartTarget = preparedStartTarget(state);
+ const draftPreparationTarget = preparationTarget(
+ state,
+ projectNameValidation.value,
+ );
+ const physicalStartAllowed = connectionPolicyAllows(state, "start-acquisition");
+ const physicalStartGuidance = finalStartTarget && !physicalStartAllowed
+ ? connectionPolicyOperatorGuidance(state, "start-acquisition")
+ : null;
+ const physicalStopGuidance = gracefulStopTarget
+ && !physicalStopPresented
+ && !terminalPhysicalStopObserved
+ ? connectionPolicyOperatorGuidance(state, "stop-acquisition")
+ : null;
+ const physicalStopGuidanceCopy = terminalPhysicalStopPending
+ ? "Команда остановки устройства уже отправлена. Ждём новое подтверждённое состояние; повторная команда не отправляется."
+ : terminalLocalRecovery
+ ? physicalStopIntentSpent
+ ? "Команда завершилась без нового подтверждённого результата. Повторная команда устройству не отправляется; завершите только локальный приём."
+ : "Управляющая команда устройству сейчас недоступна. Завершите только разрешённый сервером локальный приём или выполните read-only восстановление."
+ : terminalReadOnlyRecovery
+ ? "Управляющие действия сейчас не разрешены. Дождитесь нового подтверждённого состояния или выполните read-only восстановление."
+ : physicalStopGuidance
+ ? `${physicalStopGuidance.reason} ${physicalStopGuidance.nextAction}`
+ : gracefulStopTarget && !physicalStopPresented && physicalStopIntentSpent
+ ? "Команда завершилась без нового подтверждённого результата. Повторная команда устройству не отправляется; завершите только локальный приём."
+ : gracefulStopTarget && !physicalStopPresented
+ ? "Команда устройству недоступна в текущем подтверждённом состоянии. Завершите только локальный приём или выполните read-only восстановление."
+ : null;
- const startLive = async () => {
+ const submitFinalStart = async () => {
+ const physicalAcceptance = operatorActionPhysicalAcceptance();
+ await runAutomaticSpatialSourceStart(
+ () => startPreparedAcquisition(physicalAcceptance),
+ activateAutomaticSpatialSource,
+ openSpatialScene,
+ );
+ };
+
+ const requestLivePreparation = async () => {
setProjectNameTouched(true);
if (
- !state?.k1_ip ||
+ !connectionConfigured ||
+ !connectionMode ||
sourceRuntimeBusy ||
launchBlockedByAcquisition ||
controlRetryBlocked ||
projectNameValidation.error
) return;
- const timezoneName = Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC";
- await runAutomaticSpatialSourceStart(
- () => startCanonicalAcquisition({
- control: {
- ...PHYSICAL_ACCEPTANCE,
- timezone_name: timezoneName,
- },
- acquisition: {
- project_name: projectNameValidation.value,
- mount_type: SUPPORTED_MOUNT_TYPE,
- gnss_mode: SUPPORTED_GNSS_MODE,
- compatibility_attestation: profileSelectionForConnectionMode(
- state.connection_mode ?? "bridge",
- ),
- },
- physicalAcceptance: PHYSICAL_ACCEPTANCE,
- }),
- activateAutomaticSpatialSource,
- openSpatialScene,
- );
+ if (finalStartTarget) {
+ if (!physicalStartAllowed) return;
+ await submitFinalStart();
+ return;
+ }
+ if (!draftPreparationTarget) return;
+ const prepared = await prepareCanonicalAcquisition({
+ acquisition: {
+ project_name: projectNameValidation.value,
+ mount_type: SUPPORTED_MOUNT_TYPE,
+ gnss_mode: SUPPORTED_GNSS_MODE,
+ compatibility_attestation: profileSelectionForConnectionMode(connectionMode),
+ },
+ });
+ if (!prepared) return;
+ await submitFinalStart();
};
const submitReplay = async () => {
@@ -177,8 +371,8 @@ export function K1AcquisitionPipeline({
- {effectiveSessionIntent === "live" ? "ШАГИ 04–05 · ПРОЕКТ И ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
-
{effectiveSessionIntent === "live" ? "Назовите проект и запустите приём" : "Повторите запись"}
+ {terminalPhysicalStopPending ? "ВОССТАНОВЛЕНИЕ · КОМАНДА ОТПРАВЛЕНА" : recoveredPhysicalStop ? "ВОССТАНОВЛЕНИЕ · ОСТАНОВКА" : terminalLocalRecovery ? "ВОССТАНОВЛЕНИЕ · ЛОКАЛЬНЫЙ КОНТУР" : terminalReadOnlyRecovery ? "ВОССТАНОВЛЕНИЕ · ТОЛЬКО ЧТЕНИЕ" : recoveredActiveSession ? "СВЯЗЬ ВОССТАНОВЛЕНА · АКТИВНЫЙ ПРИЁМ" : effectiveSessionIntent === "live" ? "ШАГИ 04–05 · ПРОЕКТ И ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
+ {terminalPhysicalStopPending ? "Ожидаем подтверждение устройства" : recoveredPhysicalStop ? "Сканирование продолжается" : terminalLocalRecovery ? "Завершите локальный приём" : terminalReadOnlyRecovery ? "Ожидайте подтверждённое состояние" : recoveredActiveSession ? "Связь восстановлена · приём продолжается" : effectiveSessionIntent === "live" ? "Назовите проект и запустите приём" : "Повторите запись"}
{sourceLabel}
@@ -188,7 +382,55 @@ export function K1AcquisitionPipeline({
items={selectableSessionItems}
onChange={(intent) => { if (!sessionLocked) setSessionIntent(intent); }}
/>
- {effectiveSessionIntent === "live" ? (
+ {terminalPhysicalStopObserved ? (
+
+
+ {terminalPhysicalStopPending ? (
+ <>
+ {terminalLocalCapturePending ? "Локальный приём ещё требует завершения" : "Локальная запись завершена"}
+ Команда остановки устройства уже отправлена
+
+ Ждём новое подтверждённое состояние K1. Повторная команда устройству не отправляется.
+
+ >
+ ) : recoveredPhysicalStop ? (
+ <>
+ {terminalLocalCapturePending ? "Локальный приём ещё требует завершения" : "Локальная запись завершена"}
+ Сканирование подтверждено; требуется явный STOP
+
+ Нажмите «Остановить сканирование» ниже или в пространственной сцене. Новый проект, START и настройка сети останутся заблокированы до подтверждённого READY.
+
+ >
+ ) : terminalLocalRecovery ? (
+ <>
+ Команды устройству заблокированы
+ Доступно локальное завершение приёма
+
+ Повторная команда K1 не отправляется. Завершите локальный приём или выполните read-only восстановление.
+
+ >
+ ) : (
+ <>
+ Управляющие действия заблокированы
+ Доступно только read-only восстановление
+
+ Дождитесь нового подтверждённого состояния; локальные и управляющие команды сейчас не разрешены.
+
+ >
+ )}
+
+
+ ) : recoveredActiveSession ? (
+
+
+ Исходная сессия · {recoveredActiveSessionLabel}
+ Продолжаем тот же приём без нового START
+
+ Автоматическое восстановление не отправляло START, STOP, Bluetooth или настройки сети. Явная остановка ниже доступна только при текущем подтверждённом праве на STOP.
+
+
+
+ ) : effectiveSessionIntent === "live" ? (
@@ -228,40 +470,53 @@ export function K1AcquisitionPipeline({
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
description={projectNameTouched && projectNameValidation.error
? projectNameValidation.error
- : "Отдельной команды сохранения имени на K1 нет: оно отправляется только при START."}
+ : "Имя отправляется только при START; отдельной команды сохранения нет."}
placeholder="Например, TEST001"
/>
}
+ aria-busy={pendingAction === "live"}
+ icon={pendingAction === "live"
+ ?
+ :
}
disabled={
isBusy ||
- !state?.k1_ip ||
+ !connectionConfigured ||
projectNameValidation.error !== null ||
sourceRuntimeBusy ||
launchBlockedByAcquisition ||
- controlRetryBlocked
+ controlRetryBlocked ||
+ modeSwitchRequired ||
+ Boolean(finalStartTarget && !physicalStartAllowed)
}
- onClick={() => void startLive()}
+ onClick={() => void requestLivePreparation()}
>
{pendingAction === "live"
? controlPhase === "connecting"
- ? "Синхронизация с K1…"
+ ? "Синхронизация…"
: controlPhase === "workspace-requested"
? "Входим в рабочее пространство…"
: controlPhase === "project-requested"
? "Готовим проект и локальный приём…"
: controlPhase === "start-requested" || controlPhase === "initializing"
- ? "Калибровка оборудования…"
- : "Запускаем K1 и локальный приём…"
- : preparedCanonicalLaunch
- ? "Продолжить запуск сканирования и приёма"
- : "Запустить сканирование и локальный приём"}
+ ? "Запускаем приём…"
+ : "Подготавливаем проект и локальный приём…"
+ : finalStartTarget
+ ? "Запустить приём"
+ : preparedCanonicalLaunch
+ ? "Продолжить запуск"
+ : "Запустить приём"}
- Нажатие запуска — явное операторское действие для выбранного K1. Автоматических повторов START нет.
+ {modeSwitchRequired
+ ? `Выбран другой способ связи. Сначала установите подключение через ${desiredConnectionMode === "bridge" ? "Bridge" : desiredConnectionMode === "quick-connect" ? "Quick Connect" : "Direct Connect"}.`
+ : !connectionConfigured
+ ? "Сначала завершите подключение в выбранном режиме. START не используется для установки связи."
+ : physicalStartGuidance
+ ? `${physicalStartGuidance.reason} ${physicalStartGuidance.nextAction}`
+ : "Одно нажатие выполняет каноническую подготовку и один START после подтверждённого READY. Автоматических повторов команд нет."}
- {control?.control_socket_open && !activeAcquisition && !isBusy ? (
+ {control?.control_socket_open && !activeAcquisition && !recoveredPhysicalStop && !isBusy ? (
{controlPhase === "failed"
- ? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}`
+ ? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручное действие"}`
: controlPhase === "connecting"
- ? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ K1."
+ ? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ."
: controlPhase === "workspace-requested"
? "После подтверждённых операций 1–6 выполняется вход в рабочее пространство."
: controlPhase === "project-requested"
- ? "Выполняются операции 8–10 и готовится локальный приём; имя ещё не отправляется на K1."
+ ? "Выполняются операции 8–10 и готовится локальный приём; имя ещё не отправляется."
: controlPhase === "start-requested" || controlPhase === "initializing"
- ? "Калибровка оборудования. Не перемещайте K1; временных переходов и повторных команд нет."
+ ? "Калибровка оборудования. Не перемещайте сканер; временных переходов и повторных команд нет."
: controlPhase === "scanning"
- ? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене."
- : "Одна кнопка выражает намерение запустить сканирование. Совместимость подтверждается живым DeviceInfo; этапы идут строго по записанному порядку и только после ответов K1."}
+ ? "Режим сканирования и инициализация подтверждены. Остановка доступна в пространственной сцене."
+ : "Одна кнопка запускает весь процесс. Совместимость подключения подтверждается автоматически; каждый следующий этап начинается только после подтверждения результата."}
) : (
-
setReplayPath(event.target.value)} spellCheck={false} placeholder="sessions/.../capture.tsv" />
+ setReplayPath(event.target.value)} spellCheck={false} placeholder="sessions/.../capture.tsv" />
setReplaySpeed(event.target.value)} />
После последнего кадра начать запись заново.
@@ -301,25 +556,47 @@ export function K1AcquisitionPipeline({
)}
- {state?.source_mode === "replay"
+ {physicalStopGuidanceCopy
+ ? physicalStopGuidanceCopy
+ : recoveredPhysicalStop && physicalStopExecutable
+ ? terminalLocalCapturePending
+ ? "Локальный приём ещё требует завершения. Эта кнопка отправит ровно один явный STOP и дождётся подтверждённого результата."
+ : "Локальная запись уже остановлена. Эта кнопка отправит ровно один явный STOP и дождётся READY."
+ : state?.source_mode === "replay"
? "Остановка завершит фактически запущенный повтор записи."
: activeAcquisition || state?.source_mode === "live"
- ? vendorWriteCapable && activeAcquisition?.control_mode === "plugin-commanded"
- ? "Остановка отправит профилированную команду K1 и дождётся завершения локального сохранения."
- : "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
+ ? physicalStopPresented
+ ? "Остановка отправит профилированную команду и дождётся завершения локального сохранения."
+ : localReceiverStopExecutable
+ ? "Остановка завершает только локальный приём и сохранение. Состояние сканирования остаётся неизвестным."
+ : "Действие остановки сейчас не разрешено. Дождитесь нового подтверждённого состояния или выполните read-only восстановление."
: "Активного источника сейчас нет."}
-
void stop(
- isSoftwareCommandedAcquisition(state) ? PHYSICAL_ACCEPTANCE : undefined,
- )}
- >
- {pendingAction === "stop"
- ? state?.source_mode === "replay" ? "Останавливаем повтор…" : "Останавливаем локальный приём…"
- : state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Остановить локальный приём"}
-
+ {physicalStopPresented || localReceiverStopExecutable ? (
+
{
+ if (physicalStopExecutable) {
+ void stop(operatorActionPhysicalAcceptance());
+ return;
+ }
+ if (localReceiverStopExecutable) {
+ void stopLocalReceiver();
+ }
+ }}
+ >
+ {physicalStopInFlight
+ ? "Останавливаем устройство…"
+ : pendingAction === "stop"
+ ? physicalStopPresented ? "Останавливаем устройство…" : state?.source_mode === "replay" ? "Останавливаем повтор…" : "Завершаем локальный приём…"
+ : physicalStopPresented ? recoveredPhysicalStop ? "Остановить сканирование" : "Остановить устройство и запись" : state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Завершить локальный приём"}
+
+ ) : null}
{activeAcquisition ? (
void abort()}>
{pendingAction === "abort" ? "Прерываем локальную операцию…" : preparedAcquisition ? "Отменить подготовку" : "Аварийно завершить локальный приём"}
diff --git a/plugins/xgrids-k1/frontend/src/components/K1Diagnostics.tsx b/plugins/xgrids-k1/frontend/src/components/K1Diagnostics.tsx
index aca35e3..0ee4d17 100644
--- a/plugins/xgrids-k1/frontend/src/components/K1Diagnostics.tsx
+++ b/plugins/xgrids-k1/frontend/src/components/K1Diagnostics.tsx
@@ -8,7 +8,11 @@ import {
formatNumber,
pipelineLatency,
} from "../presentation";
-import { isConfirmedLiveState } from "../lifecycle";
+import {
+ activeConnectionEndpointLabel,
+ backendConnectionTopology,
+ isConfirmedLiveState,
+} from "../lifecycle";
import type { XgridsK1Controller } from "../runtimeContext";
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
@@ -33,6 +37,18 @@ export function K1Diagnostics({ controller, sourceLabel }: {
const { state, backendStatus, eventStatus, latencyHistory } = controller;
const streamActive = isConfirmedLiveState(state) || state?.source_mode === "replay";
const latency = pipelineLatency(streamActive ? state?.metrics : undefined);
+ const activeEndpoint = activeConnectionEndpointLabel(state);
+ const topology = backendConnectionTopology(state);
+ const unverifiedEndpoint = topology?.status !== "active" ? topology?.endpoint : null;
+ const endpointLabel = activeEndpoint
+ ? "Адрес подключения"
+ : topology?.source === "durable"
+ ? "Адрес конфигурации"
+ : topology?.source === "last-known"
+ ? "Адрес конфигурации"
+ : topology?.source === "applied"
+ ? "Адрес конфигурации"
+ : "Адрес подключения";
return (
@@ -43,7 +59,20 @@ export function K1Diagnostics({ controller, sourceLabel }: {
{eventStatusLabel(eventStatus)}
{sourceLabel}
- {state?.k1_ip || "Не получен"}
+
+ {activeEndpoint
+ ? {activeEndpoint}
+ : unverifiedEndpoint
+ ? (
+
+ {unverifiedEndpoint}
+ {topology?.status === "configured-unverified"
+ ? " · подключение ещё не подтверждено"
+ : " · связь не подтверждена"}
+
+ )
+ : Не получен }
+
diff --git a/plugins/xgrids-k1/frontend/src/components/K1Metrics.tsx b/plugins/xgrids-k1/frontend/src/components/K1Metrics.tsx
index 8eb247d..0cb1512 100644
--- a/plugins/xgrids-k1/frontend/src/components/K1Metrics.tsx
+++ b/plugins/xgrids-k1/frontend/src/components/K1Metrics.tsx
@@ -1,12 +1,14 @@
-import { isConfirmedLiveState } from "../lifecycle";
+import { hasAuthoritativeData, isConfirmedLiveState } from "../lifecycle";
import { finiteMetric, formatNumber, pipelineLatency } from "../presentation";
import type { XgridsK1Controller } from "../runtimeContext";
import { MetricCard } from "./MetricCard";
export function K1Metrics({ controller }: { controller: XgridsK1Controller }) {
const { state } = controller;
- const streamActive = isConfirmedLiveState(state) || state?.source_mode === "replay";
- const metrics = streamActive ? state?.metrics : undefined;
+ const streamAuthoritative = state?.source_mode === "replay" || Boolean(
+ isConfirmedLiveState(state) && hasAuthoritativeData(state),
+ );
+ const metrics = streamAuthoritative ? state?.metrics : undefined;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
@@ -35,7 +37,7 @@ export function K1Metrics({ controller }: { controller: XgridsK1Controller }) {
);
diff --git a/plugins/xgrids-k1/frontend/src/components/K1OperatorError.tsx b/plugins/xgrids-k1/frontend/src/components/K1OperatorError.tsx
new file mode 100644
index 0000000..66b5156
--- /dev/null
+++ b/plugins/xgrids-k1/frontend/src/components/K1OperatorError.tsx
@@ -0,0 +1,225 @@
+import { useState, type ReactNode } from "react";
+import { Button } from "@nodedc/ui-react";
+
+import type { XgridsConnectionAttempt } from "../api";
+import { hostFailureDiagnosticPresentation } from "../hostDiagnosticPresentation";
+
+const connectionAttemptStageLabels: Record = {
+ accepted: "Запрос принят",
+ "scan-selection-admitted": "Результат выбран",
+ "host-wifi-profile-preflight": "Подготовка профиля Wi‑Fi",
+ "device-ap-activation": "Подготовка локальной сети",
+ "ble-provisioning-write": "Передаются настройки сети",
+ "ble-write-dispatched": "Настройки переданы",
+ "status-observing": "Ожидание ответа",
+ "device-topology-applied": "Целевая сеть подтверждена",
+ "host-wifi-association": "Настройка связи с сетью",
+ "control-endpoint-admission": "Подготовка управляющего канала",
+ connected: "Связь подтверждена",
+ "network-configured": "Сеть настроена",
+};
+
+function attemptStageLabel(attempt: XgridsConnectionAttempt): string {
+ const normalized = attempt.stage.replace(/-failed$/, "");
+ return connectionAttemptStageLabels[normalized] ?? "Подключение остановлено";
+}
+
+function attemptSideEffectLabel(value: string): string {
+ if (value === "none") return "Команда не отправлялась";
+ if (value === "applied") return "Целевая сеть подтверждена";
+ if (value === "confirmed") return "Передача команды подтверждена";
+ return "Результат команды не подтверждён";
+}
+
+export function attemptNetworkPhaseLabel(
+ value: XgridsConnectionAttempt["phase"],
+): string {
+ const phase = String(value);
+ if (phase === "network_applied") return "Настройки сети применены";
+ if (phase === "network_outcome_unknown") {
+ return "Результат применения настроек сети не подтверждён";
+ }
+ return "Настройки сети не применены";
+}
+
+function attemptControlStateLabel(
+ value: XgridsConnectionAttempt["control_state"],
+): string {
+ if (value === "ready") return "Управляющее подключение подтверждено";
+ if (value === "control_not_ready") return "Управляющее подключение не подтверждено";
+ return "Состояние управляющего подключения неизвестно";
+}
+
+export function attemptNextActionLabel(
+ value: XgridsConnectionAttempt["safe_next_action"],
+): string {
+ switch (value) {
+ case "wait-for-current-attempt":
+ return "Дождаться завершения текущей попытки";
+ case "continue-with-control-verification":
+ return "Продолжить текущее подключение";
+ case "verify-control-read-only":
+ return "Проверить управление без изменения сети";
+ case "start-acquisition":
+ return "Готово к запуску приёма";
+ case "stop-local-receiver":
+ return "Завершить только локальный приём";
+ case "retire-unavailable-physical-target":
+ return "Исключить недоступный прежний K1 и выбрать другой";
+ case "scan-select-connect":
+ return "Выполнить новый поиск и выбрать результат";
+ case "manual-recovery-required":
+ return "Требуется ручное восстановление";
+ }
+}
+
+const publicConnectionErrorLabels: Readonly> = {
+ "network-provision-discovery-generation-conflict":
+ "Результат Bluetooth-поиска устарел до отправки. Настройки устройства не изменялись; выполните новый поиск.",
+ "connection-mode-draft-revision-conflict":
+ "Способ подключения изменился до запуска операции. Настройки устройства не изменялись; повторите явное действие.",
+ "connection-mode-draft-mismatch":
+ "Выбранный способ подключения ещё не подтверждён локальным контуром. Настройки устройства не изменялись.",
+ "physical-command-reconciliation-required":
+ "Сначала завершите отдельную проверку физического состояния K1 без изменений устройства. Новая команда не отправлялась.",
+ "physical-device-already-active":
+ "K1 всё ещё подтверждён в активном сканировании. Сначала выполните явную остановку; новая сетевая команда не отправлялась.",
+};
+
+function publicConnectionErrorLabel(
+ attempt: XgridsConnectionAttempt | null | undefined,
+ structured: ReturnType,
+): string {
+ const publicCode = attempt?.public_error_code?.trim();
+ if (publicCode && publicConnectionErrorLabels[publicCode]) {
+ return publicConnectionErrorLabels[publicCode];
+ }
+ return structured
+ ? "Системный контур безопасно остановил операцию. Автоматического повтора не было."
+ : "Подключение не завершено. Автоматического повтора не было.";
+}
+
+export function K1OperatorError({
+ diagnostic,
+ attempt,
+ title = "Локальная операция завершилась ошибкой",
+ recoveryActions,
+ compact = false,
+ showDefaultActions = true,
+ onRefresh,
+ onClear,
+}: {
+ /** Kept for call-site compatibility; unreviewed exception text is never rendered. */
+ message?: string;
+ diagnostic?: unknown;
+ attempt?: XgridsConnectionAttempt | null;
+ title?: string;
+ recoveryActions?: ReactNode;
+ compact?: boolean;
+ showDefaultActions?: boolean;
+ onRefresh: () => void;
+ onClear: () => void;
+}) {
+ const structured = hostFailureDiagnosticPresentation(diagnostic);
+ const [diagnosticCopied, setDiagnosticCopied] = useState(false);
+ const copyDiagnosticBundle = async () => {
+ if (!attempt?.diagnostic_bundle || !navigator.clipboard) return;
+ await navigator.clipboard.writeText(
+ JSON.stringify(attempt.diagnostic_bundle, null, 2),
+ );
+ setDiagnosticCopied(true);
+ };
+ const hasDetails = Boolean(structured || attempt);
+ return (
+
+
+
+
{title}
+
{publicConnectionErrorLabel(attempt, structured)}
+ {recoveryActions ? (
+
+ {recoveryActions}
+
+ ) : null}
+ {hasDetails ? (
+
+ Подробности и диагностика
+ {structured ? (
+
+
+
Причина
+ {structured.codeLabel}
+
+
+
Системный контур
+ {structured.domainLabel}
+
+
+
Влияние
+ {structured.impactLabel}
+
+
+
Что сделать
+ {structured.operatorActionLabel}
+
+
+ ) : null}
+ {attempt ? (
+
+
+
Попытка
+ {attempt.attempt_id}
+
+
+
Остановлено на шаге
+ {attemptStageLabel(attempt)}
+
+
+
Что изменилось
+ {attemptSideEffectLabel(attempt.side_effect_status)}
+
+
+
Сеть
+ {attemptNetworkPhaseLabel(attempt.phase)}
+
+
+
Управление
+ {attemptControlStateLabel(attempt.control_state)}
+
+
+
Безопасное действие
+ {attemptNextActionLabel(attempt.safe_next_action)}
+
+
+ ) : null}
+
+ ) : null}
+
+ {attempt?.diagnostic_bundle || showDefaultActions ? (
+
+ {attempt?.diagnostic_bundle ? (
+ void copyDiagnosticBundle()}>
+ {diagnosticCopied ? "Диагностика скопирована" : "Скопировать диагностику"}
+
+ ) : null}
+ {showDefaultActions ? (
+ <>
+
+ Проверить состояние
+
+
+ Закрыть
+
+ >
+ ) : null}
+
+ ) : null}
+
+ );
+}
diff --git a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx
index feef3c1..47fc2fb 100644
--- a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx
+++ b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx
@@ -1,16 +1,34 @@
-import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import {
+ ActivityIndicator,
Button,
- Checker,
GlassSurface,
Icon,
+ IconButton,
Select,
StatusBadge,
TextField,
type StatusTone,
} from "@nodedc/ui-react";
-import type { BleDevice } from "../api";
+import type {
+ BleDevice,
+ ConnectionVerifyRequest,
+ ReopenRetiredPhysicalReconciliationRequest,
+ XgridsConnectionAttempt,
+ XgridsK1State,
+ XgridsConnectionReconfiguration,
+ XgridsConnectionReconfigurationIntent,
+} from "../api";
import { profileSelectionForConnectionMode } from "../compatibility";
import {
DEFAULT_CONNECTION_MODE,
@@ -18,13 +36,1053 @@ import {
type ConnectionMode,
} from "../configuration";
import {
+ backendConnectionTopology,
+ activeConnectionReconfiguration,
+ canAdmitProvisioningConnection,
canSubmitProvisioningMutation,
+ connectionAttemptForRuntimeError,
+ connectionPolicyAllows,
+ connectionPolicyDecision,
+ isRecoveredPhysicalScanning,
+ isPhysicalStopRecoverySettling,
isReachableConnectionLease,
+ locallyInitiatedBleSessionTarget,
+ newOperationId,
+ operationByIdempotencyKey,
provisioningCandidateById,
+ provisioningFailureRequiresFreshCandidate,
provisioningIntentKey,
+ readOnlyConnectionObservationTarget,
+ readOnlyPhysicalRecoveryBinding,
+ recommendedConnectionRecoveryObservationTarget,
+ reconfigurationAllowsFreshDevice,
+ requiresReadOnlyPhysicalRecovery,
+ reopenedPhysicalReconciliationMatches,
+ retiredPhysicalReopenAuthority,
+ serverBoundAppliedNetworkObservationTarget,
+ transportRefEquivalenceKey,
+ trustedConnectionBinding,
+ type ReadOnlyConnectionObservationTarget,
} from "../lifecycle";
import { finiteMetric } from "../presentation";
import type { XgridsK1Controller } from "../runtimeContext";
+import { K1OperatorError } from "./K1OperatorError";
+import {
+ connectionActionAuthoritySnapshot,
+ type BleDiscoverySubmitResult,
+ type ConnectionActionAuthoritySnapshot,
+} from "../useXgridsK1Runtime";
+
+interface CompletedLocalNetworkIntent {
+ deviceId: string;
+ connectionMode: ConnectionMode;
+ acceptedSessionKey: string | null;
+}
+
+interface DevicePresentationSnapshot {
+ deviceId: string;
+ label: string;
+ rssi: number | null;
+}
+
+export interface PhysicalReopenPresentation {
+ key: string;
+ snapshotRuntimeId: string;
+ request: ReopenRetiredPhysicalReconciliationRequest;
+ authority: ConnectionActionAuthoritySnapshot;
+}
+
+export function physicalReopenPresentationIsCurrent(
+ presentation: PhysicalReopenPresentation | null | undefined,
+ currentAuthority: ConnectionActionAuthoritySnapshot | null | undefined,
+): boolean {
+ return Boolean(
+ presentation
+ && currentAuthority
+ && presentation.snapshotRuntimeId === currentAuthority.snapshotRuntimeId
+ && connectionActionAuthorityMatches(
+ presentation.authority,
+ currentAuthority,
+ )
+ && presentation.request.expected_desired_mode
+ === currentAuthority.connectionMode
+ && presentation.request.expected_desired_mode_revision
+ === currentAuthority.desiredModeRevision
+ && presentation.request.expected_discovery_generation
+ === currentAuthority.discoveryGeneration
+ );
+}
+
+export function admitPhysicalReopenPresentation(
+ current: PhysicalReopenPresentation | null | undefined,
+ next: PhysicalReopenPresentation,
+ currentAuthority: ConnectionActionAuthoritySnapshot | null | undefined,
+): PhysicalReopenPresentation | null {
+ // Coalesce only the click which still owns the complete live authority.
+ // A same-runtime reset/reconfiguration/binding/discovery drift releases the
+ // stale owner synchronously, before React's post-render invalidation effect,
+ // so a fresh click cannot be blocked by a late request from the old fence.
+ if (physicalReopenPresentationIsCurrent(current, currentAuthority)) {
+ return null;
+ }
+ return physicalReopenPresentationIsCurrent(next, currentAuthority)
+ ? next
+ : null;
+}
+
+export function physicalReopenSettlementIsCurrent(
+ current: PhysicalReopenPresentation | null | undefined,
+ actionKey: string,
+ currentAuthority: ConnectionActionAuthoritySnapshot | null | undefined,
+): boolean {
+ return Boolean(
+ current?.key === actionKey
+ && physicalReopenPresentationIsCurrent(current, currentAuthority)
+ );
+}
+
+export function physicalReopenClickAuthority(
+ renderedState: XgridsK1State | null | undefined,
+ connectionMode: ConnectionMode,
+ currentAuthority: ConnectionActionAuthoritySnapshot | null | undefined,
+ reopenAuthority: ReturnType,
+): ConnectionActionAuthoritySnapshot | null {
+ const renderedAuthority = connectionActionAuthoritySnapshot(
+ renderedState,
+ connectionMode,
+ );
+ if (
+ !renderedAuthority
+ || !currentAuthority
+ || !reopenAuthority
+ || !connectionActionAuthorityMatches(renderedAuthority, currentAuthority)
+ || renderedAuthority.desiredModeRevision
+ !== reopenAuthority.expectedDesiredModeRevision
+ || renderedAuthority.discoveryGeneration
+ !== reopenAuthority.expectedDiscoveryGeneration
+ ) return null;
+ return renderedAuthority;
+}
+
+export interface ScenarioResetPresentationBoundary {
+ current: boolean;
+ active: boolean;
+ key: string | null;
+}
+
+export function scenarioResetPresentationBoundary(
+ state: XgridsK1State | null | undefined,
+ connectionMode: ConnectionMode,
+): ScenarioResetPresentationBoundary {
+ const marker = state?.connection_scenario_reset;
+ const current = Boolean(
+ marker
+ && marker.revision === state?.desired_connection_mode_revision
+ && marker.desired_mode === connectionMode,
+ );
+ const runtimeId = state?.snapshot_runtime_id?.trim() || null;
+ return {
+ current,
+ active: current && marker?.active !== false,
+ key: current && runtimeId && Number.isInteger(marker?.revision)
+ ? `${runtimeId}:${marker?.revision}`
+ : null,
+ };
+}
+
+/**
+ * A reset marker owns only the clean new-device draft it created. Scan is
+ * presentation-only and deliberately keeps that boundary alive. A current
+ * backend-owned connection attempt or a new physical recovery state is newer
+ * authority and must remain visible. OperationRecord.sequence is deliberately
+ * not used here: it is a per-operation transition counter, not chronology.
+ */
+export function scenarioResetOwnsCleanConnectionDraft(
+ state: XgridsK1State | null | undefined,
+ connectionMode: ConnectionMode,
+): boolean {
+ const boundary = scenarioResetPresentationBoundary(state, connectionMode);
+ return Boolean(
+ boundary.current
+ && !state?.connection_attempt
+ && !requiresReadOnlyPhysicalRecovery(state),
+ );
+}
+
+export function physicalRecoveryPresentationAuthorityKey(
+ state: XgridsK1State | null | undefined,
+): string | null {
+ if (!requiresReadOnlyPhysicalRecovery(state)) return null;
+ const target = readOnlyConnectionObservationTarget(state);
+ const runtimeId = state?.snapshot_runtime_id?.trim();
+ const physical = state?.application_control_session?.physical_command
+ ?? state?.physical_command;
+ const recordRevision = physical?.record?.revision;
+ if (
+ !runtimeId
+ || !target?.serverBound
+ || !Number.isInteger(recordRevision)
+ ) return null;
+ return [
+ runtimeId,
+ recordRevision,
+ transportRefEquivalenceKey(target.deviceId),
+ target.connectionMode,
+ target.source,
+ target.expectedDiscoveryGeneration ?? "durable",
+ state?.desired_connection_mode_revision ?? "no-mode-revision",
+ ].join(":");
+}
+
+export type ProvisioningAttemptViewState =
+ | "hidden"
+ | "submitting"
+ | "settling"
+ | "failed"
+ | "connected"
+ | "retired";
+
+export interface ProvisioningAttemptPresentation
+ extends DevicePresentationSnapshot {
+ snapshotRuntimeId: string;
+ connectionMode: ConnectionMode;
+ idempotencyKey: string;
+ attemptId: string | null;
+ /** SSID is presentation-only. The Wi-Fi password is never retained here. */
+ ssid: string | null;
+ localPhase: "submitting" | "settling" | "failed";
+ failureMessage: string | null;
+ freshStartAllowed: boolean;
+}
+
+export function emptyProvisioningAttemptPresentation():
+ ProvisioningAttemptPresentation | null {
+ return null;
+}
+
+const TERMINAL_PROVISIONING_STATUSES = new Set([
+ "operator_action_required",
+ "succeeded",
+ "failed",
+ "cancelled",
+ "timed_out",
+ "interrupted",
+]);
+
+/**
+ * Project one click-owned Apply attempt without borrowing an unrelated global
+ * error or historical connection attempt. The local latch bridges the fast
+ * HTTP acknowledgement and the later authoritative connection projection.
+ */
+export function provisioningAttemptViewState(
+ presentation: ProvisioningAttemptPresentation | null | undefined,
+ state: XgridsK1State | null | undefined,
+): ProvisioningAttemptViewState {
+ if (!presentation) return "hidden";
+ const runtimeId = state?.snapshot_runtime_id?.trim() || null;
+ if (!runtimeId || runtimeId !== presentation.snapshotRuntimeId) {
+ return "retired";
+ }
+ if (
+ isReachableConnectionLease(state, presentation.connectionMode)
+ && observedConnectionAuthorityAllowsTarget(
+ state,
+ presentation.deviceId,
+ presentation.connectionMode,
+ )
+ ) {
+ return "connected";
+ }
+
+ const operation = operationByIdempotencyKey(
+ state,
+ "network.provision",
+ presentation.idempotencyKey,
+ );
+ const expectedAttemptId = presentation.attemptId ?? operation?.operation_id ?? null;
+ const attempt = expectedAttemptId
+ && state?.connection_attempt?.attempt_id === expectedAttemptId
+ ? state.connection_attempt
+ : null;
+ if (attempt) {
+ if (["accepted", "running"].includes(attempt.status)) return "settling";
+ // The terminal child can arrive one projection before the reachable lease.
+ // Ready is corroborating success, not a reason to flash a failure card.
+ if (attempt.status === "succeeded" && attempt.control_state === "ready") {
+ return "settling";
+ }
+ if (TERMINAL_PROVISIONING_STATUSES.has(attempt.status)) return "failed";
+ }
+ if (
+ operation
+ && ["failed", "cancelled", "timed_out", "interrupted", "operator_action_required"]
+ .includes(operation.status)
+ ) {
+ return "failed";
+ }
+ if (operation && ["accepted", "running"].includes(operation.status)) {
+ return "settling";
+ }
+ // A succeeded network.provision operation can precede the connection
+ // attempt/lease projection by one or more snapshots. It is not yet proof of
+ // a usable network, so keep the same disabled form while authority settles.
+ if (operation?.status === "succeeded") return "settling";
+ return presentation.localPhase;
+}
+
+export interface UnavailablePhysicalRetirementAuthority {
+ expectedOperationId: string;
+ expectedRevision: number;
+ expectedTransportRef: string;
+}
+
+export function connectionAttemptOwnsAppliedNetworkRecovery(
+ attempt: XgridsConnectionAttempt | null | undefined,
+): boolean {
+ if (
+ !attempt
+ || attempt.phase !== "network_applied"
+ || attempt.control_state === "ready"
+ ) return false;
+ if (["accepted", "running"].includes(attempt.status)) return true;
+ return [
+ "continue-with-control-verification",
+ "verify-control-read-only",
+ "manual-recovery-required",
+ ].includes(attempt.safe_next_action);
+}
+
+export async function dispatchUnavailablePhysicalRetirementForCurrentRuntime(
+ snapshotRuntimeId: string,
+ isSnapshotRuntimeCurrent: ((expectedSnapshotRuntimeId: string) => boolean)
+ | null
+ | undefined,
+ dispatch: (expectedSnapshotRuntimeId: string) => Promise,
+): Promise<
+ | { dispatched: false; result: null }
+ | { dispatched: true; result: T }
+> {
+ // This is the last synchronous mutation boundary. Never let a click rendered
+ // from runtime A borrow runtime B from the controller after replacement.
+ if (!isSnapshotRuntimeCurrent?.(snapshotRuntimeId)) {
+ return { dispatched: false, result: null };
+ }
+ return {
+ dispatched: true,
+ result: await dispatch(snapshotRuntimeId),
+ };
+}
+
+/**
+ * The legacy physical projection supplies the exact CAS payload, but only the
+ * public connection policy may make the local-only retirement executable.
+ */
+export function unavailablePhysicalRetirementAuthority(
+ state: XgridsK1State | null | undefined,
+): UnavailablePhysicalRetirementAuthority | null {
+ const decision = connectionPolicyDecision(
+ state,
+ "retire-unavailable-physical-target",
+ );
+ const projection = state?.physical_command?.operator_retirement;
+ const record = state?.physical_command?.record;
+ const expectedOperationId = projection?.expected_operation_id?.trim() ?? "";
+ const expectedTransportRef = projection?.expected_transport_ref?.trim() ?? "";
+ const expectedRevision = projection?.expected_revision;
+ const recordOperationId = record
+ && typeof record.operation_id === "string"
+ ? record.operation_id.trim()
+ : "";
+ const recordRevision = record && typeof record.revision === "number"
+ ? record.revision
+ : null;
+ const recordConnection = record
+ && typeof record.connection === "object"
+ && record.connection !== null
+ ? record.connection
+ : null;
+ const recordTransportRef = recordConnection
+ && "transport_ref" in recordConnection
+ && typeof recordConnection.transport_ref === "string"
+ ? recordConnection.transport_ref.trim()
+ : "";
+ if (
+ !connectionPolicyAllows(state, "retire-unavailable-physical-target")
+ || decision?.target_source !== "durable-physical-command"
+ || decision.requires_live_gatt_validation !== false
+ || decision.physical_command_allowed !== false
+ || decision.physical_outcome !== "unknown"
+ || decision.device_write_performed !== false
+ || !decision.required_transport_ref?.trim()
+ || projection?.allowed !== true
+ || projection.reason_codes.length !== 0
+ || projection.physical_outcome !== "unknown"
+ || projection.device_io_performed !== false
+ || projection.automatic_retry !== false
+ || !expectedOperationId
+ || !expectedTransportRef
+ || !Number.isInteger(expectedRevision)
+ || (expectedRevision ?? 0) < 1
+ || recordOperationId !== expectedOperationId
+ || recordRevision !== expectedRevision
+ || transportRefEquivalenceKey(recordTransportRef)
+ !== transportRefEquivalenceKey(expectedTransportRef)
+ || transportRefEquivalenceKey(decision.required_transport_ref)
+ !== transportRefEquivalenceKey(expectedTransportRef)
+ || !(
+ state?.physical_command?.requires_reconciliation === true
+ || state?.physical_command?.resolved_active_recovery_required === true
+ )
+ ) return null;
+ return {
+ expectedOperationId,
+ expectedRevision: expectedRevision as number,
+ expectedTransportRef,
+ };
+}
+
+export interface RuntimeActionFence extends ConnectionActionAuthoritySnapshot {
+ clickToken: number;
+}
+
+/** Deterministic SSR seam; production never provides this context. */
+export const RuntimeActionFenceTestContext =
+ createContext(null);
+
+interface SearchPresentation {
+ sequence: number;
+ snapshotRuntimeId: string | null;
+ connectionMode: ConnectionMode;
+ desiredModeRevision: number | null;
+ active: boolean;
+ completedDiscoveryGeneration: number | null;
+}
+
+export function emptySearchPresentation(): SearchPresentation | null {
+ return null;
+}
+
+export function localScenarioActionEpochIsCurrent(
+ capturedEpoch: number,
+ currentEpoch: number,
+): boolean {
+ return capturedEpoch === currentEpoch;
+}
+
+export function connectionActionAuthorityMatches(
+ left: ConnectionActionAuthoritySnapshot | null | undefined,
+ right: ConnectionActionAuthoritySnapshot | null | undefined,
+): boolean {
+ return Boolean(
+ left
+ && right
+ && left.snapshotRuntimeId === right.snapshotRuntimeId
+ && left.connectionMode === right.connectionMode
+ && left.desiredModeRevision === right.desiredModeRevision
+ && left.reconfigurationRevision === right.reconfigurationRevision
+ && left.reconfigurationIntentId === right.reconfigurationIntentId
+ && left.activeBindingKey === right.activeBindingKey
+ && left.discoveryGeneration === right.discoveryGeneration,
+ );
+}
+
+const TRANSIENT_RECONFIGURATION_CONTENTION_REASONS = new Set([
+ "connection-reconfiguration-lifecycle-busy",
+ "k1-lifecycle-process-lease-network-owned",
+]);
+
+/**
+ * Keep a connected Bridge action in its established layout slot while a
+ * short-lived lifecycle owner prevents this exact click. Durable safety
+ * blockers still remove the action; the backend remains the final authority.
+ */
+export function connectedReconfigurationActionApplicable(
+ state: XgridsK1State | null | undefined,
+ action: "prepare-select-device" | "prepare-change-network",
+): boolean {
+ if (connectionPolicyAllows(state, action)) return true;
+ const decision = connectionPolicyDecision(state, action);
+ return Boolean(
+ decision
+ && decision.allowed === false
+ && decision.automatic_retry === false
+ && decision.reason_codes.length > 0
+ && decision.reason_codes.every((reason) =>
+ TRANSIENT_RECONFIGURATION_CONTENTION_REASONS.has(reason)
+ ),
+ );
+}
+
+export function reconfigurationContinuationAuthority(
+ fence: ConnectionActionAuthoritySnapshot,
+ observedState: XgridsK1State | null | undefined,
+ intent: XgridsConnectionReconfigurationIntent | "cancel",
+ current: ConnectionActionAuthoritySnapshot | null | undefined,
+): ConnectionActionAuthoritySnapshot | null {
+ if (!observedState) return null;
+ const observedAuthority = connectionActionAuthoritySnapshot(
+ observedState,
+ fence.connectionMode,
+ );
+ const observedReconfiguration = observedState.connection_reconfiguration;
+ if (
+ !observedAuthority
+ || observedAuthority.snapshotRuntimeId !== fence.snapshotRuntimeId
+ || observedAuthority.connectionMode !== fence.connectionMode
+ || observedAuthority.desiredModeRevision !== fence.desiredModeRevision
+ || observedAuthority.discoveryGeneration !== fence.discoveryGeneration + 1
+ || observedAuthority.reconfigurationRevision !== fence.reconfigurationRevision + 1
+ || !connectionActionAuthorityMatches(current, observedAuthority)
+ || (
+ intent === "cancel"
+ ? observedAuthority.activeBindingKey !== fence.activeBindingKey
+ : observedAuthority.activeBindingKey !== null
+ || observedState.connection_lifecycle?.active_binding !== null
+ )
+ || (
+ intent === "cancel"
+ ? observedReconfiguration?.status !== "idle"
+ || observedReconfiguration.intent !== null
+ || observedAuthority.reconfigurationIntentId !== null
+ : observedReconfiguration?.intent !== intent
+ || observedReconfiguration.status === "idle"
+ || !observedAuthority.reconfigurationIntentId
+ )
+ ) return null;
+ return observedAuthority;
+}
+
+export function observedConnectionAuthorityAllowsTarget(
+ observedState: XgridsK1State | null | undefined,
+ deviceId: string,
+ connectionMode: ConnectionMode,
+ { allowUnbound = false }: { allowUnbound?: boolean } = {},
+): boolean {
+ if (!observedState || !deviceId) return false;
+ const currentRecovery = observedState.current_device_recovery;
+ const activeBinding = observedState.connection_lifecycle?.active_binding;
+ const activeBindingKey = observedState.connection_lifecycle?.active_binding_key
+ ?.trim() || null;
+ const referencedTargets = [
+ observedState.selected_device_id?.trim() || null,
+ currentRecovery?.transport_ref?.trim() || null,
+ activeBinding?.transport_ref?.trim() || null,
+ ].filter((value): value is string => Boolean(value));
+ const deviceKey = transportRefEquivalenceKey(deviceId);
+ if (
+ !deviceKey
+ || referencedTargets.some(
+ (value) => transportRefEquivalenceKey(value) !== deviceKey,
+ )
+ ) return false;
+ if (activeBindingKey && !activeBinding) return false;
+ if (
+ activeBinding
+ && (
+ transportRefEquivalenceKey(activeBinding.transport_ref) !== deviceKey
+ || activeBinding.connection_mode !== connectionMode
+ )
+ ) return false;
+ if (
+ currentRecovery?.transport_ref?.trim()
+ && currentRecovery.connection_mode !== connectionMode
+ ) return false;
+ if (
+ observedState.selected_device_id?.trim()
+ && observedState.connection_mode
+ && observedState.connection_mode !== connectionMode
+ ) return false;
+ return allowUnbound || referencedTargets.some(
+ (value) => transportRefEquivalenceKey(value) === deviceKey,
+ );
+}
+
+export function connectContinuationAuthority(
+ fence: ConnectionActionAuthoritySnapshot,
+ observedState: XgridsK1State | null | undefined,
+ current: ConnectionActionAuthoritySnapshot | null | undefined,
+ deviceId: string,
+ connectionMode: ConnectionMode,
+): ConnectionActionAuthoritySnapshot | null {
+ if (!observedConnectionAuthorityAllowsTarget(
+ observedState,
+ deviceId,
+ connectionMode,
+ )) return null;
+ const observedAuthority = connectionActionAuthoritySnapshot(
+ observedState,
+ fence.connectionMode,
+ );
+ if (
+ !observedAuthority
+ || observedAuthority.snapshotRuntimeId !== fence.snapshotRuntimeId
+ || observedAuthority.connectionMode !== fence.connectionMode
+ || observedAuthority.desiredModeRevision !== fence.desiredModeRevision
+ || !connectionActionAuthorityMatches(current, observedAuthority)
+ ) return null;
+ const observedReconfiguration = observedState?.connection_reconfiguration;
+ const unchangedOrdinaryConnect = Boolean(
+ fence.reconfigurationIntentId === null
+ && observedAuthority.reconfigurationIntentId === null
+ && observedAuthority.reconfigurationRevision === fence.reconfigurationRevision
+ && observedAuthority.discoveryGeneration === fence.discoveryGeneration
+ && observedReconfiguration?.status === "idle"
+ && observedReconfiguration.intent === null,
+ );
+ const exactConsumedReconfiguration = Boolean(
+ fence.reconfigurationIntentId !== null
+ && observedAuthority.reconfigurationIntentId === null
+ && observedAuthority.reconfigurationRevision === fence.reconfigurationRevision + 1
+ && observedAuthority.discoveryGeneration === fence.discoveryGeneration + 1
+ && observedReconfiguration?.status === "idle"
+ && observedReconfiguration.intent === null,
+ );
+ return unchangedOrdinaryConnect || exactConsumedReconfiguration
+ ? observedAuthority
+ : null;
+}
+
+export function runtimeActionFenceMatches(
+ fence: RuntimeActionFence | null | undefined,
+ currentSnapshotRuntimeId: string | null,
+ activeFence: RuntimeActionFence | null,
+ isActionAuthorityCurrent?: (fence: RuntimeActionFence) => boolean,
+): boolean {
+ return Boolean(
+ fence
+ && currentSnapshotRuntimeId
+ && fence.snapshotRuntimeId === currentSnapshotRuntimeId
+ && activeFence
+ && activeFence.snapshotRuntimeId === fence.snapshotRuntimeId
+ && activeFence.clickToken === fence.clickToken
+ && (
+ !isActionAuthorityCurrent
+ || isActionAuthorityCurrent(fence)
+ ),
+ );
+}
+
+export function currentRuntimeActionRequest(
+ request: T | null,
+ currentSnapshotRuntimeId: string | null,
+ activeFence: RuntimeActionFence | null,
+ isActionAuthorityCurrent?: (fence: RuntimeActionFence) => boolean,
+): T | null {
+ return runtimeActionFenceMatches(
+ request,
+ currentSnapshotRuntimeId,
+ activeFence,
+ isActionAuthorityCurrent,
+ ) ? request : null;
+}
+
+interface PreparingReconfigurationRequest extends RuntimeActionFence {
+ intent: XgridsConnectionReconfigurationIntent | "cancel";
+}
+
+export function exactChangeNetworkCandidate(
+ reconfiguration: XgridsConnectionReconfiguration | null,
+ devices: readonly BleDevice[],
+ discoveryGeneration: number | null | undefined,
+): BleDevice | null {
+ const requiredTransportRef = reconfiguration?.required_transport_ref?.trim();
+ if (
+ reconfiguration?.intent !== "change-network"
+ || reconfiguration.status !== "fresh-scan-completed"
+ || reconfiguration.required_connection_mode !== "bridge"
+ || reconfiguration.required_transport_observed !== true
+ || !requiredTransportRef
+ || !Number.isInteger(discoveryGeneration)
+ || reconfiguration.fresh_discovery_generation !== discoveryGeneration
+ ) return null;
+ return provisioningCandidateById(devices, requiredTransportRef);
+}
+
+export interface ExplicitProvisioningDraft {
+ snapshotRuntimeId: string;
+ deviceId: string;
+ connectionMode: ConnectionMode;
+ desiredModeRevision: number;
+ discoveryGeneration: number;
+ reconfigurationRevision: number;
+ reconfigurationIntentId: string | null;
+ activeBindingKey: string | null;
+ requiredTransportRef: string | null;
+ requiredConnectionMode: ConnectionMode | null;
+ /** Pre-dispatch evidence expired; only a new explicit scan may re-arm Apply. */
+ requiresFreshScanBeforeSubmit: boolean;
+}
+
+export function explicitProvisioningDraftMatches(
+ draft: ExplicitProvisioningDraft | null,
+ current: {
+ snapshotRuntimeId: string | null;
+ deviceId: string | null;
+ connectionMode: ConnectionMode;
+ desiredModeRevision: number | null | undefined;
+ discoveryGeneration: number | null | undefined;
+ reconfigurationRevision: number;
+ reconfigurationIntentId: string | null;
+ activeBindingKey: string | null;
+ requiredTransportRef: string | null;
+ requiredConnectionMode: ConnectionMode | null;
+ },
+): boolean {
+ return Boolean(
+ draft
+ && draft.snapshotRuntimeId === current.snapshotRuntimeId
+ && current.deviceId
+ && transportRefEquivalenceKey(draft.deviceId)
+ === transportRefEquivalenceKey(current.deviceId)
+ && draft.connectionMode === current.connectionMode
+ && draft.desiredModeRevision === current.desiredModeRevision
+ && draft.discoveryGeneration === current.discoveryGeneration
+ && draft.reconfigurationRevision === current.reconfigurationRevision
+ && draft.reconfigurationIntentId === current.reconfigurationIntentId
+ && draft.activeBindingKey === current.activeBindingKey
+ && (
+ draft.requiredTransportRef === null
+ ? current.requiredTransportRef === null
+ : current.requiredTransportRef !== null
+ && transportRefEquivalenceKey(draft.requiredTransportRef)
+ === transportRefEquivalenceKey(current.requiredTransportRef)
+ )
+ && draft.requiredConnectionMode === current.requiredConnectionMode,
+ );
+}
+
+export function localProvisioningDraftFenceKey({
+ snapshotRuntimeId,
+ reconfigurationRevision,
+ reconfigurationIntentId,
+ activeBindingKey,
+ requiredTransportRef,
+ requiredConnectionMode,
+}: {
+ snapshotRuntimeId: string | null;
+ reconfigurationRevision: number;
+ reconfigurationIntentId: string | null;
+ activeBindingKey: string | null;
+ requiredTransportRef: string | null;
+ requiredConnectionMode: ConnectionMode | null;
+}): string {
+ return [
+ snapshotRuntimeId ?? "no-runtime",
+ reconfigurationRevision,
+ reconfigurationIntentId ?? "idle",
+ activeBindingKey ?? "no-binding",
+ requiredTransportRef
+ ? transportRefEquivalenceKey(requiredTransportRef)
+ : "no-required-device",
+ requiredConnectionMode ?? "no-required-mode",
+ ].join(":");
+}
+
+function observationRequest(
+ target: ReadOnlyConnectionObservationTarget,
+ reconfigurationRevision: number,
+ reconfigurationIntentId: string | null,
+): ConnectionVerifyRequest | null {
+ const commonRequest = {
+ device_id: target.deviceId,
+ compatibility_attestation: profileSelectionForConnectionMode(
+ target.connectionMode,
+ ),
+ };
+ if (target.source === "fresh-scan") {
+ if (!Number.isInteger(target.expectedDiscoveryGeneration)) return null;
+ return {
+ ...commonRequest,
+ source: "fresh-scan",
+ expected_discovery_generation: target.expectedDiscoveryGeneration as number,
+ expected_reconfiguration_revision: reconfigurationRevision,
+ ...(reconfigurationIntentId
+ ? { expected_reconfiguration_intent_id: reconfigurationIntentId }
+ : {}),
+ };
+ }
+ return {
+ ...commonRequest,
+ source: target.source,
+ };
+}
+
+export function connectionRecoveryObservationTargetMatches(
+ expected: ReadOnlyConnectionObservationTarget | null | undefined,
+ current: ReadOnlyConnectionObservationTarget | null | undefined,
+): boolean {
+ return Boolean(
+ expected
+ && current
+ && expected.action === current.action
+ && expected.connectionMode === current.connectionMode
+ && expected.source === current.source
+ && expected.serverBound === true
+ && current.serverBound === true
+ && expected.expectedDiscoveryGeneration
+ === current.expectedDiscoveryGeneration
+ && transportRefEquivalenceKey(expected.deviceId)
+ === transportRefEquivalenceKey(current.deviceId),
+ );
+}
+
+export async function dispatchConnectionRecoveryObservationForCurrentRuntime(
+ snapshotRuntimeId: string,
+ renderedTarget: ReadOnlyConnectionObservationTarget | null | undefined,
+ isSnapshotRuntimeCurrent:
+ | ((expectedSnapshotRuntimeId: string) => boolean)
+ | null
+ | undefined,
+ getCurrentTarget:
+ | (() => ReadOnlyConnectionObservationTarget | null)
+ | null
+ | undefined,
+ dispatch: (target: ReadOnlyConnectionObservationTarget) => Promise,
+): Promise<
+ | { dispatched: false; result: null }
+ | { dispatched: true; result: T }
+> {
+ // Cold recovery can legitimately target a connection mode different from
+ // the browser draft. The mutation boundary is the exact server-bound target
+ // plus the rendered runtime, never that unrelated draft value.
+ if (
+ !snapshotRuntimeId.trim()
+ || !renderedTarget?.serverBound
+ || !isSnapshotRuntimeCurrent?.(snapshotRuntimeId)
+ || typeof getCurrentTarget !== "function"
+ || !connectionRecoveryObservationTargetMatches(
+ renderedTarget,
+ getCurrentTarget(),
+ )
+ ) {
+ return { dispatched: false, result: null };
+ }
+ return {
+ dispatched: true,
+ result: await dispatch(renderedTarget),
+ };
+}
+
+export interface RetiredPhysicalReopenVerificationContext {
+ target: ReadOnlyConnectionObservationTarget;
+ authority: ConnectionActionAuthoritySnapshot;
+}
+
+/**
+ * Admit the read-only half of an explicit retired-device recovery only from
+ * the current backend projection produced by that exact reopen audit. The
+ * visible BLE row is presentation evidence and never becomes a Verify target.
+ */
+export function retiredPhysicalReopenVerificationContext(
+ observedState: XgridsK1State | null | undefined,
+ request: ReopenRetiredPhysicalReconciliationRequest,
+ snapshotRuntimeId: string,
+ target: ReadOnlyConnectionObservationTarget | null | undefined,
+ authority: ConnectionActionAuthoritySnapshot | null | undefined,
+): RetiredPhysicalReopenVerificationContext | null {
+ const exactRuntimeId = snapshotRuntimeId.trim();
+ if (
+ !exactRuntimeId
+ || observedState?.snapshot_runtime_id?.trim() !== exactRuntimeId
+ || !reopenedPhysicalReconciliationMatches(observedState, request)
+ || target?.serverBound !== true
+ || target.action !== "observe-fresh-device-network"
+ || target.source !== "fresh-scan"
+ || target.connectionMode !== request.expected_desired_mode
+ || target.expectedDiscoveryGeneration
+ !== request.expected_discovery_generation
+ || transportRefEquivalenceKey(target.deviceId)
+ !== transportRefEquivalenceKey(request.expected_transport_ref)
+ || authority?.snapshotRuntimeId !== exactRuntimeId
+ || authority.connectionMode !== request.expected_desired_mode
+ || authority.desiredModeRevision
+ !== request.expected_desired_mode_revision
+ || authority.discoveryGeneration
+ !== request.expected_discovery_generation
+ ) return null;
+ return { target, authority };
+}
+
+export interface RetiredPhysicalReopenSubmitResult {
+ succeeded: boolean;
+ observedState: XgridsK1State | null;
+}
+
+export type RetiredPhysicalReopenDispatchResult =
+ | {
+ reopenDispatched: false;
+ reopenResult: null;
+ verifyDispatched: false;
+ verifyResult: null;
+ }
+ | {
+ reopenDispatched: true;
+ reopenResult: RetiredPhysicalReopenSubmitResult;
+ verifyDispatched: false;
+ verifyResult: null;
+ }
+ | {
+ reopenDispatched: true;
+ reopenResult: RetiredPhysicalReopenSubmitResult;
+ verifyDispatched: true;
+ verifyResult: TVerify;
+ };
+
+/**
+ * One explicit click owns one local ledger reopen and, only after exact
+ * current-state proof, one server-bound read-only Verify. A lost HTTP response
+ * may continue when the audit proves the same request committed. Runtime or
+ * same-runtime state drift fails closed before any observation is dispatched.
+ */
+export async function dispatchRetiredPhysicalReconciliationForCurrentRuntime<
+ TVerify,
+>({
+ snapshotRuntimeId,
+ request,
+ isSnapshotRuntimeCurrent,
+ reopen,
+ getCurrentState,
+ getCurrentTarget,
+ getCurrentAuthority,
+ expectedAuthority,
+ verify,
+}: {
+ snapshotRuntimeId: string;
+ request: ReopenRetiredPhysicalReconciliationRequest;
+ isSnapshotRuntimeCurrent:
+ | ((expectedSnapshotRuntimeId: string) => boolean)
+ | null
+ | undefined;
+ reopen: (
+ request: ReopenRetiredPhysicalReconciliationRequest,
+ expectedSnapshotRuntimeId: string,
+ ) => Promise;
+ getCurrentState: (() => XgridsK1State | null) | null | undefined;
+ getCurrentTarget:
+ | (() => ReadOnlyConnectionObservationTarget | null)
+ | null
+ | undefined;
+ getCurrentAuthority:
+ | ((connectionMode: ConnectionMode) => ConnectionActionAuthoritySnapshot | null)
+ | null
+ | undefined;
+ expectedAuthority: ConnectionActionAuthoritySnapshot;
+ verify: (
+ context: RetiredPhysicalReopenVerificationContext,
+ expectedSnapshotRuntimeId: string,
+ ) => Promise;
+}): Promise> {
+ const exactRuntimeId = snapshotRuntimeId.trim();
+ const notDispatched = {
+ reopenDispatched: false,
+ reopenResult: null,
+ verifyDispatched: false,
+ verifyResult: null,
+ } as const;
+ if (
+ !exactRuntimeId
+ || !isSnapshotRuntimeCurrent?.(exactRuntimeId)
+ || typeof getCurrentState !== "function"
+ || typeof getCurrentTarget !== "function"
+ || typeof getCurrentAuthority !== "function"
+ ) return notDispatched;
+
+ const reopenResult = await reopen(request, exactRuntimeId);
+ const reopenedOnly = {
+ reopenDispatched: true,
+ reopenResult,
+ verifyDispatched: false,
+ verifyResult: null,
+ } as const;
+ if (
+ !isSnapshotRuntimeCurrent(exactRuntimeId)
+ || reopenResult.observedState?.snapshot_runtime_id?.trim()
+ !== exactRuntimeId
+ || !reopenedPhysicalReconciliationMatches(
+ reopenResult.observedState,
+ request,
+ )
+ ) return reopenedOnly;
+
+ const currentState = getCurrentState();
+ const context = retiredPhysicalReopenVerificationContext(
+ currentState,
+ request,
+ exactRuntimeId,
+ getCurrentTarget(),
+ getCurrentAuthority(request.expected_desired_mode),
+ );
+ if (
+ !context
+ || !connectionActionAuthorityMatches(
+ expectedAuthority,
+ context.authority,
+ )
+ || !isSnapshotRuntimeCurrent(exactRuntimeId)
+ || getCurrentState() !== currentState
+ ) return reopenedOnly;
+
+ return {
+ reopenDispatched: true,
+ reopenResult,
+ verifyDispatched: true,
+ verifyResult: await verify(context, exactRuntimeId),
+ };
+}
+
+export async function clearConnectionFailureAfterSuccessfulRefresh(
+ refresh: () => Promise,
+ clearError: () => void,
+): Promise {
+ const refreshed = await refresh();
+ if (!refreshed) return false;
+ clearError();
+ return true;
+}
+
+export function connectionRecoveryEscapeKey({
+ snapshotRuntimeId,
+ attempt,
+ target,
+}: {
+ snapshotRuntimeId: string | null | undefined;
+ attempt: XgridsConnectionAttempt | null | undefined;
+ target: ReadOnlyConnectionObservationTarget | null | undefined;
+}): string | null {
+ const runtimeId = snapshotRuntimeId?.trim();
+ if (!runtimeId || (!attempt && !target)) return null;
+ const deviceKey = target
+ ? transportRefEquivalenceKey(target.deviceId)
+ : "no-target";
+ const connectionMode = target?.connectionMode
+ ?? attempt?.connection_mode
+ ?? "no-mode";
+ // Observation authority may legitimately promote configured -> fresh after
+ // this exact Scan. Its source/action is therefore presentation evidence, not
+ // part of the operator escape identity. Runtime, attempt, device and mode
+ // continue to fence a replacement or a new recovery requirement.
+ return [
+ runtimeId,
+ attempt?.attempt_id ?? "no-attempt",
+ attempt?.safe_next_action ?? "no-attempt-action",
+ deviceKey,
+ connectionMode,
+ ].join(":");
+}
+
+export function connectionRecoveryIsRequired(
+ recoveryKey: string | null | undefined,
+ escapedKey: string | null | undefined,
+): boolean {
+ return Boolean(recoveryKey && recoveryKey !== escapedKey);
+}
+
+export function connectionRecoveryEscapeAfterScan(
+ scanSucceeded: boolean,
+ recoveryKey: string | null | undefined,
+): string | null {
+ return scanSucceeded && recoveryKey ? recoveryKey : null;
+}
const connectionCopy: Record = {
bridge: {
- stepTitle: "Передайте настройки общей сети",
+ stepTitle: "Настройка общей сети",
ssidLabel: "Название общей сети Wi‑Fi",
ssidPlaceholder: "Сеть локального контура",
- buttonLabel: "Подключить K1 к общей сети",
- safetyNote: "K1 получит реквизиты существующей сети одним рассмотренным BLE-запросом без автоматического повтора.",
+ buttonLabel: "Применить",
+ safetyNote: "Настройки применяются один раз после явного нажатия.",
},
"quick-connect": {
- stepTitle: "Включите точку доступа K1 и подключитесь к ней",
- buttonLabel: "Включить точку K1 и подключиться",
- safetyNote: "Mission Core сначала проверит локальный device-scoped профиль выбранного K1. Если профиль отсутствует, операция остановится до BLE-записи. После preflight Mission Core отправит один рассмотренный AP-enable кадр и найдёт точный SSID выбранного устройства.",
+ stepTitle: "Прямое подключение",
+ buttonLabel: "Применить",
+ safetyNote: "Действие выполняется один раз после явного нажатия.",
},
"direct-connect": {
- stepTitle: "Подключите K1 к хотспоту контроллера",
+ stepTitle: "Настройка хотспота контроллера",
ssidLabel: "Название хотспота контроллера",
- ssidPlaceholder: "SSID управляющего устройства",
- buttonLabel: "Подключить K1 к хотспоту",
- safetyNote: "Хотспот должен быть уже включён, а управляющее устройство — иметь к нему маршрут. K1 получит его реквизиты одним рассмотренным BLE-запросом.",
+ ssidPlaceholder: "Название хотспота",
+ buttonLabel: "Применить",
+ safetyNote: "Перед продолжением включите хотспот контроллера.",
},
};
@@ -78,23 +1136,39 @@ function WizardStep({
);
}
-function DeviceRow({ device, selected, onSelect }: {
+function DeviceRow({
+ device,
+ selected,
+ selectionDisabled = false,
+ selectedLabel = "Выбрано",
+ disabledLabel = "Недоступно",
+ actionLabel = "Выбрать",
+ onSelect,
+}: {
device: BleDevice;
selected: boolean;
+ selectionDisabled?: boolean;
+ selectedLabel?: string;
+ disabledLabel?: string;
+ actionLabel?: string;
onSelect: () => void;
}) {
return (
- {device.name?.trim() || "Устройство без имени"}
- {device.likely_k1 ? Кандидат по имени; профиль не подтверждён : null}
+ {device.name?.trim() || "Без имени"}
+
+ {device.likely_k1
+ ? "Совпадение по имени"
+ : "Результат последнего поиска"}
+
{device.device_id}
@@ -104,10 +1178,14 @@ function DeviceRow({ device, selected, onSelect }: {
- {selected ? "Выбрано" : "Выбрать"}
+ {selected ? selectedLabel : selectionDisabled ? disabledLabel : actionLabel}
@@ -116,210 +1194,2348 @@ function DeviceRow({ device, selected, onSelect }: {
export function K1ProvisioningPipeline({
controller,
- phaseLabel,
- phaseTone,
+ desiredMode = DEFAULT_CONNECTION_MODE,
+ onDesiredModeChange = () => undefined,
}: {
controller: XgridsK1Controller;
- phaseLabel: string;
- phaseTone: StatusTone;
+ desiredMode?: ConnectionMode;
+ onDesiredModeChange?: (mode: ConnectionMode) => void | Promise;
}) {
- const { state, pendingAction, scan, connect, verifyConnection } = controller;
- const [powerConfirmed, setPowerConfirmed] = useState(false);
+ const {
+ state,
+ pendingAction,
+ error,
+ errorDiagnostic,
+ errorCorrelation,
+ refresh,
+ clearError,
+ scanWithResult,
+ connect,
+ selectConnectionMode,
+ verifyConnection,
+ prepareConnectionReconfigurationWithResult,
+ isSnapshotRuntimeCurrent,
+ getConnectionActionAuthority,
+ getConnectionRecoveryObservationTarget,
+ isConnectionPolicyActionAllowedCurrent,
+ isConnectionActionAuthorityCurrent,
+ } = controller;
const [selectedDeviceId, setSelectedDeviceId] = useState("");
+ const [modeResetPending, setModeResetPending] = useState<{
+ resetId: string;
+ targetMode: ConnectionMode;
+ } | null>(null);
+ const modeResetInFlight = pendingAction === "mode" || modeResetPending !== null;
+ const [selectedDeviceSnapshot, setSelectedDeviceSnapshot] = useState(null);
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
- const [connectionMode, setConnectionMode] = useState(
- DEFAULT_CONNECTION_MODE,
- );
- const provisioningIntentRef = useRef(null);
+ const [passwordVisible, setPasswordVisible] = useState(false);
+ useEffect(() => {
+ if (password.length === 0 && passwordVisible) {
+ setPasswordVisible(false);
+ }
+ }, [password, passwordVisible]);
+ const connectionMode = desiredMode;
+ const [successfulLocalConnect, setSuccessfulLocalConnect] =
+ useState(null);
+ const [connectionAttemptPresentation, setConnectionAttemptPresentation] =
+ useState(
+ emptyProvisioningAttemptPresentation,
+ );
+ const hydratedScenarioResetPresentationKey = useRef(null);
+ const [reconfigurationDevicePresentation, setReconfigurationDevicePresentation] =
+ useState(null);
+ const [preparingReconfigurationRequest, setPreparingReconfigurationRequest] =
+ useState(null);
+ const [candidateUnavailableMessage, setCandidateUnavailableMessage] =
+ useState(null);
+ const [scanSecondsRemaining, setScanSecondsRemaining] = useState(null);
+ const [explicitProvisioningDraft, setExplicitProvisioningDraft] =
+ useState(null);
+ const [escapedAppliedAttemptKey, setEscapedAppliedAttemptKey] =
+ useState(null);
+ const [escapedConnectionRecoveryKey, setEscapedConnectionRecoveryKey] =
+ useState(null);
+ const [failedConnectionRecoveryKey, setFailedConnectionRecoveryKey] =
+ useState(null);
+ const [failedPhysicalRecoveryKey, setFailedPhysicalRecoveryKey] =
+ useState(null);
+ // Browser-local generation for work which may settle after an awaited
+ // controller call. A scenario reset does not replace snapshot_runtime_id,
+ // so runtime identity alone cannot prevent an old Scan/Verify/Retire result
+ // from repopulating the freshly reset UI.
+ const localScenarioActionEpoch = useRef(0);
+ const observedScenarioResetActionKey = useRef(null);
+ const [searchPresentation, setSearchPresentation] =
+ useState(emptySearchPresentation);
+ const searchPresentationSequence = useRef(0);
+ const resetSearchPresentation = useCallback(() => {
+ // Invalidate any late completion from a scan which belonged to an older
+ // runtime, mode or local draft before clearing its visible latch.
+ searchPresentationSequence.current += 1;
+ setSearchPresentation(null);
+ setScanSecondsRemaining(null);
+ }, []);
+ const localDraftFence = useRef(null);
const devices = state?.devices ?? [];
- const isBusy = pendingAction !== null;
+ const snapshotRuntimeId = state?.snapshot_runtime_id?.trim() || null;
+ const scenarioResetBoundary = scenarioResetPresentationBoundary(
+ state,
+ connectionMode,
+ );
+ const activeScenarioReset = scenarioResetBoundary.active;
+ const scenarioResetPresentationKey = scenarioResetBoundary.key;
+ const actionFenceSequence = useRef(0);
+ const renderedSnapshotRuntimeId = useRef(snapshotRuntimeId);
+ const injectedRuntimeActionFence = useContext(RuntimeActionFenceTestContext);
+ const activeRuntimeActionFence = useRef(null);
+ const connectIntentSequence = useRef(0);
+ const activeConnectIntent = useRef(null);
+ if (
+ scenarioResetPresentationKey
+ && observedScenarioResetActionKey.current !== scenarioResetPresentationKey
+ ) {
+ // Observe toolbar resets as a synchronous ref boundary. The toolbar owns
+ // the same backend action as the in-panel reset, but it does not execute
+ // changeDesiredConnectionMode in this component.
+ observedScenarioResetActionKey.current = scenarioResetPresentationKey;
+ localScenarioActionEpoch.current += 1;
+ activeRuntimeActionFence.current = null;
+ activeConnectIntent.current = null;
+ searchPresentationSequence.current += 1;
+ }
+ const currentRuntimeActionFence = injectedRuntimeActionFence
+ ?? activeRuntimeActionFence.current;
+ const readCurrentRuntimeActionFence = useCallback(
+ () => injectedRuntimeActionFence ?? activeRuntimeActionFence.current,
+ [injectedRuntimeActionFence],
+ );
+ if (renderedSnapshotRuntimeId.current !== snapshotRuntimeId) {
+ renderedSnapshotRuntimeId.current = snapshotRuntimeId;
+ activeRuntimeActionFence.current = null;
+ activeConnectIntent.current = null;
+ }
+ const activateRuntimeActionFence = useCallback((
+ authority: ConnectionActionAuthoritySnapshot,
+ ): RuntimeActionFence | null => {
+ const existingFence = readCurrentRuntimeActionFence();
+ if (existingFence) {
+ const existingFenceIsCurrent = Boolean(
+ typeof isConnectionActionAuthorityCurrent === "function"
+ ? isConnectionActionAuthorityCurrent(existingFence)
+ : typeof isSnapshotRuntimeCurrent !== "function"
+ || isSnapshotRuntimeCurrent(existingFence.snapshotRuntimeId),
+ );
+ if (existingFenceIsCurrent || injectedRuntimeActionFence) return null;
+ if (activeRuntimeActionFence.current?.clickToken !== existingFence.clickToken) {
+ return null;
+ }
+ // A scenario reset can rotate the full connection authority without
+ // replacing the process runtime. Reclaim only that provably stale local
+ // click so an old settlement cannot poison every later explicit action.
+ activeRuntimeActionFence.current = null;
+ }
+ if (
+ !snapshotRuntimeId
+ || renderedSnapshotRuntimeId.current !== snapshotRuntimeId
+ || authority.snapshotRuntimeId !== snapshotRuntimeId
+ || (
+ typeof isSnapshotRuntimeCurrent === "function"
+ && !isSnapshotRuntimeCurrent(authority.snapshotRuntimeId)
+ )
+ ) return null;
+ const fence = {
+ ...authority,
+ clickToken: actionFenceSequence.current + 1,
+ };
+ actionFenceSequence.current = fence.clickToken;
+ activeRuntimeActionFence.current = fence;
+ return fence;
+ }, [
+ injectedRuntimeActionFence,
+ isConnectionActionAuthorityCurrent,
+ isSnapshotRuntimeCurrent,
+ readCurrentRuntimeActionFence,
+ snapshotRuntimeId,
+ ]);
+ const beginRuntimeActionFence = useCallback((
+ authorityMode: ConnectionMode = connectionMode,
+ ): RuntimeActionFence | null => {
+ const renderedAuthority = connectionActionAuthoritySnapshot(
+ state,
+ authorityMode,
+ );
+ const authority = typeof getConnectionActionAuthority === "function"
+ ? getConnectionActionAuthority(authorityMode)
+ : null;
+ if (
+ !snapshotRuntimeId
+ || renderedSnapshotRuntimeId.current !== snapshotRuntimeId
+ || !authority
+ || authority.snapshotRuntimeId !== snapshotRuntimeId
+ || !connectionActionAuthorityMatches(authority, renderedAuthority)
+ ) return null;
+ return activateRuntimeActionFence(authority);
+ }, [
+ activateRuntimeActionFence,
+ connectionMode,
+ getConnectionActionAuthority,
+ snapshotRuntimeId,
+ state,
+ ]);
+ const actionAuthorityIsCurrent = useCallback((
+ fence: RuntimeActionFence,
+ ) => Boolean(
+ typeof isConnectionActionAuthorityCurrent === "function"
+ ? isConnectionActionAuthorityCurrent(fence)
+ : typeof isSnapshotRuntimeCurrent !== "function"
+ || isSnapshotRuntimeCurrent(fence.snapshotRuntimeId),
+ ), [isConnectionActionAuthorityCurrent, isSnapshotRuntimeCurrent]);
+ const runtimeActionIsCurrent = useCallback((
+ fence: RuntimeActionFence | null | undefined,
+ ) => runtimeActionFenceMatches(
+ fence,
+ renderedSnapshotRuntimeId.current,
+ readCurrentRuntimeActionFence(),
+ actionAuthorityIsCurrent,
+ ), [actionAuthorityIsCurrent, readCurrentRuntimeActionFence]);
+ const runtimeClickIsCurrent = useCallback((
+ fence: RuntimeActionFence | null | undefined,
+ ) => Boolean(
+ runtimeActionFenceMatches(
+ fence,
+ renderedSnapshotRuntimeId.current,
+ readCurrentRuntimeActionFence(),
+ )
+ && fence
+ && (
+ typeof isSnapshotRuntimeCurrent !== "function"
+ || isSnapshotRuntimeCurrent(fence.snapshotRuntimeId)
+ ),
+ ), [isSnapshotRuntimeCurrent, readCurrentRuntimeActionFence]);
+ const promoteRuntimeActionFence = useCallback((
+ fence: RuntimeActionFence,
+ authority: ConnectionActionAuthoritySnapshot,
+ ): RuntimeActionFence | null => {
+ if (!runtimeClickIsCurrent(fence)) return null;
+ const promotedFence = { ...fence, ...authority };
+ if (
+ typeof isConnectionActionAuthorityCurrent === "function"
+ && !isConnectionActionAuthorityCurrent(promotedFence)
+ ) return null;
+ activeRuntimeActionFence.current = promotedFence;
+ return promotedFence;
+ }, [isConnectionActionAuthorityCurrent, runtimeClickIsCurrent]);
+ const promoteFenceFromReconfigurationResult = useCallback((
+ fence: RuntimeActionFence,
+ result: { succeeded: boolean; observedState: typeof state },
+ intent: XgridsConnectionReconfigurationIntent | "cancel",
+ ): RuntimeActionFence | null => {
+ if (
+ !runtimeClickIsCurrent(fence)
+ || !result.succeeded
+ || !result.observedState
+ || typeof getConnectionActionAuthority !== "function"
+ ) {
+ return null;
+ }
+ const observedAuthority = reconfigurationContinuationAuthority(
+ fence,
+ result.observedState,
+ intent,
+ getConnectionActionAuthority(fence.connectionMode),
+ );
+ return observedAuthority
+ ? promoteRuntimeActionFence(fence, observedAuthority)
+ : null;
+ }, [
+ getConnectionActionAuthority,
+ promoteRuntimeActionFence,
+ runtimeClickIsCurrent,
+ ]);
+ const promoteFenceFromConnectResult = useCallback((
+ fence: RuntimeActionFence,
+ observedState: typeof state,
+ expectedDeviceId: string,
+ expectedConnectionMode: ConnectionMode,
+ ): RuntimeActionFence | null => {
+ if (
+ !runtimeClickIsCurrent(fence)
+ || !observedState
+ || typeof getConnectionActionAuthority !== "function"
+ ) return null;
+ const continuationAuthority = connectContinuationAuthority(
+ fence,
+ observedState,
+ getConnectionActionAuthority(fence.connectionMode),
+ expectedDeviceId,
+ expectedConnectionMode,
+ );
+ return continuationAuthority
+ ? promoteRuntimeActionFence(fence, continuationAuthority)
+ : null;
+ }, [
+ getConnectionActionAuthority,
+ promoteRuntimeActionFence,
+ runtimeClickIsCurrent,
+ ]);
+ const retireRuntimeClickFence = useCallback((
+ fence: RuntimeActionFence | null | undefined,
+ ) => {
+ if (!runtimeClickIsCurrent(fence)) return;
+ activeRuntimeActionFence.current = null;
+ }, [runtimeClickIsCurrent]);
+ const commitDesiredModeForExplicitAction = useCallback(async (
+ explicitMode: ConnectionMode = connectionMode,
+ ): Promise => {
+ const currentAuthority = typeof getConnectionActionAuthority === "function"
+ ? getConnectionActionAuthority(explicitMode)
+ : null;
+ if (currentAuthority) return currentAuthority;
+ const expectedRevision = state?.desired_connection_mode_revision;
+ if (
+ !Number.isInteger(expectedRevision)
+ || (expectedRevision ?? -1) < 0
+ ) return null;
+ const committed = await selectConnectionMode({
+ connection_mode: explicitMode,
+ expected_revision: expectedRevision as number,
+ });
+ if (!committed || typeof getConnectionActionAuthority !== "function") {
+ return null;
+ }
+ return getConnectionActionAuthority(explicitMode);
+ }, [
+ connectionMode,
+ getConnectionActionAuthority,
+ selectConnectionMode,
+ state?.desired_connection_mode_revision,
+ ]);
+ const currentPreparingReconfigurationRequest = currentRuntimeActionRequest(
+ preparingReconfigurationRequest,
+ snapshotRuntimeId,
+ currentRuntimeActionFence,
+ actionAuthorityIsCurrent,
+ );
+ const preparingReconfigurationIntent =
+ currentPreparingReconfigurationRequest?.intent ?? null;
+ const presentedPendingAction = pendingAction && (
+ typeof isSnapshotRuntimeCurrent !== "function"
+ || runtimeActionIsCurrent(currentRuntimeActionFence)
+ ) ? pendingAction : null;
+ const reconfiguration = activeConnectionReconfiguration(state);
+ const reconfigurationRevision = state?.connection_reconfiguration?.revision ?? 0;
+ const reconfigurationIntentId = reconfiguration?.intent_id ?? null;
+ const reconfigurationIntent = reconfiguration?.intent ?? null;
+ const reconfigurationActive = connectionMode === "bridge"
+ && reconfiguration !== null;
+ const changeNetworkDialogue = Boolean(
+ reconfigurationActive && reconfigurationIntent === "change-network",
+ );
+ const changeNetworkRequiredDeviceId = changeNetworkDialogue
+ ? reconfiguration?.required_transport_ref?.trim() || ""
+ : "";
+ const activeBindingKey = state?.connection_lifecycle?.active_binding_key ?? null;
const credentialsReady = connectionMode === "quick-connect"
|| (ssid.trim().length > 0 && password.length > 0);
- const networkWriteReconciliationPending = Boolean(
- state?.network_write_reconciliation,
- );
- const deviceSummary = useMemo(
+ const freshDeviceSummary = useMemo(
() => provisioningCandidateById(devices, selectedDeviceId),
[devices, selectedDeviceId],
);
- const canConnect = !networkWriteReconciliationPending && canSubmitProvisioningMutation({
+ const localBackendSession = successfulLocalConnect?.connectionMode === connectionMode
+ && successfulLocalConnect.acceptedSessionKey
+ ? locallyInitiatedBleSessionTarget(
+ state,
+ successfulLocalConnect.deviceId,
+ connectionMode,
+ {
+ requiredSessionKey: successfulLocalConnect.acceptedSessionKey,
+ },
+ )
+ : null;
+ // Only a fresh advertisement is a target for a new network mutation. A
+ // retained backend session remains useful connection context, but must not
+ // be rendered as current BLE presence or silently reused as write authority.
+ const selectedTarget = freshDeviceSummary
+ ? {
+ deviceId: freshDeviceSummary.device_id,
+ label: freshDeviceSummary.name || freshDeviceSummary.device_id,
+ source: "fresh-scan" as const,
+ }
+ : null;
+ const retainedSessionLabel = localBackendSession
+ ? selectedDeviceSnapshot?.name?.trim() || localBackendSession.transportRef
+ : null;
+ const modeCopy = connectionCopy[connectionMode];
+ const requestedModeLabel = connectionModeOptions.find(
+ (option) => option.value === connectionMode,
+ )?.label ?? connectionMode;
+ const backendTopology = backendConnectionTopology(state);
+ // A backend-owned reconfiguration dialogue wins over a concurrently stale
+ // reachability projection. Otherwise a resumed intent could render the old
+ // connected summary and disable every fresh candidate, leaving no Cancel.
+ const selectedModeConnected = !reconfigurationActive
+ && isReachableConnectionLease(state, connectionMode);
+ const selectedModeTopology = backendTopology?.connectionMode === connectionMode
+ ? backendTopology
+ : null;
+ const selectedModeAppliedUnverified = Boolean(
+ selectedModeTopology?.source === "applied"
+ && selectedModeTopology.status !== "active",
+ );
+ const desiredModeRevision = state?.desired_connection_mode_revision;
+ const discoveryGeneration = state?.ble_discovery_generation;
+ useEffect(() => {
+ if (
+ !scenarioResetPresentationKey
+ || hydratedScenarioResetPresentationKey.current === scenarioResetPresentationKey
+ ) return;
+ // A committed scenario reset is a new local presentation boundary even
+ // when the backend process/runtime id did not change. Never retain the
+ // old click-owned settlement or a pending local request across that
+ // revision. Late promises are fenced by localScenarioActionEpoch.
+ hydratedScenarioResetPresentationKey.current = scenarioResetPresentationKey;
+ setConnectionAttemptPresentation(null);
+ setPreparingReconfigurationRequest(null);
+ setReconfigurationDevicePresentation(null);
+ setSelectedDeviceId("");
+ setSelectedDeviceSnapshot(null);
+ setSuccessfulLocalConnect(null);
+ setExplicitProvisioningDraft(null);
+ setSsid("");
+ setPassword("");
+ setCandidateUnavailableMessage(null);
+ setEscapedAppliedAttemptKey(null);
+ setEscapedConnectionRecoveryKey(null);
+ setFailedConnectionRecoveryKey(null);
+ setFailedPhysicalRecoveryKey(null);
+ setSearchPresentation(null);
+ setScanSecondsRemaining(null);
+ }, [scenarioResetPresentationKey]);
+ const searchPresentationIsCurrent = Boolean(
+ searchPresentation
+ && searchPresentation.snapshotRuntimeId === snapshotRuntimeId
+ && searchPresentation.connectionMode === connectionMode
+ && searchPresentation.desiredModeRevision === (desiredModeRevision ?? null),
+ );
+ const searchRequested = Boolean(
+ searchPresentationIsCurrent
+ && (
+ searchPresentation?.active
+ || (
+ Number.isInteger(searchPresentation?.completedDiscoveryGeneration)
+ && searchPresentation?.completedDiscoveryGeneration === discoveryGeneration
+ )
+ ),
+ );
+ const searchDisplayActive = Boolean(
+ searchPresentationIsCurrent && searchPresentation?.active,
+ );
+ const connectionAttemptView = provisioningAttemptViewState(
+ connectionAttemptPresentation,
+ state,
+ );
+ const connectionAttemptSettling = connectionAttemptView === "submitting"
+ || connectionAttemptView === "settling";
+ const connectionAttemptFailed = connectionAttemptView === "failed";
+ const provisioningMutationBusy = Boolean(
+ presentedPendingAction
+ || modeResetPending !== null
+ || currentPreparingReconfigurationRequest
+ || searchDisplayActive
+ || connectionAttemptSettling
+ );
+ const isBusy = provisioningMutationBusy;
+ const activeSearchSequence = searchDisplayActive
+ ? searchPresentation?.sequence ?? null
+ : null;
+ const reconfigurationFreshScanReady = Boolean(
+ reconfigurationActive
+ && reconfiguration?.status === "fresh-scan-completed"
+ && Number.isInteger(discoveryGeneration)
+ && reconfiguration.fresh_discovery_generation === discoveryGeneration,
+ );
+ const exactChangeNetworkFreshDevice = exactChangeNetworkCandidate(
+ reconfiguration,
+ devices,
+ discoveryGeneration,
+ );
+ const discoveryGenerationCurrent = Number.isInteger(discoveryGeneration)
+ && (discoveryGeneration ?? -1) >= 0;
+ const explicitProvisioningDraftContext = {
+ deviceId: selectedDeviceId || null,
+ connectionMode,
+ desiredModeRevision,
+ discoveryGeneration,
+ snapshotRuntimeId,
+ reconfigurationRevision,
+ reconfigurationIntentId,
+ activeBindingKey,
+ requiredTransportRef: reconfiguration?.required_transport_ref ?? null,
+ requiredConnectionMode: reconfiguration?.required_connection_mode ?? null,
+ };
+ const explicitProvisioningDraftRetained = explicitProvisioningDraftMatches(
+ explicitProvisioningDraft,
+ explicitProvisioningDraftContext,
+ );
+ const explicitProvisioningDraftContextRetained = Boolean(
+ explicitProvisioningDraft
+ && explicitProvisioningDraftMatches(
+ explicitProvisioningDraft,
+ {
+ ...explicitProvisioningDraftContext,
+ discoveryGeneration: explicitProvisioningDraft.discoveryGeneration,
+ },
+ )
+ );
+ const explicitProvisioningDraftStale = Boolean(
+ explicitProvisioningDraftContextRetained
+ && (
+ !explicitProvisioningDraftRetained
+ || explicitProvisioningDraft?.requiresFreshScanBeforeSubmit === true
+ ),
+ );
+ const explicitProvisioningRequested = Boolean(
+ explicitProvisioningDraftRetained
+ && selectedTarget
+ && transportRefEquivalenceKey(explicitProvisioningDraft?.deviceId)
+ === transportRefEquivalenceKey(selectedTarget.deviceId),
+ );
+ const explicitProvisioningRefreshRequired = Boolean(
+ explicitProvisioningDraftRetained
+ && explicitProvisioningDraft?.requiresFreshScanBeforeSubmit,
+ );
+ const backendMutationAllowed = connectionPolicyAllows(state, "provision-fresh-device");
+ const appliedNetworkAttempt = state?.connection_attempt?.phase === "network_applied"
+ ? state.connection_attempt
+ : null;
+ const correlatedConnectionAttempt = connectionAttemptForRuntimeError(
+ errorCorrelation,
+ state,
+ );
+ const unknownNetworkOutcomeAttempt = state?.connection_attempt?.phase
+ === "network_outcome_unknown"
+ && !["accepted", "running"].includes(state.connection_attempt.status)
+ ? state.connection_attempt
+ : null;
+ const connectionRecoveryAttempt = correlatedConnectionAttempt
+ ?? unknownNetworkOutcomeAttempt;
+ const connectionRecoveryObservationAllowed = Boolean(
+ !connectionRecoveryAttempt
+ || [
+ "continue-with-control-verification",
+ "verify-control-read-only",
+ ].includes(connectionRecoveryAttempt.safe_next_action),
+ );
+ const appliedAttemptRecoveryKey = appliedNetworkAttempt
+ && snapshotRuntimeId
+ ? `${snapshotRuntimeId}:${appliedNetworkAttempt.attempt_id}`
+ : null;
+ const appliedRecoveryReconfigurationPrepared = Boolean(
+ appliedNetworkAttempt?.control_state !== "ready"
+ && appliedNetworkAttempt?.connection_mode === "bridge"
+ && reconfiguration?.intent === "select-device"
+ && reconfiguration.required_connection_mode === "bridge"
+ && ["awaiting-fresh-scan", "fresh-scan-completed"].includes(
+ reconfiguration.status,
+ ),
+ );
+ const appliedRecoveryExplicitlyEscaped = Boolean(
+ appliedAttemptRecoveryKey
+ && (
+ escapedAppliedAttemptKey === appliedAttemptRecoveryKey
+ || appliedRecoveryReconfigurationPrepared
+ ),
+ );
+ const unresolvedAppliedAttempt = appliedNetworkAttempt
+ && connectionAttemptOwnsAppliedNetworkRecovery(appliedNetworkAttempt)
+ && !appliedRecoveryExplicitlyEscaped
+ ? appliedNetworkAttempt
+ : null;
+ // A current physical-command ambiguity is stronger than an older
+ // network-applied record. Never trap a powered-off/failed K1 behind the
+ // historical connection Verify card.
+ const physicalRecoveryRequired = requiresReadOnlyPhysicalRecovery(state);
+ const networkRecoveryRequired = unresolvedAppliedAttempt !== null
+ && !physicalRecoveryRequired;
+ // Scan keeps the reset-owned clean draft intact, while the first later
+ // Connect/Verify operation releases it. This hides only pre-reset saved
+ // history and never masks a new post-reset failure.
+ const scenarioResetOwnsCleanDraft = scenarioResetOwnsCleanConnectionDraft(
+ state,
+ connectionMode,
+ );
+ const connectionRecoveryTarget = !scenarioResetOwnsCleanDraft
+ && !physicalRecoveryRequired
+ && !unresolvedAppliedAttempt
+ && !selectedModeConnected
+ && !reconfigurationActive
+ && !connectionAttemptSettling
+ && connectionRecoveryObservationAllowed
+ ? recommendedConnectionRecoveryObservationTarget(state)
+ : null;
+ const connectionRecoveryKey = connectionRecoveryEscapeKey({
+ snapshotRuntimeId,
+ attempt: connectionRecoveryAttempt,
+ target: connectionRecoveryTarget,
+ });
+ const connectionRecoveryRequired = connectionRecoveryIsRequired(
+ connectionRecoveryKey,
+ escapedConnectionRecoveryKey,
+ ) && !(searchRequested && !searchDisplayActive);
+ const connectionRecoveryVerificationFailed = Boolean(
+ connectionRecoveryKey
+ && failedConnectionRecoveryKey === connectionRecoveryKey,
+ );
+ const appliedControlSettlementPending = Boolean(
+ unresolvedAppliedAttempt
+ && ["accepted", "running"].includes(unresolvedAppliedAttempt.status)
+ && unresolvedAppliedAttempt.safe_next_action === "wait-for-current-attempt",
+ );
+ const appliedNetworkRecoveryTarget = unresolvedAppliedAttempt
+ ? serverBoundAppliedNetworkObservationTarget(
+ state,
+ unresolvedAppliedAttempt.connection_mode,
+ )
+ : null;
+ const networkRecoveryModeLabel = unresolvedAppliedAttempt
+ ? connectionModeOptions.find(
+ (option) => option.value === unresolvedAppliedAttempt.connection_mode,
+ )?.label ?? unresolvedAppliedAttempt.connection_mode
+ : null;
+ const localProvisioningPrerequisitesReady = canSubmitProvisioningMutation({
devices,
selectedDeviceId,
- powerConfirmed,
credentialsReady,
- isBusy,
+ isBusy: provisioningMutationBusy,
});
- const modeCopy = connectionCopy[connectionMode];
- const selectedModeConnected = isReachableConnectionLease(state, connectionMode);
+ const physicalRecoveryBinding = physicalRecoveryRequired
+ ? readOnlyPhysicalRecoveryBinding(state)
+ : null;
+ const physicalRecoveryTarget = physicalRecoveryRequired
+ ? readOnlyConnectionObservationTarget(state)
+ : null;
+ const physicalRecoveryAuthorityKey = physicalRecoveryPresentationAuthorityKey(
+ state,
+ );
+ const physicalRecoveryVerificationFailed = Boolean(
+ physicalRecoveryAuthorityKey
+ && failedPhysicalRecoveryKey === physicalRecoveryAuthorityKey,
+ );
+ const physicalReadOnlyVerificationAvailable = Boolean(
+ physicalRecoveryTarget?.serverBound,
+ );
+ useEffect(() => {
+ if (
+ failedPhysicalRecoveryKey
+ && failedPhysicalRecoveryKey !== physicalRecoveryAuthorityKey
+ ) {
+ setFailedPhysicalRecoveryKey(null);
+ }
+ }, [failedPhysicalRecoveryKey, physicalRecoveryAuthorityKey]);
+ const physicalRecoveryModeLabel = physicalRecoveryBinding
+ ? connectionModeOptions.find(
+ (option) => option.value === physicalRecoveryBinding.connectionMode,
+ )?.label ?? physicalRecoveryBinding.connectionMode
+ : "Прежнее подключение";
+ const reconfigurationTargetAllowed = reconfiguration === null
+ || (
+ selectedTarget !== null
+ && reconfigurationAllowsFreshDevice(
+ reconfiguration,
+ selectedTarget.deviceId,
+ connectionMode,
+ )
+ );
+ const canConnect = discoveryGenerationCurrent
+ && explicitProvisioningRequested
+ && !explicitProvisioningDraftStale
+ && !explicitProvisioningRefreshRequired
+ && !unresolvedAppliedAttempt
+ && reconfigurationTargetAllowed
+ && canAdmitProvisioningConnection({
+ policyAllowed: backendMutationAllowed,
+ targetSource: selectedTarget?.source ?? null,
+ hasSuccessfulLocalConnect: successfulLocalConnect !== null,
+ localPrerequisitesReady: localProvisioningPrerequisitesReady,
+ });
+
+ const scanAllowedByPolicy = connectionPolicyAllows(state, "scan-ble");
+ const backendScanAllowed = scanAllowedByPolicy
+ && !isBusy
+ && !networkRecoveryRequired;
+ const physicalStopRecoverySettling = isPhysicalStopRecoverySettling(state);
+ const canScan = backendScanAllowed && !physicalStopRecoverySettling;
+ const canScanAppliedRecovery = Boolean(
+ unresolvedAppliedAttempt
+ && !appliedControlSettlementPending
+ && unresolvedAppliedAttempt.connection_mode !== "bridge"
+ && scanAllowedByPolicy
+ && !isBusy
+ && !physicalStopRecoverySettling,
+ );
+ const trustedBinding = trustedConnectionBinding(state);
+ const connectedEndpoint = selectedModeConnected
+ ? selectedModeTopology?.endpoint?.trim() || null
+ : null;
+ const connectedDeviceIdentity = selectedModeConnected
+ ? selectedTarget?.label
+ || retainedSessionLabel
+ || (trustedBinding?.connectionMode === connectionMode
+ ? trustedBinding.deviceId
+ : null)
+ || state?.connection_lifecycle?.active_binding?.transport_ref?.trim()
+ || connectedEndpoint
+ || "Активное подключение"
+ : null;
+ const connectionActionPending = presentedPendingAction === "connect"
+ || presentedPendingAction === "verify";
+ const verificationActionPending = presentedPendingAction === "verify";
+ const networkActionPending = presentedPendingAction === "connect"
+ || presentedPendingAction === "mode"
+ || Boolean(
+ changeNetworkDialogue
+ && presentedPendingAction === "scan"
+ );
+ const selectDeviceAllowedByPolicy = connectionPolicyAllows(
+ state,
+ "prepare-select-device",
+ );
+ const changeNetworkAllowedByPolicy = connectionPolicyAllows(
+ state,
+ "prepare-change-network",
+ );
+ const canPrepareSelectDevice = connectionMode === "bridge"
+ && !isBusy
+ && !networkRecoveryRequired
+ && selectDeviceAllowedByPolicy;
+ const canPrepareRecoverySelectDevice = Boolean(
+ unresolvedAppliedAttempt?.connection_mode === "bridge"
+ && !appliedControlSettlementPending
+ && !isBusy
+ && selectDeviceAllowedByPolicy,
+ );
+ const canPrepareChangeNetwork = connectionMode === "bridge"
+ && !isBusy
+ && !networkRecoveryRequired
+ && changeNetworkAllowedByPolicy;
+ const selectDeviceActionApplicable = connectionMode === "bridge"
+ && connectedReconfigurationActionApplicable(state, "prepare-select-device");
+ const changeNetworkActionApplicable = connectionMode === "bridge"
+ && connectedReconfigurationActionApplicable(state, "prepare-change-network");
+ const showNetworkStep = Boolean(
+ !physicalRecoveryRequired
+ && !connectionRecoveryRequired
+ && (
+ selectedModeConnected
+ || explicitProvisioningDraftRetained
+ || explicitProvisioningDraftContextRetained
+ || unresolvedAppliedAttempt
+ || connectionAttemptSettling
+ || connectionAttemptFailed
+ || (changeNetworkDialogue && Boolean(changeNetworkRequiredDeviceId))
+ )
+ );
+ const requestExplicitProvisioning = useCallback((
+ deviceId: string,
+ {
+ preservePassword = false,
+ requiresFreshScanBeforeSubmit = false,
+ }: {
+ preservePassword?: boolean;
+ requiresFreshScanBeforeSubmit?: boolean;
+ } = {},
+ ) => {
+ if (
+ networkRecoveryRequired
+ ||
+ !Number.isInteger(desiredModeRevision)
+ || (desiredModeRevision ?? -1) < 0
+ || !Number.isInteger(discoveryGeneration)
+ || (discoveryGeneration ?? -1) < 0
+ || snapshotRuntimeId === null
+ ) return;
+ setExplicitProvisioningDraft({
+ snapshotRuntimeId,
+ deviceId,
+ connectionMode,
+ desiredModeRevision: desiredModeRevision as number,
+ discoveryGeneration: discoveryGeneration as number,
+ reconfigurationRevision,
+ reconfigurationIntentId,
+ activeBindingKey,
+ requiredTransportRef: reconfiguration?.required_transport_ref ?? null,
+ requiredConnectionMode: reconfiguration?.required_connection_mode ?? null,
+ requiresFreshScanBeforeSubmit,
+ });
+ setCandidateUnavailableMessage(null);
+ if (!preservePassword) setPassword("");
+ }, [
+ activeBindingKey,
+ connectionMode,
+ desiredModeRevision,
+ discoveryGeneration,
+ networkRecoveryRequired,
+ reconfiguration?.required_connection_mode,
+ reconfiguration?.required_transport_ref,
+ reconfigurationIntentId,
+ reconfigurationRevision,
+ snapshotRuntimeId,
+ ]);
+ useEffect(() => {
+ if (!searchDisplayActive) {
+ setScanSecondsRemaining(null);
+ return;
+ }
+ const startedAt = Date.now();
+ const updateCountdown = () => {
+ const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1_000);
+ setScanSecondsRemaining(Math.max(0, 6 - elapsedSeconds));
+ };
+ updateCountdown();
+ const timer = window.setInterval(updateCountdown, 250);
+ return () => window.clearInterval(timer);
+ }, [activeSearchSequence, searchDisplayActive]);
+ useEffect(() => {
+ if (!appliedNetworkAttempt) return;
+ // Once the backend records that the network write reached the K1, local
+ // credentials and Apply authority are spent even if control verification
+ // is still unresolved. Recovery below is read-only and server-targeted.
+ setExplicitProvisioningDraft(null);
+ setSsid("");
+ setPassword("");
+ }, [appliedNetworkAttempt?.attempt_id, appliedNetworkAttempt?.phase]);
+ useEffect(() => {
+ if (connectionAttemptView !== "retired") return;
+ setConnectionAttemptPresentation(null);
+ }, [connectionAttemptView]);
+ useEffect(() => {
+ if (
+ !successfulLocalConnect
+ || localBackendSession
+ || selectedModeAppliedUnverified
+ ) return;
+ setSuccessfulLocalConnect(null);
+ // A lost LAN lease does not erase a fresh BLE target from the operator's
+ // scan, but it does retire the previous provisioning draft. Recovery tries
+ // read-only observation first; credentials require a new explicit action.
+ if (freshDeviceSummary) return;
+ setSelectedDeviceId("");
+ setSelectedDeviceSnapshot(null);
+ setExplicitProvisioningDraft(null);
+ setPassword("");
+ }, [
+ freshDeviceSummary,
+ localBackendSession,
+ selectedModeAppliedUnverified,
+ successfulLocalConnect,
+ ]);
+ useEffect(() => {
+ if (
+ !selectedDeviceId
+ || freshDeviceSummary
+ || localBackendSession
+ || explicitProvisioningDraftRetained
+ || explicitProvisioningDraftContextRetained
+ || connectionAttemptPresentation
+ ) return;
+ setSelectedDeviceId("");
+ setSelectedDeviceSnapshot(null);
+ setExplicitProvisioningDraft(null);
+ setPassword("");
+ }, [
+ connectionAttemptPresentation,
+ explicitProvisioningDraftRetained,
+ explicitProvisioningDraftContextRetained,
+ freshDeviceSummary,
+ localBackendSession,
+ selectedDeviceId,
+ ]);
useEffect(() => {
- if (selectedDeviceId && !deviceSummary) {
- provisioningIntentRef.current = null;
- setSelectedDeviceId("");
+ if (!explicitProvisioningDraft || explicitProvisioningDraftRetained) return;
+ if (explicitProvisioningDraftStale) {
+ setCandidateUnavailableMessage(
+ "Результат Bluetooth-поиска устарел. Настройки не отправлены; выполните явный повторный поиск.",
+ );
+ return;
}
- }, [deviceSummary, selectedDeviceId]);
+ if (connectionAttemptPresentation) return;
+ // Password entry is authority only for the exact fresh target, discovery
+ // generation and desired-mode revision that opened it. A later backend/tab
+ // transition invalidates that draft before another provisioning click can
+ // reuse it.
+ setExplicitProvisioningDraft(null);
+ setSelectedDeviceId("");
+ setSelectedDeviceSnapshot(null);
+ setPassword("");
+ }, [
+ connectionAttemptPresentation,
+ explicitProvisioningDraft,
+ explicitProvisioningDraftRetained,
+ explicitProvisioningDraftStale,
+ ]);
- const canAdoptExistingBridge = connectionMode === "bridge"
- && powerConfirmed
- && deviceSummary !== null
- && deviceSummary.connectable !== false
- && !isBusy;
+ useEffect(() => {
+ if (
+ !changeNetworkDialogue
+ || !exactChangeNetworkFreshDevice
+ || (
+ transportRefEquivalenceKey(selectedDeviceId)
+ === transportRefEquivalenceKey(exactChangeNetworkFreshDevice.device_id)
+ && explicitProvisioningRequested
+ )
+ ) return;
+ setSelectedDeviceId(exactChangeNetworkFreshDevice.device_id);
+ setSelectedDeviceSnapshot(exactChangeNetworkFreshDevice);
+ setSuccessfulLocalConnect(null);
+ requestExplicitProvisioning(
+ exactChangeNetworkFreshDevice.device_id,
+ {
+ preservePassword: true,
+ requiresFreshScanBeforeSubmit: false,
+ },
+ );
+ }, [
+ changeNetworkDialogue,
+ exactChangeNetworkFreshDevice,
+ explicitProvisioningRequested,
+ requestExplicitProvisioning,
+ selectedDeviceId,
+ ]);
- const resetProvisioningIntent = () => {
- provisioningIntentRef.current = null;
+ useEffect(() => {
+ const nextFence = localProvisioningDraftFenceKey({
+ snapshotRuntimeId,
+ reconfigurationRevision,
+ reconfigurationIntentId,
+ activeBindingKey,
+ requiredTransportRef: reconfiguration?.required_transport_ref ?? null,
+ requiredConnectionMode: reconfiguration?.required_connection_mode ?? null,
+ });
+ if (localDraftFence.current === null) {
+ localDraftFence.current = nextFence;
+ return;
+ }
+ if (localDraftFence.current === nextFence) return;
+ localDraftFence.current = nextFence;
+ setSuccessfulLocalConnect(null);
+ setSelectedDeviceId("");
+ setSelectedDeviceSnapshot(null);
+ setExplicitProvisioningDraft(null);
+ setSsid("");
+ setPassword("");
+ setConnectionAttemptPresentation((current) => (
+ current?.snapshotRuntimeId === snapshotRuntimeId ? current : null
+ ));
+ setReconfigurationDevicePresentation((current) => {
+ const requiredTransportRef = reconfiguration?.required_transport_ref?.trim();
+ return reconfiguration?.intent === "change-network"
+ && requiredTransportRef
+ && current
+ && transportRefEquivalenceKey(current.deviceId)
+ === transportRefEquivalenceKey(requiredTransportRef)
+ ? current
+ : null;
+ });
+ setPreparingReconfigurationRequest(null);
+ setCandidateUnavailableMessage(null);
+ setEscapedConnectionRecoveryKey(null);
+ setFailedConnectionRecoveryKey(null);
+ setFailedPhysicalRecoveryKey(null);
+ resetSearchPresentation();
+ }, [
+ activeBindingKey,
+ reconfiguration?.required_connection_mode,
+ reconfiguration?.required_transport_ref,
+ reconfigurationIntentId,
+ reconfigurationRevision,
+ resetSearchPresentation,
+ snapshotRuntimeId,
+ ]);
+
+ useEffect(() => {
+ if (!selectedModeConnected || connectionActionPending) return;
+ setConnectionAttemptPresentation(null);
+ }, [connectionActionPending, selectedModeConnected]);
+
+ useEffect(() => {
+ if (
+ reconfigurationActive
+ || presentedPendingAction === "reconfigure"
+ || preparingReconfigurationIntent
+ ) return;
+ setReconfigurationDevicePresentation(null);
+ }, [
+ presentedPendingAction,
+ preparingReconfigurationIntent,
+ reconfigurationActive,
+ ]);
+
+ const repeatDeviceScan = async ({
+ preserveDraft = false,
+ appliedRecoveryEscape = false,
+ physicalRecoveryScan = false,
+ connectionRecoveryEscape = false,
+ }: {
+ preserveDraft?: boolean;
+ appliedRecoveryEscape?: boolean;
+ physicalRecoveryScan?: boolean;
+ connectionRecoveryEscape?: boolean;
+ } = {}) => {
+ const actionEpoch = localScenarioActionEpoch.current;
+ const recoveryScanAttempt = appliedRecoveryEscape
+ && canScanAppliedRecovery
+ ? unresolvedAppliedAttempt
+ : null;
+ const exactPhysicalRecoveryScan = physicalRecoveryScan
+ && physicalRecoveryRequired
+ ? physicalRecoveryBinding
+ : null;
+ const exactConnectionRecoveryKey = connectionRecoveryEscape
+ && connectionRecoveryRequired
+ ? connectionRecoveryKey
+ : null;
+ if (
+ (!canScan && !recoveryScanAttempt)
+ || (
+ typeof isConnectionPolicyActionAllowedCurrent === "function"
+ && !isConnectionPolicyActionAllowedCurrent("scan-ble")
+ )
+ ) return;
+ const scanMode = exactPhysicalRecoveryScan?.connectionMode
+ ?? recoveryScanAttempt?.connection_mode
+ ?? connectionMode;
+ const recoveryAttemptKey = recoveryScanAttempt
+ ? appliedAttemptRecoveryKey
+ : null;
+ const preservedDeviceId = preserveDraft ? selectedDeviceId : "";
+ const preservedSnapshot = preserveDraft ? selectedDeviceSnapshot : null;
+ if (
+ (recoveryScanAttempt || exactPhysicalRecoveryScan)
+ && connectionMode !== scanMode
+ ) {
+ void onDesiredModeChange(scanMode);
+ }
+ const modeAuthority = recoveryScanAttempt
+ ? typeof getConnectionActionAuthority === "function"
+ ? getConnectionActionAuthority(scanMode)
+ : null
+ : await commitDesiredModeForExplicitAction(scanMode);
+ if (
+ !localScenarioActionEpochIsCurrent(
+ actionEpoch,
+ localScenarioActionEpoch.current,
+ )
+ || !modeAuthority
+ ) return;
+ const actionFence = activateRuntimeActionFence(modeAuthority);
+ if (!actionFence || !runtimeActionIsCurrent(actionFence)) return;
+ const searchSequence = searchPresentationSequence.current + 1;
+ searchPresentationSequence.current = searchSequence;
+ setSearchPresentation({
+ sequence: searchSequence,
+ snapshotRuntimeId: modeAuthority.snapshotRuntimeId,
+ connectionMode: scanMode,
+ desiredModeRevision: modeAuthority.desiredModeRevision,
+ active: true,
+ completedDiscoveryGeneration: null,
+ });
+ setSuccessfulLocalConnect(null);
+ if (!preserveDraft) {
+ setSelectedDeviceId("");
+ setSelectedDeviceSnapshot(null);
+ setExplicitProvisioningDraft(null);
+ setPassword("");
+ }
+ setConnectionAttemptPresentation(null);
+ setCandidateUnavailableMessage(null);
+ let scanResult: BleDiscoverySubmitResult | null = null;
+ try {
+ if (!runtimeActionIsCurrent(actionFence)) return;
+ scanResult = await scanWithResult({ durationSeconds: 6 });
+ if (
+ !localScenarioActionEpochIsCurrent(
+ actionEpoch,
+ localScenarioActionEpoch.current,
+ )
+ || !runtimeClickIsCurrent(actionFence)
+ ) return;
+ if (scanResult.succeeded && recoveryAttemptKey) {
+ setEscapedAppliedAttemptKey(recoveryAttemptKey);
+ }
+ const settledConnectionRecoveryKey = connectionRecoveryEscapeAfterScan(
+ scanResult.succeeded,
+ exactConnectionRecoveryKey,
+ );
+ if (settledConnectionRecoveryKey) {
+ setEscapedConnectionRecoveryKey(settledConnectionRecoveryKey);
+ }
+ if (!preserveDraft || !scanResult.succeeded || !preservedDeviceId) return;
+ const exactDeviceObserved = scanResult.transportRefs.some(
+ (transportRef) => transportRefEquivalenceKey(transportRef)
+ === transportRefEquivalenceKey(preservedDeviceId),
+ );
+ const refreshedAuthority = typeof getConnectionActionAuthority === "function"
+ ? getConnectionActionAuthority(scanMode)
+ : null;
+ if (!exactDeviceObserved || !refreshedAuthority) {
+ setCandidateUnavailableMessage(
+ "Выбранный K1 не найден в новом поиске. Настройки не отправлены; повторите поиск или выберите другой результат.",
+ );
+ return;
+ }
+ setSelectedDeviceId(preservedDeviceId);
+ setSelectedDeviceSnapshot(preservedSnapshot);
+ setExplicitProvisioningDraft({
+ snapshotRuntimeId: refreshedAuthority.snapshotRuntimeId,
+ deviceId: preservedDeviceId,
+ connectionMode: scanMode,
+ desiredModeRevision: refreshedAuthority.desiredModeRevision,
+ discoveryGeneration: refreshedAuthority.discoveryGeneration,
+ reconfigurationRevision: refreshedAuthority.reconfigurationRevision,
+ reconfigurationIntentId: refreshedAuthority.reconfigurationIntentId,
+ activeBindingKey: refreshedAuthority.activeBindingKey,
+ requiredTransportRef: reconfiguration?.required_transport_ref ?? null,
+ requiredConnectionMode: reconfiguration?.required_connection_mode ?? null,
+ requiresFreshScanBeforeSubmit: false,
+ });
+ setCandidateUnavailableMessage(null);
+ } finally {
+ setSearchPresentation((current) => (
+ searchPresentationSequence.current === searchSequence
+ && current?.sequence === searchSequence
+ ? {
+ ...current,
+ active: false,
+ completedDiscoveryGeneration: scanResult?.succeeded
+ ? scanResult.discoveryGeneration
+ : null,
+ }
+ : current
+ ));
+ retireRuntimeClickFence(actionFence);
+ }
};
const submitConnect = async () => {
- if (!canConnect || !deviceSummary) return;
- const idempotencyKey = provisioningIntentKey(provisioningIntentRef.current);
- provisioningIntentRef.current = idempotencyKey;
- const networkCredentials = connectionMode === "quick-connect"
- ? {}
- : { ssid: ssid.trim(), password };
- const succeeded = await connect({
- device_id: deviceSummary.device_id,
- ...networkCredentials,
- connection_mode: connectionMode,
- compatibility_attestation: profileSelectionForConnectionMode(connectionMode),
- idempotency_key: idempotencyKey,
+ const actionEpoch = localScenarioActionEpoch.current;
+ if (
+ !selectedTarget
+ || !explicitProvisioningDraft
+ || !canConnect
+ || unresolvedAppliedAttempt
+ ) return;
+ if (activeConnectIntent.current !== null) return;
+ const connectIntentToken = connectIntentSequence.current + 1;
+ connectIntentSequence.current = connectIntentToken;
+ activeConnectIntent.current = connectIntentToken;
+ const attemptedDeviceId = selectedTarget.deviceId;
+ const attemptedConnectionMode = connectionMode;
+ const attemptedSsid = connectionMode === "quick-connect" ? null : ssid.trim();
+ const attemptedPassword = password;
+ const expectedDraftRuntimeId = explicitProvisioningDraft.snapshotRuntimeId;
+ const expectedDiscoveryGeneration = explicitProvisioningDraft.discoveryGeneration;
+ const idempotencyKey = provisioningIntentKey(null);
+ const failureForCurrentAttempt = (
+ message: string,
+ freshStartAllowed: boolean,
+ ) => {
+ if (!localScenarioActionEpochIsCurrent(
+ actionEpoch,
+ localScenarioActionEpoch.current,
+ )) return;
+ setConnectionAttemptPresentation((current) => (
+ current?.idempotencyKey === idempotencyKey
+ ? {
+ ...current,
+ localPhase: "failed",
+ failureMessage: message,
+ freshStartAllowed,
+ }
+ : current
+ ));
+ setCandidateUnavailableMessage(message);
+ };
+ let actionFence: RuntimeActionFence | null = null;
+
+ // Spend the editable credentials at the click boundary. The request owns
+ // only these stack-local values; no password is retained in React state or
+ // in the presentation latch after dispatch begins.
+ setConnectionAttemptPresentation({
+ snapshotRuntimeId: expectedDraftRuntimeId,
+ connectionMode: attemptedConnectionMode,
+ idempotencyKey,
+ attemptId: null,
+ deviceId: attemptedDeviceId,
+ label: selectedDeviceSnapshot?.name?.trim() || selectedTarget.label,
+ rssi: finiteMetric(selectedDeviceSnapshot?.rssi),
+ ssid: attemptedSsid,
+ localPhase: "submitting",
+ failureMessage: null,
+ freshStartAllowed: false,
});
- if (succeeded) {
- provisioningIntentRef.current = null;
- setPassword("");
- } else {
- // Every later click is a new explicit operator intent, never an
- // automatic replay of a consumed failed journal entry. If the prior
- // write outcome is ambiguous, the backend reconciliation fence blocks
- // this new intent before another device write for both modes.
- provisioningIntentRef.current = null;
+ setExplicitProvisioningDraft(null);
+ setSsid("");
+ setPassword("");
+ setCandidateUnavailableMessage(null);
+ try {
+ const modeAuthority = await commitDesiredModeForExplicitAction();
+ if (!localScenarioActionEpochIsCurrent(
+ actionEpoch,
+ localScenarioActionEpoch.current,
+ )) return;
+ if (!modeAuthority) {
+ failureForCurrentAttempt(
+ "Состояние подключения изменилось до отправки. Настройки не отправлены; начните новый явный поиск.",
+ true,
+ );
+ return;
+ }
+ if (modeAuthority.snapshotRuntimeId !== expectedDraftRuntimeId) {
+ failureForCurrentAttempt(
+ "Среда подключения изменилась до отправки. Настройки не отправлены; выберите устройство заново.",
+ true,
+ );
+ return;
+ }
+ if (modeAuthority.discoveryGeneration !== expectedDiscoveryGeneration) {
+ failureForCurrentAttempt(
+ "Результат Bluetooth-поиска устарел до отправки. Настройки не отправлены; выполните явный повторный поиск.",
+ true,
+ );
+ return;
+ }
+ actionFence = activateRuntimeActionFence(modeAuthority);
+ if (!actionFence || !runtimeActionIsCurrent(actionFence)) {
+ failureForCurrentAttempt(
+ "Состояние подключения изменилось до отправки. Настройки не отправлены; повторите поиск Bluetooth.",
+ true,
+ );
+ return;
+ }
+
+ const networkCredentials = attemptedConnectionMode === "quick-connect"
+ ? {}
+ : { ssid: attemptedSsid ?? "", password: attemptedPassword };
+ const result = await connect({
+ device_id: attemptedDeviceId,
+ ...networkCredentials,
+ connection_mode: attemptedConnectionMode,
+ compatibility_attestation: profileSelectionForConnectionMode(
+ attemptedConnectionMode,
+ ),
+ idempotency_key: idempotencyKey,
+ expected_mode_revision: modeAuthority.desiredModeRevision,
+ expected_discovery_generation: modeAuthority.discoveryGeneration,
+ expected_reconfiguration_revision: modeAuthority.reconfigurationRevision,
+ ...(modeAuthority.reconfigurationIntentId
+ ? {
+ expected_reconfiguration_intent_id:
+ modeAuthority.reconfigurationIntentId,
+ }
+ : {}),
+ });
+ if (
+ !localScenarioActionEpochIsCurrent(
+ actionEpoch,
+ localScenarioActionEpoch.current,
+ )
+ || !runtimeClickIsCurrent(actionFence)
+ ) return;
+ const observedOperation = operationByIdempotencyKey(
+ result.observedState,
+ "network.provision",
+ idempotencyKey,
+ );
+
+ if (result.networkIntentCompleted) {
+ const promotedFence = promoteFenceFromConnectResult(
+ actionFence,
+ result.observedState,
+ attemptedDeviceId,
+ attemptedConnectionMode,
+ );
+ if (promotedFence) actionFence = promotedFence;
+ setSuccessfulLocalConnect({
+ deviceId: attemptedDeviceId,
+ connectionMode: attemptedConnectionMode,
+ acceptedSessionKey: result.acceptedSessionKey,
+ });
+ setReconfigurationDevicePresentation(null);
+ setConnectionAttemptPresentation((current) => (
+ current?.idempotencyKey === idempotencyKey
+ ? {
+ ...current,
+ attemptId: observedOperation?.operation_id ?? current.attemptId,
+ localPhase: "settling",
+ }
+ : current
+ ));
+ return;
+ }
+
+ const freshStartAllowed = result.intentDisposition === "release";
+ const failureMessage = freshStartAllowed
+ && provisioningFailureRequiresFreshCandidate(result.failureReasonCode)
+ ? "Результат Bluetooth-поиска устарел до отправки. Настройки не отправлены; выполните явный повторный поиск."
+ : freshStartAllowed
+ ? "Подключение не началось. Настройки удалены; после устранения причины начните новый явный поиск."
+ : "Результат отправки неизвестен. Повторное применение заблокировано, чтобы не отправить BLE-команду дважды; дождитесь, пока система определит безопасное продолжение.";
+ setConnectionAttemptPresentation((current) => (
+ current?.idempotencyKey === idempotencyKey
+ ? {
+ ...current,
+ attemptId: observedOperation?.operation_id ?? current.attemptId,
+ localPhase: "failed",
+ failureMessage,
+ freshStartAllowed,
+ }
+ : current
+ ));
+ setCandidateUnavailableMessage(failureMessage);
+ } catch {
+ failureForCurrentAttempt(
+ "Подключение не подтверждено. Настройки удалены; обновите состояние перед новым явным действием.",
+ false,
+ );
+ } finally {
+ if (actionFence && runtimeClickIsCurrent(actionFence)) {
+ retireRuntimeClickFence(actionFence);
+ }
+ if (activeConnectIntent.current === connectIntentToken) {
+ activeConnectIntent.current = null;
+ }
}
};
- const submitExistingBridgeAdoption = async () => {
- if (!canAdoptExistingBridge || !deviceSummary) return;
- const succeeded = await verifyConnection({
- device_id: deviceSummary.device_id,
- compatibility_attestation: profileSelectionForConnectionMode("bridge"),
- });
- if (succeeded) {
- provisioningIntentRef.current = null;
+ const verifyAppliedNetwork = async () => {
+ if (
+ !unresolvedAppliedAttempt
+ || appliedControlSettlementPending
+ || isBusy
+ ) return;
+ const request = appliedNetworkRecoveryTarget
+ ? observationRequest(
+ appliedNetworkRecoveryTarget,
+ reconfigurationRevision,
+ reconfigurationIntentId,
+ )
+ : null;
+ if (!request) {
+ setCandidateUnavailableMessage(
+ "Попытка настройки завершена, но проверка состояния без изменений сейчас недоступна. Новая BLE-запись заблокирована; обновите состояние.",
+ );
+ return;
+ }
+ const actionFence = beginRuntimeActionFence(
+ unresolvedAppliedAttempt.connection_mode,
+ );
+ if (!actionFence || !runtimeActionIsCurrent(actionFence)) return;
+ setCandidateUnavailableMessage(null);
+ try {
+ const result = await verifyConnection(request);
+ if (!runtimeClickIsCurrent(actionFence)) return;
+ if (!result.succeeded) {
+ setCandidateUnavailableMessage(
+ "Управляющее подключение не подтверждено. BLE-запись не повторялась; устраните причину и повторите проверку состояния без изменений.",
+ );
+ }
+ } finally {
+ retireRuntimeClickFence(actionFence);
}
};
- return (
-
-
-
-
- Выберите направление связи. Каждый путь выполняет не более одного сетевого изменения и не повторяет его автоматически.
-
- {
- setConnectionMode(value);
- setSsid("");
- setPassword("");
- resetProvisioningIntent();
- }}
- disabled={isBusy}
- variant="split"
- />
-
-
-
-
-
Для текущего адаптера дождитесь ровного зелёного индикатора. Это подтверждение оператора, а не аппаратная телеметрия.
-
{ setPowerConfirmed(checked); if (!checked) resetProvisioningIntent(); }}
- />
+ const verifyConnectionRecoveryTarget = async () => {
+ if (
+ !connectionRecoveryTarget?.serverBound
+ || !snapshotRuntimeId
+ || isBusy
+ || typeof getConnectionRecoveryObservationTarget !== "function"
+ ) return;
+ const actionEpoch = localScenarioActionEpoch.current;
+ const verificationKey = connectionRecoveryKey;
+ setCandidateUnavailableMessage(null);
+ const dispatched = await dispatchConnectionRecoveryObservationForCurrentRuntime(
+ snapshotRuntimeId,
+ connectionRecoveryTarget,
+ isSnapshotRuntimeCurrent,
+ getConnectionRecoveryObservationTarget,
+ async (target) => {
+ const request = observationRequest(
+ target,
+ reconfigurationRevision,
+ reconfigurationIntentId,
+ );
+ if (!request) return null;
+ return verifyConnection(request, { surfaceErrors: false });
+ },
+ );
+ if (!localScenarioActionEpochIsCurrent(
+ actionEpoch,
+ localScenarioActionEpoch.current,
+ )) return;
+ if (!dispatched.dispatched || !dispatched.result) {
+ setFailedConnectionRecoveryKey(verificationKey);
+ setCandidateUnavailableMessage(
+ "Не удалось начать переподключение: состояние K1 изменилось. Подключите новый K1 через ручной Bluetooth-поиск.",
+ );
+ return;
+ }
+ if (
+ typeof isSnapshotRuntimeCurrent === "function"
+ && !isSnapshotRuntimeCurrent(snapshotRuntimeId)
+ ) return;
+ if (!dispatched.result.succeeded) {
+ setFailedConnectionRecoveryKey(verificationKey);
+ setCandidateUnavailableMessage(
+ "Не удалось переподключиться к прежнему K1. Подключите новый K1 через ручной Bluetooth-поиск.",
+ );
+ return;
+ }
+ setFailedConnectionRecoveryKey(null);
+ setCandidateUnavailableMessage(null);
+ };
+
+ const refreshCorrelatedConnectionFailure = async () => {
+ await clearConnectionFailureAfterSuccessfulRefresh(refresh, clearError);
+ };
+
+ const clearTransientConnectionDraft = () => {
+ setSuccessfulLocalConnect(null);
+ setSelectedDeviceId("");
+ setSelectedDeviceSnapshot(null);
+ setExplicitProvisioningDraft(null);
+ setSsid("");
+ setPassword("");
+ setPasswordVisible(false);
+ setConnectionAttemptPresentation(null);
+ setCandidateUnavailableMessage(null);
+ setFailedConnectionRecoveryKey(null);
+ setFailedPhysicalRecoveryKey(null);
+ };
+
+ const verifyPhysicalRecovery = async () => {
+ const actionEpoch = localScenarioActionEpoch.current;
+ const verificationKey = physicalRecoveryAuthorityKey;
+ if (
+ !physicalRecoveryRequired
+ || !physicalRecoveryTarget?.serverBound
+ || isBusy
+ ) return;
+ const request = observationRequest(
+ physicalRecoveryTarget,
+ reconfigurationRevision,
+ reconfigurationIntentId,
+ );
+ if (!request) {
+ setFailedPhysicalRecoveryKey(verificationKey);
+ setCandidateUnavailableMessage(
+ "Не удалось переподключиться к прежнему K1. Подключите новый K1 вручную.",
+ );
+ return;
+ }
+
+ const modeAuthority = await commitDesiredModeForExplicitAction(
+ physicalRecoveryTarget.connectionMode,
+ );
+ if (!localScenarioActionEpochIsCurrent(
+ actionEpoch,
+ localScenarioActionEpoch.current,
+ )) return;
+ if (!modeAuthority) {
+ setFailedPhysicalRecoveryKey(verificationKey);
+ setCandidateUnavailableMessage(
+ "Не удалось переподключиться: состояние K1 изменилось. Подключите новый K1 вручную.",
+ );
+ return;
+ }
+ const actionFence = activateRuntimeActionFence(modeAuthority);
+ if (!actionFence || !runtimeActionIsCurrent(actionFence)) return;
+
+ setCandidateUnavailableMessage(null);
+ try {
+ const result = await verifyConnection(request);
+ if (!runtimeClickIsCurrent(actionFence)) return;
+ if (!result.succeeded) {
+ setFailedPhysicalRecoveryKey(verificationKey);
+ setCandidateUnavailableMessage(
+ "Не удалось переподключиться к прежнему K1. Подключите новый K1 вручную.",
+ );
+ return;
+ }
+ if (
+ result.observedState
+ && isRecoveredPhysicalScanning(
+ result.observedState,
+ physicalRecoveryTarget.connectionMode,
+ )
+ ) {
+ setCandidateUnavailableMessage(
+ "K1 подтвердил продолжающееся сканирование. Новая команда не отправлялась; доступна одна явная остановка.",
+ );
+ return;
+ }
+ setFailedPhysicalRecoveryKey(null);
+ setCandidateUnavailableMessage(null);
+ } finally {
+ retireRuntimeClickFence(actionFence);
+ }
+ };
+
+ const prepareReconfiguration = async (
+ intent: XgridsConnectionReconfigurationIntent,
+ { appliedRecoveryEscape = false }: { appliedRecoveryEscape?: boolean } = {},
+ ) => {
+ const recoveryEscapeAllowed = Boolean(
+ appliedRecoveryEscape
+ && intent === "select-device"
+ && canPrepareRecoverySelectDevice
+ && unresolvedAppliedAttempt?.connection_mode === "bridge",
+ );
+ const actionMode = recoveryEscapeAllowed ? "bridge" : connectionMode;
+ if (
+ (networkRecoveryRequired && !recoveryEscapeAllowed)
+ || actionMode !== "bridge"
+ || !Number.isInteger(desiredModeRevision)
+ || (desiredModeRevision ?? -1) < 0
+ ) return;
+ const actionFence = beginRuntimeActionFence(actionMode);
+ if (!actionFence || !runtimeActionIsCurrent(actionFence)) return;
+ let currentFence = actionFence;
+ setPreparingReconfigurationRequest({ ...currentFence, intent });
+ if (intent === "change-network") {
+ const exactDeviceId = trustedBinding?.deviceId
+ || state?.connection_lifecycle?.active_binding?.transport_ref?.trim()
+ || connectedDeviceIdentity;
+ if (exactDeviceId) {
+ setReconfigurationDevicePresentation({
+ deviceId: exactDeviceId,
+ label: connectedDeviceIdentity || exactDeviceId,
+ rssi: null,
+ });
+ }
+ }
+ try {
+ const result = await prepareConnectionReconfigurationWithResult({
+ intent,
+ expected_reconfiguration_revision: reconfigurationRevision,
+ expected_reconfiguration_intent_id: reconfigurationIntentId,
+ expected_desired_mode_revision: desiredModeRevision as number,
+ expected_active_binding_key: activeBindingKey,
+ });
+ const promotedReconfigurationFence = promoteFenceFromReconfigurationResult(
+ currentFence,
+ result,
+ intent,
+ );
+ if (!promotedReconfigurationFence) return;
+ currentFence = promotedReconfigurationFence;
+ setPreparingReconfigurationRequest({ ...currentFence, intent });
+ if (recoveryEscapeAllowed && appliedAttemptRecoveryKey) {
+ setEscapedAppliedAttemptKey(appliedAttemptRecoveryKey);
+ }
+ clearTransientConnectionDraft();
+ if (recoveryEscapeAllowed && connectionMode !== actionMode) {
+ void onDesiredModeChange(actionMode);
+ }
+ } finally {
+ if (runtimeClickIsCurrent(currentFence)) {
+ setPreparingReconfigurationRequest(null);
+ retireRuntimeClickFence(currentFence);
+ }
+ }
+ };
+
+ const changeDesiredConnectionMode = async (value: ConnectionMode) => {
+ if (modeResetInFlight) return;
+ const expectedRevision = state?.desired_connection_mode_revision;
+ if (!Number.isInteger(expectedRevision) || (expectedRevision ?? -1) < 0) {
+ setCandidateUnavailableMessage(
+ "Не удалось зафиксировать текущую ревизию подключения. Обновите состояние и повторите явный сброс.",
+ );
+ return;
+ }
+ setCandidateUnavailableMessage(null);
+ const resetId = newOperationId();
+ setModeResetPending({ resetId, targetMode: value });
+ try {
+ const resetAccepted = await selectConnectionMode({
+ connection_mode: value,
+ expected_revision: expectedRevision as number,
+ reset_scenario: true,
+ reset_id: resetId,
+ });
+ if (!resetAccepted) return;
+ // A committed scenario reset is also the local click-ownership
+ // boundary. The backend can rotate the connection revisions without
+ // replacing snapshot_runtime_id, so clearing only visible drafts would
+ // otherwise leave an old Scan/Connect/Verify owner wedged in this tab.
+ localScenarioActionEpoch.current += 1;
+ activeRuntimeActionFence.current = null;
+ activeConnectIntent.current = null;
+ setPreparingReconfigurationRequest(null);
+ clearTransientConnectionDraft();
+ setReconfigurationDevicePresentation(null);
+ setEscapedAppliedAttemptKey(null);
+ setEscapedConnectionRecoveryKey(null);
+ setFailedConnectionRecoveryKey(null);
+ setFailedPhysicalRecoveryKey(null);
+ resetSearchPresentation();
+ await onDesiredModeChange(value);
+ } finally {
+ setModeResetPending((pending) => (
+ pending?.resetId === resetId ? null : pending
+ ));
+ }
+ };
+
+ const selectFreshDevice = (device: BleDevice) => {
+ if (
+ networkRecoveryRequired
+ || !Number.isInteger(discoveryGeneration)
+ || (discoveryGeneration ?? -1) < 0
+ || device.connectable === false
+ ) return;
+ const unresolvedPhysicalState = state?.physical_command?.requires_reconciliation === true;
+ if (unresolvedPhysicalState) {
+ setCandidateUnavailableMessage(
+ "Для этого состояния требуется отдельное явное восстановление. Выбор не изменил устройство и не отправил команду.",
+ );
+ return;
+ }
+ if (
+ reconfigurationActive
+ && (
+ !reconfigurationFreshScanReady
+ || !reconfigurationAllowsFreshDevice(
+ reconfiguration,
+ device.device_id,
+ connectionMode,
+ )
+ )
+ ) {
+ setCandidateUnavailableMessage(
+ "Результат не принадлежит текущему явному поиску. Настройки не отправлены; повторите поиск Bluetooth.",
+ );
+ return;
+ }
+ setSelectedDeviceId(device.device_id);
+ setSelectedDeviceSnapshot(device);
+ setSuccessfulLocalConnect(null);
+ setConnectionAttemptPresentation(null);
+ setCandidateUnavailableMessage(null);
+ setPassword("");
+ requestExplicitProvisioning(device.device_id, {
+ requiresFreshScanBeforeSubmit: false,
+ });
+ };
+
+ // Only an explicit public search owns the Step-1 Bluetooth loader.
+ const searchActive = searchDisplayActive;
+ const connectionEstablished = selectedModeConnected;
+ const connectionAttemptOwnsDraft = connectionAttemptSettling
+ || (
+ connectionAttemptFailed
+ && !networkRecoveryRequired
+ && !physicalRecoveryRequired
+ );
+ const deviceSelected = Boolean(
+ connectionAttemptOwnsDraft
+ || (
+ selectedDeviceId
+ && selectedDeviceSnapshot
+ && explicitProvisioningDraftContextRetained
+ ),
+ );
+ const selectedResult = selectedDeviceSnapshot
+ || (selectedDeviceId ? provisioningCandidateById(devices, selectedDeviceId) : null);
+ const selectedResultLabel = selectedResult?.name?.trim()
+ || connectionAttemptPresentation?.label
+ || connectedDeviceIdentity
+ || reconfigurationDevicePresentation?.label
+ || changeNetworkRequiredDeviceId
+ || selectedDeviceId;
+ const selectedResultDeviceId = selectedResult?.device_id
+ || connectionAttemptPresentation?.deviceId
+ || selectedDeviceId;
+ const candidateSelectionAllowed = (device: BleDevice) => Boolean(
+ !physicalRecoveryRequired
+ && !networkRecoveryRequired
+ && device.connectable !== false
+ );
+ const actionableDevices = devices.filter(candidateSelectionAllowed);
+ const selectCandidate = (device: BleDevice) => {
+ if (!candidateSelectionAllowed(device)) {
+ setCandidateUnavailableMessage(
+ "Выбор заблокирован текущим состоянием безопасности. Выполните отдельное явное восстановление; команды устройству не отправлялись.",
+ );
+ return;
+ }
+ selectFreshDevice(device);
+ };
+ const chooseAnother = () => {
+ if (networkRecoveryRequired) {
+ setCandidateUnavailableMessage(
+ "Выбор другого K1 заблокирован: сеть уже применена, а управляющее подключение ещё не подтверждено. Выполните проверку состояния без изменений.",
+ );
+ return;
+ }
+ if (selectedModeConnected) {
+ if (canPrepareSelectDevice) {
+ void prepareReconfiguration("select-device");
+ }
+ return;
+ }
+ const returnToCurrentResults = searchRequested && !searchDisplayActive;
+ clearTransientConnectionDraft();
+ if (!returnToCurrentResults) {
+ resetSearchPresentation();
+ }
+ };
+ const changeNetwork = () => {
+ if (!canPrepareChangeNetwork) return;
+ void prepareReconfiguration("change-network");
+ };
+ const recoverBySelectingAnotherDevice = () => {
+ if (!canPrepareRecoverySelectDevice) return;
+ void prepareReconfiguration("select-device", {
+ appliedRecoveryEscape: true,
+ });
+ };
+ const attemptButtonLabel = connectionAttemptPresentation?.localPhase === "submitting"
+ ? "Применяем настройки…"
+ : "Подтверждаем подключение…";
+ const applyAction = connectionAttemptSettling ? (
+ }
+ disabled
+ >
+ {attemptButtonLabel}
+
+ ) : connectionAttemptOwnsDraft ? (
+ }
+ disabled
+ >
+ Подключение не выполнено
+
+ ) : (
+ }
+ disabled={!credentialsReady || !canConnect}
+ onClick={() => void submitConnect()}
+ >
+ {modeCopy.buttonLabel}
+
+ );
+ const provisioningFieldsDisabled = connectionAttemptOwnsDraft;
+ const presentedSsid = connectionAttemptOwnsDraft
+ ? connectionAttemptPresentation?.ssid ?? ""
+ : ssid;
+ const provisioningDraftContent = (
+ <>
+ {connectionMode === "quick-connect" ? (
+ <>
+
+ {modeCopy.stepTitle}
+
+ {connectionAttemptSettling
+ ? "Ожидаем подтверждение подключения"
+ : connectionAttemptFailed
+ ? "Подключение не подтверждено"
+ : "Готово к продолжению"}
+
-
-
- Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Метка кандидата основана только на имени; точные модель, platform type и прошивка будут проверены по живому DeviceInfo перед START.
+ {applyAction}
+ >
+ ) : (
+
+
setSsid(event.target.value)}
+ disabled={provisioningFieldsDisabled}
+ autoComplete="off"
+ spellCheck={false}
+ placeholder={modeCopy.ssidPlaceholder}
+ />
+
+ setPassword(event.target.value)}
+ disabled={provisioningFieldsDisabled}
+ autoComplete="off"
+ placeholder={connectionAttemptOwnsDraft
+ ? "Пароль передан"
+ : "Введите пароль"}
+ />
+ setPasswordVisible((visible) => !visible)}
+ >
+
+
+
+ {applyAction}
+
+ )}
+
+ {connectionAttemptSettling
+ ? "Настройки переданы один раз. Поля заблокированы до точного результата; команда не повторяется."
+ : connectionAttemptFailed
+ ? connectionAttemptPresentation?.failureMessage
+ ?? "Подключение не подтверждено. Новая команда автоматически не отправляется."
+ : modeCopy.safetyNote}
+
+ {connectionAttemptFailed
+ && connectionAttemptPresentation?.freshStartAllowed
+ && scanAllowedByPolicy ? (
}
- disabled={!powerConfirmed || isBusy}
- onClick={() => { setSelectedDeviceId(""); resetProvisioningIntent(); void scan(); }}
+ disabled={!canScan}
+ onClick={() => void repeatDeviceScan()}
>
- {pendingAction === "scan" ? "Сканируем Bluetooth — 6 секунд…" : "Показать все BLE-устройства"}
+ Начать новый поиск Bluetooth
-
- {devices.length ? devices.map((device) => (
-
{ setSelectedDeviceId(device.device_id); resetProvisioningIntent(); }}
- />
- )) : Устройства пока не найдены. Проверьте питание и повторите поиск.
}
-
-
-
+ {candidateUnavailableMessage}
+
+ ) : null}
+ >
+ );
+ const connectionRecoveryModeLabel = connectionRecoveryTarget
+ ? connectionModeOptions.find(
+ (option) => option.value === connectionRecoveryTarget.connectionMode,
+ )?.label ?? connectionRecoveryTarget.connectionMode
+ : null;
+ const connectionRecoveryActions = connectionRecoveryRequired ? (
+ <>
+ {connectionRecoveryTarget && !connectionRecoveryVerificationFailed ? (
+ }
+ disabled={
+ isBusy
+ }
+ onClick={() => void verifyConnectionRecoveryTarget()}
>
- {connectionMode === "quick-connect" ? (
-
- Канонический путь
- BLE включает AP → macOS подключает Mac к AP выбранного K1
-
- ) : (
-
- { setSsid(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" spellCheck={false} placeholder={modeCopy.ssidPlaceholder} />
- { setPassword(event.target.value); provisioningIntentRef.current = null; }} autoComplete="off" placeholder="Введите пароль" />
-
- )}
- Устройство {deviceSummary?.name || deviceSummary?.device_id || "Сначала выберите устройство"}
- {networkWriteReconciliationPending ? (
-
- Предыдущая BLE-запись завершилась до подтверждения актуального состояния K1. Новая запись заблокирована: выберите Bridge и выполните read-only подхват существующего подключения.
-
- ) : null}
- } disabled={!canConnect} onClick={() => void submitConnect()}>
- {pendingAction === "connect"
- ? connectionMode === "quick-connect" ? "Включаем точку и подключаем…" : "Подключаем…"
- : modeCopy.buttonLabel}
-
- {connectionMode === "bridge" ? (
- <>
- }
- disabled={!canAdoptExistingBridge}
- onClick={() => void submitExistingBridgeAdoption()}
- >
- {pendingAction === "verify"
- ? "Проверяем существующее подключение…"
- : "Подхватить существующее подключение"}
-
-
- Если K1 уже подключён к этой сети другим способом, Mission Core проверит и примет существующее подключение без изменения настроек Wi‑Fi.
-
- >
- ) : null}
-
- {modeCopy.safetyNote}
- {connectionMode === "quick-connect" ? " Это лабораторный путь для уже подготовленного хоста: credential provider должен существовать в системном хранилище заранее. На чистом Mac операция завершится до BLE-записи; браузер, API, журналы и evidence секрета не получают." : " Пароль передаётся только локальному сервису, не сохраняется в браузере и удаляется из формы после успеха."}
+ Переподключиться
+
+ ) : null}
+ }
+ disabled={isBusy || modeResetInFlight}
+ onClick={() => void changeDesiredConnectionMode(connectionMode)}
+ >
+ Подключить новый K1
+
+ >
+ ) : null;
+
+ return (
+
+
+
+
+
void changeDesiredConnectionMode(value)}
+ disabled={modeResetInFlight}
+ variant="split"
+ />
+ {modeResetInFlight ? (
+
+ Завершаем прежнюю локальную границу. Новый поиск не начнётся автоматически.
+ ) : null}
+ {activeScenarioReset ? (
+
+ {state?.connection_scenario_reset
+ ?.previous_device_may_continue_scanning
+ ? "Локальный сеанс закрыт; прежний K1 мог продолжить сканирование."
+ : "Прежний локальный сеанс закрыт. Поиск нового K1 запускается отдельно."}
+
+ ) : null}
+
+
+
+
0
+ ? `Результатов: ${actionableDevices.length}`
+ : "Совпадений нет"
+ : "Ожидает"
+ }
+ tone={
+ searchActive
+ ? "accent"
+ : physicalRecoveryRequired
+ && verificationActionPending
+ ? "accent"
+ : connectionEstablished
+ ? "success"
+ : networkRecoveryRequired
+ || physicalRecoveryRequired
+ || connectionRecoveryRequired
+ || explicitProvisioningDraftStale
+ ? "warning"
+ : "neutral"
+ }
+ >
+ {searchActive ? (
+
+
+
+ Поиск Bluetooth · {scanSecondsRemaining ?? 6} с
+
+
+ ) : networkRecoveryRequired && !connectionAttemptSettling ? (
+
+
+ {appliedControlSettlementPending
+ ? "Команда настройки завершена"
+ : "Новый выбор временно заблокирован"}
+
+ {networkRecoveryModeLabel}
+
+ {appliedControlSettlementPending
+ ? "Сервис подтверждает управляющее подключение без повторения BLE-команды"
+ : appliedNetworkRecoveryTarget?.deviceId
+ ?? "Сервис не определил точный прежний K1 для безопасной проверки"}
+
+
+ ) : physicalRecoveryRequired && verificationActionPending ? (
+
+
+
Проверяем прежний K1 без повторения START, STOP или настроек сети…
+
+ ) : physicalRecoveryRequired ? (
+
+
+ Прежнее подключение не подтверждено
+ {physicalRecoveryModeLabel}
+
+ {physicalRecoveryBinding?.deviceId
+ ?? "Прежний K1"}
+
+
+
+ Переподключитесь к прежнему K1 или начните чистое подключение
+ нового устройства.
+
+ {physicalReadOnlyVerificationAvailable
+ && !physicalRecoveryVerificationFailed ? (
+
}
+ disabled={isBusy || !physicalRecoveryTarget?.serverBound}
+ onClick={() => void verifyPhysicalRecovery()}
+ >
+ Переподключиться
+
+ ) : null}
+
}
+ disabled={modeResetInFlight}
+ onClick={() => void changeDesiredConnectionMode(connectionMode)}
+ >
+ Подключить новый K1
+
+ {candidateUnavailableMessage ? (
+
+ {candidateUnavailableMessage}
+
+ ) : null}
+
+ ) : connectionRecoveryRequired ? (
+
+ {correlatedConnectionAttempt && error ? (
+
void refreshCorrelatedConnectionFailure()}
+ onClear={clearError}
+ />
+ ) : (
+ <>
+
+
+ {unknownNetworkOutcomeAttempt
+ ? "Результат применения сети не подтверждён"
+ : "Сохранённое подключение требует проверки"}
+
+
+ {connectionRecoveryModeLabel
+ ?? connectionRecoveryAttempt?.connection_mode
+ ?? requestedModeLabel}
+
+ {connectionRecoveryTarget?.deviceId ? (
+ {connectionRecoveryTarget.deviceId}
+ ) : null}
+
+
+ {unknownNetworkOutcomeAttempt
+ ? "Итог прежней попытки неизвестен. Автоматического повтора не было: сначала проверьте сохранённое подключение без изменений либо начните отдельный новый поиск."
+ : "Проверка читает состояние прежнего K1 и не отправляет настройки сети, START или STOP. Новый поиск — отдельное явное действие для выбора другого устройства."}
+
+ {connectionRecoveryActions}
+ >
+ )}
+ {candidateUnavailableMessage ? (
+
+ {candidateUnavailableMessage}
+
+ ) : null}
+
+ ) : connectionEstablished ? (
+
+
+ Подключение установлено
+ {selectedResultLabel || requestedModeLabel}
+ {selectedResultDeviceId ? (
+ {selectedResultDeviceId}
+ ) : null}
+
+ {(
+ selectedModeConnected
+ ? selectDeviceActionApplicable
+ : Boolean(selectedResult)
+ ) ? (
+
}
+ disabled={selectedModeConnected ? !canPrepareSelectDevice : isBusy}
+ onClick={chooseAnother}
+ >
+ Выбрать другое
+
+ ) : null}
+
+ ) : deviceSelected ? (
+
+
+ Устройство выбрано
+ {selectedResultLabel}
+ {selectedResultDeviceId ? (
+ {selectedResultDeviceId}
+ ) : null}
+
+
}
+ disabled={isBusy}
+ onClick={chooseAnother}
+ >
+ Выбрать другое
+
+
+ ) : (
+ <>
+ {canScan ? (
+ }
+ onClick={() => void repeatDeviceScan()}
+ >
+ {searchRequested ? "Повторить поиск Bluetooth" : "Найти по Bluetooth"}
+
+ ) : null}
+
+ {candidateUnavailableMessage ? (
+
+ {candidateUnavailableMessage}
+
+ ) : null}
+
+ {searchRequested && !searchActive ? (
+ actionableDevices.length > 0 ? (
+
+ {actionableDevices.map((device) => (
+ selectCandidate(device)}
+ />
+ ))}
+
+ ) : (
+
+ Подходящих K1 не найдено. Повторите поиск.
+
+ )
+ ) : null}
+ >
+ )}
+
+ {showNetworkStep ? (
+
+ {connectionAttemptOwnsDraft ? (
+ provisioningDraftContent
+ ) : verificationActionPending ? (
+
+
+
Проверяем подключение без изменения сети…
+
+ ) : networkRecoveryRequired && appliedControlSettlementPending ? (
+
+
+
+
Подтверждаем управляющее подключение
+
+ BLE-команда не повторяется. Дождитесь результата одной
+ сервисной проверки состояния без изменений.
+
+
+
+ ) : networkRecoveryRequired ? (
+
+
+ Попытка настройки завершена
+
+ {networkRecoveryModeLabel}: управляющее подключение не подтверждено
+
+
+ {appliedNetworkRecoveryTarget?.deviceId
+ ?? "Сервис не определил точный прежний K1 для безопасной проверки"}
+
+
+
+ Старая попытка и её BLE-запись не повторяются. Обычные поиск,
+ выбор и «Применить» заблокированы; отдельные действия ниже либо
+ проверяют текущую сеть, либо начинают новый явный сценарий.
+
+
}
+ disabled={isBusy || !appliedNetworkRecoveryTarget}
+ onClick={() => void verifyAppliedNetwork()}
+ >
+ Проверить подключение без изменения сети
+
+ {canPrepareRecoverySelectDevice ? (
+
}
+ disabled={isBusy}
+ onClick={recoverBySelectingAnotherDevice}
+ >
+ Начать выбор другого K1
+
+ ) : null}
+ {canScanAppliedRecovery ? (
+
}
+ disabled={isBusy}
+ onClick={() => void repeatDeviceScan({
+ appliedRecoveryEscape: true,
+ })}
+ >
+ Начать новый поиск Bluetooth
+
+ ) : null}
+ {candidateUnavailableMessage ? (
+
+ {candidateUnavailableMessage}
+
+ ) : null}
+
+ ) : networkActionPending ? (
+
+ ) : selectedModeConnected && !changeNetworkDialogue ? (
+
+
+ Подключение установлено
+ {requestedModeLabel}
+ {connectedEndpoint ? {connectedEndpoint} : null}
+
+ {changeNetworkActionApplicable ? (
+
}
+ disabled={!canPrepareChangeNetwork}
+ onClick={changeNetwork}
+ >
+ Изменить сеть
+
+ ) : null}
+
+ ) : explicitProvisioningDraftStale ? (
+
+
Результат устарел
+
+ Результат Bluetooth-поиска изменился до отправки. Данные сети
+ сохранены только локально; команда устройству не отправлялась.
+
+
}
+ disabled={!canScan}
+ onClick={() => void repeatDeviceScan({ preserveDraft: true })}
+ >
+ Повторить поиск Bluetooth
+
+
+ ) : (
+ provisioningDraftContent
+ )}
+
+ ) : null}
);
diff --git a/plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx b/plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx
index cbb9d93..1816580 100644
--- a/plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx
+++ b/plugins/xgrids-k1/frontend/src/components/K1SpatialControls.tsx
@@ -1,13 +1,22 @@
-import { Button } from "@nodedc/ui-react";
+import { ActivityIndicator, Button } from "@nodedc/ui-react";
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
import type {
AcquisitionState,
- OperatorPresenceConfirmation,
XgridsAcquisition,
+ XgridsK1State,
} from "../api";
import {
+ activeStreamForceFinishAuthority,
+ activeStreamRecoveryPresentation,
+} from "../activeStreamRecovery";
+import {
+ canIssueCanonicalStop,
+ connectionPolicyAllows,
+ hasAuthoritativeData,
+ hasControlAuthority,
isSoftwareCommandedAcquisition,
+ requiresCanonicalStopAfterTerminalLocalFailure,
shouldRenderSpatialControls,
} from "../lifecycle";
import {
@@ -15,7 +24,15 @@ import {
formatNumber,
spatialActionFailure,
} from "../presentation";
-import { useXgridsK1Controller } from "../runtimeContext";
+import {
+ activeStopTarget,
+ operatorActionPhysicalAcceptance,
+} from "../physicalCommandConfirmation";
+import {
+ useXgridsK1Controller,
+ type XgridsK1Controller,
+} from "../runtimeContext";
+import { ActiveStreamRecoverySurface } from "./ActiveStreamRecoverySurface";
interface PhasePresentation {
label: string;
@@ -23,15 +40,31 @@ interface PhasePresentation {
busy: boolean;
}
-const PHYSICAL_ACCEPTANCE = {
- operator_present: true,
- owner_controlled_device: true,
- lixelgo_closed: true,
- battery_storage_confirmed: true,
- expected_physical_state_confirmed: true,
-} satisfies OperatorPresenceConfirmation;
+export interface K1SpatialAuthorityState {
+ controlAuthoritative: boolean;
+ dataAuthoritative: boolean;
+ softwareCommanded: boolean;
+ authorityFailure: string | null;
+}
-function phasePresentation(
+export function k1SpatialAuthorityState(
+ state: XgridsK1State | null | undefined,
+): K1SpatialAuthorityState {
+ const controlAuthoritative = hasControlAuthority(state);
+ const dataAuthoritative = hasAuthoritativeData(state);
+ return {
+ controlAuthoritative,
+ dataAuthoritative,
+ softwareCommanded: controlAuthoritative && isSoftwareCommandedAcquisition(state),
+ authorityFailure: state?.acquisition?.state === "acquiring" && !dataAuthoritative
+ ? controlAuthoritative
+ ? "Поток данных K1 не подтверждён supervisor-ом. Телеметрия скрыта до восстановления data authority."
+ : "Управляющая сессия K1 потеряна. Локальное завершение доступно, но команды устройству запрещены."
+ : null,
+ };
+}
+
+export function k1SpatialPhasePresentation(
acquisition: XgridsAcquisition,
softwareCommanded: boolean,
): PhasePresentation {
@@ -49,16 +82,20 @@ function phasePresentation(
busy: false,
},
awaiting_external_start: {
- label: "Ожидание запуска на устройстве",
- detail: "Запустите сканирование физической кнопкой K1.",
+ label: softwareCommanded
+ ? "K1 калибруется и готовит облако точек"
+ : "Ожидание запуска на устройстве",
+ detail: softwareCommanded
+ ? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
+ : "Запустите сканирование физической кнопкой K1.",
busy: true,
},
starting: {
label: softwareCommanded
- ? "Калибровка оборудования"
+ ? "K1 калибруется и готовит облако точек"
: "Подготовка локального приёмника",
detail: softwareCommanded
- ? "Статическая инициализация после запуска — не перемещайте устройство."
+ ? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
: "Mission Core запускает запись до физического старта K1.",
busy: true,
},
@@ -106,43 +143,146 @@ function formatDuration(seconds: number): string {
: `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`;
}
-export function K1SpatialControls(_props: DevicePluginConnectionProps) {
- const controller = useXgridsK1Controller();
- const { state, pendingAction, stop } = controller;
+export function runSpatialActiveStreamForceFinish(
+ controller: Pick<
+ XgridsK1Controller,
+ "state" | "forceFinishActiveStreamLocally"
+ >,
+): Promise {
+ if (!activeStreamForceFinishAuthority(controller.state)) {
+ return Promise.resolve(false);
+ }
+ return controller.forceFinishActiveStreamLocally();
+}
+
+export function K1SpatialControlsView({
+ controller,
+}: {
+ controller: XgridsK1Controller;
+}) {
+ const {
+ state,
+ pendingAction,
+ physicalStopIntentSpent,
+ physicalStopInFlight,
+ stop,
+ stopLocalReceiver,
+ forceFinishActiveStreamLocally,
+ } = controller;
const acquisition = state?.acquisition;
+ const activeRecoveryPresentation = activeStreamRecoveryPresentation(state);
+ const localForceFinishPending = pendingAction === "force-finish";
+
+ if (activeRecoveryPresentation || localForceFinishPending) {
+ return (
+ {
+ void runSpatialActiveStreamForceFinish({
+ state,
+ forceFinishActiveStreamLocally,
+ });
+ }}
+ />
+ );
+ }
+
+ const physicalStopTarget = activeStopTarget(state);
+ const localReceiverStopAllowed = connectionPolicyAllows(state, "stop-local-receiver");
+ const physicalStopExecutable = Boolean(
+ physicalStopTarget
+ && canIssueCanonicalStop(state, physicalStopIntentSpent),
+ );
+ const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
+ const stopping = ["awaiting_external_stop", "stopping", "finalizing"].includes(
+ acquisition?.state ?? "",
+ );
const cleanupPending = acquisition?.cleanup_pending === true;
+
if (!acquisition || !shouldRenderSpatialControls(state)) {
return null;
}
+ const {
+ controlAuthoritative,
+ dataAuthoritative,
+ authorityFailure,
+ } = k1SpatialAuthorityState(state);
const softwareCommanded = isSoftwareCommandedAcquisition(state);
- const phase = phasePresentation(acquisition, softwareCommanded);
- const telemetry = deviceTelemetry(state.metrics);
- const stopping = ["awaiting_external_stop", "stopping", "finalizing"].includes(
- acquisition.state,
- );
- const stopDisabled = pendingAction !== null || stopping;
+ const dataPlaneState = state?.connection_supervisor?.observed.data_plane.state;
+ const terminalPhysicalStopRequired = requiresCanonicalStopAfterTerminalLocalFailure(state);
+ const phase = physicalStopInFlight
+ ? {
+ label: "Команда остановки устройства отправлена",
+ detail: "Ждём подтверждённое состояние K1; повторная команда не отправляется.",
+ busy: true,
+ }
+ : terminalPhysicalStopRequired && physicalStopExecutable
+ ? {
+ label: "Локальный приём остановился · K1 продолжает работу",
+ detail: "Остановите устройство явной командой; новый START заблокирован.",
+ busy: false,
+ }
+ : terminalPhysicalStopRequired && localReceiverStopAllowed
+ ? {
+ label: "Состояние K1 требует безопасного восстановления",
+ detail: "Команда устройству не отправляется. Доступно разрешённое сервером локальное завершение или read-only восстановление.",
+ busy: false,
+ }
+ : terminalPhysicalStopRequired
+ ? {
+ label: "Управляющие действия заблокированы",
+ detail: "Дождитесь подтверждённого состояния или выполните read-only восстановление.",
+ busy: false,
+ }
+ : acquisition.state === "acquiring"
+ && !dataAuthoritative
+ ? {
+ label: !controlAuthoritative
+ ? "Управляющая сессия K1 потеряна"
+ : dataPlaneState === "lost"
+ ? "Связь с потоком K1 потеряна"
+ : dataPlaneState === "stalled"
+ ? "Поток K1 нестабилен"
+ : "Ожидаем подтверждённый поток K1",
+ detail: !controlAuthoritative
+ ? "Состояние acquisition сохранено как последнее известное; команды устройству не отправляются."
+ : "Управляющая сессия подтверждена, но живые данные пока не получили авторитетный статус.",
+ busy: false,
+ }
+ : k1SpatialPhasePresentation(acquisition, softwareCommanded);
+ const telemetry = deviceTelemetry(dataAuthoritative ? state.metrics : undefined);
+ const stopDisabled = pendingAction !== null
+ || stopping;
const controlFailure =
state.application_control_session?.state === "failed"
? state.application_control_session.failure?.message ||
"Канонический диалог остановлен; автоматический повтор запрещён."
: null;
- const actionFailure = spatialActionFailure(
+ const runtimeActionFailure = spatialActionFailure(
controller.error ??
controlFailure ??
(cleanupPending
- ? "Локальный поток или архив ещё не завершён. Повторите остановку."
+ ? physicalStopInFlight
+ ? "Локальный поток или архив ещё не завершён. Команда устройству уже отправлена; дождитесь подтверждённого состояния."
+ : localReceiverStopAllowed
+ ? "Локальный поток или архив ещё не завершён. Завершите только разрешённый сервером локальный приём."
+ : "Локальный поток или архив ещё не завершён. Дождитесь подтверждённого состояния или выполните read-only восстановление."
: null),
);
-
+ const actionFailure = runtimeActionFailure ?? spatialActionFailure(authorityFailure);
return (
- {phase.busy ?
: null}
+ {phase.busy ?
: null}
{phase.label}
{phase.detail}
@@ -165,20 +305,47 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
{actionFailure.detail}
) : null}
- void stop(softwareCommanded ? PHYSICAL_ACCEPTANCE : undefined)}
- >
- {pendingAction === "stop"
- ? softwareCommanded ? "Останавливаем устройство…" : "Останавливаем приём…"
- : stopping
- ? acquisition.state === "finalizing" ? "Сохраняем запись…" : "Остановка выполняется…"
- : actionFailure
- ? "Повторить остановку"
- : softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
-
+ {physicalStopPresented ? (
+ {
+ if (physicalStopExecutable) {
+ void stop(operatorActionPhysicalAcceptance());
+ }
+ }}
+ >
+
+ {physicalStopInFlight
+ ? "Останавливаем устройство…"
+ : pendingAction === "stop"
+ ? terminalPhysicalStopRequired ? "Останавливаем K1…" : softwareCommanded ? "Останавливаем устройство…" : "Останавливаем приём…"
+ : stopping
+ ? acquisition.state === "finalizing" ? "Сохраняем запись…" : "Остановка выполняется…"
+ : terminalPhysicalStopRequired ? "Остановить K1" : "Остановить устройство и запись"}
+
+
+ ) : null}
+ {!physicalStopPresented && localReceiverStopAllowed && !stopping ? (
+ void stopLocalReceiver()}
+ >
+
+ {pendingAction === "stop"
+ ? "Завершаем локальный приём…"
+ : "Завершить локальный приём"}
+
+
+ ) : null}
);
}
+
+export function K1SpatialControls(_props: DevicePluginConnectionProps) {
+ const controller = useXgridsK1Controller();
+ return ;
+}
diff --git a/plugins/xgrids-k1/frontend/src/configuration.ts b/plugins/xgrids-k1/frontend/src/configuration.ts
index 13586cb..6cbb679 100644
--- a/plugins/xgrids-k1/frontend/src/configuration.ts
+++ b/plugins/xgrids-k1/frontend/src/configuration.ts
@@ -12,17 +12,17 @@ export const connectionModeOptions: Array> = [
{
value: "bridge",
label: "Общая сеть · Bridge",
- description: "Mission Core передаёт K1 реквизиты существующей общей сети.",
+ description: "Передача реквизитов существующей общей сети.",
},
{
value: "quick-connect",
- label: "Точка доступа K1 · Quick Connect",
- description: "Лабораторный режим: Mission Core включает AP K1 и подключает только заранее подготовленный хост. Для обычной работы используйте Bridge.",
+ label: "Локальная сеть · Quick Connect",
+ description: "Связь через отдельную локальную сеть. Для обычной работы используйте Bridge.",
},
{
value: "direct-connect",
label: "Хотспот контроллера · Direct Connect",
- description: "Mission Core передаёт K1 реквизиты хотспота управляющего устройства.",
+ description: "Передача реквизитов хотспота контроллера.",
},
];
diff --git a/plugins/xgrids-k1/frontend/src/controlSessionCas.ts b/plugins/xgrids-k1/frontend/src/controlSessionCas.ts
new file mode 100644
index 0000000..a6a4cbd
--- /dev/null
+++ b/plugins/xgrids-k1/frontend/src/controlSessionCas.ts
@@ -0,0 +1,72 @@
+import { ApiError, type XgridsK1State } from "./api";
+
+export interface ExactApplicationControlCas {
+ expected_session_generation: number;
+ expected_state_revision: number;
+}
+
+export interface ExactAcquisitionControlCas {
+ expected_control_session_generation: number;
+ expected_control_state_revision: number;
+}
+
+interface ControlSessionVersion {
+ sessionGeneration: number;
+ stateRevision: number;
+}
+
+function exactControlSessionVersion(
+ state: XgridsK1State | null | undefined,
+ actionLabel: string,
+): ControlSessionVersion {
+ const session = state?.application_control_session;
+ const sessionGeneration = session?.session_generation;
+ const stateRevision = session?.state_revision;
+ if (
+ !Number.isSafeInteger(sessionGeneration)
+ || (sessionGeneration ?? -1) < 0
+ || !Number.isSafeInteger(stateRevision)
+ || (stateRevision ?? -1) < 0
+ ) {
+ throw new ApiError(
+ `Команда ${actionLabel} не отправлена: последнее принятое состояние не содержит целые session_generation и state_revision управляющей сессии. Обновите состояние K1 и повторите отдельным действием.`,
+ );
+ }
+ return {
+ sessionGeneration: sessionGeneration as number,
+ stateRevision: stateRevision as number,
+ };
+}
+
+export function exactApplicationControlCas(
+ latestAcceptedState: XgridsK1State | null | undefined,
+ actionLabel: string,
+): ExactApplicationControlCas {
+ const version = exactControlSessionVersion(latestAcceptedState, actionLabel);
+ return {
+ expected_session_generation: version.sessionGeneration,
+ expected_state_revision: version.stateRevision,
+ };
+}
+
+export function exactAcquisitionControlCas(
+ latestAcceptedState: XgridsK1State | null | undefined,
+ actionLabel: string,
+): ExactAcquisitionControlCas {
+ const version = exactControlSessionVersion(latestAcceptedState, actionLabel);
+ return {
+ expected_control_session_generation: version.sessionGeneration,
+ expected_control_state_revision: version.stateRevision,
+ };
+}
+
+export function acquisitionMutationUsesControlSession(
+ latestAcceptedState: XgridsK1State | null | undefined,
+): boolean {
+ const session = latestAcceptedState?.application_control_session;
+ return Boolean(
+ session
+ && session.mode === "interactive-canonical"
+ && !["idle", "closed", "completed"].includes(session.state),
+ );
+}
diff --git a/plugins/xgrids-k1/frontend/src/hostDiagnosticPresentation.ts b/plugins/xgrids-k1/frontend/src/hostDiagnosticPresentation.ts
new file mode 100644
index 0000000..913e41c
--- /dev/null
+++ b/plugins/xgrids-k1/frontend/src/hostDiagnosticPresentation.ts
@@ -0,0 +1,132 @@
+import {
+ isXgridsHostFailureDiagnostic,
+ type XgridsHostDiagnosticCode,
+ type XgridsHostDiagnosticAction,
+ type XgridsHostDiagnosticDomain,
+ type XgridsHostDiagnosticImpact,
+ type XgridsHostFailureDiagnostic,
+ type XgridsOperation,
+} from "./api";
+
+const CODE_LABELS: Record = {
+ "host.bluetooth.permission-denied":
+ "macOS не разрешила Mission Core использовать Bluetooth.",
+ "host.bluetooth.adapter-powered-off":
+ "Bluetooth на этом Mac выключен.",
+ "host.bluetooth.adapter-unavailable":
+ "Системный Bluetooth-адаптер сейчас недоступен.",
+ "host.bluetooth.runtime-unavailable":
+ "Локальный Bluetooth runtime не готов к новой операции.",
+ "host.bluetooth.operation-timeout":
+ "Bluetooth-операция не завершилась за ограниченное время.",
+ "host.wifi.permission-denied":
+ "macOS не разрешила Mission Core читать состояние Wi‑Fi.",
+ "host.wifi.adapter-powered-off":
+ "Wi‑Fi на этом Mac выключен.",
+ "host.wifi.interface-unavailable":
+ "Системный Wi‑Fi-интерфейс сейчас недоступен.",
+ "host.wifi.ssid-unavailable":
+ "macOS не сообщила имя текущей Wi‑Fi-сети.",
+ "host.wifi.operation-timeout":
+ "Операция с Wi‑Fi не завершилась за ограниченное время.",
+ "host.wifi.association-failed":
+ "Mac не подтвердил подключение к ожидаемой Wi‑Fi-сети.",
+ "host.keychain.interaction-required":
+ "Связка ключей требует явного подтверждения оператора.",
+ "host.keychain.permission-denied":
+ "macOS запретила чтение профиля подключения.",
+ "host.keychain.unavailable":
+ "Профиль подключения сейчас недоступен в связке ключей.",
+ "host.route.unavailable":
+ "Прямой локальный маршрут к адресу подключения не найден.",
+ "host.tcp.connection-refused":
+ "Управляющий TCP endpoint отклонил соединение.",
+ "host.tcp.connection-timeout":
+ "Управляющий TCP endpoint не ответил за ограниченное время.",
+ "host.tcp.endpoint-unavailable":
+ "Управляющий TCP endpoint недоступен из текущей сети.",
+ "host.mqtt.connection-timeout":
+ "Управляющий MQTT-канал не открылся за ограниченное время.",
+ "host.mqtt.connection-refused":
+ "Управляющий MQTT-канал отклонил соединение.",
+ "host.mqtt.transport-unavailable":
+ "Транспорт управляющего MQTT-канала недоступен.",
+ "host.filesystem.permission-denied":
+ "Mission Core не может записать обязательные данные операции в локальное хранилище.",
+ "host.filesystem.ledger-unavailable":
+ "Журнал безопасного результата операции недоступен или не подтверждён.",
+};
+
+const DOMAIN_LABELS: Record = {
+ corebluetooth: "Bluetooth macOS",
+ corewlan: "Wi‑Fi macOS",
+ keychain: "Связка ключей macOS",
+ route: "Локальный сетевой маршрут",
+ tcp: "Управляющий TCP endpoint",
+ mqtt: "Управляющий канал MQTT",
+ filesystem: "Локальное хранилище Mission Core",
+};
+
+const IMPACT_LABELS: Record = {
+ discovery: "Поиск Bluetooth сейчас недоступен.",
+ "host-network": "Сетевой путь между этим компьютером и локальным контуром недоступен.",
+ control: "Управляющая связь не установлена; команды не повторяются автоматически.",
+ "durable-safety": "Надёжная фиксация результата операции недоступна; новая команда заблокирована.",
+};
+
+const ACTION_LABELS: Record = {
+ "grant-bluetooth-permission":
+ "Разрешите Mission Core доступ к Bluetooth в системных настройках macOS, затем повторите действие вручную.",
+ "power-on-bluetooth":
+ "Включите Bluetooth на этом Mac и запустите новый поиск вручную.",
+ "restore-bluetooth-adapter":
+ "Восстановите доступность Bluetooth-адаптера macOS и перезапустите локальный сервис перед новой попыткой.",
+ "grant-wifi-permission":
+ "Разрешите Mission Core доступ к данным Wi‑Fi в системных настройках macOS, затем повторите действие вручную.",
+ "power-on-wifi":
+ "Включите Wi‑Fi на этом Mac и заново выберите требуемый способ подключения.",
+ "restore-wifi-interface":
+ "Восстановите системный Wi‑Fi-интерфейс macOS перед новой попыткой подключения.",
+ "unlock-or-authorize-keychain":
+ "Разблокируйте связку ключей macOS и подтвердите доступ Mission Core к профилю подключения.",
+ "review-keychain-access":
+ "Разрешите Mission Core чтение профиля подключения в связке ключей macOS.",
+ "join-expected-network":
+ "Установите связь этого Mac с ожидаемой локальной сетью и повторите действие.",
+ "inspect-host-route":
+ "Восстановите прямой локальный маршрут к адресу подключения.",
+ "verify-broker-endpoint":
+ "Восстановите доступность управляющего endpoint из текущей сети; команда автоматически не повторяется.",
+ "inspect-local-storage":
+ "Освободите место и восстановите доступ к локальному хранилищу Mission Core до следующей операции.",
+ "restart-local-service":
+ "Перезапустите канонический локальный сервис Mission Core и после загрузки обновите состояние.",
+ "explicit-retry":
+ "После устранения причины повторите действие отдельным нажатием; автоматического повтора нет.",
+};
+
+export interface HostFailureDiagnosticPresentation {
+ codeLabel: string;
+ domainLabel: string;
+ impactLabel: string;
+ operatorActionLabel: string;
+}
+
+export function hostFailureDiagnosticPresentation(
+ value: unknown,
+): HostFailureDiagnosticPresentation | null {
+ if (!isXgridsHostFailureDiagnostic(value)) return null;
+ return {
+ codeLabel: CODE_LABELS[value.code],
+ domainLabel: DOMAIN_LABELS[value.domain],
+ impactLabel: IMPACT_LABELS[value.impact],
+ operatorActionLabel: ACTION_LABELS[value.operator_action],
+ };
+}
+
+export function operationHostFailureDiagnostic(
+ operation: XgridsOperation | null | undefined,
+): XgridsHostFailureDiagnostic | null {
+ const diagnostic = operation?.error?.host_diagnostic;
+ return isXgridsHostFailureDiagnostic(diagnostic) ? diagnostic : null;
+}
diff --git a/plugins/xgrids-k1/frontend/src/lifecycle.ts b/plugins/xgrids-k1/frontend/src/lifecycle.ts
index 5688ded..4ea0b4c 100644
--- a/plugins/xgrids-k1/frontend/src/lifecycle.ts
+++ b/plugins/xgrids-k1/frontend/src/lifecycle.ts
@@ -5,8 +5,14 @@ import type {
BleDevice,
XgridsApplicationControlPhase,
XgridsAcquisition,
+ XgridsConnectionAttempt,
XgridsK1State,
XgridsOperation,
+ ReopenRetiredPhysicalReconciliationRequest,
+ XgridsConnectionMode,
+ XgridsConnectionReconfiguration,
+ XgridsConnectionPolicyAction,
+ XgridsConnectionPolicyDecision,
} from "./api";
const TERMINAL_ACQUISITION_STATES = new Set([
@@ -24,6 +30,9 @@ const FAILED_OPERATION_STATUSES = new Set([
]);
export type LiveStartPlan = "prepare" | "resume-prepared" | "already-running" | "blocked";
+export type LocalReceiverStopPlan =
+ | { kind: "acquisition"; acquisitionId: string }
+ | { kind: "compatibility" };
export type ControlSessionEntryPlan =
| "open"
| "continue"
@@ -50,6 +59,77 @@ export function isTerminalAcquisitionState(
return state ? TERMINAL_ACQUISITION_STATES.has(state) : false;
}
+/**
+ * Select the exact local runtime cleanup target without inheriting a retained
+ * terminal acquisition into a replay session. Terminal acquisition state is
+ * actionable only while its backend-owned local cleanup remains pending.
+ */
+export function localReceiverStopPlan(
+ state: XgridsK1State | null | undefined,
+): LocalReceiverStopPlan {
+ const acquisition = state?.acquisition;
+ const acquisitionId = acquisition?.acquisition_id?.trim();
+ const liveOrIdleRuntime = state?.source_mode === "live" || state?.source_mode === "idle";
+ if (
+ acquisition
+ && acquisitionId
+ && liveOrIdleRuntime
+ && (
+ !isTerminalAcquisitionState(acquisition.state)
+ || acquisition.cleanup_pending === true
+ )
+ ) {
+ return { kind: "acquisition", acquisitionId };
+ }
+ return { kind: "compatibility" };
+}
+
+export function isProvenLocalReceiverInactive(
+ state: XgridsK1State | null | undefined,
+): state is XgridsK1State {
+ const acquisition = state?.acquisition;
+ const acquisitionReleased = Boolean(
+ acquisition
+ && isTerminalAcquisitionState(acquisition.state)
+ && acquisition.cleanup_pending === false,
+ );
+ return Boolean(
+ state?.source_mode === "idle"
+ && (!acquisition || acquisitionReleased),
+ );
+}
+
+export function isReleasedTerminalAcquisitionFailure(
+ state: XgridsK1State | null | undefined,
+): boolean {
+ const controlState = state?.application_control_session?.state;
+ const acquisition = state?.acquisition;
+ return Boolean(
+ state?.source_mode === "idle"
+ && acquisition
+ && ["failed", "interrupted"].includes(acquisition.state)
+ && acquisition.cleanup_pending === false
+ && [
+ "idle",
+ "connection-ready",
+ "active-recovery-requested",
+ "scanning",
+ "completed",
+ "closed",
+ ].includes(controlState ?? ""),
+ );
+}
+
+export function shouldSurfaceRuntimeActionError(
+ action: string,
+ state: XgridsK1State | null | undefined,
+): boolean {
+ return !(
+ ["control", "live", "stop", "abort"].includes(action)
+ && isReleasedTerminalAcquisitionFailure(state)
+ );
+}
+
export function shouldRenderSpatialControls(
state: XgridsK1State | null | undefined,
): boolean {
@@ -57,7 +137,194 @@ export function shouldRenderSpatialControls(
if (!acquisition || state?.source_mode === "replay") return false;
return (
!isTerminalAcquisitionState(acquisition.state) ||
- acquisition.cleanup_pending === true
+ acquisition.cleanup_pending === true ||
+ requiresCanonicalStopAfterTerminalLocalFailure(state)
+ );
+}
+
+export function requiresCanonicalStopAfterTerminalLocalFailure(
+ state: XgridsK1State | null | undefined,
+): boolean {
+ const acquisition = state?.acquisition;
+ const control = state?.application_control_session;
+ return Boolean(
+ acquisition
+ && isTerminalAcquisitionState(acquisition.state)
+ && isSoftwareCommandedAcquisition(state)
+ && control?.state === "scanning"
+ && control.can_stop === true
+ );
+}
+
+export interface PhysicalStopIntentCheckpoint {
+ snapshotRuntimeId: string;
+ acquisitionId: string;
+ deviceId: string;
+ deviceSessionId: string;
+ controlSessionGeneration: number;
+ controlStateRevision: number;
+}
+
+function positiveInteger(value: unknown): value is number {
+ return Number.isSafeInteger(value) && (value as number) >= 1;
+}
+
+/**
+ * Capture the exact backend authority consumed by one physical STOP intent.
+ * Snapshot identity is retained when available, while the control-session CAS
+ * is mandatory: a presentation-only error dismissal must never manufacture a
+ * fresh command intent against the same control checkpoint.
+ */
+export function physicalStopIntentCheckpoint(
+ state: XgridsK1State | null | undefined,
+): PhysicalStopIntentCheckpoint | null {
+ const control = state?.application_control_session;
+ const acquisition = state?.acquisition;
+ const acquisitionId = acquisition?.acquisition_id?.trim();
+ const deviceId = acquisition?.device_id?.trim();
+ const deviceSessionId = acquisition?.device_session_id?.trim();
+ const runtimeId = state?.snapshot_runtime_id?.trim();
+ if (
+ !control
+ || !acquisitionId
+ || !deviceId
+ || !deviceSessionId
+ || !runtimeId
+ || !positiveInteger(control.session_generation)
+ || !positiveInteger(control.state_revision)
+ || control.state !== "scanning"
+ || control.can_stop !== true
+ || !connectionPolicyAllows(state, "stop-acquisition")
+ ) return null;
+ return {
+ snapshotRuntimeId: runtimeId,
+ acquisitionId,
+ deviceId,
+ deviceSessionId,
+ controlSessionGeneration: control.session_generation,
+ controlStateRevision: control.state_revision,
+ };
+}
+
+/**
+ * A spent physical STOP may be released only by an already-accepted exact
+ * STOP-authoritative runtime replacement, a distinct acquisition target, or
+ * an exact control-session CAS transition. A same-runtime polling snapshot
+ * whose target and control CAS stayed fixed is not new physical-command
+ * authority.
+ */
+export function authoritativeStateSupersedesPhysicalStopIntent(
+ spent: PhysicalStopIntentCheckpoint | null | undefined,
+ state: XgridsK1State | null | undefined,
+): boolean {
+ if (!spent) return false;
+ const current = physicalStopIntentCheckpoint(state);
+ if (!current) return false;
+ const sameRuntime = current.snapshotRuntimeId === spent.snapshotRuntimeId;
+ if (!sameRuntime) {
+ // Snapshot ordering is resolved before this helper is called. A different
+ // accepted runtime with a complete exact STOP gate is fresh authority even
+ // when its process-local CAS counters restarted.
+ return true;
+ }
+ const sameTarget =
+ current.acquisitionId === spent.acquisitionId
+ && current.deviceId === spent.deviceId
+ && current.deviceSessionId === spent.deviceSessionId;
+ if (!sameTarget) {
+ // A newly accepted acquisition/device/session tuple is a distinct command
+ // target. The caller has already admitted this state monotonically.
+ return true;
+ }
+ const controlCasAdvanced =
+ current.controlSessionGeneration > spent.controlSessionGeneration
+ || (
+ current.controlSessionGeneration === spent.controlSessionGeneration
+ && current.controlStateRevision > spent.controlStateRevision
+ );
+ if (!controlCasAdvanced) return false;
+ // Within one runtime the exact control CAS must advance; observation-only
+ // polls remain locked regardless of their snapshot observation revision.
+ return true;
+}
+
+/**
+ * Admit one physical STOP button only from the exact current control proof.
+ * A failed action spends that browser intent independently of its dismissible
+ * presentation error: the operator may still finish the host receiver
+ * locally, but the UI must not create a fresh physical STOP mutation from the
+ * same accepted snapshot/control CAS.
+ */
+export function canIssueCanonicalStop(
+ state: XgridsK1State | null | undefined,
+ physicalStopIntentSpent: boolean | null | undefined,
+): boolean {
+ const control = state?.application_control_session;
+ return Boolean(
+ !physicalStopIntentSpent
+ && physicalStopIntentCheckpoint(state)
+ && control?.state === "scanning"
+ && control.can_stop === true
+ && connectionPolicyAllows(state, "stop-acquisition"),
+ );
+}
+
+/**
+ * A successful read-only Verify may truthfully end in SCANNING rather than
+ * connection-ready. That state grants exactly one explicit STOP, never START
+ * or provisioning authority.
+ */
+export function isRecoveredPhysicalScanning(
+ state: XgridsK1State | null | undefined,
+ expectedMode?: XgridsConnectionMode,
+): boolean {
+ const control = state?.application_control_session;
+ const physical = control?.physical_command ?? state?.physical_command;
+ const mode = state?.active_connection_mode ?? state?.connection_mode;
+ return Boolean(
+ control?.state === "scanning"
+ && control.can_stop === true
+ && physical?.requires_reconciliation !== true
+ && physical?.resolved_active_recovery_required === true
+ && physical.observed_session_state === "scanning"
+ && (!expectedMode || mode === expectedMode)
+ && (!expectedMode || currentAppliedConnectionTopology(state, expectedMode)?.status === "active")
+ );
+}
+
+/**
+ * A STOP that was accepted before the host path disappeared is completed by
+ * the backend from its durable command ledger. The connection screen must
+ * wait for that cleanup instead of turning the condition into another device
+ * action (Scan, START, STOP, or Wi-Fi provisioning).
+ */
+export function isPhysicalStopRecoverySettling(
+ state: XgridsK1State | null | undefined,
+): boolean {
+ const control = state?.application_control_session;
+ const physical = control?.physical_command ?? state?.physical_command;
+ const record = physical?.record;
+ const action = record?.action;
+ const resolution = record?.resolution;
+ const acquisition = state?.acquisition;
+ const acquisitionStillSettling = Boolean(
+ acquisition
+ && (
+ !isTerminalAcquisitionState(acquisition.state)
+ || acquisition.cleanup_pending === true
+ ),
+ );
+ const stopOperationStillSettling = Boolean(
+ state?.operations?.some((operation) =>
+ operation.action === "acquisition.stop"
+ && ["accepted", "running", "operator_action_required"].includes(operation.status)
+ ),
+ );
+ return Boolean(
+ action === "stop"
+ && resolution !== "stop-standby-observed"
+ && (physical?.requires_reconciliation === true || physical?.status === "unresolved")
+ && (acquisitionStillSettling || stopOperationStillSettling),
);
}
@@ -69,7 +336,11 @@ export function recoverableAcquisition(
}
export function isConfirmedLiveState(state: XgridsK1State | null | undefined): boolean {
- return state?.source_mode === "live" && state.acquisition?.state === "acquiring";
+ return Boolean(
+ state?.source_mode === "live"
+ && state.acquisition?.state === "acquiring"
+ && hasAuthoritativeData(state),
+ );
}
export function isSourceRuntimeBusy(state: XgridsK1State | null | undefined): boolean {
@@ -126,11 +397,17 @@ export function normalizeRuntimePhase(
): RuntimePhase {
const phase = state?.phase;
const acquisitionState = effectiveAcquisition(state)?.state;
- if (acquisitionState === "failed" || acquisitionState === "interrupted") return "error";
+ const releasedFailure = isReleasedTerminalAcquisitionFailure(state);
+ if (
+ !releasedFailure
+ && (acquisitionState === "failed" || acquisitionState === "interrupted")
+ ) return "error";
if (acquisitionState === "awaiting_external_start" || acquisitionState === "starting") {
return "starting";
}
- if (acquisitionState === "acquiring") return "streaming";
+ if (acquisitionState === "acquiring") {
+ return isConfirmedLiveState(state) ? "streaming" : "starting";
+ }
if (
acquisitionState === "awaiting_external_stop" ||
acquisitionState === "stopping" ||
@@ -138,16 +415,21 @@ export function normalizeRuntimePhase(
) {
return "stopping";
}
- if (acquisitionState === "prepared") return "connected";
- if (phase === "error") return "error";
+ if (acquisitionState === "prepared") {
+ const topology = currentAppliedConnectionTopology(state);
+ return topology && topology.status !== "configured-offline"
+ ? "connected"
+ : "configuring";
+ }
+ if (phase === "error") return releasedFailure ? "idle" : "error";
if (phase === "connected") {
- const controlPhase = state?.application_control_session?.state;
- return controlPhase && !["idle", "connecting", "closed", "completed", "failed"].includes(controlPhase)
+ const topology = currentAppliedConnectionTopology(state);
+ return topology && topology.status !== "configured-offline"
? "connected"
: "configuring";
}
if (phase === "starting_live") return "starting";
- if (phase === "live") return "streaming";
+ if (phase === "live") return isConfirmedLiveState(state) ? "streaming" : "starting";
if (phase === "replay") return "replaying";
if (phase === "stopping") return "stopping";
if (["scanning", "device_selected", "provisioning", "connecting"].includes(phase ?? "")) {
@@ -172,6 +454,12 @@ export function sourceStatusLabel(state: XgridsK1State | null | undefined): stri
if (state.acquisition?.state === "failed" || state.phase === "error") {
return "Ошибка локального приёмника";
}
+ if (state.connection_supervisor?.observed.data_plane.state === "lost") {
+ return "Поток данных потерян";
+ }
+ if (state.connection_supervisor?.observed.data_plane.state === "stalled") {
+ return "Поток данных нестабилен";
+ }
return "Ожидание реальных данных";
}
if (state?.acquisition?.state === "prepared") return "Приём подготовлен";
@@ -201,30 +489,1022 @@ export function operationNeedsReconciliation(
return operation.error?.safe_to_retry !== true;
}
+export function operationAllowsFreshProvisioningIntent(
+ operation: XgridsOperation | null | undefined,
+): boolean {
+ return Boolean(
+ operation
+ && FAILED_OPERATION_STATUSES.has(operation.status)
+ && operation.error?.safe_to_retry === true
+ && operation.error.side_effect_status === "none",
+ );
+}
+
+const FRESH_CANDIDATE_RETRY_REASON_CODES = new Set([
+ "BleakDeviceNotFoundError",
+ "network-provision-candidate-not-fresh",
+ "network-provision-candidate-changed",
+ "network-provision-discovery-generation-conflict",
+]);
+
+/**
+ * These failures are proven pre-write rejections caused only by an expired
+ * Bluetooth capture. One explicit network-submit click may refresh discovery
+ * once and then continue with the exact returned generation. A second failure
+ * is terminal for that click; this predicate never authorizes an unbounded
+ * retry or a repeat after an ambiguous/device-write outcome.
+ */
+export function provisioningFailureRequiresFreshCandidate(
+ reasonCode: string | null | undefined,
+): boolean {
+ return typeof reasonCode === "string"
+ && FRESH_CANDIDATE_RETRY_REASON_CODES.has(reasonCode);
+}
+
+export function readOnlyVerificationClearedReconciliation(
+ previousState: XgridsK1State | null | undefined,
+ nextState: XgridsK1State | null | undefined,
+ verifiedDeviceId: string | null | undefined,
+): boolean {
+ const previousFence = previousState?.network_write_reconciliation;
+ const previousOperationId = previousFence?.operation_id?.trim();
+ const nextLedger = nextState?.network_mutation_ledger;
+ if (
+ !verifiedDeviceId
+ || !previousOperationId
+ || transportRefEquivalenceKey(previousFence?.transport_ref)
+ !== transportRefEquivalenceKey(verifiedDeviceId)
+ // An omitted field or a replacement unresolved fence is never proof that
+ // this durable operation was reconciled.
+ || nextState?.network_write_reconciliation !== null
+ || !nextLedger
+ || nextLedger.mutation_allowed !== true
+ ) {
+ return false;
+ }
+ const sameOperationResolved = Boolean(
+ nextLedger.status === "resolved"
+ && nextLedger.operation_id === previousOperationId
+ && nextLedger.stage === "resolved"
+ && nextLedger.resolution !== null,
+ );
+ const operationExplicitlyAbsent = Boolean(
+ nextLedger.status === "empty"
+ && nextLedger.operation_id === null
+ && nextLedger.stage === null,
+ );
+ return sameOperationResolved || operationExplicitlyAbsent;
+}
+
export function provisioningCandidateById(
devices: readonly BleDevice[],
selectedDeviceId: string,
): BleDevice | null {
- if (!selectedDeviceId) return null;
- return devices.find((device) => device.device_id === selectedDeviceId) ?? null;
+ const selectedKey = transportRefEquivalenceKey(selectedDeviceId);
+ if (!selectedKey) return null;
+ return devices.find(
+ (device) => transportRefEquivalenceKey(device.device_id) === selectedKey,
+ ) ?? null;
+}
+
+export function currentDeviceTransportRef(
+ state: XgridsK1State | null | undefined,
+): string | null {
+ const recoveryRef = state?.current_device_recovery?.transport_ref?.trim();
+ if (recoveryRef) return recoveryRef;
+ const selectedRef = state?.selected_device_id?.trim();
+ return selectedRef || null;
+}
+
+export interface LocallyInitiatedBleSessionTarget {
+ transportRef: string;
+ connectionMode: XgridsConnectionMode;
+ deviceSessionId: string;
+ key: string;
+}
+
+export interface LocalBleSessionBindingConstraints {
+ /** Bind only the exact session accepted by the explicit connect response. */
+ requiredSessionKey?: string | null;
+}
+
+export function bleSessionTargetForTransport(
+ state: XgridsK1State | null | undefined,
+ transportRef: string | null | undefined,
+ selectedConnectionMode: XgridsConnectionMode,
+): LocallyInitiatedBleSessionTarget | null {
+ const expectedTransportRef = transportRef?.trim();
+ const backendTransportRef = state?.current_device_recovery?.transport_ref?.trim()
+ || state?.selected_device_id?.trim();
+ const connectionMode = state?.current_device_recovery?.connection_mode
+ ?? state?.connection_mode;
+ const deviceSessionId = state?.device_session?.device_session_id?.trim();
+ if (
+ !expectedTransportRef
+ || !backendTransportRef
+ || expectedTransportRef !== backendTransportRef
+ || !deviceSessionId
+ || connectionMode !== selectedConnectionMode
+ ) {
+ return null;
+ }
+ return {
+ transportRef: backendTransportRef,
+ connectionMode,
+ deviceSessionId,
+ key: `${deviceSessionId}:${connectionMode}:${backendTransportRef}`,
+ };
+}
+
+export function acceptedBleSessionKeyAfterConnect(
+ state: XgridsK1State | null | undefined,
+ transportRef: string | null | undefined,
+ selectedConnectionMode: XgridsConnectionMode,
+ sessionKeyBeforeConnect: string | null | undefined,
+): string | null {
+ const target = bleSessionTargetForTransport(
+ state,
+ transportRef,
+ selectedConnectionMode,
+ );
+ if (!target || target.key === sessionKeyBeforeConnect) return null;
+ return target.key;
+}
+
+/**
+ * Bind backend session state only to a connection initiated by this UI
+ * instance. A browser refresh has no local device id and therefore never
+ * turns an existing backend session into an implicit operator selection.
+ */
+export function locallyInitiatedBleSessionTarget(
+ state: XgridsK1State | null | undefined,
+ locallyInitiatedDeviceId: string | null | undefined,
+ selectedConnectionMode: XgridsConnectionMode,
+ constraints: LocalBleSessionBindingConstraints = {},
+): LocallyInitiatedBleSessionTarget | null {
+ const localRef = locallyInitiatedDeviceId?.trim();
+ if (!localRef) return null;
+ const target = bleSessionTargetForTransport(
+ state,
+ localRef,
+ selectedConnectionMode,
+ );
+ if (!target) return null;
+ if (
+ constraints.requiredSessionKey
+ && constraints.requiredSessionKey !== target.key
+ ) return null;
+ return target;
+}
+
+export interface RetainedBleRecoveryTarget {
+ transportRef: string;
+ connectionMode: NonNullable | null;
+ gattValidatedRecently: boolean;
+}
+
+export function retainedBleRecoveryTarget(
+ state: XgridsK1State | null | undefined,
+): RetainedBleRecoveryTarget | null {
+ const recovery = state?.current_device_recovery;
+ const transportRef = recovery?.transport_ref?.trim();
+ if (
+ !transportRef
+ || recovery?.handle_retained !== true
+ || recovery.advertised_now === true
+ ) {
+ return null;
+ }
+ return {
+ transportRef,
+ connectionMode: recovery.connection_mode ?? null,
+ gattValidatedRecently: recovery.gatt_validated_recently === true,
+ };
+}
+
+export function connectionPolicyDecision(
+ state: XgridsK1State | null | undefined,
+ action: XgridsConnectionPolicyAction,
+): XgridsConnectionPolicyDecision | null {
+ const policy = state?.connection_policy;
+ if (
+ policy?.schema_version !== "missioncore.xgrids-k1-connection-policy/v1"
+ || policy.facts.retained_context_is_presence !== false
+ ) {
+ return null;
+ }
+ return policy.actions[action] ?? null;
+}
+
+export function connectionPolicyAllows(
+ state: XgridsK1State | null | undefined,
+ action: XgridsConnectionPolicyAction,
+): boolean {
+ const decision = connectionPolicyDecision(state, action);
+ return Boolean(
+ decision?.allowed === true
+ && decision.automatic_retry === false
+ && state?.connection_policy?.allowed_actions.includes(action),
+ );
+}
+
+export interface ProvisioningNetworkStepDisclosure {
+ /** A concrete K1 has been admitted or selected by this operator flow. */
+ deviceExplicitlySelectedOrAdmitted: boolean;
+ /** A network/connection intent, rather than discovery alone, has started. */
+ networkIntentStarted: boolean;
+}
+
+/**
+ * Keep Bluetooth discovery entirely inside step 02.
+ *
+ * Raw controller settlement is deliberately not an input: an old scan,
+ * reconfiguration, retirement, or physical-cleanup promise may still be
+ * unwinding after its authority has gone stale, but that does not mean the
+ * operator has reached the network step for the current device choice.
+ */
+export function shouldRevealProvisioningNetworkStep({
+ deviceExplicitlySelectedOrAdmitted,
+ networkIntentStarted,
+}: ProvisioningNetworkStepDisclosure): boolean {
+ return deviceExplicitlySelectedOrAdmitted || networkIntentStarted;
+}
+
+export function activeConnectionReconfiguration(
+ state: XgridsK1State | null | undefined,
+): XgridsConnectionReconfiguration | null {
+ const reconfiguration = state?.connection_reconfiguration;
+ if (
+ !reconfiguration
+ || reconfiguration.schema_version
+ !== "missioncore.xgrids-k1-connection-reconfiguration/v1"
+ || reconfiguration.intent === null
+ || reconfiguration.status === "idle"
+ ) {
+ return null;
+ }
+ return reconfiguration;
+}
+
+/**
+ * A network-change intent is pinned to the exact device and mode which were
+ * current when the operator opened it. Selecting another advertisement must
+ * not turn that intent into a different-device network write.
+ */
+export function reconfigurationAllowsFreshDevice(
+ reconfiguration: XgridsConnectionReconfiguration | null,
+ deviceId: string,
+ connectionMode: XgridsConnectionMode,
+): boolean {
+ if (!reconfiguration || !deviceId.trim()) return false;
+ if (
+ connectionMode !== "bridge"
+ || reconfiguration.required_connection_mode !== "bridge"
+ ) return false;
+ if (reconfiguration.intent === "select-device") return true;
+ return Boolean(
+ reconfiguration.intent === "change-network"
+ && transportRefEquivalenceKey(reconfiguration.required_transport_ref)
+ === transportRefEquivalenceKey(deviceId)
+ && reconfiguration.required_connection_mode === connectionMode,
+ );
+}
+
+export function readOnlyObservationShowsNetworkUnavailable(
+ state: XgridsK1State | null | undefined,
+): boolean {
+ const verification = state?.connection_verification;
+ if (!verification) return false;
+ return [
+ "device-network-applied-host-failed",
+ "host-route-mismatch",
+ "endpoint-unreachable",
+ "unreachable",
+ ].includes(verification.status) || Boolean(
+ verification.lease_state === "configured-unverified"
+ && verification.network_reachability === "unreachable",
+ ) || Boolean(
+ verification.status === "device-network-applied"
+ && verification.lease_state === "configured-unverified"
+ && verification.reason_code === "endpoint-target-unconfigured",
+ );
+}
+
+const READ_ONLY_NETWORK_UNAVAILABLE_REASON_CODES = new Set([
+ "connection-verify-address-unavailable",
+ "connection-verify-connection-missing",
+ "connection-verify-route-mismatch",
+ "connection-verify-mqtt-unreachable",
+ "configured-endpoint-unavailable",
+ "endpoint-target-unconfigured",
+]);
+
+/**
+ * Only failures which prove that the selected device cannot use the current
+ * Bridge topology may lead from read-only adoption to explicit credentials.
+ * BLE, identity, lifecycle and CAS failures deliberately stay outside this
+ * allowlist.
+ */
+export function readOnlyFailureShowsNetworkUnavailable(
+ reasonCode: string | null | undefined,
+): boolean {
+ return typeof reasonCode === "string"
+ && READ_ONLY_NETWORK_UNAVAILABLE_REASON_CODES.has(reasonCode);
+}
+
+export function requiresReadOnlyPhysicalRecovery(
+ state: XgridsK1State | null | undefined,
+): boolean {
+ const control = state?.application_control_session;
+ const physical = control?.physical_command ?? state?.physical_command;
+ return Boolean(
+ // The coordinator folds unresolved commands and resolved SCAN_OVER into
+ // requires_reconciliation, while resolved active and explicitly reopened
+ // rows remain read-only recovery through the separate active flag.
+ physical?.requires_reconciliation === true
+ || physical?.resolved_active_recovery_required === true,
+ );
+}
+
+export interface ReadOnlyPhysicalRecoveryBinding {
+ deviceId: string;
+ connectionMode: XgridsConnectionMode;
+}
+
+export interface TrustedConnectionBinding {
+ deviceId: string;
+ connectionMode: XgridsConnectionMode;
+}
+
+/**
+ * CoreBluetooth UUID text is case-insensitive. Keep the original spelling for
+ * display and exact CAS payloads, but use this key whenever refs are compared.
+ */
+export function transportRefEquivalenceKey(
+ transportRef: string | null | undefined,
+): string {
+ return transportRef?.trim().toLowerCase() ?? "";
+}
+
+/**
+ * Resolve the exact K1 remembered by durable/backend-owned state. This is
+ * selection context only: it can authorize bounded observation, but never a
+ * device write without the separate connection policy and an explicit click.
+ */
+export function trustedConnectionBinding(
+ state: XgridsK1State | null | undefined,
+): TrustedConnectionBinding | null {
+ const physical = readOnlyPhysicalRecoveryBinding(state);
+ if (physical) return physical;
+
+ const retiredTransportRefs = retiredPhysicalTransportRefs(state);
+
+ const semanticRecord = state?.semantic_topology_store?.record;
+ const semanticDeviceId = semanticRecord?.transport_ref?.trim();
+ const semanticMode = semanticRecord?.connection_mode;
+ if (
+ semanticDeviceId
+ && !retiredTransportRefs.has(transportRefEquivalenceKey(semanticDeviceId))
+ && isConnectionMode(semanticMode)
+ ) {
+ return { deviceId: semanticDeviceId, connectionMode: semanticMode };
+ }
+
+ const recovery = state?.current_device_recovery;
+ const recoveryDeviceId = recovery?.transport_ref?.trim();
+ const recoveryMode = recovery?.connection_mode;
+ if (
+ recoveryDeviceId
+ && !retiredTransportRefs.has(transportRefEquivalenceKey(recoveryDeviceId))
+ && isConnectionMode(recoveryMode)
+ ) {
+ return { deviceId: recoveryDeviceId, connectionMode: recoveryMode };
+ }
+
+ return null;
+}
+
+/** Transport refs explicitly retired by the operator must never rehydrate. */
+export function retiredPhysicalTransportRefs(
+ state: XgridsK1State | null | undefined,
+): ReadonlySet {
+ const policyRetiredRefs = state?.connection_policy?.facts.retired_transport_refs;
+ if (Array.isArray(policyRetiredRefs)) {
+ // This backend projection is the authoritative *active* deny-list. The
+ // durable ledger keeps historical retirement audits even after an explicit
+ // reconciliation reopen, so unioning every old audit would make a safely
+ // reopened UUID impossible to use forever.
+ return new Set(
+ policyRetiredRefs.map(transportRefEquivalenceKey).filter(Boolean),
+ );
+ }
+ const record = state?.physical_command?.record;
+ const retired = new Set();
+ if (!record) return retired;
+ const retirements = Array.isArray(record.operator_retirements)
+ ? record.operator_retirements
+ : [];
+ for (const candidate of retirements) {
+ if (!candidate || typeof candidate !== "object") continue;
+ const transportRef = "retired_transport_ref" in candidate
+ && typeof candidate.retired_transport_ref === "string"
+ ? candidate.retired_transport_ref.trim()
+ : "";
+ if (transportRef) retired.add(transportRefEquivalenceKey(transportRef));
+ }
+ if (record.resolution === "operator-retired-outcome-unknown") {
+ const connection = record.connection;
+ if (connection && typeof connection === "object") {
+ const transportRef = "transport_ref" in connection
+ && typeof connection.transport_ref === "string"
+ ? connection.transport_ref.trim()
+ : "";
+ if (transportRef) retired.add(transportRefEquivalenceKey(transportRef));
+ }
+ }
+ return retired;
+}
+
+export interface RetiredPhysicalReopenAuthority {
+ expectedRevision: number;
+ expectedRetirementId: string;
+ expectedTransportRef: string;
+ expectedDiscoveryGeneration: number;
+ expectedDesiredMode: XgridsConnectionMode;
+ expectedDesiredModeRevision: number;
+}
+
+/**
+ * Admit the local-only reopen affordance only for the exact fresh retired row
+ * and exact backend-projected ledger/discovery CAS. This helper grants no GATT
+ * or write authority; the separately explicit Verify remains server-fenced.
+ */
+export function retiredPhysicalReopenAuthority(
+ state: XgridsK1State | null | undefined,
+ candidateTransportRef: string,
+ connectionMode: NonNullable,
+): RetiredPhysicalReopenAuthority | null {
+ const projection = state?.physical_command?.operator_reconciliation_reopen;
+ const expectedRevision = projection?.expected_revision;
+ const expectedRetirementId = projection?.expected_retirement_id?.trim() ?? "";
+ const expectedTransportRef = projection?.expected_transport_ref?.trim() ?? "";
+ const expectedDiscoveryGeneration = projection?.expected_discovery_generation;
+ const expectedDesiredMode = projection?.expected_desired_mode;
+ const expectedDesiredModeRevision =
+ projection?.expected_desired_mode_revision;
+ const candidateKey = transportRefEquivalenceKey(candidateTransportRef);
+ const expectedKey = transportRefEquivalenceKey(expectedTransportRef);
+ const currentGeneration = state?.ble_discovery_generation;
+ const record = state?.physical_command?.record;
+ const recordRevision = record && typeof record.revision === "number"
+ ? record.revision
+ : null;
+ const retirements = record && Array.isArray(record.operator_retirements)
+ ? record.operator_retirements
+ : [];
+ const exactRetirementRecorded = retirements.some((candidate) => Boolean(
+ candidate
+ && typeof candidate === "object"
+ && "retirement_id" in candidate
+ && candidate.retirement_id === expectedRetirementId
+ && "retired_transport_ref" in candidate
+ && typeof candidate.retired_transport_ref === "string"
+ && transportRefEquivalenceKey(candidate.retired_transport_ref) === expectedKey
+ ));
+ const freshCandidates = (state?.devices ?? []).filter(
+ (device) => transportRefEquivalenceKey(device.device_id) === candidateKey,
+ );
+ const freshCandidate = freshCandidates.length === 1
+ ? freshCandidates[0]
+ : null;
+ const recordConnection = record && typeof record.connection === "object"
+ && record.connection !== null
+ ? record.connection
+ : null;
+ const recoveryConnectionMode = recordConnection
+ && "connection_mode" in recordConnection
+ && typeof recordConnection.connection_mode === "string"
+ ? recordConnection.connection_mode
+ : null;
+ const recoveryMatchesMode = Boolean(
+ recordConnection
+ && "transport_ref" in recordConnection
+ && typeof recordConnection.transport_ref === "string"
+ && transportRefEquivalenceKey(recordConnection.transport_ref) === expectedKey
+ && recoveryConnectionMode === connectionMode,
+ );
+ // CoreBluetooth may advertise the exact returned transport while marking
+ // the passive scan row non-connectable. The backend's exact reopen CAS is
+ // the only exception: the explicit click is still server-fenced, and every
+ // unrelated non-connectable row remains blocked by the presentation layer.
+ if (
+ projection?.allowed !== true
+ || projection.automatic_retry !== false
+ || projection.device_io_performed !== false
+ || projection.reason_codes.length !== 0
+ || !Number.isInteger(expectedRevision)
+ || (expectedRevision ?? 0) < 1
+ || recordRevision !== expectedRevision
+ || !expectedRetirementId
+ || !candidateKey
+ || candidateKey !== expectedKey
+ || !Number.isInteger(expectedDiscoveryGeneration)
+ || expectedDiscoveryGeneration !== currentGeneration
+ || expectedDesiredMode !== connectionMode
+ || state?.desired_connection_mode !== expectedDesiredMode
+ || !Number.isInteger(expectedDesiredModeRevision)
+ || expectedDesiredModeRevision !== state?.desired_connection_mode_revision
+ || !retiredPhysicalTransportRefs(state).has(candidateKey)
+ || !exactRetirementRecorded
+ || !freshCandidate
+ || !recoveryMatchesMode
+ ) return null;
+ return {
+ expectedRevision: expectedRevision as number,
+ expectedRetirementId,
+ expectedTransportRef,
+ expectedDiscoveryGeneration: expectedDiscoveryGeneration as number,
+ expectedDesiredMode,
+ expectedDesiredModeRevision: expectedDesiredModeRevision as number,
+ };
+}
+
+/**
+ * Prove that a lost reopen response actually committed the exact local-only
+ * ledger transition. Nothing here grants Verify by itself; the caller must
+ * also retain the original full connection-action authority.
+ */
+export function reopenedPhysicalReconciliationMatches(
+ state: XgridsK1State | null | undefined,
+ request: ReopenRetiredPhysicalReconciliationRequest,
+): boolean {
+ const record = state?.physical_command?.record;
+ const recordRevision = record && typeof record.revision === "number"
+ ? record.revision
+ : null;
+ const reopens = record && Array.isArray(record.operator_reconciliation_reopens)
+ ? record.operator_reconciliation_reopens
+ : [];
+ const requestKey = transportRefEquivalenceKey(request.expected_transport_ref);
+ const exactAudit = reopens.some((candidate) => Boolean(
+ candidate
+ && typeof candidate === "object"
+ && "reopening_id" in candidate
+ && candidate.reopening_id === request.reopening_id
+ && "retirement_id" in candidate
+ && candidate.retirement_id === request.expected_retirement_id
+ && "retired_record_revision" in candidate
+ && candidate.retired_record_revision === request.expected_revision
+ && "reopened_transport_ref" in candidate
+ && typeof candidate.reopened_transport_ref === "string"
+ && transportRefEquivalenceKey(candidate.reopened_transport_ref) === requestKey
+ && "discovery_generation" in candidate
+ && candidate.discovery_generation === request.expected_discovery_generation
+ && "reason" in candidate
+ && candidate.reason === request.reason
+ ));
+ const activeRetiredRefs = state?.connection_policy?.facts.retired_transport_refs;
+ const activeDenyRemoved = Array.isArray(activeRetiredRefs)
+ && !activeRetiredRefs.some(
+ (value) => transportRefEquivalenceKey(value) === requestKey,
+ );
+ const freshCandidates = (state?.devices ?? []).filter(
+ (device) => transportRefEquivalenceKey(device.device_id) === requestKey,
+ );
+ const freshCandidate = freshCandidates.length === 1
+ ? freshCandidates[0]
+ : null;
+ return Boolean(
+ request.expected_desired_mode === state?.desired_connection_mode
+ && request.expected_desired_mode_revision
+ === state?.desired_connection_mode_revision
+ && requestKey
+ && recordRevision === request.expected_revision + 1
+ && (record?.stage === "dispatching" || record?.stage === "observing")
+ && record.resolution === null
+ && state?.physical_command?.requires_reconciliation === true
+ && state.ble_discovery_generation === request.expected_discovery_generation
+ && exactAudit
+ && activeDenyRemoved
+ && freshCandidate
+ && freshCandidate.connectable !== false
+ );
+}
+
+/** The exact durable K1 binding that recovery is allowed to observe. */
+export function readOnlyPhysicalRecoveryBinding(
+ state: XgridsK1State | null | undefined,
+): ReadOnlyPhysicalRecoveryBinding | null {
+ if (!requiresReadOnlyPhysicalRecovery(state)) return null;
+ const actions: ReadOnlyConnectionObservationAction[] = [
+ "observe-fresh-device-network",
+ "observe-current-device-network",
+ "observe-configured-device-network",
+ ];
+ for (const action of actions) {
+ const decision = connectionPolicyDecision(state, action);
+ const deviceId = decision?.required_transport_ref?.trim();
+ const connectionMode = decision?.required_connection_mode ?? null;
+ if (deviceId && isConnectionMode(connectionMode)) {
+ return { deviceId, connectionMode };
+ }
+ }
+ return null;
+}
+
+export function canSelectConnectionMode(
+ state: XgridsK1State | null | undefined,
+): boolean {
+ const lifecycle = state?.connection_lifecycle;
+ return Boolean(
+ lifecycle?.schema_version === "missioncore.xgrids-k1-connection-lifecycle/v1"
+ && lifecycle.mode_selection.allowed === true
+ && lifecycle.mode_selection.automatic_retry === false
+ && lifecycle.allowed_actions.includes("select-connection-mode"),
+ );
+}
+
+export type ReadOnlyConnectionObservationAction = Extract<
+ XgridsConnectionPolicyAction,
+ | "observe-fresh-device-network"
+ | "observe-current-device-network"
+ | "observe-configured-device-network"
+>;
+
+export type ReadOnlyConnectionObservationSource =
+ | "fresh-scan"
+ | "retained-current-process"
+ | "durable-configured-state";
+
+export interface ReadOnlyConnectionObservationTarget {
+ action: ReadOnlyConnectionObservationAction;
+ deviceId: string;
+ connectionMode: XgridsConnectionMode;
+ source: ReadOnlyConnectionObservationSource;
+ serverBound: boolean;
+ expectedDiscoveryGeneration: number | null;
+}
+
+function isConnectionMode(value: unknown): value is XgridsConnectionMode {
+ return value === "bridge"
+ || value === "quick-connect"
+ || value === "direct-connect";
+}
+
+function exactPolicyObservationTarget(
+ state: XgridsK1State | null | undefined,
+ action: ReadOnlyConnectionObservationAction,
+ source: ReadOnlyConnectionObservationSource,
+): ReadOnlyConnectionObservationTarget | null {
+ if (!connectionPolicyAllows(state, action)) return null;
+ const decision = connectionPolicyDecision(state, action);
+ const deviceId = decision?.required_transport_ref?.trim();
+ const connectionMode = decision?.required_connection_mode ?? null;
+ if (
+ decision?.target_source !== source
+ || !deviceId
+ || !isConnectionMode(connectionMode)
+ ) {
+ return null;
+ }
+ if (source === "fresh-scan") {
+ const freshDevice = provisioningCandidateById(state?.devices ?? [], deviceId);
+ if (
+ !freshDevice
+ || freshDevice.connectable === false
+ || !Number.isInteger(state?.ble_discovery_generation)
+ || (state?.ble_discovery_generation ?? -1) < 0
+ ) return null;
+ }
+ return {
+ action,
+ deviceId,
+ connectionMode,
+ source,
+ serverBound: true,
+ expectedDiscoveryGeneration: source === "fresh-scan"
+ ? state?.ble_discovery_generation as number
+ : null,
+ };
+}
+
+const SERVER_BOUND_RECOVERY_OBSERVATION_PRIORITY:
+ReadonlyArray = [
+ "observe-current-device-network",
+ "observe-configured-device-network",
+ "observe-fresh-device-network",
+];
+
+/**
+ * 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.
+ */
+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 ReadOnlyConnectionObservationAction,
+ )
+ ? [
+ recommended as ReadOnlyConnectionObservationAction,
+ ...SERVER_BOUND_RECOVERY_OBSERVATION_PRIORITY.filter(
+ (action) => action !== recommended,
+ ),
+ ]
+ : SERVER_BOUND_RECOVERY_OBSERVATION_PRIORITY;
+ const sources: Record<
+ ReadOnlyConnectionObservationAction,
+ ReadOnlyConnectionObservationSource
+ > = {
+ "observe-current-device-network": "retained-current-process",
+ "observe-configured-device-network": "durable-configured-state",
+ "observe-fresh-device-network": "fresh-scan",
+ };
+ for (const action of orderedActions) {
+ const target = exactPolicyObservationTarget(state, action, sources[action]);
+ if (target?.serverBound) return target;
+ }
+ return null;
+}
+
+/**
+ * Resolve the one read-only BLE target authorized by the server policy.
+ * Unresolved writes never fall back to a browser selection or dropdown mode:
+ * their UUID, mode and recovery source must be pinned by the same decision.
+ */
+export function readOnlyConnectionObservationTarget(
+ state: XgridsK1State | null | undefined,
+ selectedDeviceId = "",
+ selectedConnectionMode: XgridsConnectionMode | null = null,
+): ReadOnlyConnectionObservationTarget | null {
+ const exactFresh = exactPolicyObservationTarget(
+ state,
+ "observe-fresh-device-network",
+ "fresh-scan",
+ );
+ if (exactFresh) return exactFresh;
+
+ // Outside reconciliation, the backend deliberately leaves the fresh target
+ // unpinned because the operator may choose among several current adverts.
+ // Preserve that reviewed path, but never use it for an unresolved write.
+ if (
+ !hasUnresolvedNetworkMutation(state)
+ && !requiresReadOnlyPhysicalRecovery(state)
+ && selectedConnectionMode
+ && connectionPolicyAllows(state, "observe-fresh-device-network")
+ ) {
+ const freshDevice = provisioningCandidateById(
+ state?.devices ?? [],
+ selectedDeviceId,
+ );
+ if (
+ freshDevice
+ && freshDevice.connectable !== false
+ && Number.isInteger(state?.ble_discovery_generation)
+ && (state?.ble_discovery_generation ?? -1) >= 0
+ ) {
+ return {
+ action: "observe-fresh-device-network",
+ deviceId: freshDevice.device_id,
+ connectionMode: selectedConnectionMode,
+ source: "fresh-scan",
+ serverBound: false,
+ expectedDiscoveryGeneration: state?.ble_discovery_generation as number,
+ };
+ }
+ }
+
+ return exactPolicyObservationTarget(
+ state,
+ "observe-current-device-network",
+ "retained-current-process",
+ ) ?? exactPolicyObservationTarget(
+ state,
+ "observe-configured-device-network",
+ "durable-configured-state",
+ );
+}
+
+/**
+ * Resolve recovery for an already-applied network only from a backend-pinned
+ * current/configured target. A browser-selected advertisement is never an
+ * authority for this read-only continuation, even when it happens to carry
+ * the same UUID.
+ */
+export function serverBoundAppliedNetworkObservationTarget(
+ state: XgridsK1State | null | undefined,
+ connectionMode: XgridsConnectionMode,
+): ReadOnlyConnectionObservationTarget | null {
+ const target = exactPolicyObservationTarget(
+ state,
+ "observe-current-device-network",
+ "retained-current-process",
+ ) ?? exactPolicyObservationTarget(
+ state,
+ "observe-configured-device-network",
+ "durable-configured-state",
+ );
+ return target?.serverBound === true
+ && target.connectionMode === connectionMode
+ ? target
+ : null;
+}
+
+export interface BackendConnectionTopology {
+ connectionMode: NonNullable;
+ status: "active" | "configured-unverified" | "configured-offline";
+ source: "applied" | "durable" | "last-known";
+ endpoint: string | null;
+}
+
+function sameTarget(
+ left: { ipv4: string; port: number } | null | undefined,
+ right: { ipv4: string; port: number } | null | undefined,
+): boolean {
+ return Boolean(
+ left
+ && right
+ && left.ipv4 === right.ipv4
+ && left.port === right.port,
+ );
+}
+
+export function currentAppliedConnectionTopology(
+ state: XgridsK1State | null | undefined,
+ connectionMode?: NonNullable,
+): BackendConnectionTopology | null {
+ const supervisor = state?.connection_supervisor;
+ if (!supervisor || supervisor.closed) return null;
+ const { intent, lease, observed } = supervisor;
+ const deviceNetwork = observed.device_network;
+ const mode = deviceNetwork?.connection_mode;
+ const target = deviceNetwork?.target;
+ const currentDeviceNetwork = Boolean(
+ intent
+ && mode
+ && target
+ && (!connectionMode || mode === connectionMode)
+ && deviceNetwork.state === "applied"
+ && deviceNetwork.intent_id === intent.intent_id
+ && Boolean(deviceNetwork.transport_ref)
+ && mode === intent.requested_mode
+ );
+ if (!currentDeviceNetwork || !mode || !target) return null;
+ const currentEndpoint = Boolean(
+ ["configured-unverified", "reachable"].includes(lease.state)
+ && lease.intent_id === intent?.intent_id
+ && lease.connection_mode === mode
+ && sameTarget(lease.target, target)
+ && observed.host_path.available === true
+ && observed.host_path.route_class === "direct"
+ && lease.host_path_epoch === observed.host_path.epoch
+ && observed.endpoint.intent_id === intent?.intent_id
+ && observed.endpoint.host_path_epoch === observed.host_path.epoch
+ && observed.endpoint.tcp_state === "reachable"
+ && sameTarget(target, observed.endpoint.target)
+ );
+ const identity = observed.device_identity;
+ const controlPlane = observed.control_plane;
+ const lifecycle = state?.connection_lifecycle;
+ const activeBinding = lifecycle?.active_binding;
+ const identityExact = Boolean(
+ identity.state === "verified"
+ && identity.intent_id === intent?.intent_id
+ && identity.connection_mode === mode
+ && identity.host_path_epoch === observed.host_path.epoch
+ && identity.logical_device_id
+ && lease.logical_device_id === identity.logical_device_id
+ && (!intent?.expected_device_id
+ || identity.logical_device_id === intent.expected_device_id),
+ );
+ const active = Boolean(
+ currentEndpoint
+ && lease.state === "reachable"
+ && supervisor.authority.control_allowed === true
+ && identityExact
+ && controlPlane.state === "healthy"
+ && Boolean(controlPlane.session_id)
+ && controlPlane.host_path_epoch === observed.host_path.epoch
+ && lifecycle?.schema_version === "missioncore.xgrids-k1-connection-lifecycle/v1"
+ && lifecycle.connection_ready === true
+ && lifecycle.configured_mode === mode
+ && lifecycle.active_mode === mode
+ && activeBinding?.connection_mode === mode
+ && activeBinding.intent_id === intent?.intent_id
+ && transportRefEquivalenceKey(activeBinding.transport_ref)
+ === transportRefEquivalenceKey(deviceNetwork.transport_ref)
+ && activeBinding.target_ipv4 === target.ipv4
+ && activeBinding.target_port === target.port
+ && activeBinding.host_path_epoch === observed.host_path.epoch
+ && activeBinding.control_session_id === controlPlane.session_id
+ );
+ return {
+ connectionMode: mode,
+ status: active
+ ? "active"
+ : currentEndpoint
+ ? "configured-unverified"
+ : "configured-offline",
+ source: "applied",
+ endpoint: target.ipv4,
+ };
+}
+
+export function backendConnectionTopology(
+ state: XgridsK1State | null | undefined,
+ connectionMode?: NonNullable,
+): BackendConnectionTopology | null {
+ const supervisor = state?.connection_supervisor;
+ const applied = currentAppliedConnectionTopology(state);
+ // A current BLE-proved device topology supersedes every persisted or
+ // historical address, including when the selected UI mode is different.
+ if (applied) {
+ return !connectionMode || applied.connectionMode === connectionMode
+ ? applied
+ : null;
+ }
+ const semanticStore = state?.semantic_topology_store;
+ const durable = semanticStore?.record;
+ if (
+ semanticStore?.status === "available"
+ && semanticStore.configured_offline_evidence === true
+ && semanticStore.live_connection_authority === false
+ && durable
+ && durable.schema_version === "missioncore.xgrids-k1-semantic-topology/v1"
+ && (!connectionMode || durable.connection_mode === connectionMode)
+ && Boolean(durable.ipv4.trim())
+ ) {
+ const endpointProbe = state?.configured_endpoint_probe;
+ const durableEndpointReachable = Boolean(
+ endpointProbe?.status === "reachable"
+ && endpointProbe.target_source === "durable-semantic-topology"
+ && endpointProbe.connection_mode === durable.connection_mode
+ && endpointProbe.endpoint === durable.ipv4
+ && transportRefEquivalenceKey(endpointProbe.transport_ref)
+ === transportRefEquivalenceKey(durable.transport_ref)
+ && endpointProbe.semantic_revision === durable.revision
+ && endpointProbe.host_route_available === true
+ && endpointProbe.host_route_class === "direct"
+ && endpointProbe.tcp_reachable === true
+ && endpointProbe.identity_validation === "not-performed"
+ && endpointProbe.control_authority_granted === false
+ && endpointProbe.ble_operation_performed === false
+ && endpointProbe.network_mutation_performed === false
+ && endpointProbe.automatic_retry === false
+ );
+ return {
+ connectionMode: durable.connection_mode,
+ status: durableEndpointReachable
+ ? "configured-unverified"
+ : "configured-offline",
+ source: "durable",
+ endpoint: durable.ipv4,
+ };
+ }
+ const lastKnown = supervisor?.last_known;
+ if (
+ !lastKnown
+ || (connectionMode && lastKnown.connection_mode !== connectionMode)
+ ) return null;
+ return {
+ connectionMode: lastKnown.connection_mode,
+ status: "configured-offline",
+ source: "last-known",
+ endpoint: lastKnown.target.ipv4,
+ };
+}
+
+export function hasUnresolvedNetworkMutation(
+ state: XgridsK1State | null | undefined,
+): boolean {
+ if (state?.network_write_reconciliation) return true;
+ const ledger = state?.network_mutation_ledger;
+ return Boolean(
+ ledger
+ && (
+ ledger.status === "unresolved"
+ || ledger.status === "corrupt"
+ || ledger.mutation_allowed !== true
+ ),
+ );
}
export function canSubmitProvisioningMutation({
devices,
selectedDeviceId,
- powerConfirmed,
credentialsReady,
isBusy,
}: {
devices: readonly BleDevice[];
selectedDeviceId: string;
- powerConfirmed: boolean;
credentialsReady: boolean;
isBusy: boolean;
}): boolean {
const candidate = provisioningCandidateById(devices, selectedDeviceId);
return Boolean(
- powerConfirmed &&
credentialsReady &&
!isBusy &&
candidate &&
@@ -232,16 +1512,198 @@ export function canSubmitProvisioningMutation({
);
}
+export function canAdmitProvisioningConnection({
+ policyAllowed,
+ targetSource,
+ hasSuccessfulLocalConnect,
+ localPrerequisitesReady,
+}: {
+ policyAllowed: boolean;
+ targetSource: "fresh-scan" | null;
+ hasSuccessfulLocalConnect: boolean;
+ localPrerequisitesReady: boolean;
+}): boolean {
+ return Boolean(
+ policyAllowed
+ && targetSource === "fresh-scan"
+ && !hasSuccessfulLocalConnect
+ && localPrerequisitesReady,
+ );
+}
+
export function isReachableConnectionLease(
state: XgridsK1State | null | undefined,
connectionMode: NonNullable,
): boolean {
- const verification = state?.connection_verification;
+ const supervisor = state?.connection_supervisor;
+ if (!supervisor || supervisor.closed) return false;
+ return currentAppliedConnectionTopology(state, connectionMode)?.status === "active";
+}
+
+export function isConfiguredConnectionLease(
+ state: XgridsK1State | null | undefined,
+ connectionMode: NonNullable,
+): boolean {
+ const topology = backendConnectionTopology(state, connectionMode);
+ return Boolean(topology && topology.source !== "last-known");
+}
+
+export function hasControlAuthority(
+ state: XgridsK1State | null | undefined,
+): boolean {
+ const topology = currentAppliedConnectionTopology(state);
+ return Boolean(topology?.status === "active");
+}
+
+export function hasAuthoritativeData(
+ state: XgridsK1State | null | undefined,
+): boolean {
+ const supervisor = state?.connection_supervisor;
+ const dataPlane = supervisor?.observed.data_plane;
return Boolean(
- state?.k1_ip &&
- state.connection_mode === connectionMode &&
- verification?.lease_state === "reachable" &&
- verification.network_reachability === "reachable",
+ hasControlAuthority(state)
+ && supervisor?.authority.data_ingest_authoritative === true
+ && dataPlane?.state === "healthy"
+ && Boolean(dataPlane.session_id)
+ && dataPlane.host_path_epoch === supervisor.observed.host_path.epoch,
+ );
+}
+
+export function canonicalDeviceConnectivity(
+ state: XgridsK1State | null | undefined,
+): "unknown" | "offline" | "connecting" | "connected" | "degraded" {
+ const topology = currentAppliedConnectionTopology(state);
+ if (topology?.status === "active") {
+ const supervisor = state?.connection_supervisor;
+ return supervisor
+ && ["stalled", "lost"].includes(supervisor.observed.data_plane.state)
+ ? "degraded"
+ : "connected";
+ }
+ if (topology?.status === "configured-unverified") return "connecting";
+ if (topology?.status === "configured-offline") return "offline";
+ const fallback = backendConnectionTopology(state);
+ if (fallback?.source === "last-known") return "degraded";
+ if (fallback?.status === "configured-offline") return "offline";
+ const supervisor = state?.connection_supervisor;
+ if (!supervisor || supervisor.closed) return supervisor?.closed ? "offline" : "unknown";
+ if (supervisor.lease.state === "lost" || supervisor.last_known) return "degraded";
+ return "offline";
+}
+
+export function activeConnectionEndpointLabel(
+ state: XgridsK1State | null | undefined,
+): string | null {
+ const topology = currentAppliedConnectionTopology(state);
+ return topology?.status === "active" ? topology.endpoint : null;
+}
+
+export interface ReachableConnectionLeaseIdentity {
+ key: string;
+ runtimeId: string;
+ leaseGeneration: number;
+ intentId: string;
+ hostPathEpoch: number;
+ connectionMode: NonNullable;
+}
+
+export interface RuntimeErrorCorrelation {
+ action: string;
+ runtimeId: string | null;
+ leaseGeneration: number | null;
+ connectionAttemptId: string | null;
+}
+
+/**
+ * Attach connection-attempt diagnostics only to the exact failed Connect
+ * action which produced them. A global Scan/Verify/Refresh failure must never
+ * borrow an older durable attempt merely because it remains in the snapshot.
+ */
+export function connectionAttemptForRuntimeError(
+ error: RuntimeErrorCorrelation | null | undefined,
+ state: XgridsK1State | null | undefined,
+): XgridsConnectionAttempt | null {
+ const runtimeId = state?.snapshot_runtime_id?.trim() || null;
+ const attempt = state?.connection_attempt;
+ return error?.action === "connect"
+ && typeof error.runtimeId === "string"
+ && error.runtimeId === runtimeId
+ && typeof error.connectionAttemptId === "string"
+ && attempt
+ && error.connectionAttemptId === attempt?.attempt_id
+ && !["accepted", "running"].includes(attempt.status)
+ ? attempt
+ : null;
+}
+
+export function reachableConnectionLeaseIdentity(
+ state: XgridsK1State | null | undefined,
+): ReachableConnectionLeaseIdentity | null {
+ const runtimeId = state?.snapshot_runtime_id;
+ const supervisor = state?.connection_supervisor;
+ const connectionMode = supervisor?.lease.connection_mode;
+ const leaseGeneration = supervisor?.lease.generation;
+ const intentId = supervisor?.intent?.intent_id;
+ const hostPathEpoch = supervisor?.lease.host_path_epoch;
+ if (
+ typeof runtimeId !== "string"
+ || !runtimeId.trim()
+ || !connectionMode
+ || !intentId
+ || !Number.isInteger(leaseGeneration)
+ || (leaseGeneration ?? -1) < 0
+ || !Number.isInteger(hostPathEpoch)
+ || (hostPathEpoch ?? 0) < 1
+ || !isReachableConnectionLease(state, connectionMode)
+ ) {
+ return null;
+ }
+ return {
+ key: `${runtimeId}:${intentId}:${hostPathEpoch}:${leaseGeneration}`,
+ runtimeId,
+ leaseGeneration: leaseGeneration as number,
+ intentId,
+ hostPathEpoch: hostPathEpoch as number,
+ connectionMode,
+ };
+}
+
+export function authoritativeReachableLeaseSupersedesError(
+ error: RuntimeErrorCorrelation | null | undefined,
+ state: XgridsK1State | null | undefined,
+): boolean {
+ if (
+ !error
+ || (error.action !== "connect" && error.action !== "verify")
+ || typeof error.runtimeId !== "string"
+ || !Number.isInteger(error.leaseGeneration)
+ // An omitted legacy field is not proof that the process-owned write fence
+ // was cleared. Only the canonical explicit null may dismiss the banner.
+ || hasUnresolvedNetworkMutation(state)
+ ) {
+ return false;
+ }
+ const identity = reachableConnectionLeaseIdentity(state);
+ return Boolean(
+ identity
+ && identity.runtimeId === error.runtimeId
+ && identity.leaseGeneration > (error.leaseGeneration as number),
+ );
+}
+
+/**
+ * Clear a transient action banner when a later authoritative state proves
+ * that the failed local acquisition has already been sealed and released.
+ * Every poll/WebSocket state enters through the same reducer, so recovery
+ * needs no refresh button or browser-cache reset.
+ */
+export function authoritativeStateSupersedesRuntimeError(
+ error: RuntimeErrorCorrelation | null | undefined,
+ state: XgridsK1State | null | undefined,
+): boolean {
+ return Boolean(
+ authoritativeReachableLeaseSupersedesError(error, state)
+ || (error && !shouldSurfaceRuntimeActionError(error.action, state)),
);
}
@@ -261,6 +1723,33 @@ function defaultUuid(): string {
.join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
}
+export function newOperationId(): string {
+ return `op-${defaultUuid()}`;
+}
+
+export function newPhysicalRetirementId(): string {
+ return `retirement-${defaultUuid()}`;
+}
+
+export function newPhysicalReopeningId(): string {
+ return `reopening-${defaultUuid()}`;
+}
+
+export function newMutationContext(action: string): {
+ operation_id: string;
+ idempotency_key: string;
+} {
+ const normalizedAction = action.trim();
+ if (!normalizedAction) {
+ throw new Error("Действие операции не задано; безопасный ключ не создан.");
+ }
+ const operationId = newOperationId();
+ return {
+ operation_id: operationId,
+ idempotency_key: `${normalizedAction}:${operationId}`,
+ };
+}
+
export function provisioningIntentKey(
current: string | null,
createUuid: () => string = defaultUuid,
diff --git a/plugins/xgrids-k1/frontend/src/manifest.ts b/plugins/xgrids-k1/frontend/src/manifest.ts
index 6e1e587..32edd51 100644
--- a/plugins/xgrids-k1/frontend/src/manifest.ts
+++ b/plugins/xgrids-k1/frontend/src/manifest.ts
@@ -17,12 +17,28 @@ export const xgridsK1Actions = Object.freeze({
xgridsK1Manifest,
"calibration.device-snapshot.read",
),
+ connectionModeSelect: requirePluginAction(
+ xgridsK1Manifest,
+ "connection.mode.select",
+ ),
+ connectionReconfigurePrepare: requirePluginAction(
+ xgridsK1Manifest,
+ "connection.reconfigure.prepare",
+ ),
networkProvision: requirePluginAction(xgridsK1Manifest, "network.provision"),
connectionVerify: requirePluginAction(xgridsK1Manifest, "connection.verify"),
+ configuredEndpointProbe: requirePluginAction(
+ xgridsK1Manifest,
+ "connection.endpoint-probe",
+ ),
acquisitionPrepare: requirePluginAction(xgridsK1Manifest, "acquisition.prepare"),
acquisitionStart: requirePluginAction(xgridsK1Manifest, "acquisition.start"),
acquisitionStop: requirePluginAction(xgridsK1Manifest, "acquisition.stop"),
acquisitionAbort: requirePluginAction(xgridsK1Manifest, "acquisition.abort"),
+ acquisitionForceFinishLocal: requirePluginAction(
+ xgridsK1Manifest,
+ "acquisition.force-finish-local",
+ ),
acquisitionStateRead: requirePluginAction(xgridsK1Manifest, "acquisition.state.read"),
compatibilityStreamStartLive: requirePluginAction(xgridsK1Manifest, "stream.start-live"),
streamStartReplay: requirePluginAction(xgridsK1Manifest, "stream.start-replay"),
@@ -54,4 +70,16 @@ export const xgridsK1Actions = Object.freeze({
xgridsK1Manifest,
"application-control.session.close",
),
+ physicalCommandReconcile: requirePluginAction(
+ xgridsK1Manifest,
+ "physical-command.reconcile",
+ ),
+ physicalCommandRetireUnavailable: requirePluginAction(
+ xgridsK1Manifest,
+ "physical-command.retire-unavailable",
+ ),
+ physicalCommandReopenRetiredReconciliation: requirePluginAction(
+ xgridsK1Manifest,
+ "physical-command.reopen-retired-reconciliation",
+ ),
});
diff --git a/plugins/xgrids-k1/frontend/src/messages.ts b/plugins/xgrids-k1/frontend/src/messages.ts
index 0cf447e..17eee43 100644
--- a/plugins/xgrids-k1/frontend/src/messages.ts
+++ b/plugins/xgrids-k1/frontend/src/messages.ts
@@ -48,7 +48,6 @@ const runtimeMessageReplacements: Array<[RegExp, string]> = [
],
[/Foxglove/gi, "локальный мост визуализации"],
[/MacBook/gi, "компьютер"],
- [/\bK1\b/g, "устройство"],
];
export function localizeRuntimeMessage(message: string | null | undefined): string | null {
diff --git a/plugins/xgrids-k1/frontend/src/observationSources.ts b/plugins/xgrids-k1/frontend/src/observationSources.ts
index d6774fd..34ff900 100644
--- a/plugins/xgrids-k1/frontend/src/observationSources.ts
+++ b/plugins/xgrids-k1/frontend/src/observationSources.ts
@@ -3,15 +3,28 @@ import type {
ObservationSourceAvailability,
ObservationSourceDelivery,
ObservationSourceDescriptor,
+ ObservationSourcePresentationLease,
ObservationSourceProvider,
} from "@mission-core/plugin-sdk";
-import { confirmedRuntimeSourceMode, effectiveAcquisition } from "./lifecycle";
+import {
+ activeStreamRecoveredBrowserAuthority,
+ activeStreamRecoveryOwnsPresentationDecision,
+ activeStreamRecoveryPresentationAuthority,
+ type ActiveStreamRecoveryPresentationAuthority,
+} from "./activeStreamRecovery";
+import {
+ confirmedRuntimeSourceMode,
+ effectiveAcquisition,
+ hasAuthoritativeData,
+ hasControlAuthority,
+} from "./lifecycle";
import { xgridsK1Manifest } from "./manifest";
import type {
XgridsCameraPreviewDelivery,
XgridsK1State,
XgridsSensorCatalogStream,
} from "./api";
+import { isXgridsActiveStreamRecovery } from "./api";
function providerFor(
state: XgridsK1State,
@@ -22,30 +35,72 @@ function providerFor(
pluginVersion: xgridsK1Manifest.metadata.version,
modelId: state.device_ref?.model_id || activeModel.id,
compatibilityProfileId:
- state.device_session?.compatibility_profile_id ?? state.compatibility?.profile_id ?? null,
+ (state.connection_supervisor?.observed.device_identity.state === "verified"
+ ? state.connection_supervisor.observed.device_identity.compatibility_profile_id
+ : null)
+ ?? state.device_session?.compatibility_profile_id
+ ?? state.compatibility?.profile_id
+ ?? null,
};
}
-function bindingFor(state: XgridsK1State) {
+function bindingFor(
+ state: XgridsK1State,
+ recoveryAuthority: ActiveStreamRecoveryPresentationAuthority | null,
+) {
const acquisition = effectiveAcquisition(state);
+ const controlAuthoritative = hasControlAuthority(state);
+ const recoveryAuthoritative = recoveryAuthority !== null;
return {
- deviceId: state.device_ref?.device_id ?? null,
- deviceSessionId: state.device_session?.device_session_id ?? null,
+ // A legacy snapshot may retain a selected device and session long after
+ // the control topology has disappeared. Do not publish those values as a
+ // live host binding until the supervisor has re-attested the topology.
+ deviceId: recoveryAuthoritative
+ ? acquisition?.device_id?.trim() || null
+ : controlAuthoritative ? state.device_ref?.device_id ?? null : null,
+ deviceSessionId: recoveryAuthoritative
+ ? acquisition?.device_session_id?.trim() || null
+ : controlAuthoritative ? state.device_session?.device_session_id ?? null : null,
acquisitionId: acquisition?.acquisition_id ?? null,
};
}
+function recoveryPresentationLease(
+ authority: ActiveStreamRecoveryPresentationAuthority,
+): ObservationSourcePresentationLease {
+ return {
+ kind: "active-stream-recovery",
+ runtimeId: authority.snapshotRuntimeId,
+ acquisitionId: authority.acquisitionId,
+ acquisitionStateRevision: authority.acquisitionStateRevision,
+ producerGeneration: authority.runtimeProducerGeneration,
+ recoveryGeneration: authority.recoveryGeneration,
+ };
+}
+
function catalogDeclares(state: XgridsK1State, streamId: string): boolean {
return Boolean(state.sensor_catalog?.streams?.some((stream) => stream.stream_id === streamId));
}
-function spatialAvailability(state: XgridsK1State): ObservationSourceAvailability {
+function spatialAvailability(
+ state: XgridsK1State,
+ recoveryAuthoritative: boolean,
+ recoveryOwnsPresentation: boolean,
+): ObservationSourceAvailability {
const mode = confirmedRuntimeSourceMode(state);
- if (mode !== "idle" && state.rerun_grpc_url?.trim()) return "streaming";
- if (state.rerun_grpc_url?.trim()) return "available";
- if (state.device_session?.connectivity === "degraded") return "degraded";
- if (state.device_session?.connectivity === "connected") return "available";
- return catalogDeclares(state, "spatial.point-cloud.live") ? "declared" : "unavailable";
+ if (mode === "replay" && state.rerun_grpc_url?.trim()) return "streaming";
+ const declared = catalogDeclares(state, "spatial.point-cloud.live");
+ if (recoveryAuthoritative && state.rerun_grpc_url?.trim()) return "connecting";
+ if (recoveryOwnsPresentation) return declared ? "degraded" : "unavailable";
+ if (!hasControlAuthority(state)) return declared ? "unverified" : "unavailable";
+ if (mode === "live" && state.rerun_grpc_url?.trim() && hasAuthoritativeData(state)) {
+ return "streaming";
+ }
+ if (["stalled", "lost"].includes(
+ state.connection_supervisor?.observed.data_plane.state ?? "idle",
+ )) return "degraded";
+ if (state.rerun_grpc_url?.trim() || declared) return "available";
+ return "unavailable";
}
function catalogAvailability(value: string | null | undefined): ObservationSourceAvailability {
@@ -167,15 +222,42 @@ function browserDelivery(
return { id, kind: value.kind, url, mediaType };
}
+function sameBrowserDelivery(
+ left: ObservationSourceDelivery | null,
+ right: ObservationSourceDelivery | null,
+): boolean {
+ return Boolean(
+ left
+ && right
+ && left.kind === "mse-fmp4-websocket"
+ && right.kind === "mse-fmp4-websocket"
+ && left.id === right.id
+ && left.url === right.url
+ && left.mediaType === right.mediaType,
+ );
+}
+
function cameraAvailability(
state: XgridsK1State,
stream: XgridsSensorCatalogStream,
selected: boolean,
delivery: ObservationSourceDelivery | null,
attested: boolean,
+ recoverySelected: boolean,
+ exactCurrentEpochReady: boolean,
): ObservationSourceAvailability {
+ if (recoverySelected) {
+ return exactCurrentEpochReady
+ ? "streaming"
+ : state.camera_preview?.phase?.trim().toLowerCase() === "degraded"
+ ? "degraded"
+ : "connecting";
+ }
if (!attested) return "unverified";
- if (state.device_session?.connectivity === "degraded") return "degraded";
+ if (!hasControlAuthority(state)) return "degraded";
+ if (["stalled", "lost"].includes(
+ state.connection_supervisor?.observed.data_plane.state ?? "idle",
+ )) return "degraded";
const base = catalogAvailability(stream.availability);
if (!selected) return base === "streaming" || base === "connecting" ? "available" : base;
@@ -188,12 +270,82 @@ function cameraAvailability(
return "connecting";
}
+function exactCurrentCameraEpochReady(state: XgridsK1State): boolean {
+ const recovery = state.connection_recovery;
+ const previewGeneration = state.camera_preview?.generation;
+ if (
+ !isXgridsActiveStreamRecovery(recovery)
+ || recovery.camera_media_state !== "ready"
+ || recovery.camera_media_ready !== true
+ || !Number.isInteger(previewGeneration)
+ || (previewGeneration ?? 0) < 1
+ ) return false;
+ const epoch = recovery.camera_epoch;
+ return Boolean(
+ epoch
+ && epoch.generation === previewGeneration
+ && epoch.init_committed === true
+ && epoch.first_media_committed === true
+ && epoch.committed_media_segment_count > 0,
+ );
+}
+
+function recoveryCameraTupleIsExact(
+ state: XgridsK1State,
+ authority: ActiveStreamRecoveryPresentationAuthority | null,
+ provider: ObservationSourceProvider,
+): boolean {
+ const acquisition = state.acquisition;
+ const deviceId = acquisition?.device_id?.trim();
+ const deviceSessionId = acquisition?.device_session_id?.trim();
+ const compatibilityProfileId = acquisition?.compatibility_profile_id?.trim();
+ return Boolean(
+ authority
+ && authority.recovery.camera_recovery === "owned"
+ && acquisition
+ && acquisition.acquisition_id.trim() === authority.acquisitionId
+ && deviceId
+ && deviceSessionId
+ && compatibilityProfileId
+ && state.device_ref?.device_id?.trim() === deviceId
+ && state.device_session?.device_session_id?.trim() === deviceSessionId
+ && state.device_session?.device_id?.trim() === deviceId
+ && state.device_session?.compatibility_profile_id?.trim() === compatibilityProfileId
+ && provider.compatibilityProfileId?.trim() === compatibilityProfileId
+ );
+}
+
+function cameraRecoveryPhaseRetainable(state: XgridsK1State): boolean {
+ return [
+ "active",
+ "buffering",
+ "connecting",
+ "degraded",
+ "ready",
+ "reconnecting",
+ "streaming",
+ ].includes(state.camera_preview?.phase?.trim().toLowerCase() ?? "");
+}
+
export function xgridsK1ObservationSources(
state: XgridsK1State,
activeModel: DeviceModelDefinition,
): ObservationSourceDescriptor[] {
const provider = providerFor(state, activeModel);
- const binding = bindingFor(state);
+ const recoveryAuthority = activeStreamRecoveryPresentationAuthority(state);
+ const recoveredBrowserAuthority = activeStreamRecoveredBrowserAuthority(state);
+ const browserLineageAuthority = recoveryAuthority ?? recoveredBrowserAuthority;
+ const recoveryOwnsPresentation = activeStreamRecoveryOwnsPresentationDecision(state);
+ const recoveryAuthoritative = recoveryAuthority !== null;
+ const presentationLease = browserLineageAuthority
+ ? recoveryPresentationLease(browserLineageAuthority)
+ : null;
+ const binding = bindingFor(state, browserLineageAuthority);
+ const replayAuthoritative = state.source_mode === "replay";
+ const dataAuthoritative = hasAuthoritativeData(state) && !recoveryOwnsPresentation;
+ const spatialPreviewUrl = replayAuthoritative || dataAuthoritative || recoveryAuthoritative
+ ? state.rerun_grpc_url?.trim() || null
+ : null;
const clockId = binding.acquisitionId ?? binding.deviceSessionId ?? binding.deviceId ?? null;
const descriptorId = (sourceId: string) =>
`${provider.pluginId}:${provider.modelId}:${sourceId}`;
@@ -205,12 +357,22 @@ export function xgridsK1ObservationSources(
description: "Облако точек, поза и траектория в общей 3D-сцене",
modality: "point-cloud",
role: "primary",
- availability: spatialAvailability(state),
+ availability: spatialAvailability(
+ state,
+ recoveryAuthoritative,
+ recoveryOwnsPresentation,
+ ),
transport: "rerun-grpc",
endpointLabel: state.rerun_grpc_url?.trim() ? "Rerun gRPC" : "MQTT → Rerun",
- previewUrl: state.rerun_grpc_url?.trim() || null,
+ previewUrl: spatialPreviewUrl,
delivery: null,
activation: null,
+ presentationLease: (
+ recoveryAuthoritative
+ || (recoveredBrowserAuthority !== null && dataAuthoritative)
+ ) && spatialPreviewUrl
+ ? presentationLease
+ : null,
provider,
binding,
capabilities: {
@@ -234,9 +396,27 @@ export function xgridsK1ObservationSources(
const sourceId = stream.source_id?.trim();
if (sourceId) sourceIdCounts.set(sourceId, (sourceIdCounts.get(sourceId) ?? 0) + 1);
}
- const attested = Boolean(provider.compatibilityProfileId && binding.deviceSessionId);
+ const supervisor = state.connection_supervisor;
+ const verifiedControl = state.application_control_session?.verified_control;
+ const attested = Boolean(
+ hasControlAuthority(state)
+ && provider.compatibilityProfileId
+ && binding.deviceSessionId
+ && verifiedControl
+ && supervisor?.observed.control_plane.session_id === verifiedControl.control_session_id
+ && supervisor.observed.device_identity.logical_device_id
+ === verifiedControl.logical_device_id
+ && supervisor.observed.device_identity.compatibility_profile_id
+ === verifiedControl.compatibility_profile_id,
+ );
const sessionScope = binding.deviceSessionId ?? binding.deviceId ?? "unbound";
const activeSourceId = state.camera_preview?.active_source_id?.trim() ?? null;
+ const exactCameraMediaReady = exactCurrentCameraEpochReady(state);
+ const browserLineageCameraTupleExact = recoveryCameraTupleIsExact(
+ state,
+ browserLineageAuthority,
+ provider,
+ );
const cameras = cameraRows.flatMap((stream) => {
const sourceId = stream.source_id?.trim();
@@ -249,19 +429,58 @@ export function xgridsK1ObservationSources(
const activationValid = Boolean(
groupId && Number.isInteger(maxActive) && (maxActive ?? 0) > 0,
);
- const selected = Boolean(
- attested && activationValid && rawActivation?.selected === true && activeSourceId === sourceId,
+ const normallySelected = Boolean(
+ !recoveryOwnsPresentation
+ && attested
+ && activationValid
+ && rawActivation?.selected === true
+ && activeSourceId === sourceId
+ && exactCameraMediaReady,
);
+ const streamDelivery = browserDelivery(stream.delivery);
+ const previewDelivery = browserDelivery(state.camera_preview?.delivery);
+ const candidateDelivery = stream.delivery ?? state.camera_preview?.delivery;
+ const retainedDelivery = browserDelivery(candidateDelivery);
+ const deliveryConsistent = !stream.delivery || !state.camera_preview?.delivery
+ || sameBrowserDelivery(streamDelivery, previewDelivery);
+ const streamRecoveryAvailable = [
+ "available",
+ "connecting",
+ "degraded",
+ "streaming",
+ ].includes(catalogAvailability(stream.availability));
+ const browserLineageSelected = Boolean(
+ browserLineageCameraTupleExact
+ && activationValid
+ && maxActive === 1
+ && rawActivation?.selected === true
+ && activeSourceId === sourceId
+ && cameraRecoveryPhaseRetainable(state)
+ && streamRecoveryAvailable
+ && retainedDelivery
+ && deliveryConsistent
+ );
+ const recoverySelected = recoveryAuthority !== null && browserLineageSelected;
+ const recoveredBrowserSelected = Boolean(
+ recoveredBrowserAuthority
+ && browserLineageSelected
+ );
+ const selected = normallySelected || recoverySelected || recoveredBrowserSelected;
const activation = activationValid
? {
groupId: `${provider.pluginId}:${sessionScope}:${groupId}`,
maxActive: maxActive as number,
selected,
- controllable: Boolean(attested && rawActivation?.controllable),
+ controllable: Boolean(
+ !recoveryOwnsPresentation && attested && rawActivation?.controllable,
+ ),
}
: null;
- const candidateDelivery = stream.delivery ?? state.camera_preview?.delivery;
- const delivery = selected ? browserDelivery(candidateDelivery) : null;
+ const delivery = selected && (
+ dataAuthoritative || recoverySelected || recoveredBrowserSelected
+ )
+ ? retainedDelivery
+ : null;
const label = stream.label?.trim() || sourceId;
return [{
@@ -272,12 +491,23 @@ export function xgridsK1ObservationSources(
description: "Видеоканал, опубликованный активным device-плагином",
modality: "video",
role: "auxiliary",
- availability: cameraAvailability(state, stream, selected, delivery, attested),
+ availability: cameraAvailability(
+ state,
+ stream,
+ selected,
+ delivery,
+ attested,
+ recoverySelected || recoveredBrowserSelected,
+ exactCameraMediaReady,
+ ),
transport: delivery ? "websocket" : "other",
endpointLabel: safeEndpointLabel(stream.endpoint_label) ?? "Локальный video adapter",
previewUrl: null,
delivery,
activation,
+ presentationLease: (recoverySelected || recoveredBrowserSelected) && delivery
+ ? presentationLease
+ : null,
provider,
binding,
capabilities: {
diff --git a/plugins/xgrids-k1/frontend/src/operatorIntentGeneration.ts b/plugins/xgrids-k1/frontend/src/operatorIntentGeneration.ts
index d80cf19..8f1c898 100644
--- a/plugins/xgrids-k1/frontend/src/operatorIntentGeneration.ts
+++ b/plugins/xgrids-k1/frontend/src/operatorIntentGeneration.ts
@@ -6,6 +6,15 @@ export interface OperatorIntentToken extends RuntimeGenerationToken {
readonly intentGeneration: number;
}
+export function isSnapshotRuntimeCurrent(
+ expectedSnapshotRuntimeId: string,
+ currentSnapshotRuntimeId: string | null | undefined,
+): boolean {
+ const expected = expectedSnapshotRuntimeId.trim();
+ const current = currentSnapshotRuntimeId?.trim() ?? "";
+ return Boolean(expected && current && expected === current);
+}
+
/**
* Invalidates asynchronous UI work across both plugin activation changes and
* successive explicit operator intents.
diff --git a/plugins/xgrids-k1/frontend/src/physicalCommandConfirmation.ts b/plugins/xgrids-k1/frontend/src/physicalCommandConfirmation.ts
new file mode 100644
index 0000000..09ce477
--- /dev/null
+++ b/plugins/xgrids-k1/frontend/src/physicalCommandConfirmation.ts
@@ -0,0 +1,430 @@
+import type {
+ OperatorPresenceConfirmation,
+ XgridsAcquisition,
+ XgridsApplicationControlSession,
+ XgridsConnectionMode,
+ XgridsK1State,
+} from "./api";
+import { currentAppliedConnectionTopology, isSoftwareCommandedAcquisition } from "./lifecycle";
+
+export interface PhysicalConfirmationChecks {
+ operatorPresent: boolean;
+ ownerControlledDevice: boolean;
+ lixelgoClosed: boolean;
+ batteryStorageConfirmed: boolean;
+ expectedPhysicalStateConfirmed: boolean;
+}
+
+type CompletedPhysicalConfirmationChecks = {
+ [Key in keyof PhysicalConfirmationChecks]: true;
+};
+
+export type K1PhysicalConfirmationKind = "prepare" | "start" | "stop";
+
+/**
+ * Semantic state that authorises one physical command confirmation.
+ *
+ * Timestamps, the polling snapshot revision and ConnectionSupervisor.revision
+ * are deliberately absent: the latter is an observation counter and advances
+ * even when a probe confirms the same semantic route. A read-only refresh must
+ * not invalidate an operator confirmation. Every field below, however, changes
+ * the identity, route, CAS authority, acquisition or runtime state of the
+ * command and therefore closes an already-open modal.
+ */
+export interface K1PhysicalCommandFence {
+ kind: K1PhysicalConfirmationKind;
+ commandDeviceId: string;
+ commandProjectName: string;
+ acquisitionId: string;
+ runtimeId: string | null;
+ runtimePhase: string | null;
+ runtimeSourceMode: string | null;
+ selectedDeviceId: string | null;
+ deviceRefId: string | null;
+ deviceSessionId: string | null;
+ deviceSessionDeviceId: string | null;
+ deviceSessionConnectivity: string | null;
+ connectionIntentId: string | null;
+ requestedConnectionMode: XgridsConnectionMode | null;
+ expectedDeviceId: string | null;
+ deviceNetworkState: string | null;
+ deviceNetworkIntentId: string | null;
+ transportRef: string | null;
+ connectionMode: XgridsConnectionMode | null;
+ targetIpv4: string | null;
+ targetPort: number | null;
+ hostPathEpoch: number | null;
+ hostPathAvailable: boolean | null;
+ deviceIdentityState: string | null;
+ deviceIdentityId: string | null;
+ controlPlaneState: string | null;
+ controlPlaneSessionId: string | null;
+ dataPlaneState: string | null;
+ dataPlaneSessionId: string | null;
+ leaseState: string | null;
+ leaseGeneration: number | null;
+ controlAllowed: boolean | null;
+ acquisitionStartAllowed: boolean | null;
+ dataIngestAuthoritative: boolean | null;
+ controlSessionGeneration: number | null;
+ controlStateRevision: number | null;
+ controlState: string | null;
+ controlSocketOpen: boolean | null;
+ verifiedControlSessionId: string | null;
+ controlProofRevision: number | null;
+ controlProofFresh: boolean | null;
+ deviceReportedState: string | null;
+ deviceProjectBound: boolean | null;
+ deviceInitReady: boolean | null;
+ acquisitionState: string | null;
+ acquisitionStateRevision: number | null;
+ acquisitionDeviceId: string | null;
+ acquisitionDeviceSessionId: string | null;
+ acquisitionControlMode: string | null;
+}
+
+export interface K1PhysicalCommandTarget {
+ deviceId: string;
+ connection: string;
+ projectName: string;
+ acquisitionId: string;
+ deviceState: string;
+ fence: K1PhysicalCommandFence;
+}
+
+export interface K1PhysicalCommandCheckpoint {
+ readonly kind: K1PhysicalConfirmationKind;
+ readonly target: Readonly>;
+ readonly fence: Readonly;
+ readonly fenceKey: string;
+}
+
+export interface K1PhysicalCommandConfirmationPayload {
+ readonly physicalAcceptance: Readonly;
+ readonly checkpoint: K1PhysicalCommandCheckpoint;
+}
+
+export function emptyPhysicalConfirmationChecks(): PhysicalConfirmationChecks {
+ return {
+ operatorPresent: false,
+ ownerControlledDevice: false,
+ lixelgoClosed: false,
+ batteryStorageConfirmed: false,
+ expectedPhysicalStateConfirmed: false,
+ };
+}
+
+export function physicalConfirmationComplete(
+ checks: PhysicalConfirmationChecks,
+): checks is CompletedPhysicalConfirmationChecks {
+ return (
+ checks.operatorPresent
+ && checks.ownerControlledDevice
+ && checks.lixelgoClosed
+ && checks.batteryStorageConfirmed
+ && checks.expectedPhysicalStateConfirmed
+ );
+}
+
+export function operatorPresenceConfirmation(
+ checks: PhysicalConfirmationChecks,
+): OperatorPresenceConfirmation | null {
+ if (!physicalConfirmationComplete(checks)) return null;
+ return {
+ operator_present: checks.operatorPresent,
+ owner_controlled_device: checks.ownerControlledDevice,
+ lixelgo_closed: checks.lixelgoClosed,
+ battery_storage_confirmed: checks.batteryStorageConfirmed,
+ expected_physical_state_confirmed: checks.expectedPhysicalStateConfirmed,
+ };
+}
+
+/**
+ * One deliberate click on the local K1 START/STOP action is the operator's
+ * physical acceptance. The backend still validates the exact control CAS,
+ * live DeviceInfo/status binding and command ledger before a vendor write;
+ * this helper only removes the redundant five-checkbox modal.
+ */
+export function operatorActionPhysicalAcceptance(): OperatorPresenceConfirmation {
+ return {
+ operator_present: true,
+ owner_controlled_device: true,
+ lixelgo_closed: true,
+ battery_storage_confirmed: true,
+ expected_physical_state_confirmed: true,
+ };
+}
+
+function recordValue(
+ record: Record | null | undefined,
+ key: string,
+): unknown {
+ return record?.[key];
+}
+
+function trimmed(value: unknown): string | null {
+ return typeof value === "string" && value.trim() ? value.trim() : null;
+}
+
+function integer(value: unknown): number | null {
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
+ ? value
+ : null;
+}
+
+function boolean(value: unknown): boolean | null {
+ return typeof value === "boolean" ? value : null;
+}
+
+function commandFence(
+ kind: K1PhysicalConfirmationKind,
+ state: XgridsK1State | null | undefined,
+ target: Omit,
+): K1PhysicalCommandFence {
+ const supervisor = state?.connection_supervisor;
+ const deviceNetwork = supervisor?.observed.device_network;
+ const hostPath = supervisor?.observed.host_path;
+ const deviceIdentity = supervisor?.observed.device_identity;
+ const controlPlane = supervisor?.observed.control_plane;
+ const dataPlane = supervisor?.observed.data_plane;
+ const control = state?.application_control_session;
+ const verifiedControl = control?.verified_control;
+ const acquisition = state?.acquisition;
+
+ return {
+ kind,
+ commandDeviceId: target.deviceId,
+ commandProjectName: target.projectName,
+ acquisitionId: target.acquisitionId,
+ runtimeId: trimmed(state?.snapshot_runtime_id),
+ runtimePhase: trimmed(state?.phase),
+ runtimeSourceMode: trimmed(state?.source_mode),
+ selectedDeviceId: trimmed(state?.selected_device_id),
+ deviceRefId: trimmed(state?.device_ref?.device_id),
+ deviceSessionId: trimmed(state?.device_session?.device_session_id),
+ deviceSessionDeviceId: trimmed(state?.device_session?.device_id),
+ deviceSessionConnectivity: trimmed(state?.device_session?.connectivity),
+ connectionIntentId: trimmed(supervisor?.intent?.intent_id),
+ requestedConnectionMode: supervisor?.intent?.requested_mode ?? null,
+ expectedDeviceId: trimmed(supervisor?.intent?.expected_device_id),
+ deviceNetworkState: trimmed(deviceNetwork?.state),
+ deviceNetworkIntentId: trimmed(deviceNetwork?.intent_id),
+ transportRef: trimmed(deviceNetwork?.transport_ref),
+ connectionMode: deviceNetwork?.connection_mode ?? null,
+ targetIpv4: trimmed(deviceNetwork?.target?.ipv4),
+ targetPort: integer(deviceNetwork?.target?.port),
+ hostPathEpoch: integer(hostPath?.epoch),
+ hostPathAvailable: boolean(hostPath?.available),
+ deviceIdentityState: trimmed(deviceIdentity?.state),
+ deviceIdentityId: trimmed(deviceIdentity?.logical_device_id),
+ controlPlaneState: trimmed(controlPlane?.state),
+ controlPlaneSessionId: trimmed(controlPlane?.session_id),
+ dataPlaneState: trimmed(dataPlane?.state),
+ dataPlaneSessionId: trimmed(dataPlane?.session_id),
+ leaseState: trimmed(supervisor?.lease.state),
+ leaseGeneration: integer(supervisor?.lease.generation),
+ controlAllowed: boolean(supervisor?.authority.control_allowed),
+ acquisitionStartAllowed: boolean(supervisor?.authority.acquisition_start_allowed),
+ dataIngestAuthoritative: boolean(supervisor?.authority.data_ingest_authoritative),
+ controlSessionGeneration: integer(control?.session_generation),
+ controlStateRevision: integer(control?.state_revision),
+ controlState: trimmed(control?.state),
+ controlSocketOpen: boolean(control?.control_socket_open),
+ verifiedControlSessionId: trimmed(verifiedControl?.control_session_id),
+ controlProofRevision: integer(verifiedControl?.control_proof_revision),
+ controlProofFresh: boolean(verifiedControl?.control_proof_fresh),
+ deviceReportedState: trimmed(recordValue(control?.transport, "latest_device_session_state")),
+ deviceProjectBound: boolean(recordValue(control?.transport, "latest_device_project_bound")),
+ deviceInitReady: boolean(recordValue(control?.transport, "latest_device_init_ready")),
+ acquisitionState: trimmed(acquisition?.state),
+ acquisitionStateRevision: integer(acquisition?.state_revision),
+ acquisitionDeviceId: trimmed(acquisition?.device_id),
+ acquisitionDeviceSessionId: trimmed(acquisition?.device_session_id),
+ acquisitionControlMode: trimmed(acquisition?.control_mode),
+ };
+}
+
+export function physicalCommandFenceKey(
+ kind: K1PhysicalConfirmationKind,
+ target: K1PhysicalCommandTarget,
+): string {
+ // Both values are included. This makes a mismatched component kind fail
+ // closed even if a caller accidentally supplies a target built for another
+ // physical command.
+ return JSON.stringify([
+ kind,
+ target.deviceId,
+ target.connection,
+ target.projectName,
+ target.acquisitionId,
+ target.deviceState,
+ target.fence,
+ ]);
+}
+
+export function createPhysicalCommandCheckpoint(
+ kind: K1PhysicalConfirmationKind,
+ target: K1PhysicalCommandTarget,
+): K1PhysicalCommandCheckpoint {
+ const fence = Object.freeze({ ...target.fence });
+ const targetSnapshot = Object.freeze({
+ deviceId: target.deviceId,
+ connection: target.connection,
+ projectName: target.projectName,
+ acquisitionId: target.acquisitionId,
+ deviceState: target.deviceState,
+ });
+ return Object.freeze({
+ kind,
+ target: targetSnapshot,
+ fence,
+ fenceKey: physicalCommandFenceKey(kind, target),
+ });
+}
+
+export function physicalCommandCheckpointMatches(
+ checkpoint: K1PhysicalCommandCheckpoint,
+ kind: K1PhysicalConfirmationKind,
+ target: K1PhysicalCommandTarget,
+): boolean {
+ return checkpoint.kind === kind
+ && checkpoint.fence.kind === kind
+ && target.fence.kind === kind
+ && checkpoint.fenceKey === physicalCommandFenceKey(kind, target);
+}
+
+function targetWithFence(
+ kind: K1PhysicalConfirmationKind,
+ state: XgridsK1State | null | undefined,
+ target: Omit,
+): K1PhysicalCommandTarget {
+ return {
+ ...target,
+ fence: commandFence(kind, state, target),
+ };
+}
+
+function exactReadyState(
+ control: XgridsApplicationControlSession,
+): string | null {
+ const deviceState = trimmed(recordValue(control.transport, "latest_device_session_state"));
+ const projectBound = recordValue(control.transport, "latest_device_project_bound");
+ const initReady = recordValue(control.transport, "latest_device_init_ready");
+ if (deviceState !== "ready" || projectBound !== true || initReady !== false) return null;
+ return "READY · проект привязан · инициализация не запущена";
+}
+
+function exactScanningState(
+ control: XgridsApplicationControlSession | null | undefined,
+): string {
+ const deviceState = trimmed(recordValue(control?.transport, "latest_device_session_state"));
+ const projectBound = recordValue(control?.transport, "latest_device_project_bound");
+ const initReady = recordValue(control?.transport, "latest_device_init_ready");
+ if (deviceState === "scanning" && projectBound === true && initReady === true) {
+ return "SCANNING · проект привязан · инициализация завершена";
+ }
+ return deviceState
+ ? `${deviceState.toUpperCase()} · последнее подтверждённое состояние K1`
+ : "Состояние K1 не подтверждено текущим управляющим каналом";
+}
+
+function exactConnection(
+ control: XgridsApplicationControlSession | null | undefined,
+): string | null {
+ const verified = control?.verified_control;
+ if (!verified) return null;
+ return `${verified.connection_mode} · ${verified.target_ipv4}:${verified.target_port}`;
+}
+
+function acquisitionProject(acquisition: XgridsAcquisition): string {
+ return trimmed(acquisition.project_name) ?? "Проект без опубликованного имени";
+}
+
+export function preparedStartTarget(
+ state: XgridsK1State | null | undefined,
+): K1PhysicalCommandTarget | null {
+ const acquisition = state?.acquisition;
+ const control = state?.application_control_session;
+ const verified = control?.verified_control;
+ const topology = currentAppliedConnectionTopology(state);
+ const supervisor = state?.connection_supervisor;
+ const deviceNetwork = supervisor?.observed.device_network;
+ const hostPath = supervisor?.observed.host_path;
+ const readyState = control ? exactReadyState(control) : null;
+ if (
+ !acquisition
+ || acquisition.state !== "prepared"
+ || acquisition.control_mode !== "plugin-commanded"
+ || !control
+ || control.state !== "project-ready"
+ || control.can_start !== true
+ || !verified
+ || verified.control_proof_fresh !== true
+ || verified.logical_device_id !== acquisition.device_id
+ || verified.compatibility_profile_id !== acquisition.compatibility_profile_id
+ || supervisor?.authority.acquisition_start_allowed !== true
+ || verified.intent_id !== supervisor.intent?.intent_id
+ || verified.host_path_epoch !== hostPath?.epoch
+ || verified.transport_ref !== deviceNetwork?.transport_ref
+ || verified.connection_mode !== deviceNetwork?.connection_mode
+ || verified.target_ipv4 !== deviceNetwork?.target?.ipv4
+ || verified.target_port !== deviceNetwork?.target?.port
+ || topology?.status !== "active"
+ || topology.connectionMode !== verified.connection_mode
+ || topology.endpoint !== verified.target_ipv4
+ || state?.connection_lifecycle?.ready_to_start !== true
+ || !readyState
+ ) {
+ return null;
+ }
+ return targetWithFence("start", state, {
+ deviceId: verified.logical_device_id,
+ connection: `${verified.connection_mode} · ${verified.target_ipv4}:${verified.target_port}`,
+ projectName: acquisitionProject(acquisition),
+ acquisitionId: acquisition.acquisition_id,
+ deviceState: readyState,
+ });
+}
+
+export function preparationTarget(
+ state: XgridsK1State | null | undefined,
+ projectName: string,
+): K1PhysicalCommandTarget | null {
+ const topology = currentAppliedConnectionTopology(state);
+ const supervisor = state?.connection_supervisor;
+ const deviceNetwork = supervisor?.observed.device_network;
+ if (
+ !topology
+ || !supervisor
+ || topology.status === "configured-offline"
+ || !deviceNetwork?.transport_ref
+ || !deviceNetwork.target
+ ) {
+ return null;
+ }
+ const logicalDeviceId = supervisor.observed.device_identity.logical_device_id
+ ?? supervisor.intent?.expected_device_id
+ ?? deviceNetwork.transport_ref;
+ return targetWithFence("prepare", state, {
+ deviceId: logicalDeviceId,
+ connection: `${topology.connectionMode} · ${deviceNetwork.target.ipv4}:${deviceNetwork.target.port}`,
+ projectName: projectName.trim(),
+ acquisitionId: "Будет создана подготовительным этапом; START пока недоступен",
+ deviceState: "Подготовка не начата · физический START не разрешён",
+ });
+}
+
+export function activeStopTarget(
+ state: XgridsK1State | null | undefined,
+): K1PhysicalCommandTarget | null {
+ const acquisition = state?.acquisition;
+ if (!acquisition || !isSoftwareCommandedAcquisition(state)) return null;
+ const control = state?.application_control_session;
+ return targetWithFence("stop", state, {
+ deviceId: acquisition.device_id,
+ connection: exactConnection(control) ?? "Текущий управляющий канал не подтверждён",
+ projectName: acquisitionProject(acquisition),
+ acquisitionId: acquisition.acquisition_id,
+ deviceState: exactScanningState(control),
+ });
+}
diff --git a/plugins/xgrids-k1/frontend/src/presentation.ts b/plugins/xgrids-k1/frontend/src/presentation.ts
index 7611d2f..6b3c559 100644
--- a/plugins/xgrids-k1/frontend/src/presentation.ts
+++ b/plugins/xgrids-k1/frontend/src/presentation.ts
@@ -1,7 +1,245 @@
import type { StatusTone } from "@nodedc/ui-react";
import type { BackendStatus } from "@mission-core/plugin-sdk";
-import type { XgridsK1Metrics } from "./api";
+import type {
+ XgridsConnectionPolicyAction,
+ XgridsK1Metrics,
+ XgridsK1State,
+} from "./api";
+import {
+ connectionPolicyAllows,
+ connectionPolicyDecision,
+} from "./lifecycle";
+
+const connectionPolicyReasonCopy: Record = {
+ "connection-supervisor-closed": "Контур связи K1 закрыт.",
+ "supervisor-action-not-allowed": "Текущая связь с K1 не подтверждает право на эту физическую команду.",
+ "network-provision-operation-active": "Предыдущая сетевая операция K1 ещё не завершена.",
+ "acquisition-active": "Сетевой режим K1 нельзя менять во время активного приёма.",
+ "acquisition-cleanup-pending": "Локальный приём K1 ещё завершает очистку ресурсов.",
+ "local-runtime-active": "Локальный исполнительный контур K1 ещё активен.",
+ "control-session-not-admissible-for-network-change": "Текущая управляющая сессия K1 ещё не допускает смену сети.",
+ "network-mutation-reconciliation-required": "Результат предыдущей сетевой записи K1 не подтверждён.",
+ "network-mutation-ledger-corrupt": "Журнал сетевых изменений K1 повреждён.",
+ "fresh-ble-candidate-required": "Для этого действия нужен K1 из нового Bluetooth-поиска.",
+ "retained-recovery-context-unavailable": "Сохранённый Bluetooth-контекст текущего K1 больше недоступен.",
+ "fresh-candidate-supersedes-retained-recovery": "K1 снова виден в свежем поиске; используйте новый найденный экземпляр.",
+ "reconciliation-target-not-observed": "В свежем Bluetooth-поиске не найден K1, связанный с незавершённой записью.",
+ "reconciliation-target-not-retained": "Текущий серверный Bluetooth-контекст относится не к тому K1, для которого не завершена сетевая операция.",
+ "current-device-context-unavailable": "Текущий K1 не подтверждён в оперативном контексте этого процесса.",
+ "durable-recovery-target-unavailable": "В сохранённом серверном состоянии нет одной точной пары K1 и режима для проверки после перезапуска.",
+ "fresh-candidate-supersedes-durable-recovery": "Нужный K1 снова найден свежим Bluetooth-поиском; сервер требует проверить именно свежий экземпляр.",
+ "retained-recovery-supersedes-durable-recovery": "Сервер хранит более свежий контекст текущего K1.",
+ "configured-endpoint-unavailable": "Нет подтверждённого сохранённого адреса K1 для безопасной проверки.",
+ "configured-endpoint-probe-lifecycle-busy": "Контур подключения K1 занят другой операцией.",
+ "configured-endpoint-changed-during-probe": "Адрес K1 изменился во время проверки; результат отброшен.",
+ "configured-endpoint-probe-failed": "Маршрут и управляющий endpoint K1 не удалось проверить.",
+ "host-path-unavailable": "На этом компьютере не подтверждён сетевой путь до K1.",
+ "host-route-not-direct": "Маршрут до K1 проходит не через ожидаемую локальную сеть.",
+ "endpoint-not-reachable": "Управляющий endpoint K1 сейчас недоступен.",
+ "device-identity-unverified": "Идентичность подключённого K1 ещё не подтверждена.",
+ "device-identity-stale": "Подтверждение идентичности K1 устарело.",
+ "device-identity-mismatch": "Подключённое устройство не совпало с выбранным K1.",
+ "device-identity-pin-store-corrupt": "Хранилище привязки устройства повреждено.",
+ "network-provisioning-idempotency-unavailable": "Журнал сетевых намерений K1 недоступен.",
+ "network-provisioning-idempotency-corrupt": "Журнал сетевых намерений K1 повреждён.",
+ "network-provisioning-idempotency-invalid": "Журнал сетевых намерений K1 не прошёл проверку.",
+ "network-provisioning-idempotency-operation-mismatch": "Текущая сетевая операция не совпала с сохранённым намерением.",
+ "semantic-topology-store-corrupt": "Сохранённая топология K1 повреждена.",
+ "control-plane-not-healthy": "Управляющий канал K1 не подтверждён.",
+ "connection-lease-not-reachable": "Текущая сессия связи K1 больше не подтверждена.",
+ "data-plane-stalled": "Поток данных K1 перестал обновляться.",
+ "data-plane-lost": "Поток данных K1 потерян.",
+ "physical-command-reconciliation-required": "Результат предыдущей физической команды K1 не подтверждён.",
+ "physical-device-already-active": "Последнее подтверждённое состояние K1 — активное сканирование.",
+ "physical-command-recovery-target-unavailable": "Журнал не содержит точную Bluetooth-цель для восстановления K1.",
+ "physical-command-recovery-target-not-observed": "Исходный K1 пока не найден в свежем Bluetooth-поиске.",
+ "physical-command-recovery-target-not-retained": "Сохранённый Bluetooth-контекст относится не к исходному K1.",
+ "physical-command-recovery-target-mismatch": "Выбрано другое устройство или другой способ связи, чем в незавершённой физической сессии.",
+ "physical-command-ledger-corrupt": "Журнал физических команд K1 повреждён.",
+ "physical-command-ledger-unavailable": "Журнал физических команд K1 недоступен.",
+ "physical-control-authority-unavailable": "Управляющая связь не позволяет безопасно отправить физический STOP.",
+ "ble-runtime-restart-required": "BLE-контур требует контролируемого перезапуска.",
+ "ble-runtime-cleanup-pending": "BLE-контур завершает предыдущую операцию.",
+ "ble-runtime-busy": "BLE-контур занят другой операцией.",
+ "k1-lifecycle-process-lease-network-owned": "Сетевой переход K1 ещё владеет исполнительным контуром.",
+ "k1-lifecycle-process-lease-control-owned": "Управляющая сессия K1 ещё владеет исполнительным контуром.",
+ "local-acquisition-receiver-not-active": "Активного локального приёмника сейчас нет.",
+ "physical-stop-is-authoritative": "Доступна подтверждённая физическая остановка K1; локальная очистка не должна её подменять.",
+ "action-not-implemented": "Это действие не реализовано и не может быть выполнено.",
+ "connection-reconfiguration-lifecycle-busy": "Другое действие подключения ещё не завершено.",
+ "connection-reconfiguration-bridge-only": "Это действие доступно только для подключения Bridge.",
+ "connection-reconfiguration-current-device-unavailable": "Нет точной привязки текущего устройства для изменения сети.",
+ "connection-reconfiguration-active": "Сначала завершите или отмените текущий выбор устройства или сети.",
+ "connection-reconfiguration-required-device-not-observed": "Исходное устройство не найдено в текущем Bluetooth-поиске.",
+ "connection-reconfiguration-not-active": "Активного изменения устройства или сети уже нет.",
+ "connection-reconfiguration-process-lease-busy": "Другой локальный процесс ещё управляет подключением устройства.",
+ "connection-reconfiguration-acquisition-changed": "Состояние приёма изменилось во время подготовки подключения.",
+ "connection-reconfiguration-revision-conflict": "Выбор устройства или сети уже изменился в другой вкладке.",
+ "connection-reconfiguration-binding-conflict": "Активное подключение изменилось до выполнения действия.",
+ "connection-reconfiguration-fresh-scan-required": "Для этого действия нужен новый Bluetooth-поиск.",
+ "connection-reconfiguration-discovery-conflict": "Результаты Bluetooth-поиска относятся к предыдущему действию.",
+ "connection-reconfiguration-target-mismatch": "Изменение сети разрешено только для исходного устройства.",
+ "acquisition-start-operation-active": "Запуск приёма ещё не завершён.",
+ "control-session-state-unsafe": "Управляющий диалог ещё не достиг безопасного состояния ожидания.",
+};
+
+const connectionPolicyNextActionCopy: Record = {
+ "wait-for-operation": "Дождитесь завершения текущей операции и обновите состояние.",
+ "diagnose-network-ledger": "Не отправляйте новые команды и проверьте журнал сетевой операции.",
+ "diagnose-physical-command-ledger": "Не повторяйте команду; сначала проверьте журнал физических команд.",
+ "restart-ble-runtime": "Контролируемо перезапустите локальный BLE-контур и обновите состояние.",
+ "scan-ble": "Выполните свежий поиск Bluetooth-устройств.",
+ "observe-fresh-device-network": "Дождитесь автоматического восстановления связи с выбранным K1.",
+ "observe-current-device-network": "Дождитесь автоматического восстановления связи с тем же K1.",
+ "observe-configured-device-network": "Дождитесь автоматического восстановления сохранённого подключения K1.",
+ "recover-current-device-network": "Нажмите «Подключиться заново».",
+ "inspect-host-network": "Проверьте активную локальную сеть и маршрут этого компьютера.",
+ "probe-endpoint": "Дождитесь обновления подключения K1.",
+ "verify-control-device-info": "Подключитесь заново к выбранному K1.",
+ "select-connection-intent": "Выберите способ подключения и заново подтвердите текущий K1.",
+ "start-acquisition": "Повторно откройте финальное подтверждение физического START.",
+ "stop-acquisition": "Остановите K1 через подтверждённую физическую остановку.",
+ "stop-local-receiver": "Завершите локальный приём; физическое состояние K1 проверьте вручную.",
+ "retire-unavailable-physical-target": "Явно исключите недоступный прежний K1 перед новым выбором.",
+ "manual-recovery-required": "Автоматически безопасного продолжения нет; проверьте состояние K1 вручную.",
+ "cancel-reconfiguration": "Отмените текущий выбор и вернитесь к обычному восстановлению подключения.",
+};
+
+export interface ConnectionPolicyOperatorGuidance {
+ reason: string;
+ nextAction: string;
+}
+
+const connectionModeSelectionReasonCopy: Record = {
+ "connection-mode-selection-physical-state-unsafe":
+ "Предыдущая физическая команда K1 осталась без подтверждённого результата. Поэтому способ подключения пока нельзя изменить.",
+ "connection-mode-selection-control-state-unsafe":
+ "Текущий управляющий процесс K1 ещё не завершён. После его завершения способ подключения снова станет доступен.",
+ "connection-mode-selection-lifecycle-busy":
+ "Текущее действие подключения ещё завершается. После него способ подключения снова станет доступен.",
+ "connection-reconfiguration-active":
+ "Сначала завершите или отмените текущий выбор устройства или сети.",
+};
+
+const physicalRetirementReasonCopy: Record = {
+ "physical-command-retirement-operation-conflict":
+ "Сейчас завершается другая операция с физическим состоянием устройства. Дождитесь её завершения и обновите состояние.",
+ "physical-command-retirement-state-unsafe":
+ "Состояние предыдущей физической команды изменилось. Обновите состояние перед новым выбором устройства.",
+ "physical-command-retirement-not-required":
+ "Предыдущая физическая команда уже разрешена или больше не удерживает выбор устройства. Обновите состояние и продолжите обычное подключение.",
+ "physical-command-target-retired":
+ "Предыдущее устройство уже выведено из текущего контура. Можно сразу выполнить новый явный Bluetooth-поиск.",
+ "network-provision-operation-active":
+ "Сейчас завершается подключение устройства к сети. Дождитесь результата перед выбором другого устройства.",
+ "connection-reconfiguration-active":
+ "Сначала завершите или отмените текущее изменение устройства или сети.",
+ "control-local-retirement-pending":
+ "Управляющая сессия ещё освобождает локальные ресурсы. Дождитесь завершения и обновите состояние.",
+ "acquisition-active":
+ "Сканирование ещё активно. Сначала остановите его и дождитесь подтверждённого завершения.",
+ "acquisition-cleanup-pending":
+ "Локальный приём ещё освобождает ресурсы после остановки. Дождитесь завершения.",
+ "acquisition-start-operation-active":
+ "Запуск сканирования ещё не завершён. Дождитесь его результата перед сменой устройства.",
+ "acquisition-stop-operation-active":
+ "Остановка сканирования ещё не завершена. Дождитесь её результата перед сменой устройства.",
+ "local-runtime-active":
+ "Локальный поток устройства ещё активен или завершается. Дождитесь перехода в состояние ожидания.",
+ "control-session-state-unsafe":
+ "Управляющая сессия устройства ещё не завершена. Дождитесь её закрытия и обновите состояние.",
+ "ble-runtime-busy":
+ "Bluetooth занят другой операцией устройства. Дождитесь её завершения; новый поиск автоматически не запустится.",
+ "ble-runtime-cleanup-pending":
+ "Bluetooth ещё завершает предыдущую операцию. Дождитесь освобождения соединения и обновите состояние.",
+ "ble-runtime-restart-required":
+ "Локальный Bluetooth-контур требует контролируемого перезапуска. Команды устройству не отправлялись.",
+ "k1-lifecycle-process-lease-active":
+ "Другой локальный процесс ещё завершает действие с устройством. Дождитесь его завершения и обновите состояние.",
+ "physical-command-ledger-corrupt":
+ "Журнал физической команды повреждён. Не выбирайте другое устройство до проверки журнала.",
+};
+
+const physicalReopenReasonCopy: Record = {
+ ...physicalRetirementReasonCopy,
+ "physical-command-reconciliation-reopen-not-required":
+ "Это устройство больше не требует возврата из предыдущего выбора. Обновите состояние и продолжите обычное подключение.",
+ "physical-command-reconciliation-reopen-target-not-observed":
+ "Предыдущее устройство не найдено в последнем Bluetooth-поиске. Обновите поиск после проверки питания K1.",
+ "physical-command-reconciliation-reopen-target-not-connectable":
+ "Предыдущее устройство найдено, но сейчас не принимает Bluetooth-подключение. Проверьте питание K1 и повторите явный поиск.",
+ "physical-command-reconciliation-reopen-candidate-ambiguous":
+ "Последний Bluetooth-поиск не подтвердил один точный экземпляр предыдущего K1. Повторите поиск рядом только с нужным устройством.",
+ "physical-command-reconciliation-reopen-operation-conflict":
+ "Другая операция уже меняет локальное состояние предыдущего устройства. Дождитесь её завершения и обновите поиск.",
+ "device-calibration-read-active":
+ "Сейчас читается калибровка устройства. Дождитесь завершения проверки перед повторным использованием K1.",
+};
+
+/** Human explanation for a backend-disabled topology selector. */
+export function connectionModeSelectionGuidance(
+ state: XgridsK1State | null | undefined,
+): string | null {
+ const selection = state?.connection_lifecycle?.mode_selection;
+ if (!selection) return null;
+ if (selection?.allowed === true) return null;
+ const reasonCode = selection?.reason_codes.find((code) => code.trim().length > 0);
+ return reasonCode
+ ? connectionModeSelectionReasonCopy[reasonCode]
+ ?? "Способ подключения пока недоступен, потому что состояние K1 не позволяет безопасно изменить его."
+ : "Способ подключения пока недоступен, потому что состояние K1 не позволяет безопасно изменить его."
+}
+
+/** Human explanation for a currently unavailable local-only device escape. */
+export function physicalRetirementGuidance(
+ state: XgridsK1State | null | undefined,
+): string | null {
+ const retirement = (
+ state?.physical_command
+ ?? state?.application_control_session?.physical_command
+ ?? null
+ )?.operator_retirement;
+ if (!retirement || retirement.allowed === true) return null;
+ const reasonCode = retirement.reason_codes.find((code) => code.trim().length > 0);
+ return reasonCode
+ ? physicalRetirementReasonCopy[reasonCode]
+ ?? "Выбор другого устройства пока небезопасен. Обновите состояние после завершения текущей операции."
+ : "Выбор другого устройства пока небезопасен. Обновите состояние после завершения текущей операции.";
+}
+
+/** Human explanation for a backend-disabled exact retired-device reopen. */
+export function physicalReopenGuidance(
+ state: XgridsK1State | null | undefined,
+): string | null {
+ const reopen = state?.physical_command?.operator_reconciliation_reopen;
+ if (!reopen || reopen.allowed === true) return null;
+ const reasonCode = reopen.reason_codes.find((code) => code.trim().length > 0);
+ return reasonCode
+ ? physicalReopenReasonCopy[reasonCode]
+ ?? "Повторная проверка предыдущего K1 сейчас небезопасна. Дождитесь завершения текущей операции и обновите поиск."
+ : "Повторная проверка предыдущего K1 сейчас небезопасна. Обновите поиск после завершения текущей операции.";
+}
+
+export function connectionPolicyOperatorGuidance(
+ state: XgridsK1State | null | undefined,
+ action: XgridsConnectionPolicyAction,
+): ConnectionPolicyOperatorGuidance | null {
+ if (connectionPolicyAllows(state, action)) return null;
+ const decision = connectionPolicyDecision(state, action);
+ const reasonCode = decision?.reason_codes.find((code) => code.trim().length > 0);
+ const recommendedAction = state?.connection_policy?.recommended_action?.trim();
+ return {
+ reason: reasonCode
+ ? connectionPolicyReasonCopy[reasonCode]
+ ?? "Система временно запретила действие до восстановления подтверждённого состояния."
+ : "Подтверждённая политика действия ещё не получена.",
+ nextAction: recommendedAction
+ ? connectionPolicyNextActionCopy[recommendedAction]
+ ?? "Обновите состояние подключения и следуйте рекомендованному безопасному действию."
+ : "Обновите состояние подключения перед новым действием.",
+ };
+}
const phaseLabels: Record = {
idle: "Ожидание",
diff --git a/plugins/xgrids-k1/frontend/src/projectName.ts b/plugins/xgrids-k1/frontend/src/projectName.ts
index e1b3f16..9e850fd 100644
--- a/plugins/xgrids-k1/frontend/src/projectName.ts
+++ b/plugins/xgrids-k1/frontend/src/projectName.ts
@@ -30,3 +30,24 @@ export function validateProjectName(input: string): ProjectNameValidation {
}
return { value, error: null };
}
+
+export function projectNameAfterConnectionModeSelection(
+ preparedProjectName: string | null | undefined,
+): string {
+ return preparedProjectName ?? "";
+}
+
+export function shouldHydratePreparedProject({
+ acquisitionId,
+ hydratedAcquisitionId,
+ modeSwitchRequired,
+}: {
+ acquisitionId: string | null;
+ hydratedAcquisitionId: string | null;
+ modeSwitchRequired: boolean;
+}): boolean {
+ return Boolean(
+ acquisitionId
+ && !(hydratedAcquisitionId === acquisitionId && modeSwitchRequired),
+ );
+}
diff --git a/plugins/xgrids-k1/frontend/src/runtimeContext.tsx b/plugins/xgrids-k1/frontend/src/runtimeContext.tsx
index 9c3322f..282ec26 100644
--- a/plugins/xgrids-k1/frontend/src/runtimeContext.tsx
+++ b/plugins/xgrids-k1/frontend/src/runtimeContext.tsx
@@ -8,11 +8,19 @@ import {
type MissionRuntimeState,
} from "@mission-core/plugin-sdk";
import {
+ activeConnectionEndpointLabel,
+ canonicalDeviceConnectivity,
confirmedRuntimeSourceMode,
effectiveAcquisition,
+ hasAuthoritativeData,
+ hasControlAuthority,
normalizeRuntimePhase,
spatialSourceId,
} from "./lifecycle";
+import {
+ activeStreamRecoveryOwnsPresentationDecision,
+ activeStreamRecoveryPresentationAuthority,
+} from "./activeStreamRecovery";
import { localizeRuntimeMessage } from "./messages";
import { xgridsK1Manifest } from "./manifest";
import { deviceTelemetry, finiteMetric, pipelineLatency } from "./presentation";
@@ -23,13 +31,21 @@ export type XgridsK1Controller = ReturnType;
const XgridsK1RuntimeContext = createContext(null);
-function normalizeState(
- controller: XgridsK1Controller,
+export function normalizeXgridsK1MissionState(
+ controller: Pick,
activeModel: DeviceModelDefinition,
): MissionRuntimeState | null {
const state = controller.state;
if (!state) return null;
- const metrics = state.metrics;
+ const controlAuthoritative = hasControlAuthority(state);
+ const recoveryPresentationAuthority = activeStreamRecoveryPresentationAuthority(state);
+ const recoveryOwnsPresentation = activeStreamRecoveryOwnsPresentationDecision(state);
+ const dataAuthoritative = hasAuthoritativeData(state) && !recoveryOwnsPresentation;
+ const recoveryPresentationAuthoritative = recoveryPresentationAuthority !== null;
+ const replayAuthoritative = state.source_mode === "replay";
+ const metrics = replayAuthoritative || dataAuthoritative
+ ? state.metrics
+ : undefined;
const telemetry = deviceTelemetry(metrics);
const deviceRef = state.device_ref;
const deviceSession = state.device_session;
@@ -43,13 +59,13 @@ function normalizeState(
return {
phase: normalizeRuntimePhase(state),
message: localizeRuntimeMessage(state.message),
- activeDevice: deviceRef
+ activeDevice: deviceRef && controlAuthoritative
? {
pluginId: xgridsK1Manifest.metadata.id,
modelId: deviceRef.model_id || activeModel.id,
displayName: activeModel.displayName,
instanceId: deviceRef.device_id,
- endpointLabel: state.k1_ip,
+ endpointLabel: activeConnectionEndpointLabel(state),
}
: null,
deviceSession: deviceSession
@@ -57,7 +73,7 @@ function normalizeState(
sessionId: deviceSession.device_session_id,
deviceId: deviceSession.device_id,
compatibilityProfileId: deviceSession.compatibility_profile_id,
- connectivity: deviceSession.connectivity,
+ connectivity: canonicalDeviceConnectivity(state),
}
: null,
acquisition: acquisition
@@ -80,7 +96,9 @@ function normalizeState(
stageCode: operation.stage_code,
messageCode: operation.message_code,
})),
- spatialSource: sourceUrl && resolvedSpatialSourceId
+ spatialSource: sourceUrl
+ && resolvedSpatialSourceId
+ && (replayAuthoritative || dataAuthoritative || recoveryPresentationAuthoritative)
? {
id: resolvedSpatialSourceId,
url: sourceUrl,
@@ -102,7 +120,9 @@ function normalizeState(
range: null,
},
viewerSettings: state.viewer_settings,
- sourceMode: confirmedRuntimeSourceMode(state),
+ sourceMode: recoveryPresentationAuthoritative
+ ? "live"
+ : confirmedRuntimeSourceMode(state),
metrics: {
publishedFrameCount: (
Number.isSafeInteger(metrics?.pcl_frames) &&
@@ -140,10 +160,13 @@ export function XgridsK1RuntimeProvider({
const inheritedRuntime = useMissionRuntime();
const controller = useXgridsK1Runtime(active);
const missionRuntime: MissionRuntimeController = {
- state: activeModel ? normalizeState(controller, activeModel) : null,
+ state: activeModel
+ ? normalizeXgridsK1MissionState(controller, activeModel)
+ : null,
backendStatus: controller.backendStatus,
pendingAction: controller.pendingAction,
- refresh: controller.refresh,
+ refresh: () => controller.refresh().then(() => undefined),
+ resetConnectionScenario: controller.resetConnectionScenario,
updateViewerSettings: controller.updateViewerSettings,
setObservationSourceActive: controller.setObservationSourceActive,
};
diff --git a/plugins/xgrids-k1/frontend/src/stateOrdering.ts b/plugins/xgrids-k1/frontend/src/stateOrdering.ts
index 11c88d3..0d80178 100644
--- a/plugins/xgrids-k1/frontend/src/stateOrdering.ts
+++ b/plugins/xgrids-k1/frontend/src/stateOrdering.ts
@@ -10,6 +10,66 @@ function deviceSessionScope(state: XgridsK1State): string | null {
return typeof sessionId === "string" && sessionId.trim() ? sessionId : null;
}
+interface RuntimeSnapshotStamp {
+ startedAtMonotonicNs: bigint | null;
+ startedAtEpochMs: number | null;
+ runtimeId: string;
+ revision: number;
+}
+
+function monotonicNanoseconds(value: string | null | undefined): bigint | null {
+ if (typeof value !== "string" || !/^(0|[1-9][0-9]*)$/.test(value)) return null;
+ try {
+ return BigInt(value);
+ } catch {
+ return null;
+ }
+}
+
+function runtimeSnapshotStamp(state: XgridsK1State): RuntimeSnapshotStamp | null {
+ const startedAt = state.snapshot_runtime_started_at_utc;
+ const startedAtMonotonicNs = monotonicNanoseconds(
+ state.snapshot_runtime_started_monotonic_ns,
+ );
+ const runtimeId = state.snapshot_runtime_id;
+ const revision = monotonicInteger(state.snapshot_revision);
+ if (
+ typeof runtimeId !== "string"
+ || !runtimeId.trim()
+ || revision === null
+ ) {
+ return null;
+ }
+ const parsedEpochMs = typeof startedAt === "string" ? Date.parse(startedAt) : Number.NaN;
+ const startedAtEpochMs = Number.isFinite(parsedEpochMs) ? parsedEpochMs : null;
+ if (startedAtMonotonicNs === null && startedAtEpochMs === null) return null;
+ return { startedAtMonotonicNs, startedAtEpochMs, runtimeId, revision };
+}
+
+function stampedSnapshotIsAtLeastAsNew(
+ current: RuntimeSnapshotStamp,
+ incoming: RuntimeSnapshotStamp,
+): boolean {
+ if (incoming.runtimeId === current.runtimeId) {
+ return incoming.revision >= current.revision;
+ }
+ if (incoming.startedAtMonotonicNs !== null || current.startedAtMonotonicNs !== null) {
+ if (incoming.startedAtMonotonicNs === null) return false;
+ if (current.startedAtMonotonicNs === null) return true;
+ return incoming.startedAtMonotonicNs > current.startedAtMonotonicNs;
+ }
+ if (
+ incoming.startedAtEpochMs !== null
+ && current.startedAtEpochMs !== null
+ && incoming.startedAtEpochMs !== current.startedAtEpochMs
+ ) {
+ return incoming.startedAtEpochMs > current.startedAtEpochMs;
+ }
+ // Legacy UTC-only process identities with equal timestamps cannot be
+ // ordered safely. Keep the already accepted authority.
+ return false;
+}
+
function cameraSnapshotIsAtLeastAsNew(
current: XgridsCameraPreviewState,
incoming: XgridsCameraPreviewState,
@@ -43,6 +103,17 @@ export function selectMonotonicXgridsState(
current: XgridsK1State | null,
incoming: XgridsK1State,
): XgridsK1State {
+ if (!current) return incoming;
+ const currentStamp = runtimeSnapshotStamp(current);
+ const incomingStamp = runtimeSnapshotStamp(incoming);
+ if (currentStamp || incomingStamp) {
+ if (!currentStamp) return incoming;
+ if (!incomingStamp) return current;
+ return stampedSnapshotIsAtLeastAsNew(currentStamp, incomingStamp)
+ ? incoming
+ : current;
+ }
+
if (current && deviceSessionScope(current) !== deviceSessionScope(incoming)) {
return incoming;
}
diff --git a/plugins/xgrids-k1/frontend/src/styles.css b/plugins/xgrids-k1/frontend/src/styles.css
index b23944d..ca037df 100644
--- a/plugins/xgrids-k1/frontend/src/styles.css
+++ b/plugins/xgrids-k1/frontend/src/styles.css
@@ -1,9 +1,27 @@
/* All selectors below are scoped to the XGRIDS frontend contribution. */
.xgrids-k1-plugin {
+container: xgrids-k1 / inline-size;
+width: 100%;
+min-width: 0;
+max-width: 100%;
+box-sizing: border-box;
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+> * {
+ min-width: 0;
+ max-width: 100%;
+}
+
.device-workspace__grid {
display: grid;
min-width: 0;
- grid-template-columns: minmax(23rem, 0.78fr) minmax(34rem, 1.22fr);
+ max-width: 100%;
+ grid-template-columns: minmax(0, 1fr);
align-items: start;
gap: 0.85rem;
}
@@ -11,6 +29,7 @@
.device-workspace__side {
display: grid;
min-width: 0;
+ max-width: 100%;
gap: 0.85rem;
}
@@ -18,9 +37,114 @@
.status-panel,
.latency-panel,
.session-panel {
+ min-width: 0;
+ max-width: 100%;
background: var(--station-panel);
}
+/* Every layout hop between the plugin root and the canonical controls must be
+ shrinkable. A single auto min-size in this chain lets topology/status text
+ establish a wider intrinsic track and paint the provisioning job over the
+ acquisition job even though the outer grid itself uses minmax(0, 1fr). */
+.workspace-lead,
+.metrics-grid,
+.error-banner,
+.wizard-list,
+.wizard-step,
+.field-stack,
+.session-form,
+.scan-configuration-grid,
+.device-list,
+.device-row,
+.diagnostics-grid,
+.detail-list {
+ min-width: 0;
+ max-width: 100%;
+}
+
+.metrics-grid > *,
+.device-workspace__grid > *,
+.device-workspace__side > *,
+.scan-configuration-grid > *,
+.diagnostics-grid > * {
+ min-width: 0;
+ max-width: 100%;
+}
+
+.error-banner > div {
+ min-width: 0;
+}
+
+.error-banner__copy {
+ display: grid;
+ gap: 0.2rem;
+}
+
+.error-banner--compact {
+ align-items: start;
+ padding-block: 0.7rem;
+}
+
+.error-banner__recovery-actions {
+ display: grid;
+ gap: 0.45rem;
+ margin-top: 0.55rem;
+}
+
+.error-banner__details {
+ margin-top: 0.35rem;
+ color: var(--nodedc-text-secondary);
+ font-size: 0.66rem;
+}
+
+.error-banner__details summary {
+ width: fit-content;
+ color: var(--nodedc-text-tertiary);
+ cursor: pointer;
+}
+
+.workspace-lead__status,
+.workspace-lead__status > span,
+.panel-heading > div,
+.panel-heading h2,
+.wizard-step__content,
+.wizard-step__content > header,
+.wizard-step__content > header h3,
+.connection-summary span,
+.nodedc-field__description,
+.empty-device-list,
+.retained-recovery-target small,
+.session-footer p {
+ min-width: 0;
+}
+
+.workspace-lead__status > span,
+.panel-heading h2,
+.wizard-step__content > header h3,
+.connection-summary span,
+.nodedc-field__description,
+.empty-device-list,
+.retained-recovery-target small,
+.session-footer p {
+ overflow-wrap: anywhere;
+}
+
+.workspace-lead p,
+.error-banner p,
+.step-copy,
+.safety-note,
+.live-instruction {
+ overflow-wrap: anywhere;
+}
+
+.connection-panel {
+ container: k1-connection-panel / inline-size;
+}
+
+.session-panel {
+ container: k1-session-panel / inline-size;
+}
+
.error-banner {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
@@ -58,12 +182,54 @@
line-height: 1.45;
}
+.error-banner__diagnostic {
+ display: grid;
+ min-width: 0;
+ max-width: 100%;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.65rem;
+ margin: 0.45rem 0 0;
+}
+
+.error-banner__diagnostic > div {
+ min-width: 0;
+}
+
+.error-banner__diagnostic dt,
+.error-banner__diagnostic dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+
+.error-banner__diagnostic dt {
+ color: var(--nodedc-text-tertiary);
+ font-size: 0.58rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.error-banner__diagnostic dd {
+ margin-top: 0.12rem;
+ color: var(--nodedc-text-secondary);
+ font-size: 0.66rem;
+ line-height: 1.4;
+}
+
.error-banner__actions {
display: flex;
+ min-width: 0;
+ max-width: 100%;
align-items: center;
+ flex-wrap: wrap;
gap: 0.35rem;
}
+.error-banner__actions > .nodedc-button {
+ min-width: 0;
+ max-width: 100%;
+ overflow-wrap: anywhere;
+}
+
.wizard-list {
display: grid;
margin-top: 1.4rem;
@@ -71,6 +237,8 @@
.configuration-anchor {
display: grid;
+ min-width: 0;
+ max-width: 100%;
gap: 0.55rem;
margin-top: 1.2rem;
border-radius: 0.95rem;
@@ -81,6 +249,35 @@
.configuration-anchor .nodedc-select-anchor,
.configuration-field .nodedc-select-anchor {
width: 100%;
+ min-width: 0;
+ max-width: 100%;
+}
+
+.connection-topology-summary {
+ display: grid;
+ min-width: 0;
+ max-width: 100%;
+ gap: 0.42rem;
+}
+
+.connection-topology-summary .connection-summary {
+ min-width: 0;
+ max-width: 100%;
+ margin: 0;
+}
+
+.connection-summary__value {
+ display: flex;
+ min-width: 0;
+ max-width: 100%;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.5rem;
+}
+
+.connection-summary__value strong {
+ min-width: 0;
+ max-width: 100%;
}
.wizard-step {
@@ -219,19 +416,26 @@
}
.device-row__identity strong {
- overflow: hidden;
+ min-width: 0;
+ max-width: 100%;
+ overflow: visible;
+ overflow-wrap: anywhere;
font-size: 0.69rem;
- text-overflow: ellipsis;
- white-space: nowrap;
+ text-overflow: clip;
+ white-space: normal;
}
.device-row code,
.detail-row code {
- overflow: hidden;
+ display: block;
+ min-width: 0;
+ max-width: 100%;
+ overflow: visible;
+ overflow-wrap: anywhere;
color: var(--nodedc-text-muted);
font-size: 0.58rem;
- text-overflow: ellipsis;
- white-space: nowrap;
+ text-overflow: clip;
+ white-space: normal;
}
.device-row__signal {
@@ -242,7 +446,7 @@
background: var(--nodedc-text-muted);
}
-.device-row[data-compatible="true"] .device-row__signal {
+.device-row[data-likely-k1="true"] .device-row__signal {
background: rgb(var(--nodedc-success-rgb));
}
@@ -260,14 +464,81 @@
line-height: 1.45;
}
+.retained-recovery-target {
+ display: grid;
+ min-width: 0;
+ gap: 0.42rem;
+ margin-top: 0.62rem;
+ border-radius: 0.95rem;
+ background: rgb(255 255 255 / 0.025);
+ padding: 0.78rem;
+}
+
+.retained-recovery-target > div {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.55rem;
+}
+
+.retained-recovery-target span,
+.retained-recovery-target small {
+ color: var(--nodedc-text-muted);
+ font-size: 0.58rem;
+ line-height: 1.45;
+}
+
+.retained-recovery-target code {
+ min-width: 0;
+ overflow-wrap: anywhere;
+ color: var(--nodedc-text-secondary);
+ font-size: 0.58rem;
+}
+
.field-stack,
.session-form {
display: grid;
gap: 0.85rem;
}
+.password-field-row {
+ display: grid;
+ min-width: 0;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: end;
+ gap: 0.55rem;
+}
+
+.connection-recovery-choice {
+ display: grid;
+ min-width: 0;
+ max-width: 100%;
+ gap: 0.65rem;
+ border: 1px solid rgb(255 255 255 / 0.08);
+ border-radius: 0.95rem;
+ background: rgb(255 255 255 / 0.025);
+ padding: 0.85rem;
+}
+
+.connection-recovery-choice > strong {
+ color: var(--nodedc-text-primary);
+ font-size: 0.72rem;
+ line-height: 1.4;
+}
+
+.connection-recovery-choice > .safety-note {
+ margin: 0;
+}
+
+.connection-recovery-choice--retirement {
+ border-color: rgb(var(--nodedc-danger-rgb) / 0.24);
+}
+
.connection-summary {
display: flex;
+ min-width: 0;
+ max-width: 100%;
align-items: center;
justify-content: space-between;
gap: 1rem;
@@ -283,6 +554,8 @@
}
.connection-summary strong {
+ min-width: 0;
+ max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -296,6 +569,61 @@
margin-top: 1rem;
}
+.active-stream-recovery {
+ display: grid;
+ gap: 1rem;
+ margin-top: 1rem;
+}
+
+.active-stream-recovery__state {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: start;
+ gap: 0.75rem;
+ border-radius: 0.85rem;
+ background: rgb(255 255 255 / 0.03);
+ padding: 0.85rem;
+}
+
+.active-stream-recovery__state--static {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+.active-stream-recovery__state > .nodedc-activity-indicator {
+ margin-top: 0.12rem;
+}
+
+.active-stream-recovery__copy {
+ display: grid;
+ min-width: 0;
+ gap: 0.28rem;
+}
+
+.active-stream-recovery__copy strong {
+ color: var(--nodedc-text-primary);
+ font-size: 0.72rem;
+ line-height: 1.4;
+}
+
+.active-stream-recovery__copy span,
+.active-stream-recovery__copy small,
+.active-stream-recovery__actions p {
+ margin: 0;
+ color: var(--nodedc-text-muted);
+ font-size: 0.62rem;
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+}
+
+.active-stream-recovery__copy small {
+ color: var(--nodedc-text-secondary);
+}
+
+.active-stream-recovery__actions {
+ display: grid;
+ gap: 0.55rem;
+}
+
.scan-configuration-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -375,11 +703,12 @@
}
.detail-row dd {
- overflow: hidden;
+ overflow: visible;
+ overflow-wrap: anywhere;
color: var(--nodedc-text-secondary);
text-align: right;
- text-overflow: ellipsis;
- white-space: nowrap;
+ text-overflow: clip;
+ white-space: normal;
}
.inline-state {
@@ -449,25 +778,106 @@
}
}
-@media (max-width: 1480px) {
+/* Keep both jobs usable before admitting the split composition: the
+ provisioning column retains 32 rem and the acquisition column 38 rem.
+ Below their combined working width the panels stack instead of squeezing
+ and letting intrinsic text paint into the neighbouring surface. */
+@container xgrids-k1 (min-width: 78rem) {
.xgrids-k1-plugin .device-workspace__grid {
- grid-template-columns: minmax(21rem, 0.76fr) minmax(30rem, 1.24fr);
+ grid-template-columns: minmax(32rem, 0.8fr) minmax(38rem, 1.2fr);
}
}
-@media (max-width: 1280px) {
- .xgrids-k1-plugin .device-workspace__grid {
- grid-template-columns: 1fr;
+@container k1-connection-panel (max-width: 48rem) {
+ .xgrids-k1-plugin .panel-heading,
+ .xgrids-k1-plugin .wizard-step__content > header {
+ min-width: 0;
+ align-items: flex-start;
+ flex-wrap: wrap;
}
+
+ .xgrids-k1-plugin .panel-heading > div,
+ .xgrids-k1-plugin .wizard-step__content > header h3 {
+ min-width: 0;
+ max-width: 100%;
+ overflow-wrap: anywhere;
+ }
+
+ .xgrids-k1-plugin .panel-heading > .nodedc-status,
+ .xgrids-k1-plugin .wizard-step__content > header > .nodedc-status,
+ .xgrids-k1-plugin .connection-summary__value > .nodedc-status,
+ .xgrids-k1-plugin .retained-recovery-target .nodedc-status {
+ max-width: 100%;
+ flex: 0 1 auto;
+ line-height: 1.35;
+ text-align: left;
+ white-space: normal;
+ }
+
+ .xgrids-k1-plugin .connection-summary,
+ .xgrids-k1-plugin .connection-summary--topology,
+ .xgrids-k1-plugin .connection-summary__value {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .xgrids-k1-plugin .connection-summary__value {
+ justify-content: flex-start;
+ }
+
+ .xgrids-k1-plugin .connection-summary strong {
+ overflow-wrap: anywhere;
+ text-overflow: clip;
+ white-space: normal;
+ }
+
+ .xgrids-k1-plugin .device-row__action {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .xgrids-k1-plugin .device-row__name {
+ flex-wrap: wrap;
+ }
+
+ .xgrids-k1-plugin .device-row__name small {
+ flex: 1 1 100%;
+ }
+
+ .xgrids-k1-plugin .retained-recovery-target > div {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
}
-@media (max-width: 1040px) {
+@container xgrids-k1 (max-width: 65rem) {
.xgrids-k1-plugin .diagnostics-grid {
grid-template-columns: 1fr;
}
}
-@media (max-width: 760px) {
+@container k1-session-panel (max-width: 48rem) {
+ .xgrids-k1-plugin .panel-heading {
+ min-width: 0;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ }
+
+ .xgrids-k1-plugin .panel-heading > div {
+ min-width: 0;
+ max-width: 100%;
+ overflow-wrap: anywhere;
+ }
+
+ .xgrids-k1-plugin .panel-heading > .nodedc-status {
+ max-width: 100%;
+ flex: 0 1 auto;
+ line-height: 1.35;
+ text-align: left;
+ white-space: normal;
+ }
+
.xgrids-k1-plugin .scan-configuration-grid {
grid-template-columns: 1fr;
}
@@ -480,12 +890,33 @@
grid-column: auto;
}
- .xgrids-k1-plugin .session-footer,
- .xgrids-k1-plugin .error-banner {
+ .xgrids-k1-plugin .session-footer {
align-items: stretch;
- grid-template-columns: 1fr;
flex-direction: column;
}
+}
+
+@container xgrids-k1 (max-width: 48rem) {
+ .xgrids-k1-plugin .workspace-lead,
+ .xgrids-k1-plugin .panel-heading,
+ .xgrids-k1-plugin .wizard-step__content > header {
+ min-width: 0;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ }
+
+ .xgrids-k1-plugin .workspace-lead > div,
+ .xgrids-k1-plugin .workspace-lead__status,
+ .xgrids-k1-plugin .panel-heading > div,
+ .xgrids-k1-plugin .wizard-step__content > header h3 {
+ min-width: 0;
+ max-width: 100%;
+ }
+
+ .xgrids-k1-plugin .workspace-lead__status {
+ align-items: flex-start;
+ text-align: left;
+ }
.xgrids-k1-plugin .error-banner {
grid-template-columns: auto minmax(0, 1fr);
@@ -493,12 +924,57 @@
.xgrids-k1-plugin .error-banner__actions {
grid-column: 2;
+ min-width: 0;
+ flex-wrap: wrap;
justify-content: flex-end;
}
- .xgrids-k1-plugin .device-row__action {
+ .xgrids-k1-plugin .error-banner__diagnostic {
+ grid-template-columns: 1fr;
+ gap: 0.4rem;
+ }
+
+ .xgrids-k1-plugin .retained-recovery-target > div {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+}
+
+@container xgrids-k1 (max-width: 32rem) {
+ .xgrids-k1-plugin .wizard-step {
+ grid-template-columns: 1.75rem minmax(0, 1fr);
+ gap: 0.55rem;
+ }
+
+ .xgrids-k1-plugin .wizard-step__rail span {
+ width: 1.75rem;
+ height: 1.75rem;
+ }
+
+ .xgrids-k1-plugin .detail-row {
+ grid-template-columns: 1fr;
+ gap: 0.25rem;
+ }
+
+ .xgrids-k1-plugin .detail-row dd {
+ overflow-wrap: anywhere;
+ text-align: left;
+ white-space: normal;
+ }
+
+ .xgrids-k1-plugin .error-banner {
+ grid-template-columns: 1fr;
+ }
+
+ .xgrids-k1-plugin .error-banner__dot {
+ display: none;
+ }
+
+ .xgrids-k1-plugin .error-banner__actions {
+ grid-column: 1;
align-items: stretch;
flex-direction: column;
+ justify-content: flex-start;
}
}
@@ -517,6 +993,57 @@
backdrop-filter: blur(20px);
}
+.xgrids-k1-spatial-controls--recovery {
+ display: grid;
+ min-width: min(46rem, 100%);
+ grid-template-columns: minmax(0, 1fr);
+ gap: 0.45rem;
+ padding: 0.65rem 0.75rem;
+}
+
+.active-stream-recovery__compact-heading {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.active-stream-recovery__compact-heading > span:first-child {
+ overflow: hidden;
+ color: var(--nodedc-text-muted);
+ font-size: 0.5rem;
+ font-weight: 650;
+ letter-spacing: 0.14em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.active-stream-recovery--compact {
+ min-width: 0;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 0.65rem;
+ margin-top: 0;
+}
+
+.active-stream-recovery--compact .active-stream-recovery__state {
+ min-width: 0;
+ background: transparent;
+ padding: 0;
+}
+
+.active-stream-recovery--compact .active-stream-recovery__actions {
+ max-width: 15rem;
+ grid-template-columns: auto;
+ gap: 0.25rem;
+}
+
+.active-stream-recovery--compact .active-stream-recovery__actions p {
+ font-size: 0.5rem;
+ line-height: 1.35;
+}
+
.xgrids-k1-spatial-controls__phase {
display: flex;
min-width: 11rem;
@@ -547,16 +1074,6 @@
font-size: 0.53rem;
}
-.xgrids-k1-spatial-controls__spinner {
- width: 0.82rem;
- height: 0.82rem;
- flex: 0 0 0.82rem;
- border: 1px solid rgb(255 255 255 / 0.16);
- border-top-color: var(--nodedc-text-primary);
- border-radius: 50%;
- animation: xgrids-k1-spin 900ms linear infinite;
-}
-
.xgrids-k1-spatial-controls__telemetry {
display: flex;
flex: 0 1 auto;
@@ -598,8 +1115,32 @@
white-space: nowrap;
}
-@keyframes xgrids-k1-spin {
- to { transform: rotate(360deg); }
+.xgrids-k1-spatial-controls__action-label {
+ display: block;
+ width: 9.75rem;
+ font-size: 0.66rem;
+ line-height: 1.08;
+ text-align: center;
+ white-space: normal;
+}
+
+.xgrids-k1-spatial-controls__action-label--local {
+ width: 8.75rem;
+}
+
+.connection-action-progress {
+ display: flex;
+ min-height: 2.75rem;
+ width: 100%;
+ align-items: center;
+ justify-content: center;
+ gap: 0.65rem;
+ color: var(--nodedc-text-secondary);
+}
+
+.connection-action-progress strong {
+ font-size: 0.78rem;
+ font-weight: 600;
}
@media (max-width: 960px) {
@@ -612,4 +1153,16 @@
.xgrids-k1-spatial-controls__error small {
display: none;
}
+
+ .active-stream-recovery--compact {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .active-stream-recovery--compact .active-stream-recovery__actions {
+ max-width: none;
+ }
+
+ .active-stream-recovery--compact .active-stream-recovery__actions p {
+ display: none;
+ }
}
diff --git a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts
index 3e6f464..f0f432a 100644
--- a/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts
+++ b/plugins/xgrids-k1/frontend/src/useXgridsK1Runtime.ts
@@ -11,46 +11,238 @@ import {
type EventSocketStatus,
type OpenApplicationControlSessionRequest,
type OperatorPresenceConfirmation,
+ type PrepareConnectionReconfigurationRequest,
type PrepareAcquisitionRequest,
+ type ReopenRetiredPhysicalReconciliationRequest,
type ReplayRequest,
+ type RetireUnavailablePhysicalCommandRequest,
+ type SelectConnectionModeRequest,
type XgridsOperation,
type XgridsApplicationControlPhase,
+ type XgridsConnectionMode,
+ type XgridsConnectionPolicyAction,
+ type XgridsHostFailureDiagnostic,
type XgridsK1State,
} from "./api";
import {
- controlSessionEntryPlan,
+ acceptedBleSessionKeyAfterConnect,
+ authoritativeStateSupersedesPhysicalStopIntent,
+ bleSessionTargetForTransport,
+ connectionPolicyAllows,
+ currentAppliedConnectionTopology,
+ authoritativeStateSupersedesRuntimeError,
+ isRecoveredPhysicalScanning,
+ isProvenLocalReceiverInactive,
isTerminalAcquisitionState,
liveStartPlan,
+ localReceiverStopPlan,
+ newMutationContext,
+ newOperationId,
+ operationAllowsFreshProvisioningIntent,
operationByIdempotencyKey,
operationNeedsReconciliation,
+ physicalStopIntentCheckpoint,
+ recommendedConnectionRecoveryObservationTarget,
+ readOnlyVerificationClearedReconciliation,
+ requiresCanonicalStopAfterTerminalLocalFailure,
isSoftwareCommandedAcquisition,
+ shouldSurfaceRuntimeActionError,
+ transportRefEquivalenceKey,
+ type PhysicalStopIntentCheckpoint,
+ type RuntimeErrorCorrelation,
} from "./lifecycle";
import { localizeRuntimeMessage } from "./messages";
+import {
+ acquisitionMutationUsesControlSession,
+ exactAcquisitionControlCas,
+ exactApplicationControlCas,
+} from "./controlSessionCas";
import {
awaitWhileIntentCurrent,
+ isSnapshotRuntimeCurrent as snapshotRuntimeIdsMatch,
OperatorIntentGeneration,
} from "./operatorIntentGeneration";
import { selectMonotonicXgridsState } from "./stateOrdering";
+import { operationHostFailureDiagnostic } from "./hostDiagnosticPresentation";
+import { activeStreamForceFinishAuthority } from "./activeStreamRecovery";
+import { DEFAULT_CONNECTION_MODE } from "./configuration";
export type PendingAction =
| "scan"
+ | "mode"
+ | "reconfigure"
| "connect"
| "verify"
+ | "retire"
+ | "reopen"
+ | "probe"
| "control"
| "live"
| "replay"
| "stop"
+ | "force-finish"
| "abort"
| "camera"
| "viewer";
-export interface CanonicalLiveStartRequest {
- control: OpenApplicationControlSessionRequest;
- acquisition: PrepareAcquisitionRequest;
- physicalAcceptance: OperatorPresenceConfirmation;
+export interface RuntimeActionToken {
+ runtimeGeneration: number;
+ actionSequence: number;
+}
+
+export class SnapshotRuntimeActionArbiter {
+ private current: RuntimeActionToken | null = null;
+ private sequence = 0;
+
+ begin(
+ runtimeGeneration: number,
+ supersedeCurrent = false,
+ ): RuntimeActionToken | null {
+ if (
+ this.current?.runtimeGeneration === runtimeGeneration
+ && !supersedeCurrent
+ ) return null;
+ this.sequence += 1;
+ this.current = {
+ runtimeGeneration,
+ actionSequence: this.sequence,
+ };
+ return this.current;
+ }
+
+ isCurrent(token: RuntimeActionToken): boolean {
+ return this.current?.runtimeGeneration === token.runtimeGeneration
+ && this.current.actionSequence === token.actionSequence;
+ }
+
+ retireForSnapshotChange(
+ previousSnapshotRuntimeId: string | null,
+ acceptedSnapshotRuntimeId: string | null,
+ ): boolean {
+ if (
+ !previousSnapshotRuntimeId
+ || !acceptedSnapshotRuntimeId
+ || previousSnapshotRuntimeId === acceptedSnapshotRuntimeId
+ ) return false;
+ this.current = null;
+ return true;
+ }
+
+ settle(token: RuntimeActionToken): boolean {
+ if (!this.isCurrent(token)) return false;
+ this.current = null;
+ return true;
+ }
+}
+
+export type AcquisitionPreparationDraft = Omit<
+ PrepareAcquisitionRequest,
+ | "operation_id"
+ | "idempotency_key"
+ | "expected_control_session_generation"
+ | "expected_control_state_revision"
+>;
+
+export interface CanonicalLivePreparationRequest {
+ acquisition: AcquisitionPreparationDraft;
+}
+
+export interface ProvisioningSubmitResult {
+ /** The exact Apply response was accepted, including applied-but-unready control. */
+ succeeded: boolean;
+ /** The explicit network mutation completed even if the later control proof failed. */
+ networkIntentCompleted: boolean;
+ intentDisposition: "retain" | "release";
+ /** Exact journaled failure for this provisioning attempt. */
+ failureReasonCode: string | null;
+ acceptedSessionKey: string | null;
+ observedState: XgridsK1State | null;
+}
+
+export interface ConnectionVerificationSubmitResult {
+ succeeded: boolean;
+ reconciliationCompleted: boolean;
+ observedState: XgridsK1State | null;
+ /** Exact journaled failure for this Verify operation, never an unrelated last error. */
+ reasonCode: string | null;
+}
+
+export interface RuntimeActionOptions {
+ /** Keep a bounded background observation inside its owning surface. */
+ surfaceErrors?: boolean;
+ /** Explicit BLE discovery window owned by the visible Scan action. */
+ durationSeconds?: number;
+ /** Exact durable connection attempt owned by this action, if any. */
+ connectionAttemptId?: () => string | null;
+ /** Retire an older UI callback; the backend remains the mutation authority. */
+ supersedePending?: boolean;
+ /**
+ * Pin a composite UI action to the runtime rendered at its explicit click.
+ * The literal id is checked again at the dispatch boundary; it is never
+ * replaced with a newer runtime discovered while the action is settling.
+ */
+ expectedSnapshotRuntimeId?: string;
+}
+
+export interface ConnectionActionAuthoritySnapshot {
+ snapshotRuntimeId: string;
+ connectionMode: XgridsConnectionMode;
+ desiredModeRevision: number;
+ reconfigurationRevision: number;
+ reconfigurationIntentId: string | null;
+ activeBindingKey: string | null;
+ discoveryGeneration: number;
+}
+
+export interface BleDiscoverySubmitResult {
+ succeeded: boolean;
+ snapshotRuntimeId: string | null;
+ discoveryGeneration: number | null;
+ transportRefs: readonly string[];
+}
+
+export interface ConnectionReconfigurationSubmitResult {
+ succeeded: boolean;
+ observedState: XgridsK1State | null;
+}
+
+export function connectionActionAuthoritySnapshot(
+ state: XgridsK1State | null | undefined,
+ connectionMode: XgridsConnectionMode,
+): ConnectionActionAuthoritySnapshot | null {
+ const snapshotRuntimeId = state?.snapshot_runtime_id?.trim() || null;
+ const desiredModeRevision = state?.desired_connection_mode_revision;
+ const discoveryGeneration = state?.ble_discovery_generation;
+ const reconfigurationRevision = state?.connection_reconfiguration?.revision ?? 0;
+ const reconfiguration = state?.connection_reconfiguration;
+ const reconfigurationIntentId = reconfiguration
+ && reconfiguration.intent !== null
+ && reconfiguration.status !== "idle"
+ ? reconfiguration.intent_id
+ : null;
+ if (
+ !snapshotRuntimeId
+ || state?.desired_connection_mode !== connectionMode
+ || !Number.isInteger(desiredModeRevision)
+ || (desiredModeRevision ?? -1) < 0
+ || !Number.isInteger(discoveryGeneration)
+ || (discoveryGeneration ?? -1) < 0
+ || !Number.isInteger(reconfigurationRevision)
+ || reconfigurationRevision < 0
+ ) return null;
+ return {
+ snapshotRuntimeId,
+ connectionMode,
+ desiredModeRevision: desiredModeRevision as number,
+ reconfigurationRevision,
+ reconfigurationIntentId,
+ activeBindingKey: state?.connection_lifecycle?.active_binding_key ?? null,
+ discoveryGeneration: discoveryGeneration as number,
+ };
}
const CONTROL_STATE_READ_INTERVAL_MS = 250;
+const CONTROL_PHASE_WAIT_TIMEOUT_MS = 35_000;
function controlPhase(state: XgridsK1State): XgridsApplicationControlPhase {
return state.application_control_session?.state ?? "idle";
@@ -93,7 +285,7 @@ function controlFailure(state: XgridsK1State): ApiError {
response_rejected:
"Сканер отклонил подготовительную операцию.",
compatibility_profile_mismatch:
- "Живой DeviceInfo не соответствует выбранному профилю модели, platform type, прошивки или активации.",
+ "Ответ подключённого устройства не соответствует выбранной модели K1, версии прошивки или состоянию активации.",
scan_initialization_timeout:
"После подтверждённого START сканер не завершил инициализацию в безопасный срок.",
operation_reuse_forbidden:
@@ -145,6 +337,9 @@ function controlFailure(state: XgridsK1State): ApiError {
: "";
return new ApiError(
`${reasonDetail || localizedDetail || "Канонический диалог K1 остановлен."} Этап: ${stage}. ${failedOperation} ${exchanges} ${commandStatus} ${diagnostics} ${retryStatus}`,
+ 0,
+ false,
+ failure?.host_diagnostic,
);
}
@@ -152,9 +347,16 @@ async function waitForControlPhase(
expected: XgridsApplicationControlPhase,
acceptState: (state: XgridsK1State) => void,
assertOperatorIntentCurrent: () => void,
+ timeoutMs = CONTROL_PHASE_WAIT_TIMEOUT_MS,
): Promise