feat(k1): complete canonical control lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-07-19 01:07:02 +03:00
parent d7a2c22faf
commit ffffee1879
42 changed files with 3577 additions and 556 deletions
@@ -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,