Preserve native K1 error causes and distinguish Wi-Fi actions

This commit is contained in:
DCCONSTRUCTIONS
2026-09-07 14:11:34 +03:00
parent 8f17a7e282
commit 4f715e41d2
6 changed files with 142 additions and 18 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ import sys
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.8.5"
VERSION = "0.8.6"
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
from debian import package
@@ -0,0 +1,44 @@
# Onboard K1 causal diagnostics R8
The owner screenshot after R7 corresponds to a new network.provision operation,
not connection.verify. The operation failed at connect-failed in approximately
68 ms with BleakError, side_effect_status=none and network_not_applied. It did
not cross the Wi-Fi dispatch boundary. The service remained active without a
restart. This is distinct from the Linux route-probe TypeError corrected in R7.
The Bluetooth failure origin remains unproven; a missing BlueZ device object
is only a hypothesis and is not grounds for an automatic rescan or write retry.
The existing journal logger only recorded PluginExecutionError and the facade
wrapper stack. The facade retains the native exception as __cause__, but the
logger did not follow it. The new bounded causal logger records exception class
and basename/function/line for up to six causes and ten frames per cause. It
respects suppressed context and terminates cycles. It never renders exception
messages, source lines, frame locals or payloads. Both journalled and outer
HTTP failure paths use it. Error responses and device admission are unchanged.
A regression now passes through the actual NodeBridge and plugin facade with
only the service endpoint replaced. It verifies that the native BleakError
location survives the real PluginExecutionError wrapper, exactly one connect
is followed by state observation, and generated secret material is absent from
logs and the response. A second test covers cyclic and suppressed chains.
The earlier test substituted NodeBridge.invoke and therefore never exercised
the wrapper that obscured the installed failure.
The existing wireless modal also distinguishes its two actions more clearly.
Check K1 state appears before the network fields; the provisioning action is
now labelled Apply Wi-Fi and check. No new controls, permissions, retries,
state machine, profiles or host-network changes are introduced. Final Connect
continues to require current device/session proof.
Validation: 20 NodeBridge/package lifecycle tests, 23 focused frontend and
architecture checks, 794 full Core frontend tests, Core TypeScript/production
build and scoped Ruff passed. Node 0.8.6 and optional K1 0.1.5 are reserved for
this source. Package, installation and owner UI results will be recorded after
they are observed. This increment improves diagnosis and action clarity; it
does not claim to fix the physical Bluetooth failure.
The owner screenshot and exact operation result are preserved with UTC and
monotonic collection timestamps and SHA-256 hashes under private/acceptance/
k1-node085-20260907-bluetooth-1355. Browser cache clearing for that attempt has
not been confirmed. No hardware commands were issued by the agent. The next
physical check remains through the owner UI after clearing browser cache.
@@ -133,10 +133,14 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
{bluetoothFailure&&<div data-enrollment-reveal="result" role="status"><SettingsCard align="center" title={notice}/></div>}
</SettingsCard>
{selected&&<div data-enrollment-reveal="wifi"><SettingsCard eyebrow="Шаг 02" title="Wi-Fi"
description="Укажите сеть для K1. Проверка передаст настройки сканеру и проверит связь с БК." actions={!confirmed&&
description="Проверьте связь с K1 в уже настроенной сети или укажите новую сеть Wi-Fi." actions={!confirmed&&
<Button disabled={pending} aria-busy={busy==='networks'} icon={busy==='networks'?<ActivityIndicator size="compact"/>:undefined}
onClick={()=>void run('networks')}>{busy==='networks'?'Ищем сети':'Найти сети'}</Button>}>
{!confirmed&&<>
{enrollmentAllowed(state,'verify')&&
<Button disabled={pending} aria-busy={busy==='verify'}
icon={busy==='verify'?<ActivityIndicator size="compact"/>:undefined}
onClick={()=>void run('verify')}>{busy==='verify'?'Проверяем состояние':'Проверить состояние K1'}</Button>}
<div data-enrollment-reveal="networks">
{!!networks.length&&<Select label="Сеть Wi-Fi" value={network}
options={[{value:'manual',label:'Ввести название сети'},...networks.map((value,index)=>({value:String(index),label:value.ssid,description:`Сигнал ${value.signal}% · ${value.security||'Без защиты'}`}))]}
@@ -146,11 +150,7 @@ export function DeviceEnrollmentWindow({transport,onChange,onClose,renderWindow}
<TextField label="Пароль Wi-Fi" type="password" value={password} onChange={event=>setPassword(event.target.value)} disabled={pending} autoComplete="new-password"/>
<Button disabled={pending||!enrollmentAllowed(state,'connect')||!bridgeFormValid(ssid,password)} aria-busy={busy==='connect'}
icon={busy==='connect'?<ActivityIndicator size="compact"/>:undefined}
onClick={()=>void run('connect')}>{busy==='connect'?'Проверяем подключение':'Проверить подключение'}</Button>
{enrollmentAllowed(state,'verify')&&
<Button disabled={pending} aria-busy={busy==='verify'}
icon={busy==='verify'?<ActivityIndicator size="compact"/>:undefined}
onClick={()=>void run('verify')}>{busy==='verify'?'Проверяем состояние':'Проверить состояние K1'}</Button>}
onClick={()=>void run('connect')}>{busy==='connect'?'Применяем Wi-Fi и проверяем':'Применить Wi-Fi и проверить'}</Button>
</>}
{notice&&!bluetoothFailure&&<div data-enrollment-reveal="result" role="status"><SettingsCard align="center" title={notice}/></div>}
</SettingsCard></div>}
+1 -1
View File
@@ -22,7 +22,7 @@ from credential_install import PROFILE_ID, validate # noqa: E402
from debian import package # noqa: E402
from runtime_payload import files as runtime_files # noqa: E402
VERSION = "0.1.4"
VERSION = "0.1.5"
RESOURCES = (
"plugins/xgrids-k1/profile_loader.py",
"plugins/xgrids-k1/plugin.manifest.json",
@@ -37,6 +37,29 @@ ATTESTATION = {
}
def failure_locations(error: BaseException) -> str:
"""Bounded causal stack, excluding messages, source lines and frame locals.
The facade deliberately wraps native transport errors. Logging only the
outer stack loses the actual OS failure boundary on journalled responses.
"""
chain: list[str] = []
seen: set[int] = set()
current: BaseException | None = error
while current is not None and id(current) not in seen and len(chain) < 6:
seen.add(id(current))
frames = list(traceback.walk_tb(current.__traceback__))[-10:]
locations = ";".join(
f"{Path(frame.f_code.co_filename).name}:{frame.f_code.co_name}:{line}"
for frame, line in frames
)
chain.append(f"{type(current).__name__}[{locations}]")
current = current.__cause__ or (
None if current.__suppress_context__ else current.__context__
)
return " <- ".join(chain)
class NodeEnrollmentRejected(ValueError):
"""A host admission failure proven to precede any device invocation."""
@@ -152,14 +175,11 @@ class NodeBridge:
# results, so the HTTP exception logger never sees them. Record
# only source locations here; exception text may contain a frame.
logging.getLogger(__name__).warning(
"K1 journalled invocation failed: action=%s operation=%s exception=%s locations=%s",
"K1 journalled invocation failed: action=%s operation=%s exception=%s chain=%s",
action,
identifier,
type(error).__name__,
";".join(
f"{Path(frame.filename).name}:{frame.name}:{frame.lineno}"
for frame in traceback.extract_tb(error.__traceback__)[-10:]
),
failure_locations(error),
)
# Read the exact result after a failure; never repeat an invocation
# or interpret an exception as evidence that a command was unsent.
@@ -346,15 +366,12 @@ def create_app(repository_root: Path):
# 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 locations=%s",
"K1 operation failed: action=%s exception=%s chain=%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:]
),
failure_locations(error),
)
return JSONResponse(
{
+63
View File
@@ -97,6 +97,69 @@ def test_journalled_failure_logs_source_without_secret_and_does_not_repeat(caplo
assert secret not in caplog.text
def test_real_facade_preserves_native_failure_location_in_journalled_log(caplog):
from bleak.exc import BleakError
secret = secrets.token_hex(20)
calls = []
class Service:
current = state()
def bind_runtime_event_loop(self, _loop):
pass
def require_snapshot_runtime_id(self, expected):
assert expected == self.current["snapshot_runtime_id"]
def state(self):
calls.append("state")
return copy.deepcopy(self.current)
async def connect(self, request):
calls.append("connect")
self.current["operations"] = [{
"operation_id": request.operation_id, "status": "failed",
"error": {"code": "BleakError"},
}]
raise BleakError(secret)
async def run():
device = NodeBridge(Path.cwd(), service=Service())
command = {
"operation_id": "op_" + "c" * 32, "runtime_id": "runtime-one",
"deadline_at": (datetime.now(UTC) + timedelta(seconds=60)).isoformat(),
"action": "connect", "parameters": {
"device_id": "AA:BB:CC:DD:EE:FF", "discovery_generation": 1,
"mode_revision": 0, "ssid": "test-network", "password": secret,
},
}
result = await device.deliver(command)
assert result["command_result"]["status"] == "failed"
assert calls == ["state", "connect", "state"]
assert "password" not in command["parameters"]
assert secret not in json.dumps(result)
asyncio.run(run())
assert "PluginExecutionError[" in caplog.text
assert "BleakError[" in caplog.text
assert "test_node_k1_bridge.py:connect:" in caplog.text
assert secret not in caplog.text
assert "test-network" not in caplog.text
def test_failure_locations_respects_suppression_and_bounds_cyclic_chains():
from k1link.device_plugins.xgrids_k1.node_bridge import failure_locations
first = RuntimeError(secrets.token_hex(20))
second = ValueError(secrets.token_hex(20))
first.__context__ = second
second.__cause__ = first
assert failure_locations(first) == "RuntimeError[] <- ValueError[]"
first.__suppress_context__ = True
assert failure_locations(first) == "RuntimeError[]"
def test_node_scan_reaches_service_through_real_facade_with_runtime_fence():
"""Exercise the actual admission boundary, replacing only the BLE service."""
from k1link.device_plugins.xgrids_k1.facade import SnapshotRuntimeConflict