Стабилизация переключения 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 shouldRenderK1OperationalPanels;
|
||||||
let K1ProvisioningPipeline;
|
let K1ProvisioningPipeline;
|
||||||
let RuntimeActionFenceTestContext;
|
let RuntimeActionFenceTestContext;
|
||||||
|
let LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS;
|
||||||
let emptySearchPresentation;
|
let emptySearchPresentation;
|
||||||
let emptyProvisioningAttemptPresentation;
|
let emptyProvisioningAttemptPresentation;
|
||||||
let emptyReadOnlyReconnectPresentation;
|
let emptyReadOnlyReconnectPresentation;
|
||||||
@@ -127,6 +128,7 @@ before(async () => {
|
|||||||
({
|
({
|
||||||
K1ProvisioningPipeline,
|
K1ProvisioningPipeline,
|
||||||
RuntimeActionFenceTestContext,
|
RuntimeActionFenceTestContext,
|
||||||
|
LOCAL_OPERATION_PROGRESS_DELAY_MILLISECONDS,
|
||||||
emptySearchPresentation,
|
emptySearchPresentation,
|
||||||
emptyProvisioningAttemptPresentation,
|
emptyProvisioningAttemptPresentation,
|
||||||
emptyReadOnlyReconnectPresentation,
|
emptyReadOnlyReconnectPresentation,
|
||||||
@@ -3245,6 +3247,7 @@ test("replay and local-only stop branches use bounded process copy", () => {
|
|||||||
|
|
||||||
const stopState = runtimeState();
|
const stopState = runtimeState();
|
||||||
stopState.compatibility.vendor_writes_enabled = false;
|
stopState.compatibility.vendor_writes_enabled = false;
|
||||||
|
stopState.acquisition.control_mode = "operator-manual";
|
||||||
stopState.connection_policy = {
|
stopState.connection_policy = {
|
||||||
schema_version: "missioncore.xgrids-k1-connection-policy/v1",
|
schema_version: "missioncore.xgrids-k1-connection-policy/v1",
|
||||||
facts: { retained_context_is_presence: false },
|
facts: { retained_context_is_presence: false },
|
||||||
@@ -3272,7 +3275,68 @@ test("replay and local-only stop branches use bounded process copy", () => {
|
|||||||
assertCanonicalConnectionCopy(stopMarkup);
|
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();
|
const state = runtimeState();
|
||||||
state.snapshot_runtime_id = "runtime-stop-001";
|
state.snapshot_runtime_id = "runtime-stop-001";
|
||||||
state.snapshot_revision = 40;
|
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.doesNotMatch(dismissedFailureMarkup, /Остановить устройство и запись/);
|
||||||
assert.match(dismissedFailureMarkup, /Повторная команда устройству не отправляется/);
|
|
||||||
const localButtons = buttonMarkupWithText(
|
const localButtons = buttonMarkupWithText(
|
||||||
dismissedFailureMarkup,
|
dismissedFailureMarkup,
|
||||||
"Завершить локальный приём",
|
"Завершить локальный приём",
|
||||||
);
|
);
|
||||||
assert.equal(localButtons.length, 1);
|
assert.equal(localButtons.length, 0);
|
||||||
assert.doesNotMatch(localButtons[0], /\bdisabled(?:=|\s|>)/);
|
assert.match(
|
||||||
|
dismissedFailureMarkup,
|
||||||
|
/Повторная команда и локальное завершение заблокированы/,
|
||||||
|
);
|
||||||
assertCanonicalConnectionCopy(dismissedFailureMarkup);
|
assertCanonicalConnectionCopy(dismissedFailureMarkup);
|
||||||
|
|
||||||
const classifiedStopState = structuredClone(state);
|
const classifiedStopState = structuredClone(state);
|
||||||
@@ -3485,7 +3551,11 @@ test("physical STOP is fail-closed and both acquisition surfaces fall back to lo
|
|||||||
activateAutomaticSpatialSource() {},
|
activateAutomaticSpatialSource() {},
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
assert.match(localCleanupPendingMarkup, /Завершение локального приёма…/);
|
assert.doesNotMatch(localCleanupPendingMarkup, /Завершение локального приёма…/);
|
||||||
|
assert.match(
|
||||||
|
localCleanupPendingMarkup,
|
||||||
|
/Повторная команда и локальное завершение заблокированы/,
|
||||||
|
);
|
||||||
assert.doesNotMatch(localCleanupPendingMarkup, /Остановка устройства…/);
|
assert.doesNotMatch(localCleanupPendingMarkup, /Остановка устройства…/);
|
||||||
|
|
||||||
const physicalStopPendingMarkup = renderToStaticMarkup(createElement(
|
const physicalStopPendingMarkup = renderToStaticMarkup(createElement(
|
||||||
@@ -5802,6 +5872,78 @@ test("physical recovery new-device action performs only an explicit scenario res
|
|||||||
assert.equal(reopenCalls, 0);
|
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 () => {
|
test("physical retirement dispatch refuses a rendered runtime A after runtime B is current", async () => {
|
||||||
let currentRuntimeId = "runtime-B";
|
let currentRuntimeId = "runtime-B";
|
||||||
let mutationRequests = 0;
|
let mutationRequests = 0;
|
||||||
@@ -6360,7 +6502,7 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p
|
|||||||
...provisioningController(coldQuickState),
|
...provisioningController(coldQuickState),
|
||||||
getConnectionRecoveryObservationTarget: () => coldQuickTarget,
|
getConnectionRecoveryObservationTarget: () => coldQuickTarget,
|
||||||
},
|
},
|
||||||
desiredMode: "bridge",
|
desiredMode: "quick-connect",
|
||||||
});
|
});
|
||||||
const coldQuickReconnect = buttonMarkupWithText(
|
const coldQuickReconnect = buttonMarkupWithText(
|
||||||
coldQuickMarkup,
|
coldQuickMarkup,
|
||||||
@@ -6374,6 +6516,32 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p
|
|||||||
);
|
);
|
||||||
assert.match(coldQuickMarkup, /Quick Connect/);
|
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({
|
const unrelatedMarkup = renderProvisioningWithAttempt({
|
||||||
controller: {
|
controller: {
|
||||||
...controller,
|
...controller,
|
||||||
@@ -6385,6 +6553,41 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p
|
|||||||
assert.match(unrelatedMarkup, /Сохранённое подключение/);
|
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 () => {
|
test("read-only reconnect keeps its card and loader across a transient projection", async () => {
|
||||||
const initialState = terminalConnectionRecoveryState();
|
const initialState = terminalConnectionRecoveryState();
|
||||||
initialState.connection_attempt = null;
|
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
|
already says `WIFI_AP`, then polls for the byte-51 ready flag for at most 15
|
||||||
seconds. It never retries the device write automatically.
|
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:
|
Static review of the original client also established a lifecycle requirement:
|
||||||
LixelGO keeps the same BLE manager connected after AP-ready and invokes native
|
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
|
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
|
be reported as field-accepted until one redacted live run records both sides of
|
||||||
the transition.
|
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 corrected host boundary derives a non-secret, device-scoped profile ID from
|
||||||
the selected SSID. The reviewed client contains per-device `WiFiAP_SSID` and
|
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
|
`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;
|
network_write_performed: false;
|
||||||
automatic_scan: false;
|
automatic_scan: false;
|
||||||
operation_sequence: number;
|
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;
|
} | null;
|
||||||
connection_scenario_reset_pending?: {
|
connection_scenario_reset_pending?: {
|
||||||
reset_id: string;
|
reset_id: string;
|
||||||
|
|||||||
@@ -207,9 +207,16 @@ export function K1AcquisitionPipeline({
|
|||||||
&& canIssueCanonicalStop(state, physicalStopIntentSpent),
|
&& canIssueCanonicalStop(state, physicalStopIntentSpent),
|
||||||
);
|
);
|
||||||
const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
|
const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
|
||||||
|
const protectedSoftwareCommandedTransition = Boolean(
|
||||||
|
activeAcquisition
|
||||||
|
&& activeAcquisition.control_mode === "plugin-commanded"
|
||||||
|
&& activeAcquisition.state !== "prepared"
|
||||||
|
&& !isTerminalAcquisitionState(activeAcquisition.state),
|
||||||
|
);
|
||||||
const localReceiverStopExecutable = Boolean(
|
const localReceiverStopExecutable = Boolean(
|
||||||
connectionPolicyAllows(state, "stop-local-receiver")
|
connectionPolicyAllows(state, "stop-local-receiver")
|
||||||
&& preparedAcquisition === null,
|
&& preparedAcquisition === null
|
||||||
|
&& !protectedSoftwareCommandedTransition,
|
||||||
);
|
);
|
||||||
const terminalPhysicalStopPending = terminalPhysicalStopObserved
|
const terminalPhysicalStopPending = terminalPhysicalStopObserved
|
||||||
&& physicalStopInFlight;
|
&& physicalStopInFlight;
|
||||||
@@ -306,6 +313,10 @@ export function K1AcquisitionPipeline({
|
|||||||
: "Управляющая команда устройству сейчас недоступна. Завершите только разрешённый сервером локальный приём или выполните read-only восстановление."
|
: "Управляющая команда устройству сейчас недоступна. Завершите только разрешённый сервером локальный приём или выполните read-only восстановление."
|
||||||
: terminalReadOnlyRecovery
|
: terminalReadOnlyRecovery
|
||||||
? "Управляющие действия сейчас не разрешены. Дождитесь нового подтверждённого состояния или выполните read-only восстановление."
|
? "Управляющие действия сейчас не разрешены. Дождитесь нового подтверждённого состояния или выполните read-only восстановление."
|
||||||
|
: protectedSoftwareCommandedTransition && !physicalStopPresented
|
||||||
|
? physicalStopIntentSpent
|
||||||
|
? "Команда остановки уже была принята интерфейсом. Повторная команда и локальное завершение заблокированы до нового подтверждённого состояния K1."
|
||||||
|
: "K1 выполняет переход к сканированию. Кнопка остановки появится только после подтверждённого SCANNING."
|
||||||
: physicalStopGuidance
|
: physicalStopGuidance
|
||||||
? `${physicalStopGuidance.reason} ${physicalStopGuidance.nextAction}`
|
? `${physicalStopGuidance.reason} ${physicalStopGuidance.nextAction}`
|
||||||
: gracefulStopTarget && !physicalStopPresented && physicalStopIntentSpent
|
: gracefulStopTarget && !physicalStopPresented && physicalStopIntentSpent
|
||||||
@@ -572,7 +583,7 @@ export function K1AcquisitionPipeline({
|
|||||||
: physicalStopPresented ? recoveredPhysicalStop ? "Остановить сканирование" : "Остановить устройство и запись" : state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Завершить локальный приём"}
|
: physicalStopPresented ? recoveredPhysicalStop ? "Остановить сканирование" : "Остановить устройство и запись" : state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Завершить локальный приём"}
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{activeAcquisition ? (
|
{activeAcquisition && !protectedSoftwareCommandedTransition ? (
|
||||||
<Button variant="ghost" disabled={isBusy} onClick={() => void abort()}>
|
<Button variant="ghost" disabled={isBusy} onClick={() => void abort()}>
|
||||||
{pendingAction === "abort" ? "Прерываем локальную операцию…" : preparedAcquisition ? "Отменить подготовку" : "Аварийно завершить локальный приём"}
|
{pendingAction === "abort" ? "Прерываем локальную операцию…" : preparedAcquisition ? "Отменить подготовку" : "Аварийно завершить локальный приём"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -100,6 +100,31 @@ export function emptyReadOnlyReconnectPresentation():
|
|||||||
return null;
|
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 {
|
export interface PhysicalReopenPresentation {
|
||||||
key: string;
|
key: string;
|
||||||
snapshotRuntimeId: string;
|
snapshotRuntimeId: string;
|
||||||
@@ -1240,6 +1265,9 @@ export function K1ProvisioningPipeline({
|
|||||||
targetMode: ConnectionMode;
|
targetMode: ConnectionMode;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const modeResetInFlight = pendingAction === "mode" || modeResetPending !== null;
|
const modeResetInFlight = pendingAction === "mode" || modeResetPending !== null;
|
||||||
|
const modeResetProgressVisible = useDelayedLocalOperationProgress(
|
||||||
|
modeResetInFlight,
|
||||||
|
);
|
||||||
const [selectedDeviceSnapshot, setSelectedDeviceSnapshot] = useState<BleDevice | null>(null);
|
const [selectedDeviceSnapshot, setSelectedDeviceSnapshot] = useState<BleDevice | null>(null);
|
||||||
const [ssid, setSsid] = useState("");
|
const [ssid, setSsid] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -1657,7 +1685,8 @@ export function K1ProvisioningPipeline({
|
|||||||
? readOnlyReconnectPresentation
|
? readOnlyReconnectPresentation
|
||||||
: null;
|
: null;
|
||||||
const connectionRecoveryVerificationPending =
|
const connectionRecoveryVerificationPending =
|
||||||
currentReadOnlyReconnectPresentation?.kind === "connection";
|
currentReadOnlyReconnectPresentation?.kind === "connection"
|
||||||
|
&& currentReadOnlyReconnectPresentation.connectionMode === connectionMode;
|
||||||
const physicalRecoveryVerificationPending =
|
const physicalRecoveryVerificationPending =
|
||||||
currentReadOnlyReconnectPresentation?.kind === "physical";
|
currentReadOnlyReconnectPresentation?.kind === "physical";
|
||||||
const provisioningMutationBusy = Boolean(
|
const provisioningMutationBusy = Boolean(
|
||||||
@@ -1796,7 +1825,7 @@ export function K1ProvisioningPipeline({
|
|||||||
state,
|
state,
|
||||||
connectionMode,
|
connectionMode,
|
||||||
);
|
);
|
||||||
const connectionRecoveryTarget = !physicalRecoveryRequired
|
const recommendedConnectionRecoveryTarget = !physicalRecoveryRequired
|
||||||
&& !unresolvedAppliedAttempt
|
&& !unresolvedAppliedAttempt
|
||||||
&& !selectedModeConnected
|
&& !selectedModeConnected
|
||||||
&& !reconfigurationActive
|
&& !reconfigurationActive
|
||||||
@@ -1804,6 +1833,13 @@ export function K1ProvisioningPipeline({
|
|||||||
&& connectionRecoveryObservationAllowed
|
&& connectionRecoveryObservationAllowed
|
||||||
? recommendedConnectionRecoveryObservationTarget(state)
|
? recommendedConnectionRecoveryObservationTarget(state)
|
||||||
: null;
|
: 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({
|
const connectionRecoveryKey = connectionRecoveryEscapeKey({
|
||||||
snapshotRuntimeId,
|
snapshotRuntimeId,
|
||||||
attempt: connectionRecoveryAttempt,
|
attempt: connectionRecoveryAttempt,
|
||||||
@@ -3162,10 +3198,16 @@ export function K1ProvisioningPipeline({
|
|||||||
disabled={modeResetInFlight}
|
disabled={modeResetInFlight}
|
||||||
variant="split"
|
variant="split"
|
||||||
/>
|
/>
|
||||||
{modeResetInFlight ? (
|
{modeResetProgressVisible ? (
|
||||||
<p className="safety-note" role="status">
|
<div
|
||||||
Завершение прежней локальной границы. Новый поиск не начнётся автоматически.
|
className="connection-action-progress"
|
||||||
</p>
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
<ActivityIndicator size="compact" />
|
||||||
|
<strong>Переключаем способ подключения…</strong>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{activeScenarioReset ? (
|
{activeScenarioReset ? (
|
||||||
<p className="safety-note" role="status">
|
<p className="safety-note" role="status">
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ export function K1SpatialControlsView({
|
|||||||
&& canIssueCanonicalStop(state, physicalStopIntentSpent),
|
&& canIssueCanonicalStop(state, physicalStopIntentSpent),
|
||||||
);
|
);
|
||||||
const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
|
const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
|
||||||
|
const pluginCommandedAcquisition = acquisition?.control_mode === "plugin-commanded";
|
||||||
const stopping = ["awaiting_external_stop", "stopping", "finalizing"].includes(
|
const stopping = ["awaiting_external_stop", "stopping", "finalizing"].includes(
|
||||||
acquisition?.state ?? "",
|
acquisition?.state ?? "",
|
||||||
);
|
);
|
||||||
@@ -327,7 +328,10 @@ export function K1SpatialControlsView({
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
{!physicalStopPresented && localReceiverStopAllowed && !stopping ? (
|
{!physicalStopPresented
|
||||||
|
&& localReceiverStopAllowed
|
||||||
|
&& !pluginCommandedAcquisition
|
||||||
|
&& !stopping ? (
|
||||||
<Button
|
<Button
|
||||||
size="compact"
|
size="compact"
|
||||||
variant="ghost"
|
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(
|
raise ActiveAcquisitionRecoveryCheckpointTransitionError(
|
||||||
"resolved START origin does not bind the exact prepared acquisition"
|
"resolved START origin does not bind the exact prepared acquisition"
|
||||||
)
|
)
|
||||||
if (
|
recovery_binding = cessation_status_proof.binding
|
||||||
cessation_status_proof.binding.runtime_instance_id
|
same_runtime = (
|
||||||
|
recovery_binding.runtime_instance_id
|
||||||
== checkpoint.prepared_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
|
== checkpoint.prepared_binding.control_session_id
|
||||||
or cessation_status_proof.evidence_session_id
|
or cessation_status_proof.evidence_session_id
|
||||||
== checkpoint.original_evidence_session_id
|
== checkpoint.original_evidence_session_id
|
||||||
|
or (
|
||||||
|
same_runtime
|
||||||
|
and recovery_binding.producer_generation
|
||||||
|
<= checkpoint.prepared_binding.producer_generation
|
||||||
|
)
|
||||||
):
|
):
|
||||||
raise ActiveAcquisitionRecoveryCheckpointTransitionError(
|
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(
|
exact_terminal_lineage = bool(
|
||||||
cessation_physical_proof.operation_id
|
cessation_physical_proof.operation_id
|
||||||
@@ -3474,7 +3483,7 @@ def _require_active_reconciled_standby_shape(
|
|||||||
or (
|
or (
|
||||||
same_runtime
|
same_runtime
|
||||||
and recovery_binding.producer_generation
|
and recovery_binding.producer_generation
|
||||||
== failed_binding.producer_generation
|
<= failed_binding.producer_generation
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
raise ActiveAcquisitionRecoveryCheckpointTransitionError(
|
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)
|
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"))
|
@pytest.mark.parametrize("terminal_state", ("ready", "scan_over"))
|
||||||
def test_cease_active_reconciled_standby_atomically_opens_and_closes_gap(
|
def test_cease_active_reconciled_standby_atomically_opens_and_closes_gap(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|||||||
Reference in New Issue
Block a user