feat(gateway,web): nodes dashboard + device identification#6392
feat(gateway,web): nodes dashboard + device identification#6392theonlyhennygod wants to merge 3 commits intomasterfrom
Conversation
singlerider
left a comment
There was a problem hiding this comment.
Reviewed at 9591208. Read the full diff, the linked follow-ups #6390 and #6391, and cross-checked against integration/v0.8.0 to see whether the schema additions collide with anything tracked under #5947 (the v0.8.0 schema-v3 migration).
On scope: take it all the way
The sub-issue split — filing #6390 and #6391 before opening this PR — is exactly the right discipline. That's the carve-out pattern @tidux and @Audacity88 have been asking for, and you got there on your own. Genuinely appreciated.
That said, the ask here is to roll #6390 and #6391 into this branch so #6346 actually closes when this lands. Two reasons:
- The dashboard half without the CLI half doesn't satisfy #6346. Acceptance criterion #4 in the issue reads "No CLI.
zeroclaw nodeandzeroclaw nodesboth error with 'unrecognized subcommand'" — wording your #6390 picks up verbatim. Operators running headless VPS daemons (the primary use case the issue calls out) need both surfaces; the dashboard alone is half the feature for them. Closes #6346 (partially)won't actually close #6346 cleanly. GitHub's auto-close ignores the "(partially)" parenthetical, so if this merges as-is the issue closes anyway with two acceptance criteria still unsatisfied — same wrinkle @Audacity88 flagged on #6214 earlier today. The body wording and the actual scope need to line up.
You've already done the harder half. The CLI and the heartbeat plumbing build on the /api/nodes surface and the policy block you've already shipped here. Happy to chunk-review as you push the additional commits so the cadence stays tight.
If there's a reason this needs to land separately — testing isolation, integration windowing, anything else I'm missing — push back here and we'll work it out. Default ask is the unified PR.
v0.8.0 / schema-v3 divergence (informational)
#5947 (the v0.8.0 schema-v3 migration tracker) introduced aliased map shapes for channels.<alias> and providers.models.<alias> — the worldview shifted from "one global config block per kind" to "map keyed by user-chosen name". integration/v0.8.0 currently has NodesConfig byte-identical to master (3 fields), so the two new fields here will merge forward cleanly. But: if V3 extends the same alias pattern to nodes (a reasonable extension given multi-machine fleets are exactly where aliasing pays off), the flat NodesConfig.stale_after_secs / offline_after_secs keys and the flat GET /api/nodes response shape become a near-term breaking change. Worth a forward-compat sketch in #6391 before that work starts. Not blocking this PR; just flagging so you're not surprised when V3 hits master.
Other notes
🟡 No tests for the new behaviour. 1061 LOC adds a PATCH endpoint, a GET /api/nodes endpoint, two User-Agent parsers, a SQLite migration, and a rename registry method — none have unit tests. The iOS UA parser bug below is exactly the class of issue a 5-line unit test catches. At minimum: one test per UA-parser branch (macOS, iOS, Android, Windows, Linux, empty), one for rename against a missing id, one for the migration on a pre-existing devices.db without the four new columns.
🟢 What looks good: schema additions on NodesConfig are clean (additive #[serde(default)] with named default fns). DeviceInfo extensions are properly Option<String>, backward-compatible. Server-driven policy block on the API responses is a smart pattern — the frontend never hardcodes thresholds.
| return (Some("macOS".into()), Some(version)); | ||
| } | ||
| // iOS: "iPhone; CPU iPhone OS 17_5_1 like Mac OS X" | ||
| if let Some(start) = ua.find("iPhone OS ").or_else(|| ua.find("iPad OS ")) { |
There was a problem hiding this comment.
🔴 [blocking] iOS User-Agent parser produces empty version strings on every iOS UA.
Trace: ua.find("iPhone OS ") returns the byte position of the leading 'i'. rest = &ua[start..] is "iPhone OS 17_5_1 like Mac OS X...". rest.find(' ') finds the FIRST space in rest — between "iPhone" and "OS" at index 6 — not the space between "OS" and the version digits. &rest[7..] is then "OS 17_5_1 like...". The next .find(|c| !(digit || '_')) looks for the first non-digit-non-underscore, hits 'O' at index 0, returns 0. after_space[..0] = "". Result: iOS devices come out as Some("iOS") / Some("").
Fix matches what the macOS branch above already does — skip the literal prefix length:
if let Some(start) = ua.find("iPhone OS ").or_else(|| ua.find("iPad OS ")) {
// Both prefixes are exactly 10 chars including the trailing space.
let rest = &ua[start + 10..];
let end = rest.find(|c: char| !(c.is_ascii_digit() || c == '_'))
.unwrap_or(rest.len());
let version = rest[..end].replace('_', ".");
return (Some("iOS".into()), Some(version));
}Or drop the iOS branch entirely and require the explicit X-OS-Name / X-OS-Version headers this PR already supports — UA sniffing is notoriously fragile and the headers are reliable. A unit test per branch would have caught this in seconds.
| "os_version TEXT", | ||
| "agent_version TEXT", | ||
| ] { | ||
| let _ = conn.execute(&format!("ALTER TABLE devices ADD COLUMN {column}"), []); |
There was a problem hiding this comment.
🔴 [blocking] let _ = swallows every error type, not just the "duplicate column" the comment above describes. DB-locked, permission denied, disk full, schema corruption all eat the same way and surface as zero info to operators.
Two cleaner shapes:
- Match on the specific rusqlite error code:
match conn.execute(&format!("ALTER TABLE devices ADD COLUMN {column}"), []) { Ok(_) => {} Err(rusqlite::Error::SqliteFailure(_, Some(msg))) if msg.contains("duplicate column name") => {} Err(e) => tracing::warn!( "ALTER TABLE devices ADD COLUMN {column} failed: {e}" ), }
- Idempotent existence check first:
let existing: std::collections::HashSet<String> = conn .prepare("PRAGMA table_info(devices)")? .query_map([], |row| row.get::<_, String>(1))? .filter_map(Result::ok) .collect(); for column in [...] { let name = column.split_whitespace().next().unwrap(); if existing.contains(name) { continue; } conn.execute(&format!("ALTER TABLE devices ADD COLUMN {column}"), [])?; }
Either preserves the "ignore duplicate, surface real failures" intent. The current shape gives operators no signal when the migration silently fails for a real reason.
| let device_type = | ||
| read_header("X-Device-Type").or_else(|| infer_device_type_from_ua(user_agent)); | ||
| let (ua_os_name, ua_os_version) = infer_os_from_ua(user_agent); | ||
| registry.register( |
There was a problem hiding this comment.
🟡 [suggestion] Body framing nit: the PR description calls this block a "Fixes the simple header-based POST /pair path the dashboard uses to actually call DeviceRegistry::register" — but handle_pair never called register before this PR (grep -n "registry.register" crates/zeroclaw-gateway/src/lib.rs on master returns nothing). This isn't a regression fix; it's a feature add — the simple pair path gains device-registry wiring it didn't previously have.
Reword the bullet so the diff isn't presented as restoring broken behaviour. Something like: "Wire the simple header-based POST /pair path into DeviceRegistry::register so dashboard-paired devices appear in /api/devices (previously only the JSON /api/pair flow registered)."
Same shape, accurate framing.
|
@theonlyhennygod Also, your screenshot shows elements of different heights. Is the second row always intended to be of smaller size? |
Addresses two blocking review items on PR #6392 (singlerider). iOS UA parser (`infer_os_from_ua`) - Previous logic walked `find(' ')` from the start of the "iPhone OS" match, which lands on the space between "iPhone" and "OS" rather than the one before the version digits. Result: every iOS UA produced `Some("iOS")` / `Some("")`. - Switched to per-prefix length skip ("iPhone OS " is 10 chars, "iPad OS " is 8) so both Apple mobile UAs yield the version correctly. DeviceRegistry additive migration - Replaced `let _ = conn.execute(ALTER TABLE ...)` with an idempotent existence check via `PRAGMA table_info(devices)` plus an explicit `tracing::warn!` on real failures. Operators now get a signal when the migration fails for a real reason (DB locked, permission denied, disk full) instead of silent swallowing. Tests (13 new) - `infer_os_from_ua_*` × 8: macOS, iPhone, iPad, Android, Windows, Linux, empty UA, unrecognised UA. iPhone/iPad both assert non-empty version to lock in the regression. - `infer_device_type_from_ua_branches`: covers ios/android/macos/windows /cli (zeroclaw + curl) /empty. - `rename_persists_new_name`, `rename_returns_false_for_unknown_id`, `rename_to_none_clears_label`: cover the new PATCH endpoint backing. - `migration_adds_columns_to_pre_existing_db`: builds a v1-shape `devices.db` with a row, opens a fresh registry, asserts the row survives and that new fields are reachable end-to-end. - `migration_is_idempotent_on_already_migrated_db`: re-opens the same workspace twice and confirms no panic, schema usable. Also picks up two clippy auto-fixes (unnecessary parens around closure bodies in the unrelated parts of lib.rs) flagged on the same run.
|
@singlerider thanks — generous review, all three of the technical notes were fair calls. On the blocking items (addressed in
|
It was a demo layout for how it should look, it's still needs a polish and had to remove basic redundancies. It should resemble more of the top row and not the bottom one. |
Author claims all addressed, with some pushback.
|
@theonlyhennygod When you have the functional top and bottom row screenshot, can you update? It would be cool to see its intended shape. |
|
@theonlyhennygod Also, even your most recent commit was not correctly attributed to your account. Your |
Adds a dashboard page that lists every ZeroClaw instance across the
fleet (paired clients today, daemon nodes once a fleet-add CLI lands)
with live health, identification metadata, inline rename, and token
rotation.
Backend
- DeviceInfo gains hostname / os_name / os_version / agent_version with
an additive SQLite migration; existing devices.db rows are preserved.
- POST /pair (the simple, header-based path the dashboard uses) now
registers the new client in DeviceRegistry — previously this only
happened on the JSON /api/pair flow, leaving the device list empty
after pairing through the dashboard.
- New /pair captures X-Hostname / X-OS-Name / X-OS-Version /
X-Agent-Version headers, falling back to User-Agent parsing for OS
name+version on browsers.
- New GET /api/nodes lists daemons currently registered on /ws/nodes
(in-memory, populated by NodeRegistry::register on WS handshake).
- Both /api/devices and /api/nodes now return a `policy` block with
stale_after_secs / offline_after_secs so the dashboard never
hardcodes thresholds.
- New PATCH /api/devices/:id with `{name}` body for inline rename;
the existing rotate-token endpoint is now exposed to the dashboard.
- Two new config knobs: nodes.stale_after_secs (default 300s) and
nodes.offline_after_secs (default 1800s), tunable via the standard
config CLI/API surface.
Web
- New Nodes page at /nodes with a Network sidebar entry (between
Doctor and Canvas).
- Unified card list: paired clients and daemon nodes share the same
card shape — icon (laptop / phone / cli / server / cpu), label,
short id, live health pill (Online / Stale / Offline) computed from
the server-driven policy.
- Per-card metadata: Platform (prefers OS+version, falls back to
device_type), Host, Agent version, Last seen, Joined, IP,
capabilities (for daemons).
- Inline rename on hover (pencil icon → input, ⏎ saves / Esc cancels)
and key icon for token rotation, both wired to the new endpoints.
- 30s auto-refresh; refresh button for manual pulls.
- Empty state guides the user toward `/pair` and the future
`zeroclaw node add <url>` command.
Tests
- 164 gateway tests pass; clippy clean across the workspace.
- Smoke tested end-to-end on a temp config dir: pair → rename → rotate
→ revoke, plus restart-survives persistence (devices.db preserved
across `kill / re-spawn`, schema migration applied additively).
Closes #6346 partially — see PR body for the two follow-ups still
remaining on that issue.
Mechanical formatting fix from cargo fmt --all -- --check that I missed locally before pushing. No semantic change.
Addresses two blocking review items on PR #6392 (singlerider). iOS UA parser (`infer_os_from_ua`) - Previous logic walked `find(' ')` from the start of the "iPhone OS" match, which lands on the space between "iPhone" and "OS" rather than the one before the version digits. Result: every iOS UA produced `Some("iOS")` / `Some("")`. - Switched to per-prefix length skip ("iPhone OS " is 10 chars, "iPad OS " is 8) so both Apple mobile UAs yield the version correctly. DeviceRegistry additive migration - Replaced `let _ = conn.execute(ALTER TABLE ...)` with an idempotent existence check via `PRAGMA table_info(devices)` plus an explicit `tracing::warn!` on real failures. Operators now get a signal when the migration fails for a real reason (DB locked, permission denied, disk full) instead of silent swallowing. Tests (13 new) - `infer_os_from_ua_*` × 8: macOS, iPhone, iPad, Android, Windows, Linux, empty UA, unrecognised UA. iPhone/iPad both assert non-empty version to lock in the regression. - `infer_device_type_from_ua_branches`: covers ios/android/macos/windows /cli (zeroclaw + curl) /empty. - `rename_persists_new_name`, `rename_returns_false_for_unknown_id`, `rename_to_none_clears_label`: cover the new PATCH endpoint backing. - `migration_adds_columns_to_pre_existing_db`: builds a v1-shape `devices.db` with a row, opens a fresh registry, asserts the row survives and that new fields are reachable end-to-end. - `migration_is_idempotent_on_already_migrated_db`: re-opens the same workspace twice and confirms no panic, schema usable. Also picks up two clippy auto-fixes (unnecessary parens around closure bodies in the unrelated parts of lib.rs) flagged on the same run.
1cea3fa to
b0ec407
Compare
|
@singlerider — both addressed. AttributionGood catch. The local Fixed by:
Branch is now at ScreenshotI'll get an updated capture of the polished card layout — both rows should match the top-row shape (Platform / Host / Agent / Last seen / Joined / IP, consistent height) once I re-pair fresh devices on a clean workspace. Will post here once it's ready. The bottom row in the original screenshot was older devices missing the new identification fields (hostname / OS / agent version), which made the cards shorter — same template, just less data to render. |
WareWolf-MoonWall
left a comment
There was a problem hiding this comment.
Draft Review — PR #6392
feat(gateway,web): nodes dashboard + device identification
Author: theonlyhennygod | Verdict: --comment
Take-stock before writing
What singlerider raised (all resolved, review dismissed by force-push):
- ✅ iOS UA parser bug (
find(' ')landed on wrong space → empty version strings on every iOS UA) — fixed with per-prefix length skip inb0ec407ee; 8 regression tests pin bothiPhone OSandiPad OSshapes. - ✅
let _ = conn.execute(ALTER TABLE ...)swallowing every error class — replaced with PRAGMA-first idempotent migration +tracing::warn!on real failures. - ✅ No unit tests for new behaviour — 178 lib tests now present: UA parser branches,
rename(3 cases: happy path, missing id, clear-to-None), migration idempotency and pre-existing-DB round-trip. - ✅ PR body framing ("Fixes" → additive feature wiring) — corrected.
- ✅
Closes #6346→Refs #6346— GitHub won't auto-close; #6346 stays open until #6390 (CLI) and #6391 (heartbeat) land. - ✅ Commit attribution (local
.git/configoverride) — cleaned via rebase, all three commits now showtheonlyhennygod@gmail.comfor author and committer.
Active CHANGES_REQUESTED from other reviewers: None. singlerider's review was dismissed by the force push.
Still live going into my read:
- singlerider's informational v0.8.0/schema-v3 forward-compat note (not a block; flagged for design awareness in #6391).
- Scope split question — author argued persuasively for three separate PRs; I read the argument and agree the reasoning is sound (see §5 below).
- No screenshot of the running
/nodespage has appeared yet.
Review body
Reviewed at b0ec407ee. Read the full diff, all prior inline comments and the author's response thread, the follow-up issues #6390 and #6391, and cross-checked require_auth coverage on every new route.
The technical quality here is noticeably better than the initial push. The migration fix in particular is exactly what this codebase needs to do consistently — PRAGMA-first existence check, tracing::warn! on real failures, idempotent on restarts. The UA parser regression test suite is the right class of coverage. Good iteration.
One residual production-path issue and two pre-merge prerequisites below.
🟡 [warning] rename().unwrap_or(0) silently swallows DB errors — inconsistent with the migration fix in the same PR
In crates/zeroclaw-gateway/src/api_pairing.rs, the new rename method:
let updated = conn
.execute(
"UPDATE devices SET name = ?1 WHERE id = ?2",
rusqlite::params![new_name, device_id],
)
.unwrap_or(0);On any DB error — disk full, locked, permission denied, schema corruption — unwrap_or(0) returns 0. The caller interprets that as "no row matched that id" → returns false → patch_device returns HTTP 404. The operator gets the wrong status code and zero signal in the logs.
This is the same class of error swallowing the migration path was correctly fixed for in this very PR. The PRAGMA approach in the migration now does:
if let Err(e) = conn.execute(&format!("ALTER TABLE devices ADD COLUMN {decl}"), []) {
tracing::warn!(
target: "zeroclaw_gateway::devices",
"failed to add column {name} to devices table: {e}"
);
}The rename path should follow the same pattern. A minimal fix:
let updated = match conn.execute(
"UPDATE devices SET name = ?1 WHERE id = ?2",
rusqlite::params![new_name, device_id],
) {
Ok(n) => n,
Err(e) => {
tracing::warn!(
target: "zeroclaw_gateway::devices",
device_id = %device_id,
"rename failed: {e}"
);
return false;
}
};Optionally, patch_device could propagate a 500 vs 404 distinction, but at minimum the warn should be there. Per FND-006 §4.1: "Operational errors … should result in Result<T, E>. A .unwrap() on an operational error is a deferred panic" — and unwrap_or(silent_default) is the non-panicking variant of the same pattern. Per FND-006 §4.6, the log message should carry enough context (device_id, the error) to be diagnostic in a real incident.
🟡 [warning] Screenshot still missing — required before merge per the PR itself
The PR description contains an explicit request:
"Reviewer: please drag-drop a screenshot of the running
/nodespage … into this description before merging."
The author's latest comment acknowledges this and promises an updated capture once they re-pair fresh devices on a clean workspace, but one hasn't appeared yet. For a visual UI feature that was built from a screenshot "that wasn't accessible from the editing context," seeing the rendered output is validation evidence we cannot get from CI or the diff alone. This isn't optional polish — it confirms that the card layout, health pills, and metadata rows render correctly with both old-format (no hostname/OS/agent) and new-format devices present.
I'm not going to supply this — it's the author's validation evidence to provide. Please post the screenshot before requesting final approvals.
🔵 [suggestion] PATCH /api/devices/{id} body has no name-length guard
DevicePatchBody.name: Option<String> accepts unbounded input. The global MAX_BODY_SIZE = 65_536 caps the raw request bytes, but a 64 KB device name is still absurd. A short application-layer cap (e.g. 256 or 512 chars) aligns with FND-006 §4.5 ("At every trust boundary, validate before you process") and means the SQLite TEXT column doesn't become a footgun if a buggy client sends garbage. Low urgency, but worth a one-liner if name.as_deref().is_some_and(|n| n.len() > 256) { return (StatusCode::UNPROCESSABLE_ENTITY, ...) } before the rename call.
✅ Auth coverage on both new routes verified
PATCH /api/devices/{id} calls require_auth(&state, &headers) at the top of patch_device. GET /api/nodes calls super::api::require_auth(&state, &headers) at the top of list_nodes. Both short-circuit to the auth error response before touching any state. No auth gap on the new HTTP surface.
🟢 PRAGMA-first idempotent migration is the pattern to repeat
The new migration shape — collect existing column names into a HashSet<String> via PRAGMA table_info(devices), skip present columns, warn on real errors — is the correct way to do additive SQLite migrations in this codebase. Compared to the previous let _ = conn.execute(ALTER TABLE ...) and to the SQLite-specific error-code matching approach, this is readable, safe on older SQLite versions, and doesn't require matching rusqlite internal error variants. I'd like to see this pattern referenced or pulled into a shared helper before the next migration that needs it (there will be one).
🟢 Server-driven policy block is architecturally clean
Emitting { stale_after_secs, offline_after_secs } from both /api/devices and /api/nodes and having the frontend only use DEFAULT_POLICY for first-paint is the right separation. The operator tunes the config knob; the frontend reflects it without a redeploy. The DEFAULT_POLICY constants in the frontend match the server defaults exactly, so first-paint is never visually inconsistent. Good design.
🔵 [team decision] Scope split — keeping this PR as the dashboard half
I read singlerider's ask to roll in #6390 and #6391 and the author's three-point response. The argument for three separate PRs is sound:
- This PR at 1338 LOC is already the upper end of reviewable size; adding the daemon-side WS client and heartbeat plumbing would triple it.
- The dashboard half is genuinely useful standalone today (paired-client fleet view, rename, rotate, server-driven health policy).
- #6391 has a real architectural dependency on the v0.8.0/schema-v3
nodesalias discussion (#5947) that needs to be settled beforelast_seenis added toNodeInfo.
Refs #6346 rather than Closes #6346 is the right body wording — GitHub won't auto-close, and the issue correctly stays open until #6390 and #6391 land. I support the scope as-is.
Informational — list_nodes hardcodes "status": "online"
Acknowledged in the PR: /api/nodes only lists in-memory WS-handshake registrations, no heartbeat tracking. Every daemon node therefore shows "online" permanently. The frontend UnifiedNode for daemons reads this field directly rather than computing from last_seen, which means the health pill for daemon nodes will always be green until #6391 ships. This is accurately documented and the deferred scope is justified (the heartbeat design depends on the v0.8.0 conversation). Not flagging it as a concern — just making sure the next reviewers see it explicitly.
Summary
| # | Weight | Item |
|---|---|---|
| 1 | 🟡 warning | rename().unwrap_or(0) silently swallows DB errors → add tracing::warn! with device_id and the error, consistent with the migration fix in this same PR |
| 2 | 🟡 warning | Screenshot still missing — required per the PR description before merge |
| 3 | 🔵 suggestion | Add a name-length guard (e.g. 256 chars) on PATCH body before the DB call |
| 4 | ✅ resolved | Auth coverage on PATCH /api/devices/{id} and GET /api/nodes verified |
| 5 | 🟢 praise | PRAGMA-first migration pattern — right call, worth formalising as a shared helper |
| 6 | 🟢 praise | Server-driven policy block — clean design, no threshold duplication |
| 7 | 🔵 team | Scope split (three-PR approach) — I support it; Refs wording is correct |
No new blocking items from me. Items 1 and 2 are the things I'd want to see addressed before approving; if they land I'm happy to re-review quickly.
|
@JordanTheJet — milestone alignment needed: this PR does not clearly fit within the scope boundary of any open milestone. Please advise on placement or deferral. |
|
@JordanTheJet @Audacity88 — gentle ping for review when you have a moment. Part of the v0.7.5 push, CI 12/12 green at |
Summary
master/nodesdashboard page so operators can see every ZeroClaw instance across the fleet (paired clients today, daemon nodes once the fleet-add CLI lands) with live health, identification metadata, inline rename, and token rotation — previously the dashboard had no view of paired devices at all, and the existing pairing path didn't even register them.DeviceInfowithhostname/os_name/os_version/agent_version(additive SQLite migration viaALTER TABLE … ADD COLUMNper new column, preserving existingdevices.dbrows) so cards can show real platform/host/agent metadata instead of justdevice_type.POST /pairpath intoDeviceRegistry::registerso dashboard-paired devices appear in/api/devices(previously only the JSON/api/pairflow registered). This is a feature add, not a regression fix — the simple pair path gains device-registry wiring it didn't have before.nodes.stale_after_secs(default 300s) andnodes.offline_after_secs(default 1800s), exposed in apolicyblock on both/api/devicesand/api/nodes. The frontend keeps a conservativeDEFAULT_POLICYonly for first-paint and otherwise reads the server values, so thresholds are tunable via the existing config CLI/API surface without a redeploy.PATCH /api/devices/:id({name}body) for inline rename and wires the existing rotate-token endpoint into the dashboard; newGET /api/nodeslists daemons currently registered on/ws/nodesand emits the samepolicyblock.zeroclaw node add <url>CLI (filed as [Feature]: zeroclaw node add <url> CLI — register a remote daemon #6390 — needs a daemon-side WS client + persistent peer table) and does not add real heartbeat tracking for daemon nodes (filed as [Feature]: real heartbeat tracking for daemon nodes — derive Online/Stale/Offline from last WS message #6391 — WS message timestamp + status derivation). Both linked back to [Feature]: zeroclaw node CLI + dashboard health & management (follow-up to #2991) #6346, so it stays open until they ship plus the cross-cutting docs entry.zeroclaw-gatewayHTTP surface (new route, two response-shape additions: newDeviceInfofields and apolicyblock on the list endpoints — additive, existing consumers ignore them);zeroclaw-configschema gains two newnodes.*keys (additive defaults);devices.dbschema gets four new nullable columns via in-placeALTER TABLE; the web app gains a new route and sidebar entry. No changes to providers, channels, agent loop, memory, security, tools, or daemon WS protocol.Refsrather thanClosesdeliberately so GitHub does not auto-close [Feature]: zeroclaw node CLI + dashboard health & management (follow-up to #2991) #6346 on this merge.Validation Evidence (required)
Local validation is the signal CI cannot replace. Run the full battery and paste literal output (tails, failures, warnings — not "all passed").
cargo fmt --all -- --check cargo clippy --all-targets -- -D warnings cargo testcargo fmt --all -- --check— clean.cargo clippy --all-targets -- -D warnings— clean across the workspace (3 auto-fixes applied during development in the new pair-handler additions).cargo test— 164zeroclaw-gatewaylib tests pass; full workspace test run green.rustc 1.93.1 (01f6ddf75 2026-02-11).hostname/os_name/os_version/agent_versioncorrectly; CLI/iPhone/Mac/Living-Room-Mac all render with the rightPlatformline (OS+version preferred, falls back todevice_typeonly when neither OS nor version is known — this fixes the duplication where the old card showed bothType: MacosandOS: macOS 10.15.7).PATCH /api/devices/:idwith{name}persists to SQLite; rename survives restart.DELETE /api/devices/:idremoves the device.devices.dbthat lacked the four new columns — additiveALTER TABLEapplied cleanly, existing rows preserved, new fields populated on subsequent re-pair.nodes.stale_after_secsandnodes.offline_after_secsvia the config surface and watching the server-driven policy propagate to the cards./ws/nodes(deferred to [Feature]: real heartbeat tracking for daemon nodes — derive Online/Stale/Offline from last WS message #6391 — current/api/nodesonly lists in-memoryNodeRegistry::registerentries on WS handshake) and thezeroclaw node add <url>CLI (deferred to [Feature]: zeroclaw node add <url> CLI — register a remote daemon #6390).Security & Privacy Impact (required)
Yes/No for each. Answer any
Yeswith a 1–2 sentence explanation.No)No) — all new endpoints are local gateway HTTP routes.No) — token rotation surfaces an existing endpoint to the dashboard; no change to issuance, storage, or scope.No) —hostname/os_name/os_version/agent_versionare operator-supplied device metadata stored locally indevices.db, not exfiltrated anywhere. No real identities, emails, or personal data appear in diff or fixtures.Yes, describe the risk and mitigation: N/A.Compatibility (required)
Yes)Yes) — additive only:nodes.stale_after_secs(default300) andnodes.offline_after_secs(default1800), auto-discoverable asnodes.stale-after-secs/nodes.offline-after-secsvia the existing config CLI/API surface.PATCH /api/devices/:id({name}body) and newGET /api/nodes. Existing/api/devicesresponse gains four new optionalDeviceInfofields and a top-levelpolicyblock; existing clients ignore the additions.devices.db— no manual step required.NoorYesto either: exact upgrade steps for existing users: None. Restart picks up the new defaults; the migration is automatic.Rollback (required for
risk: mediumandrisk: high)This PR touches
crates/zeroclaw-gateway/**, which the auto-labeler classifies asrisk: high. Filling the medium/high section.git revert a53154461. The additiveALTER TABLEcolumns can stay in place harmlessly (older code ignores them); no manual schema rollback needed.nodes.stale_after_secs/nodes.offline_after_secs) only affect the displayed health pill — setting them to very large values effectively suppresses Stale/Offline classification without code changes.registererrors fromapi_pairingafter aPOST /pair, orALTER TABLEmigration errors on startup againstdevices.db.PATCH /api/devices/:idorGET /api/nodesreturning404/500./nodespage failing to load, empty card list after a successful pair, or health pills stuck on the frontendDEFAULT_POLICY(would indicate the serverpolicyblock isn't being read).tail -fthe gateway log fordevice_registryandnodesspans; sustained errors on either is the signal.