2813 Commits

Author SHA1 Message Date
Michael J. Arcan 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
Michael J. Arcan 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
Kpa-clawbot 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
Kpa-clawbot 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
Kpa-clawbot 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
Kpa-clawbot 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
Michael J. Arcan 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
Michael J. Arcan 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
Michael J. Arcan 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
Kpa-clawbot 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. Arcan 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
Kpa-clawbot 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
Kpa-clawbot 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
Kpa-clawbot 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
Michael J. Arcan 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
Kpa-clawbot 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
Kpa-clawbot 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
Kpa-clawbot 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
Michael J. Arcan 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 Cai 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. Arcan fc26fb6b3a feat(#1751): show transported region scopes in repeater sidebar (#1752)
Closes #1751.
2026-06-27 14:59:50 -07:00
Michael J. Arcan 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
Kpa-clawbot 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
efiten 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
efiten 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
efiten 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
efiten 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
Kpa-clawbot 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
Kpa-clawbot 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
Kpa-clawbot 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
Kpa-clawbot 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
Kpa-clawbot 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
Kpa-clawbot 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
efiten 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
Kpa-clawbot 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
Kpa-clawbot 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
efiten 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
Kpa-clawbot 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
efiten 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
Kpa-clawbot 1476b857d9 fix(#1716): drop rf-health a11y allowlist entry — subsumed by #1720 (#1731)
Fixes #1716.

PR #1720 (merged 2026-06-13) consolidated `.rf-range-btn.active` — along
with `.clock-filter-btn.active`, `.subpath-jump-nav a`,
`.node-filter-option.node-filter-active`, `.subpath-selected`, and
`.analytics-time-range button.active` — into 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 makes the `#1716` axe allowlist entry obsolete: the underlying
violation no longer reproduces. This PR drops the entry and adds a
dedicated per-issue regression gate so a future refactor that only
breaks `.rf-range-btn.active` (without touching the other consolidated
selectors covered by `#1719`) trips with a clear `#1716` citation.

## Strict TDD red→green

### RED — `bce51a60` (test-only)
Adds `test-a11y-1716-rf-range-btn-active.js`, a pure-CSS probe with
three assertions:
- **A1** — `.rf-range-btn.active` is routed through a rule whose body
sets `background: var(--accent-strong)` + `color:
var(--text-on-accent)`.
- **A2** — the legacy `var(--accent)` + `#fff` pair (2.75:1) does NOT
  reappear on any block listing `.rf-range-btn.active`.
- **A3** — numeric contrast on the resolved tokens is ≥ 4.5:1 in both
  light and dark themes.

Locally verified the test FAILS when the consolidated active-button
block is reverted to `background: var(--accent); color: #fff`:

```
PASS A3[light]: 4.95:1 (fg=#f9fafb bg=#2563eb)
PASS A3[dark]: 4.95:1 (fg=#f9fafb bg=#2563eb)
FAIL A1: .rf-range-btn.active is NOT routed through the consolidated (--accent-strong / --text-on-accent) pair — PR #1720 regression
FAIL A2: legacy 2.75:1 pair re-emerged on .rf-range-btn.active (bg=var(--accent) fg=#fff)

FAIL: 2 assertion(s) tripped on .rf-range-btn.active (issue #1716)
```

Then restored CSS — test passes green on the consolidated state from
master.

### GREEN — `eea79791`
Removes from `tests/a11y-allowlist.yaml`:

```yaml
- route: '/analytics?tab=rf-health'
  selector: 'button[data-range="24h"]'
  rule: color-contrast
  issue: 1716
  expires_at: 2026-09-11
```

### CI wiring — `b300ce6d`
Hooks the new probe into the same `.github/workflows/deploy.yml` step
that already runs `test-a11y-axe-1668-selftest.js` and
`test-issue-1705-subpath-contrast.js`, so the gate runs on every PR.

## Gate output (after green)

```
$ node test-a11y-1716-rf-range-btn-active.js
  PASS A1: .rf-range-btn.active routes to var(--accent-strong) + var(--text-on-accent)
  PASS A2: no legacy var(--accent) + #fff pair on .rf-range-btn.active
  PASS A3[light]: 4.95:1 (fg=#f9fafb bg=#2563eb)
  PASS A3[dark]: 4.95:1 (fg=#f9fafb bg=#2563eb)

PASS: .rf-range-btn.active gated by consolidated --accent-strong / --text-on-accent pair (issue #1716)
```

The umbrella `#1719` probe also still passes (`PASS: all 4 root-cause
patterns ≥ 4.5:1 in both themes`).

## Scope

Only the `rf-health` / `.rf-range-btn.active` line. The sibling
allowlist entries for `#1714` (nodes), `#1715` (neighbor-graph), and
`#1718` (prefix-tool) are out of scope — separate issues, separate PRs.
No production CSS touched (PR #1720 did the substantive fix).

## Files changed

- `tests/a11y-allowlist.yaml` (−5 lines: drop `#1716` entry)
- `test-a11y-1716-rf-range-btn-active.js` (+177 lines: new regression
gate)
- `.github/workflows/deploy.yml` (+1 line: wire the new gate)

## Preflight

All hard gates clean (PII, branch scope, red commit, CSS-var defined,
CSS self-fallback, LIKE-on-JSON, sync migration, async migration, XSS
sinks). All warnings clean.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-06-15 13:49:41 -07:00
Kpa-clawbot b695aec4ec fix(#1714): drop nodes a11y allowlist entry — subsumed by #1720 (#1730)
## Summary

PR #1720 (commit `a344ae0a`, merged 2026-06-13) introduced
`--status-green-text=#15803d` (5.02:1 on white) and routed the
`/analytics?tab=nodes` stat-card text usage to the new token. The
remaining contrast violation that issue #1714 tracked is gone, so the
allowlist entry is stale.

## Change

Drop the single allowlist entry tagged `issue: 1714` from
`tests/a11y-allowlist.yaml`:

```yaml
- route: '/analytics?tab=nodes'
  selector: '.analytics-stat-card:nth-child(1) > div:nth-child(1)'
  rule: color-contrast
  issue: 1714
  expires_at: 2026-09-11
```

No other tabs touched (#1715/#1716/#1718 remain — separate issues,
separate PRs).

## Verification

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

Local repro was not attempted: prior PR #1723 (same allowlist-drop
shape, same upstream PR #1720) documented sandbox chromium failures
(musl/glibc + Vulkan/EGL); CI is the source of truth for this gate.

Before (current `master`, with entry present): CI a11y gate green
(the allowlist masks the now-fixed selector).
After (this PR, entry removed): CI a11y gate must remain green
because the underlying contrast was actually fixed by PR #1720.

## 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 — adding a duplicate assertion file would
  not catch anything CI does not already catch.

Mirrors the exact pattern accepted in PR #1723 (subpaths allowlist
drop, same upstream PR #1720 fix).

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ all hard gates pass, all warnings pass. ("Red commit" gate notes
this is a config-change with no test commits, justified above.)

Partial fix for #1714 (will switch to `Fixes #1714` once CI a11y gate
confirms zero net violations on `/analytics?tab=nodes`).

Refs PR #1720, PR #1723.
2026-06-15 13:49:36 -07:00
Kpa-clawbot 92e001c093 chore(a11y): drop subsumed subpaths allowlist entries (#1713) (#1723)
## Summary
PR #1720 merged (commit `a344ae0a`) and introduced the shared
`.btn-active-accent` color-contrast rule, which subsumes the per-link
allowlist entries for the subpaths tab pills on
`/analytics?tab=subpaths`.

This PR removes the 4 now-stale allowlist entries tagged `issue: 1713`:
- `a[href$="#sp-pairs"]`
- `a[href$="#sp-triples"]`
- `a[href$="#sp-quads"]`
- `a[href$="#sp-long"]`

All four were `rule: color-contrast` exemptions on
`/analytics?tab=subpaths`.

## Verification
CI a11y gate (`test-a11y-axe-1668.js`) is the authoritative check — it
re-renders the route in dark+light × desktop+mobile and asserts zero net
violations against the trimmed allowlist. Local repro was attempted but
blocked by sandbox chromium issues (musl/glibc relocations on bundled
playwright chromium; host chromium hits Vulkan/EGL init failures).
Relying on CI a11y job for the green signal.

## TDD note
Config exemption per workspace AGENTS.md:
- No test files modified.
- Config-only allowlist trim; CI must stay green without test edits.

Partial fix for #1713 (will switch to `Fixes #1713` once CI a11y gate
confirms zero net violations on `/analytics?tab=subpaths`).

Co-authored-by: corescope-bot <bot@corescope>
2026-06-13 20:48:29 -07:00
Kpa-clawbot ae88d38b12 chore(a11y): drop subsumed clock-health allowlist entries (#1717) (#1722)
## Summary

Drops the two stale `issue: 1717` entries from
`tests/a11y-allowlist.yaml`. Both were subsumed by PR #1720 (merged at
a344ae0a), which fixed the underlying color-contrast root causes on
`/analytics?tab=clock-health`:

| Removed entry | PR #1720 fix |
|---|---|
| `button[data-filter="all"]` (clock-health active filter) | P1: shared
`.btn-active-accent` class → 4.95:1 |
| `.skew-badge--no_clock` (count_max: 300) | P2: new
`--skew-badge-no-clock-bg` token → 7.56:1 |

Leaving these entries in place would mask any regression on those
surfaces, defeating the purpose of the gate.

## Scope

Config-only. Single file touched: `tests/a11y-allowlist.yaml`.

## TDD discipline

Per workspace AGENTS.md TDD exemption for config changes:
- **no test files modified**
- **config-only allowlist trim, CI green without test edits**

The local axe harness (`test-a11y-axe-1668.js`) cannot run in this
sandbox (Playwright/chromium boot issue — same constraint cited in PR
#1720). CI runs the same gate against the staging fixture and is the
source of truth.

## Verification

CI a11y-axe gate on this PR must show zero NEW violations on
`/analytics?tab=clock-health` after the entries are removed.

Partial fix for #1717 (will switch to "Fixes #1717" once CI confirms
zero violations on the clock-health tab post-removal).

Co-authored-by: Kpa-clawbot <bot@openclaw.local>
2026-06-13 20:48:26 -07:00
Kpa-clawbot cbe6e94b1a fix(#1706): expand axe route coverage to remaining analytics tabs (#1707)
Adds an anti-drift coverage selftest that fails CI if a future analytics
tab is added to `public/analytics.js` but not registered in
`test-a11y-axe-1668.js` `ROUTES`. Wires it into the
`.github/workflows/deploy.yml` axe job alongside the existing
reciprocity selftest.

## Relationship to #1706 / #1711

Follow-up to #1706 / #1711#1711 already added the 8 missing analytics
tabs to `ROUTES` (`subpaths`, `nodes`, `distance`, `neighbor-graph`,
`rf-health`, `clock-health`, `scopes`, `prefix-tool`). This PR locks
that in: a future tab added to `analytics.js` without a corresponding
`ROUTES` entry now breaks CI on this assertion. NOT closing #1706 — that
issue is already closed by #1711.

## What the gate does (headline)

`test-a11y-axe-routes-coverage.js` scrapes `<button class="tab-btn"
data-tab="...">` declarations from `public/analytics.js` and asserts
every declared tab is exercised by `test-a11y-axe-1668.js` `ROUTES` as
`/analytics?tab=<tab>`.

Asymmetry vs the existing selftest, intentional:

- `test-a11y-axe-1668-selftest.js` — checks `REGISTERED_ANALYTICS_TABS ⊆
analytics.js` dispatch arms (no dead registrations).
- `test-a11y-axe-routes-coverage.js` (new) — checks `analytics.js
data-tab buttons ⊆ ROUTES` (no axe-blind tabs).

Together they keep the axe matrix honest in both directions.

## Diff scope

Only two files vs merge base:

- `.github/workflows/deploy.yml` (+1 line — wires the new test into the
deploy-job batch)
- `test-a11y-axe-routes-coverage.js` (+74 lines, new file)

No production code changes, no `ROUTES` changes (those landed in #1711).

## TDD framing

Net-new drift gate — no prior assertions to break, no behavior change in
shipped UI. Per workspace `AGENTS.md` net-new-test exemption ("net-new
UI surfaces … test must land in the SAME PR but doesn't need to be the
FIRST commit"), this analogous net-new gate ships green from commit 1. A
red→green pair would require a synthetic regression in `analytics.js` or
`ROUTES`, which isn't appropriate for an anti-drift guard.

## Local run

Local Alpine chromium 136 crashes under Playwright's CDP probe
(`posix_fallocate64: symbol not found`) — affects the axe runner
generally, not this selftest. The coverage assertion itself is pure Node
(file read + regex + set diff) and runs locally clean. Real verdict
comes from CI's Playwright-bundled chromium.

---------

Co-authored-by: Kpa-clawbot <bot@openclaw.local>
2026-06-13 18:47:59 -07:00
Kpa-clawbot 9b8b613832 fix(#1705): WCAG AA contrast on .subpath-selected .hop-prefix (#1712)
## Summary

**Regression guard for an already-fixed bug.** The WCAG AA contrast
BLOCKER on `.subpath-selected .hop-prefix` called out in #1705 was
already resolved by PR #1708 (commit `293efdb6`), which is an ancestor
of this branch's base. This PR adds the missing automated test that
locks the fix in — it does **not** ship a CSS change.

### Why this is not a TDD red→green sequence

Test commit `0d58d1d5` is **green-on-arrival**: the fix it asserts
against had already landed days earlier on master. There is no red
commit on this branch — running the test at any point on this branch
passes. This PR therefore claims the **net-new-test exemption** per
`~/.openclaw/workspace-meshcore/AGENTS.md`: bug fixes on EXISTING UI
normally require red→green, but where the fix shipped first and the test
is a *post-hoc regression guard*, the test lands in the same PR series
but does not need to be the first commit. No production behavior is
changed by anything in this branch.

(The earlier revisions of this body presented a misleading red→green
table; that framing was wrong and has been removed.)

## What this PR actually ships

Two test files plus CI wiring — all test-only / config-only:

1. `test-issue-1705-subpath-contrast.js` — parses `public/style.css`,
extracts `--accent-strong` / `--text-on-accent` per theme,
sRGB-composites any `rgba()` foreground against the resolved background,
asserts WCAG AA ≥4.5:1 on `.subpath-selected .hop-prefix` in both
themes.
2. `test-issue-1705-subpath-contrast-e2e.js` —
Playwright/headless-Chromium variant that loads the real
`public/style.css` into a DOM mirroring `analytics.js`
`renderSubpathsTable`, then asserts `getComputedStyle` contrast on a
live `tr.subpath-selected > .hop-prefix`. Catches specificity/cascade
regressions the static parser cannot.
3. `.github/workflows/deploy.yml` PR test stage wiring — runs both tests
on every PR.

## The bug (historical, already fixed)

For context: `.subpath-selected .hop-prefix` measured ~1.87:1 in dark
theme (`rgba(255,255,255,0.6)` composited against `var(--accent)` =
`#4a9eff`). #1708 (`293efdb6`) swapped `.subpath-selected` background to
`var(--accent-strong)` (`#2563eb`) and color to `var(--text-on-accent)`
(`#f9fafb`), yielding **4.95:1** in both themes. The child `.hop-prefix`
color was set to `inherit` so the prefix cannot be decoratively muted
below AA again.

## Parser test — hardening (review r1)

Round-1 review pass surfaced 7 must-fix items on the parser test; all
addressed:

| # | Concern | Fix |
|---|---------|-----|
| MF3 | Parser asserted declared cascade, not computed style | Added the
Playwright E2E variant; `getComputedStyle` resolves specificity natively
|
| MF4 | CSS comment cited "4.83:1+" while measured value is 4.95:1 |
Comment updated to `#f9fafb on #2563eb = 4.95:1` |
| MF5 | `extractBlockTokens` silently returned `{}` when the regex
didn't match | Throws with selector label; defensive assertion verifies
`:root` declares the three tokens |
| MF6 | `'inherit'` on the child color fell back to `#ffffff` silently |
Now throws; callers either declare the parent color or use the
Playwright variant where `inherit` resolves natively |
| MF7 | `extractDecl` picked the last regex match across rules with no
specificity model | Now throws on N>1 distinct values; warns on N>1
identical values |

## E2E test — hardening (polish v2)

- M2: removed dead `<link rel="stylesheet" href="file://...">` element
from the test HTML template — `setContent` runs against `about:blank`
origin and cannot fetch `file://`, so the link was dead. CSS is injected
inline via `page.evaluate` (unchanged). Removed the now-unused `cssHref`
declaration.
- M3: fixed misleading class comment — the production table uses
`.analytics-table` (see `analytics.js` `renderSubpathsTable`), so the
fixture's `class="analytics-table subpaths-table"` was misleading.
Stripped `.subpaths-table` from the fixture and corrected the comment to
cite the real production class.

## Acceptance criteria

- [x] `.subpath-selected .hop-prefix` ≥4.5:1 in both themes — verified
by both tests (parser + E2E)
- [x] Regression test added covering the selected state, wired into PR
CI

Partial fix for #1705 — leaves the issue open for the audit-probe
alpha-compositing work (private tooling, out of scope here).

## Local test results

- `node test-issue-1705-subpath-contrast.js` (parser): **PASS** — `light
4.95:1 / dark 4.95:1`
- `node test-issue-1705-subpath-contrast-e2e.js` (Playwright): **SKIP**
in dev sandbox (chromium relocation error — musl/arm
sandbox-in-sandbox); CI installs the Playwright-bundled binary via `npx
playwright install chromium` and runs with `CHROMIUM_REQUIRE=1`

---------

Co-authored-by: CoreScope Bot <bot@corescope.local>
Co-authored-by: Kpa-clawbot <bot@openclaw.local>
Co-authored-by: clawbot <clawbot@users.noreply.github.com>
2026-06-13 16:47:41 -07:00
Kpa-clawbot a344ae0a12 fix(#1719): contrast root causes — active-btn / skew-badge / role-swatch / status-green (#1720)
## Summary

Fixes the four recurring color-contrast root causes #1719 identifies
behind ~320 axe violations on PR #1707's expanded gate. All fixes are
token-based; no hardcoded hex introduced.

## TDD

- **Red:** `151db732` — `test-a11y-1719-contrast-root-causes-e2e.js`
asserts WCAG AA on all 4 patterns; failed with 12 sub-threshold probes.
- **Green:** `dd26554e` — fixes below; test now reports 11/11 PASS.

## Patterns + measured contrast (before → after)

| # | Surface | Before | After | Note |
|---|---|---|---|---|
| P1 | `.rf-range-btn.active` / `.clock-filter-btn.active` /
`.subpath-jump-nav a` / `#ptCheckBtn` / `#ptGenBtn` | `#fff` on
`--accent` (#4a9eff) = **2.75:1** | `--text-on-accent` on
`--accent-strong` = **4.95:1** | Consolidated into ONE grouped
`.btn-active-accent, ...` rule; inline buttons now use the shared class
|
| P2 | `.skew-badge--no_clock` (dark theme) | `#fff` on `--text-muted`
(#d1d5db) = **1.47:1** | `#fff` on `--skew-badge-no-clock-bg` (#4b5563)
= **7.56:1** | New dedicated token, both themes |
| P3 | Neighbor-graph role swatches, light theme on white | room 3.30:1
/ sensor 3.19:1 / observer 4.23:1 | room **5.02** / sensor **5.02** /
observer **5.70** | `customize.js` defaults bumped to
palette-{green/amber/purple}-700 |
| P4 | `.analytics-stat-card` text in `--status-green` on white |
**2.28:1** | new `--status-green-text` = #15803d → **5.02:1** |
`--status-green` background token unchanged (still #22c55e); inline text
usages routed to the new token |

## Why this unblocks #1707

#1707's 320 axe color-contrast hits decompose into:
- 137× single-rule `.skew-badge--no_clock` → P2.
- ~N×4 active-button surfaces (rf-health / clock-health / subpaths /
prefix-tool) → P1.
- Role-swatch text on `/#/analytics?tab=neighbor-graph` (light) → P3.
- `.analytics-stat-card` text on `/#/analytics?tab=nodes` (light) → P4.

After this merges, the next CI run on #1707 should see the expanded gate
go green (or down to a small ≤5 residual the operator can triage
separately per the issue's acceptance criteria).

## Local axe gate

`BASE_URL=… node test-a11y-axe-1668.js` was **NOT** run locally — the
sandbox's bundled chromium fails to boot Playwright (known issue). CI on
this PR runs the same gate against the staging fixture; relying on that.

The dedicated test `test-a11y-1719-contrast-root-causes-e2e.js` is
CSS+JS-parse-driven (no browser) and runs in <100ms — it's the
regression net for these 4 patterns specifically.

```
$ node test-a11y-1719-contrast-root-causes-e2e.js
  PASS [P1] theme=light .rf-range-btn.active  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P1] theme=light .clock-filter-btn.active  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P1] theme=light .subpath-jump-nav a  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P1] theme=dark .rf-range-btn.active  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P1] theme=dark .clock-filter-btn.active  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P1] theme=dark .subpath-jump-nav a  fg=#f9fafb bg=#2563eb  ratio=4.95:1
  PASS [P2] theme=light .skew-badge--no_clock  fg=#fff bg=#4b5563  ratio=7.56:1
  PASS [P2] theme=dark .skew-badge--no_clock  fg=#fff bg=#4b5563  ratio=7.56:1
  PASS [P3] theme=light nodeColors.room on white  fg=#15803d bg=#ffffff  ratio=5.02:1
  PASS [P3] theme=light nodeColors.sensor on white  fg=#b45309 bg=#ffffff  ratio=5.02:1
  PASS [P3] theme=light nodeColors.observer on white  fg=#7c3aed bg=#ffffff  ratio=5.70:1
  PASS [P4] theme=light .analytics-stat-card text color (--status-green-text)  fg=#15803d bg=#ffffff  ratio=5.02:1
  PASS [P4] theme=dark .analytics-stat-card text color (--status-green-text)  fg=#22c55e bg=#232340  ratio=6.65:1

PASS: all 4 root-cause patterns ≥ 4.5:1 in both themes (issue #1719)
```

## Out-of-scope (intentional)

- Other text-on-light `var(--status-green)` usages in `nodes.js`
(Critical/Valuable labels): different surface, not the
analytics-stat-card pattern #1719 calls out. Tracked under analytics
audit umbrella.
- Hardcoded `var(--status-green, #2ecc71)` fallback in `nodes.js` lines
648/666: same scope deferral.
- Allowlist entries: none added per the issue's acceptance criteria.

Fixes #1719.

---------

Co-authored-by: clawbot <clawbot@kpa.local>
Co-authored-by: Kpa-clawbot <bot@openclaw.local>
2026-06-13 14:47:57 -07:00
Kpa-clawbot 4d2033da0f fix(#1709): restore Live map viewport from lat/lon/zoom hash params (#1721)
## Summary

Fixes #1709 — implements deep-link viewport restoration on the Live page
so `#/live?lat=43.0731&lon=-89.4012&zoom=12` (and the `node=` combo)
center+zoom the map identically to how `/#/map?lat=...&lon=...&zoom=...`
already worked.

## Approach

Extracted a shared `parseViewportHash(hashOrSearch, opts)` helper in
`public/app.js` (next to existing `getHashParams()`) and wired it into
BOTH call sites — Live and Map — so the parse/validate logic is DRY and
unit-testable.

### `parseViewportHash` contract

- Accepts a full hash (`#/live?lat=...`) OR a bare query string
(`lat=...&lon=...`).
- Returns `{lat, lon, zoom}` only if BOTH `lat` and `lon` parse to
finite numbers within bounds (`lat ∈ [-90, 90]`, `lon ∈ [-180, 180]`).
Partial lat-only or lon-only is rejected — the issue explicitly forbids
partial application of a center.
- `zoom` defaults to 12 when missing, must be numeric when present, and
is clamped to `[minZoom, maxZoom]` (defaults `[1, 20]` — sensible
Leaflet fallback when the tile-provider config isn't supplied).
- Returns `null` for any null/empty/invalid input.

### Precedence chain (Live)

1. **URL hash `lat`/`lon`/`zoom`** — highest priority. Applied BEFORE
the initial `setView()` so the very first render lands at the requested
viewport (no visible recenter from default → URL), AND in the
localStorage-restore block so URL overrides `live-map-view`.
2. `live-map-view` localStorage (existing fallback, preserved).
3. `/api/config/map` defaults (existing default, preserved).

### Node-filter URL preservation

The existing node-filter URL update logic at `public/live.js:1634` /
`1650` already seeds `params` from `getHashParams()`, so unrelated keys
(including `lat`/`lon`/`zoom`) already survive node filter changes.
Added two source-grep regression tests to guard against future
regressions (catches the anti-pattern `const params = new
URLSearchParams(); params.set('node', ...)` which would silently clobber
the viewport).

## Files changed

- `public/app.js` — `+47/-0` — new `parseViewportHash()` helper + window
expose.
- `public/map.js` — `+8/-4` — replaces inline `parseFloat`/`parseInt`
block with helper call.
- `public/live.js` — `+22/-3` — applies helper at init (`setView` line)
AND in the localStorage-restore block so URL overrides both fallbacks.
- `test-frontend-helpers.js` — `+105/-0` — 14 `parseViewportHash` unit
tests + 2 live.js source-grep regression tests for the node-filter URL
flow.

## TDD red→green

- **Red commit** `e6baf935` (FIRST commit on branch): adds tests + a
stub `parseViewportHash` returning `null`. 10 of the 14 unit tests fail
on assertion (not import error); 2 live.js source-grep tests already
pass against current master (regression guards).
- **Green commit** `43b3cb5f`: implements the helper + wires both call
sites. All 16 new tests pass.

## Test output (`node test-frontend-helpers.js`, last 15 lines)

```
   #825: deep link to unencrypted #channel falls through to REST and renders messages
   deriveKey: SHA256("#test")[:16] matches known value
   deriveKey: returns 16 bytes
   #815 preserved: deep link to #channel with stored key triggers decrypt path (no lock)
   invalidateApiCache causes api to re-fetch after cache bust
   computeChannelHash: SHA256(key)[0]
   verifyMAC: valid MAC passes
   verifyMAC: invalid MAC fails
   invalidateApiCache with no prefix busts all entries
   invalidateApiCache with prefix only busts matching

════════════════════════════════════════
  Frontend helpers: 625 passed, 2 failed
════════════════════════════════════════
```

The 2 failures (`favStar returns filled star for favorite`, `favStar
returns empty star for non-favorite`) are **pre-existing on master** and
unrelated to this PR — confirmed by running on `origin/master` before
any changes.

## Acceptance criteria (issue #1709)

1.  `#/live?lat=43.0731&lon=-89.4012&zoom=12` centers Live map at that
lat/lon/zoom.
2.  `#/live?node=ABC123&lat=43.0731&lon=-89.4012&zoom=12` applies BOTH
node filter AND viewport (`getHashParams().get('node')` already feeds
`setNodeFilter`; helper independently parses lat/lon/zoom).
3.  URL viewport params override `live-map-view` localStorage (URL
check runs first AND overrides the savedView branch).
4.  Invalid viewport params ignored safely (`parseViewportHash` returns
`null` on any out-of-range / NaN input).
5.  Missing `lat` or `lon` does NOT partially apply a center (helper
requires both).
6.  Live node-filter URL update preserves unrelated params — existing
`getHashParams()` seeding + new regression tests.
7.  No backend endpoint changes (`grep -l '\.go$' diff` → empty).

## Preflight

`bash ~/.openclaw/skills/pr-preflight/scripts/run-all.sh origin/master`
→ **clean** (all 12 gates pass, no warnings).

---------

Co-authored-by: Kpa-clawbot <bot@kpabap.dev>
Co-authored-by: Kpa-clawbot <bot@openclaw.local>
2026-06-13 10:54:28 -07:00
Kpa-clawbot 69fba8032d test(a11y): expand axe CI gate to all 14 analytics tabs + prefix-tool (#1706) (#1711)
## Summary

Closes #1706.

Expands the axe-core a11y CI gate (`test-a11y-axe-1668.js`) to cover the
7 missing analytics tabs plus the `prefix-tool` utility surface. Before
this PR, only 7 of 14 analytics tabs were gated; the other 7 could
regress on contrast/aria without CI noticing.

## Changes

**`test-a11y-axe-1668.js`** — adds 8 hash-routes to `ROUTES`:

- `/analytics?tab=subpaths`
- `/analytics?tab=nodes`
- `/analytics?tab=distance`
- `/analytics?tab=neighbor-graph`
- `/analytics?tab=rf-health`
- `/analytics?tab=clock-health`
- `/analytics?tab=scopes`
- `/analytics?tab=prefix-tool`

The `prefix-tool` route was verified by greppping `public/analytics.js`:
the dispatch arm is `case 'prefix-tool':` (line 292), the nav button
uses `data-tab="prefix-tool"` (line 132), and existing UI cross-links
use `#/analytics?tab=prefix-tool` (lines 1467, 1469, 1795, 3064). It
lives in the analytics tab strip, not a separate `/tools/...` route.

`REGISTERED_ANALYTICS_TABS` already lists all 15 tabs (the selftest's
reciprocity check kept the constant honest), so no sibling slug-list at
line ~82 needed updating beyond the existing list.

**`test-a11y-axe-1668-selftest.js`** — adds a meta-assertion that loops
over `REGISTERED_ANALYTICS_TABS` and asserts each `tab` has a matching
`/analytics?tab=<tab>` entry in `ROUTES`. This locks the gate forever:
any new analytics tab added to `analytics.js` will fail the selftest
unless its route is also gated.

## TDD shape (red → green)

- **Red commit** `a1d9aa8e` — adds the meta-assertion to the selftest.
Selftest fails with `#1706: ROUTES missing analytics tab coverage for
"/analytics?tab=subpaths"` (asserted, not a build error).
- **Green commit** `501d9572` — adds the 8 missing entries to `ROUTES`.
Selftest passes: `routes=24 themes=2 allowlist=0`.

Cell count: 24 routes × 2 themes × 2 viewports = **96 cells** (was 64).

## Allowlist / tracking issues

None yet. The 7 new analytics tabs and `prefix-tool` have not yet been
exercised by the full axe browser run in CI — that happens when this PR
runs. Per the M5 policy embedded in the gate's header, if any of the new
routes fails axe on first run, a follow-up tracking issue + per-policy
allowlist entry will be filed (the violations will NOT be silenced in
this PR, and the routes will NOT be removed). I'll watch CI and report
back.

## Out of scope

Per the issue:

- No keyboard-nav / focus-visible coverage (separate gap).
- No modal / slideover open-state scans (separate gap).
- No tufte / density work.
- No source-code modifications to any analytics tab (contrast/aria fixes
happen in follow-up PRs, not here).

## Preflight

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

## Verification

```
$ node test-a11y-axe-1668-selftest.js
PASS: a11y-axe-1668 selftest — routes=24 themes=2 allowlist=0
```

---------

Co-authored-by: clawbot <bot@kpa-clawbot.local>
Co-authored-by: Kpa-clawbot <bot@openclaw.local>
Co-authored-by: openclaw-bot <bot@openclaw>
2026-06-13 10:54:24 -07:00
Kpa-clawbot 293efdb647 fix(#1705): subpath-selected hop-prefix contrast BLOCKER (dark, 1.87:1 → ≥4.5:1) (#1708)
## Summary

Fixes the BLOCKER half of #1705: `.subpath-selected .hop-prefix`
contrast in `public/style.css`.

| | Before | After |
|---|---|---|
| background | `var(--accent)` = `#4a9eff` | `var(--accent-strong)` =
`#2563eb` |
| color (primary) | `#fff` | `var(--text-on-accent)` = `#f9fafb` |
| color (hop-prefix) | `rgba(255,255,255,0.6)` | `var(--text-on-accent)`
|
| measured contrast (hop-prefix) | **1.87:1** (composite over
`--accent`, dark) | **4.95:1** (light + dark) |

Pure token swap onto the existing `--accent-strong` / `--text-on-accent`
pair already used by `.badge-selected`, `.filter-bar .btn.active`,
`.dropdown-item:hover` etc. No new hex literals. Light and dark themes
both pass WCAG AA body text (≥4.5:1).

## TDD trail

- Red: `033f8e4c` — `test-a11y-1705-subpath-hop-prefix-e2e.js`. Parses
`public/style.css`, resolves the relevant tokens per theme, composites
the alpha-bearing text over the rendered background, asserts WCAG
contrast ≥ 4.5:1. Failed with `ratio=1.87:1` on both themes — the exact
value cited in #1705.
- Green: `db6b9dd0` — CSS fix. Test now reports `composite=#f9fafb,
ratio=4.95:1` on both themes.

Why a dedicated test (not just `test-a11y-axe-1668.js`):
`.subpath-selected` is a click-state class, so the umbrella axe gate
never sees it during initial-paint scans. This is the canonical
"state-only" a11y regression class — the umbrella gate is structurally
blind to it.

## Out of scope (documented in #1705 for separate follow-up)

- The **a11y audit probe correctness fix** (alpha-composite + parent-bg
walk) lives in workspace tooling
(`workspace-meshcore/a11y-audit/audit.py`), not in this repo. The
probe-correctness write-up is captured in #1705 itself; this PR is
exclusively the CSS BLOCKER + regression test.
- "Other rgba-based dark-mode contrast surfaces" — per the issue's
Out-of-scope section, those get filed separately if discovered.

## Local verification

```
$ node test-a11y-1705-subpath-hop-prefix-e2e.js
  PASS theme=dark  bg=#2563eb text=#f9fafb ratio=4.95:1
  PASS theme=light bg=#2563eb text=#f9fafb ratio=4.95:1
```

The full `test-a11y-axe-1668.js` gate could not be exercised on this
sandbox (Chromium SIGTRAPs against the host kernel — unrelated to this
change). CI runs it on Ubuntu where the umbrella ruleset already
enforces the 0-violation policy.

## Browser verified

CSS-only change in a CSS-variable swap. Computed values are
deterministic from the stylesheet and asserted by the new test; no JS /
DOM / render-path is touched.

## Preflight

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

Fixes #1705

---------

Co-authored-by: clawbot <bot@clawbot.local>
Co-authored-by: Kpa-clawbot <bot@clawbot>
2026-06-13 08:19:05 -07:00
Kpa-clawbot 97833c523b fix(post-packets): use v3 observations schema (closes #1196) (#1704)
## Summary

`POST /api/packets` is broken on every v3-schema install — which is the
default since #1289. The handler issues two writes against legacy v2
column names and silently swallows the observation insert's error,
returning `200 OK` with `id>0` while persisting zero observation rows.

## Root cause

`cmd/server/routes.go:1225-1235` (pre-fix) used the v2 schema shape:

```go
INSERT INTO transmissions (... path_json ...)            // path_json removed in v3
INSERT INTO observations (transmission_id, observer_id,
                          observer_name, snr, rssi, timestamp)  // v2 columns
// timestamp written as RFC3339 text; v3 wants unix INTEGER
// second Exec's error was discarded
```

v3 schema (`cmd/ingestor/db.go:289-304`): `observations.observer_idx
INTEGER` (FK `observers.rowid`), `observations.timestamp INTEGER` (unix
epoch), `path_json` lives here not on `transmissions`.

Reporter [@EldoonNemar](https://github.com/EldoonNemar) called this out
precisely in #1196 — both the schema mismatch and the divergence between
the test harness (which uses the v3 shape) and the handler (v2 shape).

## Fix

`cmd/server/routes.go`:

- `transmissions` insert: drop `path_json` column.
- Observer resolution: `INSERT OR IGNORE INTO observers (id, name, ...)`
then `SELECT rowid` — mirrors the ingestor resolver at
`cmd/ingestor/db.go:778,906`.
- `observations` insert: write `observer_idx INTEGER` + `timestamp =
time.Now().Unix()`; `path_json` moved here.
- **Propagate both insert errors** (transmission + observation) as `500`
instead of swallowing them.

## TDD

| Step  | Commit  | Result |
| ----- | ------- | ------ |
| RED | `46d25389` | Test fails on master: `id=0` because the
transmissions insert references a column not present in v3. |
| GREEN | `dae57d67` | Test passes; round-trip persists the observation
with `observer_idx` resolved from the seeded `obs1` row and a unix-epoch
`timestamp`. |

Local repro:

```
# RED on the test commit alone:
$ go test -run TestPostPacketPersistsV3Schema -count=1 .
--- FAIL: TestPostPacketPersistsV3Schema (0.03s)
    routes_test.go:4755: expected transmission id > 0, got 0
        (body: {"id":0,"decoded":{...}})
FAIL

# GREEN on HEAD:
$ go test -run TestPostPacketPersistsV3Schema -count=1 .
ok  	github.com/corescope/server	0.037s
```

## Scope

Two files, both in `cmd/server/`:
- `cmd/server/routes.go` (+38/-12) — handler rewrite
- `cmd/server/routes_test.go` (+66) — round-trip regression test

No public API signature changes. No DB schema changes (consumes the
existing v3 schema correctly).

Closes #1196
2026-06-13 00:11:02 -07:00