feat(k1): complete canonical control lifecycle
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
type XgridsK1State,
|
||||
} from "./api";
|
||||
import {
|
||||
controlSessionEntryPlan,
|
||||
isTerminalAcquisitionState,
|
||||
liveStartPlan,
|
||||
operationByIdempotencyKey,
|
||||
@@ -23,6 +24,10 @@ import {
|
||||
isSoftwareCommandedAcquisition,
|
||||
} from "./lifecycle";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
import {
|
||||
awaitWhileIntentCurrent,
|
||||
OperatorIntentGeneration,
|
||||
} from "./operatorIntentGeneration";
|
||||
import { selectMonotonicXgridsState } from "./stateOrdering";
|
||||
|
||||
export type PendingAction =
|
||||
@@ -50,19 +55,103 @@ function controlPhase(state: XgridsK1State): XgridsApplicationControlPhase {
|
||||
|
||||
function controlFailure(state: XgridsK1State): ApiError {
|
||||
const failure = state.application_control_session?.failure;
|
||||
const localizedDetail = localizeRuntimeMessage(failure?.message);
|
||||
const reasonLabels: Record<string, string> = {
|
||||
application_authority_unavailable:
|
||||
"Локальный допуск управления устройством недоступен; команды не отправлялись.",
|
||||
mqtt_connect_call_failed:
|
||||
"Управляющее MQTT-соединение со сканером не открылось.",
|
||||
mqtt_connect_rejected:
|
||||
"Сканер отклонил управляющее MQTT-соединение.",
|
||||
mqtt_connection_timeout:
|
||||
"Подключение или подписки MQTT не подтвердились вовремя.",
|
||||
mqtt_subscription_failed:
|
||||
"Сканер не подтвердил канонические MQTT-подписки.",
|
||||
mqtt_response_timeout:
|
||||
"Ожидаемый ответ сканера не пришёл до безопасной границы ожидания.",
|
||||
response_identity_decode_failed:
|
||||
"Ответ сканера не удалось безопасно разобрать и привязать к операции.",
|
||||
modeling_response_decode_failed:
|
||||
"Ответ START/STOP не удалось безопасно разобрать.",
|
||||
duplicate_application_response:
|
||||
"Сканер прислал повторный ответ на уже завершённую операцию.",
|
||||
unexpected_response_identity:
|
||||
"Получен ответ неизвестной операции; диалог остановлен.",
|
||||
response_identity_mismatch:
|
||||
"Идентичность ответа не совпала с ожидаемой операцией.",
|
||||
response_session_mismatch:
|
||||
"Session ответа не совпал с точной подготовительной операцией.",
|
||||
response_device_identity_mismatch:
|
||||
"Ответ относится не к тому экземпляру устройства.",
|
||||
response_authority_mismatch:
|
||||
"Ответ не совпал с локальным допуском приложения.",
|
||||
response_rejected:
|
||||
"Сканер отклонил подготовительную операцию.",
|
||||
compatibility_profile_mismatch:
|
||||
"Живой DeviceInfo не соответствует выбранному профилю модели, platform type, прошивки или активации.",
|
||||
scan_initialization_timeout:
|
||||
"После подтверждённого START сканер не завершил инициализацию в безопасный срок.",
|
||||
operation_reuse_forbidden:
|
||||
"Повтор уже использованной операции заблокирован.",
|
||||
};
|
||||
const reasonDetail = failure?.reason_code
|
||||
? reasonLabels[failure.reason_code]
|
||||
: undefined;
|
||||
const stageLabels: Record<string, string> = {
|
||||
connecting: "подключение и первичный диалог",
|
||||
connection: "первичный диалог",
|
||||
"connection-ready": "подключение подтверждено",
|
||||
"workspace-requested": "вход в рабочую область",
|
||||
"workspace-ready": "рабочая область готова",
|
||||
"project-requested": "подготовка проекта",
|
||||
"project-ready": "проект готов",
|
||||
"start-requested": "подтверждение запуска",
|
||||
"start-attempted": "команда запуска",
|
||||
initializing: "калибровка оборудования",
|
||||
scanning: "сканирование",
|
||||
"stop-requested": "подтверждение остановки",
|
||||
"stop-attempted": "команда остановки",
|
||||
stopping: "остановка",
|
||||
};
|
||||
const stageCode = failure?.dialogue_stage || failure?.failed_phase;
|
||||
const stage = stageCode
|
||||
? stageLabels[stageCode] ?? "неопознанный этап канонического диалога"
|
||||
: "этап не зафиксирован";
|
||||
const commandStatus = failure?.modeling_command_attempted === true
|
||||
? "Команда START или STOP могла быть отправлена; автоматический повтор запрещён."
|
||||
: failure?.modeling_command_attempted === false
|
||||
? "Команды START и STOP не отправлялись."
|
||||
: "Факт отправки START или STOP диагностически не подтверждён; повтор запрещён.";
|
||||
const retryStatus = failure?.safe_to_retry
|
||||
? "Новая попытка возможна только отдельным нажатием оператора."
|
||||
: "Повтор заблокирован до ручной проверки состояния.";
|
||||
const exchanges = typeof failure?.publish_attempts === "number"
|
||||
? `MQTT-публикаций до остановки: ${failure.publish_attempts}.`
|
||||
: "Количество MQTT-публикаций не зафиксировано.";
|
||||
const failedProtocolEvidence =
|
||||
failure?.compatibility_failure ?? failure?.correlation_failure;
|
||||
const failedOperation = failedProtocolEvidence?.operation_key
|
||||
? `Шаг протокола: ${failedProtocolEvidence.operation_key}.`
|
||||
: "";
|
||||
const diagnostics = failure?.diagnostic_evidence_unavailable?.length
|
||||
? "Часть диагностических доказательств недоступна; результат считается неизвестным."
|
||||
: "";
|
||||
return new ApiError(
|
||||
failure?.message
|
||||
? `Канонический диалог K1 остановлен: ${failure.message}`
|
||||
: "Канонический диалог K1 остановлен до запуска сканирования.",
|
||||
`${reasonDetail || localizedDetail || "Канонический диалог K1 остановлен."} Этап: ${stage}. ${failedOperation} ${exchanges} ${commandStatus} ${diagnostics} ${retryStatus}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForControlPhase(
|
||||
expected: XgridsApplicationControlPhase,
|
||||
acceptState: (state: XgridsK1State) => void,
|
||||
assertOperatorIntentCurrent: () => void,
|
||||
): Promise<XgridsK1State> {
|
||||
for (;;) {
|
||||
const nextState = await xgridsK1Api.getState();
|
||||
assertOperatorIntentCurrent();
|
||||
const nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.getState(),
|
||||
);
|
||||
acceptState(nextState);
|
||||
const phase = controlPhase(nextState);
|
||||
if (phase === expected) return nextState;
|
||||
@@ -74,15 +163,22 @@ async function waitForControlPhase(
|
||||
}
|
||||
// This cadence only reads local server state. It never schedules, retries,
|
||||
// or times a K1 command; every next write remains gated by device response.
|
||||
await new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS);
|
||||
});
|
||||
await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, CONTROL_STATE_READ_INTERVAL_MS);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function messageFor(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
const message = localizeRuntimeMessage(error.message) ?? error.message;
|
||||
// Domain errors are already written for the operator. Only HTTP details
|
||||
// originate at the backend and need vendor/runtime normalization.
|
||||
const message = error.status
|
||||
? localizeRuntimeMessage(error.message) ?? error.message
|
||||
: error.message;
|
||||
return error.status
|
||||
? `${message} (HTTP ${error.status})`
|
||||
: message;
|
||||
@@ -113,8 +209,12 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
|
||||
const mounted = useRef(true);
|
||||
const actionInFlight = useRef(false);
|
||||
const operatorIntents = useRef(new OperatorIntentGeneration());
|
||||
const actionSequence = useRef(0);
|
||||
const actionInFlight = useRef<{
|
||||
runtimeGeneration: number;
|
||||
actionSequence: number;
|
||||
} | null>(null);
|
||||
|
||||
const acceptState = useCallback((nextState: XgridsK1State) => {
|
||||
setState((currentState) => selectMonotonicXgridsState(currentState, nextState));
|
||||
@@ -122,13 +222,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async (reportErrors = true) => {
|
||||
if (!enabled) return;
|
||||
const runtimeToken = operatorIntents.current.captureRuntime();
|
||||
if (!enabled || !runtimeToken) return;
|
||||
const [healthResult, stateResult] = await Promise.allSettled([
|
||||
xgridsK1Api.getHealth(),
|
||||
xgridsK1Api.getState(),
|
||||
]);
|
||||
|
||||
if (!mounted.current) return;
|
||||
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return;
|
||||
|
||||
if (stateResult.status === "fulfilled") {
|
||||
acceptState(stateResult.value);
|
||||
@@ -150,27 +251,48 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
|
||||
const run = useCallback(
|
||||
async (action: PendingAction, operation: () => Promise<XgridsK1State>) => {
|
||||
if (!enabled) return false;
|
||||
if (actionInFlight.current) return false;
|
||||
actionInFlight.current = true;
|
||||
const runtimeToken = operatorIntents.current.captureRuntime();
|
||||
if (!enabled || !runtimeToken) return false;
|
||||
if (
|
||||
actionInFlight.current?.runtimeGeneration
|
||||
=== runtimeToken.runtimeGeneration
|
||||
) return false;
|
||||
actionSequence.current += 1;
|
||||
const actionToken = {
|
||||
runtimeGeneration: runtimeToken.runtimeGeneration,
|
||||
actionSequence: actionSequence.current,
|
||||
};
|
||||
actionInFlight.current = actionToken;
|
||||
setPendingAction(action);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false;
|
||||
const nextState = await operation();
|
||||
if (mounted.current) acceptState(nextState);
|
||||
if (!operatorIntents.current.isRuntimeCurrent(runtimeToken)) return false;
|
||||
acceptState(nextState);
|
||||
return true;
|
||||
} catch (operationError) {
|
||||
if (mounted.current) {
|
||||
if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) {
|
||||
setError(messageFor(operationError));
|
||||
if (operationError instanceof ApiError && operationError.status === 0) {
|
||||
if (
|
||||
operationError instanceof ApiError
|
||||
&& operationError.transportUnavailable
|
||||
) {
|
||||
setBackendStatus("offline");
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
actionInFlight.current = false;
|
||||
if (mounted.current) setPendingAction(null);
|
||||
if (
|
||||
actionInFlight.current?.runtimeGeneration === actionToken.runtimeGeneration
|
||||
&& actionInFlight.current.actionSequence === actionToken.actionSequence
|
||||
) {
|
||||
actionInFlight.current = null;
|
||||
if (operatorIntents.current.isRuntimeCurrent(runtimeToken)) {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[acceptState, enabled],
|
||||
@@ -237,7 +359,24 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
const startCanonicalAcquisition = useCallback(
|
||||
(request: CanonicalLiveStartRequest) =>
|
||||
run("live", async () => {
|
||||
let nextState = await xgridsK1Api.getState();
|
||||
const intentToken = operatorIntents.current.beginOperatorIntent();
|
||||
if (!intentToken) {
|
||||
throw new ApiError(
|
||||
"Экран управления закрыт; дальнейшие команды канонического диалога не отправлялись.",
|
||||
);
|
||||
}
|
||||
const assertOperatorIntentCurrent = () => {
|
||||
if (!operatorIntents.current.isOperatorIntentCurrent(intentToken)) {
|
||||
throw new ApiError(
|
||||
"Операторское действие завершено или заменено; дальнейшие команды канонического диалога не отправлялись.",
|
||||
);
|
||||
}
|
||||
};
|
||||
let nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.getState(),
|
||||
);
|
||||
let openedControlSession = false;
|
||||
acceptState(nextState);
|
||||
const plan = liveStartPlan(nextState);
|
||||
if (plan === "blocked") {
|
||||
@@ -246,50 +385,84 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
if (plan === "already-running") return nextState;
|
||||
|
||||
for (;;) {
|
||||
assertOperatorIntentCurrent();
|
||||
const phase = controlPhase(nextState);
|
||||
const acquisition = nextState.acquisition;
|
||||
const entryPlan = controlSessionEntryPlan(
|
||||
phase,
|
||||
openedControlSession,
|
||||
nextState.application_control_session?.can_open === true,
|
||||
);
|
||||
|
||||
if (["idle", "closed", "completed"].includes(phase)) {
|
||||
if (entryPlan === "failed") {
|
||||
// A failed dialogue always ends this operator intent. Even when
|
||||
// backend reconciliation says a fresh attempt may be safe, that
|
||||
// attempt requires another explicit click.
|
||||
throw controlFailure(nextState);
|
||||
}
|
||||
|
||||
if (entryPlan === "duplicate-open") {
|
||||
throw new ApiError(
|
||||
"Управляющая сессия завершилась сразу после открытия. Автоматический повтор заблокирован; проверьте состояние и повторите только отдельным нажатием.",
|
||||
);
|
||||
}
|
||||
|
||||
if (entryPlan === "open") {
|
||||
if (acquisition && !isTerminalAcquisitionState(acquisition.state)) {
|
||||
throw new ApiError(
|
||||
"Незавершённая подготовка не привязана к открытой control-сессии. Отмените её перед новым запуском.",
|
||||
);
|
||||
}
|
||||
nextState = await xgridsK1Api.openApplicationControlSession(request.control);
|
||||
acceptState(nextState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "failed") {
|
||||
if (nextState.application_control_session?.can_open !== true) {
|
||||
throw controlFailure(nextState);
|
||||
}
|
||||
nextState = await xgridsK1Api.openApplicationControlSession(request.control);
|
||||
openedControlSession = true;
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.openApplicationControlSession(request.control),
|
||||
);
|
||||
acceptState(nextState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "connecting") {
|
||||
nextState = await waitForControlPhase("connection-ready", acceptState);
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => waitForControlPhase(
|
||||
"connection-ready",
|
||||
acceptState,
|
||||
assertOperatorIntentCurrent,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "connection-ready") {
|
||||
nextState = await xgridsK1Api.enterApplicationWorkspace({
|
||||
operator_confirmed: true,
|
||||
});
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.enterApplicationWorkspace({
|
||||
operator_confirmed: true,
|
||||
}),
|
||||
);
|
||||
acceptState(nextState);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "workspace-requested") {
|
||||
nextState = await waitForControlPhase("workspace-ready", acceptState);
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => waitForControlPhase(
|
||||
"workspace-ready",
|
||||
acceptState,
|
||||
assertOperatorIntentCurrent,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "workspace-ready") {
|
||||
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
|
||||
nextState = await xgridsK1Api.prepareAcquisition(request.acquisition);
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.prepareAcquisition(request.acquisition),
|
||||
);
|
||||
acceptState(nextState);
|
||||
continue;
|
||||
}
|
||||
@@ -298,12 +471,26 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
"Текущая подготовка не принадлежит канонической control-сессии K1.",
|
||||
);
|
||||
}
|
||||
nextState = await waitForControlPhase("project-ready", acceptState);
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => waitForControlPhase(
|
||||
"project-ready",
|
||||
acceptState,
|
||||
assertOperatorIntentCurrent,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phase === "project-requested") {
|
||||
nextState = await waitForControlPhase("project-ready", acceptState);
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => waitForControlPhase(
|
||||
"project-ready",
|
||||
acceptState,
|
||||
assertOperatorIntentCurrent,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -311,11 +498,14 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
if (!acquisition || acquisition.state !== "prepared") {
|
||||
throw new ApiError("Локальный приём не подготовлен к каноническому START.");
|
||||
}
|
||||
nextState = await xgridsK1Api.startAcquisition({
|
||||
acquisition_id: acquisition.acquisition_id,
|
||||
expected_state_revision: acquisition.state_revision,
|
||||
physical_acceptance: request.physicalAcceptance,
|
||||
});
|
||||
nextState = await awaitWhileIntentCurrent(
|
||||
assertOperatorIntentCurrent,
|
||||
() => xgridsK1Api.startAcquisition({
|
||||
acquisition_id: acquisition.acquisition_id,
|
||||
expected_state_revision: acquisition.state_revision,
|
||||
physical_acceptance: request.physicalAcceptance,
|
||||
}),
|
||||
);
|
||||
acceptState(nextState);
|
||||
return nextState;
|
||||
}
|
||||
@@ -396,33 +586,6 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
[run, state],
|
||||
);
|
||||
|
||||
const confirmStoppedAtSteadyGreen = useCallback(
|
||||
() =>
|
||||
run("stop", () => {
|
||||
const acquisition = state?.acquisition;
|
||||
if (!acquisition || acquisition.state !== "awaiting_external_stop") {
|
||||
throw new ApiError("K1 сейчас не ожидает подтверждения завершённого STOP.");
|
||||
}
|
||||
const stopOperation = [...(state?.operations ?? [])]
|
||||
.reverse()
|
||||
.find(
|
||||
(operation) =>
|
||||
operation.action === "acquisition.stop" &&
|
||||
operation.status === "operator_action_required",
|
||||
);
|
||||
if (!stopOperation) {
|
||||
throw new ApiError("Не найдена исходная операция STOP; повтор команды запрещён.");
|
||||
}
|
||||
return xgridsK1Api.stopAcquisition({
|
||||
acquisition_id: acquisition.acquisition_id,
|
||||
mode: "graceful",
|
||||
operator_confirmed: true,
|
||||
operation_id: stopOperation.operation_id,
|
||||
});
|
||||
}),
|
||||
[run, state],
|
||||
);
|
||||
|
||||
const abort = useCallback(() => {
|
||||
const acquisition = state?.acquisition;
|
||||
if (!acquisition || isTerminalAcquisitionState(acquisition.state)) {
|
||||
@@ -485,7 +648,7 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
mounted.current = true;
|
||||
operatorIntents.current.deactivateRuntime();
|
||||
setState(null);
|
||||
setBackendStatus("checking");
|
||||
setEventStatus("closed");
|
||||
@@ -495,12 +658,12 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
return;
|
||||
}
|
||||
|
||||
mounted.current = true;
|
||||
operatorIntents.current.activateRuntime();
|
||||
void refresh(true);
|
||||
const poll = window.setInterval(() => void refresh(false), 4_000);
|
||||
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
operatorIntents.current.deactivateRuntime();
|
||||
window.clearInterval(poll);
|
||||
};
|
||||
}, [enabled, refresh]);
|
||||
@@ -563,7 +726,6 @@ export function useXgridsK1Runtime(enabled: boolean) {
|
||||
startPreparedAcquisition,
|
||||
startReplay,
|
||||
stop,
|
||||
confirmStoppedAtSteadyGreen,
|
||||
abort,
|
||||
setObservationSourceActive,
|
||||
updateViewerSettings,
|
||||
|
||||
Reference in New Issue
Block a user