Корректное ожидание подтверждения Bridge
This commit is contained in:
@@ -67,6 +67,7 @@ let reconfigurationAllowsFreshDevice;
|
||||
let readOnlyObservationShowsNetworkUnavailable;
|
||||
let readOnlyFailureShowsNetworkUnavailable;
|
||||
let savedBridgeRequiresNetworkSetup;
|
||||
let isConnectionControlBootstrapSettling;
|
||||
let provisioningFailureRequiresFreshCandidate;
|
||||
let trustedConnectionBinding;
|
||||
let transportRefEquivalenceKey;
|
||||
@@ -189,6 +190,7 @@ before(async () => {
|
||||
readOnlyObservationShowsNetworkUnavailable,
|
||||
readOnlyFailureShowsNetworkUnavailable,
|
||||
savedBridgeRequiresNetworkSetup,
|
||||
isConnectionControlBootstrapSettling,
|
||||
provisioningFailureRequiresFreshCandidate,
|
||||
trustedConnectionBinding,
|
||||
transportRefEquivalenceKey,
|
||||
@@ -6228,6 +6230,92 @@ test("Apply presentation stays correlated through ACK, bootstrap, terminal and c
|
||||
);
|
||||
});
|
||||
|
||||
test("current Bridge bootstrap stays visible over an older unresolved STOP checkpoint", () => {
|
||||
const presentation = provisioningAttemptPresentation({
|
||||
localPhase: "settling",
|
||||
attemptId: "network-operation-current",
|
||||
});
|
||||
const state = durableTopologyState();
|
||||
state.snapshot_runtime_id = presentation.snapshotRuntimeId;
|
||||
state.connection_attempt = {
|
||||
schema_version: "missioncore.xgrids-k1-connection-attempt/v1",
|
||||
attempt_id: presentation.attemptId,
|
||||
connection_mode: "bridge",
|
||||
status: "running",
|
||||
phase: "network_applied",
|
||||
control_state: "unknown",
|
||||
stage: "mqtt-device-info",
|
||||
public_error_code: null,
|
||||
side_effect_status: "applied",
|
||||
safe_next_action: "wait-for-current-attempt",
|
||||
automatic_retry: false,
|
||||
accepted_at: OBSERVED_AT,
|
||||
completed_at: null,
|
||||
timeline: [],
|
||||
};
|
||||
state.operations = [{
|
||||
operation_id: presentation.attemptId,
|
||||
action: "network.provision",
|
||||
status: "succeeded",
|
||||
idempotency_key: presentation.idempotencyKey,
|
||||
}];
|
||||
state.physical_command = {
|
||||
status: "unresolved",
|
||||
reason_code: "physical-command-reconciliation-required",
|
||||
requires_reconciliation: true,
|
||||
resolved_active_recovery_required: false,
|
||||
automatic_replay_allowed: false,
|
||||
runtime_bound: true,
|
||||
reconciliation_ready: true,
|
||||
observed_session_state: "scan_stopping",
|
||||
active_operation_id: "older-stop-operation",
|
||||
record: {
|
||||
revision: 306,
|
||||
operation_id: "older-stop-operation",
|
||||
action: "stop",
|
||||
stage: "observing",
|
||||
resolution: null,
|
||||
connection: {
|
||||
transport_ref: presentation.deviceId,
|
||||
connection_mode: "quick-connect",
|
||||
target_ipv4: "192.168.56.1",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(isConnectionControlBootstrapSettling(state), true);
|
||||
const markup = renderProvisioningWithAttempt({
|
||||
controller: provisioningController(state),
|
||||
desiredMode: "bridge",
|
||||
}, presentation);
|
||||
|
||||
assert.match(markup, /Подтверждение подключения…/);
|
||||
assert.match(markup, /Настройки переданы один раз/);
|
||||
assert.match(markup, /<span>02<\/span>/);
|
||||
assert.match(markup, /nodedc-activity-indicator/);
|
||||
assert.doesNotMatch(markup, /Прежнее подключение не подтверждено/);
|
||||
assert.doesNotMatch(markup, />Переподключиться<\/button>/);
|
||||
assert.doesNotMatch(markup, />Подключить новый K1<\/button>/);
|
||||
|
||||
assert.equal(isConnectionControlBootstrapSettling({
|
||||
...state,
|
||||
connection_attempt: {
|
||||
...state.connection_attempt,
|
||||
status: "succeeded",
|
||||
safe_next_action: "verify-control-read-only",
|
||||
},
|
||||
}), true);
|
||||
assert.equal(isConnectionControlBootstrapSettling({
|
||||
...state,
|
||||
connection_attempt: {
|
||||
...state.connection_attempt,
|
||||
status: "failed",
|
||||
control_state: "control_not_ready",
|
||||
safe_next_action: "manual-recovery-required",
|
||||
},
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("Apply spends secret state before I/O and never stores a password in its presentation latch", () => {
|
||||
const source = readFileSync(provisioningSourceUrl, "utf8");
|
||||
const presentationType = sourceSlice(
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
backendConnectionTopology,
|
||||
connectionAttemptForRuntimeError,
|
||||
hasControlAuthority,
|
||||
isConnectionControlBootstrapSettling,
|
||||
isConfirmedLiveState,
|
||||
isPhysicalStopRecoverySettling,
|
||||
isRecoveredPhysicalScanning,
|
||||
@@ -274,12 +275,16 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
: sourceRuntimeBusy || preparedAcquisition
|
||||
? "warning"
|
||||
: "neutral";
|
||||
const connectionControlBootstrapSettling =
|
||||
isConnectionControlBootstrapSettling(state);
|
||||
const connectionPhaseLabel = livePreparationPending
|
||||
? "Подготовка приёма"
|
||||
: sourceRuntimeBusy || preparedAcquisition
|
||||
? sourceLabel
|
||||
: activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.title
|
||||
: connectionControlBootstrapSettling
|
||||
? "Подтверждение подключения"
|
||||
: physicalStopRecoverySettling
|
||||
? "Завершение остановки"
|
||||
: recoveredPhysicalScanning
|
||||
@@ -307,6 +312,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
? sourceTone
|
||||
: activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.tone
|
||||
: connectionControlBootstrapSettling
|
||||
? "accent"
|
||||
: physicalRecoveryRequired
|
||||
? "warning"
|
||||
: projectedPhase === "error"
|
||||
@@ -322,6 +329,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
? "Команда остановки уже принята. Завершение выполняется без повторной команды."
|
||||
: activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.detail
|
||||
: connectionControlBootstrapSettling
|
||||
? "Сеть применена. Сервис проверяет управляющее подключение без повторения BLE-команды."
|
||||
: recoveredPhysicalScanning
|
||||
? "Локальная запись остановлена, но сканирование ещё продолжается."
|
||||
: physicalRecoveryRequired
|
||||
|
||||
@@ -2021,16 +2021,18 @@ export function K1ProvisioningPipeline({
|
||||
const changeNetworkActionApplicable = connectionMode === "bridge"
|
||||
&& connectedReconfigurationActionApplicable(state, "prepare-change-network");
|
||||
const showNetworkStep = Boolean(
|
||||
!presentedPhysicalRecoveryRequired
|
||||
&& !presentedConnectionRecoveryRequired
|
||||
&& (
|
||||
connectionPresentationEstablished
|
||||
|| explicitProvisioningDraftRetained
|
||||
|| explicitProvisioningDraftContextRetained
|
||||
|| unresolvedAppliedAttempt
|
||||
|| connectionAttemptSettling
|
||||
|| connectionAttemptFailed
|
||||
|| (changeNetworkDialogue && Boolean(changeNetworkRequiredDeviceId))
|
||||
connectionAttemptSettling
|
||||
|| (
|
||||
!presentedPhysicalRecoveryRequired
|
||||
&& !presentedConnectionRecoveryRequired
|
||||
&& (
|
||||
connectionPresentationEstablished
|
||||
|| explicitProvisioningDraftRetained
|
||||
|| explicitProvisioningDraftContextRetained
|
||||
|| unresolvedAppliedAttempt
|
||||
|| connectionAttemptFailed
|
||||
|| (changeNetworkDialogue && Boolean(changeNetworkRequiredDeviceId))
|
||||
)
|
||||
)
|
||||
);
|
||||
const requestExplicitProvisioning = useCallback((
|
||||
@@ -3296,6 +3298,8 @@ export function K1ProvisioningPipeline({
|
||||
? "accent"
|
||||
: connectionEstablished
|
||||
? "success"
|
||||
: connectionAttemptSettling
|
||||
? "accent"
|
||||
: networkRecoveryRequired
|
||||
|| presentedPhysicalRecoveryRequired
|
||||
|| explicitProvisioningDraftStale
|
||||
@@ -3330,7 +3334,7 @@ export function K1ProvisioningPipeline({
|
||||
?? "Сервис не определил точный прежний K1 для безопасной проверки"}
|
||||
</small>
|
||||
</div>
|
||||
) : presentedPhysicalRecoveryRequired ? (
|
||||
) : presentedPhysicalRecoveryRequired && !connectionAttemptSettling ? (
|
||||
<div
|
||||
className="field-stack"
|
||||
aria-busy={physicalReconnectPending || undefined}
|
||||
@@ -3388,7 +3392,7 @@ export function K1ProvisioningPipeline({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : presentedConnectionRecoveryRequired ? (
|
||||
) : presentedConnectionRecoveryRequired && !connectionAttemptSettling ? (
|
||||
<div
|
||||
className="field-stack"
|
||||
aria-busy={connectionReconnectPending || undefined}
|
||||
|
||||
@@ -466,6 +466,26 @@ export function sourceStatusLabel(state: XgridsK1State | null | undefined): stri
|
||||
return "Ожидание";
|
||||
}
|
||||
|
||||
/**
|
||||
* The network write has already crossed its single mutation boundary and the
|
||||
* service is now proving control authority without another BLE command. This
|
||||
* current connection intent must remain visibly pending even when an older
|
||||
* physical-command checkpoint is being reconciled by the same bootstrap.
|
||||
*/
|
||||
export function isConnectionControlBootstrapSettling(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): boolean {
|
||||
const attempt = state?.connection_attempt;
|
||||
if (
|
||||
!attempt
|
||||
|| attempt.phase !== "network_applied"
|
||||
|| attempt.control_state !== "unknown"
|
||||
) return false;
|
||||
if (["accepted", "running"].includes(attempt.status)) return true;
|
||||
return attempt.status === "succeeded"
|
||||
&& attempt.safe_next_action === "verify-control-read-only";
|
||||
}
|
||||
|
||||
export function operationByIdempotencyKey(
|
||||
state: XgridsK1State | null | undefined,
|
||||
action: string,
|
||||
|
||||
Reference in New Issue
Block a user