@cotal-ai/web 0.24.0 → 0.26.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.
package/dist/web/app.js CHANGED
@@ -119,6 +119,37 @@ function setConn(live) {
119
119
  el.querySelector(".t").textContent = live ? "live" : "disconnected";
120
120
  }
121
121
 
122
+ /** Say WHICH sources are showing their last good value, and why. Visible, not a console line: the
123
+ * whole point of keeping the snapshot is that the reader knows they are looking at it. Cleared by
124
+ * the next refresh in which everything landed, which is what recovery looks like from here. */
125
+ /** What the marker is currently saying, so a source read OUTSIDE the poll can add to it without
126
+ * erasing the poll's own findings. `refresh()` sets the four polled sources; the open channel's
127
+ * history is read later, inside `select()`, and a bare `setStale([channel])` there would drop the
128
+ * roster/channels/dms/activity refusals from the same label. */
129
+ let staleNow = [];
130
+ function setStale(stale) {
131
+ staleNow = stale;
132
+ renderStale();
133
+ }
134
+ /** Mark ONE source, or clear it, leaving every other source's mark alone. A successful read clears
135
+ * its own mark and nobody else's, which is what makes recovery per source rather than all-or-nothing. */
136
+ function markStale(name, entry) {
137
+ const rest = staleNow.filter((s) => s.name !== name);
138
+ setStale(entry ? [...rest, entry] : rest);
139
+ }
140
+ function renderStale() {
141
+ const el = $("stale");
142
+ if (!el) return;
143
+ const label = window.COTAL_SNAPSHOT.staleLabel(staleNow);
144
+ el.hidden = !label;
145
+ // CLEARED, NOT JUST HIDDEN. Returning early on recovery left the previous label and its tooltip
146
+ // sitting in the element. Hidden text is invisible until something else unhides it, and then the
147
+ // reader is told a source is stale that recovered some time ago. Recovery has to erase the claim,
148
+ // not park it.
149
+ el.querySelector(".t").textContent = label;
150
+ el.title = staleNow.map((s) => `${s.name}: ${s.reason}`).join("\n");
151
+ }
152
+
122
153
  // ── Header: golden-signal tiles ───────────────────────────────────────────────
123
154
  // Fifth tile is last-heartbeat freshness (Presence.ts), NOT blocked-duration — we cannot know
124
155
  // how long someone has been waiting (no statusSince on the wire). Label it honestly.
@@ -790,6 +821,12 @@ function refreshDerived() {
790
821
  }
791
822
 
792
823
  let loadSeq = 0;
824
+ /** The channel `channelMsgs` currently holds, so a refused re-read of THE SAME channel can keep what
825
+ * is on screen. `selected` cannot answer this: `select()` assigns it before the clear below, so by
826
+ * then it already names the channel being opened rather than the one being displayed. Without this,
827
+ * retention would show the previous channel's messages under the new channel's name, which is worse
828
+ * than an empty view. */
829
+ let shownChannel = null;
793
830
  /** The in-flight channel bootstrap: `{key, promise}`, so a second call for the same channel shares it. */
794
831
  let selecting = null;
795
832
  async function select(key) {
@@ -808,6 +845,9 @@ async function select(key) {
808
845
  // because returning early is exactly what left the buffer undrained.
809
846
  if (selecting && selecting.key === key) return selecting.promise;
810
847
  const seq = ++loadSeq;
848
+ // HELD BEFORE THE CLEAR, and only when it belongs to THIS channel. A fresh selection must not
849
+ // inherit the last channel's messages, so a switch holds nothing and starts empty as before.
850
+ const held = shownChannel === key ? channelMsgs : [];
811
851
  channelMsgs = [];
812
852
  // ARMED BEFORE THE FETCH IS ISSUED, which is the whole ordering. Re-armed on every selection
813
853
  // because each one is a fresh two-phase bootstrap: a new history read, and a live tap that is
@@ -818,15 +858,32 @@ async function select(key) {
818
858
  let release;
819
859
  selecting = { key, promise: new Promise((r) => (release = r)) };
820
860
  renderCenter();
821
- // Same reason as the activity feed: a failed history read is an empty batch, not a reason to
822
- // leave the boundary unpassed and the frames of this channel held out of the view that was
823
- // opened to look at them.
861
+ // Same reason as the activity feed: a failed history read must not leave the boundary unpassed
862
+ // and the frames of this channel held out of the view that was opened to look at them. What it
863
+ // is NOT any more is an empty batch: it is the last history that was actually read, so a refused
864
+ // poll on the open channel keeps what is on screen instead of emptying it.
824
865
  let msgs = [];
866
+ let refused = null;
825
867
  try {
826
- msgs = await (await fetch(`/api/channels/${encodeURIComponent(key)}/history?limit=200`)).json();
868
+ // READ THROUGH THE SAME GATE AS EVERY OTHER SOURCE. This was the one read on either page that
869
+ // still consumed a body without consulting the status, and it is the read behind the open
870
+ // channel. A 500 here answers `{"error":"..."}`, which is valid JSON that `fetch` does not
871
+ // reject, so the catch below never fired: the object was handed to the order machine as a
872
+ // history, the channel drew empty, and nothing said why. Measured on the shipped code before
873
+ // this line changed: last-good gone, no backfill-failed note, no stale mark.
874
+ msgs = await window.COTAL_SNAPSHOT.readJson(
875
+ await fetch(`/api/channels/${encodeURIComponent(key)}/history?limit=200`),
876
+ `#${key} history`,
877
+ );
827
878
  } catch (err) {
828
- msgs = [];
829
- noteOrder([{ type: "backfill-failed", channel: key, reason: err && err.message ? err.message : String(err) }]);
879
+ // A REFUSED READ KEEPS WHAT THE READER ALREADY HAD, for the same reason the four polled
880
+ // sources do. `refresh()` re-selects the open channel on EVERY poll, so on a link where the
881
+ // read keeps missing, an empty batch here emptied the open channel once per poll. The held
882
+ // messages take the place of the history that could not be read, which keeps the merge and
883
+ // the ordering below identical to the successful path.
884
+ refused = err && err.message ? err.message : String(err);
885
+ msgs = held;
886
+ noteOrder([{ type: "backfill-failed", channel: key, reason: refused }]);
830
887
  } finally {
831
888
  // SETTLED ON EVERY PATH, INCLUDING THE STALE ONE. A superseded load must not rebind the view it
832
889
  // no longer owns, and it must still drain the machine it armed; skipping the settle is what
@@ -842,6 +899,12 @@ async function select(key) {
842
899
  const ids = new Set(merged.map((m) => m && m.id));
843
900
  for (const m of channelMsgs) if (m && !ids.has(m.id)) merged.push(m);
844
901
  channelMsgs = merged.slice(-500);
902
+ shownChannel = key;
903
+ // SAID, NOT JUST KEPT. Retention without the mark shows old messages as though they were
904
+ // current, which is the half of this rule that turns a silent wipe into a silent lie. Marked
905
+ // per source: a refused history does not erase the poll's other marks, and a read that lands
906
+ // clears this one without touching theirs.
907
+ markStale(`#${key} history`, refused ? { kind: "refused", name: `#${key} history`, reason: refused } : null);
845
908
  }
846
909
  if (selecting && selecting.key === key) selecting = null;
847
910
  release();
@@ -885,54 +948,118 @@ async function refresh() {
885
948
  // The batch the settle will use. Only the all-activity path fills it; every other path settles on
886
949
  // empty, which is the machine's specified empty-history arm rather than a shortcut.
887
950
  let batch = [];
951
+ let activityPage = null;
888
952
  try {
889
- roster = await (await fetch("/api/roster")).json();
890
- refreshDerived();
891
- const list = await (await fetch("/api/channels")).json();
892
- // L2 shape is flat {channel,messages,description?,replay,replayWindow?,deliveryClass}.
893
- // Tolerate a nested-config server briefly (pre-restart) without re-deriving defaults.
894
- channels = new Map(
895
- list.map((c) => {
896
- if (c.replay !== undefined || c.deliveryClass !== undefined || (c.description && !c.config))
897
- return [c.channel, c];
898
- const cfg = c.config || {};
899
- return [
900
- c.channel,
901
- {
902
- messages: c.messages,
903
- description: cfg.description,
904
- replay: cfg.replay,
905
- replayWindow: cfg.replayWindow,
906
- deliveryClass: cfg.deliveryClass,
907
- },
908
- ];
909
- }),
910
- );
911
- dms = await (await fetch("/api/dms?limit=500")).json();
953
+ // ── A FAILED READ KEEPS WHAT IS ON SCREEN ─────────────────────────────────────────────────────
954
+ //
955
+ // This was a sequential chain of `(await fetch(u)).json()`, and both of its properties were
956
+ // wrong on a slow link. A 500 body is valid JSON and `fetch` does not reject on one, so the
957
+ // REFUSAL was assigned into `roster` / `channels` / `dms` as if it were the snapshot; and the
958
+ // first read that did throw skipped every read after it, so one slow route emptied the feed and
959
+ // left the rest of the page un-refreshed with nothing saying so. Measured against a broker
960
+ // behind a 160ms link: `/api/activity` 500 `{"error":"timeout"}` produced
961
+ // `Uncaught TypeError: activity is not iterable` fifteen times in twenty-five seconds.
962
+ //
963
+ // `refreshAll` reads all four concurrently, applies ONLY the ones that succeeded, and returns
964
+ // what did not land. `apply` is the only writer, so a refusal cannot reach page state at all.
965
+ const SNAP = window.COTAL_SNAPSHOT;
966
+ // The all-activity backfill is a source ONLY when the reader is on all-activity. The other three
967
+ // lenses settle the order machine on an empty batch, which is its specified empty-history arm,
968
+ // and asking for a page nobody will draw is exactly the per-channel fan-out this change bounded.
969
+ const onAllActivity = !agentSel && !dmSel && selected === "*";
970
+ const stale = await SNAP.refreshAll([
971
+ // The space name used to be a one-shot at boot with a bare `fetch().then((r) => r.json())`, so
972
+ // a refusal arrived as data and the header read `· undefined` for the rest of the session:
973
+ // the same defect as the rest of this change, on the one read that never came back to correct
974
+ // itself. It is a source like any other now, so it is gated, named when it is stale, and
975
+ // replaced by the next poll that lands. `/api/meta` is `{space, pid}` computed in process, so
976
+ // reading it every poll costs no broker work.
977
+ {
978
+ name: "space",
979
+ read: async () => SNAP.readJson(await fetch("/api/meta"), "space"),
980
+ apply: (meta) => { $("space").textContent = `· ${meta.space}`; document.title = `Cotal · ${meta.space}`; },
981
+ },
982
+ {
983
+ name: "peers",
984
+ read: async () => SNAP.readJson(await fetch("/api/roster"), "peers"),
985
+ apply: (v) => { roster = v; refreshDerived(); },
986
+ },
987
+ {
988
+ name: "channels",
989
+ read: async () => SNAP.readJson(await fetch("/api/channels"), "channels"),
990
+ // L2 shape is flat {channel,messages,description?,replay,replayWindow?,deliveryClass}.
991
+ // Tolerate a nested-config server briefly (pre-restart) without re-deriving defaults.
992
+ apply: (list) => {
993
+ channels = new Map(
994
+ list.map((c) => {
995
+ if (c.replay !== undefined || c.deliveryClass !== undefined || (c.description && !c.config))
996
+ return [c.channel, c];
997
+ const cfg = c.config || {};
998
+ return [
999
+ c.channel,
1000
+ {
1001
+ messages: c.messages,
1002
+ description: cfg.description,
1003
+ replay: cfg.replay,
1004
+ replayWindow: cfg.replayWindow,
1005
+ deliveryClass: cfg.deliveryClass,
1006
+ },
1007
+ ];
1008
+ }),
1009
+ );
1010
+ },
1011
+ },
1012
+ {
1013
+ name: "direct messages",
1014
+ read: async () => SNAP.readJson(await fetch("/api/dms?limit=500"), "direct messages"),
1015
+ apply: (v) => { dms = v; },
1016
+ },
1017
+ // Read alongside the other three rather than after them, so a refusal on it is reported in the
1018
+ // same place instead of being swallowed, and so it no longer waits for them on a slow link.
1019
+ //
1020
+ // `/api/activity` answers an ENVELOPE now, never a bare array, and the shape change is a guard
1021
+ // rather than a nuisance: a caller that ignores `partial` breaks here instead of rendering a
1022
+ // short page as a complete one. NO TOLERANCE FOR A BARE ARRAY either, and that is not
1023
+ // pedantry: `[].entries` is a real Array METHOD, so a `page.entries ?? page` tolerance quietly
1024
+ // hands a FUNCTION to the merge when the answer is a list. It is read exactly one way.
1025
+ ...(onAllActivity
1026
+ ? [{
1027
+ name: "activity",
1028
+ read: async () => SNAP.readJson(await fetch(`/api/activity?limit=200`), "activity"),
1029
+ apply: (page) => { batch = page.entries; activityPage = page; },
1030
+ }]
1031
+ : []),
1032
+ ]);
1033
+ // A PARTIAL PAGE IS NOT A REFUSAL AND IS NOT SILENCE. The entries it carries are real and are
1034
+ // applied; what the reader is told is that some sources did not answer in time, which sources,
1035
+ // and how many did. Reported on the same marker as a refusal so there is one place to look.
1036
+ if (activityPage && activityPage.partial)
1037
+ stale.push({
1038
+ kind: "partial",
1039
+ name: "activity",
1040
+ reason: `${activityPage.read} of ${activityPage.of} sources answered within ${activityPage.deadlineMs}ms; missing ${activityPage.missing.join(", ")}`,
1041
+ });
1042
+ setStale(stale);
1043
+ // The order machine's own failure arm keeps its own note: a refused history read is what makes a
1044
+ // backfill incomplete, and the notice that draws it is about ordering. The other three sources
1045
+ // are carried by the stale pill and are not backfill failures, so they are not reported as ones.
1046
+ const backfillRefused = stale.find((s) => s.name === "activity" && s.kind !== "partial");
1047
+ if (backfillRefused) noteOrder([{ type: "backfill-failed", reason: backfillRefused.reason }]);
912
1048
  renderSidebarNav();
913
- if (agentSel) {
914
- renderCenter();
915
- } else if (dmSel) {
1049
+ // THE BOUNDARY MUST PASS EVEN WHEN THE FETCH DOES NOT, and this is not a soft failure mode
1050
+ // invented here. `pending` is drained only by the settle, so a rejected request used to mean the
1051
+ // machine never settled and every frame held during it stayed invisible for the life of the page,
1052
+ // with nothing on screen saying so. That is strictly worse than what this code replaced, where a
1053
+ // failed fetch simply left the live arrivals in place.
1054
+ //
1055
+ // A failed history read IS an empty history batch: the machine already specifies that case, and
1056
+ // specifies that the baseline then comes from the earliest BUFFERED frame. So the boundary is
1057
+ // settled on empty rather than skipped, and the refusal is SURFACED (as a stale mark above and a
1058
+ // note here) instead of being swallowed. Reporting it is what keeps this from being a silent degrade.
1059
+ if (agentSel || dmSel) {
916
1060
  renderCenter();
917
1061
  } else if (selected !== "*") {
918
1062
  select(selected);
919
- } else {
920
- // THE BOUNDARY MUST PASS EVEN WHEN THE FETCH DOES NOT, and this is not a soft failure mode
921
- // invented here. `pending` is drained only by the settle, so a rejected request used to mean the
922
- // machine never settled and every frame held during it stayed invisible for the life of the page,
923
- // with nothing on screen saying so. That is strictly worse than what this code replaced, where a
924
- // failed fetch simply left the live arrivals in place.
925
- //
926
- // A failed history read IS an empty history batch: the machine already specifies that case, and
927
- // specifies that the baseline then comes from the earliest BUFFERED frame. So the boundary is
928
- // settled on empty rather than skipped, and the failure is SURFACED as a note instead of being
929
- // swallowed. Reporting it is what keeps this from being a silent degrade.
930
- try {
931
- batch = await (await fetch("/api/activity?limit=200")).json();
932
- } catch (err) {
933
- batch = [];
934
- noteOrder([{ type: "backfill-failed", reason: err && err.message ? err.message : String(err) }]);
935
- }
936
1063
  }
937
1064
  } finally {
938
1065
  // ── THE SETTLE, ON EVERY EXIT PATH ────────────────────────────────────────────────────────────
@@ -942,8 +1069,9 @@ async function refresh() {
942
1069
  // machine that this function had armed and would never settle, and none of them reached the feed.
943
1070
  // Switching back to all activity showed a feed that had never received them, and the next refresh
944
1071
  // replaced the machine and took the buffer with it. A `finally` is the only placement that
945
- // survives all four branches plus a throw from any of the fetches above, and an unguarded
946
- // `/api/roster` was one of those throws.
1072
+ // survives all four branches plus a throw from anything between the arm and here. The source
1073
+ // reads no longer supply that throw, because a refusal is reported rather than propagated; the
1074
+ // renders below the reads still can, and the cost of missing it is the same.
947
1075
  activity = batch;
948
1076
  // Same trust rule as the live feed: the backfill is tagged with the channel the SERVER
949
1077
  // requested, so the payload claim is overwritten at ingress rather than downstream.
@@ -1274,12 +1402,6 @@ if (isDemo) {
1274
1402
  document.title = "Cotal · demo";
1275
1403
  renderDemo();
1276
1404
  } else {
1277
- fetch("/api/meta")
1278
- .then((r) => r.json())
1279
- .then((m) => {
1280
- $("space").textContent = `· ${m.space}`;
1281
- document.title = `Cotal · ${m.space}`;
1282
- });
1283
1405
  refresh();
1284
1406
  connect();
1285
1407
  }
@@ -178,6 +178,7 @@
178
178
  </span>
179
179
  <a class="navlink" href="/">← Monitor</a>
180
180
  <span class="pill down" id="conn"><span class="d"></span><span class="t">connecting</span></span>
181
+ <span class="pill stale" id="stale" hidden><span class="d"></span><span class="t"></span></span>
181
182
  <span class="pill off" id="feed" hidden><span class="d"></span><span class="t">membership</span></span>
182
183
  <div class="ctrls">
183
184
  <div class="grp" id="modes">
@@ -213,6 +214,7 @@
213
214
  <aside id="detail" class="glass"></aside>
214
215
  <div class="hint" id="hint">click a node for detail · scroll to zoom · drag to pan</div>
215
216
 
217
+ <script src="/snapshot.js"></script>
216
218
  <script src="/harness.js"></script>
217
219
  <script src="/parts.js"></script>
218
220
  <script src="/agui-frame.js"></script>
package/dist/web/graph.js CHANGED
@@ -269,6 +269,30 @@
269
269
  // into it: they are different facts and the pill has to say which one.
270
270
  function membershipUnreadable() { feed.unreadable = true; setFeed(); }
271
271
 
272
+ // ── THE BOOTSTRAP MUST NOT OUTRANK THE LIVE FEED ────────────────────────────────────────────
273
+ //
274
+ // Every bootstrap read is ISSUED before its value is applied: `refreshAll` starts all six, awaits
275
+ // all six, and only then applies each. So a snapshot is always at least as old as the moment the
276
+ // page asked for it, while an SSE event is by definition newer than that moment. Now that the feed
277
+ // opens FIRST, a live `roster` or `membership` can land while those reads are still in flight, and
278
+ // applying the older snapshot afterwards silently reverts it: `updateRoster` on an empty list marks
279
+ // every unseen agent `present = false` and deletes it outright unless it is still a feed member,
280
+ // and `applyMembership` on an empty set clears `memberOf`. The agent the feed just announced
281
+ // vanishes from the graph.
282
+ //
283
+ // Both channels carry a FULL snapshot through the SAME apply function, so a live event does not
284
+ // need merging with the older read, it REPLACES it. Once the feed has spoken for a source, that
285
+ // source's bootstrap value is stale on arrival and is dropped rather than applied.
286
+ //
287
+ // WHAT IS SUPERSEDED IS THE SOURCE, NOT THE SNAPSHOT. `membership` speaks in two sentences, a
288
+ // snapshot and a REFUSAL, and each side can say either one. Writing the rule only onto the apply
289
+ // wrappers covered the snapshots and left both refusals loose, in opposite directions: a live
290
+ // refusal erased by an older successful read, and a successful live snapshot overruled by a
291
+ // bootstrap read that refused after it. Both end in the header pill making a claim about the mesh
292
+ // that is really a claim about one read, which is the one thing this pill exists not to do.
293
+ const liveApplied = new Set();
294
+ const supersededByFeed = (name, apply) => (value) => { if (!liveApplied.has(name)) apply(value); };
295
+
272
296
  function applyMembership(snap) {
273
297
  if (!snap) return;
274
298
  feed.unreadable = false; // a snapshot arrived, whatever it contains
@@ -683,31 +707,76 @@
683
707
  $("legendToggle").onclick = () => $("legend").classList.toggle("collapsed");
684
708
  function setConn(live) { const el = $("conn"); el.classList.toggle("down", !live); el.querySelector(".t").textContent = live ? "live" : "disconnected"; }
685
709
 
710
+ /** Say which sources are showing their last good value. Visible, not a console line. */
711
+ function setStale(stale) {
712
+ const el = $("stale");
713
+ if (!el) return;
714
+ const label = window.COTAL_SNAPSHOT.staleLabel(stale);
715
+ el.hidden = !label;
716
+ if (!label) return;
717
+ el.querySelector(".t").textContent = label;
718
+ el.title = stale.map((s) => `${s.name}: ${s.reason}`).join("\n");
719
+ }
720
+
686
721
  // ── boot ──
687
722
  async function load() {
688
- const [meta, roster, chans, membership, activity, dmHist] = await Promise.all([
689
- fetch("/api/meta").then((r) => r.json()), fetch("/api/roster").then((r) => r.json()), fetch("/api/channels").then((r) => r.json()),
723
+ // ── ONE REFUSED READ MUST NOT EMPTY THE PAGE ────────────────────────────────────────────────
724
+ //
725
+ // This was a six-way `Promise.all` of `fetch(u).then((r) => r.json())`. `fetch` does not reject
726
+ // on a 500 and this server's 500 body is `{"error": "..."}`, which parses, so the refusal
727
+ // arrived as DATA and the first `for (const c of chans)` threw `chans is not iterable` out of
728
+ // the whole bootstrap. The `.catch(() => [])` guards on activity and dms never fired for the
729
+ // same reason: there was nothing to catch. Because `load()` rejected, `connect()`, which runs
730
+ // as `load().then(connect)`, was never called, so the page sat at `disconnected` with no peers
731
+ // and no channel hubs and could not recover without a reload. Measured against a broker behind
732
+ // a 160ms link, where `/api/channels` and `/api/activity` both returned 500 `timeout`.
733
+ //
734
+ // Now every source is read independently, only successful reads are applied, and what did not
735
+ // land is named on screen. Nothing here can prevent `connect()` from running.
736
+ const SNAP = window.COTAL_SNAPSHOT;
737
+ let activityPage = null;
738
+ const stale = await SNAP.refreshAll([
739
+ { name: "space", read: async () => SNAP.readJson(await fetch("/api/meta"), "space"),
740
+ apply: (meta) => { $("space").textContent = "· " + meta.space; } },
741
+ { name: "channels", read: async () => SNAP.readJson(await fetch("/api/channels"), "channels"),
742
+ apply: (chans) => { for (const c of chans) { const h = ensureHub(c.channel); h.msgs = c.messages || 0; h.desc = c.description || ""; h.deliveryClass = c.deliveryClass; h.replay = c.replay; h.replayWindow = c.replayWindow; } } },
743
+ { name: "peers", read: async () => SNAP.readJson(await fetch("/api/roster"), "peers"),
744
+ apply: supersededByFeed("peers", (roster) => updateRoster(roster)) },
690
745
  // `.catch(() => ({members: []}))` here turned a failed fetch into an empty snapshot, which the
691
746
  // pill then reported as "traffic-only" — the client half of the same defect the server had.
692
747
  // A non-200 is not a snapshot either: `r.json()` on the refusal body would parse fine and
693
- // arrive as data, so the status is checked before the body is trusted.
694
- fetch("/api/membership")
695
- .then((r) => (r.ok ? r.json() : { unreadable: true }))
696
- .catch(() => ({ unreadable: true })),
697
- fetch("/api/activity?limit=400").then((r) => r.json()).catch(() => []), fetch("/api/dms?limit=400").then((r) => r.json()).catch(() => []),
748
+ // arrive as data, so the status is checked before the body is trusted. That check now lives in
749
+ // `readJson`, and an unreadable feed reaches `membershipUnreadable()` through the stale path.
750
+ { name: "membership", read: async () => SNAP.readJson(await fetch("/api/membership"), "membership"),
751
+ apply: supersededByFeed("membership", (m) => applyMembership(m)) },
752
+ // `/api/activity` answers an ENVELOPE, never a bare array; a caller that ignored `partial`
753
+ // would break here rather than seed a short page as though it were the whole backfill.
754
+ { name: "activity", read: async () => SNAP.readJson(await fetch("/api/activity?limit=400"), "activity"),
755
+ apply: (page) => { activityPage = page; seedActivity(page.entries); } },
756
+ { name: "direct messages", read: async () => SNAP.readJson(await fetch("/api/dms?limit=400"), "direct messages"),
757
+ apply: (dmHist) => seedDms(dmHist) },
698
758
  ]);
699
- $("space").textContent = "· " + meta.space;
700
- for (const c of chans) { const h = ensureHub(c.channel); h.msgs = c.messages || 0; h.desc = c.description || ""; h.deliveryClass = c.deliveryClass; h.replay = c.replay; h.replayWindow = c.replayWindow; }
701
- updateRoster(roster);
702
- // authoritative spokes BEFORE traffic seeding (no skeleton flicker) — unless the read refused,
703
- // in which case there are no spokes to draw and the pill has to say so rather than imply a mesh
704
- // with no feed.
705
- if (membership && membership.unreadable) membershipUnreadable();
706
- else applyMembership(membership);
759
+ if (activityPage && activityPage.partial)
760
+ stale.push({
761
+ kind: "partial",
762
+ name: "activity",
763
+ reason: `${activityPage.read} of ${activityPage.of} sources answered within ${activityPage.deadlineMs}ms; missing ${activityPage.missing.join(", ")}`,
764
+ });
765
+ setStale(stale);
766
+ // A bootstrap read that REFUSED is a sentence about the membership source like any other, so it
767
+ // obeys the same rule as the snapshot beside it: it is a fact about that one read, and the live
768
+ // feed may already have said something newer. Routed through `supersededByFeed` rather than a
769
+ // second copy of the condition, because two spellings of one rule is how two halves drift apart.
770
+ if (stale.some((s) => s.name === "membership")) supersededByFeed("membership", membershipUnreadable)();
771
+ alpha = 1; for (let i = 0; i < 200; i++) physics(); // pre-warm to a settled layout
772
+ const f = fitTarget(); cam.x = f.x; cam.y = f.y; cam.scale = f.scale;
773
+ }
774
+
775
+ /** Seed traffic glow + the `recent` buffer from the activity backfill so the channel detail's
776
+ * "recently active" tags and the "recent" section aren't empty until the first live SSE message
777
+ * arrives. Its own function so the read that feeds it can fail without taking the boot with it. */
778
+ function seedActivity(activity) {
707
779
  for (const e of activity) { const m = e.msg; if (m) m.channel = e.channel; const a = m?.from?.id && agents.get(m.from.id); if (e.mode === "chat" && m?.channel && a) chatHit(a, m.channel, m.ts || now()); }
708
- for (const m of dmHist) { const a = m.from?.id && agents.get(m.from.id), b = typeof m.to === "string" && agents.get(m.to); if (a && b && a !== b) dmHit(a, b, m.ts || now()); }
709
- // Seed the `recent` buffer from the activity backfill so the channel detail's "recently active" tags +
710
- // the "recent" section aren't empty until the first live SSE message arrives (norman).
711
780
  for (const e of activity.slice(-80)) {
712
781
  const m = e.msg; if (!m) continue;
713
782
  const to = e.mode === "unicast" ? (typeof m.to === "string" ? (agents.get(m.to)?.name || shortId(m.to)) : m.to?.name) : e.mode === "anycast" ? "@" + (m.toService || "") : null;
@@ -715,13 +784,26 @@
715
784
  }
716
785
  recent.sort((a, b) => a.ts - b.ts);
717
786
  if (recent.length > 80) recent.splice(0, recent.length - 80);
718
- alpha = 1; for (let i = 0; i < 200; i++) physics(); // pre-warm to a settled layout
719
- const f = fitTarget(); cam.x = f.x; cam.y = f.y; cam.scale = f.scale;
720
787
  }
721
- function connect() { const es = new EventSource("/feed"); es.onopen = () => setConn(true); es.onerror = () => setConn(false); es.addEventListener("roster", (e) => updateRoster(JSON.parse(e.data))); es.addEventListener("membership", (e) => applyMembership(JSON.parse(e.data))); es.addEventListener("membership-read-failed", () => membershipUnreadable()); es.addEventListener("message", (e) => onMessage(JSON.parse(e.data))); }
788
+
789
+ function seedDms(dmHist) {
790
+ for (const m of dmHist) { const a = m.from?.id && agents.get(m.from.id), b = typeof m.to === "string" && agents.get(m.to); if (a && b && a !== b) dmHit(a, b, m.ts || now()); }
791
+ }
792
+ function connect() { const es = new EventSource("/feed"); es.onopen = () => setConn(true); es.onerror = () => setConn(false); es.addEventListener("roster", (e) => { liveApplied.add("peers"); updateRoster(JSON.parse(e.data)); }); es.addEventListener("membership", (e) => { liveApplied.add("membership"); applyMembership(JSON.parse(e.data)); }); es.addEventListener("membership-read-failed", () => { liveApplied.add("membership"); membershipUnreadable(); }); es.addEventListener("message", (e) => onMessage(JSON.parse(e.data))); }
722
793
 
723
794
  resize();
724
795
  setInterval(setFeed, 5000); // age "live" → "stale" even without new events
725
- load().then(connect).catch((err) => { console.error(err); setConn(false); });
796
+ // THE FEED IS NOT GATED ON THE BOOTSTRAP, IN EITHER SENSE. It was once chained behind `load()`
797
+ // RESOLVING, so a single refused read left the page permanently disconnected with nothing on it.
798
+ // That was fixed by making `load()` never reject, which guaranteed `connect()` would RUN but not
799
+ // that it would run SOON: chained with `.then`, it still waited for the whole bootstrap, and that
800
+ // bootstrap reads `/api/activity?limit=400` and `/api/dms?limit=400`, both bounded by the
801
+ // aggregation deadline. On a slow link the page therefore read `disconnected` for the entire load
802
+ // window. Observed across a WAN link as "always showing disconnected, and taking long to show the
803
+ // graph". The live feed is exactly what a page showing stale data needs most, so it opens FIRST
804
+ // and the bootstrap fills in around it. The Monitor page has always done this: `app.js` ends with
805
+ // `refresh(); connect();`, concurrent, not chained.
806
+ connect();
807
+ load().catch((err) => console.error(err));
726
808
  requestAnimationFrame(frame);
727
809
  })();
@@ -52,6 +52,9 @@
52
52
  .pill .t { font-size: 11px; font-weight: 600; color: var(--green); }
53
53
  .pill.down { background: #2a1717; }
54
54
  .pill.down .d { background: var(--red); } .pill.down .t { color: var(--red); }
55
+ /* last-good data on screen, refresh refused - amber, and it carries a text label, never colour alone */
56
+ .pill.stale { background: var(--tint-amber); }
57
+ .pill.stale .d { background: var(--amber); } .pill.stale .t { color: var(--amber); }
55
58
 
56
59
  /* HealthBand tiles */
57
60
  .tiles { margin-left: auto; display: flex; gap: 10px; }
@@ -474,6 +477,7 @@
474
477
  <span class="title">Cotal</span>
475
478
  <span class="space" id="space"></span>
476
479
  <span class="pill down" id="conn"><span class="d"></span><span class="t">connecting</span></span>
480
+ <span class="pill stale" id="stale" hidden><span class="d"></span><span class="t"></span></span>
477
481
  <a class="brand-graph" href="/graph">Graph view →</a>
478
482
  </span>
479
483
  <div class="tiles" id="tiles"></div>
@@ -502,6 +506,7 @@
502
506
  </main>
503
507
  <script src="/vendor/marked.umd.js"></script>
504
508
  <script src="/vendor/purify.min.js"></script>
509
+ <script src="/snapshot.js"></script>
505
510
  <script src="/harness.js"></script>
506
511
  <script src="/parts.js"></script>
507
512
  <script src="/agui-frame.js"></script>
@@ -0,0 +1,99 @@
1
+ // How this dashboard survives a poll that fails, on both pages.
2
+ //
3
+ // A REFUSAL IS NOT DATA, AND THIS SURFACE COULD NOT TELL THEM APART. The server answers a failed
4
+ // route with a 500 whose body is `{"error": "..."}` - valid JSON. `fetch()` does not reject on a
5
+ // 500 either, so `fetch(u).then((r) => r.json())` resolves with the REFUSAL, and the page then
6
+ // treats it as the snapshot. Measured on the shipped pages against a broker behind a 160ms link:
7
+ // `/api/activity` returned 500 `{"error":"timeout"}`, `/graph` threw `TypeError: chans is not
8
+ // iterable` out of its bootstrap and never reached `connect()`, so the pill said `disconnected`
9
+ // with no peers and no channels and the page never recovered; `/` threw `activity is not iterable`
10
+ // fifteen times in twenty-five seconds. Both are the same bug: the status was never consulted, so
11
+ // the one thing that separated a refusal from an empty result never reached the code.
12
+ //
13
+ // THE ORDER OF THE TWO RULES IS THE FIX. First, a non-200 is turned into a THROW that names its
14
+ // condition. Second, a throwing read leaves the value the page already holds exactly where it is
15
+ // and is reported STALE. Either rule alone is not enough: checking the status without keeping the
16
+ // last good snapshot converts a silent corruption into a visible wipe, and keeping the last good
17
+ // snapshot without checking the status keeps nothing, because the refusal arrived as a successful
18
+ // parse and was never a failure to begin with.
19
+ //
20
+ // WHAT STALE MEANS HERE, AND WHY IT IS NOT A FALLBACK. The page keeps showing data it already had
21
+ // and SAYS SO. It does not invent a value, does not substitute a default, and does not silently
22
+ // degrade: the reader is told, per source, that what they are looking at is the last thing that
23
+ // was actually read and why the refresh did not land. Recovery is the next successful read, which
24
+ // replaces the value and clears the mark.
25
+ (() => {
26
+ /** Parse a JSON response, refusing a non-200 rather than handing its body back as data. The
27
+ * refusal names the source and the status, and carries the server's own `error` text when the
28
+ * body has one, so a caller that only logs it still says something true. */
29
+ async function readJson(res, what) {
30
+ if (!res.ok) {
31
+ let detail = "";
32
+ try {
33
+ const body = await res.json();
34
+ if (body && typeof body.error === "string") detail = `: ${body.error}`;
35
+ } catch {
36
+ /* a refusal need not be JSON; the status is the fact that matters */
37
+ }
38
+ throw new Error(`${what} refused with HTTP ${res.status}${detail}`);
39
+ }
40
+ return res.json();
41
+ }
42
+
43
+ /** Read every source concurrently and apply ONLY the ones that succeeded.
44
+ *
45
+ * `sources` is `[{ name, read, apply }]`. `apply` is called with the value of a successful read
46
+ * and is the ONLY place a source's state is written, so a failed read cannot reach it - the
47
+ * retention is structural rather than a rule someone has to remember at each call site.
48
+ * Returns the stale list, `[{ name, reason }]`, empty when everything landed.
49
+ *
50
+ * Concurrent, not sequential: on a slow link the pre-existing sequential chain made every source
51
+ * wait for the slowest one, and a throw part-way through skipped the rest entirely.
52
+ *
53
+ * WHAT IS CAUGHT IS THE READ, AND ONLY THE READ. A refusal or a network failure is this function's
54
+ * business and is turned into a named entry. An `apply` that throws is a defect in the page, not a
55
+ * fact about the link, and it is allowed to propagate: swallowing it would hide a broken renderer
56
+ * behind a stale marker that says the network is at fault. The cost is stated rather than hidden:
57
+ * a throwing apply ends the loop, so the sources after it are not applied and the caller does not
58
+ * reach its marker. Every apply here is a plain assignment or a render the page owns. */
59
+ async function refreshAll(sources) {
60
+ const settled = await Promise.all(
61
+ sources.map(async (s) => {
62
+ try {
63
+ return { value: await s.read() };
64
+ } catch (e) {
65
+ return { error: e };
66
+ }
67
+ }),
68
+ );
69
+ const stale = [];
70
+ for (let i = 0; i < settled.length; i++) {
71
+ const r = settled[i];
72
+ if ("error" in r) {
73
+ const e = r.error;
74
+ stale.push({ kind: "refused", name: sources[i].name, reason: e && e.message ? e.message : String(e) });
75
+ continue;
76
+ }
77
+ sources[i].apply(r.value);
78
+ }
79
+ return stale;
80
+ }
81
+
82
+ /** One line for the header: what is stale, what is partial, and nothing when neither.
83
+ *
84
+ * The two are DIFFERENT FACTS and the label keeps them apart. Stale means the page is showing the
85
+ * last value that was read because the refresh was refused. Partial means the refresh DID land and
86
+ * is short: some sources did not answer inside the request's deadline. Folding them into one word
87
+ * would tell a reader that data is old when it is actually new and incomplete. */
88
+ function staleLabel(stale) {
89
+ if (!stale.length) return "";
90
+ const refused = stale.filter((s) => s.kind !== "partial").map((s) => s.name);
91
+ const partial = stale.filter((s) => s.kind === "partial").map((s) => s.name);
92
+ const parts = [];
93
+ if (refused.length) parts.push(`stale: ${refused.join(", ")}`);
94
+ if (partial.length) parts.push(`partial: ${partial.join(", ")}`);
95
+ return parts.join(" · ");
96
+ }
97
+
98
+ window.COTAL_SNAPSHOT = { readJson, refreshAll, staleLabel };
99
+ })();