chore(k1): checkpoint connection recovery work

This commit is contained in:
DCCONSTRUCTIONS
2026-08-21 08:12:22 +03:00
parent a3138f3d71
commit c7843a3c7e
22 changed files with 969 additions and 293 deletions
@@ -292,6 +292,21 @@ export function isLiveRerunPresentationReady(
isUsableRecordedPlaybackRange(rangeNs);
}
/**
* `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/,
+21 -12
View File
@@ -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. |
+34 -21
View File
@@ -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>
+34 -16
View File
@@ -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")
+121 -43
View File
@@ -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."""
+4 -4
View File
@@ -132,16 +132,16 @@ def test_xgrids_live_copy_exposes_response_gated_prepare_and_one_physical_start(
assert "Запустить приём" in connection_source
assert "Запустить 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
+59 -15
View File
@@ -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,
+5 -13
View File
@@ -527,6 +527,7 @@ def test_read_only_device_info_open_does_not_require_physical_acceptance(
ready = _wait_phase(session, "connection-ready")
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(