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