fix(k1): translate onboard operation identities at plugin boundary
This commit is contained in:
@@ -8,8 +8,11 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import traceback
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -42,6 +45,29 @@ class NodeEnrollmentRejected(ValueError):
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
def plugin_operation_id(node_id: str, *, stage: str | None = None) -> str:
|
||||
"""Translate the host's opaque ID without changing either journal contract.
|
||||
|
||||
The same host intent always reaches the same plugin row. A composite action
|
||||
uses deterministic child UUIDs so prepare and start cannot alias each other.
|
||||
"""
|
||||
if not isinstance(node_id, str) or re.fullmatch(r"op_[0-9a-f]{32}", node_id) is None:
|
||||
raise NodeEnrollmentRejected("invalid-operation-id")
|
||||
identifier = UUID(hex=node_id[3:])
|
||||
if stage is not None:
|
||||
identifier = uuid5(identifier, stage)
|
||||
return f"op-{identifier}"
|
||||
|
||||
|
||||
def node_operation_id(plugin_id: str | None) -> str | None:
|
||||
"""Project only canonical plugin UUIDs back to the host correlation ID."""
|
||||
if isinstance(plugin_id, str) and re.fullmatch(
|
||||
r"op-[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}", plugin_id
|
||||
):
|
||||
return "op_" + UUID(plugin_id[3:]).hex
|
||||
return plugin_id
|
||||
|
||||
|
||||
class NodeBridge:
|
||||
def __init__(self, repository_root: Path, *, service=None):
|
||||
self.rerun = NodeRerunHub()
|
||||
@@ -64,6 +90,11 @@ class NodeBridge:
|
||||
@staticmethod
|
||||
def project(snapshot: dict) -> dict:
|
||||
lifecycle = snapshot.get("connection_lifecycle", {})
|
||||
attempt = compact_connection_attempt(snapshot.get("connection_attempt"))
|
||||
if attempt is not None:
|
||||
for key in ("attempt_id", "recovery_operation_id"):
|
||||
if key in attempt:
|
||||
attempt[key] = node_operation_id(attempt[key])
|
||||
return {
|
||||
"available": True,
|
||||
"model": "XGRIDS K1",
|
||||
@@ -81,7 +112,7 @@ class NodeBridge:
|
||||
"connected": lifecycle.get("connection_ready") is True
|
||||
and snapshot.get("active_connection_mode") == "bridge",
|
||||
"ready_to_start": lifecycle.get("ready_to_start") is True,
|
||||
"connection_attempt": compact_connection_attempt(snapshot.get("connection_attempt")),
|
||||
"connection_attempt": attempt,
|
||||
"allowed_actions": [
|
||||
action
|
||||
for action in lifecycle.get("allowed_actions", [])
|
||||
@@ -113,6 +144,20 @@ class NodeBridge:
|
||||
)
|
||||
)
|
||||
|
||||
async def invoke_journalled(self, action: str, payload: dict, identifier: str) -> dict:
|
||||
try:
|
||||
return await self.invoke(action, payload, identifier)
|
||||
except Exception:
|
||||
# Read the exact result after a failure; never repeat an invocation
|
||||
# or interpret an exception as evidence that a command was unsent.
|
||||
result = await self.invoke("state.read", {}, identifier + "-observe")
|
||||
if not any(
|
||||
item.get("operation_id") == payload["operation_id"]
|
||||
for item in result.get("operations", [])
|
||||
):
|
||||
raise
|
||||
return result
|
||||
|
||||
async def execute(self, command: dict) -> dict:
|
||||
try:
|
||||
return await self._execute(command)
|
||||
@@ -128,6 +173,7 @@ class NodeBridge:
|
||||
result = await self.state()
|
||||
result["command_result"] = {
|
||||
"operation_id": command["operation_id"],
|
||||
"action": command["action"],
|
||||
"status": "rejected",
|
||||
"error_code": error.code,
|
||||
}
|
||||
@@ -135,6 +181,7 @@ class NodeBridge:
|
||||
|
||||
async def _execute(self, command: dict) -> dict:
|
||||
action, identifier = command["action"], command["operation_id"]
|
||||
plugin_identifier = plugin_operation_id(identifier)
|
||||
parameters = command.get("parameters", {})
|
||||
if action not in {"scan", "networks", "connect", "verify"}:
|
||||
raise NodeEnrollmentRejected("unsupported-action")
|
||||
@@ -147,11 +194,11 @@ class NodeBridge:
|
||||
if action == "networks":
|
||||
return {**state, "networks": await asyncio.to_thread(wifi_networks)}
|
||||
if action == "scan":
|
||||
result = await self.invoke(
|
||||
result = await self.invoke_journalled(
|
||||
"discovery.scan",
|
||||
{
|
||||
"duration_seconds": BLE_SCAN_DEFAULT_TIMEOUT_SECONDS,
|
||||
"operation_id": identifier,
|
||||
"operation_id": plugin_identifier,
|
||||
"expected_snapshot_runtime_id": command["runtime_id"],
|
||||
},
|
||||
identifier,
|
||||
@@ -168,7 +215,7 @@ class NodeBridge:
|
||||
payload = {
|
||||
"device_id": parameters["device_id"],
|
||||
"compatibility_attestation": ATTESTATION,
|
||||
"operation_id": identifier,
|
||||
"operation_id": plugin_identifier,
|
||||
"expected_mode_revision": parameters["mode_revision"],
|
||||
"expected_discovery_generation": parameters["discovery_generation"],
|
||||
"expected_snapshot_runtime_id": state["runtime_id"],
|
||||
@@ -177,26 +224,16 @@ class NodeBridge:
|
||||
payload.update(
|
||||
connection_mode="bridge",
|
||||
allow_host_wifi_switch=False,
|
||||
idempotency_key=identifier,
|
||||
idempotency_key=plugin_identifier,
|
||||
ssid=parameters.get("ssid"),
|
||||
password=parameters.get("password"),
|
||||
)
|
||||
try:
|
||||
result = await self.invoke(
|
||||
result = await self.invoke_journalled(
|
||||
"network.provision" if action == "connect" else "connection.verify",
|
||||
payload,
|
||||
identifier,
|
||||
)
|
||||
except Exception:
|
||||
# A rejected/failed invocation may already have a durable
|
||||
# result. Read that exact journal row; never resend the
|
||||
# network command and never serialize exception/payload text.
|
||||
result = await self.invoke("state.read", {}, identifier + "-observe")
|
||||
if not any(
|
||||
item.get("operation_id") == identifier
|
||||
for item in result.get("operations", [])
|
||||
):
|
||||
raise
|
||||
finally:
|
||||
payload.pop("password", None)
|
||||
parameters.pop("password", None)
|
||||
@@ -205,7 +242,7 @@ class NodeBridge:
|
||||
(
|
||||
item
|
||||
for item in reversed(result.get("operations", []))
|
||||
if item.get("operation_id") == identifier
|
||||
if item.get("operation_id") == plugin_identifier
|
||||
),
|
||||
None,
|
||||
)
|
||||
@@ -213,6 +250,7 @@ class NodeBridge:
|
||||
error = operation.get("error") or {}
|
||||
projected["command_result"] = {
|
||||
"operation_id": identifier,
|
||||
"action": action,
|
||||
"status": operation.get("status"),
|
||||
"error_code": error.get("code"),
|
||||
}
|
||||
@@ -291,15 +329,19 @@ def create_app(repository_root: Path):
|
||||
body = await request.json()
|
||||
return await bridge.deliver(body)
|
||||
except Exception as error:
|
||||
# Class and admitted action are sufficient for transport diagnosis.
|
||||
# Never log the exception text, incoming payload or credentials.
|
||||
# Source locations identify the failing boundary without exception
|
||||
# text, source lines, local variables, payloads or credentials.
|
||||
action = body.get("action") if isinstance(body, dict) else None
|
||||
logging.getLogger(__name__).warning(
|
||||
"K1 operation failed: action=%s exception=%s",
|
||||
"K1 operation failed: action=%s exception=%s locations=%s",
|
||||
action
|
||||
if isinstance(action, str) and action in {"scan", "networks", "connect", "verify"}
|
||||
else "invalid",
|
||||
type(error).__name__,
|
||||
";".join(
|
||||
f"{Path(frame.filename).name}:{frame.name}:{frame.lineno}"
|
||||
for frame in traceback.extract_tb(error.__traceback__)[-8:]
|
||||
),
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ from .facade import (
|
||||
XGRIDS_K1_PLUGIN_VERSION,
|
||||
ViewerSettingsRequest,
|
||||
)
|
||||
from .node_bridge import ATTESTATION
|
||||
from .node_bridge import ATTESTATION, plugin_operation_id
|
||||
|
||||
PHYSICAL_ACCEPTANCE_KEYS = (
|
||||
"operator_present",
|
||||
@@ -137,6 +137,7 @@ class NodeK1Sensor:
|
||||
command.get("parameters", {}),
|
||||
command["operation_id"],
|
||||
)
|
||||
plugin_identifier = plugin_operation_id(identifier)
|
||||
if action == "details":
|
||||
return item
|
||||
if action == "close-peer":
|
||||
@@ -170,7 +171,9 @@ class NodeK1Sensor:
|
||||
return project_sensor(result, node_id)
|
||||
if action == "verify":
|
||||
result = await self.bridge.invoke(
|
||||
"connection.verify", {"expected_snapshot_runtime_id": runtime}, identifier
|
||||
"connection.verify",
|
||||
{"expected_snapshot_runtime_id": runtime, "operation_id": plugin_identifier},
|
||||
identifier,
|
||||
)
|
||||
return project_sensor(result, node_id)
|
||||
if action not in {"start", "stop"} or params.get("operator_confirmed") is not True:
|
||||
@@ -187,8 +190,8 @@ class NodeK1Sensor:
|
||||
result = await self.bridge.invoke(
|
||||
"acquisition.stop",
|
||||
{
|
||||
"operation_id": identifier,
|
||||
"idempotency_key": identifier,
|
||||
"operation_id": plugin_identifier,
|
||||
"idempotency_key": plugin_identifier,
|
||||
"acquisition_id": params.get("acquisition_id"),
|
||||
"mode": "graceful",
|
||||
"physical_acceptance": acceptance,
|
||||
@@ -223,10 +226,11 @@ class NodeK1Sensor:
|
||||
payload.update(self.cas(state, acquisition=False), operator_confirmed=True)
|
||||
elif phase == "workspace-ready" and acquisition.get("state") != "prepared":
|
||||
next_action = "acquisition.prepare"
|
||||
prepare_identifier = plugin_operation_id(identifier, stage=next_action)
|
||||
payload.update(
|
||||
self.cas(state),
|
||||
operation_id=identifier + "_prepare",
|
||||
idempotency_key=identifier + "_prepare",
|
||||
operation_id=prepare_identifier,
|
||||
idempotency_key=prepare_identifier,
|
||||
project_name="node-" + identifier[3:15],
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
@@ -234,8 +238,8 @@ class NodeK1Sensor:
|
||||
next_action = "acquisition.start"
|
||||
payload.update(
|
||||
self.cas(state),
|
||||
operation_id=identifier,
|
||||
idempotency_key=identifier,
|
||||
operation_id=plugin_identifier,
|
||||
idempotency_key=plugin_identifier,
|
||||
acquisition_id=acquisition["acquisition_id"],
|
||||
expected_state_revision=acquisition["state_revision"],
|
||||
physical_acceptance=acceptance,
|
||||
|
||||
Reference in New Issue
Block a user