@slack/radar-mcp 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -100,6 +100,12 @@ class Store {
100
100
  _localPushKey = null;
101
101
  _lastBoundLabel = null;
102
102
  _backfillToken = 0;
103
+ // True while the connection is in a dropped/waiting state. Lets pollHealth detect the
104
+ // drop->recover EDGE (device came back) so it can re-open the stream + backfill ONCE on
105
+ // recovery, instead of every poll. Without this, an auto-recovered tab flips the dot to
106
+ // connected but the dead EventSource is never re-opened and the device ring is never pulled,
107
+ // so the tab shows nothing until a manual refresh.
108
+ _dropped = false;
103
109
  _rafPending = false;
104
110
  _intervals = [];
105
111
  // ---- shared detail cache (bounded FIFO) ----
@@ -204,6 +210,7 @@ class Store {
204
210
  this._localPushKey = null;
205
211
  this._lastBoundLabel = null;
206
212
  this._backfillToken = 0;
213
+ this._dropped = false;
207
214
  this._rafPending = false;
208
215
  }
209
216
  // ---- start everything ----
@@ -317,7 +324,12 @@ class Store {
317
324
  this.closeStream();
318
325
  const es = new EventSource("/stream");
319
326
  es.onmessage = (m) => this.onFrame(m);
320
- es.onerror = () => this.setConn("waiting", "reconnecting…");
327
+ // An SSE error is the most direct signal the stream died; mark dropped so the next healthy
328
+ // poll re-opens + backfills (recovered()), not just flips the dot green.
329
+ es.onerror = () => {
330
+ this._dropped = true;
331
+ this.setConn("waiting", "reconnecting…");
332
+ };
321
333
  this._es = es;
322
334
  }
323
335
  closeStream() {
@@ -349,6 +361,10 @@ class Store {
349
361
  if (!STREAM_KINDS.includes(f.type))
350
362
  return;
351
363
  this._lastEventTs = Date.now();
364
+ // A live frame proves the stream healed itself (the browser EventSource auto-reconnects on a
365
+ // transient blip). Clear _dropped here so recovered() does NOT later tear down this healthy
366
+ // stream and re-open it: recovered() should only fire when the stream did NOT self-heal.
367
+ this._dropped = false;
352
368
  this.setConn("connected", "");
353
369
  const ev = f.event || {};
354
370
  ev._type = f.type;
@@ -593,6 +609,7 @@ class Store {
593
609
  async pollHealth() {
594
610
  if (Date.now() - this._lastEventTs < 6000) {
595
611
  this._healthMisses = 0;
612
+ this.recovered();
596
613
  this.setConn("connected", "");
597
614
  return;
598
615
  }
@@ -600,17 +617,31 @@ class Store {
600
617
  const h = await (await fetch("/health")).json();
601
618
  if (h.device) {
602
619
  this._healthMisses = 0;
620
+ this.recovered();
603
621
  this.setConn("connected", "");
604
622
  }
605
623
  else if (++this._healthMisses >= 2) {
624
+ this._dropped = true;
606
625
  this.setConn("waiting", "device offline, retrying…");
607
626
  }
608
627
  }
609
628
  catch {
610
- if (++this._healthMisses >= 2)
629
+ if (++this._healthMisses >= 2) {
630
+ this._dropped = true;
611
631
  this.setConn("waiting", "reconnecting…");
632
+ }
612
633
  }
613
634
  }
635
+ // Called when health flips back to connected. If we were in a dropped state, the EventSource
636
+ // did not survive the drop and the tab has been silent, so re-open the stream + backfill the
637
+ // device ring ONCE on the recovery edge. The _dropped guard makes this fire on the transition,
638
+ // not on every healthy poll (resyncStream clears the flag). The <6s-since-last-event fast path
639
+ // means a tab that never lost its stream skips this (live frames keep _lastEventTs fresh).
640
+ recovered() {
641
+ if (!this._dropped)
642
+ return;
643
+ this.resyncStream();
644
+ }
614
645
  async pollProfile() {
615
646
  try {
616
647
  const [profilesR, healthR] = await Promise.all([
@@ -635,6 +666,26 @@ class Store {
635
666
  /* leave the last label */
636
667
  }
637
668
  }
669
+ // Re-open the SSE stream and backfill the device ring for the ACTIVE streaming source, so the
670
+ // current tab repopulates from what the device already holds (not just new live events).
671
+ // Needed because the EventSource does not survive a device drop and pollSpec only re-applies
672
+ // (and so re-opens+backfills) on a spec-KEY change, which a reconnect is not. Guarded for
673
+ // streaming sources only, matching applySpec: db + blank own no SSE feed; log opens the stream
674
+ // but backfill() self-skips it (host-side capture, no device ring to replay).
675
+ //
676
+ // Clears _dropped itself so the two recovery entry points -- the manual reconnect() button and
677
+ // the auto recovered() edge -- collapse into one idempotent op: whichever fires first wins, the
678
+ // other sees _dropped false and no-ops. Without this, a manual reconnect on a waiting tab fires
679
+ // resyncStream() here AND again from recovered() once the health poll resolves, so the stream is
680
+ // opened, torn down, reopened, and the ring is backfilled twice.
681
+ resyncStream() {
682
+ this._dropped = false;
683
+ const src = this.spec.source;
684
+ if (this.spec.blank || !src || src === "db")
685
+ return;
686
+ this.openStream();
687
+ this.backfill();
688
+ }
638
689
  async reconnect() {
639
690
  try {
640
691
  await fetch("/enable", { method: "POST" });
@@ -645,6 +696,7 @@ class Store {
645
696
  this.pollHealth();
646
697
  this.pollProfile();
647
698
  this.pollSpec();
699
+ this.resyncStream();
648
700
  }
649
701
  }
650
702
  // ---- aggregate math (moved here from widgets per the plan: it belongs WITH the aggregate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slack/radar-mcp",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "MCP server and web dashboard for on-device debugging of the Slack Android app via ADB",
5
5
  "type": "module",
6
6
  "main": "dist/mcp/index.js",
@@ -22,7 +22,8 @@
22
22
  "start": "node dist/mcp/index.js",
23
23
  "start:web": "node dist/web/bin.js",
24
24
  "watch": "tsc --watch",
25
- "test": "npm run build && node --test tests/*.test.mjs",
25
+ "test": "npm run build && node tests/run.mjs",
26
+ "test:tap": "npm run build && node --test --test-force-exit tests/*.test.mjs",
26
27
  "test:smoke": "npm run build && bash tests/smoke.sh",
27
28
  "test:all": "npm run test && npm run test:smoke",
28
29
  "prepare": "npm run build"