@cotal-ai/web 0.18.0 → 0.20.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/agui-frame.js +210 -0
- package/dist/web/app.js +239 -41
- package/dist/web/event-order.js +283 -0
- package/dist/web/graph.html +2 -0
- package/dist/web/graph.js +52 -12
- package/dist/web/index.html +11 -0
- package/dist/web/parts.js +144 -0
- package/dist/web.d.ts +63 -1
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +130 -24
- package/dist/web.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// The browser renderer for `ag-ui.frame` parts, the dashboard's half of the AG-UI display path.
|
|
2
|
+
//
|
|
3
|
+
// WHY A SECOND IMPLEMENTATION EXISTS AT ALL, since duplication is what this tree normally refuses.
|
|
4
|
+
// `src/web/*.js` are classic <script> files served to a browser. They cannot import
|
|
5
|
+
// `@cotal-ai/core`, so they cannot reach `extensions/connector-core/src/agui-render.ts`, which is
|
|
6
|
+
// the same renderer for every other surface. There is no seam that makes one implementation serve
|
|
7
|
+
// both: the constraint is the module system, not a design choice. So the two are kept in step by an
|
|
8
|
+
// EQUIVALENCE INSTRUMENT rather than by intent. "We will keep them in sync" was explicitly refused
|
|
9
|
+
// as a plan; a cell that reddens when they diverge is the only version that survives us. That cell
|
|
10
|
+
// is `bin/smoke/agui-render-parity.smoke.ts`, and it earned its place before this file shipped: an
|
|
11
|
+
// earlier draft of this pair used `||` here against `??` there, so an event carrying an EMPTY
|
|
12
|
+
// STRING for `content`, `runId` or `toolCallName` rendered one way in the console and another way
|
|
13
|
+
// in the browser. Every fallback below therefore keys on `undefined` and never on falsiness, which
|
|
14
|
+
// is what `str()` returning `undefined` for a non-string is for.
|
|
15
|
+
//
|
|
16
|
+
// THE OUTPUT MUST MATCH `agui-render.ts` over the same frame. Every glyph, prefix and fallback
|
|
17
|
+
// string below is chosen to match it, not to look right here. If you change one, you are changing a
|
|
18
|
+
// contract that another file also implements.
|
|
19
|
+
//
|
|
20
|
+
// ── THE LINE-START INVARIANT, WHICH IS NOT COSMETIC ─────────────────────────────────────────────
|
|
21
|
+
//
|
|
22
|
+
// Every line this emits begins with a prefix carrying a non-space glyph inside the first three
|
|
23
|
+
// columns, and payload NEVER starts a line. That is load-bearing on this surface specifically:
|
|
24
|
+
// `app.js` pipes body text through `MD.render` (marked, gfm + breaks), so a payload line beginning
|
|
25
|
+
// `- ` or `# ` opens a markdown block. Measured on the shipped pipeline: a tool result whose second
|
|
26
|
+
// line began `- ` opened a list that CAPTURED the frame's own `run finished` terminator into a list
|
|
27
|
+
// item the payload created. Payload restructured scaffolding.
|
|
28
|
+
//
|
|
29
|
+
// Leading spaces do NOT establish a line start: markdown recognises a heading under up to three of
|
|
30
|
+
// them, and four spaces means a code block instead. A non-space glyph is the only prefix that is
|
|
31
|
+
// inert in both directions.
|
|
32
|
+
//
|
|
33
|
+
// ── WHAT IT DOES NOT DO ─────────────────────────────────────────────────────────────────────────
|
|
34
|
+
//
|
|
35
|
+
// It does not validate. The frame arrives over the wire and every field read here is guarded: a
|
|
36
|
+
// malformed event degrades to a named marker rather than throwing. A renderer is the wrong place to
|
|
37
|
+
// discover a producer's bug and a very good place to make one visible.
|
|
38
|
+
//
|
|
39
|
+
// It registers itself rather than being wired in by `parts.js`, so the dispatcher keeps knowing
|
|
40
|
+
// nothing about AG-UI. A failed or absent registration degrades to
|
|
41
|
+
// `[unrenderable part kind "ag-ui.frame" …]`, true and named, which is the honest-refusal property
|
|
42
|
+
// that file exists for and the reason this is not a branch inside it.
|
|
43
|
+
(() => {
|
|
44
|
+
const KIND = "ag-ui.frame";
|
|
45
|
+
|
|
46
|
+
// Created if absent, so this file and `parts.js` may load in either order. Script order is
|
|
47
|
+
// load-bearing on this surface for other reasons; it must not also be load-bearing for this.
|
|
48
|
+
window.COTAL_PART_RENDERERS = window.COTAL_PART_RENDERERS || {};
|
|
49
|
+
|
|
50
|
+
const CONT = " · ";
|
|
51
|
+
const TEXT_PREFIX = "» ";
|
|
52
|
+
const THINK_PREFIX = "(thinking) ";
|
|
53
|
+
const TOOL_PREFIX = "⚙ ";
|
|
54
|
+
const RESULT_PREFIX = " ↳ ";
|
|
55
|
+
|
|
56
|
+
// `undefined` for anything that is not a string, so a caller can tell ABSENT from EMPTY. Every
|
|
57
|
+
// fallback below uses `??` on this, never `||`: an event carrying `content: ""` reported a tool
|
|
58
|
+
// that returned nothing, and printing `(no content)` for it would state something the producer
|
|
59
|
+
// did not. `agui-render.ts` draws exactly this distinction and the parity suite pins it.
|
|
60
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
61
|
+
|
|
62
|
+
// Prefix EVERY line, not just the first. Payload values are multi-line (a tool result is the
|
|
63
|
+
// common case, but pretty-printed args, a multi-paragraph message and an error body all are) and
|
|
64
|
+
// emitting one as a single string puts its second and later lines at column 0 with nothing of the
|
|
65
|
+
// renderer's in front of them.
|
|
66
|
+
// `body` is a string at every call site (a template literal, a `str() ?? fallback`, or an
|
|
67
|
+
// accumulator concat), so it is NOT coerced here. A `String(body)` would be the only difference
|
|
68
|
+
// between this fold and the node one, and a difference whose whole purpose is to absorb a case that
|
|
69
|
+
// cannot arrive is the kind that survives until the case does arrive and then diverges silently.
|
|
70
|
+
function emit(lines, first, cont, body) {
|
|
71
|
+
const parts = body.split("\n");
|
|
72
|
+
lines.push(first + parts[0]);
|
|
73
|
+
for (let i = 1; i < parts.length; i += 1) lines.push(cont + parts[i]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function flush(lines, map, id, first, cont, suffix) {
|
|
77
|
+
const acc = map.get(id);
|
|
78
|
+
if (acc !== undefined && acc.length > 0) emit(lines, first, cont, acc + (suffix || ""));
|
|
79
|
+
map.delete(id);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Deltas are ACCUMULATED rather than printed one per line: content arrives as a stream of
|
|
83
|
+
// fragments, and a renderer that printed each would turn a sentence into a column. Keyed by
|
|
84
|
+
// messageId / toolCallId so two interleaved streams do not braid, since the ids exist because
|
|
85
|
+
// interleaving is legal.
|
|
86
|
+
//
|
|
87
|
+
// `Map`, NOT a plain object, and this is the one structural decision in the file that a reader
|
|
88
|
+
// would otherwise "simplify" back into a bug. Ids come off the wire, so they are attacker- and
|
|
89
|
+
// accident-shaped, and a plain object diverges from `agui-render.ts`'s Maps on two of them:
|
|
90
|
+
// - an INTEGER-LIKE id ("2", "10") is an array index to an object, so `Object.keys` returns it
|
|
91
|
+
// in ascending NUMERIC order regardless of insertion, and two unterminated streams flush in
|
|
92
|
+
// the wrong order, on one surface only;
|
|
93
|
+
// - `__proto__` never becomes an own key at all, so that stream is silently DROPPED.
|
|
94
|
+
// Both are invisible in the ordinary case and both are pinned by cells in the parity suite.
|
|
95
|
+
function renderEvents(events) {
|
|
96
|
+
const lines = [];
|
|
97
|
+
const text = new Map();
|
|
98
|
+
const reasoning = new Map();
|
|
99
|
+
const toolName = new Map();
|
|
100
|
+
const toolArgs = new Map();
|
|
101
|
+
|
|
102
|
+
for (const e of events) {
|
|
103
|
+
// THE ELEMENT ITSELF IS UNTRUSTED, not just its fields. `events` is an array off the wire and
|
|
104
|
+
// nothing upstream checks that each element is an object, so `null` reaches here and a bare
|
|
105
|
+
// `e.type` throws on it. `parts.js` would catch that into a named marker, which means one null
|
|
106
|
+
// element would delete every other event in the frame from the reader's view. Named per
|
|
107
|
+
// element instead, matching `agui-render.ts`, so one malformed event costs one line.
|
|
108
|
+
const type = typeof e === "object" && e !== null ? str(e.type) : undefined;
|
|
109
|
+
switch (type) {
|
|
110
|
+
case "RUN_STARTED":
|
|
111
|
+
emit(lines, "▸ ", CONT, `run ${str(e.runId) ?? "?"} started`);
|
|
112
|
+
break;
|
|
113
|
+
case "RUN_FINISHED": {
|
|
114
|
+
// `outcome` is optional by the real schema. A turn that merely ended says nothing more,
|
|
115
|
+
// and manufacturing "success" would assert something the source never said.
|
|
116
|
+
const outcome = str(e.outcome?.type);
|
|
117
|
+
emit(lines, "◂ ", CONT, `run ${str(e.runId) ?? "?"} finished${outcome ? ` (${outcome})` : ""}`);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case "RUN_ERROR": {
|
|
121
|
+
const code = str(e.code);
|
|
122
|
+
emit(lines, "✗ ", CONT, `run error${code ? ` [${code}]` : ""}: ${str(e.message) ?? "(no message)"}`);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
case "TEXT_MESSAGE_START":
|
|
127
|
+
text.set(str(e.messageId) ?? "", "");
|
|
128
|
+
break;
|
|
129
|
+
case "TEXT_MESSAGE_CONTENT": {
|
|
130
|
+
const id = str(e.messageId) ?? "";
|
|
131
|
+
text.set(id, (text.get(id) ?? "") + (str(e.delta) ?? ""));
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case "TEXT_MESSAGE_END":
|
|
135
|
+
flush(lines, text, str(e.messageId) ?? "", TEXT_PREFIX, TEXT_PREFIX);
|
|
136
|
+
break;
|
|
137
|
+
|
|
138
|
+
case "REASONING_MESSAGE_START":
|
|
139
|
+
reasoning.set(str(e.messageId) ?? "", "");
|
|
140
|
+
break;
|
|
141
|
+
case "REASONING_MESSAGE_CONTENT": {
|
|
142
|
+
const id = str(e.messageId) ?? "";
|
|
143
|
+
reasoning.set(id, (reasoning.get(id) ?? "") + (str(e.delta) ?? ""));
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case "REASONING_MESSAGE_END":
|
|
147
|
+
flush(lines, reasoning, str(e.messageId) ?? "", THINK_PREFIX, CONT);
|
|
148
|
+
break;
|
|
149
|
+
|
|
150
|
+
case "TOOL_CALL_START": {
|
|
151
|
+
const id = str(e.toolCallId) ?? "";
|
|
152
|
+
toolName.set(id, str(e.toolCallName) ?? "?");
|
|
153
|
+
toolArgs.set(id, "");
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case "TOOL_CALL_ARGS": {
|
|
157
|
+
const id = str(e.toolCallId) ?? "";
|
|
158
|
+
toolArgs.set(id, (toolArgs.get(id) ?? "") + (str(e.delta) ?? ""));
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case "TOOL_CALL_END": {
|
|
162
|
+
const id = str(e.toolCallId) ?? "";
|
|
163
|
+
emit(lines, TOOL_PREFIX, CONT, `${toolName.get(id) ?? "?"}(${toolArgs.get(id) ?? ""})`);
|
|
164
|
+
toolName.delete(id);
|
|
165
|
+
toolArgs.delete(id);
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case "TOOL_CALL_RESULT":
|
|
169
|
+
emit(lines, RESULT_PREFIX, CONT, str(e.content) ?? "(no content)");
|
|
170
|
+
break;
|
|
171
|
+
|
|
172
|
+
case "CUSTOM":
|
|
173
|
+
emit(lines, "• ", CONT, `custom ${str(e.name) ?? "(unnamed)"}`);
|
|
174
|
+
break;
|
|
175
|
+
|
|
176
|
+
// An event whose type this build does not know. NAMED, never skipped: a skipped event is a
|
|
177
|
+
// hole in a transcript that still looks complete.
|
|
178
|
+
default:
|
|
179
|
+
emit(lines, "• ", CONT, `unrecognised event ${JSON.stringify(type === undefined ? null : type)}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// A stream that ended without its END event still has content a reader needs. Dropping it would
|
|
184
|
+
// make a truncated turn indistinguishable from a silent one.
|
|
185
|
+
for (const id of [...text.keys()]) flush(lines, text, id, TEXT_PREFIX, TEXT_PREFIX, " …");
|
|
186
|
+
for (const id of [...reasoning.keys()]) flush(lines, reasoning, id, THINK_PREFIX, CONT, " …");
|
|
187
|
+
for (const id of [...toolName.keys()]) {
|
|
188
|
+
emit(lines, TOOL_PREFIX, CONT, `${toolName.get(id) ?? "?"}(${toolArgs.get(id) ?? ""}) …`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return lines;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
window.COTAL_PART_RENDERERS[KIND] = function renderAguiFrame(p) {
|
|
195
|
+
// Defensive rather than trusting, mirroring `agui-render.ts`: the dispatcher resolved us BY
|
|
196
|
+
// KIND, so this cannot fire through `parts.js` today, but a renderer is a plain function on a
|
|
197
|
+
// global map and anything can call it. Without this, a non-frame part is told it is a frame
|
|
198
|
+
// "carrying no events", which is a false statement about the part rather than a refusal to draw
|
|
199
|
+
// it. Found by the parity suite, not by reading: node refused and the browser did not.
|
|
200
|
+
if (typeof p !== "object" || p === null || p.kind !== KIND) return "[not an AG-UI frame]";
|
|
201
|
+
const events = p.events;
|
|
202
|
+
if (!Array.isArray(events) || events.length === 0) return "[AG-UI frame carrying no events]";
|
|
203
|
+
const lines = renderEvents(events);
|
|
204
|
+
// Impossible today (every branch pushes), but a future branch returning nothing must not become
|
|
205
|
+
// the silent empty string this whole exercise exists to remove.
|
|
206
|
+
return lines.length > 0
|
|
207
|
+
? lines.join("\n")
|
|
208
|
+
: `[AG-UI frame with ${events.length} event(s) and nothing to show]`;
|
|
209
|
+
};
|
|
210
|
+
})();
|
package/dist/web/app.js
CHANGED
|
@@ -23,6 +23,49 @@ let dmSel = null; // { peer, with } when a Direct-messages thread is open
|
|
|
23
23
|
let agentSel = null; // peer id when an Agent Detail drill-down is open (else selected/dmSel drive the view)
|
|
24
24
|
let activity = []; // {mode, msg} ring buffer for the all-activity view
|
|
25
25
|
let channelMsgs = []; // messages for the selected channel
|
|
26
|
+
// The shape-B bootstrap for each of the two merge sites: this page opens the live feed and only then
|
|
27
|
+
// fetches the backfill, so a frame's `seq` order is the consumer's problem. One machine per
|
|
28
|
+
// bootstrap, re-armed BEFORE its fetch starts, so frames arriving during the fetch are held rather
|
|
29
|
+
// than ordered against a baseline that has not been established yet.
|
|
30
|
+
let feedOrder = window.COTAL_EVENT_ORDER.create();
|
|
31
|
+
let channelOrder = window.COTAL_EVENT_ORDER.create();
|
|
32
|
+
// Gap and prefix notes, newest last, keyed for the surface that draws them. Kept rather than logged:
|
|
33
|
+
// a gap that only reaches the console is a gap nobody sees.
|
|
34
|
+
let orderNotes = [];
|
|
35
|
+
function noteOrder(notes) {
|
|
36
|
+
for (const n of notes) orderNotes.push(n);
|
|
37
|
+
if (orderNotes.length > 50) orderNotes = orderNotes.slice(-50);
|
|
38
|
+
}
|
|
39
|
+
/** In-flight bootstrap, so a second caller shares it instead of arming a rival machine. */
|
|
40
|
+
let refreshing = null;
|
|
41
|
+
|
|
42
|
+
/** The notes, as the banner the feed views draw above their rows.
|
|
43
|
+
*
|
|
44
|
+
* THIS EXISTS BECAUSE COMPUTING A GAP AND DRAWING ONE ARE DIFFERENT CLAIMS. The notes were collected
|
|
45
|
+
* into an array that nothing read, so the machine detected a missing frame and the page said nothing:
|
|
46
|
+
* a gap that reaches only an unused variable is a gap nobody sees, which is the same silence this
|
|
47
|
+
* lane exists to remove, one layer up. A reader has to be able to act differently on a lost frame, an
|
|
48
|
+
* evicted prefix and an ordinary feed.
|
|
49
|
+
*
|
|
50
|
+
* The three kinds are drawn as three different statements, because collapsing them would put the one
|
|
51
|
+
* that always happens on a late join next to the one that must never be ignored. */
|
|
52
|
+
function orderNoticeHtml() {
|
|
53
|
+
if (!orderNotes.length) return "";
|
|
54
|
+
const gaps = orderNotes.filter((n) => n.type === "gap");
|
|
55
|
+
const races = orderNotes.filter((n) => n.type === "boundary-hole");
|
|
56
|
+
const prefixes = orderNotes.filter((n) => n.type === "prefix-incomplete");
|
|
57
|
+
const failures = orderNotes.filter((n) => n.type === "backfill-failed");
|
|
58
|
+
const parts = [];
|
|
59
|
+
if (gaps.length) {
|
|
60
|
+
const missing = gaps.reduce((sum, g) => sum + (g.missing || 0), 0);
|
|
61
|
+
parts.push(`<b>${missing} event frame${missing === 1 ? "" : "s"} missing</b> (${gaps.length} break${gaps.length === 1 ? "" : "s"} in the stream)`);
|
|
62
|
+
}
|
|
63
|
+
if (races.length) parts.push(`${races.length} possible ordering race${races.length === 1 ? "" : "s"} at start-up, unconfirmed`);
|
|
64
|
+
if (prefixes.length) parts.push(`${prefixes.length} stream${prefixes.length === 1 ? "" : "s"} joined after the start, earlier frames not retained`);
|
|
65
|
+
if (failures.length) parts.push(`history unavailable, so ordering is based on live frames only`);
|
|
66
|
+
if (!parts.length) return "";
|
|
67
|
+
return `<div class="order-notice${gaps.length ? " fault" : ""}">${parts.join(" · ")}</div>`;
|
|
68
|
+
}
|
|
26
69
|
let modes = new Set(MODES); // delivery modes currently shown
|
|
27
70
|
let paused = false; // freeze auto-scroll so a value can be read
|
|
28
71
|
let expandAll = false; // channel-wide: expand every clamped message body (else per-message toggle)
|
|
@@ -30,8 +73,9 @@ let expandAll = false; // channel-wide: expand every clamped message body (else
|
|
|
30
73
|
const esc = (s) =>
|
|
31
74
|
String(s).replace(/[&<>]/g, (ch) => ({ "&": "&", "<": "<", ">": ">" })[ch]);
|
|
32
75
|
const time = (ts) => new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
33
|
-
|
|
34
|
-
|
|
76
|
+
// Shared with graph.js via parts.js (loaded before this file). It names a part kind it cannot
|
|
77
|
+
// draw instead of rendering it as the empty string, which read as "nothing arrived".
|
|
78
|
+
const bodyText = (msg) => window.COTAL_PARTS.partsToText(msg.parts);
|
|
35
79
|
function ago(ts) {
|
|
36
80
|
const s = Math.max(0, (Date.now() - ts) / 1000);
|
|
37
81
|
if (s < 45) return "just now";
|
|
@@ -431,6 +475,7 @@ function renderAllActivity() {
|
|
|
431
475
|
<span class="chip pause${paused ? " on" : ""}" id="pause">${paused ? "▶ resume" : "⏸ pause"}</span>
|
|
432
476
|
</span>
|
|
433
477
|
</div>
|
|
478
|
+
${orderNoticeHtml()}
|
|
434
479
|
<div class="feed">${rows.length ? rows.map(rowHTML).join("") : `<div class="empty">waiting for messages…</div>`}</div>`;
|
|
435
480
|
for (const chip of center.querySelectorAll(".chip[data-mode]"))
|
|
436
481
|
chip.onclick = () => {
|
|
@@ -515,6 +560,7 @@ function renderChannel() {
|
|
|
515
560
|
</div>
|
|
516
561
|
<div class="purpose">${purpose}</div>
|
|
517
562
|
</div>
|
|
563
|
+
${orderNoticeHtml()}
|
|
518
564
|
<div class="clist">${items.length ? items.map(cmsgHTML).join("") : `<div class="empty">no messages</div>`}</div>`;
|
|
519
565
|
const list = $("center").querySelector(".clist");
|
|
520
566
|
list.scrollTop = list.scrollHeight;
|
|
@@ -744,6 +790,8 @@ function refreshDerived() {
|
|
|
744
790
|
}
|
|
745
791
|
|
|
746
792
|
let loadSeq = 0;
|
|
793
|
+
/** The in-flight channel bootstrap: `{key, promise}`, so a second call for the same channel shares it. */
|
|
794
|
+
let selecting = null;
|
|
747
795
|
async function select(key) {
|
|
748
796
|
agentSel = null;
|
|
749
797
|
dmSel = null;
|
|
@@ -753,12 +801,51 @@ async function select(key) {
|
|
|
753
801
|
if (!isDemo) renderRoster(rosterRows()); // clear any stale Agent Detail highlight
|
|
754
802
|
if (isDemo) return (renderCenter(), renderRail());
|
|
755
803
|
if (key !== "*") {
|
|
804
|
+
// SINGLE FLIGHT PER CHANNEL, for the reason the feed has one. `refresh()` calls `select(selected)`
|
|
805
|
+
// on every poll, so two bootstraps for the SAME open channel overlapped routinely: the second
|
|
806
|
+
// re-armed `channelOrder` while the first was still fetching, and the first machine, holding every
|
|
807
|
+
// frame that arrived in that window, became unreachable. The staleness guard did not save it,
|
|
808
|
+
// because returning early is exactly what left the buffer undrained.
|
|
809
|
+
if (selecting && selecting.key === key) return selecting.promise;
|
|
756
810
|
const seq = ++loadSeq;
|
|
757
811
|
channelMsgs = [];
|
|
812
|
+
// ARMED BEFORE THE FETCH IS ISSUED, which is the whole ordering. Re-armed on every selection
|
|
813
|
+
// because each one is a fresh two-phase bootstrap: a new history read, and a live tap that is
|
|
814
|
+
// already delivering into it. Held in a LOCAL as well, so this bootstrap settles the machine it
|
|
815
|
+
// armed even if a selection of a different channel has since rebound the global.
|
|
816
|
+
const order = window.COTAL_EVENT_ORDER.create();
|
|
817
|
+
channelOrder = order;
|
|
818
|
+
let release;
|
|
819
|
+
selecting = { key, promise: new Promise((r) => (release = r)) };
|
|
758
820
|
renderCenter();
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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.
|
|
824
|
+
let msgs = [];
|
|
825
|
+
try {
|
|
826
|
+
msgs = await (await fetch(`/api/channels/${encodeURIComponent(key)}/history?limit=200`)).json();
|
|
827
|
+
} catch (err) {
|
|
828
|
+
msgs = [];
|
|
829
|
+
noteOrder([{ type: "backfill-failed", channel: key, reason: err && err.message ? err.message : String(err) }]);
|
|
830
|
+
} finally {
|
|
831
|
+
// SETTLED ON EVERY PATH, INCLUDING THE STALE ONE. A superseded load must not rebind the view it
|
|
832
|
+
// no longer owns, and it must still drain the machine it armed; skipping the settle is what
|
|
833
|
+
// orphaned frames. When the selection has moved on, the released rows are dropped with the rest
|
|
834
|
+
// of that view, which is correct because the reader is no longer looking at that channel.
|
|
835
|
+
const settled = order.backfill(msgs);
|
|
836
|
+
if (seq === loadSeq) {
|
|
837
|
+
noteOrder(settled.notes);
|
|
838
|
+
// Same merge as the activity feed and for the same reason: chat that arrived live during this
|
|
839
|
+
// fetch is only in `channelMsgs`, released frames are only in `settled.emit`, and an assignment
|
|
840
|
+
// either way round drops one of them.
|
|
841
|
+
const merged = settled.emit.slice();
|
|
842
|
+
const ids = new Set(merged.map((m) => m && m.id));
|
|
843
|
+
for (const m of channelMsgs) if (m && !ids.has(m.id)) merged.push(m);
|
|
844
|
+
channelMsgs = merged.slice(-500);
|
|
845
|
+
}
|
|
846
|
+
if (selecting && selecting.key === key) selecting = null;
|
|
847
|
+
release();
|
|
848
|
+
}
|
|
762
849
|
}
|
|
763
850
|
renderCenter();
|
|
764
851
|
renderRail();
|
|
@@ -775,46 +862,151 @@ function selectDM(peer, w) {
|
|
|
775
862
|
}
|
|
776
863
|
|
|
777
864
|
async function refresh() {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
//
|
|
782
|
-
//
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
865
|
+
// ── ONE BOOTSTRAP AT A TIME, AND IT ALWAYS ENDS ─────────────────────────────────────────────────
|
|
866
|
+
//
|
|
867
|
+
// SINGLE FLIGHT. Arming a machine and settling it are two ends of ONE bootstrap, and this function
|
|
868
|
+
// could previously be re-entered between them. The stock startup does exactly that: the file ends
|
|
869
|
+
// with `refresh(); connect();`, and `connect()`'s open handler calls `refresh()` again, so a second
|
|
870
|
+
// call re-armed `feedOrder` while the first was still fetching. The first machine, holding whatever
|
|
871
|
+
// arrived in that window, was then unreachable: the settle ran on the NEW binding and the old
|
|
872
|
+
// machine's buffer went with it. Measured on the shipped merge logic, a frame held on the first
|
|
873
|
+
// machine was simply absent from the feed afterwards. A reconnect flap did it too.
|
|
874
|
+
//
|
|
875
|
+
// Overlapping callers now share the in-flight bootstrap instead of starting a rival one. A
|
|
876
|
+
// concurrent duplicate would have re-fetched the same pages anyway, so nothing is lost by
|
|
877
|
+
// coalescing, and the pairing of one arm to one settle is restored.
|
|
878
|
+
if (refreshing) return refreshing;
|
|
879
|
+
let release;
|
|
880
|
+
refreshing = new Promise((r) => (release = r));
|
|
881
|
+
// Captured before the first await: `onMessage` appends here while every request below is in flight,
|
|
882
|
+
// and the settle rebinds `activity`.
|
|
883
|
+
const live = activity;
|
|
884
|
+
feedOrder = window.COTAL_EVENT_ORDER.create();
|
|
885
|
+
// The batch the settle will use. Only the all-activity path fills it; every other path settles on
|
|
886
|
+
// empty, which is the machine's specified empty-history arm rather than a shortcut.
|
|
887
|
+
let batch = [];
|
|
888
|
+
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();
|
|
912
|
+
renderSidebarNav();
|
|
913
|
+
if (agentSel) {
|
|
914
|
+
renderCenter();
|
|
915
|
+
} else if (dmSel) {
|
|
916
|
+
renderCenter();
|
|
917
|
+
} else if (selected !== "*") {
|
|
918
|
+
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
|
+
}
|
|
937
|
+
} finally {
|
|
938
|
+
// ── THE SETTLE, ON EVERY EXIT PATH ────────────────────────────────────────────────────────────
|
|
939
|
+
//
|
|
940
|
+
// IT USED TO RUN ONLY ON THE ALL-ACTIVITY BRANCH, while the arm ran unconditionally at the top.
|
|
941
|
+
// So whenever the reader was on a channel, a DM or an agent, every live frame was held by a
|
|
942
|
+
// machine that this function had armed and would never settle, and none of them reached the feed.
|
|
943
|
+
// Switching back to all activity showed a feed that had never received them, and the next refresh
|
|
944
|
+
// 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.
|
|
947
|
+
activity = batch;
|
|
948
|
+
// Same trust rule as the live feed: the backfill is tagged with the channel the SERVER
|
|
949
|
+
// requested, so the payload claim is overwritten at ingress rather than downstream.
|
|
950
|
+
for (const e of activity) if (e?.msg) e.msg.channel = e.channel;
|
|
951
|
+
// MERGED, NOT ASSIGNED OVER. This read `activity = await fetch(...)`, which DISCARDED every live
|
|
952
|
+
// entry `onMessage` had appended while the fetch was in flight. Retention hid it: the backfill
|
|
953
|
+
// re-read the same messages from the broker, so the overwritten arrivals came back. Event
|
|
954
|
+
// channels are no longer in that backfill, by the server's filter, so for a frame there is
|
|
955
|
+
// nothing to come back and the assignment would be a silent deletion of exactly the traffic this
|
|
956
|
+
// lane exists to make visible. The two halves of this change are that tightly coupled: the filter
|
|
957
|
+
// is what turns the pre-existing overwrite into a loss, and this is what makes the filter safe.
|
|
958
|
+
const settled = feedOrder.backfill(activity);
|
|
959
|
+
noteOrder(settled.notes);
|
|
960
|
+
// Live frames were HELD by the machine and come back inside `settled.emit` in seq order; live
|
|
961
|
+
// chat passed straight through and is only in `live`. Both have to survive, so the backfill is
|
|
962
|
+
// the BASE and unseen live arrivals are appended after it, newest last, deduped by id against
|
|
963
|
+
// what the backfill already carried.
|
|
964
|
+
//
|
|
965
|
+
// WHAT THIS ORDER DOES AND DOES NOT CLAIM. Frames of one chain are exactly ordered, by `seq`,
|
|
966
|
+
// which is the claim this file exists to make. Where a released frame sits relative to a chat
|
|
967
|
+
// message that arrived during the same fetch is approximate. It is deliberately not fixed by
|
|
968
|
+
// sorting the merged rows on `ts`: a producer's clock is not its sequence, so two frames whose
|
|
969
|
+
// timestamps disagree with their sequence numbers would be swapped back by that sort, trading the
|
|
970
|
+
// guarantee for the cosmetic.
|
|
971
|
+
const merged = settled.emit.slice();
|
|
972
|
+
const ids = new Set(merged.map((e) => e && e.msg && e.msg.id));
|
|
973
|
+
for (const e of live) if (e && e.msg && !ids.has(e.msg.id)) merged.push(e);
|
|
974
|
+
activity = merged.slice(-500);
|
|
810
975
|
renderCenter();
|
|
976
|
+
refreshing = null;
|
|
977
|
+
release();
|
|
811
978
|
}
|
|
812
979
|
}
|
|
813
980
|
|
|
814
981
|
function onMessage(entry) {
|
|
815
982
|
const { mode, msg } = entry;
|
|
816
|
-
|
|
817
|
-
|
|
983
|
+
// TRUST IS DECIDED ONCE, HERE. The server sends the channel it took from the SUBJECT (a publish
|
|
984
|
+
// grant is per-channel, so that token is covered by the minted grant); `msg.channel` is the
|
|
985
|
+
// publisher's own claim and is backed by nothing. Overwrite the claim at this boundary so no
|
|
986
|
+
// downstream reader — the channel list, the counts, the unread badges, the transcript — has to
|
|
987
|
+
// know which of the two it is holding.
|
|
988
|
+
//
|
|
989
|
+
// THE ASSIGNMENT IS UNCONDITIONAL, AND THAT IS THE WHOLE FIX. This first read
|
|
990
|
+
// `if (entry.channel) msg.channel = entry.channel;`, which FAILS OPEN: on `inst` and `svc` there
|
|
991
|
+
// is no authoritative channel, so the guard was false and the publisher's claim SURVIVED. `tap()`
|
|
992
|
+
// only JSON-decodes, so a DM or anycast payload can carry any `channel` string it likes — and
|
|
993
|
+
// `msg.channel` is consumed below with NO MODE GATE, which filed that message into the named
|
|
994
|
+
// channel's transcript and incremented its count. A sender could appear to post into a channel it
|
|
995
|
+
// holds no publish grant for. Assigning unconditionally leaves a non-chat message with
|
|
996
|
+
// `undefined`, which is the truth: it has no channel.
|
|
997
|
+
//
|
|
998
|
+
// A conditional trust rule is not a trust rule. "Overwrite when I have something better" leaves
|
|
999
|
+
// the untrusted value in place exactly when the trusted one is missing.
|
|
1000
|
+
msg.channel = entry.channel;
|
|
1001
|
+
// ORDERED, NOT JUST DEDUPED. Message-id dedupe answers "have I seen this exact message"; it cannot
|
|
1002
|
+
// answer "is a frame missing between these two", because the thing that would say so is `seq`. A
|
|
1003
|
+
// frame arriving before the backfill it belongs after is HELD here and released by `backfill()` in
|
|
1004
|
+
// `seq` order; everything else passes through and is deduped by id exactly as before.
|
|
1005
|
+
const fed = feedOrder.live(entry);
|
|
1006
|
+
noteOrder(fed.notes);
|
|
1007
|
+
for (const e of fed.emit) {
|
|
1008
|
+
if (activity.some((a) => a.msg.id === e.msg.id)) continue;
|
|
1009
|
+
activity.push(e);
|
|
818
1010
|
if (activity.length > 500) activity.shift();
|
|
819
1011
|
}
|
|
820
1012
|
if (mode === "unicast" && !dms.some((m) => m.id === msg.id)) {
|
|
@@ -827,8 +1019,14 @@ function onMessage(entry) {
|
|
|
827
1019
|
const ch = channels.get(msg.channel);
|
|
828
1020
|
channels.set(msg.channel, { ...(ch ?? {}), messages: (ch?.messages ?? 0) + 1 });
|
|
829
1021
|
if (!dmSel && selected === msg.channel) {
|
|
830
|
-
|
|
831
|
-
|
|
1022
|
+
// The selected-channel view runs the same race: `select()` fetches this channel's history with
|
|
1023
|
+
// the feed already open, so a live frame can arrive before the retained range it follows.
|
|
1024
|
+
const sel = channelOrder.live(msg);
|
|
1025
|
+
noteOrder(sel.notes);
|
|
1026
|
+
for (const m of sel.emit) {
|
|
1027
|
+
channelMsgs.push(m);
|
|
1028
|
+
if (channelMsgs.length > 500) channelMsgs.shift();
|
|
1029
|
+
}
|
|
832
1030
|
} else {
|
|
833
1031
|
unread.set(msg.channel, (unread.get(msg.channel) ?? 0) + 1);
|
|
834
1032
|
}
|