Стабилизация переключения Bridge и Quick Connect
Безопасно восстанавливает управляющее подключение и локальные checkpoint без повторных команд сканеру. Добавляет PCAP/Bridge guardrails и регрессионные проверки одношагового переподключения. Известный дефект: после второго подключения интерфейс не присоединяется к новой генерации preview правой камеры. В живой Quick Connect-сессии STOP был принят, но READY не подтвердился до таймаута; автоматический повтор STOP запрещён.
This commit is contained in:
@@ -17,6 +17,7 @@ let physicalRecoveryConnectionDetail;
|
||||
let shouldRenderK1OperationalPanels;
|
||||
let K1ProvisioningPipeline;
|
||||
let RuntimeActionFenceTestContext;
|
||||
let LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS;
|
||||
let emptySearchPresentation;
|
||||
let emptyProvisioningAttemptPresentation;
|
||||
let emptyReadOnlyReconnectPresentation;
|
||||
@@ -127,6 +128,7 @@ before(async () => {
|
||||
({
|
||||
K1ProvisioningPipeline,
|
||||
RuntimeActionFenceTestContext,
|
||||
LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS,
|
||||
emptySearchPresentation,
|
||||
emptyProvisioningAttemptPresentation,
|
||||
emptyReadOnlyReconnectPresentation,
|
||||
@@ -3245,6 +3247,7 @@ test("replay and local-only stop branches use bounded process copy", () => {
|
||||
|
||||
const stopState = runtimeState();
|
||||
stopState.compatibility.vendor_writes_enabled = false;
|
||||
stopState.acquisition.control_mode = "operator-manual";
|
||||
stopState.connection_policy = {
|
||||
schema_version: "missioncore.xgrids-k1-connection-policy/v1",
|
||||
facts: { retained_context_is_presence: false },
|
||||
@@ -3272,7 +3275,68 @@ test("replay and local-only stop branches use bounded process copy", () => {
|
||||
assertCanonicalConnectionCopy(stopMarkup);
|
||||
});
|
||||
|
||||
test("physical STOP is fail-closed and both acquisition surfaces fall back to local cleanup", () => {
|
||||
test("software-commanded calibration exposes no teardown until canonical STOP is safe", () => {
|
||||
const state = runtimeState();
|
||||
state.snapshot_runtime_id = "runtime-calibration-001";
|
||||
state.snapshot_revision = 31;
|
||||
state.phase = "starting_live";
|
||||
state.source_mode = "live";
|
||||
state.acquisition.state = "awaiting_external_start";
|
||||
state.acquisition.state_revision = 3;
|
||||
state.application_control_session = {
|
||||
session_generation: 5,
|
||||
state_revision: 7,
|
||||
state: "device-initializing",
|
||||
can_stop: false,
|
||||
control_socket_open: true,
|
||||
};
|
||||
state.connection_policy = {
|
||||
schema_version: "missioncore.xgrids-k1-connection-policy/v1",
|
||||
facts: { retained_context_is_presence: false },
|
||||
allowed_actions: ["stop-local-receiver"],
|
||||
actions: {
|
||||
"stop-acquisition": {
|
||||
allowed: false,
|
||||
reason_codes: ["supervisor-action-not-allowed"],
|
||||
target_source: "connection-supervisor",
|
||||
required_transport_ref: null,
|
||||
required_connection_mode: null,
|
||||
requires_live_gatt_validation: false,
|
||||
automatic_retry: false,
|
||||
},
|
||||
"stop-local-receiver": {
|
||||
allowed: true,
|
||||
reason_codes: [],
|
||||
target_source: "local-runtime",
|
||||
required_transport_ref: null,
|
||||
required_connection_mode: null,
|
||||
requires_live_gatt_validation: false,
|
||||
automatic_retry: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const pipelineMarkup = renderToStaticMarkup(createElement(K1AcquisitionPipeline, {
|
||||
controller: acquisitionController(state),
|
||||
desiredConnectionMode: "bridge",
|
||||
openSpatialScene() {},
|
||||
activateAutomaticSpatialSource() {},
|
||||
}));
|
||||
const spatialMarkup = renderToStaticMarkup(createElement(K1SpatialControlsView, {
|
||||
controller: acquisitionController(state),
|
||||
}));
|
||||
|
||||
assert.match(pipelineMarkup, /Кнопка остановки появится только после подтверждённого SCANNING/);
|
||||
assert.match(spatialMarkup, /K1 калибруется и готовит облако точек/);
|
||||
for (const markup of [pipelineMarkup, spatialMarkup]) {
|
||||
assert.equal(buttonMarkupWithText(markup, "Завершить локальный приём").length, 0);
|
||||
assert.equal(buttonMarkupWithText(markup, "Аварийно завершить локальный приём").length, 0);
|
||||
assert.equal(buttonMarkupWithText(markup, "Остановить устройство и запись").length, 0);
|
||||
assert.equal(buttonMarkupWithText(markup, "Отменить запуск до START").length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test("physical STOP is fail-closed across both acquisition surfaces", () => {
|
||||
const state = runtimeState();
|
||||
state.snapshot_runtime_id = "runtime-stop-001";
|
||||
state.snapshot_revision = 40;
|
||||
@@ -3442,13 +3506,15 @@ test("physical STOP is fail-closed and both acquisition surfaces fall back to lo
|
||||
},
|
||||
));
|
||||
assert.doesNotMatch(dismissedFailureMarkup, /Остановить устройство и запись/);
|
||||
assert.match(dismissedFailureMarkup, /Повторная команда устройству не отправляется/);
|
||||
const localButtons = buttonMarkupWithText(
|
||||
dismissedFailureMarkup,
|
||||
"Завершить локальный приём",
|
||||
);
|
||||
assert.equal(localButtons.length, 1);
|
||||
assert.doesNotMatch(localButtons[0], /\bdisabled(?:=|\s|>)/);
|
||||
assert.equal(localButtons.length, 0);
|
||||
assert.match(
|
||||
dismissedFailureMarkup,
|
||||
/Повторная команда и локальное завершение заблокированы/,
|
||||
);
|
||||
assertCanonicalConnectionCopy(dismissedFailureMarkup);
|
||||
|
||||
const classifiedStopState = structuredClone(state);
|
||||
@@ -3485,7 +3551,11 @@ test("physical STOP is fail-closed and both acquisition surfaces fall back to lo
|
||||
activateAutomaticSpatialSource() {},
|
||||
},
|
||||
));
|
||||
assert.match(localCleanupPendingMarkup, /Завершение локального приёма…/);
|
||||
assert.doesNotMatch(localCleanupPendingMarkup, /Завершение локального приёма…/);
|
||||
assert.match(
|
||||
localCleanupPendingMarkup,
|
||||
/Повторная команда и локальное завершение заблокированы/,
|
||||
);
|
||||
assert.doesNotMatch(localCleanupPendingMarkup, /Остановка устройства…/);
|
||||
|
||||
const physicalStopPendingMarkup = renderToStaticMarkup(createElement(
|
||||
@@ -5802,6 +5872,78 @@ test("physical recovery new-device action performs only an explicit scenario res
|
||||
assert.equal(reopenCalls, 0);
|
||||
});
|
||||
|
||||
test("topology switch stays silent for two seconds and then uses only the inline loader", async () => {
|
||||
const state = terminalConnectionRecoveryState();
|
||||
state.connection_attempt = null;
|
||||
let resolveReset;
|
||||
const resetSettlement = new Promise((resolve) => {
|
||||
resolveReset = resolve;
|
||||
});
|
||||
const resetRequests = [];
|
||||
const controller = {
|
||||
...provisioningController(state),
|
||||
selectConnectionMode: (request) => {
|
||||
resetRequests.push(request);
|
||||
return resetSettlement;
|
||||
},
|
||||
};
|
||||
const props = {
|
||||
controller,
|
||||
desiredMode: "bridge",
|
||||
onDesiredModeChange: () => undefined,
|
||||
};
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const originalClearTimeout = globalThis.clearTimeout;
|
||||
const timers = [];
|
||||
globalThis.setTimeout = (callback, delay, ...args) => {
|
||||
const timer = { callback, delay, args, cleared: false };
|
||||
timers.push(timer);
|
||||
return timer;
|
||||
};
|
||||
globalThis.clearTimeout = (timer) => {
|
||||
timer.cleared = true;
|
||||
};
|
||||
const harness = createStatefulProvisioningHarness(props);
|
||||
try {
|
||||
let tree = harness.render();
|
||||
harness.flushEffects();
|
||||
const modeSelect = elementByProp(tree, "label", "Способ подключения");
|
||||
assert.ok(modeSelect);
|
||||
modeSelect.props.onChange("quick-connect");
|
||||
assert.equal(resetRequests.length, 1);
|
||||
|
||||
tree = harness.render();
|
||||
harness.flushEffects();
|
||||
let markup = renderToStaticMarkup(tree);
|
||||
assert.doesNotMatch(markup, /Переключаем способ подключения/);
|
||||
assert.doesNotMatch(markup, /Завершение прежней локальной границы/);
|
||||
const progressTimer = timers.find(
|
||||
(timer) => timer.delay === LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS,
|
||||
);
|
||||
assert.ok(progressTimer);
|
||||
assert.equal(progressTimer.cleared, false);
|
||||
|
||||
progressTimer.callback(...progressTimer.args);
|
||||
tree = harness.render();
|
||||
markup = renderToStaticMarkup(tree);
|
||||
assert.match(markup, /Переключаем способ подключения/);
|
||||
assert.match(markup, /connection-action-progress/);
|
||||
assert.doesNotMatch(markup, /dialog|modal|window/i);
|
||||
|
||||
resolveReset(true);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
tree = harness.render();
|
||||
harness.flushEffects();
|
||||
markup = renderToStaticMarkup(tree);
|
||||
assert.doesNotMatch(markup, /Переключаем способ подключения/);
|
||||
} finally {
|
||||
harness.dispose();
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
globalThis.clearTimeout = originalClearTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
test("physical retirement dispatch refuses a rendered runtime A after runtime B is current", async () => {
|
||||
let currentRuntimeId = "runtime-B";
|
||||
let mutationRequests = 0;
|
||||
@@ -6360,7 +6502,7 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p
|
||||
...provisioningController(coldQuickState),
|
||||
getConnectionRecoveryObservationTarget: () => coldQuickTarget,
|
||||
},
|
||||
desiredMode: "bridge",
|
||||
desiredMode: "quick-connect",
|
||||
});
|
||||
const coldQuickReconnect = buttonMarkupWithText(
|
||||
coldQuickMarkup,
|
||||
@@ -6374,6 +6516,32 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p
|
||||
);
|
||||
assert.match(coldQuickMarkup, /Quick Connect/);
|
||||
|
||||
const quickTargetInsideBridgeMarkup = renderProvisioning({
|
||||
controller: {
|
||||
...provisioningController(coldQuickState),
|
||||
getConnectionRecoveryObservationTarget: () => coldQuickTarget,
|
||||
},
|
||||
desiredMode: "bridge",
|
||||
});
|
||||
assert.equal(
|
||||
buttonMarkupWithText(
|
||||
quickTargetInsideBridgeMarkup,
|
||||
"Переподключиться",
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
buttonMarkupWithText(
|
||||
quickTargetInsideBridgeMarkup,
|
||||
"Найти по Bluetooth",
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
quickTargetInsideBridgeMarkup,
|
||||
/Сохранённое подключение/,
|
||||
);
|
||||
|
||||
const unrelatedMarkup = renderProvisioningWithAttempt({
|
||||
controller: {
|
||||
...controller,
|
||||
@@ -6385,6 +6553,41 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p
|
||||
assert.match(unrelatedMarkup, /Сохранённое подключение/);
|
||||
});
|
||||
|
||||
test("saved Bridge reconnect stays hidden in a fresh Quick Connect draft", () => {
|
||||
const state = terminalConnectionRecoveryState();
|
||||
state.connection_attempt = null;
|
||||
const bridgeTarget = recommendedConnectionRecoveryObservationTarget(state);
|
||||
assert.equal(bridgeTarget?.connectionMode, "bridge");
|
||||
const controller = {
|
||||
...provisioningController(state),
|
||||
getConnectionRecoveryObservationTarget: () => bridgeTarget,
|
||||
};
|
||||
|
||||
const quickConnectMarkup = renderProvisioning({
|
||||
controller,
|
||||
desiredMode: "quick-connect",
|
||||
});
|
||||
assert.equal(
|
||||
buttonMarkupWithText(quickConnectMarkup, "Переподключиться").length,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
buttonMarkupWithText(quickConnectMarkup, "Найти по Bluetooth").length,
|
||||
1,
|
||||
);
|
||||
assert.doesNotMatch(quickConnectMarkup, /Сохранённое подключение/);
|
||||
|
||||
const bridgeMarkup = renderProvisioning({
|
||||
controller,
|
||||
desiredMode: "bridge",
|
||||
});
|
||||
assert.equal(
|
||||
buttonMarkupWithText(bridgeMarkup, "Переподключиться").length,
|
||||
1,
|
||||
);
|
||||
assert.match(bridgeMarkup, /Сохранённое подключение/);
|
||||
});
|
||||
|
||||
test("read-only reconnect keeps its card and loader across a transient projection", async () => {
|
||||
const initialState = terminalConnectionRecoveryState();
|
||||
initialState.connection_attempt = null;
|
||||
|
||||
@@ -96,6 +96,15 @@ Core Quick Connect intent emits one AP-enable frame even when the baseline mode
|
||||
already says `WIFI_AP`, then polls for the byte-51 ready flag for at most 15
|
||||
seconds. It never retries the device write automatically.
|
||||
|
||||
For that exact explicit intent, a completed write-with-response followed by
|
||||
the same canonical `WIFI_AP / 192.168.56.1 / ready` observation is an
|
||||
idempotent ensure-target success: the K1 AP was already ready, and the host may
|
||||
continue into the bounded exact-SSID CoreWLAN association. This exception
|
||||
requires the durable ledger to record `write_confirmed=true`. An unconfirmed
|
||||
dispatch, a timeout, or an unchanged read-only observation without that exact
|
||||
confirmed write remains ambiguous and cannot authorize host association or an
|
||||
automatic retry.
|
||||
|
||||
Static review of the original client also established a lifecycle requirement:
|
||||
LixelGO keeps the same BLE manager connected after AP-ready and invokes native
|
||||
Wi-Fi association from that live session. Mission Core now retains the same
|
||||
|
||||
@@ -163,6 +163,13 @@ contract coverage but remains a distinct physical acceptance gate; it must not
|
||||
be reported as field-accepted until one redacted live run records both sides of
|
||||
the transition.
|
||||
|
||||
Quick Connect Apply is an exact idempotent ensure-target operation when the
|
||||
selected K1 already reports the canonical AP-ready state. Mission Core accepts
|
||||
that unchanged state only after the one reviewed write-with-response is
|
||||
durably confirmed, then performs the normal exact device-SSID host association.
|
||||
An unconfirmed or merely read-only unchanged observation remains fail-closed;
|
||||
this rule neither retries the BLE write nor weakens Bridge admission.
|
||||
|
||||
The corrected host boundary derives a non-secret, device-scoped profile ID from
|
||||
the selected SSID. The reviewed client contains per-device `WiFiAP_SSID` and
|
||||
`WiFiAP_Password` fields, but the 2026-07-20 review of the exact official K1
|
||||
|
||||
@@ -1036,6 +1036,16 @@ export interface XgridsK1State {
|
||||
network_write_performed: false;
|
||||
automatic_scan: false;
|
||||
operation_sequence: number;
|
||||
timing?: {
|
||||
schema_version: "missioncore.xgrids-k1-connection-scenario-reset-timing/v1";
|
||||
started_at_utc: string;
|
||||
completed_at_utc: string;
|
||||
total_ms: number;
|
||||
lifecycle_boundary_wait_ms: number;
|
||||
monitor_quiescence_wait_ms: number;
|
||||
local_retirement_ms: number;
|
||||
intent_commit_ms: number;
|
||||
};
|
||||
} | null;
|
||||
connection_scenario_reset_pending?: {
|
||||
reset_id: string;
|
||||
|
||||
@@ -207,9 +207,16 @@ export function K1AcquisitionPipeline({
|
||||
&& canIssueCanonicalStop(state, physicalStopIntentSpent),
|
||||
);
|
||||
const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
|
||||
const protectedSoftwareCommandedTransition = Boolean(
|
||||
activeAcquisition
|
||||
&& activeAcquisition.control_mode === "plugin-commanded"
|
||||
&& activeAcquisition.state !== "prepared"
|
||||
&& !isTerminalAcquisitionState(activeAcquisition.state),
|
||||
);
|
||||
const localReceiverStopExecutable = Boolean(
|
||||
connectionPolicyAllows(state, "stop-local-receiver")
|
||||
&& preparedAcquisition === null,
|
||||
&& preparedAcquisition === null
|
||||
&& !protectedSoftwareCommandedTransition,
|
||||
);
|
||||
const terminalPhysicalStopPending = terminalPhysicalStopObserved
|
||||
&& physicalStopInFlight;
|
||||
@@ -306,6 +313,10 @@ export function K1AcquisitionPipeline({
|
||||
: "Управляющая команда устройству сейчас недоступна. Завершите только разрешённый сервером локальный приём или выполните read-only восстановление."
|
||||
: terminalReadOnlyRecovery
|
||||
? "Управляющие действия сейчас не разрешены. Дождитесь нового подтверждённого состояния или выполните read-only восстановление."
|
||||
: protectedSoftwareCommandedTransition && !physicalStopPresented
|
||||
? physicalStopIntentSpent
|
||||
? "Команда остановки уже была принята интерфейсом. Повторная команда и локальное завершение заблокированы до нового подтверждённого состояния K1."
|
||||
: "K1 выполняет переход к сканированию. Кнопка остановки появится только после подтверждённого SCANNING."
|
||||
: physicalStopGuidance
|
||||
? `${physicalStopGuidance.reason} ${physicalStopGuidance.nextAction}`
|
||||
: gracefulStopTarget && !physicalStopPresented && physicalStopIntentSpent
|
||||
@@ -572,7 +583,7 @@ export function K1AcquisitionPipeline({
|
||||
: physicalStopPresented ? recoveredPhysicalStop ? "Остановить сканирование" : "Остановить устройство и запись" : state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Завершить локальный приём"}
|
||||
</Button>
|
||||
) : null}
|
||||
{activeAcquisition ? (
|
||||
{activeAcquisition && !protectedSoftwareCommandedTransition ? (
|
||||
<Button variant="ghost" disabled={isBusy} onClick={() => void abort()}>
|
||||
{pendingAction === "abort" ? "Прерываем локальную операцию…" : preparedAcquisition ? "Отменить подготовку" : "Аварийно завершить локальный приём"}
|
||||
</Button>
|
||||
|
||||
@@ -100,6 +100,31 @@ export function emptyReadOnlyReconnectPresentation():
|
||||
return null;
|
||||
}
|
||||
|
||||
export const LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS = 2_000;
|
||||
|
||||
/**
|
||||
* Keep short local lifecycle settlements visually silent. Safety interlocks
|
||||
* still take effect immediately; only the progress presentation is deferred.
|
||||
*/
|
||||
export function useDelayedLocalOperationProgress(
|
||||
active: boolean,
|
||||
delayMilliseconds = LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS,
|
||||
): boolean {
|
||||
const [visible, setVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setVisible(false);
|
||||
return undefined;
|
||||
}
|
||||
const timer = globalThis.setTimeout(
|
||||
() => setVisible(true),
|
||||
delayMilliseconds,
|
||||
);
|
||||
return () => globalThis.clearTimeout(timer);
|
||||
}, [active, delayMilliseconds]);
|
||||
return active && visible;
|
||||
}
|
||||
|
||||
export interface PhysicalReopenPresentation {
|
||||
key: string;
|
||||
snapshotRuntimeId: string;
|
||||
@@ -1240,6 +1265,9 @@ export function K1ProvisioningPipeline({
|
||||
targetMode: ConnectionMode;
|
||||
} | null>(null);
|
||||
const modeResetInFlight = pendingAction === "mode" || modeResetPending !== null;
|
||||
const modeResetProgressVisible = useDelayedLocalOperationProgress(
|
||||
modeResetInFlight,
|
||||
);
|
||||
const [selectedDeviceSnapshot, setSelectedDeviceSnapshot] = useState<BleDevice | null>(null);
|
||||
const [ssid, setSsid] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -1657,7 +1685,8 @@ export function K1ProvisioningPipeline({
|
||||
? readOnlyReconnectPresentation
|
||||
: null;
|
||||
const connectionRecoveryVerificationPending =
|
||||
currentReadOnlyReconnectPresentation?.kind === "connection";
|
||||
currentReadOnlyReconnectPresentation?.kind === "connection"
|
||||
&& currentReadOnlyReconnectPresentation.connectionMode === connectionMode;
|
||||
const physicalRecoveryVerificationPending =
|
||||
currentReadOnlyReconnectPresentation?.kind === "physical";
|
||||
const provisioningMutationBusy = Boolean(
|
||||
@@ -1796,7 +1825,7 @@ export function K1ProvisioningPipeline({
|
||||
state,
|
||||
connectionMode,
|
||||
);
|
||||
const connectionRecoveryTarget = !physicalRecoveryRequired
|
||||
const recommendedConnectionRecoveryTarget = !physicalRecoveryRequired
|
||||
&& !unresolvedAppliedAttempt
|
||||
&& !selectedModeConnected
|
||||
&& !reconfigurationActive
|
||||
@@ -1804,6 +1833,13 @@ export function K1ProvisioningPipeline({
|
||||
&& connectionRecoveryObservationAllowed
|
||||
? recommendedConnectionRecoveryObservationTarget(state)
|
||||
: null;
|
||||
// A durable recovery target remains available when the operator returns to
|
||||
// its mode, but it must never leak a Bridge reconnect action into a freshly
|
||||
// selected Quick Connect draft (or vice versa).
|
||||
const connectionRecoveryTarget =
|
||||
recommendedConnectionRecoveryTarget?.connectionMode === connectionMode
|
||||
? recommendedConnectionRecoveryTarget
|
||||
: null;
|
||||
const connectionRecoveryKey = connectionRecoveryEscapeKey({
|
||||
snapshotRuntimeId,
|
||||
attempt: connectionRecoveryAttempt,
|
||||
@@ -3162,10 +3198,16 @@ export function K1ProvisioningPipeline({
|
||||
disabled={modeResetInFlight}
|
||||
variant="split"
|
||||
/>
|
||||
{modeResetInFlight ? (
|
||||
<p className="safety-note" role="status">
|
||||
Завершение прежней локальной границы. Новый поиск не начнётся автоматически.
|
||||
</p>
|
||||
{modeResetProgressVisible ? (
|
||||
<div
|
||||
className="connection-action-progress"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<ActivityIndicator size="compact" />
|
||||
<strong>Переключаем способ подключения…</strong>
|
||||
</div>
|
||||
) : null}
|
||||
{activeScenarioReset ? (
|
||||
<p className="safety-note" role="status">
|
||||
|
||||
@@ -197,6 +197,7 @@ export function K1SpatialControlsView({
|
||||
&& canIssueCanonicalStop(state, physicalStopIntentSpent),
|
||||
);
|
||||
const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
|
||||
const pluginCommandedAcquisition = acquisition?.control_mode === "plugin-commanded";
|
||||
const stopping = ["awaiting_external_stop", "stopping", "finalizing"].includes(
|
||||
acquisition?.state ?? "",
|
||||
);
|
||||
@@ -327,7 +328,10 @@ export function K1SpatialControlsView({
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{!physicalStopPresented && localReceiverStopAllowed && !stopping ? (
|
||||
{!physicalStopPresented
|
||||
&& localReceiverStopAllowed
|
||||
&& !pluginCommandedAcquisition
|
||||
&& !stopping ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
|
||||
Executable
+233
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail closed if Quick Connect work drifts from K1 protocol or Bridge baselines.
|
||||
|
||||
This checker performs local filesystem, Git, and synthetic-test validation only.
|
||||
It never opens BLE, CoreWLAN, MQTT, RTSP, or a socket to the scanner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
BASELINE_COMMIT = "1001a316385a742114523c9996c43034898c115d"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CaptureOracle:
|
||||
relative_path: str
|
||||
size_bytes: int
|
||||
sha256: str
|
||||
|
||||
|
||||
CAPTURE_ORACLES = (
|
||||
CaptureOracle(
|
||||
"sessions/iphone-k1-observation/"
|
||||
"20260716T151239Z_lixelgo-clean-cycle_d547/"
|
||||
"captures/iphone-network.pcapng",
|
||||
126_962_848,
|
||||
"e6e98e22156ba2ce041c5f6ff32a3116446aaae8b617c90c16b3f00de8d5100f",
|
||||
),
|
||||
CaptureOracle(
|
||||
"sessions/iphone-k1-observation/"
|
||||
"20260716T151239Z_lixelgo-clean-cycle_d547/"
|
||||
"captures/iphone-network.pcap",
|
||||
116_785_317,
|
||||
"45ebaebbba8c8f62ebc405eb0f7cdc496d84dcdf80c01decba61c56b4514a26f",
|
||||
),
|
||||
CaptureOracle(
|
||||
"sessions/iphone-k1-observation/"
|
||||
"20260716T152407Z_lixelgo-controls-stop_9be0/"
|
||||
"captures/iphone-network.pcapng",
|
||||
254_161_132,
|
||||
"59133299bb1c4dc2157e50401ddef4c131acd2196d7d2e2ccddd5c51b95e57cb",
|
||||
),
|
||||
CaptureOracle(
|
||||
"sessions/iphone-k1-observation/"
|
||||
"20260716T152407Z_lixelgo-controls-stop_9be0/"
|
||||
"captures/iphone-network.pcap",
|
||||
234_691_027,
|
||||
"6e267f0ab9b213e5dde732b94cf3f4b2f32fcf66f5569bcb511a13c952883606",
|
||||
),
|
||||
)
|
||||
|
||||
FROZEN_PATHS = (
|
||||
"src/k1link/device_plugins/xgrids_k1/protocol",
|
||||
"src/k1link/device_plugins/xgrids_k1/ble/ap_activation.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/ble/wifi_provisioning.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/connection_supervisor.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/physical_command_coordinator.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/physical_command_ledger.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/mqtt",
|
||||
"src/k1link/device_plugins/xgrids_k1/camera.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/viewer/runtime.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/archive.py",
|
||||
)
|
||||
|
||||
PYTEST_TARGETS = (
|
||||
"tests/test_xgrids_application_bootstrap.py",
|
||||
(
|
||||
"tests/test_xgrids_application_mqtt.py::"
|
||||
"test_unanswered_optional_status_does_not_steal_same_identity_required_refresh"
|
||||
),
|
||||
(
|
||||
"tests/test_xgrids_application_mqtt.py::"
|
||||
"test_already_arrived_optional_status_is_consumed_before_required_refresh"
|
||||
),
|
||||
(
|
||||
"tests/test_xgrids_application_mqtt.py::"
|
||||
"test_unbound_optional_status_remains_distinct_from_bound_required_refresh"
|
||||
),
|
||||
(
|
||||
"tests/test_xgrids_application_session.py::"
|
||||
"test_canonical_stages_require_operator_events_but_device_standby_does_not"
|
||||
),
|
||||
(
|
||||
"tests/test_connection_supervisor.py::"
|
||||
"test_quick_to_bridge_revokes_authority_with_same_target_and_host_epoch"
|
||||
),
|
||||
(
|
||||
"tests/test_xgrids_acquisition_lifecycle.py::"
|
||||
"test_applied_bridge_target_requires_explicit_wifi_client_mode"
|
||||
),
|
||||
(
|
||||
"tests/test_xgrids_acquisition_lifecycle.py::"
|
||||
"test_post_dispatch_bridge_accepts_exact_fw302_network_name_when_baseline_is_same"
|
||||
),
|
||||
(
|
||||
"tests/test_xgrids_acquisition_lifecycle.py::"
|
||||
"test_post_dispatch_bridge_rejects_another_fw302_network_name"
|
||||
),
|
||||
(
|
||||
"tests/test_xgrids_acquisition_lifecycle.py::"
|
||||
"test_bridge_read_only_verify_never_shortcuts_fresh_ble_status"
|
||||
),
|
||||
(
|
||||
"tests/test_xgrids_acquisition_lifecycle.py::"
|
||||
"test_durable_restart_same_bridge_baseline_cannot_resolve_ambiguous_write"
|
||||
),
|
||||
(
|
||||
"tests/test_xgrids_acquisition_lifecycle.py::"
|
||||
"test_quick_to_bridge_requires_new_explicit_scan_instead_of_retained_session"
|
||||
),
|
||||
(
|
||||
"tests/test_xgrids_acquisition_lifecycle.py::"
|
||||
"test_quick_to_bridge_does_not_retrieve_old_handle_without_new_scan"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise RuntimeError(message)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
while chunk := source.read(4 * 1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def verify_capture_oracles() -> None:
|
||||
for oracle in CAPTURE_ORACLES:
|
||||
path = REPOSITORY_ROOT / oracle.relative_path
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except FileNotFoundError:
|
||||
fail(f"capture oracle is missing: {oracle.relative_path}")
|
||||
if not stat.S_ISREG(metadata.st_mode) or path.is_symlink():
|
||||
fail(f"capture oracle is not a regular non-symlink file: {oracle.relative_path}")
|
||||
if stat.S_IMODE(metadata.st_mode) != 0o600:
|
||||
fail(f"capture oracle mode is not 0600: {oracle.relative_path}")
|
||||
if metadata.st_size != oracle.size_bytes:
|
||||
fail(
|
||||
"capture oracle size changed: "
|
||||
f"{oracle.relative_path} ({metadata.st_size} != {oracle.size_bytes})"
|
||||
)
|
||||
observed_sha256 = sha256_file(path)
|
||||
if observed_sha256 != oracle.sha256:
|
||||
fail(
|
||||
"capture oracle SHA-256 changed: "
|
||||
f"{oracle.relative_path} ({observed_sha256} != {oracle.sha256})"
|
||||
)
|
||||
print(f"[k1-guardrail] PASS capture oracles: {len(CAPTURE_ORACLES)}")
|
||||
|
||||
|
||||
def git_output(*args: str) -> str:
|
||||
result = subprocess.run(
|
||||
("git", *args),
|
||||
cwd=REPOSITORY_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def verify_frozen_contour() -> None:
|
||||
git_output("cat-file", "-e", f"{BASELINE_COMMIT}^{{commit}}")
|
||||
changed = git_output("diff", "--name-only", BASELINE_COMMIT, "--", *FROZEN_PATHS)
|
||||
untracked = git_output(
|
||||
"ls-files",
|
||||
"--others",
|
||||
"--exclude-standard",
|
||||
"--",
|
||||
*FROZEN_PATHS,
|
||||
)
|
||||
drift = tuple(line for line in (changed, untracked) if line)
|
||||
if drift:
|
||||
fail("frozen K1 protocol/Bridge contour changed:\n" + "\n".join(drift))
|
||||
print(
|
||||
"[k1-guardrail] PASS frozen protocol/Bridge contour: "
|
||||
f"baseline {BASELINE_COMMIT[:7]}"
|
||||
)
|
||||
|
||||
|
||||
def run_checked(command: tuple[str, ...]) -> None:
|
||||
print("[k1-guardrail] RUN " + " ".join(command), flush=True)
|
||||
subprocess.run(command, cwd=REPOSITORY_ROOT, check=True)
|
||||
|
||||
|
||||
def verify_synthetic_contracts() -> None:
|
||||
python = REPOSITORY_ROOT / ".venv" / "bin" / "python"
|
||||
ruff = REPOSITORY_ROOT / ".venv" / "bin" / "ruff"
|
||||
if not python.is_file():
|
||||
fail("repository Python runtime is missing: .venv/bin/python")
|
||||
if not ruff.is_file():
|
||||
fail("repository Ruff runtime is missing: .venv/bin/ruff")
|
||||
run_checked((str(python), "-m", "pytest", "-q", *PYTEST_TARGETS))
|
||||
run_checked(
|
||||
(
|
||||
str(ruff),
|
||||
"check",
|
||||
"scripts/check_k1_quick_connect_guardrails.py",
|
||||
"src/k1link/device_plugins/xgrids_k1/facade.py",
|
||||
"tests/test_xgrids_acquisition_lifecycle.py",
|
||||
)
|
||||
)
|
||||
run_checked(("git", "diff", "--check"))
|
||||
print("[k1-guardrail] PASS synthetic protocol/Bridge sentinels")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.chdir(REPOSITORY_ROOT)
|
||||
try:
|
||||
verify_capture_oracles()
|
||||
verify_frozen_contour()
|
||||
verify_synthetic_contracts()
|
||||
except (OSError, RuntimeError, subprocess.CalledProcessError) as exc:
|
||||
print(f"[k1-guardrail] FAIL {exc}", file=sys.stderr)
|
||||
return 1
|
||||
print("[k1-guardrail] PASS all checks; no scanner I/O was performed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -3045,16 +3045,25 @@ def _require_prepared_resolved_start_standby(
|
||||
raise ActiveAcquisitionRecoveryCheckpointTransitionError(
|
||||
"resolved START origin does not bind the exact prepared acquisition"
|
||||
)
|
||||
if (
|
||||
cessation_status_proof.binding.runtime_instance_id
|
||||
recovery_binding = cessation_status_proof.binding
|
||||
same_runtime = (
|
||||
recovery_binding.runtime_instance_id
|
||||
== checkpoint.prepared_binding.runtime_instance_id
|
||||
or cessation_status_proof.binding.control_session_id
|
||||
)
|
||||
if (
|
||||
recovery_binding.control_session_id
|
||||
== checkpoint.prepared_binding.control_session_id
|
||||
or cessation_status_proof.evidence_session_id
|
||||
== checkpoint.original_evidence_session_id
|
||||
or (
|
||||
same_runtime
|
||||
and recovery_binding.producer_generation
|
||||
<= checkpoint.prepared_binding.producer_generation
|
||||
)
|
||||
):
|
||||
raise ActiveAcquisitionRecoveryCheckpointTransitionError(
|
||||
"restart standby settlement requires new runtime, control and evidence sessions"
|
||||
"standby settlement requires a new control/evidence boundary; "
|
||||
"same-runtime recovery also requires a newer producer generation"
|
||||
)
|
||||
exact_terminal_lineage = bool(
|
||||
cessation_physical_proof.operation_id
|
||||
@@ -3474,7 +3483,7 @@ def _require_active_reconciled_standby_shape(
|
||||
or (
|
||||
same_runtime
|
||||
and recovery_binding.producer_generation
|
||||
== failed_binding.producer_generation
|
||||
<= failed_binding.producer_generation
|
||||
)
|
||||
):
|
||||
raise ActiveAcquisitionRecoveryCheckpointTransitionError(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1134,6 +1134,44 @@ def test_cease_prepared_resolved_start_standby_rejects_inexact_restart_proofs(
|
||||
replace(status, mqtt_retained=True)
|
||||
|
||||
|
||||
def test_cease_prepared_resolved_start_standby_accepts_fresh_same_runtime_boundary(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A new verified control generation is sufficient without a process restart."""
|
||||
|
||||
prepared_binding = _binding()
|
||||
store = _store(tmp_path, monkeypatch)
|
||||
_prepare(store, prepared_binding)
|
||||
kwargs = _cease_prepared_resolved_start_standby_kwargs(
|
||||
prepared_binding,
|
||||
terminal_state="ready",
|
||||
)
|
||||
status = kwargs["cessation_status_proof"]
|
||||
physical = kwargs["cessation_physical_proof"]
|
||||
same_runtime_binding = replace(
|
||||
status.binding,
|
||||
runtime_instance_id=prepared_binding.runtime_instance_id,
|
||||
producer_generation=prepared_binding.producer_generation + 1,
|
||||
)
|
||||
kwargs["cessation_status_proof"] = replace(
|
||||
status,
|
||||
binding=same_runtime_binding,
|
||||
)
|
||||
kwargs["cessation_physical_proof"] = replace(
|
||||
physical,
|
||||
binding=same_runtime_binding,
|
||||
)
|
||||
|
||||
ceased = store.cease_prepared_resolved_start_standby(**kwargs)
|
||||
|
||||
assert ceased.state == "ceased"
|
||||
assert ceased.current_binding == same_runtime_binding
|
||||
assert ceased.current_evidence_session_id == "evidence-restarted"
|
||||
assert ceased.activated_at_utc is None
|
||||
assert ceased.first_published_pcl_proof is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("terminal_state", ("ready", "scan_over"))
|
||||
def test_cease_active_reconciled_standby_atomically_opens_and_closes_gap(
|
||||
tmp_path: Path,
|
||||
|
||||
Reference in New Issue
Block a user