2824 Commits
Author SHA1 Message Date
a06ac8aceb fix(#1849): render — for TRACE Hop Bytes column (path bytes are SNR, not hop hashes) (#1850)
Fixes #1849.

## Problem
The packets table "HB" (Hop Bytes / col-hashsize) column always shows
`1` for TRACE packets. TRACE packets use header path bytes as per-hop
SNR readings, not truncated hop hashes — see
`internal/packetpath/route.go` `PathBytesAreHops(TRACE) = false`. The
high-2-bit "hash_size" derivation applied to a SNR byte is meaningless
(typically `1`).

## Fix
At the 3 render sites in `public/packets.js` (`buildGroupRowHtml` header
row, its child rows, `buildFlatRowHtml`), when `payload_type === 9`
(TRACE) render `—` in the `col-hashsize` cell with a `title` tooltip
explaining that TRACE path bytes are SNR readings and directing users to
the sidebar decoder for the actual hop count. Non-TRACE rows unchanged.

## TDD
- Red commit: `dd7a4e78` — `test-issue-1849-trace-hashbytes.js` asserts
col-hashsize cell equals `—` with a title tooltip for TRACE, numeric for
non-TRACE. CI must fail on this commit.
- Green commit: `115368d0` — 3-site fix in `public/packets.js`.

## Verification
```
$ node test-issue-1849-trace-hashbytes.js
 All 4 tests passed
```
Preexisting failures in `test-packets.js` (13) are unchanged by this PR
(verified via `git stash`) — unrelated to the surface touched here.

## Scope
- `public/packets.js`: 3 render sites, +21/-8.
- `test-issue-1849-trace-hashbytes.js`: new unit test.
- `test-all.sh`: wire new test.
No public API change.

---------

Co-authored-by: clawbot <bot@example.invalid>
2026-07-18 01:47:37 -07:00
8c3e397d39 fix(1846): drop 1200px cap on .observers-page (#1847)
Red commit: aabe8143d1

## Summary
Drop `max-width: 1200px` on `.observers-page` in `public/style.css`. The
observers table is column-dense and already runs `data-priority`
auto-hide on narrower viewports, so the 1200px cap crushed columns on
wide monitors while the mobile hide-column CSS had already dropped
columns for narrower widths — the exact "starts auto-hiding but never
expands back" behavior described in #1846.

Removes the cap so the browser layout uses available viewport width (as
`.analytics-page` does).

## Change
- `public/style.css:2113` — remove `max-width: 1200px` from
`.observers-page`.
- `test-issue-1846-observers-width.js` — new regression guard. Parses
`.observers-page` rule from `style.css`; fails if a `max-width` cap
`<1600px` is re-introduced (matches the `.analytics-page` convention
referenced in triage).
- `.github/workflows/deploy.yml` — wire the new test into the frontend
unit test job.

## Test discipline
- Red commit `aabe8143`: test only, no CSS change — CI fails on
assertion (`max-width 1200px must be >= 1600px`).
- Green commit `665e88a6`: CSS fix — test passes.

## Browser verification
Browser tool unavailable in this session (gateway restart required).
Change is a pure CSS width-cap removal on a single class; no JS, no
HTML, no theming implications. The regression guard makes silent
reintroduction impossible.

## E2E assertion
Regression guard is source-grep, not DOM-level:
`test-issue-1846-observers-width.js:41` (`.observers-page` block
max-width allowlist). Justification: bug is a single declarative CSS
attribute; a DOM E2E for it would just assert
`getComputedStyle(...).maxWidth === 'none'`, which mirrors the source
assertion at higher cost. If a follow-up wants a Playwright
viewport-resize check, easy to add.

## Preflight
`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— clean on the green HEAD.

Fixes #1846

---------

Co-authored-by: corescope-bot <bot@corescope.dev>
2026-07-12 22:41:01 -07:00
c7f0c6931f fix(1843): restore QR quiet zone on node-details codes (#1844)
## Summary

QR codes on node-details pages didn't scan. Two `public/nodes.js` render
sites called `qr.createSvgTag(3, 0)`; the vendor lib treats the second
arg as an **absolute pixel margin**, so `0` removed the QR quiet zone.
QR spec requires ≥4 modules of light border, so every scanner rejected
the code.

With `cellSize=3`, four modules = 12 pixels. Changed both call sites to
`createSvgTag(3, 12)`:
- `nodes.js:813` — node-details list detail
- `nodes.js:1705` — node-map overlay (also swaps background to
transparent; with margin restored, dark modules stay 12px from the SVG
edge so the transparent overlay still parses over the map tile)

Out of scope, filed as follow-up in triage: contrast tuning of the
transparent-overlay branch.

## TDD

Red commit: `3be8552b` — [test-only, CI
red](https://github.com/Kpa-clawbot/CoreScope/commit/3be8552baff64795504e8dcb888ed25cbd6a50be)
Green commit: `6f7e83a6` — two-line fix + test passes

## Test

`test-issue-1843-node-qr-quiet-zone.js` — Node/vm harness that loads
`public/vendor/qrcode.js`, grep-asserts both `nodes.js` call sites use
margin ≥ 12px, renders the SVG, and checks the `<path>` `M` coordinates
keep all dark modules ≥ 12px from every viewBox edge (and viewBox =
`modules*cellSize + 2*margin`).

Verified red on parent commit (test fails with margin=0 leaving dark
modules at 0,0), green after fix (dark modules at 12,12 with 12px free
border).

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— all gates clean.

Browser verified: pending — will validate on staging after CI. (Frontend
UX bug; DOM/grep test above exercises the same SVG code path that
scanners consume.)

E2E assertion added: `test-issue-1843-node-qr-quiet-zone.js:63` (viewBox
+ `<path>` coord grep on real-rendered SVG).

Fixes #1843.

---------

Co-authored-by: corescope-bot <bot@corescope.dev>
2026-07-10 00:44:48 -07:00
59cf5130d1 fix(1838): fold non-transport routes into scope-stats Unscoped (#1842)
Fixes #1838

## Problem

`/api/scope-stats` reported 100% scoped whenever any region was
configured. Reporter noticed on a scopeless instance that "unscoped" was
always zero — the pie visual is misleading to operators deciding on
`denyf *`.

## Root cause

`cmd/server/db.go:22` restricted the entire scope-stats denominator to
`route_type IN (0, 3)`. Per firmware `docs/packet_format.md § Route
Types`:

- `0` = `TRANSPORT_FLOOD`
- `1` = `FLOOD`
- `2` = `DIRECT`
- `3` = `TRANSPORT_DIRECT`

Only routes 0 and 3 carry `transport_code_1` (transport-level scope).
Routes 1 and 2 are inherently unscoped by protocol. The existing SQL was
correct for the "how many transport-scopable routes are actually scoped"
question, but the denominator was silently promoted to "all traffic" in
the UI. Bonus: the comment on `routeTypeTransportSQL` labelled routes
0+3 as "FLOOD (0) and DIRECT (3)" — wrong on both counts.

## Fix

- `cmd/server/db.go` — corrected the `routeTypeTransportSQL` comment;
added `routeTypeNonTransportSQL = "route_type IN (1, 2)"` alongside it.
- `GetScopeStats` runs a second `COUNT(*)` over `route_type IN (1,2) AND
first_seen >= ?` and folds that count into `Summary.Unscoped`. Same
index path as the existing query — one extra scan per `/api/scope-stats`
call (cached 30s per triage's carmack finding).
- `public/analytics.js` — Scopes tab header explains the denominator
(all observed transmissions) and which route types carry scope. Card
notes now render `X% of all traffic` for Scoped/Unscoped and `X% of
scoped` for Unknown Scope so the pie's denominator is explicit.

## TDD

- Red: `5554ffe4` — extended `TestGetScopeStats` +
`TestHandleScopeStats` with `route_type=1` and `route_type=2` rows and
asserted `Unscoped = 3` (1 transport-NULL + 2 non-transport). Ran the
tests and confirmed assertion failure (`Unscoped = 1, want 3`).
- Green: `ebbb9253` — implementation + label copy. Full `go test
./cmd/server/...` passes (54s).

## Preflight overrides

- check-branch-clean: justified — cross-stack fix by design (backend
semantics change + matching frontend label copy). All 4 files are
exactly the surface the triage comment identified.

## Verification

- `go test ./cmd/server/...` — 54s, all pass.
- Firmware confirmation: `firmware/docs/packet_format.md:20-24` (route
type table).

## Files touched

- `cmd/server/db.go` — comment fix + second COUNT query.
- `cmd/server/db_test.go` — extended fixture.
- `cmd/server/routes_test.go` — extended fixture + isolate from seed
data.
- `public/analytics.js` — labels and header copy.

---------

Co-authored-by: corescope-bot <bot@corescope.dev>
2026-07-09 22:56:29 -07:00
d60188e481 refactor(1828): split handleObserverAnalytics into 5 helpers + byTxID fast-path (#1839)
## Summary

Phase A of #1828: extract the 5 aggregate builders in
`handleObserverAnalytics` into pure helpers in a new
`cmd/server/observer_analytics.go`. Handler becomes a snapshot + filter
+ 5 composed calls.

Also adopts the `byTxID` direct-read in `buildPacketTypes` (issue body's
core observation): the payload-type histogram no longer allocates a full
`enrichObs` map + interface-boxed fields just to read `tx.PayloadType`.
That's the ~90% perf win the triage called out.

Scope is exactly Phase A per the second triage comment. Phase B
(sub-endpoints, caching, SQL migration) is deferred to a follow-up.

## Byte-identical output

- Timeline / NodesTimeline: same key set, same sort, same labels.
- PacketTypes: same keys/counts. Both legacy
(`enriched["payload_type"].(int)`) and new (`tx.PayloadType == nil`
guard) skip obs whose tx is missing or `PayloadType` is `nil`.
- SnrDistribution: same 2-unit floor bucketing (negative-side rounding
preserved), same ascending sort.
- RecentPackets: still the first 20 enriched observations (`enrichObs`
kept only here, where the extra fields are actually needed).

## TDD

- Red commit: `9dc62f43` — 7 unit tests fail on assertions (not build
errors) against stubs.
- Green commit: `8d41011d` — implementations + handler rewire. All new
tests + existing `TestObserverAnalytics*` handler tests pass.

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ clean (all 8 hard gates + 3 warnings pass).

## Non-goals

- No new endpoints.
- No SQL migration.
- No public API signature change.
- Snapshot count unchanged (still one under RLock, per #1481 P0-2).

Fixes #1828.

---------

Co-authored-by: fix-1828-bot <bot@corescope.local>
Co-authored-by: clawbot <bot@corescope>
2026-07-09 19:14:44 -07:00
9d47a4adea fix(1836): normalize pubkey case on observer↔node cross-nav links (#1837)
Fixes #1836.

Observer↔node cross-nav links from #1826 land on 404 because pubkeys are
stored lowercase in `nodes` and uppercase in `observers`, and the
backend `WHERE` lookups are case-sensitive. The two link builders now
normalize case at the boundary.

## Fix
- `public/observer-detail.js`: observer → node href passes
`currentId.toLowerCase()`.
- `public/nodes.js`: node → observer href passes
`n.public_key.toUpperCase()`.

## TDD
- Red: `test-issue-1836-crossnav-case-normalization.js` asserts the two
hrefs contain `.toLowerCase()` / `.toUpperCase()`. Fails on master.
- Green: 2-line production change makes the test pass.

Scope: 2 production line edits + 1 new test file.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-07-08 23:08:42 -07:00
d2ef624c2e feat(api): flood_advert_count_7d on the node detail endpoint (#1831)
Adds, per node, how many distinct FLOOD adverts it originated in the
last 7 days. Zero-hop adverts (route_type DIRECT) are excluded, so a
nearby observer hearing a node's cheap local adverts does not inflate
the number - the existing advert_count mixes both kinds and cannot tell
a chatty flooder (mesh-wide airtime) from
  the recommended 240-minute zero-hop cadence (local only).

Consumers (the ArcScope repeater advisor) rate advert hygiene against
the community practice of one flood advert every ~49h; with the mixed
total, a correctly configured repeater looked chatty whenever an
observer sat within zero-hop range.

Implemented like the relay-liveness fields: a pure, unit-tested counter
over (first_seen, route_type, hash) entries with the same timestamp
parsing and hash dedup, fed by a from_pubkey-indexed query capped at the
2000 most recent advert rows. The flood route-type constant is named
advertRouteTypeFlood so this merges independently
  of the open unscoped-relay PR (#1823).

---------

Co-authored-by: Waydroid Builder <build@waydroid.local>
2026-07-08 22:14:41 -07:00
4f7bb245d4 fix(#1833): pin legend toggle button above VCR bar on Live view (#1834)
## Summary

Fixes #1833 — the `.legend-toggle-btn` (palette icon) on the Live view
was hardcoded to `bottom: 1rem`, so on typical desktop viewports it sat
underneath the VCR playback bar and was unreachable. The reporter had to
hide the legend via `localStorage` to work around it.

## Fix

`public/live.css:1313` — one-character-class change:

```diff
 .legend-toggle-btn {
   position: fixed;
-  bottom: 1rem;
+  bottom: calc(var(--vcr-bar-height, 58px) + 10px);
   right: 1rem;
```

Mirrors the existing pattern already used by every other bottom-pinned
Live overlay:

- `.live-feed` (live.css:1204)
- `.live-overlay[data-position="br"]` (live.css:1372)
- `.feed-show-btn` (live.css:903)

`--vcr-bar-height` is maintained by the ResizeObserver on `.vcr-bar`, so
the button now tracks bar growth (mobile two-row layout,
safe-area-inset) instead of overlapping.

Same class of regression as #685 / #1206 / #1107 — an overlay that was
missed in the prior sweep.

## TDD

- Red commit `f6a938b7`: `test-issue-1833-legend-toggle-vcr-offset.js`
asserts `.legend-toggle-btn`'s `bottom` declaration references
`var(--vcr-bar-height`. Fails on assertion (not build error) against the
hardcoded `1rem`.
- Green commit `e399092b`: the CSS one-liner above. All 3 assertions
pass.

Grep-based CSS assertion per AGENTS.md § "E2E-DOM-grep exemption" —
there is no existing Playwright test in this area that measures the
toggle-button rect against `--vcr-bar-height`, and standing one up would
be disproportionate for a single-property fix that mirrors three
existing, tested overlay patterns.

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— all hard gates pass, no warnings.

## Browser verified

Not required per fix-issue skill (CSS-only, mirrors three existing
tested patterns). Staging will pick up the change on merge; visual
regression will be caught if the mirrored pattern breaks (grep test in
this PR + existing E2E tests on the sister overlays).

---------

Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-07-08 18:14:01 -07:00
56fe844871 test: dedupe the unscoped-relay tests via a shared fixture (#1832)
Follow-up to #1823: TestRepeaterUnscopedRelayCount and its _Bulk twin
were ~30 verbatim lines apart (DB, node insert, store seeding,
assertions), differing only in the lookup under test - seeding changes
had to land twice. Both now use a shared seedUnscopedRelayFixture +
assertUnscopedCounts and contain only their
respective lookup call. No behaviour change; the relay-liveness suite
passes.

Co-authored-by: Waydroid Builder <build@waydroid.local>
2026-07-08 15:13:39 -07:00
bd0a58e14c feat(api): add unscoped_relay_count_24h per-node field (#1823)
## What
Adds a per-node API field `unscoped_relay_count_24h` on repeater/room
nodes: the
  number of the node's 24h relay-hops that were unscoped floods
  (route_type == ROUTE_TYPE_FLOOD). A strict subset of relay_count_24h.

  ## Why
A well-configured repeater runs `flood.max.unscoped 0` and should not
rebroadcast
unscoped floods — each one is re-sent by every repeater that hears it,
so one
packet turns into mesh-wide traffic. Exposing this lets clients (the
ArcScope
repeater advisor) detect and flag that base-config problem from observed
packets.

  ## How
Computed like relay_count_24h in both paths (bulk /api/nodes + per-node
detail)
with a route_type==FLOOD filter; reuses the byPathHop index, no
migration. Wired
  into both handlers + OpenAPI schema + unit tests (per-node and bulk).

Co-authored-by: Waydroid Builder <build@waydroid.local>
2026-07-07 00:12:44 -07:00
ba68069c23 fix(#1825): add cross-nav links between observer and node detail pages (#1826)
Adds cross-navigation between the observer detail page and the node
detail page for the same pubkey (community feature request from
cwichura).

**Changes**
- `public/observer-detail.js`: new `<a
href="#/nodes/${encodeURIComponent(currentId)}">View node detail →</a>`
inside `.page-header`, next to the `<h2 id="obsTitle">`.
- `public/nodes.js`: new sibling `<a
href="#/observers/${encodeURIComponent(n.public_key)}"
class="btn-primary">Observer →</a>` in the same button row as the
`Analytics` / `Reach` anchors on the full node detail page. Uses the
existing `ph-eye` phosphor icon.

**Test — TDD red→green**
- Red commit: `8c2315e1` (`test(#1825): red — observer<->node cross-link
anchors missing`) — 4/4 assertions fail on master; CI RED.
- Green commit: `ff8f6ed7` — minimum production change; 4/4 assertions
pass locally.

Test file: `test-issue-1825-observer-node-cross-links.js` —
static-source DOM-grep style consistent with the neighbouring
`test-issue-1789-observer-firmware-cols.js` /
`test-observers-headings.js` pattern. It asserts:
1. observer-detail.js contains
`href="#/nodes/${encodeURIComponent(currentId)}"`.
2. That anchor sits inside the `.page-header` block.
3. nodes.js contains
`href="#/observers/${encodeURIComponent(n.public_key)}"`.
4. That anchor is a sibling of the analytics/reach anchors in the same
flex row.

**Notes**
- Pubkeys are `encodeURIComponent`-escaped on both sides
(defense-in-depth; MeshCore pubkeys are hex only).
- No API changes. No CSS changes. No new dependencies.

Fixes #1825

---------

Co-authored-by: meshcore-bot <meshcore-bot@users.noreply.github.com>
2026-07-06 20:13:04 -07:00
096e16409c fix(#1741): wrap test-DB insert loops in a single transaction (#1819)
## Fixes #1741

`TestBoundedLoad_OldestLoadedSet` (and any test building a 5000-row
fixture) hung/timed out, blocking reliable `go test ./cmd/server` and
CI.

  ## Root cause

The four test-DB builders in `cmd/server/bounded_load_test.go`
(`createTestDBAt`, `createTestDBWithObs`, `createTestDBWithAgedPackets`)
inserted rows in a loop with no `BEGIN`/`COMMIT`. With the pure-Go
`modernc.org/sqlite` driver every `Exec` auto-commits → one fsync per
row → ~2N fsyncs for N transmissions (tx + obs). At
`numTx=5000` that's ~10k fsyncs and the fixture blows past the test
timeout. Sibling tests with `numTx<=3000` happened to stay under the
timeout, so only the 5000-row cases visibly hung.

  ## Fix

Wrap each insert loop in a single `BEGIN`/`COMMIT` so the whole fixture
build becomes one commit. Fixtures now finish in well under a second
regardless of `numTx`; the tests' actual assertions (`oldestLoaded` set,
newest-first ordering, bounded load) are exercised instead of the
timeout masking them. Also made the
prepared-statement `Exec` calls check their error (previously discarded)
so a failed insert surfaces instead of silently leaving the DB short.

  No production code changed — test infrastructure only.

  ## Verified

- `TestBoundedLoad_OldestLoadedSet`: **0.18s** (was: 30s timeout /
FAIL).
  - Full `TestBoundedLoad*` + retention group: passes in ~1.2s.
- `go test ./...` in `cmd/server`: exit 0 (no longer blocks on this
test).

Co-authored-by: Waydroid Builder <build@waydroid.local>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-03 02:21:08 -07:00
6a32ec2b2d fix(#1729): preserve firmware-default Public channel (0x11) in analytics (#1817)
## Fixes #1729

The firmware-default **Public** channel (channel-hash byte `0x11` = 17)
was rendered as an opaque **"Encrypted (0x11)"** row at the bottom of
the analytics Channels tab, despite the key being well-known and
builtin.

  ## Root cause

`computeAnalyticsChannels` applied the #978 rainbow-table validation
(`SHA256(SHA256("#name")[:16])[0]`, the **hashtag** hash scheme) to
every decoded channel name. The Public channel is a **PSK** channel
whose hash byte is key-derived (`SHA256(key)[0]` = 17), not
hashtag-derived (`186` for `#Public`). So the ingestor-decoded name
`"Public"` failed the hashtag check and was discarded, the row forced to
`encrypted=true, name="ch17"`.

  ## Fix

Trust the ingestor's `decryptionStatus`. The ingestor already persists
`decryptionStatus:"decrypted"` when it decoded a packet with a real key
(PSK), and `"no_key"` / `"decryption_failed"` otherwise. When the packet
is `decrypted`, skip the hashtag hash check and keep the name — it came
from a key-based decryption, not a
rainbow-table lookup. The #978 mismatch rejection still applies to
non-decrypted packets, so rainbow-table collisions are still caught.

Frontend needs no change: `encrypted=false, name="Public"` lands in the
"Network" group (top), not "Encrypted".

  ## Tests

- `makeGrpTx` gains `makeGrpTxWithStatus` companion to set
`decryptionStatus`.
- `TestComputeAnalyticsChannels_PublicChannelPreserved`: hash 17 /
"Public" / `decrypted` → name stays `"Public"`, `encrypted=false`.
- `TestComputeAnalyticsChannels_UndecryptedNameStillValidated`: a
non-`decrypted` name failing the hashtag check is still downgraded to
`ch17` (#978 regression guard).

  All channel-analytics tests pass; `go build ./...` clean.

Co-authored-by: Waydroid Builder <build@waydroid.local>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 19:20:14 -07:00
750b8742a7 fix(staging-compose): decouple in-container mosquitto from standalone broker (#1813)
Red commit: `3898dbc5` (verified locally — CI run URL pending)

## Problem

A standalone `mqtt-broker` container (`eclipse-mosquitto:2`) was
provisioned out-of-band on the staging VM. It now owns MQTT, is attached
to external docker network `meshcore-net`, and binds host port `8883`.
The current `docker-compose.staging.yml` still:

- Publishes `1883:1883` on the host (dead weight; conflicts the moment
the broker moves to that port).
- Defaults `DISABLE_MOSQUITTO=false`, so the in-container mosquitto
burns RAM and briefly contests the `mqtt-broker` docker DNS name on cold
start.
- Doesn't join `meshcore-net`, so the ingestor can't resolve
`mqtt-broker:1883` via docker DNS without manual surgery.

## Fix (`docker-compose.staging.yml` only)

1. Remove the `1883:1883` host port publish from `staging-go`.
2. Flip `DISABLE_MOSQUITTO` default from `false` to `true`. Operators
can opt back in with the env var.
3. Attach `staging-go` to both `default` and `meshcore-net`; declare
`meshcore-net` as `external: true` so the file never tries to
create/destroy operator state.

Healthcheck and Caddy/443 plumbing untouched (out of scope).

## Test added (TDD framing: Option A — Go shape-asserts)

`cmd/server/staging_compose_broker_test.go:1` adds four regex-based
assertions on the compose file shape:

- staging-go does **not** bind port `1883` in ANY form (quoted/unquoted
short form, or long-form `target: 1883` / `published: 1883`).
- `DISABLE_MOSQUITTO` uses the interpolated default form
`${DISABLE_MOSQUITTO:-true}` (preserves operator override). Bare literal
`true`, or a later `=false` override in the same env block, is rejected.
- Top-level `networks:` declares `meshcore-net` as `external: true`.
- `staging-go` attaches to `meshcore-net` via a real
`services.staging-go.networks:` sub-key (comment-stripped so an
in-comment example can't masquerade).

Regex (not YAML byte-equality) so cosmetic edits don't break the guard.
No new go module deps. Red commit `3898dbc5` fails all 4 assertions on
master. Green commit `38297ff4` makes them pass. Round-1 hardening
commit `9f7155e2` tightens the regexes (per adversarial + kent-beck
must-fixes) and was verified against master's YAML shape — all 4 tests
fail on `origin/master`'s compose, pass on branch, proving the tightened
regexes still gate a real regression.

## Risk

Low, with one intentional semantic change.

- **Semantic change (v3.7+):** `DISABLE_MOSQUITTO` in
`docker-compose.staging.yml` now defaults to `true`. This is a
**deliberate flip** — the standalone `mqtt-broker` container is now
authoritative on the staging host, and running the in-container
mosquitto alongside it wastes RAM and races the docker DNS name
`mqtt-broker` on cold start. Operators who want the pre-v3.7 shape
(in-container mosquitto + host-published `1883`) must explicitly opt
back in via env override AND re-add the `1883:1883` port mapping
(concrete snippet is inline in the compose file and in `DEPLOY.md` under
"Standalone MQTT broker (staging)"). This intent is called out in a
`SEMANTIC CHANGE (v3.7+)` header comment at the top of
`docker-compose.staging.yml`.
- **Deploy prereq:** the external `meshcore-net` docker network MUST
already exist on the host before `docker compose up`. If it doesn't,
compose refuses to start `staging-go`. This is documented inline in the
compose file (with the `docker network create meshcore-net` one-liner)
and in `DEPLOY.md`.
- **Only takes effect where the standalone broker is deployed** — which
it already is on staging today. The legacy `DISABLE_MOSQUITTO=false`
path remains reachable via env override; the ingestor's upstream config
is untouched.

Partial fix — no tracking issue; follow-up to operator-side broker
provisioning.

---------

Co-authored-by: corescope-bot <bot@corescope.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-30 18:21:21 -07:00
fa15ab0a30 fix(#1809): gate background loader on LoadChunked completion (#1811)
Partial fix for #1809.

Red commit: c9c782b5 (CI runs on PR open; standalone red-branch CI not
configured — local repro proves the gate, see below).

## Problem
Issue #1809: at startup the background fill loader logged `background
load FAILED` within seconds and `backgroundLoadFailed=true` was set,
leaving the coverage gate tripped even though LoadChunked itself
completed normally.

## Root cause
`main.go:225-245` spawned `go store.loadBackgroundChunks()` as soon as
`FirstChunkReady` fired (chunk #1 = 10000 tx). But `s.oldestLoaded` is
only assigned at the end of `LoadChunked` (`chunked_load.go:329-333`),
~tens of seconds later. The bg loader read `oldestLoaded==""` at
`store.go:1462-1466`, broke out immediately, walked zero chunks, and the
coverage gate at `store.go:1543-1554` flipped
`backgroundLoadFailed=true`.

## Fix (initial)
Introduce `PacketStore.RunStartupLoad(chunkSize)` (`chunked_load.go`).
It runs `LoadChunked` first; only on success and only when
`hotStartupHours > 0` does it call `loadBackgroundChunks`. `main.go`
invokes `RunStartupLoad` in the same goroutine pattern as before, so
`FirstChunkReady` still unblocks the HTTP listener bind at chunk #1 —
only the bg loader is gated.

## Round-1 followups (this push)
Reviewer-driven hardening on top of the initial fix:

### Production behavior (commit db5592f6)
- **Steady-state semantics tightened.** `RunStartupLoad` now picks a
terminal state on every branch:
- LoadChunked error → `backgroundLoadFailed=true` with captured error
(was: `done=false, failed=false` indefinite).
- `hotStartupHours == 0` → `backgroundLoadDone=true` immediately,
`progress=100` (was: `done=false` forever → healthz stuck on
`backgroundLoadComplete=false`).
- Successful hot-window path → terminal state is whatever
`loadBackgroundChunks` sets (#1690 semantics, unchanged).
- **Runtime invariant assertion (A7).** `loadBackgroundChunks` panics
when `oldestLoaded==""` and packets exist — a future refactor that
re-introduces the parallel-spawn race fails loudly instead of silently
shipping the same coverage regression.
- **`RunStartupLoad` cleanup.** Inlined the superfluous goroutine +
channel that wrapped `LoadChunked` (direct call is equivalent).
- **Logging.** Added an INFO line between `LoadChunked` completion and
bg-loader start (the #1809 post-mortem needed exactly this signal).
Fixed the lying `"background load will start"` log that fired even on
the `hotStartupHours==0` branch.
- **Immutability documented.** `hotStartupHours` is now explicitly
documented as immutable post-construction, so the lock-free reads in
`LoadChunked` / `RunStartupLoad` / `loadBackgroundChunks` are sound.

### Test coverage (commits db5592f6 + e9e12acf)
- **Tautology fix (B1, commit e9e12acf).** The original
`Test1809_StartupLoad_BgLoaderSeesOldestLoaded` fixture seeded all 100
rows inside the 1h hot window, so `LoadChunked` alone produced
coverage=1.0 — the test passed even if `loadBackgroundChunks` was a
no-op. Rewrote the fixture to spread 100 rows over 14 days with
`hotStartupHours=24`, so only ~7 rows are hot and the remaining ~93 MUST
be loaded by the bg loader for the assertions to hold. Original
red-commit assertions kept intact; added `len(packets) > hot-only cap`
and `oldestLoaded < hot-cutoff - 12h` assertions on top.
- **New tests (commit db5592f6, `runstartup_load_test.go`)** codify the
new contracts:
  - `TestRunStartupLoad_HotStartupHoursZero_SetsDoneImmediately`
  - `TestRunStartupLoad_LoadChunkedError_SetsFailedTerminal`
  - `TestRunStartupLoad_EmptyDB_SetsDoneTerminal`
  - `TestRunStartupLoad_BgLoaderRunsAfterLoadChunkedSets_OldestLoaded`
  - `TestLoadBackgroundChunks_PanicsOnOldestLoadedEmpty_Invariant`

### Docs (commit 70fa16f7)
- Package-level doc in `chunked_load.go` now documents `RunStartupLoad`
as the orchestrator entry point alongside `LoadChunked` /
`loadStatusMiddleware` / `OnChunkLoaded`.

### Preflight (commit eec1b48c)
- Test-fixture DDL annotated with `// PREFLIGHT: async=true
reason="unit-test fixture"` so the async-migration gate distinguishes
ephemeral test schema from prod migration paths.

## What's still NOT fixed (left intentionally open)
This PR addresses the startup race specifically. Issue #1809 will be
closed by the operator after observing healthy startup logs (`background
load complete: ... coverage=100.0%`) in prod for a full restart cycle.
Do not auto-close — leave open until the operator verifies.

## Test
Local repro (red branch state, before green commit): `go test
./cmd/server/ -run Test1809_StartupLoad_BgLoaderSeesOldestLoaded` → FAIL
on assertion `backgroundLoadFailed=true ...
oldest="2026-06-30T18:56:09Z"`. After green commit + round-1 followups:
PASS. Full `cmd/server/...` suite: ok in ~70s.

## Risk
Low — startup path only. New behavior gates surfaced by the new tests;
coverage-gate semantics unchanged. Runtime panic is a new failure mode
but only fires on a state (`oldestLoaded=="" && len(packets)>0`) that is
unreachable on the current code path — it exists solely as a refactor
tripwire.

---------

Co-authored-by: mc-bot <bot@corescope.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: meshcore-bot <bot@meshcore>
2026-06-30 17:19:56 -07:00
242c7c609b fix(mqtt): escalate persistent paho disconnect + recover from emit panic + expose watchdog tick (#1749) (#1810)
# Partial fix for #1749 — MQTT watchdog escalation + panic recovery +
tick exposure

Red commit: 9912bbb3e9 (CI run:
https://github.com/Kpa-clawbot/CoreScope/actions/runs?branch=fix%2Fissue-1749)

## Problem
Production CoreScope v3.9.1 (and a more recent prod recurrence on
2026-06-30 with the wcmesh source on `ssl://mqtt2.wcmesh.com:8883`)
showed two distinct watchdog failure modes:

1. **Per-source paho machinery dies silently.** `IsConnectedFn` returns
false; paho's `SetAutoReconnect(true)` never retries. The watchdog's
`processLivenessTransition` deliberately stays silent on
`LivenessDisconnected`, trusting paho to recover — so there is no
escalation path when that trust is misplaced.
2. **Watchdog goroutine death.** Three sources went silent within ~60s
of each other; no `WATCHDOG` log lines for 75 min. The most plausible
single point of failure is a panic inside `emit` (e.g. a blocked log
pipe) killing the loop with no defer/recover.

## Changes

**`cmd/ingestor/mqtt_watchdog.go`**
- Added `disconnectedReconnectMultiplier = 5` constant.
- Added `DisconnectedSinceUnix int64` (atomic) on `SourceLivenessState`.
Stamped on the first tick the source is observed disconnected; cleared
on any non-disconnected tick.
- `processLivenessTransition` now escalates: when `(now -
DisconnectedSinceUnix) > multiplier × threshold`, emits a `WATCHDOG
ESCALATION` WARN and calls `maybeForceReconnect` (subject to existing
`forceReconnectThrottle`). Distinct from the existing `LivenessStalled`
path so operators can grep escalation events independently.
- Added package-level `watchdogLastTickUnix atomic.Int64` +
`WatchdogLastTickUnix()` getter. The loop stamps it BEFORE per-source
processing — a wedged source-handler does not freeze the clock for an
external observer.
- `runLivenessWatchdogLoop` wraps each per-source
`processLivenessTransition` call in `func() { defer recover; ... }()` so
a panic in `emit` (or in any per-source code path) is logged and
skipped, not fatal. The loop continues to the next source and the next
tick.

**`cmd/ingestor/stats_file.go`**
- Added `WatchdogLastTickUnix int64` field on `IngestorStatsSnapshot`
(additive, `omitempty`); populated from `WatchdogLastTickUnix()` each
stats tick.

**`cmd/server/mqtt_status.go`**
- `MqttStatusResponse` gains `WatchdogLastTickUnix int64` (additive,
`omitempty`) sourced from the ingestor stats file; surfaced via `GET
/api/mqtt/status`.

**`config.example.json`**
- No new config field added — the multiplier is a code constant (5×) per
the issue's "N×threshold (e.g. 5×)" recommendation. The active
per-source `threshold` is the 5-minute scan threshold hard-coded at the
sole `runLivenessWatchdog` callsite (`cmd/ingestor/main.go:460`:
`runLivenessWatchdog(60*time.Second, 5*time.Minute)`), so escalation
fires at ~25 minutes (5 × 5min) of continuous disconnect — plus a
deterministic per-source jitter of 0..30s (#1810 round-1, see Taleb #4)
to avoid synchronized escalation across N sources sharing an upstream
broker outage.

## Acceptance (#1749)
- [x] Persistent `LivenessDisconnected` > N×threshold → force-reconnect
+ WARN
- [x] Watchdog goroutine liveness clock exposed (`WatchdogLastTickUnix`
in `/api/mqtt/status`)
- [x] Test: `IsConnectedFn` false for >5×threshold → assert
`ForceReconnectFn` invoked at least once
- [x] Test: panic in `emit` → assert loop recovers and continues ticking

## Test plan
- 4 new tests in `cmd/ingestor/mqtt_watchdog_1749_test.go` (all RED on
master, GREEN on this PR).
- Existing watchdog tests (`mqtt_watchdog_force_reconnect_test.go`,
`mqtt_reconnect_test.go`, r1/r2/m1 suites) continue to pass — the
escalation path is additive.

## Preflight
- TDD: red commit pushed and asserted to fail BEFORE green commit
landed.
- PII grep: clean on diff and PR body.
- Worktree: `_wt-fix-1749` on branch `fix/issue-1749`.

---------

Co-authored-by: corescope-bot <bot@corescope.dev>
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: bot <bot@local>
2026-06-30 15:16:31 -07:00
b74a64ccfa fix(ui): canonical payload label map across packets/live/packet-filter (#1799) (#1804)
## Summary

Replaces the three drifted per-surface payload-type label vocabularies
with a single canonical map keyed by firmware enum name.

Per the locked triage comment on #1799
([comment-4823975431](https://github.com/Kpa-clawbot/CoreScope/issues/1799#issuecomment-4823975431)):

> Create `public/payload-labels.js` exporting `{GRP_DATA: {short:'Group
Data', long:'Group data packet', enumId:6}, ...}`. Migrate `packets.js
typeMap`, `packet-filter.js FW_PAYLOAD_TYPES`, `live.js TYPE_COLORS
legend` to consume it. E2E that scrapes each surface and asserts label
equality.

## Changes

- **`public/payload-labels.js`** (new) — canonical map exposed as
`window.PayloadLabels` and `window.PayloadLabelsApi`. Keys are firmware
enum names; values carry `{short, long, enumId}` plus derived
`SHORT_BY_ID` / `FW_PAYLOAD_TYPES` / `TYPE_ALIASES` for legacy callers.
- **`public/packets.js`** — `TYPE_NAMES` + `typeMap` now read from
`PayloadLabelsApi.SHORT_BY_ID`. Literal kept only as a defensive
fallback for the case where the script tag fails to load.
- **`public/packet-filter.js`** — `FW_PAYLOAD_TYPES` + `TYPE_ALIASES`
now sourced from `PayloadLabelsApi`. Literal fallback retained so `node
test-packet-filter.js` still works headlessly.
- **`public/live.js`** — legend `<li>` rows are now generated from
`window.PayloadLabels` in stable order, killing the third-vocabulary
`Message — Group text` / `Direct — Direct message` drift the #1797
review surfaced.
- **`public/index.html`** — `<script src="payload-labels.js">` loaded
before `roles.js` / `packet-filter.js` / `packets.js`.
- **`test-issue-1799-label-vocab-e2e.js`** (new) — Playwright E2E.
Scrapes `#liveLegend` rows and the `/packets` type-filter checklist,
asserts each label matches `window.PayloadLabels[ENUM].short` for
`TXT_MSG`, `GRP_TXT`, `GRP_DATA`. Also verifies `window.PacketFilter`
still recognises the enum names.
- **`.github/workflows/deploy.yml`** — wired the new E2E into the
existing Playwright block.

## TDD trail

- Red commit `eb392d4` — adds the failing E2E only (asserts
`window.PayloadLabels` exists and labels match; both fail).
- Green commit `44e902a` — introduces the canonical map and migrates the
three surfaces.

## Verification

- `node test-packet-filter.js` — 92/92 pass with the new fallback
wiring.
- Preflight: `bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh
origin/master` — clean.

Browser verified: E2E `test-issue-1799-label-vocab-e2e.js` exercises
`/live` legend + `/packets` type filter against a Playwright headless
Chromium; CI's Playwright block runs it on every push.

E2E assertion added: `test-issue-1799-label-vocab-e2e.js:139` —
`assert(fromLegend === canon, ...)` and `assert(fromPackets === canon,
...)` per enum.

Fixes #1799

---------

Co-authored-by: mc-bot <bot@corescope>
Co-authored-by: openclaw-bot <bot@openclaw.local>
Co-authored-by: clawbot <clawbot@kpa.com>
Co-authored-by: clawbot <bot@clawbot.local>
2026-06-30 05:48:47 -07:00
4654ce3386 feat(analytics): "My Repeaters" favorites monitoring dashboard (#1761)
My Repeaters monitoring dashboard. Closes #1765.

---------

Co-authored-by: Waydroid Builder <build@waydroid.local>
2026-06-30 00:51:26 -07:00
30e4151f7a fix: neighbor-graph tab never renders after filtering down (#1758)
The Analytics → Neighbor Graph tab fetches the full (uncapped) graph
and,
when it exceeds NODE_LIMIT (1000), skips the force simulation with a
"use filters to reduce the node count" notice. But filtering never
actually
re-enabled rendering:

- the node-count guard tested _ngState.allNodes (the immutable full
fetched
set, assigned once in createGraphState and never reassigned) instead of
the
displayed/filtered _ngState.nodes, so its verdict was fixed at load
time;
- the entire draw loop lives in startGraphRenderer(), which ran exactly
once
  at load and was never called from applyNGFilters(), so a filter change
updated the node/edge arrays and stat cards but never un-hid the canvas
or
scheduled an animation frame -> the graph stayed blank no matter how few
  nodes remained.

This explains both reported symptoms (selects too many nodes initially
AND
stays broken once restricted to fewer).

Fix: make the render lifecycle filter-aware.
- startGraphRenderer() now guards on the displayed set (_ngState.nodes),
cancels any running rAF loop before re-deciding, toggles the canvas plus
a
  stable-id "skipped" notice, and restarts cleanly (no double loops).
- applyNGFilters() calls startGraphRenderer() so every filter change
  re-evaluates the guard and (re)starts or stops the loop.
- the initial render now goes through applyNGFilters() so the first
paint
already respects the default filters (observers unchecked, saved
min-score)
  instead of dumping the full fetched graph.

Test: `node --check public/analytics.js` passes. Manually: open
Analytics → Neighbor Graph on a
mesh with >1000 nodes → the "skipped" notice shows; tighten filters
(min-score up / roles
off) below 1000 → the graph now renders (was blank before); loosen again
→ notice returns.

Frontend-only change (`public/analytics.js`); no backend/API change.

---
**TDD note (review round 1):** Single-commit community bug-fix on an
existing UI surface (no "net-new UI" exemption). The e2e
`test-issue-1758-ng-filter-rerenders-e2e.js` is the red→green gate — it
fails on `origin/master` (the renderer kept the node-count guard on the
full fetched graph and never un-hid the canvas) and passes with the fix.
Per AGENTS.md the separate red/green-commit *form* is a bot rule, not a
contributor gate.

---------

Co-authored-by: Waydroid Builder <build@waydroid.local>
Co-authored-by: Waydroid Builder <claude@michael.arcan.de>
2026-06-29 15:54:02 -07:00
9ae547ed7b test: de-flake distance-202 and anchor-bias tests (deterministic timing) (#1808)
Two server tests flaked intermittently and reddened CI on unrelated
(frontend)
PRs that merged master:

- TestDistanceConcurrentRequestsDuringBuildReturn202 asserted all 10
concurrent
requests get 202 'during the build window', but the lazy distance build
on the
tiny test DB finishes almost instantly, so on a fast machine some
requests
raced past it and got 200 (~50% flake). Add a nil-by-default
distanceBuildHook
seam on PacketStore (zero overhead in prod) that the test uses to hold
the
build open until all requests have been served — making the window
guarantee
  deterministic.

- TestHandleNodePaths_AnchorBiasInconsistency_Issue1278 queried /paths
right
  after store.Load(), racing the path-hop index that Load() builds in a
  background goroutine (#1008); the membership/canonical result was thus
  non-deterministic (rarer flake, worse under suite load). Wait for
  PathHopIndexReady() before querying.

Both run 30x green and pass -race. No production behavior change (hook
is nil).

Co-authored-by: Waydroid Builder <claude@michael.arcan.de>
2026-06-29 15:53:59 -07:00
ec0ebeda2f fix(#1793): WebSocket CheckOrigin allowlist (block cross-origin scrapers) (#1795)
## Summary

Closes the wide-open `/ws` WebSocket upgrader (`CheckOrigin: return
true`) that lets any browser origin scrape live packet data. Replaces it
with an explicit allowlist consulted from `cfg.CORSAllowedOrigins`, plus
an implicit same-origin allowance and an empty-Origin (non-browser
client) allowance.

Fixes #1793.

## Rules (`Hub.checkOrigin`)

- Empty `Origin` header → **allow** (non-browser clients; per-IP
rate/deny gating tracked separately in #1794).
- `Origin` host == request `Host` (case-insensitive) → **allow**
(same-origin).
- `Origin` matches an entry in `cfg.CORSAllowedOrigins` by exact
case-insensitive match → **allow**.
- `"*"` in `cfg.CORSAllowedOrigins` is **deliberately ignored** for
`/ws`. A startup `[ws] WARNING:` is logged once when present.
- Anything else → **reject** (gorilla returns 403).

### Deliberate divergence from CORS XHR

CORS XHR (`corsMiddleware`) still honors `"*"` for read-only
cross-origin GETs. The `/ws` upgrade does NOT, per OWASP's WebSocket
Security Cheat Sheet:

> Use an allowlist, not a denylist. Avoid wildcards or substring
matching.

—
https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html

`"*"` on the WS path would re-open the exact CSWSH/scraping vector this
PR closes, so it is rejected with a startup warning rather than silently
honored. This intentional asymmetry is documented in the updated
`_comment_corsAllowedOrigins` in `config.example.json`.

## TDD red → green

- `e5974c6a` **RED** — adds `cmd/server/websocket_checkorigin_test.go`
with five cases; `SetAllowedOrigins` introduced as an enforcement stub
so the test compiles and fails on the assertion (CI fails on this commit
by design).
- `a4791dc3` **GREEN** — implements `Hub.checkOrigin`, wires
`SetAllowedOrigins` from `main.go`, updates the config example. All
tests pass.

## Tests added (`cmd/server/websocket_checkorigin_test.go`)

- `TestCheckOriginRejectsForeignOrigin` — foreign Origin → 403
- `TestCheckOriginAllowsEmptyOrigin` — non-browser client → 101
- `TestCheckOriginAllowsSameHost` — same-origin → 101
- `TestCheckOriginAllowsAllowlistedOrigin` — exact allowlist match → 101
- `TestCheckOriginWildcardDoesNotAllowForeignOrigin` — `"*"` in
allowlist still rejects foreign origin → 403

## Files changed

- `cmd/server/websocket.go` — `Hub.allowedOrigins`, `SetAllowedOrigins`,
`checkOrigin`, wired into `Upgrader.CheckOrigin`.
- `cmd/server/main.go` — `hub.SetAllowedOrigins(cfg.CORSAllowedOrigins)`
at the single call site.
- `cmd/server/websocket_checkorigin_test.go` — new test file.
- `config.example.json` — updated `_comment_corsAllowedOrigins` to
document `/ws` gating and the `"*"` divergence.

## Out of scope (follow-up)

- **#1794** — per-IP rate limit / deny list / connection cap for
non-browser clients (which still bypass Origin because they don't send
one). Layered defense; not in this PR.

## Verification

- `go test ./cmd/server/...` — all server tests pass locally (574s).
- Preflight clean (`bash
~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`).

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-29 05:48:58 -07:00
Michael J. ArcanandGitHub ae2e3933dd feat(server): store memory diagnostics + drop redundant obs.RawHex (#1773)
Drops the redundant per-observation RawHex (~98MB on a live store;
reader already falls back to tx.RawHex #881) and adds an opt-in
/api/perf?mem=1 memory breakdown (flood-forward share + per-component
bytes). Profiled against a live instance.

**Savings substantiation:** live-instance profiling shows ~1.66M
observations in the store, each previously carrying its own
per-observation `raw_hex` (avg ≈118 hex chars ≈59 bytes) that exactly
duplicates the parent transmission's `raw_hex`. Dropping the duplicate
on every load/ingest path eliminates ≈98 MB of redundant in-memory
storage plus ~1.66M string allocations, with no data loss — the read
path (`enrichObs`) already falls back to `tx.RawHex` when `obs.RawHex`
is empty (verified by the new safety-gate test). The patched build
cannot be run against the live instance here; instead the new opt-in
`/api/perf?mem=1` diagnostic lets operators measure the
real before/after (`trackedMB` and the per-component breakdown) directly
after deploy.
2026-06-28 13:48:47 -07:00
707d70c738 fix(packets): clamp .col-details to one line on mobile (#1770 S path) (#1805)
## Summary

Partial fix for #1770 (S quick-fix path only; L refactor remains as
follow-up).

The packets-view virtual-scroller assumes a constant
`VSCROLL_ROW_HEIGHT`, but the base rule at `public/style.css` L1097 lets
`td.col-details` wrap on narrow viewports (`white-space: normal;
word-break: break-word`). Wrapped rows produce variable row heights →
visible jitter when scrolling past ~900px on iOS.

**Quick-fix (S path):** under the existing `@media (max-width: 640px)`
block in `public/style.css`, clamp `.col-details` to a single line:

```css
.data-table td.col-details {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
```

Trade-off accepted in triage: Details column truncates on mobile in
exchange for smooth scrolling. The base rule keeps wrapping on desktop
(≥641px) so nothing changes there.

**Out of scope:** the full L-path fix (per-row measurement,
`_rowHeightsPx[]`, cumulative offsets, re-measure on hop-resolver
finalize) — tracked separately on #1770.

## TDD

- **Red commit** `7f58bedc` — adds
`test-issue-1770-mobile-row-clamp.js`, a CSS-grep test (same pattern as
`test-issue-1364-pill-no-clamp.js`) that walks every `@media (max-width:
640px)` block in `public/style.css` and asserts a `.col-details` rule
declares `white-space: nowrap`, `overflow: hidden`, and `text-overflow:
ellipsis`. Verified to FAIL on master (assertion failure, not a parse
error) and PASS after the CSS change.
- **Green commit** `d46271b8` — applies the 5-line CSS clamp inside the
existing mobile breakpoint at L2362.

## Files touched

- `public/style.css` (+13)
- `test-issue-1770-mobile-row-clamp.js` (+101, new)

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ all gates pass (PII, branch scope, red commit, css-vars, css
self-fallback, LIKE-on-JSON, sync migration, async-migration, XSS). No
warnings.

---------

Co-authored-by: clawbot <bot@clawbot.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-28 07:48:39 -07:00
b3189c613a fix(#1802): decode CONTROL DISCOVER_REQ/RESP subtype + body fields (#1806)
## Summary
Extend CONTROL packet decoding to surface DISCOVER_REQ / DISCOVER_RESP
subtype plus body fields in the packet detail view. Previously only the
byte0 zero-hop flag was decoded; the body was rendered as opaque hex.

## What changed

**Backend** — `cmd/ingestor/decoder.go` `decodeControl()`
- New `Payload` fields (all omitempty): `CtrlSubtype`, `CtrlFilter`,
`CtrlTag`, `CtrlSince`, `CtrlNodeType`, `CtrlSNR`, `CtrlPubKey`.
- Subtype derived from `byte0 & 0xF0`: `0x80` → `DISCOVER_REQ`, `0x90` →
`DISCOVER_RESP`, otherwise `UNKNOWN`.
- REQ body parsed when `len(buf) >= 6`: `filter:u8 | tag:u32 LE`, plus
optional `since:u32 LE` when 4 more bytes remain.
- RESP body parsed when `len(buf) >= 6`: `node_type` (low nibble of
byte0), `snr:i8`, `tag:u32 LE`, and `pubkey` hex — 32 bytes when full, 8
bytes when prefix-only.
- Every field gated on length; short/truncated bodies emit subtype only
and never panic.
- `CtrlZeroHop` retained for backwards compatibility (rename flagged for
follow-up per triage).

**Frontend** — `public/packets.js` `getDetailPreview()`
- New `decoded.type === 'CONTROL'` branch renders subtype + present body
fields (filter / tag / since / node_type / snr / pubkey). Each field
shown only when populated, so truncated CONTROL still gets a subtype
label.

## Wire format reference
- `firmware/src/Mesh.cpp:69` — `CTL_TYPE_NODE_DISCOVER_REQ=0x80`,
`CTL_TYPE_NODE_DISCOVER_RESP=0x90`.
- `firmware/examples/simple_repeater/MyMesh.cpp:773-820` — body parse /
build.

## Tests (red → green, per AGENTS.md STRICT TDD)
- `cmd/ingestor/issue1802_test.go` — 6 cases: REQ full body (with
since), REQ no-since, RESP 32B pubkey, RESP 8B prefix pubkey, RESP
truncated pubkey (no panic, no pubkey emitted), short body (subtype
only), unknown subtype. Red commit `43713d3a` → green commit `d4b28180`.
Pre-existing CONTROL tests (`TestDecodeControlZeroHop`,
`TestDecodeControlMultiHop`) still pass.
- `test-packets.js` — 3 cases on `getDetailPreview`: DISCOVER_REQ
(filter+tag rendered), DISCOVER_RESP (snr+pubkey rendered), UNKNOWN
subtype label. Red commit `be23e349` → green commit `845d6c48`.

## Preflight overrides
- `check-branch-clean` (cross-stack): justified — issue #1802 explicitly
spans backend decoder (`cmd/ingestor/decoder.go`) and frontend renderer
(`public/packets.js`) per triage comment. Tests in both layers.
Single-purpose PR.

## Scope discipline
Files touched: `cmd/ingestor/decoder.go`,
`cmd/ingestor/issue1802_test.go`, `public/packets.js`,
`test-packets.js`. No other files. No firmware changes. No
`cmd/server/decoder.go` changes. No `CtrlZeroHop` rename (deferred per
triage).

Fixes #1802

---------

Co-authored-by: clawbot <bot@meshcore.local>
2026-06-28 06:32:07 -07:00
120ac052d3 fix(packets): add Multipart/Control/Raw Custom to type filter checklist (#1798) (#1803)
## Summary

Fixes #1798. Extends the Packets-page `typeMap` in `public/packets.js`
to include three firmware payload types that were previously missing
from the multi-select checklist:

- `10` — Multipart
- `11` — Control
- `15` — Raw Custom

Other surfaces (`public/packet-filter.js` `FW_PAYLOAD_TYPES`,
`public/live.js` `TYPE_COLORS`, `public/map.js`) already knew about
these types; only the Packets-page checklist UI omitted them, forcing
operators to hand-type filter expressions to filter on them.

## Red → green

- Red commit: `359e3645ac41506e563c19dfbd49983fb4ec9638` — adds E2E that
opens `#typeMenu` and asserts each new `data-type-id="10|11|15"`
checkbox renders with the exact label. Fails on assertion (DOM selectors
return null) against the pre-fix `typeMap`.
- Green commit: `f484e8cb88659b14fed7aa7fefcdfb3f0eb6c186` — single-line
literal extension; test goes green.

## E2E assertion added

`test-e2e-playwright.js:576` — `Packets type filter includes
Multipart/Control/Raw Custom (#1798)` (asserts the three new
`data-type-id` checkboxes render with their exact labels in the rendered
Packets-page checklist DOM).

## Files touched

- `public/packets.js` — extend `typeMap` literal
- `test-e2e-playwright.js` — new E2E test asserting the three checkboxes
render

## Browser verified

E2E test scrapes the rendered Packets-page DOM via Playwright; CI runs
it against the local Go server fixture in the `e2e-test` job.

Fixes #1798

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-28 01:23:47 -07:00
3efa37c46c feat(server): complete the #672 4-axis repeater usefulness score (#1762)
Adds Coverage (harmonic reach) + Redundancy (Tarjan articulation) axes +
composite & grade. Closes #672.
**TDD note (BLOCKER-1):** Community PR delivered as a single squashed
commit, so there is no separate pre-fix failing-test commit — please
accept as a community-PR exemption. The tests are *gating*, not just
thorough: each axis test pins a specific topology outcome (coverage on
line/star/disconnected/weight-sensitive; redundancy
online/triangle/star/bridged-cliques), and an end-to-end `/api/nodes`
surface test drives the whole pipeline and asserts the composite
diverges from the Traffic axis. Inverting the `1/weight` distance,
dropping the NaN/Inf reject, removing the `redundancyMinWeight` floor,
or aliasing `usefulness_score` back onto `traffic_share_score` each
break a specific assertion. The axis functions are pure (no hidden
state), so the suite fully characterises the behavior without the red
anchor.

Co-authored-by: Waydroid Builder <build@waydroid.local>
2026-06-27 22:03:05 -07:00
5e096147e5 fix(#1800): add routed_through filter, clarify path, fix hex lexer error (#1801)
Fixes #1800.

## Three changes

1. **`routed_through` field** — new `FIELDS` entry. Resolves against
`packet.resolved_path` (handles both the JSON-string form from
`/api/packets/by-id` and the already-parsed-array form from
`/api/packets`). Returns a space-joined lower-case hex string so
`contains` / `starts_with` / `==` work the same way they already do for
`hash`.
2. **`path` desc clarified + `path_prefixes` alias** — `path` desc now
reads `Hop path as 1-byte prefixes joined (e.g. a3→7f). For pubkey
search use routed_through.` `path_prefixes` is added as a
discoverability alias and resolves to the same value.
3. **Lexer hex-token error** — when the number/duration tokenizer hits
an unknown unit AND the slice (extended forward through any remaining
`[0-9a-fA-F]`) is pure hex of length ≥ 4, the lexer now returns:

   ```
Hex value must be quoted: try 'field == "<hex>"' or use the
starts_with/contains operator
   ```

instead of `Invalid duration unit 'f' at position N (expected
s/m/h/d/w)`. The duration-unit error is preserved for non-hex cases
(`age < 5x` still errors with the original message).

## TDD

Red commit `e44ac00a` adds 7 assertions that fail with the unmodified
code (proven by stashing the impl and re-running — output: `7 failed`).
Green commit `7b623721` makes them pass.

Tests added in `test-packet-filter.js` (`#1800: …`):
- `routed_through starts_with "2f0b00"` matches packet with JSON-string
`resolved_path`
- same, against array-form `resolved_path` (handles real `/api/packets`
shape)
- `routed_through contains "<full-pubkey>"` matches
- `routed_through contains "2f0b"` matches
- `routed_through starts_with "deadbe"` does NOT match
- `path 2f0b001247a047ca` → error contains `Hex value must be quoted`
- regression: `path contains "a3"` still matches `path_json=["a3","7f"]`
- `routed_through` listed in `FIELDS`
- `path_prefixes` alias resolves like `path`

`node test-packet-filter.js` → `=== Results: 92 passed, 0 failed ===`
Sibling JS tests (`test-packet-filter-ux.js`,
`test-packet-filter-time.js`, in-file self-tests) all green.

## Browser verification

Browser tool was unavailable this session, so I executed
`public/packet-filter.js` in a Node VM context (identical execution) and
exercised it against a live `/api/packets?limit=200` response from
staging:

- `routed_through starts_with "41b1"` returned 1 matching packet (whose
`resolved_path[0]` is
`41b1eabc3c6e88997242051ee53fa5840761dff02ac5f6d9904f23985395ec31`)
- `routed_through starts_with "bccf91"` matched a packet with that hop
- `routed_through starts_with "deadbe"` matched nothing (correct)
- `PF.suggest('routed_t', 8)` returned `['routed_through']`
- `PF.suggest('route', 5)` returned `['route', 'routed_through']`
- `PF.compile('path 2f0b001247a047ca').error` is verbatim: `Hex value
must be quoted: try 'field == "<hex>"' or use the starts_with/contains
operator`
- `PF.compile('age < 5x').error` is still `Invalid duration unit 'x' at
position 7 (expected s/m/h/d/w)` — duration-unit message preserved for
non-hex cases.

## Out of scope (per issue)

- No server-side filter pushdown.
- No operator-list changes.
- No `resolved_path` changes — it already ships on `/api/packets`,
`/api/packets/by-id`, `/api/live`.

---------

Co-authored-by: meshcore-bot <meshcore-bot@users.noreply.github.com>
2026-06-27 21:02:37 -07:00
d5ceb27334 fix(#1792): decode GRP_DATA channel hash + inner data_type/len/blob in details cell (#1796)
Red commit: 11d8c51e8a (CI:
https://github.com/Kpa-clawbot/CoreScope/actions?query=branch%3Afix%2Fissue-1792)

## What

Render GRP_DATA (PAYLOAD_TYPE 0x06) channel hash + (when decrypted)
inner `data_type` / `data_len` / blob hex in the packets table "details"
cell, mirroring the existing GRP_TXT branch in `public/packets.js
getDetailPreview()`.

Previously these packets showed only the opaque payload bytes —
operators had no way to see channel distribution or recognize specific
data_type values at a glance.

## How

`public/packets.js` — one new branch right after the GRP_TXT branch:

- Always renders `Ch 0xNN` (from `channelHashHex` or computed from
`channelHash`).
- `decryptionStatus = no_key | decryption_failed` → status label, same
shape as GRP_TXT.
- `decryptionStatus = decrypted` → adds `type=0xNNNN len=N` plus a
`<code>` block with the blob hex, truncated to 32 hex chars (16 bytes)
with `…` when longer.

Inner layout per `firmware/src/helpers/BaseChatMesh.cpp:382-385` (uint16
LE data_type, u8 data_len, blob).

## TDD

- **Red commit** `11d8c51e`: 4 new assertion-shaped failures in
`test-packets.js` getDetailPreview suite (`packets.js tests: 70 passed,
17 failed` → +4 vs baseline 13).
- **Green commit** `3466ed09`: 17 → 13 failed (baseline
emoji-vs-Phosphor drift, unrelated). All 4 GRP_DATA assertions pass.

## Optional check #2 — backend JSON parity

Confirmed `cmd/server/decoder.go` and `cmd/ingestor/decoder.go` use
identical JSON tags (`channelHashHex`, `decryptionStatus`, `dataType`,
`dataLen`, `decryptedBlob`). No backend change needed — server emits
envelope-only fields, ingestor adds the inner fields when a channel key
matches; the frontend handles both shapes.

## Scope

- 2 files: `public/packets.js` (+17 lines), `test-packets.js` (+48 lines
test).
- No public API change. No CSS. No migration.

Preflight clean (PII, branch scope, red commit, CSS, LIKE-on-JSON,
sync/async migration, XSS sinks — all pass).

Fixes #1792

---------

Co-authored-by: Kpa-clawbot <bot@example.com>
Co-authored-by: meshcore-bot <bot@meshcore.local>
Co-authored-by: Kpa-clawbot <bot@openclaw.local>
2026-06-28 02:17:27 +00:00
770749a8ce fix(#1791): add 'Group Data' (payload_type=6) to packets type filter (#1797)
Fixes #1791.

## What

Adds `6:'Group Data'` to the `typeMap` in `public/packets.js` so the
Packets-view "message type" multi-select shows a Group Data checkbox.
The filter pipeline already keys by integer payload_type, so this just
registers the missing option. Also aligns the Live-view legend label in
`public/live.js` to "Group Data" for cross-view consistency.

## Why

Triage (in #1791) confirmed payload_type=6 (GRP_DATA) was the only
ordinary type omitted from the static `typeMap`. `packet-filter.js`,
`live.js`, `app.js`, and `map.js` all already know about it — only the
Packets-page checklist was missing it.

## Test (TDD red → green)

Branch history (4 production commits before round-1 review):

- `19ed5beb` — **test-only red commit**: adds Playwright E2E that opens
the type-filter menu, asserts a `data-type-id="6"` checkbox labeled
"Group Data" exists, selects it, and asserts every visible row's type
badge reads "Group Data". Also seeds one GRP_DATA packet into the CI
fixture (`.github/workflows/deploy.yml`) so the filter has a row to
match.
- `823a7d8d` — adds the one-line `typeMap` entry. First CI run on this
commit failed on an unrelated test (not the #1791 assertion); the #1791
test ran and passed.
- `eec2428` — fixture cleanup: `path_json=[]`/`resolved_path=[]` so the
seeded GRP_DATA hop-row count matches the raw_hex `path_len=0`. CI
green.
- `8f85f5f` — labels the type-6 entry "Group Data" (was briefly "Grp
Data"). CI green.

E2E assertion: `test-e2e-playwright.js` block `Packets type filter
includes Group Data (#1791)`.

## Round-1 review follow-ups

- `e3651c99` — `public/live.js` legend: `'Grp Data'` → `'Group Data'`.
- `4475c2f7` — test cleanup hardening: error string aligned to
assertion, duplicated selector extracted, regex tightened to strict
equality, `#typeMenu` explicitly closed, `meshcore-time-window`
localStorage key cleared, page reloaded so the in-memory `selectedTypes`
Set is reset.
- `b90bc33f` — `.github/workflows/deploy.yml`: drop self-referential
`#1797` citation from fixture comment, switch synthetic fixture id from
`-1` to `-1000000` sentinel with explanatory comment.

## Scope

Single-line typeMap registration plus its E2E test scaffolding, fixture
seed, and the live.js label alignment.

---------

Co-authored-by: clawbot <bot@openclaw.dev>
Co-authored-by: meshcore-bot <bot@meshcore.local>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-27 19:09:18 -07:00
17654dd090 docs(api): document per-node usefulness metrics in OpenAPI (#1769)
Documented Node schema (the four #672 usefulness axes + composite + A-F
grade + relay fields) and response schemas on the node endpoints.
Documentation-only; no behaviour change. Pairs with #1762 (documents the
metrics it adds).

Co-authored-by: Waydroid Builder <build@waydroid.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 15:01:05 -07:00
Kevin CaiandGitHub 1adb0116b2 fix: don't re-render node dots when scrubbing the Live timeline (#1754)
## What

Scrubbing the Live page timeline no longer re-renders all node dots.

## Why

`vcrReplayFromTs()` ran `clearNodeMarkers()` (wiping `nodesLayer` and
`nodeMarkers`) and then `loadNodes()` rebuilt every marker from scratch.
A single scrub click destroyed and recreated the entire node layer;
visible flicker plus unnecessary DOM work (even though `addNodeMarker()`
already no-ops nodes that still exist).

## How

- `vcrReplayFromTs()` now clears only the transient animation/path
layers.
- The time-scoped branch of `loadNodes()` reconciles against the
existing markers: removes only nodes absent at the target time, adds
genuinely new ones, leaves shared dots untouched.

## Testing

- `test-live.js`: 95/95 pass
- `node --check public/live.js` clean
2026-06-27 15:00:18 -07:00
Michael J. ArcanandGitHub fc26fb6b3a feat(#1751): show transported region scopes in repeater sidebar (#1752)
Closes #1751.
2026-06-27 14:59:50 -07:00
c03f2ebbcc live: cap animation-canvas DPR at 1.5 and redraw at ~60fps (#1737)
The live-map animation overlay re-clears and re-draws its full backing
store
every animation frame. Two unbounded multipliers make that expensive:

1. devicePixelRatio is uncapped in updateAnimCanvas(). The canvas is
already
~1.4x the screen area (20% pad per side), so at DPR 2-3 it allocates and
fills 5-12x the screen's pixels per frame. Cap at 1.5 — lines stay
crisp,
   per-frame fill cost drops up to ~4x on hi-DPI displays.

2. renderAnimations() reschedules via rAF with no rate limit, so on
120/144Hz
displays it does 2-2.4x the work for no visible gain. Add a ~60fps
guard.
Progress is time-based (tickDt, itself capped at 32ms), so skipping
frames
preserves motion exactly. Paused frames fall through to the existing
sleep.

No behavior change on a standard 60Hz / 1x-DPI display. Existing
animation
tests (test-live-dt-cap-1524, test-live-anims) unaffected.

Co-authored-by: Michael <claude@michael.arcan.de>
Co-authored-by: efiten <erwin.fiten@gmail.com>
2026-06-27 14:59:31 -07:00
9757178aad fix(#1789): add Firmware + Client columns to observers table (#1790)
## Summary

Adds **Firmware** and **Client** columns to the observers table
(`#/observers`). Both values already come back from `/api/observers`
(`firmware`, `client_version`) — they were just never rendered. Fleet
operators have been asking to sort/scan firmware versions to coordinate
upgrades.

Closes #1789.

## Changes

- `public/observers.js`
- Two new `<th data-priority="4" data-sort-key="...">` headers
(Firmware, Client). Priority 4 matches Clock Offset / Uptime so
`TableResponsive` hides them first on narrow viewports.
- Two new `<td class="mono">` cells with
`data-value="${escapeHtml(raw)}"` for sort and the rendered text
escape-wrapped.
- `truncateBuildSuffix()` helper trims the long `" Build: ..."` tail
from firmware in the displayed text; the full string is preserved in
`title=` for hover.
- `test-issue-1789-observer-firmware-cols.js` — TDD red→green
static-source regression test (same pattern as
`test-observers-headings.js`).
- `test-observers-headings.js` — updated expected heading list with the
two new columns (existing #1039 invariant test).
- `test-all.sh` — wires the new test into CI.

## TDD evidence

- Red: `c6c4e594c1084d664730666ba069871ac6d9755c` — test commit fails on
assertions (not import errors): 5/6 cases fail because the
headers/cells/title attr don't yet exist; the column-count invariant
still passes because both thead and tbody are unmodified.
- Green: `02a0246a185950c903828ac1225d6795f5a3b2f4` — implementation;
all 6 cases pass.

## Browser verified

To be verified post-deploy on staging
(`http://analyzer-stg.00id.net/#/observers`). No backend changes —
purely additive frontend render of fields that are already on the wire.

## Perf

No new API calls, no extra fetches, two extra template-literal cells per
observer row (~10s of observers in prod). O(n) render unchanged.

---------

Co-authored-by: clawbot <bot@meshcore.local>
2026-06-27 14:54:58 -07:00
f0763aecce fix(#1726): clear stale "varies" hash size once a node settles (#1788)
Fixes #1726.

## Problem

A MeshCore v1.16.0 repeater configured for 2-byte path hashes
(`path.hash.mode=1`) — e.g. `36f6c7c7…` (`DK_3400_RAK_TEST`) — kept
showing as **"varies"** / mixed 1-byte + 2-byte for the full 7-day
advert window.

Per the live data in the issue triage: of the node's ~20 recent adverts,
exactly **one** (2026-06-09, across 15 distinct observer paths) was a
genuine 1-byte flood advert; every other advert was 2-byte. The
flip-flop heuristic in `computeNodeHashSizeInfo` weighs that stale
advert equally with recent ones, so an operator who flips
`path.hash.mode` mid-flight (or a single old 1-byte advert) stays
flagged for the full window with no way to signal "the config is settled
now."

## Fix

Two coupled changes in `cmd/server/store.go` `computeNodeHashSizeInfo`:

1. **Chronological ordering.** `byPayloadType[4]` iterates in insertion
order, not timestamp order, so `HashSize = Seq[last]` could pick the
wrong advert under out-of-order MQTT ingest or chunked cold-load (the
"carmack" concern from triage). We now collect `(FirstSeen, size)` pairs
and **stable-sort by `FirstSeen`**; ties keep insertion order,
preserving prior behavior when timestamps are equal.

2. **Recency decay.** After `transitions >= 2` raises the flip-flop
flag, clear it when the most recent `hashSizeRecentAgreeCount` (= **3**)
non-zero-hop adverts all agree on a single size. A node still flapping
(recent adverts disagree) stays flagged. `3` mirrors the existing
≥3-observation threshold used to raise the flag.

## Policy note

Triage marked this **needs-operator-input** because the decay is a
behavior/policy change. This PR implements the rule the triage proposed
("if the last 3 adverts agree, clear inconsistent"), which matches the
reporter's stated expectation. Happy to adjust the threshold or gate it
differently per your call.

## Tests

`cmd/server/issue1726_hash_decay_test.go`:
- `TestIssue1726_SettledNodeNotInconsistent` — reporter's case
(`[2,1,2,2,2]` within window) → `Inconsistent=false`, `HashSize=2`.
- `TestIssue1726_HashSizeUsesChronologicallyLatest` — out-of-order
insertion still reports the chronologically-latest size.
- `TestIssue1726_ActiveFlapperStaysInconsistent` — a node whose recent
adverts disagree stays flagged.

Existing flip-flop / hash-collision tests unchanged and green; full
`cmd/server` package suite passes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Erwin Fiten <e.fiten@opteco.be>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 05:05:46 -07:00
d437958474 fix(map): pin APC (Napa) and STS (Sonoma) observers (#1786) (#1787)
Fixes the map-coordinate gap in #1786.

## Problem

Observers tagged with IATA code **APC** (Napa County) or **STS**
(Charles M. Schulz–Sonoma County) render with no location and never pin
on the map.

## Root cause

`iataCoords` in `cmd/server/routes.go` is a hardcoded `IATA -> lat/lon`
lookup used purely for placing observer/region markers on the map. It
had no entry for APC or STS, so those observers had no coordinates to
render with.

This is **display-only**. Ingestion is not gated on these codes:
`IsObserverIATAAllowed` (`cmd/ingestor/config.go`) short-circuits to
`true` when the observer IATA whitelist is empty — which is the staging
configuration. The reporter''s "packets disappear entirely" symptom is
therefore **not** explained by this code path (likely an upstream
`meshcoretomqtt`/broker topic issue; needs operator `mosquitto_sub`
confirmation per triage).

## Fix

- Add `APC {38.2132, -122.2807}` and `STS {38.509, -122.8128}` to
`iataCoords`, matching the airports'' published coordinates.
- Add a regression test (`TestIataCoordsIncludesNapaAndSonoma`)
asserting both are present with the expected coordinates.

## Verification

- `go test ./cmd/server/` — full package passes (`ok`).
- `go vet ./cmd/server/` — clean.

## Scope note

Checked the repo for other statically-enumerable region codes
(`config.example.json` regions: SJC/SFO/OAK/MRY) — all already covered.
The broader "are other in-use codes missing" question can only be
answered against the live `cfg.Regions` + `db.GetDistinctIATAs()` set,
which is operational, not in-tree.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Erwin Fiten <e.fiten@opteco.be>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 02:44:28 -07:00
55e203a9c8 fix(nav): surface Coverage route in mobile nav when enabled (#1783)
Fixes #1782.

## Problem

When `clientRxCoverage` is enabled, the **Coverage** route
(`#/rx-coverage`) is reachable from the desktop top-nav but
**unreachable on mobile** — neither the bottom-nav "More" sheet (phones,
≤768px) nor the edge-swipe drawer (touch tablets, >768px) lists it.

## Root cause

`public/roles.js` injects the Coverage link **only into the desktop
top-nav** (`.nav-links`), gated on `window.MC_CLIENT_RX_COVERAGE`. The
two mobile nav surfaces build their long-tail lists from **independent
hardcoded arrays** that omitted `rx-coverage`:

- `public/bottom-nav.js` → `MORE_ROUTES`
- `public/nav-drawer.js` → `ROUTES`

Both even carry `!! MANUAL SYNC REQUIRED !!` comments. Because the link
is injected into the DOM (not these arrays) and is config-gated, it
never reached mobile.

## Fix

Both surfaces now insert the Coverage entry **right after Analytics**
(matching the desktop top-nav insertion point) when
`window.MC_CLIENT_RX_COVERAGE` is true. The check is evaluated at **lazy
build time** (first sheet/drawer open), by which point `MeshConfigReady`
has resolved the flag. Default-off behaviour is unchanged, so the
default nav still matches the existing nav-overflow tests.

## Testing

Adds `test-rx-coverage-mobile-nav-e2e.js`, which:

- skips cleanly when Chromium is unavailable (`CHROMIUM_REQUIRE=1` makes
it a hard fail) or when `clientRxCoverage` is disabled — mirroring
`test-node-reach-coverage-e2e.js`;
- at 360px asserts Coverage is present in the bottom-nav More sheet,
ordered after Analytics, and that tapping it navigates to
`#/rx-coverage`;
- at 1024px asserts Coverage is present in the edge-swipe drawer,
ordered after Analytics.

Verified locally against a server built from this branch with
`clientRxCoverage` enabled (migrated `test-fixtures/e2e-fixture.db`):

- new test: **3/3 pass**; reverting the two source files makes it **fail
3/3** (true regression test);
- existing nav e2e suites still green: `test-nav-drawer-1064-e2e.js`
(11/11), `test-bottom-nav-1061-e2e.js` (31/31),
`test-nav-more-floor-1139-e2e.js` (10/10).

## Notes

- No perf impact: the route list is built once, lazily, on first
sheet/drawer open.
- The hardcoded `MORE_ROUTES` / `ROUTES` arrays remain the source of
truth for the always-on routes; this only conditionally appends the one
opt-in route, consistent with how `roles.js` already gates the desktop
link.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:19:45 -07:00
5c0de8fb41 feat(live): optional "Multibyte only" view filter (#1780) (#1781)
Closes #1780.

## What

Adds an opt-in **"Multibyte only"** toggle to the live map controls.
When ON, packets whose path hash size is `< 2` bytes (single-byte, or
unresolvable) are excluded from the entire live view — feed, map
polylines/rain, and the packet counter — in both LIVE and REPLAY modes.

- **Default OFF** — no behavior change for existing users.
- Persisted in `localStorage` under `live-multibyte-only`.
- Distinct from the existing global "hide 1-byte path hops" toggle: that
filters individual hops within a path at every render site; this filters
whole packets, on the live view only. They share no state.

## How

- **`public/hop-filter.js`** — new pure, dependency-free classifier
`MC_packetHashSize(rawHex, routeType)` returning `1|2|3`, or `0` when
unresolvable. Reads the path-length byte from `raw_hex` (`(pathByte >>
6) + 1`), offset `5` for transport routes (route_type 0/3) else `1` —
mirroring the existing `getPathLenOffset`/`computeBreakdownRanges` logic
in `app.js`. Lives next to the existing `hopByteLen`/`MC_*` family;
`app.js` is untouched (no duplication of the byte math).
- **`public/live.js`** — `groupIsMultibyte(packets)` consumes that
helper; applied at two render-time sites: the top of `renderPacketTree`
(above the counter increment, so the counter reflects multibyte-only)
and inside the `rebuildFeedList` group loop (so toggling re-filters the
buffered feed). Toggle markup + change handler mirror the existing
`liveFavoritesToggle` pattern.

## Why read from `raw_hex` and not the path hops

The hash size is a property of the whole packet and is present even for
zero-hop packets (where there are no hops to inspect), so reading the
path-length byte is correct in all cases. Unresolvable size is treated
as single-byte (excluded when ON) — we only show packets we can
positively confirm are multibyte.

## Performance (hot path)

The filter runs in the packet-render hot path, so: classification is
**O(1) per packet group** — it reads the first resolvable observation's
`raw_hex` (a short hex string, single `parseInt` of one byte) and
short-circuits. No per-packet API calls, no allocation in the loop, no
added O(n²). When the toggle is OFF (default) the check is a single
boolean guard and does nothing else. The buffered-feed re-filter reuses
the existing `rebuildFeedList` pass — no extra traversal.

## Tests

- **Unit** (`test-live-multibyte-filter.js`, 9 cases):
single/2-byte/3-byte classification, transport-route offset,
missing/short/garbage `raw_hex` → 0, whitespace tolerance.
- **E2E** (`test-live-multibyte-only-e2e.js`, Playwright): toggle
present and defaults OFF; ON hides a single-byte packet while a
multibyte one renders; OFF restores it; setting persists across reload.
Registered in the CI live-E2E block in `deploy.yml`.

## Docs

User-guide entry added in `docs/user-guide/live.md`.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:56:08 -07:00
57956712e7 fix(#1768): Relay Airtime Share uses LoRa Time-on-Air (preamble-aware) — partial fix (#1776)
Partial fix for #1768 — Relay Airtime Share now uses closed-form LoRa
Time-on-Air instead of a payload-bytes-only proxy, removing the ~3-4×
bias against small frames (preamble + fixed-symbol intercept).

cross-stack: justified — backend score formula needs a frontend caption
change (`public/analytics.js` dumbbell preset banner + tooltip) so
operators can interpret the assumed PHY block. Both move together or the
metric is misleading.

## Red commit

`8da57062` — failing test asserts ToA-based score (~83.48 % ADVERT share
on the locked acceptance fixture) instead of the byte proxy's 95.24 %.
`internal/lora.TimeOnAir` was a zero-returning stub at the red commit;
tests failed with assertion errors, not build errors.

## Green commit

`dd402edd` — implements `lora.TimeOnAir` (Semtech AN1200.13 / SX126x
§6.1.4 closed form, cross-checked against RadioLib), wires `score =
TimeOnAir(payloadBytes, preset) × distinctRelays` in
`cmd/server/relay_airtime_share.go`, surfaces the preset in the JSON
response and analytics caption.

## Config (per AGENTS Config Documentation Rule)

New keys under existing `analytics` block:

```json
"loraPreset": { "freq": 869600000, "bw": 62.5, "sf": 8, "cr": 5 }
```

Defaults match the deployment's actual `get radio` (869.6 MHz / BW 62.5
kHz / SF 8 / CR 4/5). `CRC=1`, `IH=0`, `DE = (T_sym ≥ 16 ms)`, and the
SF-dependent preamble (32 for SF≤8 else 16, per firmware
`preambleLengthForSF` / MeshCore PR #1954) are firmware-fixed constants
in `internal/lora/toa.go` and intentionally NOT surfaced as config (per
re-triage).

## Scope

In-scope files (6):

- `internal/lora/toa.go` (new package — closed-form ToA)
- `internal/lora/toa_test.go` (table-driven preset tests)
- `cmd/server/relay_airtime_share.go` (wire ToA into score)
- `cmd/server/relay_airtime_share_test.go` (recomputed expected values)
- `cmd/server/config.go` + `config.example.json` (preset config keys)
- `public/analytics.js` (preset caption on dumbbell chart + tooltip)

Plus `cmd/server/go.mod` (replace directive for the new internal
module).

## Deferred to v2 (separate issues per re-triage)

- Per-observation SF/BW + radio-settings-aware dedup (blocked: ingestor
stores SNR/RSSI only, no SF/BW on observations).
- CR-per-hop dual-point sensitivity band (CR scales only the payload
symbol term `(CR+4)`, not the preamble/header; second-order accuracy
gain).
- Cross-SF bridge accounting.

## Tests

```
cd internal/lora && go test ./...           → PASS
cd cmd/server && go test -run RelayAirtime  → PASS
```

## Preflight overrides

- `check-branch-clean` (cross-stack): justified above — score formula
change requires matching caption update; both files trace to the same
issue.

---------

Co-authored-by: kpa-clawbot <kpa-clawbot@users.noreply.github.com>
Co-authored-by: Kpa-clawbot <bot@openclaw.local>
Co-authored-by: bot <bot@meshcore>
2026-06-22 10:07:49 -07:00
b3b8bec5ec feat(filter): expose payload.destHash + payload.srcHash in filter autocomplete (#1774) (#1775)
Resolves #1774.

## What

Adds `payload.destHash` and `payload.srcHash` to the filter autocomplete
suggestions array in `public/packet-filter.js`.

## Why

The decoder already emits `destHash` and `srcHash` JSON keys for `REQ` /
`RESPONSE` / `TXT_MSG` / `PATH` / `ANON_REQ` packets (see
`cmd/ingestor/decoder.go`), and the generic `payload.*` accessor in the
filter language (`packet-filter.js:296-308`) already evaluates these
fields correctly — i.e. `payload.destHash == "2f"` has always worked.
The gap was purely autocomplete + docs: the two names were missing from
the `FIELDS` (SUGGESTIONS) array, so operators discovering filters via
the suggestion popup couldn't find them.

Per the triage on #1774, the canonical names match the decoder's
serialization (`destHash` / `srcHash`), not the reporter's proposed
`dest` / `src` — so the filter token agrees with the raw-JSON view and
the packet detail's `Dest Hash (1B)` label.

## Tests

- Red commit (`test-packet-filter.js`): asserts
`filter('payload.destHash == "2f"')` matches a fake REQ packet AND that
`FIELDS` contains entries named `payload.destHash` / `payload.srcHash`.
Fails on the FIELDS assertion only (filter() already works).
- Green commit: adds the two entries. All 83 packet-filter tests pass.

## Scope

Single-file change in `public/packet-filter.js` (+2 lines) + 23 lines of
test coverage. No decoder changes, no backend changes, no API signature
changes.

Fixes #1774.

---------

Co-authored-by: clawbot <bot@corescope>
2026-06-21 20:52:09 -07:00
72d451221c tone down naive-clock observer notice (#1478 follow-up) (#1759)
Red commit: d33a43c7 (CI run: will fail on assertion — 8 tests assert
new tone)

## Summary

Tones down the naive-clock observer banner from a big yellow alert card
to a small, neutral inline notice. Operators and firmware devs found the
original disproportionately scary.

**Before:** Yellow-bordered card, ⚠️ emoji, bold "Naive observer clock —
timing is being clamped" heading, shame words ("muddies
propagation-delay analytics"), multi-line fix instructions.

**After:** Single muted-color line with `role="note"`, no emoji, no
alarm styling. Fix guidance collapsed into a `<details>` expander.

- No backend behavior changes — clamping logic is untouched
- Observer list chip (`observers.js`) left as-is

Refs #1478

---------

Co-authored-by: Kpa-clawbot <bot@openclaw.local>
2026-06-20 18:43:08 -07:00
735d9eb516 fix(#1715): dark-theme role swatches via per-theme CSS tokens (#1757)
## Summary

Dark-theme variants of the neighbor-graph role swatches (`#ngRoleChecks`
labels on `/analytics?tab=neighbor-graph`) still failed WCAG AA after
#1720's light-theme fix because the swatches used inline
`style="color:#..."` from `customize.js` `DEFAULTS.nodeColors`
(palette-700) — bypassing the theme tokens entirely.

Measured before:

| Role | Color | vs `#1a1a2e` (dark) |
|---|---|---|
| repeater | `#dc2626` | 3.53:1  |
| companion | `#2563eb` | 3.30:1  |
| observer | `#8b5cf6` | 4.02:1  |

## Fix

- Defines `--role-{repeater,companion,room,sensor,observer}` in `:root`
(palette-700, ≥4.5:1 on white) and overrides them in both dark blocks
(`[data-theme="dark"]` + the `@media (prefers-color-scheme: dark)`
mirror) with palette-400/500 shades that clear AA on `#1a1a2e`.
- Refactors the neighbor-graph swatch DOM in `public/analytics.js` from
inline `style="color:${hex}"` to class-based `<span class="role-swatch
role-swatch--{role}">`, with matching CSS rules that read the tokens.
- Removes all 5 `#1715` entries from `tests/a11y-allowlist.yaml` per the
issue's acceptance criteria.

After:

| Role | Light (vs `#fff`) | Dark (vs `#1a1a2e`) |
|---|---|---|
| repeater | `#dc2626` 4.83:1 | `#ef4444` 4.53:1 |
| companion | `#2563eb` 5.17:1 | `#3b82f6` 4.64:1 |
| room | `#15803d` 5.02:1 | `#16a34a` 5.18:1 |
| sensor | `#b45309` 5.02:1 | `#d97706` 5.35:1 |
| observer | `#7c3aed` 5.70:1 | `#a78bfa` 6.27:1 |

## Tests

- `test-a11y-1715-dark-role-swatches.js` — CSS-driven WCAG AA probes for
the 5 per-theme `--role-*` tokens plus markup invariants (no inline
color span; class names present in `#ngRoleChecks` block).
- Red commit: `a09ec21c` — fails on assertion with 12
below-threshold/markup probes.
- Green commit: `f87dcd64` — all probes PASS.
- `tests/a11y-allowlist.yaml` shed all 5 entries; the umbrella
`test-a11y-axe-1668.js` (CI) is now the live-browser net for those
cells.

## Preflight overrides

- `check-xss-sinks.sh` flags `public/analytics.js:2502` (label
"observer" appears in an `innerHTML=\`tpl\`` line). The flagged token is
a hardcoded literal string — no user-controlled data flows into that
template. No template content changed in this PR; the flag is
preexisting noise from the heuristic scan and the gate ultimately marks
 pass.

Fixes #1715

---------

Co-authored-by: clawbot <clawbot@kpa.local>
Co-authored-by: clawbot <bot@example.com>
2026-06-20 16:15:58 -07:00
e465e1c6c6 perf(#1740): replace idx_tx_last_seen with partial index WHERE last_seen=0 (#1756)
Fixes #1740.

## What

Replace the full `idx_tx_last_seen` with a partial index `WHERE
last_seen=0`. Two ordered, sequential migrations in
`internal/dbschema/dbschema.go::ensureTransmissionsLastSeenColumn`:

- **(a)** `CREATE INDEX IF NOT EXISTS idx_tx_last_seen_zero ON
transmissions(id) WHERE last_seen=0`
- **(b)** `DROP INDEX IF EXISTS idx_tx_last_seen` — gated after (a)
succeeds (sequential `Exec`; (b) never runs if (a) errors)

## Why

The only consumer is `chunkedTxLastSeenBackfill`'s `WHERE last_seen=0`
scan + `MAX(id)` lookup. The full index covers ALL rows including the
long tail where `last_seen != 0` after backfill converges (71K+ rows in
prod per carmack's #1740 note). The partial index degenerates to ~the
count of un-backfilled rows (0 in steady state, bounded by ingest rate
during ops) and stops competing for page cache.

## Migration cost

Both migrations are sync and annotated `PREFLIGHT: async=false`:
- (a) `CREATE INDEX` on partial subset `WHERE last_seen=0` is bounded by
un-backfilled rows — a small superset of the inflight ingest window, not
a full table scan.
- (b) `DROP INDEX` is a metadata-only schema rewrite in SQLite — no row
scan at any size.

`dbschema/` has no access to `Store.RunAsyncMigration` (that helper
lives in `cmd/ingestor/` and is the wrong layer for the schema
source-of-truth per #1321), so sync is the only path here regardless.

## TDD

- **RED** `a529f0f4`: `EXPLAIN QUERY PLAN` test asserting the backfill
`MAX(id) WHERE last_seen=0` query uses `idx_tx_last_seen_zero` + a
second test asserting the legacy `idx_tx_last_seen` is dropped
post-Apply. Both failed on assertion (planner picked the full index;
partial index didn't exist).
- **GREEN** `208fde8a`: add migrations (a) and (b). Both tests pass.

## Files touched

- `internal/dbschema/dbschema.go` — swap the index
- `internal/dbschema/dbschema_test.go` — `EXPLAIN QUERY PLAN` pin + DROP
assertion
- `cmd/ingestor/db.go` — comment refresh only (idx_tx_last_seen →
idx_tx_last_seen_zero)

## Acceptance

-  New partial index created
-  Old full index dropped via gated migration (sequential, order
preserved)
-  Query plan test asserts partial-index usage
-  Existing migration tests still green (`internal/dbschema`,
`cmd/ingestor` TestIssue1690 + applySchema)

---------

Co-authored-by: clawbot <bot@openclaw>
2026-06-20 15:45:28 -07:00
db5520f70f fix(nodes): copy URL buttons produce malformed origin#frag URLs (#1753) (#1755)
## Problem

The **Copy URL** and **Copy short URL** buttons on the node detail page
produced URLs like:

```
https://analyzer.00id.net#/nodes/abcdef…
```

The `/` between the authority and the fragment is missing. RFC 3986
allows that form, but several mobile browsers (and some link-detection
heuristics) reject or mis-parse it.

## Fix

Three sites in `public/nodes.js` concatenated `location.origin` with a
literal that started with `'#/'`. Prepend `/`:

- `public/nodes.js:772` — full Copy URL (full pubkey)
- `public/nodes.js:783` — Copy short URL (8-char prefix)
- `public/nodes.js:1580` — side-pane Copy URL

All three now build `https://analyzer.00id.net/#/nodes/…`, which every
browser accepts.

## Tests

`test-issue-1753-copy-url-slash.js` — extracts every `location.origin +
'<literal>'` site from `public/nodes.js` and asserts the literal starts
with `/`. Wired into `.github/workflows/deploy.yml`.

- **Red commit** `b4df2786` — test added; CI fails on assertion (3 of 4
cases) because the literals still start with `'#/'`.
- **Green commit** `2c59f7c0` — three literals fixed to `'/#/nodes/'`;
test passes (4/4).

## Verification

```
$ node test-issue-1753-copy-url-slash.js
issue-1753 copy-URL slash regression
   found at least 3 location.origin + literal sites in public/nodes.js
   public/nodes.js:772 literal starts with "/#/" (got "/#/nodes/")
   public/nodes.js:783 literal starts with "/#/" (got "/#/nodes/")
   public/nodes.js:1580 literal starts with "/#/" (got "/#/nodes/")
  4 passed, 0 failed
```

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ clean (all gates + warnings green).

Fixes #1753

---------

Co-authored-by: openclaw-bot <bot@openclaw.dev>
2026-06-20 11:44:00 -07:00
f780fe7d0b ci: bump Go test timeout 15m -> 20m (server + ingestor) (#1750)
## Problem

The server Go suite runs ~13–15m against a **15m** `go test -timeout`,
so on slower CI runners it intermittently hits `panic: test timed out
after 15m0s` (`cmd/server`, e.g. `db_test.go`) — false-red CI that a
plain rerun clears. Observed on PR #1728 (15m25s on the passing attempt
— right at the ceiling).

## Fix

Bump both `go test` invocations (`cmd/server` and `cmd/ingestor`) from
`-timeout 15m` to `-timeout 20m` for headroom. No test or application
code changes — CI workflow only.

## Verification

Workflow-only change; this PR's own CI is the confirmation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Erwin Fiten <e.fiten@opteco.be>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:37:58 -07:00
0765c2cc69 fix(#1718): drop prefix-tool a11y allowlist entries — subsumed by #1720 (#1736)
## Summary

PR #1720 (merged 2026-06-13) consolidated active button states onto the
shared `.btn-active-accent` rule that paints
`background: var(--accent-strong)` (`#2563eb`) +
`color: var(--text-on-accent)` (`#f9fafb`) = **4.95:1**, WCAG AA pass in
both themes. That subsumes the `#ptCheckBtn` / `#ptGenBtn`
color-contrast violations issue #1718 tracked, so the allowlist entries
are stale.

## Change

Drop the two `issue: 1718` entries from `tests/a11y-allowlist.yaml`:

```yaml
- route: '/analytics?tab=prefix-tool'
  selector: '#ptCheckBtn'
  rule: color-contrast
  issue: 1718
  expires_at: 2026-09-11
- route: '/analytics?tab=prefix-tool'
  selector: '#ptGenBtn'
  rule: color-contrast
  issue: 1718
  expires_at: 2026-09-11
```

No other tabs touched. `#1715` dark-theme work and other
`expires_at: 2026-09-11` entries are out of scope — separate issues,
separate PRs. No production CSS/JS modified (PR #1720 did the
substantive fix).

## Verification

The CI a11y gate (`test-a11y-axe-1668.js`) is the authoritative check.
It re-renders `/analytics?tab=prefix-tool` in dark+light × desktop+
mobile and asserts zero net violations against the trimmed allowlist.
With this PR the entries are gone — if PR #1720's fix were ever
reverted, the gate fails immediately with no allowlist masking it.

Local repro not attempted: sandbox chromium lacks the
`@axe-core/playwright` module (matches the documented limitation in
PR #1730 / PR #1723). CI is the source of truth for this gate.

## TDD note

Config-change exemption per workspace AGENTS.md:
- No test files modified.
- No production code modified.
- Config-only allowlist trim; CI must stay green without test edits.
- The gate itself is the test — dropping the allowlist entries IS the
  red→green transition (entries gone → axe runs unfiltered → must
  remain pass because #1720 fixed the root cause).

Mirrors the exact pattern accepted in PR #1722 (clock-health),
PR #1723 (subpaths), PR #1730 (nodes), and PR #1731 (rf-health) —
same allowlist-drop shape, same upstream PR #1720 fix.

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
— config-only change; PII grep on diff clean.

Fixes #1718.

Refs PR #1720, PR #1722, PR #1723, PR #1730, PR #1731.

Co-authored-by: Kpa-clawbot <bot@openclaw.local>
Co-authored-by: efiten <erwin.fiten@gmail.com>
2026-06-19 11:37:22 -07:00
aadc182d0c docs: correct README license label MIT → GPL-3.0-or-later (#1744) (#1746)
Fixes #1744

## Summary
README's License section advertised `MIT`, but the repo's `LICENSE` file
is GNU GPL v3 (`GNU GENERAL PUBLIC LICENSE / Version 3, 29 June 2007`).
Updated the README label to the SPDX identifier `GPL-3.0-or-later` so it
matches the actual license text.

## Change
- `README.md`: `MIT` → `GPL-3.0-or-later` (single line)

## TDD exemption
Pure docs change, no behavior. Per `AGENTS.md` TDD section: *"Pure docs
/ pure comments: no test required. Kent Beck gate still runs,
rubber-stamps with 'no behavior change' justification."* No production
code, no tests touched.

## Out of scope
The reporter also flagged two other items; per triage these are
explicitly out of scope here:
- Root-directory clutter — already tracked by #1385.
- Missing "About" page — needs its own issue; not addressed in this PR.

Co-authored-by: Kpa-clawbot <bot@openclaw.local>
Co-authored-by: efiten <erwin.fiten@gmail.com>
2026-06-19 11:37:19 -07:00
22fe929da2 feat: opt-in mobile client-RX coverage (crowdsourced RF reach) + /api/nodes/resolve (#1728)
Implements #1727.

## What this adds

**Mobile client-RX coverage** — an opt-in, crowdsourced RF-coverage
feature. A roaming MeshCore **companion** radio (driven by the
open-source [corescope-rx](https://github.com/efiten/corescope-rx) PWA,
GPLv3) reports which nodes it heard directly, tagged with the phone's
GPS and the packet's SNR/RSSI. CoreScope ingests these into a new
`client_receptions` table and renders per-node **hex coverage** on the
Reach page, plus a standalone **Coverage dashboard** (`#/rx-coverage`)
with a top-mobile-observers leaderboard.

Also includes **`GET /api/nodes/resolve?prefix=<hex>`** — a read-only
node-name lookup by pubkey prefix (`{name, pubkey, ambiguous}`), used by
the companion app for friendly names.

## Opt-in — default OFF (zero impact on existing deployments)

The whole feature is gated behind one config flag, **disabled by
default**:

```jsonc
"clientRxCoverage": { "enabled": false }
```

When disabled (the default): the ingestor writes **no**
`client_receptions`; the three coverage endpoints return a clean
**404**; the UI hides the Coverage nav link, the `#/rx-coverage` route,
and the Reach-page toggle. `/api/nodes/resolve` is always available (not
coverage-specific).

## How it works

```
companion ──BLE 0x88 (snr+rssi+raw)──▶ corescope-rx PWA ──▶ MQTT meshcore/client/{pubkey}/packets
                                                                      │
                                          ingestor (gated) ──▶ client_receptions (GPS + SNR + heard-key)
                                                                      │
              server: pure-Go hex grid ──▶ GeoJSON ──▶ Reach hex overlay + Coverage dashboard
```

- **Direct-only capture:** records only what the companion heard itself
and directly — a 0-hop advert's pubkey, or `path[last]` (last forwarder)
for FLOOD routes; ≥2-byte path-hash required. Upstream hops discarded.
- **No new deps:** hexbins are a pure-Go pointy-top grid over Web
Mercator (`cmd/server/hexgrid.go`) computed at query time
(`CGO_ENABLED=0` / `modernc.org/sqlite` friendly); frontend uses the
existing Leaflet.
- **Trust:** companion pubkey = identity; an EMQX ACL binds each client
to publish only to its own `meshcore/client/{pubkey}/packets` topic.
Payload contract in `docs/client-rx-coverage.md`.

## How to enable / try it

1. In `config.json`, set `"clientRxCoverage": { "enabled": true }` and
restart server + ingestor.
2. Point an EMQX (or any broker) listener so a client can publish to
`meshcore/client/<pubkey>/packets`; the ingestor already subscribes
under `meshcore/#`.
3. Run the [corescope-rx](https://github.com/efiten/corescope-rx) PWA on
an Android phone paired (BLE) to a MeshCore companion — it captures
heard nodes + GPS and publishes.
4. View results: per-node Reach page → toggle **coverage**, or the
**Coverage** dashboard at `#/rx-coverage`.

## What's where

- **Ingestor:** `cmd/ingestor/client_reception.go` (ingest), `db.go`
(`client_receptions` + `client_observers` schema), `main.go` (gated
dispatch), `config.go` (flag).
- **Server:** `cmd/server/rx_coverage.go` + `rx_dashboard.go`
(endpoints, self-guard 404 when off), `hexgrid.go` (pure-Go grid),
`node_resolve.go` (resolve), `routes.go` / `types.go` / `config.go`
(wiring + flag + `/api/config/client` field).
- **Frontend:** `public/rx-coverage.js` (dashboard),
`node-reach-coverage.js` + `.css` (overlay), `node-reach.js` (Reach
toggle, flag-gated), `roles.js` (reads the flag, hides nav when off).
- **Docs:** `docs/client-rx-coverage.md`.

## Testing

- Go: `cd cmd/server && go test ./...` and `cd cmd/ingestor && go test
./...` — green, including new gate tests (`coverage_gate_test.go` in
both: off → no rows / 404, on → works) and the rx-coverage / resolve /
hexgrid suites.
- JS: `node test-coverage-gate.js`, `node test-node-reach-coverage.js`
(wired into CI). The Playwright `test-node-reach-coverage-e2e.js` is
wired into the e2e job and **skips when `clientRxCoverage` is
disabled**, so it's safe under the default-off config.

## Notes for reviewers

- The four new routes are registered in
`cmd/server/openapi_known_gaps.json` (the existing OpenAPI-completeness
ratchet), matching how other not-yet-spec'd routes are tracked. Happy to
write full OpenAPI spec entries instead if you prefer.
- Commits are split per layer (ingestor / server endpoints / resolve /
frontend / CI) for review.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Erwin Fiten <e.fiten@opteco.be>
2026-06-19 11:37:16 -07:00
df28efaed9 docs(agents): contributor onboarding pack for AI-driven workflows (#1734)
## What

Adds `docs/agents/` — an onboarding pack for external contributors using
their own AI coding agent (Claude Code, Codex, Cursor, Aider, OpenClaw,
etc.).

## Why

Maintainers run an agent-driven workflow against this repo. External
contributors using agents benefit from the same discipline (TDD
red→green, PII preflight, parallel persona polish, three-axis merge
readiness) but had nothing portable to point at. This documents the
**process** and the **reusable building blocks** in an agent-agnostic
way.

## Contents

```
docs/agents/
  README.md
  WORKFLOW.md             # pipeline + planning + PII preflight + force-push + worktrees
  RULES.md                # 36 hard-won discipline rules
  TDD.md                  # red→green requirement, exemptions
  SUBAGENT-BRIEF-TEMPLATE.md
  skills/                 # 14 task playbooks (intake, fix, polish, merge-gate, release, ops...)
  personas/               # 14 review voices (carmack, dijkstra, torvalds, meshcore, taleb, ...)
```

## Scope

Docs-only. No code changes. Existing `AGENTS.md` is unchanged. All
committed text uses sanitized placeholders (`<workspace>`, `<repo>`,
`YOUR_NAME`, `YOUR_HANDLE`, etc.) — no personal names, phones, IPs,
keys, or absolute home/root paths.

## Verification

- PII preflight grep on staged diff: only matches are the literal
placeholders inside the documented sanitized example
(`YOUR_NAME|YOUR_HANDLE|...|api[_-]?key|...`).
- Off-topic skill grep on `docs/agents/`: clean (zero hits for the
wrong-language/off-topic skill names that were scrubbed from the prior
attempt).

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
Co-authored-by: Kpa-clawbot <bot@openclaw.local>
Co-authored-by: efiten <erwin.fiten@gmail.com>
2026-06-19 11:37:10 -07:00
bdf5f647d4 fix(ci): freshen all e2e-fixture observation timestamps (unblocks #1630 reach e2e) (#1747)
## Problem

`test-issue-1630-reach-mobile-e2e.js` has been failing on `master` since
~2026-06-16, in its precondition `pickRepeaterWithReach` ("no repeater
with reach links found in fixture") — not in any of its actual
assertions. The same SHA passed on 06-15 and failed on 06-16 with no
code change, i.e. it tracks the wall clock, not the tree.

## Root cause

`tools/freshen-fixture.sh` shifts `nodes`, `transmissions`, `observers`
and `neighbor_edges` timestamps to ~now, but for
`observations.timestamp` it only rewrote rows where `timestamp = 0 OR
timestamp IS NULL`. Real-timestamped observations stayed frozen at
fixture-capture time.

Per-node reach (`/api/nodes/{pk}/reach?days=N` → `scanReachRows`,
`cmd/server/node_reach.go`) windows on `observations.timestamp >=
sinceEpoch`. ~30 days after the fixture was captured, the newest
observation aged out of the 30-day window, so reach returned no links
and the test could find no repeater with reach.

## Fix

Shift all non-zero `observations.timestamp` forward by the same offset
(preserving relative order), mirroring the other tables in the script.
The offset subquery is uncorrelated, so SQLite evaluates `MAX` once on
the pre-update state (same idiom the existing blocks rely on).

## Verification

Ran the updated script against `test-fixtures/e2e-fixture.db`:

```
BEFORE: newest observation 31 days old  → outside the 30-day reach window
AFTER : newest observation  0 days old, all 500 observations within 30 days
```

CI Playwright on this PR is the end-to-end confirmation. Scope is the CI
fixture helper only — no application code, schema, or runtime behaviour
changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Erwin Fiten <erwin.fiten@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 08:50:08 +02:00