Preserve native K1 error causes and distinguish Wi-Fi actions
This commit is contained in:
@@ -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>}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user