chore(k1): checkpoint connection recovery work

This commit is contained in:
DCCONSTRUCTIONS
2026-08-21 08:12:22 +03:00
parent a3138f3d71
commit c7843a3c7e
22 changed files with 969 additions and 293 deletions
@@ -292,6 +292,21 @@ export function isLiveRerunPresentationReady(
isUsableRecordedPlaybackRange(rangeNs); isUsableRecordedPlaybackRange(rangeNs);
} }
/**
* `recording_open` can arrive before Rerun has registered the live timeline.
* Selecting it at that point is a silent no-op, so keep retrying only until
* the exact live timeline is both available and active.
*/
export function liveTimelineNeedsSynchronization(
followLive: boolean,
activeTimeline: string | null | undefined,
rangeNs: { min: number; max: number } | null,
): boolean {
return followLive &&
isUsableRecordedPlaybackRange(rangeNs) &&
activeTimeline !== "stream_time";
}
/** /**
* Key the native receiver to its data-plane binding. Recovery authority is a * Key the native receiver to its data-plane binding. Recovery authority is a
* retry fence projected from changing supervisor snapshots; it must not tear * retry fence projected from changing supervisor snapshots; it must not tear
@@ -1435,6 +1450,7 @@ export function RerunViewport({
let rangeNs = viewer.get_time_range(event.recording_id, timeline); let rangeNs = viewer.get_time_range(event.recording_id, timeline);
let currentNs = viewer.get_current_time(event.recording_id, timeline); let currentNs = viewer.get_current_time(event.recording_id, timeline);
let playing = viewer.get_playing(event.recording_id); let playing = viewer.get_playing(event.recording_id);
let liveTimelineSynchronized = !followLive;
if (!followLive && playing) { if (!followLive && playing) {
try { try {
viewer.set_playing(event.recording_id, false); viewer.set_playing(event.recording_id, false);
@@ -1496,6 +1512,17 @@ export function RerunViewport({
if (disposed || !playbackState) return; if (disposed || !playbackState) return;
try { try {
rangeNs = viewer.get_time_range(event.recording_id, timeline); rangeNs = viewer.get_time_range(event.recording_id, timeline);
if (followLive && !liveTimelineSynchronized) {
let activeTimeline = viewer.get_active_timeline(event.recording_id);
if (liveTimelineNeedsSynchronization(followLive, activeTimeline, rangeNs)) {
// The first selection above may have raced timeline
// creation. Once a real range exists this retry is safe
// and preserves Rerun's native live-following cursor.
viewer.set_active_timeline(event.recording_id, timeline);
activeTimeline = viewer.get_active_timeline(event.recording_id);
}
liveTimelineSynchronized = activeTimeline === timeline;
}
currentNs = viewer.get_current_time(event.recording_id, timeline); currentNs = viewer.get_current_time(event.recording_id, timeline);
playing = viewer.get_playing(event.recording_id); playing = viewer.get_playing(event.recording_id);
} catch { } catch {
@@ -1511,7 +1538,7 @@ export function RerunViewport({
setRecordingBufferProgress(recordedBuffer.bufferProgress); setRecordingBufferProgress(recordedBuffer.bufferProgress);
} }
const readyToRender = followLive const readyToRender = followLive
? isLiveRerunPresentationReady( ? liveTimelineSynchronized && isLiveRerunPresentationReady(
viewerStartResolved, viewerStartResolved,
rangeNs, rangeNs,
liveActivitySequenceRef.current, liveActivitySequenceRef.current,
@@ -157,6 +157,7 @@ function SpatialWorkspace({
const [showSegmentation, setShowSegmentation] = useState(false); const [showSegmentation, setShowSegmentation] = useState(false);
const [showCuboids3d, setShowCuboids3d] = useState(false); const [showCuboids3d, setShowCuboids3d] = useState(false);
const recordedSource = Boolean(recordedReplay) || /\.rrd(?:$|[?#])/i.test(sourceUrl); const recordedSource = Boolean(recordedReplay) || /\.rrd(?:$|[?#])/i.test(sourceUrl);
const liveRerunSource = !recordedSource && /^rerun\+https?:\/\//i.test(sourceUrl.trim());
const recordedSessionGate: RecordedAdmissionPhase = recordedSource const recordedSessionGate: RecordedAdmissionPhase = recordedSource
? recordedSessionAdmission?.phase ?? "loading" ? recordedSessionAdmission?.phase ?? "loading"
: "ready"; : "ready";
@@ -165,6 +166,10 @@ function SpatialWorkspace({
isRecordedPlaybackPresentationReady(viewerStatus, playbackState)); isRecordedPlaybackPresentationReady(viewerStatus, playbackState));
const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay"; const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
const metrics = streamActive ? state?.metrics : undefined; const metrics = streamActive ? state?.metrics : undefined;
// An explicit manual gRPC source has no Mission Core metrics producer. Its
// native Rerun range is therefore the only available activity proof.
const livePresentationActivitySequence = metrics?.publishedFrameCount ??
(liveRerunSource && !streamActive ? 1 : null);
const latency = pipelineLatency(metrics); const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frameRateHz); const frameRate = finiteMetric(metrics?.frameRateHz);
const points = finiteMetric(metrics?.pointCount); const points = finiteMetric(metrics?.pointCount);
@@ -495,8 +500,8 @@ function SpatialWorkspace({
<RerunViewport <RerunViewport
sourceUrl={sourceUrl} sourceUrl={sourceUrl}
recordedArtifact={recordedSource ? recordedReplay : null} recordedArtifact={recordedSource ? recordedReplay : null}
followLive={!recordedReplay && streamActive} followLive={liveRerunSource}
liveActivitySequence={metrics?.publishedFrameCount} liveActivitySequence={livePresentationActivitySequence}
liveStreamId={state?.spatialSource?.id} liveStreamId={state?.spatialSource?.id}
liveRecoveryAuthorityIdentity={!recordedSource && streamActive ? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource) : null} liveRecoveryAuthorityIdentity={!recordedSource && streamActive ? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource) : null}
autoplayWhenReady={recordedSource} autoplayWhenReady={recordedSource}
@@ -590,7 +590,14 @@ test("canonical K1 preparation stops before START and guards every async stage",
canonicalPreparation.match( canonicalPreparation.match(
/expected_snapshot_runtime_id: actionSnapshotRuntimeId/g, /expected_snapshot_runtime_id: actionSnapshotRuntimeId/g,
)?.length, )?.length,
3, 4,
);
assert.match(canonicalPreparation, /xgridsK1Api\.openApplicationControlSession\(\{/);
assert.match(canonicalPreparation, /\.\.\.request\.physicalAcceptance/);
assert.ok(
canonicalPreparation.indexOf("xgridsK1Api.openApplicationControlSession")
< canonicalPreparation.indexOf("xgridsK1Api.enterApplicationWorkspace"),
"Verify inspection must be replaced before workspace entry",
); );
assert.match(canonicalPreparation, /xgridsK1Api\.reconcilePhysicalCommand\(\{/); assert.match(canonicalPreparation, /xgridsK1Api\.reconcilePhysicalCommand\(\{/);
assert.doesNotMatch(canonicalPreparation, /await xgridsK1Api\./); assert.doesNotMatch(canonicalPreparation, /await xgridsK1Api\./);
@@ -308,7 +308,7 @@ test("connected presentation uses canonical process copy", () => {
assert.match(connection, /<h2>Подключение \{model\.displayName\}<\/h2>/); assert.match(connection, /<h2>Подключение \{model\.displayName\}<\/h2>/);
assert.match( assert.match(
connection, connection,
/const operationalPanelsVisible = shouldRenderK1OperationalPanels\(state\)/, /const operationalPanelsVisible = shouldRenderK1OperationalPanels\(\s*state,\s*controller\.pendingAction,\s*\)/,
); );
assert.match( assert.match(
connection, connection,
@@ -395,7 +395,7 @@ test("K1 START renders an in-button spinner for the complete live orchestration"
assert.doesNotMatch(acquisition, /readOnlyConnectionObservationTarget\(state\)/); assert.doesNotMatch(acquisition, /readOnlyConnectionObservationTarget\(state\)/);
assert.match(acquisition, /appliedTopology\?\.status === "active"\s*&& desiredModeMatchesActive/); assert.match(acquisition, /appliedTopology\?\.status === "active"\s*&& desiredModeMatchesActive/);
assert.match(acquisition, /if \(!draftPreparationTarget\) return;/); assert.match(acquisition, /if \(!draftPreparationTarget\) return;/);
assert.match(acquisition, /START не используется для установки связи/); assert.doesNotMatch(acquisition, /START не используется для установки связи/);
assert.match(acquisition, /Синхронизация…/); assert.match(acquisition, /Синхронизация…/);
}); });
@@ -502,7 +502,7 @@ test("K1 mode reset is explicit while Scan and Apply keep exact backend CAS", ()
assert.match(acquisition, /state\?\.active_connection_mode/); assert.match(acquisition, /state\?\.active_connection_mode/);
assert.match(acquisition, /desiredSelectionCommitted/); assert.match(acquisition, /desiredSelectionCommitted/);
assert.match(acquisition, /configuredConnectionMode !== desiredConnectionMode/); assert.match(acquisition, /configuredConnectionMode !== desiredConnectionMode/);
assert.match(acquisition, /Выбран другой способ связи/); assert.doesNotMatch(acquisition, /Выбран другой способ связи/);
}); });
test("top-right device utility is an explicit pending-aware K1 scenario reset", async () => { test("top-right device utility is an explicit pending-aware K1 scenario reset", async () => {
@@ -990,7 +990,7 @@ test("every K1 connection and acquisition action is fenced to the accepted backe
[...runtime.matchAll( [...runtime.matchAll(
/expected_snapshot_runtime_id: actionSnapshotRuntimeId/g, /expected_snapshot_runtime_id: actionSnapshotRuntimeId/g,
)].length, )].length,
3, 4,
); );
assert.match( assert.match(
runtime, runtime,
@@ -1064,7 +1064,7 @@ test("K1 orchestration accepts backend recovery but still requires exact topolog
); );
assert.match(acquisition, /desiredModeMatchesActive/); assert.match(acquisition, /desiredModeMatchesActive/);
assert.match(acquisition, /modeSwitchRequired/); assert.match(acquisition, /modeSwitchRequired/);
assert.match(acquisition, /Выбран другой способ связи/); assert.doesNotMatch(acquisition, /Выбран другой способ связи/);
assert.match(acquisition, /prepareCanonicalAcquisition/); assert.match(acquisition, /prepareCanonicalAcquisition/);
assert.match(acquisition, /startPreparedAcquisition/); assert.match(acquisition, /startPreparedAcquisition/);
assert.match(acquisition, /operatorActionPhysicalAcceptance\(\)/); assert.match(acquisition, /operatorActionPhysicalAcceptance\(\)/);
@@ -65,6 +65,7 @@ let shellPresentation;
let reconfigurationAllowsFreshDevice; let reconfigurationAllowsFreshDevice;
let readOnlyObservationShowsNetworkUnavailable; let readOnlyObservationShowsNetworkUnavailable;
let readOnlyFailureShowsNetworkUnavailable; let readOnlyFailureShowsNetworkUnavailable;
let savedBridgeRequiresNetworkSetup;
let provisioningFailureRequiresFreshCandidate; let provisioningFailureRequiresFreshCandidate;
let trustedConnectionBinding; let trustedConnectionBinding;
let transportRefEquivalenceKey; let transportRefEquivalenceKey;
@@ -183,6 +184,7 @@ before(async () => {
reconfigurationAllowsFreshDevice, reconfigurationAllowsFreshDevice,
readOnlyObservationShowsNetworkUnavailable, readOnlyObservationShowsNetworkUnavailable,
readOnlyFailureShowsNetworkUnavailable, readOnlyFailureShowsNetworkUnavailable,
savedBridgeRequiresNetworkSetup,
provisioningFailureRequiresFreshCandidate, provisioningFailureRequiresFreshCandidate,
trustedConnectionBinding, trustedConnectionBinding,
transportRefEquivalenceKey, transportRefEquivalenceKey,
@@ -1057,12 +1059,15 @@ function connectionPipelinesController(state) {
}; };
} }
function renderConnectionPipelines(state) { function renderConnectionPipelines(state, pendingAction = null) {
return renderToStaticMarkup(createElement(K1ConnectionPipelines, { return renderToStaticMarkup(createElement(K1ConnectionPipelines, {
controller: connectionPipelinesController(state), controller: {
...connectionPipelinesController(state),
pendingAction,
},
desiredConnectionMode: "bridge", desiredConnectionMode: "bridge",
onDesiredConnectionModeChange() {}, onDesiredConnectionModeChange() {},
operationalPanelsVisible: shouldRenderK1OperationalPanels(state), operationalPanelsVisible: shouldRenderK1OperationalPanels(state, pendingAction),
openSpatialScene() {}, openSpatialScene() {},
activateAutomaticSpatialSource() {}, activateAutomaticSpatialSource() {},
sourceLabel: "Ожидание", sourceLabel: "Ожидание",
@@ -2872,7 +2877,7 @@ test("runtime errors borrow diagnostics only from their exact Connect attempt",
); );
}); });
test("connection recovery observation follows recommended exact policy then safe priority", () => { test("connection recovery keeps current then durable priority over a stale fresh recommendation", () => {
const state = durableTopologyState(); const state = durableTopologyState();
state.snapshot_runtime_id = "runtime-recovery-priority"; state.snapshot_runtime_id = "runtime-recovery-priority";
state.devices = [{ state.devices = [{
@@ -2893,7 +2898,7 @@ test("connection recovery observation follows recommended exact policy then safe
state.connection_policy = { state.connection_policy = {
schema_version: "missioncore.xgrids-k1-connection-policy/v1", schema_version: "missioncore.xgrids-k1-connection-policy/v1",
facts: { retained_context_is_presence: false }, facts: { retained_context_is_presence: false },
recommended_action: "observe-configured-device-network", recommended_action: "observe-fresh-device-network",
allowed_actions: [ allowed_actions: [
"observe-current-device-network", "observe-current-device-network",
"observe-configured-device-network", "observe-configured-device-network",
@@ -2913,10 +2918,21 @@ test("connection recovery observation follows recommended exact policy then safe
}; };
const recommended = recommendedConnectionRecoveryObservationTarget(state); const recommended = recommendedConnectionRecoveryObservationTarget(state);
assert.equal(recommended?.action, "observe-configured-device-network"); assert.equal(recommended?.action, "observe-current-device-network");
assert.equal(recommended?.deviceId, "configured-k1"); assert.equal(recommended?.deviceId, "current-k1");
state.connection_policy.actions["observe-current-device-network"].allowed = false;
const durable = recommendedConnectionRecoveryObservationTarget(state);
assert.equal(durable?.action, "observe-configured-device-network");
assert.equal(durable?.deviceId, "configured-k1");
state.connection_policy.actions["observe-configured-device-network"].allowed = false;
const fresh = recommendedConnectionRecoveryObservationTarget(state);
assert.equal(fresh?.action, "observe-fresh-device-network");
assert.equal(fresh?.deviceId, "fresh-k1");
state.connection_policy.recommended_action = "scan-ble"; state.connection_policy.recommended_action = "scan-ble";
state.connection_policy.actions["observe-current-device-network"].allowed = true;
const fallback = recommendedConnectionRecoveryObservationTarget(state); const fallback = recommendedConnectionRecoveryObservationTarget(state);
assert.equal(fallback?.action, "observe-current-device-network"); assert.equal(fallback?.action, "observe-current-device-network");
assert.equal(fallback?.deviceId, "current-k1"); assert.equal(fallback?.deviceId, "current-k1");
@@ -3109,6 +3125,19 @@ test("cold disconnected connection SSR hides every operational panel", () => {
assert.doesNotMatch(unresolvedHistoryMarkup, /class="device-workspace__side/); assert.doesNotMatch(unresolvedHistoryMarkup, /class="device-workspace__side/);
}); });
test("live preparation keeps operational panels mounted across a control handoff", () => {
const state = durableTopologyState();
const markup = renderConnectionPipelines(state, "live");
assert.equal(shouldRenderK1OperationalPanels(state, "live"), true);
assert.match(markup, /class="metrics-grid/);
assert.match(markup, /class="[^"]*\bsession-panel\b/);
assert.match(markup, /class="diagnostics-grid/);
assert.match(markup, /class="device-workspace__side/);
assert.match(markup, /Подключение установлено/);
assert.doesNotMatch(markup, /Переподключиться|Подключить заново/);
});
test("connected, active, replay and recovery SSR retain operational panels", () => { test("connected, active, replay and recovery SSR retain operational panels", () => {
const connected = runtimeState(); const connected = runtimeState();
connected.phase = "connected"; connected.phase = "connected";
@@ -3911,6 +3940,34 @@ test("explicit read-only recovery keeps its network-unavailable classification",
} }
}); });
test("a BLE-proven missing Bridge address requires network setup across later retries", () => {
const state = {
connection_verification: {
status: "unreachable",
reason_code: "connection-verify-address-unavailable",
},
last_operation: {
action: "connection.verify",
status: "failed",
error: { code: "connection-verify-device-not-rediscovered" },
},
};
assert.equal(savedBridgeRequiresNetworkSetup(state), true);
assert.equal(savedBridgeRequiresNetworkSetup({
last_operation: {
action: "connection.verify",
status: "failed",
error: { code: "connection-verify-address-unavailable" },
},
}), true);
assert.equal(savedBridgeRequiresNetworkSetup({
connection_verification: {
status: "unreachable",
reason_code: "connection-verify-device-not-rediscovered",
},
}), false);
});
test("pre-write candidate loss is classified stale without granting UI continuation", () => { test("pre-write candidate loss is classified stale without granting UI continuation", () => {
for (const reasonCode of [ for (const reasonCode of [
"BleakDeviceNotFoundError", "BleakDeviceNotFoundError",
@@ -4256,7 +4313,7 @@ test("fresh Scan keeps retired audit rows selectable without recovery I/O", () =
assertCanonicalConnectionCopy(historicalAuditMarkup); assertCanonicalConnectionCopy(historicalAuditMarkup);
}); });
test("only current network recovery safe-next states retain a historical applied attempt", () => { test("only an active network operation owns an applied-attempt screen", () => {
const attempt = { const attempt = {
schema_version: "missioncore.xgrids-k1-connection-attempt/v1", schema_version: "missioncore.xgrids-k1-connection-attempt/v1",
attempt_id: "attempt-network-recovery-ownership", attempt_id: "attempt-network-recovery-ownership",
@@ -4283,7 +4340,7 @@ test("only current network recovery safe-next states retain a historical applied
...attempt, ...attempt,
safe_next_action: safeNextAction, safe_next_action: safeNextAction,
}), }),
true, false,
safeNextAction, safeNextAction,
); );
} }
@@ -5205,6 +5262,51 @@ test("an unresolved physical command has one explicit server-bound read-only rec
/Результаты последнего Bluetooth-поиска|nearby-unrelated-device|>Выбрать<|>Применить</, /Результаты последнего Bluetooth-поиска|nearby-unrelated-device|>Выбрать<|>Применить</,
); );
const networkSetupState = structuredClone(state);
networkSetupState.connection_verification = {
status: "unreachable",
lease_state: "disconnected",
network_reachability: "unreachable",
reason_code: "connection-verify-address-unavailable",
};
networkSetupState.last_operation = {
action: "connection.verify",
status: "failed",
error: { code: "connection-verify-device-not-rediscovered" },
};
const networkSetupMarkup = renderToStaticMarkup(createElement(
K1ProvisioningPipeline,
{
controller: provisioningController(networkSetupState),
desiredMode: "bridge",
},
));
const networkSetupReconnect = buttonMarkupWithText(
networkSetupMarkup,
"Переподключиться",
);
assert.equal(networkSetupReconnect.length, 1);
assert.doesNotMatch(networkSetupReconnect[0], /\bdisabled(?:=|\s|>)/);
const networkSetupAction = buttonMarkupWithText(
networkSetupMarkup,
"Подключить K1 к общей сети",
);
assert.equal(networkSetupAction.length, 1);
assert.doesNotMatch(networkSetupAction[0], /\bdisabled(?:=|\s|>)/);
assert.match(
networkSetupMarkup,
/K1 не подключён к сохранённой общей сети/,
);
assert.match(networkSetupMarkup, /не сообщил адрес Bridge/);
assert.doesNotMatch(
networkSetupMarkup,
/Локальная операция завершилась ошибкой|Проверьте питание и сеть/,
);
assert.match(
physicalRecoveryConnectionDetail(networkSetupState),
/K1 ответил по Bluetooth, но не подключён к сохранённой общей сети/,
);
const policyDeniedState = structuredClone(state); const policyDeniedState = structuredClone(state);
policyDeniedState.connection_policy.allowed_actions = ["scan-ble"]; policyDeniedState.connection_policy.allowed_actions = ["scan-ble"];
delete policyDeniedState.connection_policy.actions["observe-configured-device-network"]; delete policyDeniedState.connection_policy.actions["observe-configured-device-network"];
@@ -5258,9 +5360,10 @@ test("an unresolved physical command has one explicit server-bound read-only rec
/scanWithResult\(|connect\(|retireUnavailable|reopenRetired|reconcilePhysicalCommand/, /scanWithResult\(|connect\(|retireUnavailable|reopenRetired|reconcilePhysicalCommand/,
); );
assert.match(physicalRecovery, /physicalRecoveryTarget\?\.serverBound/); assert.match(physicalRecovery, /physicalRecoveryTarget\?\.serverBound/);
assert.match(physicalRecovery, /surfaceErrors: false/);
}); });
test("supervisor DeviceInfo Verify keeps the exact physical reconnect visible", () => { test("supervisor DeviceInfo Verify outranks a stale exact fresh row for physical recovery", () => {
const state = durableTopologyState(); const state = durableTopologyState();
const recoveryTransport = "F89438FA-55ED-85AD-EED7-734AC84746D8"; const recoveryTransport = "F89438FA-55ED-85AD-EED7-734AC84746D8";
state.snapshot_runtime_id = "runtime-terminal-control-physical-recovery"; state.snapshot_runtime_id = "runtime-terminal-control-physical-recovery";
@@ -5299,11 +5402,20 @@ test("supervisor DeviceInfo Verify keeps the exact physical reconnect visible",
recommended_action: "scan-ble", recommended_action: "scan-ble",
allowed_actions: [ allowed_actions: [
"scan-ble", "scan-ble",
"observe-fresh-device-network",
"inspect-configured-endpoint", "inspect-configured-endpoint",
"verify-control-device-info", "verify-control-device-info",
], ],
actions: { actions: {
"observe-fresh-device-network": deniedObservation("fresh-scan"), "observe-fresh-device-network": {
allowed: true,
reason_codes: [],
target_source: "fresh-scan",
required_transport_ref: recoveryTransport,
required_connection_mode: "bridge",
requires_live_gatt_validation: true,
automatic_retry: false,
},
"observe-current-device-network": deniedObservation( "observe-current-device-network": deniedObservation(
"retained-current-process", "retained-current-process",
), ),
@@ -5320,6 +5432,13 @@ test("supervisor DeviceInfo Verify keeps the exact physical reconnect visible",
}, },
}, },
}; };
state.devices = [{
device_id: recoveryTransport,
name: "Projected K1",
connectable: true,
likely_k1: true,
}];
state.ble_discovery_generation = 2;
assert.deepEqual(readOnlyConnectionObservationTarget(state), { assert.deepEqual(readOnlyConnectionObservationTarget(state), {
action: "verify-control-device-info", action: "verify-control-device-info",
@@ -5341,6 +5460,7 @@ test("supervisor DeviceInfo Verify keeps the exact physical reconnect visible",
const mismatched = structuredClone(state); const mismatched = structuredClone(state);
mismatched.semantic_topology_store.record.transport_ref = "another-k1"; mismatched.semantic_topology_store.record.transport_ref = "another-k1";
mismatched.connection_policy.actions["observe-fresh-device-network"].allowed = false;
assert.equal(readOnlyConnectionObservationTarget(mismatched), null); assert.equal(readOnlyConnectionObservationTarget(mismatched), null);
}); });
@@ -5929,8 +6049,16 @@ test("terminal Apply recovery is bounded and never claims that the network is re
controller: provisioningController(state), controller: provisioningController(state),
desiredMode: "bridge", desiredMode: "bridge",
}, presentation); }, presentation);
assert.match(markup, /Управление не подтверждено/); assert.match(markup, /Сохранённое подключение/);
assert.match(markup, /Попытка настройки завершена/); assert.match(markup, /Подключить заново/);
assert.equal(
buttonMarkupWithText(markup, "Подключить новый K1").length,
1,
);
assert.doesNotMatch(
markup,
/Управление не подтверждено|выбор заблокирован|Попытка настройки завершена/,
);
assert.doesNotMatch(markup, /Сеть (?:готова|настроена)|Сетевые настройки уже применены/); assert.doesNotMatch(markup, /Сеть (?:готова|настроена)|Сетевые настройки уже применены/);
assert.doesNotMatch(markup, /Пароль передан|FIELD-NET|>Применить</); assert.doesNotMatch(markup, /Пароль передан|FIELD-NET|>Применить</);
assert.doesNotMatch(markup, /aria-busy="true"/); assert.doesNotMatch(markup, /aria-busy="true"/);
@@ -6231,7 +6359,7 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p
desiredMode: "bridge", desiredMode: "bridge",
}, presentation); }, presentation);
assert.doesNotMatch(unrelatedMarkup, /op-b7ea404d/); assert.doesNotMatch(unrelatedMarkup, /op-b7ea404d/);
assert.match(unrelatedMarkup, /Результат применения сети не подтверждён/); assert.match(unrelatedMarkup, /Сохранённое подключение/);
}); });
test("read-only reconnect keeps its card and loader across a transient projection", async () => { test("read-only reconnect keeps its card and loader across a transient projection", async () => {
@@ -6294,7 +6422,7 @@ test("read-only reconnect keeps its card and loader across a transient projectio
assert.match(pendingReconnect[0], /disabled/); assert.match(pendingReconnect[0], /disabled/);
assert.match(pendingReconnect[0], /aria-busy="true"/); assert.match(pendingReconnect[0], /aria-busy="true"/);
assert.match(pendingReconnect[0], /nodedc-activity-indicator/); assert.match(pendingReconnect[0], /nodedc-activity-indicator/);
assert.match(pendingMarkup, /Сохранённое подключение требует проверки/); assert.match(pendingMarkup, /Сохранённое подключение/);
assert.doesNotMatch(pendingMarkup, /connection-action-progress/); assert.doesNotMatch(pendingMarkup, /connection-action-progress/);
assert.equal( assert.equal(
harness.stateValue(emptyReadOnlyReconnectPresentation)?.kind, harness.stateValue(emptyReadOnlyReconnectPresentation)?.kind,
@@ -6320,7 +6448,7 @@ test("read-only reconnect keeps its card and loader across a transient projectio
} }
}); });
test("cold saved new-device path resets once, performs zero Scan, and survives reload", async () => { test("cold saved target keeps fast reconnect after a reset and reload", async () => {
const state = terminalConnectionRecoveryState(); const state = terminalConnectionRecoveryState();
state.connection_attempt = null; state.connection_attempt = null;
const target = recommendedConnectionRecoveryObservationTarget(state); const target = recommendedConnectionRecoveryObservationTarget(state);
@@ -6424,14 +6552,18 @@ test("cold saved new-device path resets once, performs zero Scan, and survives r
}, },
desiredMode: "bridge", desiredMode: "bridge",
}); });
assert.equal(buttonMarkupWithText(reloadedMarkup, "Переподключиться").length, 0); const reloadedReconnect = buttonMarkupWithText(
reloadedMarkup,
"Переподключиться",
);
assert.equal(reloadedReconnect.length, 1);
assert.doesNotMatch(reloadedReconnect[0], /\bdisabled(?:=|\s|>)/);
const cleanScan = buttonMarkupWithText(reloadedMarkup, "Найти по Bluetooth"); const cleanScan = buttonMarkupWithText(reloadedMarkup, "Найти по Bluetooth");
assert.equal(cleanScan.length, 1); assert.equal(cleanScan.length, 1);
assert.doesNotMatch(cleanScan[0], /\bdisabled(?:=|\s|>)/); assert.doesNotMatch(cleanScan[0], /\bdisabled(?:=|\s|>)/);
assert.doesNotMatch( assert.match(reloadedMarkup, /Сохранённое подключение/);
reloadedMarkup, assert.match(reloadedMarkup, /Можно переподключиться/);
/Нужна проверка|Прежнее подключение не подтверждено|Проверяем прежний K1/, assert.doesNotMatch(reloadedMarkup, /Подключить новый K1/);
);
}); });
test("a current post-reset failed or unknown attempt outranks the durable reset marker after reload", () => { test("a current post-reset failed or unknown attempt outranks the durable reset marker after reload", () => {
@@ -6478,7 +6610,7 @@ test("a current post-reset failed or unknown attempt outranks the durable reset
}, },
desiredMode: "bridge", desiredMode: "bridge",
}); });
assert.match(unknownReloadMarkup, /Результат применения сети не подтверждён/); assert.match(unknownReloadMarkup, /Сохранённое подключение/);
assert.equal( assert.equal(
buttonMarkupWithText(unknownReloadMarkup, "Переподключиться").length, buttonMarkupWithText(unknownReloadMarkup, "Переподключиться").length,
1, 1,
@@ -6501,9 +6633,20 @@ test("a current post-reset failed or unknown attempt outranks the durable reset
controller: provisioningController(failed), controller: provisioningController(failed),
desiredMode: "bridge", desiredMode: "bridge",
}); });
assert.match(failedReloadMarkup, /Управление не подтверждено/); assert.match(failedReloadMarkup, /Сохранённое подключение/);
assert.match(failedReloadMarkup, /Новый выбор временно заблокирован/); assert.match(failedReloadMarkup, /Можно переподключиться/);
assert.doesNotMatch(failedReloadMarkup, /Совпадений нет|Ожидает/); assert.equal(
buttonMarkupWithText(failedReloadMarkup, "Переподключиться").length,
1,
);
assert.equal(
buttonMarkupWithText(failedReloadMarkup, "Подключить новый K1").length,
1,
);
assert.doesNotMatch(
failedReloadMarkup,
/Управление не подтверждено|выбор заблокирован|Новый выбор временно заблокирован/,
);
}); });
test("Apply never owns hidden refresh or an automatic continuation", () => { test("Apply never owns hidden refresh or an automatic continuation", () => {
@@ -9,6 +9,7 @@ let claimExclusiveLiveViewer;
let createRecordedOpenWatchdog; let createRecordedOpenWatchdog;
let createReentrantViewerDisposer; let createReentrantViewerDisposer;
let isLiveRerunPresentationReady; let isLiveRerunPresentationReady;
let liveTimelineNeedsSynchronization;
let liveRerunReceiverBindingIdentity; let liveRerunReceiverBindingIdentity;
let recordedOpenWatchdogTimeoutMs; let recordedOpenWatchdogTimeoutMs;
let rerunViewerInitialSource; let rerunViewerInitialSource;
@@ -26,6 +27,7 @@ before(async () => {
createRecordedOpenWatchdog, createRecordedOpenWatchdog,
createReentrantViewerDisposer, createReentrantViewerDisposer,
isLiveRerunPresentationReady, isLiveRerunPresentationReady,
liveTimelineNeedsSynchronization,
liveRerunReceiverBindingIdentity, liveRerunReceiverBindingIdentity,
recordedOpenWatchdogTimeoutMs, recordedOpenWatchdogTimeoutMs,
rerunViewerInitialSource, rerunViewerInitialSource,
@@ -82,6 +84,14 @@ test("live presentation waits for the exact receiver to expose a usable range",
assert.equal(isLiveRerunPresentationReady(true, { min: 1, max: 1 }, 1), true); assert.equal(isLiveRerunPresentationReady(true, { min: 1, max: 1 }, 1), true);
}); });
test("live timeline is retried only after stream_time exists and until it is active", () => {
assert.equal(liveTimelineNeedsSynchronization(false, undefined, { min: 1, max: 2 }), false);
assert.equal(liveTimelineNeedsSynchronization(true, undefined, null), false);
assert.equal(liveTimelineNeedsSynchronization(true, undefined, { min: 1, max: 2 }), true);
assert.equal(liveTimelineNeedsSynchronization(true, "log_time", { min: 1, max: 2 }), true);
assert.equal(liveTimelineNeedsSynchronization(true, "stream_time", { min: 1, max: 2 }), false);
});
test("live receiver binding stays stable across recovery authority projections", () => { test("live receiver binding stays stable across recovery authority projections", () => {
const sourceUrl = "rerun+http://127.0.0.1:9877/proxy"; const sourceUrl = "rerun+http://127.0.0.1:9877/proxy";
const streamId = "acq-001"; const streamId = "acq-001";
@@ -217,7 +227,11 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
); );
assert.match( assert.match(
source, source,
/const readyToRender = followLive\s*\? isLiveRerunPresentationReady\(/, /const readyToRender = followLive\s*\? liveTimelineSynchronized && isLiveRerunPresentationReady\(/,
);
assert.match(
source,
/viewer\.get_active_timeline\(event\.recording_id\)[\s\S]*viewer\.set_active_timeline\(event\.recording_id, timeline\)/,
); );
assert.match( assert.match(
source, source,
@@ -257,7 +271,16 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan"
new URL("../src/workspaces/Workspaces.tsx", import.meta.url), new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
"utf8", "utf8",
); );
assert.match(source, /followLive=\{!recordedReplay && streamActive\}/); assert.match(
source,
/const liveRerunSource = !recordedSource && \/\^rerun\\\+https\?:\\\/\\\//,
);
assert.match(
source,
/const livePresentationActivitySequence = metrics\?\.publishedFrameCount \?\?[\s\S]*liveRerunSource && !streamActive \? 1 : null/,
);
assert.match(source, /followLive=\{liveRerunSource\}/);
assert.match(source, /liveActivitySequence=\{livePresentationActivitySequence\}/);
assert.match( assert.match(
source, source,
/sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/, /sourceUrl\.trim\(\) && pointCloudVisible && !intentionalSourceEnd/,
+21 -12
View File
@@ -1,6 +1,6 @@
# K1 connection supervision canon # K1 connection supervision canon
Status: canonical target and product acceptance contract, updated 2026-08-11; Status: canonical target and product acceptance contract, updated 2026-08-20;
implementation/hardware conformance remains tracked by the acceptance manifest. implementation/hardware conformance remains tracked by the acceptance manifest.
Operator recovery procedure: [`runbooks/K1_CONNECTION_RECOVERY.md`](runbooks/K1_CONNECTION_RECOVERY.md). Operator recovery procedure: [`runbooks/K1_CONNECTION_RECOVERY.md`](runbooks/K1_CONNECTION_RECOVERY.md).
@@ -16,10 +16,12 @@ point packet means that the device is connected.
The product goal is a simple operator-owned lifecycle: every app start, The product goal is a simple operator-owned lifecycle: every app start,
disconnect, explicit stop, or committed network-mode transition ends the old disconnect, explicit stop, or committed network-mode transition ends the old
connection session. A normal connection has one discovery intent and one live connection authority. The last exactly confirmed K1 and topology remain a
application intent: the operator explicitly searches, selects one fresh result, durable read-only fast-reconnect target until the operator changes the device or
enters the network data immediately, and presses **Применить** once. Opening the network. A normal new connection has one discovery intent and one application
UI, selecting a result and editing credentials are presentation inputs only. 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 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 device or host I/O and starts no discovery. Historical attempts are audit
evidence only. They never evidence only. They never
@@ -109,9 +111,14 @@ terminology, physical-ledger state and protocol recovery names are not operator
ceremonies. ceremonies.
1. A cold plugin section shows the connection-mode selector and Step 01 1. A cold plugin section shows the connection-mode selector and Step 01
**Подключение** with its explicit Bluetooth search action. Historical K1 **Подключение**. When an exact last confirmed K1 and unchanged topology are
identity never adds a reconnect prompt on cold entry. Opening or polling the available, it shows the saved target with **Переподключиться** plus the
surface performs no action. separate **Найти по Bluetooth** path. The reconnect action performs only a
server-pinned read-only observation; it sends no network settings, START or
STOP. A browser-cache reset or local scenario reset closes live authority but
does not erase this durable offer. Audit-only historical identities do not
create it. Without an eligible durable target, Step 01 shows only the explicit
Bluetooth search action. Opening or polling the surface performs no action.
Each selector/escape gesture sends one explicit CAS-fenced Each selector/escape gesture sends one explicit CAS-fenced
`reset_scenario`; it may seal only old local receiver/camera/control owners `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, and retire their audit lineage, but performs no BLE, device/host Wi-Fi,
@@ -173,9 +180,11 @@ reopened record from fresh DeviceInfo plus non-retained DeviceStatus: READY
settles standby, while identity-bound initialized SCANNING settles active without replaying settles standby, while identity-bound initialized SCANNING settles active without replaying
START and materializes only a STOP-only control shell. A reset-owned settlement START and materializes only a STOP-only control shell. A reset-owned settlement
never restarts the retired receiver, camera, evidence writer or acquisition. never restarts the retired receiver, camera, evidence writer or acquisition.
Exact operator-facing physical recovery keeps its backend CAS and Exact operator-facing physical recovery keeps its backend CAS and transport
transport pinning, but appears only inside an established session after an pinning and remains distinct from cold fast reconnect. Fast reconnect only
actual interruption, never in cold entry or Bluetooth results. re-proves an unchanged saved target; unresolved physical START/STOP state still
uses the stricter recovery path. Neither action appears inside Bluetooth result
rows.
Outside that reset-owned STOP-only settlement, fresh non-retained READY proof 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 resolves physical ambiguity as standby; fresh exact same-project SCANNING resolves it as active and exposes only the
@@ -248,7 +257,7 @@ not fabricate the state of another row.
| Boundary that can fail | Typical real scenario | Required response | | Boundary that can fail | Typical real scenario | Required response |
| --- | --- | --- | | --- | --- | --- |
| Browser ↔ local API | app refresh/restart, closed tab, suspended renderer, lost HTTP response | App restart/close ends its operator connection session without sending a device command; a single lost response never repeats an action. The next app session starts with discovery and selection. | | Browser ↔ local API | app refresh/restart, closed tab, suspended renderer, lost HTTP response | App restart/close ends live operator authority without sending a device command; a single lost response never repeats an action. When an exact last confirmed K1/topology remains durable, the next app session offers an explicit read-only fast reconnect alongside fresh discovery. Otherwise it starts with discovery and selection. |
| Mission Core process | crash, upgrade, second process, stale worker thread | Close the active connection session on restart; retain terminal audit only; stable process locks prevent a second controller; old runtime generations cannot publish evidence into a new session. | | 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. | | 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. | | 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. |
+34 -21
View File
@@ -12,16 +12,19 @@ the wizard the two step names are exactly **Подключение** and **Се
### Cold entry ### Cold entry
On a clean cold entry show only: On a clean cold entry show:
- the connection-mode selector; - the connection-mode selector;
- Step 01 **Подключение** with the explicit Bluetooth search action. - Step 01 **Подключение**;
- when an exact last confirmed K1 and unchanged topology are durable,
**Переподключиться** and the separate **Найти по Bluetooth** path;
- otherwise only the explicit Bluetooth search action.
Historical K1 identity never adds a reconnect choice to cold entry. If local **Переподключиться** is a read-only fast connection to the server-pinned last
session ownership or an older connection scenario exists, one explicit K1. It does not send network settings, START or STOP. Historical audit identity
`reset_scenario` CAS first closes only that local scenario. Only after the reset alone never creates this choice. A browser-cache reset or accepted
is accepted does clean Step 01 expose **Найти по Bluetooth** as a separate `reset_scenario` closes the old live/local authority but does not erase the
click; reset never starts Scan itself. last confirmed target. Reset never starts reconnect or Scan itself.
Do not render Step 02 yet and do not start discovery automatically. The mode Do not render Step 02 yet and do not start discovery automatically. The mode
selector and same-mode **Подключить новый K1** escape remain available through selector and same-mode **Подключить новый K1** escape remain available through
@@ -42,12 +45,14 @@ the request owns the current action it reads **Сбрасываем подклю
does not dispatch a second reset until that bounded request settles. It remains does not dispatch a second reset until that bounded request settles. It remains
available to supersede any other local action. The accepted revision always available to supersede any other local action. The accepted revision always
returns the new local scenario and even a dirty browser selector to canonical returns the new local scenario and even a dirty browser selector to canonical
**Bridge**, clears the old browser/backend presentation and source owners, and **Bridge**, clears old browser drafts and source owners, and leaves both an
leaves Scan as a separate click. It performs no hidden Scan, Verify, Connect, eligible durable fast reconnect and Scan as separate clicks. It performs no
hidden Scan, Verify, Connect,
START, STOP, BLE or network write and never substitutes a passive `state.read` 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 for the reset mutation. After settlement the product surface contains no stale
UUID, result count, **Повторить поиск**, reconnect error, selected device, result count, **Повторить поиск**, reconnect error, selected draft or
credentials or recovery card; it returns to **Найти по Bluetooth**. Late credentials. It retains only the exact durable fast-reconnect card when one is
eligible, alongside **Найти по Bluetooth**. Late
Scan/Verify settlements from the retired scenario cannot repopulate it. Scan/Verify settlements from the retired scenario cannot repopulate it.
The retained reset marker fences only work that belonged to the retired 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 scenario. A newly correlated post-reset network attempt that fails or has an
@@ -196,8 +201,10 @@ CoreBluetooth object and live GATT validation before any write.
The selected session ends on proven disconnect, explicit lifecycle stop, a The selected session ends on proven disconnect, explicit lifecycle stop, a
committed network transition, selection of another device, backend restart or committed network transition, selection of another device, backend restart or
proven native cleanup. A later connection always requires an explicit search proven native cleanup. A later new-device connection requires an explicit search
and **Выбрать**. Polling can update presentation but starts neither operation. and **Выбрать**. An unchanged exact last confirmed K1 may instead use the
explicit read-only fast reconnect. Polling can update presentation but starts
neither operation.
Bridge, Quick Connect and Direct Connect are separate topologies. In 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 any state, changing the mode or choosing another K1 in the same mode sends one
@@ -260,7 +267,8 @@ fenced.
| Event | Product result | Operator path | | Event | Product result | Operator path |
| --- | --- | --- | | --- | --- | --- |
| Cold entry | Mode plus Step 01 and explicit Scan; zero device I/O before Scan | Start search explicitly | | Cold entry with an exact last confirmed K1/topology | Mode plus Step 01, read-only **Переподключиться**, and separate explicit Scan; zero device I/O before a click | Fast reconnect or start search explicitly |
| Cold entry without an eligible durable target | Mode plus Step 01 and explicit Scan; zero device I/O before Scan | Start search explicitly |
| Disconnected/idle mode or same-mode new-device request with unresolved durable physical history | Local session/audit lineage is retired under one reset CAS; zero device/host I/O and no automatic Scan | Start the clean Step 01 search explicitly; the old physical outcome remains auditable | | 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 | | 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 running | Step 01 spinner and visible countdown | Wait or let the bounded search end |
@@ -280,7 +288,8 @@ fenced.
| A physical START/STOP edge is unresolved | Mutation stays fenced; no technical wizard ceremony | Search/select remains explicit; backend admits only a safe exact path | | 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 | | 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 | | Another candidate is selected while old authority is unavailable and no reset-owned new scenario exists | Selection stays local and Apply remains denied | Start an explicit new connection scenario, then Scan and select again |
| Browser refresh or backend restart | No automatic operation and no restored live selection | Begin from the cold progressive wizard | | Browser refresh or cache reset | No automatic operation and no restored live authority; exact durable last target remains available for read-only fast reconnect | Reconnect explicitly or begin a fresh Bluetooth search |
| Backend restart without an eligible durable target | No automatic operation and no restored live selection | Begin from the cold progressive wizard |
## Physical START/STOP safety remains separate ## Physical START/STOP safety remains separate
@@ -336,15 +345,19 @@ loader belongs to the explicit action that created it and ends with it.
Software tests do not replace a real K1/macOS/router run. Accept sequentially: 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 1. Open cold and prove mode plus Step 01 are visible, while Step 02 is absent
visible, while Step 02 is absent and no discovery starts automatically. With both empty and and no discovery starts automatically. With an exact last confirmed K1 and
unchanged topology, prove **Переподключиться** and **Найти по Bluetooth** are
both available after reload and browser-cache reset; reconnect performs only
read-only verification. With no eligible durable target, prove only explicit
Scan is available. With both empty and
unresolved durable physical history, change the mode and prove one local unresolved durable physical history, change the mode and prove one local
reset CAS, zero device/host calls and no automatic Scan. Repeat from active, reset CAS, zero device/host calls and no automatic Scan. Repeat from active,
reconnecting and terminal `cleanup_pending` states; prove local sources are reconnecting and terminal `cleanup_pending` states; prove local sources are
sealed, the old K1 is not claimed stopped, and a local cleanup failure leaves 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 the exact reset retryable. With an exact prior connection, prove one reset
contains no historical reconnect prompt; after one reset CAS and zero Scan, CAS and zero Scan preserve its fast-reconnect card and a separate clean
a separate clean **Найти по Bluetooth** action remains clean after reload. **Найти по Bluetooth** action after reload.
2. Start discovery and prove the spinner and seconds countdown remain visible 2. Start discovery and prove the spinner and seconds countdown remain visible
for the bounded search, then the exact result count appears. for the bounded search, then the exact result count appears.
3. With multiple advertisements, prove every ordinary connectable row keeps 3. With multiple advertisements, prove every ordinary connectable row keeps
@@ -27,6 +27,7 @@ import {
recoverableAcquisition, recoverableAcquisition,
requiresCanonicalStopAfterTerminalLocalFailure, requiresCanonicalStopAfterTerminalLocalFailure,
requiresReadOnlyPhysicalRecovery, requiresReadOnlyPhysicalRecovery,
savedBridgeRequiresNetworkSetup,
sourceStatusLabel, sourceStatusLabel,
} from "./lifecycle"; } from "./lifecycle";
import { phaseLabel, phaseTone } from "./presentation"; import { phaseLabel, phaseTone } from "./presentation";
@@ -34,6 +35,7 @@ import {
useXgridsK1Controller, useXgridsK1Controller,
type XgridsK1Controller, type XgridsK1Controller,
} from "./runtimeContext"; } from "./runtimeContext";
import type { PendingAction } from "./useXgridsK1Runtime";
import type { XgridsK1State } from "./api"; import type { XgridsK1State } from "./api";
import { import {
DEFAULT_CONNECTION_MODE, DEFAULT_CONNECTION_MODE,
@@ -59,6 +61,9 @@ export function physicalRecoveryConnectionDetail(
state: XgridsK1State | null | undefined, state: XgridsK1State | null | undefined,
): string | null { ): string | null {
if (!requiresReadOnlyPhysicalRecovery(state)) return null; if (!requiresReadOnlyPhysicalRecovery(state)) return null;
if (savedBridgeRequiresNetworkSetup(state)) {
return "K1 ответил по Bluetooth, но не подключён к сохранённой общей сети. Настройки устройства не изменялись; подключите K1 к общей сети заново.";
}
const retirementAvailable = Boolean( const retirementAvailable = Boolean(
unavailablePhysicalRetirementAuthority(state), unavailablePhysicalRetirementAuthority(state),
); );
@@ -91,9 +96,11 @@ function connectionPhaseFallbackLabel(phase: string | null | undefined): string
*/ */
export function shouldRenderK1OperationalPanels( export function shouldRenderK1OperationalPanels(
state: XgridsK1State | null | undefined, state: XgridsK1State | null | undefined,
pendingAction: PendingAction | null = null,
): boolean { ): boolean {
return Boolean( return Boolean(
hasControlAuthority(state) pendingAction === "live"
|| hasControlAuthority(state)
|| state?.source_mode === "live" || state?.source_mode === "live"
|| state?.source_mode === "replay" || state?.source_mode === "replay"
|| recoverableAcquisition(state) || recoverableAcquisition(state)
@@ -167,11 +174,13 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
const confirmedLive = isConfirmedLiveState(state); const confirmedLive = isConfirmedLiveState(state);
const sourceRuntimeBusy = isSourceRuntimeBusy(state); const sourceRuntimeBusy = isSourceRuntimeBusy(state);
const livePreparationPending = controller.pendingAction === "live";
const preparedAcquisition = recoverableAcquisition(state)?.state === "prepared"; const preparedAcquisition = recoverableAcquisition(state)?.state === "prepared";
const activeRecoveryPresentation = activeStreamRecoveryPresentation(state); const activeRecoveryPresentation = activeStreamRecoveryPresentation(state);
const sourceLabel = activeRecoveryPresentation?.title ?? sourceStatusLabel(state); const sourceLabel = activeRecoveryPresentation?.title ?? sourceStatusLabel(state);
const releasedAcquisitionFailure = isReleasedTerminalAcquisitionFailure(state); const releasedAcquisitionFailure = isReleasedTerminalAcquisitionFailure(state);
const physicalRecoveryRequired = requiresReadOnlyPhysicalRecovery(state); const physicalRecoveryRequired = requiresReadOnlyPhysicalRecovery(state);
const savedBridgeNetworkSetupRequired = savedBridgeRequiresNetworkSetup(state);
const physicalStopRecoverySettling = isPhysicalStopRecoverySettling(state); const physicalStopRecoverySettling = isPhysicalStopRecoverySettling(state);
const recoveredPhysicalScanning = physicalRecoveryRequired const recoveredPhysicalScanning = physicalRecoveryRequired
&& state?.application_control_session?.state === "scanning" && state?.application_control_session?.state === "scanning"
@@ -265,7 +274,9 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
: sourceRuntimeBusy || preparedAcquisition : sourceRuntimeBusy || preparedAcquisition
? "warning" ? "warning"
: "neutral"; : "neutral";
const connectionPhaseLabel = sourceRuntimeBusy || preparedAcquisition const connectionPhaseLabel = livePreparationPending
? "Подготовка приёма"
: sourceRuntimeBusy || preparedAcquisition
? sourceLabel ? sourceLabel
: activeRecoveryPresentation : activeRecoveryPresentation
? activeRecoveryPresentation.title ? activeRecoveryPresentation.title
@@ -274,7 +285,9 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
: recoveredPhysicalScanning : recoveredPhysicalScanning
? "Сканирование продолжается" ? "Сканирование продолжается"
: physicalRecoveryRequired : physicalRecoveryRequired
? "Требуется действие" ? savedBridgeNetworkSetupRequired
? "Нужна настройка сети"
: "Требуется действие"
: projectedPhase === "error" : projectedPhase === "error"
? connectionPhaseFallbackLabel(projectedPhase) ? connectionPhaseFallbackLabel(projectedPhase)
: connectionTopology?.status === "active" : connectionTopology?.status === "active"
@@ -288,7 +301,9 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
: connectionTopology?.source === "last-known" : connectionTopology?.source === "last-known"
? "Подключение отсутствует" ? "Подключение отсутствует"
: connectionPhaseFallbackLabel(projectedPhase); : connectionPhaseFallbackLabel(projectedPhase);
const connectionPhaseTone = sourceRuntimeBusy || preparedAcquisition const connectionPhaseTone = livePreparationPending
? "warning"
: sourceRuntimeBusy || preparedAcquisition
? sourceTone ? sourceTone
: activeRecoveryPresentation : activeRecoveryPresentation
? activeRecoveryPresentation.tone ? activeRecoveryPresentation.tone
@@ -301,7 +316,9 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
: connectionTopology?.status === "configured-unverified" : connectionTopology?.status === "configured-unverified"
? "neutral" ? "neutral"
: "neutral"; : "neutral";
const connectionPhaseDetail = physicalStopRecoverySettling const connectionPhaseDetail = livePreparationPending
? "Подготовка продолжается."
: physicalStopRecoverySettling
? "Команда остановки уже принята. Завершение выполняется без повторной команды." ? "Команда остановки уже принята. Завершение выполняется без повторной команды."
: activeRecoveryPresentation : activeRecoveryPresentation
? activeRecoveryPresentation.detail ? activeRecoveryPresentation.detail
@@ -315,7 +332,10 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
: !sourceRuntimeBusy && connectionTopology?.status === "active" : !sourceRuntimeBusy && connectionTopology?.status === "active"
? "Готово к новой сессии." ? "Готово к новой сессии."
: "Ожидается состояние локального контура."; : "Ожидается состояние локального контура.";
const operationalPanelsVisible = shouldRenderK1OperationalPanels(state); const operationalPanelsVisible = shouldRenderK1OperationalPanels(
state,
controller.pendingAction,
);
return ( return (
<div className="device-workspace xgrids-k1-plugin"> <div className="device-workspace xgrids-k1-plugin">
@@ -293,9 +293,6 @@ export function K1AcquisitionPipeline({
projectNameValidation.value, projectNameValidation.value,
); );
const physicalStartAllowed = connectionPolicyAllows(state, "start-acquisition"); const physicalStartAllowed = connectionPolicyAllows(state, "start-acquisition");
const physicalStartGuidance = finalStartTarget && !physicalStartAllowed
? connectionPolicyOperatorGuidance(state, "start-acquisition")
: null;
const physicalStopGuidance = gracefulStopTarget const physicalStopGuidance = gracefulStopTarget
&& !physicalStopPresented && !physicalStopPresented
&& !terminalPhysicalStopObserved && !terminalPhysicalStopObserved
@@ -317,8 +314,9 @@ export function K1AcquisitionPipeline({
? "Команда устройству недоступна в текущем подтверждённом состоянии. Завершите только локальный приём или выполните read-only восстановление." ? "Команда устройству недоступна в текущем подтверждённом состоянии. Завершите только локальный приём или выполните read-only восстановление."
: null; : null;
const submitFinalStart = async () => { const submitFinalStart = async (
const physicalAcceptance = operatorActionPhysicalAcceptance(); physicalAcceptance = operatorActionPhysicalAcceptance(),
) => {
await runAutomaticSpatialSourceStart( await runAutomaticSpatialSourceStart(
() => startPreparedAcquisition(physicalAcceptance), () => startPreparedAcquisition(physicalAcceptance),
activateAutomaticSpatialSource, activateAutomaticSpatialSource,
@@ -342,6 +340,7 @@ export function K1AcquisitionPipeline({
return; return;
} }
if (!draftPreparationTarget) return; if (!draftPreparationTarget) return;
const physicalAcceptance = operatorActionPhysicalAcceptance();
const prepared = await prepareCanonicalAcquisition({ const prepared = await prepareCanonicalAcquisition({
acquisition: { acquisition: {
project_name: projectNameValidation.value, project_name: projectNameValidation.value,
@@ -349,9 +348,10 @@ export function K1AcquisitionPipeline({
gnss_mode: SUPPORTED_GNSS_MODE, gnss_mode: SUPPORTED_GNSS_MODE,
compatibility_attestation: profileSelectionForConnectionMode(connectionMode), compatibility_attestation: profileSelectionForConnectionMode(connectionMode),
}, },
physicalAcceptance,
}); });
if (!prepared) return; if (!prepared) return;
await submitFinalStart(); await submitFinalStart(physicalAcceptance);
}; };
const submitReplay = async () => { const submitReplay = async () => {
@@ -458,7 +458,6 @@ export function K1AcquisitionPipeline({
</div> </div>
<TextField <TextField
label="Название проекта" label="Название проекта"
hint="Имя войдёт в единственный канонический START"
value={projectName} value={projectName}
onChange={(event) => { onChange={(event) => {
setProjectName(event.target.value); setProjectName(event.target.value);
@@ -470,7 +469,7 @@ export function K1AcquisitionPipeline({
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined} aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
description={projectNameTouched && projectNameValidation.error description={projectNameTouched && projectNameValidation.error
? projectNameValidation.error ? projectNameValidation.error
: "Имя отправляется только при START; отдельной команды сохранения нет."} : undefined}
placeholder="Например, TEST001" placeholder="Например, TEST001"
/> />
<Button <Button
@@ -507,15 +506,6 @@ export function K1AcquisitionPipeline({
? "Продолжить запуск" ? "Продолжить запуск"
: "Запустить приём"} : "Запустить приём"}
</Button> </Button>
<p className="start-confirmation-note">
{modeSwitchRequired
? `Выбран другой способ связи. Сначала установите подключение через ${desiredConnectionMode === "bridge" ? "Bridge" : desiredConnectionMode === "quick-connect" ? "Quick Connect" : "Direct Connect"}.`
: !connectionConfigured
? "Сначала завершите подключение в выбранном режиме. START не используется для установки связи."
: physicalStartGuidance
? `${physicalStartGuidance.reason} ${physicalStartGuidance.nextAction}`
: "Одно нажатие выполняет каноническую подготовку и один START после подтверждённого READY. Автоматических повторов команд нет."}
</p>
{control?.control_socket_open && !activeAcquisition && !recoveredPhysicalStop && !isBusy ? ( {control?.control_socket_open && !activeAcquisition && !recoveredPhysicalStop && !isBusy ? (
<Button <Button
variant="ghost" variant="ghost"
@@ -525,21 +515,6 @@ export function K1AcquisitionPipeline({
Отменить запуск до START Отменить запуск до START
</Button> </Button>
) : null} ) : null}
<p className="live-instruction">
{controlPhase === "failed"
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручное действие"}`
: controlPhase === "connecting"
? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ."
: controlPhase === "workspace-requested"
? "После подтверждённых операций 1–6 выполняется вход в рабочее пространство."
: controlPhase === "project-requested"
? "Выполняются операции 8–10 и готовится локальный приём; имя ещё не отправляется."
: controlPhase === "start-requested" || controlPhase === "initializing"
? "Калибровка оборудования. Не перемещайте сканер; временных переходов и повторных команд нет."
: controlPhase === "scanning"
? "Режим сканирования и инициализация подтверждены. Остановка доступна в пространственной сцене."
: "Одна кнопка запускает весь процесс. Совместимость подключения подтверждается автоматически; каждый следующий этап начинается только после подтверждения результата."}
</p>
</div> </div>
) : ( ) : (
<div className="session-form session-form--replay"> <div className="session-form session-form--replay">
@@ -59,6 +59,7 @@ import {
requiresReadOnlyPhysicalRecovery, requiresReadOnlyPhysicalRecovery,
reopenedPhysicalReconciliationMatches, reopenedPhysicalReconciliationMatches,
retiredPhysicalReopenAuthority, retiredPhysicalReopenAuthority,
savedBridgeRequiresNetworkSetup,
serverBoundAppliedNetworkObservationTarget, serverBoundAppliedNetworkObservationTarget,
transportRefEquivalenceKey, transportRefEquivalenceKey,
trustedConnectionBinding, trustedConnectionBinding,
@@ -358,12 +359,11 @@ export function connectionAttemptOwnsAppliedNetworkRecovery(
|| attempt.phase !== "network_applied" || attempt.phase !== "network_applied"
|| attempt.control_state === "ready" || attempt.control_state === "ready"
) return false; ) return false;
if (["accepted", "running"].includes(attempt.status)) return true; // Only an operation that is still executing owns the screen. A terminal
return [ // network-applied record is audit history: the backend may continue to use
"continue-with-control-verification", // it as a command fence, but it must never replace the operator's two normal
"verify-control-read-only", // choices (verify the saved K1 or start a fresh Bluetooth connection).
"manual-recovery-required", return ["accepted", "running"].includes(attempt.status);
].includes(attempt.safe_next_action);
} }
export async function dispatchUnavailablePhysicalRetirementForCurrentRuntime<T>( export async function dispatchUnavailablePhysicalRetirementForCurrentRuntime<T>(
@@ -1537,6 +1537,7 @@ export function K1ProvisioningPipeline({
typeof isSnapshotRuntimeCurrent !== "function" typeof isSnapshotRuntimeCurrent !== "function"
|| runtimeActionIsCurrent(currentRuntimeActionFence) || runtimeActionIsCurrent(currentRuntimeActionFence)
) ? pendingAction : null; ) ? pendingAction : null;
const livePreparationPending = pendingAction === "live";
const reconfiguration = activeConnectionReconfiguration(state); const reconfiguration = activeConnectionReconfiguration(state);
const reconfigurationRevision = state?.connection_reconfiguration?.revision ?? 0; const reconfigurationRevision = state?.connection_reconfiguration?.revision ?? 0;
const reconfigurationIntentId = reconfiguration?.intent_id ?? null; const reconfigurationIntentId = reconfiguration?.intent_id ?? null;
@@ -1660,7 +1661,8 @@ export function K1ProvisioningPipeline({
const physicalRecoveryVerificationPending = const physicalRecoveryVerificationPending =
currentReadOnlyReconnectPresentation?.kind === "physical"; currentReadOnlyReconnectPresentation?.kind === "physical";
const provisioningMutationBusy = Boolean( const provisioningMutationBusy = Boolean(
presentedPendingAction livePreparationPending
|| presentedPendingAction
|| modeResetPending !== null || modeResetPending !== null
|| currentPreparingReconfigurationRequest || currentPreparingReconfigurationRequest
|| searchDisplayActive || searchDisplayActive
@@ -1731,6 +1733,11 @@ export function K1ProvisioningPipeline({
const appliedNetworkAttempt = state?.connection_attempt?.phase === "network_applied" const appliedNetworkAttempt = state?.connection_attempt?.phase === "network_applied"
? state.connection_attempt ? state.connection_attempt
: null; : null;
const terminalAppliedRecoveryAttempt = appliedNetworkAttempt
&& appliedNetworkAttempt.control_state !== "ready"
&& !["accepted", "running"].includes(appliedNetworkAttempt.status)
? appliedNetworkAttempt
: null;
const correlatedConnectionAttempt = connectionAttemptForRuntimeError( const correlatedConnectionAttempt = connectionAttemptForRuntimeError(
errorCorrelation, errorCorrelation,
state, state,
@@ -1741,9 +1748,11 @@ export function K1ProvisioningPipeline({
? state.connection_attempt ? state.connection_attempt
: null; : null;
const connectionRecoveryAttempt = correlatedConnectionAttempt const connectionRecoveryAttempt = correlatedConnectionAttempt
?? unknownNetworkOutcomeAttempt; ?? unknownNetworkOutcomeAttempt
?? terminalAppliedRecoveryAttempt;
const connectionRecoveryObservationAllowed = Boolean( const connectionRecoveryObservationAllowed = Boolean(
!connectionRecoveryAttempt !connectionRecoveryAttempt
|| connectionRecoveryAttempt.phase === "network_applied"
|| [ || [
"continue-with-control-verification", "continue-with-control-verification",
"verify-control-read-only", "verify-control-read-only",
@@ -1780,15 +1789,14 @@ export function K1ProvisioningPipeline({
const physicalRecoveryRequired = requiresReadOnlyPhysicalRecovery(state); const physicalRecoveryRequired = requiresReadOnlyPhysicalRecovery(state);
const networkRecoveryRequired = unresolvedAppliedAttempt !== null const networkRecoveryRequired = unresolvedAppliedAttempt !== null
&& !physicalRecoveryRequired; && !physicalRecoveryRequired;
// Scan keeps the reset-owned clean draft intact, while the first later // A settled reset owns the clean new-device draft, but it must not erase the
// Connect/Verify operation releases it. This hides only pre-reset saved // separately durable fast-reconnect offer. The operator can either verify
// history and never masks a new post-reset failure. // the last confirmed K1 without writes or start a fresh Bluetooth search.
const scenarioResetOwnsCleanDraft = scenarioResetOwnsCleanConnectionDraft( const scenarioResetOwnsCleanDraft = scenarioResetOwnsCleanConnectionDraft(
state, state,
connectionMode, connectionMode,
); );
const connectionRecoveryTarget = !scenarioResetOwnsCleanDraft const connectionRecoveryTarget = !physicalRecoveryRequired
&& !physicalRecoveryRequired
&& !unresolvedAppliedAttempt && !unresolvedAppliedAttempt
&& !selectedModeConnected && !selectedModeConnected
&& !reconfigurationActive && !reconfigurationActive
@@ -1805,10 +1813,12 @@ export function K1ProvisioningPipeline({
connectionRecoveryKey, connectionRecoveryKey,
escapedConnectionRecoveryKey, escapedConnectionRecoveryKey,
) && !(searchRequested && !searchDisplayActive); ) && !(searchRequested && !searchDisplayActive);
const presentedConnectionRecoveryRequired = connectionRecoveryRequired const presentedConnectionRecoveryRequired = !livePreparationPending && (
|| connectionRecoveryVerificationPending; connectionRecoveryRequired || connectionRecoveryVerificationPending
);
const presentedPhysicalRecoveryRequired = physicalRecoveryRequired const presentedPhysicalRecoveryRequired = physicalRecoveryRequired
|| physicalRecoveryVerificationPending; || physicalRecoveryVerificationPending;
const savedBridgeNetworkSetupRequired = savedBridgeRequiresNetworkSetup(state);
const appliedControlSettlementPending = Boolean( const appliedControlSettlementPending = Boolean(
unresolvedAppliedAttempt unresolvedAppliedAttempt
&& ["accepted", "running"].includes(unresolvedAppliedAttempt.status) && ["accepted", "running"].includes(unresolvedAppliedAttempt.status)
@@ -1893,10 +1903,12 @@ export function K1ProvisioningPipeline({
&& !physicalStopRecoverySettling, && !physicalStopRecoverySettling,
); );
const trustedBinding = trustedConnectionBinding(state); const trustedBinding = trustedConnectionBinding(state);
const connectedEndpoint = selectedModeConnected const connectionPresentationEstablished = selectedModeConnected
|| livePreparationPending;
const connectedEndpoint = connectionPresentationEstablished
? selectedModeTopology?.endpoint?.trim() || null ? selectedModeTopology?.endpoint?.trim() || null
: null; : null;
const connectedDeviceIdentity = selectedModeConnected const connectedDeviceIdentity = connectionPresentationEstablished
? selectedTarget?.label ? selectedTarget?.label
|| retainedSessionLabel || retainedSessionLabel
|| (trustedBinding?.connectionMode === connectionMode || (trustedBinding?.connectionMode === connectionMode
@@ -1951,7 +1963,7 @@ export function K1ProvisioningPipeline({
!presentedPhysicalRecoveryRequired !presentedPhysicalRecoveryRequired
&& !presentedConnectionRecoveryRequired && !presentedConnectionRecoveryRequired
&& ( && (
selectedModeConnected connectionPresentationEstablished
|| explicitProvisioningDraftRetained || explicitProvisioningDraftRetained
|| explicitProvisioningDraftContextRetained || explicitProvisioningDraftContextRetained
|| unresolvedAppliedAttempt || unresolvedAppliedAttempt
@@ -2619,10 +2631,13 @@ export function K1ProvisioningPipeline({
) return; ) return;
if (!dispatched.result.succeeded) { if (!dispatched.result.succeeded) {
setCandidateUnavailableMessage( setCandidateUnavailableMessage(
"Не удалось переподключиться к прежнему K1. Проверьте питание и сеть, затем повторите проверку.", dispatched.result.reasonCode === "connection-verify-address-unavailable"
? "K1 ответил по Bluetooth, но не подключён к сохранённой общей сети. Старый Bridge использовать нельзя; подключите K1 к общей сети заново — настройки устройства не менялись."
: "Не удалось переподключиться к прежнему K1. Проверьте питание и сеть, затем повторите проверку.",
); );
return; return;
} }
clearError();
setCandidateUnavailableMessage(null); setCandidateUnavailableMessage(null);
} finally { } finally {
setReadOnlyReconnectPresentation((current) => ( setReadOnlyReconnectPresentation((current) => (
@@ -2699,14 +2714,20 @@ export function K1ProvisioningPipeline({
const actionFence = activateRuntimeActionFence(modeAuthority); const actionFence = activateRuntimeActionFence(modeAuthority);
if (!actionFence || !runtimeActionIsCurrent(actionFence)) return; if (!actionFence || !runtimeActionIsCurrent(actionFence)) return;
try { try {
const result = await verifyConnection(request); const result = await verifyConnection(request, { surfaceErrors: false });
if (!runtimeClickIsCurrent(actionFence)) return; if (!runtimeClickIsCurrent(actionFence)) return;
if (!result.succeeded) { if (!result.succeeded) {
setCandidateUnavailableMessage( setCandidateUnavailableMessage(
"Не удалось переподключиться к прежнему K1. Проверьте питание и сеть, затем повторите проверку.", result.reasonCode === "connection-verify-address-unavailable"
? "K1 ответил по Bluetooth, но не подключён к сохранённой общей сети. Старый Bridge использовать нельзя; подключите K1 к общей сети заново — настройки устройства не менялись."
: "Не удалось переподключиться к прежнему K1. Проверьте питание и сеть, затем повторите проверку.",
); );
return; return;
} }
// A successful authoritative Verify supersedes any earlier local
// connection banner, including a failure that settled immediately
// before the recovered state reached this browser.
clearError();
if ( if (
result.observedState result.observedState
&& isRecoveredPhysicalScanning( && isRecoveredPhysicalScanning(
@@ -2882,7 +2903,7 @@ export function K1ProvisioningPipeline({
// Only an explicit public search owns the Step-1 Bluetooth loader. // Only an explicit public search owns the Step-1 Bluetooth loader.
const searchActive = searchDisplayActive; const searchActive = searchDisplayActive;
const connectionEstablished = selectedModeConnected; const connectionEstablished = connectionPresentationEstablished;
const connectionAttemptOwnsDraft = connectionAttemptSettling const connectionAttemptOwnsDraft = connectionAttemptSettling
|| ( || (
connectionAttemptFailed connectionAttemptFailed
@@ -3103,19 +3124,23 @@ export function K1ProvisioningPipeline({
: "Переподключиться"} : "Переподключиться"}
</Button> </Button>
) : null} ) : null}
<Button {!scenarioResetOwnsCleanDraft ? (
width="full" <Button
variant={ width="full"
connectionRecoveryTarget || connectionRecoveryVerificationPending variant={
? "secondary" connectionRecoveryTarget || connectionRecoveryVerificationPending
: "primary" ? "secondary"
} : "primary"
icon={<Icon name="search" />} }
disabled={isBusy || modeResetInFlight} icon={<Icon name="search" />}
onClick={() => void changeDesiredConnectionMode(connectionMode)} disabled={isBusy || modeResetInFlight}
> onClick={() => void changeDesiredConnectionMode(connectionMode)}
Подключить новый K1 >
</Button> {savedBridgeNetworkSetupRequired
? "Подключить K1 к общей сети"
: "Подключить новый K1"}
</Button>
) : null}
</> </>
) : null; ) : null;
@@ -3164,7 +3189,9 @@ export function K1ProvisioningPipeline({
: presentedPhysicalRecoveryRequired : presentedPhysicalRecoveryRequired
? verificationActionPending ? verificationActionPending
? "Переподключение…" ? "Переподключение…"
: "Нужно выбрать действие" : savedBridgeNetworkSetupRequired
? "Нужна настройка сети"
: "Нужно выбрать действие"
: networkRecoveryRequired : networkRecoveryRequired
? appliedControlSettlementPending ? appliedControlSettlementPending
? "Подтверждение управления" ? "Подтверждение управления"
@@ -3173,8 +3200,8 @@ export function K1ProvisioningPipeline({
? connectionReconnectPending ? connectionReconnectPending
? "Переподключение…" ? "Переподключение…"
: connectionRecoveryTarget : connectionRecoveryTarget
? "Нужна проверка" ? "Можно переподключиться"
: "Нужен новый поиск" : "Подключить заново"
: connectionEstablished : connectionEstablished
? "Подключение установлено" ? "Подключение установлено"
: explicitProvisioningDraftStale : explicitProvisioningDraftStale
@@ -3200,7 +3227,6 @@ export function K1ProvisioningPipeline({
? "success" ? "success"
: networkRecoveryRequired : networkRecoveryRequired
|| presentedPhysicalRecoveryRequired || presentedPhysicalRecoveryRequired
|| presentedConnectionRecoveryRequired
|| explicitProvisioningDraftStale || explicitProvisioningDraftStale
? "warning" ? "warning"
: "neutral" : "neutral"
@@ -3239,15 +3265,20 @@ export function K1ProvisioningPipeline({
aria-busy={physicalReconnectPending || undefined} aria-busy={physicalReconnectPending || undefined}
> >
<div className="connection-summary" role="status"> <div className="connection-summary" role="status">
<span>Прежнее подключение не подтверждено</span> <span>
{savedBridgeNetworkSetupRequired
? "K1 не подключён к сохранённой общей сети"
: "Прежнее подключение не подтверждено"}
</span>
<strong>{physicalRecoveryModeLabel}</strong> <strong>{physicalRecoveryModeLabel}</strong>
<small> <small>
{presentedPhysicalRecoveryDeviceId ?? "Прежний K1"} {presentedPhysicalRecoveryDeviceId ?? "Прежний K1"}
</small> </small>
</div> </div>
<p className="safety-note"> <p className="safety-note">
Переподключитесь к прежнему K1 или начните чистое подключение {savedBridgeNetworkSetupRequired
нового устройства. ? "K1 ответил по Bluetooth, но не сообщил адрес Bridge. Настройки устройства не изменялись; требуется новое подключение к общей сети."
: "Переподключитесь к прежнему K1 или начните чистое подключение нового устройства."}
</p> </p>
{physicalReadOnlyVerificationAvailable ? ( {physicalReadOnlyVerificationAvailable ? (
<Button <Button
@@ -3276,7 +3307,9 @@ export function K1ProvisioningPipeline({
disabled={isBusy || modeResetInFlight} disabled={isBusy || modeResetInFlight}
onClick={() => void changeDesiredConnectionMode(connectionMode)} onClick={() => void changeDesiredConnectionMode(connectionMode)}
> >
Подключить новый K1 {savedBridgeNetworkSetupRequired
? "Подключить K1 к общей сети"
: "Подключить новый K1"}
</Button> </Button>
{candidateUnavailableMessage ? ( {candidateUnavailableMessage ? (
<p className="safety-note" role="status"> <p className="safety-note" role="status">
@@ -3289,7 +3322,8 @@ export function K1ProvisioningPipeline({
className="field-stack" className="field-stack"
aria-busy={connectionReconnectPending || undefined} aria-busy={connectionReconnectPending || undefined}
> >
{correlatedConnectionAttempt && error ? ( {correlatedConnectionAttempt && error
&& !savedBridgeNetworkSetupRequired ? (
<K1OperatorError <K1OperatorError
message={error} message={error}
diagnostic={errorDiagnostic} diagnostic={errorDiagnostic}
@@ -3305,9 +3339,9 @@ export function K1ProvisioningPipeline({
<> <>
<div className="connection-summary" role="status"> <div className="connection-summary" role="status">
<span> <span>
{unknownNetworkOutcomeAttempt {savedBridgeNetworkSetupRequired
? "Результат применения сети не подтверждён" ? "K1 не подключён к сохранённой общей сети"
: "Сохранённое подключение требует проверки"} : "Сохранённое подключение"}
</span> </span>
<strong> <strong>
{connectionRecoveryModeLabel {connectionRecoveryModeLabel
@@ -3319,13 +3353,24 @@ export function K1ProvisioningPipeline({
) : null} ) : null}
</div> </div>
<p className="safety-note"> <p className="safety-note">
{unknownNetworkOutcomeAttempt {savedBridgeNetworkSetupRequired
? "Итог прежней попытки неизвестен. Автоматического повтора не было: сначала проверьте сохранённое подключение без изменений либо начните отдельный новый поиск." ? "K1 ответил по Bluetooth, но не сообщил адрес Bridge. Настройки устройства не изменялись; требуется новое подключение к общей сети."
: роверка читает состояние прежнего K1 и не отправляет настройки сети, START или STOP. Новый поиск — отдельное явное действие для выбора другого устройства."} : ереподключение использует сохранённый K1 без изменения сети. Новое подключение запускается отдельно через Bluetooth."}
</p> </p>
{connectionRecoveryActions} {connectionRecoveryActions}
</> </>
)} )}
{scenarioResetOwnsCleanDraft ? (
<Button
width="full"
variant="secondary"
icon={<Icon name="search" />}
disabled={!canScan}
onClick={() => void repeatDeviceScan()}
>
Найти по Bluetooth
</Button>
) : null}
{candidateUnavailableMessage ? ( {candidateUnavailableMessage ? (
<p className="safety-note" role="status"> <p className="safety-note" role="status">
{candidateUnavailableMessage} {candidateUnavailableMessage}
@@ -3444,7 +3489,7 @@ export function K1ProvisioningPipeline({
? "Настройка…" ? "Настройка…"
: explicitProvisioningDraftStale : explicitProvisioningDraftStale
? "Результат устарел" ? "Результат устарел"
: selectedModeConnected : connectionPresentationEstablished
? "Готово" ? "Готово"
: "Ожидает настройки" : "Ожидает настройки"
} }
@@ -3459,7 +3504,7 @@ export function K1ProvisioningPipeline({
? "accent" ? "accent"
: networkRecoveryRequired || explicitProvisioningDraftStale : networkRecoveryRequired || explicitProvisioningDraftStale
? "warning" ? "warning"
: selectedModeConnected : connectionPresentationEstablished
? "success" ? "success"
: "neutral" : "neutral"
} }
@@ -3558,7 +3603,7 @@ export function K1ProvisioningPipeline({
<ActivityIndicator /> <ActivityIndicator />
<strong>Настройка сети</strong> <strong>Настройка сети</strong>
</div> </div>
) : selectedModeConnected && !changeNetworkDialogue ? ( ) : connectionPresentationEstablished && !changeNetworkDialogue ? (
<div className="field-stack"> <div className="field-stack">
<div className="connection-summary"> <div className="connection-summary">
<span>Подключение установлено</span> <span>Подключение установлено</span>
+34 -16
View File
@@ -812,6 +812,26 @@ export function readOnlyFailureShowsNetworkUnavailable(
&& READ_ONLY_NETWORK_UNAVAILABLE_REASON_CODES.has(reasonCode); && READ_ONLY_NETWORK_UNAVAILABLE_REASON_CODES.has(reasonCode);
} }
/**
* Preserve the stronger BLE observation across refreshes and later scan
* failures: this saved Bridge needs network setup, not another old-address
* reconnect attempt.
*/
export function savedBridgeRequiresNetworkSetup(
state: XgridsK1State | null | undefined,
): boolean {
if (
state?.connection_verification?.reason_code
=== "connection-verify-address-unavailable"
) return true;
const operation = state?.last_operation;
return Boolean(
operation?.action === "connection.verify"
&& operation.status === "failed"
&& operation.error?.code === "connection-verify-address-unavailable",
);
}
export function requiresReadOnlyPhysicalRecovery( export function requiresReadOnlyPhysicalRecovery(
state: XgridsK1State | null | undefined, state: XgridsK1State | null | undefined,
): boolean { ): boolean {
@@ -1201,24 +1221,15 @@ ReadonlyArray<ReadOnlyNetworkObservationAction> = [
/** /**
* Resolve a recovery Verify target from the public policy, never from a * Resolve a recovery Verify target from the public policy, never from a
* browser selection. The backend recommendation wins when it names an exact * browser selection. A reconnect must prefer already bound or durable state
* allowed observation; older compatible projections fall back in the same * over a projected fresh scan: a browser projection cannot prove that the
* current -> configured -> fresh order used by the supervisor. * native BLEDevice is still retained by the backend process. The backend
* policy still decides which exact actions are allowed; this helper only
* chooses the safest allowed source in current -> configured -> fresh order.
*/ */
export function recommendedConnectionRecoveryObservationTarget( export function recommendedConnectionRecoveryObservationTarget(
state: XgridsK1State | null | undefined, state: XgridsK1State | null | undefined,
): ReadOnlyConnectionObservationTarget | null { ): ReadOnlyConnectionObservationTarget | null {
const recommended = state?.connection_policy?.recommended_action;
const orderedActions = SERVER_BOUND_RECOVERY_OBSERVATION_PRIORITY.includes(
recommended as ReadOnlyNetworkObservationAction,
)
? [
recommended as ReadOnlyNetworkObservationAction,
...SERVER_BOUND_RECOVERY_OBSERVATION_PRIORITY.filter(
(action) => action !== recommended,
),
]
: SERVER_BOUND_RECOVERY_OBSERVATION_PRIORITY;
const sources: Record< const sources: Record<
ReadOnlyNetworkObservationAction, ReadOnlyNetworkObservationAction,
ReadOnlyConnectionObservationSource ReadOnlyConnectionObservationSource
@@ -1227,7 +1238,7 @@ export function recommendedConnectionRecoveryObservationTarget(
"observe-configured-device-network": "durable-configured-state", "observe-configured-device-network": "durable-configured-state",
"observe-fresh-device-network": "fresh-scan", "observe-fresh-device-network": "fresh-scan",
}; };
for (const action of orderedActions) { for (const action of SERVER_BOUND_RECOVERY_OBSERVATION_PRIORITY) {
const target = exactPolicyObservationTarget(state, action, sources[action]); const target = exactPolicyObservationTarget(state, action, sources[action]);
if (target?.serverBound) return target; if (target?.serverBound) return target;
} }
@@ -1282,6 +1293,13 @@ export function readOnlyConnectionObservationTarget(
selectedDeviceId = "", selectedDeviceId = "",
selectedConnectionMode: XgridsConnectionMode | null = null, selectedConnectionMode: XgridsConnectionMode | null = null,
): ReadOnlyConnectionObservationTarget | null { ): ReadOnlyConnectionObservationTarget | null {
// An unresolved physical command must first use the exact durable
// DeviceInfo path when the supervisor authorizes it. A projected fresh BLE
// row can outlive the backend's native BLEDevice and therefore cannot
// outrank this read-only reconciliation route.
const exactControl = exactControlVerificationTarget(state);
if (exactControl) return exactControl;
const exactFresh = exactPolicyObservationTarget( const exactFresh = exactPolicyObservationTarget(
state, state,
"observe-fresh-device-network", "observe-fresh-device-network",
@@ -1327,7 +1345,7 @@ export function readOnlyConnectionObservationTarget(
state, state,
"observe-configured-device-network", "observe-configured-device-network",
"durable-configured-state", "durable-configured-state",
) ?? exactControlVerificationTarget(state); );
} }
/** /**
@@ -145,6 +145,12 @@ export type AcquisitionPreparationDraft = Omit<
export interface CanonicalLivePreparationRequest { export interface CanonicalLivePreparationRequest {
acquisition: AcquisitionPreparationDraft; acquisition: AcquisitionPreparationDraft;
physicalAcceptance: OperatorPresenceConfirmation;
}
function runtimeTimezoneName(): string {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return typeof timezone === "string" && timezone.trim() ? timezone : "UTC";
} }
export interface ProvisioningSubmitResult { export interface ProvisioningSubmitResult {
@@ -677,7 +683,7 @@ export function connectionVerificationFailureMessage(
"connection-verify-exact-uuid-scan-timeout": "connection-verify-exact-uuid-scan-timeout":
"Mission Core не получил объявление точного сохранённого CoreBluetooth UUID в отведённое окно. Команды K1 не отправлялись; это не является выводом о состоянии устройства.", "Mission Core не получил объявление точного сохранённого CoreBluetooth UUID в отведённое окно. Команды K1 не отправлялись; это не является выводом о состоянии устройства.",
"connection-verify-address-unavailable": "connection-verify-address-unavailable":
"K1 ответил, но не сообщил адрес в общей сети. Bridge пока не подключён; настройки устройства не менялись.", "K1 ответил по Bluetooth, но не подключён к сохранённой общей сети. Старый Bridge использовать нельзя; подключите K1 к общей сети заново — настройки устройства не менялись.",
"connection-verify-target-not-distinguishable-from-baseline": "connection-verify-target-not-distinguishable-from-baseline":
"K1 ответил, но приложение не смогло подтвердить, что прежние настройки сети были применены. Автоматического повтора и новой записи не было.", "K1 ответил, но приложение не смогло подтвердить, что прежние настройки сети были применены. Автоматического повтора и новой записи не было.",
"connection-verify-route-mismatch": "connection-verify-route-mismatch":
@@ -1732,6 +1738,27 @@ export function useXgridsK1Runtime(enabled: boolean) {
} }
continue; continue;
} }
if (nextState.application_control_session?.inspection_only === true) {
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => xgridsK1Api.openApplicationControlSession({
...request.physicalAcceptance,
timezone_name: runtimeTimezoneName(),
expected_snapshot_runtime_id: actionSnapshotRuntimeId,
}),
);
acceptState(nextState);
assertOperatorIntentCurrent();
nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent,
() => waitForControlPhase(
"connection-ready",
acceptState,
assertOperatorIntentCurrent,
),
);
continue;
}
nextState = await awaitWhileIntentCurrent( nextState = await awaitWhileIntentCurrent(
assertOperatorIntentCurrent, assertOperatorIntentCurrent,
() => { () => {
@@ -1989,9 +1989,11 @@ class ActiveAcquisitionRecoveryCheckpointStore:
raise ActiveAcquisitionRecoveryCheckpointTransitionError( raise ActiveAcquisitionRecoveryCheckpointTransitionError(
"only a prepared or active checkpoint can cease" "only a prepared or active checkpoint can cease"
) )
_require_physical_lineage_base(current, physical_proof)
transport_revision = current.transport_revision transport_revision = current.transport_revision
current_binding = current.current_binding current_binding = current.current_binding
gap_started_at = current.last_gap_started_at_utc
gap_started_revision = current.last_gap_started_transport_revision
gap_failed_binding = current.last_gap_failed_binding
gap_recovered_at = current.last_gap_recovered_at_utc gap_recovered_at = current.last_gap_recovered_at_utc
gap_recovered_revision = current.last_gap_recovered_transport_revision gap_recovered_revision = current.last_gap_recovered_transport_revision
prepared_resolution: ActiveAcquisitionRecoveryPhysicalLineageProof | None = None prepared_resolution: ActiveAcquisitionRecoveryPhysicalLineageProof | None = None
@@ -2003,7 +2005,21 @@ class ActiveAcquisitionRecoveryCheckpointStore:
) )
prepared_resolution = physical_proof prepared_resolution = physical_proof
current_binding = physical_proof.binding current_binding = physical_proof.binding
if (
physical_proof.binding.target_ipv4
!= current.connection.target_ipv4
):
transport_revision = _next_transport_revision(
current.transport_revision
)
gap_started_at = current.updated_at_utc
gap_started_revision = current.transport_revision
gap_failed_binding = current.prepared_binding
assert status_proof is not None
gap_recovered_at = status_proof.observed_at_utc
gap_recovered_revision = transport_revision
else: else:
_require_physical_lineage_base(current, physical_proof)
assert status_proof is not None assert status_proof is not None
_require_active_cessation( _require_active_cessation(
current, current,
@@ -2054,6 +2070,9 @@ class ActiveAcquisitionRecoveryCheckpointStore:
), ),
updated_at_utc=now, updated_at_utc=now,
ceased_at_utc=ceased_at, ceased_at_utc=ceased_at,
last_gap_started_at_utc=gap_started_at,
last_gap_started_transport_revision=gap_started_revision,
last_gap_failed_binding=gap_failed_binding,
last_gap_recovered_at_utc=gap_recovered_at, last_gap_recovered_at_utc=gap_recovered_at,
last_gap_recovered_transport_revision=gap_recovered_revision, last_gap_recovered_transport_revision=gap_recovered_revision,
cessation_status_proof=status_proof, cessation_status_proof=status_proof,
@@ -2594,6 +2613,7 @@ def _require_binding_matches_checkpoint_values(
connection: ActiveAcquisitionRecoveryConnection, connection: ActiveAcquisitionRecoveryConnection,
compatibility_profile_id: str, compatibility_profile_id: str,
binding: ActiveAcquisitionRecoveryTransportBinding, binding: ActiveAcquisitionRecoveryTransportBinding,
allow_target_ipv4_change: bool = False,
) -> None: ) -> None:
if ( if (
binding.logical_device_id != identity.logical_device_id binding.logical_device_id != identity.logical_device_id
@@ -2602,7 +2622,10 @@ def _require_binding_matches_checkpoint_values(
or binding.compatibility_profile_id != compatibility_profile_id or binding.compatibility_profile_id != compatibility_profile_id
or binding.transport_ref != connection.transport_ref or binding.transport_ref != connection.transport_ref
or binding.connection_mode != connection.connection_mode or binding.connection_mode != connection.connection_mode
or binding.target_ipv4 != connection.target_ipv4 or (
not allow_target_ipv4_change
and binding.target_ipv4 != connection.target_ipv4
)
or binding.target_port != connection.target_port or binding.target_port != connection.target_port
): ):
raise ActiveAcquisitionRecoveryCheckpointTransitionError( raise ActiveAcquisitionRecoveryCheckpointTransitionError(
@@ -2619,6 +2642,11 @@ def _require_status_binding(
connection=checkpoint.connection, connection=checkpoint.connection,
compatibility_profile_id=checkpoint.compatibility_profile_id, compatibility_profile_id=checkpoint.compatibility_profile_id,
binding=proof.binding, binding=proof.binding,
allow_target_ipv4_change=(
checkpoint.state != "prepared"
and checkpoint.last_gap_recovered_at_utc is not None
and proof.binding == checkpoint.current_binding
),
) )
@@ -2654,6 +2682,8 @@ def _require_gap_cessation_evidence_session(
def _require_physical_lineage_base( def _require_physical_lineage_base(
checkpoint: ActiveAcquisitionRecoveryCheckpoint, checkpoint: ActiveAcquisitionRecoveryCheckpoint,
proof: ActiveAcquisitionRecoveryPhysicalLineageProof, proof: ActiveAcquisitionRecoveryPhysicalLineageProof,
*,
allow_target_ipv4_change: bool = False,
) -> None: ) -> None:
if ( if (
proof.acquisition_id != checkpoint.acquisition_id proof.acquisition_id != checkpoint.acquisition_id
@@ -2668,6 +2698,14 @@ def _require_physical_lineage_base(
connection=checkpoint.connection, connection=checkpoint.connection,
compatibility_profile_id=checkpoint.compatibility_profile_id, compatibility_profile_id=checkpoint.compatibility_profile_id,
binding=proof.binding, binding=proof.binding,
allow_target_ipv4_change=(
allow_target_ipv4_change
or (
checkpoint.state != "prepared"
and checkpoint.last_gap_recovered_at_utc is not None
and proof.binding == checkpoint.current_binding
)
),
) )
@@ -3103,6 +3141,14 @@ def _require_prepared_cessation(
status_proof: ActiveAcquisitionRecoveryStatusProof | None, status_proof: ActiveAcquisitionRecoveryStatusProof | None,
physical_proof: ActiveAcquisitionRecoveryPhysicalLineageProof, physical_proof: ActiveAcquisitionRecoveryPhysicalLineageProof,
) -> None: ) -> None:
target_ipv4_changed = (
physical_proof.binding.target_ipv4 != checkpoint.connection.target_ipv4
)
_require_physical_lineage_base(
checkpoint,
physical_proof,
allow_target_ipv4_change=target_ipv4_changed,
)
if ( if (
physical_proof.operation_id != checkpoint.original_start_operation_id physical_proof.operation_id != checkpoint.original_start_operation_id
or physical_proof.action != "start" or physical_proof.action != "start"
@@ -3138,7 +3184,26 @@ def _require_prepared_cessation(
raise ActiveAcquisitionRecoveryCheckpointTransitionError( raise ActiveAcquisitionRecoveryCheckpointTransitionError(
"prepared terminal reconciliation requires fresh READY/SCAN_OVER" "prepared terminal reconciliation requires fresh READY/SCAN_OVER"
) )
_require_status_binding(checkpoint, status_proof) _require_binding_matches_checkpoint_values(
identity=checkpoint.identity,
connection=checkpoint.connection,
compatibility_profile_id=checkpoint.compatibility_profile_id,
binding=status_proof.binding,
allow_target_ipv4_change=target_ipv4_changed,
)
if target_ipv4_changed and (
status_proof.source != "explicit-read-only-reconciliation"
or status_proof.binding.runtime_instance_id
== checkpoint.prepared_binding.runtime_instance_id
or status_proof.binding.control_session_id
== checkpoint.prepared_binding.control_session_id
or status_proof.evidence_session_id
== checkpoint.original_evidence_session_id
):
raise ActiveAcquisitionRecoveryCheckpointTransitionError(
"prepared target change requires fresh DeviceInfo-bound runtime, "
"control and evidence sessions"
)
_require_matching_observed_proofs( _require_matching_observed_proofs(
status_proof=status_proof, status_proof=status_proof,
physical_proof=physical_proof, physical_proof=physical_proof,
@@ -3331,16 +3396,13 @@ def _require_active_reconciled_standby_shape(
== failed_evidence_session_id == failed_evidence_session_id
or ( or (
same_runtime same_runtime
and ( and recovery_binding.producer_generation
recovery_binding.host_path_epoch == failed_binding.host_path_epoch == failed_binding.producer_generation
or recovery_binding.producer_generation
== failed_binding.producer_generation
)
) )
): ):
raise ActiveAcquisitionRecoveryCheckpointTransitionError( raise ActiveAcquisitionRecoveryCheckpointTransitionError(
"standby settlement requires a new control/evidence boundary; " "standby settlement requires a new control/evidence boundary; "
"same-runtime recovery also requires a new host path and producer generation" "same-runtime recovery also requires a new producer generation"
) )
gap_started = _validated_timestamp( gap_started = _validated_timestamp(
@@ -4565,19 +4627,29 @@ def _validate_checkpoint_semantics(
_validate_evidence_policy(checkpoint.evidence_policy) _validate_evidence_policy(checkpoint.evidence_policy)
_validate_mount_type(checkpoint.mount_type) _validate_mount_type(checkpoint.mount_type)
_validate_gnss_mode(checkpoint.gnss_mode) _validate_gnss_mode(checkpoint.gnss_mode)
for binding in (checkpoint.prepared_binding, checkpoint.current_binding): _require_binding_matches_checkpoint_values(
_require_binding_matches_checkpoint_values( identity=checkpoint.identity,
identity=checkpoint.identity, connection=checkpoint.connection,
connection=checkpoint.connection, compatibility_profile_id=checkpoint.compatibility_profile_id,
compatibility_profile_id=checkpoint.compatibility_profile_id, binding=checkpoint.prepared_binding,
binding=binding, )
) _require_binding_matches_checkpoint_values(
identity=checkpoint.identity,
connection=checkpoint.connection,
compatibility_profile_id=checkpoint.compatibility_profile_id,
binding=checkpoint.current_binding,
allow_target_ipv4_change=(
checkpoint.state != "prepared"
and checkpoint.last_gap_recovered_at_utc is not None
),
)
if checkpoint.last_gap_failed_binding is not None: if checkpoint.last_gap_failed_binding is not None:
_require_binding_matches_checkpoint_values( _require_binding_matches_checkpoint_values(
identity=checkpoint.identity, identity=checkpoint.identity,
connection=checkpoint.connection, connection=checkpoint.connection,
compatibility_profile_id=checkpoint.compatibility_profile_id, compatibility_profile_id=checkpoint.compatibility_profile_id,
binding=checkpoint.last_gap_failed_binding, binding=checkpoint.last_gap_failed_binding,
allow_target_ipv4_change=(checkpoint.state != "prepared"),
) )
_canonical_timestamp_value(checkpoint.created_at_utc, field_name="created_at_utc") _canonical_timestamp_value(checkpoint.created_at_utc, field_name="created_at_utc")
_canonical_timestamp_value(checkpoint.updated_at_utc, field_name="updated_at_utc") _canonical_timestamp_value(checkpoint.updated_at_utc, field_name="updated_at_utc")
+121 -43
View File
@@ -3555,6 +3555,66 @@ class XgridsK1CompatibilityService:
self._application_control_session.snapshot() self._application_control_session.snapshot()
) )
def _retire_verified_inspection_for_operator_dialogue(
self,
inspection: Mapping[str, Any],
) -> None:
"""Close Verify's ordinal-1 socket before a canonical operator dialogue.
Verify is intentionally read-only and may remain open while the Park UI
waits for the next explicit action. The captured LixelGO dialogue does
not resume that old socket: ordinals 1-6 are one contiguous connection
stage on a fresh socket. Retiring the inspection here is local-only;
it cannot publish START, STOP, Wi-Fi credentials, or another K1 request.
"""
generation = inspection.get("session_generation")
revision = inspection.get("state_revision")
if not isinstance(generation, int) or not isinstance(revision, int):
raise ApplicationAcceptanceError(
"read-only inspection checkpoint is unavailable"
)
if not (
inspection.get("state") == "connection-ready"
and inspection.get("inspection_only") is True
):
raise ApplicationAcceptanceError(
"only a connection-ready inspection may be replaced"
)
self._application_control_session.close_prestart(
expected_session_generation=generation,
expected_state_revision=revision,
)
deadline = time.monotonic() + CONTROL_LOCAL_RETIREMENT_TIMEOUT_SECONDS
while True:
terminal = dict(self._application_control_session.snapshot())
if terminal.get("can_open") is True:
break
if time.monotonic() >= deadline:
# Bounded local socket teardown only. ``close`` never invents
# a physical STOP for a pre-START session.
self._application_control_session.close()
terminal = dict(self._application_control_session.snapshot())
if terminal.get("can_open") is not True:
raise ApplicationAcceptanceError(
"read-only inspection worker is still retiring"
)
break
time.sleep(0.05)
self._retire_application_control_for_network_change(
allow_terminal_failure=True,
)
logger.info(
"K1 read-only inspection retired before canonical operator dialogue",
extra={
"event_code": "k1_control_inspection_retired_before_operator_dialogue",
"device_write_performed": False,
"automatic_retry": False,
},
)
def _retry_pending_local_control_retirement(self) -> bool: def _retry_pending_local_control_retirement(self) -> bool:
"""Finish local socket retirement after its worker actually exits.""" """Finish local socket retirement after its worker actually exits."""
@@ -4400,32 +4460,6 @@ class XgridsK1CompatibilityService:
allow_receiver_rehydrate=False, allow_receiver_rehydrate=False,
) )
) )
if (
physical_reconciliation.get("resolution")
!= "physical-active-observed"
):
inspected = self._application_control_session.snapshot()
if (
inspected.get("state") == "connection-ready"
and inspected.get("inspection_only") is True
and inspected.get("inspection_promotion_allowed") is not True
):
generation = inspected.get("session_generation")
revision = inspected.get("state_revision")
if not isinstance(generation, int) or not isinstance(
revision,
int,
):
raise ConnectionVerificationError(
"read-only continuation lost its inspection checkpoint",
reason_code=(
"control-bootstrap-inspection-checkpoint-invalid"
),
)
self._application_control_session.release_inspection_for_operator_dialogue(
expected_session_generation=generation,
expected_state_revision=revision,
)
if ( if (
physical_reconciliation.get("resolution") physical_reconciliation.get("resolution")
== "physical-active-observed" == "physical-active-observed"
@@ -14816,23 +14850,6 @@ class XgridsK1CompatibilityService:
physical_reconciliation = await self._reconcile_physical_command_after_verify_owned( physical_reconciliation = await self._reconcile_physical_command_after_verify_owned(
verify_operation_id=verify_operation_id, verify_operation_id=verify_operation_id,
) )
verified_session = dict(self._application_control_session.snapshot())
if (
verified_session.get("state") == "connection-ready"
and verified_session.get("inspection_only") is True
and verified_session.get("inspection_promotion_allowed") is not True
):
generation = verified_session.get("session_generation")
revision = verified_session.get("state_revision")
if not isinstance(generation, int) or not isinstance(revision, int):
raise ConnectionVerificationError(
"Verify не получил точный checkpoint inspection-сессии",
reason_code="connection-verify-control-checkpoint-invalid",
)
self._application_control_session.release_inspection_for_operator_dialogue(
expected_session_generation=generation,
expected_state_revision=revision,
)
if provisional_topology is not None: if provisional_topology is not None:
control_stage = "semantic-topology-commit" control_stage = "semantic-topology-commit"
self._commit_provisional_fresh_bridge_topology(provisional_topology) self._commit_provisional_fresh_bridge_topology(provisional_topology)
@@ -15023,6 +15040,59 @@ class XgridsK1CompatibilityService:
or resolved_active_recovery_required or resolved_active_recovery_required
or resolved_scan_over_recovery_required or resolved_scan_over_recovery_required
): ):
# The physical-ledger reconciliation may have committed before
# an older process failed to persist the matching checkpoint
# cessation. A later explicit Verify must close that durable
# fsync gap from the immutable latest READY/SCAN_OVER proof;
# otherwise the UI can look connected while the next START is
# still fail-closed by a stale PREPARED checkpoint. This is a
# local projection only and never publishes a K1 command.
ledger_snapshot = self._physical_command_ledger.snapshot()
record = ledger_snapshot.record
latest_reconciliation = (
record.reconciliations[-1]
if record is not None and record.reconciliations
else None
)
checkpoint_settlement_required = bool(
checkpoint_trust_token is not None
and ledger_snapshot.status == "resolved"
and record is not None
and latest_reconciliation is not None
and latest_reconciliation.resolution
== "physical-standby-observed"
and latest_reconciliation.kind
in {
"ambiguous-outcome",
"prepared-stop-classification",
"resolved-active-cessation",
}
and latest_reconciliation.observation.source
== "explicit-read-only-reconciliation"
and latest_reconciliation.observation.session_state
in {"ready", "scan_over"}
and not latest_reconciliation.observation.project_bound
and not latest_reconciliation.observation.init_ready
and not latest_reconciliation.observation.mqtt_retained
)
if checkpoint_settlement_required:
assert record is not None
assert latest_reconciliation is not None
settled = self._settle_restart_checkpoint_after_verified_standby(
token=checkpoint_trust_token,
reconciliation_id=(
latest_reconciliation.reconciliation_id
),
reconciled_record=record.as_dict(),
)
if not settled:
raise ConnectionVerificationError(
"Подтверждённый READY не закрыл старую локальную START-сессию",
reason_code=(
self._active_acquisition_checkpoint_reason
or "restart-standby-checkpoint-settlement-failed"
),
)
return { return {
"performed": False, "performed": False,
"resolution": None, "resolution": None,
@@ -19052,6 +19122,14 @@ class XgridsK1CompatibilityService:
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES: if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
raise RuntimeError("control-сессия должна быть открыта до подготовки acquisition") raise RuntimeError("control-сессия должна быть открыта до подготовки acquisition")
existing_control = self._application_control_session.snapshot() existing_control = self._application_control_session.snapshot()
if (
existing_control.get("state") == "connection-ready"
and existing_control.get("inspection_only") is True
):
self._retire_verified_inspection_for_operator_dialogue(
existing_control,
)
existing_control = self._application_control_session.snapshot()
if existing_control.get("state") in { if existing_control.get("state") in {
"connection-ready", "connection-ready",
"workspace-ready", "workspace-ready",
@@ -265,7 +265,8 @@ class InteractiveApplicationControlSession:
# Opening the MQTT dialogue begins with only the read-only DeviceInfo # Opening the MQTT dialogue begins with only the read-only DeviceInfo
# bootstrap. Durable identity admission must accept that proof before # bootstrap. Durable identity admission must accept that proof before
# a normal session may continue its pre-START preparation; inspection # a normal session may continue its pre-START preparation; inspection
# sessions remain read-only until explicitly promoted. Physical # sessions remain read-only and must retire before a normal dialogue.
# Physical
# acceptance belongs to the exact START/STOP dispatch boundaries below; # acceptance belongs to the exact START/STOP dispatch boundaries below;
# a connection intent must never fabricate those confirmations merely # a connection intent must never fabricate those confirmations merely
# to prove that the selected K1 is ready. # to prove that the selected K1 is ready.
@@ -375,27 +376,6 @@ class InteractiveApplicationControlSession:
self._workspace_requested.set() self._workspace_requested.set()
return self.snapshot() return self.snapshot()
def release_inspection_for_operator_dialogue(
self,
*,
expected_session_generation: int,
expected_state_revision: int,
) -> dict[str, object]:
"""End Verify's read-only boundary without sending another request."""
with self._lock:
self._require_checkpoint_locked(
expected_session_generation=expected_session_generation,
expected_state_revision=expected_state_revision,
)
self._require_phase_locked("connection-ready")
if not self._inspection_only:
return self.snapshot()
if not self._inspection_promotion_allowed:
self._inspection_promotion_allowed = True
self._state_revision += 1
return self.snapshot()
def validate_connection_binding(self) -> None: def validate_connection_binding(self) -> None:
"""Fail closed when the DeviceInfo-bound route lost command authority.""" """Fail closed when the DeviceInfo-bound route lost command authority."""
+4 -4
View File
@@ -132,16 +132,16 @@ def test_xgrids_live_copy_exposes_response_gated_prepare_and_one_physical_start(
assert "Запустить приём" in connection_source assert "Запустить приём" in connection_source
assert "Запустить K1" not in connection_source assert "Запустить K1" not in connection_source
assert "Одно нажатие выполняет каноническую подготовку и один START" in ( assert "Одно нажатие выполняет каноническую подготовку и один START" not in (
connection_source connection_source
) )
assert "Проверить условия и отправить START" not in connection_source assert "Проверить условия и отправить START" not in connection_source
assert "Физический START отправляется только после отдельного финального окна" not in ( assert "Физический START отправляется только после отдельного финального окна" not in (
connection_source connection_source
) )
assert "Имя войдёт в единственный канонический START" in connection_source assert "Имя войдёт в единственный канонический START" not in connection_source
assert "один START после подтверждённого READY" in connection_source assert "один START после подтверждённого READY" not in connection_source
assert "Автоматических повторов команд нет" in connection_source assert "Автоматических повторов команд нет" not in connection_source
assert "Подключить управление K1" not in connection_source assert "Подключить управление K1" not in connection_source
assert "Открыть рабочее пространство K1" not in connection_source assert "Открыть рабочее пространство K1" not in connection_source
assert "Сохранить проект и подготовить локальный приём" not in connection_source assert "Сохранить проект и подготовить локальный приём" not in connection_source
+59 -15
View File
@@ -675,20 +675,6 @@ class FakeInteractiveControlSession:
self.state_revision += 1 self.state_revision += 1
return self.snapshot() return self.snapshot()
def release_inspection_for_operator_dialogue(
self,
*,
expected_session_generation: int,
expected_state_revision: int,
) -> dict[str, object]:
self._accept_checkpoint(
expected_session_generation=expected_session_generation,
expected_state_revision=expected_state_revision,
)
assert self.state == "connection-ready"
self.inspection_promotion_allowed = True
return self.snapshot()
def adopt_reconciled_scanning( def adopt_reconciled_scanning(
self, self,
*, *,
@@ -6553,6 +6539,64 @@ def test_control_session_reuses_reachable_process_owned_connection_lease(
service._release_application_control_process_lease() # noqa: SLF001 service._release_application_control_process_lease() # noqa: SLF001
def test_operator_control_open_replaces_verify_inspection_with_fresh_dialogue(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
transitions: list[str] = []
class RecordingControlSession(FakeInteractiveControlSession):
def close_prestart(self, **kwargs: object) -> dict[str, object]:
transitions.append("inspection-closed")
return super().close_prestart(**kwargs)
def retire_for_network_change(self, **kwargs: object) -> dict[str, object]:
transitions.append("inspection-retired")
return super().retire_for_network_change(**kwargs)
def open(self, *, inspection_only: bool = False, **kwargs: object) -> dict[str, object]:
transitions.append(
"inspection-opened" if inspection_only else "canonical-opened"
)
self.session_generation += 1
return super().open(inspection_only=inspection_only, **kwargs)
control = RecordingControlSession(initial_state="connection-ready")
control.session_generation = 7
control.state_revision = 11
control.inspection_only = True
control.inspection_promotion_allowed = False
service._application_control_session = control # type: ignore[assignment] # noqa: SLF001
binding = _seed_supervised_connection(service)
control.verified_control = _verified_control_for_binding(binding)
service._acquire_application_control_process_lease() # noqa: SLF001
monkeypatch.setattr(facade_module, "_control_endpoint_reachable", lambda _target: True)
monkeypatch.setattr(facade_module, "_inspect_host_path", _direct_host_path)
state = service.open_application_control_session(
OpenApplicationControlSessionRequest(
operator_present=True,
owner_controlled_device=True,
lixelgo_closed=True,
battery_storage_confirmed=True,
expected_physical_state_confirmed=True,
timezone_name="Europe/Moscow",
)
)
assert transitions == [
"inspection-closed",
"inspection-retired",
"canonical-opened",
]
assert state["application_control_session"]["state"] == "connection-ready"
assert state["application_control_session"]["inspection_only"] is False
assert state["application_control_session"]["session_generation"] == 8
service._release_application_control_process_lease() # noqa: SLF001
def test_reachable_connection_lease_supports_repeated_independent_control_sessions( def test_reachable_connection_lease_supports_repeated_independent_control_sessions(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
tmp_path: Path, tmp_path: Path,
@@ -27705,7 +27749,7 @@ def test_reset_retired_apply_continuation_settles_without_receiver_rehydrate(
else: else:
assert control["state"] == "connection-ready" assert control["state"] == "connection-ready"
assert control["inspection_only"] is True assert control["inspection_only"] is True
assert control["inspection_promotion_allowed"] is True assert control["inspection_promotion_allowed"] is False
assert settled["connection_attempt"]["safe_next_action"] == ( assert settled["connection_attempt"]["safe_next_action"] == (
"start-acquisition" "start-acquisition"
) )
@@ -521,7 +521,11 @@ def test_ready_after_dispatched_stop_ceases_active_checkpoint(
) )
restarted = _connection( restarted = _connection(
control_session_id="checkpoint-dispatched-stop-restarted-control", control_session_id="checkpoint-dispatched-stop-restarted-control",
host_path_epoch=2, # A read-only reconnect on the same Wi-Fi route legitimately keeps the
# host-path epoch. Fresh control/evidence and producer generations are
# the replay fences; requiring a route change strands a confirmed READY
# behind an ACTIVE checkpoint, as observed in the live Bridge flow.
host_path_epoch=(original.host_path_epoch if same_process else 2),
producer_generation=2, producer_generation=2,
) )
coordinator = service._physical_command_coordinator # noqa: SLF001 coordinator = service._physical_command_coordinator # noqa: SLF001
@@ -593,7 +597,8 @@ def test_ready_after_dispatched_stop_ceases_active_checkpoint(
# The physical ledger fsync is durable while checkpoint cessation did not # The physical ledger fsync is durable while checkpoint cessation did not
# commit. Recovery may happen after a process restart or, as observed in # commit. Recovery may happen after a process restart or, as observed in
# production, later in the same process under a fresh control/path epoch. # production, later in the same process under a fresh control epoch while
# the host route itself remains unchanged.
if same_process: if same_process:
monkeypatch.setattr( monkeypatch.setattr(
service, service,
@@ -87,13 +87,14 @@ def _binding(
host_path_epoch: int = 10, host_path_epoch: int = 10,
control_session_id: str = "control-A", control_session_id: str = "control-A",
producer_generation: int = 7, producer_generation: int = 7,
target_ipv4: str = CONNECTION.target_ipv4,
) -> ActiveAcquisitionRecoveryTransportBinding: ) -> ActiveAcquisitionRecoveryTransportBinding:
return ActiveAcquisitionRecoveryTransportBinding( return ActiveAcquisitionRecoveryTransportBinding(
runtime_instance_id=runtime_instance_id, runtime_instance_id=runtime_instance_id,
intent_id="intent-001", intent_id="intent-001",
transport_ref=CONNECTION.transport_ref, transport_ref=CONNECTION.transport_ref,
connection_mode=CONNECTION.connection_mode, connection_mode=CONNECTION.connection_mode,
target_ipv4=CONNECTION.target_ipv4, target_ipv4=target_ipv4,
target_port=CONNECTION.target_port, target_port=CONNECTION.target_port,
host_path_epoch=host_path_epoch, host_path_epoch=host_path_epoch,
control_session_id=control_session_id, control_session_id=control_session_id,
@@ -1313,6 +1314,7 @@ def test_cease_active_reconciled_standby_rejects_inexact_restart_proofs(
status.binding, status.binding,
runtime_instance_id=binding.runtime_instance_id, runtime_instance_id=binding.runtime_instance_id,
host_path_epoch=binding.host_path_epoch, host_path_epoch=binding.host_path_epoch,
producer_generation=binding.producer_generation,
), ),
replace( replace(
status.binding, status.binding,
@@ -2211,6 +2213,101 @@ def test_prepared_crash_cannot_cease_on_status_alone(
assert ceased.prepared_resolution_proof is not None assert ceased.prepared_resolution_proof is not None
def test_prepared_ambiguous_start_standby_accepts_verified_dhcp_target_change(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
prepared_binding = _binding()
store = _store(tmp_path, monkeypatch)
prepared = _prepare(store, prepared_binding)
recovered_binding = _binding(
runtime_instance_id="runtime-recovered-new-address",
host_path_epoch=1,
control_session_id="control-recovered-new-address",
producer_generation=1,
target_ipv4="192.168.43.54",
)
ready = _status(
"ready",
binding=recovered_binding,
evidence_session_id="evidence-recovered-new-address",
observed_at="2026-08-13T12:01:00.000Z",
)
physical = _physical(
"physical-standby-observed",
binding=recovered_binding,
proof_id="physical-ambiguous-ready-new-address",
observed_at=ready.observed_at_utc,
)
ceased = store.cease(
transition_id="transition-cease-ambiguous-new-address",
expected_revision=prepared.revision,
expected_acquisition_id=ACQUISITION_ID,
expected_start_operation_id=START_OPERATION_ID,
status_proof=ready,
physical_proof=physical,
)
assert ceased.state == "ceased"
assert ceased.transport_revision == prepared.transport_revision + 1
assert ceased.prepared_binding == prepared_binding
assert ceased.current_binding == recovered_binding
assert ceased.last_gap_failed_binding == prepared_binding
assert ceased.last_gap_started_transport_revision == prepared.transport_revision
assert ceased.last_gap_recovered_transport_revision == ceased.transport_revision
assert ceased.cessation_status_proof == ready
assert ceased.cessation_physical_proof == physical
assert (
ActiveAcquisitionRecoveryCheckpointStore(tmp_path / "repository")
.snapshot()
.checkpoint
== ceased
)
def test_prepared_dhcp_target_change_rejects_stale_runtime_proof(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
prepared_binding = _binding()
store = _store(tmp_path, monkeypatch)
prepared = _prepare(store, prepared_binding)
stale_binding = _binding(
runtime_instance_id=prepared_binding.runtime_instance_id,
host_path_epoch=prepared_binding.host_path_epoch + 1,
control_session_id=prepared_binding.control_session_id,
producer_generation=prepared_binding.producer_generation + 1,
target_ipv4="192.168.43.54",
)
ready = _status(
"ready",
binding=stale_binding,
evidence_session_id="evidence-recovered-new-address",
observed_at="2026-08-13T12:01:00.000Z",
)
with pytest.raises(
ActiveAcquisitionRecoveryCheckpointTransitionError,
match="target change requires fresh DeviceInfo-bound runtime",
):
store.cease(
transition_id="transition-reject-stale-new-address",
expected_revision=prepared.revision,
expected_acquisition_id=ACQUISITION_ID,
expected_start_operation_id=START_OPERATION_ID,
status_proof=ready,
physical_proof=_physical(
"physical-standby-observed",
binding=stale_binding,
proof_id="physical-stale-ready-new-address",
observed_at=ready.observed_at_utc,
),
)
assert store.snapshot().checkpoint == prepared
def test_active_cease_requires_exact_current_binding_and_physical_stop( def test_active_cease_requires_exact_current_binding_and_physical_stop(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@@ -828,6 +828,102 @@ def test_ambiguous_prepared_restart_waits_for_first_pcl_before_activation(
assert ledger_record.resolution == "physical-active-observed" assert ledger_record.resolution == "physical-active-observed"
def test_ambiguous_prepared_restart_ready_settles_after_dhcp_target_change(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
first = restart_support._service(tmp_path, monkeypatch)
store = restart_support._seed_matching_prepared_start(first)
ledger = first._physical_command_ledger # noqa: SLF001
original = restart_support._prepared_connection()
ledger.mark_dispatching(restart_support.START_OPERATION_ID)
ledger.mark_observing(
restart_support.START_OPERATION_ID,
publish_call_returned=True,
packet_id=41,
)
ledger.mark_qos2_completed(restart_support.START_OPERATION_ID, packet_id=41)
ledger.record_application_response(
restart_support.START_OPERATION_ID,
restart_support.PhysicalCommandApplicationResponse(
operation_id=restart_support.START_OPERATION_ID,
action="start",
control_session_id=original.control_session_id,
host_path_epoch=original.host_path_epoch,
producer_generation=original.producer_generation,
result_code=restart_support.PHYSICAL_COMMAND_APPLICATION_SUCCESS_CODE,
success=True,
payload_sha256="f" * 64,
observed_at_utc="2026-08-13T12:00:01.000Z",
),
)
restarted = restart_support._service(tmp_path, monkeypatch)
token = restarted._validate_active_acquisition_checkpoint_lineage() # noqa: SLF001
assert token is not None
recovered = restart_support.PhysicalCommandConnectionBinding(
intent_id=original.intent_id,
transport_ref=original.transport_ref,
connection_mode=original.connection_mode,
target_ipv4="192.168.68.54",
target_port=original.target_port,
host_path_epoch=2,
control_session_id="control-restart-safety-new-address",
producer_generation=2,
)
ready = restart_support.PhysicalCommandStatusEvidence(
source="explicit-read-only-reconciliation",
vendor_device_id_sha256=restart_support.VENDOR_SHA256,
device_serial_sha256=restart_support.SERIAL_SHA256,
control_session_id=recovered.control_session_id,
host_path_epoch=recovered.host_path_epoch,
producer_generation=recovered.producer_generation,
session_state="ready",
session_state_code=300,
project_bound=False,
project_id_sha256=None,
init_ready=False,
status_message_sha256="9" * 64,
mqtt_retained=False,
observed_at_utc="2026-08-13T12:20:01.000Z",
)
record = restarted._physical_command_ledger.reconcile_ambiguous( # noqa: SLF001
restart_support.START_OPERATION_ID,
reconciliation_id="reconciliation-ready-new-dhcp-address",
resolution="physical-standby-observed",
verified_binding=restart_support.PhysicalCommandVerifiedBinding(
verification_id="verification-ready-new-dhcp-address",
identity=restart_support.PhysicalCommandIdentity(
vendor_device_id_sha256=restart_support.VENDOR_SHA256,
device_serial_sha256=restart_support.SERIAL_SHA256,
),
connection=recovered,
device_info_message_sha256="8" * 64,
verified_at_utc="2026-08-13T12:20:00.000Z",
),
observation=ready,
)
assert (
restarted._physical_command_coordinator.snapshot()[ # noqa: SLF001
"recovery_requirement"
]
is None
)
result = asyncio.run(
restarted._reconcile_physical_command_after_verify_owned( # noqa: SLF001
verify_operation_id="verify-catches-up-checkpoint-new-address",
)
)
assert result["performed"] is False
checkpoint = store.snapshot().checkpoint
assert checkpoint is not None
assert checkpoint.state == "ceased"
assert checkpoint.prepared_binding.target_ipv4 == "192.168.68.52"
assert checkpoint.current_binding.target_ipv4 == "192.168.68.54"
assert checkpoint.transport_revision == 2
def test_composite_prepared_restart_uses_exact_origin_and_zero_start_replay( def test_composite_prepared_restart_uses_exact_origin_and_zero_start_replay(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
+5 -13
View File
@@ -527,6 +527,7 @@ def test_read_only_device_info_open_does_not_require_physical_acceptance(
ready = _wait_phase(session, "connection-ready") ready = _wait_phase(session, "connection-ready")
assert ready["verified_control"] is not None assert ready["verified_control"] is not None
assert FakeExecutor.records[:2] == ["inspection:1", "connection:2-6"]
assert coordinator.prepares == [] assert coordinator.prepares == []
assert "start:11-14" not in FakeExecutor.records assert "start:11-14" not in FakeExecutor.records
assert "stop" not in FakeExecutor.records assert "stop" not in FakeExecutor.records
@@ -536,7 +537,7 @@ def test_read_only_device_info_open_does_not_require_physical_acceptance(
) )
def test_inspection_session_rejects_workspace_until_verify_releases_boundary( def test_inspection_session_rejects_workspace_and_closes_without_connection_stage(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
FakeExecutor.records = [] FakeExecutor.records = []
@@ -561,21 +562,12 @@ def test_inspection_session_rejects_workspace_until_verify_releases_boundary(
) )
assert FakeExecutor.records == ["inspection:1", "wait:workspace-entered"] assert FakeExecutor.records == ["inspection:1", "wait:workspace-entered"]
released = session.release_inspection_for_operator_dialogue( session.close_prestart(
expected_session_generation=ready["session_generation"], # type: ignore[arg-type] expected_session_generation=ready["session_generation"], # type: ignore[arg-type]
expected_state_revision=ready["state_revision"], # type: ignore[arg-type] expected_state_revision=ready["state_revision"], # type: ignore[arg-type]
) )
session.enter_workspace( _wait_phase(session, "closed")
expected_session_generation=released["session_generation"], # type: ignore[arg-type] assert FakeExecutor.records == ["inspection:1", "wait:workspace-entered"]
expected_state_revision=released["state_revision"], # type: ignore[arg-type]
)
_wait_phase(session, "workspace-ready")
assert FakeExecutor.records[:4] == [
"inspection:1",
"wait:workspace-entered",
"connection:2-6",
"workspace:7",
]
@pytest.mark.parametrize( @pytest.mark.parametrize(